Skip to content
11 changes: 7 additions & 4 deletions elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ This directory contains the current Elixir/OTP implementation of Symphony, based
## How it works

1. Polls Linear for candidate work
2. Creates an isolated workspace per issue
2. Creates a workspace per issue
3. Launches Codex in [App Server mode](https://developers.openai.com/codex/app-server/) inside the
workspace
4. Sends a workflow prompt to Codex
Expand Down Expand Up @@ -116,8 +116,9 @@ Notes:
- `codex.turn_sandbox_policy` defaults to a `workspaceWrite` policy rooted at the current issue workspace
- Supported `codex.approval_policy` values depend on the targeted Codex app-server version. In the current local Codex schema, string values include `untrusted`, `on-failure`, `on-request`, and `never`, and object-form `reject` is also supported.
- Supported `codex.thread_sandbox` values: `read-only`, `workspace-write`, `danger-full-access`.
- Supported `codex.turn_sandbox_policy.type` values: `dangerFullAccess`, `readOnly`,
`externalSandbox`, `workspaceWrite`.
- When `codex.turn_sandbox_policy` is set explicitly, Symphony passes the map through to Codex
unchanged. Compatibility then depends on the targeted Codex app-server version rather than local
Symphony validation.
- `agent.max_turns` caps how many back-to-back Codex turns Symphony will run in a single agent
invocation when a turn completes normally but the issue is still in an active state. Default: `20`.
- If the Markdown body is blank, Symphony uses a default prompt template that includes the issue
Expand All @@ -144,7 +145,9 @@ codex:
command: "$CODEX_BIN app-server --model gpt-5.3-codex"
```

- If `WORKFLOW.md` is missing or has invalid YAML, startup and scheduling are halted until fixed.
- If `WORKFLOW.md` is missing or has invalid YAML at startup, Symphony does not boot.
- If a later reload fails, Symphony keeps running with the last known good workflow and logs the
reload error until the file is fixed.
- `server.port` or CLI `--port` enables the optional Phoenix LiveView dashboard and JSON API at
`/`, `/api/v1/state`, `/api/v1/<issue_identifier>`, and `/api/v1/refresh`.

Expand Down
2 changes: 1 addition & 1 deletion elixir/lib/symphony_elixir/agent_runner.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
defmodule SymphonyElixir.AgentRunner do
@moduledoc """
Executes a single Linear issue in an isolated workspace with Codex.
Executes a single Linear issue in its workspace with Codex.
"""

require Logger
Expand Down
37 changes: 23 additions & 14 deletions elixir/lib/symphony_elixir/codex/app_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule SymphonyElixir.Codex.AppServer do
"""

require Logger
alias SymphonyElixir.{Codex.DynamicTool, Config}
alias SymphonyElixir.{Codex.DynamicTool, Config, PathSafety}

@initialize_id 1
@thread_start_id 2
Expand Down Expand Up @@ -37,10 +37,9 @@ defmodule SymphonyElixir.Codex.AppServer do

@spec start_session(Path.t()) :: {:ok, session()} | {:error, term()}
def start_session(workspace) do
with :ok <- validate_workspace_cwd(workspace),
{:ok, port} <- start_port(workspace) do
with {:ok, expanded_workspace} <- validate_workspace_cwd(workspace),
{:ok, port} <- start_port(expanded_workspace) do
metadata = port_metadata(port)
expanded_workspace = Path.expand(workspace)

with {:ok, session_policies} <- session_policies(expanded_workspace),
{:ok, thread_id} <- do_start_session(port, expanded_workspace, session_policies) do
Expand Down Expand Up @@ -142,20 +141,30 @@ defmodule SymphonyElixir.Codex.AppServer do
end

defp validate_workspace_cwd(workspace) when is_binary(workspace) do
workspace_path = Path.expand(workspace)
workspace_root = Path.expand(Config.settings!().workspace.root)
expanded_workspace = Path.expand(workspace)
expanded_root = Path.expand(Config.settings!().workspace.root)
expanded_root_prefix = expanded_root <> "/"

root_prefix = workspace_root <> "/"
with {:ok, canonical_workspace} <- PathSafety.canonicalize(expanded_workspace),
{:ok, canonical_root} <- PathSafety.canonicalize(expanded_root) do
canonical_root_prefix = canonical_root <> "/"

cond do
workspace_path == workspace_root ->
{:error, {:invalid_workspace_cwd, :workspace_root, workspace_path}}
cond do
canonical_workspace == canonical_root ->
{:error, {:invalid_workspace_cwd, :workspace_root, canonical_workspace}}

not String.starts_with?(workspace_path <> "/", root_prefix) ->
{:error, {:invalid_workspace_cwd, :outside_workspace_root, workspace_path, workspace_root}}
String.starts_with?(canonical_workspace <> "/", canonical_root_prefix) ->
{:ok, canonical_workspace}

true ->
:ok
String.starts_with?(expanded_workspace <> "/", expanded_root_prefix) ->
{:error, {:invalid_workspace_cwd, :symlink_escape, expanded_workspace, canonical_root}}

true ->
{:error, {:invalid_workspace_cwd, :outside_workspace_root, canonical_workspace, canonical_root}}
end
else
{:error, {:path_canonicalize_failed, path, reason}} ->
{:error, {:invalid_workspace_cwd, :path_unreadable, path, reason}}
end
end

Expand Down
24 changes: 16 additions & 8 deletions elixir/lib/symphony_elixir/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,13 @@ defmodule SymphonyElixir.Config do

@spec codex_turn_sandbox_policy(Path.t() | nil) :: map()
def codex_turn_sandbox_policy(workspace \\ nil) do
settings!()
|> Schema.resolve_turn_sandbox_policy(workspace)
case Schema.resolve_runtime_turn_sandbox_policy(settings!(), workspace) do
{:ok, policy} ->
policy

{:error, reason} ->
raise ArgumentError, message: "Invalid codex turn sandbox policy: #{inspect(reason)}"
end
end

@spec workflow_prompt() :: String.t()
Expand Down Expand Up @@ -96,12 +101,15 @@ defmodule SymphonyElixir.Config do
@spec codex_runtime_settings(Path.t() | nil) :: {:ok, codex_runtime_settings()} | {:error, term()}
def codex_runtime_settings(workspace \\ nil) do
with {:ok, settings} <- settings() do
{:ok,
%{
approval_policy: settings.codex.approval_policy,
thread_sandbox: settings.codex.thread_sandbox,
turn_sandbox_policy: Schema.resolve_turn_sandbox_policy(settings, workspace)
}}
with {:ok, turn_sandbox_policy} <-
Schema.resolve_runtime_turn_sandbox_policy(settings, workspace) do
{:ok,
%{
approval_policy: settings.codex.approval_policy,
thread_sandbox: settings.codex.thread_sandbox,
turn_sandbox_policy: turn_sandbox_policy
}}
end
end
end

Expand Down
24 changes: 24 additions & 0 deletions elixir/lib/symphony_elixir/config/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ defmodule SymphonyElixir.Config.Schema do

import Ecto.Changeset

alias SymphonyElixir.PathSafety

@primary_key false

@type t :: %__MODULE__{}
Expand Down Expand Up @@ -278,6 +280,18 @@ defmodule SymphonyElixir.Config.Schema do
end
end

@spec resolve_runtime_turn_sandbox_policy(%__MODULE__{}, Path.t() | nil) ::
{:ok, map()} | {:error, term()}
def resolve_runtime_turn_sandbox_policy(settings, workspace \\ nil) do
case settings.codex.turn_sandbox_policy do
%{} = policy ->
{:ok, policy}

_ ->
default_runtime_turn_sandbox_policy(workspace || settings.workspace.root)
end
end

@spec normalize_issue_state(String.t()) :: String.t()
def normalize_issue_state(state_name) when is_binary(state_name) do
String.downcase(state_name)
Expand Down Expand Up @@ -457,6 +471,16 @@ defmodule SymphonyElixir.Config.Schema do
}
end

defp default_runtime_turn_sandbox_policy(workspace_root) when is_binary(workspace_root) do
with {:ok, canonical_workspace_root} <- PathSafety.canonicalize(workspace_root) do
{:ok, default_turn_sandbox_policy(canonical_workspace_root)}
end
end

defp default_runtime_turn_sandbox_policy(workspace_root) do
{:error, {:unsafe_turn_sandbox_policy, {:invalid_workspace_root, workspace_root}}}
end

defp format_errors(changeset) do
changeset
|> traverse_errors(&translate_error/1)
Expand Down
62 changes: 58 additions & 4 deletions elixir/lib/symphony_elixir/linear/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,22 @@ defmodule SymphonyElixir.Linear.Client do
|> finalize_paginated_issues()
end

@doc false
@spec fetch_issue_states_by_ids_for_test([String.t()], (String.t(), map() -> {:ok, map()} | {:error, term()})) ::
{:ok, [Issue.t()]} | {:error, term()}
def fetch_issue_states_by_ids_for_test(issue_ids, graphql_fun)
when is_list(issue_ids) and is_function(graphql_fun, 2) do
ids = Enum.uniq(issue_ids)

case ids do
[] ->
{:ok, []}

ids ->
do_fetch_issue_states(ids, nil, graphql_fun)
end
end

defp do_fetch_by_states(project_slug, state_names, assignee_filter) do
do_fetch_by_states_page(project_slug, state_names, assignee_filter, nil, [])
end
Expand Down Expand Up @@ -256,19 +272,57 @@ defmodule SymphonyElixir.Linear.Client do
defp finalize_paginated_issues(acc_issues) when is_list(acc_issues), do: Enum.reverse(acc_issues)

defp do_fetch_issue_states(ids, assignee_filter) do
case graphql(@query_by_ids, %{
ids: ids,
first: Enum.min([length(ids), @issue_page_size]),
do_fetch_issue_states(ids, assignee_filter, &graphql/2)
end

defp do_fetch_issue_states(ids, assignee_filter, graphql_fun)
when is_list(ids) and is_function(graphql_fun, 2) do
issue_order_index = issue_order_index(ids)
do_fetch_issue_states_page(ids, assignee_filter, graphql_fun, [], issue_order_index)
end

defp do_fetch_issue_states_page([], _assignee_filter, _graphql_fun, acc_issues, issue_order_index) do
acc_issues
|> finalize_paginated_issues()
|> sort_issues_by_requested_ids(issue_order_index)
|> then(&{:ok, &1})
end

defp do_fetch_issue_states_page(ids, assignee_filter, graphql_fun, acc_issues, issue_order_index) do
{batch_ids, rest_ids} = Enum.split(ids, @issue_page_size)

case graphql_fun.(@query_by_ids, %{
ids: batch_ids,
first: length(batch_ids),
relationFirst: @issue_page_size
}) do
{:ok, body} ->
decode_linear_response(body, assignee_filter)
with {:ok, issues} <- decode_linear_response(body, assignee_filter) do
updated_acc = prepend_page_issues(issues, acc_issues)
do_fetch_issue_states_page(rest_ids, assignee_filter, graphql_fun, updated_acc, issue_order_index)
end

{:error, reason} ->
{:error, reason}
end
end

defp issue_order_index(ids) when is_list(ids) do
ids
|> Enum.with_index()
|> Map.new()
end

defp sort_issues_by_requested_ids(issues, issue_order_index)
when is_list(issues) and is_map(issue_order_index) do
fallback_index = map_size(issue_order_index)

Enum.sort_by(issues, fn
%Issue{id: issue_id} -> Map.get(issue_order_index, issue_id, fallback_index)
_ -> fallback_index
end)
end

defp build_graphql_payload(query, variables, operation_name) do
%{
"query" => query,
Expand Down
Loading