From 273074597282d9f3fd0b066dc1ad61ea1340a039 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Mar 2026 06:13:55 +0000 Subject: [PATCH] refactor: remove Elixir implementation, keep Node.js only, rename to Symphony-nodejs Summary: - Delete entire elixir/ directory (Elixir/OTP implementation) - Rename project from Symphony to Symphony-nodejs - Update package.json name to symphony-nodejs - Rewrite README.md, README.en.md, README.zh-CN.md removing all Elixir references and keeping only Node.js content - Update nodejs/README.md to remove Elixir comparison table - Replace GitHub Actions workflows: Elixir CI -> Node.js CI - Remove pr-description-lint.yml (relied on Elixir mix task) - Update PR template to reference Node.js commands - Update .codex/worktree_init.sh to point to nodejs/ - Update .codex/skills/push to use npm ci instead of make -C elixir all - Update .codex/skills/debug to remove Elixir-specific log references - Update .codex/skills/land to reference Node.js validation - Remove .codex/skills/linear/ (Linear-specific, Elixir only) - SPEC.md left as-is (language-agnostic specification document) Co-authored-by: Julia Smith --- .codex/skills/debug/SKILL.md | 94 +- .codex/skills/land/SKILL.md | 2 +- .codex/skills/linear/SKILL.md | 388 ---- .codex/skills/push/SKILL.md | 12 +- .codex/worktree_init.sh | 10 +- .github/pull_request_template.md | 2 +- .github/workflows/make-all.yml | 30 +- .github/workflows/pr-description-lint.yml | 33 - README.en.md | 310 +-- README.md | 49 +- README.zh-CN.md | 317 +-- elixir/.formatter.exs | 5 - elixir/.gitattributes | 1 - elixir/.gitignore | 53 - elixir/AGENTS.md | 64 - elixir/Makefile | 44 - elixir/README.md | 188 -- elixir/WORKFLOW.md | 326 --- elixir/config/config.exs | 16 - elixir/docs/logging.md | 40 - elixir/docs/token_accounting.md | 304 --- elixir/lib/mix/tasks/pr_body.check.ex | 216 -- elixir/lib/mix/tasks/specs.check.ex | 53 - .../lib/mix/tasks/workspace.before_remove.ex | 140 -- elixir/lib/symphony_elixir.ex | 47 - elixir/lib/symphony_elixir/agent_runner.ex | 154 -- elixir/lib/symphony_elixir/cli.ex | 191 -- .../lib/symphony_elixir/codex/app_server.ex | 985 --------- .../lib/symphony_elixir/codex/dynamic_tool.ex | 212 -- elixir/lib/symphony_elixir/config.ex | 938 -------- elixir/lib/symphony_elixir/http_server.ex | 88 - elixir/lib/symphony_elixir/linear/adapter.ex | 91 - elixir/lib/symphony_elixir/linear/client.ex | 530 ----- elixir/lib/symphony_elixir/linear/issue.ex | 43 - elixir/lib/symphony_elixir/log_file.ex | 80 - elixir/lib/symphony_elixir/orchestrator.ex | 1457 ------------ elixir/lib/symphony_elixir/prompt_builder.ex | 64 - elixir/lib/symphony_elixir/specs_check.ex | 175 -- .../lib/symphony_elixir/status_dashboard.ex | 1949 ----------------- elixir/lib/symphony_elixir/tracker.ex | 46 - elixir/lib/symphony_elixir/tracker/memory.ex | 72 - elixir/lib/symphony_elixir/workflow.ex | 123 -- elixir/lib/symphony_elixir/workflow_store.ex | 153 -- elixir/lib/symphony_elixir/workspace.ex | 282 --- .../symphony_elixir_web/components/layouts.ex | 56 - .../observability_api_controller.ex | 63 - .../controllers/static_asset_controller.ex | 35 - elixir/lib/symphony_elixir_web/endpoint.ex | 32 - elixir/lib/symphony_elixir_web/error_html.ex | 8 - elixir/lib/symphony_elixir_web/error_json.ex | 8 - .../live/dashboard_live.ex | 330 --- .../observability_pubsub.ex | 25 - elixir/lib/symphony_elixir_web/presenter.ex | 181 -- elixir/lib/symphony_elixir_web/router.ex | 41 - .../lib/symphony_elixir_web/static_assets.ex | 33 - elixir/mise.toml | 3 - elixir/mix.exs | 98 - elixir/mix.lock | 38 - elixir/priv/static/dashboard.css | 463 ---- .../backoff_queue.evidence.md | 23 - .../backoff_queue.snapshot.txt | 21 - .../credits_unlimited.evidence.md | 20 - .../credits_unlimited.snapshot.txt | 18 - .../idle.evidence.md | 20 - .../idle.snapshot.txt | 18 - .../idle_with_dashboard_url.evidence.md | 21 - .../idle_with_dashboard_url.snapshot.txt | 19 - .../super_busy.evidence.md | 21 - .../super_busy.snapshot.txt | 19 - elixir/test/mix/tasks/pr_body_check_test.exs | 341 --- .../test/mix/tasks/specs_check_task_test.exs | 112 - .../tasks/workspace_before_remove_test.exs | 390 ---- elixir/test/support/snapshot_support.exs | 78 - elixir/test/support/test_support.exs | 270 --- .../test/symphony_elixir/app_server_test.exs | 1058 --------- elixir/test/symphony_elixir/cli_test.exs | 139 -- elixir/test/symphony_elixir/core_test.exs | 1530 ------------- .../symphony_elixir/dynamic_tool_test.exs | 378 ---- .../test/symphony_elixir/extensions_test.exs | 741 ------- elixir/test/symphony_elixir/log_file_test.exs | 13 - .../observability_pubsub_test.exs | 28 - .../orchestrator_status_test.exs | 1602 -------------- .../test/symphony_elixir/specs_check_test.exs | 92 - .../status_dashboard_snapshot_test.exs | 288 --- .../workspace_and_config_test.exs | 885 -------- elixir/test/test_helper.exs | 3 - nodejs/README.md | 16 +- nodejs/package.json | 2 +- 88 files changed, 162 insertions(+), 19795 deletions(-) delete mode 100644 .codex/skills/linear/SKILL.md delete mode 100644 .github/workflows/pr-description-lint.yml delete mode 100644 elixir/.formatter.exs delete mode 100644 elixir/.gitattributes delete mode 100644 elixir/.gitignore delete mode 100644 elixir/AGENTS.md delete mode 100644 elixir/Makefile delete mode 100644 elixir/README.md delete mode 100644 elixir/WORKFLOW.md delete mode 100644 elixir/config/config.exs delete mode 100644 elixir/docs/logging.md delete mode 100644 elixir/docs/token_accounting.md delete mode 100644 elixir/lib/mix/tasks/pr_body.check.ex delete mode 100644 elixir/lib/mix/tasks/specs.check.ex delete mode 100644 elixir/lib/mix/tasks/workspace.before_remove.ex delete mode 100644 elixir/lib/symphony_elixir.ex delete mode 100644 elixir/lib/symphony_elixir/agent_runner.ex delete mode 100644 elixir/lib/symphony_elixir/cli.ex delete mode 100644 elixir/lib/symphony_elixir/codex/app_server.ex delete mode 100644 elixir/lib/symphony_elixir/codex/dynamic_tool.ex delete mode 100644 elixir/lib/symphony_elixir/config.ex delete mode 100644 elixir/lib/symphony_elixir/http_server.ex delete mode 100644 elixir/lib/symphony_elixir/linear/adapter.ex delete mode 100644 elixir/lib/symphony_elixir/linear/client.ex delete mode 100644 elixir/lib/symphony_elixir/linear/issue.ex delete mode 100644 elixir/lib/symphony_elixir/log_file.ex delete mode 100644 elixir/lib/symphony_elixir/orchestrator.ex delete mode 100644 elixir/lib/symphony_elixir/prompt_builder.ex delete mode 100644 elixir/lib/symphony_elixir/specs_check.ex delete mode 100644 elixir/lib/symphony_elixir/status_dashboard.ex delete mode 100644 elixir/lib/symphony_elixir/tracker.ex delete mode 100644 elixir/lib/symphony_elixir/tracker/memory.ex delete mode 100644 elixir/lib/symphony_elixir/workflow.ex delete mode 100644 elixir/lib/symphony_elixir/workflow_store.ex delete mode 100644 elixir/lib/symphony_elixir/workspace.ex delete mode 100644 elixir/lib/symphony_elixir_web/components/layouts.ex delete mode 100644 elixir/lib/symphony_elixir_web/controllers/observability_api_controller.ex delete mode 100644 elixir/lib/symphony_elixir_web/controllers/static_asset_controller.ex delete mode 100644 elixir/lib/symphony_elixir_web/endpoint.ex delete mode 100644 elixir/lib/symphony_elixir_web/error_html.ex delete mode 100644 elixir/lib/symphony_elixir_web/error_json.ex delete mode 100644 elixir/lib/symphony_elixir_web/live/dashboard_live.ex delete mode 100644 elixir/lib/symphony_elixir_web/observability_pubsub.ex delete mode 100644 elixir/lib/symphony_elixir_web/presenter.ex delete mode 100644 elixir/lib/symphony_elixir_web/router.ex delete mode 100644 elixir/lib/symphony_elixir_web/static_assets.ex delete mode 100644 elixir/mise.toml delete mode 100644 elixir/mix.exs delete mode 100644 elixir/mix.lock delete mode 100644 elixir/priv/static/dashboard.css delete mode 100644 elixir/test/fixtures/status_dashboard_snapshots/backoff_queue.evidence.md delete mode 100644 elixir/test/fixtures/status_dashboard_snapshots/backoff_queue.snapshot.txt delete mode 100644 elixir/test/fixtures/status_dashboard_snapshots/credits_unlimited.evidence.md delete mode 100644 elixir/test/fixtures/status_dashboard_snapshots/credits_unlimited.snapshot.txt delete mode 100644 elixir/test/fixtures/status_dashboard_snapshots/idle.evidence.md delete mode 100644 elixir/test/fixtures/status_dashboard_snapshots/idle.snapshot.txt delete mode 100644 elixir/test/fixtures/status_dashboard_snapshots/idle_with_dashboard_url.evidence.md delete mode 100644 elixir/test/fixtures/status_dashboard_snapshots/idle_with_dashboard_url.snapshot.txt delete mode 100644 elixir/test/fixtures/status_dashboard_snapshots/super_busy.evidence.md delete mode 100644 elixir/test/fixtures/status_dashboard_snapshots/super_busy.snapshot.txt delete mode 100644 elixir/test/mix/tasks/pr_body_check_test.exs delete mode 100644 elixir/test/mix/tasks/specs_check_task_test.exs delete mode 100644 elixir/test/mix/tasks/workspace_before_remove_test.exs delete mode 100644 elixir/test/support/snapshot_support.exs delete mode 100644 elixir/test/support/test_support.exs delete mode 100644 elixir/test/symphony_elixir/app_server_test.exs delete mode 100644 elixir/test/symphony_elixir/cli_test.exs delete mode 100644 elixir/test/symphony_elixir/core_test.exs delete mode 100644 elixir/test/symphony_elixir/dynamic_tool_test.exs delete mode 100644 elixir/test/symphony_elixir/extensions_test.exs delete mode 100644 elixir/test/symphony_elixir/log_file_test.exs delete mode 100644 elixir/test/symphony_elixir/observability_pubsub_test.exs delete mode 100644 elixir/test/symphony_elixir/orchestrator_status_test.exs delete mode 100644 elixir/test/symphony_elixir/specs_check_test.exs delete mode 100644 elixir/test/symphony_elixir/status_dashboard_snapshot_test.exs delete mode 100644 elixir/test/symphony_elixir/workspace_and_config_test.exs delete mode 100644 elixir/test/test_helper.exs diff --git a/.codex/skills/debug/SKILL.md b/.codex/skills/debug/SKILL.md index d4388a0b12..96bc3435cb 100644 --- a/.codex/skills/debug/SKILL.md +++ b/.codex/skills/debug/SKILL.md @@ -1,8 +1,8 @@ --- name: debug description: - Investigate stuck runs and execution failures by tracing Symphony and Codex - logs with issue/session identifiers; use when runs stall, retry repeatedly, or + Investigate stuck runs and execution failures by tracing Symphony-nodejs logs + with issue/session identifiers; use when runs stall, retry repeatedly, or fail unexpectedly. --- @@ -11,25 +11,21 @@ description: ## Goals - Find why a run is stuck, retrying, or failing. -- Correlate Linear issue identity to a Codex session quickly. +- Correlate issue identity to a session quickly. - Read the right logs in the right order to isolate root cause. ## Log Sources -- Primary runtime log: `log/symphony.log` - - Default comes from `SymphonyElixir.LogFile` (`log/symphony.log`). - - Includes orchestrator, agent runner, and Codex app-server lifecycle logs. -- Rotated runtime logs: `log/symphony.log*` - - Check these when the relevant run is older. +- Primary runtime log: structured Winston JSON logs output to stdout/stderr. +- Check any log files configured in the Winston transport settings. ## Correlation Keys -- `issue_identifier`: human ticket key (example: `MT-625`) -- `issue_id`: Linear UUID (stable internal ID) -- `session_id`: Codex thread-turn pair (`-`) +- `issue_identifier`: human ticket key (example: `PROJ-123`) +- `issue_id`: Jira issue ID (stable internal ID) +- `session_id`: agent session identifier -`elixir/docs/logging.md` requires these fields for issue/session lifecycle logs. Use -them as your join keys during debugging. +Use these fields as your join keys during debugging. ## Quick Triage (Stuck Run) @@ -38,81 +34,47 @@ them as your join keys during debugging. 3. Extract `session_id` from matching lines. 4. Trace that `session_id` across start, stream, completion/failure, and stall handling logs. -5. Decide class of failure: timeout/stall, app-server startup failure, turn +5. Decide class of failure: timeout/stall, agent startup failure, turn failure, or orchestrator retry loop. ## Commands ```bash # 1) Narrow by ticket key (fastest entry point) -rg -n "issue_identifier=MT-625" log/symphony.log* +rg -n "PROJ-123" log/*.log -# 2) If needed, narrow by Linear UUID -rg -n "issue_id=" log/symphony.log* +# 2) Pull session IDs seen for that ticket +rg -o "session_id=[^ ;]+" log/*.log | sort -u -# 3) Pull session IDs seen for that ticket -rg -o "session_id=[^ ;]+" log/symphony.log* | sort -u +# 3) Trace one session end-to-end +rg -n "" log/*.log -# 4) Trace one session end-to-end -rg -n "session_id=-" log/symphony.log* - -# 5) Focus on stuck/retry signals -rg -n "Issue stalled|scheduling retry|turn_timeout|turn_failed|Codex session failed|Codex session ended with error" log/symphony.log* +# 4) Focus on stuck/retry signals +rg -n "stall|retry|timeout|failed|error" log/*.log ``` ## Investigation Flow 1. Locate the ticket slice: - - Search by `issue_identifier=`. - - If noise is high, add `issue_id=`. + - Search by `issue_identifier`. + - If noise is high, add `issue_id`. 2. Establish timeline: - - Identify first `Codex session started ... session_id=...`. - - Follow with `Codex session completed`, `ended with error`, or worker exit - lines. + - Identify first session start event. + - Follow with session completed, failed, or worker exit lines. 3. Classify the problem: - - Stall loop: `Issue stalled ... restarting with backoff`. - - App-server startup: `Codex session failed ...`. - - Turn execution failure: `turn_failed`, `turn_cancelled`, `turn_timeout`, or - `ended with error`. - - Worker crash: `Agent task exited ... reason=...`. + - Stall loop: stall detection and backoff restart. + - Agent startup: session initialization failure. + - Turn execution failure: turn failed, timeout, or error. + - Worker crash: agent task exited with error reason. 4. Validate scope: - - Check whether failures are isolated to one issue/session or repeating across - multiple tickets. + - Check whether failures are isolated to one issue/session or repeating + across multiple tickets. 5. Capture evidence: - Save key log lines with timestamps, `issue_identifier`, `issue_id`, and `session_id`. - Record probable root cause and the exact failing stage. -## Reading Codex Session Logs - -In Symphony, Codex session diagnostics are emitted into `log/symphony.log` and -keyed by `session_id`. Read them as a lifecycle: - -1. `Codex session started ... session_id=...` -2. Session stream/lifecycle events for the same `session_id` -3. Terminal event: - - `Codex session completed ...`, or - - `Codex session ended with error ...`, or - - `Issue stalled ... restarting with backoff` - -For one specific session investigation, keep the trace narrow: - -1. Capture one `session_id` for the ticket. -2. Build a timestamped slice for only that session: - - `rg -n "session_id=-" log/symphony.log*` -3. Mark the exact failing stage: - - Startup failure before stream events (`Codex session failed ...`). - - Turn/runtime failure after stream events (`turn_*` / `ended with error`). - - Stall recovery (`Issue stalled ... restarting with backoff`). -4. Pair findings with `issue_identifier` and `issue_id` from nearby lines to - confirm you are not mixing concurrent retries. - -Always pair session findings with `issue_identifier`/`issue_id` to avoid mixing -concurrent runs. - ## Notes - Prefer `rg` over `grep` for speed on large logs. -- Check rotated logs (`log/symphony.log*`) before concluding data is missing. -- If required context fields are missing in new log statements, align with - `elixir/docs/logging.md` conventions. +- Check rotated logs before concluding data is missing. diff --git a/.codex/skills/land/SKILL.md b/.codex/skills/land/SKILL.md index ef712f849a..a4b7db8824 100644 --- a/.codex/skills/land/SKILL.md +++ b/.codex/skills/land/SKILL.md @@ -26,7 +26,7 @@ description: ## Steps 1. Locate the PR for the current branch. -2. Confirm the full gauntlet is green locally before any push. +2. Confirm the project builds cleanly locally before any push (`cd nodejs && npm ci`). 3. If the working tree has uncommitted changes, commit with the `commit` skill and push with the `push` skill before proceeding. 4. Check mergeability and conflicts against main. diff --git a/.codex/skills/linear/SKILL.md b/.codex/skills/linear/SKILL.md deleted file mode 100644 index 3482084dd6..0000000000 --- a/.codex/skills/linear/SKILL.md +++ /dev/null @@ -1,388 +0,0 @@ ---- -name: linear -description: | - Use Symphony's `linear_graphql` client tool for raw Linear GraphQL - operations such as comment editing and upload flows. ---- - -# Linear GraphQL - -Use this skill for raw Linear GraphQL work during Symphony app-server sessions. - -## Primary tool - -Use the `linear_graphql` client tool exposed by Symphony's app-server session. -It reuses Symphony's configured Linear auth for the session. - -Tool input: - -```json -{ - "query": "query or mutation document", - "variables": { - "optional": "graphql variables object" - } -} -``` - -Tool behavior: - -- Send one GraphQL operation per tool call. -- Treat a top-level `errors` array as a failed GraphQL operation even if the - tool call itself completed. -- Keep queries/mutations narrowly scoped; ask only for the fields you need. - -## Discovering unfamiliar operations - -When you need an unfamiliar mutation, input type, or object field, use targeted -introspection through `linear_graphql`. - -List mutation names: - -```graphql -query ListMutations { - __type(name: "Mutation") { - fields { - name - } - } -} -``` - -Inspect a specific input object: - -```graphql -query CommentCreateInputShape { - __type(name: "CommentCreateInput") { - inputFields { - name - type { - kind - name - ofType { - kind - name - } - } - } - } -} -``` - -## Common workflows - -### Query an issue by key, identifier, or id - -Use these progressively: - -- Start with `issue(id: $key)` when you have a ticket key such as `MT-686`. -- Fall back to `issues(filter: ...)` when you need identifier search semantics. -- Once you have the internal issue id, prefer `issue(id: $id)` for narrower reads. - -Lookup by issue key: - -```graphql -query IssueByKey($key: String!) { - issue(id: $key) { - id - identifier - title - state { - id - name - type - } - project { - id - name - } - branchName - url - description - updatedAt - links { - nodes { - id - url - title - } - } - } -} -``` - -Lookup by identifier filter: - -```graphql -query IssueByIdentifier($identifier: String!) { - issues(filter: { identifier: { eq: $identifier } }, first: 1) { - nodes { - id - identifier - title - state { - id - name - type - } - project { - id - name - } - branchName - url - description - updatedAt - } - } -} -``` - -Resolve a key to an internal id: - -```graphql -query IssueByIdOrKey($id: String!) { - issue(id: $id) { - id - identifier - title - } -} -``` - -Read the issue once the internal id is known: - -```graphql -query IssueDetails($id: String!) { - issue(id: $id) { - id - identifier - title - url - description - state { - id - name - type - } - project { - id - name - } - attachments { - nodes { - id - title - url - sourceType - } - } - } -} -``` - -### Query team workflow states for an issue - -Use this before changing issue state when you need the exact `stateId`: - -```graphql -query IssueTeamStates($id: String!) { - issue(id: $id) { - id - team { - id - key - name - states { - nodes { - id - name - type - } - } - } - } -} -``` - -### Edit an existing comment - -Use `commentUpdate` through `linear_graphql`: - -```graphql -mutation UpdateComment($id: String!, $body: String!) { - commentUpdate(id: $id, input: { body: $body }) { - success - comment { - id - body - } - } -} -``` - -### Create a comment - -Use `commentCreate` through `linear_graphql`: - -```graphql -mutation CreateComment($issueId: String!, $body: String!) { - commentCreate(input: { issueId: $issueId, body: $body }) { - success - comment { - id - url - } - } -} -``` - -### Move an issue to a different state - -Use `issueUpdate` with the destination `stateId`: - -```graphql -mutation MoveIssueToState($id: String!, $stateId: String!) { - issueUpdate(id: $id, input: { stateId: $stateId }) { - success - issue { - id - identifier - state { - id - name - } - } - } -} -``` - -### Attach a GitHub PR to an issue - -Use the GitHub-specific attachment mutation when linking a PR: - -```graphql -mutation AttachGitHubPR($issueId: String!, $url: String!, $title: String) { - attachmentLinkGitHubPR( - issueId: $issueId - url: $url - title: $title - linkKind: links - ) { - success - attachment { - id - title - url - } - } -} -``` - -If you only need a plain URL attachment and do not care about GitHub-specific -link metadata, use: - -```graphql -mutation AttachURL($issueId: String!, $url: String!, $title: String) { - attachmentLinkURL(issueId: $issueId, url: $url, title: $title) { - success - attachment { - id - title - url - } - } -} -``` - -### Introspection patterns used during schema discovery - -Use these when the exact field or mutation shape is unclear: - -```graphql -query QueryFields { - __type(name: "Query") { - fields { - name - } - } -} -``` - -```graphql -query IssueFieldArgs { - __type(name: "Query") { - fields { - name - args { - name - type { - kind - name - ofType { - kind - name - ofType { - kind - name - } - } - } - } - } - } -} -``` - -### Upload a video to a comment - -Do this in three steps: - -1. Call `linear_graphql` with `fileUpload` to get `uploadUrl`, `assetUrl`, and - any required upload headers. -2. Upload the local file bytes to `uploadUrl` with `curl -X PUT` and the exact - headers returned by `fileUpload`. -3. Call `linear_graphql` again with `commentCreate` (or `commentUpdate`) and - include the resulting `assetUrl` in the comment body. - -Useful mutations: - -```graphql -mutation FileUpload( - $filename: String! - $contentType: String! - $size: Int! - $makePublic: Boolean -) { - fileUpload( - filename: $filename - contentType: $contentType - size: $size - makePublic: $makePublic - ) { - success - uploadFile { - uploadUrl - assetUrl - headers { - key - value - } - } - } -} -``` - -## Usage rules - -- Use `linear_graphql` for comment edits, uploads, and ad-hoc Linear API - queries. -- Prefer the narrowest issue lookup that matches what you already know: - key -> identifier search -> internal id. -- For state transitions, fetch team states first and use the exact `stateId` - instead of hardcoding names inside mutations. -- Prefer `attachmentLinkGitHubPR` over a generic URL attachment when linking a - GitHub PR to a Linear issue. -- Do not introduce new raw-token shell helpers for GraphQL access. -- If you need shell work for uploads, only use it for signed upload URLs - returned by `fileUpload`; those URLs already carry the needed authorization. diff --git a/.codex/skills/push/SKILL.md b/.codex/skills/push/SKILL.md index 350f7e1942..d0756a48c1 100644 --- a/.codex/skills/push/SKILL.md +++ b/.codex/skills/push/SKILL.md @@ -26,7 +26,7 @@ description: ## Steps 1. Identify current branch and confirm remote state. -2. Run local validation (`make -C elixir all`) before pushing. +2. Run local validation (`cd nodejs && npm ci`) before pushing. 3. Push branch to `origin` with upstream tracking if needed, using whatever remote URL is already configured. 4. If push is not clean/rejected: @@ -52,8 +52,7 @@ description: scope (all intended work on the branch), not just the newest commits, including newly added work, removed work, or changed approach. - Do not reuse stale description text from earlier iterations. -7. Validate PR body with `mix pr_body.check` and fix all reported issues. -8. Reply with the PR URL from `gh pr view`. +7. Reply with the PR URL from `gh pr view`. ## Commands @@ -62,7 +61,7 @@ description: branch=$(git branch --show-current) # Minimal validation gate -make -C elixir all +cd nodejs && npm ci # Initial push: respect the current origin remote. git push -u origin HEAD @@ -99,11 +98,6 @@ fi # 2) gh pr edit --body-file /tmp/pr_body.md # 3) for branch updates, re-check that title/body still match current diff -tmp_pr_body=$(mktemp) -gh pr view --json body -q .body > "$tmp_pr_body" -(cd elixir && mix pr_body.check --file "$tmp_pr_body") -rm -f "$tmp_pr_body" - # Show PR URL for the reply gh pr view --json url -q .url ``` diff --git a/.codex/worktree_init.sh b/.codex/worktree_init.sh index ee39abf02b..c94cc5e7b5 100755 --- a/.codex/worktree_init.sh +++ b/.codex/worktree_init.sh @@ -3,14 +3,12 @@ set -eo pipefail script_dir="$(cd "$(dirname "$0")" && pwd)" repo_root="$(cd "$script_dir/.." && pwd)" -project_root="$repo_root/elixir" +project_root="$repo_root/nodejs" -if ! command -v mise >/dev/null 2>&1; then - echo "mise is required. Install it from https://mise.jdx.dev/getting-started.html" >&2 +if ! command -v node >/dev/null 2>&1; then + echo "Node.js is required. Install it from https://nodejs.org/" >&2 exit 1 fi cd "$project_root" -mise trust - -make setup +npm ci diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0f1a8bb753..2ebeac3a8e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,5 +18,5 @@ #### Test Plan -- [ ] `make -C elixir all` +- [ ] `cd nodejs && npm ci` - [ ] diff --git a/.github/workflows/make-all.yml b/.github/workflows/make-all.yml index ca2ab27449..968b378c17 100644 --- a/.github/workflows/make-all.yml +++ b/.github/workflows/make-all.yml @@ -1,4 +1,4 @@ -name: make-all +name: ci on: pull_request: @@ -7,32 +7,22 @@ on: - main jobs: - make-all: + ci: runs-on: ubuntu-latest defaults: run: - working-directory: elixir + working-directory: nodejs steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up mise tools - uses: jdx/mise-action@v3 + - name: Set up Node.js + uses: actions/setup-node@v4 with: - install: true - cache: true - working_directory: elixir + node-version: '18' + cache: 'npm' + cache-dependency-path: nodejs/package-lock.json - - name: Cache deps and build - uses: actions/cache@v4 - with: - path: | - elixir/deps - elixir/_build - key: ${{ runner.os }}-mix-${{ hashFiles('elixir/mix.lock') }} - restore-keys: | - ${{ runner.os }}-mix- - - - name: Verify make all - run: make all + - name: Install dependencies + run: npm ci diff --git a/.github/workflows/pr-description-lint.yml b/.github/workflows/pr-description-lint.yml deleted file mode 100644 index 9f2e6ac5fc..0000000000 --- a/.github/workflows/pr-description-lint.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: pr-description-lint - -on: - pull_request: - types: [opened, edited, reopened, synchronize, ready_for_review] - -jobs: - validate-pr-description: - runs-on: ubuntu-latest - defaults: - run: - working-directory: elixir - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up mise tools - uses: jdx/mise-action@v3 - with: - install: true - cache: true - working_directory: elixir - - - name: Validate PR description format - env: - PR_BODY_JSON: ${{ toJson(github.event.pull_request.body) }} - run: | - mix local.hex --force - mix local.rebar --force - mix deps.get - printf '%s' "$PR_BODY_JSON" | jq -r '.' > /tmp/pr_body.md - mix pr_body.check --file /tmp/pr_body.md diff --git a/README.en.md b/README.en.md index a915057b70..ba3289ba27 100644 --- a/README.en.md +++ b/README.en.md @@ -1,13 +1,9 @@ -# Symphony +# Symphony-nodejs -**Symphony** is a coding agent orchestrator that turns project work into isolated, autonomous implementation runs, allowing teams to manage work instead of supervising coding agents. - -[![Symphony demo video preview](.github/media/symphony-demo-poster.jpg)](.github/media/symphony-demo.mp4) - -_In this [demo video](.github/media/symphony-demo.mp4), Symphony monitors a Linear board for work and spawns agents to handle the tasks. The agents complete the tasks and provide proof of work: CI status, PR review feedback, complexity analysis, and walkthrough videos. When accepted, the agents land the PR safely. Engineers do not need to supervise Codex; they can manage the work at a higher level._ +**Symphony-nodejs** is a coding agent orchestrator that turns project work into isolated, autonomous implementation runs, allowing teams to manage work instead of supervising coding agents. > [!WARNING] -> Symphony is a low-key engineering preview for testing in trusted environments. +> Symphony-nodejs is a low-key engineering preview for testing in trusted environments. --- @@ -16,13 +12,10 @@ _In this [demo video](.github/media/symphony-demo.mp4), Symphony monitors a Line - [Overview](#overview) - [Core Features](#core-features) - [System Architecture](#system-architecture) -- [Two Implementations](#two-implementations) - - [Elixir Implementation (Linear + Codex)](#elixir-implementation-linear--codex) - - [Node.js Implementation (Jira + GitLab + AI)](#nodejs-implementation-jira--gitlab--ai) +- [Technology Stack](#technology-stack) - [Getting Started](#getting-started) - [Option 1: Build Your Own](#option-1-build-your-own) - - [Option 2: Elixir Reference Implementation](#option-2-elixir-reference-implementation) - - [Option 3: Node.js Implementation](#option-3-nodejs-implementation) + - [Option 2: Node.js Implementation](#option-2-nodejs-implementation) - [Configuration Reference](#configuration-reference) - [WORKFLOW.md File Format](#workflowmd-file-format) - [Front Matter Fields](#front-matter-fields) @@ -47,7 +40,7 @@ _In this [demo video](.github/media/symphony-demo.mp4), Symphony monitors a Line ## Overview -Symphony is a long-running automation service that continuously reads work from an issue tracker (such as Linear or Jira), creates an isolated workspace for each issue, and runs a coding agent session for that issue inside the workspace. +Symphony-nodejs is a long-running automation service that continuously reads work from an issue tracker (such as Jira), creates an isolated workspace for each issue, and runs a coding agent session for that issue inside the workspace. The service solves four core operational problems: @@ -58,7 +51,7 @@ The service solves four core operational problems: ### Important Boundary -- Symphony is a scheduler/runner and tracker reader. +- Symphony-nodejs is a scheduler/runner and tracker reader. - Ticket writes (state transitions, comments, PR links) are typically performed by the coding agent using tools available in the workflow/runtime environment. - A successful run may end at a workflow-defined handoff state (for example `Human Review`), not necessarily `Done`. @@ -75,7 +68,7 @@ The service solves four core operational problems: | Active run reconciliation | Automatically stops runs when issues move to terminal or non-active states | | Stall detection | Detects inactive agent sessions and triggers retry | | Hot reload configuration | Automatically reloads config and prompt template on `WORKFLOW.md` changes without restart | -| Optional web dashboard | Phoenix LiveView (Elixir) or Express (Node.js) dashboard | +| Optional web dashboard | Express dashboard | | JSON REST API | Runtime state query and operational debugging endpoints | | Token usage tracking | Tracks input/output/total token consumption of coding agent sessions | | Rate limit monitoring | Tracks the latest agent rate limit payload | @@ -86,7 +79,7 @@ The service solves four core operational problems: ## System Architecture -Symphony is designed with a layered architecture for portability and clarity: +Symphony-nodejs is designed with a layered architecture for portability and clarity: ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -103,7 +96,7 @@ Symphony is designed with a layered architecture for portability and clarity: │ Filesystem lifecycle, workspace preparation, agent protocol │ ├─────────────────────────────────────────────────────────────────┤ │ Integration Layer │ -│ Tracker adapter (Linear / Jira) API calls and normalization │ +│ Tracker adapter (Jira) API calls and normalization │ ├─────────────────────────────────────────────────────────────────┤ │ Observability Layer │ │ Structured logs + optional status dashboard and JSON API │ @@ -122,33 +115,7 @@ Symphony is designed with a layered architecture for portability and clarity: --- -## Two Implementations - -Symphony ships with two reference implementations, both following the [`SPEC.md`](SPEC.md) specification. - -### Elixir Implementation (Linear + Codex) - -| Component | Technology | -|-----------|------------| -| Language | Elixir ~1.19 (OTP 28) | -| Runtime management | mise | -| Web framework | Phoenix LiveView + Bandit | -| HTTP client | Req | -| YAML parsing | yaml_elixir | -| Template engine | Solid (Liquid-compatible) | -| Linting | Credo + Dialyzer | -| Test framework | ExUnit (100% coverage threshold) | -| Issue tracker | Linear (GraphQL API) | -| Coding agent | Codex app-server (JSON-RPC stdio) | - -**Key capabilities:** -- OTP supervision trees for process reliability -- Hot code reloading during development without stopping active subagents -- Built-in `linear_graphql` client-side tool for raw Linear GraphQL calls during agent sessions -- Phoenix LiveView real-time dashboard -- Compiles to a standalone executable (escript) - -### Node.js Implementation (Jira + GitLab + AI) +## Technology Stack | Component | Technology | |-----------|------------| @@ -168,18 +135,6 @@ Symphony ships with two reference implementations, both following the [`SPEC.md` - **`prompt-only` (default)** — Generates prompts from Jira issues using the WORKFLOW.md template. You copy the prompt and feed it to your AI manually. - **`auto`** — Sends prompts directly to your OpenAI-compatible AI endpoint, runs multi-turn conversations, and tracks token usage. -### Implementation Comparison - -| Feature | Elixir | Node.js | -|---------|--------|---------| -| Issue tracker | Linear | Jira | -| Code hosting | GitHub | GitLab | -| Agent protocol | Codex app-server (JSON-RPC stdio) | OpenAI HTTP API (chat completions) | -| Dashboard | Phoenix LiveView (real-time) | Express (static HTML) | -| Test suite | Full (ExUnit + Credo + Dialyzer) | Not included | -| CI/CD | GitHub Actions | Not included | -| Build artifact | escript executable | Direct Node.js execution | - --- ## Getting Started @@ -191,59 +146,7 @@ Tell your favorite coding agent to build Symphony in a programming language of y > Implement Symphony according to the following spec: > https://github.com/openai/symphony/blob/main/SPEC.md -### Option 2: Elixir Reference Implementation - -#### Prerequisites - -- Install [mise](https://mise.jdx.dev/) to manage Elixir/Erlang versions -- Get a Linear Personal API Key: Linear Settings → Security & access → Personal API keys -- Set the key as the `LINEAR_API_KEY` environment variable -- Ensure your codebase is set up for [harness engineering](https://openai.com/index/harness-engineering/) - -#### Installation and Running - -```bash -git clone https://github.com/openai/symphony -cd symphony/elixir - -# Install runtimes (Elixir + Erlang) -mise trust -mise install - -# Install dependencies and build -mise exec -- mix setup -mise exec -- mix build - -# Start Symphony -mise exec -- ./bin/symphony ./WORKFLOW.md -``` - -#### Optional CLI Flags - -```bash -# Specify a custom workflow file path -./bin/symphony /path/to/custom/WORKFLOW.md - -# Enable the web dashboard (specify port) -./bin/symphony ./WORKFLOW.md --port 4000 - -# Custom log directory -./bin/symphony ./WORKFLOW.md --logs-root /var/log/symphony - -# Combine flags -./bin/symphony ./WORKFLOW.md --port 4000 --logs-root /var/log/symphony -``` - -#### Setting Up for Your Repository - -1. Copy the `elixir/WORKFLOW.md` to your repository. -2. Optionally copy the `commit`, `push`, `pull`, `land`, and `linear` skills from `.codex/skills/` to your repo. -3. Customize the `WORKFLOW.md` for your project: - - Get your Linear project slug from the project URL. - - Configure the `hooks.after_create` to clone your repository. - - Adjust active/terminal states as needed (note: the default workflow depends on non-standard Linear statuses like "Rework", "Human Review", and "Merging"). - -### Option 3: Node.js Implementation +### Option 2: Node.js Implementation #### Prerequisites @@ -293,14 +196,13 @@ Open http://localhost:3000 for the dashboard. ### WORKFLOW.md File Format -`WORKFLOW.md` is Symphony's core configuration file, using YAML front matter + Markdown body format: +`WORKFLOW.md` is Symphony-nodejs's core configuration file, using YAML front matter + Markdown body format: ```markdown --- # YAML front matter (runtime settings) tracker: - kind: linear - project_slug: "my-project-slug" + kind: jira workspace: root: ~/code/workspaces agent: @@ -308,7 +210,7 @@ agent: --- -You are working on Linear issue {{ issue.identifier }} +You are working on Jira issue {{ issue.identifier }} Title: {{ issue.title }} Description: {{ issue.description }} @@ -327,10 +229,7 @@ Description: {{ issue.description }} | Field | Type | Default | Description | |-------|------|---------|-------------| -| `kind` | string | — | Required. `linear` (Elixir) or `jira` / `memory` (Node.js) | -| `endpoint` | string | `https://api.linear.app/graphql` | GraphQL endpoint (Linear) | -| `api_key` | string | `$LINEAR_API_KEY` | API key, supports `$VAR_NAME` env resolution | -| `project_slug` | string | — | Linear project slug (required for `linear` kind) | +| `kind` | string | — | Required. `jira` or `memory` | | `active_states` | list/string | `Todo, In Progress` | Active state names | | `terminal_states` | list/string | `Closed, Cancelled, Canceled, Duplicate, Done` | Terminal state names | @@ -365,24 +264,7 @@ Description: {{ issue.description }} | `max_retry_backoff_ms` | integer | `300000` (5 min) | Maximum retry backoff delay | | `max_concurrent_agents_by_state` | map | `{}` | Per-state concurrency limits (state keys normalized to lowercase) | -#### `codex` (Codex Agent Configuration — Elixir) - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `command` | string | `codex app-server` | Launch command (executed via `bash -lc`) | -| `approval_policy` | string/object | implementation-defined | Codex approval policy | -| `thread_sandbox` | string | implementation-defined | Thread sandbox mode | -| `turn_sandbox_policy` | object | implementation-defined | Turn sandbox policy | -| `turn_timeout_ms` | integer | `3600000` (1 hour) | Turn timeout | -| `read_timeout_ms` | integer | `5000` | Read timeout | -| `stall_timeout_ms` | integer | `300000` (5 min) | Stall detection timeout | - -**Default safety posture (Elixir):** -- `approval_policy` defaults to `{"reject":{"sandbox_approval":true,"rules":true,"mcp_elicitations":true}}` -- `thread_sandbox` defaults to `workspace-write` -- `turn_sandbox_policy` defaults to a `workspaceWrite` policy rooted at the current issue workspace - -#### `ai` (AI Configuration — Node.js) +#### `ai` (AI Configuration) | Field | Type | Default | Description | |-------|------|---------|-------------| @@ -425,7 +307,7 @@ The Markdown body of `WORKFLOW.md` is the per-issue prompt template, rendered us **Template example:** ```markdown -You are working on Linear issue {{ issue.identifier }} +You are working on Jira issue {{ issue.identifier }} {% if attempt %} This is retry attempt #{{ attempt }}. Resume from current workspace state. @@ -439,7 +321,7 @@ Labels: {{ issue.labels }} ### Dynamic Hot Reload -Symphony watches `WORKFLOW.md` for changes and automatically reloads when modifications are detected: +Symphony-nodejs watches `WORKFLOW.md` for changes and automatically reloads when modifications are detected: - Config changes (polling interval, concurrency limits, active/terminal states, etc.) take effect immediately for subsequent scheduling - Prompt template changes apply to future runs @@ -553,38 +435,9 @@ A run attempt transitions through these phases: ## Coding Agent Integration Protocol -### Elixir: Codex App-Server Protocol - -Symphony communicates with the Codex app-server via JSON-RPC over stdio: +### OpenAI-Compatible HTTP API -1. **Launch** — `bash -lc ` with workspace path as working directory -2. **Handshake** — Send `initialize` → `initialized` → `thread/start` → `turn/start` in sequence -3. **Stream** — Read line-delimited JSON messages from stdout until turn terminates -4. **Continue** — If continuing, issue a new `turn/start` on the same thread -5. **Termination** — `turn/completed` (success), `turn/failed`/`turn/cancelled`/timeout/process exit (failure) - -**Startup handshake example:** - -```json -{"id":1,"method":"initialize","params":{"clientInfo":{"name":"symphony","version":"1.0"},"capabilities":{}}} -{"method":"initialized","params":{}} -{"id":2,"method":"thread/start","params":{"approvalPolicy":"never","sandbox":"workspace-write","cwd":"/abs/workspace"}} -{"id":3,"method":"turn/start","params":{"threadId":"","input":[{"type":"text","text":""}],"cwd":"/abs/workspace","title":"ABC-123: Example"}} -``` - -**Approval and tool call handling:** -- Command/file-change approvals are handled according to the configured policy -- Unsupported dynamic tool calls are rejected without stalling the session -- User input requests are treated as hard failure by default - -**Optional `linear_graphql` tool:** -- Exposes raw Linear GraphQL access through the app-server session -- Allows agents to query and mutate Linear data using Symphony's configured auth -- Input: `{ "query": "...", "variables": { ... } }` - -### Node.js: OpenAI-Compatible HTTP API - -The Node.js implementation communicates with AI endpoints via the standard OpenAI chat completions HTTP API, supporting multi-turn conversations. +Symphony-nodejs communicates with AI endpoints via the standard OpenAI chat completions HTTP API, supporting multi-turn conversations. --- @@ -598,15 +451,7 @@ The Node.js implementation communicates with AI endpoints via the standard OpenA | `fetch_issues_by_states(state_names)` | Startup terminal workspace cleanup | | `fetch_issue_states_by_ids(issue_ids)` | Active run reconciliation (state refresh) | -### Linear Integration (Elixir) - -- GraphQL API, default endpoint `https://api.linear.app/graphql` -- Authentication via `Authorization` header -- Projects filtered by `project.slugId` -- Pagination support (default page size: 50) -- Optional `linear_graphql` client-side tool for agent sessions - -### Jira Integration (Node.js) +### Jira Integration - REST API - Supports filtering by project key, assignee, and status @@ -626,7 +471,7 @@ All tracker implementations normalize issues to a common model: ### Web Dashboard -Both implementations serve a human-readable dashboard at `/`, displaying: +The dashboard is served at `/`, displaying: - Active sessions and their status - Retry delay queue @@ -635,10 +480,7 @@ Both implementations serve a human-readable dashboard at `/`, displaying: - Recent events - Health/error indicators -**Elixir** uses Phoenix LiveView for real-time updates. -**Node.js** uses Express with a static HTML dashboard. - -![Symphony Elixir screenshot](.github/media/elixir-screenshot.png) +The dashboard uses Express with a static HTML page. ### JSON REST API @@ -738,7 +580,7 @@ Both implementations serve a human-readable dashboard at `/`, displaying: ### Restart Recovery -Symphony uses intentionally in-memory state. After restart, recovery happens through: +Symphony-nodejs uses intentionally in-memory state. After restart, recovery happens through: 1. Startup terminal workspace cleanup 2. Fresh polling of active issues @@ -777,7 +619,6 @@ No retry timers or running sessions are restored from prior process memory. ### Hardening Recommendations -- Tighten Codex approval and sandbox settings - Add external isolation layers (OS/container/VM sandboxing, network restrictions) - Filter which issues, projects, or labels are eligible for dispatch - Reduce available tools, credentials, filesystem paths, and network destinations to the minimum needed @@ -789,32 +630,6 @@ No retry timers or running sessions are restored from prior process memory. ## Testing -### Elixir - -```bash -cd elixir - -# Run the full quality gate (format check + lint + coverage + dialyzer) -make all - -# Or run individual targets -make fmt-check # Format check -make lint # Credo linting + @spec check -make coverage # ExUnit tests (100% coverage threshold) -make dialyzer # Type checking -make build # Compile to escript -``` - -The Elixir test suite covers: -- Workflow and config parsing (front matter, defaults, env resolution) -- Workspace management and safety invariants -- Issue tracker client (normalization, pagination, error handling) -- Orchestrator dispatch, reconciliation, and retry logic -- Coding-agent app-server client protocol -- Prompt rendering with strict variable checking - -### Node.js - ```bash cd nodejs npm install @@ -827,7 +642,7 @@ npm run dev # Development mode (file watch auto-restart) ## Project Structure ``` -symphony/ +symphony-nodejs/ ├── README.md # Project overview (original) ├── README.zh-CN.md # Chinese README ├── README.en.md # English detailed README @@ -835,9 +650,7 @@ symphony/ ├── LICENSE # Apache License 2.0 │ ├── .github/ -│ ├── workflows/ -│ │ ├── make-all.yml # CI: format, lint, coverage, dialyzer -│ │ └── pr-description-lint.yml # PR description validation +│ ├── workflows/ # CI workflows │ ├── pull_request_template.md # PR template │ └── media/ # Demo video, screenshots │ @@ -848,50 +661,8 @@ symphony/ │ ├── push/SKILL.md # Push skill │ ├── pull/SKILL.md # Pull skill │ ├── land/SKILL.md # Land/merge skill -│ ├── linear/SKILL.md # Linear interaction skill │ └── debug/SKILL.md # Debug skill │ -├── elixir/ # Elixir/OTP implementation -│ ├── lib/ -│ │ ├── symphony_elixir/ -│ │ │ ├── cli.ex # CLI entry point -│ │ │ ├── config.ex # Typed configuration layer -│ │ │ ├── workflow.ex # WORKFLOW.md parser -│ │ │ ├── workflow_store.ex # File watcher + hot reload -│ │ │ ├── orchestrator.ex # Poll/dispatch/retry/reconciliation -│ │ │ ├── agent_runner.ex # Per-issue turn runner -│ │ │ ├── workspace.ex # Per-issue workspace management -│ │ │ ├── prompt_builder.ex # Liquid template rendering -│ │ │ ├── tracker.ex # Tracker adapter interface -│ │ │ ├── status_dashboard.ex # Status dashboard logic -│ │ │ ├── http_server.ex # HTTP server management -│ │ │ ├── log_file.ex # Log file management -│ │ │ ├── linear/ -│ │ │ │ ├── client.ex # Linear GraphQL client -│ │ │ │ ├── adapter.ex # Linear data adapter -│ │ │ │ └── issue.ex # Linear issue model -│ │ │ ├── codex/ -│ │ │ │ ├── app_server.ex # Codex app-server client -│ │ │ │ └── dynamic_tool.ex # Dynamic tools (linear_graphql) -│ │ │ └── tracker/ -│ │ │ └── memory.ex # In-memory tracker adapter -│ │ └── symphony_elixir_web/ -│ │ ├── endpoint.ex # Phoenix endpoint -│ │ ├── router.ex # Route definitions -│ │ ├── live/ -│ │ │ └── dashboard_live.ex # LiveView dashboard -│ │ └── controllers/ -│ │ └── observability_api_controller.ex # JSON API -│ ├── test/ # ExUnit tests -│ ├── config/config.exs # Mix configuration -│ ├── mix.exs # Project definition and dependencies -│ ├── Makefile # Build and test targets -│ ├── WORKFLOW.md # Example workflow -│ ├── AGENTS.md # Agent coding conventions -│ └── docs/ -│ ├── logging.md # Logging conventions -│ └── token_accounting.md # Token accounting docs -│ └── nodejs/ # Node.js implementation ├── src/ │ ├── index.js # CLI entry point @@ -929,7 +700,7 @@ The full language-agnostic specification is in [`SPEC.md`](SPEC.md), covering: - Orchestration state machine design - Polling, scheduling, and reconciliation algorithms - Workspace management and safety specification -- Coding-agent app-server protocol +- Coding-agent protocol - Issue tracker integration contract - Prompt construction and context assembly - Logging and observability specification @@ -945,42 +716,33 @@ You can provide `SPEC.md` to any coding agent and have it implement a fully conf ## FAQ -### Why was Elixir chosen for the reference implementation? - -Elixir is built on Erlang/BEAM/OTP, which excels at supervising long-running processes. It has an active ecosystem of tools and libraries. It also supports hot code reloading without stopping actively running subagents, which is very useful during development. - ### How do I set this up for my own codebase? -Launch `codex` in your repo, give it the URL to the Symphony repo, and ask it to set things up: - -> Set up Symphony for my repository based on -> https://github.com/openai/symphony/blob/main/elixir/README.md - -### What is the relationship between Symphony and Codex? +Check out [nodejs/README.md](nodejs/README.md) for detailed setup instructions. You can also ask your favorite coding agent: -Symphony is the orchestration layer, responsible for fetching work from issue trackers, managing workspaces, dispatching tasks, and handling retries. Codex is the execution layer, responsible for actual code writing and tool invocation. Symphony communicates with Codex via the app-server protocol (JSON-RPC over stdio). +> Set up Symphony-nodejs for my repository based on +> https://github.com/openai/symphony/blob/main/nodejs/README.md -### Can I use other coding agents instead of Codex? +### Can I use other coding agents? Yes. The Node.js implementation demonstrates how to use a generic OpenAI-compatible HTTP API. You can also implement your own agent integration based on `SPEC.md`, as long as the agent supports the required communication protocol. ### Where should WORKFLOW.md be placed? -It is recommended to place `WORKFLOW.md` in the repository root and version-control it. This allows teams to manage agent prompts and runtime settings alongside their code. Symphony looks for `WORKFLOW.md` in the current working directory by default, but a custom path can be specified via CLI argument. +It is recommended to place `WORKFLOW.md` in the repository root and version-control it. This allows teams to manage agent prompts and runtime settings alongside their code. Symphony-nodejs looks for `WORKFLOW.md` in the current working directory by default, but a custom path can be specified via CLI argument. -### How do I harden Symphony for production? +### How do I harden Symphony-nodejs for production? Refer to Section 15 of `SPEC.md` for security and operational safety guidance: -- Tighten Codex approval and sandbox settings - Add container/VM isolation layers - Restrict network access - Filter which issues are eligible for dispatch - Minimize available tools and credentials - Run under a dedicated OS user with restricted permissions -### What happens when Symphony restarts? +### What happens when Symphony-nodejs restarts? -Symphony uses in-memory state, so no retry timers or running sessions survive a restart. Recovery is tracker-driven: the service performs startup terminal workspace cleanup, polls for active issues, and re-dispatches eligible work automatically. +Symphony-nodejs uses in-memory state, so no retry timers or running sessions survive a restart. Recovery is tracker-driven: the service performs startup terminal workspace cleanup, polls for active issues, and re-dispatches eligible work automatically. ### How does the multi-turn agent loop work? diff --git a/README.md b/README.md index 8a8e006d5b..0671321098 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,17 @@ -# Symphony +# Symphony-nodejs -Symphony turns project work into isolated, autonomous implementation runs, allowing teams to manage +Symphony-nodejs turns project work into isolated, autonomous implementation runs, allowing teams to manage work instead of supervising coding agents. -[![Symphony demo video preview](.github/media/symphony-demo-poster.jpg)](.github/media/symphony-demo.mp4) - -_In this [demo video](.github/media/symphony-demo.mp4), Symphony monitors a Linear board for work and spawns agents to handle the tasks. The agents complete the tasks and provide proof of work: CI status, PR review feedback, complexity analysis, and walkthrough videos. When accepted, the agents land the PR safely. Engineers do not need to supervise Codex; they can manage the work at a higher level._ - > [!WARNING] -> Symphony is a low-key engineering preview for testing in trusted environments. +> Symphony-nodejs is a low-key engineering preview for testing in trusted environments. -## Running Symphony +## Running Symphony-nodejs ### Requirements -Symphony works best in codebases that have adopted -[harness engineering](https://openai.com/index/harness-engineering/). Symphony is the next step -- +Symphony-nodejs works best in codebases that have adopted +[harness engineering](https://openai.com/index/harness-engineering/). Symphony-nodejs is the next step -- moving from managing coding agents to managing work that needs to get done. ### Option 1. Make your own @@ -25,14 +21,35 @@ Tell your favorite coding agent to build Symphony in a programming language of y > Implement Symphony according to the following spec: > https://github.com/openai/symphony/blob/main/SPEC.md -### Option 2. Use our experimental reference implementation +### Option 2. Use the Node.js implementation + +#### Prerequisites + +- Node.js >= 18 +- Jira account and API token +- (Optional) GitLab account and Personal Access Token +- (Optional, for `auto` mode) OpenAI-compatible AI API endpoint + +#### Installation and Running + +```bash +cd nodejs +npm install + +# Copy and fill in your credentials +cp .env.example .env +# Edit .env with your Jira, GitLab, AI credentials + +# Run (uses ./WORKFLOW.md by default) +node src/index.js + +# Or specify a workflow file and port +node src/index.js ./WORKFLOW.md --port 3000 +``` -Check out [elixir/README.md](elixir/README.md) for instructions on how to set up your environment -and run the Elixir-based Symphony implementation. You can also ask your favorite coding agent to -help with the setup: +Open http://localhost:3000 for the dashboard. -> Set up Symphony for my repository based on -> https://github.com/openai/symphony/blob/main/elixir/README.md +Check out [nodejs/README.md](nodejs/README.md) for detailed instructions. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index d18a55cc3d..e9c795dccf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,13 +1,9 @@ -# Symphony +# Symphony-nodejs -**Symphony** 是一个编码代理编排器,它将项目工作转化为隔离的、自主的执行运行,让团队专注于管理工作而非监督编码代理。 - -[![Symphony 演示视频预览](.github/media/symphony-demo-poster.jpg)](.github/media/symphony-demo.mp4) - -_在这个[演示视频](.github/media/symphony-demo.mp4)中,Symphony 监控 Linear 看板中的工作并派生代理来处理任务。代理完成任务并提供工作证据:CI 状态、PR 审查反馈、复杂度分析和演示视频。当被接受后,代理会安全地合并 PR。工程师无需监督 Codex;他们可以在更高层级管理工作。_ +**Symphony-nodejs** 是一个编码代理编排器,它将项目工作转化为隔离的、自主的执行运行,让团队专注于管理工作而非监督编码代理。 > [!WARNING] -> Symphony 是一个低调的工程预览版本,仅供在可信环境中测试。 +> Symphony-nodejs 是一个低调的工程预览版本,仅供在可信环境中测试。 --- @@ -16,13 +12,10 @@ _在这个[演示视频](.github/media/symphony-demo.mp4)中,Symphony 监控 L - [项目简介](#项目简介) - [核心特性](#核心特性) - [系统架构](#系统架构) -- [两种实现](#两种实现) - - [Elixir 实现(Linear + Codex)](#elixir-实现linear--codex) - - [Node.js 实现(Jira + GitLab + AI)](#nodejs-实现jira--gitlab--ai) +- [技术栈](#技术栈) - [快速开始](#快速开始) - [方式一:让代理为你构建](#方式一让代理为你构建) - - [方式二:使用 Elixir 参考实现](#方式二使用-elixir-参考实现) - - [方式三:使用 Node.js 实现](#方式三使用-nodejs-实现) + - [方式二:使用 Node.js 实现](#方式二使用-nodejs-实现) - [配置详解](#配置详解) - [WORKFLOW.md 文件格式](#workflowmd-文件格式) - [前置配置字段](#前置配置字段) @@ -47,7 +40,7 @@ _在这个[演示视频](.github/media/symphony-demo.mp4)中,Symphony 监控 L ## 项目简介 -Symphony 是一个长期运行的自动化服务,它持续从问题追踪器(如 Linear 或 Jira)中读取工作,为每个问题创建隔离的工作空间,并在工作空间内运行编码代理会话。 +Symphony-nodejs 是一个长期运行的自动化服务,它持续从问题追踪器(如 Jira)中读取工作,为每个问题创建隔离的工作空间,并在工作空间内运行编码代理会话。 该服务解决了四个核心运营问题: @@ -58,7 +51,7 @@ Symphony 是一个长期运行的自动化服务,它持续从问题追踪器 ### 重要边界 -- Symphony 是一个调度器/运行器和追踪器读取器。 +- Symphony-nodejs 是一个调度器/运行器和追踪器读取器。 - 工单写入(状态转换、评论、PR 链接)通常由编码代理使用工作流/运行时环境中可用的工具来执行。 - 一次成功的运行可能结束于工作流定义的交接状态(例如 `Human Review`),而不一定是 `Done`。 @@ -75,7 +68,7 @@ Symphony 是一个长期运行的自动化服务,它持续从问题追踪器 | 活跃运行协调 | 当问题状态变为终态或非活跃态时自动停止运行 | | 卡死检测 | 检测无活动的代理会话并触发重试 | | 热重载配置 | 修改 `WORKFLOW.md` 后无需重启即可自动重新加载配置和提示模板 | -| 可选 Web 仪表盘 | Phoenix LiveView(Elixir)或 Express(Node.js)仪表盘 | +| 可选 Web 仪表盘 | Express 仪表盘 | | JSON REST API | 提供运行时状态查询和操作调试接口 | | Token 使用追踪 | 跟踪编码代理的输入/输出/总 token 消耗 | | 速率限制监控 | 追踪最新的代理速率限制负载 | @@ -86,7 +79,7 @@ Symphony 是一个长期运行的自动化服务,它持续从问题追踪器 ## 系统架构 -Symphony 采用分层架构设计,易于移植和理解: +Symphony-nodejs 采用分层架构设计,易于移植和理解: ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -103,7 +96,7 @@ Symphony 采用分层架构设计,易于移植和理解: │ 文件系统生命周期、工作空间准备、编码代理协议 │ ├─────────────────────────────────────────────────────────────────┤ │ 集成层 (Integration Layer) │ -│ 追踪器适配器(Linear / Jira)的 API 调用和数据规范化 │ +│ 追踪器适配器(Jira)的 API 调用和数据规范化 │ ├─────────────────────────────────────────────────────────────────┤ │ 可观测性层 (Observability Layer) │ │ 结构化日志 + 可选的状态仪表盘和 JSON API │ @@ -122,33 +115,7 @@ Symphony 采用分层架构设计,易于移植和理解: --- -## 两种实现 - -Symphony 提供了两种参考实现,均遵循 [`SPEC.md`](SPEC.md) 规范。 - -### Elixir 实现(Linear + Codex) - -| 组件 | 技术 | -|------|------| -| 语言 | Elixir ~1.19(OTP 28) | -| 运行时管理 | mise | -| Web 框架 | Phoenix LiveView + Bandit | -| HTTP 客户端 | Req | -| YAML 解析 | yaml_elixir | -| 模板引擎 | Solid(Liquid 兼容) | -| 代码检查 | Credo + Dialyzer | -| 测试框架 | ExUnit(100% 覆盖率阈值) | -| 问题追踪器 | Linear(GraphQL API) | -| 编码代理 | Codex app-server(JSON-RPC stdio) | - -**特色功能:** -- OTP 监督树确保进程可靠性 -- 开发时热代码重载,无需停止活跃的子代理 -- 内置 `linear_graphql` 客户端工具,支持原始 Linear GraphQL 调用 -- Phoenix LiveView 实时仪表盘 -- 编译为独立可执行文件(escript) - -### Node.js 实现(Jira + GitLab + AI) +## 技术栈 | 组件 | 技术 | |------|------| @@ -168,18 +135,6 @@ Symphony 提供了两种参考实现,均遵循 [`SPEC.md`](SPEC.md) 规范。 - **`prompt-only`(默认)** — 从 Jira 问题生成提示,你手动将提示复制到公司的 AI 端点。 - **`auto`** — 直接将提示发送到 OpenAI 兼容的 AI 端点,运行多轮对话并追踪 token 使用。 -### 实现对比 - -| 特性 | Elixir | Node.js | -|------|--------|---------| -| 问题追踪器 | Linear | Jira | -| 代码托管 | GitHub | GitLab | -| 编码代理协议 | Codex app-server (JSON-RPC stdio) | OpenAI HTTP API (聊天补全) | -| 仪表盘 | Phoenix LiveView(实时) | Express(静态 HTML) | -| 测试套件 | 完整(ExUnit + Credo + Dialyzer) | 未提供 | -| CI/CD | GitHub Actions | 未提供 | -| 构建产物 | escript 可执行文件 | 直接运行 Node.js | - --- ## 快速开始 @@ -191,46 +146,7 @@ Symphony 提供了两种参考实现,均遵循 [`SPEC.md`](SPEC.md) 规范。 > 按照以下规范实现 Symphony: > https://github.com/openai/symphony/blob/main/SPEC.md -### 方式二:使用 Elixir 参考实现 - -#### 前置条件 - -- 安装 [mise](https://mise.jdx.dev/) 来管理 Elixir/Erlang 版本 -- 获取 Linear Personal API Key:Linear Settings → Security & access → Personal API keys -- 将 API Key 设置为 `LINEAR_API_KEY` 环境变量 - -#### 安装和运行 - -```bash -git clone https://github.com/openai/symphony -cd symphony/elixir - -# 安装运行时(Elixir + Erlang) -mise trust -mise install - -# 安装依赖并编译 -mise exec -- mix setup -mise exec -- mix build - -# 启动 Symphony -mise exec -- ./bin/symphony ./WORKFLOW.md -``` - -#### 可选 CLI 参数 - -```bash -# 指定自定义工作流文件路径 -./bin/symphony /path/to/custom/WORKFLOW.md - -# 启用 Web 仪表盘(指定端口) -./bin/symphony ./WORKFLOW.md --port 4000 - -# 自定义日志目录 -./bin/symphony ./WORKFLOW.md --logs-root /var/log/symphony -``` - -### 方式三:使用 Node.js 实现 +### 方式二:使用 Node.js 实现 #### 前置条件 @@ -280,14 +196,13 @@ node src/index.js ./WORKFLOW.md --port 3000 ### WORKFLOW.md 文件格式 -`WORKFLOW.md` 是 Symphony 的核心配置文件,使用 YAML 前置配置 + Markdown 正文格式: +`WORKFLOW.md` 是 Symphony-nodejs 的核心配置文件,使用 YAML 前置配置 + Markdown 正文格式: ```markdown --- # YAML 前置配置(运行时设置) tracker: - kind: linear - project_slug: "my-project-slug" + kind: jira workspace: root: ~/code/workspaces agent: @@ -295,7 +210,7 @@ agent: --- -你正在处理 Linear 工单 {{ issue.identifier }} +你正在处理 Jira 工单 {{ issue.identifier }} 标题: {{ issue.title }} 描述: {{ issue.description }} @@ -314,10 +229,7 @@ agent: | 字段 | 类型 | 默认值 | 描述 | |------|------|--------|------| -| `kind` | string | — | 必填。`linear`(Elixir)或 `jira` / `memory`(Node.js) | -| `endpoint` | string | `https://api.linear.app/graphql` | GraphQL 端点(Linear) | -| `api_key` | string | `$LINEAR_API_KEY` | API 密钥,支持 `$VAR_NAME` 环境变量解析 | -| `project_slug` | string | — | Linear 项目 slug(`linear` 类型必填) | +| `kind` | string | — | 必填。`jira` 或 `memory` | | `active_states` | list/string | `Todo, In Progress` | 活跃状态列表 | | `terminal_states` | list/string | `Closed, Cancelled, Canceled, Duplicate, Done` | 终态列表 | @@ -352,19 +264,7 @@ agent: | `max_retry_backoff_ms` | integer | `300000`(5分钟) | 重试退避的最大延迟 | | `max_concurrent_agents_by_state` | map | `{}` | 按状态的并发限制(状态名标准化为小写) | -#### `codex`(Codex 代理配置 — Elixir) - -| 字段 | 类型 | 默认值 | 描述 | -|------|------|--------|------| -| `command` | string | `codex app-server` | 启动命令(通过 `bash -lc` 执行) | -| `approval_policy` | string/object | 实现定义 | Codex 审批策略 | -| `thread_sandbox` | string | 实现定义 | 线程沙箱模式 | -| `turn_sandbox_policy` | object | 实现定义 | Turn 沙箱策略 | -| `turn_timeout_ms` | integer | `3600000`(1小时) | Turn 超时 | -| `read_timeout_ms` | integer | `5000` | 读取超时 | -| `stall_timeout_ms` | integer | `300000`(5分钟) | 卡死检测超时 | - -#### `ai`(AI 配置 — Node.js) +#### `ai`(AI 配置) | 字段 | 类型 | 默认值 | 描述 | |------|------|--------|------| @@ -407,7 +307,7 @@ agent: **模板示例:** ```markdown -你正在处理 Linear 工单 {{ issue.identifier }} +你正在处理 Jira 工单 {{ issue.identifier }} {% if attempt %} 这是第 {{ attempt }} 次重试。请从当前工作空间状态继续,而非从头开始。 @@ -421,7 +321,7 @@ agent: ### 动态热重载 -Symphony 监控 `WORKFLOW.md` 文件变更,并在检测到修改时自动重新加载: +Symphony-nodejs 监控 `WORKFLOW.md` 文件变更,并在检测到修改时自动重新加载: - 配置变更(轮询间隔、并发限制、活跃/终态状态等)立即生效,影响后续调度 - 提示模板变更对未来的运行生效 @@ -494,18 +394,6 @@ Symphony 监控 `WORKFLOW.md` 文件变更,并在检测到修改时自动重 4. **排序** — 按优先级、创建时间、标识符排序 5. **分发** — 在有可用槽位时分发合格问题 -### 候选选择规则 - -问题必须同时满足以下条件才能被分发: - -- 具有 `id`、`identifier`、`title` 和 `state` -- 状态在 `active_states` 中且不在 `terminal_states` 中 -- 未在 `running` 中运行 -- 未在 `claimed` 中被认领 -- 全局并发槽位可用 -- 按状态并发槽位可用 -- `Todo` 状态的阻塞者规则通过(无非终态阻塞者) - ### 重试与退避 | 场景 | 延迟计算 | @@ -517,19 +405,9 @@ Symphony 监控 `WORKFLOW.md` 文件变更,并在检测到修改时自动重 ## 编码代理集成协议 -### Elixir:Codex App-Server 协议 - -Symphony 通过 JSON-RPC stdio 协议与 Codex app-server 通信: - -1. **启动** — `bash -lc `,工作目录为工作空间路径 -2. **握手** — 依次发送 `initialize` → `initialized` → `thread/start` → `turn/start` -3. **流式处理** — 读取 stdout 的行分隔 JSON 消息直到 turn 终止 -4. **续接** — 若需继续,在同一线程上发起新的 `turn/start` -5. **终止条件** — `turn/completed`(成功)、`turn/failed`/`turn/cancelled`/超时/进程退出(失败) +### OpenAI 兼容 HTTP API -### Node.js:OpenAI 兼容 HTTP API - -Node.js 实现通过标准的 OpenAI 聊天补全 HTTP API 与 AI 端点通信,支持多轮对话。 +Symphony-nodejs 通过标准的 OpenAI 聊天补全 HTTP API 与 AI 端点通信,支持多轮对话。 --- @@ -543,15 +421,7 @@ Node.js 实现通过标准的 OpenAI 聊天补全 HTTP API 与 AI 端点通信 | `fetch_issues_by_states(state_names)` | 启动时终态清理 | | `fetch_issue_states_by_ids(issue_ids)` | 活跃运行协调(状态刷新) | -### Linear 集成(Elixir) - -- GraphQL API,端点默认为 `https://api.linear.app/graphql` -- 通过 `Authorization` 头发送认证令牌 -- 使用 `project.slugId` 过滤项目问题 -- 支持分页(默认每页 50 条) -- 可选 `linear_graphql` 客户端工具供代理在会话中使用 - -### Jira 集成(Node.js) +### Jira 集成 - REST API - 支持按项目键、受理人、状态过滤 @@ -563,7 +433,7 @@ Node.js 实现通过标准的 OpenAI 聊天补全 HTTP API 与 AI 端点通信 ### Web 仪表盘 -两种实现都在 `/` 路径提供人类可读的仪表盘,展示: +仪表盘在 `/` 路径提供,展示: - 活跃会话及其状态 - 重试延迟队列 @@ -572,8 +442,7 @@ Node.js 实现通过标准的 OpenAI 聊天补全 HTTP API 与 AI 端点通信 - 最近事件 - 健康/错误指标 -**Elixir** 使用 Phoenix LiveView 实现实时更新仪表盘。 -**Node.js** 使用 Express 提供静态 HTML 仪表盘。 +使用 Express 提供静态 HTML 仪表盘。 ### JSON REST API @@ -583,43 +452,6 @@ Node.js 实现通过标准的 OpenAI 聊天补全 HTTP API 与 AI 端点通信 | GET | `/api/v1/` | 返回单个问题的详细信息 | | POST | `/api/v1/refresh` | 触发立即轮询和协调 | -**`GET /api/v1/state` 响应示例:** - -```json -{ - "generated_at": "2026-03-06T10:15:30Z", - "counts": { "running": 2, "retrying": 1 }, - "running": [ - { - "issue_id": "abc123", - "issue_identifier": "MT-649", - "state": "In Progress", - "session_id": "thread-1-turn-1", - "turn_count": 7, - "last_event": "turn_completed", - "started_at": "2026-03-06T10:10:12Z", - "tokens": { "input_tokens": 1200, "output_tokens": 800, "total_tokens": 2000 } - } - ], - "retrying": [ - { - "issue_id": "def456", - "issue_identifier": "MT-650", - "attempt": 3, - "due_at": "2026-03-06T10:16:00Z", - "error": "no available orchestrator slots" - } - ], - "codex_totals": { - "input_tokens": 5000, - "output_tokens": 2400, - "total_tokens": 7400, - "seconds_running": 1834.2 - }, - "rate_limits": null -} -``` - --- ## 故障模型与恢复策略 @@ -636,7 +468,7 @@ Node.js 实现通过标准的 OpenAI 聊天补全 HTTP API 与 AI 端点通信 ### 重启恢复 -Symphony 采用纯内存状态设计,重启后通过以下方式恢复: +Symphony-nodejs 采用纯内存状态设计,重启后通过以下方式恢复: 1. 启动时终态工作空间清理 2. 重新轮询活跃问题 @@ -664,16 +496,8 @@ Symphony 采用纯内存状态设计,重启后通过以下方式恢复: - 不在日志中记录 API 令牌或密钥环境变量 - 验证密钥存在性但不打印内容 -### 钩子脚本安全 - -- 钩子是来自 `WORKFLOW.md` 的任意 shell 脚本,被视为完全信任的配置 -- 钩子在工作空间目录内运行 -- 钩子输出在日志中会被截断 -- 钩子超时是必需的,以避免阻塞编排器 - ### 强化建议 -- 收紧 Codex 审批和沙箱设置 - 添加外部隔离层(OS/容器/VM 沙箱、网络限制) - 过滤允许分发的问题/项目/标签 - 将代理可用的工具、凭证、文件路径和网络目标减少到最低需要 @@ -682,24 +506,6 @@ Symphony 采用纯内存状态设计,重启后通过以下方式恢复: ## 测试 -### Elixir - -```bash -cd elixir - -# 运行完整质量门(格式检查 + lint + 覆盖率 + dialyzer) -make all - -# 或单独运行 -make fmt-check # 格式检查 -make lint # Credo 代码检查 + @spec 检查 -make coverage # ExUnit 测试(100% 覆盖率阈值) -make dialyzer # 类型检查 -make build # 编译为 escript -``` - -### Node.js - ```bash cd nodejs npm install @@ -712,7 +518,7 @@ npm run dev # 开发模式(文件监控自动重启) ## 项目结构 ``` -symphony/ +symphony-nodejs/ ├── README.md # 项目概述 ├── README.zh-CN.md # 中文 README ├── README.en.md # 英文详细 README @@ -720,9 +526,7 @@ symphony/ ├── LICENSE # Apache License 2.0 │ ├── .github/ -│ ├── workflows/ -│ │ ├── make-all.yml # CI:格式、lint、覆盖率、dialyzer -│ │ └── pr-description-lint.yml # PR 描述验证 +│ ├── workflows/ # CI 工作流 │ ├── pull_request_template.md # PR 模板 │ └── media/ # 演示视频、截图 │ @@ -733,50 +537,8 @@ symphony/ │ ├── push/SKILL.md # 推送技能 │ ├── pull/SKILL.md # 拉取技能 │ ├── land/SKILL.md # 合并着陆技能 -│ ├── linear/SKILL.md # Linear 交互技能 │ └── debug/SKILL.md # 调试技能 │ -├── elixir/ # Elixir/OTP 实现 -│ ├── lib/ -│ │ ├── symphony_elixir/ -│ │ │ ├── cli.ex # CLI 入口 -│ │ │ ├── config.ex # 类型化配置层 -│ │ │ ├── workflow.ex # WORKFLOW.md 解析器 -│ │ │ ├── workflow_store.ex # 文件监控 + 热重载 -│ │ │ ├── orchestrator.ex # 轮询/分发/重试/协调 -│ │ │ ├── agent_runner.ex # 按问题 turn 运行器 -│ │ │ ├── workspace.ex # 按问题工作空间管理 -│ │ │ ├── prompt_builder.ex # Liquid 模板渲染 -│ │ │ ├── tracker.ex # 追踪器适配器接口 -│ │ │ ├── status_dashboard.ex # 状态仪表盘逻辑 -│ │ │ ├── http_server.ex # HTTP 服务器管理 -│ │ │ ├── log_file.ex # 日志文件管理 -│ │ │ ├── linear/ -│ │ │ │ ├── client.ex # Linear GraphQL 客户端 -│ │ │ │ ├── adapter.ex # Linear 数据适配器 -│ │ │ │ └── issue.ex # Linear 问题模型 -│ │ │ ├── codex/ -│ │ │ │ ├── app_server.ex # Codex app-server 客户端 -│ │ │ │ └── dynamic_tool.ex # 动态工具(linear_graphql) -│ │ │ └── tracker/ -│ │ │ └── memory.ex # 内存追踪器适配器 -│ │ └── symphony_elixir_web/ -│ │ ├── endpoint.ex # Phoenix 端点 -│ │ ├── router.ex # 路由定义 -│ │ ├── live/ -│ │ │ └── dashboard_live.ex # LiveView 仪表盘 -│ │ └── controllers/ -│ │ └── observability_api_controller.ex # JSON API -│ ├── test/ # ExUnit 测试 -│ ├── config/config.exs # Mix 配置 -│ ├── mix.exs # 项目定义和依赖 -│ ├── Makefile # 构建和测试目标 -│ ├── WORKFLOW.md # 示例工作流 -│ ├── AGENTS.md # 代理编码约定 -│ └── docs/ -│ ├── logging.md # 日志约定 -│ └── token_accounting.md # Token 计费文档 -│ └── nodejs/ # Node.js 实现 ├── src/ │ ├── index.js # CLI 入口 @@ -814,7 +576,7 @@ symphony/ - 编排状态机详细设计 - 轮询、调度和协调算法 - 工作空间管理和安全规范 -- 编码代理 App-Server 协议 +- 编码代理协议 - 问题追踪器集成契约 - 提示构建和上下文组装 - 日志和可观测性规范 @@ -830,37 +592,32 @@ symphony/ ## 常见问题 -### 为什么选择 Elixir 作为参考实现? - -Elixir 构建在 Erlang/BEAM/OTP 之上,非常适合监督长期运行的进程。它拥有活跃的工具和库生态系统,还支持热代码重载而无需停止正在运行的子代理,这在开发过程中非常有用。 - ### 我怎么为自己的代码库设置? -启动 `codex`,给它 Symphony 仓库的 URL,然后让它帮你设置: - -> 基于 https://github.com/openai/symphony/blob/main/elixir/README.md 为我的仓库设置 Symphony +查看 [nodejs/README.md](nodejs/README.md) 获取详细设置说明。你也可以让编码代理帮忙: -### Symphony 和 Codex 是什么关系? +> 基于 https://github.com/openai/symphony/blob/main/nodejs/README.md 为我的仓库设置 Symphony-nodejs -Symphony 是编排层,负责从问题追踪器获取工作、管理工作空间、分发任务并处理重试。Codex 是执行层,负责实际的代码编写和工具调用。Symphony 通过 Codex app-server 协议(JSON-RPC stdio)与 Codex 通信。 - -### 我可以用其他编码代理替代 Codex 吗? +### 我可以用其他编码代理吗? 可以。Node.js 实现已经演示了如何使用通用的 OpenAI 兼容 HTTP API。你也可以基于 `SPEC.md` 实现自己的代理集成,只要代理支持所需的通信协议。 ### WORKFLOW.md 应该放在哪里? -推荐将 `WORKFLOW.md` 放在仓库根目录并进行版本控制。这样团队可以将代理提示和运行时设置与代码一起管理。Symphony 默认在当前工作目录查找 `WORKFLOW.md`,也可以通过 CLI 参数指定自定义路径。 +推荐将 `WORKFLOW.md` 放在仓库根目录并进行版本控制。这样团队可以将代理提示和运行时设置与代码一起管理。Symphony-nodejs 默认在当前工作目录查找 `WORKFLOW.md`,也可以通过 CLI 参数指定自定义路径。 -### 如何在生产环境中加固 Symphony? +### 如何在生产环境中加固 Symphony-nodejs? 参考 `SPEC.md` 第 15 节的安全和操作安全指南: -- 收紧 Codex 审批和沙箱设置 - 添加容器/VM 隔离层 - 限制网络访问 - 过滤允许分发的问题范围 - 限制代理可用的工具和凭证 +### Symphony-nodejs 重启后会怎样? + +Symphony-nodejs 采用纯内存状态设计,重启后不会恢复重试定时器或运行中的会话。恢复由追踪器驱动:服务启动时清理终态工作空间、轮询活跃问题、重新分发合格工作。 + --- ## 许可证 diff --git a/elixir/.formatter.exs b/elixir/.formatter.exs deleted file mode 100644 index 59e60a5c2e..0000000000 --- a/elixir/.formatter.exs +++ /dev/null @@ -1,5 +0,0 @@ -# Used by "mix format" -[ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], - line_length: 200 -] diff --git a/elixir/.gitattributes b/elixir/.gitattributes deleted file mode 100644 index 6db95539d7..0000000000 --- a/elixir/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -test/fixtures/status_dashboard_snapshots/* linguist-generated=true diff --git a/elixir/.gitignore b/elixir/.gitignore deleted file mode 100644 index a251439010..0000000000 --- a/elixir/.gitignore +++ /dev/null @@ -1,53 +0,0 @@ -# The directory Mix will write compiled artifacts to. -/_build/ - -# If you run "mix test --cover", coverage assets end up here. -/cover/ - -# The directory Mix downloads your dependencies sources to. -/deps/ - -# Where third-party dependencies like ExDoc output generated docs. -/doc/ - -# Temporary files, for example, from tests. -/tmp/ - -# Generated browser assets. -/priv/static/assets/ - -# Local runtime logs. -/log/ -/logs/ - -# If the VM crashes, it generates a dump, let's ignore it too. -erl_crash.dump - -# Elixir language server and tooling. -/.elixir_ls/ -/.fetch/ - -# Editor / OS temporary files. -.DS_Store -*.swp -*.swo -*~ - -# IDE folders. -.idea/ -.vscode/ -/bin/ - -# Local environment and auth artifacts. -.env -.env.* -.secrets -.credentials -status.txt -.codex/original-user-prompt.txt - -# Also ignore archive artifacts (built via "mix archive.build"). -*.ez - -# Ignore package tarball (built via "mix hex.build"). -symphony_elixir-*.tar diff --git a/elixir/AGENTS.md b/elixir/AGENTS.md deleted file mode 100644 index 884aefa905..0000000000 --- a/elixir/AGENTS.md +++ /dev/null @@ -1,64 +0,0 @@ -# Symphony Elixir - -This directory contains the Elixir agent orchestration service that polls Linear, creates per-issue workspaces, and runs Codex in app-server mode. - -## Environment - -- Elixir: `1.19.x` (OTP 28) via `mise`. -- Install deps: `mix setup`. -- Main quality gate: `make all` (format check, lint, coverage, dialyzer). - - -## Codebase-Specific Conventions - -- Runtime config is loaded from `WORKFLOW.md` front matter via `SymphonyElixir.Workflow` and `SymphonyElixir.Config`. -- Keep the implementation aligned with [`../SPEC.md`](../SPEC.md) where practical. - - The implementation may be a superset of the spec. - - The implementation must not conflict with the spec. - - If implementation changes meaningfully alter the intended behavior, update the spec in the same - change where practical so the spec stays current. -- Prefer adding config access through `SymphonyElixir.Config` instead of ad-hoc env reads. -- Workspace safety is critical: - - Never run Codex turn cwd in source repo. - - Workspaces must stay under configured workspace root. -- Orchestrator behavior is stateful and concurrency-sensitive; preserve retry, reconciliation, and cleanup semantics. -- Follow `docs/logging.md` for logging conventions and required issue/session context fields. - -## Tests and Validation - -Run targeted tests while iterating, then run full gates before handoff. - -```bash -make all -``` - -## Required Rules - -- Public functions (`def`) in `lib/` must have an adjacent `@spec`. -- `defp` specs are optional. -- `@impl` callback implementations are exempt from local `@spec` requirement. -- Keep changes narrowly scoped; avoid unrelated refactors. -- Follow existing module/style patterns in `lib/symphony_elixir/*`. - -Validation command: - -```bash -mix specs.check -``` - -## PR Requirements - -- PR body must follow `../.github/pull_request_template.md` exactly. -- Validate PR body locally when needed: - -```bash -mix pr_body.check --file /path/to/pr_body.md -``` - -## Docs Update Policy - -If behavior/config changes, update docs in the same PR: - -- `../README.md` for project concept and goals. -- `README.md` for Elixir implementation and run instructions. -- `WORKFLOW.md` for workflow/config contract changes. diff --git a/elixir/Makefile b/elixir/Makefile deleted file mode 100644 index 9c1ae9096d..0000000000 --- a/elixir/Makefile +++ /dev/null @@ -1,44 +0,0 @@ -.PHONY: help all setup deps build fmt fmt-check lint test coverage ci dialyzer - -MIX ?= mix - -help: - @echo "Targets: setup, deps, fmt, fmt-check, lint, test, coverage, dialyzer, ci" - -setup: - $(MIX) setup - -deps: - $(MIX) deps.get - -build: - $(MIX) build - -fmt: - $(MIX) format - -fmt-check: - $(MIX) format --check-formatted - -lint: - $(MIX) lint - -coverage: - $(MIX) test --cover - -test: - $(MIX) test - -dialyzer: - $(MIX) deps.get - $(MIX) dialyzer --format short - -ci: - $(MAKE) setup - $(MAKE) build - $(MAKE) fmt-check - $(MAKE) lint - $(MAKE) coverage - $(MAKE) dialyzer - -all: ci diff --git a/elixir/README.md b/elixir/README.md deleted file mode 100644 index 3f711587ce..0000000000 --- a/elixir/README.md +++ /dev/null @@ -1,188 +0,0 @@ -# Symphony Elixir - -This directory contains the current Elixir/OTP implementation of Symphony, based on -[`SPEC.md`](../SPEC.md) at the repository root. - -> [!WARNING] -> Symphony Elixir is prototype software intended for evaluation only and is presented as-is. -> We recommend implementing your own hardened version based on `SPEC.md`. - -## Screenshot - -![Symphony Elixir screenshot](../.github/media/elixir-screenshot.png) - -## How it works - -1. Polls Linear for candidate work -2. Creates an isolated 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 -5. Keeps Codex working on the issue until the work is done - -During app-server sessions, Symphony also serves a client-side `linear_graphql` tool so that repo -skills can make raw Linear GraphQL calls. - -If a claimed issue moves to a terminal state (`Done`, `Closed`, `Cancelled`, or `Duplicate`), -Symphony stops the active agent for that issue and cleans up matching workspaces. - -## How to use it - -1. Make sure your codebase is set up to work well with agents: see - [Harness engineering](https://openai.com/index/harness-engineering/). -2. Get a new personal token in Linear via Settings → Security & access → Personal API keys, and - set it as the `LINEAR_API_KEY` environment variable. -3. Copy this directory's `WORKFLOW.md` to your repo. -4. Optionally copy the `commit`, `push`, `pull`, `land`, and `linear` skills to your repo. - - The `linear` skill expects Symphony's `linear_graphql` app-server tool for raw Linear GraphQL - operations such as comment editing or upload flows. -5. Customize the copied `WORKFLOW.md` file for your project. - - To get your project's slug, right-click the project and copy its URL. The slug is part of the - URL. - - When creating a workflow based on this repo, note that it depends on non-standard Linear - issue statuses: "Rework", "Human Review", and "Merging". You can customize them in - Team Settings → Workflow in Linear. -6. Follow the instructions below to install the required runtime dependencies and start the service. - -## Prerequisites - -We recommend using [mise](https://mise.jdx.dev/) to manage Elixir/Erlang versions. - -```bash -mise install -mise exec -- elixir --version -``` - -## Run - -```bash -git clone https://github.com/openai/symphony -cd symphony/elixir -mise trust -mise install -mise exec -- mix setup -mise exec -- mix build -mise exec -- ./bin/symphony ./WORKFLOW.md -``` - -## Configuration - -Pass a custom workflow file path to `./bin/symphony` when starting the service: - -```bash -./bin/symphony /path/to/custom/WORKFLOW.md -``` - -If no path is passed, Symphony defaults to `./WORKFLOW.md`. - -Optional flags: - -- `--logs-root` tells Symphony to write logs under a different directory (default: `./log`) -- `--port` also starts the Phoenix observability service (default: disabled) - -The `WORKFLOW.md` file uses YAML front matter for configuration, plus a Markdown body used as the -Codex session prompt. - -Minimal example: - -```md ---- -tracker: - kind: linear - project_slug: "..." -workspace: - root: ~/code/workspaces -hooks: - after_create: | - git clone git@github.com:your-org/your-repo.git . -agent: - max_concurrent_agents: 10 - max_turns: 20 -codex: - command: codex app-server ---- - -You are working on a Linear issue {{ issue.identifier }}. - -Title: {{ issue.title }} Body: {{ issue.description }} -``` - -Notes: - -- If a value is missing, defaults are used. -- Safer Codex defaults are used when policy fields are omitted: - - `codex.approval_policy` defaults to `{"reject":{"sandbox_approval":true,"rules":true,"mcp_elicitations":true}}` - - `codex.thread_sandbox` defaults to `workspace-write` - - `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`. -- `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 - identifier, title, and body. -- Use `hooks.after_create` to bootstrap a fresh workspace. For a Git-backed repo, you can run - `git clone ... .` there, along with any other setup commands you need. -- If a hook needs `mise exec` inside a freshly cloned workspace, trust the repo config and fetch - the project dependencies in `hooks.after_create` before invoking `mise` later from other hooks. -- `tracker.api_key` reads from `LINEAR_API_KEY` when unset or when value is `$LINEAR_API_KEY`. -- For path values, `~` is expanded to the home directory. -- For env-backed path values, use `$VAR`. `workspace.root` resolves `$VAR` before path handling, - while `codex.command` stays a shell command string and any `$VAR` expansion there happens in the - launched shell. - -```yaml -tracker: - api_key: $LINEAR_API_KEY -workspace: - root: $SYMPHONY_WORKSPACE_ROOT -hooks: - after_create: | - git clone --depth 1 "$SOURCE_REPO_URL" . -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. -- `server.port` or CLI `--port` enables the optional Phoenix LiveView dashboard and JSON API at - `/`, `/api/v1/state`, `/api/v1/`, and `/api/v1/refresh`. - -## Web dashboard - -The observability UI now runs on a minimal Phoenix stack: - -- LiveView for the dashboard at `/` -- JSON API for operational debugging under `/api/v1/*` -- Bandit as the HTTP server -- Phoenix dependency static assets for the LiveView client bootstrap - -## Project Layout - -- `lib/`: application code and Mix tasks -- `test/`: ExUnit coverage for runtime behavior -- `WORKFLOW.md`: in-repo workflow contract used by local runs -- `../.codex/`: repository-local Codex skills and setup helpers - -## Testing - -```bash -make all -``` - -## FAQ - -### Why Elixir? - -Elixir is built on Erlang/BEAM/OTP, which is great for supervising long-running processes. It has an -active ecosystem of tools and libraries. It also supports hot code reloading without stopping -actively running subagents, which is very useful during development. - -### What's the easiest way to set this up for my own codebase? - -Launch `codex` in your repo, give it the URL to the Symphony repo, and ask it to set things up for -you. - -## License - -This project is licensed under the [Apache License 2.0](../LICENSE). diff --git a/elixir/WORKFLOW.md b/elixir/WORKFLOW.md deleted file mode 100644 index d102b62fea..0000000000 --- a/elixir/WORKFLOW.md +++ /dev/null @@ -1,326 +0,0 @@ ---- -tracker: - kind: linear - project_slug: "symphony-0c79b11b75ea" - active_states: - - Todo - - In Progress - - Merging - - Rework - terminal_states: - - Closed - - Cancelled - - Canceled - - Duplicate - - Done -polling: - interval_ms: 5000 -workspace: - root: ~/code/symphony-workspaces -hooks: - after_create: | - git clone --depth 1 https://github.com/openai/symphony . - if command -v mise >/dev/null 2>&1; then - cd elixir && mise trust && mise exec -- mix deps.get - fi - before_remove: | - cd elixir && mise exec -- mix workspace.before_remove -agent: - max_concurrent_agents: 10 - max_turns: 20 -codex: - command: codex --config shell_environment_policy.inherit=all --config model_reasoning_effort=xhigh --model gpt-5.3-codex app-server - approval_policy: never - thread_sandbox: workspace-write - turn_sandbox_policy: - type: workspaceWrite ---- - -You are working on a Linear ticket `{{ issue.identifier }}` - -{% if attempt %} -Continuation context: - -- This is retry attempt #{{ attempt }} because the ticket is still in an active state. -- Resume from the current workspace state instead of restarting from scratch. -- Do not repeat already-completed investigation or validation unless needed for new code changes. -- Do not end the turn while the issue remains in an active state unless you are blocked by missing required permissions/secrets. - {% endif %} - -Issue context: -Identifier: {{ issue.identifier }} -Title: {{ issue.title }} -Current status: {{ issue.state }} -Labels: {{ issue.labels }} -URL: {{ issue.url }} - -Description: -{% if issue.description %} -{{ issue.description }} -{% else %} -No description provided. -{% endif %} - -Instructions: - -1. This is an unattended orchestration session. Never ask a human to perform follow-up actions. -2. Only stop early for a true blocker (missing required auth/permissions/secrets). If blocked, record it in the workpad and move the issue according to workflow. -3. Final message must report completed actions and blockers only. Do not include "next steps for user". - -Work only in the provided repository copy. Do not touch any other path. - -## Prerequisite: Linear MCP or `linear_graphql` tool is available - -The agent should be able to talk to Linear, either via a configured Linear MCP server or injected `linear_graphql` tool. If none are present, stop and ask the user to configure Linear. - -## Default posture - -- Start by determining the ticket's current status, then follow the matching flow for that status. -- Start every task by opening the tracking workpad comment and bringing it up to date before doing new implementation work. -- Spend extra effort up front on planning and verification design before implementation. -- Reproduce first: always confirm the current behavior/issue signal before changing code so the fix target is explicit. -- Keep ticket metadata current (state, checklist, acceptance criteria, links). -- Treat a single persistent Linear comment as the source of truth for progress. -- Use that single workpad comment for all progress and handoff notes; do not post separate "done"/summary comments. -- Treat any ticket-authored `Validation`, `Test Plan`, or `Testing` section as non-negotiable acceptance input: mirror it in the workpad and execute it before considering the work complete. -- When meaningful out-of-scope improvements are discovered during execution, - file a separate Linear issue instead of expanding scope. The follow-up issue - must include a clear title, description, and acceptance criteria, be placed in - `Backlog`, be assigned to the same project as the current issue, link the - current issue as `related`, and use `blockedBy` when the follow-up depends on - the current issue. -- Move status only when the matching quality bar is met. -- Operate autonomously end-to-end unless blocked by missing requirements, secrets, or permissions. -- Use the blocked-access escape hatch only for true external blockers (missing required tools/auth) after exhausting documented fallbacks. - -## Related skills - -- `linear`: interact with Linear. -- `commit`: produce clean, logical commits during implementation. -- `push`: keep remote branch current and publish updates. -- `pull`: keep branch updated with latest `origin/main` before handoff. -- `land`: when ticket reaches `Merging`, explicitly open and follow `.codex/skills/land/SKILL.md`, which includes the `land` loop. - -## Status map - -- `Backlog` -> out of scope for this workflow; do not modify. -- `Todo` -> queued; immediately transition to `In Progress` before active work. - - Special case: if a PR is already attached, treat as feedback/rework loop (run full PR feedback sweep, address or explicitly push back, revalidate, return to `Human Review`). -- `In Progress` -> implementation actively underway. -- `Human Review` -> PR is attached and validated; waiting on human approval. -- `Merging` -> approved by human; execute the `land` skill flow (do not call `gh pr merge` directly). -- `Rework` -> reviewer requested changes; planning + implementation required. -- `Done` -> terminal state; no further action required. - -## Step 0: Determine current ticket state and route - -1. Fetch the issue by explicit ticket ID. -2. Read the current state. -3. Route to the matching flow: - - `Backlog` -> do not modify issue content/state; stop and wait for human to move it to `Todo`. - - `Todo` -> immediately move to `In Progress`, then ensure bootstrap workpad comment exists (create if missing), then start execution flow. - - If PR is already attached, start by reviewing all open PR comments and deciding required changes vs explicit pushback responses. - - `In Progress` -> continue execution flow from current scratchpad comment. - - `Human Review` -> wait and poll for decision/review updates. - - `Merging` -> on entry, open and follow `.codex/skills/land/SKILL.md`; do not call `gh pr merge` directly. - - `Rework` -> run rework flow. - - `Done` -> do nothing and shut down. -4. Check whether a PR already exists for the current branch and whether it is closed. - - If a branch PR exists and is `CLOSED` or `MERGED`, treat prior branch work as non-reusable for this run. - - Create a fresh branch from `origin/main` and restart execution flow as a new attempt. -5. For `Todo` tickets, do startup sequencing in this exact order: - - `update_issue(..., state: "In Progress")` - - find/create `## Codex Workpad` bootstrap comment - - only then begin analysis/planning/implementation work. -6. Add a short comment if state and issue content are inconsistent, then proceed with the safest flow. - -## Step 1: Start/continue execution (Todo or In Progress) - -1. Find or create a single persistent scratchpad comment for the issue: - - Search existing comments for a marker header: `## Codex Workpad`. - - Ignore resolved comments while searching; only active/unresolved comments are eligible to be reused as the live workpad. - - If found, reuse that comment; do not create a new workpad comment. - - If not found, create one workpad comment and use it for all updates. - - Persist the workpad comment ID and only write progress updates to that ID. -2. If arriving from `Todo`, do not delay on additional status transitions: the issue should already be `In Progress` before this step begins. -3. Immediately reconcile the workpad before new edits: - - Check off items that are already done. - - Expand/fix the plan so it is comprehensive for current scope. - - Ensure `Acceptance Criteria` and `Validation` are current and still make sense for the task. -4. Start work by writing/updating a hierarchical plan in the workpad comment. -5. Ensure the workpad includes a compact environment stamp at the top as a code fence line: - - Format: `:@` - - Example: `devbox-01:/home/dev-user/code/symphony-workspaces/MT-32@7bdde33bc` - - Do not include metadata already inferable from Linear issue fields (`issue ID`, `status`, `branch`, `PR link`). -6. Add explicit acceptance criteria and TODOs in checklist form in the same comment. - - If changes are user-facing, include a UI walkthrough acceptance criterion that describes the end-to-end user path to validate. - - If changes touch app files or app behavior, add explicit app-specific flow checks to `Acceptance Criteria` in the workpad (for example: launch path, changed interaction path, and expected result path). - - If the ticket description/comment context includes `Validation`, `Test Plan`, or `Testing` sections, copy those requirements into the workpad `Acceptance Criteria` and `Validation` sections as required checkboxes (no optional downgrade). -7. Run a principal-style self-review of the plan and refine it in the comment. -8. Before implementing, capture a concrete reproduction signal and record it in the workpad `Notes` section (command/output, screenshot, or deterministic UI behavior). -9. Run the `pull` skill to sync with latest `origin/main` before any code edits, then record the pull/sync result in the workpad `Notes`. - - Include a `pull skill evidence` note with: - - merge source(s), - - result (`clean` or `conflicts resolved`), - - resulting `HEAD` short SHA. -10. Compact context and proceed to execution. - -## PR feedback sweep protocol (required) - -When a ticket has an attached PR, run this protocol before moving to `Human Review`: - -1. Identify the PR number from issue links/attachments. -2. Gather feedback from all channels: - - Top-level PR comments (`gh pr view --comments`). - - Inline review comments (`gh api repos///pulls//comments`). - - Review summaries/states (`gh pr view --json reviews`). -3. Treat every actionable reviewer comment (human or bot), including inline review comments, as blocking until one of these is true: - - code/test/docs updated to address it, or - - explicit, justified pushback reply is posted on that thread. -4. Update the workpad plan/checklist to include each feedback item and its resolution status. -5. Re-run validation after feedback-driven changes and push updates. -6. Repeat this sweep until there are no outstanding actionable comments. - -## Blocked-access escape hatch (required behavior) - -Use this only when completion is blocked by missing required tools or missing auth/permissions that cannot be resolved in-session. - -- GitHub is **not** a valid blocker by default. Always try fallback strategies first (alternate remote/auth mode, then continue publish/review flow). -- Do not move to `Human Review` for GitHub access/auth until all fallback strategies have been attempted and documented in the workpad. -- If a non-GitHub required tool is missing, or required non-GitHub auth is unavailable, move the ticket to `Human Review` with a short blocker brief in the workpad that includes: - - what is missing, - - why it blocks required acceptance/validation, - - exact human action needed to unblock. -- Keep the brief concise and action-oriented; do not add extra top-level comments outside the workpad. - -## Step 2: Execution phase (Todo -> In Progress -> Human Review) - -1. Determine current repo state (`branch`, `git status`, `HEAD`) and verify the kickoff `pull` sync result is already recorded in the workpad before implementation continues. -2. If current issue state is `Todo`, move it to `In Progress`; otherwise leave the current state unchanged. -3. Load the existing workpad comment and treat it as the active execution checklist. - - Edit it liberally whenever reality changes (scope, risks, validation approach, discovered tasks). -4. Implement against the hierarchical TODOs and keep the comment current: - - Check off completed items. - - Add newly discovered items in the appropriate section. - - Keep parent/child structure intact as scope evolves. - - Update the workpad immediately after each meaningful milestone (for example: reproduction complete, code change landed, validation run, review feedback addressed). - - Never leave completed work unchecked in the plan. - - For tickets that started as `Todo` with an attached PR, run the full PR feedback sweep protocol immediately after kickoff and before new feature work. -5. Run validation/tests required for the scope. - - Mandatory gate: execute all ticket-provided `Validation`/`Test Plan`/ `Testing` requirements when present; treat unmet items as incomplete work. - - Prefer a targeted proof that directly demonstrates the behavior you changed. - - You may make temporary local proof edits to validate assumptions (for example: tweak a local build input for `make`, or hardcode a UI account / response path) when this increases confidence. - - Revert every temporary proof edit before commit/push. - - Document these temporary proof steps and outcomes in the workpad `Validation`/`Notes` sections so reviewers can follow the evidence. - - If app-touching, run `launch-app` validation and capture/upload media via `github-pr-media` before handoff. -6. Re-check all acceptance criteria and close any gaps. -7. Before every `git push` attempt, run the required validation for your scope and confirm it passes; if it fails, address issues and rerun until green, then commit and push changes. -8. Attach PR URL to the issue (prefer attachment; use the workpad comment only if attachment is unavailable). - - Ensure the GitHub PR has label `symphony` (add it if missing). -9. Merge latest `origin/main` into branch, resolve conflicts, and rerun checks. -10. Update the workpad comment with final checklist status and validation notes. - - Mark completed plan/acceptance/validation checklist items as checked. - - Add final handoff notes (commit + validation summary) in the same workpad comment. - - Do not include PR URL in the workpad comment; keep PR linkage on the issue via attachment/link fields. - - Add a short `### Confusions` section at the bottom when any part of task execution was unclear/confusing, with concise bullets. - - Do not post any additional completion summary comment. -11. Before moving to `Human Review`, poll PR feedback and checks: - - Read the PR `Manual QA Plan` comment (when present) and use it to sharpen UI/runtime test coverage for the current change. - - Run the full PR feedback sweep protocol. - - Confirm PR checks are passing (green) after the latest changes. - - Confirm every required ticket-provided validation/test-plan item is explicitly marked complete in the workpad. - - Repeat this check-address-verify loop until no outstanding comments remain and checks are fully passing. - - Re-open and refresh the workpad before state transition so `Plan`, `Acceptance Criteria`, and `Validation` exactly match completed work. -12. Only then move issue to `Human Review`. - - Exception: if blocked by missing required non-GitHub tools/auth per the blocked-access escape hatch, move to `Human Review` with the blocker brief and explicit unblock actions. -13. For `Todo` tickets that already had a PR attached at kickoff: - - Ensure all existing PR feedback was reviewed and resolved, including inline review comments (code changes or explicit, justified pushback response). - - Ensure branch was pushed with any required updates. - - Then move to `Human Review`. - -## Step 3: Human Review and merge handling - -1. When the issue is in `Human Review`, do not code or change ticket content. -2. Poll for updates as needed, including GitHub PR review comments from humans and bots. -3. If review feedback requires changes, move the issue to `Rework` and follow the rework flow. -4. If approved, human moves the issue to `Merging`. -5. When the issue is in `Merging`, open and follow `.codex/skills/land/SKILL.md`, then run the `land` skill in a loop until the PR is merged. Do not call `gh pr merge` directly. -6. After merge is complete, move the issue to `Done`. - -## Step 4: Rework handling - -1. Treat `Rework` as a full approach reset, not incremental patching. -2. Re-read the full issue body and all human comments; explicitly identify what will be done differently this attempt. -3. Close the existing PR tied to the issue. -4. Remove the existing `## Codex Workpad` comment from the issue. -5. Create a fresh branch from `origin/main`. -6. Start over from the normal kickoff flow: - - If current issue state is `Todo`, move it to `In Progress`; otherwise keep the current state. - - Create a new bootstrap `## Codex Workpad` comment. - - Build a fresh plan/checklist and execute end-to-end. - -## Completion bar before Human Review - -- Step 1/2 checklist is fully complete and accurately reflected in the single workpad comment. -- Acceptance criteria and required ticket-provided validation items are complete. -- Validation/tests are green for the latest commit. -- PR feedback sweep is complete and no actionable comments remain. -- PR checks are green, branch is pushed, and PR is linked on the issue. -- Required PR metadata is present (`symphony` label). -- If app-touching, runtime validation/media requirements from `App runtime validation (required)` are complete. - -## Guardrails - -- If the branch PR is already closed/merged, do not reuse that branch or prior implementation state for continuation. -- For closed/merged branch PRs, create a new branch from `origin/main` and restart from reproduction/planning as if starting fresh. -- If issue state is `Backlog`, do not modify it; wait for human to move to `Todo`. -- Do not edit the issue body/description for planning or progress tracking. -- Use exactly one persistent workpad comment (`## Codex Workpad`) per issue. -- If comment editing is unavailable in-session, use the update script. Only report blocked if both MCP editing and script-based editing are unavailable. -- Temporary proof edits are allowed only for local verification and must be reverted before commit. -- If out-of-scope improvements are found, create a separate Backlog issue rather - than expanding current scope, and include a clear - title/description/acceptance criteria, same-project assignment, a `related` - link to the current issue, and `blockedBy` when the follow-up depends on the - current issue. -- Do not move to `Human Review` unless the `Completion bar before Human Review` is satisfied. -- In `Human Review`, do not make changes; wait and poll. -- If state is terminal (`Done`), do nothing and shut down. -- Keep issue text concise, specific, and reviewer-oriented. -- If blocked and no workpad exists yet, add one blocker comment describing blocker, impact, and next unblock action. - -## Workpad template - -Use this exact structure for the persistent workpad comment and keep it updated in place throughout execution: - -````md -## Codex Workpad - -```text -:@ -``` - -### Plan - -- [ ] 1\. Parent task - - [ ] 1.1 Child task - - [ ] 1.2 Child task -- [ ] 2\. Parent task - -### Acceptance Criteria - -- [ ] Criterion 1 -- [ ] Criterion 2 - -### Validation - -- [ ] targeted tests: `` - -### Notes - -- - -### Confusions - -- -```` diff --git a/elixir/config/config.exs b/elixir/config/config.exs deleted file mode 100644 index 11744f6602..0000000000 --- a/elixir/config/config.exs +++ /dev/null @@ -1,16 +0,0 @@ -import Config - -config :phoenix, :json_library, Jason - -config :symphony_elixir, SymphonyElixirWeb.Endpoint, - adapter: Bandit.PhoenixAdapter, - url: [host: "localhost"], - render_errors: [ - formats: [html: SymphonyElixirWeb.ErrorHTML, json: SymphonyElixirWeb.ErrorJSON], - layout: false - ], - pubsub_server: SymphonyElixir.PubSub, - live_view: [signing_salt: "symphony-live-view"], - secret_key_base: String.duplicate("s", 64), - check_origin: false, - server: false diff --git a/elixir/docs/logging.md b/elixir/docs/logging.md deleted file mode 100644 index a88e6eae92..0000000000 --- a/elixir/docs/logging.md +++ /dev/null @@ -1,40 +0,0 @@ -# Logging Best Practices - -This guide defines logging conventions for Symphony so Codex can diagnose failures quickly. - -## Goals - -- Make logs searchable by issue and session. -- Capture enough execution context to identify root cause without reruns. -- Keep messages stable so dashboards/alerts are reliable. - -## Required Context Fields - -When logging issue-related work, include both identifiers: - -- `issue_id`: Linear internal UUID (stable foreign key). -- `issue_identifier`: human ticket key (for example `MT-620`). - -When logging Codex execution lifecycle events, include: - -- `session_id`: combined Codex thread/turn identifier. - -## Message Design - -- Use explicit `key=value` pairs in message text for high-signal fields. -- Prefer deterministic wording for recurring lifecycle events. -- Include the action outcome (`completed`, `failed`, `retrying`) and the reason/error when available. -- Avoid logging large payloads unless required for debugging. - -## Scope Guidance - -- `AgentRunner`: log start/completion/failure with issue context, plus `session_id` when known. -- `Orchestrator`: log dispatch, retry, terminal/non-active transitions, and worker exits with issue context. Include `session_id` whenever running-entry data has it. -- `Codex.AppServer`: log session start/completion/error with issue context and `session_id`. - -## Checklist For New Logs - -- Is this event tied to a Linear issue? Include `issue_id` and `issue_identifier`. -- Is this event tied to a Codex session? Include `session_id`. -- Is the failure reason present and concise? -- Is the message format consistent with existing lifecycle logs? diff --git a/elixir/docs/token_accounting.md b/elixir/docs/token_accounting.md deleted file mode 100644 index 2c6e107be2..0000000000 --- a/elixir/docs/token_accounting.md +++ /dev/null @@ -1,304 +0,0 @@ -# Codex Token Accounting - -This document explains how Codex reports token usage through the app-server protocol and how Symphony should account for it. - -It is based on the current Codex source in `codex-rs`, especially: - -- `app-server/README.md` -- `protocol/src/protocol.rs` -- `app-server/src/bespoke_event_handling.rs` -- `app-server-protocol/src/protocol/v2.rs` -- `exec/src/event_processor_with_jsonl_output.rs` -- `state/src/extract.rs` - -## Short Version - -- `last_token_usage` means "the latest increment". -- `total_token_usage` means "the cumulative total so far". -- `thread/tokenUsage/updated` is the live streaming notification for token usage. -- `turn/completed` carries final turn state, and turn-level usage is exposed separately from the live thread token stream. -- Generic `usage` fields are event-specific. Do not assume every `usage` payload is a cumulative thread total. - -## Primary Source Semantics - -Codex defines `TokenUsageInfo` like this: - -```rust -pub struct TokenUsageInfo { - pub total_token_usage: TokenUsage, - pub last_token_usage: TokenUsage, - pub model_context_window: Option, -} -``` - -The important behavior is in `append_last_usage`: - -```rust -pub fn append_last_usage(&mut self, last: &TokenUsage) { - self.total_token_usage.add_assign(last); - self.last_token_usage = last.clone(); -} -``` - -That gives the core semantics: - -- `last_token_usage`: the newest chunk of usage that was just added -- `total_token_usage`: the accumulated total after adding that chunk - -This is the most important accounting rule in the Codex source. - -## Event Types - -### `codex/event/token_count` - -Codex core emits token count events containing `TokenUsageInfo`. - -These events can carry: - -- `info.total_token_usage` -- `info.last_token_usage` -- `info.model_context_window` - -Symphony sees these events wrapped inside the app-server message stream. - -Meaning: - -- `total_token_usage` is an absolute cumulative snapshot -- `last_token_usage` is the delta that produced that snapshot - -### `thread/tokenUsage/updated` - -The app-server converts token count events into a dedicated thread-scoped notification: - -```rust -let notification = ThreadTokenUsageUpdatedNotification { - thread_id: conversation_id.to_string(), - turn_id, - token_usage, -}; -``` - -`ThreadTokenUsage` is defined as: - -```rust -pub struct ThreadTokenUsage { - pub total: TokenUsageBreakdown, - pub last: TokenUsageBreakdown, - pub model_context_window: Option, -} -``` - -And it is populated directly from `TokenUsageInfo`: - -```rust -impl From for ThreadTokenUsage { - fn from(value: CoreTokenUsageInfo) -> Self { - Self { - total: value.total_token_usage.into(), - last: value.last_token_usage.into(), - model_context_window: value.model_context_window, - } - } -} -``` - -Meaning: - -- `thread/tokenUsage/updated` is the canonical live notification for token usage -- `tokenUsage.total` is an absolute thread total -- `tokenUsage.last` is the latest increment that produced that total - -The app-server README is explicit: token usage streams separately via `thread/tokenUsage/updated`. - -### `turn/completed` - -The app-server README says `turn/completed` carries final turn state and token usage. - -There are two important details: - -1. The app-server protocol `turn/completed` notification contains a final `turn` object. -2. The `exec` event processor also emits a turn-completed event that includes a `usage` struct. - -In the `exec` event processor, the turn-completed usage is built from the most recent captured `total_token_usage`: - -```rust -if let Some(info) = &ev.info { - self.last_total_token_usage = Some(info.total_token_usage.clone()); -} -``` - -Then on turn completion: - -```rust -let usage = if let Some(u) = &self.last_total_token_usage { - Usage { - input_tokens: u.input_tokens, - cached_input_tokens: u.cached_input_tokens, - output_tokens: u.output_tokens, - } -} -``` - -Important consequence: - -- a turn-completed `usage` payload is not the same schema as `ThreadTokenUsage` -- it should be interpreted in the context of the specific event that emitted it -- it must not be blindly mixed with `thread/tokenUsage/updated` accounting - -### Generic `usage` - -Codex uses the word `usage` in multiple places. - -That does not mean all `usage` maps have the same semantics. - -Examples: - -- `thread/tokenUsage/updated.tokenUsage.total`: absolute cumulative thread total -- `thread/tokenUsage/updated.tokenUsage.last`: latest delta -- turn-completed `usage`: event-specific completion usage payload - -Rule: - -- never classify a `usage` map by name alone -- classify it by event type and payload path - -## What The Metrics Mean - -### Absolute totals - -These are safe high-water-mark style counters: - -- `info.total_token_usage` -- `tokenUsage.total` on `thread/tokenUsage/updated` - -Use these when you want: - -- live dashboard totals -- stable per-thread accumulation -- recovery after missed intermediate events - -### Deltas - -These are incremental additions: - -- `info.last_token_usage` -- `tokenUsage.last` on `thread/tokenUsage/updated` - -Use these only when: - -- no absolute total is available -- you are explicitly handling additive updates - -### Context window - -`model_context_window` is not spend. It is the model's context limit. - -Codex also has logic that can "fill to context window", which sets: - -- `total_token_usage.total_tokens = context_window` -- `last_token_usage.total_tokens = delta` - -So `total_tokens` can reflect context-window normalization behavior, not just a raw upstream token report. - -For Symphony, `model_context_window` should be displayed or logged separately from spend. - -## Recommended Accounting Strategy For Symphony - -Track usage per active Codex thread. - -For each thread, keep: - -- `absolute_total`: latest accepted absolute total snapshot -- `accumulated_total`: the total you expose in UI/API -- `last_seen_turn_id` - -### Preferred source order - -When a token-related event arrives, use this precedence: - -1. `thread/tokenUsage/updated.tokenUsage.total` -2. `TokenCountEvent.info.total_token_usage` - -Ignore these for accounting: - -- `thread/tokenUsage/updated.tokenUsage.last` -- `TokenCountEvent.info.last_token_usage` -- generic `usage` maps -- turn-completed `usage` - -Do not treat generic `params.usage` as equivalent to a cumulative thread total unless the event type makes that meaning explicit. - -### Algorithm - -#### If an absolute total is present - -- Treat it as a thread-level snapshot. -- If it is greater than or equal to the stored `absolute_total`, replace the stored absolute total. -- Set exposed totals from that absolute snapshot. -- Do not add the corresponding delta again. - -#### If no absolute total is present - -- Ignore the event for accounting. -- Keep the last accepted absolute high-water mark unchanged. - -### Why this matters - -If you misclassify a per-turn `usage` payload as an absolute thread total, later turns can appear to stall because a smaller per-turn number is compared against a larger cumulative baseline. - -## What Symphony Should And Should Not Do - -### Do - -- Prefer `thread/tokenUsage/updated` for live reporting. -- Treat `tokenUsage.total` as authoritative for thread totals. -- Key accounting by `thread_id`, not just issue id. -- Expect one thread to span multiple turns when Symphony reuses a live Codex thread. - -### Do not - -- Do not treat every `usage` map as absolute. -- Do not count `tokenUsage.last` or `last_token_usage` into dashboard totals. -- Do not add turn-completed `usage` on top of already-counted live thread totals unless you can prove it represents missing spend. -- Do not reset accounting just because a new turn starts on the same thread. - -## Practical Interpretation For Symphony Logs - -When reading raw app-server events: - -- `codex/event/token_count` - - useful if you are inspecting nested `info.total_token_usage` -- `thread/tokenUsage/updated` - - best source for live dashboard and API totals -- `turn/completed` - - best used as end-of-turn state, not as an unconditional additive token event - -## Why `total_token_usage` Is The Durable Choice - -Codex itself consistently prefers cumulative totals when it needs durable state: - -- the state extractor stores `info.total_token_usage.total_tokens` -- the exec event processor caches the last `total_token_usage` and uses that on turn completion - -That is a strong signal for Symphony: - -- use absolute totals as the main accounting surface -- ignore last/delta values for totals - -## Recommended Symphony Documentation Contract - -If Symphony documents token reporting externally, the contract should be: - -- Live token totals come from Codex thread-scoped cumulative usage. -- Incremental usage may also be emitted, but Symphony does not use it for totals. -- Turn-completed usage is event-specific and should not be assumed to be a fresh additive increment. -- Reporting is thread-based, and multiple turns can occur on one thread. - -## Implementation Checklist - -- Prefer `thread/tokenUsage/updated.tokenUsage.total` -- Fallback to `info.total_token_usage` -- Ignore `last` for totals -- Key totals by `thread_id` -- Do not classify generic `usage` by field name alone -- Do not double-count turn-completed usage after live updates diff --git a/elixir/lib/mix/tasks/pr_body.check.ex b/elixir/lib/mix/tasks/pr_body.check.ex deleted file mode 100644 index e2d6b68294..0000000000 --- a/elixir/lib/mix/tasks/pr_body.check.ex +++ /dev/null @@ -1,216 +0,0 @@ -defmodule Mix.Tasks.PrBody.Check do - use Mix.Task - - @shortdoc "Validate PR body format against the repository PR template" - - @moduledoc """ - Validates a PR description markdown file against the structure and expectations - implied by the repository pull request template. - - Usage: - - mix pr_body.check --file /path/to/pr_body.md - """ - - @template_paths [ - ".github/pull_request_template.md", - "../.github/pull_request_template.md" - ] - - @impl Mix.Task - def run(args) do - {opts, _argv, invalid} = OptionParser.parse(args, strict: [file: :string, help: :boolean], aliases: [h: :help]) - - cond do - opts[:help] -> - Mix.shell().info(@moduledoc) - - invalid != [] -> - Mix.raise("Invalid option(s): #{inspect(invalid)}") - - true -> - file_path = required_opt(opts, :file) - - with {:ok, template_path, template} <- read_template(), - {:ok, body} <- read_file(file_path), - {:ok, headings} <- extract_template_headings(template, template_path), - :ok <- lint_and_print(template_path, template, body, headings) do - Mix.shell().info("PR body format OK") - else - {:error, message} -> Mix.raise(message) - end - end - end - - defp read_template do - case Enum.find_value(@template_paths, &read_template_candidate/1) do - {:ok, _path, _template} = result -> - result - - nil -> - joined_paths = Enum.join(@template_paths, ", ") - {:error, "Unable to read PR template from any of: #{joined_paths}"} - end - end - - defp read_template_candidate(path) do - case File.read(path) do - {:ok, content} -> {:ok, path, content} - {:error, _reason} -> nil - end - end - - defp required_opt(opts, key) do - case opts[key] do - nil -> Mix.raise("Missing required option --#{key}") - value -> value - end - end - - defp read_file(path) do - case File.read(path) do - {:ok, content} -> {:ok, content} - {:error, reason} -> {:error, "Unable to read #{path}: #{inspect(reason)}"} - end - end - - defp extract_template_headings(template, template_path) do - headings = - Regex.scan(~r/^\#{4,6}\s+.+$/m, template) - |> Enum.map(&hd/1) - - if headings == [] do - {:error, "No markdown headings found in #{template_path}"} - else - {:ok, headings} - end - end - - defp lint_and_print(template_path, template, body, headings) do - errors = lint(template, body, headings) - - if errors == [] do - :ok - else - Enum.each(errors, fn err -> Mix.shell().error("ERROR: #{err}") end) - - {:error, "PR body format invalid. Read `#{template_path}` and follow it precisely."} - end - end - - defp lint(template, body, headings) do - [] - |> check_required_headings(body, headings) - |> check_order(body, headings) - |> check_no_placeholders(body) - |> check_sections_from_template(template, body, headings) - end - - defp check_required_headings(errors, body, headings) do - missing = Enum.filter(headings, fn heading -> heading_position(body, heading) == :nomatch end) - errors ++ Enum.map(missing, fn heading -> "Missing required heading: #{heading}" end) - end - - defp check_order(errors, body, headings) do - positions = - headings - |> Enum.map(&heading_position(body, &1)) - |> Enum.reject(&(&1 == :nomatch)) - - if positions == Enum.sort(positions), do: errors, else: errors ++ ["Required headings are out of order."] - end - - defp check_no_placeholders(errors, body) do - if String.contains?(body, ")."] - else - errors - end - end - - defp check_sections_from_template(errors, template, body, headings) do - Enum.reduce(headings, errors, fn heading, acc -> - template_section = capture_heading_section(template, heading, headings) - body_section = capture_heading_section(body, heading, headings) - - cond do - is_nil(body_section) -> - acc - - String.trim(body_section) == "" -> - acc ++ ["Section cannot be empty: #{heading}"] - - true -> - acc - |> maybe_require_bullets(heading, template_section, body_section) - |> maybe_require_checkboxes(heading, template_section, body_section) - end - end) - end - - defp maybe_require_bullets(errors, heading, template_section, body_section) do - requires_bullets = Regex.match?(~r/^- /m, template_section || "") - - if requires_bullets and not Regex.match?(~r/^- /m, body_section) do - errors ++ ["Section must include at least one bullet item: #{heading}"] - else - errors - end - end - - defp maybe_require_checkboxes(errors, heading, template_section, body_section) do - requires_checkboxes = Regex.match?(~r/^- \[ \] /m, template_section || "") - - if requires_checkboxes and not Regex.match?(~r/^- \[[ xX]\] /m, body_section) do - errors ++ ["Section must include at least one checkbox item: #{heading}"] - else - errors - end - end - - defp heading_position(body, heading) do - case :binary.match(body, heading) do - {idx, _len} -> idx - :nomatch -> :nomatch - end - end - - defp capture_heading_section(doc, heading, headings) do - with {heading_idx, _} <- :binary.match(doc, heading), - section_start <- heading_idx + byte_size(heading), - true <- section_start + 2 <= byte_size(doc), - "\n\n" <- binary_part(doc, section_start, 2) do - extract_section_content(doc, section_start + 2, heading, headings) - else - :nomatch -> nil - false -> "" - _ -> nil - end - end - - defp extract_section_content(doc, content_start, heading, headings) do - content = binary_part(doc, content_start, byte_size(doc) - content_start) - - case next_heading_offset(content, heading, headings) do - nil -> content - offset -> binary_part(content, 0, offset) - end - end - - defp next_heading_offset(content, heading, headings) do - headings_after(heading, headings) - |> Enum.map(fn marker -> :binary.match(content, marker) end) - |> Enum.filter(&(&1 != :nomatch)) - |> Enum.map(fn {idx, _} -> idx end) - |> case do - [] -> nil - indexes -> Enum.min(indexes) - end - end - - defp headings_after(current_heading, headings) do - headings - |> Enum.filter(&(&1 != current_heading)) - |> Enum.map(&("\n" <> &1)) - end -end diff --git a/elixir/lib/mix/tasks/specs.check.ex b/elixir/lib/mix/tasks/specs.check.ex deleted file mode 100644 index 8f7d9e2429..0000000000 --- a/elixir/lib/mix/tasks/specs.check.ex +++ /dev/null @@ -1,53 +0,0 @@ -defmodule Mix.Tasks.Specs.Check do - use Mix.Task - - alias SymphonyElixir.SpecsCheck - - @moduledoc """ - Enforces adjacent `@spec` declarations for public APIs in `lib/`. - """ - @shortdoc "Fails when public functions in lib/ are missing adjacent @specs" - - @switches [paths: :keep, exemptions_file: :string] - @default_paths ["lib"] - - @impl Mix.Task - def run(args) do - {opts, _argv, _invalid} = OptionParser.parse(args, strict: @switches) - - paths = Keyword.get_values(opts, :paths) - scanned_paths = if paths == [], do: @default_paths, else: paths - - exemptions = - case Keyword.get(opts, :exemptions_file) do - nil -> MapSet.new() - path -> load_exemptions(path) - end - - findings = SpecsCheck.missing_public_specs(scanned_paths, exemptions: exemptions) - - if findings == [] do - Mix.shell().info("specs.check: all public functions have @spec or exemption") - :ok - else - Enum.each(findings, fn finding -> - Mix.shell().error("#{finding.file}:#{finding.line} missing @spec for #{SpecsCheck.finding_identifier(finding)}") - end) - - Mix.raise("specs.check failed with #{length(findings)} missing @spec declaration(s)") - end - end - - defp load_exemptions(path) do - if File.exists?(path) do - path - |> File.read!() - |> String.split("\n") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "" or String.starts_with?(&1, "#"))) - |> MapSet.new() - else - MapSet.new() - end - end -end diff --git a/elixir/lib/mix/tasks/workspace.before_remove.ex b/elixir/lib/mix/tasks/workspace.before_remove.ex deleted file mode 100644 index d6cd27a885..0000000000 --- a/elixir/lib/mix/tasks/workspace.before_remove.ex +++ /dev/null @@ -1,140 +0,0 @@ -defmodule Mix.Tasks.Workspace.BeforeRemove do - use Mix.Task - - @shortdoc "Close open GitHub PRs for the current branch before workspace removal" - - @moduledoc """ - Closes open pull requests for the current Git branch. - - This task is intended for use from the `before_remove` workspace hook. - - Usage: - - mix workspace.before_remove - mix workspace.before_remove --branch feature/my-branch - mix workspace.before_remove --repo openai/symphony - """ - - @default_repo "openai/symphony" - - @impl Mix.Task - def run(args) do - {opts, _argv, invalid} = - OptionParser.parse(args, - strict: [branch: :string, help: :boolean, repo: :string], - aliases: [h: :help] - ) - - cond do - opts[:help] -> - Mix.shell().info(@moduledoc) - - invalid != [] -> - Mix.raise("Invalid option(s): #{inspect(invalid)}") - - true -> - repo = opts[:repo] || @default_repo - branch = opts[:branch] || current_branch() - - maybe_close_open_pull_requests(repo, branch) - end - end - - defp maybe_close_open_pull_requests(_repo, nil), do: :ok - - defp maybe_close_open_pull_requests(repo, branch) do - if gh_available?() and gh_authenticated?() do - repo - |> list_open_pull_request_numbers(branch) - |> Enum.each(&close_pull_request(repo, branch, &1)) - end - - :ok - end - - defp gh_available? do - not is_nil(System.find_executable("gh")) - end - - defp gh_authenticated? do - match?({:ok, _output}, run_command("gh", ["auth", "status"])) - end - - defp list_open_pull_request_numbers(repo, branch) do - case run_command("gh", [ - "pr", - "list", - "--repo", - repo, - "--head", - branch, - "--state", - "open", - "--json", - "number", - "--jq", - ".[].number" - ]) do - {:ok, output} -> - output - |> String.split("\n", trim: true) - |> Enum.reject(&(&1 == "")) - - {:error, _reason} -> - [] - end - end - - defp close_pull_request(repo, branch, pr_number) do - case run_command("gh", [ - "pr", - "close", - pr_number, - "--repo", - repo, - "--comment", - closing_comment(branch) - ]) do - {:ok, _output} -> - Mix.shell().info("Closed PR ##{pr_number} for branch #{branch}") - - {:error, {status, output}} -> - trimmed_output = String.trim(output) - - Mix.shell().error("Failed to close PR ##{pr_number} for branch #{branch}: exit #{status}#{format_output(trimmed_output)}") - end - end - - defp closing_comment(branch) do - "Closing because the Linear issue for branch #{branch} entered a terminal state without merge." - end - - defp format_output(""), do: "" - defp format_output(output), do: " output=#{inspect(output)}" - - defp current_branch do - case run_command("git", ["branch", "--show-current"]) do - {:ok, output} -> - case String.trim(output) do - "" -> nil - branch -> branch - end - - {:error, _reason} -> - nil - end - end - - defp run_command(command, args) do - case System.find_executable(command) do - nil -> - {:error, {:enoent, ""}} - - path -> - case System.cmd(path, args, stderr_to_stdout: true) do - {output, 0} -> {:ok, output} - {output, status} -> {:error, {status, output}} - end - end - end -end diff --git a/elixir/lib/symphony_elixir.ex b/elixir/lib/symphony_elixir.ex deleted file mode 100644 index 18561af83f..0000000000 --- a/elixir/lib/symphony_elixir.ex +++ /dev/null @@ -1,47 +0,0 @@ -defmodule SymphonyElixir do - @moduledoc """ - Entry point for the Symphony orchestrator. - """ - - @doc """ - Start the orchestrator in the current BEAM node. - """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - SymphonyElixir.Orchestrator.start_link(opts) - end -end - -defmodule SymphonyElixir.Application do - @moduledoc """ - OTP application entrypoint that starts core supervisors and workers. - """ - - use Application - - @impl true - def start(_type, _args) do - :ok = SymphonyElixir.LogFile.configure() - - children = [ - {Phoenix.PubSub, name: SymphonyElixir.PubSub}, - {Task.Supervisor, name: SymphonyElixir.TaskSupervisor}, - SymphonyElixir.WorkflowStore, - SymphonyElixir.Orchestrator, - SymphonyElixir.HttpServer, - SymphonyElixir.StatusDashboard - ] - - Supervisor.start_link( - children, - strategy: :one_for_one, - name: SymphonyElixir.Supervisor - ) - end - - @impl true - def stop(_state) do - SymphonyElixir.StatusDashboard.render_offline_status() - :ok - end -end diff --git a/elixir/lib/symphony_elixir/agent_runner.ex b/elixir/lib/symphony_elixir/agent_runner.ex deleted file mode 100644 index 7292a4bc27..0000000000 --- a/elixir/lib/symphony_elixir/agent_runner.ex +++ /dev/null @@ -1,154 +0,0 @@ -defmodule SymphonyElixir.AgentRunner do - @moduledoc """ - Executes a single Linear issue in an isolated workspace with Codex. - """ - - require Logger - alias SymphonyElixir.Codex.AppServer - alias SymphonyElixir.{Config, Linear.Issue, PromptBuilder, Tracker, Workspace} - - @spec run(map(), pid() | nil, keyword()) :: :ok | no_return() - def run(issue, codex_update_recipient \\ nil, opts \\ []) do - Logger.info("Starting agent run for #{issue_context(issue)}") - - case Workspace.create_for_issue(issue) do - {:ok, workspace} -> - try do - with :ok <- Workspace.run_before_run_hook(workspace, issue), - :ok <- run_codex_turns(workspace, issue, codex_update_recipient, opts) do - :ok - else - {:error, reason} -> - Logger.error("Agent run failed for #{issue_context(issue)}: #{inspect(reason)}") - raise RuntimeError, "Agent run failed for #{issue_context(issue)}: #{inspect(reason)}" - end - after - Workspace.run_after_run_hook(workspace, issue) - end - - {:error, reason} -> - Logger.error("Agent run failed for #{issue_context(issue)}: #{inspect(reason)}") - raise RuntimeError, "Agent run failed for #{issue_context(issue)}: #{inspect(reason)}" - end - end - - defp codex_message_handler(recipient, issue) do - fn message -> - send_codex_update(recipient, issue, message) - end - end - - defp send_codex_update(recipient, %Issue{id: issue_id}, message) - when is_binary(issue_id) and is_pid(recipient) do - send(recipient, {:codex_worker_update, issue_id, message}) - :ok - end - - defp send_codex_update(_recipient, _issue, _message), do: :ok - - defp run_codex_turns(workspace, issue, codex_update_recipient, opts) do - max_turns = Keyword.get(opts, :max_turns, Config.agent_max_turns()) - issue_state_fetcher = Keyword.get(opts, :issue_state_fetcher, &Tracker.fetch_issue_states_by_ids/1) - - with {:ok, session} <- AppServer.start_session(workspace) do - try do - do_run_codex_turns(session, workspace, issue, codex_update_recipient, opts, issue_state_fetcher, 1, max_turns) - after - AppServer.stop_session(session) - end - end - end - - defp do_run_codex_turns(app_session, workspace, issue, codex_update_recipient, opts, issue_state_fetcher, turn_number, max_turns) do - prompt = build_turn_prompt(issue, opts, turn_number, max_turns) - - with {:ok, turn_session} <- - AppServer.run_turn( - app_session, - prompt, - issue, - on_message: codex_message_handler(codex_update_recipient, issue) - ) do - Logger.info("Completed agent run for #{issue_context(issue)} session_id=#{turn_session[:session_id]} workspace=#{workspace} turn=#{turn_number}/#{max_turns}") - - case continue_with_issue?(issue, issue_state_fetcher) do - {:continue, refreshed_issue} when turn_number < max_turns -> - Logger.info("Continuing agent run for #{issue_context(refreshed_issue)} after normal turn completion turn=#{turn_number}/#{max_turns}") - - do_run_codex_turns( - app_session, - workspace, - refreshed_issue, - codex_update_recipient, - opts, - issue_state_fetcher, - turn_number + 1, - max_turns - ) - - {:continue, refreshed_issue} -> - Logger.info("Reached agent.max_turns for #{issue_context(refreshed_issue)} with issue still active; returning control to orchestrator") - - :ok - - {:done, _refreshed_issue} -> - :ok - - {:error, reason} -> - {:error, reason} - end - end - end - - defp build_turn_prompt(issue, opts, 1, _max_turns), do: PromptBuilder.build_prompt(issue, opts) - - defp build_turn_prompt(_issue, _opts, turn_number, max_turns) do - """ - Continuation guidance: - - - The previous Codex turn completed normally, but the Linear issue is still in an active state. - - This is continuation turn ##{turn_number} of #{max_turns} for the current agent run. - - Resume from the current workspace and workpad state instead of restarting from scratch. - - The original task instructions and prior turn context are already present in this thread, so do not restate them before acting. - - Focus on the remaining ticket work and do not end the turn while the issue stays active unless you are truly blocked. - """ - end - - defp continue_with_issue?(%Issue{id: issue_id} = issue, issue_state_fetcher) when is_binary(issue_id) do - case issue_state_fetcher.([issue_id]) do - {:ok, [%Issue{} = refreshed_issue | _]} -> - if active_issue_state?(refreshed_issue.state) do - {:continue, refreshed_issue} - else - {:done, refreshed_issue} - end - - {:ok, []} -> - {:done, issue} - - {:error, reason} -> - {:error, {:issue_state_refresh_failed, reason}} - end - end - - defp continue_with_issue?(issue, _issue_state_fetcher), do: {:done, issue} - - defp active_issue_state?(state_name) when is_binary(state_name) do - normalized_state = normalize_issue_state(state_name) - - Config.linear_active_states() - |> Enum.any?(fn active_state -> normalize_issue_state(active_state) == normalized_state end) - end - - defp active_issue_state?(_state_name), do: false - - defp normalize_issue_state(state_name) when is_binary(state_name) do - state_name - |> String.trim() - |> String.downcase() - end - - defp issue_context(%Issue{id: issue_id, identifier: identifier}) do - "issue_id=#{issue_id} issue_identifier=#{identifier}" - end -end diff --git a/elixir/lib/symphony_elixir/cli.ex b/elixir/lib/symphony_elixir/cli.ex deleted file mode 100644 index d5c7eb1833..0000000000 --- a/elixir/lib/symphony_elixir/cli.ex +++ /dev/null @@ -1,191 +0,0 @@ -defmodule SymphonyElixir.CLI do - @moduledoc """ - Escript entrypoint for running Symphony with an explicit WORKFLOW.md path. - """ - - alias SymphonyElixir.LogFile - - @acknowledgement_switch :i_understand_that_this_will_be_running_without_the_usual_guardrails - @switches [{@acknowledgement_switch, :boolean}, logs_root: :string, port: :integer] - - @type ensure_started_result :: {:ok, [atom()]} | {:error, term()} - @type deps :: %{ - file_regular?: (String.t() -> boolean()), - set_workflow_file_path: (String.t() -> :ok | {:error, term()}), - set_logs_root: (String.t() -> :ok | {:error, term()}), - set_server_port_override: (non_neg_integer() | nil -> :ok | {:error, term()}), - ensure_all_started: (-> ensure_started_result()) - } - - @spec main([String.t()]) :: no_return() - def main(args) do - case evaluate(args) do - :ok -> - wait_for_shutdown() - - {:error, message} -> - IO.puts(:stderr, message) - System.halt(1) - end - end - - @spec evaluate([String.t()], deps()) :: :ok | {:error, String.t()} - def evaluate(args, deps \\ runtime_deps()) do - case OptionParser.parse(args, strict: @switches) do - {opts, [], []} -> - with :ok <- require_guardrails_acknowledgement(opts), - :ok <- maybe_set_logs_root(opts, deps), - :ok <- maybe_set_server_port(opts, deps) do - run(Path.expand("WORKFLOW.md"), deps) - end - - {opts, [workflow_path], []} -> - with :ok <- require_guardrails_acknowledgement(opts), - :ok <- maybe_set_logs_root(opts, deps), - :ok <- maybe_set_server_port(opts, deps) do - run(workflow_path, deps) - end - - _ -> - {:error, usage_message()} - end - end - - @spec run(String.t(), deps()) :: :ok | {:error, String.t()} - def run(workflow_path, deps) do - expanded_path = Path.expand(workflow_path) - - if deps.file_regular?.(expanded_path) do - :ok = deps.set_workflow_file_path.(expanded_path) - - case deps.ensure_all_started.() do - {:ok, _started_apps} -> - :ok - - {:error, reason} -> - {:error, "Failed to start Symphony with workflow #{expanded_path}: #{inspect(reason)}"} - end - else - {:error, "Workflow file not found: #{expanded_path}"} - end - end - - @spec usage_message() :: String.t() - defp usage_message do - "Usage: symphony [--logs-root ] [--port ] [path-to-WORKFLOW.md]" - end - - @spec runtime_deps() :: deps() - defp runtime_deps do - %{ - file_regular?: &File.regular?/1, - set_workflow_file_path: &SymphonyElixir.Workflow.set_workflow_file_path/1, - set_logs_root: &set_logs_root/1, - set_server_port_override: &set_server_port_override/1, - ensure_all_started: fn -> Application.ensure_all_started(:symphony_elixir) end - } - end - - defp maybe_set_logs_root(opts, deps) do - case Keyword.get_values(opts, :logs_root) do - [] -> - :ok - - values -> - logs_root = values |> List.last() |> String.trim() - - if logs_root == "" do - {:error, usage_message()} - else - :ok = deps.set_logs_root.(Path.expand(logs_root)) - end - end - end - - defp require_guardrails_acknowledgement(opts) do - if Keyword.get(opts, @acknowledgement_switch, false) do - :ok - else - {:error, acknowledgement_banner()} - end - end - - @spec acknowledgement_banner() :: String.t() - defp acknowledgement_banner do - lines = [ - "This Symphony implementation is a low key engineering preview.", - "Codex will run without any guardrails.", - "SymphonyElixir is not a supported product and is presented as-is.", - "To proceed, start with `--i-understand-that-this-will-be-running-without-the-usual-guardrails` CLI argument" - ] - - width = Enum.max(Enum.map(lines, &String.length/1)) - border = String.duplicate("─", width + 2) - top = "╭" <> border <> "╮" - bottom = "╰" <> border <> "╯" - spacer = "│ " <> String.duplicate(" ", width) <> " │" - - content = - [ - top, - spacer - | Enum.map(lines, fn line -> - "│ " <> String.pad_trailing(line, width) <> " │" - end) - ] ++ [spacer, bottom] - - [ - IO.ANSI.red(), - IO.ANSI.bright(), - Enum.join(content, "\n"), - IO.ANSI.reset() - ] - |> IO.iodata_to_binary() - end - - defp set_logs_root(logs_root) do - Application.put_env(:symphony_elixir, :log_file, LogFile.default_log_file(logs_root)) - :ok - end - - defp maybe_set_server_port(opts, deps) do - case Keyword.get_values(opts, :port) do - [] -> - :ok - - values -> - port = List.last(values) - - if is_integer(port) and port >= 0 do - :ok = deps.set_server_port_override.(port) - else - {:error, usage_message()} - end - end - end - - defp set_server_port_override(port) when is_integer(port) and port >= 0 do - Application.put_env(:symphony_elixir, :server_port_override, port) - :ok - end - - @spec wait_for_shutdown() :: no_return() - defp wait_for_shutdown do - case Process.whereis(SymphonyElixir.Supervisor) do - nil -> - IO.puts(:stderr, "Symphony supervisor is not running") - System.halt(1) - - pid -> - ref = Process.monitor(pid) - - receive do - {:DOWN, ^ref, :process, ^pid, reason} -> - case reason do - :normal -> System.halt(0) - _ -> System.halt(1) - end - end - end - end -end diff --git a/elixir/lib/symphony_elixir/codex/app_server.ex b/elixir/lib/symphony_elixir/codex/app_server.ex deleted file mode 100644 index e824c63b8d..0000000000 --- a/elixir/lib/symphony_elixir/codex/app_server.ex +++ /dev/null @@ -1,985 +0,0 @@ -defmodule SymphonyElixir.Codex.AppServer do - @moduledoc """ - Minimal client for the Codex app-server JSON-RPC 2.0 stream over stdio. - """ - - require Logger - alias SymphonyElixir.{Codex.DynamicTool, Config} - - @initialize_id 1 - @thread_start_id 2 - @turn_start_id 3 - @port_line_bytes 1_048_576 - @max_stream_log_bytes 1_000 - @non_interactive_tool_input_answer "This is a non-interactive session. Operator input is unavailable." - - @type session :: %{ - port: port(), - metadata: map(), - approval_policy: String.t() | map(), - auto_approve_requests: boolean(), - thread_sandbox: String.t(), - turn_sandbox_policy: map(), - thread_id: String.t(), - workspace: Path.t() - } - - @spec run(Path.t(), String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()} - def run(workspace, prompt, issue, opts \\ []) do - with {:ok, session} <- start_session(workspace) do - try do - run_turn(session, prompt, issue, opts) - after - stop_session(session) - end - end - end - - @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 - 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 - {:ok, - %{ - port: port, - metadata: metadata, - approval_policy: session_policies.approval_policy, - auto_approve_requests: session_policies.approval_policy == "never", - thread_sandbox: session_policies.thread_sandbox, - turn_sandbox_policy: session_policies.turn_sandbox_policy, - thread_id: thread_id, - workspace: expanded_workspace - }} - else - {:error, reason} -> - stop_port(port) - {:error, reason} - end - end - end - - @spec run_turn(session(), String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()} - def run_turn( - %{ - port: port, - metadata: metadata, - approval_policy: approval_policy, - auto_approve_requests: auto_approve_requests, - turn_sandbox_policy: turn_sandbox_policy, - thread_id: thread_id, - workspace: workspace - }, - prompt, - issue, - opts \\ [] - ) do - on_message = Keyword.get(opts, :on_message, &default_on_message/1) - - tool_executor = - Keyword.get(opts, :tool_executor, fn tool, arguments -> - DynamicTool.execute(tool, arguments) - end) - - case start_turn(port, thread_id, prompt, issue, workspace, approval_policy, turn_sandbox_policy) do - {:ok, turn_id} -> - session_id = "#{thread_id}-#{turn_id}" - Logger.info("Codex session started for #{issue_context(issue)} session_id=#{session_id}") - - emit_message( - on_message, - :session_started, - %{ - session_id: session_id, - thread_id: thread_id, - turn_id: turn_id - }, - metadata - ) - - case await_turn_completion(port, on_message, tool_executor, auto_approve_requests) do - {:ok, result} -> - Logger.info("Codex session completed for #{issue_context(issue)} session_id=#{session_id}") - - {:ok, - %{ - result: result, - session_id: session_id, - thread_id: thread_id, - turn_id: turn_id - }} - - {:error, reason} -> - Logger.warning("Codex session ended with error for #{issue_context(issue)} session_id=#{session_id}: #{inspect(reason)}") - - emit_message( - on_message, - :turn_ended_with_error, - %{ - session_id: session_id, - reason: reason - }, - metadata - ) - - {:error, reason} - end - - {:error, reason} -> - Logger.error("Codex session failed for #{issue_context(issue)}: #{inspect(reason)}") - emit_message(on_message, :startup_failed, %{reason: reason}, metadata) - {:error, reason} - end - end - - @spec stop_session(session()) :: :ok - def stop_session(%{port: port}) when is_port(port) do - stop_port(port) - end - - defp validate_workspace_cwd(workspace) when is_binary(workspace) do - workspace_path = Path.expand(workspace) - workspace_root = Path.expand(Config.workspace_root()) - - root_prefix = workspace_root <> "/" - - cond do - workspace_path == workspace_root -> - {:error, {:invalid_workspace_cwd, :workspace_root, workspace_path}} - - not String.starts_with?(workspace_path <> "/", root_prefix) -> - {:error, {:invalid_workspace_cwd, :outside_workspace_root, workspace_path, workspace_root}} - - true -> - :ok - end - end - - defp start_port(workspace) do - executable = System.find_executable("bash") - - if is_nil(executable) do - {:error, :bash_not_found} - else - port = - Port.open( - {:spawn_executable, String.to_charlist(executable)}, - [ - :binary, - :exit_status, - :stderr_to_stdout, - args: [~c"-lc", String.to_charlist(Config.codex_command())], - cd: String.to_charlist(workspace), - line: @port_line_bytes - ] - ) - - {:ok, port} - end - end - - defp port_metadata(port) when is_port(port) do - case :erlang.port_info(port, :os_pid) do - {:os_pid, os_pid} -> - %{codex_app_server_pid: to_string(os_pid)} - - _ -> - %{} - end - end - - defp send_initialize(port) do - payload = %{ - "method" => "initialize", - "id" => @initialize_id, - "params" => %{ - "capabilities" => %{ - "experimentalApi" => true - }, - "clientInfo" => %{ - "name" => "symphony-orchestrator", - "title" => "Symphony Orchestrator", - "version" => "0.1.0" - } - } - } - - send_message(port, payload) - - with {:ok, _} <- await_response(port, @initialize_id) do - send_message(port, %{"method" => "initialized", "params" => %{}}) - :ok - end - end - - defp session_policies(workspace) do - Config.codex_runtime_settings(workspace) - end - - defp do_start_session(port, workspace, session_policies) do - case send_initialize(port) do - :ok -> start_thread(port, workspace, session_policies) - {:error, reason} -> {:error, reason} - end - end - - defp start_thread(port, workspace, %{approval_policy: approval_policy, thread_sandbox: thread_sandbox}) do - send_message(port, %{ - "method" => "thread/start", - "id" => @thread_start_id, - "params" => %{ - "approvalPolicy" => approval_policy, - "sandbox" => thread_sandbox, - "cwd" => Path.expand(workspace), - "dynamicTools" => DynamicTool.tool_specs() - } - }) - - case await_response(port, @thread_start_id) do - {:ok, %{"thread" => thread_payload}} -> - case thread_payload do - %{"id" => thread_id} -> {:ok, thread_id} - _ -> {:error, {:invalid_thread_payload, thread_payload}} - end - - other -> - other - end - end - - defp start_turn(port, thread_id, prompt, issue, workspace, approval_policy, turn_sandbox_policy) do - send_message(port, %{ - "method" => "turn/start", - "id" => @turn_start_id, - "params" => %{ - "threadId" => thread_id, - "input" => [ - %{ - "type" => "text", - "text" => prompt - } - ], - "cwd" => Path.expand(workspace), - "title" => "#{issue.identifier}: #{issue.title}", - "approvalPolicy" => approval_policy, - "sandboxPolicy" => turn_sandbox_policy - } - }) - - case await_response(port, @turn_start_id) do - {:ok, %{"turn" => %{"id" => turn_id}}} -> {:ok, turn_id} - other -> other - end - end - - defp await_turn_completion(port, on_message, tool_executor, auto_approve_requests) do - receive_loop(port, on_message, Config.codex_turn_timeout_ms(), "", tool_executor, auto_approve_requests) - end - - defp receive_loop(port, on_message, timeout_ms, pending_line, tool_executor, auto_approve_requests) do - receive do - {^port, {:data, {:eol, chunk}}} -> - complete_line = pending_line <> to_string(chunk) - handle_incoming(port, on_message, complete_line, timeout_ms, tool_executor, auto_approve_requests) - - {^port, {:data, {:noeol, chunk}}} -> - receive_loop( - port, - on_message, - timeout_ms, - pending_line <> to_string(chunk), - tool_executor, - auto_approve_requests - ) - - {^port, {:exit_status, status}} -> - {:error, {:port_exit, status}} - after - timeout_ms -> - {:error, :turn_timeout} - end - end - - defp handle_incoming(port, on_message, data, timeout_ms, tool_executor, auto_approve_requests) do - payload_string = to_string(data) - - case Jason.decode(payload_string) do - {:ok, %{"method" => "turn/completed"} = payload} -> - emit_turn_event(on_message, :turn_completed, payload, payload_string, port, payload) - {:ok, :turn_completed} - - {:ok, %{"method" => "turn/failed", "params" => _} = payload} -> - emit_turn_event( - on_message, - :turn_failed, - payload, - payload_string, - port, - Map.get(payload, "params") - ) - - {:error, {:turn_failed, Map.get(payload, "params")}} - - {:ok, %{"method" => "turn/cancelled", "params" => _} = payload} -> - emit_turn_event( - on_message, - :turn_cancelled, - payload, - payload_string, - port, - Map.get(payload, "params") - ) - - {:error, {:turn_cancelled, Map.get(payload, "params")}} - - {:ok, %{"method" => method} = payload} - when is_binary(method) -> - handle_turn_method( - port, - on_message, - payload, - payload_string, - method, - timeout_ms, - tool_executor, - auto_approve_requests - ) - - {:ok, payload} -> - emit_message( - on_message, - :other_message, - %{ - payload: payload, - raw: payload_string - }, - metadata_from_message(port, payload) - ) - - receive_loop(port, on_message, timeout_ms, "", tool_executor, auto_approve_requests) - - {:error, _reason} -> - log_non_json_stream_line(payload_string, "turn stream") - - emit_message( - on_message, - :malformed, - %{ - payload: payload_string, - raw: payload_string - }, - metadata_from_message(port, %{raw: payload_string}) - ) - - receive_loop(port, on_message, timeout_ms, "", tool_executor, auto_approve_requests) - end - end - - defp emit_turn_event(on_message, event, payload, payload_string, port, payload_details) do - emit_message( - on_message, - event, - %{ - payload: payload, - raw: payload_string, - details: payload_details - }, - metadata_from_message(port, payload) - ) - end - - defp handle_turn_method( - port, - on_message, - payload, - payload_string, - method, - timeout_ms, - tool_executor, - auto_approve_requests - ) do - metadata = metadata_from_message(port, payload) - - case maybe_handle_approval_request( - port, - method, - payload, - payload_string, - on_message, - metadata, - tool_executor, - auto_approve_requests - ) do - :input_required -> - emit_message( - on_message, - :turn_input_required, - %{payload: payload, raw: payload_string}, - metadata - ) - - {:error, {:turn_input_required, payload}} - - :approved -> - receive_loop(port, on_message, timeout_ms, "", tool_executor, auto_approve_requests) - - :approval_required -> - emit_message( - on_message, - :approval_required, - %{payload: payload, raw: payload_string}, - metadata - ) - - {:error, {:approval_required, payload}} - - :unhandled -> - if needs_input?(method, payload) do - emit_message( - on_message, - :turn_input_required, - %{payload: payload, raw: payload_string}, - metadata - ) - - {:error, {:turn_input_required, payload}} - else - emit_message( - on_message, - :notification, - %{ - payload: payload, - raw: payload_string - }, - metadata - ) - - Logger.debug("Codex notification: #{inspect(method)}") - receive_loop(port, on_message, timeout_ms, "", tool_executor, auto_approve_requests) - end - end - end - - defp maybe_handle_approval_request( - port, - "item/commandExecution/requestApproval", - %{"id" => id} = payload, - payload_string, - on_message, - metadata, - _tool_executor, - auto_approve_requests - ) do - approve_or_require( - port, - id, - "acceptForSession", - payload, - payload_string, - on_message, - metadata, - auto_approve_requests - ) - end - - defp maybe_handle_approval_request( - port, - "item/tool/call", - %{"id" => id, "params" => params} = payload, - payload_string, - on_message, - metadata, - tool_executor, - _auto_approve_requests - ) do - tool_name = tool_call_name(params) - arguments = tool_call_arguments(params) - - result = tool_executor.(tool_name, arguments) - - send_message(port, %{ - "id" => id, - "result" => result - }) - - event = - case result do - %{"success" => true} -> :tool_call_completed - _ when is_nil(tool_name) -> :unsupported_tool_call - _ -> :tool_call_failed - end - - emit_message(on_message, event, %{payload: payload, raw: payload_string}, metadata) - - :approved - end - - defp maybe_handle_approval_request( - port, - "execCommandApproval", - %{"id" => id} = payload, - payload_string, - on_message, - metadata, - _tool_executor, - auto_approve_requests - ) do - approve_or_require( - port, - id, - "approved_for_session", - payload, - payload_string, - on_message, - metadata, - auto_approve_requests - ) - end - - defp maybe_handle_approval_request( - port, - "applyPatchApproval", - %{"id" => id} = payload, - payload_string, - on_message, - metadata, - _tool_executor, - auto_approve_requests - ) do - approve_or_require( - port, - id, - "approved_for_session", - payload, - payload_string, - on_message, - metadata, - auto_approve_requests - ) - end - - defp maybe_handle_approval_request( - port, - "item/fileChange/requestApproval", - %{"id" => id} = payload, - payload_string, - on_message, - metadata, - _tool_executor, - auto_approve_requests - ) do - approve_or_require( - port, - id, - "acceptForSession", - payload, - payload_string, - on_message, - metadata, - auto_approve_requests - ) - end - - defp maybe_handle_approval_request( - port, - "item/tool/requestUserInput", - %{"id" => id, "params" => params} = payload, - payload_string, - on_message, - metadata, - _tool_executor, - auto_approve_requests - ) do - maybe_auto_answer_tool_request_user_input( - port, - id, - params, - payload, - payload_string, - on_message, - metadata, - auto_approve_requests - ) - end - - defp maybe_handle_approval_request( - _port, - _method, - _payload, - _payload_string, - _on_message, - _metadata, - _tool_executor, - _auto_approve_requests - ) do - :unhandled - end - - defp approve_or_require( - port, - id, - decision, - payload, - payload_string, - on_message, - metadata, - true - ) do - send_message(port, %{"id" => id, "result" => %{"decision" => decision}}) - - emit_message( - on_message, - :approval_auto_approved, - %{payload: payload, raw: payload_string, decision: decision}, - metadata - ) - - :approved - end - - defp approve_or_require( - _port, - _id, - _decision, - _payload, - _payload_string, - _on_message, - _metadata, - false - ) do - :approval_required - end - - defp maybe_auto_answer_tool_request_user_input( - port, - id, - params, - payload, - payload_string, - on_message, - metadata, - true - ) do - case tool_request_user_input_approval_answers(params) do - {:ok, answers, decision} -> - send_message(port, %{"id" => id, "result" => %{"answers" => answers}}) - - emit_message( - on_message, - :approval_auto_approved, - %{payload: payload, raw: payload_string, decision: decision}, - metadata - ) - - :approved - - :error -> - reply_with_non_interactive_tool_input_answer( - port, - id, - params, - payload, - payload_string, - on_message, - metadata - ) - end - end - - defp maybe_auto_answer_tool_request_user_input( - port, - id, - params, - payload, - payload_string, - on_message, - metadata, - false - ) do - reply_with_non_interactive_tool_input_answer( - port, - id, - params, - payload, - payload_string, - on_message, - metadata - ) - end - - defp tool_request_user_input_approval_answers(%{"questions" => questions}) when is_list(questions) do - answers = - Enum.reduce_while(questions, %{}, fn question, acc -> - case tool_request_user_input_approval_answer(question) do - {:ok, question_id, answer_label} -> - {:cont, Map.put(acc, question_id, %{"answers" => [answer_label]})} - - :error -> - {:halt, :error} - end - end) - - case answers do - :error -> :error - answer_map when map_size(answer_map) > 0 -> {:ok, answer_map, "Approve this Session"} - _ -> :error - end - end - - defp tool_request_user_input_approval_answers(_params), do: :error - - defp reply_with_non_interactive_tool_input_answer( - port, - id, - params, - payload, - payload_string, - on_message, - metadata - ) do - case tool_request_user_input_unavailable_answers(params) do - {:ok, answers} -> - send_message(port, %{"id" => id, "result" => %{"answers" => answers}}) - - emit_message( - on_message, - :tool_input_auto_answered, - %{payload: payload, raw: payload_string, answer: @non_interactive_tool_input_answer}, - metadata - ) - - :approved - - :error -> - :input_required - end - end - - defp tool_request_user_input_unavailable_answers(%{"questions" => questions}) when is_list(questions) do - answers = - Enum.reduce_while(questions, %{}, fn question, acc -> - case tool_request_user_input_question_id(question) do - {:ok, question_id} -> - {:cont, Map.put(acc, question_id, %{"answers" => [@non_interactive_tool_input_answer]})} - - :error -> - {:halt, :error} - end - end) - - case answers do - :error -> :error - answer_map when map_size(answer_map) > 0 -> {:ok, answer_map} - _ -> :error - end - end - - defp tool_request_user_input_unavailable_answers(_params), do: :error - - defp tool_request_user_input_question_id(%{"id" => question_id}) when is_binary(question_id), - do: {:ok, question_id} - - defp tool_request_user_input_question_id(_question), do: :error - - defp tool_request_user_input_approval_answer(%{"id" => question_id, "options" => options}) - when is_binary(question_id) and is_list(options) do - case tool_request_user_input_approval_option_label(options) do - nil -> :error - answer_label -> {:ok, question_id, answer_label} - end - end - - defp tool_request_user_input_approval_answer(_question), do: :error - - defp tool_request_user_input_approval_option_label(options) do - options - |> Enum.map(&tool_request_user_input_option_label/1) - |> Enum.reject(&is_nil/1) - |> case do - labels -> - Enum.find(labels, &(&1 == "Approve this Session")) || - Enum.find(labels, &(&1 == "Approve Once")) || - Enum.find(labels, &approval_option_label?/1) - end - end - - defp tool_request_user_input_option_label(%{"label" => label}) when is_binary(label), do: label - defp tool_request_user_input_option_label(_option), do: nil - - defp approval_option_label?(label) when is_binary(label) do - normalized_label = - label - |> String.trim() - |> String.downcase() - - String.starts_with?(normalized_label, "approve") or String.starts_with?(normalized_label, "allow") - end - - defp await_response(port, request_id) do - with_timeout_response(port, request_id, Config.codex_read_timeout_ms(), "") - end - - defp with_timeout_response(port, request_id, timeout_ms, pending_line) do - receive do - {^port, {:data, {:eol, chunk}}} -> - complete_line = pending_line <> to_string(chunk) - handle_response(port, request_id, complete_line, timeout_ms) - - {^port, {:data, {:noeol, chunk}}} -> - with_timeout_response(port, request_id, timeout_ms, pending_line <> to_string(chunk)) - - {^port, {:exit_status, status}} -> - {:error, {:port_exit, status}} - after - timeout_ms -> - {:error, :response_timeout} - end - end - - defp handle_response(port, request_id, data, timeout_ms) do - payload = to_string(data) - - case Jason.decode(payload) do - {:ok, %{"id" => ^request_id, "error" => error}} -> - {:error, {:response_error, error}} - - {:ok, %{"id" => ^request_id, "result" => result}} -> - {:ok, result} - - {:ok, %{"id" => ^request_id} = response_payload} -> - {:error, {:response_error, response_payload}} - - {:ok, %{} = other} -> - Logger.debug("Ignoring message while waiting for response: #{inspect(other)}") - with_timeout_response(port, request_id, timeout_ms, "") - - {:error, _} -> - log_non_json_stream_line(payload, "response stream") - with_timeout_response(port, request_id, timeout_ms, "") - end - end - - defp log_non_json_stream_line(data, stream_label) do - text = - data - |> to_string() - |> String.trim() - |> String.slice(0, @max_stream_log_bytes) - - if text != "" do - if String.match?(text, ~r/\b(error|warn|warning|failed|fatal|panic|exception)\b/i) do - Logger.warning("Codex #{stream_label} output: #{text}") - else - Logger.debug("Codex #{stream_label} output: #{text}") - end - end - end - - defp issue_context(%{id: issue_id, identifier: identifier}) do - "issue_id=#{issue_id} issue_identifier=#{identifier}" - end - - defp stop_port(port) when is_port(port) do - case :erlang.port_info(port) do - :undefined -> - :ok - - _ -> - try do - Port.close(port) - :ok - rescue - ArgumentError -> - :ok - end - end - end - - defp emit_message(on_message, event, details, metadata) when is_function(on_message, 1) do - message = metadata |> Map.merge(details) |> Map.put(:event, event) |> Map.put(:timestamp, DateTime.utc_now()) - on_message.(message) - end - - defp metadata_from_message(port, payload) do - port |> port_metadata() |> maybe_set_usage(payload) - end - - defp maybe_set_usage(metadata, payload) when is_map(payload) do - usage = Map.get(payload, "usage") || Map.get(payload, :usage) - - if is_map(usage) do - Map.put(metadata, :usage, usage) - else - metadata - end - end - - defp maybe_set_usage(metadata, _payload), do: metadata - - defp default_on_message(_message), do: :ok - - defp tool_call_name(params) when is_map(params) do - case Map.get(params, "tool") || Map.get(params, :tool) || Map.get(params, "name") || Map.get(params, :name) do - name when is_binary(name) -> - case String.trim(name) do - "" -> nil - trimmed -> trimmed - end - - _ -> - nil - end - end - - defp tool_call_name(_params), do: nil - - defp tool_call_arguments(params) when is_map(params) do - Map.get(params, "arguments") || Map.get(params, :arguments) || %{} - end - - defp tool_call_arguments(_params), do: %{} - - defp send_message(port, message) do - line = Jason.encode!(message) <> "\n" - Port.command(port, line) - end - - defp needs_input?(method, payload) - when is_binary(method) and is_map(payload) do - String.starts_with?(method, "turn/") && input_required_method?(method, payload) - end - - defp needs_input?(_method, _payload), do: false - - defp input_required_method?(method, payload) when is_binary(method) do - method in [ - "turn/input_required", - "turn/needs_input", - "turn/need_input", - "turn/request_input", - "turn/request_response", - "turn/provide_input", - "turn/approval_required" - ] || request_payload_requires_input?(payload) - end - - defp request_payload_requires_input?(payload) do - params = Map.get(payload, "params") - needs_input_field?(payload) || needs_input_field?(params) - end - - defp needs_input_field?(payload) when is_map(payload) do - Map.get(payload, "requiresInput") == true or - Map.get(payload, "needsInput") == true or - Map.get(payload, "input_required") == true or - Map.get(payload, "inputRequired") == true or - Map.get(payload, "type") == "input_required" or - Map.get(payload, "type") == "needs_input" - end - - defp needs_input_field?(_payload), do: false -end diff --git a/elixir/lib/symphony_elixir/codex/dynamic_tool.ex b/elixir/lib/symphony_elixir/codex/dynamic_tool.ex deleted file mode 100644 index 716d360705..0000000000 --- a/elixir/lib/symphony_elixir/codex/dynamic_tool.ex +++ /dev/null @@ -1,212 +0,0 @@ -defmodule SymphonyElixir.Codex.DynamicTool do - @moduledoc """ - Executes client-side tool calls requested by Codex app-server turns. - """ - - alias SymphonyElixir.Linear.Client - - @linear_graphql_tool "linear_graphql" - @linear_graphql_description """ - Execute a raw GraphQL query or mutation against Linear using Symphony's configured auth. - """ - @linear_graphql_input_schema %{ - "type" => "object", - "additionalProperties" => false, - "required" => ["query"], - "properties" => %{ - "query" => %{ - "type" => "string", - "description" => "GraphQL query or mutation document to execute against Linear." - }, - "variables" => %{ - "type" => ["object", "null"], - "description" => "Optional GraphQL variables object.", - "additionalProperties" => true - } - } - } - - @spec execute(String.t() | nil, term(), keyword()) :: map() - def execute(tool, arguments, opts \\ []) do - case tool do - @linear_graphql_tool -> - execute_linear_graphql(arguments, opts) - - other -> - failure_response(%{ - "error" => %{ - "message" => "Unsupported dynamic tool: #{inspect(other)}.", - "supportedTools" => supported_tool_names() - } - }) - end - end - - @spec tool_specs() :: [map()] - def tool_specs do - [ - %{ - "name" => @linear_graphql_tool, - "description" => @linear_graphql_description, - "inputSchema" => @linear_graphql_input_schema - } - ] - end - - defp execute_linear_graphql(arguments, opts) do - linear_client = Keyword.get(opts, :linear_client, &Client.graphql/3) - - with {:ok, query, variables} <- normalize_linear_graphql_arguments(arguments), - {:ok, response} <- linear_client.(query, variables, []) do - graphql_response(response) - else - {:error, reason} -> - failure_response(tool_error_payload(reason)) - end - end - - defp normalize_linear_graphql_arguments(arguments) when is_binary(arguments) do - case String.trim(arguments) do - "" -> {:error, :missing_query} - query -> {:ok, query, %{}} - end - end - - defp normalize_linear_graphql_arguments(arguments) when is_map(arguments) do - case normalize_query(arguments) do - {:ok, query} -> - case normalize_variables(arguments) do - {:ok, variables} -> - {:ok, query, variables} - - {:error, reason} -> - {:error, reason} - end - - {:error, reason} -> - {:error, reason} - end - end - - defp normalize_linear_graphql_arguments(_arguments), do: {:error, :invalid_arguments} - - defp normalize_query(arguments) do - case Map.get(arguments, "query") || Map.get(arguments, :query) do - query when is_binary(query) -> - case String.trim(query) do - "" -> {:error, :missing_query} - trimmed -> {:ok, trimmed} - end - - _ -> - {:error, :missing_query} - end - end - - defp normalize_variables(arguments) do - case Map.get(arguments, "variables") || Map.get(arguments, :variables) || %{} do - variables when is_map(variables) -> {:ok, variables} - _ -> {:error, :invalid_variables} - end - end - - defp graphql_response(response) do - success = - case response do - %{"errors" => errors} when is_list(errors) and errors != [] -> false - %{errors: errors} when is_list(errors) and errors != [] -> false - _ -> true - end - - %{ - "success" => success, - "contentItems" => [ - %{ - "type" => "inputText", - "text" => encode_payload(response) - } - ] - } - end - - defp failure_response(payload) do - %{ - "success" => false, - "contentItems" => [ - %{ - "type" => "inputText", - "text" => encode_payload(payload) - } - ] - } - end - - defp encode_payload(payload) when is_map(payload) or is_list(payload) do - Jason.encode!(payload, pretty: true) - end - - defp encode_payload(payload), do: inspect(payload) - - defp tool_error_payload(:missing_query) do - %{ - "error" => %{ - "message" => "`linear_graphql` requires a non-empty `query` string." - } - } - end - - defp tool_error_payload(:invalid_arguments) do - %{ - "error" => %{ - "message" => "`linear_graphql` expects either a GraphQL query string or an object with `query` and optional `variables`." - } - } - end - - defp tool_error_payload(:invalid_variables) do - %{ - "error" => %{ - "message" => "`linear_graphql.variables` must be a JSON object when provided." - } - } - end - - defp tool_error_payload(:missing_linear_api_token) do - %{ - "error" => %{ - "message" => "Symphony is missing Linear auth. Set `linear.api_key` in `WORKFLOW.md` or export `LINEAR_API_KEY`." - } - } - end - - defp tool_error_payload({:linear_api_status, status}) do - %{ - "error" => %{ - "message" => "Linear GraphQL request failed with HTTP #{status}.", - "status" => status - } - } - end - - defp tool_error_payload({:linear_api_request, reason}) do - %{ - "error" => %{ - "message" => "Linear GraphQL request failed before receiving a successful response.", - "reason" => inspect(reason) - } - } - end - - defp tool_error_payload(reason) do - %{ - "error" => %{ - "message" => "Linear GraphQL tool execution failed.", - "reason" => inspect(reason) - } - } - end - - defp supported_tool_names do - Enum.map(tool_specs(), & &1["name"]) - end -end diff --git a/elixir/lib/symphony_elixir/config.ex b/elixir/lib/symphony_elixir/config.ex deleted file mode 100644 index 3a9f0d997a..0000000000 --- a/elixir/lib/symphony_elixir/config.ex +++ /dev/null @@ -1,938 +0,0 @@ -defmodule SymphonyElixir.Config do - @moduledoc """ - Runtime configuration loaded from `WORKFLOW.md`. - """ - - alias NimbleOptions - alias SymphonyElixir.Workflow - - @default_active_states ["Todo", "In Progress"] - @default_terminal_states ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] - @default_linear_endpoint "https://api.linear.app/graphql" - @default_prompt_template """ - You are working on a Linear issue. - - Identifier: {{ issue.identifier }} - Title: {{ issue.title }} - - Body: - {% if issue.description %} - {{ issue.description }} - {% else %} - No description provided. - {% endif %} - """ - @default_poll_interval_ms 30_000 - @default_workspace_root Path.join(System.tmp_dir!(), "symphony_workspaces") - @default_hook_timeout_ms 60_000 - @default_max_concurrent_agents 10 - @default_agent_max_turns 20 - @default_max_retry_backoff_ms 300_000 - @default_codex_command "codex app-server" - @default_codex_turn_timeout_ms 3_600_000 - @default_codex_read_timeout_ms 5_000 - @default_codex_stall_timeout_ms 300_000 - @default_codex_approval_policy %{ - "reject" => %{ - "sandbox_approval" => true, - "rules" => true, - "mcp_elicitations" => true - } - } - @default_codex_thread_sandbox "workspace-write" - @default_observability_enabled true - @default_observability_refresh_ms 1_000 - @default_observability_render_interval_ms 16 - @default_server_host "127.0.0.1" - @workflow_options_schema NimbleOptions.new!( - tracker: [ - type: :map, - default: %{}, - keys: [ - kind: [type: {:or, [:string, nil]}, default: nil], - endpoint: [type: :string, default: @default_linear_endpoint], - api_key: [type: {:or, [:string, nil]}, default: nil], - project_slug: [type: {:or, [:string, nil]}, default: nil], - assignee: [type: {:or, [:string, nil]}, default: nil], - active_states: [ - type: {:list, :string}, - default: @default_active_states - ], - terminal_states: [ - type: {:list, :string}, - default: @default_terminal_states - ] - ] - ], - polling: [ - type: :map, - default: %{}, - keys: [ - interval_ms: [type: :integer, default: @default_poll_interval_ms] - ] - ], - workspace: [ - type: :map, - default: %{}, - keys: [ - root: [type: {:or, [:string, nil]}, default: @default_workspace_root] - ] - ], - agent: [ - type: :map, - default: %{}, - keys: [ - max_concurrent_agents: [ - type: :integer, - default: @default_max_concurrent_agents - ], - max_turns: [ - type: :pos_integer, - default: @default_agent_max_turns - ], - max_retry_backoff_ms: [ - type: :pos_integer, - default: @default_max_retry_backoff_ms - ], - max_concurrent_agents_by_state: [ - type: {:map, :string, :pos_integer}, - default: %{} - ] - ] - ], - codex: [ - type: :map, - default: %{}, - keys: [ - command: [type: :string, default: @default_codex_command], - turn_timeout_ms: [ - type: :integer, - default: @default_codex_turn_timeout_ms - ], - read_timeout_ms: [ - type: :integer, - default: @default_codex_read_timeout_ms - ], - stall_timeout_ms: [ - type: :integer, - default: @default_codex_stall_timeout_ms - ] - ] - ], - hooks: [ - type: :map, - default: %{}, - keys: [ - after_create: [type: {:or, [:string, nil]}, default: nil], - before_run: [type: {:or, [:string, nil]}, default: nil], - after_run: [type: {:or, [:string, nil]}, default: nil], - before_remove: [type: {:or, [:string, nil]}, default: nil], - timeout_ms: [type: :pos_integer, default: @default_hook_timeout_ms] - ] - ], - observability: [ - type: :map, - default: %{}, - keys: [ - dashboard_enabled: [ - type: :boolean, - default: @default_observability_enabled - ], - refresh_ms: [ - type: :integer, - default: @default_observability_refresh_ms - ], - render_interval_ms: [ - type: :integer, - default: @default_observability_render_interval_ms - ] - ] - ], - server: [ - type: :map, - default: %{}, - keys: [ - port: [type: {:or, [:non_neg_integer, nil]}, default: nil], - host: [type: :string, default: @default_server_host] - ] - ] - ) - - @type workflow_payload :: Workflow.loaded_workflow() - @type tracker_kind :: String.t() | nil - @type codex_runtime_settings :: %{ - approval_policy: String.t() | map(), - thread_sandbox: String.t(), - turn_sandbox_policy: map() - } - @type workspace_hooks :: %{ - after_create: String.t() | nil, - before_run: String.t() | nil, - after_run: String.t() | nil, - before_remove: String.t() | nil, - timeout_ms: pos_integer() - } - - @spec current_workflow() :: {:ok, workflow_payload()} | {:error, term()} - def current_workflow do - Workflow.current() - end - - @spec tracker_kind() :: tracker_kind() - def tracker_kind do - get_in(validated_workflow_options(), [:tracker, :kind]) - end - - @spec linear_endpoint() :: String.t() - def linear_endpoint do - get_in(validated_workflow_options(), [:tracker, :endpoint]) - end - - @spec linear_api_token() :: String.t() | nil - def linear_api_token do - validated_workflow_options() - |> get_in([:tracker, :api_key]) - |> resolve_env_value(System.get_env("LINEAR_API_KEY")) - |> normalize_secret_value() - end - - @spec linear_project_slug() :: String.t() | nil - def linear_project_slug do - get_in(validated_workflow_options(), [:tracker, :project_slug]) - end - - @spec linear_assignee() :: String.t() | nil - def linear_assignee do - validated_workflow_options() - |> get_in([:tracker, :assignee]) - |> resolve_env_value(System.get_env("LINEAR_ASSIGNEE")) - |> normalize_secret_value() - end - - @spec linear_active_states() :: [String.t()] - def linear_active_states do - get_in(validated_workflow_options(), [:tracker, :active_states]) - end - - @spec linear_terminal_states() :: [String.t()] - def linear_terminal_states do - get_in(validated_workflow_options(), [:tracker, :terminal_states]) - end - - @spec poll_interval_ms() :: pos_integer() - def poll_interval_ms do - get_in(validated_workflow_options(), [:polling, :interval_ms]) - end - - @spec workspace_root() :: Path.t() - def workspace_root do - validated_workflow_options() - |> get_in([:workspace, :root]) - |> resolve_path_value(@default_workspace_root) - end - - @spec workspace_hooks() :: workspace_hooks() - def workspace_hooks do - hooks = get_in(validated_workflow_options(), [:hooks]) - - %{ - after_create: Map.get(hooks, :after_create), - before_run: Map.get(hooks, :before_run), - after_run: Map.get(hooks, :after_run), - before_remove: Map.get(hooks, :before_remove), - timeout_ms: Map.get(hooks, :timeout_ms) - } - end - - @spec hook_timeout_ms() :: pos_integer() - def hook_timeout_ms do - get_in(validated_workflow_options(), [:hooks, :timeout_ms]) - end - - @spec max_concurrent_agents() :: pos_integer() - def max_concurrent_agents do - get_in(validated_workflow_options(), [:agent, :max_concurrent_agents]) - end - - @spec max_retry_backoff_ms() :: pos_integer() - def max_retry_backoff_ms do - get_in(validated_workflow_options(), [:agent, :max_retry_backoff_ms]) - end - - @spec agent_max_turns() :: pos_integer() - def agent_max_turns do - get_in(validated_workflow_options(), [:agent, :max_turns]) - end - - @spec max_concurrent_agents_for_state(term()) :: pos_integer() - def max_concurrent_agents_for_state(state_name) when is_binary(state_name) do - state_limits = get_in(validated_workflow_options(), [:agent, :max_concurrent_agents_by_state]) - global_limit = max_concurrent_agents() - Map.get(state_limits, normalize_issue_state(state_name), global_limit) - end - - def max_concurrent_agents_for_state(_state_name), do: max_concurrent_agents() - - @spec codex_command() :: String.t() - def codex_command do - get_in(validated_workflow_options(), [:codex, :command]) - end - - @spec codex_turn_timeout_ms() :: pos_integer() - def codex_turn_timeout_ms do - get_in(validated_workflow_options(), [:codex, :turn_timeout_ms]) - end - - @spec codex_approval_policy() :: String.t() | map() - def codex_approval_policy do - case resolve_codex_approval_policy() do - {:ok, approval_policy} -> approval_policy - {:error, _reason} -> @default_codex_approval_policy - end - end - - @spec codex_thread_sandbox() :: String.t() - def codex_thread_sandbox do - case resolve_codex_thread_sandbox() do - {:ok, thread_sandbox} -> thread_sandbox - {:error, _reason} -> @default_codex_thread_sandbox - end - end - - @spec codex_turn_sandbox_policy(Path.t() | nil) :: map() - def codex_turn_sandbox_policy(workspace \\ nil) do - case resolve_codex_turn_sandbox_policy(workspace) do - {:ok, turn_sandbox_policy} -> turn_sandbox_policy - {:error, _reason} -> default_codex_turn_sandbox_policy(workspace) - end - end - - @spec codex_read_timeout_ms() :: pos_integer() - def codex_read_timeout_ms do - get_in(validated_workflow_options(), [:codex, :read_timeout_ms]) - end - - @spec codex_stall_timeout_ms() :: non_neg_integer() - def codex_stall_timeout_ms do - validated_workflow_options() - |> get_in([:codex, :stall_timeout_ms]) - |> max(0) - end - - @spec workflow_prompt() :: String.t() - def workflow_prompt do - case current_workflow() do - {:ok, %{prompt_template: prompt}} -> - if String.trim(prompt) == "", do: @default_prompt_template, else: prompt - - _ -> - @default_prompt_template - end - end - - @spec observability_enabled?() :: boolean() - def observability_enabled? do - get_in(validated_workflow_options(), [:observability, :dashboard_enabled]) - end - - @spec observability_refresh_ms() :: pos_integer() - def observability_refresh_ms do - get_in(validated_workflow_options(), [:observability, :refresh_ms]) - end - - @spec observability_render_interval_ms() :: pos_integer() - def observability_render_interval_ms do - get_in(validated_workflow_options(), [:observability, :render_interval_ms]) - end - - @spec server_port() :: non_neg_integer() | nil - def server_port do - case Application.get_env(:symphony_elixir, :server_port_override) do - port when is_integer(port) and port >= 0 -> - port - - _ -> - get_in(validated_workflow_options(), [:server, :port]) - end - end - - @spec server_host() :: String.t() - def server_host do - get_in(validated_workflow_options(), [:server, :host]) - end - - @spec validate!() :: :ok | {:error, term()} - def validate! do - with {:ok, _workflow} <- current_workflow(), - :ok <- require_tracker_kind(), - :ok <- require_linear_token(), - :ok <- require_linear_project(), - :ok <- require_valid_codex_runtime_settings() do - require_codex_command() - end - end - - @spec codex_runtime_settings(Path.t() | nil) :: {:ok, codex_runtime_settings()} | {:error, term()} - def codex_runtime_settings(workspace \\ nil) do - with {:ok, approval_policy} <- resolve_codex_approval_policy(), - {:ok, thread_sandbox} <- resolve_codex_thread_sandbox(), - {:ok, turn_sandbox_policy} <- resolve_codex_turn_sandbox_policy(workspace) do - {:ok, - %{ - approval_policy: approval_policy, - thread_sandbox: thread_sandbox, - turn_sandbox_policy: turn_sandbox_policy - }} - end - end - - defp require_tracker_kind do - case tracker_kind() do - "linear" -> :ok - "memory" -> :ok - nil -> {:error, :missing_tracker_kind} - other -> {:error, {:unsupported_tracker_kind, other}} - end - end - - defp require_linear_token do - case tracker_kind() do - "linear" -> - if is_binary(linear_api_token()) do - :ok - else - {:error, :missing_linear_api_token} - end - - _ -> - :ok - end - end - - defp require_linear_project do - case tracker_kind() do - "linear" -> - if is_binary(linear_project_slug()) do - :ok - else - {:error, :missing_linear_project_slug} - end - - _ -> - :ok - end - end - - defp require_codex_command do - if byte_size(String.trim(codex_command())) > 0 do - :ok - else - {:error, :missing_codex_command} - end - end - - defp require_valid_codex_runtime_settings do - case codex_runtime_settings() do - {:ok, _settings} -> :ok - {:error, reason} -> {:error, reason} - end - end - - defp validated_workflow_options do - workflow_config() - |> extract_workflow_options() - |> NimbleOptions.validate!(@workflow_options_schema) - end - - defp extract_workflow_options(config) do - %{ - tracker: extract_tracker_options(section_map(config, "tracker")), - polling: extract_polling_options(section_map(config, "polling")), - workspace: extract_workspace_options(section_map(config, "workspace")), - agent: extract_agent_options(section_map(config, "agent")), - codex: extract_codex_options(section_map(config, "codex")), - hooks: extract_hooks_options(section_map(config, "hooks")), - observability: extract_observability_options(section_map(config, "observability")), - server: extract_server_options(section_map(config, "server")) - } - end - - defp extract_tracker_options(section) do - %{} - |> put_if_present(:kind, normalize_tracker_kind(scalar_string_value(Map.get(section, "kind")))) - |> put_if_present(:endpoint, scalar_string_value(Map.get(section, "endpoint"))) - |> put_if_present(:api_key, binary_value(Map.get(section, "api_key"), allow_empty: true)) - |> put_if_present(:project_slug, scalar_string_value(Map.get(section, "project_slug"))) - |> put_if_present(:active_states, csv_value(Map.get(section, "active_states"))) - |> put_if_present(:terminal_states, csv_value(Map.get(section, "terminal_states"))) - end - - defp extract_polling_options(section) do - %{} - |> put_if_present(:interval_ms, integer_value(Map.get(section, "interval_ms"))) - end - - defp extract_workspace_options(section) do - %{} - |> put_if_present(:root, binary_value(Map.get(section, "root"))) - end - - defp extract_agent_options(section) do - %{} - |> put_if_present(:max_concurrent_agents, integer_value(Map.get(section, "max_concurrent_agents"))) - |> put_if_present(:max_turns, positive_integer_value(Map.get(section, "max_turns"))) - |> put_if_present(:max_retry_backoff_ms, positive_integer_value(Map.get(section, "max_retry_backoff_ms"))) - |> put_if_present( - :max_concurrent_agents_by_state, - state_limits_value(Map.get(section, "max_concurrent_agents_by_state")) - ) - end - - defp extract_codex_options(section) do - %{} - |> put_if_present(:command, command_value(Map.get(section, "command"))) - |> put_if_present(:turn_timeout_ms, integer_value(Map.get(section, "turn_timeout_ms"))) - |> put_if_present(:read_timeout_ms, integer_value(Map.get(section, "read_timeout_ms"))) - |> put_if_present(:stall_timeout_ms, integer_value(Map.get(section, "stall_timeout_ms"))) - end - - defp extract_hooks_options(section) do - %{} - |> put_if_present(:after_create, hook_command_value(Map.get(section, "after_create"))) - |> put_if_present(:before_run, hook_command_value(Map.get(section, "before_run"))) - |> put_if_present(:after_run, hook_command_value(Map.get(section, "after_run"))) - |> put_if_present(:before_remove, hook_command_value(Map.get(section, "before_remove"))) - |> put_if_present(:timeout_ms, positive_integer_value(Map.get(section, "timeout_ms"))) - end - - defp extract_observability_options(section) do - %{} - |> put_if_present(:dashboard_enabled, boolean_value(Map.get(section, "dashboard_enabled"))) - |> put_if_present(:refresh_ms, integer_value(Map.get(section, "refresh_ms"))) - |> put_if_present(:render_interval_ms, integer_value(Map.get(section, "render_interval_ms"))) - end - - defp extract_server_options(section) do - %{} - |> put_if_present(:port, non_negative_integer_value(Map.get(section, "port"))) - |> put_if_present(:host, scalar_string_value(Map.get(section, "host"))) - end - - defp section_map(config, key) do - case Map.get(config, key) do - section when is_map(section) -> section - _ -> %{} - end - end - - defp put_if_present(map, _key, :omit), do: map - defp put_if_present(map, key, value), do: Map.put(map, key, value) - - defp scalar_string_value(nil), do: :omit - defp scalar_string_value(value) when is_binary(value), do: String.trim(value) - defp scalar_string_value(value) when is_boolean(value), do: to_string(value) - defp scalar_string_value(value) when is_integer(value), do: to_string(value) - defp scalar_string_value(value) when is_float(value), do: to_string(value) - defp scalar_string_value(value) when is_atom(value), do: Atom.to_string(value) - defp scalar_string_value(_value), do: :omit - - defp binary_value(value, opts \\ []) - - defp binary_value(value, opts) when is_binary(value) do - allow_empty = Keyword.get(opts, :allow_empty, false) - - if value == "" and not allow_empty do - :omit - else - value - end - end - - defp binary_value(_value, _opts), do: :omit - - defp command_value(value) when is_binary(value) do - case String.trim(value) do - "" -> :omit - trimmed -> trimmed - end - end - - defp command_value(_value), do: :omit - - defp hook_command_value(value) when is_binary(value) do - case String.trim(value) do - "" -> :omit - _ -> String.trim_trailing(value) - end - end - - defp hook_command_value(_value), do: :omit - - defp csv_value(values) when is_list(values) do - values - |> Enum.reduce([], fn value, acc -> maybe_append_csv_value(acc, value) end) - |> Enum.reverse() - |> case do - [] -> :omit - normalized_values -> normalized_values - end - end - - defp csv_value(value) when is_binary(value) do - value - |> String.split(",", trim: true) - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - |> case do - [] -> :omit - normalized_values -> normalized_values - end - end - - defp csv_value(_value), do: :omit - - defp maybe_append_csv_value(acc, value) do - case scalar_string_value(value) do - :omit -> - acc - - normalized -> - append_csv_value_if_present(acc, normalized) - end - end - - defp append_csv_value_if_present(acc, value) do - trimmed = String.trim(value) - - if trimmed == "" do - acc - else - [trimmed | acc] - end - end - - defp integer_value(value) do - case parse_integer(value) do - {:ok, parsed} -> parsed - :error -> :omit - end - end - - defp positive_integer_value(value) do - case parse_positive_integer(value) do - {:ok, parsed} -> parsed - :error -> :omit - end - end - - defp non_negative_integer_value(value) do - case parse_non_negative_integer(value) do - {:ok, parsed} -> parsed - :error -> :omit - end - end - - defp boolean_value(value) when is_boolean(value), do: value - - defp boolean_value(value) when is_binary(value) do - case String.downcase(String.trim(value)) do - "true" -> true - "false" -> false - _ -> :omit - end - end - - defp boolean_value(_value), do: :omit - - defp state_limits_value(value) when is_map(value) do - value - |> Enum.reduce(%{}, fn {state_name, limit}, acc -> - case parse_positive_integer(limit) do - {:ok, parsed} -> - Map.put(acc, normalize_issue_state(to_string(state_name)), parsed) - - :error -> - acc - end - end) - end - - defp state_limits_value(_value), do: :omit - - defp parse_integer(value) when is_integer(value), do: {:ok, value} - - defp parse_integer(value) when is_binary(value) do - case Integer.parse(String.trim(value)) do - {parsed, _} -> {:ok, parsed} - :error -> :error - end - end - - defp parse_integer(_value), do: :error - - defp parse_positive_integer(value) do - case parse_integer(value) do - {:ok, parsed} when parsed > 0 -> {:ok, parsed} - _ -> :error - end - end - - defp parse_non_negative_integer(value) do - case parse_integer(value) do - {:ok, parsed} when parsed >= 0 -> {:ok, parsed} - _ -> :error - end - end - - defp fetch_value(paths, default) do - config = workflow_config() - - case resolve_config_value(config, paths) do - :missing -> default - value -> value - end - end - - defp resolve_codex_approval_policy do - case fetch_value([["codex", "approval_policy"]], :missing) do - :missing -> - {:ok, @default_codex_approval_policy} - - nil -> - {:ok, @default_codex_approval_policy} - - value when is_binary(value) -> - approval_policy = String.trim(value) - - if approval_policy == "" do - {:error, {:invalid_codex_approval_policy, value}} - else - {:ok, approval_policy} - end - - value when is_map(value) -> - {:ok, value} - - value -> - {:error, {:invalid_codex_approval_policy, value}} - end - end - - defp resolve_codex_thread_sandbox do - case fetch_value([["codex", "thread_sandbox"]], :missing) do - :missing -> - {:ok, @default_codex_thread_sandbox} - - nil -> - {:ok, @default_codex_thread_sandbox} - - value when is_binary(value) -> - thread_sandbox = String.trim(value) - - if thread_sandbox == "" do - {:error, {:invalid_codex_thread_sandbox, value}} - else - {:ok, thread_sandbox} - end - - value -> - {:error, {:invalid_codex_thread_sandbox, value}} - end - end - - defp resolve_codex_turn_sandbox_policy(workspace) do - case fetch_value([["codex", "turn_sandbox_policy"]], :missing) do - :missing -> - {:ok, default_codex_turn_sandbox_policy(workspace)} - - nil -> - {:ok, default_codex_turn_sandbox_policy(workspace)} - - value when is_map(value) -> - {:ok, value} - - value -> - {:error, {:invalid_codex_turn_sandbox_policy, {:unsupported_value, value}}} - end - end - - defp default_codex_turn_sandbox_policy(workspace) do - writable_root = - if is_binary(workspace) and String.trim(workspace) != "" do - Path.expand(workspace) - else - Path.expand(workspace_root()) - end - - %{ - "type" => "workspaceWrite", - "writableRoots" => [writable_root], - "readOnlyAccess" => %{"type" => "fullAccess"}, - "networkAccess" => false, - "excludeTmpdirEnvVar" => false, - "excludeSlashTmp" => false - } - end - - defp normalize_issue_state(state_name) when is_binary(state_name) do - state_name - |> String.trim() - |> String.downcase() - end - - defp normalize_tracker_kind(kind) when is_binary(kind) do - kind - |> String.trim() - |> String.downcase() - |> case do - "" -> nil - normalized -> normalized - end - end - - defp normalize_tracker_kind(_kind), do: nil - - defp workflow_config do - case current_workflow() do - {:ok, %{config: config}} when is_map(config) -> - normalize_keys(config) - - _ -> - %{} - end - end - - defp resolve_config_value(%{} = config, paths) do - Enum.reduce_while(paths, :missing, fn path, _acc -> - case get_in_path(config, path) do - :missing -> {:cont, :missing} - value -> {:halt, value} - end - end) - end - - defp get_in_path(config, path) when is_list(path) and is_map(config) do - get_in_path(config, path, 0) - end - - defp get_in_path(_, _), do: :missing - - defp get_in_path(config, [], _depth), do: config - - defp get_in_path(%{} = current, [segment | rest], _depth) do - case Map.fetch(current, normalize_key(segment)) do - {:ok, value} -> get_in_path(value, rest, 0) - :error -> :missing - end - end - - defp get_in_path(_, _, _depth), do: :missing - - defp normalize_keys(value) when is_map(value) do - Enum.reduce(value, %{}, fn {key, raw_value}, normalized -> - Map.put(normalized, normalize_key(key), normalize_keys(raw_value)) - end) - end - - defp normalize_keys(value) when is_list(value), do: Enum.map(value, &normalize_keys/1) - defp normalize_keys(value), do: value - - defp normalize_key(value) when is_atom(value), do: Atom.to_string(value) - defp normalize_key(value), do: to_string(value) - - defp resolve_path_value(:missing, default), do: default - defp resolve_path_value(nil, default), do: default - - defp resolve_path_value(value, default) when is_binary(value) do - case normalize_path_token(value) do - :missing -> - default - - path -> - path - |> String.trim() - |> preserve_command_name() - |> then(fn - "" -> default - resolved -> resolved - end) - end - end - - defp resolve_path_value(_value, default), do: default - - defp preserve_command_name(path) do - cond do - uri_path?(path) -> - path - - String.contains?(path, "/") or String.contains?(path, "\\") -> - Path.expand(path) - - true -> - path - end - end - - defp uri_path?(path) do - String.match?(to_string(path), ~r/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//) - end - - defp resolve_env_value(:missing, fallback), do: fallback - defp resolve_env_value(nil, fallback), do: fallback - - defp resolve_env_value(value, fallback) when is_binary(value) do - trimmed = String.trim(value) - - case env_reference_name(trimmed) do - {:ok, env_name} -> - env_name - |> System.get_env() - |> then(fn - nil -> fallback - "" -> nil - env_value -> env_value - end) - - :error -> - trimmed - end - end - - defp resolve_env_value(_value, fallback), do: fallback - - defp normalize_path_token(value) when is_binary(value) do - trimmed = String.trim(value) - - case env_reference_name(trimmed) do - {:ok, env_name} -> resolve_env_token(env_name) - :error -> trimmed - end - end - - defp env_reference_name("$" <> env_name) do - if String.match?(env_name, ~r/^[A-Za-z_][A-Za-z0-9_]*$/) do - {:ok, env_name} - else - :error - end - end - - defp env_reference_name(_value), do: :error - - defp resolve_env_token(value) do - case System.get_env(value) do - nil -> :missing - env_value -> env_value - end - end - - defp normalize_secret_value(value) when is_binary(value) do - case String.trim(value) do - "" -> nil - trimmed -> trimmed - end - end - - defp normalize_secret_value(_value), do: nil -end diff --git a/elixir/lib/symphony_elixir/http_server.ex b/elixir/lib/symphony_elixir/http_server.ex deleted file mode 100644 index 47686e934f..0000000000 --- a/elixir/lib/symphony_elixir/http_server.ex +++ /dev/null @@ -1,88 +0,0 @@ -defmodule SymphonyElixir.HttpServer do - @moduledoc """ - Compatibility facade that starts the Phoenix observability endpoint when enabled. - """ - - alias SymphonyElixir.{Config, Orchestrator} - alias SymphonyElixirWeb.Endpoint - - @secret_key_bytes 48 - - @spec child_spec(keyword()) :: Supervisor.child_spec() - def child_spec(opts) do - %{ - id: __MODULE__, - start: {__MODULE__, :start_link, [opts]} - } - end - - @spec start_link(keyword()) :: GenServer.on_start() | :ignore - def start_link(opts \\ []) do - case Keyword.get(opts, :port, Config.server_port()) do - port when is_integer(port) and port >= 0 -> - host = Keyword.get(opts, :host, Config.server_host()) - orchestrator = Keyword.get(opts, :orchestrator, Orchestrator) - snapshot_timeout_ms = Keyword.get(opts, :snapshot_timeout_ms, 15_000) - - with {:ok, ip} <- parse_host(host) do - endpoint_opts = [ - server: true, - http: [ip: ip, port: port], - url: [host: normalize_host(host)], - orchestrator: orchestrator, - snapshot_timeout_ms: snapshot_timeout_ms, - secret_key_base: secret_key_base() - ] - - endpoint_config = - :symphony_elixir - |> Application.get_env(Endpoint, []) - |> Keyword.merge(endpoint_opts) - - Application.put_env(:symphony_elixir, Endpoint, endpoint_config) - Endpoint.start_link() - end - - _ -> - :ignore - end - end - - @spec bound_port(term()) :: non_neg_integer() | nil - def bound_port(_server \\ __MODULE__) do - case Bandit.PhoenixAdapter.server_info(Endpoint, :http) do - {:ok, {_ip, port}} when is_integer(port) -> port - _ -> nil - end - rescue - _error -> nil - catch - :exit, _reason -> nil - end - - defp parse_host({_, _, _, _} = ip), do: {:ok, ip} - defp parse_host({_, _, _, _, _, _, _, _} = ip), do: {:ok, ip} - - defp parse_host(host) when is_binary(host) do - charhost = String.to_charlist(host) - - case :inet.parse_address(charhost) do - {:ok, ip} -> - {:ok, ip} - - {:error, _reason} -> - case :inet.getaddr(charhost, :inet) do - {:ok, ip} -> {:ok, ip} - {:error, _reason} -> :inet.getaddr(charhost, :inet6) - end - end - end - - defp normalize_host(host) when host in ["", nil], do: "127.0.0.1" - defp normalize_host(host) when is_binary(host), do: host - defp normalize_host(host), do: to_string(host) - - defp secret_key_base do - Base.encode64(:crypto.strong_rand_bytes(@secret_key_bytes), padding: false) - end -end diff --git a/elixir/lib/symphony_elixir/linear/adapter.ex b/elixir/lib/symphony_elixir/linear/adapter.ex deleted file mode 100644 index ab17eeec4e..0000000000 --- a/elixir/lib/symphony_elixir/linear/adapter.ex +++ /dev/null @@ -1,91 +0,0 @@ -defmodule SymphonyElixir.Linear.Adapter do - @moduledoc """ - Linear-backed tracker adapter. - """ - - @behaviour SymphonyElixir.Tracker - - alias SymphonyElixir.Linear.Client - - @create_comment_mutation """ - mutation SymphonyCreateComment($issueId: String!, $body: String!) { - commentCreate(input: {issueId: $issueId, body: $body}) { - success - } - } - """ - - @update_state_mutation """ - mutation SymphonyUpdateIssueState($issueId: String!, $stateId: String!) { - issueUpdate(id: $issueId, input: {stateId: $stateId}) { - success - } - } - """ - - @state_lookup_query """ - query SymphonyResolveStateId($issueId: String!, $stateName: String!) { - issue(id: $issueId) { - team { - states(filter: {name: {eq: $stateName}}, first: 1) { - nodes { - id - } - } - } - } - } - """ - - @spec fetch_candidate_issues() :: {:ok, [term()]} | {:error, term()} - def fetch_candidate_issues, do: client_module().fetch_candidate_issues() - - @spec fetch_issues_by_states([String.t()]) :: {:ok, [term()]} | {:error, term()} - def fetch_issues_by_states(states), do: client_module().fetch_issues_by_states(states) - - @spec fetch_issue_states_by_ids([String.t()]) :: {:ok, [term()]} | {:error, term()} - def fetch_issue_states_by_ids(issue_ids), do: client_module().fetch_issue_states_by_ids(issue_ids) - - @spec create_comment(String.t(), String.t()) :: :ok | {:error, term()} - def create_comment(issue_id, body) when is_binary(issue_id) and is_binary(body) do - with {:ok, response} <- client_module().graphql(@create_comment_mutation, %{issueId: issue_id, body: body}), - true <- get_in(response, ["data", "commentCreate", "success"]) == true do - :ok - else - false -> {:error, :comment_create_failed} - {:error, reason} -> {:error, reason} - _ -> {:error, :comment_create_failed} - end - end - - @spec update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} - def update_issue_state(issue_id, state_name) - when is_binary(issue_id) and is_binary(state_name) do - with {:ok, state_id} <- resolve_state_id(issue_id, state_name), - {:ok, response} <- - client_module().graphql(@update_state_mutation, %{issueId: issue_id, stateId: state_id}), - true <- get_in(response, ["data", "issueUpdate", "success"]) == true do - :ok - else - false -> {:error, :issue_update_failed} - {:error, reason} -> {:error, reason} - _ -> {:error, :issue_update_failed} - end - end - - defp client_module do - Application.get_env(:symphony_elixir, :linear_client_module, Client) - end - - defp resolve_state_id(issue_id, state_name) do - with {:ok, response} <- - client_module().graphql(@state_lookup_query, %{issueId: issue_id, stateName: state_name}), - state_id when is_binary(state_id) <- - get_in(response, ["data", "issue", "team", "states", "nodes", Access.at(0), "id"]) do - {:ok, state_id} - else - {:error, reason} -> {:error, reason} - _ -> {:error, :state_not_found} - end - end -end diff --git a/elixir/lib/symphony_elixir/linear/client.ex b/elixir/lib/symphony_elixir/linear/client.ex deleted file mode 100644 index ad8eee550d..0000000000 --- a/elixir/lib/symphony_elixir/linear/client.ex +++ /dev/null @@ -1,530 +0,0 @@ -defmodule SymphonyElixir.Linear.Client do - @moduledoc """ - Thin Linear GraphQL client for polling candidate issues. - """ - - require Logger - alias SymphonyElixir.{Config, Linear.Issue} - - @issue_page_size 50 - @max_error_body_log_bytes 1_000 - - @query """ - query SymphonyLinearPoll($projectSlug: String!, $stateNames: [String!]!, $first: Int!, $relationFirst: Int!, $after: String) { - issues(filter: {project: {slugId: {eq: $projectSlug}}, state: {name: {in: $stateNames}}}, first: $first, after: $after) { - nodes { - id - identifier - title - description - priority - state { - name - } - branchName - url - assignee { - id - } - labels { - nodes { - name - } - } - inverseRelations(first: $relationFirst) { - nodes { - type - issue { - id - identifier - state { - name - } - } - } - } - createdAt - updatedAt - } - pageInfo { - hasNextPage - endCursor - } - } - } - """ - - @query_by_ids """ - query SymphonyLinearIssuesById($ids: [ID!]!, $first: Int!, $relationFirst: Int!) { - issues(filter: {id: {in: $ids}}, first: $first) { - nodes { - id - identifier - title - description - priority - state { - name - } - branchName - url - assignee { - id - } - labels { - nodes { - name - } - } - inverseRelations(first: $relationFirst) { - nodes { - type - issue { - id - identifier - state { - name - } - } - } - } - createdAt - updatedAt - } - } - } - """ - - @viewer_query """ - query SymphonyLinearViewer { - viewer { - id - } - } - """ - - @spec fetch_candidate_issues() :: {:ok, [Issue.t()]} | {:error, term()} - def fetch_candidate_issues do - project_slug = Config.linear_project_slug() - - cond do - is_nil(Config.linear_api_token()) -> - {:error, :missing_linear_api_token} - - is_nil(project_slug) -> - {:error, :missing_linear_project_slug} - - true -> - with {:ok, assignee_filter} <- routing_assignee_filter() do - do_fetch_by_states(project_slug, Config.linear_active_states(), assignee_filter) - end - end - end - - @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} - def fetch_issues_by_states(state_names) when is_list(state_names) do - normalized_states = Enum.map(state_names, &to_string/1) |> Enum.uniq() - - if normalized_states == [] do - {:ok, []} - else - project_slug = Config.linear_project_slug() - - cond do - is_nil(Config.linear_api_token()) -> - {:error, :missing_linear_api_token} - - is_nil(project_slug) -> - {:error, :missing_linear_project_slug} - - true -> - do_fetch_by_states(project_slug, normalized_states, nil) - end - end - end - - @spec fetch_issue_states_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} - def fetch_issue_states_by_ids(issue_ids) when is_list(issue_ids) do - ids = Enum.uniq(issue_ids) - - case ids do - [] -> - {:ok, []} - - ids -> - with {:ok, assignee_filter} <- routing_assignee_filter() do - do_fetch_issue_states(ids, assignee_filter) - end - end - end - - @spec graphql(String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()} - def graphql(query, variables \\ %{}, opts \\ []) - when is_binary(query) and is_map(variables) and is_list(opts) do - payload = build_graphql_payload(query, variables, Keyword.get(opts, :operation_name)) - request_fun = Keyword.get(opts, :request_fun, &post_graphql_request/2) - - with {:ok, headers} <- graphql_headers(), - {:ok, %{status: 200, body: body}} <- request_fun.(payload, headers) do - {:ok, body} - else - {:ok, response} -> - Logger.error( - "Linear GraphQL request failed status=#{response.status}" <> - linear_error_context(payload, response) - ) - - {:error, {:linear_api_status, response.status}} - - {:error, reason} -> - Logger.error("Linear GraphQL request failed: #{inspect(reason)}") - {:error, {:linear_api_request, reason}} - end - end - - @doc false - @spec normalize_issue_for_test(map()) :: Issue.t() | nil - def normalize_issue_for_test(issue) when is_map(issue) do - normalize_issue(issue, nil) - end - - @doc false - @spec normalize_issue_for_test(map(), String.t() | nil) :: Issue.t() | nil - def normalize_issue_for_test(issue, assignee) when is_map(issue) do - assignee_filter = - case assignee do - value when is_binary(value) -> - case build_assignee_filter(value) do - {:ok, filter} -> filter - {:error, _reason} -> nil - end - - _ -> - nil - end - - normalize_issue(issue, assignee_filter) - end - - @doc false - @spec next_page_cursor_for_test(map()) :: {:ok, String.t()} | :done | {:error, term()} - def next_page_cursor_for_test(page_info) when is_map(page_info), do: next_page_cursor(page_info) - - @doc false - @spec merge_issue_pages_for_test([[Issue.t()]]) :: [Issue.t()] - def merge_issue_pages_for_test(issue_pages) when is_list(issue_pages) do - issue_pages - |> Enum.reduce([], &prepend_page_issues/2) - |> finalize_paginated_issues() - 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 - - defp do_fetch_by_states_page(project_slug, state_names, assignee_filter, after_cursor, acc_issues) do - with {:ok, body} <- - graphql(@query, %{ - projectSlug: project_slug, - stateNames: state_names, - first: @issue_page_size, - relationFirst: @issue_page_size, - after: after_cursor - }), - {:ok, issues, page_info} <- decode_linear_page_response(body, assignee_filter) do - updated_acc = prepend_page_issues(issues, acc_issues) - - case next_page_cursor(page_info) do - {:ok, next_cursor} -> - do_fetch_by_states_page(project_slug, state_names, assignee_filter, next_cursor, updated_acc) - - :done -> - {:ok, finalize_paginated_issues(updated_acc)} - - {:error, reason} -> - {:error, reason} - end - end - end - - defp prepend_page_issues(issues, acc_issues) when is_list(issues) and is_list(acc_issues) do - Enum.reverse(issues, acc_issues) - end - - 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]), - relationFirst: @issue_page_size - }) do - {:ok, body} -> - decode_linear_response(body, assignee_filter) - - {:error, reason} -> - {:error, reason} - end - end - - defp build_graphql_payload(query, variables, operation_name) do - %{ - "query" => query, - "variables" => variables - } - |> maybe_put_operation_name(operation_name) - end - - defp maybe_put_operation_name(payload, operation_name) when is_binary(operation_name) do - trimmed = String.trim(operation_name) - - if trimmed == "" do - payload - else - Map.put(payload, "operationName", trimmed) - end - end - - defp maybe_put_operation_name(payload, _operation_name), do: payload - - defp linear_error_context(payload, response) when is_map(payload) do - operation_name = - case Map.get(payload, "operationName") do - name when is_binary(name) and name != "" -> " operation=#{name}" - _ -> "" - end - - body = - response - |> Map.get(:body) - |> summarize_error_body() - - operation_name <> " body=" <> body - end - - defp summarize_error_body(body) when is_binary(body) do - body - |> String.replace(~r/\s+/, " ") - |> String.trim() - |> truncate_error_body() - |> inspect() - end - - defp summarize_error_body(body) do - body - |> inspect(limit: 20, printable_limit: @max_error_body_log_bytes) - |> truncate_error_body() - end - - defp truncate_error_body(body) when is_binary(body) do - if byte_size(body) > @max_error_body_log_bytes do - binary_part(body, 0, @max_error_body_log_bytes) <> "..." - else - body - end - end - - defp graphql_headers do - case Config.linear_api_token() do - nil -> - {:error, :missing_linear_api_token} - - token -> - {:ok, - [ - {"Authorization", token}, - {"Content-Type", "application/json"} - ]} - end - end - - defp post_graphql_request(payload, headers) do - Req.post(Config.linear_endpoint(), - headers: headers, - json: payload, - connect_options: [timeout: 30_000] - ) - end - - defp decode_linear_response(%{"data" => %{"issues" => %{"nodes" => nodes}}}, assignee_filter) do - issues = - nodes - |> Enum.map(&normalize_issue(&1, assignee_filter)) - |> Enum.reject(&is_nil(&1)) - - {:ok, issues} - end - - defp decode_linear_response(%{"errors" => errors}, _assignee_filter) do - {:error, {:linear_graphql_errors, errors}} - end - - defp decode_linear_response(_unknown, _assignee_filter) do - {:error, :linear_unknown_payload} - end - - defp decode_linear_page_response( - %{ - "data" => %{ - "issues" => %{ - "nodes" => nodes, - "pageInfo" => %{"hasNextPage" => has_next_page, "endCursor" => end_cursor} - } - } - }, - assignee_filter - ) do - with {:ok, issues} <- decode_linear_response(%{"data" => %{"issues" => %{"nodes" => nodes}}}, assignee_filter) do - {:ok, issues, %{has_next_page: has_next_page == true, end_cursor: end_cursor}} - end - end - - defp decode_linear_page_response(response, assignee_filter), do: decode_linear_response(response, assignee_filter) - - defp next_page_cursor(%{has_next_page: true, end_cursor: end_cursor}) - when is_binary(end_cursor) and byte_size(end_cursor) > 0 do - {:ok, end_cursor} - end - - defp next_page_cursor(%{has_next_page: true}), do: {:error, :linear_missing_end_cursor} - defp next_page_cursor(_), do: :done - - defp normalize_issue(issue, assignee_filter) when is_map(issue) do - assignee = issue["assignee"] - - %Issue{ - id: issue["id"], - identifier: issue["identifier"], - title: issue["title"], - description: issue["description"], - priority: parse_priority(issue["priority"]), - state: get_in(issue, ["state", "name"]), - branch_name: issue["branchName"], - url: issue["url"], - assignee_id: assignee_field(assignee, "id"), - blocked_by: extract_blockers(issue), - labels: extract_labels(issue), - assigned_to_worker: assigned_to_worker?(assignee, assignee_filter), - created_at: parse_datetime(issue["createdAt"]), - updated_at: parse_datetime(issue["updatedAt"]) - } - end - - defp normalize_issue(_issue, _assignee_filter), do: nil - - defp assignee_field(%{} = assignee, field) when is_binary(field), do: assignee[field] - defp assignee_field(_assignee, _field), do: nil - - defp assigned_to_worker?(_assignee, nil), do: true - - defp assigned_to_worker?(%{} = assignee, %{match_values: match_values}) - when is_struct(match_values, MapSet) do - assignee - |> assignee_id() - |> then(fn - nil -> false - assignee_id -> MapSet.member?(match_values, assignee_id) - end) - end - - defp assigned_to_worker?(_assignee, _assignee_filter), do: false - - defp assignee_id(%{} = assignee), do: normalize_assignee_match_value(assignee["id"]) - - defp routing_assignee_filter do - case Config.linear_assignee() do - nil -> - {:ok, nil} - - assignee -> - build_assignee_filter(assignee) - end - end - - defp build_assignee_filter(assignee) when is_binary(assignee) do - case normalize_assignee_match_value(assignee) do - nil -> - {:ok, nil} - - "me" -> - resolve_viewer_assignee_filter() - - normalized -> - {:ok, %{configured_assignee: assignee, match_values: MapSet.new([normalized])}} - end - end - - defp resolve_viewer_assignee_filter do - case graphql(@viewer_query, %{}) do - {:ok, %{"data" => %{"viewer" => viewer}}} when is_map(viewer) -> - case assignee_id(viewer) do - nil -> - {:error, :missing_linear_viewer_identity} - - viewer_id -> - {:ok, %{configured_assignee: "me", match_values: MapSet.new([viewer_id])}} - end - - {:ok, _body} -> - {:error, :missing_linear_viewer_identity} - - {:error, reason} -> - {:error, reason} - end - end - - defp normalize_assignee_match_value(value) when is_binary(value) do - case value |> String.trim() do - "" -> nil - normalized -> normalized - end - end - - defp normalize_assignee_match_value(_value), do: nil - - defp extract_labels(%{"labels" => %{"nodes" => labels}}) when is_list(labels) do - labels - |> Enum.map(& &1["name"]) - |> Enum.reject(&is_nil/1) - |> Enum.map(&String.downcase/1) - end - - defp extract_labels(_), do: [] - - defp extract_blockers(%{"inverseRelations" => %{"nodes" => inverse_relations}}) - when is_list(inverse_relations) do - inverse_relations - |> Enum.flat_map(fn - %{"type" => relation_type, "issue" => blocker_issue} - when is_binary(relation_type) and is_map(blocker_issue) -> - if String.downcase(String.trim(relation_type)) == "blocks" do - [ - %{ - id: blocker_issue["id"], - identifier: blocker_issue["identifier"], - state: get_in(blocker_issue, ["state", "name"]) - } - ] - else - [] - end - - _ -> - [] - end) - end - - defp extract_blockers(_), do: [] - - defp parse_datetime(nil), do: nil - - defp parse_datetime(raw) do - case DateTime.from_iso8601(raw) do - {:ok, dt, _offset} -> dt - _ -> nil - end - end - - defp parse_priority(priority) when is_integer(priority), do: priority - defp parse_priority(_priority), do: nil -end diff --git a/elixir/lib/symphony_elixir/linear/issue.ex b/elixir/lib/symphony_elixir/linear/issue.ex deleted file mode 100644 index 7ed94760b6..0000000000 --- a/elixir/lib/symphony_elixir/linear/issue.ex +++ /dev/null @@ -1,43 +0,0 @@ -defmodule SymphonyElixir.Linear.Issue do - @moduledoc """ - Normalized Linear issue representation used by the orchestrator. - """ - - defstruct [ - :id, - :identifier, - :title, - :description, - :priority, - :state, - :branch_name, - :url, - :assignee_id, - blocked_by: [], - labels: [], - assigned_to_worker: true, - created_at: nil, - updated_at: nil - ] - - @type t :: %__MODULE__{ - id: String.t() | nil, - identifier: String.t() | nil, - title: String.t() | nil, - description: String.t() | nil, - priority: integer() | nil, - state: String.t() | nil, - branch_name: String.t() | nil, - url: String.t() | nil, - assignee_id: String.t() | nil, - labels: [String.t()], - assigned_to_worker: boolean(), - created_at: DateTime.t() | nil, - updated_at: DateTime.t() | nil - } - - @spec label_names(t()) :: [String.t()] - def label_names(%__MODULE__{labels: labels}) do - labels - end -end diff --git a/elixir/lib/symphony_elixir/log_file.ex b/elixir/lib/symphony_elixir/log_file.ex deleted file mode 100644 index ea3ad3f8c5..0000000000 --- a/elixir/lib/symphony_elixir/log_file.ex +++ /dev/null @@ -1,80 +0,0 @@ -defmodule SymphonyElixir.LogFile do - @moduledoc """ - Configures OTP's built-in rotating disk log handler for application logs. - """ - - require Logger - - @handler_id :symphony_disk_log - @default_log_relative_path "log/symphony.log" - @default_max_bytes 10 * 1024 * 1024 - @default_max_files 5 - - @spec default_log_file() :: Path.t() - def default_log_file do - default_log_file(File.cwd!()) - end - - @spec default_log_file(Path.t()) :: Path.t() - def default_log_file(logs_root) when is_binary(logs_root) do - Path.join(logs_root, @default_log_relative_path) - end - - @spec configure() :: :ok - def configure do - log_file = Application.get_env(:symphony_elixir, :log_file, default_log_file()) - max_bytes = Application.get_env(:symphony_elixir, :log_file_max_bytes, @default_max_bytes) - max_files = Application.get_env(:symphony_elixir, :log_file_max_files, @default_max_files) - - setup_disk_handler(log_file, max_bytes, max_files) - end - - defp setup_disk_handler(log_file, max_bytes, max_files) do - expanded_path = Path.expand(log_file) - :ok = File.mkdir_p(Path.dirname(expanded_path)) - :ok = remove_existing_handler() - - case :logger.add_handler( - @handler_id, - :logger_disk_log_h, - disk_log_handler_config(expanded_path, max_bytes, max_files) - ) do - :ok -> - remove_default_console_handler() - :ok - - {:error, reason} -> - Logger.warning("Failed to configure rotating log file handler: #{inspect(reason)}") - :ok - end - end - - defp remove_existing_handler do - case :logger.remove_handler(@handler_id) do - :ok -> :ok - {:error, {:not_found, @handler_id}} -> :ok - {:error, _reason} -> :ok - end - end - - defp remove_default_console_handler do - case :logger.remove_handler(:default) do - :ok -> :ok - {:error, {:not_found, :default}} -> :ok - {:error, _reason} -> :ok - end - end - - defp disk_log_handler_config(path, max_bytes, max_files) do - %{ - level: :all, - formatter: {:logger_formatter, %{single_line: true}}, - config: %{ - file: String.to_charlist(path), - type: :wrap, - max_no_bytes: max_bytes, - max_no_files: max_files - } - } - end -end diff --git a/elixir/lib/symphony_elixir/orchestrator.ex b/elixir/lib/symphony_elixir/orchestrator.ex deleted file mode 100644 index a4dead129e..0000000000 --- a/elixir/lib/symphony_elixir/orchestrator.ex +++ /dev/null @@ -1,1457 +0,0 @@ -defmodule SymphonyElixir.Orchestrator do - @moduledoc """ - Polls Linear and dispatches repository copies to Codex-backed workers. - """ - - use GenServer - require Logger - import Bitwise, only: [<<<: 2] - - alias SymphonyElixir.{AgentRunner, Config, StatusDashboard, Tracker, Workspace} - alias SymphonyElixir.Linear.Issue - - @continuation_retry_delay_ms 1_000 - @failure_retry_base_ms 10_000 - # Slightly above the dashboard render interval so "checking now…" can render. - @poll_transition_render_delay_ms 20 - @empty_codex_totals %{ - input_tokens: 0, - output_tokens: 0, - total_tokens: 0, - seconds_running: 0 - } - - defmodule State do - @moduledoc """ - Runtime state for the orchestrator polling loop. - """ - - defstruct [ - :poll_interval_ms, - :max_concurrent_agents, - :next_poll_due_at_ms, - :poll_check_in_progress, - running: %{}, - completed: MapSet.new(), - claimed: MapSet.new(), - retry_attempts: %{}, - codex_totals: nil, - codex_rate_limits: nil - ] - end - - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @impl true - def init(_opts) do - now_ms = System.monotonic_time(:millisecond) - - state = %State{ - poll_interval_ms: Config.poll_interval_ms(), - max_concurrent_agents: Config.max_concurrent_agents(), - next_poll_due_at_ms: now_ms, - poll_check_in_progress: false, - codex_totals: @empty_codex_totals, - codex_rate_limits: nil - } - - run_terminal_workspace_cleanup() - :ok = schedule_tick(0) - - {:ok, state} - end - - @impl true - def handle_info(:tick, state) do - state = refresh_runtime_config(state) - state = %{state | poll_check_in_progress: true, next_poll_due_at_ms: nil} - - notify_dashboard() - :ok = schedule_poll_cycle_start() - {:noreply, state} - end - - def handle_info(:run_poll_cycle, state) do - state = refresh_runtime_config(state) - state = maybe_dispatch(state) - now_ms = System.monotonic_time(:millisecond) - next_poll_due_at_ms = now_ms + state.poll_interval_ms - :ok = schedule_tick(state.poll_interval_ms) - - state = %{state | poll_check_in_progress: false, next_poll_due_at_ms: next_poll_due_at_ms} - - notify_dashboard() - {:noreply, state} - end - - def handle_info( - {:DOWN, ref, :process, _pid, reason}, - %{running: running} = state - ) do - case find_issue_id_for_ref(running, ref) do - nil -> - {:noreply, state} - - issue_id -> - {running_entry, state} = pop_running_entry(state, issue_id) - state = record_session_completion_totals(state, running_entry) - session_id = running_entry_session_id(running_entry) - - state = - case reason do - :normal -> - Logger.info("Agent task completed for issue_id=#{issue_id} session_id=#{session_id}; scheduling active-state continuation check") - - state - |> complete_issue(issue_id) - |> schedule_issue_retry(issue_id, 1, %{ - identifier: running_entry.identifier, - delay_type: :continuation - }) - - _ -> - Logger.warning("Agent task exited for issue_id=#{issue_id} session_id=#{session_id} reason=#{inspect(reason)}; scheduling retry") - - next_attempt = next_retry_attempt_from_running(running_entry) - - schedule_issue_retry(state, issue_id, next_attempt, %{ - identifier: running_entry.identifier, - error: "agent exited: #{inspect(reason)}" - }) - end - - Logger.info("Agent task finished for issue_id=#{issue_id} session_id=#{session_id} reason=#{inspect(reason)}") - - notify_dashboard() - {:noreply, state} - end - end - - def handle_info( - {:codex_worker_update, issue_id, %{event: _, timestamp: _} = update}, - %{running: running} = state - ) do - case Map.get(running, issue_id) do - nil -> - {:noreply, state} - - running_entry -> - {updated_running_entry, token_delta} = integrate_codex_update(running_entry, update) - - state = - state - |> apply_codex_token_delta(token_delta) - |> apply_codex_rate_limits(update) - - notify_dashboard() - {:noreply, %{state | running: Map.put(running, issue_id, updated_running_entry)}} - end - end - - def handle_info({:codex_worker_update, _issue_id, _update}, state), do: {:noreply, state} - - def handle_info({:retry_issue, issue_id}, state) do - result = - case pop_retry_attempt_state(state, issue_id) do - {:ok, attempt, metadata, state} -> handle_retry_issue(state, issue_id, attempt, metadata) - :missing -> {:noreply, state} - end - - notify_dashboard() - result - end - - def handle_info(msg, state) do - Logger.debug("Orchestrator ignored message: #{inspect(msg)}") - {:noreply, state} - end - - defp maybe_dispatch(%State{} = state) do - state = reconcile_running_issues(state) - - with :ok <- Config.validate!(), - {:ok, issues} <- Tracker.fetch_candidate_issues(), - true <- available_slots(state) > 0 do - choose_issues(issues, state) - else - {:error, :missing_linear_api_token} -> - Logger.error("Linear API token missing in WORKFLOW.md") - state - - {:error, :missing_linear_project_slug} -> - Logger.error("Linear project slug missing in WORKFLOW.md") - state - - {:error, :missing_tracker_kind} -> - Logger.error("Tracker kind missing in WORKFLOW.md") - - state - - {:error, {:unsupported_tracker_kind, kind}} -> - Logger.error("Unsupported tracker kind in WORKFLOW.md: #{inspect(kind)}") - - state - - {:error, :missing_codex_command} -> - Logger.error("Codex command missing in WORKFLOW.md") - state - - {:error, {:invalid_codex_approval_policy, value}} -> - Logger.error("Invalid codex.approval_policy in WORKFLOW.md: #{inspect(value)}") - state - - {:error, {:invalid_codex_thread_sandbox, value}} -> - Logger.error("Invalid codex.thread_sandbox in WORKFLOW.md: #{inspect(value)}") - state - - {:error, {:invalid_codex_turn_sandbox_policy, reason}} -> - Logger.error("Invalid codex.turn_sandbox_policy in WORKFLOW.md: #{inspect(reason)}") - state - - {:error, {:missing_workflow_file, path, reason}} -> - Logger.error("Missing WORKFLOW.md at #{path}: #{inspect(reason)}") - state - - {:error, :workflow_front_matter_not_a_map} -> - Logger.error("Failed to parse WORKFLOW.md: workflow front matter must decode to a map") - state - - {:error, {:workflow_parse_error, reason}} -> - Logger.error("Failed to parse WORKFLOW.md: #{inspect(reason)}") - state - - {:error, reason} -> - Logger.error("Failed to fetch from Linear: #{inspect(reason)}") - state - - false -> - state - end - end - - defp reconcile_running_issues(%State{} = state) do - state = reconcile_stalled_running_issues(state) - running_ids = Map.keys(state.running) - - if running_ids == [] do - state - else - case Tracker.fetch_issue_states_by_ids(running_ids) do - {:ok, issues} -> - reconcile_running_issue_states( - issues, - state, - active_state_set(), - terminal_state_set() - ) - - {:error, reason} -> - Logger.debug("Failed to refresh running issue states: #{inspect(reason)}; keeping active workers") - - state - end - end - end - - @doc false - @spec reconcile_issue_states_for_test([Issue.t()], term()) :: term() - def reconcile_issue_states_for_test(issues, %State{} = state) when is_list(issues) do - reconcile_running_issue_states(issues, state, active_state_set(), terminal_state_set()) - end - - def reconcile_issue_states_for_test(issues, state) when is_list(issues) do - reconcile_running_issue_states(issues, state, active_state_set(), terminal_state_set()) - end - - @doc false - @spec should_dispatch_issue_for_test(Issue.t(), term()) :: boolean() - def should_dispatch_issue_for_test(%Issue{} = issue, %State{} = state) do - should_dispatch_issue?(issue, state, active_state_set(), terminal_state_set()) - end - - @doc false - @spec revalidate_issue_for_dispatch_for_test(Issue.t(), ([String.t()] -> term())) :: - {:ok, Issue.t()} | {:skip, Issue.t() | :missing} | {:error, term()} - def revalidate_issue_for_dispatch_for_test(%Issue{} = issue, issue_fetcher) - when is_function(issue_fetcher, 1) do - revalidate_issue_for_dispatch(issue, issue_fetcher, terminal_state_set()) - end - - @doc false - @spec sort_issues_for_dispatch_for_test([Issue.t()]) :: [Issue.t()] - def sort_issues_for_dispatch_for_test(issues) when is_list(issues) do - sort_issues_for_dispatch(issues) - end - - defp reconcile_running_issue_states([], state, _active_states, _terminal_states), do: state - - defp reconcile_running_issue_states([issue | rest], state, active_states, terminal_states) do - reconcile_running_issue_states( - rest, - reconcile_issue_state(issue, state, active_states, terminal_states), - active_states, - terminal_states - ) - end - - defp reconcile_issue_state(%Issue{} = issue, state, active_states, terminal_states) do - cond do - terminal_issue_state?(issue.state, terminal_states) -> - Logger.info("Issue moved to terminal state: #{issue_context(issue)} state=#{issue.state}; stopping active agent") - - terminate_running_issue(state, issue.id, true) - - !issue_routable_to_worker?(issue) -> - Logger.info("Issue no longer routed to this worker: #{issue_context(issue)} assignee=#{inspect(issue.assignee_id)}; stopping active agent") - - terminate_running_issue(state, issue.id, false) - - active_issue_state?(issue.state, active_states) -> - refresh_running_issue_state(state, issue) - - true -> - Logger.info("Issue moved to non-active state: #{issue_context(issue)} state=#{issue.state}; stopping active agent") - - terminate_running_issue(state, issue.id, false) - end - end - - defp reconcile_issue_state(_issue, state, _active_states, _terminal_states), do: state - - defp refresh_running_issue_state(%State{} = state, %Issue{} = issue) do - case Map.get(state.running, issue.id) do - %{issue: _} = running_entry -> - %{state | running: Map.put(state.running, issue.id, %{running_entry | issue: issue})} - - _ -> - state - end - end - - defp terminate_running_issue(%State{} = state, issue_id, cleanup_workspace) do - case Map.get(state.running, issue_id) do - nil -> - release_issue_claim(state, issue_id) - - %{pid: pid, ref: ref, identifier: identifier} = running_entry -> - state = record_session_completion_totals(state, running_entry) - - if cleanup_workspace do - cleanup_issue_workspace(identifier) - end - - if is_pid(pid) do - terminate_task(pid) - end - - if is_reference(ref) do - Process.demonitor(ref, [:flush]) - end - - %{ - state - | running: Map.delete(state.running, issue_id), - claimed: MapSet.delete(state.claimed, issue_id), - retry_attempts: Map.delete(state.retry_attempts, issue_id) - } - - _ -> - release_issue_claim(state, issue_id) - end - end - - defp reconcile_stalled_running_issues(%State{} = state) do - timeout_ms = Config.codex_stall_timeout_ms() - - cond do - timeout_ms <= 0 -> - state - - map_size(state.running) == 0 -> - state - - true -> - now = DateTime.utc_now() - - Enum.reduce(state.running, state, fn {issue_id, running_entry}, state_acc -> - restart_stalled_issue(state_acc, issue_id, running_entry, now, timeout_ms) - end) - end - end - - defp restart_stalled_issue(state, issue_id, running_entry, now, timeout_ms) do - elapsed_ms = stall_elapsed_ms(running_entry, now) - - if is_integer(elapsed_ms) and elapsed_ms > timeout_ms do - identifier = Map.get(running_entry, :identifier, issue_id) - session_id = running_entry_session_id(running_entry) - - Logger.warning("Issue stalled: issue_id=#{issue_id} issue_identifier=#{identifier} session_id=#{session_id} elapsed_ms=#{elapsed_ms}; restarting with backoff") - - next_attempt = next_retry_attempt_from_running(running_entry) - - state - |> terminate_running_issue(issue_id, false) - |> schedule_issue_retry(issue_id, next_attempt, %{ - identifier: identifier, - error: "stalled for #{elapsed_ms}ms without codex activity" - }) - else - state - end - end - - defp stall_elapsed_ms(running_entry, now) do - running_entry - |> last_activity_timestamp() - |> case do - %DateTime{} = timestamp -> - max(0, DateTime.diff(now, timestamp, :millisecond)) - - _ -> - nil - end - end - - defp last_activity_timestamp(running_entry) when is_map(running_entry) do - Map.get(running_entry, :last_codex_timestamp) || Map.get(running_entry, :started_at) - end - - defp last_activity_timestamp(_running_entry), do: nil - - defp terminate_task(pid) when is_pid(pid) do - case Task.Supervisor.terminate_child(SymphonyElixir.TaskSupervisor, pid) do - :ok -> - :ok - - {:error, :not_found} -> - Process.exit(pid, :shutdown) - end - end - - defp terminate_task(_pid), do: :ok - - defp choose_issues(issues, state) do - active_states = active_state_set() - terminal_states = terminal_state_set() - - issues - |> sort_issues_for_dispatch() - |> Enum.reduce(state, fn issue, state_acc -> - if should_dispatch_issue?(issue, state_acc, active_states, terminal_states) do - dispatch_issue(state_acc, issue) - else - state_acc - end - end) - end - - defp sort_issues_for_dispatch(issues) when is_list(issues) do - Enum.sort_by(issues, fn - %Issue{} = issue -> - {priority_rank(issue.priority), issue_created_at_sort_key(issue), issue.identifier || issue.id || ""} - - _ -> - {priority_rank(nil), issue_created_at_sort_key(nil), ""} - end) - end - - defp priority_rank(priority) when is_integer(priority) and priority in 1..4, do: priority - defp priority_rank(_priority), do: 5 - - defp issue_created_at_sort_key(%Issue{created_at: %DateTime{} = created_at}) do - DateTime.to_unix(created_at, :microsecond) - end - - defp issue_created_at_sort_key(%Issue{}), do: 9_223_372_036_854_775_807 - defp issue_created_at_sort_key(_issue), do: 9_223_372_036_854_775_807 - - defp should_dispatch_issue?( - %Issue{} = issue, - %State{running: running, claimed: claimed} = state, - active_states, - terminal_states - ) do - candidate_issue?(issue, active_states, terminal_states) and - !todo_issue_blocked_by_non_terminal?(issue, terminal_states) and - !MapSet.member?(claimed, issue.id) and - !Map.has_key?(running, issue.id) and - available_slots(state) > 0 and - state_slots_available?(issue, running) - end - - defp should_dispatch_issue?(_issue, _state, _active_states, _terminal_states), do: false - - defp state_slots_available?(%Issue{state: issue_state}, running) when is_map(running) do - limit = Config.max_concurrent_agents_for_state(issue_state) - used = running_issue_count_for_state(running, issue_state) - limit > used - end - - defp state_slots_available?(_issue, _running), do: false - - defp running_issue_count_for_state(running, issue_state) when is_map(running) do - normalized_state = normalize_issue_state(issue_state) - - Enum.count(running, fn - {_id, %{issue: %Issue{state: state_name}}} -> - normalize_issue_state(state_name) == normalized_state - - _ -> - false - end) - end - - defp candidate_issue?( - %Issue{ - id: id, - identifier: identifier, - title: title, - state: state_name - } = issue, - active_states, - terminal_states - ) - when is_binary(id) and is_binary(identifier) and is_binary(title) and is_binary(state_name) do - issue_routable_to_worker?(issue) and - active_issue_state?(state_name, active_states) and - !terminal_issue_state?(state_name, terminal_states) - end - - defp candidate_issue?(_issue, _active_states, _terminal_states), do: false - - defp issue_routable_to_worker?(%Issue{assigned_to_worker: assigned_to_worker}) - when is_boolean(assigned_to_worker), - do: assigned_to_worker - - defp issue_routable_to_worker?(_issue), do: true - - defp todo_issue_blocked_by_non_terminal?( - %Issue{state: issue_state, blocked_by: blockers}, - terminal_states - ) - when is_binary(issue_state) and is_list(blockers) do - normalize_issue_state(issue_state) == "todo" and - Enum.any?(blockers, fn - %{state: blocker_state} when is_binary(blocker_state) -> - !terminal_issue_state?(blocker_state, terminal_states) - - _ -> - true - end) - end - - defp todo_issue_blocked_by_non_terminal?(_issue, _terminal_states), do: false - - defp terminal_issue_state?(state_name, terminal_states) when is_binary(state_name) do - MapSet.member?(terminal_states, normalize_issue_state(state_name)) - end - - defp terminal_issue_state?(_state_name, _terminal_states), do: false - - defp active_issue_state?(state_name, active_states) when is_binary(state_name) do - MapSet.member?(active_states, normalize_issue_state(state_name)) - end - - defp normalize_issue_state(state_name) when is_binary(state_name) do - String.downcase(String.trim(state_name)) - end - - defp terminal_state_set do - Config.linear_terminal_states() - |> Enum.map(&normalize_issue_state/1) - |> Enum.filter(&(&1 != "")) - |> MapSet.new() - end - - defp active_state_set do - Config.linear_active_states() - |> Enum.map(&normalize_issue_state/1) - |> Enum.filter(&(&1 != "")) - |> MapSet.new() - end - - defp dispatch_issue(%State{} = state, issue, attempt \\ nil) do - case revalidate_issue_for_dispatch(issue, &Tracker.fetch_issue_states_by_ids/1, terminal_state_set()) do - {:ok, %Issue{} = refreshed_issue} -> - do_dispatch_issue(state, refreshed_issue, attempt) - - {:skip, :missing} -> - Logger.info("Skipping dispatch; issue no longer active or visible: #{issue_context(issue)}") - state - - {:skip, %Issue{} = refreshed_issue} -> - Logger.info("Skipping stale dispatch after issue refresh: #{issue_context(refreshed_issue)} state=#{inspect(refreshed_issue.state)} blocked_by=#{length(refreshed_issue.blocked_by)}") - - state - - {:error, reason} -> - Logger.warning("Skipping dispatch; issue refresh failed for #{issue_context(issue)}: #{inspect(reason)}") - state - end - end - - defp do_dispatch_issue(%State{} = state, issue, attempt) do - recipient = self() - - case Task.Supervisor.start_child(SymphonyElixir.TaskSupervisor, fn -> - AgentRunner.run(issue, recipient, attempt: attempt) - end) do - {:ok, pid} -> - ref = Process.monitor(pid) - - Logger.info("Dispatching issue to agent: #{issue_context(issue)} pid=#{inspect(pid)} attempt=#{inspect(attempt)}") - - running = - Map.put(state.running, issue.id, %{ - pid: pid, - ref: ref, - identifier: issue.identifier, - issue: issue, - session_id: nil, - last_codex_message: nil, - last_codex_timestamp: nil, - last_codex_event: nil, - codex_app_server_pid: nil, - codex_input_tokens: 0, - codex_output_tokens: 0, - codex_total_tokens: 0, - codex_last_reported_input_tokens: 0, - codex_last_reported_output_tokens: 0, - codex_last_reported_total_tokens: 0, - turn_count: 0, - retry_attempt: normalize_retry_attempt(attempt), - started_at: DateTime.utc_now() - }) - - %{ - state - | running: running, - claimed: MapSet.put(state.claimed, issue.id), - retry_attempts: Map.delete(state.retry_attempts, issue.id) - } - - {:error, reason} -> - Logger.error("Unable to spawn agent for #{issue_context(issue)}: #{inspect(reason)}") - next_attempt = if is_integer(attempt), do: attempt + 1, else: nil - - schedule_issue_retry(state, issue.id, next_attempt, %{ - identifier: issue.identifier, - error: "failed to spawn agent: #{inspect(reason)}" - }) - end - end - - defp revalidate_issue_for_dispatch(%Issue{id: issue_id}, issue_fetcher, terminal_states) - when is_binary(issue_id) and is_function(issue_fetcher, 1) do - case issue_fetcher.([issue_id]) do - {:ok, [%Issue{} = refreshed_issue | _]} -> - if retry_candidate_issue?(refreshed_issue, terminal_states) do - {:ok, refreshed_issue} - else - {:skip, refreshed_issue} - end - - {:ok, []} -> - {:skip, :missing} - - {:error, reason} -> - {:error, reason} - end - end - - defp revalidate_issue_for_dispatch(issue, _issue_fetcher, _terminal_states), do: {:ok, issue} - - defp complete_issue(%State{} = state, issue_id) do - %{ - state - | completed: MapSet.put(state.completed, issue_id), - retry_attempts: Map.delete(state.retry_attempts, issue_id) - } - end - - defp schedule_issue_retry(%State{} = state, issue_id, attempt, metadata) - when is_binary(issue_id) and is_map(metadata) do - previous_retry = Map.get(state.retry_attempts, issue_id, %{attempt: 0}) - next_attempt = if is_integer(attempt), do: attempt, else: previous_retry.attempt + 1 - delay_ms = retry_delay(next_attempt, metadata) - old_timer = Map.get(previous_retry, :timer_ref) - due_at_ms = System.monotonic_time(:millisecond) + delay_ms - identifier = pick_retry_identifier(issue_id, previous_retry, metadata) - error = pick_retry_error(previous_retry, metadata) - - if is_reference(old_timer) do - Process.cancel_timer(old_timer) - end - - timer_ref = Process.send_after(self(), {:retry_issue, issue_id}, delay_ms) - - error_suffix = if is_binary(error), do: " error=#{error}", else: "" - - Logger.warning("Retrying issue_id=#{issue_id} issue_identifier=#{identifier} in #{delay_ms}ms (attempt #{next_attempt})#{error_suffix}") - - %{ - state - | retry_attempts: - Map.put(state.retry_attempts, issue_id, %{ - attempt: next_attempt, - timer_ref: timer_ref, - due_at_ms: due_at_ms, - identifier: identifier, - error: error - }) - } - end - - defp pop_retry_attempt_state(%State{} = state, issue_id) do - case Map.get(state.retry_attempts, issue_id) do - %{attempt: attempt} = retry_entry -> - metadata = %{ - identifier: Map.get(retry_entry, :identifier), - error: Map.get(retry_entry, :error) - } - - {:ok, attempt, metadata, %{state | retry_attempts: Map.delete(state.retry_attempts, issue_id)}} - - _ -> - :missing - end - end - - defp handle_retry_issue(%State{} = state, issue_id, attempt, metadata) do - case Tracker.fetch_candidate_issues() do - {:ok, issues} -> - issues - |> find_issue_by_id(issue_id) - |> handle_retry_issue_lookup(state, issue_id, attempt, metadata) - - {:error, reason} -> - Logger.warning("Retry poll failed for issue_id=#{issue_id} issue_identifier=#{metadata[:identifier] || issue_id}: #{inspect(reason)}") - - {:noreply, - schedule_issue_retry( - state, - issue_id, - attempt + 1, - Map.merge(metadata, %{error: "retry poll failed: #{inspect(reason)}"}) - )} - end - end - - defp handle_retry_issue_lookup(%Issue{} = issue, state, issue_id, attempt, metadata) do - terminal_states = terminal_state_set() - - cond do - terminal_issue_state?(issue.state, terminal_states) -> - Logger.info("Issue state is terminal: issue_id=#{issue_id} issue_identifier=#{issue.identifier} state=#{issue.state}; removing associated workspace") - - cleanup_issue_workspace(issue.identifier) - {:noreply, release_issue_claim(state, issue_id)} - - retry_candidate_issue?(issue, terminal_states) -> - handle_active_retry(state, issue, attempt, metadata) - - true -> - Logger.debug("Issue left active states, removing claim issue_id=#{issue_id} issue_identifier=#{issue.identifier}") - - {:noreply, release_issue_claim(state, issue_id)} - end - end - - defp handle_retry_issue_lookup(nil, state, issue_id, _attempt, _metadata) do - Logger.debug("Issue no longer visible, removing claim issue_id=#{issue_id}") - {:noreply, release_issue_claim(state, issue_id)} - end - - defp cleanup_issue_workspace(identifier) when is_binary(identifier) do - Workspace.remove_issue_workspaces(identifier) - end - - defp cleanup_issue_workspace(_identifier), do: :ok - - defp run_terminal_workspace_cleanup do - case Tracker.fetch_issues_by_states(Config.linear_terminal_states()) do - {:ok, issues} -> - issues - |> Enum.each(fn - %Issue{identifier: identifier} when is_binary(identifier) -> - cleanup_issue_workspace(identifier) - - _ -> - :ok - end) - - {:error, reason} -> - Logger.warning("Skipping startup terminal workspace cleanup; failed to fetch terminal issues: #{inspect(reason)}") - end - end - - defp notify_dashboard do - StatusDashboard.notify_update() - end - - defp handle_active_retry(state, issue, attempt, metadata) do - if retry_candidate_issue?(issue, terminal_state_set()) and - dispatch_slots_available?(issue, state) do - {:noreply, dispatch_issue(state, issue, attempt)} - else - Logger.debug("No available slots for retrying #{issue_context(issue)}; retrying again") - - {:noreply, - schedule_issue_retry( - state, - issue.id, - attempt + 1, - Map.merge(metadata, %{ - identifier: issue.identifier, - error: "no available orchestrator slots" - }) - )} - end - end - - defp release_issue_claim(%State{} = state, issue_id) do - %{state | claimed: MapSet.delete(state.claimed, issue_id)} - end - - defp retry_delay(attempt, metadata) when is_integer(attempt) and attempt > 0 and is_map(metadata) do - if metadata[:delay_type] == :continuation and attempt == 1 do - @continuation_retry_delay_ms - else - failure_retry_delay(attempt) - end - end - - defp failure_retry_delay(attempt) do - max_delay_power = min(attempt - 1, 10) - min(@failure_retry_base_ms * (1 <<< max_delay_power), Config.max_retry_backoff_ms()) - end - - defp normalize_retry_attempt(attempt) when is_integer(attempt) and attempt > 0, do: attempt - defp normalize_retry_attempt(_attempt), do: 0 - - defp next_retry_attempt_from_running(running_entry) do - case Map.get(running_entry, :retry_attempt) do - attempt when is_integer(attempt) and attempt > 0 -> attempt + 1 - _ -> nil - end - end - - defp pick_retry_identifier(issue_id, previous_retry, metadata) do - metadata[:identifier] || Map.get(previous_retry, :identifier) || issue_id - end - - defp pick_retry_error(previous_retry, metadata) do - metadata[:error] || Map.get(previous_retry, :error) - end - - defp find_issue_by_id(issues, issue_id) when is_binary(issue_id) do - Enum.find(issues, fn - %Issue{id: ^issue_id} -> - true - - _ -> - false - end) - end - - defp find_issue_id_for_ref(running, ref) do - running - |> Enum.find_value(fn {issue_id, %{ref: running_ref}} -> - if running_ref == ref, do: issue_id - end) - end - - defp running_entry_session_id(%{session_id: session_id}) when is_binary(session_id), - do: session_id - - defp running_entry_session_id(_running_entry), do: "n/a" - - defp issue_context(%Issue{id: issue_id, identifier: identifier}) do - "issue_id=#{issue_id} issue_identifier=#{identifier}" - end - - defp available_slots(%State{} = state) do - max( - (state.max_concurrent_agents || Config.max_concurrent_agents()) - map_size(state.running), - 0 - ) - end - - @spec request_refresh() :: map() | :unavailable - def request_refresh do - request_refresh(__MODULE__) - end - - @spec request_refresh(GenServer.server()) :: map() | :unavailable - def request_refresh(server) do - if Process.whereis(server) do - GenServer.call(server, :request_refresh) - else - :unavailable - end - end - - @spec snapshot() :: map() | :timeout | :unavailable - def snapshot, do: snapshot(__MODULE__, 15_000) - - @spec snapshot(GenServer.server(), timeout()) :: map() | :timeout | :unavailable - def snapshot(server, timeout) do - if Process.whereis(server) do - try do - GenServer.call(server, :snapshot, timeout) - catch - :exit, {:timeout, _} -> :timeout - :exit, _ -> :unavailable - end - else - :unavailable - end - end - - @impl true - def handle_call(:snapshot, _from, state) do - state = refresh_runtime_config(state) - now = DateTime.utc_now() - now_ms = System.monotonic_time(:millisecond) - - running = - state.running - |> Enum.map(fn {issue_id, metadata} -> - %{ - issue_id: issue_id, - identifier: metadata.identifier, - state: metadata.issue.state, - session_id: metadata.session_id, - codex_app_server_pid: metadata.codex_app_server_pid, - codex_input_tokens: metadata.codex_input_tokens, - codex_output_tokens: metadata.codex_output_tokens, - codex_total_tokens: metadata.codex_total_tokens, - turn_count: Map.get(metadata, :turn_count, 0), - started_at: metadata.started_at, - last_codex_timestamp: metadata.last_codex_timestamp, - last_codex_message: metadata.last_codex_message, - last_codex_event: metadata.last_codex_event, - runtime_seconds: running_seconds(metadata.started_at, now) - } - end) - - retrying = - state.retry_attempts - |> Enum.map(fn {issue_id, %{attempt: attempt, due_at_ms: due_at_ms} = retry} -> - %{ - issue_id: issue_id, - attempt: attempt, - due_in_ms: max(0, due_at_ms - now_ms), - identifier: Map.get(retry, :identifier), - error: Map.get(retry, :error) - } - end) - - {:reply, - %{ - running: running, - retrying: retrying, - codex_totals: state.codex_totals, - rate_limits: Map.get(state, :codex_rate_limits), - polling: %{ - checking?: state.poll_check_in_progress == true, - next_poll_in_ms: next_poll_in_ms(state.next_poll_due_at_ms, now_ms), - poll_interval_ms: state.poll_interval_ms - } - }, state} - end - - def handle_call(:request_refresh, _from, state) do - now_ms = System.monotonic_time(:millisecond) - already_due? = is_integer(state.next_poll_due_at_ms) and state.next_poll_due_at_ms <= now_ms - coalesced = state.poll_check_in_progress == true or already_due? - - unless coalesced do - :ok = schedule_tick(0) - end - - {:reply, - %{ - queued: true, - coalesced: coalesced, - requested_at: DateTime.utc_now(), - operations: ["poll", "reconcile"] - }, state} - end - - defp integrate_codex_update(running_entry, %{event: event, timestamp: timestamp} = update) do - token_delta = extract_token_delta(running_entry, update) - codex_input_tokens = Map.get(running_entry, :codex_input_tokens, 0) - codex_output_tokens = Map.get(running_entry, :codex_output_tokens, 0) - codex_total_tokens = Map.get(running_entry, :codex_total_tokens, 0) - codex_app_server_pid = Map.get(running_entry, :codex_app_server_pid) - last_reported_input = Map.get(running_entry, :codex_last_reported_input_tokens, 0) - last_reported_output = Map.get(running_entry, :codex_last_reported_output_tokens, 0) - last_reported_total = Map.get(running_entry, :codex_last_reported_total_tokens, 0) - turn_count = Map.get(running_entry, :turn_count, 0) - - { - Map.merge(running_entry, %{ - last_codex_timestamp: timestamp, - last_codex_message: summarize_codex_update(update), - session_id: session_id_for_update(running_entry.session_id, update), - last_codex_event: event, - codex_app_server_pid: codex_app_server_pid_for_update(codex_app_server_pid, update), - codex_input_tokens: codex_input_tokens + token_delta.input_tokens, - codex_output_tokens: codex_output_tokens + token_delta.output_tokens, - codex_total_tokens: codex_total_tokens + token_delta.total_tokens, - codex_last_reported_input_tokens: max(last_reported_input, token_delta.input_reported), - codex_last_reported_output_tokens: max(last_reported_output, token_delta.output_reported), - codex_last_reported_total_tokens: max(last_reported_total, token_delta.total_reported), - turn_count: turn_count_for_update(turn_count, running_entry.session_id, update) - }), - token_delta - } - end - - defp codex_app_server_pid_for_update(_existing, %{codex_app_server_pid: pid}) - when is_binary(pid), - do: pid - - defp codex_app_server_pid_for_update(_existing, %{codex_app_server_pid: pid}) - when is_integer(pid), - do: Integer.to_string(pid) - - defp codex_app_server_pid_for_update(_existing, %{codex_app_server_pid: pid}) when is_list(pid), - do: to_string(pid) - - defp codex_app_server_pid_for_update(existing, _update), do: existing - - defp session_id_for_update(_existing, %{session_id: session_id}) when is_binary(session_id), - do: session_id - - defp session_id_for_update(existing, _update), do: existing - - defp turn_count_for_update(existing_count, existing_session_id, %{ - event: :session_started, - session_id: session_id - }) - when is_integer(existing_count) and is_binary(session_id) do - if session_id == existing_session_id do - existing_count - else - existing_count + 1 - end - end - - defp turn_count_for_update(existing_count, _existing_session_id, _update) - when is_integer(existing_count), - do: existing_count - - defp turn_count_for_update(_existing_count, _existing_session_id, _update), do: 0 - - defp summarize_codex_update(update) do - %{ - event: update[:event], - message: update[:payload] || update[:raw], - timestamp: update[:timestamp] - } - end - - defp schedule_tick(delay_ms) do - :timer.send_after(delay_ms, self(), :tick) - :ok - end - - defp schedule_poll_cycle_start do - :timer.send_after(@poll_transition_render_delay_ms, self(), :run_poll_cycle) - :ok - end - - defp next_poll_in_ms(nil, _now_ms), do: nil - - defp next_poll_in_ms(next_poll_due_at_ms, now_ms) when is_integer(next_poll_due_at_ms) do - max(0, next_poll_due_at_ms - now_ms) - end - - defp pop_running_entry(state, issue_id) do - {Map.get(state.running, issue_id), %{state | running: Map.delete(state.running, issue_id)}} - end - - defp record_session_completion_totals(state, running_entry) when is_map(running_entry) do - runtime_seconds = running_seconds(running_entry.started_at, DateTime.utc_now()) - - codex_totals = - apply_token_delta( - state.codex_totals, - %{ - input_tokens: 0, - output_tokens: 0, - total_tokens: 0, - seconds_running: runtime_seconds - } - ) - - %{state | codex_totals: codex_totals} - end - - defp record_session_completion_totals(state, _running_entry), do: state - - defp refresh_runtime_config(%State{} = state) do - %{ - state - | poll_interval_ms: Config.poll_interval_ms(), - max_concurrent_agents: Config.max_concurrent_agents() - } - end - - defp retry_candidate_issue?(%Issue{} = issue, terminal_states) do - candidate_issue?(issue, active_state_set(), terminal_states) and - !todo_issue_blocked_by_non_terminal?(issue, terminal_states) - end - - defp dispatch_slots_available?(%Issue{} = issue, %State{} = state) do - available_slots(state) > 0 and state_slots_available?(issue, state.running) - end - - defp apply_codex_token_delta( - %{codex_totals: codex_totals} = state, - %{input_tokens: input, output_tokens: output, total_tokens: total} = token_delta - ) - when is_integer(input) and is_integer(output) and is_integer(total) do - %{state | codex_totals: apply_token_delta(codex_totals, token_delta)} - end - - defp apply_codex_token_delta(state, _token_delta), do: state - - defp apply_codex_rate_limits(%State{} = state, update) when is_map(update) do - case extract_rate_limits(update) do - %{} = rate_limits -> - %{state | codex_rate_limits: rate_limits} - - _ -> - state - end - end - - defp apply_codex_rate_limits(state, _update), do: state - - defp apply_token_delta(codex_totals, token_delta) do - input_tokens = Map.get(codex_totals, :input_tokens, 0) + token_delta.input_tokens - output_tokens = Map.get(codex_totals, :output_tokens, 0) + token_delta.output_tokens - total_tokens = Map.get(codex_totals, :total_tokens, 0) + token_delta.total_tokens - - seconds_running = - Map.get(codex_totals, :seconds_running, 0) + Map.get(token_delta, :seconds_running, 0) - - %{ - input_tokens: max(0, input_tokens), - output_tokens: max(0, output_tokens), - total_tokens: max(0, total_tokens), - seconds_running: max(0, seconds_running) - } - end - - defp extract_token_delta(running_entry, %{event: _, timestamp: _} = update) do - running_entry = running_entry || %{} - usage = extract_token_usage(update) - - { - compute_token_delta( - running_entry, - :input, - usage, - :codex_last_reported_input_tokens - ), - compute_token_delta( - running_entry, - :output, - usage, - :codex_last_reported_output_tokens - ), - compute_token_delta( - running_entry, - :total, - usage, - :codex_last_reported_total_tokens - ) - } - |> Tuple.to_list() - |> then(fn [input, output, total] -> - %{ - input_tokens: input.delta, - output_tokens: output.delta, - total_tokens: total.delta, - input_reported: input.reported, - output_reported: output.reported, - total_reported: total.reported - } - end) - end - - defp compute_token_delta(running_entry, token_key, usage, reported_key) do - next_total = get_token_usage(usage, token_key) - prev_reported = Map.get(running_entry, reported_key, 0) - - delta = - if is_integer(next_total) and next_total >= prev_reported do - next_total - prev_reported - else - 0 - end - - %{ - delta: max(delta, 0), - reported: if(is_integer(next_total), do: next_total, else: prev_reported) - } - end - - defp extract_token_usage(update) do - payloads = [ - update[:usage], - Map.get(update, "usage"), - Map.get(update, :usage), - update[:payload], - Map.get(update, "payload"), - update - ] - - Enum.find_value(payloads, &absolute_token_usage_from_payload/1) || - Enum.find_value(payloads, &turn_completed_usage_from_payload/1) || - %{} - end - - defp extract_rate_limits(update) do - rate_limits_from_payload(update[:rate_limits]) || - rate_limits_from_payload(Map.get(update, "rate_limits")) || - rate_limits_from_payload(Map.get(update, :rate_limits)) || - rate_limits_from_payload(update[:payload]) || - rate_limits_from_payload(Map.get(update, "payload")) || - rate_limits_from_payload(update) - end - - defp absolute_token_usage_from_payload(payload) when is_map(payload) do - absolute_paths = [ - ["params", "msg", "payload", "info", "total_token_usage"], - [:params, :msg, :payload, :info, :total_token_usage], - ["params", "msg", "info", "total_token_usage"], - [:params, :msg, :info, :total_token_usage], - ["params", "tokenUsage", "total"], - [:params, :tokenUsage, :total], - ["tokenUsage", "total"], - [:tokenUsage, :total] - ] - - explicit_map_at_paths(payload, absolute_paths) - end - - defp absolute_token_usage_from_payload(_payload), do: nil - - defp turn_completed_usage_from_payload(payload) when is_map(payload) do - method = Map.get(payload, "method") || Map.get(payload, :method) - - if method in ["turn/completed", :turn_completed] do - direct = - Map.get(payload, "usage") || - Map.get(payload, :usage) || - map_at_path(payload, ["params", "usage"]) || - map_at_path(payload, [:params, :usage]) - - if is_map(direct) and integer_token_map?(direct), do: direct - end - end - - defp turn_completed_usage_from_payload(_payload), do: nil - - defp rate_limits_from_payload(payload) when is_map(payload) do - direct = Map.get(payload, "rate_limits") || Map.get(payload, :rate_limits) - - cond do - rate_limits_map?(direct) -> - direct - - rate_limits_map?(payload) -> - payload - - true -> - rate_limit_payloads(payload) - end - end - - defp rate_limits_from_payload(payload) when is_list(payload) do - rate_limit_payloads(payload) - end - - defp rate_limits_from_payload(_payload), do: nil - - defp rate_limit_payloads(payload) when is_map(payload) do - Map.values(payload) - |> Enum.reduce_while(nil, fn - value, nil -> - case rate_limits_from_payload(value) do - nil -> {:cont, nil} - rate_limits -> {:halt, rate_limits} - end - - _value, result -> - {:halt, result} - end) - end - - defp rate_limit_payloads(payload) when is_list(payload) do - payload - |> Enum.reduce_while(nil, fn - value, nil -> - case rate_limits_from_payload(value) do - nil -> {:cont, nil} - rate_limits -> {:halt, rate_limits} - end - - _value, result -> - {:halt, result} - end) - end - - defp rate_limits_map?(payload) when is_map(payload) do - limit_id = - Map.get(payload, "limit_id") || - Map.get(payload, :limit_id) || - Map.get(payload, "limit_name") || - Map.get(payload, :limit_name) - - has_buckets = - Enum.any?( - ["primary", :primary, "secondary", :secondary, "credits", :credits], - &Map.has_key?(payload, &1) - ) - - !is_nil(limit_id) and has_buckets - end - - defp rate_limits_map?(_payload), do: false - - defp explicit_map_at_paths(payload, paths) when is_map(payload) and is_list(paths) do - Enum.find_value(paths, fn path -> - value = map_at_path(payload, path) - - if is_map(value) and integer_token_map?(value), do: value - end) - end - - defp explicit_map_at_paths(_payload, _paths), do: nil - - defp map_at_path(payload, path) when is_map(payload) and is_list(path) do - Enum.reduce_while(path, payload, fn key, acc -> - if is_map(acc) and Map.has_key?(acc, key) do - {:cont, Map.get(acc, key)} - else - {:halt, nil} - end - end) - end - - defp map_at_path(_payload, _path), do: nil - - defp integer_token_map?(payload) do - token_fields = [ - :input_tokens, - :output_tokens, - :total_tokens, - :prompt_tokens, - :completion_tokens, - :inputTokens, - :outputTokens, - :totalTokens, - :promptTokens, - :completionTokens, - "input_tokens", - "output_tokens", - "total_tokens", - "prompt_tokens", - "completion_tokens", - "inputTokens", - "outputTokens", - "totalTokens", - "promptTokens", - "completionTokens" - ] - - token_fields - |> Enum.any?(fn field -> - value = payload_get(payload, field) - !is_nil(integer_like(value)) - end) - end - - defp get_token_usage(usage, :input), - do: - payload_get(usage, [ - "input_tokens", - "prompt_tokens", - :input_tokens, - :prompt_tokens, - :input, - "promptTokens", - :promptTokens, - "inputTokens", - :inputTokens - ]) - - defp get_token_usage(usage, :output), - do: - payload_get(usage, [ - "output_tokens", - "completion_tokens", - :output_tokens, - :completion_tokens, - :output, - :completion, - "outputTokens", - :outputTokens, - "completionTokens", - :completionTokens - ]) - - defp get_token_usage(usage, :total), - do: - payload_get(usage, [ - "total_tokens", - "total", - :total_tokens, - :total, - "totalTokens", - :totalTokens - ]) - - defp payload_get(payload, fields) when is_list(fields) do - Enum.find_value(fields, fn field -> map_integer_value(payload, field) end) - end - - defp payload_get(payload, field), do: map_integer_value(payload, field) - - defp map_integer_value(payload, field) do - if is_map(payload) do - value = Map.get(payload, field) - integer_like(value) - else - nil - end - end - - defp running_seconds(%DateTime{} = started_at, %DateTime{} = now) do - max(0, DateTime.diff(now, started_at, :second)) - end - - defp running_seconds(_started_at, _now), do: 0 - - defp integer_like(value) when is_integer(value) and value >= 0, do: value - - defp integer_like(value) when is_binary(value) do - case Integer.parse(String.trim(value)) do - {num, _} when num >= 0 -> num - _ -> nil - end - end - - defp integer_like(_value), do: nil -end diff --git a/elixir/lib/symphony_elixir/prompt_builder.ex b/elixir/lib/symphony_elixir/prompt_builder.ex deleted file mode 100644 index 1b334a105d..0000000000 --- a/elixir/lib/symphony_elixir/prompt_builder.ex +++ /dev/null @@ -1,64 +0,0 @@ -defmodule SymphonyElixir.PromptBuilder do - @moduledoc """ - Builds agent prompts from Linear issue data. - """ - - alias SymphonyElixir.{Config, Workflow} - - @render_opts [strict_variables: true, strict_filters: true] - - @spec build_prompt(SymphonyElixir.Linear.Issue.t(), keyword()) :: String.t() - def build_prompt(issue, opts \\ []) do - template = - Workflow.current() - |> prompt_template!() - |> parse_template!() - - template - |> Solid.render!( - %{ - "attempt" => Keyword.get(opts, :attempt), - "issue" => issue |> Map.from_struct() |> to_solid_map() - }, - @render_opts - ) - |> IO.iodata_to_binary() - end - - defp prompt_template!({:ok, %{prompt_template: prompt}}), do: default_prompt(prompt) - - defp prompt_template!({:error, reason}) do - raise RuntimeError, "workflow_unavailable: #{inspect(reason)}" - end - - defp parse_template!(prompt) when is_binary(prompt) do - Solid.parse!(prompt) - rescue - error -> - reraise %RuntimeError{ - message: "template_parse_error: #{Exception.message(error)} template=#{inspect(prompt)}" - }, - __STACKTRACE__ - end - - defp to_solid_map(map) when is_map(map) do - Map.new(map, fn {key, value} -> {to_string(key), to_solid_value(value)} end) - end - - defp to_solid_value(%DateTime{} = value), do: DateTime.to_iso8601(value) - defp to_solid_value(%NaiveDateTime{} = value), do: NaiveDateTime.to_iso8601(value) - defp to_solid_value(%Date{} = value), do: Date.to_iso8601(value) - defp to_solid_value(%Time{} = value), do: Time.to_iso8601(value) - defp to_solid_value(%_{} = value), do: value |> Map.from_struct() |> to_solid_map() - defp to_solid_value(value) when is_map(value), do: to_solid_map(value) - defp to_solid_value(value) when is_list(value), do: Enum.map(value, &to_solid_value/1) - defp to_solid_value(value), do: value - - defp default_prompt(prompt) when is_binary(prompt) do - if String.trim(prompt) == "" do - Config.workflow_prompt() - else - prompt - end - end -end diff --git a/elixir/lib/symphony_elixir/specs_check.ex b/elixir/lib/symphony_elixir/specs_check.ex deleted file mode 100644 index 4e445bc0a7..0000000000 --- a/elixir/lib/symphony_elixir/specs_check.ex +++ /dev/null @@ -1,175 +0,0 @@ -defmodule SymphonyElixir.SpecsCheck do - @moduledoc false - - @type finding :: %{ - file: String.t(), - module: String.t(), - name: atom(), - arity: non_neg_integer(), - line: pos_integer() - } - - @spec missing_public_specs([Path.t()], keyword()) :: [finding()] - def missing_public_specs(paths, opts \\ []) do - exemptions = - opts - |> Keyword.get(:exemptions, []) - |> MapSet.new() - - paths - |> Enum.flat_map(&collect_elixir_files/1) - |> Enum.flat_map(&file_findings(&1, exemptions)) - |> Enum.sort_by(&{&1.file, &1.line, &1.name, &1.arity}) - end - - @spec finding_identifier(finding()) :: String.t() - def finding_identifier(%{module: module, name: name, arity: arity}) do - "#{module}.#{name}/#{arity}" - end - - defp collect_elixir_files(path) do - cond do - File.regular?(path) and String.ends_with?(path, ".ex") -> - [path] - - File.dir?(path) -> - Path.wildcard(Path.join(path, "**/*.ex")) - - true -> - [] - end - end - - defp file_findings(file, exemptions) do - with {:ok, source} <- File.read(file), - {:ok, ast} <- Code.string_to_quoted(source, columns: true, file: file) do - ast - |> module_nodes() - |> Enum.flat_map(fn {module_name, body} -> - find_missing_specs(body, module_name, file, exemptions) - end) - else - {:error, {line, error, token}} -> - Mix.raise("Unable to parse #{file}:#{line} #{error} #{inspect(token)}") - - {:error, reason} -> - Mix.raise("Unable to read #{file}: #{inspect(reason)}") - end - end - - defp module_nodes(ast) do - {_ast, modules} = - Macro.prewalk(ast, [], fn - {:defmodule, _meta, [module_ast, [do: body]]} = node, acc -> - {node, [{Macro.to_string(module_ast), body} | acc]} - - node, acc -> - {node, acc} - end) - - Enum.reverse(modules) - end - - defp find_missing_specs(body, module_name, file, exemptions) do - body - |> normalize_block() - |> Enum.reduce(initial_state(), fn form, state -> - consume_form(form, state, module_name, file, exemptions) - end) - |> Map.fetch!(:findings) - end - - defp initial_state do - %{pending_specs: MapSet.new(), pending_impl: false, seen_defs: MapSet.new(), findings: []} - end - - defp consume_form({:@, _, [{:spec, _, spec_nodes}]}, state, _module_name, _file, _exemptions) do - ids = - spec_nodes - |> Enum.flat_map(&extract_spec_identifiers/1) - |> MapSet.new() - - %{state | pending_specs: MapSet.union(state.pending_specs, ids)} - end - - defp consume_form({:@, _, [{:impl, _, _}]}, state, _module_name, _file, _exemptions) do - %{state | pending_impl: true} - end - - defp consume_form({:@, _, _}, state, _module_name, _file, _exemptions), do: state - - defp consume_form({:def, meta, [head_ast, _]} = _form, state, module_name, file, exemptions) do - {name, arity} = def_head_to_identifier(head_ast) - - id = {name, arity} - - if MapSet.member?(state.seen_defs, id) do - %{state | pending_specs: MapSet.new(), pending_impl: false} - else - finding = %{ - file: file, - module: module_name, - name: name, - arity: arity, - line: Keyword.get(meta, :line, 1) - } - - next_state = %{ - state - | pending_specs: MapSet.new(), - pending_impl: false, - seen_defs: MapSet.put(state.seen_defs, id) - } - - if compliant?(finding, state, exemptions) do - next_state - else - %{next_state | findings: [finding | next_state.findings]} - end - end - end - - defp consume_form({:defp, _, _}, state, _module_name, _file, _exemptions) do - %{state | pending_specs: MapSet.new(), pending_impl: false} - end - - defp consume_form(_form, state, _module_name, _file, _exemptions) do - %{state | pending_specs: MapSet.new(), pending_impl: false} - end - - defp compliant?(finding, state, exemptions) do - id = {finding.name, finding.arity} - - MapSet.member?(state.pending_specs, id) or - state.pending_impl or - MapSet.member?(exemptions, finding_identifier(finding)) - end - - defp normalize_block({:__block__, _, forms}), do: forms - defp normalize_block(form), do: [form] - - defp extract_spec_identifiers({:"::", _, [head, _return_type]}) do - case spec_head_to_identifier(head) do - nil -> [] - id -> [id] - end - end - - defp extract_spec_identifiers({:when, _, [{:"::", _, [head, _return_type]} | _guards]}) do - case spec_head_to_identifier(head) do - nil -> [] - id -> [id] - end - end - - defp extract_spec_identifiers(_), do: [] - - defp spec_head_to_identifier({:when, _, [inner | _guards]}), do: spec_head_to_identifier(inner) - defp spec_head_to_identifier({name, _, args}) when is_atom(name) and is_list(args), do: {name, length(args)} - defp spec_head_to_identifier({name, _, nil}) when is_atom(name), do: {name, 0} - defp spec_head_to_identifier(_), do: nil - - defp def_head_to_identifier({:when, _, [head | _guards]}), do: def_head_to_identifier(head) - defp def_head_to_identifier({name, _, args}) when is_atom(name) and is_list(args), do: {name, length(args)} - defp def_head_to_identifier({name, _, nil}) when is_atom(name), do: {name, 0} -end diff --git a/elixir/lib/symphony_elixir/status_dashboard.ex b/elixir/lib/symphony_elixir/status_dashboard.ex deleted file mode 100644 index 19b628bfac..0000000000 --- a/elixir/lib/symphony_elixir/status_dashboard.ex +++ /dev/null @@ -1,1949 +0,0 @@ -defmodule SymphonyElixir.StatusDashboard do - @moduledoc """ - Renders a status snapshot for orchestrator and worker activity as a terminal UI. - """ - - use GenServer - require Logger - - alias SymphonyElixir.{Config, HttpServer} - alias SymphonyElixir.Orchestrator - alias SymphonyElixirWeb.ObservabilityPubSub - - @minimum_idle_rerender_ms 1_000 - @throughput_window_ms 5_000 - @throughput_graph_window_ms 10 * 60 * 1000 - @throughput_graph_columns 24 - @sparkline_blocks ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] - @running_id_width 8 - @running_stage_width 14 - @running_pid_width 8 - @running_age_width 12 - @running_tokens_width 10 - @running_session_width 14 - @running_event_default_width 44 - @running_event_min_width 12 - @running_row_chrome_width 10 - @default_terminal_columns 115 - - @ansi_reset IO.ANSI.reset() - @ansi_bold IO.ANSI.bright() - @ansi_blue IO.ANSI.blue() - @ansi_cyan IO.ANSI.cyan() - @ansi_dim IO.ANSI.faint() - @ansi_green IO.ANSI.green() - @ansi_red IO.ANSI.red() - @ansi_orange IO.ANSI.yellow() - @ansi_yellow IO.ANSI.yellow() - @ansi_magenta IO.ANSI.magenta() - @ansi_gray IO.ANSI.light_black() - - defstruct [ - :refresh_ms, - :enabled, - :render_interval_ms, - :refresh_ms_override, - :enabled_override, - :render_interval_ms_override, - :render_fun, - :token_samples, - :last_tps_second, - :last_tps_value, - :last_rendered_content, - :last_rendered_at_ms, - :pending_content, - :flush_timer_ref, - :last_snapshot_fingerprint - ] - - @type t :: %__MODULE__{ - refresh_ms: pos_integer(), - enabled: boolean(), - render_interval_ms: pos_integer(), - refresh_ms_override: pos_integer() | nil, - enabled_override: boolean() | nil, - render_interval_ms_override: pos_integer() | nil, - render_fun: (String.t() -> term()), - token_samples: [{integer(), integer()}], - last_tps_second: integer() | nil, - last_tps_value: float() | nil, - last_rendered_content: String.t() | nil, - last_rendered_at_ms: integer() | nil, - pending_content: String.t() | nil, - flush_timer_ref: reference() | nil, - last_snapshot_fingerprint: term() | nil - } - - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, opts, name: name) - end - - @spec notify_update(GenServer.name()) :: :ok - def notify_update(server \\ __MODULE__) do - ObservabilityPubSub.broadcast_update() - - case GenServer.whereis(server) do - pid when is_pid(pid) -> - send(pid, :refresh) - :ok - - _ -> - :ok - end - end - - @spec init(keyword()) :: {:ok, t()} - def init(opts) do - refresh_ms_override = keyword_override(opts, :refresh_ms) - enabled_override = keyword_override(opts, :enabled) - render_interval_ms_override = keyword_override(opts, :render_interval_ms) - refresh_ms = refresh_ms_override || Config.observability_refresh_ms() - render_interval_ms = render_interval_ms_override || Config.observability_render_interval_ms() - render_fun = Keyword.get(opts, :render_fun, &render_to_terminal/1) - enabled = resolve_override(enabled_override, Config.observability_enabled?() and dashboard_enabled?()) - schedule_tick(refresh_ms, enabled) - - {:ok, - %__MODULE__{ - refresh_ms: refresh_ms, - enabled: enabled, - render_interval_ms: render_interval_ms, - refresh_ms_override: refresh_ms_override, - enabled_override: enabled_override, - render_interval_ms_override: render_interval_ms_override, - render_fun: render_fun, - token_samples: [], - last_tps_second: nil, - last_tps_value: nil, - last_rendered_content: nil, - last_rendered_at_ms: nil, - pending_content: nil, - flush_timer_ref: nil, - last_snapshot_fingerprint: nil - }} - end - - @spec render_offline_status() :: :ok - def render_offline_status do - content = - [ - colorize("╭─ SYMPHONY STATUS", @ansi_bold), - colorize("│ app_status=offline", @ansi_red), - closing_border() - ] - |> Enum.join("\n") - - render_to_terminal(content) - :ok - rescue - error in [ArgumentError, RuntimeError] -> - Logger.warning("Failed rendering offline status: #{Exception.message(error)}") - :ok - end - - @spec handle_info(term(), t()) :: {:noreply, t()} - def handle_info(:tick, %{enabled: true} = state) do - state = refresh_runtime_config(state) - state = maybe_render(state) - schedule_tick(state.refresh_ms, true) - {:noreply, state} - end - - def handle_info(:refresh, %{enabled: true} = state), do: {:noreply, maybe_render(refresh_runtime_config(state))} - def handle_info(:refresh, state), do: {:noreply, state} - - def handle_info({:flush_render, timer_ref}, %{enabled: true, flush_timer_ref: timer_ref} = state) do - now_ms = System.monotonic_time(:millisecond) - - state = - case state.pending_content do - nil -> - %{state | flush_timer_ref: nil} - - content -> - state - |> Map.put(:flush_timer_ref, nil) - |> Map.put(:pending_content, nil) - |> render_content(content, now_ms) - end - - {:noreply, state} - end - - def handle_info({:flush_render, _timer_ref}, state), do: {:noreply, state} - def handle_info(:tick, state), do: {:noreply, state} - - defp refresh_runtime_config(%__MODULE__{} = state) do - %{ - state - | enabled: resolve_override(state.enabled_override, Config.observability_enabled?() and dashboard_enabled?()), - refresh_ms: state.refresh_ms_override || Config.observability_refresh_ms(), - render_interval_ms: state.render_interval_ms_override || Config.observability_render_interval_ms() - } - end - - defp schedule_tick(refresh_ms, true), do: Process.send_after(self(), :tick, refresh_ms) - defp schedule_tick(_refresh_ms, false), do: :ok - - defp maybe_render(state) do - now_ms = System.monotonic_time(:millisecond) - {snapshot_data, token_samples} = snapshot_with_samples(state.token_samples, now_ms) - state = Map.put(state, :token_samples, token_samples) - - current_tokens = snapshot_total_tokens(snapshot_data) - - {tps_second, tps} = - throttled_tps( - state.last_tps_second, - state.last_tps_value, - now_ms, - token_samples, - current_tokens - ) - - state = - state - |> Map.put(:last_tps_second, tps_second) - |> Map.put(:last_tps_value, tps) - - if snapshot_data != state.last_snapshot_fingerprint or periodic_rerender_due?(state, now_ms) do - content = - format_snapshot_content( - snapshot_data, - tps - ) - - state - |> maybe_update_snapshot_fingerprint(snapshot_data) - |> maybe_enqueue_render(content, now_ms) - else - state - end - rescue - error in [ArgumentError, RuntimeError] -> - Logger.warning("Failed rendering status dashboard: #{Exception.message(error)}") - state - end - - defp maybe_enqueue_render(state, content, now_ms) do - cond do - content == state.last_rendered_content -> - state - - render_now?(state, now_ms) -> - render_content(state, content, now_ms) - - true -> - schedule_flush_render(%{state | pending_content: content}, now_ms) - end - end - - defp maybe_update_snapshot_fingerprint(state, snapshot_data) do - if snapshot_data == state.last_snapshot_fingerprint do - state - else - Map.put(state, :last_snapshot_fingerprint, snapshot_data) - end - end - - defp periodic_rerender_due?(%{last_rendered_at_ms: nil}, _now_ms), do: true - - defp periodic_rerender_due?(%{last_rendered_at_ms: last_rendered_at_ms}, now_ms) - when is_integer(last_rendered_at_ms) do - now_ms - last_rendered_at_ms >= @minimum_idle_rerender_ms - end - - defp periodic_rerender_due?(_state, _now_ms), do: false - - defp render_now?(%{last_rendered_at_ms: nil, flush_timer_ref: nil}, _now_ms), do: true - - defp render_now?(%{last_rendered_at_ms: last_rendered_at_ms, render_interval_ms: render_interval_ms}, now_ms) - when is_integer(last_rendered_at_ms) and is_integer(render_interval_ms) do - now_ms - last_rendered_at_ms >= render_interval_ms - end - - defp render_now?(_state, _now_ms), do: false - - defp schedule_flush_render(%{flush_timer_ref: timer_ref} = state, _now_ms) when is_reference(timer_ref), - do: state - - defp schedule_flush_render(state, now_ms) do - delay_ms = flush_delay_ms(state, now_ms) - timer_ref = make_ref() - Process.send_after(self(), {:flush_render, timer_ref}, delay_ms) - %{state | flush_timer_ref: timer_ref} - end - - defp flush_delay_ms(%{last_rendered_at_ms: nil}, _now_ms), do: 1 - - defp flush_delay_ms( - %{last_rendered_at_ms: last_rendered_at_ms, render_interval_ms: render_interval_ms}, - now_ms - ) do - remaining = render_interval_ms - (now_ms - last_rendered_at_ms) - max(1, remaining) - end - - defp render_content(state, content, now_ms) do - state.render_fun.(content) - - %{ - state - | last_rendered_content: content, - last_rendered_at_ms: now_ms, - pending_content: nil, - flush_timer_ref: nil - } - rescue - error in [ArgumentError, RuntimeError] -> - Logger.warning("Failed rendering terminal dashboard frame: #{Exception.message(error)}") - %{state | pending_content: nil, flush_timer_ref: nil} - end - - defp snapshot_with_samples(token_samples, now_ms) do - case snapshot_payload() do - {:ok, %{running: running, retrying: retrying, codex_totals: codex_totals} = snapshot} -> - total_tokens = Map.get(codex_totals, :total_tokens, 0) - - { - {:ok, - %{ - running: running, - retrying: retrying, - codex_totals: codex_totals, - rate_limits: Map.get(snapshot, :rate_limits), - polling: Map.get(snapshot, :polling) - }}, - update_token_samples(token_samples, now_ms, total_tokens) - } - - :error -> - { - :error, - prune_samples(token_samples, now_ms) - } - end - end - - defp format_snapshot_content(snapshot_data, tps, terminal_columns_override \\ nil) do - case snapshot_data do - {:ok, %{running: running, retrying: retrying, codex_totals: codex_totals} = snapshot} -> - rate_limits = Map.get(snapshot, :rate_limits) - project_link_lines = format_project_link_lines() - project_refresh_line = format_project_refresh_line(Map.get(snapshot, :polling)) - codex_input_tokens = Map.get(codex_totals, :input_tokens, 0) - codex_output_tokens = Map.get(codex_totals, :output_tokens, 0) - codex_total_tokens = Map.get(codex_totals, :total_tokens, 0) - codex_seconds_running = Map.get(codex_totals, :seconds_running, 0) - agent_count = length(running) - max_agents = Config.max_concurrent_agents() - running_event_width = running_event_width(terminal_columns_override) - running_rows = format_running_rows(running, running_event_width) - running_to_backoff_spacer = if(running == [], do: [], else: ["│"]) - backoff_rows = format_retry_rows(retrying) - - ([ - colorize("╭─ SYMPHONY STATUS", @ansi_bold), - colorize("│ Agents: ", @ansi_bold) <> - colorize("#{agent_count}", @ansi_green) <> - colorize("/", @ansi_gray) <> - colorize("#{max_agents}", @ansi_gray), - colorize("│ Throughput: ", @ansi_bold) <> colorize("#{format_tps(tps)} tps", @ansi_cyan), - colorize("│ Runtime: ", @ansi_bold) <> - colorize(format_runtime_seconds(codex_seconds_running), @ansi_magenta), - colorize("│ Tokens: ", @ansi_bold) <> - colorize("in #{format_count(codex_input_tokens)}", @ansi_yellow) <> - colorize(" | ", @ansi_gray) <> - colorize("out #{format_count(codex_output_tokens)}", @ansi_yellow) <> - colorize(" | ", @ansi_gray) <> - colorize("total #{format_count(codex_total_tokens)}", @ansi_yellow), - colorize("│ Rate Limits: ", @ansi_bold) <> format_rate_limits(rate_limits), - project_link_lines, - project_refresh_line, - colorize("├─ Running", @ansi_bold), - "│", - running_table_header_row(running_event_width), - running_table_separator_row(running_event_width) - ] ++ - running_rows ++ - running_to_backoff_spacer ++ - [colorize("├─ Backoff queue", @ansi_bold), "│"] ++ - backoff_rows ++ - [closing_border()]) - |> List.flatten() - |> Enum.join("\n") - - :error -> - [ - colorize("╭─ SYMPHONY STATUS", @ansi_bold), - colorize("│ Orchestrator snapshot unavailable", @ansi_red), - colorize("│ Throughput: ", @ansi_bold) <> colorize("#{format_tps(tps)} tps", @ansi_cyan), - format_project_link_lines(), - format_project_refresh_line(nil), - closing_border() - ] - |> List.flatten() - |> Enum.join("\n") - end - end - - defp format_project_link_lines do - project_part = - case Config.linear_project_slug() do - project_slug when is_binary(project_slug) and project_slug != "" -> - colorize(linear_project_url(project_slug), @ansi_cyan) - - _ -> - colorize("n/a", @ansi_gray) - end - - project_line = colorize("│ Project: ", @ansi_bold) <> project_part - - case dashboard_url() do - url when is_binary(url) -> - [project_line, colorize("│ Dashboard: ", @ansi_bold) <> colorize(url, @ansi_cyan)] - - _ -> - [project_line] - end - end - - defp format_project_refresh_line(%{checking?: true}) do - colorize("│ Next refresh: ", @ansi_bold) <> colorize("checking now…", @ansi_cyan) - end - - defp format_project_refresh_line(%{next_poll_in_ms: due_in_ms}) when is_integer(due_in_ms) do - due_in_ms = max(due_in_ms, 0) - seconds = div(due_in_ms + 999, 1000) - colorize("│ Next refresh: ", @ansi_bold) <> colorize("#{seconds}s", @ansi_cyan) - end - - defp format_project_refresh_line(_) do - colorize("│ Next refresh: ", @ansi_bold) <> colorize("n/a", @ansi_gray) - end - - defp linear_project_url(project_slug), do: "https://linear.app/project/#{project_slug}/issues" - - defp dashboard_url do - dashboard_url(Config.server_host(), Config.server_port(), HttpServer.bound_port()) - end - - defp dashboard_url(_host, nil, _bound_port), do: nil - - defp dashboard_url(host, configured_port, bound_port) do - port = bound_port || configured_port - - if is_integer(port) and port > 0 do - "http://#{dashboard_url_host(host)}:#{port}/" - else - nil - end - end - - defp dashboard_url_host(host) when host in ["0.0.0.0", "::", "[::]", ""], do: "127.0.0.1" - - defp dashboard_url_host(host) when is_binary(host) do - trimmed_host = String.trim(host) - - cond do - trimmed_host in ["0.0.0.0", "::", "[::]", ""] -> - "127.0.0.1" - - String.starts_with?(trimmed_host, "[") and String.ends_with?(trimmed_host, "]") -> - trimmed_host - - String.contains?(trimmed_host, ":") -> - "[#{trimmed_host}]" - - true -> - trimmed_host - end - end - - defp render_to_terminal(content) do - IO.write([ - IO.ANSI.home(), - IO.ANSI.clear(), - normalize_status_lines(content), - "\n" - ]) - end - - defp update_token_samples(samples, now_ms, total_tokens) do - prune_graph_samples([{now_ms, total_tokens} | samples], now_ms) - end - - defp prune_samples(samples, now_ms) do - min_timestamp = now_ms - @throughput_window_ms - Enum.filter(samples, fn {timestamp, _} -> timestamp >= min_timestamp end) - end - - defp prune_graph_samples(samples, now_ms) do - min_timestamp = now_ms - max(@throughput_window_ms, @throughput_graph_window_ms) - Enum.filter(samples, fn {timestamp, _} -> timestamp >= min_timestamp end) - end - - @doc false - @spec rolling_tps([{integer(), integer()}], integer(), integer()) :: float() - def rolling_tps(samples, now_ms, current_tokens) do - samples = [{now_ms, current_tokens} | samples] - samples = prune_samples(samples, now_ms) - - case samples do - [] -> - 0.0 - - [_one] -> - 0.0 - - _ -> - first = List.last(samples) - {start_ms, start_tokens} = first - elapsed_ms = now_ms - start_ms - delta_tokens = max(0, current_tokens - start_tokens) - - if elapsed_ms <= 0 do - 0.0 - else - delta_tokens / (elapsed_ms / 1000.0) - end - end - end - - @doc false - @spec throttled_tps(integer() | nil, float() | nil, integer(), [{integer(), integer()}], integer()) :: - {integer(), float()} - def throttled_tps(last_second, last_value, now_ms, token_samples, current_tokens) do - second = div(now_ms, 1000) - - if is_integer(last_second) and last_second == second and is_number(last_value) do - {second, last_value} - else - {second, rolling_tps(token_samples, now_ms, current_tokens)} - end - end - - @doc false - @spec format_timestamp_for_test(DateTime.t()) :: String.t() - def format_timestamp_for_test(%DateTime{} = datetime), do: format_timestamp(datetime) - - @doc false - @spec format_snapshot_content_for_test(term(), number()) :: String.t() - def format_snapshot_content_for_test(snapshot_data, tps), do: format_snapshot_content(snapshot_data, tps) - - @doc false - @spec format_snapshot_content_for_test(term(), number(), integer() | nil) :: String.t() - def format_snapshot_content_for_test(snapshot_data, tps, terminal_columns), - do: format_snapshot_content(snapshot_data, tps, terminal_columns) - - @doc false - @spec dashboard_url_for_test(String.t(), non_neg_integer() | nil, non_neg_integer() | nil) :: - String.t() | nil - def dashboard_url_for_test(host, configured_port, bound_port), - do: dashboard_url(host, configured_port, bound_port) - - defp snapshot_payload do - if Process.whereis(Orchestrator) do - case Orchestrator.snapshot() do - %{ - running: running, - retrying: retrying, - codex_totals: codex_totals - } = snapshot - when is_list(running) and is_list(retrying) -> - {:ok, - %{ - running: running, - retrying: retrying, - codex_totals: codex_totals, - rate_limits: Map.get(snapshot, :rate_limits), - polling: Map.get(snapshot, :polling) - }} - - _ -> - :error - end - else - :error - end - end - - defp format_running_rows(running, running_event_width) do - if running == [] do - [ - "│ " <> colorize("No active agents", @ansi_gray), - "│" - ] - else - running - |> Enum.sort_by(& &1.identifier) - |> Enum.map(&format_running_summary(&1, running_event_width)) - end - end - - # credo:disable-for-next-line - defp format_running_summary(running_entry, running_event_width) do - issue = format_cell(running_entry.identifier || "unknown", @running_id_width) - state = running_entry.state || "unknown" - state_display = format_cell(to_string(state), @running_stage_width) - session = running_entry.session_id |> compact_session_id() |> format_cell(@running_session_width) - pid = format_cell(running_entry.codex_app_server_pid || "n/a", @running_pid_width) - total_tokens = running_entry.codex_total_tokens || 0 - runtime_seconds = running_entry.runtime_seconds || 0 - turn_count = Map.get(running_entry, :turn_count, 0) - age = format_cell(format_runtime_and_turns(runtime_seconds, turn_count), @running_age_width) - event = running_entry.last_codex_event || "none" - event_label = format_cell(summarize_message(running_entry.last_codex_message), running_event_width) - - tokens = format_count(total_tokens) |> format_cell(@running_tokens_width, :right) - - status_color = - case event do - :none -> @ansi_red - "codex/event/token_count" -> @ansi_yellow - "codex/event/task_started" -> @ansi_green - "turn_completed" -> @ansi_magenta - _ -> @ansi_blue - end - - [ - "│ ", - status_dot(status_color), - " ", - colorize(issue, @ansi_cyan), - " ", - colorize(state_display, status_color), - " ", - colorize(pid, @ansi_yellow), - " ", - colorize(age, @ansi_magenta), - " ", - colorize(tokens, @ansi_yellow), - " ", - colorize(session, @ansi_cyan), - " ", - colorize(event_label, status_color) - ] - |> Enum.join("") - end - - @doc false - @spec format_running_summary_for_test(map(), integer() | nil) :: String.t() - def format_running_summary_for_test(running_entry, terminal_columns \\ nil), - do: format_running_summary(running_entry, running_event_width(terminal_columns)) - - @doc false - @spec format_tps_for_test(number()) :: String.t() - def format_tps_for_test(value), do: format_tps(value) - - @doc false - @spec tps_graph_for_test([{integer(), integer()}], integer(), integer()) :: String.t() - def tps_graph_for_test(samples, now_ms, current_tokens), do: tps_graph(samples, now_ms, current_tokens) - - defp format_retry_rows(retrying) do - if retrying == [] do - ["│ " <> colorize("No queued retries", @ansi_gray)] - else - retrying - |> Enum.sort_by(& &1.due_in_ms) - |> Enum.map_join(", ", &format_retry_summary/1) - |> String.split(", ") - end - end - - defp format_retry_summary(retry_entry) do - issue_id = retry_entry.issue_id || "unknown" - identifier = retry_entry.identifier || issue_id - attempt = retry_entry.attempt || 0 - due_in_ms = retry_entry.due_in_ms || 0 - error = format_retry_error(retry_entry.error) - - "│ #{colorize("↻", @ansi_orange)} " <> - colorize("#{identifier}", @ansi_red) <> - " " <> - colorize("attempt=#{attempt}", @ansi_yellow) <> - colorize(" in ", @ansi_dim) <> - colorize(next_in_words(due_in_ms), @ansi_cyan) <> - error - end - - defp next_in_words(due_in_ms) when is_integer(due_in_ms) do - secs = div(due_in_ms, 1000) - millis = rem(due_in_ms, 1000) - "#{secs}.#{String.pad_leading(to_string(millis), 3, "0")}s" - end - - defp next_in_words(_), do: "n/a" - - defp format_retry_error(error) when is_binary(error) do - sanitized = - error - |> String.replace("\\r\\n", " ") - |> String.replace("\\r", " ") - |> String.replace("\\n", " ") - |> String.replace("\r\n", " ") - |> String.replace("\r", " ") - |> String.replace("\n", " ") - |> String.replace(~r/\s+/, " ") - |> String.trim() - - if sanitized == "" do - "" - else - " " <> colorize("error=#{truncate(sanitized, 96)}", @ansi_dim) - end - end - - defp format_retry_error(_), do: "" - - defp format_runtime_seconds(seconds) when is_integer(seconds) do - mins = div(seconds, 60) - secs = rem(seconds, 60) - "#{mins}m #{secs}s" - end - - defp format_runtime_seconds(seconds) when is_binary(seconds), do: seconds - defp format_runtime_seconds(_), do: "0m 0s" - - defp format_runtime_and_turns(seconds, turn_count) when is_integer(turn_count) and turn_count > 0 do - "#{format_runtime_seconds(seconds)} / #{turn_count}" - end - - defp format_runtime_and_turns(seconds, _turn_count), do: format_runtime_seconds(seconds) - - defp format_count(nil), do: "0" - - defp format_count(value) when is_integer(value) do - value - |> Integer.to_string() - |> group_thousands() - end - - defp format_count(value) when is_binary(value) do - value - |> String.trim() - |> Integer.parse() - |> case do - {number, ""} -> group_thousands(Integer.to_string(number)) - _ -> value - end - end - - defp format_count(value), do: to_string(value) - - defp running_table_header_row(running_event_width) do - header = - [ - format_cell("ID", @running_id_width), - format_cell("STAGE", @running_stage_width), - format_cell("PID", @running_pid_width), - format_cell("AGE / TURN", @running_age_width), - format_cell("TOKENS", @running_tokens_width), - format_cell("SESSION", @running_session_width), - format_cell("EVENT", running_event_width) - ] - |> Enum.join(" ") - - "│ " <> colorize(header, @ansi_gray) - end - - defp running_table_separator_row(running_event_width) do - separator_width = - @running_id_width + - @running_stage_width + - @running_pid_width + - @running_age_width + - @running_tokens_width + - @running_session_width + - running_event_width + 6 - - "│ " <> colorize(String.duplicate("─", separator_width), @ansi_gray) - end - - defp running_event_width(terminal_columns) do - terminal_columns = terminal_columns || terminal_columns() - - max( - @running_event_min_width, - terminal_columns - fixed_running_width() - @running_row_chrome_width - ) - end - - defp fixed_running_width do - @running_id_width + - @running_stage_width + - @running_pid_width + - @running_age_width + - @running_tokens_width + - @running_session_width - end - - defp terminal_columns do - case :io.columns() do - {:ok, columns} when is_integer(columns) and columns > 0 -> - columns - - _ -> - terminal_columns_from_env() - end - end - - defp terminal_columns_from_env do - case System.get_env("COLUMNS") do - nil -> - fixed_running_width() + @running_row_chrome_width + @running_event_default_width - - value -> - case Integer.parse(String.trim(value)) do - {columns, ""} when columns > 0 -> columns - _ -> @default_terminal_columns - end - end - end - - defp format_cell(value, width, align \\ :left) do - value = - value - |> to_string() - |> String.replace("\n", " ") - |> String.replace(~r/\s+/, " ") - |> String.trim() - |> truncate_plain(width) - - case align do - :right -> String.pad_leading(value, width) - _ -> String.pad_trailing(value, width) - end - end - - defp truncate_plain(value, width) do - if byte_size(value) <= width do - value - else - String.slice(value, 0, width - 3) <> "..." - end - end - - defp compact_session_id(nil), do: "n/a" - defp compact_session_id(session_id) when not is_binary(session_id), do: "n/a" - - defp compact_session_id(session_id) do - if String.length(session_id) > 10 do - String.slice(session_id, 0, 4) <> "..." <> String.slice(session_id, -6, 6) - else - session_id - end - end - - defp group_thousands(value) when is_binary(value) do - sign = if String.starts_with?(value, "-"), do: "-", else: "" - unsigned = if sign == "", do: value, else: String.slice(value, 1, String.length(value) - 1) - - unsigned - |> String.reverse() - |> String.replace(~r/(\d{3})(?=\d)/, "\\1,") - |> String.reverse() - |> prepend(sign) - end - - defp prepend("", value), do: value - defp prepend(prefix, value), do: prefix <> value - - defp format_tps(value) when is_number(value) do - value - |> trunc() - |> Integer.to_string() - |> group_thousands() - end - - defp tps_graph(samples, now_ms, current_tokens) do - bucket_ms = div(@throughput_graph_window_ms, @throughput_graph_columns) - active_bucket_start = div(now_ms, bucket_ms) * bucket_ms - graph_window_start = active_bucket_start - (@throughput_graph_columns - 1) * bucket_ms - - rates = - [{now_ms, current_tokens} | samples] - |> prune_graph_samples(now_ms) - |> Enum.sort_by(&elem(&1, 0)) - |> Enum.chunk_every(2, 1, :discard) - |> Enum.map(fn [{start_ms, start_tokens}, {end_ms, end_tokens}] -> - elapsed_ms = end_ms - start_ms - delta_tokens = max(0, end_tokens - start_tokens) - tps = if elapsed_ms <= 0, do: 0.0, else: delta_tokens / (elapsed_ms / 1000.0) - {end_ms, tps} - end) - - bucketed_tps = - 0..(@throughput_graph_columns - 1) - |> Enum.map(fn bucket_idx -> - bucket_start = graph_window_start + bucket_idx * bucket_ms - bucket_end = bucket_start + bucket_ms - last_bucket? = bucket_idx == @throughput_graph_columns - 1 - - values = - rates - |> Enum.filter(fn {timestamp, _tps} -> - in_bucket?(timestamp, bucket_start, bucket_end, last_bucket?) - end) - |> Enum.map(fn {_timestamp, tps} -> tps end) - - if values == [] do - 0.0 - else - Enum.sum(values) / length(values) - end - end) - - max_tps = Enum.max(bucketed_tps, fn -> 0.0 end) - - bucketed_tps - |> Enum.map_join(fn value -> - index = - if max_tps <= 0 do - 0 - else - round(value / max_tps * (length(@sparkline_blocks) - 1)) - end - - Enum.at(@sparkline_blocks, index, "▁") - end) - end - - defp in_bucket?(timestamp, bucket_start, bucket_end, true), - do: timestamp >= bucket_start and timestamp <= bucket_end - - defp in_bucket?(timestamp, bucket_start, bucket_end, false), - do: timestamp >= bucket_start and timestamp < bucket_end - - defp format_rate_limits(nil), do: colorize("unavailable", @ansi_gray) - - defp format_rate_limits(rate_limits) when is_map(rate_limits) do - limit_id = - map_value(rate_limits, ["limit_id", :limit_id, "limit_name", :limit_name]) || - "unknown" - - primary = format_rate_limit_bucket(map_value(rate_limits, ["primary", :primary])) - secondary = format_rate_limit_bucket(map_value(rate_limits, ["secondary", :secondary])) - credits = format_rate_limit_credits(map_value(rate_limits, ["credits", :credits])) - - colorize(to_string(limit_id), @ansi_yellow) <> - colorize(" | ", @ansi_gray) <> - colorize("primary #{primary}", @ansi_cyan) <> - colorize(" | ", @ansi_gray) <> - colorize("secondary #{secondary}", @ansi_cyan) <> - colorize(" | ", @ansi_gray) <> - colorize(credits, @ansi_green) - end - - defp format_rate_limits(other) do - other - |> inspect(limit: 10) - |> truncate(80) - |> colorize(@ansi_gray) - end - - defp format_rate_limit_bucket(nil), do: "n/a" - - defp format_rate_limit_bucket(bucket) when is_map(bucket) do - remaining = map_value(bucket, ["remaining", :remaining]) - limit = map_value(bucket, ["limit", :limit]) - - reset_value = - map_value(bucket, [ - "reset_in_seconds", - :reset_in_seconds, - "resetInSeconds", - :resetInSeconds, - "reset_at", - :reset_at, - "resetAt", - :resetAt, - "resets_at", - :resets_at, - "resetsAt", - :resetsAt - ]) - - base = - cond do - integer_like?(remaining) and integer_like?(limit) -> - "#{format_count(remaining)}/#{format_count(limit)}" - - integer_like?(remaining) -> - "remaining #{format_count(remaining)}" - - integer_like?(limit) -> - "limit #{format_count(limit)}" - - map_size(bucket) == 0 -> - "n/a" - - true -> - bucket |> inspect(limit: 6) |> truncate(40) - end - - if is_nil(reset_value) do - base - else - "#{base} reset #{format_reset_value(reset_value)}" - end - end - - defp format_rate_limit_bucket(other), do: to_string(other) - - defp format_rate_limit_credits(nil), do: "credits n/a" - - defp format_rate_limit_credits(credits) when is_map(credits) do - unlimited = map_value(credits, ["unlimited", :unlimited]) == true - has_credits = map_value(credits, ["has_credits", :has_credits]) == true - balance = map_value(credits, ["balance", :balance]) - - cond do - unlimited -> - "credits unlimited" - - has_credits and is_number(balance) -> - "credits #{format_number(balance)}" - - has_credits -> - "credits available" - - true -> - "credits none" - end - end - - defp format_rate_limit_credits(other), do: "credits #{to_string(other)}" - - defp format_reset_value(value) when is_integer(value), do: "#{format_count(value)}s" - defp format_reset_value(value) when is_binary(value), do: value - defp format_reset_value(value), do: to_string(value) - - defp format_number(value) when is_integer(value), do: format_count(value) - - defp format_number(value) when is_float(value) do - value - |> Float.round(2) - |> :erlang.float_to_binary(decimals: 2) - end - - defp map_value(map, keys) when is_map(map) and is_list(keys) do - Enum.find_value(keys, &Map.get(map, &1)) - end - - defp map_value(_map, _keys), do: nil - - defp integer_like?(value) when is_integer(value), do: true - defp integer_like?(_value), do: false - - defp status_dot(color_code) do - colorize("●", color_code) - end - - defp snapshot_total_tokens({:ok, %{codex_totals: codex_totals}}) when is_map(codex_totals) do - Map.get(codex_totals, :total_tokens, 0) - end - - defp snapshot_total_tokens(_snapshot_data), do: 0 - - defp format_timestamp(datetime) do - datetime - |> DateTime.truncate(:second) - |> DateTime.to_string() - end - - defp normalize_status_lines(content) do - content - end - - defp closing_border, do: "╰─" - - defp colorize(value, code) do - "#{code}#{value}#{@ansi_reset}" - end - - @doc false - @spec humanize_codex_message(term()) :: String.t() - def humanize_codex_message(nil), do: "no codex message yet" - - def humanize_codex_message(%{event: event, message: message}) do - payload = unwrap_codex_message_payload(message) - - (humanize_codex_event(event, message, payload) || humanize_codex_payload(payload)) - |> truncate(140) - end - - def humanize_codex_message(%{message: message}) do - message - |> unwrap_codex_message_payload() - |> humanize_codex_payload() - |> truncate(140) - end - - def humanize_codex_message(message) do - message - |> unwrap_codex_message_payload() - |> humanize_codex_payload() - |> truncate(140) - end - - defp summarize_message(message), do: humanize_codex_message(message) - - defp humanize_codex_event(:session_started, _message, payload) do - session_id = map_value(payload, ["session_id", :session_id]) - - if is_binary(session_id) do - "session started (#{session_id})" - else - "session started" - end - end - - defp humanize_codex_event(:turn_input_required, _message, _payload), do: "turn blocked: waiting for user input" - - defp humanize_codex_event(:approval_auto_approved, message, payload) do - method = - map_value(payload, ["method", :method]) || - map_path(message, ["payload", "method"]) || - map_path(message, [:payload, :method]) - - decision = map_value(message, ["decision", :decision]) - - base = - if is_binary(method) do - "#{humanize_codex_method(method, payload)} (auto-approved)" - else - "approval request auto-approved" - end - - if is_binary(decision), do: "#{base}: #{decision}", else: base - end - - defp humanize_codex_event(:tool_input_auto_answered, message, payload) do - answer = map_value(message, ["answer", :answer]) - - base = - case humanize_codex_method("item/tool/requestUserInput", payload) do - nil -> "tool input auto-answered" - text -> "#{text} (auto-answered)" - end - - if is_binary(answer), do: "#{base}: #{inline_text(answer)}", else: base - end - - defp humanize_codex_event(:tool_call_completed, _message, payload), - do: humanize_dynamic_tool_event("dynamic tool call completed", payload) - - defp humanize_codex_event(:tool_call_failed, _message, payload), - do: humanize_dynamic_tool_event("dynamic tool call failed", payload) - - defp humanize_codex_event(:unsupported_tool_call, _message, payload), - do: humanize_dynamic_tool_event("unsupported dynamic tool call rejected", payload) - - defp humanize_codex_event(:turn_ended_with_error, message, _payload), do: "turn ended with error: #{format_reason(message)}" - defp humanize_codex_event(:startup_failed, message, _payload), do: "startup failed: #{format_reason(message)}" - defp humanize_codex_event(:turn_failed, _message, payload), do: humanize_codex_method("turn/failed", payload) - defp humanize_codex_event(:turn_cancelled, _message, _payload), do: "turn cancelled" - defp humanize_codex_event(:malformed, _message, _payload), do: "malformed JSON event from codex" - defp humanize_codex_event(_event, _message, _payload), do: nil - - defp unwrap_codex_message_payload(%{} = message) do - cond do - is_binary(map_value(message, ["method", :method])) -> message - is_binary(map_value(message, ["session_id", :session_id])) -> message - is_binary(map_value(message, ["reason", :reason])) -> message - true -> map_value(message, ["payload", :payload]) || message - end - end - - defp unwrap_codex_message_payload(message), do: message - - defp humanize_codex_payload(%{} = payload) do - case map_value(payload, ["method", :method]) do - method when is_binary(method) -> - humanize_codex_method(method, payload) - - _ -> - cond do - is_binary(map_value(payload, ["session_id", :session_id])) -> - "session started (#{map_value(payload, ["session_id", :session_id])})" - - match?(%{"error" => _}, payload) -> - "error: #{format_error_value(Map.get(payload, "error"))}" - - true -> - payload - |> inspect(pretty: true, limit: 30) - |> String.replace("\n", " ") - |> sanitize_ansi_and_control_bytes() - |> String.trim() - end - end - end - - defp humanize_codex_payload(payload) when is_binary(payload) do - payload - |> String.replace("\n", " ") - |> sanitize_ansi_and_control_bytes() - |> String.trim() - end - - defp humanize_codex_payload(payload) do - payload - |> inspect(pretty: true, limit: 20) - |> String.replace("\n", " ") - |> sanitize_ansi_and_control_bytes() - |> String.trim() - end - - defp sanitize_ansi_and_control_bytes(value) when is_binary(value) do - value - |> String.replace(~r/\x1B\[[0-9;]*[A-Za-z]/, "") - |> String.replace(~r/\x1B./, "") - |> String.replace(~r/[\x00-\x1F\x7F]/, "") - end - - defp humanize_codex_method("thread/started", payload) do - thread_id = map_path(payload, ["params", "thread", "id"]) || map_path(payload, [:params, :thread, :id]) - - if is_binary(thread_id) do - "thread started (#{thread_id})" - else - "thread started" - end - end - - defp humanize_codex_method("turn/started", payload) do - turn_id = map_path(payload, ["params", "turn", "id"]) || map_path(payload, [:params, :turn, :id]) - - if is_binary(turn_id) do - "turn started (#{turn_id})" - else - "turn started" - end - end - - defp humanize_codex_method("turn/completed", payload) do - status = - map_path(payload, ["params", "turn", "status"]) || - map_path(payload, [:params, :turn, :status]) || - "completed" - - usage = - map_path(payload, ["params", "usage"]) || - map_path(payload, [:params, :usage]) || - map_path(payload, ["params", "tokenUsage"]) || - map_path(payload, [:params, :tokenUsage]) || - map_value(payload, ["usage", :usage]) - - usage_suffix = - case format_usage_counts(usage) do - nil -> "" - usage_text -> " (#{usage_text})" - end - - "turn completed (#{status})#{usage_suffix}" - end - - defp humanize_codex_method("turn/failed", payload) do - error_message = - map_path(payload, ["params", "error", "message"]) || - map_path(payload, [:params, :error, :message]) - - if is_binary(error_message), do: "turn failed: #{error_message}", else: "turn failed" - end - - defp humanize_codex_method("turn/cancelled", _payload), do: "turn cancelled" - - defp humanize_codex_method("turn/diff/updated", payload) do - diff = - map_path(payload, ["params", "diff"]) || - map_path(payload, [:params, :diff]) || - "" - - if is_binary(diff) and diff != "" do - line_count = diff |> String.split("\n", trim: true) |> length() - "turn diff updated (#{line_count} lines)" - else - "turn diff updated" - end - end - - defp humanize_codex_method("turn/plan/updated", payload) do - plan_entries = - map_path(payload, ["params", "plan"]) || - map_path(payload, [:params, :plan]) || - map_path(payload, ["params", "steps"]) || - map_path(payload, [:params, :steps]) || - map_path(payload, ["params", "items"]) || - map_path(payload, [:params, :items]) || - [] - - if is_list(plan_entries) do - "plan updated (#{length(plan_entries)} steps)" - else - "plan updated" - end - end - - defp humanize_codex_method("thread/tokenUsage/updated", payload) do - usage = - map_path(payload, ["params", "tokenUsage", "total"]) || - map_path(payload, [:params, :tokenUsage, :total]) || - map_value(payload, ["usage", :usage]) - - case format_usage_counts(usage) do - nil -> "thread token usage updated" - usage_text -> "thread token usage updated (#{usage_text})" - end - end - - defp humanize_codex_method("item/started", payload), do: humanize_item_lifecycle("started", payload) - defp humanize_codex_method("item/completed", payload), do: humanize_item_lifecycle("completed", payload) - - defp humanize_codex_method("item/agentMessage/delta", payload), - do: humanize_streaming_event("agent message streaming", payload) - - defp humanize_codex_method("item/plan/delta", payload), - do: humanize_streaming_event("plan streaming", payload) - - defp humanize_codex_method("item/reasoning/summaryTextDelta", payload), - do: humanize_streaming_event("reasoning summary streaming", payload) - - defp humanize_codex_method("item/reasoning/summaryPartAdded", payload), - do: humanize_streaming_event("reasoning summary section added", payload) - - defp humanize_codex_method("item/reasoning/textDelta", payload), - do: humanize_streaming_event("reasoning text streaming", payload) - - defp humanize_codex_method("item/commandExecution/outputDelta", payload), - do: humanize_streaming_event("command output streaming", payload) - - defp humanize_codex_method("item/fileChange/outputDelta", payload), - do: humanize_streaming_event("file change output streaming", payload) - - defp humanize_codex_method("item/commandExecution/requestApproval", payload) do - command = extract_command(payload) - - if is_binary(command) do - "command approval requested (#{command})" - else - "command approval requested" - end - end - - defp humanize_codex_method("item/fileChange/requestApproval", payload) do - change_count = map_path(payload, ["params", "fileChangeCount"]) || map_path(payload, ["params", "changeCount"]) - - if is_integer(change_count) and change_count > 0 do - "file change approval requested (#{change_count} files)" - else - "file change approval requested" - end - end - - defp humanize_codex_method("item/tool/requestUserInput", payload) do - question = - map_path(payload, ["params", "question"]) || - map_path(payload, ["params", "prompt"]) || - map_path(payload, [:params, :question]) || - map_path(payload, [:params, :prompt]) - - if is_binary(question) and String.trim(question) != "" do - "tool requires user input: #{inline_text(question)}" - else - "tool requires user input" - end - end - - defp humanize_codex_method("tool/requestUserInput", payload), - do: humanize_codex_method("item/tool/requestUserInput", payload) - - defp humanize_codex_method("account/updated", payload) do - auth_mode = - map_path(payload, ["params", "authMode"]) || - map_path(payload, [:params, :authMode]) || - "unknown" - - "account updated (auth #{auth_mode})" - end - - defp humanize_codex_method("account/rateLimits/updated", payload) do - rate_limits = - map_path(payload, ["params", "rateLimits"]) || - map_path(payload, [:params, :rateLimits]) - - "rate limits updated: #{format_rate_limits_summary(rate_limits)}" - end - - defp humanize_codex_method("account/chatgptAuthTokens/refresh", _payload), do: "account auth token refresh requested" - - defp humanize_codex_method("item/tool/call", payload) do - tool = dynamic_tool_name(payload) - - if is_binary(tool) and String.trim(tool) != "" do - "dynamic tool call requested (#{tool})" - else - "dynamic tool call requested" - end - end - - defp humanize_codex_method(<<"codex/event/", suffix::binary>>, payload) do - humanize_codex_wrapper_event(suffix, payload) - end - - defp humanize_codex_method(method, payload) do - msg_type = - map_path(payload, ["params", "msg", "type"]) || - map_path(payload, [:params, :msg, :type]) - - if is_binary(msg_type) do - "#{method} (#{msg_type})" - else - method - end - end - - defp humanize_dynamic_tool_event(base, payload) do - case dynamic_tool_name(payload) do - tool when is_binary(tool) -> - trimmed = String.trim(tool) - - if trimmed == "" do - base - else - "#{base} (#{trimmed})" - end - - _ -> - base - end - end - - defp dynamic_tool_name(payload) do - map_path(payload, ["params", "tool"]) || - map_path(payload, ["params", "name"]) || - map_path(payload, [:params, :tool]) || - map_path(payload, [:params, :name]) - end - - defp humanize_item_lifecycle(state, payload) do - item = - map_path(payload, ["params", "item"]) || - map_path(payload, [:params, :item]) || - %{} - - item_type = item |> map_value(["type", :type]) |> humanize_item_type() - item_status = map_value(item, ["status", :status]) - item_id = map_value(item, ["id", :id]) - - details = - [] - |> append_if_present(short_id(item_id)) - |> append_if_present(humanize_status(item_status)) - - detail_suffix = if details == [], do: "", else: " (#{Enum.join(details, ", ")})" - "item #{state}: #{item_type}#{detail_suffix}" - end - - defp humanize_codex_wrapper_event("mcp_startup_update", payload) do - server = - map_path(payload, ["params", "msg", "server"]) || - map_path(payload, [:params, :msg, :server]) || - "mcp" - - state = - map_path(payload, ["params", "msg", "status", "state"]) || - map_path(payload, [:params, :msg, :status, :state]) || - "updated" - - "mcp startup: #{server} #{state}" - end - - defp humanize_codex_wrapper_event("mcp_startup_complete", _payload), do: "mcp startup complete" - defp humanize_codex_wrapper_event("task_started", _payload), do: "task started" - defp humanize_codex_wrapper_event("user_message", _payload), do: "user message received" - - defp humanize_codex_wrapper_event("item_started", payload) do - case wrapper_payload_type(payload) do - "token_count" -> humanize_codex_wrapper_event("token_count", payload) - type when is_binary(type) -> "item started (#{humanize_item_type(type)})" - _ -> "item started" - end - end - - defp humanize_codex_wrapper_event("item_completed", payload) do - case wrapper_payload_type(payload) do - "token_count" -> humanize_codex_wrapper_event("token_count", payload) - type when is_binary(type) -> "item completed (#{humanize_item_type(type)})" - _ -> "item completed" - end - end - - defp humanize_codex_wrapper_event("agent_message_delta", payload), - do: humanize_streaming_event("agent message streaming", payload) - - defp humanize_codex_wrapper_event("agent_message_content_delta", payload), - do: humanize_streaming_event("agent message content streaming", payload) - - defp humanize_codex_wrapper_event("agent_reasoning_delta", payload), - do: humanize_streaming_event("reasoning streaming", payload) - - defp humanize_codex_wrapper_event("reasoning_content_delta", payload), - do: humanize_streaming_event("reasoning content streaming", payload) - - defp humanize_codex_wrapper_event("agent_reasoning_section_break", _payload), do: "reasoning section break" - defp humanize_codex_wrapper_event("agent_reasoning", payload), do: humanize_reasoning_update(payload) - defp humanize_codex_wrapper_event("turn_diff", _payload), do: "turn diff updated" - defp humanize_codex_wrapper_event("exec_command_begin", payload), do: humanize_exec_command_begin(payload) - defp humanize_codex_wrapper_event("exec_command_end", payload), do: humanize_exec_command_end(payload) - defp humanize_codex_wrapper_event("exec_command_output_delta", _payload), do: "command output streaming" - defp humanize_codex_wrapper_event("mcp_tool_call_begin", _payload), do: "mcp tool call started" - defp humanize_codex_wrapper_event("mcp_tool_call_end", _payload), do: "mcp tool call completed" - - defp humanize_codex_wrapper_event("token_count", payload) do - usage = extract_first_path(payload, token_usage_paths()) - - case format_usage_counts(usage) do - nil -> "token count update" - usage_text -> "token count update (#{usage_text})" - end - end - - defp humanize_codex_wrapper_event(other, payload) do - msg_type = - map_path(payload, ["params", "msg", "type"]) || - map_path(payload, [:params, :msg, :type]) - - if is_binary(msg_type) do - "#{other} (#{msg_type})" - else - other - end - end - - defp humanize_exec_command_begin(payload) do - command = - map_path(payload, ["params", "msg", "command"]) || - map_path(payload, [:params, :msg, :command]) || - map_path(payload, ["params", "msg", "parsed_cmd"]) || - map_path(payload, [:params, :msg, :parsed_cmd]) - - command = normalize_command(command) - - if is_binary(command) do - command - else - "command started" - end - end - - defp humanize_exec_command_end(payload) do - exit_code = - map_path(payload, ["params", "msg", "exit_code"]) || - map_path(payload, [:params, :msg, :exit_code]) || - map_path(payload, ["params", "msg", "exitCode"]) || - map_path(payload, [:params, :msg, :exitCode]) - - if is_integer(exit_code) do - "command completed (exit #{exit_code})" - else - "command completed" - end - end - - defp format_usage_counts(usage) when is_map(usage) do - input = - parse_integer( - map_value(usage, [ - "input_tokens", - :input_tokens, - "prompt_tokens", - :prompt_tokens, - "inputTokens", - :inputTokens, - "promptTokens", - :promptTokens - ]) - ) - - output = - parse_integer( - map_value(usage, [ - "output_tokens", - :output_tokens, - "completion_tokens", - :completion_tokens, - "outputTokens", - :outputTokens, - "completionTokens", - :completionTokens - ]) - ) - - total = - parse_integer( - map_value(usage, [ - "total_tokens", - :total_tokens, - "total", - :total, - "totalTokens", - :totalTokens - ]) - ) - - parts = - [] - |> append_usage_part("in", input) - |> append_usage_part("out", output) - |> append_usage_part("total", total) - - case parts do - [] -> nil - _ -> Enum.join(parts, ", ") - end - end - - defp format_usage_counts(_usage), do: nil - - defp append_usage_part(parts, _label, value) when not is_integer(value), do: parts - defp append_usage_part(parts, label, value), do: parts ++ ["#{label} #{format_count(value)}"] - - defp format_rate_limits_summary(nil), do: "n/a" - - defp format_rate_limits_summary(rate_limits) when is_map(rate_limits) do - primary = map_value(rate_limits, ["primary", :primary]) - secondary = map_value(rate_limits, ["secondary", :secondary]) - - primary_text = format_rate_limit_bucket_summary(primary) - secondary_text = format_rate_limit_bucket_summary(secondary) - - cond do - primary_text != nil and secondary_text != nil -> "primary #{primary_text}; secondary #{secondary_text}" - primary_text != nil -> "primary #{primary_text}" - secondary_text != nil -> "secondary #{secondary_text}" - true -> "n/a" - end - end - - defp format_rate_limits_summary(_rate_limits), do: "n/a" - - defp format_rate_limit_bucket_summary(bucket) when is_map(bucket) do - used_percent = map_value(bucket, ["usedPercent", :usedPercent]) - window_mins = map_value(bucket, ["windowDurationMins", :windowDurationMins]) - - cond do - is_number(used_percent) and is_integer(window_mins) -> - "#{used_percent}% / #{window_mins}m" - - is_number(used_percent) -> - "#{used_percent}% used" - - true -> - nil - end - end - - defp format_rate_limit_bucket_summary(_bucket), do: nil - - defp format_error_value(%{"message" => message}) when is_binary(message), do: message - defp format_error_value(%{message: message}) when is_binary(message), do: message - defp format_error_value(error), do: inspect(error, limit: 10) - - defp format_reason(message) when is_map(message) do - case map_value(message, ["reason", :reason]) do - nil -> - message - |> inspect(limit: 10) - |> inline_text() - - reason -> - format_error_value(reason) - end - end - - defp format_reason(other), do: format_error_value(other) - - defp humanize_streaming_event(label, payload) do - case extract_delta_preview(payload) do - nil -> label - preview -> "#{label}: #{preview}" - end - end - - defp humanize_reasoning_update(payload) do - case extract_reasoning_focus(payload) do - nil -> "reasoning update" - focus -> "reasoning update: #{focus}" - end - end - - defp extract_reasoning_focus(payload) do - value = extract_first_path(payload, reasoning_focus_paths()) - - if is_binary(value) do - trimmed = String.trim(value) - if trimmed == "", do: nil, else: inline_text(trimmed) - else - nil - end - end - - defp extract_delta_preview(payload) do - delta = extract_first_path(payload, delta_paths()) - - case delta do - value when is_binary(value) -> - trimmed = String.trim(value) - if trimmed == "", do: nil, else: inline_text(trimmed) - - _ -> - nil - end - end - - defp extract_command(payload) do - payload - |> map_path(["params", "parsedCmd"]) - |> fallback_command(payload) - |> normalize_command() - end - - defp fallback_command(nil, payload) do - map_path(payload, ["params", "command"]) || - map_path(payload, ["params", "cmd"]) || - map_path(payload, ["params", "argv"]) || - map_path(payload, ["params", "args"]) - end - - defp fallback_command(command, _payload), do: command - - defp normalize_command(%{} = command) do - binary_command = map_value(command, ["parsedCmd", :parsedCmd, "command", :command, "cmd", :cmd]) - args = map_value(command, ["args", :args, "argv", :argv]) - - if is_binary(binary_command) and is_list(args) do - normalize_command([binary_command | args]) - else - normalize_command(binary_command || args) - end - end - - defp normalize_command(command) when is_binary(command), do: inline_text(command) - - defp normalize_command(command) when is_list(command) do - if Enum.all?(command, &is_binary/1) do - command - |> Enum.join(" ") - |> inline_text() - else - nil - end - end - - defp normalize_command(_command), do: nil - - defp humanize_item_type(nil), do: "item" - - defp humanize_item_type(type) when is_binary(type) do - type - |> String.replace(~r/([a-z0-9])([A-Z])/, "\\1 \\2") - |> String.replace("_", " ") - |> String.replace("/", " ") - |> String.downcase() - |> String.trim() - end - - defp humanize_item_type(type), do: to_string(type) - - defp humanize_status(status) when is_binary(status) do - status - |> String.replace("_", " ") - |> String.replace("-", " ") - |> String.downcase() - |> String.trim() - end - - defp humanize_status(_status), do: nil - - defp short_id(id) when is_binary(id) and byte_size(id) > 12, do: String.slice(id, 0, 12) - defp short_id(id) when is_binary(id), do: id - defp short_id(_id), do: nil - - defp append_if_present(list, value) when is_binary(value) and value != "", do: list ++ [value] - defp append_if_present(list, _value), do: list - - defp wrapper_payload_type(payload) do - map_path(payload, ["params", "msg", "payload", "type"]) || - map_path(payload, [:params, :msg, :payload, :type]) - end - - defp inline_text(text) when is_binary(text) do - text - |> String.replace("\n", " ") - |> String.replace(~r/\s+/, " ") - |> String.trim() - |> truncate(80) - end - - defp inline_text(other), do: other |> to_string() |> inline_text() - - defp parse_integer(value) when is_integer(value), do: value - - defp parse_integer(value) when is_binary(value) do - case Integer.parse(String.trim(value)) do - {parsed, ""} -> parsed - _ -> nil - end - end - - defp parse_integer(_value), do: nil - - defp token_usage_paths do - [ - ["params", "msg", "payload", "info", "total_token_usage"], - [:params, :msg, :payload, :info, :total_token_usage], - ["params", "msg", "info", "total_token_usage"], - [:params, :msg, :info, :total_token_usage], - ["params", "tokenUsage", "total"], - [:params, :tokenUsage, :total] - ] - end - - defp delta_paths do - [ - ["params", "delta"], - [:params, :delta], - ["params", "msg", "delta"], - [:params, :msg, :delta], - ["params", "textDelta"], - [:params, :textDelta], - ["params", "msg", "textDelta"], - [:params, :msg, :textDelta], - ["params", "outputDelta"], - [:params, :outputDelta], - ["params", "msg", "outputDelta"], - [:params, :msg, :outputDelta], - ["params", "text"], - [:params, :text], - ["params", "msg", "text"], - [:params, :msg, :text], - ["params", "summaryText"], - [:params, :summaryText], - ["params", "msg", "summaryText"], - [:params, :msg, :summaryText], - ["params", "msg", "content"], - [:params, :msg, :content], - ["params", "msg", "payload", "delta"], - [:params, :msg, :payload, :delta], - ["params", "msg", "payload", "textDelta"], - [:params, :msg, :payload, :textDelta], - ["params", "msg", "payload", "outputDelta"], - [:params, :msg, :payload, :outputDelta], - ["params", "msg", "payload", "text"], - [:params, :msg, :payload, :text], - ["params", "msg", "payload", "summaryText"], - [:params, :msg, :payload, :summaryText], - ["params", "msg", "payload", "content"], - [:params, :msg, :payload, :content] - ] - end - - defp reasoning_focus_paths do - [ - ["params", "reason"], - [:params, :reason], - ["params", "summaryText"], - [:params, :summaryText], - ["params", "summary"], - [:params, :summary], - ["params", "text"], - [:params, :text], - ["params", "msg", "reason"], - [:params, :msg, :reason], - ["params", "msg", "summaryText"], - [:params, :msg, :summaryText], - ["params", "msg", "summary"], - [:params, :msg, :summary], - ["params", "msg", "text"], - [:params, :msg, :text], - ["params", "msg", "payload", "reason"], - [:params, :msg, :payload, :reason], - ["params", "msg", "payload", "summaryText"], - [:params, :msg, :payload, :summaryText], - ["params", "msg", "payload", "summary"], - [:params, :msg, :payload, :summary], - ["params", "msg", "payload", "text"], - [:params, :msg, :payload, :text] - ] - end - - defp extract_first_path(payload, paths) do - Enum.find_value(paths, fn path -> - map_path(payload, path) - end) - end - - defp map_path(data, [key | rest]) when is_map(data) do - case fetch_map_key(data, key) do - {:ok, value} when rest == [] -> value - {:ok, value} -> map_path(value, rest) - :error -> nil - end - end - - defp map_path(_data, _path), do: nil - - defp fetch_map_key(map, key) when is_map(map) do - case Map.fetch(map, key) do - {:ok, value} -> - {:ok, value} - - :error -> - alternate = alternate_key(key) - - if alternate == key do - :error - else - Map.fetch(map, alternate) - end - end - end - - defp alternate_key(key) when is_binary(key) do - String.to_existing_atom(key) - rescue - ArgumentError -> key - end - - defp alternate_key(key) when is_atom(key), do: Atom.to_string(key) - defp alternate_key(key), do: key - - defp truncate(value, max) when byte_size(value) > max do - value |> String.slice(0, max) |> Kernel.<>("...") - end - - defp truncate(value, _max), do: value - - defp dashboard_enabled? do - if Code.ensure_loaded?(Mix) and function_exported?(Mix, :env, 0) do - try do - Mix.env() != :test - rescue - _ -> true - end - else - true - end - end - - defp keyword_override(opts, key) do - if Keyword.has_key?(opts, key), do: Keyword.fetch!(opts, key), else: nil - end - - defp resolve_override(nil, default), do: default - defp resolve_override(override, _default), do: override -end diff --git a/elixir/lib/symphony_elixir/tracker.ex b/elixir/lib/symphony_elixir/tracker.ex deleted file mode 100644 index 504b54af30..0000000000 --- a/elixir/lib/symphony_elixir/tracker.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule SymphonyElixir.Tracker do - @moduledoc """ - Adapter boundary for issue tracker reads and writes. - """ - - alias SymphonyElixir.Config - - @callback fetch_candidate_issues() :: {:ok, [term()]} | {:error, term()} - @callback fetch_issues_by_states([String.t()]) :: {:ok, [term()]} | {:error, term()} - @callback fetch_issue_states_by_ids([String.t()]) :: {:ok, [term()]} | {:error, term()} - @callback create_comment(String.t(), String.t()) :: :ok | {:error, term()} - @callback update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} - - @spec fetch_candidate_issues() :: {:ok, [term()]} | {:error, term()} - def fetch_candidate_issues do - adapter().fetch_candidate_issues() - end - - @spec fetch_issues_by_states([String.t()]) :: {:ok, [term()]} | {:error, term()} - def fetch_issues_by_states(states) do - adapter().fetch_issues_by_states(states) - end - - @spec fetch_issue_states_by_ids([String.t()]) :: {:ok, [term()]} | {:error, term()} - def fetch_issue_states_by_ids(issue_ids) do - adapter().fetch_issue_states_by_ids(issue_ids) - end - - @spec create_comment(String.t(), String.t()) :: :ok | {:error, term()} - def create_comment(issue_id, body) do - adapter().create_comment(issue_id, body) - end - - @spec update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} - def update_issue_state(issue_id, state_name) do - adapter().update_issue_state(issue_id, state_name) - end - - @spec adapter() :: module() - def adapter do - case Config.tracker_kind() do - "memory" -> SymphonyElixir.Tracker.Memory - _ -> SymphonyElixir.Linear.Adapter - end - end -end diff --git a/elixir/lib/symphony_elixir/tracker/memory.ex b/elixir/lib/symphony_elixir/tracker/memory.ex deleted file mode 100644 index ad84a23c6b..0000000000 --- a/elixir/lib/symphony_elixir/tracker/memory.ex +++ /dev/null @@ -1,72 +0,0 @@ -defmodule SymphonyElixir.Tracker.Memory do - @moduledoc """ - In-memory tracker adapter used for tests and local development. - """ - - @behaviour SymphonyElixir.Tracker - - alias SymphonyElixir.Linear.Issue - - @spec fetch_candidate_issues() :: {:ok, [Issue.t()]} | {:error, term()} - def fetch_candidate_issues do - {:ok, issue_entries()} - end - - @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} - def fetch_issues_by_states(state_names) do - normalized_states = - state_names - |> Enum.map(&normalize_state/1) - |> MapSet.new() - - {:ok, - Enum.filter(issue_entries(), fn %Issue{state: state} -> - MapSet.member?(normalized_states, normalize_state(state)) - end)} - end - - @spec fetch_issue_states_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} - def fetch_issue_states_by_ids(issue_ids) do - wanted_ids = MapSet.new(issue_ids) - - {:ok, - Enum.filter(issue_entries(), fn %Issue{id: id} -> - MapSet.member?(wanted_ids, id) - end)} - end - - @spec create_comment(String.t(), String.t()) :: :ok | {:error, term()} - def create_comment(issue_id, body) do - send_event({:memory_tracker_comment, issue_id, body}) - :ok - end - - @spec update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} - def update_issue_state(issue_id, state_name) do - send_event({:memory_tracker_state_update, issue_id, state_name}) - :ok - end - - defp configured_issues do - Application.get_env(:symphony_elixir, :memory_tracker_issues, []) - end - - defp issue_entries do - Enum.filter(configured_issues(), &match?(%Issue{}, &1)) - end - - defp send_event(message) do - case Application.get_env(:symphony_elixir, :memory_tracker_recipient) do - pid when is_pid(pid) -> send(pid, message) - _ -> :ok - end - end - - defp normalize_state(state) when is_binary(state) do - state - |> String.trim() - |> String.downcase() - end - - defp normalize_state(_state), do: "" -end diff --git a/elixir/lib/symphony_elixir/workflow.ex b/elixir/lib/symphony_elixir/workflow.ex deleted file mode 100644 index 9c25cedd70..0000000000 --- a/elixir/lib/symphony_elixir/workflow.ex +++ /dev/null @@ -1,123 +0,0 @@ -defmodule SymphonyElixir.Workflow do - @moduledoc """ - Loads workflow configuration and prompt from WORKFLOW.md. - """ - - alias SymphonyElixir.WorkflowStore - - @workflow_file_name "WORKFLOW.md" - - @spec workflow_file_path() :: Path.t() - def workflow_file_path do - Application.get_env(:symphony_elixir, :workflow_file_path) || - Path.join(File.cwd!(), @workflow_file_name) - end - - @spec set_workflow_file_path(Path.t()) :: :ok - def set_workflow_file_path(path) when is_binary(path) do - Application.put_env(:symphony_elixir, :workflow_file_path, path) - maybe_reload_store() - :ok - end - - @spec clear_workflow_file_path() :: :ok - def clear_workflow_file_path do - Application.delete_env(:symphony_elixir, :workflow_file_path) - maybe_reload_store() - :ok - end - - @type loaded_workflow :: %{ - config: map(), - prompt: String.t(), - prompt_template: String.t() - } - - @spec current() :: {:ok, loaded_workflow()} | {:error, term()} - def current do - case Process.whereis(WorkflowStore) do - pid when is_pid(pid) -> - WorkflowStore.current() - - _ -> - load() - end - end - - @spec load() :: {:ok, loaded_workflow()} | {:error, term()} - def load do - load(workflow_file_path()) - end - - @spec load(Path.t()) :: {:ok, loaded_workflow()} | {:error, term()} - def load(path) when is_binary(path) do - case File.read(path) do - {:ok, content} -> - parse(content) - - {:error, reason} -> - {:error, {:missing_workflow_file, path, reason}} - end - end - - defp parse(content) do - {front_matter_lines, prompt_lines} = split_front_matter(content) - - case front_matter_yaml_to_map(front_matter_lines) do - {:ok, front_matter} -> - prompt = Enum.join(prompt_lines, "\n") |> String.trim() - - {:ok, - %{ - config: front_matter, - prompt: prompt, - prompt_template: prompt - }} - - {:error, :workflow_front_matter_not_a_map} -> - {:error, :workflow_front_matter_not_a_map} - - {:error, reason} -> - {:error, {:workflow_parse_error, reason}} - end - end - - defp split_front_matter(content) do - lines = String.split(content, ~r/\R/, trim: false) - - case lines do - ["---" | tail] -> - {front, rest} = Enum.split_while(tail, &(&1 != "---")) - - case rest do - ["---" | prompt_lines] -> {front, prompt_lines} - _ -> {front, []} - end - - _ -> - {[], lines} - end - end - - defp front_matter_yaml_to_map(lines) do - yaml = Enum.join(lines, "\n") - - if String.trim(yaml) == "" do - {:ok, %{}} - else - case YamlElixir.read_from_string(yaml) do - {:ok, decoded} when is_map(decoded) -> {:ok, decoded} - {:ok, _} -> {:error, :workflow_front_matter_not_a_map} - {:error, reason} -> {:error, reason} - end - end - end - - defp maybe_reload_store do - if Process.whereis(WorkflowStore) do - _ = WorkflowStore.force_reload() - end - - :ok - end -end diff --git a/elixir/lib/symphony_elixir/workflow_store.ex b/elixir/lib/symphony_elixir/workflow_store.ex deleted file mode 100644 index 3d687fd9b5..0000000000 --- a/elixir/lib/symphony_elixir/workflow_store.ex +++ /dev/null @@ -1,153 +0,0 @@ -defmodule SymphonyElixir.WorkflowStore do - @moduledoc """ - Caches the last known good workflow and reloads it when `WORKFLOW.md` changes. - """ - - use GenServer - require Logger - - alias SymphonyElixir.Workflow - - @poll_interval_ms 1_000 - - defmodule State do - @moduledoc false - - defstruct [:path, :stamp, :workflow] - end - - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - GenServer.start_link(__MODULE__, opts, name: __MODULE__) - end - - @spec current() :: {:ok, Workflow.loaded_workflow()} | {:error, term()} - def current do - case Process.whereis(__MODULE__) do - pid when is_pid(pid) -> - GenServer.call(__MODULE__, :current) - - _ -> - Workflow.load() - end - end - - @spec force_reload() :: :ok | {:error, term()} - def force_reload do - case Process.whereis(__MODULE__) do - pid when is_pid(pid) -> - GenServer.call(__MODULE__, :force_reload) - - _ -> - case Workflow.load() do - {:ok, _workflow} -> :ok - {:error, reason} -> {:error, reason} - end - end - end - - @impl true - def init(_opts) do - case load_state(Workflow.workflow_file_path()) do - {:ok, state} -> - schedule_poll() - {:ok, state} - - {:error, reason} -> - {:stop, reason} - end - end - - @impl true - def handle_call(:current, _from, %State{} = state) do - case reload_state(state) do - {:ok, new_state} -> - {:reply, {:ok, new_state.workflow}, new_state} - - {:error, _reason, new_state} -> - {:reply, {:ok, new_state.workflow}, new_state} - end - end - - def handle_call(:force_reload, _from, %State{} = state) do - case reload_state(state) do - {:ok, new_state} -> - {:reply, :ok, new_state} - - {:error, reason, new_state} -> - {:reply, {:error, reason}, new_state} - end - end - - @impl true - def handle_info(:poll, %State{} = state) do - schedule_poll() - - case reload_state(state) do - {:ok, new_state} -> {:noreply, new_state} - {:error, _reason, new_state} -> {:noreply, new_state} - end - end - - defp schedule_poll do - Process.send_after(self(), :poll, @poll_interval_ms) - end - - defp reload_state(%State{} = state) do - path = Workflow.workflow_file_path() - - if path != state.path do - reload_path(path, state) - else - reload_current_path(path, state) - end - end - - defp reload_path(path, state) do - case load_state(path) do - {:ok, new_state} -> - {:ok, new_state} - - {:error, reason} -> - log_reload_error(path, reason) - {:error, reason, state} - end - end - - defp reload_current_path(path, state) do - case current_stamp(path) do - {:ok, stamp} when stamp == state.stamp -> - {:ok, state} - - {:ok, _stamp} -> - reload_path(path, state) - - {:error, reason} -> - log_reload_error(path, reason) - {:error, reason, state} - end - end - - defp load_state(path) do - with {:ok, workflow} <- Workflow.load(path), - {:ok, stamp} <- current_stamp(path) do - {:ok, %State{path: path, stamp: stamp, workflow: workflow}} - else - {:error, reason} -> - {:error, reason} - end - end - - defp current_stamp(path) when is_binary(path) do - with {:ok, stat} <- File.stat(path, time: :posix), - {:ok, content} <- File.read(path) do - {:ok, {stat.mtime, stat.size, :erlang.phash2(content)}} - else - {:error, reason} -> {:error, reason} - end - end - - defp log_reload_error(path, reason) do - Logger.error("Failed to reload workflow path=#{path} reason=#{inspect(reason)}; keeping last known good configuration") - end -end diff --git a/elixir/lib/symphony_elixir/workspace.ex b/elixir/lib/symphony_elixir/workspace.ex deleted file mode 100644 index e8c085ea56..0000000000 --- a/elixir/lib/symphony_elixir/workspace.ex +++ /dev/null @@ -1,282 +0,0 @@ -defmodule SymphonyElixir.Workspace do - @moduledoc """ - Creates isolated per-issue workspaces for parallel Codex agents. - """ - - require Logger - alias SymphonyElixir.Config - - @excluded_entries MapSet.new([".elixir_ls", "tmp"]) - - @spec create_for_issue(map() | String.t() | nil) :: {:ok, Path.t()} | {:error, term()} - def create_for_issue(issue_or_identifier) do - issue_context = issue_context(issue_or_identifier) - - try do - safe_id = safe_identifier(issue_context.issue_identifier) - - workspace = workspace_path_for_issue(safe_id) - - with :ok <- validate_workspace_path(workspace), - {:ok, created?} <- ensure_workspace(workspace), - :ok <- maybe_run_after_create_hook(workspace, issue_context, created?) do - {:ok, workspace} - end - rescue - error in [ArgumentError, ErlangError, File.Error] -> - Logger.error("Workspace creation failed #{issue_log_context(issue_context)} error=#{Exception.message(error)}") - {:error, error} - end - end - - defp ensure_workspace(workspace) do - cond do - File.dir?(workspace) -> - clean_tmp_artifacts(workspace) - {:ok, false} - - File.exists?(workspace) -> - File.rm_rf!(workspace) - create_workspace(workspace) - - true -> - create_workspace(workspace) - end - end - - defp create_workspace(workspace) do - File.rm_rf!(workspace) - File.mkdir_p!(workspace) - {:ok, true} - end - - @spec remove(Path.t()) :: {:ok, [String.t()]} | {:error, term(), String.t()} - def remove(workspace) do - case File.exists?(workspace) do - true -> - case validate_workspace_path(workspace) do - :ok -> - maybe_run_before_remove_hook(workspace) - File.rm_rf(workspace) - - {:error, reason} -> - {:error, reason, ""} - end - - false -> - File.rm_rf(workspace) - end - end - - @spec remove_issue_workspaces(term()) :: :ok - def remove_issue_workspaces(identifier) when is_binary(identifier) do - safe_id = safe_identifier(identifier) - workspace = Path.join(Config.workspace_root(), safe_id) - - remove(workspace) - :ok - end - - def remove_issue_workspaces(_identifier) do - :ok - end - - @spec run_before_run_hook(Path.t(), map() | String.t() | nil) :: :ok | {:error, term()} - def run_before_run_hook(workspace, issue_or_identifier) when is_binary(workspace) do - issue_context = issue_context(issue_or_identifier) - - case Config.workspace_hooks()[:before_run] do - nil -> - :ok - - command -> - run_hook(command, workspace, issue_context, "before_run") - end - end - - @spec run_after_run_hook(Path.t(), map() | String.t() | nil) :: :ok - def run_after_run_hook(workspace, issue_or_identifier) when is_binary(workspace) do - issue_context = issue_context(issue_or_identifier) - - case Config.workspace_hooks()[:after_run] do - nil -> - :ok - - command -> - run_hook(command, workspace, issue_context, "after_run") - |> ignore_hook_failure() - end - end - - defp workspace_path_for_issue(safe_id) when is_binary(safe_id) do - Path.join(Config.workspace_root(), safe_id) - end - - defp safe_identifier(identifier) do - String.replace(identifier || "issue", ~r/[^a-zA-Z0-9._-]/, "_") - end - - defp clean_tmp_artifacts(workspace) do - Enum.each(MapSet.to_list(@excluded_entries), fn entry -> - File.rm_rf(Path.join(workspace, entry)) - end) - end - - defp maybe_run_after_create_hook(workspace, issue_context, created?) do - case created? do - true -> - case Config.workspace_hooks()[:after_create] do - nil -> - :ok - - command -> - run_hook(command, workspace, issue_context, "after_create") - end - - false -> - :ok - end - end - - defp maybe_run_before_remove_hook(workspace) do - case File.dir?(workspace) do - true -> - case Config.workspace_hooks()[:before_remove] do - nil -> - :ok - - command -> - run_hook( - command, - workspace, - %{issue_id: nil, issue_identifier: Path.basename(workspace)}, - "before_remove" - ) - |> ignore_hook_failure() - end - - false -> - :ok - end - end - - defp ignore_hook_failure(:ok), do: :ok - defp ignore_hook_failure({:error, _reason}), do: :ok - - defp run_hook(command, workspace, issue_context, hook_name) do - timeout_ms = Config.workspace_hooks()[:timeout_ms] - - Logger.info("Running workspace hook hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace}") - - task = - Task.async(fn -> - System.cmd("sh", ["-lc", command], cd: workspace, stderr_to_stdout: true) - end) - - case Task.yield(task, timeout_ms) do - {:ok, cmd_result} -> - handle_hook_command_result(cmd_result, workspace, issue_context, hook_name) - - nil -> - Task.shutdown(task, :brutal_kill) - - Logger.warning("Workspace hook timed out hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace} timeout_ms=#{timeout_ms}") - - {:error, {:workspace_hook_timeout, hook_name, timeout_ms}} - end - end - - defp handle_hook_command_result({_output, 0}, _workspace, _issue_id, _hook_name) do - :ok - end - - defp handle_hook_command_result({output, status}, workspace, issue_context, hook_name) do - sanitized_output = sanitize_hook_output_for_log(output) - - Logger.warning("Workspace hook failed hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace} status=#{status} output=#{inspect(sanitized_output)}") - - {:error, {:workspace_hook_failed, hook_name, status, output}} - end - - defp sanitize_hook_output_for_log(output, max_bytes \\ 2_048) do - binary_output = IO.iodata_to_binary(output) - - case byte_size(binary_output) <= max_bytes do - true -> - binary_output - - false -> - binary_part(binary_output, 0, max_bytes) <> "... (truncated)" - end - end - - defp validate_workspace_path(workspace) when is_binary(workspace) do - expanded_workspace = Path.expand(workspace) - root = Path.expand(Config.workspace_root()) - root_prefix = root <> "/" - - cond do - expanded_workspace == root -> - {:error, {:workspace_equals_root, expanded_workspace, root}} - - String.starts_with?(expanded_workspace <> "/", root_prefix) -> - ensure_no_symlink_components(expanded_workspace, root) - - true -> - {:error, {:workspace_outside_root, expanded_workspace, root}} - end - end - - defp ensure_no_symlink_components(workspace, root) do - workspace - |> Path.relative_to(root) - |> Path.split() - |> Enum.reduce_while(root, fn segment, current_path -> - next_path = Path.join(current_path, segment) - - case File.lstat(next_path) do - {:ok, %File.Stat{type: :symlink}} -> - {:halt, {:error, {:workspace_symlink_escape, next_path, root}}} - - {:ok, _stat} -> - {:cont, next_path} - - {:error, :enoent} -> - {:halt, :ok} - - {:error, reason} -> - {:halt, {:error, {:workspace_path_unreadable, next_path, reason}}} - end - end) - |> case do - :ok -> :ok - {:error, _reason} = error -> error - _final_path -> :ok - end - end - - defp issue_context(%{id: issue_id, identifier: identifier}) do - %{ - issue_id: issue_id, - issue_identifier: identifier || "issue" - } - end - - defp issue_context(identifier) when is_binary(identifier) do - %{ - issue_id: nil, - issue_identifier: identifier - } - end - - defp issue_context(_identifier) do - %{ - issue_id: nil, - issue_identifier: "issue" - } - end - - defp issue_log_context(%{issue_id: issue_id, issue_identifier: issue_identifier}) do - "issue_id=#{issue_id || "n/a"} issue_identifier=#{issue_identifier || "issue"}" - end -end diff --git a/elixir/lib/symphony_elixir_web/components/layouts.ex b/elixir/lib/symphony_elixir_web/components/layouts.ex deleted file mode 100644 index afac13e3fc..0000000000 --- a/elixir/lib/symphony_elixir_web/components/layouts.ex +++ /dev/null @@ -1,56 +0,0 @@ -defmodule SymphonyElixirWeb.Layouts do - @moduledoc """ - Shared layouts for the observability dashboard. - """ - - use Phoenix.Component - - @spec root(map()) :: Phoenix.LiveView.Rendered.t() - def root(assigns) do - assigns = assign(assigns, :csrf_token, Plug.CSRFProtection.get_csrf_token()) - - ~H""" - - - - - - - Symphony Observability - - - - - - - - {@inner_content} - - - """ - end - - @spec app(map()) :: Phoenix.LiveView.Rendered.t() - def app(assigns) do - ~H""" -
- {@inner_content} -
- """ - end -end diff --git a/elixir/lib/symphony_elixir_web/controllers/observability_api_controller.ex b/elixir/lib/symphony_elixir_web/controllers/observability_api_controller.ex deleted file mode 100644 index da764f12dd..0000000000 --- a/elixir/lib/symphony_elixir_web/controllers/observability_api_controller.ex +++ /dev/null @@ -1,63 +0,0 @@ -defmodule SymphonyElixirWeb.ObservabilityApiController do - @moduledoc """ - JSON API for Symphony observability data. - """ - - use Phoenix.Controller, formats: [:json] - - alias Plug.Conn - alias SymphonyElixirWeb.{Endpoint, Presenter} - - @spec state(Conn.t(), map()) :: Conn.t() - def state(conn, _params) do - json(conn, Presenter.state_payload(orchestrator(), snapshot_timeout_ms())) - end - - @spec issue(Conn.t(), map()) :: Conn.t() - def issue(conn, %{"issue_identifier" => issue_identifier}) do - case Presenter.issue_payload(issue_identifier, orchestrator(), snapshot_timeout_ms()) do - {:ok, payload} -> - json(conn, payload) - - {:error, :issue_not_found} -> - error_response(conn, 404, "issue_not_found", "Issue not found") - end - end - - @spec refresh(Conn.t(), map()) :: Conn.t() - def refresh(conn, _params) do - case Presenter.refresh_payload(orchestrator()) do - {:ok, payload} -> - conn - |> put_status(202) - |> json(payload) - - {:error, :unavailable} -> - error_response(conn, 503, "orchestrator_unavailable", "Orchestrator is unavailable") - end - end - - @spec method_not_allowed(Conn.t(), map()) :: Conn.t() - def method_not_allowed(conn, _params) do - error_response(conn, 405, "method_not_allowed", "Method not allowed") - end - - @spec not_found(Conn.t(), map()) :: Conn.t() - def not_found(conn, _params) do - error_response(conn, 404, "not_found", "Route not found") - end - - defp error_response(conn, status, code, message) do - conn - |> put_status(status) - |> json(%{error: %{code: code, message: message}}) - end - - defp orchestrator do - Endpoint.config(:orchestrator) || SymphonyElixir.Orchestrator - end - - defp snapshot_timeout_ms do - Endpoint.config(:snapshot_timeout_ms) || 15_000 - end -end diff --git a/elixir/lib/symphony_elixir_web/controllers/static_asset_controller.ex b/elixir/lib/symphony_elixir_web/controllers/static_asset_controller.ex deleted file mode 100644 index faaaf46c9f..0000000000 --- a/elixir/lib/symphony_elixir_web/controllers/static_asset_controller.ex +++ /dev/null @@ -1,35 +0,0 @@ -defmodule SymphonyElixirWeb.StaticAssetController do - @moduledoc """ - Serves the dashboard's embedded CSS and JavaScript assets. - """ - - use Phoenix.Controller, formats: [] - - alias Plug.Conn - alias SymphonyElixirWeb.StaticAssets - - @spec dashboard_css(Conn.t(), map()) :: Conn.t() - def dashboard_css(conn, _params), do: serve(conn, "/dashboard.css") - - @spec phoenix_html_js(Conn.t(), map()) :: Conn.t() - def phoenix_html_js(conn, _params), do: serve(conn, "/vendor/phoenix_html/phoenix_html.js") - - @spec phoenix_js(Conn.t(), map()) :: Conn.t() - def phoenix_js(conn, _params), do: serve(conn, "/vendor/phoenix/phoenix.js") - - @spec phoenix_live_view_js(Conn.t(), map()) :: Conn.t() - def phoenix_live_view_js(conn, _params), do: serve(conn, "/vendor/phoenix_live_view/phoenix_live_view.js") - - defp serve(conn, path) do - case StaticAssets.fetch(path) do - {:ok, content_type, body} -> - conn - |> put_resp_content_type(content_type) - |> put_resp_header("cache-control", "public, max-age=31536000") - |> send_resp(200, body) - - :error -> - send_resp(conn, 404, "Not Found") - end - end -end diff --git a/elixir/lib/symphony_elixir_web/endpoint.ex b/elixir/lib/symphony_elixir_web/endpoint.ex deleted file mode 100644 index 200e38a688..0000000000 --- a/elixir/lib/symphony_elixir_web/endpoint.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule SymphonyElixirWeb.Endpoint do - @moduledoc """ - Phoenix endpoint for Symphony's optional observability UI and API. - """ - - use Phoenix.Endpoint, otp_app: :symphony_elixir - - @session_options [ - store: :cookie, - key: "_symphony_elixir_key", - signing_salt: "symphony-session" - ] - - socket("/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [session: @session_options]], - longpoll: false - ) - - plug(Plug.RequestId) - plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) - - plug(Plug.Parsers, - parsers: [:urlencoded, :multipart, :json], - pass: ["*/*"], - json_decoder: Jason - ) - - plug(Plug.MethodOverride) - plug(Plug.Head) - plug(Plug.Session, @session_options) - plug(SymphonyElixirWeb.Router) -end diff --git a/elixir/lib/symphony_elixir_web/error_html.ex b/elixir/lib/symphony_elixir_web/error_html.ex deleted file mode 100644 index 5b2722a26b..0000000000 --- a/elixir/lib/symphony_elixir_web/error_html.ex +++ /dev/null @@ -1,8 +0,0 @@ -defmodule SymphonyElixirWeb.ErrorHTML do - @moduledoc false - - @spec render(String.t(), map()) :: String.t() - def render(template, _assigns) do - Phoenix.Controller.status_message_from_template(template) - end -end diff --git a/elixir/lib/symphony_elixir_web/error_json.ex b/elixir/lib/symphony_elixir_web/error_json.ex deleted file mode 100644 index 5babea4c28..0000000000 --- a/elixir/lib/symphony_elixir_web/error_json.ex +++ /dev/null @@ -1,8 +0,0 @@ -defmodule SymphonyElixirWeb.ErrorJSON do - @moduledoc false - - @spec render(String.t(), map()) :: map() - def render(template, _assigns) do - %{error: %{code: "request_failed", message: Phoenix.Controller.status_message_from_template(template)}} - end -end diff --git a/elixir/lib/symphony_elixir_web/live/dashboard_live.ex b/elixir/lib/symphony_elixir_web/live/dashboard_live.ex deleted file mode 100644 index a30631c113..0000000000 --- a/elixir/lib/symphony_elixir_web/live/dashboard_live.ex +++ /dev/null @@ -1,330 +0,0 @@ -defmodule SymphonyElixirWeb.DashboardLive do - @moduledoc """ - Live observability dashboard for Symphony. - """ - - use Phoenix.LiveView, layout: {SymphonyElixirWeb.Layouts, :app} - - alias SymphonyElixirWeb.{Endpoint, ObservabilityPubSub, Presenter} - @runtime_tick_ms 1_000 - - @impl true - def mount(_params, _session, socket) do - socket = - socket - |> assign(:payload, load_payload()) - |> assign(:now, DateTime.utc_now()) - - if connected?(socket) do - :ok = ObservabilityPubSub.subscribe() - schedule_runtime_tick() - end - - {:ok, socket} - end - - @impl true - def handle_info(:runtime_tick, socket) do - schedule_runtime_tick() - {:noreply, assign(socket, :now, DateTime.utc_now())} - end - - @impl true - def handle_info(:observability_updated, socket) do - {:noreply, - socket - |> assign(:payload, load_payload()) - |> assign(:now, DateTime.utc_now())} - end - - @impl true - def render(assigns) do - ~H""" -
-
-
-
-

- Symphony Observability -

-

- Operations Dashboard -

-

- Current state, retry pressure, token usage, and orchestration health for the active Symphony runtime. -

-
- -
- - - Live - - - - Offline - -
-
-
- - <%= if @payload[:error] do %> -
-

- Snapshot unavailable -

-

- <%= @payload.error.code %>: <%= @payload.error.message %> -

-
- <% else %> -
-
-

Running

-

<%= @payload.counts.running %>

-

Active issue sessions in the current runtime.

-
- -
-

Retrying

-

<%= @payload.counts.retrying %>

-

Issues waiting for the next retry window.

-
- -
-

Total tokens

-

<%= format_int(@payload.codex_totals.total_tokens) %>

-

- In <%= format_int(@payload.codex_totals.input_tokens) %> / Out <%= format_int(@payload.codex_totals.output_tokens) %> -

-
- -
-

Runtime

-

<%= format_runtime_seconds(total_runtime_seconds(@payload, @now)) %>

-

Total Codex runtime across completed and active sessions.

-
-
- -
-
-
-

Rate limits

-

Latest upstream rate-limit snapshot, when available.

-
-
- -
<%= pretty_value(@payload.rate_limits) %>
-
- -
-
-
-

Running sessions

-

Active issues, last known agent activity, and token usage.

-
-
- - <%= if @payload.running == [] do %> -

No active sessions.

- <% else %> -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IssueStateSessionRuntime / turnsCodex updateTokens
-
- <%= entry.issue_identifier %> - JSON details -
-
- - <%= entry.state %> - - -
- <%= if entry.session_id do %> - - <% else %> - n/a - <% end %> -
-
<%= format_runtime_and_turns(entry.started_at, entry.turn_count, @now) %> -
- <%= entry.last_message || to_string(entry.last_event || "n/a") %> - - <%= entry.last_event || "n/a" %> - <%= if entry.last_event_at do %> - · <%= entry.last_event_at %> - <% end %> - -
-
-
- Total: <%= format_int(entry.tokens.total_tokens) %> - In <%= format_int(entry.tokens.input_tokens) %> / Out <%= format_int(entry.tokens.output_tokens) %> -
-
-
- <% end %> -
- -
-
-
-

Retry queue

-

Issues waiting for the next retry window.

-
-
- - <%= if @payload.retrying == [] do %> -

No issues are currently backing off.

- <% else %> -
- - - - - - - - - - - - - - - - - -
IssueAttemptDue atError
-
- <%= entry.issue_identifier %> - JSON details -
-
<%= entry.attempt %><%= entry.due_at || "n/a" %><%= entry.error || "n/a" %>
-
- <% end %> -
- <% end %> -
- """ - end - - defp load_payload do - Presenter.state_payload(orchestrator(), snapshot_timeout_ms()) - end - - defp orchestrator do - Endpoint.config(:orchestrator) || SymphonyElixir.Orchestrator - end - - defp snapshot_timeout_ms do - Endpoint.config(:snapshot_timeout_ms) || 15_000 - end - - defp completed_runtime_seconds(payload) do - payload.codex_totals.seconds_running || 0 - end - - defp total_runtime_seconds(payload, now) do - completed_runtime_seconds(payload) + - Enum.reduce(payload.running, 0, fn entry, total -> - total + runtime_seconds_from_started_at(entry.started_at, now) - end) - end - - defp format_runtime_and_turns(started_at, turn_count, now) when is_integer(turn_count) and turn_count > 0 do - "#{format_runtime_seconds(runtime_seconds_from_started_at(started_at, now))} / #{turn_count}" - end - - defp format_runtime_and_turns(started_at, _turn_count, now), - do: format_runtime_seconds(runtime_seconds_from_started_at(started_at, now)) - - defp format_runtime_seconds(seconds) when is_number(seconds) do - whole_seconds = max(trunc(seconds), 0) - mins = div(whole_seconds, 60) - secs = rem(whole_seconds, 60) - "#{mins}m #{secs}s" - end - - defp runtime_seconds_from_started_at(%DateTime{} = started_at, %DateTime{} = now) do - DateTime.diff(now, started_at, :second) - end - - defp runtime_seconds_from_started_at(started_at, %DateTime{} = now) when is_binary(started_at) do - case DateTime.from_iso8601(started_at) do - {:ok, parsed, _offset} -> runtime_seconds_from_started_at(parsed, now) - _ -> 0 - end - end - - defp runtime_seconds_from_started_at(_started_at, _now), do: 0 - - defp format_int(value) when is_integer(value) do - value - |> Integer.to_string() - |> String.reverse() - |> String.replace(~r/.{3}(?=.)/, "\\0,") - |> String.reverse() - end - - defp format_int(_value), do: "n/a" - - defp state_badge_class(state) do - base = "state-badge" - normalized = state |> to_string() |> String.downcase() - - cond do - String.contains?(normalized, ["progress", "running", "active"]) -> "#{base} state-badge-active" - String.contains?(normalized, ["blocked", "error", "failed"]) -> "#{base} state-badge-danger" - String.contains?(normalized, ["todo", "queued", "pending", "retry"]) -> "#{base} state-badge-warning" - true -> base - end - end - - defp schedule_runtime_tick do - Process.send_after(self(), :runtime_tick, @runtime_tick_ms) - end - - defp pretty_value(nil), do: "n/a" - defp pretty_value(value), do: inspect(value, pretty: true, limit: :infinity) -end diff --git a/elixir/lib/symphony_elixir_web/observability_pubsub.ex b/elixir/lib/symphony_elixir_web/observability_pubsub.ex deleted file mode 100644 index a3fead26f2..0000000000 --- a/elixir/lib/symphony_elixir_web/observability_pubsub.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule SymphonyElixirWeb.ObservabilityPubSub do - @moduledoc """ - PubSub helpers for observability dashboard updates. - """ - - @pubsub SymphonyElixir.PubSub - @topic "observability:dashboard" - @update_message :observability_updated - - @spec subscribe() :: :ok | {:error, term()} - def subscribe do - Phoenix.PubSub.subscribe(@pubsub, @topic) - end - - @spec broadcast_update() :: :ok - def broadcast_update do - case Process.whereis(@pubsub) do - pid when is_pid(pid) -> - Phoenix.PubSub.broadcast(@pubsub, @topic, @update_message) - - _ -> - :ok - end - end -end diff --git a/elixir/lib/symphony_elixir_web/presenter.ex b/elixir/lib/symphony_elixir_web/presenter.ex deleted file mode 100644 index 34eb1e6641..0000000000 --- a/elixir/lib/symphony_elixir_web/presenter.ex +++ /dev/null @@ -1,181 +0,0 @@ -defmodule SymphonyElixirWeb.Presenter do - @moduledoc """ - Shared projections for the observability API and dashboard. - """ - - alias SymphonyElixir.{Config, Orchestrator, StatusDashboard} - - @spec state_payload(GenServer.name(), timeout()) :: map() - def state_payload(orchestrator, snapshot_timeout_ms) do - generated_at = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() - - case Orchestrator.snapshot(orchestrator, snapshot_timeout_ms) do - %{} = snapshot -> - %{ - generated_at: generated_at, - counts: %{ - running: length(snapshot.running), - retrying: length(snapshot.retrying) - }, - running: Enum.map(snapshot.running, &running_entry_payload/1), - retrying: Enum.map(snapshot.retrying, &retry_entry_payload/1), - codex_totals: snapshot.codex_totals, - rate_limits: snapshot.rate_limits - } - - :timeout -> - %{generated_at: generated_at, error: %{code: "snapshot_timeout", message: "Snapshot timed out"}} - - :unavailable -> - %{generated_at: generated_at, error: %{code: "snapshot_unavailable", message: "Snapshot unavailable"}} - end - end - - @spec issue_payload(String.t(), GenServer.name(), timeout()) :: {:ok, map()} | {:error, :issue_not_found} - def issue_payload(issue_identifier, orchestrator, snapshot_timeout_ms) when is_binary(issue_identifier) do - case Orchestrator.snapshot(orchestrator, snapshot_timeout_ms) do - %{} = snapshot -> - running = Enum.find(snapshot.running, &(&1.identifier == issue_identifier)) - retry = Enum.find(snapshot.retrying, &(&1.identifier == issue_identifier)) - - if is_nil(running) and is_nil(retry) do - {:error, :issue_not_found} - else - {:ok, issue_payload_body(issue_identifier, running, retry)} - end - - _ -> - {:error, :issue_not_found} - end - end - - @spec refresh_payload(GenServer.name()) :: {:ok, map()} | {:error, :unavailable} - def refresh_payload(orchestrator) do - case Orchestrator.request_refresh(orchestrator) do - :unavailable -> - {:error, :unavailable} - - payload -> - {:ok, Map.update!(payload, :requested_at, &DateTime.to_iso8601/1)} - end - end - - defp issue_payload_body(issue_identifier, running, retry) do - %{ - issue_identifier: issue_identifier, - issue_id: issue_id_from_entries(running, retry), - status: issue_status(running, retry), - workspace: %{ - path: Path.join(Config.workspace_root(), issue_identifier) - }, - attempts: %{ - restart_count: restart_count(retry), - current_retry_attempt: retry_attempt(retry) - }, - running: running && running_issue_payload(running), - retry: retry && retry_issue_payload(retry), - logs: %{ - codex_session_logs: [] - }, - recent_events: (running && recent_events_payload(running)) || [], - last_error: retry && retry.error, - tracked: %{} - } - end - - defp issue_id_from_entries(running, retry), - do: (running && running.issue_id) || (retry && retry.issue_id) - - defp restart_count(retry), do: max(retry_attempt(retry) - 1, 0) - defp retry_attempt(nil), do: 0 - defp retry_attempt(retry), do: retry.attempt || 0 - - defp issue_status(_running, nil), do: "running" - defp issue_status(nil, _retry), do: "retrying" - defp issue_status(_running, _retry), do: "running" - - defp running_entry_payload(entry) do - %{ - issue_id: entry.issue_id, - issue_identifier: entry.identifier, - state: entry.state, - session_id: entry.session_id, - turn_count: Map.get(entry, :turn_count, 0), - last_event: entry.last_codex_event, - last_message: summarize_message(entry.last_codex_message), - started_at: iso8601(entry.started_at), - last_event_at: iso8601(entry.last_codex_timestamp), - tokens: %{ - input_tokens: entry.codex_input_tokens, - output_tokens: entry.codex_output_tokens, - total_tokens: entry.codex_total_tokens - } - } - end - - defp retry_entry_payload(entry) do - %{ - issue_id: entry.issue_id, - issue_identifier: entry.identifier, - attempt: entry.attempt, - due_at: due_at_iso8601(entry.due_in_ms), - error: entry.error - } - end - - defp running_issue_payload(running) do - %{ - session_id: running.session_id, - turn_count: Map.get(running, :turn_count, 0), - state: running.state, - started_at: iso8601(running.started_at), - last_event: running.last_codex_event, - last_message: summarize_message(running.last_codex_message), - last_event_at: iso8601(running.last_codex_timestamp), - tokens: %{ - input_tokens: running.codex_input_tokens, - output_tokens: running.codex_output_tokens, - total_tokens: running.codex_total_tokens - } - } - end - - defp retry_issue_payload(retry) do - %{ - attempt: retry.attempt, - due_at: due_at_iso8601(retry.due_in_ms), - error: retry.error - } - end - - defp recent_events_payload(running) do - [ - %{ - at: iso8601(running.last_codex_timestamp), - event: running.last_codex_event, - message: summarize_message(running.last_codex_message) - } - ] - |> Enum.reject(&is_nil(&1.at)) - end - - defp summarize_message(nil), do: nil - defp summarize_message(message), do: StatusDashboard.humanize_codex_message(message) - - defp due_at_iso8601(due_in_ms) when is_integer(due_in_ms) do - DateTime.utc_now() - |> DateTime.add(div(due_in_ms, 1_000), :second) - |> DateTime.truncate(:second) - |> DateTime.to_iso8601() - end - - defp due_at_iso8601(_due_in_ms), do: nil - - defp iso8601(%DateTime{} = datetime) do - datetime - |> DateTime.truncate(:second) - |> DateTime.to_iso8601() - end - - defp iso8601(_datetime), do: nil -end diff --git a/elixir/lib/symphony_elixir_web/router.ex b/elixir/lib/symphony_elixir_web/router.ex deleted file mode 100644 index e3f09a88db..0000000000 --- a/elixir/lib/symphony_elixir_web/router.ex +++ /dev/null @@ -1,41 +0,0 @@ -defmodule SymphonyElixirWeb.Router do - @moduledoc """ - Router for Symphony's observability dashboard and API. - """ - - use Phoenix.Router - import Phoenix.LiveView.Router - - pipeline :browser do - plug(:fetch_session) - plug(:fetch_live_flash) - plug(:put_root_layout, html: {SymphonyElixirWeb.Layouts, :root}) - plug(:protect_from_forgery) - plug(:put_secure_browser_headers) - end - - scope "/", SymphonyElixirWeb do - get("/dashboard.css", StaticAssetController, :dashboard_css) - get("/vendor/phoenix_html/phoenix_html.js", StaticAssetController, :phoenix_html_js) - get("/vendor/phoenix/phoenix.js", StaticAssetController, :phoenix_js) - get("/vendor/phoenix_live_view/phoenix_live_view.js", StaticAssetController, :phoenix_live_view_js) - end - - scope "/", SymphonyElixirWeb do - pipe_through(:browser) - - live("/", DashboardLive, :index) - end - - scope "/", SymphonyElixirWeb do - get("/api/v1/state", ObservabilityApiController, :state) - - match(:*, "/", ObservabilityApiController, :method_not_allowed) - match(:*, "/api/v1/state", ObservabilityApiController, :method_not_allowed) - post("/api/v1/refresh", ObservabilityApiController, :refresh) - match(:*, "/api/v1/refresh", ObservabilityApiController, :method_not_allowed) - get("/api/v1/:issue_identifier", ObservabilityApiController, :issue) - match(:*, "/api/v1/:issue_identifier", ObservabilityApiController, :method_not_allowed) - match(:*, "/*path", ObservabilityApiController, :not_found) - end -end diff --git a/elixir/lib/symphony_elixir_web/static_assets.ex b/elixir/lib/symphony_elixir_web/static_assets.ex deleted file mode 100644 index 7d6418b72b..0000000000 --- a/elixir/lib/symphony_elixir_web/static_assets.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule SymphonyElixirWeb.StaticAssets do - @moduledoc false - - @dashboard_css_path Path.expand("../../priv/static/dashboard.css", __DIR__) - @phoenix_html_js_path Application.app_dir(:phoenix_html, "priv/static/phoenix_html.js") - @phoenix_js_path Application.app_dir(:phoenix, "priv/static/phoenix.js") - @phoenix_live_view_js_path Application.app_dir(:phoenix_live_view, "priv/static/phoenix_live_view.js") - - @external_resource @dashboard_css_path - @external_resource @phoenix_html_js_path - @external_resource @phoenix_js_path - @external_resource @phoenix_live_view_js_path - - @dashboard_css File.read!(@dashboard_css_path) - @phoenix_html_js File.read!(@phoenix_html_js_path) - @phoenix_js File.read!(@phoenix_js_path) - @phoenix_live_view_js File.read!(@phoenix_live_view_js_path) - - @assets %{ - "/dashboard.css" => {"text/css", @dashboard_css}, - "/vendor/phoenix_html/phoenix_html.js" => {"application/javascript", @phoenix_html_js}, - "/vendor/phoenix/phoenix.js" => {"application/javascript", @phoenix_js}, - "/vendor/phoenix_live_view/phoenix_live_view.js" => {"application/javascript", @phoenix_live_view_js} - } - - @spec fetch(String.t()) :: {:ok, String.t(), binary()} | :error - def fetch(path) when is_binary(path) do - case Map.fetch(@assets, path) do - {:ok, {content_type, body}} -> {:ok, content_type, body} - :error -> :error - end - end -end diff --git a/elixir/mise.toml b/elixir/mise.toml deleted file mode 100644 index 439bbb2617..0000000000 --- a/elixir/mise.toml +++ /dev/null @@ -1,3 +0,0 @@ -[tools] -erlang = "28" -elixir = "1.19.5-otp-28" diff --git a/elixir/mix.exs b/elixir/mix.exs deleted file mode 100644 index 062706aab7..0000000000 --- a/elixir/mix.exs +++ /dev/null @@ -1,98 +0,0 @@ -defmodule SymphonyElixir.MixProject do - use Mix.Project - - def project do - [ - app: :symphony_elixir, - version: "0.1.0", - elixir: "~> 1.19", - compilers: [:phoenix_live_view] ++ Mix.compilers(), - start_permanent: Mix.env() == :prod, - test_coverage: [ - summary: [ - threshold: 100 - ], - ignore_modules: [ - SymphonyElixir.Config, - SymphonyElixir.Linear.Client, - SymphonyElixir.SpecsCheck, - SymphonyElixir.Orchestrator, - SymphonyElixir.Orchestrator.State, - SymphonyElixir.AgentRunner, - SymphonyElixir.CLI, - SymphonyElixir.Codex.AppServer, - SymphonyElixir.Codex.DynamicTool, - SymphonyElixir.HttpServer, - SymphonyElixir.StatusDashboard, - SymphonyElixir.LogFile, - SymphonyElixir.Workspace, - SymphonyElixirWeb.DashboardLive, - SymphonyElixirWeb.Endpoint, - SymphonyElixirWeb.ErrorHTML, - SymphonyElixirWeb.ErrorJSON, - SymphonyElixirWeb.Layouts, - SymphonyElixirWeb.ObservabilityApiController, - SymphonyElixirWeb.Presenter, - SymphonyElixirWeb.StaticAssetController, - SymphonyElixirWeb.StaticAssets, - SymphonyElixirWeb.Router, - SymphonyElixirWeb.Router.Helpers - ] - ], - test_ignore_filters: [ - "test/support/snapshot_support.exs", - "test/support/test_support.exs" - ], - dialyzer: [ - plt_add_apps: [:mix] - ], - escript: escript(), - aliases: aliases(), - deps: deps() - ] - end - - # Run "mix help compile.app" to learn about applications. - def application do - [ - mod: {SymphonyElixir.Application, []}, - extra_applications: [:logger] - ] - end - - # Run "mix help deps" to learn about dependencies. - defp deps do - [ - {:bandit, "~> 1.8"}, - {:floki, ">= 0.30.0", only: :test}, - {:lazy_html, ">= 0.1.0", only: :test}, - {:phoenix, "~> 1.8.0"}, - {:phoenix_html, "~> 4.2"}, - {:phoenix_live_view, "~> 1.1.0"}, - {:req, "~> 0.5"}, - {:jason, "~> 1.4"}, - {:yaml_elixir, "~> 2.12"}, - {:solid, "~> 1.2"}, - {:nimble_options, "~> 1.1"}, - {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.4", only: [:dev], runtime: false} - ] - end - - defp aliases do - [ - setup: ["deps.get"], - build: ["escript.build"], - lint: ["specs.check", "credo --strict"] - ] - end - - defp escript do - [ - app: nil, - main_module: SymphonyElixir.CLI, - name: "symphony", - path: "bin/symphony" - ] - end -end diff --git a/elixir/mix.lock b/elixir/mix.lock deleted file mode 100644 index 4f52fd7003..0000000000 --- a/elixir/mix.lock +++ /dev/null @@ -1,38 +0,0 @@ -%{ - "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, - "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, - "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, - "date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"}, - "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, - "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, - "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, - "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, - "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, - "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, - "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, - "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"}, - "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"}, - "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "phoenix": {:hex, :phoenix, "1.8.4", "0387f84f00071cba8d71d930b9121b2fb3645197a9206c31b908d2e7902a4851", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c988b1cd3b084eebb13e6676d572597d387fa607dab258526637b4e6c4c08543"}, - "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.25", "abc1bdf7f148d7f9a003f149834cc858b24290c433b10ef6d1cbb1d6e9a211ca", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b8946e474799da1f874eab7e9ce107502c96ca318ed46d19f811f847df270865"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, - "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, - "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, - "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, - "solid": {:hex, :solid, "1.2.2", "615d3fb75e12b575d99976ca49f242b1e603f98489d30bf8634b5ab47d85e33f", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "410d0af6c0cdfd9d58ed2d22158f4fb0733a49f7b59b8e3bdb26f05919ae38ae"}, - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, - "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, - "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, - "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, - "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, -} diff --git a/elixir/priv/static/dashboard.css b/elixir/priv/static/dashboard.css deleted file mode 100644 index bc191c0ca1..0000000000 --- a/elixir/priv/static/dashboard.css +++ /dev/null @@ -1,463 +0,0 @@ -:root { - color-scheme: light; - --page: #f7f7f8; - --page-soft: #fbfbfc; - --page-deep: #ececf1; - --card: rgba(255, 255, 255, 0.94); - --card-muted: #f3f4f6; - --ink: #202123; - --muted: #6e6e80; - --line: #ececf1; - --line-strong: #d9d9e3; - --accent: #10a37f; - --accent-ink: #0f513f; - --accent-soft: #e8faf4; - --danger: #b42318; - --danger-soft: #fef3f2; - --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.05); - --shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.08); -} - -* { - box-sizing: border-box; -} - -html { - background: var(--page); -} - -body { - margin: 0; - min-height: 100vh; - background: - radial-gradient(circle at top, rgba(16, 163, 127, 0.12) 0%, rgba(16, 163, 127, 0) 30%), - linear-gradient(180deg, var(--page-soft) 0%, var(--page) 24%, #f3f4f6 100%); - color: var(--ink); - font-family: "Sohne", "SF Pro Text", "Helvetica Neue", "Segoe UI", sans-serif; - line-height: 1.5; -} - -a { - color: var(--ink); - text-decoration: none; - transition: color 140ms ease; -} - -a:hover { - color: var(--accent); -} - -button { - appearance: none; - border: 1px solid var(--accent); - background: var(--accent); - color: white; - border-radius: 999px; - padding: 0.72rem 1.08rem; - cursor: pointer; - font: inherit; - font-weight: 600; - letter-spacing: -0.01em; - box-shadow: 0 8px 20px rgba(16, 163, 127, 0.18); - transition: - transform 140ms ease, - box-shadow 140ms ease, - background 140ms ease, - border-color 140ms ease; -} - -button:hover { - transform: translateY(-1px); - box-shadow: 0 12px 24px rgba(16, 163, 127, 0.22); -} - -button.secondary { - background: var(--card); - color: var(--ink); - border-color: var(--line-strong); - box-shadow: var(--shadow-sm); -} - -button.secondary:hover { - box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); -} - -.subtle-button { - appearance: none; - border: 1px solid var(--line-strong); - background: rgba(255, 255, 255, 0.72); - color: var(--muted); - border-radius: 999px; - padding: 0.34rem 0.72rem; - cursor: pointer; - font: inherit; - font-size: 0.82rem; - font-weight: 600; - letter-spacing: 0.01em; - box-shadow: none; - transition: - background 140ms ease, - border-color 140ms ease, - color 140ms ease; -} - -.subtle-button:hover { - transform: none; - box-shadow: none; - background: white; - border-color: var(--muted); - color: var(--ink); -} - -pre { - margin: 0; - white-space: pre-wrap; - word-break: break-word; -} - -code, -pre, -.mono { - font-family: "Sohne Mono", "SFMono-Regular", "SF Mono", Consolas, "Liberation Mono", monospace; -} - -.mono, -.numeric { - font-variant-numeric: tabular-nums slashed-zero; - font-feature-settings: "tnum" 1, "zero" 1; -} - -.app-shell { - max-width: 1280px; - margin: 0 auto; - padding: 2rem 1rem 3.5rem; -} - -.dashboard-shell { - display: grid; - gap: 1rem; -} - -.hero-card, -.section-card, -.metric-card, -.error-card { - background: var(--card); - border: 1px solid rgba(217, 217, 227, 0.82); - box-shadow: var(--shadow-sm); - backdrop-filter: blur(18px); -} - -.hero-card { - border-radius: 28px; - padding: clamp(1.25rem, 3vw, 2rem); - box-shadow: var(--shadow-lg); -} - -.hero-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 1.25rem; - align-items: start; -} - -.eyebrow { - margin: 0; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: 0.76rem; - font-weight: 600; -} - -.hero-title { - margin: 0.35rem 0 0; - font-size: clamp(2rem, 4vw, 3.3rem); - line-height: 0.98; - letter-spacing: -0.04em; -} - -.hero-copy { - margin: 0.75rem 0 0; - max-width: 46rem; - color: var(--muted); - font-size: 1rem; -} - -.status-stack { - display: grid; - justify-items: end; - align-content: start; - min-width: min(100%, 9rem); -} - -.status-badge { - display: inline-flex; - align-items: center; - gap: 0.45rem; - min-height: 2rem; - padding: 0.35rem 0.78rem; - border-radius: 999px; - border: 1px solid var(--line); - background: var(--card-muted); - color: var(--muted); - font-size: 0.82rem; - font-weight: 700; - letter-spacing: 0.01em; -} - -.status-badge-dot { - width: 0.52rem; - height: 0.52rem; - border-radius: 999px; - background: currentColor; - opacity: 0.9; -} - -.status-badge-live { - display: none; - background: var(--accent-soft); - border-color: rgba(16, 163, 127, 0.18); - color: var(--accent-ink); -} - -.status-badge-offline { - background: #f5f5f7; - border-color: var(--line-strong); - color: var(--muted); -} - -[data-phx-main].phx-connected .status-badge-live { - display: inline-flex; -} - -[data-phx-main].phx-connected .status-badge-offline { - display: none; -} - -.metric-grid { - display: grid; - gap: 0.85rem; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); -} - -.metric-card { - border-radius: 22px; - padding: 1rem 1.05rem 1.1rem; -} - -.metric-label { - margin: 0; - color: var(--muted); - font-size: 0.82rem; - font-weight: 600; - letter-spacing: 0.01em; -} - -.metric-value { - margin: 0.35rem 0 0; - font-size: clamp(1.6rem, 2vw, 2.1rem); - line-height: 1.05; - letter-spacing: -0.03em; -} - -.metric-detail { - margin: 0.45rem 0 0; - color: var(--muted); - font-size: 0.88rem; -} - -.section-card { - border-radius: 24px; - padding: 1.15rem; -} - -.section-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 1rem; - flex-wrap: wrap; -} - -.section-title { - margin: 0; - font-size: 1.08rem; - line-height: 1.2; - letter-spacing: -0.02em; -} - -.section-copy { - margin: 0.35rem 0 0; - color: var(--muted); - font-size: 0.94rem; -} - -.table-wrap { - overflow-x: auto; - margin-top: 1rem; -} - -.data-table { - width: 100%; - min-width: 720px; - border-collapse: collapse; -} - -.data-table-running { - table-layout: fixed; - min-width: 980px; -} - -.data-table th { - padding: 0 0.5rem 0.75rem 0; - text-align: left; - color: var(--muted); - font-size: 0.78rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.data-table td { - padding: 0.9rem 0.5rem 0.9rem 0; - border-top: 1px solid var(--line); - vertical-align: top; - font-size: 0.94rem; -} - -.issue-stack, -.session-stack, -.detail-stack, -.token-stack { - display: grid; - gap: 0.24rem; - min-width: 0; -} - -.event-text { - font-weight: 500; - line-height: 1.45; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.event-meta { - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.state-badge { - display: inline-flex; - align-items: center; - min-height: 1.85rem; - padding: 0.3rem 0.68rem; - border-radius: 999px; - border: 1px solid var(--line); - background: var(--card-muted); - color: var(--ink); - font-size: 0.8rem; - font-weight: 600; - line-height: 1; -} - -.state-badge-active { - background: var(--accent-soft); - border-color: rgba(16, 163, 127, 0.18); - color: var(--accent-ink); -} - -.state-badge-warning { - background: #fff7e8; - border-color: #f1d8a6; - color: #8a5a00; -} - -.state-badge-danger { - background: var(--danger-soft); - border-color: #f6d3cf; - color: var(--danger); -} - -.issue-id { - font-weight: 600; - letter-spacing: -0.01em; -} - -.issue-link { - color: var(--muted); - font-size: 0.86rem; -} - -.muted { - color: var(--muted); -} - -.code-panel { - margin-top: 1rem; - padding: 1rem; - border-radius: 18px; - background: #f5f5f7; - border: 1px solid var(--line); - color: #353740; - font-size: 0.9rem; -} - -.empty-state { - margin: 1rem 0 0; - color: var(--muted); -} - -.error-card { - border-radius: 24px; - padding: 1.25rem; - background: linear-gradient(180deg, #fff8f7 0%, var(--danger-soft) 100%); - border-color: #f6d3cf; -} - -.error-title { - margin: 0; - color: var(--danger); - font-size: 1.15rem; - letter-spacing: -0.02em; -} - -.error-copy { - margin: 0.45rem 0 0; - color: var(--danger); -} - -@media (max-width: 860px) { - .app-shell { - padding: 1rem 0.85rem 2rem; - } - - .hero-grid { - grid-template-columns: 1fr; - } - - .status-stack { - justify-items: start; - } - - .metric-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 560px) { - .metric-grid { - grid-template-columns: 1fr; - } - - .section-card, - .hero-card, - .error-card { - border-radius: 20px; - padding: 1rem; - } -} diff --git a/elixir/test/fixtures/status_dashboard_snapshots/backoff_queue.evidence.md b/elixir/test/fixtures/status_dashboard_snapshots/backoff_queue.evidence.md deleted file mode 100644 index d231f4446c..0000000000 --- a/elixir/test/fixtures/status_dashboard_snapshots/backoff_queue.evidence.md +++ /dev/null @@ -1,23 +0,0 @@ -```text -╭─ SYMPHONY STATUS -│ Agents: 1/10 -│ Throughput: 15 tps -│ Runtime: 45m 0s -│ Tokens: in 18,000 | out 2,200 | total 20,200 -│ Rate Limits: gpt-5 | primary 0/20,000 reset 95s | secondary 0/60 reset 45s | credits none -│ Project: https://linear.app/project/project/issues -│ Next refresh: n/a -├─ Running -│ -│ ID STAGE PID AGE / TURN TOKENS SESSION EVENT -│ ─────────────────────────────────────────────────────────────────────────────────────────────────────────────── -│ ● MT-638 retrying 4242 20m 25s / 7 14,200 thre...567890 agent message streaming: waiting on ... -│ -├─ Backoff queue -│ -│ ↻ MT-450 attempt=4 in 1.250s error=rate limit exhausted -│ ↻ MT-451 attempt=2 in 3.900s error=retrying after API timeout with jitter -│ ↻ MT-452 attempt=6 in 8.100s error=worker crashed restarting cleanly -│ ↻ MT-453 attempt=1 in 11.000s error=fourth queued retry should also render after removing the top-three limit -╰─ -``` diff --git a/elixir/test/fixtures/status_dashboard_snapshots/backoff_queue.snapshot.txt b/elixir/test/fixtures/status_dashboard_snapshots/backoff_queue.snapshot.txt deleted file mode 100644 index 59708399e7..0000000000 --- a/elixir/test/fixtures/status_dashboard_snapshots/backoff_queue.snapshot.txt +++ /dev/null @@ -1,21 +0,0 @@ -\e[1m╭─ SYMPHONY STATUS\e[0m -\e[1m│ Agents: \e[0m\e[32m1\e[0m\e[90m/\e[0m\e[90m10\e[0m -\e[1m│ Throughput: \e[0m\e[36m15 tps\e[0m -\e[1m│ Runtime: \e[0m\e[35m45m 0s\e[0m -\e[1m│ Tokens: \e[0m\e[33min 18,000\e[0m\e[90m | \e[0m\e[33mout 2,200\e[0m\e[90m | \e[0m\e[33mtotal 20,200\e[0m -\e[1m│ Rate Limits: \e[0m\e[33mgpt-5\e[0m\e[90m | \e[0m\e[36mprimary 0/20,000 reset 95s\e[0m\e[90m | \e[0m\e[36msecondary 0/60 reset 45s\e[0m\e[90m | \e[0m\e[32mcredits none\e[0m -\e[1m│ Project: \e[0m\e[36mhttps://linear.app/project/project/issues\e[0m -\e[1m│ Next refresh: \e[0m\e[90mn/a\e[0m -\e[1m├─ Running\e[0m -│ -│ \e[90mID STAGE PID AGE / TURN TOKENS SESSION EVENT \e[0m -│ \e[90m───────────────────────────────────────────────────────────────────────────────────────────────────────────────\e[0m -│ \e[34m●\e[0m \e[36mMT-638 \e[0m \e[34mretrying \e[0m \e[33m4242 \e[0m \e[35m20m 25s / 7 \e[0m \e[33m 14,200\e[0m \e[36mthre...567890 \e[0m \e[34magent message streaming: waiting on ...\e[0m -│ -\e[1m├─ Backoff queue\e[0m -│ -│ \e[33m↻\e[0m \e[31mMT-450\e[0m \e[33mattempt=4\e[0m\e[2m in \e[0m\e[36m1.250s\e[0m \e[2merror=rate limit exhausted\e[0m -│ \e[33m↻\e[0m \e[31mMT-451\e[0m \e[33mattempt=2\e[0m\e[2m in \e[0m\e[36m3.900s\e[0m \e[2merror=retrying after API timeout with jitter\e[0m -│ \e[33m↻\e[0m \e[31mMT-452\e[0m \e[33mattempt=6\e[0m\e[2m in \e[0m\e[36m8.100s\e[0m \e[2merror=worker crashed restarting cleanly\e[0m -│ \e[33m↻\e[0m \e[31mMT-453\e[0m \e[33mattempt=1\e[0m\e[2m in \e[0m\e[36m11.000s\e[0m \e[2merror=fourth queued retry should also render after removing the top-three limit\e[0m -╰─ diff --git a/elixir/test/fixtures/status_dashboard_snapshots/credits_unlimited.evidence.md b/elixir/test/fixtures/status_dashboard_snapshots/credits_unlimited.evidence.md deleted file mode 100644 index dec3c8ec0c..0000000000 --- a/elixir/test/fixtures/status_dashboard_snapshots/credits_unlimited.evidence.md +++ /dev/null @@ -1,20 +0,0 @@ -```text -╭─ SYMPHONY STATUS -│ Agents: 1/10 -│ Throughput: 42 tps -│ Runtime: 1m 15s -│ Tokens: in 90 | out 12 | total 102 -│ Rate Limits: priority-tier | primary 100/100 reset 1s | secondary 500/500 reset 1s | credits unlimited -│ Project: https://linear.app/project/project/issues -│ Next refresh: n/a -├─ Running -│ -│ ID STAGE PID AGE / TURN TOKENS SESSION EVENT -│ ─────────────────────────────────────────────────────────────────────────────────────────────────────────────── -│ ● MT-777 running 4242 1m 15s / 7 3,200 thre...567890 thread token usage updated (in 90, o... -│ -├─ Backoff queue -│ -│ No queued retries -╰─ -``` diff --git a/elixir/test/fixtures/status_dashboard_snapshots/credits_unlimited.snapshot.txt b/elixir/test/fixtures/status_dashboard_snapshots/credits_unlimited.snapshot.txt deleted file mode 100644 index 99ebc459e4..0000000000 --- a/elixir/test/fixtures/status_dashboard_snapshots/credits_unlimited.snapshot.txt +++ /dev/null @@ -1,18 +0,0 @@ -\e[1m╭─ SYMPHONY STATUS\e[0m -\e[1m│ Agents: \e[0m\e[32m1\e[0m\e[90m/\e[0m\e[90m10\e[0m -\e[1m│ Throughput: \e[0m\e[36m42 tps\e[0m -\e[1m│ Runtime: \e[0m\e[35m1m 15s\e[0m -\e[1m│ Tokens: \e[0m\e[33min 90\e[0m\e[90m | \e[0m\e[33mout 12\e[0m\e[90m | \e[0m\e[33mtotal 102\e[0m -\e[1m│ Rate Limits: \e[0m\e[33mpriority-tier\e[0m\e[90m | \e[0m\e[36mprimary 100/100 reset 1s\e[0m\e[90m | \e[0m\e[36msecondary 500/500 reset 1s\e[0m\e[90m | \e[0m\e[32mcredits unlimited\e[0m -\e[1m│ Project: \e[0m\e[36mhttps://linear.app/project/project/issues\e[0m -\e[1m│ Next refresh: \e[0m\e[90mn/a\e[0m -\e[1m├─ Running\e[0m -│ -│ \e[90mID STAGE PID AGE / TURN TOKENS SESSION EVENT \e[0m -│ \e[90m───────────────────────────────────────────────────────────────────────────────────────────────────────────────\e[0m -│ \e[33m●\e[0m \e[36mMT-777 \e[0m \e[33mrunning \e[0m \e[33m4242 \e[0m \e[35m1m 15s / 7 \e[0m \e[33m 3,200\e[0m \e[36mthre...567890 \e[0m \e[33mthread token usage updated (in 90, o...\e[0m -│ -\e[1m├─ Backoff queue\e[0m -│ -│ \e[90mNo queued retries\e[0m -╰─ diff --git a/elixir/test/fixtures/status_dashboard_snapshots/idle.evidence.md b/elixir/test/fixtures/status_dashboard_snapshots/idle.evidence.md deleted file mode 100644 index 336bf4e323..0000000000 --- a/elixir/test/fixtures/status_dashboard_snapshots/idle.evidence.md +++ /dev/null @@ -1,20 +0,0 @@ -```text -╭─ SYMPHONY STATUS -│ Agents: 0/10 -│ Throughput: 0 tps -│ Runtime: 0m 0s -│ Tokens: in 0 | out 0 | total 0 -│ Rate Limits: unavailable -│ Project: https://linear.app/project/project/issues -│ Next refresh: n/a -├─ Running -│ -│ ID STAGE PID AGE / TURN TOKENS SESSION EVENT -│ ─────────────────────────────────────────────────────────────────────────────────────────────────────────────── -│ No active agents -│ -├─ Backoff queue -│ -│ No queued retries -╰─ -``` diff --git a/elixir/test/fixtures/status_dashboard_snapshots/idle.snapshot.txt b/elixir/test/fixtures/status_dashboard_snapshots/idle.snapshot.txt deleted file mode 100644 index 8c38d5fec4..0000000000 --- a/elixir/test/fixtures/status_dashboard_snapshots/idle.snapshot.txt +++ /dev/null @@ -1,18 +0,0 @@ -\e[1m╭─ SYMPHONY STATUS\e[0m -\e[1m│ Agents: \e[0m\e[32m0\e[0m\e[90m/\e[0m\e[90m10\e[0m -\e[1m│ Throughput: \e[0m\e[36m0 tps\e[0m -\e[1m│ Runtime: \e[0m\e[35m0m 0s\e[0m -\e[1m│ Tokens: \e[0m\e[33min 0\e[0m\e[90m | \e[0m\e[33mout 0\e[0m\e[90m | \e[0m\e[33mtotal 0\e[0m -\e[1m│ Rate Limits: \e[0m\e[90munavailable\e[0m -\e[1m│ Project: \e[0m\e[36mhttps://linear.app/project/project/issues\e[0m -\e[1m│ Next refresh: \e[0m\e[90mn/a\e[0m -\e[1m├─ Running\e[0m -│ -│ \e[90mID STAGE PID AGE / TURN TOKENS SESSION EVENT \e[0m -│ \e[90m───────────────────────────────────────────────────────────────────────────────────────────────────────────────\e[0m -│ \e[90mNo active agents\e[0m -│ -\e[1m├─ Backoff queue\e[0m -│ -│ \e[90mNo queued retries\e[0m -╰─ diff --git a/elixir/test/fixtures/status_dashboard_snapshots/idle_with_dashboard_url.evidence.md b/elixir/test/fixtures/status_dashboard_snapshots/idle_with_dashboard_url.evidence.md deleted file mode 100644 index 1072ad5115..0000000000 --- a/elixir/test/fixtures/status_dashboard_snapshots/idle_with_dashboard_url.evidence.md +++ /dev/null @@ -1,21 +0,0 @@ -```text -╭─ SYMPHONY STATUS -│ Agents: 0/10 -│ Throughput: 0 tps -│ Runtime: 0m 0s -│ Tokens: in 0 | out 0 | total 0 -│ Rate Limits: unavailable -│ Project: https://linear.app/project/project/issues -│ Dashboard: http://127.0.0.1:4000/ -│ Next refresh: n/a -├─ Running -│ -│ ID STAGE PID AGE / TURN TOKENS SESSION EVENT -│ ─────────────────────────────────────────────────────────────────────────────────────────────────────────────── -│ No active agents -│ -├─ Backoff queue -│ -│ No queued retries -╰─ -``` diff --git a/elixir/test/fixtures/status_dashboard_snapshots/idle_with_dashboard_url.snapshot.txt b/elixir/test/fixtures/status_dashboard_snapshots/idle_with_dashboard_url.snapshot.txt deleted file mode 100644 index 934df5e4d1..0000000000 --- a/elixir/test/fixtures/status_dashboard_snapshots/idle_with_dashboard_url.snapshot.txt +++ /dev/null @@ -1,19 +0,0 @@ -\e[1m╭─ SYMPHONY STATUS\e[0m -\e[1m│ Agents: \e[0m\e[32m0\e[0m\e[90m/\e[0m\e[90m10\e[0m -\e[1m│ Throughput: \e[0m\e[36m0 tps\e[0m -\e[1m│ Runtime: \e[0m\e[35m0m 0s\e[0m -\e[1m│ Tokens: \e[0m\e[33min 0\e[0m\e[90m | \e[0m\e[33mout 0\e[0m\e[90m | \e[0m\e[33mtotal 0\e[0m -\e[1m│ Rate Limits: \e[0m\e[90munavailable\e[0m -\e[1m│ Project: \e[0m\e[36mhttps://linear.app/project/project/issues\e[0m -\e[1m│ Dashboard: \e[0m\e[36mhttp://127.0.0.1:4000/\e[0m -\e[1m│ Next refresh: \e[0m\e[90mn/a\e[0m -\e[1m├─ Running\e[0m -│ -│ \e[90mID STAGE PID AGE / TURN TOKENS SESSION EVENT \e[0m -│ \e[90m───────────────────────────────────────────────────────────────────────────────────────────────────────────────\e[0m -│ \e[90mNo active agents\e[0m -│ -\e[1m├─ Backoff queue\e[0m -│ -│ \e[90mNo queued retries\e[0m -╰─ diff --git a/elixir/test/fixtures/status_dashboard_snapshots/super_busy.evidence.md b/elixir/test/fixtures/status_dashboard_snapshots/super_busy.evidence.md deleted file mode 100644 index 30915a090e..0000000000 --- a/elixir/test/fixtures/status_dashboard_snapshots/super_busy.evidence.md +++ /dev/null @@ -1,21 +0,0 @@ -```text -╭─ SYMPHONY STATUS -│ Agents: 2/10 -│ Throughput: 1,842 tps -│ Runtime: 72m 1s -│ Tokens: in 250,000 | out 18,500 | total 268,500 -│ Rate Limits: gpt-5 | primary 12,345/20,000 reset 30s | secondary 45/60 reset 12s | credits 9876.50 -│ Project: https://linear.app/project/project/issues -│ Next refresh: n/a -├─ Running -│ -│ ID STAGE PID AGE / TURN TOKENS SESSION EVENT -│ ─────────────────────────────────────────────────────────────────────────────────────────────────────────────── -│ ● MT-101 running 4242 13m 5s / 11 120,450 thre...567890 turn completed (completed) -│ ● MT-102 running 5252 6m 52s / 4 89,200 thre...567890 mix test --cover -│ -├─ Backoff queue -│ -│ No queued retries -╰─ -``` diff --git a/elixir/test/fixtures/status_dashboard_snapshots/super_busy.snapshot.txt b/elixir/test/fixtures/status_dashboard_snapshots/super_busy.snapshot.txt deleted file mode 100644 index 62b1d7ac09..0000000000 --- a/elixir/test/fixtures/status_dashboard_snapshots/super_busy.snapshot.txt +++ /dev/null @@ -1,19 +0,0 @@ -\e[1m╭─ SYMPHONY STATUS\e[0m -\e[1m│ Agents: \e[0m\e[32m2\e[0m\e[90m/\e[0m\e[90m10\e[0m -\e[1m│ Throughput: \e[0m\e[36m1,842 tps\e[0m -\e[1m│ Runtime: \e[0m\e[35m72m 1s\e[0m -\e[1m│ Tokens: \e[0m\e[33min 250,000\e[0m\e[90m | \e[0m\e[33mout 18,500\e[0m\e[90m | \e[0m\e[33mtotal 268,500\e[0m -\e[1m│ Rate Limits: \e[0m\e[33mgpt-5\e[0m\e[90m | \e[0m\e[36mprimary 12,345/20,000 reset 30s\e[0m\e[90m | \e[0m\e[36msecondary 45/60 reset 12s\e[0m\e[90m | \e[0m\e[32mcredits 9876.50\e[0m -\e[1m│ Project: \e[0m\e[36mhttps://linear.app/project/project/issues\e[0m -\e[1m│ Next refresh: \e[0m\e[90mn/a\e[0m -\e[1m├─ Running\e[0m -│ -│ \e[90mID STAGE PID AGE / TURN TOKENS SESSION EVENT \e[0m -│ \e[90m───────────────────────────────────────────────────────────────────────────────────────────────────────────────\e[0m -│ \e[35m●\e[0m \e[36mMT-101 \e[0m \e[35mrunning \e[0m \e[33m4242 \e[0m \e[35m13m 5s / 11 \e[0m \e[33m 120,450\e[0m \e[36mthre...567890 \e[0m \e[35mturn completed (completed) \e[0m -│ \e[32m●\e[0m \e[36mMT-102 \e[0m \e[32mrunning \e[0m \e[33m5252 \e[0m \e[35m6m 52s / 4 \e[0m \e[33m 89,200\e[0m \e[36mthre...567890 \e[0m \e[32mmix test --cover \e[0m -│ -\e[1m├─ Backoff queue\e[0m -│ -│ \e[90mNo queued retries\e[0m -╰─ diff --git a/elixir/test/mix/tasks/pr_body_check_test.exs b/elixir/test/mix/tasks/pr_body_check_test.exs deleted file mode 100644 index 6f4cf71eb3..0000000000 --- a/elixir/test/mix/tasks/pr_body_check_test.exs +++ /dev/null @@ -1,341 +0,0 @@ -defmodule Mix.Tasks.PrBody.CheckTest do - use ExUnit.Case, async: false - - alias Mix.Tasks.PrBody.Check - - import ExUnit.CaptureIO - - @template """ - #### Context - - - - #### TL;DR - - ** - - #### Summary - - - - - #### Alternatives - - - - - #### Test Plan - - - [ ] - """ - - @valid_body """ - #### Context - - Context text. - - #### TL;DR - - Short summary. - - #### Summary - - - First change. - - #### Alternatives - - - Alternative considered. - - #### Test Plan - - - [x] Ran targeted checks. - """ - - setup do - Mix.Task.reenable("pr_body.check") - :ok - end - - test "prints help" do - output = capture_io(fn -> Check.run(["--help"]) end) - assert output =~ "mix pr_body.check --file /path/to/pr_body.md" - end - - test "fails on invalid options" do - assert_raise Mix.Error, ~r/Invalid option/, fn -> - Check.run(["lint", "--wat"]) - end - end - - test "fails when file option is missing" do - assert_raise Mix.Error, ~r/Missing required option --file/, fn -> - Check.run(["lint"]) - end - end - - test "fails when template is missing" do - in_temp_repo(fn -> - File.write!("body.md", @valid_body) - - assert_raise Mix.Error, ~r/Unable to read PR template/, fn -> - Check.run(["lint", "--file", "body.md"]) - end - end) - end - - test "fails when template has no headings" do - in_temp_repo(fn -> - write_template!("no headings here") - File.write!("body.md", @valid_body) - - assert_raise Mix.Error, ~r/No markdown headings found/, fn -> - Check.run(["lint", "--file", "body.md"]) - end - end) - end - - test "fails when body file is missing" do - in_temp_repo(fn -> - write_template!(@template) - - assert_raise Mix.Error, ~r/Unable to read missing\.md/, fn -> - Check.run(["lint", "--file", "missing.md"]) - end - end) - end - - test "fails when body still has placeholders" do - in_temp_repo(fn -> - write_template!(@template) - File.write!("body.md", @template) - - error_output = - capture_io(:stderr, fn -> - assert_raise Mix.Error, ~r/PR body format invalid/, fn -> - Check.run(["lint", "--file", "body.md"]) - end - end) - - assert error_output =~ "PR description still contains template placeholder comments" - end) - end - - test "fails when heading is missing" do - in_temp_repo(fn -> - write_template!(@template) - - missing_heading = String.replace(@valid_body, "#### Alternatives\n\n- Alternative considered.\n\n", "") - File.write!("body.md", missing_heading) - - error_output = - capture_io(:stderr, fn -> - assert_raise Mix.Error, ~r/PR body format invalid/, fn -> - Check.run(["lint", "--file", "body.md"]) - end - end) - - assert error_output =~ "Missing required heading: #### Alternatives" - end) - end - - test "fails when headings are out of order" do - in_temp_repo(fn -> - write_template!(@template) - - out_of_order = """ - #### TL;DR - - Short summary. - - #### Context - - Context text. - - #### Summary - - - First change. - - #### Alternatives - - - Alternative considered. - - #### Test Plan - - - [x] Ran targeted checks. - """ - - File.write!("body.md", out_of_order) - - error_output = - capture_io(:stderr, fn -> - assert_raise Mix.Error, ~r/PR body format invalid/, fn -> - Check.run(["lint", "--file", "body.md"]) - end - end) - - assert error_output =~ "Required headings are out of order." - end) - end - - test "fails on empty section" do - in_temp_repo(fn -> - write_template!(@template) - - empty_context = String.replace(@valid_body, "Context text.", "") - File.write!("body.md", empty_context) - - error_output = - capture_io(:stderr, fn -> - assert_raise Mix.Error, ~r/PR body format invalid/, fn -> - Check.run(["lint", "--file", "body.md"]) - end - end) - - assert error_output =~ "Section cannot be empty: #### Context" - end) - end - - test "fails when a middle section is blank before the next heading" do - in_temp_repo(fn -> - write_template!(@template) - - blank_alternatives = """ - #### Context - - Context text. - - #### TL;DR - - Short summary. - - #### Summary - - - First change. - - #### Alternatives - - - #### Test Plan - - - [x] Ran targeted checks. - """ - - File.write!("body.md", blank_alternatives) - - error_output = - capture_io(:stderr, fn -> - assert_raise Mix.Error, ~r/PR body format invalid/, fn -> - Check.run(["lint", "--file", "body.md"]) - end - end) - - assert error_output =~ "Section cannot be empty: #### Alternatives" - end) - end - - test "fails when bullet and checkbox expectations are not met" do - in_temp_repo(fn -> - write_template!(@template) - - invalid_body = """ - #### Context - - Context text. - - #### TL;DR - - Short summary. - - #### Summary - - Not a bullet. - - #### Alternatives - - Also not a bullet. - - #### Test Plan - - No checkbox. - """ - - File.write!("body.md", invalid_body) - - error_output = - capture_io(:stderr, fn -> - assert_raise Mix.Error, ~r/PR body format invalid/, fn -> - Check.run(["lint", "--file", "body.md"]) - end - end) - - assert error_output =~ "Section must include at least one bullet item: #### Summary" - assert error_output =~ "Section must include at least one bullet item: #### Alternatives" - assert error_output =~ "Section must include at least one bullet item: #### Test Plan" - assert error_output =~ "Section must include at least one checkbox item: #### Test Plan" - end) - end - - test "fails when heading has no content delimiter" do - in_temp_repo(fn -> - write_template!(@template) - File.write!("body.md", "#### Context\nContext text.") - - capture_io(:stderr, fn -> - assert_raise Mix.Error, ~r/PR body format invalid/, fn -> - Check.run(["lint", "--file", "body.md"]) - end - end) - end) - end - - test "fails when heading appears at end of file" do - in_temp_repo(fn -> - write_template!(@template) - File.write!("body.md", "#### Context") - - error_output = - capture_io(:stderr, fn -> - assert_raise Mix.Error, ~r/PR body format invalid/, fn -> - Check.run(["lint", "--file", "body.md"]) - end - end) - - assert error_output =~ "Section cannot be empty: #### Context" - end) - end - - test "passes for valid body" do - in_temp_repo(fn -> - write_template!(@template) - File.write!("body.md", @valid_body) - - output = - capture_io(fn -> - Check.run(["lint", "--file", "body.md"]) - end) - - assert output =~ "PR body format OK" - end) - end - - defp in_temp_repo(fun) do - unique = System.unique_integer([:positive, :monotonic]) - root = Path.join(System.tmp_dir!(), "validate-pr-body-task-test-#{unique}") - - File.rm_rf!(root) - File.mkdir_p!(root) - - original_cwd = File.cwd!() - - try do - File.cd!(root) - fun.() - after - File.cd!(original_cwd) - File.rm_rf!(root) - end - end - - defp write_template!(content) do - File.mkdir_p!(".github") - File.write!(".github/pull_request_template.md", content) - end -end diff --git a/elixir/test/mix/tasks/specs_check_task_test.exs b/elixir/test/mix/tasks/specs_check_task_test.exs deleted file mode 100644 index 6909b5ad18..0000000000 --- a/elixir/test/mix/tasks/specs_check_task_test.exs +++ /dev/null @@ -1,112 +0,0 @@ -defmodule Mix.Tasks.Specs.CheckTaskTest do - use ExUnit.Case, async: false - - import ExUnit.CaptureIO - - alias Mix.Tasks.Specs.Check - - setup do - Mix.Task.reenable("specs.check") - :ok - end - - test "uses the default lib path when all public functions have specs" do - in_temp_project(fn -> - write_module!("lib/sample.ex", """ - defmodule Sample do - @spec ok(term()) :: term() - def ok(arg), do: arg - end - """) - - output = - capture_io(fn -> - assert :ok = Check.run([]) - end) - - assert output =~ "specs.check: all public functions have @spec or exemption" - end) - end - - test "raises when an explicit path contains missing specs" do - in_temp_project(fn -> - write_module!("src/sample.ex", """ - defmodule Sample do - def missing(arg), do: arg - end - """) - - error_output = - capture_io(:stderr, fn -> - assert_raise Mix.Error, ~r/specs.check failed with 1 missing @spec declaration/, fn -> - Check.run(["--paths", "src"]) - end - end) - - assert error_output =~ "src/sample.ex:2 missing @spec for Sample.missing/1" - end) - end - - test "loads exemptions from a file and ignores comments and blank lines" do - in_temp_project(fn -> - write_module!("lib/sample.ex", """ - defmodule Sample do - def legacy(arg), do: arg - end - """) - - File.mkdir_p!("config") - - File.write!("config/specs_exemptions.txt", """ - # existing exemptions - - Sample.legacy/1 - """) - - output = - capture_io(fn -> - assert :ok = Check.run(["--paths", "lib", "--exemptions-file", "config/specs_exemptions.txt"]) - end) - - assert output =~ "specs.check: all public functions have @spec or exemption" - end) - end - - test "treats a missing exemptions file as empty" do - in_temp_project(fn -> - write_module!("lib/sample.ex", """ - defmodule Sample do - @spec ok(term()) :: term() - def ok(arg), do: arg - end - """) - - output = - capture_io(fn -> - assert :ok = Check.run(["--exemptions-file", "config/missing.txt"]) - end) - - assert output =~ "specs.check: all public functions have @spec or exemption" - end) - end - - defp in_temp_project(fun) do - root = Path.join(System.tmp_dir!(), "specs-check-task-test-#{System.unique_integer([:positive, :monotonic])}") - original_cwd = File.cwd!() - - File.rm_rf!(root) - File.mkdir_p!(root) - - try do - File.cd!(root, fun) - after - File.cd!(original_cwd) - File.rm_rf!(root) - end - end - - defp write_module!(path, source) do - File.mkdir_p!(Path.dirname(path)) - File.write!(path, source) - end -end diff --git a/elixir/test/mix/tasks/workspace_before_remove_test.exs b/elixir/test/mix/tasks/workspace_before_remove_test.exs deleted file mode 100644 index 2bbcb3549b..0000000000 --- a/elixir/test/mix/tasks/workspace_before_remove_test.exs +++ /dev/null @@ -1,390 +0,0 @@ -defmodule Mix.Tasks.Workspace.BeforeRemoveTest do - use ExUnit.Case, async: false - - alias Mix.Tasks.Workspace.BeforeRemove - - import ExUnit.CaptureIO - - setup do - Mix.Task.reenable("workspace.before_remove") - :ok - end - - test "prints help" do - output = - capture_io(fn -> - BeforeRemove.run(["--help"]) - end) - - assert output =~ "mix workspace.before_remove" - end - - test "fails on invalid options" do - assert_raise Mix.Error, ~r/Invalid option/, fn -> - BeforeRemove.run(["--wat"]) - end - end - - test "no-ops when branch is unavailable" do - with_path([], fn -> - in_temp_dir(fn -> - output = - capture_io(fn -> - BeforeRemove.run([]) - end) - - assert output == "" - end) - end) - end - - test "no-ops when gh is unavailable" do - with_path([], fn -> - output = - capture_io(fn -> - BeforeRemove.run(["--branch", "feature/no-gh"]) - end) - - assert output == "" - end) - end - - test "uses current branch for lookup when branch option is omitted" do - with_fake_gh_and_git( - """ - #!/bin/sh - printf '%s\n' "$*" >> "$GH_LOG" - - if [ "$1" = "auth" ] && [ "$2" = "status" ]; then - exit 0 - fi - - if [ "$1" = "pr" ] && [ "$2" = "list" ]; then - printf '101\n102\n' - exit 0 - fi - - if [ "$1" = "pr" ] && [ "$2" = "close" ] && [ "$3" = "101" ]; then - exit 0 - fi - - if [ "$1" = "pr" ] && [ "$2" = "close" ] && [ "$3" = "102" ]; then - printf 'boom\n' >&2 - exit 17 - fi - - exit 99 - """, - """ - #!/bin/sh - printf 'feature/workpad\n' - exit 0 - """, - fn log_path -> - {output, error_output} = - capture_task_output(fn -> - BeforeRemove.run([]) - end) - - assert output =~ "Closed PR #101 for branch feature/workpad" - assert error_output =~ "Failed to close PR #102 for branch feature/workpad" - - log = File.read!(log_path) - - assert log =~ - "pr list --repo openai/symphony --head feature/workpad --state open --json number --jq .[].number" - - assert log =~ "pr close 101 --repo openai/symphony" - assert log =~ "pr close 102 --repo openai/symphony" - end - ) - end - - test "closes open pull requests for the branch and tolerates close failures" do - with_fake_gh(fn log_path -> - File.write!(log_path, "") - - {output, error_output} = - capture_task_output(fn -> - BeforeRemove.run(["--branch", "feature/workpad"]) - end) - - assert output =~ "Closed PR #101 for branch feature/workpad" - assert error_output =~ "Failed to close PR #102 for branch feature/workpad" - - log = File.read!(log_path) - - assert log =~ "auth status" - assert log =~ "pr list --repo openai/symphony --head feature/workpad --state open --json number --jq .[].number" - assert log =~ "pr close 101 --repo openai/symphony" - assert log =~ "pr close 102 --repo openai/symphony" - - {second_output, error_output} = - capture_task_output(fn -> - Mix.Task.reenable("workspace.before_remove") - BeforeRemove.run(["--branch", "feature/workpad"]) - end) - - assert second_output =~ "Closed PR #101 for branch feature/workpad" - assert error_output =~ "Failed to close PR #102 for branch feature/workpad" - end) - end - - test "formats close failures without command stderr output" do - with_fake_gh( - """ - #!/bin/sh - printf '%s\n' "$*" >> "$GH_LOG" - - if [ "$1" = "auth" ] && [ "$2" = "status" ]; then - exit 0 - fi - - if [ "$1" = "pr" ] && [ "$2" = "list" ]; then - printf '102\n' - exit 0 - fi - - if [ "$1" = "pr" ] && [ "$2" = "close" ] && [ "$3" = "102" ]; then - exit 17 - fi - - exit 99 - """, - fn log_path -> - error_output = - capture_io(:stderr, fn -> - Mix.Task.reenable("workspace.before_remove") - BeforeRemove.run(["--branch", "feature/no-output"]) - end) - - assert error_output =~ "Failed to close PR #102 for branch feature/no-output: exit 17" - refute error_output =~ "output=" - log = File.read!(log_path) - assert log =~ "pr list --repo openai/symphony --head feature/no-output --state open --json number --jq .[].number" - assert log =~ "pr close 102 --repo openai/symphony" - end - ) - end - - test "no-ops when PR list fails for current branch" do - with_fake_gh( - """ - #!/bin/sh - printf '%s\n' "$*" >> "$GH_LOG" - - if [ "$1" = "auth" ] && [ "$2" = "status" ]; then - exit 0 - fi - - if [ "$1" = "pr" ] && [ "$2" = "list" ]; then - exit 1 - fi - - exit 99 - """, - fn log_path -> - output = - capture_io(fn -> - BeforeRemove.run(["--branch", "feature/list-fails"]) - end) - - assert output == "" - - log = File.read!(log_path) - assert log =~ "auth status" - - assert log =~ - "pr list --repo openai/symphony --head feature/list-fails --state open --json number --jq .[].number" - - refute log =~ "pr close" - end - ) - end - - test "no-ops when git current branch is blank" do - with_fake_gh_and_git( - """ - #!/bin/sh - printf '%s\n' "$*" >> "$GH_LOG" - - if [ "$1" = "auth" ] && [ "$2" = "status" ]; then - exit 0 - fi - - exit 99 - """, - """ - #!/bin/sh - printf '\n' - exit 0 - """, - fn log_path -> - output = - capture_io(fn -> - BeforeRemove.run([]) - end) - - assert output == "" - - log = File.read!(log_path) - assert log == "" - refute log =~ "pr list" - end - ) - end - - test "no-ops when gh auth is unavailable" do - with_fake_gh( - """ - #!/bin/sh - printf '%s\n' "$*" >> "$GH_LOG" - if [ "$1" = "auth" ] && [ "$2" = "status" ]; then - exit 1 - fi - exit 99 - """, - fn log_path -> - BeforeRemove.run(["--branch", "feature/no-auth"]) - - log = File.read!(log_path) - assert log =~ "auth status" - refute log =~ "pr list" - end - ) - end - - defp with_fake_gh(fun) do - with_fake_binaries( - %{ - "gh" => """ - #!/bin/sh - printf '%s\n' "$*" >> "$GH_LOG" - - if [ "$1" = "auth" ] && [ "$2" = "status" ]; then - exit 0 - fi - - if [ "$1" = "pr" ] && [ "$2" = "list" ]; then - printf '101\n102\n' - exit 0 - fi - - if [ "$1" = "pr" ] && [ "$2" = "close" ] && [ "$3" = "101" ]; then - exit 0 - fi - - if [ "$1" = "pr" ] && [ "$2" = "close" ] && [ "$3" = "102" ]; then - printf 'boom\n' >&2 - exit 17 - fi - - exit 99 - """ - }, - fun - ) - end - - defp with_fake_gh(script, fun) do - with_fake_binaries(%{"gh" => script}, fun) - end - - defp with_fake_gh_and_git(gh_script, git_script, fun) do - with_fake_binaries(%{"gh" => gh_script, "git" => git_script}, fun) - end - - defp with_fake_binaries(scripts, fun) do - unique = System.unique_integer([:positive, :monotonic]) - root = Path.join(System.tmp_dir!(), "workspace-before-remove-task-test-#{unique}") - bin_dir = Path.join(root, "bin") - log_path = Path.join(root, "gh.log") - - try do - File.rm_rf!(root) - File.mkdir_p!(bin_dir) - File.write!(log_path, "") - original_path = System.get_env("PATH") || "" - path_with_binaries = Enum.join([bin_dir, original_path], ":") - - Enum.each(scripts, fn {name, script} -> - path = Path.join(bin_dir, name) - File.write!(path, script) - File.chmod!(path, 0o755) - end) - - with_env( - %{ - "GH_LOG" => log_path, - "PATH" => path_with_binaries - }, - fn -> - fun.(log_path) - end - ) - after - File.rm_rf!(root) - end - end - - defp with_path(paths, fun) do - with_env(%{"PATH" => Enum.join(paths, ":")}, fun) - end - - defp with_env(overrides, fun) do - keys = Map.keys(overrides) - previous = Map.new(keys, fn key -> {key, System.get_env(key)} end) - - try do - Enum.each(overrides, fn {key, value} -> System.put_env(key, value) end) - fun.() - after - Enum.each(previous, fn - {key, nil} -> System.delete_env(key) - {key, value} -> System.put_env(key, value) - end) - end - end - - defp in_temp_dir(fun) do - unique = System.unique_integer([:positive, :monotonic]) - root = Path.join(System.tmp_dir!(), "workspace-before-remove-empty-dir-#{unique}") - - File.rm_rf!(root) - File.mkdir_p!(root) - - original_cwd = File.cwd!() - - try do - File.cd!(root) - fun.() - after - File.cd!(original_cwd) - File.rm_rf!(root) - end - end - - defp capture_task_output(fun) do - parent = self() - ref = make_ref() - - error_output = - capture_io(:stderr, fn -> - output = - capture_io(fn -> - fun.() - end) - - send(parent, {ref, output}) - end) - - output = - receive do - {^ref, output} -> output - after - 1_000 -> flunk("Timed out waiting for captured task output") - end - - {output, error_output} - end -end diff --git a/elixir/test/support/snapshot_support.exs b/elixir/test/support/snapshot_support.exs deleted file mode 100644 index 3ad5ed755b..0000000000 --- a/elixir/test/support/snapshot_support.exs +++ /dev/null @@ -1,78 +0,0 @@ -defmodule SymphonyElixir.TestSupport.Snapshot do - import ExUnit.Assertions - - @snapshot_root Path.expand("../fixtures", __DIR__) - @ansi_regex ~r/\e\[[0-9;]*m/ - - @update_snapshot_hint "Run `UPDATE_SNAPSHOTS=1 mix test test/symphony_elixir/status_dashboard_snapshot_test.exs` to create or update fixtures." - - def assert_dashboard_snapshot!(name, raw_ansi_content) - when is_binary(name) and is_binary(raw_ansi_content) do - assert_snapshot!( - Path.join("status_dashboard_snapshots", "#{name}.snapshot.txt"), - escape_ansi(raw_ansi_content) - ) - - assert_snapshot!( - Path.join("status_dashboard_snapshots", "#{name}.evidence.md"), - evidence_markdown(raw_ansi_content) - ) - - :ok - end - - def assert_snapshot!(relative_path, content) - when is_binary(relative_path) and is_binary(content) do - path = snapshot_path(relative_path) - normalized = normalize_content(content) - - File.mkdir_p!(Path.dirname(path)) - - if update_snapshots?() do - File.write!(path, normalized) - :ok - else - case File.read(path) do - {:ok, expected} -> - assert normalized == expected, - "Snapshot mismatch for `#{relative_path}`. #{@update_snapshot_hint}" - - {:error, :enoent} -> - flunk("Missing snapshot fixture `#{relative_path}`. #{@update_snapshot_hint}") - - {:error, reason} -> - flunk("Failed reading snapshot fixture `#{relative_path}`: #{inspect(reason)}") - end - end - end - - def escape_ansi(content) when is_binary(content), do: String.replace(content, <<27>>, "\\e") - - def strip_ansi(content) when is_binary(content), do: Regex.replace(@ansi_regex, content, "") - - def evidence_markdown(raw_ansi_content) when is_binary(raw_ansi_content) do - plain = - raw_ansi_content - |> strip_ansi() - |> normalize_content() - |> String.trim_trailing("\n") - - "```text\n#{plain}\n```\n" - end - - defp snapshot_path(relative_path), do: Path.join(@snapshot_root, relative_path) - - defp update_snapshots? do - System.get_env("UPDATE_SNAPSHOTS") - |> to_string() - |> String.downcase() - |> Kernel.in(["1", "true", "yes"]) - end - - defp normalize_content(content) do - content - |> String.replace("\r\n", "\n") - |> String.trim_trailing("\n") - |> Kernel.<>("\n") - end -end diff --git a/elixir/test/support/test_support.exs b/elixir/test/support/test_support.exs deleted file mode 100644 index bea30f2cf1..0000000000 --- a/elixir/test/support/test_support.exs +++ /dev/null @@ -1,270 +0,0 @@ -defmodule SymphonyElixir.TestSupport do - @workflow_prompt "You are an agent for this repository." - - defmacro __using__(_opts) do - quote do - use ExUnit.Case - import ExUnit.CaptureLog - - alias SymphonyElixir.AgentRunner - alias SymphonyElixir.CLI - alias SymphonyElixir.Codex.AppServer - alias SymphonyElixir.Config - alias SymphonyElixir.HttpServer - alias SymphonyElixir.Linear.Client - alias SymphonyElixir.Linear.Issue - alias SymphonyElixir.Orchestrator - alias SymphonyElixir.PromptBuilder - alias SymphonyElixir.StatusDashboard - alias SymphonyElixir.Tracker - alias SymphonyElixir.Workflow - alias SymphonyElixir.WorkflowStore - alias SymphonyElixir.Workspace - - import SymphonyElixir.TestSupport, - only: [write_workflow_file!: 1, write_workflow_file!: 2, restore_env: 2, stop_default_http_server: 0] - - setup do - workflow_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-workflow-#{System.unique_integer([:positive])}" - ) - - File.mkdir_p!(workflow_root) - workflow_file = Path.join(workflow_root, "WORKFLOW.md") - write_workflow_file!(workflow_file) - Workflow.set_workflow_file_path(workflow_file) - if Process.whereis(SymphonyElixir.WorkflowStore), do: SymphonyElixir.WorkflowStore.force_reload() - stop_default_http_server() - - on_exit(fn -> - Application.delete_env(:symphony_elixir, :workflow_file_path) - Application.delete_env(:symphony_elixir, :server_port_override) - Application.delete_env(:symphony_elixir, :memory_tracker_issues) - Application.delete_env(:symphony_elixir, :memory_tracker_recipient) - File.rm_rf(workflow_root) - end) - - :ok - end - end - end - - def write_workflow_file!(path, overrides \\ []) do - workflow = workflow_content(overrides) - File.write!(path, workflow) - - if Process.whereis(SymphonyElixir.WorkflowStore) do - try do - SymphonyElixir.WorkflowStore.force_reload() - catch - :exit, _reason -> :ok - end - end - - :ok - end - - def restore_env(key, nil), do: System.delete_env(key) - def restore_env(key, value), do: System.put_env(key, value) - - def stop_default_http_server do - case Enum.find(Supervisor.which_children(SymphonyElixir.Supervisor), fn - {SymphonyElixir.HttpServer, _pid, _type, _modules} -> true - _child -> false - end) do - {SymphonyElixir.HttpServer, pid, _type, _modules} when is_pid(pid) -> - :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, SymphonyElixir.HttpServer) - - if Process.alive?(pid) do - Process.exit(pid, :normal) - end - - :ok - - _ -> - :ok - end - end - - defp workflow_content(overrides) do - config = - Keyword.merge( - [ - tracker_kind: "linear", - tracker_endpoint: "https://api.linear.app/graphql", - tracker_api_token: "token", - tracker_project_slug: "project", - tracker_assignee: nil, - tracker_active_states: ["Todo", "In Progress"], - tracker_terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"], - poll_interval_ms: 30_000, - workspace_root: Path.join(System.tmp_dir!(), "symphony_workspaces"), - max_concurrent_agents: 10, - max_turns: 20, - max_retry_backoff_ms: 300_000, - max_concurrent_agents_by_state: %{}, - codex_command: "codex app-server", - codex_approval_policy: %{reject: %{sandbox_approval: true, rules: true, mcp_elicitations: true}}, - codex_thread_sandbox: "workspace-write", - codex_turn_sandbox_policy: nil, - codex_turn_timeout_ms: 3_600_000, - codex_read_timeout_ms: 5_000, - codex_stall_timeout_ms: 300_000, - hook_after_create: nil, - hook_before_run: nil, - hook_after_run: nil, - hook_before_remove: nil, - hook_timeout_ms: 60_000, - observability_enabled: true, - observability_refresh_ms: 1_000, - observability_render_interval_ms: 16, - server_port: nil, - server_host: nil, - prompt: @workflow_prompt - ], - overrides - ) - - tracker_kind = Keyword.get(config, :tracker_kind) - tracker_endpoint = Keyword.get(config, :tracker_endpoint) - tracker_api_token = Keyword.get(config, :tracker_api_token) - tracker_project_slug = Keyword.get(config, :tracker_project_slug) - tracker_assignee = Keyword.get(config, :tracker_assignee) - tracker_active_states = Keyword.get(config, :tracker_active_states) - tracker_terminal_states = Keyword.get(config, :tracker_terminal_states) - poll_interval_ms = Keyword.get(config, :poll_interval_ms) - workspace_root = Keyword.get(config, :workspace_root) - max_concurrent_agents = Keyword.get(config, :max_concurrent_agents) - max_turns = Keyword.get(config, :max_turns) - max_retry_backoff_ms = Keyword.get(config, :max_retry_backoff_ms) - max_concurrent_agents_by_state = Keyword.get(config, :max_concurrent_agents_by_state) - codex_command = Keyword.get(config, :codex_command) - codex_approval_policy = Keyword.get(config, :codex_approval_policy) - codex_thread_sandbox = Keyword.get(config, :codex_thread_sandbox) - codex_turn_sandbox_policy = Keyword.get(config, :codex_turn_sandbox_policy) - codex_turn_timeout_ms = Keyword.get(config, :codex_turn_timeout_ms) - codex_read_timeout_ms = Keyword.get(config, :codex_read_timeout_ms) - codex_stall_timeout_ms = Keyword.get(config, :codex_stall_timeout_ms) - hook_after_create = Keyword.get(config, :hook_after_create) - hook_before_run = Keyword.get(config, :hook_before_run) - hook_after_run = Keyword.get(config, :hook_after_run) - hook_before_remove = Keyword.get(config, :hook_before_remove) - hook_timeout_ms = Keyword.get(config, :hook_timeout_ms) - observability_enabled = Keyword.get(config, :observability_enabled) - observability_refresh_ms = Keyword.get(config, :observability_refresh_ms) - observability_render_interval_ms = Keyword.get(config, :observability_render_interval_ms) - server_port = Keyword.get(config, :server_port) - server_host = Keyword.get(config, :server_host) - prompt = Keyword.get(config, :prompt) - - sections = - [ - "---", - "tracker:", - " kind: #{yaml_value(tracker_kind)}", - " endpoint: #{yaml_value(tracker_endpoint)}", - " api_key: #{yaml_value(tracker_api_token)}", - " project_slug: #{yaml_value(tracker_project_slug)}", - " assignee: #{yaml_value(tracker_assignee)}", - " active_states: #{yaml_value(tracker_active_states)}", - " terminal_states: #{yaml_value(tracker_terminal_states)}", - "polling:", - " interval_ms: #{yaml_value(poll_interval_ms)}", - "workspace:", - " root: #{yaml_value(workspace_root)}", - "agent:", - " max_concurrent_agents: #{yaml_value(max_concurrent_agents)}", - " max_turns: #{yaml_value(max_turns)}", - " max_retry_backoff_ms: #{yaml_value(max_retry_backoff_ms)}", - " max_concurrent_agents_by_state: #{yaml_value(max_concurrent_agents_by_state)}", - "codex:", - " command: #{yaml_value(codex_command)}", - " approval_policy: #{yaml_value(codex_approval_policy)}", - " thread_sandbox: #{yaml_value(codex_thread_sandbox)}", - " turn_sandbox_policy: #{yaml_value(codex_turn_sandbox_policy)}", - " turn_timeout_ms: #{yaml_value(codex_turn_timeout_ms)}", - " read_timeout_ms: #{yaml_value(codex_read_timeout_ms)}", - " stall_timeout_ms: #{yaml_value(codex_stall_timeout_ms)}", - hooks_yaml(hook_after_create, hook_before_run, hook_after_run, hook_before_remove, hook_timeout_ms), - observability_yaml(observability_enabled, observability_refresh_ms, observability_render_interval_ms), - server_yaml(server_port, server_host), - "---", - prompt - ] - |> Enum.reject(&(&1 in [nil, ""])) - - Enum.join(sections, "\n") <> "\n" - end - - defp yaml_value(value) when is_binary(value) do - "\"" <> String.replace(value, "\"", "\\\"") <> "\"" - end - - defp yaml_value(value) when is_integer(value), do: to_string(value) - defp yaml_value(true), do: "true" - defp yaml_value(false), do: "false" - defp yaml_value(nil), do: "null" - - defp yaml_value(values) when is_list(values) do - "[" <> Enum.map_join(values, ", ", &yaml_value/1) <> "]" - end - - defp yaml_value(values) when is_map(values) do - "{" <> - Enum.map_join(values, ", ", fn {key, value} -> - "#{yaml_value(to_string(key))}: #{yaml_value(value)}" - end) <> "}" - end - - defp yaml_value(value), do: yaml_value(to_string(value)) - - defp hooks_yaml(nil, nil, nil, nil, timeout_ms), do: "hooks:\n timeout_ms: #{yaml_value(timeout_ms)}" - - defp hooks_yaml(hook_after_create, hook_before_run, hook_after_run, hook_before_remove, timeout_ms) do - [ - "hooks:", - " timeout_ms: #{yaml_value(timeout_ms)}", - hook_entry("after_create", hook_after_create), - hook_entry("before_run", hook_before_run), - hook_entry("after_run", hook_after_run), - hook_entry("before_remove", hook_before_remove) - ] - |> Enum.reject(&is_nil/1) - |> Enum.join("\n") - end - - defp observability_yaml(enabled, refresh_ms, render_interval_ms) do - [ - "observability:", - " dashboard_enabled: #{yaml_value(enabled)}", - " refresh_ms: #{yaml_value(refresh_ms)}", - " render_interval_ms: #{yaml_value(render_interval_ms)}" - ] - |> Enum.join("\n") - end - - defp server_yaml(nil, nil), do: nil - - defp server_yaml(port, host) do - [ - "server:", - port && " port: #{yaml_value(port)}", - host && " host: #{yaml_value(host)}" - ] - |> Enum.reject(&is_nil/1) - |> Enum.join("\n") - end - - defp hook_entry(_name, nil), do: nil - - defp hook_entry(name, command) when is_binary(command) do - indented = - command - |> String.split("\n") - |> Enum.map_join("\n", &(" " <> &1)) - - " #{name}: |\n#{indented}" - end -end diff --git a/elixir/test/symphony_elixir/app_server_test.exs b/elixir/test/symphony_elixir/app_server_test.exs deleted file mode 100644 index 20ab61e9c1..0000000000 --- a/elixir/test/symphony_elixir/app_server_test.exs +++ /dev/null @@ -1,1058 +0,0 @@ -defmodule SymphonyElixir.AppServerTest do - use SymphonyElixir.TestSupport - - test "app server rejects the workspace root and paths outside workspace root" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-cwd-guard-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - outside_workspace = Path.join(test_root, "outside") - - File.mkdir_p!(workspace_root) - File.mkdir_p!(outside_workspace) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root - ) - - issue = %Issue{ - id: "issue-workspace-guard", - identifier: "MT-999", - title: "Validate workspace guard", - description: "Ensure app-server refuses invalid cwd targets", - state: "In Progress", - url: "https://example.org/issues/MT-999", - labels: ["backend"] - } - - assert {:error, {:invalid_workspace_cwd, :workspace_root, _path}} = - AppServer.run(workspace_root, "guard", issue) - - assert {:error, {:invalid_workspace_cwd, :outside_workspace_root, _path, _root}} = - AppServer.run(outside_workspace, "guard", issue) - after - File.rm_rf(test_root) - end - end - - test "app server marks request-for-input events as a hard failure" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-input-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-88") - codex_binary = Path.join(test_root, "fake-codex") - trace_file = Path.join(test_root, "codex-input.trace") - previous_trace = System.get_env("SYMP_TEST_CODEx_TRACE") - - on_exit(fn -> - if is_binary(previous_trace) do - System.put_env("SYMP_TEST_CODEx_TRACE", previous_trace) - else - System.delete_env("SYMP_TEST_CODEx_TRACE") - end - end) - - System.put_env("SYMP_TEST_CODEx_TRACE", trace_file) - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - trace_file="${SYMP_TEST_CODEx_TRACE:-/tmp/codex-input.trace}" - count=0 - while IFS= read -r line; do - count=$((count + 1)) - printf 'JSON:%s\\n' \"$line\" >> \"$trace_file\" - - case \"$count\" in - 1) - printf '%s\\n' '{\"id\":1,\"result\":{}}' - ;; - 2) - printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-88\"}}}' - ;; - 3) - printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-88\"}}}' - ;; - 4) - printf '%s\\n' '{\"method\":\"turn/input_required\",\"id\":\"resp-1\",\"params\":{\"requiresInput\":true,\"reason\":\"blocked\"}}' - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server" - ) - - issue = %Issue{ - id: "issue-input", - identifier: "MT-88", - title: "Input needed", - description: "Cannot satisfy codex input", - state: "In Progress", - url: "https://example.org/issues/MT-88", - labels: ["backend"] - } - - assert {:error, {:turn_input_required, payload}} = - AppServer.run(workspace, "Needs input", issue) - - assert payload["method"] == "turn/input_required" - after - File.rm_rf(test_root) - end - end - - test "app server fails when command execution approval is required under safer defaults" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-approval-required-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-89") - codex_binary = Path.join(test_root, "fake-codex") - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - count=0 - while IFS= read -r _line; do - count=$((count + 1)) - - case "$count" in - 1) - printf '%s\\n' '{"id":1,"result":{}}' - ;; - 2) - printf '%s\\n' '{"id":2,"result":{"thread":{"id":"thread-89"}}}' - ;; - 3) - printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-89"}}}' - printf '%s\\n' '{"id":99,"method":"item/commandExecution/requestApproval","params":{"command":"gh pr view","cwd":"/tmp","reason":"need approval"}}' - ;; - *) - sleep 1 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server" - ) - - issue = %Issue{ - id: "issue-approval-required", - identifier: "MT-89", - title: "Approval required", - description: "Ensure safer defaults do not auto approve requests", - state: "In Progress", - url: "https://example.org/issues/MT-89", - labels: ["backend"] - } - - assert {:error, {:approval_required, payload}} = - AppServer.run(workspace, "Handle approval request", issue) - - assert payload["method"] == "item/commandExecution/requestApproval" - after - File.rm_rf(test_root) - end - end - - test "app server auto-approves command execution approval requests when approval policy is never" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-auto-approve-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-89") - codex_binary = Path.join(test_root, "fake-codex") - trace_file = Path.join(test_root, "codex-auto-approve.trace") - previous_trace = System.get_env("SYMP_TEST_CODex_TRACE") - - on_exit(fn -> - if is_binary(previous_trace) do - System.put_env("SYMP_TEST_CODex_TRACE", previous_trace) - else - System.delete_env("SYMP_TEST_CODex_TRACE") - end - end) - - System.put_env("SYMP_TEST_CODex_TRACE", trace_file) - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - trace_file="${SYMP_TEST_CODex_TRACE:-/tmp/codex-auto-approve.trace}" - count=0 - while IFS= read -r line; do - count=$((count + 1)) - printf 'JSON:%s\\n' \"$line\" >> \"$trace_file\" - - case \"$count\" in - 1) - printf '%s\\n' '{\"id\":1,\"result\":{}}' - ;; - 2) - ;; - 3) - printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-89\"}}}' - ;; - 4) - printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-89\"}}}' - printf '%s\\n' '{\"id\":99,\"method\":\"item/commandExecution/requestApproval\",\"params\":{\"command\":\"gh pr view\",\"cwd\":\"/tmp\",\"reason\":\"need approval\"}}' - ;; - 5) - printf '%s\\n' '{\"method\":\"turn/completed\"}' - exit 0 - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server", - codex_approval_policy: "never" - ) - - issue = %Issue{ - id: "issue-auto-approve", - identifier: "MT-89", - title: "Auto approve request", - description: "Ensure app-server approval requests are handled automatically", - state: "In Progress", - url: "https://example.org/issues/MT-89", - labels: ["backend"] - } - - assert {:ok, _result} = AppServer.run(workspace, "Handle approval request", issue) - - trace = File.read!(trace_file) - lines = String.split(trace, "\n", trim: true) - - assert Enum.any?(lines, fn line -> - if String.starts_with?(line, "JSON:") do - payload = - line - |> String.trim_leading("JSON:") - |> Jason.decode!() - - payload["id"] == 1 and - get_in(payload, ["params", "capabilities", "experimentalApi"]) == true - else - false - end - end) - - assert Enum.any?(lines, fn line -> - if String.starts_with?(line, "JSON:") do - payload = - line - |> String.trim_leading("JSON:") - |> Jason.decode!() - - payload["id"] == 2 and - case get_in(payload, ["params", "dynamicTools"]) do - [ - %{ - "description" => description, - "inputSchema" => %{"required" => ["query"]}, - "name" => "linear_graphql" - } - ] -> - description =~ "Linear" - - _ -> - false - end - else - false - end - end) - - assert Enum.any?(lines, fn line -> - if String.starts_with?(line, "JSON:") do - payload = - line - |> String.trim_leading("JSON:") - |> Jason.decode!() - - payload["id"] == 99 and get_in(payload, ["result", "decision"]) == "acceptForSession" - else - false - end - end) - after - File.rm_rf(test_root) - end - end - - test "app server auto-approves MCP tool approval prompts when approval policy is never" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-tool-user-input-auto-approve-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-717") - codex_binary = Path.join(test_root, "fake-codex") - trace_file = Path.join(test_root, "codex-tool-user-input-auto-approve.trace") - previous_trace = System.get_env("SYMP_TEST_CODEx_TRACE") - - on_exit(fn -> - if is_binary(previous_trace) do - System.put_env("SYMP_TEST_CODEx_TRACE", previous_trace) - else - System.delete_env("SYMP_TEST_CODEx_TRACE") - end - end) - - System.put_env("SYMP_TEST_CODEx_TRACE", trace_file) - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - trace_file="${SYMP_TEST_CODEx_TRACE:-/tmp/codex-tool-user-input-auto-approve.trace}" - count=0 - while IFS= read -r line; do - count=$((count + 1)) - printf 'JSON:%s\\n' \"$line\" >> \"$trace_file\" - - case \"$count\" in - 1) - printf '%s\\n' '{\"id\":1,\"result\":{}}' - ;; - 2) - ;; - 3) - printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-717\"}}}' - ;; - 4) - printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-717\"}}}' - printf '%s\\n' '{\"id\":110,\"method\":\"item/tool/requestUserInput\",\"params\":{\"itemId\":\"call-717\",\"questions\":[{\"header\":\"Approve app tool call?\",\"id\":\"mcp_tool_call_approval_call-717\",\"isOther\":false,\"isSecret\":false,\"options\":[{\"description\":\"Run the tool and continue.\",\"label\":\"Approve Once\"},{\"description\":\"Run the tool and remember this choice for this session.\",\"label\":\"Approve this Session\"},{\"description\":\"Decline this tool call and continue.\",\"label\":\"Deny\"},{\"description\":\"Cancel this tool call\",\"label\":\"Cancel\"}],\"question\":\"The linear MCP server wants to run the tool \\\"Save issue\\\", which may modify or delete data. Allow this action?\"}],\"threadId\":\"thread-717\",\"turnId\":\"turn-717\"}}' - ;; - 5) - printf '%s\\n' '{\"method\":\"turn/completed\"}' - exit 0 - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server", - codex_approval_policy: "never" - ) - - issue = %Issue{ - id: "issue-tool-user-input-auto-approve", - identifier: "MT-717", - title: "Auto approve MCP tool request user input", - description: "Ensure app tool approval prompts continue automatically", - state: "In Progress", - url: "https://example.org/issues/MT-717", - labels: ["backend"] - } - - assert {:ok, _result} = AppServer.run(workspace, "Handle tool approval prompt", issue) - - trace = File.read!(trace_file) - lines = String.split(trace, "\n", trim: true) - - assert Enum.any?(lines, fn line -> - if String.starts_with?(line, "JSON:") do - payload = - line - |> String.trim_leading("JSON:") - |> Jason.decode!() - - payload["id"] == 110 and - get_in(payload, ["result", "answers", "mcp_tool_call_approval_call-717", "answers"]) == - ["Approve this Session"] - else - false - end - end) - after - File.rm_rf(test_root) - end - end - - test "app server sends a generic non-interactive answer for freeform tool input prompts" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-tool-user-input-required-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-718") - codex_binary = Path.join(test_root, "fake-codex") - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - count=0 - while IFS= read -r _line; do - count=$((count + 1)) - - case "$count" in - 1) - printf '%s\\n' '{"id":1,"result":{}}' - ;; - 2) - ;; - 3) - printf '%s\\n' '{"id":2,"result":{"thread":{"id":"thread-718"}}}' - ;; - 4) - printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-718"}}}' - printf '%s\\n' '{"id":111,"method":"item/tool/requestUserInput","params":{"itemId":"call-718","questions":[{"header":"Provide context","id":"freeform-718","isOther":false,"isSecret":false,"options":null,"question":"What comment should I post back to the issue?"}],"threadId":"thread-718","turnId":"turn-718"}}' - ;; - 5) - printf '%s\\n' '{"method":"turn/completed"}' - exit 0 - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server", - codex_approval_policy: "never" - ) - - issue = %Issue{ - id: "issue-tool-user-input-required", - identifier: "MT-718", - title: "Non interactive tool input answer", - description: "Ensure arbitrary tool prompts receive a generic answer", - state: "In Progress", - url: "https://example.org/issues/MT-718", - labels: ["backend"] - } - - on_message = fn message -> send(self(), {:app_server_message, message}) end - - assert {:ok, _result} = - AppServer.run(workspace, "Handle generic tool input", issue, on_message: on_message) - - assert_received {:app_server_message, - %{ - event: :tool_input_auto_answered, - answer: "This is a non-interactive session. Operator input is unavailable." - }} - after - File.rm_rf(test_root) - end - end - - test "app server sends a generic non-interactive answer for option-based tool input prompts" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-tool-user-input-options-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-719") - codex_binary = Path.join(test_root, "fake-codex") - trace_file = Path.join(test_root, "codex-tool-user-input-options.trace") - previous_trace = System.get_env("SYMP_TEST_CODEx_TRACE") - - on_exit(fn -> - if is_binary(previous_trace) do - System.put_env("SYMP_TEST_CODEx_TRACE", previous_trace) - else - System.delete_env("SYMP_TEST_CODEx_TRACE") - end - end) - - System.put_env("SYMP_TEST_CODEx_TRACE", trace_file) - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - trace_file="${SYMP_TEST_CODEx_TRACE:-/tmp/codex-tool-user-input-options.trace}" - count=0 - while IFS= read -r line; do - count=$((count + 1)) - printf 'JSON:%s\\n' \"$line\" >> \"$trace_file\" - - case \"$count\" in - 1) - printf '%s\\n' '{\"id\":1,\"result\":{}}' - ;; - 2) - ;; - 3) - printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-719\"}}}' - ;; - 4) - printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-719\"}}}' - printf '%s\\n' '{\"id\":112,\"method\":\"item/tool/requestUserInput\",\"params\":{\"itemId\":\"call-719\",\"questions\":[{\"header\":\"Choose an action\",\"id\":\"options-719\",\"isOther\":false,\"isSecret\":false,\"options\":[{\"description\":\"Use the default behavior.\",\"label\":\"Use default\"},{\"description\":\"Skip this step.\",\"label\":\"Skip\"}],\"question\":\"How should I proceed?\"}],\"threadId\":\"thread-719\",\"turnId\":\"turn-719\"}}' - ;; - 5) - printf '%s\\n' '{\"method\":\"turn/completed\"}' - exit 0 - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server" - ) - - issue = %Issue{ - id: "issue-tool-user-input-options", - identifier: "MT-719", - title: "Option based tool input answer", - description: "Ensure option prompts receive a generic non-interactive answer", - state: "In Progress", - url: "https://example.org/issues/MT-719", - labels: ["backend"] - } - - assert {:ok, _result} = - AppServer.run(workspace, "Handle option based tool input", issue) - - trace = File.read!(trace_file) - lines = String.split(trace, "\n", trim: true) - - assert Enum.any?(lines, fn line -> - if String.starts_with?(line, "JSON:") do - payload = - line - |> String.trim_leading("JSON:") - |> Jason.decode!() - - payload["id"] == 112 and - get_in(payload, ["result", "answers", "options-719", "answers"]) == [ - "This is a non-interactive session. Operator input is unavailable." - ] - else - false - end - end) - after - File.rm_rf(test_root) - end - end - - test "app server rejects unsupported dynamic tool calls without stalling" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-tool-call-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-90") - codex_binary = Path.join(test_root, "fake-codex") - trace_file = Path.join(test_root, "codex-tool-call.trace") - previous_trace = System.get_env("SYMP_TEST_CODEx_TRACE") - - on_exit(fn -> - if is_binary(previous_trace) do - System.put_env("SYMP_TEST_CODEx_TRACE", previous_trace) - else - System.delete_env("SYMP_TEST_CODEx_TRACE") - end - end) - - System.put_env("SYMP_TEST_CODEx_TRACE", trace_file) - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - trace_file="${SYMP_TEST_CODEx_TRACE:-/tmp/codex-tool-call.trace}" - count=0 - while IFS= read -r line; do - count=$((count + 1)) - printf 'JSON:%s\\n' \"$line\" >> \"$trace_file\" - - case \"$count\" in - 1) - printf '%s\\n' '{\"id\":1,\"result\":{}}' - ;; - 2) - ;; - 3) - printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-90\"}}}' - ;; - 4) - printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-90\"}}}' - printf '%s\\n' '{\"id\":101,\"method\":\"item/tool/call\",\"params\":{\"tool\":\"some_tool\",\"callId\":\"call-90\",\"threadId\":\"thread-90\",\"turnId\":\"turn-90\",\"arguments\":{}}}' - ;; - 5) - printf '%s\\n' '{\"method\":\"turn/completed\"}' - exit 0 - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server" - ) - - issue = %Issue{ - id: "issue-tool-call", - identifier: "MT-90", - title: "Unsupported tool call", - description: "Ensure unsupported tool calls do not stall a turn", - state: "In Progress", - url: "https://example.org/issues/MT-90", - labels: ["backend"] - } - - assert {:ok, _result} = AppServer.run(workspace, "Reject unsupported tool calls", issue) - - trace = File.read!(trace_file) - lines = String.split(trace, "\n", trim: true) - - assert Enum.any?(lines, fn line -> - if String.starts_with?(line, "JSON:") do - payload = - line - |> String.trim_leading("JSON:") - |> Jason.decode!() - - payload["id"] == 101 and - get_in(payload, ["result", "success"]) == false and - get_in(payload, ["result", "contentItems", Access.at(0), "type"]) == "inputText" and - String.contains?( - get_in(payload, ["result", "contentItems", Access.at(0), "text"]), - "Unsupported dynamic tool" - ) - else - false - end - end) - after - File.rm_rf(test_root) - end - end - - test "app server executes supported dynamic tool calls and returns the tool result" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-supported-tool-call-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-90A") - codex_binary = Path.join(test_root, "fake-codex") - trace_file = Path.join(test_root, "codex-supported-tool-call.trace") - previous_trace = System.get_env("SYMP_TEST_CODEx_TRACE") - - on_exit(fn -> - if is_binary(previous_trace) do - System.put_env("SYMP_TEST_CODEx_TRACE", previous_trace) - else - System.delete_env("SYMP_TEST_CODEx_TRACE") - end - end) - - System.put_env("SYMP_TEST_CODEx_TRACE", trace_file) - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - trace_file="${SYMP_TEST_CODEx_TRACE:-/tmp/codex-supported-tool-call.trace}" - count=0 - while IFS= read -r line; do - count=$((count + 1)) - printf 'JSON:%s\\n' \"$line\" >> \"$trace_file\" - - case \"$count\" in - 1) - printf '%s\\n' '{\"id\":1,\"result\":{}}' - ;; - 2) - ;; - 3) - printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-90a\"}}}' - ;; - 4) - printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-90a\"}}}' - printf '%s\\n' '{\"id\":102,\"method\":\"item/tool/call\",\"params\":{\"name\":\"linear_graphql\",\"callId\":\"call-90a\",\"threadId\":\"thread-90a\",\"turnId\":\"turn-90a\",\"arguments\":{\"query\":\"query Viewer { viewer { id } }\",\"variables\":{\"includeTeams\":false}}}}' - ;; - 5) - printf '%s\\n' '{\"method\":\"turn/completed\"}' - exit 0 - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server" - ) - - issue = %Issue{ - id: "issue-supported-tool-call", - identifier: "MT-90A", - title: "Supported tool call", - description: "Ensure supported tool calls return tool output", - state: "In Progress", - url: "https://example.org/issues/MT-90A", - labels: ["backend"] - } - - test_pid = self() - - tool_executor = fn tool, arguments -> - send(test_pid, {:tool_called, tool, arguments}) - - %{ - "success" => true, - "contentItems" => [ - %{ - "type" => "inputText", - "text" => ~s({"data":{"viewer":{"id":"usr_123"}}}) - } - ] - } - end - - assert {:ok, _result} = - AppServer.run(workspace, "Handle supported tool calls", issue, tool_executor: tool_executor) - - assert_received {:tool_called, "linear_graphql", - %{ - "query" => "query Viewer { viewer { id } }", - "variables" => %{"includeTeams" => false} - }} - - trace = File.read!(trace_file) - lines = String.split(trace, "\n", trim: true) - - assert Enum.any?(lines, fn line -> - if String.starts_with?(line, "JSON:") do - payload = - line - |> String.trim_leading("JSON:") - |> Jason.decode!() - - payload["id"] == 102 and - get_in(payload, ["result", "success"]) == true and - get_in(payload, ["result", "contentItems", Access.at(0), "text"]) == - ~s({"data":{"viewer":{"id":"usr_123"}}}) - else - false - end - end) - after - File.rm_rf(test_root) - end - end - - test "app server emits tool_call_failed for supported tool failures" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-tool-call-failed-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-90B") - codex_binary = Path.join(test_root, "fake-codex") - trace_file = Path.join(test_root, "codex-tool-call-failed.trace") - previous_trace = System.get_env("SYMP_TEST_CODEx_TRACE") - - on_exit(fn -> - if is_binary(previous_trace) do - System.put_env("SYMP_TEST_CODEx_TRACE", previous_trace) - else - System.delete_env("SYMP_TEST_CODEx_TRACE") - end - end) - - System.put_env("SYMP_TEST_CODEx_TRACE", trace_file) - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - trace_file="${SYMP_TEST_CODEx_TRACE:-/tmp/codex-tool-call-failed.trace}" - count=0 - while IFS= read -r line; do - count=$((count + 1)) - printf 'JSON:%s\\n' \"$line\" >> \"$trace_file\" - - case \"$count\" in - 1) - printf '%s\\n' '{\"id\":1,\"result\":{}}' - ;; - 2) - ;; - 3) - printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-90b\"}}}' - ;; - 4) - printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-90b\"}}}' - printf '%s\\n' '{\"id\":103,\"method\":\"item/tool/call\",\"params\":{\"tool\":\"linear_graphql\",\"callId\":\"call-90b\",\"threadId\":\"thread-90b\",\"turnId\":\"turn-90b\",\"arguments\":{\"query\":\"query Viewer { viewer { id } }\"}}}' - ;; - 5) - printf '%s\\n' '{\"method\":\"turn/completed\"}' - exit 0 - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server" - ) - - issue = %Issue{ - id: "issue-tool-call-failed", - identifier: "MT-90B", - title: "Tool call failed", - description: "Ensure supported tool failures emit a distinct event", - state: "In Progress", - url: "https://example.org/issues/MT-90B", - labels: ["backend"] - } - - test_pid = self() - - tool_executor = fn tool, arguments -> - send(test_pid, {:tool_called, tool, arguments}) - - %{ - "success" => false, - "contentItems" => [ - %{ - "type" => "inputText", - "text" => ~s({"error":{"message":"boom"}}) - } - ] - } - end - - on_message = fn message -> send(test_pid, {:app_server_message, message}) end - - assert {:ok, _result} = - AppServer.run(workspace, "Handle failed tool calls", issue, - on_message: on_message, - tool_executor: tool_executor - ) - - assert_received {:tool_called, "linear_graphql", %{"query" => "query Viewer { viewer { id } }"}} - - assert_received {:app_server_message, %{event: :tool_call_failed, payload: %{"params" => %{"tool" => "linear_graphql"}}}} - after - File.rm_rf(test_root) - end - end - - test "app server buffers partial JSON lines until newline terminator" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-partial-line-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-91") - codex_binary = Path.join(test_root, "fake-codex") - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - count=0 - while IFS= read -r line; do - count=$((count + 1)) - - case "$count" in - 1) - padding=$(printf '%*s' 1100000 '' | tr ' ' a) - printf '{"id":1,"result":{},"padding":"%s"}\\n' "$padding" - ;; - 2) - printf '%s\\n' '{"id":2,"result":{"thread":{"id":"thread-91"}}}' - ;; - 3) - printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-91"}}}' - ;; - 4) - printf '%s\\n' '{"method":"turn/completed"}' - exit 0 - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server" - ) - - issue = %Issue{ - id: "issue-partial-line", - identifier: "MT-91", - title: "Partial line decode", - description: "Ensure JSON parsing waits for newline-delimited messages", - state: "In Progress", - url: "https://example.org/issues/MT-91", - labels: ["backend"] - } - - assert {:ok, _result} = AppServer.run(workspace, "Validate newline-delimited buffering", issue) - after - File.rm_rf(test_root) - end - end - - test "app server captures codex side output and logs it through Logger" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-stderr-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-92") - codex_binary = Path.join(test_root, "fake-codex") - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - count=0 - while IFS= read -r line; do - count=$((count + 1)) - - case "$count" in - 1) - printf '%s\\n' '{"id":1,"result":{}}' - ;; - 2) - printf '%s\\n' '{"id":2,"result":{"thread":{"id":"thread-92"}}}' - ;; - 3) - printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-92"}}}' - ;; - 4) - printf '%s\\n' 'warning: this is stderr noise' >&2 - printf '%s\\n' '{"method":"turn/completed"}' - exit 0 - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server" - ) - - issue = %Issue{ - id: "issue-stderr", - identifier: "MT-92", - title: "Capture stderr", - description: "Ensure codex stderr is captured and logged", - state: "In Progress", - url: "https://example.org/issues/MT-92", - labels: ["backend"] - } - - log = - capture_log(fn -> - assert {:ok, _result} = AppServer.run(workspace, "Capture stderr log", issue) - end) - - assert log =~ "Codex turn stream output: warning: this is stderr noise" - after - File.rm_rf(test_root) - end - end -end diff --git a/elixir/test/symphony_elixir/cli_test.exs b/elixir/test/symphony_elixir/cli_test.exs deleted file mode 100644 index 4e42147936..0000000000 --- a/elixir/test/symphony_elixir/cli_test.exs +++ /dev/null @@ -1,139 +0,0 @@ -defmodule SymphonyElixir.CLITest do - use ExUnit.Case, async: true - - alias SymphonyElixir.CLI - - @ack_flag "--i-understand-that-this-will-be-running-without-the-usual-guardrails" - - test "returns the guardrails acknowledgement banner when the flag is missing" do - parent = self() - - deps = %{ - file_regular?: fn _path -> - send(parent, :file_checked) - true - end, - set_workflow_file_path: fn _path -> - send(parent, :workflow_set) - :ok - end, - set_logs_root: fn _path -> - send(parent, :logs_root_set) - :ok - end, - set_server_port_override: fn _port -> - send(parent, :port_set) - :ok - end, - ensure_all_started: fn -> - send(parent, :started) - {:ok, [:symphony_elixir]} - end - } - - assert {:error, banner} = CLI.evaluate(["WORKFLOW.md"], deps) - assert banner =~ "This Symphony implementation is a low key engineering preview." - assert banner =~ "Codex will run without any guardrails." - assert banner =~ "SymphonyElixir is not a supported product and is presented as-is." - assert banner =~ @ack_flag - refute_received :file_checked - refute_received :workflow_set - refute_received :logs_root_set - refute_received :port_set - refute_received :started - end - - test "defaults to WORKFLOW.md when workflow path is missing" do - deps = %{ - file_regular?: fn path -> Path.basename(path) == "WORKFLOW.md" end, - set_workflow_file_path: fn _path -> :ok end, - set_logs_root: fn _path -> :ok end, - set_server_port_override: fn _port -> :ok end, - ensure_all_started: fn -> {:ok, [:symphony_elixir]} end - } - - assert :ok = CLI.evaluate([@ack_flag], deps) - end - - test "uses an explicit workflow path override when provided" do - parent = self() - workflow_path = "tmp/custom/WORKFLOW.md" - expanded_path = Path.expand(workflow_path) - - deps = %{ - file_regular?: fn path -> - send(parent, {:workflow_checked, path}) - path == expanded_path - end, - set_workflow_file_path: fn path -> - send(parent, {:workflow_set, path}) - :ok - end, - set_logs_root: fn _path -> :ok end, - set_server_port_override: fn _port -> :ok end, - ensure_all_started: fn -> {:ok, [:symphony_elixir]} end - } - - assert :ok = CLI.evaluate([@ack_flag, workflow_path], deps) - assert_received {:workflow_checked, ^expanded_path} - assert_received {:workflow_set, ^expanded_path} - end - - test "accepts --logs-root and passes an expanded root to runtime deps" do - parent = self() - - deps = %{ - file_regular?: fn _path -> true end, - set_workflow_file_path: fn _path -> :ok end, - set_logs_root: fn path -> - send(parent, {:logs_root, path}) - :ok - end, - set_server_port_override: fn _port -> :ok end, - ensure_all_started: fn -> {:ok, [:symphony_elixir]} end - } - - assert :ok = CLI.evaluate([@ack_flag, "--logs-root", "tmp/custom-logs", "WORKFLOW.md"], deps) - assert_received {:logs_root, expanded_path} - assert expanded_path == Path.expand("tmp/custom-logs") - end - - test "returns not found when workflow file does not exist" do - deps = %{ - file_regular?: fn _path -> false end, - set_workflow_file_path: fn _path -> :ok end, - set_logs_root: fn _path -> :ok end, - set_server_port_override: fn _port -> :ok end, - ensure_all_started: fn -> {:ok, [:symphony_elixir]} end - } - - assert {:error, message} = CLI.evaluate([@ack_flag, "WORKFLOW.md"], deps) - assert message =~ "Workflow file not found:" - end - - test "returns startup error when app cannot start" do - deps = %{ - file_regular?: fn _path -> true end, - set_workflow_file_path: fn _path -> :ok end, - set_logs_root: fn _path -> :ok end, - set_server_port_override: fn _port -> :ok end, - ensure_all_started: fn -> {:error, :boom} end - } - - assert {:error, message} = CLI.evaluate([@ack_flag, "WORKFLOW.md"], deps) - assert message =~ "Failed to start Symphony with workflow" - assert message =~ ":boom" - end - - test "returns ok when workflow exists and app starts" do - deps = %{ - file_regular?: fn _path -> true end, - set_workflow_file_path: fn _path -> :ok end, - set_logs_root: fn _path -> :ok end, - set_server_port_override: fn _port -> :ok end, - ensure_all_started: fn -> {:ok, [:symphony_elixir]} end - } - - assert :ok = CLI.evaluate([@ack_flag, "WORKFLOW.md"], deps) - end -end diff --git a/elixir/test/symphony_elixir/core_test.exs b/elixir/test/symphony_elixir/core_test.exs deleted file mode 100644 index 400c006e4f..0000000000 --- a/elixir/test/symphony_elixir/core_test.exs +++ /dev/null @@ -1,1530 +0,0 @@ -defmodule SymphonyElixir.CoreTest do - use SymphonyElixir.TestSupport - - test "config defaults and validation checks" do - write_workflow_file!(Workflow.workflow_file_path(), - tracker_api_token: nil, - tracker_project_slug: nil, - poll_interval_ms: nil, - tracker_active_states: nil, - tracker_terminal_states: nil, - codex_command: nil - ) - - assert Config.poll_interval_ms() == 30_000 - assert Config.linear_active_states() == ["Todo", "In Progress"] - assert Config.linear_terminal_states() == ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] - assert Config.linear_assignee() == nil - assert Config.agent_max_turns() == 20 - - write_workflow_file!(Workflow.workflow_file_path(), poll_interval_ms: "invalid") - assert Config.poll_interval_ms() == 30_000 - - write_workflow_file!(Workflow.workflow_file_path(), poll_interval_ms: 45_000) - assert Config.poll_interval_ms() == 45_000 - - write_workflow_file!(Workflow.workflow_file_path(), max_turns: 0) - assert Config.agent_max_turns() == 20 - - write_workflow_file!(Workflow.workflow_file_path(), max_turns: 5) - assert Config.agent_max_turns() == 5 - - write_workflow_file!(Workflow.workflow_file_path(), tracker_active_states: "Todo, Review,") - assert Config.linear_active_states() == ["Todo", "Review"] - - write_workflow_file!(Workflow.workflow_file_path(), - tracker_api_token: "token", - tracker_project_slug: nil - ) - - assert {:error, :missing_linear_project_slug} = Config.validate!() - - write_workflow_file!(Workflow.workflow_file_path(), - tracker_project_slug: "project", - codex_command: "" - ) - - assert :ok = Config.validate!() - - write_workflow_file!(Workflow.workflow_file_path(), codex_command: "/bin/sh app-server") - assert :ok = Config.validate!() - - write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: "definitely-not-valid") - assert :ok = Config.validate!() - - write_workflow_file!(Workflow.workflow_file_path(), codex_thread_sandbox: "unsafe-ish") - assert :ok = Config.validate!() - - write_workflow_file!(Workflow.workflow_file_path(), - codex_turn_sandbox_policy: %{type: "workspaceWrite", writableRoots: ["relative/path"]} - ) - - assert :ok = Config.validate!() - - write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: 123) - assert {:error, {:invalid_codex_approval_policy, 123}} = Config.validate!() - - write_workflow_file!(Workflow.workflow_file_path(), codex_thread_sandbox: 123) - assert {:error, {:invalid_codex_thread_sandbox, 123}} = Config.validate!() - - write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: 123) - assert {:error, {:unsupported_tracker_kind, "123"}} = Config.validate!() - end - - test "current WORKFLOW.md file is valid and complete" do - original_workflow_path = Workflow.workflow_file_path() - on_exit(fn -> Workflow.set_workflow_file_path(original_workflow_path) end) - Workflow.clear_workflow_file_path() - - assert {:ok, %{config: config, prompt: prompt}} = Workflow.load() - assert is_map(config) - - tracker = Map.get(config, "tracker", %{}) - assert is_map(tracker) - assert Map.get(tracker, "kind") == "linear" - assert is_binary(Map.get(tracker, "project_slug")) - assert is_list(Map.get(tracker, "active_states")) - assert is_list(Map.get(tracker, "terminal_states")) - - hooks = Map.get(config, "hooks", %{}) - assert is_map(hooks) - assert Map.get(hooks, "after_create") =~ "git clone --depth 1 https://github.com/openai/symphony ." - assert Map.get(hooks, "after_create") =~ "cd elixir && mise trust" - assert Map.get(hooks, "after_create") =~ "mise exec -- mix deps.get" - assert Map.get(hooks, "before_remove") =~ "cd elixir && mise exec -- mix workspace.before_remove" - - assert String.trim(prompt) != "" - assert is_binary(Config.workflow_prompt()) - assert Config.workflow_prompt() == prompt - end - - test "linear api token resolves from LINEAR_API_KEY env var" do - previous_linear_api_key = System.get_env("LINEAR_API_KEY") - env_api_key = "test-linear-api-key" - - on_exit(fn -> restore_env("LINEAR_API_KEY", previous_linear_api_key) end) - System.put_env("LINEAR_API_KEY", env_api_key) - - write_workflow_file!(Workflow.workflow_file_path(), - tracker_api_token: nil, - tracker_project_slug: "project", - codex_command: "/bin/sh app-server" - ) - - assert Config.linear_api_token() == env_api_key - assert Config.linear_project_slug() == "project" - assert :ok = Config.validate!() - end - - test "linear assignee resolves from LINEAR_ASSIGNEE env var" do - previous_linear_assignee = System.get_env("LINEAR_ASSIGNEE") - env_assignee = "dev@example.com" - - on_exit(fn -> restore_env("LINEAR_ASSIGNEE", previous_linear_assignee) end) - System.put_env("LINEAR_ASSIGNEE", env_assignee) - - write_workflow_file!(Workflow.workflow_file_path(), - tracker_assignee: nil, - tracker_project_slug: "project", - codex_command: "/bin/sh app-server" - ) - - assert Config.linear_assignee() == env_assignee - end - - test "workflow file path defaults to WORKFLOW.md in the current working directory when app env is unset" do - original_workflow_path = Workflow.workflow_file_path() - - on_exit(fn -> - Workflow.set_workflow_file_path(original_workflow_path) - end) - - Workflow.clear_workflow_file_path() - - assert Workflow.workflow_file_path() == Path.join(File.cwd!(), "WORKFLOW.md") - end - - test "workflow file path resolves from app env when set" do - app_workflow_path = "/tmp/app/WORKFLOW.md" - - on_exit(fn -> - Workflow.clear_workflow_file_path() - end) - - Workflow.set_workflow_file_path(app_workflow_path) - - assert Workflow.workflow_file_path() == app_workflow_path - end - - test "workflow load accepts prompt-only files without front matter" do - workflow_path = Path.join(Path.dirname(Workflow.workflow_file_path()), "PROMPT_ONLY_WORKFLOW.md") - File.write!(workflow_path, "Prompt only\n") - - assert {:ok, %{config: %{}, prompt: "Prompt only", prompt_template: "Prompt only"}} = - Workflow.load(workflow_path) - end - - test "workflow load accepts unterminated front matter with an empty prompt" do - workflow_path = Path.join(Path.dirname(Workflow.workflow_file_path()), "UNTERMINATED_WORKFLOW.md") - File.write!(workflow_path, "---\ntracker:\n kind: linear\n") - - assert {:ok, %{config: %{"tracker" => %{"kind" => "linear"}}, prompt: "", prompt_template: ""}} = - Workflow.load(workflow_path) - end - - test "workflow load rejects non-map front matter" do - workflow_path = Path.join(Path.dirname(Workflow.workflow_file_path()), "INVALID_FRONT_MATTER_WORKFLOW.md") - File.write!(workflow_path, "---\n- not-a-map\n---\nPrompt body\n") - - assert {:error, :workflow_front_matter_not_a_map} = Workflow.load(workflow_path) - end - - test "SymphonyElixir.start_link delegates to the orchestrator" do - write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") - Application.put_env(:symphony_elixir, :memory_tracker_issues, []) - orchestrator_pid = Process.whereis(SymphonyElixir.Orchestrator) - - on_exit(fn -> - if is_nil(Process.whereis(SymphonyElixir.Orchestrator)) do - case Supervisor.restart_child(SymphonyElixir.Supervisor, SymphonyElixir.Orchestrator) do - {:ok, _pid} -> :ok - {:error, {:already_started, _pid}} -> :ok - end - end - end) - - if is_pid(orchestrator_pid) do - assert :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, SymphonyElixir.Orchestrator) - end - - assert {:ok, pid} = SymphonyElixir.start_link() - assert Process.whereis(SymphonyElixir.Orchestrator) == pid - - GenServer.stop(pid) - end - - test "linear issue state reconciliation fetch with no running issues is a no-op" do - assert {:ok, []} = Client.fetch_issue_states_by_ids([]) - end - - test "non-active issue state stops running agent without cleaning workspace" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-nonactive-reconcile-#{System.unique_integer([:positive])}" - ) - - issue_id = "issue-1" - issue_identifier = "MT-555" - workspace = Path.join(test_root, issue_identifier) - - try do - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: test_root, - tracker_active_states: ["Todo", "In Progress", "In Review"], - tracker_terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate"] - ) - - File.mkdir_p!(test_root) - File.mkdir_p!(workspace) - - agent_pid = - spawn(fn -> - receive do - :stop -> :ok - end - end) - - state = %Orchestrator.State{ - running: %{ - issue_id => %{ - pid: agent_pid, - ref: nil, - identifier: issue_identifier, - issue: %Issue{id: issue_id, state: "Todo", identifier: issue_identifier}, - started_at: DateTime.utc_now() - } - }, - claimed: MapSet.new([issue_id]), - codex_totals: %{input_tokens: 0, output_tokens: 0, total_tokens: 0, seconds_running: 0}, - retry_attempts: %{} - } - - issue = %Issue{ - id: issue_id, - identifier: issue_identifier, - state: "Backlog", - title: "Queued", - description: "Not started", - labels: [] - } - - updated_state = Orchestrator.reconcile_issue_states_for_test([issue], state) - - refute Map.has_key?(updated_state.running, issue_id) - refute MapSet.member?(updated_state.claimed, issue_id) - refute Process.alive?(agent_pid) - assert File.exists?(workspace) - after - File.rm_rf(test_root) - end - end - - test "terminal issue state stops running agent and cleans workspace" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-terminal-reconcile-#{System.unique_integer([:positive])}" - ) - - issue_id = "issue-2" - issue_identifier = "MT-556" - workspace = Path.join(test_root, issue_identifier) - - try do - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: test_root, - tracker_active_states: ["Todo", "In Progress", "In Review"], - tracker_terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate"] - ) - - File.mkdir_p!(test_root) - File.mkdir_p!(workspace) - - agent_pid = - spawn(fn -> - receive do - :stop -> :ok - end - end) - - state = %Orchestrator.State{ - running: %{ - issue_id => %{ - pid: agent_pid, - ref: nil, - identifier: issue_identifier, - issue: %Issue{id: issue_id, state: "In Progress", identifier: issue_identifier}, - started_at: DateTime.utc_now() - } - }, - claimed: MapSet.new([issue_id]), - codex_totals: %{input_tokens: 0, output_tokens: 0, total_tokens: 0, seconds_running: 0}, - retry_attempts: %{} - } - - issue = %Issue{ - id: issue_id, - identifier: issue_identifier, - state: "Closed", - title: "Done", - description: "Completed", - labels: [] - } - - updated_state = Orchestrator.reconcile_issue_states_for_test([issue], state) - - refute Map.has_key?(updated_state.running, issue_id) - refute MapSet.member?(updated_state.claimed, issue_id) - refute Process.alive?(agent_pid) - refute File.exists?(workspace) - after - File.rm_rf(test_root) - end - end - - test "reconcile updates running issue state for active issues" do - issue_id = "issue-3" - - state = %Orchestrator.State{ - running: %{ - issue_id => %{ - pid: self(), - ref: nil, - identifier: "MT-557", - issue: %Issue{ - id: issue_id, - identifier: "MT-557", - state: "Todo" - }, - started_at: DateTime.utc_now() - } - }, - claimed: MapSet.new([issue_id]), - codex_totals: %{input_tokens: 0, output_tokens: 0, total_tokens: 0, seconds_running: 0}, - retry_attempts: %{} - } - - issue = %Issue{ - id: issue_id, - identifier: "MT-557", - state: "In Progress", - title: "Active state refresh", - description: "State should be refreshed", - labels: [] - } - - updated_state = Orchestrator.reconcile_issue_states_for_test([issue], state) - updated_entry = updated_state.running[issue_id] - - assert Map.has_key?(updated_state.running, issue_id) - assert MapSet.member?(updated_state.claimed, issue_id) - assert updated_entry.issue.state == "In Progress" - end - - test "reconcile stops running issue when it is reassigned away from this worker" do - issue_id = "issue-reassigned" - - agent_pid = - spawn(fn -> - receive do - :stop -> :ok - end - end) - - state = %Orchestrator.State{ - running: %{ - issue_id => %{ - pid: agent_pid, - ref: nil, - identifier: "MT-561", - issue: %Issue{ - id: issue_id, - identifier: "MT-561", - state: "In Progress", - assigned_to_worker: true - }, - started_at: DateTime.utc_now() - } - }, - claimed: MapSet.new([issue_id]), - codex_totals: %{input_tokens: 0, output_tokens: 0, total_tokens: 0, seconds_running: 0}, - retry_attempts: %{} - } - - issue = %Issue{ - id: issue_id, - identifier: "MT-561", - state: "In Progress", - title: "Reassigned active issue", - description: "Worker should stop", - labels: [], - assigned_to_worker: false - } - - updated_state = Orchestrator.reconcile_issue_states_for_test([issue], state) - - refute Map.has_key?(updated_state.running, issue_id) - refute MapSet.member?(updated_state.claimed, issue_id) - refute Process.alive?(agent_pid) - end - - test "normal worker exit schedules active-state continuation retry" do - issue_id = "issue-resume" - ref = make_ref() - orchestrator_name = Module.concat(__MODULE__, :ContinuationOrchestrator) - {:ok, pid} = Orchestrator.start_link(name: orchestrator_name) - - on_exit(fn -> - if Process.alive?(pid) do - Process.exit(pid, :normal) - end - end) - - initial_state = :sys.get_state(pid) - - running_entry = %{ - pid: self(), - ref: ref, - identifier: "MT-558", - issue: %Issue{id: issue_id, identifier: "MT-558", state: "In Progress"}, - started_at: DateTime.utc_now() - } - - :sys.replace_state(pid, fn _ -> - initial_state - |> Map.put(:running, %{issue_id => running_entry}) - |> Map.put(:claimed, MapSet.new([issue_id])) - |> Map.put(:retry_attempts, %{}) - end) - - send(pid, {:DOWN, ref, :process, self(), :normal}) - Process.sleep(50) - state = :sys.get_state(pid) - - refute Map.has_key?(state.running, issue_id) - assert MapSet.member?(state.completed, issue_id) - assert %{attempt: 1, due_at_ms: due_at_ms} = state.retry_attempts[issue_id] - assert is_integer(due_at_ms) - assert_due_in_range(due_at_ms, 500, 1_100) - end - - test "abnormal worker exit increments retry attempt progressively" do - issue_id = "issue-crash" - ref = make_ref() - orchestrator_name = Module.concat(__MODULE__, :CrashRetryOrchestrator) - {:ok, pid} = Orchestrator.start_link(name: orchestrator_name) - - on_exit(fn -> - if Process.alive?(pid) do - Process.exit(pid, :normal) - end - end) - - initial_state = :sys.get_state(pid) - - running_entry = %{ - pid: self(), - ref: ref, - identifier: "MT-559", - retry_attempt: 2, - issue: %Issue{id: issue_id, identifier: "MT-559", state: "In Progress"}, - started_at: DateTime.utc_now() - } - - :sys.replace_state(pid, fn _ -> - initial_state - |> Map.put(:running, %{issue_id => running_entry}) - |> Map.put(:claimed, MapSet.new([issue_id])) - |> Map.put(:retry_attempts, %{}) - end) - - send(pid, {:DOWN, ref, :process, self(), :boom}) - Process.sleep(50) - state = :sys.get_state(pid) - - assert %{attempt: 3, due_at_ms: due_at_ms, identifier: "MT-559", error: "agent exited: :boom"} = - state.retry_attempts[issue_id] - - assert_due_in_range(due_at_ms, 39_500, 40_500) - end - - test "first abnormal worker exit waits before retrying" do - issue_id = "issue-crash-initial" - ref = make_ref() - orchestrator_name = Module.concat(__MODULE__, :InitialCrashRetryOrchestrator) - {:ok, pid} = Orchestrator.start_link(name: orchestrator_name) - - on_exit(fn -> - if Process.alive?(pid) do - Process.exit(pid, :normal) - end - end) - - initial_state = :sys.get_state(pid) - - running_entry = %{ - pid: self(), - ref: ref, - identifier: "MT-560", - issue: %Issue{id: issue_id, identifier: "MT-560", state: "In Progress"}, - started_at: DateTime.utc_now() - } - - :sys.replace_state(pid, fn _ -> - initial_state - |> Map.put(:running, %{issue_id => running_entry}) - |> Map.put(:claimed, MapSet.new([issue_id])) - |> Map.put(:retry_attempts, %{}) - end) - - send(pid, {:DOWN, ref, :process, self(), :boom}) - Process.sleep(50) - state = :sys.get_state(pid) - - assert %{attempt: 1, due_at_ms: due_at_ms, identifier: "MT-560", error: "agent exited: :boom"} = - state.retry_attempts[issue_id] - - assert_due_in_range(due_at_ms, 9_000, 10_500) - end - - defp assert_due_in_range(due_at_ms, min_remaining_ms, max_remaining_ms) do - remaining_ms = due_at_ms - System.monotonic_time(:millisecond) - - assert remaining_ms >= min_remaining_ms - assert remaining_ms <= max_remaining_ms - end - - test "fetch issues by states with empty state set is a no-op" do - assert {:ok, []} = Client.fetch_issues_by_states([]) - end - - test "prompt builder renders issue and attempt values from workflow template" do - workflow_prompt = - "Ticket {{ issue.identifier }} {{ issue.title }} labels={{ issue.labels }} attempt={{ attempt }}" - - write_workflow_file!(Workflow.workflow_file_path(), prompt: workflow_prompt) - - issue = %Issue{ - identifier: "S-1", - title: "Refactor backend request path", - description: "Replace transport layer", - state: "Todo", - url: "https://example.org/issues/S-1", - labels: ["backend"] - } - - prompt = PromptBuilder.build_prompt(issue, attempt: 3) - - assert prompt =~ "Ticket S-1 Refactor backend request path" - assert prompt =~ "labels=backend" - assert prompt =~ "attempt=3" - end - - test "prompt builder renders issue datetime fields without crashing" do - workflow_prompt = "Ticket {{ issue.identifier }} created={{ issue.created_at }} updated={{ issue.updated_at }}" - - write_workflow_file!(Workflow.workflow_file_path(), prompt: workflow_prompt) - - created_at = DateTime.from_naive!(~N[2026-02-26 18:06:48], "Etc/UTC") - updated_at = DateTime.from_naive!(~N[2026-02-26 18:07:03], "Etc/UTC") - - issue = %Issue{ - identifier: "MT-697", - title: "Live smoke", - description: "Prompt should serialize datetimes", - state: "Todo", - url: "https://example.org/issues/MT-697", - labels: [], - created_at: created_at, - updated_at: updated_at - } - - prompt = PromptBuilder.build_prompt(issue) - - assert prompt =~ "Ticket MT-697" - assert prompt =~ "created=2026-02-26T18:06:48Z" - assert prompt =~ "updated=2026-02-26T18:07:03Z" - end - - test "prompt builder normalizes nested date-like values, maps, and structs in issue fields" do - write_workflow_file!(Workflow.workflow_file_path(), prompt: "Ticket {{ issue.identifier }}") - - issue = %Issue{ - identifier: "MT-701", - title: "Serialize nested values", - description: "Prompt builder should normalize nested terms", - state: "Todo", - url: "https://example.org/issues/MT-701", - labels: [ - ~N[2026-02-27 12:34:56], - ~D[2026-02-28], - ~T[12:34:56], - %{phase: "test"}, - URI.parse("https://example.org/issues/MT-701") - ] - } - - assert PromptBuilder.build_prompt(issue) == "Ticket MT-701" - end - - test "prompt builder uses strict variable rendering" do - workflow_prompt = "Work on ticket {{ missing.ticket_id }} and follow these steps." - - write_workflow_file!(Workflow.workflow_file_path(), prompt: workflow_prompt) - - issue = %Issue{ - identifier: "MT-123", - title: "Investigate broken sync", - description: "Reproduce and fix", - state: "In Progress", - url: "https://example.org/issues/MT-123", - labels: ["bug"] - } - - assert_raise Solid.RenderError, fn -> - PromptBuilder.build_prompt(issue) - end - end - - test "prompt builder surfaces invalid template content with prompt context" do - write_workflow_file!(Workflow.workflow_file_path(), prompt: "{% if issue.identifier %}") - - issue = %Issue{ - identifier: "MT-999", - title: "Broken prompt", - description: "Invalid template syntax", - state: "Todo", - url: "https://example.org/issues/MT-999", - labels: [] - } - - assert_raise RuntimeError, ~r/template_parse_error:.*template="/s, fn -> - PromptBuilder.build_prompt(issue) - end - end - - test "prompt builder uses a sensible default template when workflow prompt is blank" do - write_workflow_file!(Workflow.workflow_file_path(), prompt: " \n") - - issue = %Issue{ - identifier: "MT-777", - title: "Make fallback prompt useful", - description: "Include enough issue context to start working.", - state: "In Progress", - url: "https://example.org/issues/MT-777", - labels: ["prompt"] - } - - prompt = PromptBuilder.build_prompt(issue) - - assert prompt =~ "You are working on a Linear issue." - assert prompt =~ "Identifier: MT-777" - assert prompt =~ "Title: Make fallback prompt useful" - assert prompt =~ "Body:" - assert prompt =~ "Include enough issue context to start working." - assert Config.workflow_prompt() =~ "{{ issue.identifier }}" - assert Config.workflow_prompt() =~ "{{ issue.title }}" - assert Config.workflow_prompt() =~ "{{ issue.description }}" - end - - test "prompt builder default template handles missing issue body" do - write_workflow_file!(Workflow.workflow_file_path(), prompt: "") - - issue = %Issue{ - identifier: "MT-778", - title: "Handle empty body", - description: nil, - state: "Todo", - url: "https://example.org/issues/MT-778", - labels: [] - } - - prompt = PromptBuilder.build_prompt(issue) - - assert prompt =~ "Identifier: MT-778" - assert prompt =~ "Title: Handle empty body" - assert prompt =~ "No description provided." - end - - test "prompt builder reports workflow load failures separately from template parse errors" do - original_workflow_path = Workflow.workflow_file_path() - workflow_store_pid = Process.whereis(SymphonyElixir.WorkflowStore) - - on_exit(fn -> - Workflow.set_workflow_file_path(original_workflow_path) - - if is_pid(workflow_store_pid) and is_nil(Process.whereis(SymphonyElixir.WorkflowStore)) do - Supervisor.restart_child(SymphonyElixir.Supervisor, SymphonyElixir.WorkflowStore) - end - end) - - assert :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, SymphonyElixir.WorkflowStore) - - Workflow.set_workflow_file_path(Path.join(System.tmp_dir!(), "missing-workflow-#{System.unique_integer([:positive])}.md")) - - issue = %Issue{ - identifier: "MT-780", - title: "Workflow unavailable", - description: "Missing workflow file", - state: "Todo", - url: "https://example.org/issues/MT-780", - labels: [] - } - - assert_raise RuntimeError, ~r/workflow_unavailable:/, fn -> - PromptBuilder.build_prompt(issue) - end - end - - test "in-repo WORKFLOW.md renders correctly" do - workflow_path = Workflow.workflow_file_path() - Workflow.set_workflow_file_path(Path.expand("WORKFLOW.md", File.cwd!())) - - issue = %Issue{ - identifier: "MT-616", - title: "Use rich templates for WORKFLOW.md", - description: "Render with rich template variables", - state: "In Progress", - url: "https://example.org/issues/MT-616/use-rich-templates-for-workflowmd", - labels: ["templating", "workflow"] - } - - on_exit(fn -> Workflow.set_workflow_file_path(workflow_path) end) - - prompt = PromptBuilder.build_prompt(issue, attempt: 2) - - assert prompt =~ "You are working on a Linear ticket `MT-616`" - assert prompt =~ "Issue context:" - assert prompt =~ "Identifier: MT-616" - assert prompt =~ "Title: Use rich templates for WORKFLOW.md" - assert prompt =~ "Current status: In Progress" - assert prompt =~ "https://example.org/issues/MT-616/use-rich-templates-for-workflowmd" - assert prompt =~ "This is an unattended orchestration session." - assert prompt =~ "Only stop early for a true blocker" - assert prompt =~ "Do not include \"next steps for user\"" - assert prompt =~ "open and follow `.codex/skills/land/SKILL.md`" - assert prompt =~ "Do not call `gh pr merge` directly" - assert prompt =~ "Continuation context:" - assert prompt =~ "retry attempt #2" - end - - test "prompt builder adds continuation guidance for retries" do - workflow_prompt = "{% if attempt %}Retry #" <> "{{ attempt }}" <> "{% endif %}" - write_workflow_file!(Workflow.workflow_file_path(), prompt: workflow_prompt) - - issue = %Issue{ - identifier: "MT-201", - title: "Continue autonomous ticket", - description: "Retry flow", - state: "In Progress", - url: "https://example.org/issues/MT-201", - labels: [] - } - - prompt = PromptBuilder.build_prompt(issue, attempt: 2) - - assert prompt == "Retry #2" - end - - test "agent runner keeps workspace after successful codex run" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-agent-runner-retain-workspace-#{System.unique_integer([:positive])}" - ) - - try do - template_repo = Path.join(test_root, "source") - workspace_root = Path.join(test_root, "workspaces") - codex_binary = Path.join(test_root, "fake-codex") - - File.mkdir_p!(template_repo) - File.mkdir_p!(workspace_root) - File.write!(Path.join(template_repo, "README.md"), "# test") - System.cmd("git", ["-C", template_repo, "init", "-b", "main"]) - System.cmd("git", ["-C", template_repo, "config", "user.name", "Test User"]) - System.cmd("git", ["-C", template_repo, "config", "user.email", "test@example.com"]) - System.cmd("git", ["-C", template_repo, "add", "README.md"]) - System.cmd("git", ["-C", template_repo, "commit", "-m", "initial"]) - - File.write!(codex_binary, """ - #!/bin/sh - count=0 - while IFS= read -r line; do - count=$((count + 1)) - case "$count" in - 1) - printf '%s\\n' '{\"id\":1,\"result\":{}}' - ;; - 2) - ;; - 3) - printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-1\"}}}' - ;; - 4) - printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-1\"}}}' - printf '%s\\n' '{\"method\":\"turn/completed\"}' - exit 0 - ;; - *) - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - hook_after_create: "cp #{Path.join(template_repo, "README.md")} README.md", - codex_command: "#{codex_binary} app-server" - ) - - issue = %Issue{ - identifier: "S-99", - title: "Smoke test", - description: "Run and keep workspace", - state: "In Progress", - url: "https://example.org/issues/S-99", - labels: ["backend"] - } - - before = MapSet.new(File.ls!(workspace_root)) - assert :ok = AgentRunner.run(issue) - entries_after = MapSet.new(File.ls!(workspace_root)) - - created = - MapSet.difference(entries_after, before) |> Enum.filter(&(&1 == "S-99")) - - created = MapSet.new(created) - - assert MapSet.size(created) == 1 - workspace_name = created |> Enum.to_list() |> List.first() - assert workspace_name == "S-99" - - workspace = Path.join(workspace_root, workspace_name) - assert File.exists?(workspace) - assert File.exists?(Path.join(workspace, "README.md")) - after - File.rm_rf(test_root) - end - end - - test "agent runner forwards timestamped codex updates to recipient" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-agent-runner-updates-#{System.unique_integer([:positive])}" - ) - - try do - template_repo = Path.join(test_root, "source") - workspace_root = Path.join(test_root, "workspaces") - codex_binary = Path.join(test_root, "fake-codex") - - File.mkdir_p!(template_repo) - File.write!(Path.join(template_repo, "README.md"), "# test") - System.cmd("git", ["-C", template_repo, "init", "-b", "main"]) - System.cmd("git", ["-C", template_repo, "config", "user.name", "Test User"]) - System.cmd("git", ["-C", template_repo, "config", "user.email", "test@example.com"]) - System.cmd("git", ["-C", template_repo, "add", "README.md"]) - System.cmd("git", ["-C", template_repo, "commit", "-m", "initial"]) - - File.write!( - codex_binary, - """ - #!/bin/sh - count=0 - while IFS= read -r line; do - count=$((count + 1)) - case "$count" in - 1) - printf '%s\\n' '{\"id\":1,\"result\":{}}' - ;; - 2) - printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-live\"}}}' - ;; - 3) - printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-live\"}}}' - ;; - 4) - printf '%s\\n' '{\"method\":\"turn/completed\"}' - ;; - *) - ;; - esac - done - """ - ) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - hook_after_create: "cp #{Path.join(template_repo, "README.md")} README.md", - codex_command: "#{codex_binary} app-server" - ) - - issue = %Issue{ - id: "issue-live-updates", - identifier: "MT-99", - title: "Smoke test", - description: "Capture codex updates", - state: "In Progress", - url: "https://example.org/issues/MT-99", - labels: ["backend"] - } - - test_pid = self() - - assert :ok = - AgentRunner.run( - issue, - test_pid, - issue_state_fetcher: fn [_issue_id] -> {:ok, [%{issue | state: "Done"}]} end - ) - - assert_receive {:codex_worker_update, "issue-live-updates", - %{ - event: :session_started, - timestamp: %DateTime{}, - session_id: session_id - }}, - 500 - - assert session_id == "thread-live-turn-live" - after - File.rm_rf(test_root) - end - end - - test "agent runner continues with a follow-up turn while the issue remains active" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-agent-runner-continuation-#{System.unique_integer([:positive])}" - ) - - try do - template_repo = Path.join(test_root, "source") - workspace_root = Path.join(test_root, "workspaces") - codex_binary = Path.join(test_root, "fake-codex") - trace_file = Path.join(test_root, "codex.trace") - - File.mkdir_p!(template_repo) - File.write!(Path.join(template_repo, "README.md"), "# test") - System.cmd("git", ["-C", template_repo, "init", "-b", "main"]) - System.cmd("git", ["-C", template_repo, "config", "user.name", "Test User"]) - System.cmd("git", ["-C", template_repo, "config", "user.email", "test@example.com"]) - System.cmd("git", ["-C", template_repo, "add", "README.md"]) - System.cmd("git", ["-C", template_repo, "commit", "-m", "initial"]) - - File.write!(codex_binary, """ - #!/bin/sh - trace_file="${SYMP_TEST_CODEx_TRACE:-/tmp/codex.trace}" - run_id="$(date +%s%N)-$$" - printf 'RUN:%s\\n' "$run_id" >> "$trace_file" - count=0 - - while IFS= read -r line; do - count=$((count + 1)) - printf 'JSON:%s\\n' "$line" >> "$trace_file" - case "$count" in - 1) - printf '%s\\n' '{"id":1,"result":{}}' - ;; - 2) - ;; - 3) - printf '%s\\n' '{"id":2,"result":{"thread":{"id":"thread-cont"}}}' - ;; - 4) - printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-cont-1"}}}' - printf '%s\\n' '{"method":"turn/completed"}' - ;; - 5) - printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-cont-2"}}}' - printf '%s\\n' '{"method":"turn/completed"}' - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - System.put_env("SYMP_TEST_CODEx_TRACE", trace_file) - - on_exit(fn -> System.delete_env("SYMP_TEST_CODEx_TRACE") end) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - hook_after_create: "cp #{Path.join(template_repo, "README.md")} README.md", - codex_command: "#{codex_binary} app-server", - max_turns: 3 - ) - - parent = self() - - state_fetcher = fn [_issue_id] -> - attempt = Process.get(:agent_turn_fetch_count, 0) + 1 - Process.put(:agent_turn_fetch_count, attempt) - send(parent, {:issue_state_fetch, attempt}) - - state = - if attempt == 1 do - "In Progress" - else - "Done" - end - - {:ok, - [ - %Issue{ - id: "issue-continue", - identifier: "MT-247", - title: "Continue until done", - description: "Still active after first turn", - state: state - } - ]} - end - - issue = %Issue{ - id: "issue-continue", - identifier: "MT-247", - title: "Continue until done", - description: "Still active after first turn", - state: "In Progress", - url: "https://example.org/issues/MT-247", - labels: [] - } - - assert :ok = AgentRunner.run(issue, nil, issue_state_fetcher: state_fetcher) - assert_receive {:issue_state_fetch, 1} - assert_receive {:issue_state_fetch, 2} - - lines = File.read!(trace_file) |> String.split("\n", trim: true) - - assert length(Enum.filter(lines, &String.starts_with?(&1, "RUN:"))) == 1 - assert length(Enum.filter(lines, &String.contains?(&1, "\"method\":\"thread/start\""))) == 1 - - turn_texts = - lines - |> Enum.filter(&String.starts_with?(&1, "JSON:")) - |> Enum.map(&String.trim_leading(&1, "JSON:")) - |> Enum.map(&Jason.decode!/1) - |> Enum.filter(&(&1["method"] == "turn/start")) - |> Enum.map(fn payload -> - get_in(payload, ["params", "input"]) - |> Enum.map_join("\n", &Map.get(&1, "text", "")) - end) - - assert length(turn_texts) == 2 - assert Enum.at(turn_texts, 0) =~ "You are an agent for this repository." - refute Enum.at(turn_texts, 1) =~ "You are an agent for this repository." - assert Enum.at(turn_texts, 1) =~ "Continuation guidance:" - assert Enum.at(turn_texts, 1) =~ "continuation turn #2 of 3" - after - System.delete_env("SYMP_TEST_CODEx_TRACE") - File.rm_rf(test_root) - end - end - - test "agent runner stops continuing once agent.max_turns is reached" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-agent-runner-max-turns-#{System.unique_integer([:positive])}" - ) - - try do - template_repo = Path.join(test_root, "source") - workspace_root = Path.join(test_root, "workspaces") - codex_binary = Path.join(test_root, "fake-codex") - trace_file = Path.join(test_root, "codex.trace") - - File.mkdir_p!(template_repo) - File.write!(Path.join(template_repo, "README.md"), "# test") - System.cmd("git", ["-C", template_repo, "init", "-b", "main"]) - System.cmd("git", ["-C", template_repo, "config", "user.name", "Test User"]) - System.cmd("git", ["-C", template_repo, "config", "user.email", "test@example.com"]) - System.cmd("git", ["-C", template_repo, "add", "README.md"]) - System.cmd("git", ["-C", template_repo, "commit", "-m", "initial"]) - - File.write!(codex_binary, """ - #!/bin/sh - trace_file="${SYMP_TEST_CODEx_TRACE:-/tmp/codex.trace}" - printf 'RUN\\n' >> "$trace_file" - count=0 - - while IFS= read -r line; do - count=$((count + 1)) - printf 'JSON:%s\\n' "$line" >> "$trace_file" - case "$count" in - 1) - printf '%s\\n' '{"id":1,"result":{}}' - ;; - 2) - ;; - 3) - printf '%s\\n' '{"id":2,"result":{"thread":{"id":"thread-max"}}}' - ;; - 4) - printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-max-1"}}}' - printf '%s\\n' '{"method":"turn/completed"}' - ;; - 5) - printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-max-2"}}}' - printf '%s\\n' '{"method":"turn/completed"}' - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - System.put_env("SYMP_TEST_CODEx_TRACE", trace_file) - - on_exit(fn -> System.delete_env("SYMP_TEST_CODEx_TRACE") end) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - hook_after_create: "cp #{Path.join(template_repo, "README.md")} README.md", - codex_command: "#{codex_binary} app-server", - max_turns: 2 - ) - - state_fetcher = fn [_issue_id] -> - {:ok, - [ - %Issue{ - id: "issue-max-turns", - identifier: "MT-248", - title: "Stop at max turns", - description: "Still active", - state: "In Progress" - } - ]} - end - - issue = %Issue{ - id: "issue-max-turns", - identifier: "MT-248", - title: "Stop at max turns", - description: "Still active", - state: "In Progress", - url: "https://example.org/issues/MT-248", - labels: [] - } - - assert :ok = AgentRunner.run(issue, nil, issue_state_fetcher: state_fetcher) - - trace = File.read!(trace_file) - assert length(String.split(trace, "RUN", trim: true)) == 1 - assert length(Regex.scan(~r/"method":"turn\/start"/, trace)) == 2 - after - System.delete_env("SYMP_TEST_CODEx_TRACE") - File.rm_rf(test_root) - end - end - - test "app server starts with workspace cwd and expected startup command" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-args-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-77") - codex_binary = Path.join(test_root, "fake-codex") - trace_file = Path.join(test_root, "codex-args.trace") - previous_trace = System.get_env("SYMP_TEST_CODex_TRACE") - - on_exit(fn -> - if is_binary(previous_trace) do - System.put_env("SYMP_TEST_CODex_TRACE", previous_trace) - else - System.delete_env("SYMP_TEST_CODex_TRACE") - end - end) - - System.put_env("SYMP_TEST_CODex_TRACE", trace_file) - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - trace_file="${SYMP_TEST_CODex_TRACE:-/tmp/codex-args.trace}" - count=0 - printf 'ARGV:%s\\n' \"$*\" >> \"$trace_file\" - printf 'CWD:%s\\n' \"$PWD\" >> \"$trace_file\" - - while IFS= read -r line; do - count=$((count + 1)) - printf 'JSON:%s\\n' \"$line\" >> \"$trace_file\" - case \"$count\" in - 1) - printf '%s\\n' '{\"id\":1,\"result\":{}}' - ;; - 2) - printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-77\"}}}' - ;; - 3) - printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-77\"}}}' - ;; - 4) - printf '%s\\n' '{\"method\":\"turn/completed\"}' - exit 0 - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server" - ) - - issue = %Issue{ - id: "issue-args", - identifier: "MT-77", - title: "Validate codex args", - description: "Check startup args and cwd", - state: "In Progress", - url: "https://example.org/issues/MT-77", - labels: ["backend"] - } - - assert {:ok, _result} = AppServer.run(workspace, "Fix workspace start args", issue) - - trace = File.read!(trace_file) - lines = String.split(trace, "\n", trim: true) - - assert argv_line = Enum.find(lines, fn line -> String.starts_with?(line, "ARGV:") end) - assert String.contains?(argv_line, "app-server") - refute Enum.any?(lines, &String.contains?(&1, "--yolo")) - assert cwd_line = Enum.find(lines, fn line -> String.starts_with?(line, "CWD:") end) - assert String.ends_with?(cwd_line, Path.basename(workspace)) - - assert Enum.any?(lines, fn line -> - if String.starts_with?(line, "JSON:") do - line - |> String.trim_leading("JSON:") - |> Jason.decode!() - |> then(fn payload -> - expected_approval_policy = %{ - "reject" => %{ - "sandbox_approval" => true, - "rules" => true, - "mcp_elicitations" => true - } - } - - payload["method"] == "thread/start" && - get_in(payload, ["params", "approvalPolicy"]) == expected_approval_policy && - get_in(payload, ["params", "sandbox"]) == "workspace-write" && - get_in(payload, ["params", "cwd"]) == Path.expand(workspace) - end) - else - false - end - end) - - expected_turn_sandbox_policy = %{ - "type" => "workspaceWrite", - "writableRoots" => [Path.expand(workspace)], - "readOnlyAccess" => %{"type" => "fullAccess"}, - "networkAccess" => false, - "excludeTmpdirEnvVar" => false, - "excludeSlashTmp" => false - } - - assert Enum.any?(lines, fn line -> - if String.starts_with?(line, "JSON:") do - line - |> String.trim_leading("JSON:") - |> Jason.decode!() - |> then(fn payload -> - expected_approval_policy = %{ - "reject" => %{ - "sandbox_approval" => true, - "rules" => true, - "mcp_elicitations" => true - } - } - - payload["method"] == "turn/start" && - get_in(payload, ["params", "cwd"]) == Path.expand(workspace) && - get_in(payload, ["params", "approvalPolicy"]) == expected_approval_policy && - get_in(payload, ["params", "sandboxPolicy"]) == expected_turn_sandbox_policy - end) - else - false - end - end) - after - File.rm_rf(test_root) - end - end - - test "app server startup command supports codex args override from workflow config" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-custom-args-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-88") - codex_binary = Path.join(test_root, "fake-codex") - trace_file = Path.join(test_root, "codex-custom-args.trace") - previous_trace = System.get_env("SYMP_TEST_CODex_TRACE") - - on_exit(fn -> - if is_binary(previous_trace) do - System.put_env("SYMP_TEST_CODex_TRACE", previous_trace) - else - System.delete_env("SYMP_TEST_CODex_TRACE") - end - end) - - System.put_env("SYMP_TEST_CODex_TRACE", trace_file) - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - trace_file="${SYMP_TEST_CODex_TRACE:-/tmp/codex-custom-args.trace}" - count=0 - printf 'ARGV:%s\\n' \"$*\" >> \"$trace_file\" - - while IFS= read -r line; do - count=$((count + 1)) - case \"$count\" in - 1) - printf '%s\\n' '{\"id\":1,\"result\":{}}' - ;; - 2) - printf '%s\\n' '{\"id\":2,\"result\":{\"thread\":{\"id\":\"thread-88\"}}}' - ;; - 3) - printf '%s\\n' '{\"id\":3,\"result\":{\"turn\":{\"id\":\"turn-88\"}}}' - ;; - 4) - printf '%s\\n' '{\"method\":\"turn/completed\"}' - exit 0 - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} --model gpt-5.3-codex app-server" - ) - - issue = %Issue{ - id: "issue-custom-args", - identifier: "MT-88", - title: "Validate custom codex args", - description: "Check startup args override", - state: "In Progress", - url: "https://example.org/issues/MT-88", - labels: ["backend"] - } - - assert {:ok, _result} = AppServer.run(workspace, "Fix workspace start args", issue) - - trace = File.read!(trace_file) - lines = String.split(trace, "\n", trim: true) - - assert argv_line = Enum.find(lines, fn line -> String.starts_with?(line, "ARGV:") end) - assert String.contains?(argv_line, "--model gpt-5.3-codex app-server") - refute String.contains?(argv_line, "--ask-for-approval never") - refute String.contains?(argv_line, "--sandbox danger-full-access") - after - File.rm_rf(test_root) - end - end - - test "app server startup payload uses configurable approval and sandbox settings from workflow config" do - test_root = - Path.join( - System.tmp_dir!(), - "symphony-elixir-app-server-policy-overrides-#{System.unique_integer([:positive])}" - ) - - try do - workspace_root = Path.join(test_root, "workspaces") - workspace = Path.join(workspace_root, "MT-99") - codex_binary = Path.join(test_root, "fake-codex") - trace_file = Path.join(test_root, "codex-policy-overrides.trace") - previous_trace = System.get_env("SYMP_TEST_CODex_TRACE") - - on_exit(fn -> - if is_binary(previous_trace) do - System.put_env("SYMP_TEST_CODex_TRACE", previous_trace) - else - System.delete_env("SYMP_TEST_CODex_TRACE") - end - end) - - System.put_env("SYMP_TEST_CODex_TRACE", trace_file) - File.mkdir_p!(workspace) - - File.write!(codex_binary, """ - #!/bin/sh - trace_file="${SYMP_TEST_CODex_TRACE:-/tmp/codex-policy-overrides.trace}" - count=0 - - while IFS= read -r line; do - count=$((count + 1)) - printf 'JSON:%s\\n' "$line" >> "$trace_file" - - case "$count" in - 1) - printf '%s\\n' '{"id":1,"result":{}}' - ;; - 2) - printf '%s\\n' '{"id":2,"result":{"thread":{"id":"thread-99"}}}' - ;; - 3) - printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-99"}}}' - ;; - 4) - printf '%s\\n' '{"method":"turn/completed"}' - exit 0 - ;; - *) - exit 0 - ;; - esac - done - """) - - File.chmod!(codex_binary, 0o755) - - write_workflow_file!(Workflow.workflow_file_path(), - workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server", - codex_approval_policy: "on-request", - codex_thread_sandbox: "workspace-write", - codex_turn_sandbox_policy: %{ - type: "workspaceWrite", - writableRoots: [Path.expand(workspace), Path.join(Path.expand(workspace_root), ".cache")] - } - ) - - issue = %Issue{ - id: "issue-policy-overrides", - identifier: "MT-99", - title: "Validate codex policy overrides", - description: "Check startup policy payload overrides", - state: "In Progress", - url: "https://example.org/issues/MT-99", - labels: ["backend"] - } - - assert {:ok, _result} = AppServer.run(workspace, "Fix workspace start args", issue) - - lines = File.read!(trace_file) |> String.split("\n", trim: true) - - assert Enum.any?(lines, fn line -> - if String.starts_with?(line, "JSON:") do - line - |> String.trim_leading("JSON:") - |> Jason.decode!() - |> then(fn payload -> - payload["method"] == "thread/start" && - get_in(payload, ["params", "approvalPolicy"]) == "on-request" && - get_in(payload, ["params", "sandbox"]) == "workspace-write" - end) - else - false - end - end) - - expected_turn_policy = %{ - "type" => "workspaceWrite", - "writableRoots" => [Path.expand(workspace), Path.join(Path.expand(workspace_root), ".cache")] - } - - assert Enum.any?(lines, fn line -> - if String.starts_with?(line, "JSON:") do - line - |> String.trim_leading("JSON:") - |> Jason.decode!() - |> then(fn payload -> - payload["method"] == "turn/start" && - get_in(payload, ["params", "approvalPolicy"]) == "on-request" && - get_in(payload, ["params", "sandboxPolicy"]) == expected_turn_policy - end) - else - false - end - end) - after - File.rm_rf(test_root) - end - end -end diff --git a/elixir/test/symphony_elixir/dynamic_tool_test.exs b/elixir/test/symphony_elixir/dynamic_tool_test.exs deleted file mode 100644 index a5536e0338..0000000000 --- a/elixir/test/symphony_elixir/dynamic_tool_test.exs +++ /dev/null @@ -1,378 +0,0 @@ -defmodule SymphonyElixir.Codex.DynamicToolTest do - use SymphonyElixir.TestSupport - - alias SymphonyElixir.Codex.DynamicTool - - test "tool_specs advertises the linear_graphql input contract" do - assert [ - %{ - "description" => description, - "inputSchema" => %{ - "properties" => %{ - "query" => _, - "variables" => _ - }, - "required" => ["query"], - "type" => "object" - }, - "name" => "linear_graphql" - } - ] = DynamicTool.tool_specs() - - assert description =~ "Linear" - end - - test "unsupported tools return a failure payload with the supported tool list" do - response = DynamicTool.execute("not_a_real_tool", %{}) - - assert response["success"] == false - - assert [ - %{ - "type" => "inputText", - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ - "error" => %{ - "message" => ~s(Unsupported dynamic tool: "not_a_real_tool".), - "supportedTools" => ["linear_graphql"] - } - } - end - - test "linear_graphql returns successful GraphQL responses as tool text" do - test_pid = self() - - response = - DynamicTool.execute( - "linear_graphql", - %{ - "query" => "query Viewer { viewer { id } }", - "variables" => %{"includeTeams" => false} - }, - linear_client: fn query, variables, opts -> - send(test_pid, {:linear_client_called, query, variables, opts}) - {:ok, %{"data" => %{"viewer" => %{"id" => "usr_123"}}}} - end - ) - - assert_received {:linear_client_called, "query Viewer { viewer { id } }", %{"includeTeams" => false}, []} - - assert response["success"] == true - - assert [ - %{ - "type" => "inputText", - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{"data" => %{"viewer" => %{"id" => "usr_123"}}} - end - - test "linear_graphql accepts a raw GraphQL query string" do - test_pid = self() - - response = - DynamicTool.execute( - "linear_graphql", - " query Viewer { viewer { id } } ", - linear_client: fn query, variables, opts -> - send(test_pid, {:linear_client_called, query, variables, opts}) - {:ok, %{"data" => %{"viewer" => %{"id" => "usr_456"}}}} - end - ) - - assert_received {:linear_client_called, "query Viewer { viewer { id } }", %{}, []} - assert response["success"] == true - end - - test "linear_graphql ignores legacy operationName arguments" do - test_pid = self() - - response = - DynamicTool.execute( - "linear_graphql", - %{"query" => "query Viewer { viewer { id } }", "operationName" => "Viewer"}, - linear_client: fn query, variables, opts -> - send(test_pid, {:linear_client_called, query, variables, opts}) - {:ok, %{"data" => %{"viewer" => %{"id" => "usr_789"}}}} - end - ) - - assert_received {:linear_client_called, "query Viewer { viewer { id } }", %{}, []} - assert response["success"] == true - end - - test "linear_graphql passes multi-operation documents through unchanged" do - test_pid = self() - - query = """ - query Viewer { viewer { id } } - query Teams { teams { nodes { id } } } - """ - - response = - DynamicTool.execute( - "linear_graphql", - %{"query" => query}, - linear_client: fn forwarded_query, variables, opts -> - send(test_pid, {:linear_client_called, forwarded_query, variables, opts}) - {:ok, %{"errors" => [%{"message" => "Must provide operation name if query contains multiple operations."}]}} - end - ) - - assert_received {:linear_client_called, forwarded_query, %{}, []} - assert forwarded_query == String.trim(query) - assert response["success"] == false - end - - test "linear_graphql rejects blank raw query strings even when using the default client" do - response = DynamicTool.execute("linear_graphql", " ") - - assert response["success"] == false - - assert [ - %{ - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ - "error" => %{ - "message" => "`linear_graphql` requires a non-empty `query` string." - } - } - end - - test "linear_graphql marks GraphQL error responses as failures while preserving the body" do - response = - DynamicTool.execute( - "linear_graphql", - %{"query" => "mutation BadMutation { nope }"}, - linear_client: fn _query, _variables, _opts -> - {:ok, %{"errors" => [%{"message" => "Unknown field `nope`"}], "data" => nil}} - end - ) - - assert response["success"] == false - - assert [ - %{ - "type" => "inputText", - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ - "data" => nil, - "errors" => [%{"message" => "Unknown field `nope`"}] - } - end - - test "linear_graphql marks atom-key GraphQL error responses as failures" do - response = - DynamicTool.execute( - "linear_graphql", - %{"query" => "query Viewer { viewer { id } }"}, - linear_client: fn _query, _variables, _opts -> - {:ok, %{errors: [%{message: "boom"}], data: nil}} - end - ) - - assert response["success"] == false - end - - test "linear_graphql validates required arguments before calling Linear" do - response = - DynamicTool.execute( - "linear_graphql", - %{"variables" => %{"commentId" => "comment-1"}}, - linear_client: fn _query, _variables, _opts -> - flunk("linear client should not be called when arguments are invalid") - end - ) - - assert response["success"] == false - - assert [ - %{ - "type" => "inputText", - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ - "error" => %{ - "message" => "`linear_graphql` requires a non-empty `query` string." - } - } - - blank_query = - DynamicTool.execute( - "linear_graphql", - %{"query" => " "}, - linear_client: fn _query, _variables, _opts -> - flunk("linear client should not be called when the query is blank") - end - ) - - assert blank_query["success"] == false - end - - test "linear_graphql rejects invalid argument types" do - response = - DynamicTool.execute( - "linear_graphql", - [:not, :valid], - linear_client: fn _query, _variables, _opts -> - flunk("linear client should not be called when arguments are invalid") - end - ) - - assert response["success"] == false - - assert [ - %{ - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ - "error" => %{ - "message" => "`linear_graphql` expects either a GraphQL query string or an object with `query` and optional `variables`." - } - } - end - - test "linear_graphql rejects invalid variables" do - response = - DynamicTool.execute( - "linear_graphql", - %{"query" => "query Viewer { viewer { id } }", "variables" => ["bad"]}, - linear_client: fn _query, _variables, _opts -> - flunk("linear client should not be called when variables are invalid") - end - ) - - assert response["success"] == false - - assert [ - %{ - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ - "error" => %{ - "message" => "`linear_graphql.variables` must be a JSON object when provided." - } - } - end - - test "linear_graphql formats transport and auth failures" do - missing_token = - DynamicTool.execute( - "linear_graphql", - %{"query" => "query Viewer { viewer { id } }"}, - linear_client: fn _query, _variables, _opts -> {:error, :missing_linear_api_token} end - ) - - assert missing_token["success"] == false - - assert [ - %{ - "text" => missing_token_text - } - ] = missing_token["contentItems"] - - assert Jason.decode!(missing_token_text) == %{ - "error" => %{ - "message" => "Symphony is missing Linear auth. Set `linear.api_key` in `WORKFLOW.md` or export `LINEAR_API_KEY`." - } - } - - status_error = - DynamicTool.execute( - "linear_graphql", - %{"query" => "query Viewer { viewer { id } }"}, - linear_client: fn _query, _variables, _opts -> {:error, {:linear_api_status, 503}} end - ) - - assert [ - %{ - "text" => status_error_text - } - ] = status_error["contentItems"] - - assert Jason.decode!(status_error_text) == %{ - "error" => %{ - "message" => "Linear GraphQL request failed with HTTP 503.", - "status" => 503 - } - } - - request_error = - DynamicTool.execute( - "linear_graphql", - %{"query" => "query Viewer { viewer { id } }"}, - linear_client: fn _query, _variables, _opts -> {:error, {:linear_api_request, :timeout}} end - ) - - assert [ - %{ - "text" => request_error_text - } - ] = request_error["contentItems"] - - assert Jason.decode!(request_error_text) == %{ - "error" => %{ - "message" => "Linear GraphQL request failed before receiving a successful response.", - "reason" => ":timeout" - } - } - end - - test "linear_graphql formats unexpected failures from the client" do - response = - DynamicTool.execute( - "linear_graphql", - %{"query" => "query Viewer { viewer { id } }"}, - linear_client: fn _query, _variables, _opts -> {:error, :boom} end - ) - - assert response["success"] == false - - assert [ - %{ - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ - "error" => %{ - "message" => "Linear GraphQL tool execution failed.", - "reason" => ":boom" - } - } - end - - test "linear_graphql falls back to inspect for non-JSON payloads" do - response = - DynamicTool.execute( - "linear_graphql", - %{"query" => "query Viewer { viewer { id } }"}, - linear_client: fn _query, _variables, _opts -> {:ok, :ok} end - ) - - assert response["success"] == true - - assert [ - %{ - "text" => ":ok" - } - ] = response["contentItems"] - end -end diff --git a/elixir/test/symphony_elixir/extensions_test.exs b/elixir/test/symphony_elixir/extensions_test.exs deleted file mode 100644 index 59c8d0580b..0000000000 --- a/elixir/test/symphony_elixir/extensions_test.exs +++ /dev/null @@ -1,741 +0,0 @@ -defmodule SymphonyElixir.ExtensionsTest do - use SymphonyElixir.TestSupport - - import Phoenix.ConnTest - import Phoenix.LiveViewTest - - alias SymphonyElixir.Linear.Adapter - alias SymphonyElixir.Tracker.Memory - - @endpoint SymphonyElixirWeb.Endpoint - - defmodule FakeLinearClient do - def fetch_candidate_issues do - send(self(), :fetch_candidate_issues_called) - {:ok, [:candidate]} - end - - def fetch_issues_by_states(states) do - send(self(), {:fetch_issues_by_states_called, states}) - {:ok, states} - end - - def fetch_issue_states_by_ids(issue_ids) do - send(self(), {:fetch_issue_states_by_ids_called, issue_ids}) - {:ok, issue_ids} - end - - def graphql(query, variables) do - send(self(), {:graphql_called, query, variables}) - - case Process.get({__MODULE__, :graphql_results}) do - [result | rest] -> - Process.put({__MODULE__, :graphql_results}, rest) - result - - _ -> - Process.get({__MODULE__, :graphql_result}) - end - end - end - - defmodule SlowOrchestrator do - use GenServer - - def start_link(opts) do - GenServer.start_link(__MODULE__, :ok, opts) - end - - def init(:ok), do: {:ok, :ok} - - def handle_call(:snapshot, _from, state) do - Process.sleep(25) - {:reply, %{}, state} - end - - def handle_call(:request_refresh, _from, state) do - {:reply, :unavailable, state} - end - end - - defmodule StaticOrchestrator do - use GenServer - - def start_link(opts) do - name = Keyword.fetch!(opts, :name) - GenServer.start_link(__MODULE__, opts, name: name) - end - - def init(opts), do: {:ok, opts} - - def handle_call(:snapshot, _from, state) do - {:reply, Keyword.fetch!(state, :snapshot), state} - end - - def handle_call(:request_refresh, _from, state) do - {:reply, Keyword.get(state, :refresh, :unavailable), state} - end - end - - setup do - linear_client_module = Application.get_env(:symphony_elixir, :linear_client_module) - - on_exit(fn -> - if is_nil(linear_client_module) do - Application.delete_env(:symphony_elixir, :linear_client_module) - else - Application.put_env(:symphony_elixir, :linear_client_module, linear_client_module) - end - end) - - :ok - end - - setup do - endpoint_config = Application.get_env(:symphony_elixir, SymphonyElixirWeb.Endpoint, []) - - on_exit(fn -> - Application.put_env(:symphony_elixir, SymphonyElixirWeb.Endpoint, endpoint_config) - end) - - :ok - end - - test "workflow store reloads changes, keeps last good workflow, and falls back when stopped" do - ensure_workflow_store_running() - assert {:ok, %{prompt: "You are an agent for this repository."}} = Workflow.current() - - write_workflow_file!(Workflow.workflow_file_path(), prompt: "Second prompt") - send(WorkflowStore, :poll) - - assert_eventually(fn -> - match?({:ok, %{prompt: "Second prompt"}}, Workflow.current()) - end) - - File.write!(Workflow.workflow_file_path(), "---\ntracker: [\n---\nBroken prompt\n") - assert {:error, _reason} = WorkflowStore.force_reload() - assert {:ok, %{prompt: "Second prompt"}} = Workflow.current() - - third_workflow = Path.join(Path.dirname(Workflow.workflow_file_path()), "THIRD_WORKFLOW.md") - write_workflow_file!(third_workflow, prompt: "Third prompt") - Workflow.set_workflow_file_path(third_workflow) - assert {:ok, %{prompt: "Third prompt"}} = Workflow.current() - - assert :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, WorkflowStore) - assert {:ok, %{prompt: "Third prompt"}} = WorkflowStore.current() - assert :ok = WorkflowStore.force_reload() - assert {:ok, _pid} = Supervisor.restart_child(SymphonyElixir.Supervisor, WorkflowStore) - end - - test "workflow store init stops on missing workflow file" do - missing_path = Path.join(Path.dirname(Workflow.workflow_file_path()), "MISSING_WORKFLOW.md") - Workflow.set_workflow_file_path(missing_path) - - assert {:stop, {:missing_workflow_file, ^missing_path, :enoent}} = WorkflowStore.init([]) - end - - test "workflow store start_link and poll callback cover missing-file error paths" do - ensure_workflow_store_running() - existing_path = Workflow.workflow_file_path() - manual_path = Path.join(Path.dirname(existing_path), "MANUAL_WORKFLOW.md") - missing_path = Path.join(Path.dirname(existing_path), "MANUAL_MISSING_WORKFLOW.md") - - assert :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, WorkflowStore) - - Workflow.set_workflow_file_path(missing_path) - - assert {:error, {:missing_workflow_file, ^missing_path, :enoent}} = - WorkflowStore.force_reload() - - write_workflow_file!(manual_path, prompt: "Manual workflow prompt") - Workflow.set_workflow_file_path(manual_path) - - assert {:ok, manual_pid} = WorkflowStore.start_link() - assert Process.alive?(manual_pid) - - state = :sys.get_state(manual_pid) - File.write!(manual_path, "---\ntracker: [\n---\nBroken prompt\n") - assert {:noreply, returned_state} = WorkflowStore.handle_info(:poll, state) - assert returned_state.workflow.prompt == "Manual workflow prompt" - refute returned_state.stamp == nil - assert_receive :poll, 1_100 - - Workflow.set_workflow_file_path(missing_path) - assert {:noreply, path_error_state} = WorkflowStore.handle_info(:poll, returned_state) - assert path_error_state.workflow.prompt == "Manual workflow prompt" - assert_receive :poll, 1_100 - - Workflow.set_workflow_file_path(manual_path) - File.rm!(manual_path) - assert {:noreply, removed_state} = WorkflowStore.handle_info(:poll, path_error_state) - assert removed_state.workflow.prompt == "Manual workflow prompt" - assert_receive :poll, 1_100 - - Process.exit(manual_pid, :normal) - restart_result = Supervisor.restart_child(SymphonyElixir.Supervisor, WorkflowStore) - - assert match?({:ok, _pid}, restart_result) or - match?({:error, {:already_started, _pid}}, restart_result) - - Workflow.set_workflow_file_path(existing_path) - WorkflowStore.force_reload() - end - - test "tracker delegates to memory and linear adapters" do - issue = %Issue{id: "issue-1", identifier: "MT-1", state: "In Progress"} - Application.put_env(:symphony_elixir, :memory_tracker_issues, [issue, %{id: "ignored"}]) - Application.put_env(:symphony_elixir, :memory_tracker_recipient, self()) - write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") - - assert Config.tracker_kind() == "memory" - assert SymphonyElixir.Tracker.adapter() == Memory - assert {:ok, [^issue]} = SymphonyElixir.Tracker.fetch_candidate_issues() - assert {:ok, [^issue]} = SymphonyElixir.Tracker.fetch_issues_by_states([" in progress ", 42]) - assert {:ok, [^issue]} = SymphonyElixir.Tracker.fetch_issue_states_by_ids(["issue-1"]) - assert :ok = SymphonyElixir.Tracker.create_comment("issue-1", "comment") - assert :ok = SymphonyElixir.Tracker.update_issue_state("issue-1", "Done") - assert_receive {:memory_tracker_comment, "issue-1", "comment"} - assert_receive {:memory_tracker_state_update, "issue-1", "Done"} - - Application.delete_env(:symphony_elixir, :memory_tracker_recipient) - assert :ok = Memory.create_comment("issue-1", "quiet") - assert :ok = Memory.update_issue_state("issue-1", "Quiet") - - write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "linear") - assert SymphonyElixir.Tracker.adapter() == Adapter - end - - test "linear adapter delegates reads and validates mutation responses" do - Application.put_env(:symphony_elixir, :linear_client_module, FakeLinearClient) - - assert {:ok, [:candidate]} = Adapter.fetch_candidate_issues() - assert_receive :fetch_candidate_issues_called - - assert {:ok, ["Todo"]} = Adapter.fetch_issues_by_states(["Todo"]) - assert_receive {:fetch_issues_by_states_called, ["Todo"]} - - assert {:ok, ["issue-1"]} = Adapter.fetch_issue_states_by_ids(["issue-1"]) - assert_receive {:fetch_issue_states_by_ids_called, ["issue-1"]} - - Process.put( - {FakeLinearClient, :graphql_result}, - {:ok, %{"data" => %{"commentCreate" => %{"success" => true}}}} - ) - - assert :ok = Adapter.create_comment("issue-1", "hello") - assert_receive {:graphql_called, create_comment_query, %{body: "hello", issueId: "issue-1"}} - assert create_comment_query =~ "commentCreate" - - Process.put( - {FakeLinearClient, :graphql_result}, - {:ok, %{"data" => %{"commentCreate" => %{"success" => false}}}} - ) - - assert {:error, :comment_create_failed} = - Adapter.create_comment("issue-1", "broken") - - Process.put({FakeLinearClient, :graphql_result}, {:error, :boom}) - - assert {:error, :boom} = Adapter.create_comment("issue-1", "boom") - - Process.put({FakeLinearClient, :graphql_result}, {:ok, %{"data" => %{}}}) - assert {:error, :comment_create_failed} = Adapter.create_comment("issue-1", "weird") - - Process.put({FakeLinearClient, :graphql_result}, :unexpected) - assert {:error, :comment_create_failed} = Adapter.create_comment("issue-1", "odd") - - Process.put( - {FakeLinearClient, :graphql_results}, - [ - {:ok, - %{ - "data" => %{ - "issue" => %{"team" => %{"states" => %{"nodes" => [%{"id" => "state-1"}]}}} - } - }}, - {:ok, %{"data" => %{"issueUpdate" => %{"success" => true}}}} - ] - ) - - assert :ok = Adapter.update_issue_state("issue-1", "Done") - assert_receive {:graphql_called, state_lookup_query, %{issueId: "issue-1", stateName: "Done"}} - assert state_lookup_query =~ "states" - - assert_receive {:graphql_called, update_issue_query, %{issueId: "issue-1", stateId: "state-1"}} - - assert update_issue_query =~ "issueUpdate" - - Process.put( - {FakeLinearClient, :graphql_results}, - [ - {:ok, - %{ - "data" => %{ - "issue" => %{"team" => %{"states" => %{"nodes" => [%{"id" => "state-1"}]}}} - } - }}, - {:ok, %{"data" => %{"issueUpdate" => %{"success" => false}}}} - ] - ) - - assert {:error, :issue_update_failed} = - Adapter.update_issue_state("issue-1", "Broken") - - Process.put({FakeLinearClient, :graphql_results}, [{:error, :boom}]) - - assert {:error, :boom} = Adapter.update_issue_state("issue-1", "Boom") - - Process.put({FakeLinearClient, :graphql_results}, [{:ok, %{"data" => %{}}}]) - assert {:error, :state_not_found} = Adapter.update_issue_state("issue-1", "Missing") - - Process.put( - {FakeLinearClient, :graphql_results}, - [ - {:ok, - %{ - "data" => %{ - "issue" => %{"team" => %{"states" => %{"nodes" => [%{"id" => "state-1"}]}}} - } - }}, - {:ok, %{"data" => %{}}} - ] - ) - - assert {:error, :issue_update_failed} = Adapter.update_issue_state("issue-1", "Weird") - - Process.put( - {FakeLinearClient, :graphql_results}, - [ - {:ok, - %{ - "data" => %{ - "issue" => %{"team" => %{"states" => %{"nodes" => [%{"id" => "state-1"}]}}} - } - }}, - :unexpected - ] - ) - - assert {:error, :issue_update_failed} = Adapter.update_issue_state("issue-1", "Odd") - end - - test "phoenix observability api preserves state, issue, and refresh responses" do - snapshot = static_snapshot() - orchestrator_name = Module.concat(__MODULE__, :ObservabilityApiOrchestrator) - - {:ok, _pid} = - StaticOrchestrator.start_link( - name: orchestrator_name, - snapshot: snapshot, - refresh: %{ - queued: true, - coalesced: false, - requested_at: DateTime.utc_now(), - operations: ["poll", "reconcile"] - } - ) - - start_test_endpoint(orchestrator: orchestrator_name, snapshot_timeout_ms: 50) - - conn = get(build_conn(), "/api/v1/state") - state_payload = json_response(conn, 200) - - assert state_payload == %{ - "generated_at" => state_payload["generated_at"], - "counts" => %{"running" => 1, "retrying" => 1}, - "running" => [ - %{ - "issue_id" => "issue-http", - "issue_identifier" => "MT-HTTP", - "state" => "In Progress", - "session_id" => "thread-http", - "turn_count" => 7, - "last_event" => "notification", - "last_message" => "rendered", - "started_at" => state_payload["running"] |> List.first() |> Map.fetch!("started_at"), - "last_event_at" => nil, - "tokens" => %{"input_tokens" => 4, "output_tokens" => 8, "total_tokens" => 12} - } - ], - "retrying" => [ - %{ - "issue_id" => "issue-retry", - "issue_identifier" => "MT-RETRY", - "attempt" => 2, - "due_at" => state_payload["retrying"] |> List.first() |> Map.fetch!("due_at"), - "error" => "boom" - } - ], - "codex_totals" => %{ - "input_tokens" => 4, - "output_tokens" => 8, - "total_tokens" => 12, - "seconds_running" => 42.5 - }, - "rate_limits" => %{"primary" => %{"remaining" => 11}} - } - - conn = get(build_conn(), "/api/v1/MT-HTTP") - issue_payload = json_response(conn, 200) - - assert issue_payload == %{ - "issue_identifier" => "MT-HTTP", - "issue_id" => "issue-http", - "status" => "running", - "workspace" => %{"path" => Path.join(Config.workspace_root(), "MT-HTTP")}, - "attempts" => %{"restart_count" => 0, "current_retry_attempt" => 0}, - "running" => %{ - "session_id" => "thread-http", - "turn_count" => 7, - "state" => "In Progress", - "started_at" => issue_payload["running"]["started_at"], - "last_event" => "notification", - "last_message" => "rendered", - "last_event_at" => nil, - "tokens" => %{"input_tokens" => 4, "output_tokens" => 8, "total_tokens" => 12} - }, - "retry" => nil, - "logs" => %{"codex_session_logs" => []}, - "recent_events" => [], - "last_error" => nil, - "tracked" => %{} - } - - conn = get(build_conn(), "/api/v1/MT-RETRY") - - assert %{"status" => "retrying", "retry" => %{"attempt" => 2, "error" => "boom"}} = - json_response(conn, 200) - - conn = get(build_conn(), "/api/v1/MT-MISSING") - - assert json_response(conn, 404) == %{ - "error" => %{"code" => "issue_not_found", "message" => "Issue not found"} - } - - conn = post(build_conn(), "/api/v1/refresh", %{}) - - assert %{"queued" => true, "coalesced" => false, "operations" => ["poll", "reconcile"]} = - json_response(conn, 202) - end - - test "phoenix observability api preserves 405, 404, and unavailable behavior" do - unavailable_orchestrator = Module.concat(__MODULE__, :UnavailableOrchestrator) - start_test_endpoint(orchestrator: unavailable_orchestrator, snapshot_timeout_ms: 5) - - assert json_response(post(build_conn(), "/api/v1/state", %{}), 405) == - %{"error" => %{"code" => "method_not_allowed", "message" => "Method not allowed"}} - - assert json_response(get(build_conn(), "/api/v1/refresh"), 405) == - %{"error" => %{"code" => "method_not_allowed", "message" => "Method not allowed"}} - - assert json_response(post(build_conn(), "/", %{}), 405) == - %{"error" => %{"code" => "method_not_allowed", "message" => "Method not allowed"}} - - assert json_response(post(build_conn(), "/api/v1/MT-1", %{}), 405) == - %{"error" => %{"code" => "method_not_allowed", "message" => "Method not allowed"}} - - assert json_response(get(build_conn(), "/unknown"), 404) == - %{"error" => %{"code" => "not_found", "message" => "Route not found"}} - - state_payload = json_response(get(build_conn(), "/api/v1/state"), 200) - - assert state_payload == - %{ - "generated_at" => state_payload["generated_at"], - "error" => %{"code" => "snapshot_unavailable", "message" => "Snapshot unavailable"} - } - - assert json_response(post(build_conn(), "/api/v1/refresh", %{}), 503) == - %{ - "error" => %{ - "code" => "orchestrator_unavailable", - "message" => "Orchestrator is unavailable" - } - } - end - - test "phoenix observability api preserves snapshot timeout behavior" do - timeout_orchestrator = Module.concat(__MODULE__, :TimeoutOrchestrator) - {:ok, _pid} = SlowOrchestrator.start_link(name: timeout_orchestrator) - start_test_endpoint(orchestrator: timeout_orchestrator, snapshot_timeout_ms: 1) - - timeout_payload = json_response(get(build_conn(), "/api/v1/state"), 200) - - assert timeout_payload == - %{ - "generated_at" => timeout_payload["generated_at"], - "error" => %{"code" => "snapshot_timeout", "message" => "Snapshot timed out"} - } - end - - test "dashboard bootstraps liveview from embedded static assets" do - orchestrator_name = Module.concat(__MODULE__, :AssetOrchestrator) - - {:ok, _pid} = - StaticOrchestrator.start_link( - name: orchestrator_name, - snapshot: static_snapshot(), - refresh: %{ - queued: true, - coalesced: false, - requested_at: DateTime.utc_now(), - operations: ["poll"] - } - ) - - start_test_endpoint(orchestrator: orchestrator_name, snapshot_timeout_ms: 50) - - html = html_response(get(build_conn(), "/"), 200) - assert html =~ "/dashboard.css" - assert html =~ "/vendor/phoenix_html/phoenix_html.js" - assert html =~ "/vendor/phoenix/phoenix.js" - assert html =~ "/vendor/phoenix_live_view/phoenix_live_view.js" - refute html =~ "/assets/app.js" - refute html =~ "