diff --git a/.codex/skills/release/SKILL.md b/.codex/skills/release/SKILL.md new file mode 100644 index 0000000000..9e35930b58 --- /dev/null +++ b/.codex/skills/release/SKILL.md @@ -0,0 +1,28 @@ +--- +name: release +description: Cut a Symphony release by bumping the committed version, landing it, tagging the merged commit, and verifying the Burrito release workflow. Use when asked to release, tag, or retag Symphony. +--- + +# Release + +1. Start from fresh `origin/main` in a clean worktree. Never disturb unrelated + local changes. +2. Pick the requested version. If none is given, use the next patch after the + latest `vX.Y.Z` tag. +3. Update `elixir/mix.exs` so `version: "X.Y.Z"` matches the intended tag. + Search the old version and change other files only when they are true + release-version sources, not examples. +4. Run `make -C elixir all`, then commit, push, create a PR, and land it. +5. Fetch the merged `main` commit. Verify its `mix.exs` version, then create an + annotated tag on that exact commit: + + ```sh + git tag -a vX.Y.Z -m "Symphony vX.Y.Z" + git push origin vX.Y.Z + ``` + +6. Watch `burrito-release` until it finishes. Verify the build, smoke, and + release jobs pass and that the GitHub release has all expected assets. + +Do not tag an uncommitted or unmerged revision. Do not move a published tag +without explicit user approval. diff --git a/.github/workflows/burrito-release.yml b/.github/workflows/burrito-release.yml new file mode 100644 index 0000000000..ed8034490b --- /dev/null +++ b/.github/workflows/burrito-release.yml @@ -0,0 +1,146 @@ +name: burrito-release + +on: + workflow_dispatch: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + build: + name: build-${{ matrix.target }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + target: [linux_x86_64, linux_arm64, macos_x86_64, macos_arm64] + defaults: + run: + working-directory: elixir + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up mise tools + uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 + with: + install: true + cache: true + working_directory: elixir + + - name: Install Zig + run: mise install zig@0.15.2 + + - name: Install xz + run: sudo apt-get update && sudo apt-get install -y xz-utils + + - name: Install Mix dependencies + run: | + mix local.hex --force + mix local.rebar --force + mix deps.get + + - name: Verify release tag matches project version + if: startsWith(github.ref, 'refs/tags/v') + run: | + project_version="$(sed -nE 's/^[[:space:]]*version: "([^"]+)",$/\1/p' mix.exs | head -n 1)" + expected_tag="v$project_version" + + if test -z "$project_version" || test "$GITHUB_REF_NAME" != "$expected_tag"; then + echo "Release tag $GITHUB_REF_NAME does not match mix.exs version $project_version." + exit 1 + fi + + - name: Build Burrito executable + run: BURRITO_TARGET=${{ matrix.target }} MIX_ENV=prod mise exec zig@0.15.2 -- mix release symphony --overwrite + + - name: Stage release assets + run: | + if test "$GITHUB_REF_TYPE" = "tag"; then + version="$GITHUB_REF_NAME" + else + version="${GITHUB_SHA::7}" + fi + + artifact="symphony-${version}-${{ matrix.target }}" + mkdir -p dist + cp "burrito_out/symphony_${{ matrix.target }}" "dist/$artifact" + cd dist + + sha256sum "$artifact" > "$artifact.sha256" + + - name: Upload release assets + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ matrix.target }} + path: elixir/dist/* + if-no-files-found: error + + smoke: + name: smoke-${{ matrix.target }} + needs: build + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: linux_x86_64 + runner: ubuntu-24.04 + - target: linux_arm64 + runner: ubuntu-24.04-arm + - target: macos_x86_64 + runner: macos-15-intel + - target: macos_arm64 + runner: macos-14 + + steps: + - name: Download release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ matrix.target }} + path: dist + + - name: Smoke test executable + run: | + artifact="$(find dist -type f ! -name '*.sha256' -print -quit)" + test -n "$artifact" + chmod +x "$artifact" + + set +e + output="$(SYMPHONY_INSTALL_DIR="$RUNNER_TEMP/symphony-install" "$artifact" 2>&1)" + status=$? + set -e + + test "$status" -eq 1 + grep -F "This Symphony implementation is a low key engineering preview." <<<"$output" + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [build, smoke] + runs-on: ubuntu-24.04 + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Download release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: dist + + - name: Create or update GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" dist/*/* --clobber + else + gh release create "$TAG" dist/*/* --verify-tag --generate-notes + fi diff --git a/.github/workflows/make-all.yml b/.github/workflows/make-all.yml index ca2ab27449..f27fc7c666 100644 --- a/.github/workflows/make-all.yml +++ b/.github/workflows/make-all.yml @@ -15,17 +15,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up mise tools - uses: jdx/mise-action@v3 + uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 with: install: true cache: true working_directory: elixir - name: Cache deps and build - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | elixir/deps diff --git a/.github/workflows/pr-description-lint.yml b/.github/workflows/pr-description-lint.yml index 9f2e6ac5fc..5b7743be8f 100644 --- a/.github/workflows/pr-description-lint.yml +++ b/.github/workflows/pr-description-lint.yml @@ -4,6 +4,9 @@ on: pull_request: types: [opened, edited, reopened, synchronize, ready_for_review] +permissions: + contents: read + jobs: validate-pr-description: runs-on: ubuntu-latest @@ -13,10 +16,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Set up mise tools - uses: jdx/mise-action@v3 + uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 with: install: true cache: true diff --git a/README.md b/README.md index 8a8e006d5b..e0f24a0484 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Symphony 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) +[![Symphony demo video preview](.github/media/symphony-demo-poster.jpg)](https://player.vimeo.com/video/1186371009?h=5626e4b899) -_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._ +_In this [demo video](https://player.vimeo.com/video/1186371009?h=5626e4b899), 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. diff --git a/SPEC.md b/SPEC.md index 47d6abe725..a6b44e1623 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,11 +4,20 @@ Status: Draft v1 (language-agnostic) Purpose: Define a service that orchestrates coding agents to get project work done. +## Normative Language + +The key words `MUST`, `MUST NOT`, `REQUIRED`, `SHOULD`, `SHOULD NOT`, `RECOMMENDED`, `MAY`, and +`OPTIONAL` in this document are to be interpreted as described in RFC 2119. + +`Implementation-defined` means the behavior is part of the implementation contract, but this +specification does not prescribe one universal policy. Implementations MUST document the selected +behavior. + ## 1. Problem Statement -Symphony is a long-running automation service that continuously reads work from an issue tracker -(Linear in this specification version), creates an isolated workspace for each issue, and runs a -coding agent session for that issue inside the workspace. +Symphony is a long-running automation service that continuously reads work from a configured issue +tracker, creates an isolated workspace for each issue, and runs a coding agent session for that +issue inside the workspace. The service solves four operational problems: @@ -21,15 +30,17 @@ The service solves four operational problems: Implementations are expected to document their trust and safety posture explicitly. This specification does not require a single approval, sandbox, or operator-confirmation policy; some -implementations may target trusted environments with a high-trust configuration, while others may -require stricter approvals or sandboxing. +implementations target trusted environments with a high-trust configuration, while others require +stricter approvals or sandboxing. Important boundary: - Symphony 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 + through provider-native tools executed by Symphony with the configured tracker credential. +- When tracker credentials are supplied through host-side secret references, the coding-agent child + process does not need a duplicate tracker login or direct access to raw tracker credentials. +- A successful run can end at a workflow-defined handoff state (for example `Human Review`), not necessarily `Done`. ## 2. Goals and Non-Goals @@ -43,7 +54,8 @@ Important boundary: - Recover from transient failures with exponential backoff. - Load runtime behavior from a repository-owned `WORKFLOW.md` contract. - Expose operator-visible observability (at minimum structured logs). -- Support restart recovery without requiring a persistent database. +- Support tracker/filesystem-driven restart recovery without requiring a persistent database; exact + in-memory scheduler state is not restored. ### 2.2 Non-Goals @@ -70,11 +82,13 @@ Important boundary: - Applies defaults and environment variable indirection. - Performs validation used by the orchestrator before dispatch. -3. `Issue Tracker Client` +3. `Issue Tracker Adapter` - Fetches candidate issues in active states. - Fetches current states for specific issue IDs (reconciliation). - Fetches terminal-state issues during startup cleanup. - Normalizes tracker payloads into a stable issue model. + - MAY expose provider-native agent tools without adding provider-specific write APIs to the + orchestrator. 4. `Orchestrator` - Owns the poll tick. @@ -94,7 +108,7 @@ Important boundary: - Launches the coding agent app-server client. - Streams agent updates back to the orchestrator. -7. `Status Surface` (optional) +7. `Status Surface` (OPTIONAL) - Presents human-readable runtime status (for example terminal output, dashboard, or other operator-facing view). @@ -119,19 +133,21 @@ Symphony is easiest to port when kept in these layers: 4. `Execution Layer` (workspace + agent subprocess) - Filesystem lifecycle, workspace preparation, coding-agent protocol. -5. `Integration Layer` (Linear adapter) +5. `Integration Layer` (selected tracker adapter) - API calls and normalization for tracker data. + - Provider-native agent tools and centralized tracker authentication. -6. `Observability Layer` (logs + optional status surface) +6. `Observability Layer` (logs + OPTIONAL status surface) - Operator visibility into orchestrator and agent behavior. ### 3.3 External Dependencies -- Issue tracker API (Linear for `tracker.kind: linear` in this specification version). +- One configured issue tracker API. - Local filesystem for workspaces and logs. -- Optional workspace population tooling (for example Git CLI, if used). -- Coding-agent executable that supports JSON-RPC-like app-server mode over stdio. -- Host environment authentication for the issue tracker and coding agent. +- OPTIONAL workspace population tooling (for example Git CLI, if used). +- Coding-agent executable that supports the targeted Codex app-server mode. +- Host environment authentication for the issue tracker and coding agent. Host-side tracker secret + environment variables SHOULD NOT be inherited by the coding-agent child process. ## 4. Core Domain Model @@ -139,30 +155,44 @@ Symphony is easiest to port when kept in these layers: #### 4.1.1 Issue -Normalized issue record used by orchestration, prompt rendering, and observability output. +Normalized schedulable work item used by orchestration, prompt rendering, and observability output. +The name `Issue` is generic in this specification; an adapter MAY map it from a ticket, card, +project item, or another provider-native work object. Fields: - `id` (string) - - Stable tracker-internal ID. + - REQUIRED stable dispatch identity within the configured tracker scope. + - Opaque to the orchestrator. It MAY be a project-item or board-entry ID instead of the + provider's underlying ticket ID. +- `native_ref` (object or null) + - OPTIONAL non-secret provider identifiers needed by provider-native tools. + - Opaque to the orchestrator and preserved for prompt/tool context. - `identifier` (string) - - Human-readable ticket key (example: `ABC-123`). + - REQUIRED human-readable ticket key (example: `ABC-123`). + - MUST be unique within the configured tracker scope because it names workspaces and + operator-facing routes. An adapter spanning multiple namespaces MUST disambiguate it. - `title` (string) - `description` (string or null) - `priority` (integer or null) - Lower numbers are higher priority in dispatch sorting. - `state` (string) - - Current tracker state name. + - REQUIRED current provider-native state name. - `branch_name` (string or null) - Tracker-provided branch metadata if available. - `url` (string or null) +- `assignee_id` (string or null) - `labels` (list of strings) - Normalized to lowercase. - `blocked_by` (list of blocker refs) - - Each blocker ref contains: + - Best-effort provider metadata. Each blocker ref contains: - `id` (string or null) - `identifier` (string or null) - `state` (string or null) +- `dispatchable` (boolean) + - REQUIRED adapter-derived eligibility for provider-specific rules that the generic scheduler + cannot infer safely, such as assignment, board membership, or blocker semantics. + - The orchestrator still applies configured state, label, claim, retry, and concurrency rules. - `created_at` (timestamp or null) - `updated_at` (timestamp or null) @@ -194,9 +224,8 @@ Filesystem workspace assigned to one issue identifier. Fields (logical): -- `path` (workspace path; current runtime typically uses absolute paths, but relative roots are - possible if configured without path separators) -- `workspace_key` (sanitized issue identifier) +- `path` (absolute workspace path) +- `workspace_key` (collision-resistant sanitized issue identifier) - `created_now` (boolean, used to gate `after_create` hook) #### 4.1.5 Run Attempt @@ -211,7 +240,7 @@ Fields (logical): - `workspace_path` - `started_at` - `status` -- `error` (optional) +- `error` (OPTIONAL) #### 4.1.6 Live Session (Agent Session Metadata) @@ -266,14 +295,23 @@ Fields: ### 4.2 Stable Identifiers and Normalization Rules - `Issue ID` - - Use for tracker lookups and internal map keys. + - Use for tracker refresh calls and internal map keys. + - Treat it as an opaque dispatch identity; do not assume it is the provider's underlying ticket + ID. +- `Native Ref` + - Preserve as opaque non-secret data for provider-native agent tools and prompt rendering. + - Never use it as an orchestrator map key or interpret provider-specific fields in core logic. - `Issue Identifier` - Use for human-readable logs and workspace naming. + - Require uniqueness within the configured tracker scope. - `Workspace Key` - Derive from `issue.identifier` by replacing any character not in `[A-Za-z0-9._-]` with `_`. - - Use the sanitized value for the workspace directory name. + - If sanitization changes the identifier, append a stable hash suffix of the original identifier + with at least 64 bits of entropy using only allowed workspace-key characters, making keys for + distinct identifiers that sanitize to the same text collision-resistant. + - Use the resulting value for the workspace directory name. - `Normalized Issue State` - - Compare states after `trim` + `lowercase`. + - Compare states after trimming surrounding whitespace and applying `lowercase`. - `Session ID` - Compose from coding-agent `thread_id` and `turn_id` as `-`. @@ -293,11 +331,11 @@ Loader behavior: ### 5.2 File Format -`WORKFLOW.md` is a Markdown file with optional YAML front matter. +`WORKFLOW.md` is a Markdown file with OPTIONAL YAML front matter. Design note: -- `WORKFLOW.md` should be self-contained enough to describe and run different workflows (prompt, +- `WORKFLOW.md` SHOULD be self-contained enough to describe and run different workflows (prompt, runtime settings, hooks, and tracker selection/config) without requiring out-of-band service-specific configuration. @@ -306,7 +344,7 @@ Parsing rules: - If file starts with `---`, parse lines until the next `---` as YAML front matter. - Remaining lines become the prompt body. - If front matter is absent, treat the entire file as prompt body and use an empty config map. -- YAML front matter must decode to a map/object; non-map YAML is an error. +- YAML front matter MUST decode to a map/object; non-map YAML is an error. - Prompt body is trimmed before use. Returned workflow object: @@ -325,44 +363,49 @@ Top-level keys: - `agent` - `codex` -Unknown keys should be ignored for forward compatibility. +Unknown keys SHOULD be ignored for forward compatibility. Note: -- The workflow front matter is extensible. Optional extensions may define additional top-level keys - (for example `server`) without changing the core schema above. -- Extensions should document their field schema, defaults, validation rules, and whether changes +- The workflow front matter is extensible. Extensions MAY define additional top-level keys without + changing the core schema above. +- Extensions SHOULD document their field schema, defaults, validation rules, and whether changes apply dynamically or require restart. -- Common extension: `server.port` (integer) enables the optional HTTP server described in Section - 13.7. #### 5.3.1 `tracker` (object) Fields: - `kind` (string) - - Required for dispatch. - - Current supported value: `linear` -- `endpoint` (string) - - Default for `tracker.kind == "linear"`: `https://api.linear.app/graphql` -- `api_key` (string) - - May be a literal token or `$VAR_NAME`. - - Canonical environment variable for `tracker.kind == "linear"`: `LINEAR_API_KEY`. - - If `$VAR_NAME` resolves to an empty string, treat the key as missing. -- `project_slug` (string) - - Required for dispatch when `tracker.kind == "linear"`. -- `active_states` (list of strings or comma-separated string) - - Default: `Todo`, `In Progress` -- `terminal_states` (list of strings or comma-separated string) - - Default: `Closed`, `Cancelled`, `Canceled`, `Duplicate`, `Done` + - REQUIRED for dispatch. + - Selects one implementation-supported tracker adapter. +- `provider` (object) + - Default: `{}`. + - Adapter-owned configuration such as endpoint, scope/project selector, and credentials. + - Core Symphony MUST preserve unknown keys and MUST NOT prescribe one cross-provider credential + or scope schema. + - Each adapter MUST document its required keys, defaults, secret keys, `$VAR_NAME` support, and + validation errors. + - If a documented secret `$VAR_NAME` resolves to an empty string, treat that secret as missing. +- `required_labels` (list of strings) + - Default: `[]`. + - An issue MUST contain every configured label to dispatch or continue. + - Matching ignores case and surrounding whitespace. + - A blank configured label matches no issue. +- `active_states` (list of strings) + - REQUIRED unless the selected adapter profile documents a default. + - Values are provider-native state names compared case-insensitively by the scheduler. +- `terminal_states` (list of strings) + - REQUIRED unless the selected adapter profile documents a default. + - Values are provider-native state names compared case-insensitively by the scheduler. #### 5.3.2 `polling` (object) Fields: -- `interval_ms` (integer or string integer) +- `interval_ms` (integer) - Default: `30000` - - Changes should be re-applied at runtime and affect future tick scheduling without restart. + - Changes SHOULD be re-applied at runtime and affect future tick scheduling without restart. #### 5.3.3 `workspace` (object) @@ -370,47 +413,51 @@ Fields: - `root` (path string or `$VAR`) - Default: `/symphony_workspaces` - - `~` and strings containing path separators are expanded. - - Bare strings without path separators are preserved as-is (relative roots are allowed but - discouraged). + - `~` is expanded. + - Relative paths are resolved relative to the directory containing `WORKFLOW.md`. + - The effective workspace root is normalized to an absolute path before use. #### 5.3.4 `hooks` (object) Fields: -- `after_create` (multiline shell script string, optional) +- `after_create` (multiline shell script string, OPTIONAL) - Runs only when a workspace directory is newly created. - Failure aborts workspace creation. -- `before_run` (multiline shell script string, optional) +- `before_run` (multiline shell script string, OPTIONAL) - Runs before each agent attempt after workspace preparation and before launching the coding agent. - Failure aborts the current attempt. -- `after_run` (multiline shell script string, optional) +- `after_run` (multiline shell script string, OPTIONAL) - Runs after each agent attempt (success, failure, timeout, or cancellation) once the workspace exists. - Failure is logged but ignored. -- `before_remove` (multiline shell script string, optional) +- `before_remove` (multiline shell script string, OPTIONAL) - Runs before workspace deletion if the directory exists. - Failure is logged but ignored; cleanup still proceeds. -- `timeout_ms` (integer, optional) +- `timeout_ms` (integer, OPTIONAL) - Default: `60000` - Applies to all workspace hooks. - - Non-positive values should be treated as invalid and fall back to the default. - - Changes should be re-applied at runtime for future hook executions. + - Invalid values fail configuration validation. + - Changes SHOULD be re-applied at runtime for future hook executions. #### 5.3.5 `agent` (object) Fields: -- `max_concurrent_agents` (integer or string integer) +- `max_concurrent_agents` (integer) - Default: `10` - - Changes should be re-applied at runtime and affect subsequent dispatch decisions. -- `max_retry_backoff_ms` (integer or string integer) + - Changes SHOULD be re-applied at runtime and affect subsequent dispatch decisions. +- `max_turns` (positive integer) + - Default: `20` + - Limits the number of coding-agent turns within one worker session. + - Invalid values fail configuration validation. +- `max_retry_backoff_ms` (integer) - Default: `300000` (5 minutes) - - Changes should be re-applied at runtime and affect future retry scheduling. + - Changes SHOULD be re-applied at runtime and affect future retry scheduling. - `max_concurrent_agents_by_state` (map `state_name -> positive integer`) - Default: empty map. - - State keys are normalized (`trim` + `lowercase`) for lookup. + - State keys are normalized (`trim + lowercase`) for lookup. - Invalid entries (non-positive or non-numeric) are ignored. #### 5.3.6 `codex` (object) @@ -419,16 +466,16 @@ Fields: For Codex-owned config values such as `approval_policy`, `thread_sandbox`, and `turn_sandbox_policy`, supported values are defined by the targeted Codex app-server version. -Implementors should treat them as pass-through Codex config values rather than relying on a +Implementors SHOULD treat them as pass-through Codex config values rather than relying on a hand-maintained enum in this spec. To inspect the installed Codex schema, run `codex app-server generate-json-schema --out ` and inspect the relevant definitions referenced -by `v2/ThreadStartParams.json` and `v2/TurnStartParams.json`. Implementations may validate these +by `v2/ThreadStartParams.json` and `v2/TurnStartParams.json`. Implementations MAY validate these fields locally if they want stricter startup checks. - `command` (string shell command) - Default: `codex app-server` - The runtime launches this command via `bash -lc` in the workspace directory. - - The launched process must speak a compatible app-server protocol over stdio. + - The launched process MUST speak a compatible app-server protocol over stdio. - `approval_policy` (Codex `AskForApproval` value) - Default: implementation-defined. - `thread_sandbox` (Codex `SandboxMode` value) @@ -450,8 +497,8 @@ The Markdown body of `WORKFLOW.md` is the per-issue prompt template. Rendering requirements: - Use a strict template engine (Liquid-compatible semantics are sufficient). -- Unknown variables must fail rendering. -- Unknown filters must fail rendering. +- Unknown variables MUST fail rendering. +- Unknown filters MUST fail rendering. Template input variables: @@ -463,9 +510,9 @@ Template input variables: Fallback prompt behavior: -- If the workflow prompt body is empty, the runtime may use a minimal default prompt - (`You are working on an issue from Linear.`). -- Workflow file read/parse failures are configuration/validation errors and should not silently fall +- If the workflow prompt body is empty, the runtime MAY use a minimal default prompt + (`You are working on an issue from the configured tracker.`). +- Workflow file read/parse failures are configuration/validation errors and SHOULD NOT silently fall back to a prompt. ### 5.5 Workflow Validation and Error Surface @@ -485,14 +532,20 @@ Dispatch gating behavior: ## 6. Configuration Specification -### 6.1 Source Precedence and Resolution Semantics +### 6.1 Configuration Resolution Pipeline + +Configuration is resolved in this order: -Configuration precedence: +1. Select the workflow file path (explicit runtime setting, otherwise cwd default). +2. Parse YAML front matter into a raw config map. +3. Apply built-in defaults for missing OPTIONAL fields. +4. Resolve `$VAR_NAME` indirection for config values that explicitly contain `$VAR_NAME`, plus any + adapter-owned fallback environment names documented for omitted provider fields. +5. Coerce and validate typed values. -1. Workflow file path selection (runtime setting -> cwd default). -2. YAML front matter values. -3. Environment indirection via `$VAR_NAME` inside selected YAML values. -4. Built-in defaults. +Environment variables do not globally override YAML values. They are used only when a config value +explicitly references them, or when an adapter profile documents a host-side fallback for an +omitted provider field. Such a fallback is adapter-local, not a cross-provider convention. Value coercion semantics: @@ -501,25 +554,27 @@ Value coercion semantics: - `$VAR` expansion for env-backed path values - Apply expansion only to values intended to be local filesystem paths; do not rewrite URIs or arbitrary shell command strings. +- Relative `workspace.root` values resolve relative to the directory containing the selected + `WORKFLOW.md`. ### 6.2 Dynamic Reload Semantics -Dynamic reload is required: +Dynamic reload is REQUIRED: -- The software should watch `WORKFLOW.md` for changes. -- On change, it should re-read and re-apply workflow config and prompt template without restart. -- The software should attempt to adjust live behavior to the new config (for example polling +- The software MUST detect `WORKFLOW.md` changes. +- On change, it MUST re-read and re-apply workflow config and prompt template without restart. +- The software MUST attempt to adjust live behavior to the new config (for example polling cadence, concurrency limits, active/terminal states, codex settings, workspace paths/hooks, and prompt content for future runs). - Reloaded config applies to future dispatch, retry scheduling, reconciliation decisions, hook execution, and agent launches. -- Implementations are not required to restart in-flight agent sessions automatically when config +- Implementations are not REQUIRED to restart in-flight agent sessions automatically when config changes. -- Extensions that manage their own listeners/resources (for example an HTTP server port change) may +- Extensions that manage their own listeners/resources (for example an HTTP server port change) MAY require restart unless the implementation explicitly supports live rebind. -- Implementations should also re-validate/reload defensively during runtime operations (for example +- Implementations SHOULD also re-validate/reload defensively during runtime operations (for example before dispatch) in case filesystem watch events are missed. -- Invalid reloads should not crash the service; keep operating with the last known good effective +- Invalid reloads MUST NOT crash the service; keep operating with the last known good effective configuration and emit an operator-visible error. ### 6.3 Dispatch Preflight Validation @@ -543,22 +598,23 @@ Validation checks: - Workflow file can be loaded and parsed. - `tracker.kind` is present and supported. -- `tracker.api_key` is present after `$` resolution. -- `tracker.project_slug` is present when required by the selected tracker kind. +- The selected adapter accepts `tracker.provider` after documented defaults and `$VAR` + resolution. - `codex.command` is present and non-empty. -### 6.4 Config Fields Summary (Cheat Sheet) +### 6.4 Core Config Fields Summary (Cheat Sheet) This section is intentionally redundant so a coding agent can implement the config layer quickly. - -- `tracker.kind`: string, required, currently `linear` -- `tracker.endpoint`: string, default `https://api.linear.app/graphql` when `tracker.kind=linear` -- `tracker.api_key`: string or `$VAR`, canonical env `LINEAR_API_KEY` when `tracker.kind=linear` -- `tracker.project_slug`: string, required when `tracker.kind=linear` -- `tracker.active_states`: list/string, default `Todo, In Progress` -- `tracker.terminal_states`: list/string, default `Closed, Cancelled, Canceled, Duplicate, Done` +Extension fields are documented in the extension section that defines them. Core conformance does +not require recognizing or validating extension fields unless that extension is implemented. + +- `tracker.kind`: string, REQUIRED, selects one supported adapter +- `tracker.provider`: object, default `{}`, adapter-owned endpoint/scope/auth settings +- `tracker.required_labels`: list of strings, default `[]` +- `tracker.active_states`: list of provider-native state names, adapter-defined default +- `tracker.terminal_states`: list of provider-native state names, adapter-defined default - `polling.interval_ms`: integer, default `30000` -- `workspace.root`: path, default `/symphony_workspaces` +- `workspace.root`: path resolved to absolute, default `/symphony_workspaces` - `hooks.after_create`: shell script or null - `hooks.before_run`: shell script or null - `hooks.after_run`: shell script or null @@ -575,8 +631,6 @@ This section is intentionally redundant so a coding agent can implement the conf - `codex.turn_timeout_ms`: integer, default `3600000` - `codex.read_timeout_ms`: integer, default `5000` - `codex.stall_timeout_ms`: integer, default `300000` -- `server.port` (extension): integer, optional; enables the optional HTTP server, `0` may be used - for ephemeral local bind, and CLI `--port` overrides it ## 7. Orchestration State Machine @@ -608,12 +662,12 @@ claim state. Important nuance: - A successful worker exit does not mean the issue is done forever. -- The worker may continue through multiple back-to-back coding-agent turns before it exits. +- The worker MAY continue through multiple back-to-back coding-agent turns before it exits. - After each normal turn completion, the worker re-checks the tracker issue state. -- If the issue is still in an active state, the worker should start another turn on the same live +- If the issue is still in an active state, the worker SHOULD start another turn on the same live coding-agent thread in the same workspace, up to `agent.max_turns`. -- The first turn should use the full rendered task prompt. -- Continuation turns should send only continuation guidance to the existing thread, not resend the +- The first turn SHOULD use the full rendered task prompt. +- Continuation turns SHOULD send only continuation guidance to the existing thread, not resend the original task prompt that is already present in thread history. - Once the worker exits normally, the orchestrator still schedules a short continuation retry (about 1 second) so it can re-check whether the issue remains active and needs another worker @@ -671,9 +725,9 @@ Distinct terminal reasons are important because retry logic and logs differ. ### 7.4 Idempotency and Recovery Rules - The orchestrator serializes state mutations through one authority to avoid duplicate dispatch. -- `claimed` and `running` checks are required before launching any worker. +- `claimed` and `running` checks are REQUIRED before launching any worker. - Reconciliation runs before dispatch on every tick. -- Restart recovery is tracker-driven and filesystem-driven (no durable orchestrator DB required). +- Restart recovery is tracker-driven and filesystem-driven (without a durable orchestrator DB). - Startup terminal cleanup removes stale workspaces for issues already in terminal states. ## 8. Polling, Scheduling, and Reconciliation @@ -683,7 +737,7 @@ Distinct terminal reasons are important because retry logic and logs differ. At startup, the service validates config, performs startup cleanup, schedules an immediate tick, and then repeats every `polling.interval_ms`. -The effective poll interval should be updated when workflow config changes are re-applied. +The effective poll interval SHOULD be updated when workflow config changes are re-applied. Tick sequence: @@ -703,17 +757,21 @@ An issue is dispatch-eligible only if all are true: - It has `id`, `identifier`, `title`, and `state`. - Its state is in `active_states` and not in `terminal_states`. +- Its adapter-provided `dispatchable` value is `true`. +- It contains every label in `tracker.required_labels`. - It is not already in `running`. - It is not already in `claimed`. - Global concurrency slots are available. - Per-state concurrency slots are available. -- Blocker rule for `Todo` state passes: - - If the issue state is `Todo`, do not dispatch when any blocker is non-terminal. + +For refresh and continuation checks, `issue_routable(issue)` means only that adapter-provided +`dispatchable` is true and all `tracker.required_labels` match. State, claims, and concurrency are +checked separately by the surrounding algorithm. Sorting order (stable intent): -1. `priority` ascending (1..4 are preferred; null/unknown sorts last) -2. `created_at` oldest first +1. `priority` ascending for values `1..4`; all other integers and null sort after that bucket +2. `created_at` oldest first; null sorts last 3. `identifier` lexicographic tie-breaker ### 8.3 Concurrency Control @@ -744,20 +802,19 @@ Backoff formula: Retry handling behavior: -1. Fetch active candidate issues (not all issues). -2. Find the specific issue by `issue_id`. -3. If not found, release claim. -4. If found and still candidate-eligible: +1. Refresh the specific issue with `fetch_issues_by_ids([issue_id])`. +2. If not found, release claim. +3. If found in a terminal state, clean its workspace and release claim. +4. If found and still active and routable: - Dispatch if slots are available. - Otherwise requeue with error `no available orchestrator slots`. -5. If found but no longer active, release claim. +5. If found but no longer active or routable, release claim without dispatch. Note: -- Terminal-state workspace cleanup is handled by startup cleanup and active-run reconciliation - (including terminal transitions for currently running issues). -- Retry handling mainly operates on active candidates and releases claims when the issue is absent, - rather than performing terminal cleanup itself. +- Terminal-state workspace cleanup is handled by startup cleanup, active-run reconciliation, and + retry refreshes that observe a terminal transition. +- ID refresh avoids treating a terminal, non-active, or newly unroutable issue as merely absent. ### 8.5 Active Run Reconciliation @@ -776,7 +833,8 @@ Part B: Tracker state refresh - Fetch current issue states for all running issue IDs. - For each running issue: - If tracker state is terminal: terminate worker and clean workspace. - - If tracker state is still active: update the in-memory issue snapshot. + - If tracker state is still active and routable: update the in-memory issue snapshot. + - If tracker state is active but no longer routable: terminate worker without workspace cleanup. - If tracker state is neither active nor terminal: terminate worker without workspace cleanup. - If state refresh fails, keep workers running and try again on the next tick. @@ -796,12 +854,11 @@ This prevents stale terminal workspaces from accumulating after restarts. Workspace root: -- `workspace.root` (normalized path; the current config layer expands path-like values and preserves - bare relative names) +- `workspace.root` (normalized absolute path) Per-issue workspace path: -- `/` +- `/` Workspace persistence: @@ -814,7 +871,8 @@ Input: `issue.identifier` Algorithm summary: -1. Sanitize identifier to `workspace_key`. +1. Derive `workspace_key` using Section 4.2, including the stable original-identifier hash when + sanitization changes the identifier. 2. Compute workspace path under workspace root. 3. Ensure the workspace path exists as a directory. 4. Mark `created_now=true` only if the directory was created during this call; otherwise @@ -827,19 +885,19 @@ Notes: - Workspace preparation beyond directory creation (for example dependency bootstrap, checkout/sync, code generation) is implementation-defined and is typically handled via hooks. -### 9.3 Optional Workspace Population (Implementation-Defined) +### 9.3 OPTIONAL Workspace Population (Implementation-Defined) The spec does not require any built-in VCS or repository bootstrap behavior. -Implementations may populate or synchronize the workspace using implementation-defined logic and/or +Implementations MAY populate or synchronize the workspace using implementation-defined logic and/or hooks (for example `after_create` and/or `before_run`). Failure handling: - Workspace population/synchronization failures return an error for the current attempt. -- If failure happens while creating a brand-new workspace, implementations may remove the partially +- If failure happens while creating a brand-new workspace, implementations MAY remove the partially prepared directory. -- Reused workspaces should not be destructively reset on population failure unless that policy is +- Reused workspaces SHOULD NOT be destructively reset on population failure unless that policy is explicitly chosen and documented. ### 9.4 Workspace Hooks @@ -876,7 +934,7 @@ Invariant 1: Run the coding agent only in the per-issue workspace path. - Before launching the coding-agent subprocess, validate: - `cwd == workspace_path` -Invariant 2: Workspace path must stay inside workspace root. +Invariant 2: Workspace path MUST stay inside workspace root. - Normalize both paths to absolute. - Require `workspace_path` to have `workspace_root` as a prefix directory. @@ -886,20 +944,24 @@ Invariant 3: Workspace key is sanitized. - Only `[A-Za-z0-9._-]` allowed in workspace directory names. - Replace all other characters with `_`. +- If replacement changes the identifier, append a stable original-identifier hash suffix with at + least 64 bits of entropy so keys remain collision-resistant after sanitization. ## 10. Agent Runner Protocol (Coding Agent Integration) -This section defines the language-neutral contract for integrating a coding agent app-server. +This section defines Symphony's language-neutral responsibilities when integrating a Codex +app-server. The Codex app-server protocol for the targeted Codex version is the source of truth for +protocol schemas, message payloads, transport framing, and method names. -Compatibility profile: +Protocol source of truth: -- The normative contract is message ordering, required behaviors, and the logical fields that must - be extracted (for example session IDs, completion state, approval handling, and usage/rate-limit - telemetry). -- Exact JSON field names may vary slightly across compatible app-server versions. -- Implementations should tolerate equivalent payload shapes when they carry the same logical - meaning, especially for nested IDs, approval requests, user-input-required signals, and - token/rate-limit metadata. +- Implementations MUST send messages that are valid for the targeted Codex app-server version. +- Implementations MUST consult the targeted Codex app-server documentation or generated schema + instead of treating this specification as a protocol schema. +- If this specification appears to conflict with the targeted Codex app-server protocol, the Codex + protocol controls protocol shape and transport behavior. +- Symphony-specific requirements in this section still control orchestration behavior, workspace + selection, prompt construction, continuation handling, and observability extraction. ### 10.1 Launch Contract @@ -908,107 +970,84 @@ Subprocess launch parameters: - Command: `codex.command` - Invocation: `bash -lc ` - Working directory: workspace path -- Stdout/stderr: separate streams -- Framing: line-delimited protocol messages on stdout (JSON-RPC-like JSON per line) +- Transport/framing: the protocol transport required by the targeted Codex app-server version Notes: - The default command is `codex app-server`. -- Approval policy, cwd, and prompt are expressed in the protocol messages in Section 10.2. +- Approval policy, sandbox policy, cwd, prompt input, and OPTIONAL tool declarations are supplied + using fields supported by the targeted Codex app-server version. -Recommended additional process settings: +RECOMMENDED additional process settings: - Max line size: 10 MB (for safe buffering) -### 10.2 Session Startup Handshake +### 10.2 Session Startup Responsibilities Reference: https://developers.openai.com/codex/app-server/ -The client must send these protocol messages in order: - -Illustrative startup transcript (equivalent payload shapes are acceptable if they preserve the same -semantics): - -```json -{"id":1,"method":"initialize","params":{"clientInfo":{"name":"symphony","version":"1.0"},"capabilities":{}}} -{"method":"initialized","params":{}} -{"id":2,"method":"thread/start","params":{"approvalPolicy":"","sandbox":"","cwd":"/abs/workspace"}} -{"id":3,"method":"turn/start","params":{"threadId":"","input":[{"type":"text","text":""}],"cwd":"/abs/workspace","title":"ABC-123: Example","approvalPolicy":"","sandboxPolicy":{"type":""}}} -``` - -1. `initialize` request - - Params include: - - `clientInfo` object (for example `{name, version}`) - - `capabilities` object (may be empty) - - If the targeted Codex app-server requires capability negotiation for dynamic tools, include the - necessary capability flag(s) here. - - Wait for response (`read_timeout_ms`) -2. `initialized` notification -3. `thread/start` request - - Params include: - - `approvalPolicy` = implementation-defined session approval policy value - - `sandbox` = implementation-defined session sandbox value - - `cwd` = absolute workspace path - - If optional client-side tools are implemented, include their advertised tool specs using the - protocol mechanism supported by the targeted Codex app-server version. -4. `turn/start` request - - Params include: - - `threadId` - - `input` = single text item containing rendered prompt for the first turn, or continuation - guidance for later turns on the same thread - - `cwd` - - `title` = `: ` - - `approvalPolicy` = implementation-defined turn approval policy value - - `sandboxPolicy` = implementation-defined object-form sandbox policy payload when required by - the targeted app-server version +Startup MUST follow the targeted Codex app-server contract. Symphony additionally requires the +client to: + +- Start the app-server subprocess in the per-issue workspace. +- Initialize the app-server session using the targeted Codex app-server protocol. +- Create or resume a coding-agent thread according to the targeted protocol. +- Supply the absolute per-issue workspace path as the thread/turn working directory wherever the + targeted protocol accepts cwd. +- Start the first turn with the rendered issue prompt. +- Start later in-worker continuation turns on the same live thread with continuation guidance rather + than resending the original issue prompt. +- Supply the implementation's documented approval and sandbox policy using fields supported by the + targeted protocol. +- Include issue-identifying metadata, such as `: `, when the targeted + protocol supports turn or session titles. +- Advertise implemented client-side tools using the targeted protocol. Session identifiers: -- Read `thread_id` from `thread/start` result `result.thread.id` -- Read `turn_id` from each `turn/start` result `result.turn.id` +- Extract `thread_id` from the thread identity returned by the targeted Codex app-server protocol. +- Extract `turn_id` from each turn identity returned by the targeted Codex app-server protocol. - Emit `session_id = "-"` - Reuse the same `thread_id` for all continuation turns inside one worker run ### 10.3 Streaming Turn Processing -The client reads line-delimited messages until the turn terminates. +The client processes app-server updates according to the targeted Codex app-server protocol until +the active turn terminates. Completion conditions: -- `turn/completed` -> success -- `turn/failed` -> failure -- `turn/cancelled` -> failure -- turn timeout (`turn_timeout_ms`) -> failure +- Targeted-protocol turn completion signal -> success +- Targeted-protocol turn failure signal -> failure +- Targeted-protocol turn cancellation signal -> failure +- turn stream silence timeout (`turn_timeout_ms`) -> failure - subprocess exit -> failure Continuation processing: -- If the worker decides to continue after a successful turn, it should issue another `turn/start` - on the same live `threadId`. -- The app-server subprocess should remain alive across those continuation turns and be stopped only +- If the worker decides to continue after a successful turn, it SHOULD start another turn on the same + live thread using the targeted protocol. +- The app-server subprocess SHOULD remain alive across those continuation turns and be stopped only when the worker run is ending. -Line handling requirements: +Transport handling requirements: -- Read protocol messages from stdout only. -- Buffer partial stdout lines until newline arrives. -- Attempt JSON parse on complete stdout lines. -- Stderr is not part of the protocol stream: - - ignore it or log it as diagnostics - - do not attempt protocol JSON parsing on stderr +- Follow the transport and framing rules of the targeted Codex app-server version. +- For stdio-based transports, keep protocol stream handling separate from diagnostic stderr + handling unless the targeted protocol specifies otherwise. ### 10.4 Emitted Runtime Events (Upstream to Orchestrator) -The app-server client emits structured events to the orchestrator callback. Each event should +The app-server client emits structured events to the orchestrator callback. Each event SHOULD include: - `event` (enum/string) - `timestamp` (UTC timestamp) - `codex_app_server_pid` (if available) -- optional `usage` map (token counts) +- OPTIONAL `usage` map (token counts) - payload fields as needed -Important emitted events may include: +Important emitted events include, for example: - `session_started` - `startup_failed` @@ -1029,10 +1068,10 @@ Approval, sandbox, and user-input behavior is implementation-defined. Policy requirements: -- Each implementation should document its chosen approval, sandbox, and operator-confirmation +- Each implementation MUST document its chosen approval, sandbox, and operator-confirmation posture. -- Approval requests and user-input-required events must not leave a run stalled indefinitely. An - implementation should either satisfy them, surface them to an operator, auto-resolve them, or +- Approval requests and user-input-required events MUST NOT leave a run stalled indefinitely. An + implementation MAY either satisfy them, surface them to an operator, auto-resolve them, or fail the run according to its documented policy. Example high-trust behavior: @@ -1043,76 +1082,72 @@ Example high-trust behavior: Unsupported dynamic tool calls: -- Supported dynamic tool calls that are explicitly implemented and advertised by the runtime should +- Supported dynamic tool calls that are explicitly implemented and advertised by the runtime SHOULD be handled according to their extension contract. -- If the agent requests a dynamic tool call (`item/tool/call`) that is not supported, return a tool - failure response and continue the session. +- If the agent requests a dynamic tool call that is not supported, return a tool failure response + using the targeted protocol and continue the session. - This prevents the session from stalling on unsupported tool execution paths. -Optional client-side tool extension: +Optional provider-native agent tool extension: + +- An adapter MAY expose provider-native tools to the app-server session. +- The selected adapter's tool specs SHOULD be advertised during session startup using the protocol + mechanism supported by the targeted Codex app-server version. +- Tool specs, adapter selection, and effective tracker settings MUST be bound to one session + snapshot. A workflow reload applies to future sessions; it MUST NOT make an in-flight session + advertise one provider and execute another. +- Tool names, schemas, and result payloads are adapter-owned. Symphony does not standardize a + lowest-common-denominator CRUD API. +- The runtime MUST execute advertised tool calls host-side with the active adapter configuration and + MUST NOT require the coding-agent child process to read raw tracker tokens from disk or + environment. +- The runtime SHOULD pass the current normalized issue to the adapter as internal execution context. + The adapter MAY use `issue.id` and `issue.native_ref` to preserve provider-specific richness + without teaching the orchestrator provider semantics. +- Tracker credentials SHOULD NOT be inherited by the coding-agent child process. An adapter that + resolves credentials from environment variables MUST declare which secret environment names the + launcher removes from local and remote child environments. Literal credentials in a repo-owned + `WORKFLOW.md` remain readable to a child with workspace access and SHOULD NOT be used when this + isolation matters. +- Unsupported tool names MUST return a structured failure result using the targeted protocol and + continue the session. +- Each adapter that ships tools MUST document: + - tool names and input schemas; + - whether a tool can mutate tracker state; + - scope/authorization behavior; + - result and error semantics; + - any provider-side idempotency or rate-limit expectations. + +Minimal language-neutral adapter hooks for this OPTIONAL extension: -- An implementation may expose a limited set of client-side tools to the app-server session. -- Current optional standardized tool: `linear_graphql`. -- If implemented, supported tools should be advertised to the app-server session during startup - using the protocol mechanism supported by the targeted Codex app-server version. -- Unsupported tool names should still return a failure result and continue the session. - -`linear_graphql` extension contract: - -- Purpose: execute a raw GraphQL query or mutation against Linear using Symphony's configured - tracker auth for the current session. -- Availability: only meaningful when `tracker.kind == "linear"` and valid Linear auth is configured. -- Preferred input shape: - - ```json - { - "query": "single GraphQL query or mutation document", - "variables": { - "optional": "graphql variables object" - } - } - ``` - -- `query` must be a non-empty string. -- `query` must contain exactly one GraphQL operation. -- `variables` is optional and, when present, must be a JSON object. -- Implementations may additionally accept a raw GraphQL query string as shorthand input. -- Execute one GraphQL operation per tool call. -- If the provided document contains multiple operations, reject the tool call as invalid input. -- `operationName` selection is intentionally out of scope for this extension. -- Reuse the configured Linear endpoint and auth from the active Symphony workflow/runtime config; do - not require the coding agent to read raw tokens from disk. -- Tool result semantics: - - transport success + no top-level GraphQL `errors` -> `success=true` - - top-level GraphQL `errors` present -> `success=false`, but preserve the GraphQL response body - for debugging - - invalid input, missing auth, or transport failure -> `success=false` with an error payload -- Return the GraphQL response or error payload as structured tool output that the model can inspect - in-session. - -Illustrative responses (equivalent payload shapes are acceptable if they preserve the same outcome): - -```json -{"id":"","result":{"approved":true}} -{"id":"","result":{"success":false,"error":"unsupported_tool_call"}} +```text +agent_tool_specs() -> list +secret_environment_names() -> list +execute_agent_tool(name, arguments, context={issue}) -> ToolResult ``` -Hard failure on user input requirement: +`ToolResult` MUST distinguish success from failure and carry JSON-safe structured output that can +be translated to the targeted app-server protocol. The context contains the normalized issue, never +the credential. + +User-input-required policy: -- If the agent requests user input, fail the run attempt immediately. -- The client detects this via: - - explicit method (`item/tool/requestUserInput`), or - - turn methods/flags indicating input is required. +- Implementations MUST document how targeted-protocol user-input-required signals are handled. +- A run MUST NOT stall indefinitely waiting for user input. +- A conforming implementation MAY fail the run, surface the request to an operator, satisfy it + through an approved operator channel, or auto-resolve it according to its documented policy. +- The example high-trust behavior above fails user-input-required turns immediately. ### 10.6 Timeouts and Error Mapping Timeouts: - `codex.read_timeout_ms`: request/response timeout during startup and sync requests -- `codex.turn_timeout_ms`: total turn stream timeout +- `codex.turn_timeout_ms`: maximum silence interval while a turn stream is active; each + app-server output resets it, so it is not a total turn runtime cap - `codex.stall_timeout_ms`: enforced by orchestrator based on event inactivity -Error mapping (recommended normalized categories): +Error mapping (RECOMMENDED normalized categories): - `codex_not_found` - `invalid_workspace_cwd` @@ -1140,66 +1175,130 @@ Note: - Workspaces are intentionally preserved after successful runs. -## 11. Issue Tracker Integration Contract (Linear-Compatible) - -### 11.1 Required Operations - -An implementation must support these tracker adapter operations: - -1. `fetch_candidate_issues()` - - Return issues in configured active states for a configured project. - -2. `fetch_issues_by_states(state_names)` - - Used for startup terminal cleanup. - -3. `fetch_issue_states_by_ids(issue_ids)` - - Used for active-run reconciliation. - -### 11.2 Query Semantics (Linear) - -Linear-specific requirements for `tracker.kind == "linear"`: - -- `tracker.kind == "linear"` -- GraphQL endpoint (default `https://api.linear.app/graphql`) -- Auth token sent in `Authorization` header -- `tracker.project_slug` maps to Linear project `slugId` -- Candidate issue query filters project using `project: { slugId: { eq: $projectSlug } }` -- Issue-state refresh query uses GraphQL issue IDs with variable type `[ID!]` -- Pagination required for candidate issues -- Page size default: `50` -- Network timeout: `30000 ms` - -Important: - -- Linear GraphQL schema details can drift. Keep query construction isolated and test the exact query - fields/types required by this specification. - -A non-Linear implementation may change transport details, but the normalized outputs must match the -domain model in Section 4. +## 11. Issue Tracker Integration Contract + +The issue tracker boundary is deliberately small: a portable read kernel for scheduling plus +OPTIONAL provider-native agent tools. Do not add generic comment/state/attachment CRUD merely to +make providers look alike; those operations lose useful provider semantics and are not needed by +the orchestrator. + +### 11.1 REQUIRED Adapter Operations + +An implementation MUST support these adapter operations: + +1. `fetch_issues_by_states(state_names)` + - Return normalized issues visible in the configured tracker scope and requested state names. + - The adapter MUST apply provider-side scope selection and pagination. + - Used with configured active states for candidate polling and terminal states for startup + cleanup. + - When used for candidate polling, include active scoped issues even when + `dispatchable=false`; the scheduler owns that final filter. + - The orchestrator applies `required_labels`, `dispatchable`, claims, retries, and concurrency + after normalization. + - An empty `state_names` list MUST return an empty result without a provider request. + +2. `fetch_issues_by_ids(issue_ids)` + - Return current normalized issue snapshots for the supplied opaque dispatch IDs. + - Used for active-run reconciliation and stale-dispatch revalidation. + - An empty `issue_ids` list MUST return an empty result without a provider request. + - IDs no longer visible in the configured scope are omitted; the orchestrator treats omission as + "no longer visible" rather than inventing a synthetic state. + +Both operations return either `ok(list)` or an adapter error. For portability, an adapter +error SHOULD expose a stable category and human-readable message. An implementation MAY use a +language-native tagged error, exception, tuple, or enum instead of a literal error object when its +adapter profile documents how those public error forms map to category and message. The +orchestrator relies only on success versus failure. + +The operations are atomic from the scheduler's perspective after a paging or transport failure. For +these rules, a record is malformed only when the adapter cannot produce the required normalized +fields (`id`, `identifier`, `title`, `state`, and explicit `dispatchable`) or cannot produce a +valid `Issue` after applying the optional-field fallback rules in Section 11.3. Unusable nullable +or best-effort provider metadata MAY normalize to `null`, an empty list, or omitted best-effort +entries; that fallback alone does not make a record malformed. + +A state-list call MAY omit an individually malformed provider record because it was never safe to +dispatch, and SHOULD log that omission. An ID-refresh call MUST fail instead of silently omitting a +malformed requested record, because omission is meaningful. A successful +`fetch_issues_by_ids` result is complete for that call. Output order is not significant, input IDs +are treated as a set, and each dispatch ID appears at most once. + +The refresh operation returns full normalized snapshots, not only state strings, because label, +assignment, routing, and provider-specific dispatchability can change while a run is active. + +### 11.2 Adapter Responsibilities + +Each adapter owns: + +- construction from the current effective tracker configuration, including active/terminal states; +- endpoint, authentication, transport, timeouts, pagination, and rate-limit handling; +- provider-specific scope selection (project, board, team, repository, query, or equivalent); +- mapping provider payloads into the normalized Issue fields in Section 4.1.1; +- choosing a stable dispatch identity and preserving any distinct underlying IDs in `native_ref`; +- deriving `dispatchable` from provider-specific routing rules; +- preserving provider-native state names while allowing case-insensitive scheduler comparison; +- OPTIONAL provider-native agent tools and their authorization boundary. + +The orchestrator MUST NOT inspect provider payloads, assume that `issue.id` is an underlying +ticket ID, or branch on provider-specific blocker, board, transition, or comment semantics. + +Each adapter MUST publish a compact profile in implementation documentation, not only code, +containing: + +- exact supported `tracker.kind` value; +- exact `tracker.provider` keys, defaults, secret keys/environment names, and validation errors; +- scope selection, pagination behavior, and provider request limits; +- `id` and `native_ref` mapping; +- state, label, priority, timestamp, `dispatchable`, malformed-record, and optional-field + normalization; +- provider-native tool names/schemas, mutation capability, scope, and result/error behavior if any; +- mapping from public language-native error forms to portable transport/auth/rate-limit error + categories and human-readable messages. ### 11.3 Normalization Rules -Candidate issue normalization should produce fields listed in Section 4.1.1. - -Additional normalization details: - -- `labels` -> lowercase strings -- `blocked_by` -> derived from inverse relations where relation type is `blocks` -- `priority` -> integer only (non-integers become null) -- `created_at` and `updated_at` -> parse ISO-8601 timestamps +Adapter output MUST satisfy Section 4.1.1. In addition: + +- Every listed field MUST be present in the normalized record. Nullable fields use `null`; + collection fields use an empty list when absent. +- `id`, `identifier`, `title`, and `state` MUST be non-empty strings. +- `labels` MUST be trimmed, lowercased strings; blank labels MUST be dropped and duplicate labels + SHOULD be removed. +- `priority` MUST be an integer or null. +- The scheduler ranks priorities `1..4` before null/unknown values; other integers sort with + null/unknown unless an implementation documents a different mapping. +- `created_at` and `updated_at` MUST represent parsed RFC 3339 instants or null; the in-memory + timestamp type is implementation-defined. +- Unusable provider values for nullable fields MAY normalize to `null`. Unusable best-effort + collection entries MAY be dropped; if no usable entries remain, use an empty list. These + fallbacks MUST NOT be used for `id`, `identifier`, `title`, `state`, or explicit + `dispatchable`. +- Preserve provider spelling in `state`, but trim and lowercase only for scheduler comparisons. +- `blocked_by` is best-effort metadata; adapters MUST NOT invent blocker semantics they cannot + represent reliably. +- `dispatchable` MUST be explicit. It is `true` only when provider-specific eligibility checks + pass; the generic scheduler never tries to reconstruct those checks from `native_ref`. +- `native_ref` MUST be null or a JSON-safe object containing only non-secret values safe to expose + in prompt/tool context. If provider metadata cannot be represented safely, normalize + `native_ref` to null; otherwise preserve the retained object verbatim. ### 11.4 Error Handling Contract -Recommended error categories: +RECOMMENDED adapter error categories: - `unsupported_tracker_kind` -- `missing_tracker_api_key` -- `missing_tracker_project_slug` -- `linear_api_request` (transport failures) -- `linear_api_status` (non-200 HTTP) -- `linear_graphql_errors` -- `linear_unknown_payload` -- `linear_missing_end_cursor` (pagination integrity error) +- `invalid_tracker_config` +- `missing_tracker_secret` +- `tracker_request` (transport failure) +- `tracker_status` (non-success response) +- `tracker_response` (malformed or semantically invalid payload) +- `tracker_pagination` (pagination integrity failure) +- `tracker_rate_limited` + +For portability, every adapter profile MUST document how each public language-native error form +maps to a stable `category` and human-readable `message`. A literal `{category, message}` +object is not required. Adapters MAY add `retryable`, `retry_after_ms`, provider status, and +provider-specific detail, but the orchestrator only relies on success vs. failure. Orchestrator behavior on tracker errors: @@ -1207,17 +1306,19 @@ Orchestrator behavior on tracker errors: - Running-state refresh failure: log and keep active workers running. - Startup terminal cleanup failure: log warning and continue startup. -### 11.5 Tracker Writes (Important Boundary) +### 11.5 Tracker Writes and Agent Tools (Important Boundary) Symphony does not require first-class tracker write APIs in the orchestrator. -- Ticket mutations (state transitions, comments, PR metadata) are typically handled by the coding - agent using tools defined by the workflow prompt. +- Ticket mutations (state transitions, comments, attachments, PR metadata) are typically handled by + the coding agent through the selected adapter's provider-native tools. +- Tools execute in Symphony with the configured adapter credential; the child receives tool results, + not a raw token. +- The current normalized issue is available to tool execution as context, including opaque + `native_ref`, so adapters can retain provider richness without adding it to the core scheduler. - The service remains a scheduler/runner and tracker reader. - Workflow-specific success often means "reached the next handoff state" (for example `Human Review`) rather than tracker terminal state `Done`. -- If the optional `linear_graphql` client-side tool extension is implemented, it is still part of - the agent toolchain rather than orchestrator business logic. ## 12. Prompt Construction and Context Assembly @@ -1227,7 +1328,7 @@ Inputs to prompt rendering: - `workflow.prompt_template` - normalized `issue` object -- optional `attempt` integer (retry/continuation metadata) +- OPTIONAL `attempt` integer (retry/continuation metadata) ### 12.2 Rendering Rules @@ -1238,12 +1339,14 @@ Inputs to prompt rendering: ### 12.3 Retry/Continuation Semantics -`attempt` should be passed to the template because the workflow prompt may provide different -instructions for: +`attempt` SHOULD be passed to the template as a 1-based retry/continuation count: -- first run (`attempt` null or absent) -- continuation run after a successful prior session -- retry after error/timeout/stall +- first run: `attempt` is null or absent; +- any later run: `attempt` is an integer. + +The core `attempt` value does not distinguish a normal continuation from an error/timeout/stall +retry. An implementation MAY expose an additional `retry_kind` template field if workflows need +that distinction, but it is not part of core conformance. ### 12.4 Failure Semantics @@ -1256,12 +1359,12 @@ If prompt rendering fails: ### 13.1 Logging Conventions -Required context fields for issue-related logs: +REQUIRED context fields for issue-related logs: - `issue_id` - `issue_identifier` -Required context for coding-agent session lifecycle logs: +REQUIRED context for coding-agent session lifecycle logs: - `session_id` @@ -1274,23 +1377,24 @@ Message formatting requirements: ### 13.2 Logging Outputs and Sinks -The spec does not prescribe where logs must go (stderr, file, remote sink, etc.). +The spec does not prescribe where logs are written (stderr, file, remote sink, etc.). Requirements: -- Operators must be able to see startup/validation/dispatch failures without attaching a debugger. -- Implementations may write to one or more sinks. -- If a configured log sink fails, the service should continue running when possible and emit an +- Operators MUST be able to see startup/validation/dispatch failures without attaching a debugger. +- Implementations MAY write to one or more sinks. +- If a configured log sink fails, the service SHOULD continue running when possible and emit an operator-visible warning through any remaining sink. -### 13.3 Runtime Snapshot / Monitoring Interface (Optional but Recommended) +### 13.3 Runtime Snapshot / Monitoring Interface (OPTIONAL but RECOMMENDED) If the implementation exposes a synchronous runtime snapshot (for dashboards or monitoring), it -should return: +SHOULD return: - `running` (list of running session rows) -- each running row should include `turn_count` +- each running row SHOULD include `turn_count` - `retrying` (list of retry queue rows) +- session and retry rows SHOULD include the tracker-provided issue URL when available - `codex_totals` - `input_tokens` - `output_tokens` @@ -1298,24 +1402,24 @@ should return: - `seconds_running` (aggregate runtime seconds as of snapshot time, including active sessions) - `rate_limits` (latest coding-agent rate limit payload, if available) -Recommended snapshot error modes: +RECOMMENDED snapshot error modes: - `timeout` - `unavailable` -### 13.4 Optional Human-Readable Status Surface +### 13.4 OPTIONAL Human-Readable Status Surface -A human-readable status surface (terminal output, dashboard, etc.) is optional and +A human-readable status surface (terminal output, dashboard, etc.) is OPTIONAL and implementation-defined. -If present, it should draw from orchestrator state/metrics only and must not be required for +If present, it SHOULD draw from orchestrator state/metrics only and MUST NOT be REQUIRED for correctness. ### 13.5 Session Metrics and Token Accounting Token accounting rules: -- Agent events may include token counts in multiple payload shapes. +- Agent events can include token counts in multiple payload shapes. - Prefer absolute thread totals when available, such as: - `thread/tokenUsage/updated` payloads - `total_token_usage` within token-count wrapper events @@ -1329,49 +1433,53 @@ Token accounting rules: Runtime accounting: -- Runtime should be reported as a live aggregate at snapshot/render time. -- Implementations may maintain a cumulative counter for ended sessions and add active-session +- Runtime SHOULD be reported as a live aggregate at snapshot/render time. +- Implementations MAY maintain a cumulative counter for ended sessions and add active-session elapsed time derived from `running` entries (for example `started_at`) when producing a snapshot/status view. - Add run duration seconds to the cumulative ended-session runtime when a session ends (normal exit or cancellation/termination). -- Continuous background ticking of runtime totals is not required. +- Continuous background ticking of runtime totals is not REQUIRED. Rate-limit tracking: - Track the latest rate-limit payload seen in any agent update. - Any human-readable presentation of rate-limit data is implementation-defined. -### 13.6 Humanized Agent Event Summaries (Optional) +### 13.6 Humanized Agent Event Summaries (OPTIONAL) -Humanized summaries of raw agent protocol events are optional. +Humanized summaries of raw agent protocol events are OPTIONAL. If implemented: - Treat them as observability-only output. - Do not make orchestrator logic depend on humanized strings. -### 13.7 Optional HTTP Server Extension +### 13.7 OPTIONAL HTTP Server Extension -This section defines an optional HTTP interface for observability and operational control. +This section defines an OPTIONAL HTTP interface for observability and operational control. If implemented: -- The HTTP server is an extension and is not required for conformance. -- The implementation may serve server-rendered HTML or a client-side application for the dashboard. -- The dashboard/API must be observability/control surfaces only and must not become required for +- The HTTP server is an extension and is not REQUIRED for conformance. +- The implementation MAY serve server-rendered HTML or a client-side application for the dashboard. +- The dashboard/API MUST be observability/control surfaces only and MUST NOT become REQUIRED for orchestrator correctness. +Extension config: + +- `server.port` (integer, OPTIONAL) + - Enables the HTTP server extension. + - `0` requests an ephemeral port for local development and tests. + - CLI `--port` overrides `server.port` when both are present. + Enablement (extension): - Start the HTTP server when a CLI `--port` argument is provided. - Start the HTTP server when `server.port` is present in `WORKFLOW.md` front matter. -- `server.port` is extension configuration and is intentionally not part of the core front-matter - schema in Section 5.3. -- Precedence: CLI `--port` overrides `server.port` when both are present. -- `server.port` must be an integer. Positive values bind that port. `0` may be used to request an - ephemeral port for local development and tests. -- Implementations should bind loopback by default (`127.0.0.1` or host equivalent) unless explicitly +- The `server` top-level key is owned by this extension. +- Positive `server.port` values bind that port. +- Implementations SHOULD bind loopback by default (`127.0.0.1` or host equivalent) unless explicitly configured otherwise. - Changes to HTTP listener settings (for example `server.port`) do not need to hot-rebind; restart-required behavior is conformant. @@ -1379,7 +1487,7 @@ Enablement (extension): #### 13.7.1 Human-Readable Dashboard (`/`) - Host a human-readable dashboard at `/`. -- The returned document should depict the current state of the system (for example active sessions, +- The returned document SHOULD depict the current state of the system (for example active sessions, retry delays, token consumption, runtime totals, recent events, and health/error indicators). - It is up to the implementation whether this is server-generated HTML or a client-side app that consumes the JSON API below. @@ -1406,6 +1514,7 @@ Minimum endpoints: { "issue_id": "abc123", "issue_identifier": "MT-649", + "issue_url": "https://tracker.example/issues/MT-649", "state": "In Progress", "session_id": "thread-1-turn-1", "turn_count": 7, @@ -1424,6 +1533,7 @@ Minimum endpoints: { "issue_id": "def456", "issue_identifier": "MT-650", + "issue_url": "https://tracker.example/issues/MT-650", "attempt": 3, "due_at": "2026-02-24T20:16:00Z", "error": "no available orchestrator slots" @@ -1497,7 +1607,7 @@ Minimum endpoints: - `POST /api/v1/refresh` - Queues an immediate tracker poll + reconciliation cycle (best-effort trigger; implementations - may coalesce repeated requests). + MAY coalesce repeated requests). - Suggested request body: empty body or `{}`. - Suggested response (`202 Accepted`) shape: @@ -1512,12 +1622,12 @@ Minimum endpoints: API design notes: -- The JSON shapes above are the recommended baseline for interoperability and debugging ergonomics. -- Implementations may add fields, but should avoid breaking existing fields within a version. -- Endpoints should be read-only except for operational triggers like `/refresh`. -- Unsupported methods on defined routes should return `405 Method Not Allowed`. -- API errors should use a JSON envelope such as `{"error":{"code":"...","message":"..."}}`. -- If the dashboard is a client-side app, it should consume this API rather than duplicating state +- The JSON shapes above are the RECOMMENDED baseline for interoperability and debugging ergonomics. +- Implementations MAY add fields, but SHOULD avoid breaking existing fields within a version. +- Endpoints SHOULD be read-only except for operational triggers like `/refresh`. +- Unsupported methods on defined routes SHOULD return `405 Method Not Allowed`. +- API errors SHOULD use a JSON envelope such as `{"error":{"code":"...","message":"..."}}`. +- If the dashboard is a client-side app, it SHOULD consume this API rather than duplicating state logic. ## 14. Failure Model and Recovery Strategy @@ -1527,12 +1637,12 @@ API design notes: 1. `Workflow/Config Failures` - Missing `WORKFLOW.md` - Invalid YAML front matter - - Unsupported tracker kind or missing tracker credentials/project slug + - Unsupported tracker kind or invalid adapter-owned tracker configuration - Missing coding-agent executable 2. `Workspace Failures` - Workspace directory creation failure - - Workspace population/synchronization failure (implementation-defined; may come from hooks) + - Workspace population/synchronization failure (implementation-defined; can come from hooks) - Invalid workspace path configuration - Hook timeout/failure @@ -1540,15 +1650,15 @@ API design notes: - Startup handshake failure - Turn failed/cancelled - Turn timeout - - User input requested (hard fail) + - User input requested and handled as failure by the implementation's documented policy - Subprocess exit - Stalled session (no activity) 4. `Tracker Failures` - - API transport errors - - Non-200 status - - GraphQL errors - - malformed payloads + - Provider transport errors + - Non-success provider responses + - Provider-reported application errors + - Malformed payloads 5. `Observability Failures` - Snapshot timeout @@ -1579,6 +1689,9 @@ API design notes: ### 14.3 Partial State Recovery (Restart) Current design is intentionally in-memory for scheduler state. +Restart recovery means the service can resume useful operation by polling tracker state and reusing +preserved workspaces. It does not mean retry timers, running sessions, or live worker state survive +process restart. After restart: @@ -1594,7 +1707,8 @@ After restart: Operators can control behavior by: - Editing `WORKFLOW.md` (prompt and most runtime settings). -- `WORKFLOW.md` changes should be detected and re-applied automatically without restart. +- `WORKFLOW.md` changes are detected and re-applied automatically without restart according to + Section 6.2. - Changing issue states in the tracker: - terminal state -> running session is stopped and workspace cleaned when reconciled - non-active state -> running session is stopped without cleanup @@ -1609,9 +1723,9 @@ Each implementation defines its own trust boundary. Operational safety requirements: -- Implementations should state clearly whether they are intended for trusted environments, more +- Implementations SHOULD state clearly whether they are intended for trusted environments, more restrictive environments, or both. -- Implementations should state clearly whether they rely on auto-approved actions, operator +- Implementations SHOULD state clearly whether they rely on auto-approved actions, operator approvals, stricter sandboxing, or some combination of those controls. - Workspace isolation and path validation are important baseline controls, but they are not a substitute for whatever approval and sandbox policy an implementation chooses. @@ -1620,11 +1734,11 @@ Operational safety requirements: Mandatory: -- Workspace path must remain under configured workspace root. -- Coding-agent cwd must be the per-issue workspace path for the current run. -- Workspace directory names must use sanitized identifiers. +- Workspace path MUST remain under configured workspace root. +- Coding-agent cwd MUST be the per-issue workspace path for the current run. +- Workspace directory names MUST use sanitized identifiers. -Recommended additional hardening for ports: +RECOMMENDED additional hardening for ports: - Run under a dedicated OS user. - Restrict workspace root permissions. @@ -1635,6 +1749,12 @@ Recommended additional hardening for ports: - Support `$VAR` indirection in workflow config. - Do not log API tokens or secret env values. - Validate presence of secrets without printing them. +- Execute provider-native tracker tools in the Symphony host process with the configured adapter + credential. +- Do not pass tracker credentials through the coding-agent child environment. Adapters MUST declare + secret environment names so local and remote launchers can remove them from child environments. +- Do not place literal tracker credentials in a repo-owned `WORKFLOW.md` when the child can read + that workspace; use host-side secret references instead. ### 15.4 Hook Script Safety @@ -1644,20 +1764,20 @@ Implications: - Hooks are fully trusted configuration. - Hooks run inside the workspace directory. -- Hook output should be truncated in logs. -- Hook timeouts are required to avoid hanging the orchestrator. +- Hook output SHOULD be truncated in logs. +- Hook timeouts are REQUIRED to avoid hanging the orchestrator. ### 15.5 Harness Hardening Guidance -Running Codex agents against repositories, issue trackers, and other inputs that may contain +Running Codex agents against repositories, issue trackers, and other inputs that can contain sensitive data or externally-controlled content can be dangerous. A permissive deployment can lead to data leaks, destructive mutations, or full machine compromise if the agent is induced to execute harmful commands or use overly-powerful integrations. -Implementations should explicitly evaluate their own risk profile and harden the execution harness +Implementations SHOULD explicitly evaluate their own risk profile and harden the execution harness where appropriate. This specification intentionally does not mandate a single hardening posture, but -ports should not assume that tracker data, repository contents, prompt inputs, or tool arguments are -fully trustworthy just because they originate inside a normal workflow. +implementations SHOULD NOT assume that tracker data, repository contents, prompt inputs, or tool +arguments are fully trustworthy just because they originate inside a normal workflow. Possible hardening measures include: @@ -1665,14 +1785,14 @@ Possible hardening measures include: of running with a maximally permissive configuration. - Adding external isolation layers such as OS/container/VM sandboxing, network restrictions, or separate credentials beyond the built-in Codex policy controls. -- Filtering which Linear issues, projects, teams, labels, or other tracker sources are eligible for - dispatch so untrusted or out-of-scope tasks do not automatically reach the agent. -- Narrowing the optional `linear_graphql` tool so it can only read or mutate data inside the - intended project scope, rather than exposing general workspace-wide tracker access. +- Filtering which issues, projects, boards, teams, labels, or other tracker sources are eligible + for dispatch so untrusted or out-of-scope tasks do not automatically reach the agent. +- Narrowing provider-native tools so they can only read or mutate data inside the intended tracker + scope, rather than exposing general workspace-wide tracker access. - Reducing the set of client-side tools, credentials, filesystem paths, and network destinations available to the agent to the minimum needed for the workflow. -The correct controls are deployment-specific, but implementations should document them clearly and +The correct controls are deployment-specific, but implementations SHOULD document them clearly and treat harness hardening as part of the core safety model rather than an optional afterthought. ## 16. Reference Algorithms (Language-Agnostic) @@ -1720,7 +1840,7 @@ on_tick(state): schedule_tick(state.poll_interval_ms) return state - issues = tracker.fetch_candidate_issues() + issues = tracker.fetch_issues_by_states(active_states) if issues failed: log_tracker_error() notify_observers() @@ -1749,7 +1869,7 @@ function reconcile_running_issues(state): if running_ids is empty: return state - refreshed = tracker.fetch_issue_states_by_ids(running_ids) + refreshed = tracker.fetch_issues_by_ids(running_ids) if refreshed failed: log_debug("keep workers running") return state @@ -1757,11 +1877,15 @@ function reconcile_running_issues(state): for issue in refreshed: if issue.state in terminal_states: state = terminate_running_issue(state, issue.id, cleanup_workspace=true) - else if issue.state in active_states: + else if issue.state in active_states and issue_routable(issue): state.running[issue.id].issue = issue else: state = terminate_running_issue(state, issue.id, cleanup_workspace=false) + returned_ids = set(issue.id for issue in refreshed) + for missing_id in running_ids - returned_ids: + state = terminate_running_issue(state, missing_id, cleanup_workspace=false) + return state ``` @@ -1842,15 +1966,18 @@ function run_agent_attempt(issue, attempt, orchestrator_channel): run_hook_best_effort("after_run", workspace.path) fail_worker("agent turn error") - refreshed_issue = tracker.fetch_issue_states_by_ids([issue.id]) + refreshed_issue = tracker.fetch_issues_by_ids([issue.id]) if refreshed_issue failed: app_server.stop_session(session) run_hook_best_effort("after_run", workspace.path) fail_worker("issue state refresh error") - issue = refreshed_issue[0] or issue + if refreshed_issue is empty: + break + + issue = refreshed_issue[0] - if issue.state is not active: + if issue.state is not active or not issue_routable(issue): break if turn_number >= max_turns: @@ -1893,19 +2020,23 @@ on_retry_timer(issue_id, state): if missing: return state - candidates = tracker.fetch_candidate_issues() + refreshed = tracker.fetch_issues_by_ids([issue_id]) if fetch failed: return schedule_retry(state, issue_id, retry_entry.attempt + 1, { identifier: retry_entry.identifier, - error: "retry poll failed" + error: "retry refresh failed" }) - issue = find_by_id(candidates, issue_id) + issue = find_by_id(refreshed, issue_id) if issue is null: state.claimed.remove(issue_id) return state - if available_slots(state) == 0: + if not retry_dispatch_allowed(issue, state, ignore_existing_claim=issue_id): + state.claimed.remove(issue_id) + return state + + if no_available_slots(state): return schedule_retry(state, issue_id, retry_entry.attempt + 1, { identifier: issue.identifier, error: "no available orchestrator slots" @@ -1916,15 +2047,15 @@ on_retry_timer(issue_id, state): ## 17. Test and Validation Matrix -A conforming implementation should include tests that cover the behaviors defined in this +A conforming implementation SHOULD include tests that cover the behaviors defined in this specification. Validation profiles: -- `Core Conformance`: deterministic tests required for all conforming implementations. -- `Extension Conformance`: required only for optional features that an implementation chooses to +- `Core Conformance`: deterministic tests REQUIRED for all conforming implementations. +- `Extension Conformance`: REQUIRED only for OPTIONAL features that an implementation chooses to ship. -- `Real Integration Profile`: environment-dependent smoke/integration checks recommended before +- `Real Integration Profile`: environment-dependent smoke/integration checks RECOMMENDED before production use. Unless otherwise noted, Sections 17.1 through 17.7 are `Core Conformance`. Bullets that begin with @@ -1941,10 +2072,10 @@ Unless otherwise noted, Sections 17.1 through 17.7 are `Core Conformance`. Bulle - Missing `WORKFLOW.md` returns typed error - Invalid YAML front matter returns typed error - Front matter non-map returns typed error -- Config defaults apply when optional values are missing -- `tracker.kind` validation enforces currently supported kind (`linear`) -- `tracker.api_key` works (including `$VAR` indirection) -- `$VAR` resolution works for tracker API key and path values +- Config defaults apply when OPTIONAL values are missing +- `tracker.kind` validation enforces an implementation-supported adapter +- `tracker.provider` preserves adapter-owned keys and validates them through the selected adapter +- `$VAR` resolution works for documented adapter secret keys and path values - `~` path expansion works - `codex.command` is preserved as a shell command string - Per-state concurrency override map normalizes state names and ignores invalid values @@ -1958,32 +2089,40 @@ Unless otherwise noted, Sections 17.1 through 17.7 are `Core Conformance`. Bulle - Existing workspace directory is reused - Existing non-directory path at workspace location is handled safely (replace or fail per implementation policy) -- Optional workspace population/synchronization errors are surfaced -- Temporary artifacts (`tmp`, `.elixir_ls`) are removed during prep +- OPTIONAL workspace population/synchronization errors are surfaced - `after_create` hook runs only on new workspace creation - `before_run` hook runs before each attempt and failure/timeouts abort the current attempt - `after_run` hook runs after each attempt and failure/timeouts are logged and ignored - `before_remove` hook runs on cleanup and failures/timeouts are ignored -- Workspace path sanitization and root containment invariants are enforced before agent launch +- Workspace path sanitization, stable original-identifier-hash collision resistance, and root + containment invariants are enforced before agent launch +- Identifiers unchanged by sanitization keep their deterministic workspace key; conformance tests + include distinct identifiers that sanitize to the same text and verify distinct keys - Agent launch uses the per-issue workspace path as cwd and rejects out-of-root paths -### 17.3 Issue Tracker Client +### 17.3 Issue Tracker Adapter -- Candidate issue fetch uses active states and project slug -- Linear query uses the specified project filter field (`slugId`) -- Empty `fetch_issues_by_states([])` returns empty without API call +- Candidate issue fetch applies configured active states and adapter-owned scope selection +- Empty `fetch_issues_by_states([])` returns empty without a provider call +- Empty `fetch_issues_by_ids([])` returns empty without a provider call - Pagination preserves order across multiple pages -- Blockers are normalized from inverse relations of type `blocks` - Labels are normalized to lowercase -- Issue state refresh by ID returns minimal normalized issues -- Issue state refresh query uses GraphQL ID typing (`[ID!]`) as specified in Section 11.2 -- Error mapping for request errors, non-200, GraphQL errors, malformed payloads +- Unusable optional provider metadata normalizes to null/empty without hiding valid required fields +- State-list reads log omitted malformed required records; ID refresh fails malformed requested + records instead of treating them as invisible +- Refresh by opaque dispatch ID returns full normalized issue snapshots +- A distinct provider ticket ID or project-item ID is preserved in `native_ref` when needed +- Provider-specific routing/blocker/assignment rules become explicit `dispatchable` +- The adapter publishes the required compact profile for config, scope, normalization, tools, and + portable error mapping +- Error mapping covers config, request, non-success response, malformed payload, pagination, and + rate limiting, including documented category/message mappings for language-native errors ### 17.4 Orchestrator Dispatch, Reconciliation, and Retry - Dispatch sort order is priority then oldest creation time -- `Todo` issue with non-terminal blockers is not eligible -- `Todo` issue with terminal blockers is eligible +- `dispatchable=false` issues are not eligible +- Required-label filtering is case-insensitive and applies after adapter normalization - Active-state issue refresh updates running entry state - Non-active state stops running agent without workspace cleanup - Terminal state stops running agent and cleans workspace @@ -2001,29 +2140,30 @@ Unless otherwise noted, Sections 17.1 through 17.7 are `Core Conformance`. Bulle ### 17.5 Coding-Agent App-Server Client - Launch command uses workspace cwd and invokes `bash -lc ` -- Startup handshake sends `initialize`, `initialized`, `thread/start`, `turn/start` -- `initialize` includes client identity/capabilities payload required by the targeted Codex - app-server protocol +- Session startup follows the targeted Codex app-server protocol. +- Client identity/capability payloads are valid when the targeted Codex app-server protocol requires + them. - Policy-related startup payloads use the implementation's documented approval/sandbox settings -- `thread/start` and `turn/start` parse nested IDs and emit `session_started` +- Thread and turn identities exposed by the targeted protocol are extracted and used to emit + `session_started` - Request/response read timeout is enforced - Turn timeout is enforced -- Partial JSON lines are buffered until newline -- Stdout and stderr are handled separately; protocol JSON is parsed from stdout only -- Non-JSON stderr lines are logged but do not crash parsing +- Transport framing required by the targeted protocol is handled correctly +- For stdio-based transports, diagnostic stderr handling is kept separate from the protocol stream - Command/file-change approvals are handled according to the implementation's documented policy - Unsupported dynamic tool calls are rejected without stalling the session - User input requests are handled according to the implementation's documented policy and do not stall indefinitely -- Usage and rate-limit payloads are extracted from nested payload shapes -- Compatible payload variants for approvals, user-input-required signals, and usage/rate-limit - telemetry are accepted when they preserve the same logical meaning -- If optional client-side tools are implemented, the startup handshake advertises the supported tool - specs required for discovery by the targeted app-server version -- If the optional `linear_graphql` client-side tool extension is implemented: - - the tool is advertised to the session - - valid `query` / `variables` inputs execute against configured Linear auth - - top-level GraphQL `errors` produce `success=false` while preserving the GraphQL body +- Usage and rate-limit telemetry exposed by the targeted protocol is extracted +- Approval, user-input-required, usage, and rate-limit signals are interpreted according to the + targeted protocol +- If client-side tools are implemented, session startup advertises the supported tool specs + using the targeted app-server protocol +- If provider-native agent tools are implemented: + - only the selected adapter's tools are advertised to the session + - valid inputs execute host-side with configured adapter auth + - the current normalized issue and `native_ref` are available as internal tool context + - tracker secrets are not inherited by the coding-agent child process - invalid arguments, missing auth, and transport failures return structured failure payloads - unsupported tool names still fail without stalling the session @@ -2040,24 +2180,24 @@ Unless otherwise noted, Sections 17.1 through 17.7 are `Core Conformance`. Bulle ### 17.7 CLI and Host Lifecycle -- CLI accepts an optional positional workflow path argument (`path-to-WORKFLOW.md`) +- CLI accepts a positional workflow path argument (`path-to-WORKFLOW.md`) - CLI uses `./WORKFLOW.md` when no workflow path argument is provided - CLI errors on nonexistent explicit workflow path or missing default `./WORKFLOW.md` - CLI surfaces startup failure cleanly - CLI exits with success when application starts and shuts down normally - CLI exits nonzero when startup fails or the host process exits abnormally -### 17.8 Real Integration Profile (Recommended) +### 17.8 Real Integration Profile (RECOMMENDED) -These checks are recommended for production readiness and may be skipped in CI when credentials, +These checks are RECOMMENDED for production readiness and MAY be skipped in CI when credentials, network access, or external service permissions are unavailable. -- A real tracker smoke test can be run with valid credentials supplied by `LINEAR_API_KEY` or a - documented local bootstrap mechanism (for example `~/.linear_api_key`). -- Real integration tests should use isolated test identifiers/workspaces and clean up tracker +- A real tracker smoke test can be run with valid credentials supplied through the selected + adapter's documented secret mechanism. +- Real integration tests SHOULD use isolated test identifiers/workspaces and clean up tracker artifacts when practical. -- A skipped real-integration test should be reported as skipped, not silently treated as passed. -- If a real-integration profile is explicitly enabled in CI or release validation, failures should +- A skipped real-integration test SHOULD be reported as skipped, not silently treated as passed. +- If a real-integration profile is explicitly enabled in CI or release validation, failures SHOULD fail that job. ## 18. Implementation Checklist (Definition of Done) @@ -2068,18 +2208,18 @@ Use the same validation profiles as Section 17: - Section 18.2 = `Extension Conformance` - Section 18.3 = `Real Integration Profile` -### 18.1 Required for Conformance +### 18.1 REQUIRED for Conformance - Workflow path selection supports explicit runtime path and cwd default - `WORKFLOW.md` loader with YAML front matter + prompt body split - Typed config layer with defaults and `$` resolution - Dynamic `WORKFLOW.md` watch/reload/re-apply for config and prompt - Polling orchestrator with single-authority mutable state -- Issue tracker client with candidate fetch + state refresh + terminal fetch -- Workspace manager with sanitized per-issue workspaces +- Issue tracker adapter with state-list + ID-refresh reads +- Workspace manager with sanitized, collision-resistant per-issue workspaces - Workspace lifecycle hooks (`after_create`, `before_run`, `after_run`, `before_remove`) - Hook timeout config (`hooks.timeout_ms`, default `60000`) -- Coding-agent app-server subprocess client with JSON line protocol +- Coding-agent app-server subprocess client with the targeted transport/framing protocol - Codex launch command config (`codex.command`, default `codex app-server`) - Strict prompt rendering with `issue` and `attempt` variables - Exponential retry queue with continuation retries after normal exit @@ -2087,24 +2227,85 @@ Use the same validation profiles as Section 17: - Reconciliation that stops runs on terminal/non-active tracker states - Workspace cleanup for terminal issues (startup sweep + active transition) - Structured logs with `issue_id`, `issue_identifier`, and `session_id` -- Operator-visible observability (structured logs; optional snapshot/status surface) +- Operator-visible observability (structured logs; OPTIONAL snapshot/status surface) -### 18.2 Recommended Extensions (Not Required for Conformance) +### 18.2 RECOMMENDED Extensions (Not REQUIRED for Conformance) -- Optional HTTP server honors CLI `--port` over `server.port`, uses a safe default bind host, and +- HTTP server extension honors CLI `--port` over `server.port`, uses a safe default bind host, and exposes the baseline endpoints/error semantics in Section 13.7 if shipped. -- Optional `linear_graphql` client-side tool extension exposes raw Linear GraphQL access through the - app-server session using configured Symphony auth. +- Provider-native agent tools, when shipped, execute through the app-server session using + host-side configured adapter auth without passing tracker secrets to the child. - TODO: Persist retry queue and session metadata across process restarts. - TODO: Make observability settings configurable in workflow front matter without prescribing UI implementation details. -- TODO: Add first-class tracker write APIs (comments/state transitions) in the orchestrator instead - of only via agent tools. -- TODO: Add pluggable issue tracker adapters beyond Linear. +- TODO: Extract common semantic helper tools only after multiple adapters demonstrate real + duplication; do not preemptively replace provider-native tools with generic CRUD. -### 18.3 Operational Validation Before Production (Recommended) +### 18.3 Operational Validation Before Production (RECOMMENDED) - Run the `Real Integration Profile` from Section 17.8 with valid credentials and network access. - Verify hook execution and workflow path resolution on the target host OS/shell environment. -- If the optional HTTP server is shipped, verify the configured port behavior and loopback/default +- If the OPTIONAL HTTP server is shipped, verify the configured port behavior and loopback/default bind expectations on the target environment. + +## Appendix A. SSH Worker Extension (OPTIONAL) + +This appendix describes a common extension profile in which Symphony keeps one central +orchestrator but executes worker runs on one or more remote hosts over SSH. + +Extension config: + +- `worker.ssh_hosts` (list of SSH host strings, OPTIONAL) + - When omitted, work runs locally. +- `worker.max_concurrent_agents_per_host` (positive integer, OPTIONAL) + - Shared per-host cap applied across configured SSH hosts. + +### A.1 Execution Model + +- The orchestrator remains the single source of truth for polling, claims, retries, and + reconciliation. +- `worker.ssh_hosts` provides the candidate SSH destinations for remote execution. +- Each worker run is assigned to one host at a time, and that host becomes part of the run's + effective execution identity along with the issue workspace. +- `workspace.root` is interpreted on the remote host, not on the orchestrator host. +- The coding-agent app-server is launched over SSH stdio instead of as a local subprocess, so the + orchestrator still owns the session lifecycle even though commands execute remotely. +- Continuation turns inside one worker lifetime SHOULD stay on the same host and workspace. +- A remote host SHOULD satisfy the same basic contract as a local worker environment: reachable + shell, writable workspace root, coding-agent executable, and any required auth or repository + prerequisites. + +### A.2 Scheduling Notes + +- SSH hosts MAY be treated as a pool for dispatch. +- Implementations MAY prefer the previously used host on retries when that host is still + available. +- `worker.max_concurrent_agents_per_host` is an OPTIONAL shared per-host cap across configured SSH + hosts. +- When all SSH hosts are at capacity, dispatch SHOULD wait rather than silently falling back to a + different execution mode. +- Implementations MAY fail over to another host when the original host is unavailable before work + has meaningfully started. +- Once a run has already produced side effects, a transparent rerun on another host SHOULD be + treated as a new attempt, not as invisible failover. + +### A.3 Problems to Consider + +- Remote environment drift: + - Each host needs the expected shell environment, coding-agent executable, auth, and repository + prerequisites. +- Workspace locality: + - Workspaces are usually host-local, so moving an issue to a different host is typically a cold + restart unless shared storage exists. +- Path and command safety: + - Remote path resolution, shell quoting, and workspace-boundary checks matter more once execution + crosses a machine boundary. +- Startup and failover semantics: + - Implementations SHOULD distinguish host-connectivity/startup failures from in-workspace agent + failures so the same ticket is not accidentally re-executed on multiple hosts. +- Host health and saturation: + - A dead or overloaded host SHOULD reduce available capacity, not cause duplicate execution or an + accidental fallback to local work. +- Cleanup and observability: + - Operators need to know which host owns a run, where its workspace lives, and whether cleanup + happened on the right machine. diff --git a/docs/symphony-smoke-board-review.md b/docs/symphony-smoke-board-review.md new file mode 100644 index 0000000000..0eaab456b7 --- /dev/null +++ b/docs/symphony-smoke-board-review.md @@ -0,0 +1,3 @@ +# Symphony Board Review Smoke Test + +This brief note confirms this is a Symphony smoke test for the board review workflow. diff --git a/docs/symphony-smoke-test-one.md b/docs/symphony-smoke-test-one.md new file mode 100644 index 0000000000..ff9f057a3f --- /dev/null +++ b/docs/symphony-smoke-test-one.md @@ -0,0 +1,3 @@ +# Symphony Smoke Test + +Symphony picked up Jira issue SD-4 and handled it by adding this isolated smoke-test doc. diff --git a/elixir/.gitignore b/elixir/.gitignore index a251439010..bdf8faee43 100644 --- a/elixir/.gitignore +++ b/elixir/.gitignore @@ -37,6 +37,8 @@ erl_crash.dump .idea/ .vscode/ /bin/ +/burrito_out/ +/dist/ # Local environment and auth artifacts. .env diff --git a/elixir/AGENTS.md b/elixir/AGENTS.md index 884aefa905..1d09e815d2 100644 --- a/elixir/AGENTS.md +++ b/elixir/AGENTS.md @@ -22,12 +22,22 @@ This directory contains the Elixir agent orchestration service that polls Linear - 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. +- Simplicity is a project constraint: prefer the smallest coherent design with one clear owner and + invariant. Push back on extra abstractions, duplicated policy, and speculative flexibility. +- For stateful changes, check startup, reload, restart, and failure recovery together before editing. - 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. +- Prefer narrow tests that exercise real OTP processes and observable behavior over mock-only or + broad end-to-end coverage; prove health with a synchronous call or stable effect, not only a PID. +- For non-trivial changes, use an adversarial review early to challenge complexity and try to break + adjacent lifecycle paths; a reproducible failure blocks landing even if other reviews are clean. +- If tests need repeated global restarts or bespoke cleanup, first fix the shared harness or + ownership boundary. + ```bash make all ``` @@ -37,6 +47,8 @@ make all - Public functions (`def`) in `lib/` must have an adjacent `@spec`. - `defp` specs are optional. - `@impl` callback implementations are exempt from local `@spec` requirement. +- Evaluate proposed directions instead of agreeing reflexively; surface simpler designs and material + trade-offs early. - Keep changes narrowly scoped; avoid unrelated refactors. - Follow existing module/style patterns in `lib/symphony_elixir/*`. diff --git a/elixir/Makefile b/elixir/Makefile index 9c1ae9096d..61c40270aa 100644 --- a/elixir/Makefile +++ b/elixir/Makefile @@ -1,9 +1,9 @@ -.PHONY: help all setup deps build fmt fmt-check lint test coverage ci dialyzer +.PHONY: help all setup deps build fmt fmt-check lint test coverage ci dialyzer e2e MIX ?= mix help: - @echo "Targets: setup, deps, fmt, fmt-check, lint, test, coverage, dialyzer, ci" + @echo "Targets: setup, deps, fmt, fmt-check, lint, test, coverage, dialyzer, e2e, ci" setup: $(MIX) setup @@ -33,6 +33,9 @@ dialyzer: $(MIX) deps.get $(MIX) dialyzer --format short +e2e: + SYMPHONY_RUN_LIVE_E2E=1 $(MIX) test test/symphony_elixir/live_e2e_test.exs + ci: $(MAKE) setup $(MAKE) build diff --git a/elixir/README.md b/elixir/README.md index 3f711587ce..f17a297f6b 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -13,19 +13,28 @@ This directory contains the current Elixir/OTP implementation of Symphony, based ## How it works -1. Polls Linear for candidate work -2. Creates an isolated workspace per issue +1. Polls the configured tracker for candidate work (included adapters: Linear, GitHub Issues, Jira + Cloud, Asana, and GitLab) +2. Creates a workspace per issue 3. Launches Codex in [App Server mode](https://developers.openai.com/codex/app-server/) inside the workspace 4. Sends a workflow prompt to Codex 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. +During app-server sessions, the selected tracker adapter may advertise provider-native tools. The +Linear serves `linear_graphql`, GitHub Issues serves `github_api`, Jira Cloud serves +`jira_rest`, Asana serves `asana_api`, and GitLab serves `gitlab_api`. Symphony executes those +tools with configured host-side auth and removes declared tracker-token environment variables from +the Codex child, so the agent does not need a second tracker login. 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. +If Codex reports that operator input, approval, or MCP elicitation is required, Symphony keeps the +issue claimed and exposes it as blocked in the runtime state, JSON API, and dashboard. Blocked +entries are in memory only; restarting the orchestrator clears that blocked map, so any still-active +tracker issue can become a dispatch candidate again after restart. + ## How to use it 1. Make sure your codebase is set up to work well with agents: see @@ -65,6 +74,29 @@ mise exec -- mix build mise exec -- ./bin/symphony ./WORKFLOW.md ``` +## Burrito releases + +Symphony ships self-contained executables built with +[Burrito](https://github.com/burrito-elixir/burrito). They embed Erlang/OTP, Elixir, and Symphony, +but still expect `codex`, `git`, and the selected tracker credentials on the target machine. + +Supported release targets: + +- `macos_arm64` +- `macos_x86_64` +- `linux_arm64` +- `linux_x86_64` + +`v*` tags publish all four targets with checksums. A manual workflow run builds the same +artifacts without creating a release. + +After downloading the executable for your platform from a release: + +```bash +chmod +x ./symphony-v0.0.1-macos_arm64 +./symphony-v0.0.1-macos_arm64 ./WORKFLOW.md +``` + ## Configuration Pass a custom workflow file path to `./bin/symphony` when starting the service: @@ -89,7 +121,8 @@ Minimal example: --- tracker: kind: linear - project_slug: "..." + provider: + project_slug: "..." workspace: root: ~/code/workspaces hooks: @@ -102,7 +135,7 @@ codex: command: codex app-server --- -You are working on a Linear issue {{ issue.identifier }}. +You are working on an issue from the configured tracker {{ issue.identifier }}. Title: {{ issue.title }} Body: {{ issue.description }} ``` @@ -110,14 +143,26 @@ Title: {{ issue.title }} Body: {{ issue.description }} Notes: - If a value is missing, defaults are used. +- `tracker.kind` selects an adapter. Adapter-owned endpoint, scope, and auth settings belong under + `tracker.provider`; the current Linear adapter still accepts the older flat `endpoint`, + `api_key`, `project_slug`, and `assignee` aliases for compatibility. +- `tracker.required_labels` is optional. When set, an issue must have every + configured label to dispatch or continue running. Label matching ignores + case and surrounding whitespace. A blank configured label matches no issue. - 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 +- `codex.turn_timeout_ms` is the maximum silence interval while a turn is streaming. Each + app-server update resets it; it is not a total turn runtime cap. - Supported `codex.approval_policy` values depend on the targeted Codex app-server version. In the current local Codex schema, string values include `untrusted`, `on-failure`, `on-request`, and `never`, and object-form `reject` is also supported. - Supported `codex.thread_sandbox` values: `read-only`, `workspace-write`, `danger-full-access`. -- Supported `codex.turn_sandbox_policy.type` values: `dangerFullAccess`, `readOnly`, - `externalSandbox`, `workspaceWrite`. +- When `codex.turn_sandbox_policy` is set explicitly, Symphony passes the map through to Codex + unchanged. Compatibility then depends on the targeted Codex app-server version rather than local + Symphony validation. +- Workflows that run package managers or other commands that resolve external hosts should set + `networkAccess: true` in `codex.turn_sandbox_policy`; otherwise DNS/network access may be denied + by the Codex turn sandbox. - `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 @@ -126,7 +171,11 @@ Notes: `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 the Linear adapter, `tracker.provider.api_key` reads from `LINEAR_API_KEY` when unset or + when value is `$LINEAR_API_KEY`. The legacy flat `tracker.api_key` alias behaves the same way. +- Do not put a literal tracker token in a repo-owned `WORKFLOW.md` if Codex can read that + workspace. Use `$VAR`/host-side secret references so Symphony can keep the token out of the + child environment. - 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 @@ -134,20 +183,110 @@ Notes: ```yaml tracker: - api_key: $LINEAR_API_KEY + provider: + 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" + command: "$CODEX_BIN --config 'model=\"gpt-5.5\"' app-server" ``` -- If `WORKFLOW.md` is missing or has invalid YAML, startup and scheduling are halted until fixed. +- If `WORKFLOW.md` is missing or has invalid YAML at startup, Symphony does not boot. +- If a later reload fails, Symphony keeps running with the last known good workflow and logs the + reload error until the file is fixed. - `server.port` or CLI `--port` enables the optional Phoenix LiveView dashboard and JSON API at `/`, `/api/v1/state`, `/api/v1/`, and `/api/v1/refresh`. +### Linear adapter profile + +- Config: use `tracker.kind: linear` with `tracker.provider.endpoint` (default + `https://api.linear.app/graphql`), `api_key` (defaults to `LINEAR_API_KEY` and accepts + `$VAR`), required `project_slug`, and optional `assignee` (a Linear user ID or `me`, + defaulting to `LINEAR_ASSIGNEE`). + The legacy flat `tracker.endpoint`, `api_key`, `project_slug`, and `assignee` aliases remain + supported. `required_labels`, `active_states`, and `terminal_states` stay under `tracker`. +- Scope and paging: candidate reads filter the configured project slug and requested state names, + following Linear pages of 50. ID refreshes are also project-scoped and batch up to 50 IDs. Empty + state/ID lists return `{:ok, []}` without a Linear request. +- Identity and normalization: `issue.id` is the Linear issue ID and `issue.native_ref` is currently + `nil`. Records missing a nonblank ID, identifier, title, or state are dropped from candidate + pages and fail ID refreshes. State keeps Linear's spelling; integer priorities are preserved and + other priority values become `nil`; RFC 3339 timestamps are parsed and unusable timestamps become + `nil`. Labels are trimmed, lowercased, deduplicated, and blanks are dropped; blockers come from + inverse `blocks` relations. +- Dispatchability: the adapter marks an issue dispatchable only when optional assignee routing + matches and a `Todo` issue has no non-terminal blocker. The generic scheduler then applies + active/terminal states, required labels, claims, retries, and concurrency. +- Tool: the Linear adapter advertises `linear_graphql`, accepting either a raw query string or an + object with nonblank `query` and optional object `variables`. Symphony executes it host-side + with the session-bound endpoint/token and strips declared token environment variables from the + Codex child. `project_slug` scopes scheduler reads, not raw tool calls; the tool can access + whatever the configured Linear token can access. +- Responsibility and errors: `linear_graphql` adds no idempotency key, retry, scope guard, or + rate-limit policy, so workflows own idempotent mutations and handling provider errors. Read/config + failures use `{:error, :missing_linear_api_token}`, `{:error, :missing_linear_project_slug}`, + `{:error, :invalid_linear_endpoint}`, `{:error, :invalid_linear_assignee}`, + `{:error, :missing_linear_viewer_identity}`, `{:error, {:linear_api_status, status}}`, + `{:error, {:linear_api_request, reason}}`, `{:error, {:linear_graphql_errors, errors}}`, + `{:error, :linear_unknown_payload}`, or `{:error, :linear_missing_end_cursor}`. Tool results + are maps with `"success"`, JSON-string `"output"`, and text `"contentItems"`; invalid + arguments, missing auth, and transport failures return `"success" => false` with + `{"error": {"message": ...}}`, while top-level GraphQL errors preserve the response body with + `"success" => false`. + For portable reporting, map missing/invalid token, project, endpoint, assignee, or viewer errors + to `tracker_config` or `tracker_auth`, request failures to `tracker_transport`, non-200 responses to + `tracker_response` (`429` is `tracker_rate_limited`), GraphQL/unknown payload failures to + `tracker_payload`, and missing cursors to `tracker_pagination`; logs and tool responses carry the + human-readable provider detail. + +### GitHub Issues adapter + +- Config: use `tracker.kind: github` with required `tracker.provider.repo` in `owner/repo` form, + optional `token` (defaults to `GITHUB_TOKEN` and accepts `$VAR`), and optional `api_url` + (default `https://api.github.com`, HTTPS only). Set explicit `active_states` and + `terminal_states`; active entries may be `open` and terminal entries may be `closed`. +- Reads and identity: polling is scoped to the configured repository; `issue.id` is the + repository issue number, `issue.identifier` is `GH-`, hidden or deleted `404` issues are + omitted on refresh, and pull requests returned by the Issues API are not dispatchable. +- Tool and auth: `github_api` accepts a relative REST `path` plus optional `params` and JSON + `body`; Symphony executes it host-side with the session-bound token, strips `GITHUB_TOKEN` and + configured `$VAR` token names from the Codex child, and leaves raw tool access limited by that + token's GitHub permissions. + +### Jira Cloud adapter + +- Config: use `tracker.kind: jira` with provider `base_url`, `email`, `api_token`, and required + `project_key`; the first three default to `JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_API_TOKEN` + and accept `$VAR`. Set explicit Jira-native `active_states` and `terminal_states`. +- Issues and reads: candidate reads and ID refreshes stay scoped to the configured project and + requested statuses; `issue.id` is Jira's immutable ID and `issue.identifier` is the issue key. +- Blockers: inward `Blocks` links populate `blocked_by`; issues in Jira's `new` status category + wait until blockers reach configured terminal states, while in-progress categories keep running. +- Tool: `jira_rest` sends relative `/rest/api/3/` requests host-side with configured Basic auth, + strips token environment variables from Codex, and can reach whatever the Jira credential can. + +### Asana adapter + +- Config: use `tracker.kind: asana` with required `tracker.provider.project_gid`, optional + `endpoint` (default `https://app.asana.com/api/1.0`), and `api_key` (defaults to `ASANA_PAT` and + accepts `$VAR`); `active_states` and `terminal_states` are project section names. +- Scope: Symphony polls tasks in the configured project, treats their section as state, and omits + deleted or out-of-project tasks during ID refreshes. +- Tool: `asana_api` sends relative Asana REST requests host-side with the configured auth; Symphony + strips `ASANA_PAT` and configured token variables from the Codex child, while raw tool calls are + not limited to the configured project. + +### GitLab adapter + +- Configure `tracker.kind: gitlab` with `tracker.provider.project_path`, optional `api_url`, and + `api_key` (default `GITLAB_PAT`); use `opened` and `closed` tracker states. +- Symphony reads project issues by IID and exposes route-safe `GL-` identifiers. +- `gitlab_api` forwards raw GitLab REST requests with host-side auth and keeps GitLab token env vars + out of the Codex child. + ## Web dashboard The observability UI now runs on a minimal Phoenix stack: @@ -156,6 +295,7 @@ The observability UI now runs on a minimal Phoenix stack: - JSON API for operational debugging under `/api/v1/*` - Bandit as the HTTP server - Phoenix dependency static assets for the LiveView client bootstrap +- Tracker issue identifiers link to the tracker-provided URL when it uses `http` or `https` ## Project Layout @@ -170,6 +310,77 @@ The observability UI now runs on a minimal Phoenix stack: make all ``` +Run the real external end-to-end test only when you want Symphony to create disposable Linear +resources and launch a real `codex app-server` session: + +```bash +cd elixir +export LINEAR_API_KEY=... +make e2e +``` + +Optional environment variables: + +- `SYMPHONY_LIVE_LINEAR_TEAM_KEY` defaults to `SYME2E` +- `SYMPHONY_LIVE_SSH_WORKER_HOSTS` uses those SSH hosts when set, as a comma-separated list + +`make e2e` runs two live scenarios: +- one with a local worker +- one with SSH workers + +If `SYMPHONY_LIVE_SSH_WORKER_HOSTS` is unset, the SSH scenario uses `docker compose` to start two +disposable SSH workers on `localhost:`. The live test generates a temporary SSH keypair, +mounts the host `~/.codex/auth.json` into each worker, verifies that Symphony can talk to them +over real SSH, then runs the same orchestration flow against those worker addresses. This keeps +the transport representative without depending on long-lived external machines. + +Set `SYMPHONY_LIVE_SSH_WORKER_HOSTS` if you want `make e2e` to target real SSH hosts instead. + +The live test creates a temporary Linear project and issue, writes a temporary `WORKFLOW.md`, runs +a real agent turn, verifies the workspace side effect, requires Codex to comment on and close the +Linear issue, then marks the project completed so the run remains visible in Linear. + +Run the opt-in GitHub Issues live test with a disposable/scratch repository: + +```bash +cd elixir +export SYMPHONY_LIVE_GITHUB_REPO=owner/scratch-repo +export GITHUB_TOKEN=... +SYMPHONY_RUN_GITHUB_LIVE_E2E=1 mix test test/symphony_elixir/github_live_e2e_test.exs +``` + +Run the opt-in Jira Cloud live test against a disposable project whose credential can browse, +create, comment on, transition, and delete issues: + +```bash +cd elixir +export JIRA_BASE_URL=https://your-site.atlassian.net +export JIRA_EMAIL=... +export JIRA_API_TOKEN=... +export SYMPHONY_LIVE_JIRA_PROJECT_KEY=TEST +SYMPHONY_RUN_JIRA_LIVE_E2E=1 mix test test/symphony_elixir/jira_live_e2e_test.exs +``` + +Run the opt-in Asana live E2E against disposable Asana resources: + +```bash +cd elixir +export ASANA_PAT=... +export SYMPHONY_LIVE_ASANA_WORKSPACE_GID=... +# Required only when the workspace is an organization: +# export SYMPHONY_LIVE_ASANA_TEAM_GID=... +SYMPHONY_RUN_ASANA_LIVE_E2E=1 mix test test/symphony_elixir/asana_live_e2e_test.exs +``` + +Run the opt-in GitLab live E2E against a disposable project: + +```bash +cd elixir +export GITLAB_PAT=... +export SYMPHONY_LIVE_GITLAB_PROJECT_ID=... +SYMPHONY_RUN_GITLAB_LIVE_E2E=1 mix test test/symphony_elixir/gitlab_live_e2e_test.exs +``` + ## FAQ ### Why Elixir? diff --git a/elixir/WORKFLOW.md b/elixir/WORKFLOW.md index d102b62fea..c87d04838c 100644 --- a/elixir/WORKFLOW.md +++ b/elixir/WORKFLOW.md @@ -1,7 +1,9 @@ --- tracker: kind: linear - project_slug: "symphony-0c79b11b75ea" + provider: + project_slug: "symphony-0c79b11b75ea" + required_labels: [] active_states: - Todo - In Progress @@ -29,22 +31,23 @@ 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 + command: codex --config shell_environment_policy.inherit=all --config 'model="gpt-5.5"' --config model_reasoning_effort=xhigh app-server approval_policy: never thread_sandbox: workspace-write turn_sandbox_policy: type: workspaceWrite + networkAccess: true --- You are working on a Linear ticket `{{ issue.identifier }}` {% if attempt %} -Continuation context: +Follow-up context: -- This is retry attempt #{{ attempt }} because the ticket is still in an active state. +- This is follow-up attempt #{{ attempt }}. It may be a normal continuation or a retry after a failure. - 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. +- Do not end the turn while the work item remains in an active state unless you are blocked by missing required access. {% endif %} Issue context: @@ -63,15 +66,15 @@ No description provided. 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. +1. This is an unattended orchestration session. Do not ask a human to perform follow-up actions. +2. Only stop early for a true external blocker (missing required tools, auth, permissions, or secrets). If blocked, record it in the workpad and move the issue according to the 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. +The agent should be able to talk to Linear, either via a configured Linear MCP server or injected `linear_graphql` tool. If neither is present, treat that as blocked access: record it in the workpad and move the issue according to the workflow instead of asking a user to configure Linear. ## Default posture diff --git a/elixir/config/config.exs b/elixir/config/config.exs index 11744f6602..a5a526c86f 100644 --- a/elixir/config/config.exs +++ b/elixir/config/config.exs @@ -14,3 +14,8 @@ config :symphony_elixir, SymphonyElixirWeb.Endpoint, secret_key_base: String.duplicate("s", 64), check_origin: false, server: false + +if config_env() == :test do + config :symphony_elixir, + workflow_file_path: Path.expand("../test/fixtures/startup_workflow.md", __DIR__) +end diff --git a/elixir/lib/mix/tasks/workspace.before_remove.ex b/elixir/lib/mix/tasks/workspace.before_remove.ex index d6cd27a885..8489f1de12 100644 --- a/elixir/lib/mix/tasks/workspace.before_remove.ex +++ b/elixir/lib/mix/tasks/workspace.before_remove.ex @@ -106,7 +106,7 @@ defmodule Mix.Tasks.Workspace.BeforeRemove do end defp closing_comment(branch) do - "Closing because the Linear issue for branch #{branch} entered a terminal state without merge." + "Closing because the tracker issue for branch #{branch} entered a terminal state without merge." end defp format_output(""), do: "" diff --git a/elixir/lib/symphony_elixir.ex b/elixir/lib/symphony_elixir.ex index 18561af83f..a08b63e624 100644 --- a/elixir/lib/symphony_elixir.ex +++ b/elixir/lib/symphony_elixir.ex @@ -4,11 +4,11 @@ defmodule SymphonyElixir do """ @doc """ - Start the orchestrator in the current BEAM node. + Start the agent runtime in the current BEAM node. """ - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - SymphonyElixir.Orchestrator.start_link(opts) + @spec start_link() :: Supervisor.on_start() + def start_link do + SymphonyElixir.AgentRuntimeSupervisor.start_link([]) end end @@ -19,15 +19,26 @@ defmodule SymphonyElixir.Application do use Application + @dialyzer {:nowarn_function, start_burrito_cli: 0} + @impl true def start(_type, _args) do + if burrito_runtime?() do + start_burrito_cli() + else + start_runtime() + end + end + + @doc false + @spec start_runtime() :: Supervisor.on_start() + def start_runtime do :ok = SymphonyElixir.LogFile.configure() children = [ {Phoenix.PubSub, name: SymphonyElixir.PubSub}, - {Task.Supervisor, name: SymphonyElixir.TaskSupervisor}, SymphonyElixir.WorkflowStore, - SymphonyElixir.Orchestrator, + SymphonyElixir.AgentRuntimeSupervisor, SymphonyElixir.HttpServer, SymphonyElixir.StatusDashboard ] @@ -44,4 +55,17 @@ defmodule SymphonyElixir.Application do SymphonyElixir.StatusDashboard.render_offline_status() :ok end + + defp start_burrito_cli do + Task.start_link(fn -> + SymphonyElixir.CLI.main( + plain_arguments(), + &start_runtime/0 + ) + end) + end + + defp burrito_runtime?, do: System.get_env("__BURRITO") == "1" + + defp plain_arguments, do: Enum.map(:init.get_plain_arguments(), &to_string/1) end diff --git a/elixir/lib/symphony_elixir/agent_runner.ex b/elixir/lib/symphony_elixir/agent_runner.ex index 7292a4bc27..f99a2d9755 100644 --- a/elixir/lib/symphony_elixir/agent_runner.ex +++ b/elixir/lib/symphony_elixir/agent_runner.ex @@ -1,34 +1,57 @@ defmodule SymphonyElixir.AgentRunner do @moduledoc """ - Executes a single Linear issue in an isolated workspace with Codex. + Executes a single tracker work item in its workspace with Codex. """ require Logger alias SymphonyElixir.Codex.AppServer - alias SymphonyElixir.{Config, Linear.Issue, PromptBuilder, Tracker, Workspace} + alias SymphonyElixir.{Config, PromptBuilder, Tracker, Workspace} + alias SymphonyElixir.Tracker.Issue + + @type worker_host :: String.t() | nil + + @doc false + @spec continue_with_issue_for_test(Issue.t(), ([String.t()] -> term())) :: + {:continue, Issue.t()} | {:done, Issue.t()} | {:error, term()} + def continue_with_issue_for_test(%Issue{} = issue, issue_state_fetcher) + when is_function(issue_state_fetcher, 1) do + continue_with_issue?(issue, issue_state_fetcher) + end @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)}") + # The orchestrator owns host retries so one worker lifetime never hops machines. + worker_host = selected_worker_host(Keyword.get(opts, :worker_host), Config.settings!().worker.ssh_hosts) + + Logger.info("Starting agent run for #{issue_context(issue)} worker_host=#{worker_host_for_log(worker_host)}") + + case run_on_worker_host(issue, codex_update_recipient, opts, worker_host) do + :ok -> + :ok + + {: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 - case Workspace.create_for_issue(issue) do + defp run_on_worker_host(issue, codex_update_recipient, opts, worker_host) do + Logger.info("Starting worker attempt for #{issue_context(issue)} worker_host=#{worker_host_for_log(worker_host)}") + + case Workspace.create_for_issue(issue, worker_host) do {:ok, workspace} -> + send_worker_runtime_info(codex_update_recipient, issue, worker_host, 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)}" + with :ok <- Workspace.run_before_run_hook(workspace, issue, worker_host) do + run_codex_turns(workspace, issue, codex_update_recipient, opts, worker_host) end after - Workspace.run_after_run_hook(workspace, issue) + Workspace.run_after_run_hook(workspace, issue, worker_host) 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)}" + {:error, reason} end end @@ -46,11 +69,27 @@ defmodule SymphonyElixir.AgentRunner do 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) + defp send_worker_runtime_info(recipient, %Issue{id: issue_id}, worker_host, workspace) + when is_binary(issue_id) and is_pid(recipient) and is_binary(workspace) do + send( + recipient, + {:worker_runtime_info, issue_id, + %{ + worker_host: worker_host, + workspace_path: workspace + }} + ) + + :ok + end - with {:ok, session} <- AppServer.start_session(workspace) do + defp send_worker_runtime_info(_recipient, _issue, _worker_host, _workspace), do: :ok + + defp run_codex_turns(workspace, issue, codex_update_recipient, opts, worker_host) do + max_turns = Keyword.get(opts, :max_turns, Config.settings!().agent.max_turns) + issue_state_fetcher = Keyword.get(opts, :issue_state_fetcher, &Tracker.fetch_issues_by_ids/1) + + with {:ok, session} <- AppServer.start_session(workspace, worker_host: worker_host) do try do do_run_codex_turns(session, workspace, issue, codex_update_recipient, opts, issue_state_fetcher, 1, max_turns) after @@ -106,7 +145,7 @@ defmodule SymphonyElixir.AgentRunner do """ Continuation guidance: - - The previous Codex turn completed normally, but the Linear issue is still in an active state. + - The previous Codex turn completed normally, but the tracker work item 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. @@ -117,7 +156,7 @@ defmodule SymphonyElixir.AgentRunner do 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 + if active_issue_state?(refreshed_issue.state) and issue_routable?(refreshed_issue) do {:continue, refreshed_issue} else {:done, refreshed_issue} @@ -136,12 +175,35 @@ defmodule SymphonyElixir.AgentRunner do defp active_issue_state?(state_name) when is_binary(state_name) do normalized_state = normalize_issue_state(state_name) - Config.linear_active_states() + Config.settings!().tracker.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 issue_routable?(%Issue{} = issue) do + Issue.routable?(issue, Config.settings!().tracker.required_labels) + end + + defp selected_worker_host(nil, []), do: nil + + defp selected_worker_host(preferred_host, configured_hosts) when is_list(configured_hosts) do + hosts = + configured_hosts + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + + case preferred_host do + host when is_binary(host) and host != "" -> host + _ when hosts == [] -> nil + _ -> List.first(hosts) + end + end + + defp worker_host_for_log(nil), do: "local" + defp worker_host_for_log(worker_host), do: worker_host + defp normalize_issue_state(state_name) when is_binary(state_name) do state_name |> String.trim() diff --git a/elixir/lib/symphony_elixir/agent_runtime_supervisor.ex b/elixir/lib/symphony_elixir/agent_runtime_supervisor.ex new file mode 100644 index 0000000000..b03ede8725 --- /dev/null +++ b/elixir/lib/symphony_elixir/agent_runtime_supervisor.ex @@ -0,0 +1,34 @@ +defmodule SymphonyElixir.AgentRuntimeSupervisor do + @moduledoc """ + Supervises the scheduler authority together with its agent tasks. + """ + + use Supervisor + + @spec start_link(keyword()) :: Supervisor.on_start() + def start_link(opts) do + name = Keyword.get(opts, :name, __MODULE__) + Supervisor.start_link(__MODULE__, opts, name: name) + end + + @impl true + def init(opts) do + task_supervisor_name = + Keyword.get(opts, :task_supervisor_name, SymphonyElixir.TaskSupervisor) + + orchestrator_name = Keyword.get(opts, :orchestrator_name, SymphonyElixir.Orchestrator) + + children = [ + Supervisor.child_spec( + {Task.Supervisor, name: task_supervisor_name}, + id: task_supervisor_name + ), + Supervisor.child_spec( + {SymphonyElixir.Orchestrator, name: orchestrator_name, task_supervisor: task_supervisor_name}, + id: orchestrator_name + ) + ] + + Supervisor.init(children, strategy: :one_for_all) + end +end diff --git a/elixir/lib/symphony_elixir/asana/adapter.ex b/elixir/lib/symphony_elixir/asana/adapter.ex new file mode 100644 index 0000000000..473e65eda0 --- /dev/null +++ b/elixir/lib/symphony_elixir/asana/adapter.ex @@ -0,0 +1,50 @@ +defmodule SymphonyElixir.Asana.Adapter do + @moduledoc """ + Asana-backed tracker adapter. + """ + + @behaviour SymphonyElixir.Tracker + + alias SymphonyElixir.Asana.{AgentTool, Client} + alias SymphonyElixir.Tracker.Issue + + @spec validate_config(map()) :: :ok | {:error, term()} + def validate_config(tracker_settings) do + with :ok <- validate_states(tracker_settings.active_states, :missing_asana_active_states), + :ok <- validate_states(tracker_settings.terminal_states, :missing_asana_terminal_states) do + Client.validate_settings(tracker_settings) + end + end + + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states(states), do: client_module().fetch_issues_by_states(states) + + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(ids), do: client_module().fetch_issues_by_ids(ids) + + @spec agent_tool_specs() :: [map()] + def agent_tool_specs, do: AgentTool.tool_specs() + + @spec execute_agent_tool(String.t(), term(), keyword()) :: map() + def execute_agent_tool(tool, arguments, opts), do: AgentTool.execute(tool, arguments, opts) + + @spec secret_environment_names(map()) :: [String.t()] + def secret_environment_names(tracker_settings), do: Client.secret_environment_names(tracker_settings) + + defp client_module do + Application.get_env(:symphony_elixir, :asana_client_module, Client) + end + + defp validate_states(states, _missing_error) when is_list(states) do + if Enum.all?(states, &present_string?/1) do + :ok + else + {:error, :invalid_asana_states} + end + end + + defp validate_states(_states, missing_error), do: {:error, missing_error} + + defp present_string?(value) when is_binary(value), do: String.trim(value) != "" + defp present_string?(_value), do: false +end diff --git a/elixir/lib/symphony_elixir/asana/agent_tool.ex b/elixir/lib/symphony_elixir/asana/agent_tool.ex new file mode 100644 index 0000000000..84c24141bc --- /dev/null +++ b/elixir/lib/symphony_elixir/asana/agent_tool.ex @@ -0,0 +1,174 @@ +defmodule SymphonyElixir.Asana.AgentTool do + @moduledoc """ + Provider-native Asana REST tool exposed to Codex app-server turns. + """ + + alias SymphonyElixir.Asana.Client + + @asana_api_tool "asana_api" + @allowed_methods ["GET", "POST", "PUT", "DELETE"] + @asana_api_description """ + Execute an Asana REST API request using Symphony's configured auth. + """ + @asana_api_input_schema %{ + "type" => "object", + "additionalProperties" => false, + "required" => ["method", "path"], + "properties" => %{ + "method" => %{ + "type" => "string", + "enum" => @allowed_methods, + "description" => "Asana REST method." + }, + "path" => %{ + "type" => "string", + "description" => "Asana REST path such as /tasks/{task_gid}/stories." + }, + "query" => %{ + "type" => ["object", "null"], + "description" => "Optional query parameters.", + "additionalProperties" => true + }, + "body" => %{ + "description" => "Optional JSON request body." + } + } + } + + @spec execute(String.t() | nil, term(), keyword()) :: map() + def execute(tool, arguments, opts) do + case tool do + @asana_api_tool -> execute_asana_api(arguments, opts) + other -> unsupported_tool_response(other) + end + end + + @spec tool_specs() :: [map()] + def tool_specs do + [ + %{ + "name" => @asana_api_tool, + "description" => @asana_api_description, + "inputSchema" => @asana_api_input_schema + } + ] + end + + defp execute_asana_api(arguments, opts) do + asana_client = Keyword.get(opts, :asana_client, &Client.request/5) + client_opts = Keyword.take(opts, [:tracker_settings]) + + with {:ok, method, path, query, body} <- normalize_arguments(arguments), + {:ok, %{status: status, body: response_body}} <- + asana_client.(method, path, query, body, client_opts), + true <- is_integer(status) do + rest_response(status, response_body) + else + {:error, reason} -> failure_response(tool_error_payload(reason)) + _ -> failure_response(tool_error_payload(:asana_unknown_payload)) + end + end + + defp normalize_arguments(arguments) when is_map(arguments) do + with {:ok, method} <- normalize_method(Map.get(arguments, "method")), + {:ok, path} <- normalize_path(Map.get(arguments, "path")), + {:ok, query} <- normalize_query(Map.get(arguments, "query")) do + {:ok, method, path, query, Map.get(arguments, "body")} + end + end + + defp normalize_arguments(_arguments), do: {:error, :invalid_arguments} + + defp normalize_method(method) when is_binary(method) do + normalized = method |> String.trim() |> String.upcase() + if normalized in @allowed_methods, do: {:ok, normalized}, else: {:error, :invalid_method} + end + + defp normalize_method(_method), do: {:error, :invalid_method} + + defp normalize_path(path) when is_binary(path) do + trimmed = String.trim(path) + + if String.starts_with?(trimmed, "/") and + not String.contains?(trimmed, ["://", "\n", "\r", <<0>>]) do + {:ok, trimmed} + else + {:error, :invalid_path} + end + end + + defp normalize_path(_path), do: {:error, :invalid_path} + + defp normalize_query(nil), do: {:ok, %{}} + defp normalize_query(query) when is_map(query), do: {:ok, query} + defp normalize_query(_query), do: {:error, :invalid_query} + + defp rest_response(status, body) do + dynamic_tool_response(status in 200..299, encode_payload(%{"status" => status, "body" => body})) + end + + defp failure_response(payload), do: dynamic_tool_response(false, encode_payload(payload)) + + defp dynamic_tool_response(success, output) do + %{ + "success" => success, + "output" => output, + "contentItems" => [%{"type" => "inputText", "text" => output}] + } + end + + defp encode_payload(payload) do + case Jason.encode(payload, pretty: true) do + {:ok, output} -> output + {:error, _reason} -> inspect(payload) + end + end + + defp unsupported_tool_response(tool) do + failure_response(%{ + "error" => %{ + "message" => "Unsupported dynamic tool: #{inspect(tool)}.", + "supportedTools" => supported_tool_names() + } + }) + end + + defp tool_error_payload(:invalid_arguments) do + %{"error" => %{"message" => "asana_api expects an object with method and path."}} + end + + defp tool_error_payload(:invalid_method) do + %{"error" => %{"message" => "asana_api.method must be GET, POST, PUT, or DELETE."}} + end + + defp tool_error_payload(:invalid_path) do + %{"error" => %{"message" => "asana_api.path must be a relative Asana REST path."}} + end + + defp tool_error_payload(:invalid_query) do + %{"error" => %{"message" => "asana_api.query must be a JSON object when provided."}} + end + + defp tool_error_payload(:missing_asana_api_key) do + %{ + "error" => %{ + "message" => "Symphony is missing Asana auth. Set tracker.provider.api_key or export ASANA_PAT." + } + } + end + + defp tool_error_payload({:asana_api_request, reason}) do + %{ + "error" => %{ + "message" => "Asana API request failed before receiving a successful response.", + "reason" => inspect(reason) + } + } + end + + defp tool_error_payload(reason) do + %{"error" => %{"message" => "Asana REST tool execution failed.", "reason" => inspect(reason)}} + end + + defp supported_tool_names, do: Enum.map(tool_specs(), & &1["name"]) +end diff --git a/elixir/lib/symphony_elixir/asana/client.ex b/elixir/lib/symphony_elixir/asana/client.ex new file mode 100644 index 0000000000..37f72b6015 --- /dev/null +++ b/elixir/lib/symphony_elixir/asana/client.ex @@ -0,0 +1,416 @@ +defmodule SymphonyElixir.Asana.Client do + @moduledoc """ + Thin Asana REST client for project-scoped task polling. + """ + + require Logger + alias SymphonyElixir.Config + alias SymphonyElixir.Tracker.Issue + + @default_endpoint "https://app.asana.com/api/1.0" + @page_size 100 + @task_fields [ + "gid", + "name", + "notes", + "completed", + "resource_subtype", + "assignee.gid", + "tags.name", + "memberships.project.gid", + "memberships.section.gid", + "memberships.section.name", + "permalink_url", + "created_at", + "modified_at" + ] + @task_fields_query Enum.join(@task_fields, ",") + + @spec validate_settings(map()) :: :ok | {:error, term()} + def validate_settings(tracker_settings) do + with {:ok, _settings} <- settings(tracker_settings), do: :ok + end + + @spec secret_environment_names(map()) :: [String.t()] + def secret_environment_names(tracker_settings) do + provider = provider_settings(tracker_settings) + + ["ASANA_PAT" | env_reference_names([provider["api_key"]])] + |> Enum.uniq() + end + + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states(states) when is_list(states) do + fetch_issues_by_states(states, Config.settings!().tracker, &perform_request/5) + end + + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(ids) when is_list(ids) do + fetch_issues_by_ids(ids, Config.settings!().tracker, &perform_request/5) + end + + @spec request(String.t(), String.t(), map(), term(), keyword()) :: + {:ok, %{status: integer(), body: term()}} | {:error, term()} + def request(method, path, query, body, opts \\ []) + when is_binary(method) and is_binary(path) and is_map(query) and is_list(opts) do + tracker_settings = Keyword.get_lazy(opts, :tracker_settings, fn -> Config.settings!().tracker end) + request_fun = Keyword.get(opts, :request_fun, &perform_request/5) + + with {:ok, asana_settings} <- settings(tracker_settings) do + request_fun.(method, path, query, body, asana_settings) + end + end + + @doc false + @spec normalize_issue_for_test(map(), map()) :: Issue.t() | nil + def normalize_issue_for_test(task, tracker_settings) + when is_map(task) and is_map(tracker_settings) do + case settings(tracker_settings) do + {:ok, asana_settings} -> normalize_issue(task, asana_settings) + _ -> nil + end + end + + @doc false + @spec fetch_issues_by_states_for_test([String.t()], map(), function()) :: + {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states_for_test(states, tracker_settings, request_fun) + when is_list(states) and is_map(tracker_settings) and is_function(request_fun, 5) do + fetch_issues_by_states(states, tracker_settings, request_fun) + end + + @doc false + @spec fetch_issues_by_ids_for_test([String.t()], map(), function()) :: + {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids_for_test(ids, tracker_settings, request_fun) + when is_list(ids) and is_map(tracker_settings) and is_function(request_fun, 5) do + fetch_issues_by_ids(ids, tracker_settings, request_fun) + end + + defp fetch_issues_by_states([], _tracker_settings, _request_fun), do: {:ok, []} + + defp fetch_issues_by_states(states, tracker_settings, request_fun) do + with {:ok, asana_settings} <- settings(tracker_settings) do + fetch_task_pages(states, asana_settings, nil, request_fun, []) + end + end + + defp fetch_task_pages(states, settings, offset, request_fun, pages) do + query = + %{ + "limit" => @page_size, + "opt_fields" => @task_fields_query + } + |> maybe_put("offset", offset) + + with {:ok, payload} <- + request_with_settings( + "GET", + "/projects/#{encoded(settings.project_gid)}/tasks", + query, + nil, + settings, + request_fun, + false + ), + {:ok, raw_tasks, next_offset} <- task_page(payload) do + issues = normalize_candidate_page(raw_tasks, settings, states) + updated_pages = [issues | pages] + + case next_offset do + :done -> {:ok, updated_pages |> Enum.reverse() |> List.flatten()} + next -> fetch_task_pages(states, settings, next, request_fun, updated_pages) + end + end + end + + defp fetch_issues_by_ids([], _tracker_settings, _request_fun), do: {:ok, []} + + defp fetch_issues_by_ids(ids, tracker_settings, request_fun) do + with {:ok, asana_settings} <- settings(tracker_settings) do + ids + |> Enum.uniq() + |> fetch_task_ids(asana_settings, request_fun, []) + end + end + + defp fetch_task_ids([], _settings, _request_fun, issues), do: {:ok, Enum.reverse(issues)} + + defp fetch_task_ids([id | rest], settings, request_fun, issues) do + with {:ok, payload} <- + request_with_settings( + "GET", + "/tasks/#{encoded(id)}", + %{"opt_fields" => @task_fields_query}, + nil, + settings, + request_fun, + true + ) do + continue_task_id_fetch(payload, rest, settings, request_fun, issues) + end + end + + defp continue_task_id_fetch(:not_found, rest, settings, request_fun, issues) do + fetch_task_ids(rest, settings, request_fun, issues) + end + + defp continue_task_id_fetch(%{"data" => raw_task}, rest, settings, request_fun, issues) + when is_map(raw_task) do + if task_outside_project?(raw_task, settings.project_gid) do + fetch_task_ids(rest, settings, request_fun, issues) + else + case normalize_issue(raw_task, settings) do + %Issue{} = issue -> fetch_task_ids(rest, settings, request_fun, [issue | issues]) + nil -> {:error, :asana_unknown_payload} + end + end + end + + defp continue_task_id_fetch(_payload, _rest, _settings, _request_fun, _issues) do + {:error, :asana_unknown_payload} + end + + defp task_page(%{"data" => tasks, "next_page" => nil}) when is_list(tasks) do + {:ok, tasks, :done} + end + + defp task_page(%{"data" => tasks, "next_page" => %{"offset" => offset}}) + when is_list(tasks) and is_binary(offset) and offset != "" do + {:ok, tasks, offset} + end + + defp task_page(%{"data" => tasks, "next_page" => next_page}) + when is_list(tasks) and is_map(next_page) do + {:error, :asana_missing_next_page_offset} + end + + defp task_page(_payload), do: {:error, :asana_unknown_payload} + + defp normalize_candidate_page(raw_tasks, settings, states) do + requested_states = states |> Enum.map(&normalize_state/1) |> MapSet.new() + issues = Enum.map(raw_tasks, &normalize_issue(&1, settings)) + malformed_count = Enum.count(issues, &is_nil/1) + + if malformed_count > 0 do + Logger.warning("Dropping malformed Asana task records count=#{malformed_count}") + end + + issues + |> Enum.reject(&is_nil/1) + |> Enum.filter(&MapSet.member?(requested_states, normalize_state(&1.state))) + end + + defp normalize_issue(%{"gid" => gid, "name" => name} = task, settings) + when is_binary(gid) and is_binary(name) do + membership = project_membership(task, settings.project_gid) + state = get_in(membership, ["section", "name"]) + + if present_string?(gid) and present_string?(name) and present_string?(state) do + %Issue{ + id: gid, + native_ref: native_ref(gid, settings.project_gid, membership), + identifier: "ASANA-#{gid}", + title: name, + description: blank_to_nil(task["notes"]), + priority: nil, + state: state, + branch_name: nil, + url: task["permalink_url"], + assignee_id: get_in(task, ["assignee", "gid"]), + labels: extract_labels(task["tags"]), + blocked_by: [], + dispatchable: task["completed"] == false and task["resource_subtype"] != "section", + created_at: parse_datetime(task["created_at"]), + updated_at: parse_datetime(task["modified_at"]) + } + end + end + + defp normalize_issue(_task, _settings), do: nil + + defp project_membership(%{"memberships" => memberships}, project_gid) when is_list(memberships) do + Enum.find(memberships, &(get_in(&1, ["project", "gid"]) == project_gid)) + end + + defp project_membership(_task, _project_gid), do: nil + + defp task_outside_project?(%{"gid" => gid, "name" => name, "memberships" => memberships}, project_gid) + when is_binary(gid) and is_binary(name) and is_list(memberships) do + present_string?(gid) and present_string?(name) and is_nil(project_membership(%{"memberships" => memberships}, project_gid)) + end + + defp task_outside_project?(_task, _project_gid), do: false + + defp native_ref(task_gid, project_gid, membership) do + %{ + "task_gid" => task_gid, + "project_gid" => project_gid, + "section_gid" => get_in(membership, ["section", "gid"]) + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + end + + defp extract_labels(tags) when is_list(tags) do + tags + |> Enum.flat_map(fn + %{"name" => name} when is_binary(name) -> [name] + _ -> [] + end) + |> Enum.map(&(String.trim(&1) |> String.downcase())) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp extract_labels(_tags), do: [] + + defp blank_to_nil(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + text -> text + end + end + + defp blank_to_nil(_value), do: nil + + defp parse_datetime(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> datetime + _ -> nil + end + end + + defp parse_datetime(_value), do: nil + + defp request_with_settings(method, path, query, body, settings, request_fun, allow_not_found) do + case request_fun.(method, path, query, body, settings) do + {:ok, %{status: status, body: payload}} when status in 200..299 -> + {:ok, payload} + + {:ok, %{status: 404}} when allow_not_found -> + {:ok, :not_found} + + {:ok, %{status: status}} when is_integer(status) -> + Logger.error("Asana API request failed status=#{status} method=#{method} path=#{path}") + {:error, {:asana_api_status, status}} + + {:error, reason} -> + {:error, reason} + + _ -> + {:error, :asana_unknown_payload} + end + end + + defp perform_request(method, path, query, body, settings) do + with {:ok, request_method} <- request_method(method) do + request_opts = [ + method: request_method, + url: settings.endpoint <> path, + headers: asana_headers(settings.api_key), + params: query, + connect_options: [timeout: 30_000] + ] + + request_opts = if is_nil(body), do: request_opts, else: Keyword.put(request_opts, :json, body) + + case Req.request(request_opts) do + {:ok, response} -> {:ok, %{status: response.status, body: response.body}} + {:error, reason} -> {:error, {:asana_api_request, reason}} + end + end + end + + defp settings(tracker_settings) when is_map(tracker_settings) do + provider = provider_settings(tracker_settings) + endpoint = provider["endpoint"] || @default_endpoint + api_key = resolve_setting(provider["api_key"], System.get_env("ASANA_PAT")) + project_gid = resolve_setting(provider["project_gid"], nil) + + cond do + not valid_endpoint?(endpoint) -> + {:error, :invalid_asana_endpoint} + + not present_string?(api_key) -> + {:error, :missing_asana_api_key} + + not present_string?(project_gid) -> + {:error, :missing_asana_project_gid} + + true -> + {:ok, + %{ + endpoint: String.trim_trailing(endpoint, "/"), + api_key: api_key, + project_gid: project_gid + }} + end + end + + defp provider_settings(%{provider: provider}) when is_map(provider), do: provider + defp provider_settings(_tracker_settings), do: %{} + + defp resolve_setting(nil, fallback), do: normalize_string(fallback) + + defp resolve_setting("$" <> env_name, fallback) do + if valid_env_name?(env_name) do + normalize_string(System.get_env(env_name) || fallback) + else + nil + end + end + + defp resolve_setting(value, _fallback), do: normalize_string(value) + + defp normalize_string(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_string(_value), do: nil + + defp env_reference_names(values) do + Enum.flat_map(values, fn + "$" <> env_name when is_binary(env_name) -> if valid_env_name?(env_name), do: [env_name], else: [] + _ -> [] + end) + end + + defp valid_env_name?(name), do: String.match?(name, ~r/^[A-Za-z_][A-Za-z0-9_]*$/) + + defp valid_endpoint?(value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: "https", host: host} when is_binary(host) -> true + _ -> false + end + end + + defp valid_endpoint?(_value), do: false + + defp asana_headers(api_key) do + [ + {"Accept", "application/json"}, + {"Authorization", "Bearer #{api_key}"} + ] + end + + defp encoded(value), do: URI.encode(value, &URI.char_unreserved?/1) + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp request_method("GET"), do: {:ok, :get} + defp request_method("POST"), do: {:ok, :post} + defp request_method("PUT"), do: {:ok, :put} + defp request_method("DELETE"), do: {:ok, :delete} + defp request_method(_method), do: {:error, :invalid_asana_method} + + defp normalize_state(value) when is_binary(value), do: value |> String.trim() |> String.downcase() + defp normalize_state(_value), do: "" + + defp present_string?(value) when is_binary(value), do: String.trim(value) != "" + defp present_string?(_value), do: false +end diff --git a/elixir/lib/symphony_elixir/cli.ex b/elixir/lib/symphony_elixir/cli.ex index d5c7eb1833..63f62b9cd6 100644 --- a/elixir/lib/symphony_elixir/cli.ex +++ b/elixir/lib/symphony_elixir/cli.ex @@ -19,7 +19,13 @@ defmodule SymphonyElixir.CLI do @spec main([String.t()]) :: no_return() def main(args) do - case evaluate(args) do + main(args, fn -> Application.ensure_all_started(:symphony_elixir) end) + end + + @doc false + @spec main([String.t()], (-> ensure_started_result())) :: no_return() + def main(args, ensure_all_started) do + case evaluate(args, runtime_deps(ensure_all_started)) do :ok -> wait_for_shutdown() @@ -76,13 +82,13 @@ defmodule SymphonyElixir.CLI do end @spec runtime_deps() :: deps() - defp runtime_deps do + defp runtime_deps(ensure_all_started \\ fn -> Application.ensure_all_started(:symphony_elixir) end) 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 + ensure_all_started: ensure_all_started } end diff --git a/elixir/lib/symphony_elixir/codex/app_server.ex b/elixir/lib/symphony_elixir/codex/app_server.ex index e824c63b8d..ae3db622f3 100644 --- a/elixir/lib/symphony_elixir/codex/app_server.ex +++ b/elixir/lib/symphony_elixir/codex/app_server.ex @@ -4,15 +4,13 @@ defmodule SymphonyElixir.Codex.AppServer do """ require Logger - alias SymphonyElixir.{Codex.DynamicTool, Config} + alias SymphonyElixir.{Codex.DynamicTool, Config, PathSafety, SSH} @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(), @@ -21,12 +19,14 @@ defmodule SymphonyElixir.Codex.AppServer do thread_sandbox: String.t(), turn_sandbox_policy: map(), thread_id: String.t(), - workspace: Path.t() + workspace: Path.t(), + worker_host: String.t() | nil, + dynamic_tool_binding: map() } @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 + with {:ok, session} <- start_session(workspace, opts) do try do run_turn(session, prompt, issue, opts) after @@ -35,15 +35,18 @@ defmodule SymphonyElixir.Codex.AppServer do 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) + @spec start_session(Path.t(), keyword()) :: {:ok, session()} | {:error, term()} + def start_session(workspace, opts \\ []) do + worker_host = Keyword.get(opts, :worker_host) + dynamic_tool_binding = DynamicTool.bind() + + with {:ok, expanded_workspace} <- validate_workspace_cwd(workspace, worker_host), + {:ok, port} <- start_port(expanded_workspace, worker_host, dynamic_tool_binding) do + metadata = port_metadata(port, worker_host) - with {:ok, session_policies} <- session_policies(expanded_workspace), - {:ok, thread_id} <- do_start_session(port, expanded_workspace, session_policies) do + with {:ok, session_policies} <- session_policies(expanded_workspace, worker_host), + {:ok, thread_id} <- + do_start_session(port, expanded_workspace, session_policies, dynamic_tool_binding) do {:ok, %{ port: port, @@ -53,7 +56,9 @@ defmodule SymphonyElixir.Codex.AppServer do thread_sandbox: session_policies.thread_sandbox, turn_sandbox_policy: session_policies.turn_sandbox_policy, thread_id: thread_id, - workspace: expanded_workspace + workspace: expanded_workspace, + worker_host: worker_host, + dynamic_tool_binding: dynamic_tool_binding }} else {:error, reason} -> @@ -72,7 +77,8 @@ defmodule SymphonyElixir.Codex.AppServer do auto_approve_requests: auto_approve_requests, turn_sandbox_policy: turn_sandbox_policy, thread_id: thread_id, - workspace: workspace + workspace: workspace, + dynamic_tool_binding: dynamic_tool_binding }, prompt, issue, @@ -82,7 +88,7 @@ defmodule SymphonyElixir.Codex.AppServer do tool_executor = Keyword.get(opts, :tool_executor, fn tool, arguments -> - DynamicTool.execute(tool, arguments) + DynamicTool.execute(tool, arguments, dynamic_tool_binding, issue: issue) end) case start_turn(port, thread_id, prompt, issue, workspace, approval_policy, turn_sandbox_policy) do @@ -141,25 +147,49 @@ defmodule SymphonyElixir.Codex.AppServer 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()) + defp validate_workspace_cwd(workspace, nil) when is_binary(workspace) do + expanded_workspace = Path.expand(workspace) + expanded_root = Config.local_workspace_root() + expanded_root_prefix = expanded_root <> "/" + + with {:ok, canonical_workspace} <- PathSafety.canonicalize(expanded_workspace), + {:ok, canonical_root} <- PathSafety.canonicalize(expanded_root) do + canonical_root_prefix = canonical_root <> "/" + + cond do + canonical_workspace == canonical_root -> + {:error, {:invalid_workspace_cwd, :workspace_root, canonical_workspace}} + + String.starts_with?(canonical_workspace <> "/", canonical_root_prefix) -> + {:ok, canonical_workspace} - root_prefix = workspace_root <> "/" + String.starts_with?(expanded_workspace <> "/", expanded_root_prefix) -> + {:error, {:invalid_workspace_cwd, :symlink_escape, expanded_workspace, canonical_root}} + + true -> + {:error, {:invalid_workspace_cwd, :outside_workspace_root, canonical_workspace, canonical_root}} + end + else + {:error, {:path_canonicalize_failed, path, reason}} -> + {:error, {:invalid_workspace_cwd, :path_unreadable, path, reason}} + end + end + defp validate_workspace_cwd(workspace, worker_host) + when is_binary(workspace) and is_binary(worker_host) do cond do - workspace_path == workspace_root -> - {:error, {:invalid_workspace_cwd, :workspace_root, workspace_path}} + String.trim(workspace) == "" -> + {:error, {:invalid_workspace_cwd, :empty_remote_workspace, worker_host}} - not String.starts_with?(workspace_path <> "/", root_prefix) -> - {:error, {:invalid_workspace_cwd, :outside_workspace_root, workspace_path, workspace_root}} + String.contains?(workspace, ["\n", "\r", <<0>>]) -> + {:error, {:invalid_workspace_cwd, :invalid_remote_workspace, worker_host, workspace}} true -> - :ok + {:ok, workspace} end end - defp start_port(workspace) do + defp start_port(workspace, nil, dynamic_tool_binding) do executable = System.find_executable("bash") if is_nil(executable) do @@ -172,8 +202,9 @@ defmodule SymphonyElixir.Codex.AppServer do :binary, :exit_status, :stderr_to_stdout, - args: [~c"-lc", String.to_charlist(Config.codex_command())], + args: [~c"-lc", String.to_charlist(local_launch_command(dynamic_tool_binding))], cd: String.to_charlist(workspace), + env: tracker_secret_port_env(dynamic_tool_binding), line: @port_line_bytes ] ) @@ -182,13 +213,62 @@ defmodule SymphonyElixir.Codex.AppServer do 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)} + defp start_port(workspace, worker_host, dynamic_tool_binding) when is_binary(worker_host) do + remote_command = remote_launch_command(workspace, dynamic_tool_binding) + SSH.start_port(worker_host, remote_command, line: @port_line_bytes) + end - _ -> - %{} + defp local_launch_command(dynamic_tool_binding) do + [ + tracker_secret_unset_command(dynamic_tool_binding), + "exec #{Config.settings!().codex.command}" + ] + |> Enum.reject(&is_nil/1) + |> Enum.join(" && ") + end + + defp remote_launch_command(workspace, dynamic_tool_binding) when is_binary(workspace) do + [ + "cd #{shell_escape(workspace)}", + tracker_secret_unset_command(dynamic_tool_binding), + "exec #{Config.settings!().codex.command}" + ] + |> Enum.reject(&is_nil/1) + |> Enum.join(" && ") + end + + defp tracker_secret_port_env(dynamic_tool_binding) do + dynamic_tool_binding.secret_environment_names + |> valid_environment_names() + |> Enum.map(fn name -> {String.to_charlist(name), false} end) + end + + defp tracker_secret_unset_command(dynamic_tool_binding) do + case dynamic_tool_binding.secret_environment_names |> valid_environment_names() do + [] -> nil + names -> "unset " <> Enum.join(names, " ") + end + end + + defp valid_environment_names(names) do + Enum.filter(names, fn name -> + is_binary(name) and String.match?(name, ~r/^[A-Za-z_][A-Za-z0-9_]*$/) + end) + end + + defp port_metadata(port, worker_host) when is_port(port) do + base_metadata = + case :erlang.port_info(port, :os_pid) do + {:os_pid, os_pid} -> + %{codex_app_server_pid: to_string(os_pid)} + + _ -> + %{} + end + + case worker_host do + host when is_binary(host) -> Map.put(base_metadata, :worker_host, host) + _ -> base_metadata end end @@ -216,26 +296,35 @@ defmodule SymphonyElixir.Codex.AppServer do end end - defp session_policies(workspace) do + defp session_policies(workspace, nil) do Config.codex_runtime_settings(workspace) end - defp do_start_session(port, workspace, session_policies) do + defp session_policies(workspace, worker_host) when is_binary(worker_host) do + Config.codex_runtime_settings(workspace, remote: true) + end + + defp do_start_session(port, workspace, session_policies, dynamic_tool_binding) do case send_initialize(port) do - :ok -> start_thread(port, workspace, session_policies) + :ok -> start_thread(port, workspace, session_policies, dynamic_tool_binding) {:error, reason} -> {:error, reason} end end - defp start_thread(port, workspace, %{approval_policy: approval_policy, thread_sandbox: thread_sandbox}) do + defp start_thread( + port, + workspace, + %{approval_policy: approval_policy, thread_sandbox: thread_sandbox}, + dynamic_tool_binding + ) 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() + "cwd" => workspace, + "dynamicTools" => dynamic_tool_binding.tool_specs } }) @@ -263,7 +352,7 @@ defmodule SymphonyElixir.Codex.AppServer do "text" => prompt } ], - "cwd" => Path.expand(workspace), + "cwd" => workspace, "title" => "#{issue.identifier}: #{issue.title}", "approvalPolicy" => approval_policy, "sandboxPolicy" => turn_sandbox_policy @@ -277,7 +366,14 @@ defmodule SymphonyElixir.Codex.AppServer do 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) + receive_loop( + port, + on_message, + Config.settings!().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 @@ -365,15 +461,17 @@ defmodule SymphonyElixir.Codex.AppServer do {: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}) - ) + if protocol_message_candidate?(payload_string) do + emit_message( + on_message, + :malformed, + %{ + payload: payload_string, + raw: payload_string + }, + metadata_from_message(port, %{raw: payload_string}) + ) + end receive_loop(port, on_message, timeout_ms, "", tool_executor, auto_approve_requests) end @@ -499,7 +597,10 @@ defmodule SymphonyElixir.Codex.AppServer do tool_name = tool_call_name(params) arguments = tool_call_arguments(params) - result = tool_executor.(tool_name, arguments) + result = + tool_name + |> tool_executor.(arguments) + |> normalize_dynamic_tool_result() send_message(port, %{ "id" => id, @@ -619,6 +720,44 @@ defmodule SymphonyElixir.Codex.AppServer do :unhandled end + defp normalize_dynamic_tool_result(%{"success" => success} = result) when is_boolean(success) do + output = + case Map.get(result, "output") do + existing_output when is_binary(existing_output) -> existing_output + _ -> dynamic_tool_output(result) + end + + content_items = + case Map.get(result, "contentItems") do + existing_items when is_list(existing_items) -> existing_items + _ -> dynamic_tool_content_items(output) + end + + result + |> Map.put("output", output) + |> Map.put("contentItems", content_items) + end + + defp normalize_dynamic_tool_result(result) do + %{ + "success" => false, + "output" => inspect(result), + "contentItems" => dynamic_tool_content_items(inspect(result)) + } + end + + defp dynamic_tool_output(%{"contentItems" => [%{"text" => text} | _]}) when is_binary(text), do: text + defp dynamic_tool_output(result), do: Jason.encode!(result, pretty: true) + + defp dynamic_tool_content_items(output) when is_binary(output) do + [ + %{ + "type" => "inputText", + "text" => output + } + ] + end + defp approve_or_require( port, id, @@ -678,38 +817,21 @@ defmodule SymphonyElixir.Codex.AppServer do :approved :error -> - reply_with_non_interactive_tool_input_answer( - port, - id, - params, - payload, - payload_string, - on_message, - metadata - ) + :input_required end end defp maybe_auto_answer_tool_request_user_input( - port, - id, - params, - payload, - payload_string, - on_message, - metadata, + _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 + ), + do: :input_required defp tool_request_user_input_approval_answers(%{"questions" => questions}) when is_list(questions) do answers = @@ -732,64 +854,15 @@ defmodule SymphonyElixir.Codex.AppServer do 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} + if String.starts_with?(question_id, "mcp_tool_call_approval_") do + case tool_request_user_input_approval_option_label(options) do + nil -> :error + answer_label -> {:ok, question_id, answer_label} + end + else + :error end end @@ -820,7 +893,7 @@ defmodule SymphonyElixir.Codex.AppServer do end defp await_response(port, request_id) do - with_timeout_response(port, request_id, Config.codex_read_timeout_ms(), "") + with_timeout_response(port, request_id, Config.settings!().codex.read_timeout_ms, "") end defp with_timeout_response(port, request_id, timeout_ms, pending_line) do @@ -879,6 +952,13 @@ defmodule SymphonyElixir.Codex.AppServer do end end + defp protocol_message_candidate?(data) do + data + |> to_string() + |> String.trim_leading() + |> String.starts_with?("{") + end + defp issue_context(%{id: issue_id, identifier: identifier}) do "issue_id=#{issue_id} issue_identifier=#{identifier}" end @@ -905,7 +985,7 @@ defmodule SymphonyElixir.Codex.AppServer do end defp metadata_from_message(port, payload) do - port |> port_metadata() |> maybe_set_usage(payload) + port |> port_metadata(nil) |> maybe_set_usage(payload) end defp maybe_set_usage(metadata, payload) when is_map(payload) do @@ -920,6 +1000,10 @@ defmodule SymphonyElixir.Codex.AppServer do defp maybe_set_usage(metadata, _payload), do: metadata + defp shell_escape(value) when is_binary(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end + defp default_on_message(_message), do: :ok defp tool_call_name(params) when is_map(params) do @@ -948,6 +1032,8 @@ defmodule SymphonyElixir.Codex.AppServer do Port.command(port, line) end + defp needs_input?("mcpServer/elicitation/request", payload) when is_map(payload), do: true + defp needs_input?(method, payload) when is_binary(method) and is_map(payload) do String.starts_with?(method, "turn/") && input_required_method?(method, payload) diff --git a/elixir/lib/symphony_elixir/codex/dynamic_tool.ex b/elixir/lib/symphony_elixir/codex/dynamic_tool.ex index 716d360705..505c9cbe46 100644 --- a/elixir/lib/symphony_elixir/codex/dynamic_tool.ex +++ b/elixir/lib/symphony_elixir/codex/dynamic_tool.ex @@ -1,212 +1,17 @@ defmodule SymphonyElixir.Codex.DynamicTool do @moduledoc """ - Executes client-side tool calls requested by Codex app-server turns. + Dispatches client-side tool calls to the configured tracker adapter. """ - alias SymphonyElixir.Linear.Client + alias SymphonyElixir.Tracker - @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) - } - } + @spec execute(String.t() | nil, term(), map(), keyword()) :: map() + def execute(tool, arguments, binding, opts \\ []) do + Tracker.execute_bound_agent_tool(binding, tool, arguments, opts) end - defp supported_tool_names do - Enum.map(tool_specs(), & &1["name"]) + @spec bind() :: map() + def bind do + Tracker.bind_agent_tools() end end diff --git a/elixir/lib/symphony_elixir/config.ex b/elixir/lib/symphony_elixir/config.ex index 3a9f0d997a..ae4cb0b2da 100644 --- a/elixir/lib/symphony_elixir/config.ex +++ b/elixir/lib/symphony_elixir/config.ex @@ -3,14 +3,11 @@ defmodule SymphonyElixir.Config do Runtime configuration loaded from `WORKFLOW.md`. """ - alias NimbleOptions - alias SymphonyElixir.Workflow + alias SymphonyElixir.{Config.Schema, Tracker} + alias SymphonyElixir.{Workflow, WorkflowStore} - @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. + You are working on an issue from the configured tracker. Identifier: {{ issue.identifier }} Title: {{ issue.title }} @@ -22,306 +19,56 @@ defmodule SymphonyElixir.Config do 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]) + @spec settings() :: {:ok, Schema.t()} | {:error, term()} + def settings do + WorkflowStore.settings() end - @spec poll_interval_ms() :: pos_integer() - def poll_interval_ms do - get_in(validated_workflow_options(), [:polling, :interval_ms]) - end + @spec settings!() :: Schema.t() + def settings! do + case settings() do + {:ok, settings} -> + settings - @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]) + {:error, reason} -> + raise ArgumentError, message: format_config_error(reason) + end 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() + config = settings!() - @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 + Map.get( + config.agent.max_concurrent_agents_by_state, + Schema.normalize_issue_state(state_name), + config.agent.max_concurrent_agents + ) 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 + def max_concurrent_agents_for_state(_state_name), do: settings!().agent.max_concurrent_agents @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 + case Schema.resolve_runtime_turn_sandbox_policy(settings!(), workspace) do + {:ok, policy} -> + policy - @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) + {:error, reason} -> + raise ArgumentError, message: "Invalid codex turn sandbox policy: #{inspect(reason)}" + end end @spec workflow_prompt() :: String.t() def workflow_prompt do - case current_workflow() do + case Workflow.current() do {:ok, %{prompt_template: prompt}} -> if String.trim(prompt) == "", do: @default_prompt_template, else: prompt @@ -330,609 +77,68 @@ defmodule SymphonyElixir.Config do 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]) + port when is_integer(port) and port >= 0 -> port + _ -> settings!().server.port end end - @spec server_host() :: String.t() - def server_host do - get_in(validated_workflow_options(), [:server, :host]) + @doc false + @spec local_workspace_root() :: Path.t() + def local_workspace_root do + workflow_dir = Workflow.workflow_file_path() |> Path.expand() |> Path.dirname() + Path.expand(settings!().workspace.root, workflow_dir) 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 + WorkflowStore.force_reload() + end + + @spec codex_runtime_settings(Path.t() | nil, keyword()) :: + {:ok, codex_runtime_settings()} | {:error, term()} + def codex_runtime_settings(workspace \\ nil, opts \\ []) do + with {:ok, settings} <- settings() do + with {:ok, turn_sandbox_policy} <- + Schema.resolve_runtime_turn_sandbox_policy(settings, workspace, opts) do + {:ok, + %{ + approval_policy: settings.codex.approval_policy, + thread_sandbox: settings.codex.thread_sandbox, + turn_sandbox_policy: turn_sandbox_policy + }} 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 + @doc false + @spec validate_settings(Schema.t()) :: :ok | {:error, term()} + def validate_settings(settings) do + if is_nil(settings.tracker.kind) do + {:error, :missing_tracker_kind} + else + Tracker.validate_config(settings.tracker) end end - defp resolve_env_value(_value, fallback), do: fallback + defp format_config_error(reason) do + case reason do + {:invalid_workflow_config, message} -> + "Invalid WORKFLOW.md config: #{message}" - defp normalize_path_token(value) when is_binary(value) do - trimmed = String.trim(value) + {:missing_workflow_file, path, raw_reason} -> + "Missing WORKFLOW.md at #{path}: #{inspect(raw_reason)}" - case env_reference_name(trimmed) do - {:ok, env_name} -> resolve_env_token(env_name) - :error -> trimmed - end - end + {:workflow_parse_error, raw_reason} -> + "Failed to parse WORKFLOW.md: #{inspect(raw_reason)}" - 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 + :workflow_front_matter_not_a_map -> + "Failed to parse WORKFLOW.md: workflow front matter must decode to a map" - 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 + other -> + "Invalid WORKFLOW.md config: #{inspect(other)}" 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/config/schema.ex b/elixir/lib/symphony_elixir/config/schema.ex new file mode 100644 index 0000000000..6ebb278fac --- /dev/null +++ b/elixir/lib/symphony_elixir/config/schema.ex @@ -0,0 +1,645 @@ +defmodule SymphonyElixir.Config.Schema do + @moduledoc false + + use Ecto.Schema + + import Ecto.Changeset + + alias SymphonyElixir.PathSafety + + @primary_key false + @linear_endpoint "https://api.linear.app/graphql" + @linear_active_states ["Todo", "In Progress"] + @linear_terminal_states ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] + + @type t :: %__MODULE__{} + + defmodule StringOrMap do + @moduledoc false + @behaviour Ecto.Type + + @spec type() :: :map + def type, do: :map + + @spec embed_as(term()) :: :self + def embed_as(_format), do: :self + + @spec equal?(term(), term()) :: boolean() + def equal?(left, right), do: left == right + + @spec cast(term()) :: {:ok, String.t() | map()} | :error + def cast(value) when is_binary(value) or is_map(value), do: {:ok, value} + def cast(_value), do: :error + + @spec load(term()) :: {:ok, String.t() | map()} | :error + def load(value) when is_binary(value) or is_map(value), do: {:ok, value} + def load(_value), do: :error + + @spec dump(term()) :: {:ok, String.t() | map()} | :error + def dump(value) when is_binary(value) or is_map(value), do: {:ok, value} + def dump(_value), do: :error + end + + defmodule Tracker do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:kind, :string) + field(:endpoint, :string) + field(:api_key, :string) + field(:project_slug, :string) + field(:assignee, :string) + field(:provider, :map, default: %{}) + field(:secret_environment_names, {:array, :string}, default: []) + field(:required_labels, {:array, :string}, default: []) + field(:active_states, {:array, :string}) + field(:terminal_states, {:array, :string}) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast( + attrs, + [ + :kind, + :endpoint, + :api_key, + :project_slug, + :assignee, + :provider, + :required_labels, + :active_states, + :terminal_states + ], + empty_values: [] + ) + |> update_change(:required_labels, fn labels -> + labels + |> Enum.map(&(String.trim(&1) |> String.downcase())) + |> Enum.uniq() + end) + end + end + + defmodule Polling do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:interval_ms, :integer, default: 30_000) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast(attrs, [:interval_ms], empty_values: []) + |> validate_number(:interval_ms, greater_than: 0) + end + end + + defmodule Workspace do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:root, :string, default: Path.join(System.tmp_dir!(), "symphony_workspaces")) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast(attrs, [:root], empty_values: []) + end + end + + defmodule Worker do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:ssh_hosts, {:array, :string}, default: []) + field(:max_concurrent_agents_per_host, :integer) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast(attrs, [:ssh_hosts, :max_concurrent_agents_per_host], empty_values: []) + |> validate_number(:max_concurrent_agents_per_host, greater_than: 0) + end + end + + defmodule Agent do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + alias SymphonyElixir.Config.Schema + + @primary_key false + embedded_schema do + field(:max_concurrent_agents, :integer, default: 10) + field(:max_turns, :integer, default: 20) + field(:max_retry_backoff_ms, :integer, default: 300_000) + field(:max_concurrent_agents_by_state, :map, default: %{}) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast( + attrs, + [:max_concurrent_agents, :max_turns, :max_retry_backoff_ms, :max_concurrent_agents_by_state], + empty_values: [] + ) + |> validate_number(:max_concurrent_agents, greater_than: 0) + |> validate_number(:max_turns, greater_than: 0) + |> validate_number(:max_retry_backoff_ms, greater_than: 0) + |> update_change(:max_concurrent_agents_by_state, &Schema.normalize_state_limits/1) + |> Schema.validate_state_limits(:max_concurrent_agents_by_state) + end + end + + defmodule Codex do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:command, :string, default: "codex app-server") + + field(:approval_policy, StringOrMap, + default: %{ + "reject" => %{ + "sandbox_approval" => true, + "rules" => true, + "mcp_elicitations" => true + } + } + ) + + field(:thread_sandbox, :string, default: "workspace-write") + field(:turn_sandbox_policy, :map) + field(:turn_timeout_ms, :integer, default: 3_600_000) + field(:read_timeout_ms, :integer, default: 5_000) + field(:stall_timeout_ms, :integer, default: 300_000) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast( + attrs, + [ + :command, + :approval_policy, + :thread_sandbox, + :turn_sandbox_policy, + :turn_timeout_ms, + :read_timeout_ms, + :stall_timeout_ms + ], + empty_values: [] + ) + |> validate_required([:command]) + |> validate_change(:command, fn :command, command -> + if command != "" and String.trim(command) == "" do + [command: "can't be blank"] + else + [] + end + end) + |> validate_number(:turn_timeout_ms, greater_than: 0) + |> validate_number(:read_timeout_ms, greater_than: 0) + |> validate_number(:stall_timeout_ms, greater_than_or_equal_to: 0) + end + end + + defmodule Hooks do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:after_create, :string) + field(:before_run, :string) + field(:after_run, :string) + field(:before_remove, :string) + field(:timeout_ms, :integer, default: 60_000) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast(attrs, [:after_create, :before_run, :after_run, :before_remove, :timeout_ms], empty_values: []) + |> validate_number(:timeout_ms, greater_than: 0) + end + end + + defmodule Observability do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:dashboard_enabled, :boolean, default: true) + field(:refresh_ms, :integer, default: 1_000) + field(:render_interval_ms, :integer, default: 16) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast(attrs, [:dashboard_enabled, :refresh_ms, :render_interval_ms], empty_values: []) + |> validate_number(:refresh_ms, greater_than: 0) + |> validate_number(:render_interval_ms, greater_than: 0) + end + end + + defmodule Server do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:port, :integer) + field(:host, :string, default: "127.0.0.1") + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(schema, attrs) do + schema + |> cast(attrs, [:port, :host], empty_values: []) + |> validate_number(:port, greater_than_or_equal_to: 0) + end + end + + embedded_schema do + embeds_one(:tracker, Tracker, on_replace: :update, defaults_to_struct: true) + embeds_one(:polling, Polling, on_replace: :update, defaults_to_struct: true) + embeds_one(:workspace, Workspace, on_replace: :update, defaults_to_struct: true) + embeds_one(:worker, Worker, on_replace: :update, defaults_to_struct: true) + embeds_one(:agent, Agent, on_replace: :update, defaults_to_struct: true) + embeds_one(:codex, Codex, on_replace: :update, defaults_to_struct: true) + embeds_one(:hooks, Hooks, on_replace: :update, defaults_to_struct: true) + embeds_one(:observability, Observability, on_replace: :update, defaults_to_struct: true) + embeds_one(:server, Server, on_replace: :update, defaults_to_struct: true) + end + + @spec parse(map()) :: {:ok, %__MODULE__{}} | {:error, {:invalid_workflow_config, String.t()}} + def parse(config) when is_map(config) do + config + |> normalize_keys() + |> drop_nil_values() + |> changeset() + |> apply_action(:validate) + |> case do + {:ok, settings} -> + {:ok, finalize_settings(settings)} + + {:error, changeset} -> + {:error, {:invalid_workflow_config, format_errors(changeset)}} + end + end + + @spec resolve_turn_sandbox_policy(%__MODULE__{}, Path.t() | nil) :: map() + def resolve_turn_sandbox_policy(settings, workspace \\ nil) do + case settings.codex.turn_sandbox_policy do + %{} = policy -> + policy + + _ -> + workspace + |> default_workspace_root(settings.workspace.root) + |> expand_local_workspace_root() + |> default_turn_sandbox_policy() + end + end + + @spec resolve_runtime_turn_sandbox_policy(%__MODULE__{}, Path.t() | nil, keyword()) :: + {:ok, map()} | {:error, term()} + def resolve_runtime_turn_sandbox_policy(settings, workspace \\ nil, opts \\ []) do + case settings.codex.turn_sandbox_policy do + %{} = policy -> + {:ok, policy} + + _ -> + workspace + |> default_workspace_root(settings.workspace.root) + |> default_runtime_turn_sandbox_policy(opts) + end + end + + @spec normalize_issue_state(String.t()) :: String.t() + def normalize_issue_state(state_name) when is_binary(state_name) do + state_name + |> String.trim() + |> String.downcase() + end + + @doc false + @spec normalize_state_limits(nil | map()) :: map() + def normalize_state_limits(nil), do: %{} + + def normalize_state_limits(limits) when is_map(limits) do + Enum.reduce(limits, %{}, fn {state_name, limit}, acc -> + Map.put(acc, normalize_issue_state(to_string(state_name)), limit) + end) + end + + @doc false + @spec validate_state_limits(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t() + def validate_state_limits(changeset, field) do + validate_change(changeset, field, fn ^field, limits -> + Enum.flat_map(limits, fn {state_name, limit} -> + cond do + state_name |> to_string() |> String.trim() == "" -> + [{field, "state names must not be blank"}] + + not is_integer(limit) or limit <= 0 -> + [{field, "limits must be positive integers"}] + + true -> + [] + end + end) + end) + end + + defp changeset(attrs) do + %__MODULE__{} + |> cast(attrs, []) + |> cast_embed(:tracker, with: &Tracker.changeset/2) + |> cast_embed(:polling, with: &Polling.changeset/2) + |> cast_embed(:workspace, with: &Workspace.changeset/2) + |> cast_embed(:worker, with: &Worker.changeset/2) + |> cast_embed(:agent, with: &Agent.changeset/2) + |> cast_embed(:codex, with: &Codex.changeset/2) + |> cast_embed(:hooks, with: &Hooks.changeset/2) + |> cast_embed(:observability, with: &Observability.changeset/2) + |> cast_embed(:server, with: &Server.changeset/2) + end + + defp finalize_settings(settings) do + provider = normalize_optional_map(settings.tracker.provider) || %{} + + {api_key, assignee, provider, secret_environment_names} = + case settings.tracker.kind do + "linear" -> + linear_provider = + provider + |> Map.put_new("endpoint", settings.tracker.endpoint || @linear_endpoint) + |> Map.put_new("api_key", settings.tracker.api_key) + |> Map.put_new("project_slug", settings.tracker.project_slug) + |> Map.put_new("assignee", settings.tracker.assignee) + + resolved_api_key = + resolve_secret_setting(linear_provider["api_key"], System.get_env("LINEAR_API_KEY")) + + resolved_assignee = + resolve_secret_setting(linear_provider["assignee"], System.get_env("LINEAR_ASSIGNEE")) + + { + resolved_api_key, + resolved_assignee, + linear_provider, + ["LINEAR_API_KEY" | env_reference_names([linear_provider["api_key"]])] + } + + _ -> + {settings.tracker.api_key, settings.tracker.assignee, provider, []} + end + + {active_states, terminal_states} = + case settings.tracker.kind do + kind when kind in ["linear", "memory"] -> + { + settings.tracker.active_states || @linear_active_states, + settings.tracker.terminal_states || @linear_terminal_states + } + + _ -> + {settings.tracker.active_states, settings.tracker.terminal_states} + end + + tracker = %{ + settings.tracker + | endpoint: Map.get(provider, "endpoint", settings.tracker.endpoint), + api_key: api_key, + project_slug: Map.get(provider, "project_slug", settings.tracker.project_slug), + assignee: assignee, + provider: provider, + secret_environment_names: Enum.uniq(secret_environment_names), + active_states: active_states, + terminal_states: terminal_states + } + + workspace = %{ + settings.workspace + | root: resolve_path_value(settings.workspace.root, Path.join(System.tmp_dir!(), "symphony_workspaces")) + } + + codex = %{ + settings.codex + | approval_policy: normalize_keys(settings.codex.approval_policy), + turn_sandbox_policy: normalize_optional_map(settings.codex.turn_sandbox_policy) + } + + %{settings | tracker: tracker, workspace: workspace, codex: codex} + end + + 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_optional_map(nil), do: nil + defp normalize_optional_map(value) when is_map(value), do: normalize_keys(value) + + defp normalize_key(value) when is_atom(value), do: Atom.to_string(value) + defp normalize_key(value), do: to_string(value) + + defp drop_nil_values(value) when is_map(value) do + Enum.reduce(value, %{}, fn {key, nested}, acc -> + case drop_nil_values(nested) do + nil -> acc + normalized -> Map.put(acc, key, normalized) + end + end) + end + + defp drop_nil_values(value) when is_list(value), do: Enum.map(value, &drop_nil_values/1) + defp drop_nil_values(value), do: value + + defp resolve_secret_setting(nil, fallback), do: normalize_secret_value(fallback) + + defp resolve_secret_setting(value, fallback) when is_binary(value) do + case resolve_env_value(value, fallback) do + resolved when is_binary(resolved) -> normalize_secret_value(resolved) + resolved -> resolved + end + end + + defp resolve_secret_setting(value, _fallback), do: value + + defp resolve_path_value(value, default) when is_binary(value) do + case normalize_path_token(value) do + :missing -> + default + + "" -> + default + + path -> + path + end + end + + defp resolve_env_value(value, fallback) when is_binary(value) do + case env_reference_name(value) do + {:ok, env_name} -> + case System.get_env(env_name) do + nil -> fallback + "" -> nil + env_value -> env_value + end + + :error -> + value + end + end + + defp normalize_path_token(value) when is_binary(value) do + case env_reference_name(value) do + {:ok, env_name} -> resolve_env_token(env_name) + :error -> value + 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 env_reference_names(values) when is_list(values) do + Enum.flat_map(values, fn value -> + case env_reference_name(value) do + {:ok, env_name} -> [env_name] + :error -> [] + end + end) + end + + defp resolve_env_token(env_name) do + case System.get_env(env_name) do + nil -> :missing + env_value -> env_value + end + end + + defp normalize_secret_value(value) when is_binary(value) do + if value == "", do: nil, else: value + end + + defp normalize_secret_value(_value), do: nil + + defp default_turn_sandbox_policy(workspace) do + %{ + "type" => "workspaceWrite", + "writableRoots" => [workspace], + "readOnlyAccess" => %{"type" => "fullAccess"}, + "networkAccess" => false, + "excludeTmpdirEnvVar" => false, + "excludeSlashTmp" => false + } + end + + defp default_runtime_turn_sandbox_policy(workspace_root, opts) when is_binary(workspace_root) do + if Keyword.get(opts, :remote, false) do + {:ok, default_turn_sandbox_policy(workspace_root)} + else + with expanded_workspace_root <- expand_local_workspace_root(workspace_root), + {:ok, canonical_workspace_root} <- PathSafety.canonicalize(expanded_workspace_root) do + {:ok, default_turn_sandbox_policy(canonical_workspace_root)} + end + end + end + + defp default_runtime_turn_sandbox_policy(workspace_root, _opts) do + {:error, {:unsafe_turn_sandbox_policy, {:invalid_workspace_root, workspace_root}}} + end + + defp default_workspace_root(workspace, _fallback) when is_binary(workspace) and workspace != "", + do: workspace + + defp default_workspace_root(nil, fallback), do: fallback + defp default_workspace_root("", fallback), do: fallback + defp default_workspace_root(workspace, _fallback), do: workspace + + defp expand_local_workspace_root(workspace_root) + when is_binary(workspace_root) and workspace_root != "" do + Path.expand(workspace_root) + end + + defp expand_local_workspace_root(_workspace_root) do + Path.expand(Path.join(System.tmp_dir!(), "symphony_workspaces")) + end + + defp format_errors(changeset) do + changeset + |> traverse_errors(&translate_error/1) + |> flatten_errors() + |> Enum.join(", ") + end + + defp flatten_errors(errors, prefix \\ nil) + + defp flatten_errors(errors, prefix) when is_map(errors) do + Enum.flat_map(errors, fn {key, value} -> + next_prefix = + case prefix do + nil -> to_string(key) + current -> current <> "." <> to_string(key) + end + + flatten_errors(value, next_prefix) + end) + end + + defp flatten_errors(errors, prefix) when is_list(errors) do + Enum.map(errors, &(prefix <> " " <> &1)) + end + + defp translate_error({message, options}) do + Enum.reduce(options, message, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", error_value_to_string(value)) + end) + end + + defp error_value_to_string(value) when is_atom(value), do: Atom.to_string(value) + defp error_value_to_string(value), do: inspect(value) +end diff --git a/elixir/lib/symphony_elixir/github/adapter.ex b/elixir/lib/symphony_elixir/github/adapter.ex new file mode 100644 index 0000000000..5a770167ee --- /dev/null +++ b/elixir/lib/symphony_elixir/github/adapter.ex @@ -0,0 +1,63 @@ +defmodule SymphonyElixir.GitHub.Adapter do + @moduledoc """ + GitHub Issues-backed tracker adapter. + """ + + @behaviour SymphonyElixir.Tracker + + alias SymphonyElixir.GitHub.{AgentTool, Client} + alias SymphonyElixir.Tracker.Issue + + @active_states ["open"] + @terminal_states ["closed"] + + @spec validate_config(map()) :: :ok | {:error, term()} + def validate_config(tracker_settings) do + with :ok <- + validate_states( + tracker_settings.active_states, + @active_states, + :missing_github_active_states + ), + :ok <- + validate_states( + tracker_settings.terminal_states, + @terminal_states, + :missing_github_terminal_states + ) do + Client.validate_settings(tracker_settings) + end + end + + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states(states), do: client_module().fetch_issues_by_states(states) + + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(issue_ids), do: client_module().fetch_issues_by_ids(issue_ids) + + @spec agent_tool_specs() :: [map()] + def agent_tool_specs, do: AgentTool.tool_specs() + + @spec execute_agent_tool(String.t(), term(), keyword()) :: map() + def execute_agent_tool(tool, arguments, opts), do: AgentTool.execute(tool, arguments, opts) + + @spec secret_environment_names(map()) :: [String.t()] + def secret_environment_names(tracker_settings), do: Client.secret_environment_names(tracker_settings) + + defp client_module do + Application.get_env(:symphony_elixir, :github_client_module, Client) + end + + defp validate_states(states, allowed_states, _missing_error) when is_list(states) do + if Enum.all?(states, &(normalize_state(&1) in allowed_states)) do + :ok + else + {:error, :invalid_github_states} + end + end + + defp validate_states(_states, _allowed_states, missing_error), do: {:error, missing_error} + + defp normalize_state(state) when is_binary(state), do: state |> String.trim() |> String.downcase() + defp normalize_state(_state), do: "" +end diff --git a/elixir/lib/symphony_elixir/github/agent_tool.ex b/elixir/lib/symphony_elixir/github/agent_tool.ex new file mode 100644 index 0000000000..9b42aa767f --- /dev/null +++ b/elixir/lib/symphony_elixir/github/agent_tool.ex @@ -0,0 +1,173 @@ +defmodule SymphonyElixir.GitHub.AgentTool do + @moduledoc """ + Provider-native GitHub REST tool exposed to Codex app-server turns. + """ + + alias SymphonyElixir.GitHub.Client + + @github_api_tool "github_api" + @allowed_methods ["GET", "POST", "PATCH", "PUT", "DELETE"] + @github_api_description """ + Execute a GitHub REST API request using Symphony's configured auth. + """ + @github_api_input_schema %{ + "type" => "object", + "additionalProperties" => false, + "required" => ["method", "path"], + "properties" => %{ + "method" => %{ + "type" => "string", + "enum" => @allowed_methods, + "description" => "GitHub REST method." + }, + "path" => %{ + "type" => "string", + "description" => "GitHub REST path such as /repos/owner/repo/issues/1/comments." + }, + "params" => %{ + "type" => ["object", "null"], + "description" => "Optional query parameters.", + "additionalProperties" => true + }, + "body" => %{ + "description" => "Optional JSON request body." + } + } + } + + @spec execute(String.t() | nil, term(), keyword()) :: map() + def execute(tool, arguments, opts) do + case tool do + @github_api_tool -> execute_github_api(arguments, opts) + other -> unsupported_tool_response(other) + end + end + + @spec tool_specs() :: [map()] + def tool_specs do + [ + %{ + "name" => @github_api_tool, + "description" => @github_api_description, + "inputSchema" => @github_api_input_schema + } + ] + end + + defp execute_github_api(arguments, opts) do + github_client = Keyword.get(opts, :github_client, &Client.request/5) + client_opts = Keyword.take(opts, [:tracker_settings]) + + with {:ok, method, path, params, body} <- normalize_arguments(arguments), + {:ok, %{status: status, body: response_body}} <- + github_client.(method, path, params, body, client_opts), + true <- is_integer(status) do + rest_response(status, response_body) + else + {:error, reason} -> failure_response(tool_error_payload(reason)) + _ -> failure_response(tool_error_payload(:github_unknown_payload)) + end + end + + defp normalize_arguments(arguments) when is_map(arguments) do + with {:ok, method} <- normalize_method(Map.get(arguments, "method")), + {:ok, path} <- normalize_path(Map.get(arguments, "path")), + {:ok, params} <- normalize_params(Map.get(arguments, "params")) do + {:ok, method, path, params, Map.get(arguments, "body")} + end + end + + defp normalize_arguments(_arguments), do: {:error, :invalid_arguments} + + defp normalize_method(method) when is_binary(method) do + normalized = method |> String.trim() |> String.upcase() + if normalized in @allowed_methods, do: {:ok, normalized}, else: {:error, :invalid_method} + end + + defp normalize_method(_method), do: {:error, :invalid_method} + + defp normalize_path(path) when is_binary(path) do + trimmed = String.trim(path) + + if String.starts_with?(trimmed, "/") and not String.contains?(trimmed, ["://", "\n", "\r", <<0>>]) do + {:ok, trimmed} + else + {:error, :invalid_path} + end + end + + defp normalize_path(_path), do: {:error, :invalid_path} + + defp normalize_params(nil), do: {:ok, %{}} + defp normalize_params(params) when is_map(params), do: {:ok, params} + defp normalize_params(_params), do: {:error, :invalid_params} + + defp rest_response(status, body) do + dynamic_tool_response(status in 200..299, encode_payload(%{"status" => status, "body" => body})) + end + + defp failure_response(payload), do: dynamic_tool_response(false, encode_payload(payload)) + + defp dynamic_tool_response(success, output) do + %{ + "success" => success, + "output" => output, + "contentItems" => [%{"type" => "inputText", "text" => output}] + } + end + + defp encode_payload(payload) do + case Jason.encode(payload, pretty: true) do + {:ok, output} -> output + {:error, _reason} -> inspect(payload) + end + end + + defp unsupported_tool_response(tool) do + failure_response(%{ + "error" => %{ + "message" => "Unsupported dynamic tool: #{inspect(tool)}.", + "supportedTools" => supported_tool_names() + } + }) + end + + defp tool_error_payload(:invalid_arguments) do + %{"error" => %{"message" => "`github_api` expects an object with `method` and `path`."}} + end + + defp tool_error_payload(:invalid_method) do + %{"error" => %{"message" => "`github_api.method` must be GET, POST, PATCH, PUT, or DELETE."}} + end + + defp tool_error_payload(:invalid_path) do + %{"error" => %{"message" => "`github_api.path` must be a relative GitHub REST path."}} + end + + defp tool_error_payload(:invalid_params) do + %{"error" => %{"message" => "`github_api.params` must be a JSON object when provided."}} + end + + defp tool_error_payload(:missing_github_token) do + %{ + "error" => %{ + "message" => "Symphony is missing GitHub auth. Set `tracker.provider.token` in `WORKFLOW.md` or export `GITHUB_TOKEN`." + } + } + end + + defp tool_error_payload({:github_api_request, reason}) do + %{ + "error" => %{ + "message" => "GitHub API request failed before receiving a successful response.", + "reason" => inspect(reason) + } + } + end + + defp tool_error_payload(reason) do + %{"error" => %{"message" => "GitHub API tool execution failed.", "reason" => inspect(reason)}} + end + + defp supported_tool_names, do: Enum.map(tool_specs(), & &1["name"]) +end diff --git a/elixir/lib/symphony_elixir/github/client.ex b/elixir/lib/symphony_elixir/github/client.ex new file mode 100644 index 0000000000..6e3f170fbd --- /dev/null +++ b/elixir/lib/symphony_elixir/github/client.ex @@ -0,0 +1,392 @@ +defmodule SymphonyElixir.GitHub.Client do + @moduledoc """ + Thin GitHub REST client for repository issue polling. + """ + + require Logger + alias SymphonyElixir.Config + alias SymphonyElixir.Tracker.Issue + + @default_api_url "https://api.github.com" + @api_version "2022-11-28" + @page_size 100 + @user_agent "symphony" + + @spec validate_settings(map()) :: :ok | {:error, term()} + def validate_settings(tracker_settings) do + with {:ok, _settings} <- settings(tracker_settings), do: :ok + end + + @spec secret_environment_names(map()) :: [String.t()] + def secret_environment_names(tracker_settings) do + provider = provider_settings(tracker_settings) + + ["GITHUB_TOKEN" | env_reference_names([provider["token"]])] + |> Enum.uniq() + 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 + fetch_issues_by_states(state_names, Config.settings!().tracker, &perform_request/5) + end + + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(issue_ids) when is_list(issue_ids) do + fetch_issues_by_ids(issue_ids, Config.settings!().tracker, &perform_request/5) + end + + @spec request(String.t(), String.t(), map(), term(), keyword()) :: + {:ok, %{status: integer(), body: term()}} | {:error, term()} + def request(method, path, params, body, opts \\ []) + when is_binary(method) and is_binary(path) and is_map(params) and is_list(opts) do + tracker_settings = Keyword.get_lazy(opts, :tracker_settings, fn -> Config.settings!().tracker end) + request_fun = Keyword.get(opts, :request_fun, &perform_request/5) + + with {:ok, github_settings} <- settings(tracker_settings) do + request_fun.(method, path, params, body, github_settings) + end + end + + @doc false + @spec normalize_issue_for_test(map(), String.t()) :: Issue.t() | nil + def normalize_issue_for_test(issue, repo) when is_map(issue) and is_binary(repo) do + normalize_issue(issue, repo) + end + + @doc false + @spec fetch_issues_by_states_for_test([String.t()], map(), function()) :: + {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states_for_test(state_names, tracker_settings, request_fun) + when is_list(state_names) and is_map(tracker_settings) and is_function(request_fun, 5) do + fetch_issues_by_states(state_names, tracker_settings, request_fun) + end + + @doc false + @spec fetch_issues_by_ids_for_test([String.t()], map(), function()) :: + {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids_for_test(issue_ids, tracker_settings, request_fun) + when is_list(issue_ids) and is_map(tracker_settings) and is_function(request_fun, 5) do + fetch_issues_by_ids(issue_ids, tracker_settings, request_fun) + end + + defp fetch_issues_by_states(state_names, tracker_settings, request_fun) do + normalized_states = state_names |> Enum.map(&normalize_state/1) |> MapSet.new() + + case github_state_query(normalized_states) do + nil -> + {:ok, []} + + state_query -> + with {:ok, github_settings} <- settings(tracker_settings) do + do_fetch_pages(github_settings, state_query, normalized_states, 1, request_fun, []) + end + end + end + + defp fetch_issues_by_ids(issue_ids, tracker_settings, request_fun) do + ids = Enum.uniq(issue_ids) + + case ids do + [] -> + {:ok, []} + + ids -> + with {:ok, github_settings} <- settings(tracker_settings) do + fetch_issue_ids(ids, github_settings, request_fun, []) + end + end + end + + defp do_fetch_pages(settings, state_query, requested_states, page, request_fun, acc) do + params = %{ + "state" => state_query, + "per_page" => @page_size, + "page" => page, + "sort" => "created", + "direction" => "asc" + } + + with {:ok, payload} <- + request_with_settings( + "GET", + repository_issues_path(settings), + params, + nil, + settings, + request_fun, + false + ), + true <- is_list(payload) or {:error, :github_unknown_payload} do + issues = normalize_state_page(payload, settings.repo, requested_states) + updated_acc = [issues | acc] + + if length(payload) < @page_size do + {:ok, updated_acc |> Enum.reverse() |> List.flatten()} + else + do_fetch_pages(settings, state_query, requested_states, page + 1, request_fun, updated_acc) + end + end + end + + defp fetch_issue_ids([], _settings, _request_fun, acc), do: {:ok, Enum.reverse(acc)} + + defp fetch_issue_ids([id | rest], settings, request_fun, acc) do + with {:ok, issue_number} <- parse_issue_number(id), + {:ok, payload} <- + request_with_settings( + "GET", + repository_issue_path(settings, issue_number), + %{}, + nil, + settings, + request_fun, + true + ) do + continue_issue_id_fetch(payload, rest, settings, request_fun, acc) + end + end + + defp continue_issue_id_fetch(:not_found, rest, settings, request_fun, acc) do + fetch_issue_ids(rest, settings, request_fun, acc) + end + + defp continue_issue_id_fetch(%{} = raw_issue, rest, settings, request_fun, acc) do + case normalize_issue(raw_issue, settings.repo) do + %Issue{} = issue -> fetch_issue_ids(rest, settings, request_fun, [issue | acc]) + nil -> {:error, :github_unknown_payload} + end + end + + defp continue_issue_id_fetch(_payload, _rest, _settings, _request_fun, _acc) do + {:error, :github_unknown_payload} + end + + defp normalize_state_page(payload, repo, requested_states) do + issues = Enum.map(payload, &normalize_issue(&1, repo)) + malformed_count = Enum.count(issues, &is_nil/1) + + if malformed_count > 0 do + Logger.warning("Dropping malformed GitHub issue records count=#{malformed_count}") + end + + issues + |> Enum.reject(&is_nil/1) + |> Enum.filter(&MapSet.member?(requested_states, normalize_state(&1.state))) + end + + defp normalize_issue(issue, repo) when is_map(issue) and is_binary(repo) do + issue_number = issue["number"] + state = issue["state"] + + if is_integer(issue_number) and issue_number > 0 and + Enum.all?([issue["title"], state], &present_string?/1) do + %Issue{ + id: Integer.to_string(issue_number), + native_ref: native_ref(issue, repo), + identifier: "GH-#{issue_number}", + title: issue["title"], + description: issue["body"], + state: state, + url: issue["html_url"], + assignee_id: get_in(issue, ["assignee", "login"]), + labels: extract_labels(issue), + blocked_by: [], + dispatchable: not Map.has_key?(issue, "pull_request"), + created_at: parse_datetime(issue["created_at"]), + updated_at: parse_datetime(issue["updated_at"]) + } + end + end + + defp normalize_issue(_issue, _repo), do: nil + + defp native_ref(issue, repo) do + %{ + "id" => issue["id"], + "node_id" => issue["node_id"], + "number" => issue["number"], + "repo" => repo + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + |> case do + empty when map_size(empty) == 0 -> nil + ref -> ref + end + end + + defp extract_labels(%{"labels" => labels}) when is_list(labels) do + labels + |> Enum.flat_map(fn + %{"name" => name} when is_binary(name) -> [name] + name when is_binary(name) -> [name] + _ -> [] + end) + |> Enum.map(&(String.trim(&1) |> String.downcase())) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp extract_labels(_issue), do: [] + + defp parse_datetime(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> datetime + _ -> nil + end + end + + defp parse_datetime(_value), do: nil + + defp request_with_settings(method, path, params, body, settings, request_fun, allow_not_found) do + case request_fun.(method, path, params, body, settings) do + {:ok, %{status: status, body: payload}} when status in 200..299 -> + {:ok, payload} + + {:ok, %{status: 404}} when allow_not_found -> + {:ok, :not_found} + + {:ok, %{status: status}} when is_integer(status) -> + Logger.error("GitHub API request failed status=#{status} method=#{method} path=#{path}") + {:error, {:github_api_status, status}} + + {:error, reason} -> + {:error, reason} + + _ -> + {:error, :github_unknown_payload} + end + end + + defp perform_request(method, path, params, body, settings) do + with {:ok, request_method} <- request_method(method) do + request_opts = [ + method: request_method, + url: settings.api_url <> path, + headers: github_headers(settings.token), + params: params, + connect_options: [timeout: 30_000] + ] + + request_opts = if is_nil(body), do: request_opts, else: Keyword.put(request_opts, :json, body) + + case Req.request(request_opts) do + {:ok, response} -> {:ok, %{status: response.status, body: response.body}} + {:error, reason} -> {:error, {:github_api_request, reason}} + end + end + end + + defp settings(tracker_settings) when is_map(tracker_settings) do + provider = provider_settings(tracker_settings) + api_url = provider["api_url"] || @default_api_url + repo = resolve_setting(provider["repo"], System.get_env("GITHUB_REPO")) + token = resolve_setting(provider["token"], System.get_env("GITHUB_TOKEN")) + + cond do + not valid_api_url?(api_url) -> {:error, :invalid_github_api_url} + not present_string?(repo) -> {:error, :missing_github_repo} + not valid_repo?(repo) -> {:error, :invalid_github_repo} + not present_string?(token) -> {:error, :missing_github_token} + true -> {:ok, %{api_url: String.trim_trailing(api_url, "/"), repo: repo, token: token}} + end + end + + defp provider_settings(%{provider: provider}) when is_map(provider), do: provider + defp provider_settings(_tracker_settings), do: %{} + + defp resolve_setting(nil, fallback), do: normalize_string(fallback) + + defp resolve_setting("$" <> env_name, fallback) do + if valid_env_name?(env_name) do + normalize_string(System.get_env(env_name) || fallback) + else + nil + end + end + + defp resolve_setting(value, _fallback), do: normalize_string(value) + + defp normalize_string(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_string(_value), do: nil + + defp env_reference_names(values) do + Enum.flat_map(values, fn + "$" <> env_name when is_binary(env_name) -> if valid_env_name?(env_name), do: [env_name], else: [] + _ -> [] + end) + end + + defp valid_env_name?(name), do: String.match?(name, ~r/^[A-Za-z_][A-Za-z0-9_]*$/) + + defp valid_api_url?(value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: "https", host: host} when is_binary(host) -> true + _ -> false + end + end + + defp valid_api_url?(_value), do: false + defp valid_repo?(repo) when is_binary(repo), do: String.match?(repo, ~r/^[^\s\/]+\/[^\s\/]+$/) + defp valid_repo?(_repo), do: false + + defp repository_issues_path(settings), do: "/repos/#{encoded_repo(settings.repo)}/issues" + + defp repository_issue_path(settings, issue_number), + do: "#{repository_issues_path(settings)}/#{issue_number}" + + defp encoded_repo(repo) do + repo + |> String.split("/", parts: 2) + |> Enum.map_join("/", fn segment -> URI.encode(segment, &URI.char_unreserved?/1) end) + end + + defp github_headers(token) do + [ + {"Accept", "application/vnd.github+json"}, + {"Authorization", "Bearer #{token}"}, + {"X-GitHub-Api-Version", @api_version}, + {"User-Agent", @user_agent} + ] + end + + defp github_state_query(states) do + has_open? = MapSet.member?(states, "open") + has_closed? = MapSet.member?(states, "closed") + + cond do + has_open? and has_closed? -> "all" + has_open? -> "open" + has_closed? -> "closed" + true -> nil + end + end + + defp parse_issue_number(value) when is_binary(value) do + case Integer.parse(value) do + {number, ""} when number > 0 -> {:ok, number} + _ -> {:error, :invalid_github_issue_id} + end + end + + defp parse_issue_number(_value), do: {:error, :invalid_github_issue_id} + + defp request_method("GET"), do: {:ok, :get} + defp request_method("POST"), do: {:ok, :post} + defp request_method("PATCH"), do: {:ok, :patch} + defp request_method("PUT"), do: {:ok, :put} + defp request_method("DELETE"), do: {:ok, :delete} + defp request_method(_method), do: {:error, :invalid_github_method} + + defp normalize_state(value) when is_binary(value), do: value |> String.trim() |> String.downcase() + defp normalize_state(_value), do: "" + + defp present_string?(value) when is_binary(value), do: String.trim(value) != "" + defp present_string?(_value), do: false +end diff --git a/elixir/lib/symphony_elixir/gitlab/adapter.ex b/elixir/lib/symphony_elixir/gitlab/adapter.ex new file mode 100644 index 0000000000..0689cde000 --- /dev/null +++ b/elixir/lib/symphony_elixir/gitlab/adapter.ex @@ -0,0 +1,63 @@ +defmodule SymphonyElixir.GitLab.Adapter do + @moduledoc """ + GitLab Issues-backed tracker adapter. + """ + + @behaviour SymphonyElixir.Tracker + + alias SymphonyElixir.GitLab.{AgentTool, Client} + alias SymphonyElixir.Tracker.Issue + + @active_states ["opened"] + @terminal_states ["closed"] + + @spec validate_config(map()) :: :ok | {:error, term()} + def validate_config(tracker_settings) do + with :ok <- + validate_states( + tracker_settings.active_states, + @active_states, + :missing_gitlab_active_states + ), + :ok <- + validate_states( + tracker_settings.terminal_states, + @terminal_states, + :missing_gitlab_terminal_states + ) do + Client.validate_settings(tracker_settings) + end + end + + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states(states), do: client_module().fetch_issues_by_states(states) + + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(ids), do: client_module().fetch_issues_by_ids(ids) + + @spec agent_tool_specs() :: [map()] + def agent_tool_specs, do: AgentTool.tool_specs() + + @spec execute_agent_tool(String.t(), term(), keyword()) :: map() + def execute_agent_tool(tool, arguments, opts), do: AgentTool.execute(tool, arguments, opts) + + @spec secret_environment_names(map()) :: [String.t()] + def secret_environment_names(tracker_settings), do: Client.secret_environment_names(tracker_settings) + + defp client_module do + Application.get_env(:symphony_elixir, :gitlab_client_module, Client) + end + + defp validate_states(states, allowed_states, _missing_error) when is_list(states) do + if Enum.all?(states, &(normalize_state(&1) in allowed_states)) do + :ok + else + {:error, :invalid_gitlab_states} + end + end + + defp validate_states(_states, _allowed_states, missing_error), do: {:error, missing_error} + + defp normalize_state(state) when is_binary(state), do: state |> String.trim() |> String.downcase() + defp normalize_state(_state), do: "" +end diff --git a/elixir/lib/symphony_elixir/gitlab/agent_tool.ex b/elixir/lib/symphony_elixir/gitlab/agent_tool.ex new file mode 100644 index 0000000000..b360a966eb --- /dev/null +++ b/elixir/lib/symphony_elixir/gitlab/agent_tool.ex @@ -0,0 +1,174 @@ +defmodule SymphonyElixir.GitLab.AgentTool do + @moduledoc """ + Provider-native GitLab REST tool exposed to Codex app-server turns. + """ + + alias SymphonyElixir.GitLab.Client + + @gitlab_api_tool "gitlab_api" + @allowed_methods ["GET", "POST", "PUT", "DELETE"] + @gitlab_api_description """ + Execute a GitLab REST API request using Symphony's configured auth. + """ + @gitlab_api_input_schema %{ + "type" => "object", + "additionalProperties" => false, + "required" => ["method", "path"], + "properties" => %{ + "method" => %{ + "type" => "string", + "enum" => @allowed_methods, + "description" => "GitLab REST method." + }, + "path" => %{ + "type" => "string", + "description" => "GitLab REST path such as /projects/group%2Frepo/issues/1/notes." + }, + "query" => %{ + "type" => ["object", "null"], + "description" => "Optional query parameters.", + "additionalProperties" => true + }, + "body" => %{ + "description" => "Optional JSON request body." + } + } + } + + @spec execute(String.t() | nil, term(), keyword()) :: map() + def execute(tool, arguments, opts) do + case tool do + @gitlab_api_tool -> execute_gitlab_api(arguments, opts) + other -> unsupported_tool_response(other) + end + end + + @spec tool_specs() :: [map()] + def tool_specs do + [ + %{ + "name" => @gitlab_api_tool, + "description" => @gitlab_api_description, + "inputSchema" => @gitlab_api_input_schema + } + ] + end + + defp execute_gitlab_api(arguments, opts) do + gitlab_client = Keyword.get(opts, :gitlab_client, &Client.request/5) + client_opts = Keyword.take(opts, [:tracker_settings]) + + with {:ok, method, path, query, body} <- normalize_arguments(arguments), + {:ok, %{status: status, body: response_body}} <- + gitlab_client.(method, path, query, body, client_opts), + true <- is_integer(status) do + rest_response(status, response_body) + else + {:error, reason} -> failure_response(tool_error_payload(reason)) + _ -> failure_response(tool_error_payload(:gitlab_unknown_payload)) + end + end + + defp normalize_arguments(arguments) when is_map(arguments) do + with {:ok, method} <- normalize_method(Map.get(arguments, "method")), + {:ok, path} <- normalize_path(Map.get(arguments, "path")), + {:ok, query} <- normalize_query(Map.get(arguments, "query")) do + {:ok, method, path, query, Map.get(arguments, "body")} + end + end + + defp normalize_arguments(_arguments), do: {:error, :invalid_arguments} + + defp normalize_method(method) when is_binary(method) do + normalized = method |> String.trim() |> String.upcase() + if normalized in @allowed_methods, do: {:ok, normalized}, else: {:error, :invalid_method} + end + + defp normalize_method(_method), do: {:error, :invalid_method} + + defp normalize_path(path) when is_binary(path) do + trimmed = String.trim(path) + + if String.starts_with?(trimmed, "/") and + not String.contains?(trimmed, ["://", "\n", "\r", <<0>>]) do + {:ok, trimmed} + else + {:error, :invalid_path} + end + end + + defp normalize_path(_path), do: {:error, :invalid_path} + + defp normalize_query(nil), do: {:ok, %{}} + defp normalize_query(query) when is_map(query), do: {:ok, query} + defp normalize_query(_query), do: {:error, :invalid_query} + + defp rest_response(status, body) do + dynamic_tool_response(status in 200..299, encode_payload(%{"status" => status, "body" => body})) + end + + defp failure_response(payload), do: dynamic_tool_response(false, encode_payload(payload)) + + defp dynamic_tool_response(success, output) do + %{ + "success" => success, + "output" => output, + "contentItems" => [%{"type" => "inputText", "text" => output}] + } + end + + defp encode_payload(payload) do + case Jason.encode(payload, pretty: true) do + {:ok, output} -> output + {:error, _reason} -> inspect(payload) + end + end + + defp unsupported_tool_response(tool) do + failure_response(%{ + "error" => %{ + "message" => "Unsupported dynamic tool: #{inspect(tool)}.", + "supportedTools" => supported_tool_names() + } + }) + end + + defp tool_error_payload(:invalid_arguments) do + %{"error" => %{"message" => "gitlab_api expects an object with method and path."}} + end + + defp tool_error_payload(:invalid_method) do + %{"error" => %{"message" => "gitlab_api.method must be GET, POST, PUT, or DELETE."}} + end + + defp tool_error_payload(:invalid_path) do + %{"error" => %{"message" => "gitlab_api.path must be a relative GitLab REST path."}} + end + + defp tool_error_payload(:invalid_query) do + %{"error" => %{"message" => "gitlab_api.query must be a JSON object when provided."}} + end + + defp tool_error_payload(:missing_gitlab_api_key) do + %{ + "error" => %{ + "message" => "Symphony is missing GitLab auth. Set tracker.provider.api_key or export GITLAB_PAT." + } + } + end + + defp tool_error_payload({:gitlab_api_request, reason}) do + %{ + "error" => %{ + "message" => "GitLab API request failed before receiving a successful response.", + "reason" => inspect(reason) + } + } + end + + defp tool_error_payload(reason) do + %{"error" => %{"message" => "GitLab REST tool execution failed.", "reason" => inspect(reason)}} + end + + defp supported_tool_names, do: Enum.map(tool_specs(), & &1["name"]) +end diff --git a/elixir/lib/symphony_elixir/gitlab/client.ex b/elixir/lib/symphony_elixir/gitlab/client.ex new file mode 100644 index 0000000000..e27ef81a3d --- /dev/null +++ b/elixir/lib/symphony_elixir/gitlab/client.ex @@ -0,0 +1,414 @@ +defmodule SymphonyElixir.GitLab.Client do + @moduledoc """ + Thin GitLab REST client for project-scoped issue polling. + """ + + require Logger + alias SymphonyElixir.Config + alias SymphonyElixir.Tracker.Issue + + @default_api_url "https://gitlab.com/api/v4" + @page_size 100 + + @spec validate_settings(map()) :: :ok | {:error, term()} + def validate_settings(tracker_settings) do + with {:ok, _settings} <- settings(tracker_settings), do: :ok + end + + @spec secret_environment_names(map()) :: [String.t()] + def secret_environment_names(tracker_settings) do + provider = provider_settings(tracker_settings) + + ["GITLAB_PAT", "GITLAB_ACCESS_TOKEN" | env_reference_names([provider["api_key"]])] + |> Enum.uniq() + end + + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states(states) when is_list(states) do + fetch_issues_by_states(states, Config.settings!().tracker, &perform_request/5) + end + + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(ids) when is_list(ids) do + fetch_issues_by_ids(ids, Config.settings!().tracker, &perform_request/5) + end + + @spec request(String.t(), String.t(), map(), term(), keyword()) :: + {:ok, %{status: integer(), body: term()}} | {:error, term()} + def request(method, path, query, body, opts \\ []) + when is_binary(method) and is_binary(path) and is_map(query) and is_list(opts) do + tracker_settings = Keyword.get_lazy(opts, :tracker_settings, fn -> Config.settings!().tracker end) + request_fun = Keyword.get(opts, :request_fun, &perform_request/5) + + with {:ok, gitlab_settings} <- settings(tracker_settings) do + request_fun.(method, path, query, body, gitlab_settings) + end + end + + @doc false + @spec normalize_issue_for_test(map(), map()) :: Issue.t() | nil + def normalize_issue_for_test(issue, tracker_settings) + when is_map(issue) and is_map(tracker_settings) do + case settings(tracker_settings) do + {:ok, gitlab_settings} -> normalize_issue(issue, gitlab_settings) + _ -> nil + end + end + + @doc false + @spec fetch_issues_by_states_for_test([String.t()], map(), function()) :: + {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states_for_test(states, tracker_settings, request_fun) + when is_list(states) and is_map(tracker_settings) and is_function(request_fun, 5) do + fetch_issues_by_states(states, tracker_settings, request_fun) + end + + @doc false + @spec fetch_issues_by_ids_for_test([String.t()], map(), function()) :: + {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids_for_test(ids, tracker_settings, request_fun) + when is_list(ids) and is_map(tracker_settings) and is_function(request_fun, 5) do + fetch_issues_by_ids(ids, tracker_settings, request_fun) + end + + defp fetch_issues_by_states(states, tracker_settings, request_fun) do + normalized_states = states |> Enum.map(&normalize_state/1) |> MapSet.new() + + case gitlab_state_query(normalized_states) do + nil -> + {:ok, []} + + state_query -> + with {:ok, gitlab_settings} <- settings(tracker_settings) do + fetch_issue_pages(gitlab_settings, state_query, normalized_states, 1, request_fun, []) + end + end + end + + defp fetch_issue_pages(settings, state_query, requested_states, page, request_fun, pages) do + query = %{ + "state" => state_query, + "per_page" => @page_size, + "page" => page, + "order_by" => "created_at", + "sort" => "asc" + } + + with {:ok, payload} <- + request_with_settings( + "GET", + project_issues_path(settings), + query, + nil, + settings, + request_fun, + false + ), + true <- is_list(payload) or {:error, :gitlab_unknown_payload} do + issues = normalize_state_page(payload, settings, requested_states) + updated_pages = [issues | pages] + + if length(payload) < @page_size do + {:ok, updated_pages |> Enum.reverse() |> List.flatten()} + else + fetch_issue_pages(settings, state_query, requested_states, page + 1, request_fun, updated_pages) + end + end + end + + defp fetch_issues_by_ids(ids, tracker_settings, request_fun) do + case Enum.uniq(ids) do + [] -> + {:ok, []} + + unique_ids -> + with {:ok, gitlab_settings} <- settings(tracker_settings) do + fetch_issue_ids(unique_ids, gitlab_settings, request_fun, []) + end + end + end + + defp fetch_issue_ids([], _settings, _request_fun, issues), do: {:ok, Enum.reverse(issues)} + + defp fetch_issue_ids([id | rest], settings, request_fun, issues) do + with {:ok, iid} <- parse_iid(id), + {:ok, payload} <- + request_with_settings( + "GET", + project_issue_path(settings, iid), + %{}, + nil, + settings, + request_fun, + true + ) do + continue_issue_id_fetch(payload, rest, settings, request_fun, issues) + end + end + + defp continue_issue_id_fetch(:not_found, rest, settings, request_fun, issues) do + fetch_issue_ids(rest, settings, request_fun, issues) + end + + defp continue_issue_id_fetch(%{} = raw_issue, rest, settings, request_fun, issues) do + case normalize_issue(raw_issue, settings) do + %Issue{} = issue -> fetch_issue_ids(rest, settings, request_fun, [issue | issues]) + nil -> {:error, :gitlab_unknown_payload} + end + end + + defp continue_issue_id_fetch(_payload, _rest, _settings, _request_fun, _issues) do + {:error, :gitlab_unknown_payload} + end + + defp normalize_state_page(raw_issues, settings, requested_states) do + issues = Enum.map(raw_issues, &normalize_issue(&1, settings)) + malformed_count = Enum.count(issues, &is_nil/1) + + if malformed_count > 0 do + Logger.warning("Dropping malformed GitLab issue records count=#{malformed_count}") + end + + issues + |> Enum.reject(&is_nil/1) + |> Enum.filter(&MapSet.member?(requested_states, normalize_state(&1.state))) + end + + defp normalize_issue(%{"iid" => iid, "title" => title, "state" => state} = issue, settings) + when is_integer(iid) and iid > 0 and is_binary(title) and is_binary(state) do + if present_string?(title) and present_string?(state) do + %Issue{ + id: Integer.to_string(iid), + native_ref: native_ref(issue, settings.project_path), + identifier: "GL-#{iid}", + title: title, + description: blank_to_nil(issue["description"]), + priority: nil, + state: state, + branch_name: nil, + url: issue["web_url"], + assignee_id: assignee_id(issue), + labels: extract_labels(issue["labels"]), + blocked_by: [], + dispatchable: true, + created_at: parse_datetime(issue["created_at"]), + updated_at: parse_datetime(issue["updated_at"]) + } + end + end + + defp normalize_issue(_issue, _settings), do: nil + + defp native_ref(issue, project_path) do + %{ + "id" => issue["id"], + "iid" => issue["iid"], + "project_id" => issue["project_id"], + "project_path" => project_path, + "references" => issue["references"] + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + end + + defp assignee_id(%{"assignees" => [assignee | _]}) when is_map(assignee), do: assignee_id(assignee) + + defp assignee_id(%{"assignee" => assignee}) when is_map(assignee), do: assignee_id(assignee) + + defp assignee_id(%{"id" => id}) when is_integer(id), do: Integer.to_string(id) + + defp assignee_id(%{"username" => username}) when is_binary(username), do: username + + defp assignee_id(_assignee), do: nil + + defp extract_labels(labels) when is_list(labels) do + labels + |> Enum.filter(&is_binary/1) + |> Enum.map(&(String.trim(&1) |> String.downcase())) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp extract_labels(_labels), do: [] + + defp blank_to_nil(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + text -> text + end + end + + defp blank_to_nil(_value), do: nil + + defp parse_datetime(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> datetime + _ -> nil + end + end + + defp parse_datetime(_value), do: nil + + defp request_with_settings(method, path, query, body, settings, request_fun, allow_not_found) do + case request_fun.(method, path, query, body, settings) do + {:ok, %{status: status, body: payload}} when status in 200..299 -> + {:ok, payload} + + {:ok, %{status: 404}} when allow_not_found -> + {:ok, :not_found} + + {:ok, %{status: status}} when is_integer(status) -> + Logger.error("GitLab API request failed status=#{status} method=#{method} path=#{path}") + {:error, {:gitlab_api_status, status}} + + {:error, reason} -> + {:error, reason} + + _ -> + {:error, :gitlab_unknown_payload} + end + end + + defp perform_request(method, path, query, body, settings) do + with {:ok, request_method} <- request_method(method) do + request_opts = [ + method: request_method, + url: settings.api_url <> path, + headers: gitlab_headers(settings.api_key), + params: query, + connect_options: [timeout: 30_000] + ] + + request_opts = if is_nil(body), do: request_opts, else: Keyword.put(request_opts, :json, body) + + case Req.request(request_opts) do + {:ok, response} -> {:ok, %{status: response.status, body: response.body}} + {:error, reason} -> {:error, {:gitlab_api_request, reason}} + end + end + end + + defp settings(tracker_settings) when is_map(tracker_settings) do + provider = provider_settings(tracker_settings) + api_url = provider["api_url"] || @default_api_url + project_path = resolve_setting(provider["project_path"], System.get_env("GITLAB_PROJECT_PATH")) + + api_key = resolve_setting(provider["api_key"], System.get_env("GITLAB_PAT")) + + cond do + not valid_api_url?(api_url) -> + {:error, :invalid_gitlab_api_url} + + not present_string?(project_path) -> + {:error, :missing_gitlab_project_path} + + not valid_project_path?(project_path) -> + {:error, :invalid_gitlab_project_path} + + not present_string?(api_key) -> + {:error, :missing_gitlab_api_key} + + true -> + {:ok, + %{ + api_url: String.trim_trailing(api_url, "/"), + api_key: api_key, + project_path: project_path + }} + end + end + + defp provider_settings(%{provider: provider}) when is_map(provider), do: provider + defp provider_settings(_tracker_settings), do: %{} + + defp resolve_setting(nil, fallback), do: normalize_string(fallback) + + defp resolve_setting("$" <> env_name, fallback) do + if valid_env_name?(env_name) do + normalize_string(System.get_env(env_name) || fallback) + else + nil + end + end + + defp resolve_setting(value, _fallback), do: normalize_string(value) + + defp normalize_string(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_string(_value), do: nil + + defp env_reference_names(values) do + Enum.flat_map(values, fn + "$" <> env_name when is_binary(env_name) -> if valid_env_name?(env_name), do: [env_name], else: [] + _ -> [] + end) + end + + defp valid_env_name?(name), do: String.match?(name, ~r/^[A-Za-z_][A-Za-z0-9_]*$/) + + defp valid_api_url?(value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: "https", host: host} when is_binary(host) -> true + _ -> false + end + end + + defp valid_api_url?(_value), do: false + + defp valid_project_path?(value) when is_binary(value) do + not String.contains?(value, [" ", "\t", "\n", "\r", <<0>>]) + end + + defp valid_project_path?(_value), do: false + + defp project_issues_path(settings), do: "/projects/#{encoded(settings.project_path)}/issues" + + defp project_issue_path(settings, iid), do: "#{project_issues_path(settings)}/#{iid}" + + defp gitlab_headers(api_key) do + [ + {"Accept", "application/json"}, + {"PRIVATE-TOKEN", api_key} + ] + end + + defp gitlab_state_query(states) do + has_opened? = MapSet.member?(states, "opened") + has_closed? = MapSet.member?(states, "closed") + + cond do + has_opened? and has_closed? -> "all" + has_opened? -> "opened" + has_closed? -> "closed" + true -> nil + end + end + + defp parse_iid(value) when is_binary(value) do + case Integer.parse(value) do + {iid, ""} when iid > 0 -> {:ok, iid} + _ -> {:error, :invalid_gitlab_issue_id} + end + end + + defp parse_iid(_value), do: {:error, :invalid_gitlab_issue_id} + + defp encoded(value), do: URI.encode(value, &URI.char_unreserved?/1) + + defp request_method("GET"), do: {:ok, :get} + defp request_method("POST"), do: {:ok, :post} + defp request_method("PUT"), do: {:ok, :put} + defp request_method("DELETE"), do: {:ok, :delete} + defp request_method(_method), do: {:error, :invalid_gitlab_method} + + defp normalize_state(value) when is_binary(value), do: value |> String.trim() |> String.downcase() + defp normalize_state(_value), do: "" + + defp present_string?(value) when is_binary(value), do: String.trim(value) != "" + defp present_string?(_value), do: false +end diff --git a/elixir/lib/symphony_elixir/http_server.ex b/elixir/lib/symphony_elixir/http_server.ex index 47686e934f..5f947b880a 100644 --- a/elixir/lib/symphony_elixir/http_server.ex +++ b/elixir/lib/symphony_elixir/http_server.ex @@ -20,7 +20,7 @@ defmodule SymphonyElixir.HttpServer do 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()) + host = Keyword.get(opts, :host, Config.settings!().server.host) orchestrator = Keyword.get(opts, :orchestrator, Orchestrator) snapshot_timeout_ms = Keyword.get(opts, :snapshot_timeout_ms, 15_000) diff --git a/elixir/lib/symphony_elixir/jira/adapter.ex b/elixir/lib/symphony_elixir/jira/adapter.ex new file mode 100644 index 0000000000..a68ff3c28d --- /dev/null +++ b/elixir/lib/symphony_elixir/jira/adapter.ex @@ -0,0 +1,50 @@ +defmodule SymphonyElixir.Jira.Adapter do + @moduledoc """ + Jira Cloud-backed tracker adapter. + """ + + @behaviour SymphonyElixir.Tracker + + alias SymphonyElixir.Jira.{AgentTool, Client} + alias SymphonyElixir.Tracker.Issue + + @spec validate_config(map()) :: :ok | {:error, term()} + def validate_config(tracker_settings) do + with :ok <- validate_states(tracker_settings.active_states, :missing_jira_active_states), + :ok <- validate_states(tracker_settings.terminal_states, :missing_jira_terminal_states) do + Client.validate_settings(tracker_settings) + end + end + + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states(states), do: client_module().fetch_issues_by_states(states) + + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(ids), do: client_module().fetch_issues_by_ids(ids) + + @spec agent_tool_specs() :: [map()] + def agent_tool_specs, do: AgentTool.tool_specs() + + @spec execute_agent_tool(String.t(), term(), keyword()) :: map() + def execute_agent_tool(tool, arguments, opts), do: AgentTool.execute(tool, arguments, opts) + + @spec secret_environment_names(map()) :: [String.t()] + def secret_environment_names(tracker_settings), do: Client.secret_environment_names(tracker_settings) + + defp client_module do + Application.get_env(:symphony_elixir, :jira_client_module, Client) + end + + defp validate_states(states, _missing_error) when is_list(states) do + if Enum.all?(states, &present_string?/1) do + :ok + else + {:error, :invalid_jira_states} + end + end + + defp validate_states(_states, missing_error), do: {:error, missing_error} + + defp present_string?(value) when is_binary(value), do: String.trim(value) != "" + defp present_string?(_value), do: false +end diff --git a/elixir/lib/symphony_elixir/jira/agent_tool.ex b/elixir/lib/symphony_elixir/jira/agent_tool.ex new file mode 100644 index 0000000000..c33e0202c2 --- /dev/null +++ b/elixir/lib/symphony_elixir/jira/agent_tool.ex @@ -0,0 +1,174 @@ +defmodule SymphonyElixir.Jira.AgentTool do + @moduledoc """ + Provider-native Jira Cloud REST tool exposed to Codex app-server turns. + """ + + alias SymphonyElixir.Jira.Client + + @jira_rest_tool "jira_rest" + @allowed_methods ["GET", "POST", "PUT", "DELETE"] + @jira_rest_description """ + Execute a Jira Cloud REST v3 request using Symphony's configured auth. + """ + @jira_rest_input_schema %{ + "type" => "object", + "additionalProperties" => false, + "required" => ["method", "path"], + "properties" => %{ + "method" => %{ + "type" => "string", + "enum" => @allowed_methods, + "description" => "Jira REST method." + }, + "path" => %{ + "type" => "string", + "description" => "Jira REST v3 path beginning with /rest/api/3/." + }, + "query" => %{ + "type" => ["object", "null"], + "description" => "Optional query parameters.", + "additionalProperties" => true + }, + "body" => %{ + "description" => "Optional JSON request body." + } + } + } + + @spec execute(String.t() | nil, term(), keyword()) :: map() + def execute(tool, arguments, opts) do + case tool do + @jira_rest_tool -> execute_jira_rest(arguments, opts) + other -> unsupported_tool_response(other) + end + end + + @spec tool_specs() :: [map()] + def tool_specs do + [ + %{ + "name" => @jira_rest_tool, + "description" => @jira_rest_description, + "inputSchema" => @jira_rest_input_schema + } + ] + end + + defp execute_jira_rest(arguments, opts) do + jira_client = Keyword.get(opts, :jira_client, &Client.request/5) + client_opts = Keyword.take(opts, [:tracker_settings]) + + with {:ok, method, path, query, body} <- normalize_arguments(arguments), + {:ok, %{status: status, body: response_body}} <- + jira_client.(method, path, query, body, client_opts), + true <- is_integer(status) do + rest_response(status, response_body) + else + {:error, reason} -> failure_response(tool_error_payload(reason)) + _ -> failure_response(tool_error_payload(:jira_unknown_payload)) + end + end + + defp normalize_arguments(arguments) when is_map(arguments) do + with {:ok, method} <- normalize_method(Map.get(arguments, "method")), + {:ok, path} <- normalize_path(Map.get(arguments, "path")), + {:ok, query} <- normalize_query(Map.get(arguments, "query")) do + {:ok, method, path, query, Map.get(arguments, "body")} + end + end + + defp normalize_arguments(_arguments), do: {:error, :invalid_arguments} + + defp normalize_method(method) when is_binary(method) do + normalized = method |> String.trim() |> String.upcase() + if normalized in @allowed_methods, do: {:ok, normalized}, else: {:error, :invalid_method} + end + + defp normalize_method(_method), do: {:error, :invalid_method} + + defp normalize_path(path) when is_binary(path) do + trimmed = String.trim(path) + + if String.starts_with?(trimmed, "/rest/api/3/") and + not String.contains?(trimmed, ["://", "\n", "\r", <<0>>]) do + {:ok, trimmed} + else + {:error, :invalid_path} + end + end + + defp normalize_path(_path), do: {:error, :invalid_path} + + defp normalize_query(nil), do: {:ok, %{}} + defp normalize_query(query) when is_map(query), do: {:ok, query} + defp normalize_query(_query), do: {:error, :invalid_query} + + defp rest_response(status, body) do + dynamic_tool_response(status in 200..299, encode_payload(%{"status" => status, "body" => body})) + end + + defp failure_response(payload), do: dynamic_tool_response(false, encode_payload(payload)) + + defp dynamic_tool_response(success, output) do + %{ + "success" => success, + "output" => output, + "contentItems" => [%{"type" => "inputText", "text" => output}] + } + end + + defp encode_payload(payload) do + case Jason.encode(payload, pretty: true) do + {:ok, output} -> output + {:error, _reason} -> inspect(payload) + end + end + + defp unsupported_tool_response(tool) do + failure_response(%{ + "error" => %{ + "message" => "Unsupported dynamic tool: #{inspect(tool)}.", + "supportedTools" => supported_tool_names() + } + }) + end + + defp tool_error_payload(:invalid_arguments) do + %{"error" => %{"message" => "jira_rest expects an object with method and path."}} + end + + defp tool_error_payload(:invalid_method) do + %{"error" => %{"message" => "jira_rest.method must be GET, POST, PUT, or DELETE."}} + end + + defp tool_error_payload(:invalid_path) do + %{"error" => %{"message" => "jira_rest.path must begin with /rest/api/3/."}} + end + + defp tool_error_payload(:invalid_query) do + %{"error" => %{"message" => "jira_rest.query must be a JSON object when provided."}} + end + + defp tool_error_payload(:missing_jira_api_token) do + %{ + "error" => %{ + "message" => "Symphony is missing Jira auth. Set tracker.provider.api_token or export JIRA_API_TOKEN." + } + } + end + + defp tool_error_payload({:jira_api_request, reason}) do + %{ + "error" => %{ + "message" => "Jira API request failed before receiving a successful response.", + "reason" => inspect(reason) + } + } + end + + defp tool_error_payload(reason) do + %{"error" => %{"message" => "Jira REST tool execution failed.", "reason" => inspect(reason)}} + end + + defp supported_tool_names, do: Enum.map(tool_specs(), & &1["name"]) +end diff --git a/elixir/lib/symphony_elixir/jira/client.ex b/elixir/lib/symphony_elixir/jira/client.ex new file mode 100644 index 0000000000..e9847e5ecd --- /dev/null +++ b/elixir/lib/symphony_elixir/jira/client.ex @@ -0,0 +1,532 @@ +defmodule SymphonyElixir.Jira.Client do + @moduledoc """ + Thin Jira Cloud REST client for project-scoped issue polling. + """ + + require Logger + alias SymphonyElixir.Config + alias SymphonyElixir.Tracker.Issue + + @page_size 100 + @issue_fields [ + "summary", + "description", + "status", + "labels", + "assignee", + "created", + "updated", + "project", + "issuelinks" + ] + + @spec validate_settings(map()) :: :ok | {:error, term()} + def validate_settings(tracker_settings) do + with {:ok, _settings} <- settings(tracker_settings), do: :ok + end + + @spec secret_environment_names(map()) :: [String.t()] + def secret_environment_names(tracker_settings) do + provider = provider_settings(tracker_settings) + + ["JIRA_API_TOKEN" | env_reference_names([provider["api_token"]])] + |> Enum.uniq() + end + + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states(states) when is_list(states) do + fetch_issues_by_states(states, Config.settings!().tracker, &perform_request/5) + end + + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(ids) when is_list(ids) do + fetch_issues_by_ids(ids, Config.settings!().tracker, &perform_request/5) + end + + @spec request(String.t(), String.t(), map(), term(), keyword()) :: + {:ok, %{status: integer(), body: term()}} | {:error, term()} + def request(method, path, query, body, opts \\ []) + when is_binary(method) and is_binary(path) and is_map(query) and is_list(opts) do + tracker_settings = Keyword.get_lazy(opts, :tracker_settings, fn -> Config.settings!().tracker end) + request_fun = Keyword.get(opts, :request_fun, &perform_request/5) + + with {:ok, jira_settings} <- settings(tracker_settings) do + request_fun.(method, path, query, body, jira_settings) + end + end + + @doc false + @spec normalize_issue_for_test(map(), map()) :: Issue.t() | nil + def normalize_issue_for_test(issue, tracker_settings) + when is_map(issue) and is_map(tracker_settings) do + case settings(tracker_settings) do + {:ok, jira_settings} -> normalize_issue(issue, jira_settings) + _ -> nil + end + end + + @doc false + @spec fetch_issues_by_states_for_test([String.t()], map(), function()) :: + {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states_for_test(states, tracker_settings, request_fun) + when is_list(states) and is_map(tracker_settings) and is_function(request_fun, 5) do + fetch_issues_by_states(states, tracker_settings, request_fun) + end + + @doc false + @spec fetch_issues_by_ids_for_test([String.t()], map(), function()) :: + {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids_for_test(ids, tracker_settings, request_fun) + when is_list(ids) and is_map(tracker_settings) and is_function(request_fun, 5) do + fetch_issues_by_ids(ids, tracker_settings, request_fun) + end + + defp fetch_issues_by_states([], _tracker_settings, _request_fun), do: {:ok, []} + + defp fetch_issues_by_states(states, tracker_settings, request_fun) do + with {:ok, jira_settings} <- settings(tracker_settings) do + fetch_state_pages(states, jira_settings, nil, request_fun, []) + end + end + + defp fetch_state_pages(states, settings, next_page_token, request_fun, pages) do + body = + %{ + "jql" => state_jql(settings.project_key, states), + "fields" => @issue_fields, + "maxResults" => @page_size + } + |> maybe_put("nextPageToken", next_page_token) + + with {:ok, payload} <- + request_with_settings( + "POST", + "/rest/api/3/search/jql", + %{}, + body, + settings, + request_fun + ), + {:ok, raw_issues, next_token} <- state_page(payload) do + issues = normalize_candidate_page(raw_issues, settings, states) + updated_pages = [issues | pages] + + case next_token do + :done -> {:ok, updated_pages |> Enum.reverse() |> List.flatten()} + token -> fetch_state_pages(states, settings, token, request_fun, updated_pages) + end + end + end + + defp fetch_issues_by_ids([], _tracker_settings, _request_fun), do: {:ok, []} + + defp fetch_issues_by_ids(ids, tracker_settings, request_fun) do + with {:ok, jira_settings} <- settings(tracker_settings) do + ids + |> Enum.uniq() + |> Enum.chunk_every(@page_size) + |> fetch_id_batches(jira_settings, request_fun, []) + end + end + + defp fetch_id_batches([], _settings, _request_fun, pages) do + {:ok, pages |> Enum.reverse() |> List.flatten()} + end + + defp fetch_id_batches([ids | rest], settings, request_fun, pages) do + body = %{"issueIdsOrKeys" => ids, "fields" => @issue_fields} + + with {:ok, payload} <- + request_with_settings( + "POST", + "/rest/api/3/issue/bulkfetch", + %{}, + body, + settings, + request_fun + ), + {:ok, raw_issues} <- issues_payload(payload), + {:ok, issues} <- normalize_requested_issues(raw_issues, ids, settings) do + fetch_id_batches(rest, settings, request_fun, [issues | pages]) + end + end + + defp state_page(%{"issues" => issues, "isLast" => true}) when is_list(issues) do + {:ok, issues, :done} + end + + defp state_page(%{"issues" => issues, "isLast" => false, "nextPageToken" => token}) + when is_list(issues) and is_binary(token) and token != "" do + {:ok, issues, token} + end + + defp state_page(%{"issues" => issues, "isLast" => false}) when is_list(issues) do + {:error, :jira_missing_next_page_token} + end + + defp state_page(_payload), do: {:error, :jira_unknown_payload} + + defp issues_payload(%{"issues" => issues}) when is_list(issues), do: {:ok, issues} + defp issues_payload(_payload), do: {:error, :jira_unknown_payload} + + defp normalize_candidate_page(raw_issues, settings, states) do + requested_states = states |> Enum.map(&normalize_state/1) |> MapSet.new() + issues = Enum.map(raw_issues, &normalize_issue(&1, settings)) + malformed_count = Enum.count(issues, &is_nil/1) + + if malformed_count > 0 do + Logger.warning("Dropping malformed Jira issue records count=#{malformed_count}") + end + + issues + |> Enum.reject(&is_nil/1) + |> Enum.filter(&MapSet.member?(requested_states, normalize_state(&1.state))) + end + + defp normalize_requested_issues(raw_issues, requested_ids, settings) do + requested = MapSet.new(requested_ids) + + raw_issues + |> Enum.reduce_while({:ok, %{}}, fn raw_issue, {:ok, issues_by_id} -> + normalize_requested_issue(raw_issue, requested, settings, issues_by_id) + end) + |> case do + {:ok, issues_by_id} -> + {:ok, Enum.flat_map(requested_ids, &(Map.fetch(issues_by_id, &1) |> fetched_issue()))} + + {:error, reason} -> + {:error, reason} + end + end + + defp normalize_requested_issue(%{"id" => id} = raw_issue, requested, settings, issues_by_id) + when is_binary(id) do + project_key = issue_project_key(raw_issue) + + cond do + not MapSet.member?(requested, id) -> + {:cont, {:ok, issues_by_id}} + + is_nil(project_key) -> + {:halt, {:error, :jira_unknown_payload}} + + not same_project_key?(project_key, settings.project_key) -> + {:cont, {:ok, issues_by_id}} + + true -> + case normalize_issue(raw_issue, settings) do + %Issue{} = issue -> {:cont, {:ok, Map.put(issues_by_id, id, issue)}} + nil -> {:halt, {:error, :jira_unknown_payload}} + end + end + end + + defp normalize_requested_issue(_raw_issue, _requested, _settings, _issues_by_id) do + {:halt, {:error, :jira_unknown_payload}} + end + + defp fetched_issue({:ok, issue}), do: [issue] + defp fetched_issue(:error), do: [] + + defp normalize_issue(%{"id" => id, "key" => key, "fields" => fields}, settings) + when is_binary(id) and is_binary(key) and is_map(fields) do + title = fields["summary"] + state = get_in(fields, ["status", "name"]) + status_category = get_in(fields, ["status", "statusCategory", "key"]) + blockers = extract_blockers(fields["issuelinks"]) + + if same_project_key?(issue_project_key(%{"fields" => fields}), settings.project_key) and + present_string?(id) and + present_string?(key) and present_string?(title) and present_string?(state) do + %Issue{ + id: id, + native_ref: nil, + identifier: key, + title: title, + description: description_text(fields["description"]), + priority: nil, + state: state, + branch_name: nil, + url: "#{settings.base_url}/browse/#{URI.encode(key, &URI.char_unreserved?/1)}", + assignee_id: get_in(fields, ["assignee", "accountId"]), + labels: extract_labels(fields["labels"]), + blocked_by: blockers, + dispatchable: dispatchable?(state, status_category, blockers, settings.terminal_states), + created_at: parse_datetime(fields["created"]), + updated_at: parse_datetime(fields["updated"]) + } + end + end + + defp normalize_issue(_issue, _settings), do: nil + + defp description_text(nil), do: nil + + defp description_text(value) when is_binary(value) do + blank_to_nil(value) + end + + defp description_text(value) when is_map(value) do + value + |> adf_text() + |> blank_to_nil() + end + + defp description_text(_value), do: nil + + defp adf_text(%{"type" => "hardBreak"}), do: "\n" + defp adf_text(%{"text" => text}) when is_binary(text), do: text + + defp adf_text(%{"type" => type, "content" => content}) + when type in ["paragraph", "heading", "blockquote"] and is_list(content) do + Enum.map_join(content, "", &adf_text/1) <> "\n" + end + + defp adf_text(%{"content" => content}) when is_list(content) do + Enum.map_join(content, "", &adf_text/1) + end + + defp adf_text(%{"attrs" => attrs}) when is_map(attrs) do + attrs["text"] || attrs["shortName"] || attrs["url"] || "" + end + + defp adf_text(_value), do: "" + + defp blank_to_nil(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + text -> text + end + end + + defp extract_labels(labels) when is_list(labels) do + labels + |> Enum.filter(&is_binary/1) + |> Enum.map(&(String.trim(&1) |> String.downcase())) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp extract_labels(_labels), do: [] + + defp extract_blockers(links) when is_list(links) do + Enum.flat_map(links, &extract_blocker/1) + end + + defp extract_blockers(_links), do: [] + + defp extract_blocker(%{"type" => %{"name" => type_name}, "inwardIssue" => blocker_issue}) + when is_binary(type_name) and is_map(blocker_issue) do + if normalize_state(type_name) == "blocks" do + [blocker_ref(blocker_issue)] + else + [] + end + end + + defp extract_blocker(_link), do: [] + + defp blocker_ref(blocker_issue) do + %{ + id: optional_string(blocker_issue["id"]), + identifier: optional_string(blocker_issue["key"]), + state: optional_string(get_in(blocker_issue, ["fields", "status", "name"])) + } + end + + defp dispatchable?(state, status_category, blockers, terminal_states) do + not blocks_gate_dispatch?(state, status_category) or + Enum.all?(blockers, &terminal_blocker?(&1, terminal_states)) + end + + defp blocks_gate_dispatch?(_state, status_category) + when is_binary(status_category) and status_category != "" do + normalize_state(status_category) == "new" + end + + defp blocks_gate_dispatch?(state, _status_category) do + normalize_state(state) in ["todo", "to do"] + end + + defp terminal_blocker?(%{state: state}, terminal_states) + when is_binary(state) and is_list(terminal_states) do + normalize_state(state) in terminal_states + end + + defp terminal_blocker?(_blocker, _terminal_states), do: false + + defp issue_project_key(raw_issue), do: get_in(raw_issue, ["fields", "project", "key"]) + + defp same_project_key?(left, right) when is_binary(left) and is_binary(right) do + String.downcase(String.trim(left)) == String.downcase(String.trim(right)) + end + + defp same_project_key?(_left, _right), do: false + + defp parse_datetime(value) when is_binary(value) do + normalized = Regex.replace(~r/([+-]\d{2})(\d{2})$/, value, "\\1:\\2") + + case DateTime.from_iso8601(normalized) do + {:ok, datetime, _offset} -> datetime + _ -> nil + end + end + + defp parse_datetime(_value), do: nil + + defp request_with_settings(method, path, query, body, settings, request_fun) do + case request_fun.(method, path, query, body, settings) do + {:ok, %{status: status, body: payload}} when status in 200..299 -> + {:ok, payload} + + {:ok, %{status: status}} when is_integer(status) -> + Logger.error("Jira API request failed status=#{status} method=#{method} path=#{path}") + {:error, {:jira_api_status, status}} + + {:error, reason} -> + {:error, reason} + + _ -> + {:error, :jira_unknown_payload} + end + end + + defp perform_request(method, path, query, body, settings) do + with {:ok, request_method} <- request_method(method) do + request_opts = [ + method: request_method, + url: settings.base_url <> path, + headers: jira_headers(settings.email, settings.api_token), + params: query, + connect_options: [timeout: 30_000] + ] + + request_opts = if is_nil(body), do: request_opts, else: Keyword.put(request_opts, :json, body) + + case Req.request(request_opts) do + {:ok, response} -> {:ok, %{status: response.status, body: response.body}} + {:error, reason} -> {:error, {:jira_api_request, reason}} + end + end + end + + defp settings(tracker_settings) when is_map(tracker_settings) do + provider = provider_settings(tracker_settings) + base_url = resolve_setting(provider["base_url"], System.get_env("JIRA_BASE_URL")) + email = resolve_setting(provider["email"], System.get_env("JIRA_EMAIL")) + api_token = resolve_setting(provider["api_token"], System.get_env("JIRA_API_TOKEN")) + project_key = resolve_setting(provider["project_key"], nil) + + cond do + not valid_base_url?(base_url) -> + {:error, :invalid_jira_base_url} + + not present_string?(email) -> + {:error, :missing_jira_email} + + not present_string?(api_token) -> + {:error, :missing_jira_api_token} + + not present_string?(project_key) -> + {:error, :missing_jira_project_key} + + true -> + {:ok, + %{ + base_url: String.trim_trailing(base_url, "/"), + email: email, + api_token: api_token, + project_key: project_key, + terminal_states: terminal_states(tracker_settings) + }} + end + end + + defp provider_settings(%{provider: provider}) when is_map(provider), do: provider + defp provider_settings(_tracker_settings), do: %{} + + defp resolve_setting(nil, fallback), do: normalize_string(fallback) + + defp resolve_setting("$" <> env_name, fallback) do + if valid_env_name?(env_name) do + normalize_string(System.get_env(env_name) || fallback) + else + nil + end + end + + defp resolve_setting(value, _fallback), do: normalize_string(value) + + defp normalize_string(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_string(_value), do: nil + + defp optional_string(value) when is_binary(value) do + if present_string?(value), do: value, else: nil + end + + defp optional_string(_value), do: nil + + defp terminal_states(%{terminal_states: states}) when is_list(states) do + states + |> Enum.map(&normalize_state/1) + |> Enum.reject(&(&1 == "")) + end + + defp terminal_states(_tracker_settings), do: [] + + defp env_reference_names(values) do + Enum.flat_map(values, fn + "$" <> env_name when is_binary(env_name) -> if valid_env_name?(env_name), do: [env_name], else: [] + _ -> [] + end) + end + + defp valid_env_name?(name), do: String.match?(name, ~r/^[A-Za-z_][A-Za-z0-9_]*$/) + + defp valid_base_url?(value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: "https", host: host, query: nil, fragment: nil} when is_binary(host) -> true + _ -> false + end + end + + defp valid_base_url?(_value), do: false + + defp jira_headers(email, api_token) do + [ + {"Accept", "application/json"}, + {"Authorization", "Basic #{Base.encode64("#{email}:#{api_token}")}"} + ] + end + + defp state_jql(project_key, states) do + quoted_states = Enum.map_join(states, ", ", &jql_string/1) + "project = #{jql_string(project_key)} AND status IN (#{quoted_states})" + end + + defp jql_string(value) do + escaped = value |> String.replace("\\", "\\\\") |> String.replace("\"", "\\\"") + "\"#{escaped}\"" + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp request_method("GET"), do: {:ok, :get} + defp request_method("POST"), do: {:ok, :post} + defp request_method("PUT"), do: {:ok, :put} + defp request_method("DELETE"), do: {:ok, :delete} + defp request_method(_method), do: {:error, :invalid_jira_method} + + defp normalize_state(value) when is_binary(value), do: value |> String.trim() |> String.downcase() + defp normalize_state(_value), do: "" + + defp present_string?(value) when is_binary(value), do: String.trim(value) != "" + defp present_string?(_value), do: false +end diff --git a/elixir/lib/symphony_elixir/linear/adapter.ex b/elixir/lib/symphony_elixir/linear/adapter.ex index ab17eeec4e..d2811544c5 100644 --- a/elixir/lib/symphony_elixir/linear/adapter.ex +++ b/elixir/lib/symphony_elixir/linear/adapter.ex @@ -5,87 +5,50 @@ defmodule SymphonyElixir.Linear.Adapter do @behaviour SymphonyElixir.Tracker - alias SymphonyElixir.Linear.Client + alias SymphonyElixir.Linear.{AgentTool, Client} + alias SymphonyElixir.Tracker.Issue - @create_comment_mutation """ - mutation SymphonyCreateComment($issueId: String!, $body: String!) { - commentCreate(input: {issueId: $issueId, body: $body}) { - success - } - } - """ + @spec validate_config(map()) :: :ok | {:error, term()} + def validate_config(tracker_settings) do + cond do + not present_string?(tracker_settings.endpoint) -> + {:error, :invalid_linear_endpoint} - @update_state_mutation """ - mutation SymphonyUpdateIssueState($issueId: String!, $stateId: String!) { - issueUpdate(id: $issueId, input: {stateId: $stateId}) { - success - } - } - """ + not present_string?(tracker_settings.api_key) -> + {:error, :missing_linear_api_token} - @state_lookup_query """ - query SymphonyResolveStateId($issueId: String!, $stateName: String!) { - issue(id: $issueId) { - team { - states(filter: {name: {eq: $stateName}}, first: 1) { - nodes { - id - } - } - } - } - } - """ + not present_string?(tracker_settings.project_slug) -> + {:error, :missing_linear_project_slug} - @spec fetch_candidate_issues() :: {:ok, [term()]} | {:error, term()} - def fetch_candidate_issues, do: client_module().fetch_candidate_issues() + not is_nil(tracker_settings.assignee) and not present_string?(tracker_settings.assignee) -> + {:error, :invalid_linear_assignee} + + true -> + :ok + end + end - @spec fetch_issues_by_states([String.t()]) :: {:ok, [term()]} | {:error, term()} + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {: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 fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(issue_ids), do: client_module().fetch_issues_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 agent_tool_specs() :: [map()] + def agent_tool_specs, do: AgentTool.tool_specs() - @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 + @spec execute_agent_tool(String.t(), term(), keyword()) :: map() + def execute_agent_tool(tool, arguments, opts) do + AgentTool.execute(tool, arguments, opts) end + @spec secret_environment_names(map()) :: [String.t()] + def secret_environment_names(tracker_settings), do: tracker_settings.secret_environment_names + 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 + defp present_string?(value) when is_binary(value), do: String.trim(value) != "" + defp present_string?(_value), do: false end diff --git a/elixir/lib/symphony_elixir/linear/agent_tool.ex b/elixir/lib/symphony_elixir/linear/agent_tool.ex new file mode 100644 index 0000000000..73cfd725c9 --- /dev/null +++ b/elixir/lib/symphony_elixir/linear/agent_tool.ex @@ -0,0 +1,210 @@ +defmodule SymphonyElixir.Linear.AgentTool do + @moduledoc """ + Provider-native Linear tool exposed to 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) + client_opts = Keyword.take(opts, [:tracker_settings]) + + with {:ok, query, variables} <- normalize_linear_graphql_arguments(arguments), + {:ok, response} <- linear_client.(query, variables, client_opts) 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 + + dynamic_tool_response(success, encode_payload(response)) + end + + defp failure_response(payload) do + dynamic_tool_response(false, encode_payload(payload)) + end + + defp dynamic_tool_response(success, output) when is_boolean(success) and is_binary(output) do + %{ + "success" => success, + "output" => output, + "contentItems" => [ + %{ + "type" => "inputText", + "text" => output + } + ] + } + 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 `tracker.provider.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/linear/client.ex b/elixir/lib/symphony_elixir/linear/client.ex index ad8eee550d..fb32b6078f 100644 --- a/elixir/lib/symphony_elixir/linear/client.ex +++ b/elixir/lib/symphony_elixir/linear/client.ex @@ -4,7 +4,8 @@ defmodule SymphonyElixir.Linear.Client do """ require Logger - alias SymphonyElixir.{Config, Linear.Issue} + alias SymphonyElixir.Config + alias SymphonyElixir.Tracker.Issue @issue_page_size 50 @max_error_body_log_bytes 1_000 @@ -55,8 +56,8 @@ defmodule SymphonyElixir.Linear.Client do """ @query_by_ids """ - query SymphonyLinearIssuesById($ids: [ID!]!, $first: Int!, $relationFirst: Int!) { - issues(filter: {id: {in: $ids}}, first: $first) { + query SymphonyLinearIssuesById($ids: [ID!]!, $projectSlug: String!, $first: Int!, $relationFirst: Int!) { + issues(filter: {id: {in: $ids}, project: {slugId: {eq: $projectSlug}}}, first: $first) { nodes { id identifier @@ -103,48 +104,24 @@ defmodule SymphonyElixir.Linear.Client do } """ - @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} + case normalized_states do + [] -> + {:ok, []} - true -> - do_fetch_by_states(project_slug, normalized_states, nil) - end + states -> + with {:ok, tracker} <- configured_tracker_for_read(), + {:ok, assignee_filter} <- routing_assignee_filter() do + do_fetch_by_states(tracker.project_slug, states, assignee_filter) + 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 + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(issue_ids) when is_list(issue_ids) do ids = Enum.uniq(issue_ids) case ids do @@ -152,8 +129,9 @@ defmodule SymphonyElixir.Linear.Client do {:ok, []} ids -> - with {:ok, assignee_filter} <- routing_assignee_filter() do - do_fetch_issue_states(ids, assignee_filter) + with {:ok, tracker} <- configured_tracker_for_read(), + {:ok, assignee_filter} <- routing_assignee_filter() do + do_fetch_issue_states(ids, tracker.project_slug, assignee_filter) end end end @@ -162,9 +140,14 @@ defmodule SymphonyElixir.Linear.Client do 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) + tracker_settings = Keyword.get_lazy(opts, :tracker_settings, fn -> Config.settings!().tracker end) + + request_fun = + Keyword.get(opts, :request_fun, fn request_payload, headers -> + post_graphql_request(request_payload, headers, tracker_settings.endpoint) + end) - with {:ok, headers} <- graphql_headers(), + with {:ok, headers} <- graphql_headers(tracker_settings), {:ok, %{status: 200, body: body}} <- request_fun.(payload, headers) do {:ok, body} else @@ -218,6 +201,22 @@ defmodule SymphonyElixir.Linear.Client do |> finalize_paginated_issues() end + @doc false + @spec fetch_issues_by_ids_for_test([String.t()], (String.t(), map() -> {:ok, map()} | {:error, term()})) :: + {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids_for_test(issue_ids, graphql_fun) + when is_list(issue_ids) and is_function(graphql_fun, 2) do + ids = Enum.uniq(issue_ids) + + case ids do + [] -> + {:ok, []} + + ids -> + do_fetch_issue_states(ids, "test-project", nil, graphql_fun) + end + end + defp do_fetch_by_states(project_slug, state_names, assignee_filter) do do_fetch_by_states_page(project_slug, state_names, assignee_filter, nil, []) end @@ -253,20 +252,67 @@ defmodule SymphonyElixir.Linear.Client do defp finalize_paginated_issues(acc_issues) when is_list(acc_issues), do: Enum.reverse(acc_issues) - defp do_fetch_issue_states(ids, assignee_filter) do - case graphql(@query_by_ids, %{ - ids: ids, - first: Enum.min([length(ids), @issue_page_size]), + defp do_fetch_issue_states(ids, project_slug, assignee_filter) do + do_fetch_issue_states(ids, project_slug, assignee_filter, &graphql/2) + end + + defp do_fetch_issue_states(ids, project_slug, assignee_filter, graphql_fun) + when is_list(ids) and is_binary(project_slug) and is_function(graphql_fun, 2) do + issue_order_index = issue_order_index(ids) + do_fetch_issue_states_page(ids, project_slug, assignee_filter, graphql_fun, [], issue_order_index) + end + + defp do_fetch_issue_states_page([], _project_slug, _assignee_filter, _graphql_fun, acc_issues, issue_order_index) do + acc_issues + |> finalize_paginated_issues() + |> sort_issues_by_requested_ids(issue_order_index) + |> then(&{:ok, &1}) + end + + defp do_fetch_issue_states_page(ids, project_slug, assignee_filter, graphql_fun, acc_issues, issue_order_index) do + {batch_ids, rest_ids} = Enum.split(ids, @issue_page_size) + + case graphql_fun.(@query_by_ids, %{ + ids: batch_ids, + projectSlug: project_slug, + first: length(batch_ids), relationFirst: @issue_page_size }) do {:ok, body} -> - decode_linear_response(body, assignee_filter) + with {:ok, issues} <- decode_linear_response_strict(body, assignee_filter) do + updated_acc = prepend_page_issues(issues, acc_issues) + + do_fetch_issue_states_page( + rest_ids, + project_slug, + assignee_filter, + graphql_fun, + updated_acc, + issue_order_index + ) + end {:error, reason} -> {:error, reason} end end + defp issue_order_index(ids) when is_list(ids) do + ids + |> Enum.with_index() + |> Map.new() + end + + defp sort_issues_by_requested_ids(issues, issue_order_index) + when is_list(issues) and is_map(issue_order_index) do + fallback_index = map_size(issue_order_index) + + Enum.sort_by(issues, fn + %Issue{id: issue_id} -> Map.get(issue_order_index, issue_id, fallback_index) + _ -> fallback_index + end) + end + defp build_graphql_payload(query, variables, operation_name) do %{ "query" => query, @@ -324,8 +370,8 @@ defmodule SymphonyElixir.Linear.Client do end end - defp graphql_headers do - case Config.linear_api_token() do + defp graphql_headers(tracker_settings) do + case tracker_settings.api_key do nil -> {:error, :missing_linear_api_token} @@ -338,28 +384,55 @@ defmodule SymphonyElixir.Linear.Client do end end - defp post_graphql_request(payload, headers) do - Req.post(Config.linear_endpoint(), + defp post_graphql_request(payload, headers, endpoint) do + Req.post(endpoint, headers: headers, json: payload, connect_options: [timeout: 30_000] ) end - defp decode_linear_response(%{"data" => %{"issues" => %{"nodes" => nodes}}}, assignee_filter) do + defp decode_linear_response(response, assignee_filter) do + decode_linear_response(response, assignee_filter, :drop_malformed) + end + + defp decode_linear_response_strict(response, assignee_filter) do + decode_linear_response(response, assignee_filter, :error_on_malformed) + end + + defp decode_linear_response( + %{"data" => %{"issues" => %{"nodes" => nodes}}}, + assignee_filter, + malformed_policy + ) + when is_list(nodes) do issues = nodes |> Enum.map(&normalize_issue(&1, assignee_filter)) - |> Enum.reject(&is_nil(&1)) - {:ok, issues} + malformed_count = Enum.count(issues, &is_nil/1) + + case {malformed_policy, malformed_count > 0} do + {:error_on_malformed, true} -> + {:error, :linear_unknown_payload} + + {:drop_malformed, true} -> + Logger.warning("Dropping malformed Linear issue records count=#{malformed_count}") + {:ok, Enum.reject(issues, &is_nil/1)} + + {:drop_malformed, false} -> + {:ok, issues} + + {_, false} -> + {:ok, issues} + end end - defp decode_linear_response(%{"errors" => errors}, _assignee_filter) do + defp decode_linear_response(%{"errors" => errors}, _assignee_filter, _malformed_policy) do {:error, {:linear_graphql_errors, errors}} end - defp decode_linear_response(_unknown, _assignee_filter) do + defp decode_linear_response(_unknown, _assignee_filter, _malformed_policy) do {:error, :linear_unknown_payload} end @@ -390,24 +463,29 @@ defmodule SymphonyElixir.Linear.Client do 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"]) - } + state_name = get_in(issue, ["state", "name"]) + + if Enum.all?([issue["id"], issue["identifier"], issue["title"], state_name], &present_string?/1) do + assignee = issue["assignee"] + blockers = extract_blockers(issue) + + %Issue{ + id: issue["id"], + identifier: issue["identifier"], + title: issue["title"], + description: issue["description"], + priority: parse_priority(issue["priority"]), + state: state_name, + branch_name: issue["branchName"], + url: issue["url"], + assignee_id: assignee_field(assignee, "id"), + blocked_by: blockers, + labels: extract_labels(issue), + dispatchable: dispatchable?(state_name, blockers, assignee, assignee_filter), + created_at: parse_datetime(issue["createdAt"]), + updated_at: parse_datetime(issue["updatedAt"]) + } + end end defp normalize_issue(_issue, _assignee_filter), do: nil @@ -415,6 +493,40 @@ defmodule SymphonyElixir.Linear.Client do defp assignee_field(%{} = assignee, field) when is_binary(field), do: assignee[field] defp assignee_field(_assignee, _field), do: nil + defp dispatchable?(state_name, blockers, assignee, assignee_filter) do + assigned_to_worker?(assignee, assignee_filter) and + not blocked_before_dispatch?(state_name, blockers) + end + + defp blocked_before_dispatch?(state_name, blockers) + when is_binary(state_name) and is_list(blockers) do + normalize_state_name(state_name) == "todo" and + Enum.any?(blockers, fn + %{state: blocker_state} when is_binary(blocker_state) -> + not terminal_state?(blocker_state) + + _ -> + true + end) + end + + defp blocked_before_dispatch?(_state_name, _blockers), do: false + + defp terminal_state?(state_name) when is_binary(state_name) do + terminal_states = + Config.settings!().tracker.terminal_states + |> Enum.map(&normalize_state_name/1) + |> MapSet.new() + + MapSet.member?(terminal_states, normalize_state_name(state_name)) + end + + defp normalize_state_name(state_name) when is_binary(state_name) do + state_name + |> String.trim() + |> String.downcase() + end + defp assigned_to_worker?(_assignee, nil), do: true defp assigned_to_worker?(%{} = assignee, %{match_values: match_values}) @@ -432,7 +544,7 @@ defmodule SymphonyElixir.Linear.Client do defp assignee_id(%{} = assignee), do: normalize_assignee_match_value(assignee["id"]) defp routing_assignee_filter do - case Config.linear_assignee() do + case Config.settings!().tracker.assignee do nil -> {:ok, nil} @@ -441,6 +553,16 @@ defmodule SymphonyElixir.Linear.Client do end end + defp configured_tracker_for_read do + tracker = Config.settings!().tracker + + cond do + is_nil(tracker.api_key) -> {:error, :missing_linear_api_token} + is_nil(tracker.project_slug) -> {:error, :missing_linear_project_slug} + true -> {:ok, tracker} + end + end + defp build_assignee_filter(assignee) when is_binary(assignee) do case normalize_assignee_match_value(assignee) do nil -> @@ -485,8 +607,10 @@ defmodule SymphonyElixir.Linear.Client do 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) + |> Enum.filter(&is_binary/1) + |> Enum.map(&(String.trim(&1) |> String.downcase())) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() end defp extract_labels(_), do: [] @@ -516,6 +640,9 @@ defmodule SymphonyElixir.Linear.Client do defp extract_blockers(_), do: [] + defp present_string?(value) when is_binary(value), do: String.trim(value) != "" + defp present_string?(_value), do: false + defp parse_datetime(nil), do: nil defp parse_datetime(raw) do 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/orchestrator.ex b/elixir/lib/symphony_elixir/orchestrator.ex index a4dead129e..b52e991c08 100644 --- a/elixir/lib/symphony_elixir/orchestrator.ex +++ b/elixir/lib/symphony_elixir/orchestrator.ex @@ -1,6 +1,6 @@ defmodule SymphonyElixir.Orchestrator do @moduledoc """ - Polls Linear and dispatches repository copies to Codex-backed workers. + Polls the configured issue tracker and dispatches repository copies to Codex-backed workers. """ use GenServer @@ -8,7 +8,7 @@ defmodule SymphonyElixir.Orchestrator do import Bitwise, only: [<<<: 2] alias SymphonyElixir.{AgentRunner, Config, StatusDashboard, Tracker, Workspace} - alias SymphonyElixir.Linear.Issue + alias SymphonyElixir.Tracker.Issue @continuation_retry_delay_ms 1_000 @failure_retry_base_ms 10_000 @@ -31,15 +31,20 @@ defmodule SymphonyElixir.Orchestrator do :max_concurrent_agents, :next_poll_due_at_ms, :poll_check_in_progress, + :tick_timer_ref, + :tick_token, + task_supervisor: SymphonyElixir.TaskSupervisor, running: %{}, completed: MapSet.new(), claimed: MapSet.new(), + blocked: %{}, retry_attempts: %{}, codex_totals: nil, codex_rate_limits: nil ] end + @doc false @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts \\ []) do name = Keyword.get(opts, :name, __MODULE__) @@ -47,28 +52,63 @@ defmodule SymphonyElixir.Orchestrator do end @impl true - def init(_opts) do - now_ms = System.monotonic_time(:millisecond) + def init(opts) do + case Config.settings() do + {:ok, config} -> + now_ms = System.monotonic_time(:millisecond) + + state = %State{ + poll_interval_ms: config.polling.interval_ms, + max_concurrent_agents: config.agent.max_concurrent_agents, + next_poll_due_at_ms: now_ms, + poll_check_in_progress: false, + tick_timer_ref: nil, + tick_token: nil, + task_supervisor: Keyword.get(opts, :task_supervisor, SymphonyElixir.TaskSupervisor), + codex_totals: @empty_codex_totals, + codex_rate_limits: nil + } - 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() + state = schedule_tick(state, 0) - run_terminal_workspace_cleanup() - :ok = schedule_tick(0) + {:ok, state} - {:ok, state} + {:error, reason} -> + {:stop, reason} + end end @impl true + def handle_info({:tick, tick_token}, %{tick_token: tick_token} = state) + when is_reference(tick_token) do + state = refresh_runtime_config(state) + + state = %{ + state + | poll_check_in_progress: true, + next_poll_due_at_ms: nil, + tick_timer_ref: nil, + tick_token: nil + } + + notify_dashboard() + :ok = schedule_poll_cycle_start() + {:noreply, state} + end + + def handle_info({:tick, _tick_token}, state), do: {:noreply, state} + def handle_info(:tick, state) do state = refresh_runtime_config(state) - state = %{state | poll_check_in_progress: true, next_poll_due_at_ms: nil} + + state = %{ + state + | poll_check_in_progress: true, + next_poll_due_at_ms: nil, + tick_timer_ref: nil, + tick_token: nil + } notify_dashboard() :ok = schedule_poll_cycle_start() @@ -78,11 +118,8 @@ defmodule SymphonyElixir.Orchestrator do 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} + state = schedule_tick(state, state.poll_interval_ms) + state = %{state | poll_check_in_progress: false} notify_dashboard() {:noreply, state} @@ -101,33 +138,29 @@ defmodule SymphonyElixir.Orchestrator do 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 = handle_agent_down(reason, state, issue_id, running_entry, session_id) - 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") + Logger.info("Agent task finished for issue_id=#{issue_id} session_id=#{session_id} reason=#{inspect(reason)}") - next_attempt = next_retry_attempt_from_running(running_entry) + notify_dashboard() + {:noreply, state} + end + end - schedule_issue_retry(state, issue_id, next_attempt, %{ - identifier: running_entry.identifier, - error: "agent exited: #{inspect(reason)}" - }) - end + def handle_info({:worker_runtime_info, issue_id, runtime_info}, %{running: running} = state) + when is_binary(issue_id) and is_map(runtime_info) do + case Map.get(running, issue_id) do + nil -> + {:noreply, state} - Logger.info("Agent task finished for issue_id=#{issue_id} session_id=#{session_id} reason=#{inspect(reason)}") + running_entry -> + updated_running_entry = + running_entry + |> maybe_put_runtime_value(:worker_host, runtime_info[:worker_host]) + |> maybe_put_runtime_value(:workspace_path, runtime_info[:workspace_path]) notify_dashboard() - {:noreply, state} + {:noreply, %{state | running: Map.put(running, issue_id, updated_running_entry)}} end end @@ -154,9 +187,9 @@ defmodule SymphonyElixir.Orchestrator do def handle_info({:codex_worker_update, _issue_id, _update}, state), do: {:noreply, state} - def handle_info({:retry_issue, issue_id}, state) do + def handle_info({:retry_issue, issue_id, retry_token}, state) do result = - case pop_retry_attempt_state(state, issue_id) do + case pop_retry_attempt_state(state, issue_id, retry_token) do {:ok, attempt, metadata, state} -> handle_retry_issue(state, issue_id, attempt, metadata) :missing -> {:noreply, state} end @@ -165,25 +198,78 @@ defmodule SymphonyElixir.Orchestrator do result end + def handle_info({:retry_issue, _issue_id}, state), do: {:noreply, state} + def handle_info(msg, state) do Logger.debug("Orchestrator ignored message: #{inspect(msg)}") {:noreply, state} end + defp handle_agent_down(:normal, state, issue_id, running_entry, session_id) do + if input_required_blocker?(running_entry) do + block_input_required_agent_down(state, issue_id, running_entry, session_id, :normal) + else + 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, + issue_url: running_entry.issue.url, + delay_type: :continuation, + worker_host: Map.get(running_entry, :worker_host), + workspace_path: Map.get(running_entry, :workspace_path) + }) + end + end + + defp handle_agent_down(reason, state, issue_id, running_entry, session_id) do + if input_required_blocker?(running_entry) do + block_input_required_agent_down(state, issue_id, running_entry, session_id, reason) + else + retry_agent_down(state, issue_id, running_entry, session_id, reason) + end + end + + defp block_input_required_agent_down(state, issue_id, running_entry, session_id, reason) do + error = blocker_error(running_entry, "agent exited: #{inspect(reason)}") + + Logger.warning("Agent task blocked for issue_id=#{issue_id} issue_identifier=#{running_entry.identifier} session_id=#{session_id}: #{error}") + + block_issue_from_entry(state, issue_id, running_entry, error) + end + + defp retry_agent_down(state, issue_id, running_entry, session_id, reason) do + 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, + issue_url: running_entry.issue.url, + error: "agent exited: #{inspect(reason)}", + worker_host: Map.get(running_entry, :worker_host), + workspace_path: Map.get(running_entry, :workspace_path) + }) + end + defp maybe_dispatch(%State{} = state) do - state = reconcile_running_issues(state) + state = + state + |> reconcile_running_issues() + |> reconcile_blocked_issues() with :ok <- Config.validate!(), - {:ok, issues} <- Tracker.fetch_candidate_issues(), + {:ok, issues} <- Tracker.fetch_issues_by_states(Config.settings!().tracker.active_states), 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") + Logger.error("Tracker API token missing in WORKFLOW.md") state {:error, :missing_linear_project_slug} -> - Logger.error("Linear project slug missing in WORKFLOW.md") + Logger.error("Tracker project scope missing in WORKFLOW.md") state {:error, :missing_tracker_kind} -> @@ -196,20 +282,8 @@ defmodule SymphonyElixir.Orchestrator do 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)}") + {:error, {:invalid_workflow_config, message}} -> + Logger.error("Invalid WORKFLOW.md config: #{message}") state {:error, {:missing_workflow_file, path, reason}} -> @@ -225,7 +299,7 @@ defmodule SymphonyElixir.Orchestrator do state {:error, reason} -> - Logger.error("Failed to fetch from Linear: #{inspect(reason)}") + Logger.error("Failed to fetch from issue tracker: #{inspect(reason)}") state false -> @@ -240,14 +314,15 @@ defmodule SymphonyElixir.Orchestrator do if running_ids == [] do state else - case Tracker.fetch_issue_states_by_ids(running_ids) do + case Tracker.fetch_issues_by_ids(running_ids) do {:ok, issues} -> - reconcile_running_issue_states( - issues, + issues + |> reconcile_running_issue_states( state, active_state_set(), terminal_state_set() ) + |> reconcile_missing_running_issue_ids(running_ids, issues) {:error, reason} -> Logger.debug("Failed to refresh running issue states: #{inspect(reason)}; keeping active workers") @@ -257,6 +332,30 @@ defmodule SymphonyElixir.Orchestrator do end end + defp reconcile_blocked_issues(%State{} = state) do + blocked_ids = Map.keys(state.blocked) + + if blocked_ids == [] do + state + else + case Tracker.fetch_issues_by_ids(blocked_ids) do + {:ok, issues} -> + issues + |> reconcile_blocked_issue_states( + state, + active_state_set(), + terminal_state_set() + ) + |> reconcile_missing_blocked_issue_ids(blocked_ids, issues) + + {:error, reason} -> + Logger.debug("Failed to refresh blocked issue states: #{inspect(reason)}; keeping blocked issues") + + 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 @@ -267,6 +366,21 @@ defmodule SymphonyElixir.Orchestrator do reconcile_running_issue_states(issues, state, active_state_set(), terminal_state_set()) end + @doc false + @spec reconcile_blocked_issue_states_for_test([Issue.t()], term()) :: term() + def reconcile_blocked_issue_states_for_test(issues, %State{} = state) when is_list(issues) do + reconcile_blocked_issue_states(issues, state, active_state_set(), terminal_state_set()) + end + + @doc false + @spec handle_retry_issue_lookup_for_test(Issue.t(), term(), String.t(), non_neg_integer(), map()) :: + term() + def handle_retry_issue_lookup_for_test(%Issue{} = issue, %State{} = state, issue_id, attempt, metadata) + when is_binary(issue_id) and is_integer(attempt) and attempt >= 0 and is_map(metadata) do + {:noreply, updated_state} = handle_retry_issue_lookup(issue, state, issue_id, attempt, metadata) + updated_state + end + @doc false @spec should_dispatch_issue_for_test(Issue.t(), term()) :: boolean() def should_dispatch_issue_for_test(%Issue{} = issue, %State{} = state) do @@ -287,6 +401,12 @@ defmodule SymphonyElixir.Orchestrator do sort_issues_for_dispatch(issues) end + @doc false + @spec select_worker_host_for_test(term(), String.t() | nil) :: String.t() | nil | :no_worker_capacity + def select_worker_host_for_test(%State{} = state, preferred_worker_host) do + select_worker_host(state, preferred_worker_host) + 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 @@ -305,7 +425,7 @@ defmodule SymphonyElixir.Orchestrator do terminate_running_issue(state, issue.id, true) - !issue_routable_to_worker?(issue) -> + !issue_routable?(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) @@ -322,6 +442,95 @@ defmodule SymphonyElixir.Orchestrator do defp reconcile_issue_state(_issue, state, _active_states, _terminal_states), do: state + defp reconcile_blocked_issue_states([], state, _active_states, _terminal_states), do: state + + defp reconcile_blocked_issue_states([issue | rest], state, active_states, terminal_states) do + reconcile_blocked_issue_states( + rest, + reconcile_blocked_issue_state(issue, state, active_states, terminal_states), + active_states, + terminal_states + ) + end + + defp reconcile_blocked_issue_state(%Issue{} = issue, state, active_states, terminal_states) do + cond do + terminal_issue_state?(issue.state, terminal_states) -> + Logger.info("Blocked issue moved to terminal state: #{issue_context(issue)} state=#{issue.state}; releasing block") + cleanup_issue_workspace(issue, Map.get(state.blocked, issue.id, %{})) + release_issue_claim(state, issue.id) + + !issue_routable?(issue) -> + Logger.info("Blocked issue no longer routed to this worker: #{issue_context(issue)} assignee=#{inspect(issue.assignee_id)}; releasing block") + release_issue_claim(state, issue.id) + + active_issue_state?(issue.state, active_states) -> + refresh_blocked_issue_state(state, issue) + + true -> + Logger.info("Blocked issue moved to non-active state: #{issue_context(issue)} state=#{issue.state}; releasing block") + release_issue_claim(state, issue.id) + end + end + + defp reconcile_blocked_issue_state(_issue, state, _active_states, _terminal_states), do: state + + defp reconcile_missing_running_issue_ids(%State{} = state, requested_issue_ids, issues) + when is_list(requested_issue_ids) and is_list(issues) do + visible_issue_ids = + issues + |> Enum.flat_map(fn + %Issue{id: issue_id} when is_binary(issue_id) -> [issue_id] + _ -> [] + end) + |> MapSet.new() + + Enum.reduce(requested_issue_ids, state, fn issue_id, state_acc -> + if MapSet.member?(visible_issue_ids, issue_id) do + state_acc + else + log_missing_running_issue(state_acc, issue_id) + terminate_running_issue(state_acc, issue_id, false) + end + end) + end + + defp reconcile_missing_running_issue_ids(state, _requested_issue_ids, _issues), do: state + + defp reconcile_missing_blocked_issue_ids(%State{} = state, requested_issue_ids, issues) + when is_list(requested_issue_ids) and is_list(issues) do + visible_issue_ids = + issues + |> Enum.flat_map(fn + %Issue{id: issue_id} when is_binary(issue_id) -> [issue_id] + _ -> [] + end) + |> MapSet.new() + + Enum.reduce(requested_issue_ids, state, fn issue_id, state_acc -> + if MapSet.member?(visible_issue_ids, issue_id) do + state_acc + else + Logger.info("Blocked issue no longer visible during state refresh: issue_id=#{issue_id}; releasing block") + release_issue_claim(state_acc, issue_id) + end + end) + end + + defp reconcile_missing_blocked_issue_ids(state, _requested_issue_ids, _issues), do: state + + defp log_missing_running_issue(%State{} = state, issue_id) when is_binary(issue_id) do + case Map.get(state.running, issue_id) do + %{identifier: identifier} -> + Logger.info("Issue no longer visible during running-state refresh: issue_id=#{issue_id} issue_identifier=#{identifier}; stopping active agent") + + _ -> + Logger.info("Issue no longer visible during running-state refresh: issue_id=#{issue_id}; stopping active agent") + end + end + + defp log_missing_running_issue(_state, _issue_id), do: :ok + defp refresh_running_issue_state(%State{} = state, %Issue{} = issue) do case Map.get(state.running, issue.id) do %{issue: _} = running_entry -> @@ -332,6 +541,16 @@ defmodule SymphonyElixir.Orchestrator do end end + defp refresh_blocked_issue_state(%State{} = state, %Issue{} = issue) do + case Map.get(state.blocked, issue.id) do + %{issue: _} = blocked_entry -> + %{state | blocked: Map.put(state.blocked, issue.id, %{blocked_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 -> @@ -340,22 +559,17 @@ defmodule SymphonyElixir.Orchestrator do %{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 + stop_running_task(pid, ref, state.task_supervisor) - if is_pid(pid) do - terminate_task(pid) - end - - if is_reference(ref) do - Process.demonitor(ref, [:flush]) + if cleanup_workspace do + cleanup_issue_workspace(Map.get(running_entry, :issue, identifier), running_entry) end %{ state | running: Map.delete(state.running, issue_id), claimed: MapSet.delete(state.claimed, issue_id), + blocked: Map.delete(state.blocked, issue_id), retry_attempts: Map.delete(state.retry_attempts, issue_id) } @@ -365,7 +579,7 @@ defmodule SymphonyElixir.Orchestrator do end defp reconcile_stalled_running_issues(%State{} = state) do - timeout_ms = Config.codex_stall_timeout_ms() + timeout_ms = Config.settings!().codex.stall_timeout_ms cond do timeout_ms <= 0 -> @@ -378,11 +592,19 @@ defmodule SymphonyElixir.Orchestrator do 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) + maybe_restart_stalled_issue(state_acc, issue_id, running_entry, now, timeout_ms) end) end end + defp maybe_restart_stalled_issue(state, issue_id, running_entry, now, timeout_ms) do + if Map.has_key?(state.blocked, issue_id) do + state + else + restart_stalled_issue(state, issue_id, running_entry, now, timeout_ms) + end + end + defp restart_stalled_issue(state, issue_id, running_entry, now, timeout_ms) do elapsed_ms = stall_elapsed_ms(running_entry, now) @@ -390,16 +612,27 @@ defmodule SymphonyElixir.Orchestrator 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") + if input_required_blocker?(running_entry) do + error = blocker_error(running_entry, "stalled for #{elapsed_ms}ms after Codex requested operator input") - next_attempt = next_retry_attempt_from_running(running_entry) + Logger.warning("Issue blocked: issue_id=#{issue_id} issue_identifier=#{identifier} session_id=#{session_id} elapsed_ms=#{elapsed_ms}; #{error}") - 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" - }) + state + |> record_session_completion_totals(running_entry) + |> stop_and_block_issue(issue_id, running_entry, error) + else + 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, + issue_url: running_entry.issue.url, + error: "stalled for #{elapsed_ms}ms without codex activity" + }) + end else state end @@ -423,8 +656,72 @@ defmodule SymphonyElixir.Orchestrator do 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 + defp input_required_blocker?(running_entry) when is_map(running_entry) do + Map.get(running_entry, :last_codex_event) in [:turn_input_required, :approval_required] or + not is_nil(input_required_completion_outcome(Map.get(running_entry, :completion))) or + codex_message_method(Map.get(running_entry, :last_codex_message)) == + "mcpServer/elicitation/request" + end + + defp input_required_blocker?(_running_entry), do: false + + defp input_required_completion_outcome(completion) when is_map(completion) do + outcome = Map.get(completion, :outcome) || Map.get(completion, "outcome") + normalize_input_required_outcome(outcome) + end + + defp input_required_completion_outcome(_completion), do: nil + + defp normalize_input_required_outcome(outcome) + when outcome in [:input_required, :needs_input, :approval_required], + do: outcome + + defp normalize_input_required_outcome(outcome) when is_binary(outcome) do + case outcome do + "input_required" -> :input_required + "needs_input" -> :needs_input + "approval_required" -> :approval_required + _ -> nil + end + end + + defp normalize_input_required_outcome(_outcome), do: nil + + defp blocker_error(running_entry, fallback) when is_map(running_entry) do + codex_event_blocker_error(Map.get(running_entry, :last_codex_event)) || + completion_blocker_error(Map.get(running_entry, :completion)) || + codex_message_blocker_error(Map.get(running_entry, :last_codex_message)) || + fallback + end + + defp blocker_error(_running_entry, fallback), do: fallback + + defp codex_event_blocker_error(:turn_input_required), do: "codex turn requires operator input" + defp codex_event_blocker_error(:approval_required), do: "codex turn requires approval" + defp codex_event_blocker_error(_event), do: nil + + defp completion_blocker_error(completion) do + case input_required_completion_outcome(completion) do + outcome when outcome in [:input_required, :needs_input] -> "codex turn requires operator input" + :approval_required -> "codex turn requires approval" + nil -> nil + end + end + + defp codex_message_blocker_error(message) do + if codex_message_method(message) == "mcpServer/elicitation/request" do + "codex MCP elicitation requires operator input" + end + end + + defp codex_message_method(%{message: %{"method" => method}}) when is_binary(method), do: method + defp codex_message_method(%{message: %{method: method}}) when is_binary(method), do: method + defp codex_message_method(%{"method" => method}) when is_binary(method), do: method + defp codex_message_method(%{method: method}) when is_binary(method), do: method + defp codex_message_method(_message), do: nil + + defp terminate_task(pid, task_supervisor) when is_pid(pid) do + case Task.Supervisor.terminate_child(task_supervisor, pid) do :ok -> :ok @@ -433,7 +730,53 @@ defmodule SymphonyElixir.Orchestrator do end end - defp terminate_task(_pid), do: :ok + defp terminate_task(_pid, _task_supervisor), do: :ok + + defp stop_running_task(pid, ref, task_supervisor) do + if is_pid(pid) do + terminate_task(pid, task_supervisor) + end + + if is_reference(ref) do + Process.demonitor(ref, [:flush]) + end + + :ok + end + + defp stop_and_block_issue(%State{} = state, issue_id, running_entry, error) do + stop_running_task( + Map.get(running_entry, :pid), + Map.get(running_entry, :ref), + state.task_supervisor + ) + + block_issue_from_entry(state, issue_id, running_entry, error) + end + + defp block_issue_from_entry(%State{} = state, issue_id, running_entry, error) do + blocked_entry = %{ + issue_id: issue_id, + identifier: Map.get(running_entry, :identifier, issue_id), + issue: Map.get(running_entry, :issue), + worker_host: Map.get(running_entry, :worker_host), + workspace_path: Map.get(running_entry, :workspace_path), + session_id: running_entry_session_id(running_entry), + error: error, + blocked_at: DateTime.utc_now(), + last_codex_message: Map.get(running_entry, :last_codex_message), + last_codex_event: Map.get(running_entry, :last_codex_event), + last_codex_timestamp: Map.get(running_entry, :last_codex_timestamp) + } + + %{ + state + | running: Map.delete(state.running, issue_id), + retry_attempts: Map.delete(state.retry_attempts, issue_id), + claimed: MapSet.put(state.claimed, issue_id), + blocked: Map.put(state.blocked, issue_id, blocked_entry) + } + end defp choose_issues(issues, state) do active_states = active_state_set() @@ -472,16 +815,17 @@ defmodule SymphonyElixir.Orchestrator do defp should_dispatch_issue?( %Issue{} = issue, - %State{running: running, claimed: claimed} = state, + %State{running: running, claimed: claimed, blocked: blocked} = 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 + !Map.has_key?(blocked, issue.id) and available_slots(state) > 0 and - state_slots_available?(issue, running) + state_slots_available?(issue, running) and + worker_slots_available?(state) end defp should_dispatch_issue?(_issue, _state, _active_states, _terminal_states), do: false @@ -517,42 +861,27 @@ defmodule SymphonyElixir.Orchestrator do 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 + Enum.all?([id, identifier, title, state_name], &present_string?/1) and + issue_routable?(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) + defp issue_routable?(%Issue{} = issue) do + Issue.routable?(issue, Config.settings!().tracker.required_labels) 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 present_string?(value) when is_binary(value), do: String.trim(value) != "" + defp present_string?(_value), 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 @@ -562,49 +891,73 @@ defmodule SymphonyElixir.Orchestrator do end defp terminal_state_set do - Config.linear_terminal_states() + Config.settings!().tracker.terminal_states |> Enum.map(&normalize_issue_state/1) |> Enum.filter(&(&1 != "")) |> MapSet.new() end defp active_state_set do - Config.linear_active_states() + Config.settings!().tracker.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 + defp dispatch_issue(%State{} = state, issue, attempt \\ nil, preferred_worker_host \\ nil) do + case refresh_issue_for_dispatch(issue) do {:ok, %Issue{} = refreshed_issue} -> - do_dispatch_issue(state, refreshed_issue, attempt) + do_dispatch_issue(state, refreshed_issue, attempt, preferred_worker_host) + + {:skip, _reason} -> + state + + {:error, _reason} -> + state + end + end + + defp refresh_issue_for_dispatch(issue) do + case revalidate_issue_for_dispatch(issue, &Tracker.fetch_issues_by_ids/1, terminal_state_set()) do + {:ok, %Issue{} = refreshed_issue} -> + {:ok, refreshed_issue} {:skip, :missing} -> Logger.info("Skipping dispatch; issue no longer active or visible: #{issue_context(issue)}") - state + {:skip, :missing} {: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 + {:skip, refreshed_issue} {:error, reason} -> Logger.warning("Skipping dispatch; issue refresh failed for #{issue_context(issue)}: #{inspect(reason)}") - state + {:error, reason} end end - defp do_dispatch_issue(%State{} = state, issue, attempt) do + defp do_dispatch_issue(%State{} = state, issue, attempt, preferred_worker_host) do recipient = self() - case Task.Supervisor.start_child(SymphonyElixir.TaskSupervisor, fn -> - AgentRunner.run(issue, recipient, attempt: attempt) + case select_worker_host(state, preferred_worker_host) do + :no_worker_capacity -> + Logger.debug("No SSH worker slots available for #{issue_context(issue)} preferred_worker_host=#{inspect(preferred_worker_host)}") + state + + worker_host -> + spawn_issue_on_worker_host(state, issue, attempt, recipient, worker_host) + end + end + + defp spawn_issue_on_worker_host(%State{} = state, issue, attempt, recipient, worker_host) do + case Task.Supervisor.start_child(state.task_supervisor, fn -> + AgentRunner.run(issue, recipient, attempt: attempt, worker_host: worker_host) end) do {:ok, pid} -> ref = Process.monitor(pid) - Logger.info("Dispatching issue to agent: #{issue_context(issue)} pid=#{inspect(pid)} attempt=#{inspect(attempt)}") + Logger.info("Dispatching issue to agent: #{issue_context(issue)} pid=#{inspect(pid)} attempt=#{inspect(attempt)} worker_host=#{worker_host || "local"}") running = Map.put(state.running, issue.id, %{ @@ -612,6 +965,8 @@ defmodule SymphonyElixir.Orchestrator do ref: ref, identifier: issue.identifier, issue: issue, + worker_host: worker_host, + workspace_path: nil, session_id: nil, last_codex_message: nil, last_codex_timestamp: nil, @@ -641,7 +996,9 @@ defmodule SymphonyElixir.Orchestrator do schedule_issue_retry(state, issue.id, next_attempt, %{ identifier: issue.identifier, - error: "failed to spawn agent: #{inspect(reason)}" + issue_url: issue.url, + error: "failed to spawn agent: #{inspect(reason)}", + worker_host: worker_host }) end end @@ -680,15 +1037,19 @@ defmodule SymphonyElixir.Orchestrator do 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) + retry_token = make_ref() due_at_ms = System.monotonic_time(:millisecond) + delay_ms identifier = pick_retry_identifier(issue_id, previous_retry, metadata) + issue_url = pick_retry_issue_url(previous_retry, metadata) error = pick_retry_error(previous_retry, metadata) + worker_host = pick_retry_worker_host(previous_retry, metadata) + workspace_path = pick_retry_workspace_path(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) + timer_ref = Process.send_after(self(), {:retry_issue, issue_id, retry_token}, delay_ms) error_suffix = if is_binary(error), do: " error=#{error}", else: "" @@ -700,19 +1061,26 @@ defmodule SymphonyElixir.Orchestrator do Map.put(state.retry_attempts, issue_id, %{ attempt: next_attempt, timer_ref: timer_ref, + retry_token: retry_token, due_at_ms: due_at_ms, identifier: identifier, - error: error + issue_url: issue_url, + error: error, + worker_host: worker_host, + workspace_path: workspace_path }) } end - defp pop_retry_attempt_state(%State{} = state, issue_id) do + defp pop_retry_attempt_state(%State{} = state, issue_id, retry_token) when is_reference(retry_token) do case Map.get(state.retry_attempts, issue_id) do - %{attempt: attempt} = retry_entry -> + %{attempt: attempt, retry_token: ^retry_token} = retry_entry -> metadata = %{ identifier: Map.get(retry_entry, :identifier), - error: Map.get(retry_entry, :error) + issue_url: Map.get(retry_entry, :issue_url), + error: Map.get(retry_entry, :error), + worker_host: Map.get(retry_entry, :worker_host), + workspace_path: Map.get(retry_entry, :workspace_path) } {:ok, attempt, metadata, %{state | retry_attempts: Map.delete(state.retry_attempts, issue_id)}} @@ -723,7 +1091,7 @@ defmodule SymphonyElixir.Orchestrator do end defp handle_retry_issue(%State{} = state, issue_id, attempt, metadata) do - case Tracker.fetch_candidate_issues() do + case Tracker.fetch_issues_by_ids([issue_id]) do {:ok, issues} -> issues |> find_issue_by_id(issue_id) @@ -749,7 +1117,7 @@ defmodule SymphonyElixir.Orchestrator 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) + cleanup_issue_workspace(issue, metadata) {:noreply, release_issue_claim(state, issue_id)} retry_candidate_issue?(issue, terminal_states) -> @@ -767,19 +1135,35 @@ defmodule SymphonyElixir.Orchestrator do {:noreply, release_issue_claim(state, issue_id)} end - defp cleanup_issue_workspace(identifier) when is_binary(identifier) do - Workspace.remove_issue_workspaces(identifier) + defp cleanup_issue_workspace(identifier, worker_host \\ nil) + + defp cleanup_issue_workspace(issue_or_identifier, metadata) when is_map(metadata) do + case Map.get(metadata, :workspace_path) do + workspace_path when is_binary(workspace_path) and workspace_path != "" -> + Workspace.remove_recorded(workspace_path, Map.get(metadata, :worker_host)) + + _ -> + cleanup_issue_workspace(issue_or_identifier, Map.get(metadata, :worker_host)) + end + end + + defp cleanup_issue_workspace(%Issue{} = issue, worker_host) do + Workspace.remove_issue_workspaces(issue, worker_host) + end + + defp cleanup_issue_workspace(identifier, worker_host) when is_binary(identifier) do + Workspace.remove_issue_workspaces(identifier, worker_host) end - defp cleanup_issue_workspace(_identifier), do: :ok + defp cleanup_issue_workspace(_issue_or_identifier, _worker_host), do: :ok defp run_terminal_workspace_cleanup do - case Tracker.fetch_issues_by_states(Config.linear_terminal_states()) do + case Tracker.fetch_issues_by_states(Config.settings!().tracker.terminal_states) do {:ok, issues} -> issues |> Enum.each(fn - %Issue{identifier: identifier} when is_binary(identifier) -> - cleanup_issue_workspace(identifier) + %Issue{} = issue -> + cleanup_issue_workspace(issue) _ -> :ok @@ -796,8 +1180,30 @@ defmodule SymphonyElixir.Orchestrator do 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)} + dispatch_slots_available?(issue, state) and + worker_slots_available?(state, metadata[:worker_host]) do + case refresh_issue_for_dispatch(issue) do + {:ok, %Issue{} = refreshed_issue} -> + {:noreply, do_dispatch_issue(state, refreshed_issue, attempt, metadata[:worker_host])} + + {:skip, :missing} -> + {:noreply, release_issue_claim(state, issue.id)} + + {:skip, %Issue{} = refreshed_issue} -> + handle_retry_issue_lookup(refreshed_issue, state, issue.id, attempt, metadata) + + {:error, reason} -> + {:noreply, + schedule_issue_retry( + state, + issue.id, + attempt + 1, + Map.merge(metadata, %{ + identifier: issue.identifier, + error: "retry dispatch refresh failed: #{inspect(reason)}" + }) + )} + end else Logger.debug("No available slots for retrying #{issue_context(issue)}; retrying again") @@ -815,7 +1221,12 @@ defmodule SymphonyElixir.Orchestrator do end defp release_issue_claim(%State{} = state, issue_id) do - %{state | claimed: MapSet.delete(state.claimed, issue_id)} + %{ + state + | claimed: MapSet.delete(state.claimed, issue_id), + blocked: Map.delete(state.blocked, issue_id), + retry_attempts: Map.delete(state.retry_attempts, issue_id) + } end defp retry_delay(attempt, metadata) when is_integer(attempt) and attempt > 0 and is_map(metadata) do @@ -828,7 +1239,7 @@ defmodule SymphonyElixir.Orchestrator do 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()) + min(@failure_retry_base_ms * (1 <<< max_delay_power), Config.settings!().agent.max_retry_backoff_ms) end defp normalize_retry_attempt(attempt) when is_integer(attempt) and attempt > 0, do: attempt @@ -845,10 +1256,90 @@ defmodule SymphonyElixir.Orchestrator do metadata[:identifier] || Map.get(previous_retry, :identifier) || issue_id end + defp pick_retry_issue_url(previous_retry, metadata) do + metadata[:issue_url] || Map.get(previous_retry, :issue_url) + end + defp pick_retry_error(previous_retry, metadata) do metadata[:error] || Map.get(previous_retry, :error) end + defp pick_retry_worker_host(previous_retry, metadata) do + metadata[:worker_host] || Map.get(previous_retry, :worker_host) + end + + defp pick_retry_workspace_path(previous_retry, metadata) do + metadata[:workspace_path] || Map.get(previous_retry, :workspace_path) + end + + defp maybe_put_runtime_value(running_entry, _key, nil), do: running_entry + + defp maybe_put_runtime_value(running_entry, key, value) when is_map(running_entry) do + Map.put(running_entry, key, value) + end + + defp select_worker_host(%State{} = state, preferred_worker_host) do + case Config.settings!().worker.ssh_hosts do + [] -> + nil + + hosts -> + available_hosts = Enum.filter(hosts, &worker_host_slots_available?(state, &1)) + + cond do + available_hosts == [] -> + :no_worker_capacity + + preferred_worker_host_available?(preferred_worker_host, available_hosts) -> + preferred_worker_host + + true -> + least_loaded_worker_host(state, available_hosts) + end + end + end + + defp preferred_worker_host_available?(preferred_worker_host, hosts) + when is_binary(preferred_worker_host) and is_list(hosts) do + preferred_worker_host != "" and preferred_worker_host in hosts + end + + defp preferred_worker_host_available?(_preferred_worker_host, _hosts), do: false + + defp least_loaded_worker_host(%State{} = state, hosts) when is_list(hosts) do + hosts + |> Enum.with_index() + |> Enum.min_by(fn {host, index} -> + {running_worker_host_count(state.running, host), index} + end) + |> elem(0) + end + + defp running_worker_host_count(running, worker_host) when is_map(running) and is_binary(worker_host) do + Enum.count(running, fn + {_issue_id, %{worker_host: ^worker_host}} -> true + _ -> false + end) + end + + defp worker_slots_available?(%State{} = state) do + select_worker_host(state, nil) != :no_worker_capacity + end + + defp worker_slots_available?(%State{} = state, preferred_worker_host) do + select_worker_host(state, preferred_worker_host) != :no_worker_capacity + end + + defp worker_host_slots_available?(%State{} = state, worker_host) when is_binary(worker_host) do + case Config.settings!().worker.max_concurrent_agents_per_host do + limit when is_integer(limit) and limit > 0 -> + running_worker_host_count(state.running, worker_host) < limit + + _ -> + true + end + end + defp find_issue_by_id(issues, issue_id) when is_binary(issue_id) do Enum.find(issues, fn %Issue{id: ^issue_id} -> @@ -877,7 +1368,8 @@ defmodule SymphonyElixir.Orchestrator do defp available_slots(%State{} = state) do max( - (state.max_concurrent_agents || Config.max_concurrent_agents()) - map_size(state.running), + (state.max_concurrent_agents || Config.settings!().agent.max_concurrent_agents) - + map_size(state.running), 0 ) end @@ -925,7 +1417,10 @@ defmodule SymphonyElixir.Orchestrator do %{ issue_id: issue_id, identifier: metadata.identifier, + issue_url: metadata.issue.url, state: metadata.issue.state, + worker_host: Map.get(metadata, :worker_host), + workspace_path: Map.get(metadata, :workspace_path), session_id: metadata.session_id, codex_app_server_pid: metadata.codex_app_server_pid, codex_input_tokens: metadata.codex_input_tokens, @@ -948,7 +1443,29 @@ defmodule SymphonyElixir.Orchestrator do attempt: attempt, due_in_ms: max(0, due_at_ms - now_ms), identifier: Map.get(retry, :identifier), - error: Map.get(retry, :error) + issue_url: Map.get(retry, :issue_url), + error: Map.get(retry, :error), + worker_host: Map.get(retry, :worker_host), + workspace_path: Map.get(retry, :workspace_path) + } + end) + + blocked = + state.blocked + |> Enum.map(fn {issue_id, metadata} -> + %{ + issue_id: issue_id, + identifier: Map.get(metadata, :identifier), + issue_url: blocked_issue_url(metadata), + state: blocked_issue_state(metadata), + worker_host: Map.get(metadata, :worker_host), + workspace_path: Map.get(metadata, :workspace_path), + session_id: Map.get(metadata, :session_id), + error: Map.get(metadata, :error), + blocked_at: Map.get(metadata, :blocked_at), + last_codex_timestamp: Map.get(metadata, :last_codex_timestamp), + last_codex_message: Map.get(metadata, :last_codex_message), + last_codex_event: Map.get(metadata, :last_codex_event) } end) @@ -956,6 +1473,7 @@ defmodule SymphonyElixir.Orchestrator do %{ running: running, retrying: retrying, + blocked: blocked, codex_totals: state.codex_totals, rate_limits: Map.get(state, :codex_rate_limits), polling: %{ @@ -970,10 +1488,7 @@ defmodule SymphonyElixir.Orchestrator 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 + state = if coalesced, do: state, else: schedule_tick(state, 0) {:reply, %{ @@ -984,6 +1499,12 @@ defmodule SymphonyElixir.Orchestrator do }, state} end + defp blocked_issue_state(%{issue: %Issue{state: state}}), do: state + defp blocked_issue_state(_metadata), do: nil + + defp blocked_issue_url(%{issue: %Issue{url: url}}), do: url + defp blocked_issue_url(_metadata), do: nil + 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) @@ -1058,9 +1579,20 @@ defmodule SymphonyElixir.Orchestrator do } end - defp schedule_tick(delay_ms) do - :timer.send_after(delay_ms, self(), :tick) - :ok + defp schedule_tick(%State{} = state, delay_ms) when is_integer(delay_ms) and delay_ms >= 0 do + if is_reference(state.tick_timer_ref) do + Process.cancel_timer(state.tick_timer_ref) + end + + tick_token = make_ref() + timer_ref = Process.send_after(self(), {:tick, tick_token}, delay_ms) + + %{ + state + | tick_timer_ref: timer_ref, + tick_token: tick_token, + next_poll_due_at_ms: System.monotonic_time(:millisecond) + delay_ms + } end defp schedule_poll_cycle_start do @@ -1098,16 +1630,17 @@ defmodule SymphonyElixir.Orchestrator do defp record_session_completion_totals(state, _running_entry), do: state defp refresh_runtime_config(%State{} = state) do + config = Config.settings!() + %{ state - | poll_interval_ms: Config.poll_interval_ms(), - max_concurrent_agents: Config.max_concurrent_agents() + | poll_interval_ms: config.polling.interval_ms, + max_concurrent_agents: config.agent.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) + candidate_issue?(issue, active_state_set(), terminal_states) end defp dispatch_slots_available?(%Issue{} = issue, %State{} = state) do diff --git a/elixir/lib/symphony_elixir/path_safety.ex b/elixir/lib/symphony_elixir/path_safety.ex new file mode 100644 index 0000000000..fca59887ae --- /dev/null +++ b/elixir/lib/symphony_elixir/path_safety.ex @@ -0,0 +1,50 @@ +defmodule SymphonyElixir.PathSafety do + @moduledoc false + + @spec canonicalize(Path.t()) :: {:ok, Path.t()} | {:error, term()} + def canonicalize(path) when is_binary(path) do + expanded_path = Path.expand(path) + {root, segments} = split_absolute_path(expanded_path) + + case resolve_segments(root, [], segments) do + {:ok, canonical_path} -> + {:ok, canonical_path} + + {:error, reason} -> + {:error, {:path_canonicalize_failed, expanded_path, reason}} + end + end + + defp split_absolute_path(path) when is_binary(path) do + [root | segments] = Path.split(path) + {root, segments} + end + + defp resolve_segments(root, resolved_segments, []), do: {:ok, join_path(root, resolved_segments)} + + defp resolve_segments(root, resolved_segments, [segment | rest]) do + candidate_path = join_path(root, resolved_segments ++ [segment]) + + case File.lstat(candidate_path) do + {:ok, %File.Stat{type: :symlink}} -> + with {:ok, target} <- :file.read_link_all(String.to_charlist(candidate_path)) do + resolved_target = Path.expand(IO.chardata_to_string(target), join_path(root, resolved_segments)) + {target_root, target_segments} = split_absolute_path(resolved_target) + resolve_segments(target_root, [], target_segments ++ rest) + end + + {:ok, _stat} -> + resolve_segments(root, resolved_segments ++ [segment], rest) + + {:error, :enoent} -> + {:ok, join_path(root, resolved_segments ++ [segment | rest])} + + {:error, reason} -> + {:error, reason} + end + end + + defp join_path(root, segments) when is_list(segments) do + Enum.reduce(segments, root, fn segment, acc -> Path.join(acc, segment) end) + end +end diff --git a/elixir/lib/symphony_elixir/prompt_builder.ex b/elixir/lib/symphony_elixir/prompt_builder.ex index 1b334a105d..70c519fc0c 100644 --- a/elixir/lib/symphony_elixir/prompt_builder.ex +++ b/elixir/lib/symphony_elixir/prompt_builder.ex @@ -1,13 +1,13 @@ defmodule SymphonyElixir.PromptBuilder do @moduledoc """ - Builds agent prompts from Linear issue data. + Builds agent prompts from normalized tracker work item data. """ alias SymphonyElixir.{Config, Workflow} @render_opts [strict_variables: true, strict_filters: true] - @spec build_prompt(SymphonyElixir.Linear.Issue.t(), keyword()) :: String.t() + @spec build_prompt(SymphonyElixir.Tracker.Issue.t(), keyword()) :: String.t() def build_prompt(issue, opts \\ []) do template = Workflow.current() diff --git a/elixir/lib/symphony_elixir/ssh.ex b/elixir/lib/symphony_elixir/ssh.ex new file mode 100644 index 0000000000..0493adb083 --- /dev/null +++ b/elixir/lib/symphony_elixir/ssh.ex @@ -0,0 +1,100 @@ +defmodule SymphonyElixir.SSH do + @moduledoc false + + @spec run(String.t(), String.t(), keyword()) :: {:ok, {String.t(), non_neg_integer()}} | {:error, term()} + def run(host, command, opts \\ []) when is_binary(host) and is_binary(command) do + with {:ok, executable} <- ssh_executable() do + {:ok, System.cmd(executable, ssh_args(host, command), opts)} + end + end + + @spec start_port(String.t(), String.t(), keyword()) :: {:ok, port()} | {:error, term()} + def start_port(host, command, opts \\ []) when is_binary(host) and is_binary(command) do + with {:ok, executable} <- ssh_executable() do + line_bytes = Keyword.get(opts, :line) + + port_opts = + [ + :binary, + :exit_status, + :stderr_to_stdout, + args: Enum.map(ssh_args(host, command), &String.to_charlist/1) + ] + |> maybe_put_line_option(line_bytes) + + {:ok, Port.open({:spawn_executable, String.to_charlist(executable)}, port_opts)} + end + end + + @spec remote_shell_command(String.t()) :: String.t() + def remote_shell_command(command) when is_binary(command) do + "bash -lc " <> shell_escape(command) + end + + defp ssh_executable do + case System.find_executable("ssh") do + nil -> {:error, :ssh_not_found} + executable -> {:ok, executable} + end + end + + defp ssh_args(host, command) do + %{destination: destination, port: port} = parse_target(host) + + [] + |> maybe_put_config() + |> Kernel.++(["-T"]) + |> maybe_put_port(port) + |> Kernel.++([destination, remote_shell_command(command)]) + end + + defp maybe_put_line_option(port_opts, nil), do: port_opts + defp maybe_put_line_option(port_opts, line_bytes), do: Keyword.put(port_opts, :line, line_bytes) + + defp maybe_put_config(args) do + case System.get_env("SYMPHONY_SSH_CONFIG") do + config_path when is_binary(config_path) and config_path != "" -> + args ++ ["-F", config_path] + + _ -> + args + end + end + + defp maybe_put_port(args, nil), do: args + defp maybe_put_port(args, port), do: args ++ ["-p", port] + + defp parse_target(target) when is_binary(target) do + trimmed_target = String.trim(target) + + # OpenSSH does not interpret bare "host:port" as "host + port"; it treats the + # whole value as a hostname and leaves the port at 22. We split that shorthand + # here so worker config can use "localhost:2222" without requiring ssh:// URIs. + case Regex.run(~r/^(.*):(\d+)$/, trimmed_target, capture: :all_but_first) do + [destination, port] -> + if valid_port_destination?(destination) do + %{destination: destination, port: port} + else + %{destination: trimmed_target, port: nil} + end + + _ -> + %{destination: trimmed_target, port: nil} + end + end + + defp valid_port_destination?(destination) when is_binary(destination) do + destination != "" and + (not String.contains?(destination, ":") or bracketed_host?(destination)) + end + + defp bracketed_host?(destination) when is_binary(destination) do + # IPv6 literals contain ":" already, so we only accept additional ":port" + # parsing when the host is explicitly bracketed, e.g. "[::1]:2222". + String.contains?(destination, "[") and String.contains?(destination, "]") + end + + defp shell_escape(value) when is_binary(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end +end diff --git a/elixir/lib/symphony_elixir/status_dashboard.ex b/elixir/lib/symphony_elixir/status_dashboard.ex index 19b628bfac..81a7ae103d 100644 --- a/elixir/lib/symphony_elixir/status_dashboard.ex +++ b/elixir/lib/symphony_elixir/status_dashboard.ex @@ -99,10 +99,11 @@ defmodule SymphonyElixir.StatusDashboard 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() + observability = Config.settings!().observability + refresh_ms = refresh_ms_override || observability.refresh_ms + render_interval_ms = render_interval_ms_override || 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?()) + enabled = resolve_override(enabled_override, observability.dashboard_enabled and dashboard_enabled?()) schedule_tick(refresh_ms, enabled) {:ok, @@ -176,11 +177,13 @@ defmodule SymphonyElixir.StatusDashboard do def handle_info(:tick, state), do: {:noreply, state} defp refresh_runtime_config(%__MODULE__{} = state) do + observability = Config.settings!().observability + %{ 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() + | enabled: resolve_override(state.enabled_override, observability.dashboard_enabled and dashboard_enabled?()), + refresh_ms: state.refresh_ms_override || observability.refresh_ms, + render_interval_ms: state.render_interval_ms_override || observability.render_interval_ms } end @@ -338,7 +341,7 @@ defmodule SymphonyElixir.StatusDashboard do 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() + max_agents = Config.settings!().agent.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: ["│"]) @@ -391,8 +394,9 @@ defmodule SymphonyElixir.StatusDashboard do defp format_project_link_lines do project_part = - case Config.linear_project_slug() do - project_slug when is_binary(project_slug) and project_slug != "" -> + case Config.settings!().tracker do + %{kind: "linear", project_slug: project_slug} + when is_binary(project_slug) and project_slug != "" -> colorize(linear_project_url(project_slug), @ansi_cyan) _ -> @@ -427,7 +431,7 @@ defmodule SymphonyElixir.StatusDashboard do 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()) + dashboard_url(Config.settings!().server.host, Config.server_port(), HttpServer.bound_port()) end defp dashboard_url(_host, nil, _bound_port), do: nil @@ -1121,18 +1125,6 @@ defmodule SymphonyElixir.StatusDashboard do 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) diff --git a/elixir/lib/symphony_elixir/tracker.ex b/elixir/lib/symphony_elixir/tracker.ex index 504b54af30..f588e63694 100644 --- a/elixir/lib/symphony_elixir/tracker.ex +++ b/elixir/lib/symphony_elixir/tracker.ex @@ -1,46 +1,141 @@ defmodule SymphonyElixir.Tracker do @moduledoc """ - Adapter boundary for issue tracker reads and writes. + Adapter boundary for issue tracker reads and provider-native agent tools. + + The orchestrator only depends on the read callbacks. Agent-side mutations stay + behind optional provider-native tools so tracker-specific capabilities do not + leak into scheduler policy. """ alias SymphonyElixir.Config + alias SymphonyElixir.Tracker.Issue - @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()} + @adapters %{ + "asana" => SymphonyElixir.Asana.Adapter, + "github" => SymphonyElixir.GitHub.Adapter, + "gitlab" => SymphonyElixir.GitLab.Adapter, + "jira" => SymphonyElixir.Jira.Adapter, + "linear" => SymphonyElixir.Linear.Adapter, + "memory" => SymphonyElixir.Tracker.Memory + } - @spec fetch_candidate_issues() :: {:ok, [term()]} | {:error, term()} - def fetch_candidate_issues do - adapter().fetch_candidate_issues() - end + @callback fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + @callback fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + @callback agent_tool_specs() :: [map()] + @callback execute_agent_tool(String.t(), term(), keyword()) :: map() + @callback secret_environment_names(map()) :: [String.t()] + @callback validate_config(map()) :: :ok | {:error, term()} + + @optional_callbacks agent_tool_specs: 0, + execute_agent_tool: 3, + validate_config: 1 - @spec fetch_issues_by_states([String.t()]) :: {:ok, [term()]} | {:error, term()} + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {: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) + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(issue_ids) do + adapter().fetch_issues_by_ids(issue_ids) + end + + @doc """ + Captures the selected adapter and effective tracker settings for one + app-server session so tool advertisement and execution cannot drift across a + workflow reload. + """ + @spec bind_agent_tools() :: map() + def bind_agent_tools do + tracker_settings = Config.settings!().tracker + adapter = adapter_for_settings!(tracker_settings) + + %{ + adapter: adapter, + tracker_settings: tracker_settings, + tool_specs: adapter_agent_tool_specs(adapter), + secret_environment_names: adapter_secret_environment_names(adapter, tracker_settings) + } end - @spec create_comment(String.t(), String.t()) :: :ok | {:error, term()} - def create_comment(issue_id, body) do - adapter().create_comment(issue_id, body) + @spec execute_bound_agent_tool(map(), String.t(), term(), keyword()) :: map() + def execute_bound_agent_tool( + %{adapter: adapter, tracker_settings: tracker_settings}, + tool, + arguments, + opts \\ [] + ) do + execute_agent_tool_with_adapter( + adapter, + tool, + arguments, + Keyword.put(opts, :tracker_settings, tracker_settings) + ) 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) + @spec validate_config(map()) :: :ok | {:error, term()} + def validate_config(%{kind: kind} = tracker_settings) do + with {:ok, adapter} <- adapter_for_kind(kind) do + if Code.ensure_loaded?(adapter) and function_exported?(adapter, :validate_config, 1) do + adapter.validate_config(tracker_settings) + else + :ok + end + end end @spec adapter() :: module() def adapter do - case Config.tracker_kind() do - "memory" -> SymphonyElixir.Tracker.Memory - _ -> SymphonyElixir.Linear.Adapter + Config.settings!().tracker + |> adapter_for_settings!() + end + + @spec adapter_for_kind(String.t()) :: {:ok, module()} | {:error, term()} + def adapter_for_kind(kind) do + case Map.fetch(@adapters, kind) do + {:ok, adapter} -> {:ok, adapter} + :error -> {:error, {:unsupported_tracker_kind, kind}} end end + + defp adapter_for_settings!(%{kind: kind}) do + {:ok, adapter} = adapter_for_kind(kind) + adapter + end + + defp adapter_agent_tool_specs(adapter) do + if Code.ensure_loaded?(adapter) and function_exported?(adapter, :agent_tool_specs, 0) do + adapter.agent_tool_specs() + else + [] + end + end + + defp execute_agent_tool_with_adapter(adapter, tool, arguments, opts) do + if Code.ensure_loaded?(adapter) and function_exported?(adapter, :execute_agent_tool, 3) do + adapter.execute_agent_tool(tool, arguments, opts) + else + unsupported_agent_tool_response(tool) + end + end + + defp adapter_secret_environment_names(adapter, tracker_settings) do + adapter.secret_environment_names(tracker_settings) + end + + defp unsupported_agent_tool_response(tool) do + output = + Jason.encode!(%{ + "error" => %{ + "message" => "Unsupported dynamic tool: #{inspect(tool)}.", + "supportedTools" => [] + } + }) + + %{ + "success" => false, + "output" => output, + "contentItems" => [%{"type" => "inputText", "text" => output}] + } + end end diff --git a/elixir/lib/symphony_elixir/tracker/issue.ex b/elixir/lib/symphony_elixir/tracker/issue.ex new file mode 100644 index 0000000000..a846a05836 --- /dev/null +++ b/elixir/lib/symphony_elixir/tracker/issue.ex @@ -0,0 +1,67 @@ +defmodule SymphonyElixir.Tracker.Issue do + @moduledoc """ + Normalized work item representation used by the orchestrator. + + `id` is the stable dispatch identity for the configured tracker scope. It may + differ from a provider's underlying issue ID when the scheduled item is a + board or project entry. `native_ref` carries non-secret provider identifiers + needed by provider-native agent tools. `identifier` remains the human-readable + value used to derive the workspace key and must be unique within that scope. + """ + + defstruct [ + :id, + :native_ref, + :identifier, + :title, + :description, + :priority, + :state, + :branch_name, + :url, + :assignee_id, + blocked_by: [], + labels: [], + dispatchable: false, + created_at: nil, + updated_at: nil + ] + + @type t :: %__MODULE__{ + id: String.t() | nil, + native_ref: map() | 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()], + blocked_by: [map()], + dispatchable: 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 + + @spec routable?(t(), [String.t()]) :: boolean() + def routable?(%__MODULE__{dispatchable: true, labels: labels}, required_labels) + when is_list(labels) and is_list(required_labels) do + issue_labels = MapSet.new(labels, &normalize_label/1) + Enum.all?(required_labels, &MapSet.member?(issue_labels, normalize_label(&1))) + end + + def routable?(%__MODULE__{}, _required_labels), do: false + + defp normalize_label(label) when is_binary(label) do + label + |> String.trim() + |> String.downcase() + end +end diff --git a/elixir/lib/symphony_elixir/tracker/memory.ex b/elixir/lib/symphony_elixir/tracker/memory.ex index ad84a23c6b..359b2d30db 100644 --- a/elixir/lib/symphony_elixir/tracker/memory.ex +++ b/elixir/lib/symphony_elixir/tracker/memory.ex @@ -5,12 +5,7 @@ defmodule SymphonyElixir.Tracker.Memory do @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 + alias SymphonyElixir.Tracker.Issue @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} def fetch_issues_by_states(state_names) do @@ -25,8 +20,8 @@ defmodule SymphonyElixir.Tracker.Memory do end)} end - @spec fetch_issue_states_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} - def fetch_issue_states_by_ids(issue_ids) do + @spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_ids(issue_ids) do wanted_ids = MapSet.new(issue_ids) {:ok, @@ -35,17 +30,8 @@ defmodule SymphonyElixir.Tracker.Memory do 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 + @spec secret_environment_names(map()) :: [String.t()] + def secret_environment_names(_tracker_settings), do: [] defp configured_issues do Application.get_env(:symphony_elixir, :memory_tracker_issues, []) @@ -55,13 +41,6 @@ defmodule SymphonyElixir.Tracker.Memory 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() diff --git a/elixir/lib/symphony_elixir/workflow_store.ex b/elixir/lib/symphony_elixir/workflow_store.ex index 3d687fd9b5..9160a17ff0 100644 --- a/elixir/lib/symphony_elixir/workflow_store.ex +++ b/elixir/lib/symphony_elixir/workflow_store.ex @@ -6,6 +6,8 @@ defmodule SymphonyElixir.WorkflowStore do use GenServer require Logger + alias SymphonyElixir.Config + alias SymphonyElixir.Config.Schema alias SymphonyElixir.Workflow @poll_interval_ms 1_000 @@ -13,7 +15,7 @@ defmodule SymphonyElixir.WorkflowStore do defmodule State do @moduledoc false - defstruct [:path, :stamp, :workflow] + defstruct [:path, :stamp, :workflow, :settings] end @spec start_link(keyword()) :: GenServer.on_start() @@ -32,6 +34,20 @@ defmodule SymphonyElixir.WorkflowStore do end end + @spec settings() :: {:ok, Schema.t()} | {:error, term()} + def settings do + case Process.whereis(__MODULE__) do + pid when is_pid(pid) -> + GenServer.call(__MODULE__, :settings) + + _ -> + case load_state(Workflow.workflow_file_path()) do + {:ok, %State{settings: settings}} -> {:ok, settings} + {:error, reason} -> {:error, reason} + end + end + end + @spec force_reload() :: :ok | {:error, term()} def force_reload do case Process.whereis(__MODULE__) do @@ -39,8 +55,8 @@ defmodule SymphonyElixir.WorkflowStore do GenServer.call(__MODULE__, :force_reload) _ -> - case Workflow.load() do - {:ok, _workflow} -> :ok + case load_state(Workflow.workflow_file_path()) do + {:ok, _state} -> :ok {:error, reason} -> {:error, reason} end end @@ -79,6 +95,16 @@ defmodule SymphonyElixir.WorkflowStore do end end + def handle_call(:settings, _from, %State{} = state) do + case reload_state(state) do + {:ok, new_state} -> + {:reply, {:ok, new_state.settings}, new_state} + + {:error, _reason, new_state} -> + {:reply, {:ok, new_state.settings}, new_state} + end + end + @impl true def handle_info(:poll, %State{} = state) do schedule_poll() @@ -130,8 +156,10 @@ defmodule SymphonyElixir.WorkflowStore do defp load_state(path) do with {:ok, workflow} <- Workflow.load(path), + {:ok, settings} <- Schema.parse(workflow.config), + :ok <- Config.validate_settings(settings), {:ok, stamp} <- current_stamp(path) do - {:ok, %State{path: path, stamp: stamp, workflow: workflow}} + {:ok, %State{path: path, stamp: stamp, workflow: workflow, settings: settings}} else {:error, reason} -> {:error, reason} diff --git a/elixir/lib/symphony_elixir/workspace.ex b/elixir/lib/symphony_elixir/workspace.ex index e8c085ea56..28ee2daf10 100644 --- a/elixir/lib/symphony_elixir/workspace.ex +++ b/elixir/lib/symphony_elixir/workspace.ex @@ -4,36 +4,43 @@ defmodule SymphonyElixir.Workspace do """ require Logger - alias SymphonyElixir.Config + alias SymphonyElixir.{Config, PathSafety, SSH} - @excluded_entries MapSet.new([".elixir_ls", "tmp"]) + @remote_workspace_marker "__SYMPHONY_WORKSPACE__" - @spec create_for_issue(map() | String.t() | nil) :: {:ok, Path.t()} | {:error, term()} - def create_for_issue(issue_or_identifier) do + @type worker_host :: String.t() | nil + + @spec create_for_issue(map() | String.t() | nil, worker_host()) :: + {:ok, Path.t()} | {:error, term()} + def create_for_issue(issue_or_identifier, worker_host \\ nil) do issue_context = issue_context(issue_or_identifier) try do - safe_id = safe_identifier(issue_context.issue_identifier) + safe_id = workspace_key(issue_or_identifier) - workspace = workspace_path_for_issue(safe_id) + with {:ok, workspace} <- workspace_path_for_issue(safe_id, worker_host), + :ok <- validate_workspace_path(workspace, worker_host), + {:ok, workspace, created?} <- ensure_workspace(workspace, worker_host) do + case maybe_run_after_create_hook(workspace, issue_context, created?, worker_host) do + :ok -> + {:ok, workspace} - with :ok <- validate_workspace_path(workspace), - {:ok, created?} <- ensure_workspace(workspace), - :ok <- maybe_run_after_create_hook(workspace, issue_context, created?) do - {:ok, workspace} + {:error, _reason} = error -> + cleanup_failed_new_workspace(workspace, created?, worker_host) + error + end end rescue error in [ArgumentError, ErlangError, File.Error] -> - Logger.error("Workspace creation failed #{issue_log_context(issue_context)} error=#{Exception.message(error)}") + Logger.error("Workspace creation failed #{issue_log_context(issue_context)} worker_host=#{worker_host_for_log(worker_host)} error=#{Exception.message(error)}") {:error, error} end end - defp ensure_workspace(workspace) do + defp ensure_workspace(workspace, nil) do cond do File.dir?(workspace) -> - clean_tmp_artifacts(workspace) - {:ok, false} + {:ok, workspace, false} File.exists?(workspace) -> File.rm_rf!(workspace) @@ -44,20 +51,55 @@ defmodule SymphonyElixir.Workspace do end end + defp ensure_workspace(workspace, worker_host) when is_binary(worker_host) do + script = + [ + "set -eu", + remote_shell_assign("workspace", workspace), + "if [ -d \"$workspace\" ]; then", + " created=0", + "elif [ -e \"$workspace\" ]; then", + " rm -rf \"$workspace\"", + " mkdir -p \"$workspace\"", + " created=1", + "else", + " mkdir -p \"$workspace\"", + " created=1", + "fi", + "cd \"$workspace\"", + "printf '%s\\t%s\\t%s\\n' '#{@remote_workspace_marker}' \"$created\" \"$(pwd -P)\"" + ] + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n") + + case run_remote_command(worker_host, script, Config.settings!().hooks.timeout_ms) do + {:ok, {output, 0}} -> + parse_remote_workspace_output(output) + + {:ok, {output, status}} -> + {:error, {:workspace_prepare_failed, worker_host, status, output}} + + {:error, reason} -> + {:error, reason} + end + end + defp create_workspace(workspace) do File.rm_rf!(workspace) File.mkdir_p!(workspace) - {:ok, true} + {:ok, workspace, true} end @spec remove(Path.t()) :: {:ok, [String.t()]} | {:error, term(), String.t()} - def remove(workspace) do + def remove(workspace), do: remove(workspace, nil) + + @spec remove(Path.t(), worker_host()) :: {:ok, [String.t()]} | {:error, term(), String.t()} + def remove(workspace, nil) do case File.exists?(workspace) do true -> - case validate_workspace_path(workspace) do + case validate_workspace_path(workspace, nil) do :ok -> - maybe_run_before_remove_hook(workspace) - File.rm_rf(workspace) + remove_local_workspace(workspace) {:error, reason} -> {:error, reason, ""} @@ -68,69 +110,193 @@ defmodule SymphonyElixir.Workspace do end end + def remove(workspace, worker_host) when is_binary(worker_host) do + maybe_run_before_remove_hook(workspace, worker_host) + + script = + [ + remote_shell_assign("workspace", workspace), + "rm -rf \"$workspace\"" + ] + |> Enum.join("\n") + + case run_remote_command(worker_host, script, Config.settings!().hooks.timeout_ms) do + {:ok, {_output, 0}} -> + {:ok, []} + + {:ok, {output, status}} -> + {:error, {:workspace_remove_failed, worker_host, status, output}, ""} + + {:error, reason} -> + {:error, reason, ""} + end + end + + @doc false + @spec remove_recorded(Path.t(), worker_host()) :: {:ok, [String.t()]} | {:error, term(), String.t()} + def remove_recorded(workspace, nil) when is_binary(workspace) do + if Path.type(workspace) == :absolute do + case validate_recorded_workspace_path(workspace) do + :ok -> + remove_local_workspace(workspace) + + {:error, reason} -> + {:error, reason, ""} + end + else + {:error, {:workspace_path_unreadable, workspace, :not_absolute}, ""} + end + end + + def remove_recorded(workspace, worker_host) when is_binary(workspace) and is_binary(worker_host) do + remove(workspace, worker_host) + end + + def remove_recorded(workspace, _worker_host) do + {:error, {:workspace_path_unreadable, workspace, :invalid}, ""} + end + + defp remove_local_workspace(workspace) do + maybe_run_before_remove_hook(workspace, nil) + File.rm_rf(workspace) + 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) + def remove_issue_workspaces(identifier), do: remove_issue_workspaces(identifier, nil) + + @spec remove_issue_workspaces(term(), worker_host()) :: :ok + def remove_issue_workspaces(%{id: _issue_id, identifier: _identifier} = issue, worker_host) + when is_binary(worker_host) do + case workspace_path_for_issue(workspace_key(issue), worker_host) do + {:ok, workspace} -> remove(workspace, worker_host) + {:error, _reason} -> :ok + end + + :ok + end + + def remove_issue_workspaces(%{id: _issue_id, identifier: _identifier} = issue, nil) do + case Config.settings!().worker.ssh_hosts do + [] -> + case workspace_path_for_issue(workspace_key(issue), nil) do + {:ok, workspace} -> remove(workspace, nil) + {:error, _reason} -> :ok + end + + worker_hosts -> + Enum.each(worker_hosts, &remove_issue_workspaces(issue, &1)) + end + + :ok + end + + def remove_issue_workspaces(identifier, worker_host) when is_binary(identifier) and is_binary(worker_host) do + case workspace_path_for_issue(workspace_key(identifier), worker_host) do + {:ok, workspace} -> remove(workspace, worker_host) + {:error, _reason} -> :ok + end - remove(workspace) :ok end - def remove_issue_workspaces(_identifier) do + def remove_issue_workspaces(identifier, nil) when is_binary(identifier) do + case Config.settings!().worker.ssh_hosts do + [] -> + case workspace_path_for_issue(workspace_key(identifier), nil) do + {:ok, workspace} -> remove(workspace, nil) + {:error, _reason} -> :ok + end + + worker_hosts -> + Enum.each(worker_hosts, &remove_issue_workspaces(identifier, &1)) + end + :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 + def remove_issue_workspaces(_identifier, _worker_host), do: :ok + + @spec run_before_run_hook(Path.t(), map() | String.t() | nil, worker_host()) :: + :ok | {:error, term()} + def run_before_run_hook(workspace, issue_or_identifier, worker_host \\ nil) when is_binary(workspace) do issue_context = issue_context(issue_or_identifier) + hooks = Config.settings!().hooks - case Config.workspace_hooks()[:before_run] do + case hooks.before_run do nil -> :ok command -> - run_hook(command, workspace, issue_context, "before_run") + run_hook(command, workspace, issue_context, "before_run", worker_host) 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 + @spec run_after_run_hook(Path.t(), map() | String.t() | nil, worker_host()) :: :ok + def run_after_run_hook(workspace, issue_or_identifier, worker_host \\ nil) when is_binary(workspace) do issue_context = issue_context(issue_or_identifier) + hooks = Config.settings!().hooks - case Config.workspace_hooks()[:after_run] do + case hooks.after_run do nil -> :ok command -> - run_hook(command, workspace, issue_context, "after_run") + run_hook(command, workspace, issue_context, "after_run", worker_host) |> 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) + defp workspace_path_for_issue(safe_id, nil) when is_binary(safe_id) do + Config.local_workspace_root() + |> Path.join(safe_id) + |> PathSafety.canonicalize() end - defp safe_identifier(identifier) do - String.replace(identifier || "issue", ~r/[^a-zA-Z0-9._-]/, "_") + defp workspace_path_for_issue(safe_id, worker_host) when is_binary(safe_id) and is_binary(worker_host) do + {:ok, Path.join(Config.settings!().workspace.root, safe_id)} + end + + @doc """ + Returns the collision-safe directory name for an issue identifier. + + The hash is derived from the original identifier so callers that only know the identifier can + derive the same key as callers holding a full tracker issue. + """ + @spec workspace_key(map() | String.t() | nil) :: String.t() + def workspace_key(%{identifier: identifier}), do: workspace_key(identifier) + + def workspace_key(identifier) when is_binary(identifier) do + safe_identifier = safe_identifier(identifier) + + if safe_identifier == identifier do + safe_identifier + else + "#{safe_identifier}--#{short_identifier_hash(identifier)}" + end end - defp clean_tmp_artifacts(workspace) do - Enum.each(MapSet.to_list(@excluded_entries), fn entry -> - File.rm_rf(Path.join(workspace, entry)) - end) + def workspace_key(_identifier), do: "issue" + + defp safe_identifier(identifier) when is_binary(identifier), + do: String.replace(identifier, ~r/[^a-zA-Z0-9._-]/, "_") + + defp short_identifier_hash(identifier) do + :crypto.hash(:sha256, identifier) + |> Base.encode16(case: :lower) + |> binary_part(0, 16) end - defp maybe_run_after_create_hook(workspace, issue_context, created?) do + defp maybe_run_after_create_hook(workspace, issue_context, created?, worker_host) do + hooks = Config.settings!().hooks + case created? do true -> - case Config.workspace_hooks()[:after_create] do + case hooks.after_create do nil -> :ok command -> - run_hook(command, workspace, issue_context, "after_create") + run_hook(command, workspace, issue_context, "after_create", worker_host) end false -> @@ -138,10 +304,36 @@ defmodule SymphonyElixir.Workspace do end end - defp maybe_run_before_remove_hook(workspace) do + defp cleanup_failed_new_workspace(_workspace, false, _worker_host), do: :ok + + defp cleanup_failed_new_workspace(workspace, true, nil) do + case File.rm_rf(workspace) do + {:ok, _removed} -> + :ok + + {:error, reason, path} -> + Logger.warning("Failed to remove partial workspace path=#{path} reason=#{inspect(reason)}") + end + end + + defp cleanup_failed_new_workspace(workspace, true, worker_host) when is_binary(worker_host) do + script = [remote_shell_assign("workspace", workspace), "rm -rf \"$workspace\""] |> Enum.join("\n") + + case run_remote_command(worker_host, script, Config.settings!().hooks.timeout_ms) do + {:ok, {_output, 0}} -> + :ok + + result -> + Logger.warning("Failed to remove partial workspace worker_host=#{worker_host_for_log(worker_host)} result=#{inspect(result)}") + end + end + + defp maybe_run_before_remove_hook(workspace, nil) do + hooks = Config.settings!().hooks + case File.dir?(workspace) do true -> - case Config.workspace_hooks()[:before_remove] do + case hooks.before_remove do nil -> :ok @@ -150,7 +342,8 @@ defmodule SymphonyElixir.Workspace do command, workspace, %{issue_id: nil, issue_identifier: Path.basename(workspace)}, - "before_remove" + "before_remove", + nil ) |> ignore_hook_failure() end @@ -160,13 +353,51 @@ defmodule SymphonyElixir.Workspace do end end + defp maybe_run_before_remove_hook(workspace, worker_host) when is_binary(worker_host) do + hooks = Config.settings!().hooks + + case hooks.before_remove do + nil -> + :ok + + command -> + script = + [ + remote_shell_assign("workspace", workspace), + "if [ -d \"$workspace\" ]; then", + " cd \"$workspace\"", + " #{command}", + "fi" + ] + |> Enum.join("\n") + + run_remote_command(worker_host, script, Config.settings!().hooks.timeout_ms) + |> case do + {:ok, {output, status}} -> + handle_hook_command_result( + {output, status}, + workspace, + %{issue_id: nil, issue_identifier: Path.basename(workspace)}, + "before_remove" + ) + + {:error, {:workspace_hook_timeout, "before_remove", _timeout_ms} = reason} -> + {:error, reason} + + {:error, reason} -> + {:error, reason} + end + |> ignore_hook_failure() + 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] + defp run_hook(command, workspace, issue_context, hook_name, nil) do + timeout_ms = Config.settings!().hooks.timeout_ms - Logger.info("Running workspace hook hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace}") + Logger.info("Running workspace hook hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace} worker_host=local") task = Task.async(fn -> @@ -180,12 +411,29 @@ defmodule SymphonyElixir.Workspace do 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}") + Logger.warning("Workspace hook timed out hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace} worker_host=local timeout_ms=#{timeout_ms}") {:error, {:workspace_hook_timeout, hook_name, timeout_ms}} end end + defp run_hook(command, workspace, issue_context, hook_name, worker_host) when is_binary(worker_host) do + timeout_ms = Config.settings!().hooks.timeout_ms + + Logger.info("Running workspace hook hook=#{hook_name} #{issue_log_context(issue_context)} workspace=#{workspace} worker_host=#{worker_host}") + + case run_remote_command(worker_host, "cd #{shell_escape(workspace)} && #{command}", timeout_ms) do + {:ok, cmd_result} -> + handle_hook_command_result(cmd_result, workspace, issue_context, hook_name) + + {:error, {:workspace_hook_timeout, ^hook_name, _timeout_ms} = reason} -> + {:error, reason} + + {:error, reason} -> + {:error, reason} + end + end + defp handle_hook_command_result({_output, 0}, _workspace, _issue_id, _hook_name) do :ok end @@ -210,51 +458,116 @@ defmodule SymphonyElixir.Workspace do 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 <> "/" + defp validate_workspace_path(workspace, nil) when is_binary(workspace) do + validate_local_workspace_path(workspace, Config.local_workspace_root()) + end + defp validate_workspace_path(workspace, worker_host) + when is_binary(workspace) and is_binary(worker_host) do cond do - expanded_workspace == root -> - {:error, {:workspace_equals_root, expanded_workspace, root}} + String.trim(workspace) == "" -> + {:error, {:workspace_path_unreadable, workspace, :empty}} - String.starts_with?(expanded_workspace <> "/", root_prefix) -> - ensure_no_symlink_components(expanded_workspace, root) + String.contains?(workspace, ["\n", "\r", <<0>>]) -> + {:error, {:workspace_path_unreadable, workspace, :invalid_characters}} true -> - {:error, {:workspace_outside_root, expanded_workspace, root}} + :ok 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) + defp validate_recorded_workspace_path(workspace) when is_binary(workspace) do + validate_local_workspace_path(workspace, Path.dirname(workspace)) + end - case File.lstat(next_path) do - {:ok, %File.Stat{type: :symlink}} -> - {:halt, {:error, {:workspace_symlink_escape, next_path, root}}} + defp validate_local_workspace_path(workspace, workspace_root) + when is_binary(workspace) and is_binary(workspace_root) do + expanded_workspace = Path.expand(workspace) + expanded_root = Path.expand(workspace_root) + expanded_root_prefix = expanded_root <> "/" - {:ok, _stat} -> - {:cont, next_path} + with {:ok, canonical_workspace} <- PathSafety.canonicalize(expanded_workspace), + {:ok, canonical_root} <- PathSafety.canonicalize(expanded_root) do + canonical_root_prefix = canonical_root <> "/" - {:error, :enoent} -> - {:halt, :ok} + cond do + canonical_workspace == canonical_root -> + {:error, {:workspace_equals_root, canonical_workspace, canonical_root}} - {:error, reason} -> - {:halt, {:error, {:workspace_path_unreadable, next_path, reason}}} + String.starts_with?(canonical_workspace <> "/", canonical_root_prefix) -> + :ok + + String.starts_with?(expanded_workspace <> "/", expanded_root_prefix) -> + {:error, {:workspace_symlink_escape, expanded_workspace, canonical_root}} + + true -> + {:error, {:workspace_outside_root, canonical_workspace, canonical_root}} end - end) - |> case do - :ok -> :ok - {:error, _reason} = error -> error - _final_path -> :ok + else + {:error, {:path_canonicalize_failed, path, reason}} -> + {:error, {:workspace_path_unreadable, path, reason}} end end + defp remote_shell_assign(variable_name, raw_path) + when is_binary(variable_name) and is_binary(raw_path) do + [ + "#{variable_name}=#{shell_escape(raw_path)}", + "case \"$#{variable_name}\" in", + " '~') #{variable_name}=\"$HOME\" ;;", + " '~/'*) " <> variable_name <> "=\"$HOME/${" <> variable_name <> "#\\~/}\" ;;", + "esac" + ] + |> Enum.join("\n") + end + + defp parse_remote_workspace_output(output) do + lines = String.split(IO.iodata_to_binary(output), "\n", trim: true) + + payload = + Enum.find_value(lines, fn line -> + case String.split(line, "\t", parts: 3) do + [@remote_workspace_marker, created, path] when created in ["0", "1"] and path != "" -> + {created == "1", path} + + _ -> + nil + end + end) + + case payload do + {created?, workspace} when is_boolean(created?) and is_binary(workspace) -> + {:ok, workspace, created?} + + _ -> + {:error, {:workspace_prepare_failed, :invalid_output, output}} + end + end + + defp run_remote_command(worker_host, script, timeout_ms) + when is_binary(worker_host) and is_binary(script) and is_integer(timeout_ms) and timeout_ms > 0 do + task = + Task.async(fn -> + SSH.run(worker_host, script, stderr_to_stdout: true) + end) + + case Task.yield(task, timeout_ms) do + {:ok, result} -> + result + + nil -> + Task.shutdown(task, :brutal_kill) + {:error, {:workspace_hook_timeout, "remote_command", timeout_ms}} + end + end + + defp shell_escape(value) when is_binary(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end + + defp worker_host_for_log(nil), do: "local" + defp worker_host_for_log(worker_host), do: worker_host + defp issue_context(%{id: issue_id, identifier: identifier}) do %{ issue_id: issue_id, diff --git a/elixir/lib/symphony_elixir_web/components/layouts.ex b/elixir/lib/symphony_elixir_web/components/layouts.ex index afac13e3fc..7cc0c0d95f 100644 --- a/elixir/lib/symphony_elixir_web/components/layouts.ex +++ b/elixir/lib/symphony_elixir_web/components/layouts.ex @@ -7,7 +7,11 @@ defmodule SymphonyElixirWeb.Layouts do @spec root(map()) :: Phoenix.LiveView.Rendered.t() def root(assigns) do - assigns = assign(assigns, :csrf_token, Plug.CSRFProtection.get_csrf_token()) + assigns = + assigns + |> assign(:csrf_token, Plug.CSRFProtection.get_csrf_token()) + |> assign(:dashboard_css_url, SymphonyElixirWeb.StaticAssets.dashboard_css_url()) + |> assign(:favicon_url, SymphonyElixirWeb.StaticAssets.favicon_url()) ~H""" @@ -17,6 +21,7 @@ defmodule SymphonyElixirWeb.Layouts do Symphony Observability + @@ -36,7 +41,7 @@ defmodule SymphonyElixirWeb.Layouts do window.liveSocket = liveSocket; }); - + {@inner_content} diff --git a/elixir/lib/symphony_elixir_web/controllers/static_asset_controller.ex b/elixir/lib/symphony_elixir_web/controllers/static_asset_controller.ex index faaaf46c9f..d36a40c461 100644 --- a/elixir/lib/symphony_elixir_web/controllers/static_asset_controller.ex +++ b/elixir/lib/symphony_elixir_web/controllers/static_asset_controller.ex @@ -11,6 +11,9 @@ defmodule SymphonyElixirWeb.StaticAssetController do @spec dashboard_css(Conn.t(), map()) :: Conn.t() def dashboard_css(conn, _params), do: serve(conn, "/dashboard.css") + @spec favicon(Conn.t(), map()) :: Conn.t() + def favicon(conn, _params), do: serve(conn, "/favicon.png") + @spec phoenix_html_js(Conn.t(), map()) :: Conn.t() def phoenix_html_js(conn, _params), do: serve(conn, "/vendor/phoenix_html/phoenix_html.js") diff --git a/elixir/lib/symphony_elixir_web/live/dashboard_live.ex b/elixir/lib/symphony_elixir_web/live/dashboard_live.ex index a30631c113..7439e3a09f 100644 --- a/elixir/lib/symphony_elixir_web/live/dashboard_live.ex +++ b/elixir/lib/symphony_elixir_web/live/dashboard_live.ex @@ -91,6 +91,12 @@ defmodule SymphonyElixirWeb.DashboardLive do

Issues waiting for the next retry window.

+
+

Blocked

+

<%= @payload.counts.blocked %>

+

Issues paused for operator input or approval.

+
+

Total tokens

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

@@ -152,7 +158,7 @@ defmodule SymphonyElixirWeb.DashboardLive do
- <%= entry.issue_identifier %> + <.issue_identifier identifier={entry.issue_identifier} url={entry.issue_url} /> JSON details
@@ -206,6 +212,80 @@ defmodule SymphonyElixirWeb.DashboardLive do <% end %> +
+
+
+

Blocked sessions

+

Issues paused because Codex requested operator input or approval.

+
+
+ + <%= if @payload.blocked == [] do %> +

No blocked sessions.

+ <% else %> +
+ + + + + + + + + + + + + + + + + + + + + +
IssueStateSessionBlocked atLast updateError
+
+ <.issue_identifier identifier={entry.issue_identifier} url={entry.issue_url} /> + JSON details +
+
+ + <%= entry.state || "Blocked" %> + + + <%= if entry.session_id do %> + + <% else %> + n/a + <% end %> + <%= entry.blocked_at || "n/a" %> +
+ <%= 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 %> + +
+
<%= entry.error || "n/a" %>
+
+ <% end %> +
+
@@ -231,7 +311,7 @@ defmodule SymphonyElixirWeb.DashboardLive do
- <%= entry.issue_identifier %> + <.issue_identifier identifier={entry.issue_identifier} url={entry.issue_url} /> JSON details
@@ -261,6 +341,42 @@ defmodule SymphonyElixirWeb.DashboardLive do Endpoint.config(:snapshot_timeout_ms) || 15_000 end + attr(:identifier, :string, required: true) + attr(:url, :string, default: nil) + + defp issue_identifier(assigns) do + assigns = assign(assigns, :href, external_issue_url(assigns.url)) + + ~H""" + <%= if @href do %> + <%= @identifier %> + <% else %> + <%= @identifier %> + <% end %> + """ + end + + defp external_issue_url(url) when is_binary(url) do + url = String.trim(url) + + case URI.parse(url) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + url + + _ -> + nil + end + end + + defp external_issue_url(_url), do: nil + defp completed_runtime_seconds(payload) do payload.codex_totals.seconds_running || 0 end diff --git a/elixir/lib/symphony_elixir_web/presenter.ex b/elixir/lib/symphony_elixir_web/presenter.ex index 34eb1e6641..f1608f3250 100644 --- a/elixir/lib/symphony_elixir_web/presenter.ex +++ b/elixir/lib/symphony_elixir_web/presenter.ex @@ -3,7 +3,7 @@ defmodule SymphonyElixirWeb.Presenter do Shared projections for the observability API and dashboard. """ - alias SymphonyElixir.{Config, Orchestrator, StatusDashboard} + alias SymphonyElixir.{Config, Orchestrator, StatusDashboard, Workspace} @spec state_payload(GenServer.name(), timeout()) :: map() def state_payload(orchestrator, snapshot_timeout_ms) do @@ -15,10 +15,12 @@ defmodule SymphonyElixirWeb.Presenter do generated_at: generated_at, counts: %{ running: length(snapshot.running), - retrying: length(snapshot.retrying) + retrying: length(snapshot.retrying), + blocked: length(Map.get(snapshot, :blocked, [])) }, running: Enum.map(snapshot.running, &running_entry_payload/1), retrying: Enum.map(snapshot.retrying, &retry_entry_payload/1), + blocked: Enum.map(Map.get(snapshot, :blocked, []), &blocked_entry_payload/1), codex_totals: snapshot.codex_totals, rate_limits: snapshot.rate_limits } @@ -37,11 +39,12 @@ defmodule SymphonyElixirWeb.Presenter do %{} = snapshot -> running = Enum.find(snapshot.running, &(&1.identifier == issue_identifier)) retry = Enum.find(snapshot.retrying, &(&1.identifier == issue_identifier)) + blocked = Enum.find(Map.get(snapshot, :blocked, []), &(&1.identifier == issue_identifier)) - if is_nil(running) and is_nil(retry) do + if is_nil(running) and is_nil(retry) and is_nil(blocked) do {:error, :issue_not_found} else - {:ok, issue_payload_body(issue_identifier, running, retry)} + {:ok, issue_payload_body(issue_identifier, running, retry, blocked)} end _ -> @@ -60,13 +63,14 @@ defmodule SymphonyElixirWeb.Presenter do end end - defp issue_payload_body(issue_identifier, running, retry) do + defp issue_payload_body(issue_identifier, running, retry, blocked) do %{ issue_identifier: issue_identifier, - issue_id: issue_id_from_entries(running, retry), - status: issue_status(running, retry), + issue_id: issue_id_from_entries(running, retry, blocked), + status: issue_status(running, retry, blocked), workspace: %{ - path: Path.join(Config.workspace_root(), issue_identifier) + path: workspace_path(issue_identifier, running, retry, blocked), + host: workspace_host(running, retry, blocked) }, attempts: %{ restart_count: restart_count(retry), @@ -74,31 +78,35 @@ defmodule SymphonyElixirWeb.Presenter do }, running: running && running_issue_payload(running), retry: retry && retry_issue_payload(retry), + blocked: blocked && blocked_issue_payload(blocked), logs: %{ codex_session_logs: [] }, - recent_events: (running && recent_events_payload(running)) || [], - last_error: retry && retry.error, + recent_events: recent_events_payload(running || blocked), + last_error: (blocked && blocked.error) || (retry && retry.error), tracked: %{} } end - defp issue_id_from_entries(running, retry), - do: (running && running.issue_id) || (retry && retry.issue_id) + defp issue_id_from_entries(running, retry, blocked), + do: (running && running.issue_id) || (retry && retry.issue_id) || (blocked && blocked.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 issue_status(running, _retry, _blocked) when not is_nil(running), do: "running" + defp issue_status(nil, retry, _blocked) when not is_nil(retry), do: "retrying" + defp issue_status(nil, nil, _blocked), do: "blocked" defp running_entry_payload(entry) do %{ issue_id: entry.issue_id, issue_identifier: entry.identifier, + issue_url: Map.get(entry, :issue_url), state: entry.state, + worker_host: Map.get(entry, :worker_host), + workspace_path: Map.get(entry, :workspace_path), session_id: entry.session_id, turn_count: Map.get(entry, :turn_count, 0), last_event: entry.last_codex_event, @@ -117,14 +125,36 @@ defmodule SymphonyElixirWeb.Presenter do %{ issue_id: entry.issue_id, issue_identifier: entry.identifier, + issue_url: Map.get(entry, :issue_url), attempt: entry.attempt, due_at: due_at_iso8601(entry.due_in_ms), - error: entry.error + error: entry.error, + worker_host: Map.get(entry, :worker_host), + workspace_path: Map.get(entry, :workspace_path) + } + end + + defp blocked_entry_payload(entry) do + %{ + issue_id: entry.issue_id, + issue_identifier: entry.identifier, + issue_url: Map.get(entry, :issue_url), + state: entry.state, + error: entry.error, + worker_host: Map.get(entry, :worker_host), + workspace_path: Map.get(entry, :workspace_path), + session_id: entry.session_id, + blocked_at: iso8601(entry.blocked_at), + last_event: entry.last_codex_event, + last_message: summarize_message(entry.last_codex_message), + last_event_at: iso8601(entry.last_codex_timestamp) } end defp running_issue_payload(running) do %{ + worker_host: Map.get(running, :worker_host), + workspace_path: Map.get(running, :workspace_path), session_id: running.session_id, turn_count: Map.get(running, :turn_count, 0), state: running.state, @@ -144,16 +174,47 @@ defmodule SymphonyElixirWeb.Presenter do %{ attempt: retry.attempt, due_at: due_at_iso8601(retry.due_in_ms), - error: retry.error + error: retry.error, + worker_host: Map.get(retry, :worker_host), + workspace_path: Map.get(retry, :workspace_path) } end - defp recent_events_payload(running) do + defp blocked_issue_payload(blocked) do + %{ + worker_host: Map.get(blocked, :worker_host), + workspace_path: Map.get(blocked, :workspace_path), + session_id: blocked.session_id, + state: blocked.state, + error: blocked.error, + blocked_at: iso8601(blocked.blocked_at), + last_event: blocked.last_codex_event, + last_message: summarize_message(blocked.last_codex_message), + last_event_at: iso8601(blocked.last_codex_timestamp) + } + end + + defp workspace_path(issue_identifier, running, retry, blocked) do + (running && Map.get(running, :workspace_path)) || + (retry && Map.get(retry, :workspace_path)) || + (blocked && Map.get(blocked, :workspace_path)) || + Path.join(Config.settings!().workspace.root, Workspace.workspace_key(issue_identifier)) + end + + defp workspace_host(running, retry, blocked) do + (running && Map.get(running, :worker_host)) || + (retry && Map.get(retry, :worker_host)) || + (blocked && Map.get(blocked, :worker_host)) + end + + defp recent_events_payload(nil), do: [] + + defp recent_events_payload(entry) do [ %{ - at: iso8601(running.last_codex_timestamp), - event: running.last_codex_event, - message: summarize_message(running.last_codex_message) + at: iso8601(entry.last_codex_timestamp), + event: entry.last_codex_event, + message: summarize_message(entry.last_codex_message) } ] |> Enum.reject(&is_nil(&1.at)) diff --git a/elixir/lib/symphony_elixir_web/router.ex b/elixir/lib/symphony_elixir_web/router.ex index e3f09a88db..42ca5ced11 100644 --- a/elixir/lib/symphony_elixir_web/router.ex +++ b/elixir/lib/symphony_elixir_web/router.ex @@ -16,6 +16,7 @@ defmodule SymphonyElixirWeb.Router do scope "/", SymphonyElixirWeb do get("/dashboard.css", StaticAssetController, :dashboard_css) + get("/favicon.png", StaticAssetController, :favicon) 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) diff --git a/elixir/lib/symphony_elixir_web/static_assets.ex b/elixir/lib/symphony_elixir_web/static_assets.ex index 7d6418b72b..9d0f2d1124 100644 --- a/elixir/lib/symphony_elixir_web/static_assets.ex +++ b/elixir/lib/symphony_elixir_web/static_assets.ex @@ -2,27 +2,43 @@ defmodule SymphonyElixirWeb.StaticAssets do @moduledoc false @dashboard_css_path Path.expand("../../priv/static/dashboard.css", __DIR__) + @favicon_path Path.expand("../../priv/static/favicon.png", __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 @favicon_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) + @dashboard_css_digest :crypto.hash(:sha256, @dashboard_css) + |> Base.encode16(case: :lower) + |> binary_part(0, 12) + @favicon File.read!(@favicon_path) + @favicon_digest :crypto.hash(:sha256, @favicon) + |> Base.encode16(case: :lower) + |> binary_part(0, 12) @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}, + "/favicon.png" => {"image/png", @favicon}, "/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 dashboard_css_url() :: String.t() + def dashboard_css_url, do: "/dashboard.css?v=#{@dashboard_css_digest}" + + @spec favicon_url() :: String.t() + def favicon_url, do: "/favicon.png?v=#{@favicon_digest}" + @spec fetch(String.t()) :: {:ok, String.t(), binary()} | :error def fetch(path) when is_binary(path) do case Map.fetch(@assets, path) do diff --git a/elixir/mix.exs b/elixir/mix.exs index 062706aab7..be14dd8b4a 100644 --- a/elixir/mix.exs +++ b/elixir/mix.exs @@ -4,7 +4,7 @@ defmodule SymphonyElixir.MixProject do def project do [ app: :symphony_elixir, - version: "0.1.0", + version: "0.0.2", elixir: "~> 1.19", compilers: [:phoenix_live_view] ++ Mix.compilers(), start_permanent: Mix.env() == :prod, @@ -13,12 +13,17 @@ defmodule SymphonyElixir.MixProject do threshold: 100 ], ignore_modules: [ + SymphonyElixir.Asana.Client, SymphonyElixir.Config, + SymphonyElixir.GitHub.Client, + SymphonyElixir.GitLab.Client, + SymphonyElixir.Jira.Client, SymphonyElixir.Linear.Client, SymphonyElixir.SpecsCheck, SymphonyElixir.Orchestrator, SymphonyElixir.Orchestrator.State, SymphonyElixir.AgentRunner, + SymphonyElixir.Application, SymphonyElixir.CLI, SymphonyElixir.Codex.AppServer, SymphonyElixir.Codex.DynamicTool, @@ -47,6 +52,7 @@ defmodule SymphonyElixir.MixProject do plt_add_apps: [:mix] ], escript: escript(), + releases: releases(), aliases: aliases(), deps: deps() ] @@ -73,7 +79,8 @@ defmodule SymphonyElixir.MixProject do {:jason, "~> 1.4"}, {:yaml_elixir, "~> 2.12"}, {:solid, "~> 1.2"}, - {:nimble_options, "~> 1.1"}, + {:ecto, "~> 3.13"}, + {:burrito, "~> 1.5", only: :prod, runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev], runtime: false} ] @@ -95,4 +102,20 @@ defmodule SymphonyElixir.MixProject do path: "bin/symphony" ] end + + defp releases do + [ + symphony: [ + steps: [:assemble, &Burrito.wrap/1], + burrito: [ + targets: [ + macos_arm64: [os: :darwin, cpu: :aarch64], + macos_x86_64: [os: :darwin, cpu: :x86_64], + linux_arm64: [os: :linux, cpu: :aarch64], + linux_x86_64: [os: :linux, cpu: :x86_64] + ] + ] + ] + ] + end end diff --git a/elixir/mix.lock b/elixir/mix.lock index 4f52fd7003..c8ef36ee84 100644 --- a/elixir/mix.lock +++ b/elixir/mix.lock @@ -1,11 +1,13 @@ %{ "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"}, + "burrito": {:hex, :burrito, "1.5.0", "d68ec01df2871f1d5bc603b883a78546c75761ac73c1bec1b7ae2cc74790fcd1", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "3861abda7bffa733862b48da3e03df0b4cd41abf6fd24b91745f5c16d971e5fa"}, "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"}, + "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "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"}, @@ -31,6 +33,7 @@ "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"}, + "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, "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"}, diff --git a/elixir/priv/static/dashboard.css b/elixir/priv/static/dashboard.css index bc191c0ca1..d5b453de10 100644 --- a/elixir/priv/static/dashboard.css +++ b/elixir/priv/static/dashboard.css @@ -388,6 +388,14 @@ pre, letter-spacing: -0.01em; } +.issue-id-link { + color: inherit; + text-decoration: underline; + text-decoration-color: currentColor; + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; +} + .issue-link { color: var(--muted); font-size: 0.86rem; diff --git a/elixir/priv/static/favicon.png b/elixir/priv/static/favicon.png new file mode 100644 index 0000000000..01a738fc29 Binary files /dev/null and b/elixir/priv/static/favicon.png differ diff --git a/elixir/test/fixtures/startup_workflow.md b/elixir/test/fixtures/startup_workflow.md new file mode 100644 index 0000000000..7c2a5e256b --- /dev/null +++ b/elixir/test/fixtures/startup_workflow.md @@ -0,0 +1,8 @@ +--- +tracker: + kind: memory +codex: + command: codex app-server +--- + +Test workflow. diff --git a/elixir/test/support/live_e2e_docker/Dockerfile b/elixir/test/support/live_e2e_docker/Dockerfile new file mode 100644 index 0000000000..974625c1cd --- /dev/null +++ b/elixir/test/support/live_e2e_docker/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + git \ + openssh-server \ + python3 \ + ripgrep \ + && rm -rf /var/lib/apt/lists/* + +RUN install -d -m 700 /root/.ssh /root/.codex /run/symphony/ssh /var/run/sshd + +RUN npm install --global @openai/codex + +COPY symphony-live-worker.conf /etc/ssh/sshd_config.d/symphony-live-worker.conf +COPY live_worker_entrypoint.sh /usr/local/bin/symphony-live-worker +RUN chmod 755 /usr/local/bin/symphony-live-worker + +EXPOSE 22 + +ENTRYPOINT ["/usr/local/bin/symphony-live-worker"] diff --git a/elixir/test/support/live_e2e_docker/docker-compose.yml b/elixir/test/support/live_e2e_docker/docker-compose.yml new file mode 100644 index 0000000000..31584538d9 --- /dev/null +++ b/elixir/test/support/live_e2e_docker/docker-compose.yml @@ -0,0 +1,20 @@ +services: + worker1: + build: + context: . + dockerfile: Dockerfile + ports: + - "${SYMPHONY_LIVE_DOCKER_WORKER_1_PORT}:22" + volumes: + - ${SYMPHONY_LIVE_DOCKER_AUTHORIZED_KEY}:/run/symphony/ssh/authorized_key.pub:ro + - ${SYMPHONY_LIVE_DOCKER_AUTH_JSON}:/root/.codex/auth.json:ro + + worker2: + build: + context: . + dockerfile: Dockerfile + ports: + - "${SYMPHONY_LIVE_DOCKER_WORKER_2_PORT}:22" + volumes: + - ${SYMPHONY_LIVE_DOCKER_AUTHORIZED_KEY}:/run/symphony/ssh/authorized_key.pub:ro + - ${SYMPHONY_LIVE_DOCKER_AUTH_JSON}:/root/.codex/auth.json:ro diff --git a/elixir/test/support/live_e2e_docker/live_worker_entrypoint.sh b/elixir/test/support/live_e2e_docker/live_worker_entrypoint.sh new file mode 100644 index 0000000000..3b70e6f4e4 --- /dev/null +++ b/elixir/test/support/live_e2e_docker/live_worker_entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +install -d -m 700 /root/.ssh /root/.codex + +if [ ! -s /run/symphony/ssh/authorized_key.pub ]; then + echo "missing authorized key at /run/symphony/ssh/authorized_key.pub" >&2 + exit 1 +fi + +install -m 600 /run/symphony/ssh/authorized_key.pub /root/.ssh/authorized_keys + +exec /usr/sbin/sshd -D -e diff --git a/elixir/test/support/live_e2e_docker/symphony-live-worker.conf b/elixir/test/support/live_e2e_docker/symphony-live-worker.conf new file mode 100644 index 0000000000..45cc12dc6d --- /dev/null +++ b/elixir/test/support/live_e2e_docker/symphony-live-worker.conf @@ -0,0 +1,7 @@ +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +UsePAM no +PermitRootLogin yes +AuthorizedKeysFile .ssh/authorized_keys diff --git a/elixir/test/support/test_support.exs b/elixir/test/support/test_support.exs index bea30f2cf1..ca7c5ddfff 100644 --- a/elixir/test/support/test_support.exs +++ b/elixir/test/support/test_support.exs @@ -12,11 +12,11 @@ defmodule SymphonyElixir.TestSupport do 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.Tracker.Issue alias SymphonyElixir.Workflow alias SymphonyElixir.WorkflowStore alias SymphonyElixir.Workspace @@ -42,7 +42,6 @@ defmodule SymphonyElixir.TestSupport do 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) @@ -97,10 +96,13 @@ defmodule SymphonyElixir.TestSupport do tracker_api_token: "token", tracker_project_slug: "project", tracker_assignee: nil, + tracker_required_labels: [], 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"), + worker_ssh_hosts: [], + worker_max_concurrent_agents_per_host: nil, max_concurrent_agents: 10, max_turns: 20, max_retry_backoff_ms: 300_000, @@ -132,10 +134,13 @@ defmodule SymphonyElixir.TestSupport do 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_required_labels = Keyword.get(config, :tracker_required_labels) 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) + worker_ssh_hosts = Keyword.get(config, :worker_ssh_hosts) + worker_max_concurrent_agents_per_host = Keyword.get(config, :worker_max_concurrent_agents_per_host) 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) @@ -168,12 +173,14 @@ defmodule SymphonyElixir.TestSupport do " api_key: #{yaml_value(tracker_api_token)}", " project_slug: #{yaml_value(tracker_project_slug)}", " assignee: #{yaml_value(tracker_assignee)}", + " required_labels: #{yaml_value(tracker_required_labels)}", " 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)}", + worker_yaml(worker_ssh_hosts, worker_max_concurrent_agents_per_host), "agent:", " max_concurrent_agents: #{yaml_value(max_concurrent_agents)}", " max_turns: #{yaml_value(max_turns)}", @@ -235,6 +242,21 @@ defmodule SymphonyElixir.TestSupport do |> Enum.join("\n") end + defp worker_yaml(ssh_hosts, max_concurrent_agents_per_host) + when ssh_hosts in [nil, []] and is_nil(max_concurrent_agents_per_host), + do: nil + + defp worker_yaml(ssh_hosts, max_concurrent_agents_per_host) do + [ + "worker:", + ssh_hosts not in [nil, []] && " ssh_hosts: #{yaml_value(ssh_hosts)}", + !is_nil(max_concurrent_agents_per_host) && + " max_concurrent_agents_per_host: #{yaml_value(max_concurrent_agents_per_host)}" + ] + |> Enum.reject(&(&1 in [nil, false])) + |> Enum.join("\n") + end + defp observability_yaml(enabled, refresh_ms, render_interval_ms) do [ "observability:", diff --git a/elixir/test/symphony_elixir/app_server_test.exs b/elixir/test/symphony_elixir/app_server_test.exs index 20ab61e9c1..4b23aed554 100644 --- a/elixir/test/symphony_elixir/app_server_test.exs +++ b/elixir/test/symphony_elixir/app_server_test.exs @@ -39,6 +39,239 @@ defmodule SymphonyElixir.AppServerTest do end end + test "app server rejects symlink escape cwd paths under the workspace root" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-app-server-symlink-cwd-guard-#{System.unique_integer([:positive])}" + ) + + try do + workspace_root = Path.join(test_root, "workspaces") + outside_workspace = Path.join(test_root, "outside") + symlink_workspace = Path.join(workspace_root, "MT-1000") + + File.mkdir_p!(workspace_root) + File.mkdir_p!(outside_workspace) + File.ln_s!(outside_workspace, symlink_workspace) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root + ) + + issue = %Issue{ + id: "issue-workspace-symlink-guard", + identifier: "MT-1000", + title: "Validate symlink workspace guard", + description: "Ensure app-server refuses symlink escape cwd targets", + state: "In Progress", + url: "https://example.org/issues/MT-1000", + labels: ["backend"] + } + + assert {:error, {:invalid_workspace_cwd, :symlink_escape, ^symlink_workspace, _root}} = + AppServer.run(symlink_workspace, "guard", issue) + after + File.rm_rf(test_root) + end + end + + test "turn timeout resets on stream updates and fires after silence" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-app-server-turn-timeout-#{System.unique_integer([:positive])}" + ) + + try do + workspace_root = Path.join(test_root, "workspaces") + workspace = Path.join(workspace_root, "MT-TIMEOUT") + 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-timeout"}}}' ;; + 4) + printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-timeout"}}}' + sleep 0.15 + printf '%s\n' '{"method":"item/updated","params":{"item":{"id":"one"}}}' + sleep 0.15 + printf '%s\n' '{"method":"item/updated","params":{"item":{"id":"two"}}}' + sleep 0.15 + 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_turn_timeout_ms: 250 + ) + + issue = %Issue{ + id: "issue-turn-timeout", + identifier: "MT-TIMEOUT", + title: "Stream timeout", + description: "Keep active streams alive", + state: "In Progress", + url: "https://example.org/issues/MT-TIMEOUT", + labels: ["backend"] + } + + assert {:ok, _result} = AppServer.run(workspace, "stream updates", issue) + + 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-silent"}}}' ;; + 4) + printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-silent"}}}' + sleep 0.4 + printf '%s\n' '{"method":"turn/completed"}' + exit 0 + ;; + *) exit 0 ;; + esac + done + """) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + codex_command: "#{codex_binary} app-server", + codex_turn_timeout_ms: 100 + ) + + assert {:error, :turn_timeout} = AppServer.run(workspace, "silent turn", issue) + after + File.rm_rf(test_root) + end + end + + test "app server passes explicit turn sandbox policies through unchanged" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-app-server-supported-turn-policies-#{System.unique_integer([:positive])}" + ) + + try do + workspace_root = Path.join(test_root, "workspaces") + workspace = Path.join(workspace_root, "MT-1001") + codex_binary = Path.join(test_root, "fake-codex") + trace_file = Path.join(test_root, "codex-supported-turn-policies.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-turn-policies.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-1001"}}}' + ;; + 3) + printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-1001"}}}' + ;; + 4) + printf '%s\\n' '{"method":"turn/completed"}' + exit 0 + ;; + *) + exit 0 + ;; + esac + done + """) + + File.chmod!(codex_binary, 0o755) + + issue = %Issue{ + id: "issue-supported-turn-policies", + identifier: "MT-1001", + title: "Validate explicit turn sandbox policy passthrough", + description: "Ensure runtime startup forwards configured turn sandbox policies unchanged", + state: "In Progress", + url: "https://example.org/issues/MT-1001", + labels: ["backend"] + } + + policy_cases = [ + %{"type" => "dangerFullAccess"}, + %{"type" => "externalSandbox", "profile" => "remote-ci"}, + %{"type" => "workspaceWrite", "writableRoots" => ["relative/path"], "networkAccess" => true}, + %{"type" => "futureSandbox", "nested" => %{"flag" => true}} + ] + + Enum.each(policy_cases, fn configured_policy -> + File.rm(trace_file) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + codex_command: "#{codex_binary} app-server", + codex_turn_sandbox_policy: configured_policy + ) + + assert {:ok, _result} = AppServer.run(workspace, "Validate supported turn policy", 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 + line + |> String.trim_leading("JSON:") + |> Jason.decode!() + |> then(fn payload -> + payload["method"] == "turn/start" && + get_in(payload, ["params", "sandboxPolicy"]) == configured_policy + end) + else + false + end + end) + end) + 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( @@ -118,6 +351,71 @@ defmodule SymphonyElixir.AppServerTest do end end + test "app server treats MCP elicitation requests as hard input blockers" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-app-server-mcp-elicitation-#{System.unique_integer([:positive])}" + ) + + try do + workspace_root = Path.join(test_root, "workspaces") + workspace = Path.join(workspace_root, "MT-188") + 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-188"}}}' + ;; + 3) + printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-188"}}}' + ;; + 4) + printf '%s\\n' '{"method":"mcpServer/elicitation/request","params":{"message":"Need operator input"}}' + ;; + *) + 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-mcp-elicitation", + identifier: "MT-188", + title: "MCP elicitation", + description: "Cannot satisfy MCP input", + state: "In Progress", + url: "https://example.org/issues/MT-188", + labels: ["backend"] + } + + assert {:error, {:turn_input_required, payload}} = + AppServer.run(workspace, "Needs MCP input", issue) + + assert payload["method"] == "mcpServer/elicitation/request" + 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( @@ -417,7 +715,7 @@ defmodule SymphonyElixir.AppServerTest do end end - test "app server sends a generic non-interactive answer for freeform tool input prompts" do + test "app server blocks freeform tool input prompts" do test_root = Path.join( System.tmp_dir!(), @@ -478,22 +776,16 @@ defmodule SymphonyElixir.AppServerTest do 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 {:error, {:turn_input_required, payload}} = + AppServer.run(workspace, "Handle generic tool input", issue) - assert_received {:app_server_message, - %{ - event: :tool_input_auto_answered, - answer: "This is a non-interactive session. Operator input is unavailable." - }} + assert payload["method"] == "item/tool/requestUserInput" 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 "app server blocks option-based tool input prompts" do test_root = Path.join( System.tmp_dir!(), @@ -504,27 +796,13 @@ defmodule SymphonyElixir.AppServerTest 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 + while IFS= read -r _line; do count=$((count + 1)) - printf 'JSON:%s\\n' \"$line\" >> \"$trace_file\" case \"$count\" in 1) @@ -537,7 +815,7 @@ defmodule SymphonyElixir.AppServerTest do ;; 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\"}}' + 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\":\"Proceed with the requested action.\",\"label\":\"Allow\"},{\"description\":\"Do not proceed.\",\"label\":\"Deny\"}],\"question\":\"How should I proceed?\"}],\"threadId\":\"thread-719\",\"turnId\":\"turn-719\"}}' ;; 5) printf '%s\\n' '{\"method\":\"turn/completed\"}' @@ -554,40 +832,24 @@ defmodule SymphonyElixir.AppServerTest do write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root, - codex_command: "#{codex_binary} app-server" + codex_command: "#{codex_binary} app-server", + codex_approval_policy: "never" ) 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", + title: "Option based tool input block", + description: "Ensure option prompts require operator input", state: "In Progress", url: "https://example.org/issues/MT-719", labels: ["backend"] } - assert {:ok, _result} = + assert {:error, {:turn_input_required, payload}} = 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) + assert payload["method"] == "item/tool/requestUserInput" after File.rm_rf(test_root) end @@ -681,9 +943,8 @@ defmodule SymphonyElixir.AppServerTest do 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"]), + get_in(payload, ["result", "output"]), "Unsupported dynamic tool" ) else @@ -806,7 +1067,7 @@ defmodule SymphonyElixir.AppServerTest do payload["id"] == 102 and get_in(payload, ["result", "success"]) == true and - get_in(payload, ["result", "contentItems", Access.at(0), "text"]) == + get_in(payload, ["result", "output"]) == ~s({"data":{"viewer":{"id":"usr_123"}}}) else false @@ -1045,14 +1306,326 @@ defmodule SymphonyElixir.AppServerTest do labels: ["backend"] } + test_pid = self() + on_message = fn message -> send(test_pid, {:app_server_message, message}) end + log = capture_log(fn -> - assert {:ok, _result} = AppServer.run(workspace, "Capture stderr log", issue) + assert {:ok, _result} = + AppServer.run(workspace, "Capture stderr log", issue, on_message: on_message) end) + assert_received {:app_server_message, %{event: :turn_completed}} + refute_received {:app_server_message, %{event: :malformed}} assert log =~ "Codex turn stream output: warning: this is stderr noise" after File.rm_rf(test_root) end end + + test "app server emits malformed events for JSON-like protocol lines that fail to decode" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-app-server-malformed-protocol-#{System.unique_integer([:positive])}" + ) + + try do + workspace_root = Path.join(test_root, "workspaces") + workspace = Path.join(workspace_root, "MT-93") + 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-93"}}}' + ;; + 3) + printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-93"}}}' + ;; + 4) + printf '%s\\n' '{"method":"turn/completed"' + 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-malformed-protocol", + identifier: "MT-93", + title: "Malformed protocol frame", + description: "Ensure malformed JSON-like frames are surfaced to the orchestrator", + state: "In Progress", + url: "https://example.org/issues/MT-93", + labels: ["backend"] + } + + test_pid = self() + on_message = fn message -> send(test_pid, {:app_server_message, message}) end + + assert {:ok, _result} = + AppServer.run(workspace, "Capture malformed protocol line", issue, on_message: on_message) + + assert_received {:app_server_message, %{event: :malformed, payload: "{\"method\":\"turn/completed\""}} + assert_received {:app_server_message, %{event: :turn_completed}} + after + File.rm_rf(test_root) + end + end + + test "app server does not pass tracker credentials to the local Codex child" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-app-server-secret-env-#{System.unique_integer([:positive])}" + ) + + custom_secret_env = "SYMP_CUSTOM_LINEAR_API_KEY_#{System.unique_integer([:positive])}" + profile_marker_env = "SYMP_TEST_BASH_PROFILE_LOADED_#{System.unique_integer([:positive])}" + previous_secret = System.get_env("LINEAR_API_KEY") + previous_custom_secret = System.get_env(custom_secret_env) + previous_home = System.get_env("HOME") + previous_trace = System.get_env("SYMP_TEST_CODEx_TRACE") + + on_exit(fn -> + restore_env("LINEAR_API_KEY", previous_secret) + restore_env(custom_secret_env, previous_custom_secret) + restore_env("HOME", previous_home) + restore_env("SYMP_TEST_CODEx_TRACE", previous_trace) + end) + + try do + bash_home = Path.join(test_root, "bash-home") + workspace_root = Path.join(test_root, "workspaces") + workspace = Path.join(workspace_root, "MT-SECRET") + codex_binary = Path.join(test_root, "fake-codex") + trace_file = Path.join(test_root, "codex-secret-env.trace") + + File.mkdir_p!(bash_home) + File.mkdir_p!(workspace) + + File.write!(Path.join(bash_home, ".bash_profile"), """ + export LINEAR_API_KEY='profile-canonical-secret-that-must-not-reach-child' + export #{custom_secret_env}='profile-custom-secret-that-must-not-reach-child' + export #{profile_marker_env}=1 + """) + + System.put_env("LINEAR_API_KEY", "canonical-secret-that-must-not-reach-child") + System.put_env(custom_secret_env, "custom-secret-that-must-not-reach-child") + System.put_env("HOME", bash_home) + System.put_env("SYMP_TEST_CODEx_TRACE", trace_file) + + File.write!(codex_binary, """ + #!/bin/sh + trace_file="$SYMP_TEST_CODEx_TRACE" + printf 'PROFILE_LOADED:%s\n' "$#{profile_marker_env}" >> "$trace_file" + printf 'CANONICAL_SECRET:%s\n' "$LINEAR_API_KEY" >> "$trace_file" + printf 'CUSTOM_SECRET:%s\n' "$#{custom_secret_env}" >> "$trace_file" + 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-secret"}}}' + ;; + 3) + printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-secret"}}}' + ;; + 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, + tracker_api_token: "$#{custom_secret_env}", + codex_command: "#{codex_binary} app-server" + ) + + issue = %Issue{ + id: "issue-secret-env", + identifier: "MT-SECRET", + title: "Keep tracker auth in Symphony", + description: "Ensure the child cannot bypass the centrally-authenticated tool boundary", + state: "In Progress", + url: "https://example.org/issues/MT-SECRET", + labels: ["security"] + } + + assert {:ok, _result} = AppServer.run(workspace, "Do not inherit tracker auth", issue) + assert File.read!(trace_file) =~ "PROFILE_LOADED:1\n" + assert File.read!(trace_file) =~ "CANONICAL_SECRET:\n" + assert File.read!(trace_file) =~ "CUSTOM_SECRET:\n" + refute File.read!(trace_file) =~ "secret-that-must-not-reach-child" + after + File.rm_rf(test_root) + end + end + + test "app server launches over ssh for remote workers" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-app-server-remote-ssh-#{System.unique_integer([:positive])}" + ) + + previous_path = System.get_env("PATH") + previous_trace = System.get_env("SYMP_TEST_SSH_TRACE") + + on_exit(fn -> + restore_env("PATH", previous_path) + restore_env("SYMP_TEST_SSH_TRACE", previous_trace) + end) + + try do + trace_file = Path.join(test_root, "ssh.trace") + fake_ssh = Path.join(test_root, "ssh") + remote_workspace = "/remote/workspaces/MT-REMOTE" + + File.mkdir_p!(test_root) + System.put_env("SYMP_TEST_SSH_TRACE", trace_file) + System.put_env("PATH", test_root <> ":" <> (previous_path || "")) + + File.write!(fake_ssh, """ + #!/bin/sh + trace_file="${SYMP_TEST_SSH_TRACE:-/tmp/symphony-fake-ssh.trace}" + count=0 + printf 'ARGV:%s\\n' "$*" >> "$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-remote"}}}' + ;; + 3) + printf '%s\\n' '{"id":3,"result":{"turn":{"id":"turn-remote"}}}' + ;; + 4) + printf '%s\\n' '{"method":"turn/completed"}' + exit 0 + ;; + *) + exit 0 + ;; + esac + done + """) + + File.chmod!(fake_ssh, 0o755) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: "/remote/workspaces", + codex_command: "fake-remote-codex app-server" + ) + + issue = %Issue{ + id: "issue-remote", + identifier: "MT-REMOTE", + title: "Run remote app server", + description: "Validate ssh-backed codex startup", + state: "In Progress", + url: "https://example.org/issues/MT-REMOTE", + labels: ["backend"] + } + + assert {:ok, _result} = + AppServer.run( + remote_workspace, + "Run remote worker", + issue, + worker_host: "worker-01:2200" + ) + + trace = File.read!(trace_file) + lines = String.split(trace, "\n", trim: true) + + assert argv_line = Enum.find(lines, &String.starts_with?(&1, "ARGV:")) + assert argv_line =~ "-T -p 2200 worker-01 bash -lc" + assert argv_line =~ "cd " + assert argv_line =~ remote_workspace + assert argv_line =~ "unset LINEAR_API_KEY" + assert argv_line =~ "exec " + assert argv_line =~ "fake-remote-codex app-server" + + expected_turn_policy = %{ + "type" => "workspaceWrite", + "writableRoots" => [remote_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 -> + payload["method"] == "thread/start" && + get_in(payload, ["params", "cwd"]) == remote_workspace + end) + else + false + end + end) + + 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", "cwd"]) == remote_workspace && + 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/asana_adapter_test.exs b/elixir/test/symphony_elixir/asana_adapter_test.exs new file mode 100644 index 0000000000..ea56955bb8 --- /dev/null +++ b/elixir/test/symphony_elixir/asana_adapter_test.exs @@ -0,0 +1,443 @@ +defmodule SymphonyElixir.Asana.AdapterTest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.Asana.Adapter, as: AsanaAdapter + alias SymphonyElixir.Asana.AgentTool, as: AsanaAgentTool + alias SymphonyElixir.Asana.Client, as: AsanaClient + + defmodule FakeAsanaClient do + def fetch_issues_by_states(states) do + send(self(), {:asana_states_called, states}) + {:ok, states} + end + + def fetch_issues_by_ids(ids) do + send(self(), {:asana_ids_called, ids}) + {:ok, ids} + end + end + + setup do + asana_client_module = Application.get_env(:symphony_elixir, :asana_client_module) + + on_exit(fn -> + if is_nil(asana_client_module) do + Application.delete_env(:symphony_elixir, :asana_client_module) + else + Application.put_env(:symphony_elixir, :asana_client_module, asana_client_module) + end + end) + + :ok + end + + test "adapter validates Asana config, delegates reads, and advertises asana_api" do + settings = tracker_settings() + + assert :ok = AsanaAdapter.validate_config(settings) + + assert {:error, :missing_asana_active_states} = + AsanaAdapter.validate_config(%{settings | active_states: nil}) + + assert {:error, :missing_asana_terminal_states} = + AsanaAdapter.validate_config(%{settings | terminal_states: nil}) + + assert :ok = AsanaAdapter.validate_config(%{settings | active_states: [], terminal_states: []}) + + assert {:error, :invalid_asana_states} = + AsanaAdapter.validate_config(%{settings | active_states: [""]}) + + assert {:error, :invalid_asana_states} = + AsanaAdapter.validate_config(%{settings | terminal_states: [42]}) + + Application.put_env(:symphony_elixir, :asana_client_module, FakeAsanaClient) + + assert {:ok, ["Todo"]} = AsanaAdapter.fetch_issues_by_states(["Todo"]) + assert_receive {:asana_states_called, ["Todo"]} + + assert {:ok, ["42"]} = AsanaAdapter.fetch_issues_by_ids(["42"]) + assert_receive {:asana_ids_called, ["42"]} + + assert [%{"name" => "asana_api"}] = AsanaAdapter.agent_tool_specs() + + assert AsanaAdapter.execute_agent_tool( + "asana_api", + %{"method" => "GET", "path" => "/users/me"}, + asana_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: 200, body: %{"data" => %{"gid" => "me"}}}} + end + )["success"] + end + + test "client validates project settings and declares token environments" do + assert :ok = AsanaClient.validate_settings(tracker_settings()) + + assert {:error, :missing_asana_project_gid} = + AsanaClient.validate_settings(tracker_settings(%{"project_gid" => 123})) + + assert {:error, :missing_asana_api_key} = + AsanaClient.validate_settings(tracker_settings(%{"api_key" => 123})) + + assert {:error, :invalid_asana_endpoint} = + AsanaClient.validate_settings(tracker_settings(%{"endpoint" => "not a url"})) + + assert {:error, :invalid_asana_endpoint} = + AsanaClient.validate_settings(tracker_settings(%{"endpoint" => "http://app.asana.com/api/1.0"})) + + assert AsanaClient.secret_environment_names(tracker_settings(%{"api_key" => "$SYMPHONY_ASANA_PAT"})) == ["ASANA_PAT", "SYMPHONY_ASANA_PAT"] + + assert {:ok, []} = + AsanaClient.fetch_issues_by_states_for_test( + ["Todo"], + tracker_settings(%{"project_gid" => " project-1 ", "api_key" => " token "}), + fn "GET", "/projects/project-1/tasks", _query, nil, settings -> + assert settings.project_gid == "project-1" + assert settings.api_key == "token" + {:ok, %{status: 200, body: %{"data" => [], "next_page" => nil}}} + end + ) + end + + test "client normalizes Asana tasks without dropping provider details" do + task = AsanaClient.normalize_issue_for_test(raw_task("42"), tracker_settings()) + + assert task.id == "42" + assert task.identifier == "ASANA-42" + + assert task.native_ref == %{ + "task_gid" => "42", + "project_gid" => "project-1", + "section_gid" => "section-todo" + } + + assert task.title == "Task 42" + assert task.description == "Notes 42" + assert task.state == "Todo" + assert task.url == "https://app.asana.test/0/project-1/42" + assert task.assignee_id == "assignee-1" + assert task.labels == ["bug", "platform"] + assert task.blocked_by == [] + assert task.dispatchable + assert %DateTime{} = task.created_at + assert %DateTime{} = task.updated_at + + refute AsanaClient.normalize_issue_for_test( + Map.put(raw_task("43"), "completed", true), + tracker_settings() + ).dispatchable + + assert AsanaClient.normalize_issue_for_test( + Map.put(raw_task("44"), "resource_subtype", "milestone"), + tracker_settings() + ).dispatchable + + refute AsanaClient.normalize_issue_for_test( + Map.put(raw_task("46"), "resource_subtype", "section"), + tracker_settings() + ).dispatchable + + assert AsanaClient.normalize_issue_for_test( + Map.put(raw_task("45"), "memberships", []), + tracker_settings() + ) == nil + end + + test "client pages state reads, filters requested sections, and drops malformed records" do + first_page = [raw_task("1"), raw_task("2"), Map.put(raw_task("3"), "name", "")] + second_page = [task_in_section("4", "Done", "section-done")] + + request_fun = fn "GET", "/projects/project-1/tasks", query, nil, settings -> + send(self(), {:asana_page, query, settings}) + + body = + case query["offset"] do + nil -> %{"data" => first_page, "next_page" => %{"offset" => "next-token"}} + "next-token" -> %{"data" => second_page, "next_page" => nil} + end + + {:ok, %{status: 200, body: body}} + end + + log = + capture_log(fn -> + assert {:ok, issues} = + AsanaClient.fetch_issues_by_states_for_test( + [" todo "], + tracker_settings(), + request_fun + ) + + assert Enum.map(issues, & &1.id) == ["1", "2"] + end) + + assert log =~ "Dropping malformed Asana task records count=1" + + assert_receive {:asana_page, %{"limit" => 100, "opt_fields" => opt_fields}, %{project_gid: "project-1"}} + + assert opt_fields =~ "memberships.section.name" + assert_receive {:asana_page, %{"offset" => "next-token"}, %{project_gid: "project-1"}} + + assert {:ok, []} = + AsanaClient.fetch_issues_by_states_for_test( + [], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + flunk("empty Asana states should not make an HTTP request") + end + ) + end + + test "client refreshes IDs in order, omits 404s and out-of-project tasks, and rejects malformed refreshes" do + request_fun = fn "GET", path, %{"opt_fields" => _fields}, nil, _settings -> + send(self(), {:asana_id_path, path}) + + case path do + "/tasks/2" -> {:ok, %{status: 200, body: %{"data" => raw_task("2")}}} + "/tasks/1" -> {:ok, %{status: 200, body: %{"data" => raw_task("1")}}} + "/tasks/404" -> {:ok, %{status: 404, body: %{"errors" => []}}} + "/tasks/out" -> {:ok, %{status: 200, body: %{"data" => out_of_project_task("out")}}} + end + end + + assert {:ok, issues} = + AsanaClient.fetch_issues_by_ids_for_test( + ["2", "1", "404", "out", "2"], + tracker_settings(), + request_fun + ) + + assert Enum.map(issues, & &1.id) == ["2", "1"] + assert_receive {:asana_id_path, "/tasks/2"} + assert_receive {:asana_id_path, "/tasks/1"} + assert_receive {:asana_id_path, "/tasks/404"} + assert_receive {:asana_id_path, "/tasks/out"} + refute_receive {:asana_id_path, "/tasks/2"} + + assert {:error, :asana_unknown_payload} = + AsanaClient.fetch_issues_by_ids_for_test( + ["bad"], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + {:ok, %{status: 200, body: %{"data" => Map.put(raw_task("bad"), "name", "")}}} + end + ) + + assert {:ok, []} = + AsanaClient.fetch_issues_by_ids_for_test( + [], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + flunk("empty Asana IDs should not make an HTTP request") + end + ) + end + + test "asana_api preserves REST status and body while rejecting unsafe arguments" do + test_pid = self() + tracker_settings = tracker_settings() + + response = + AsanaAgentTool.execute( + "asana_api", + %{ + "method" => "post", + "path" => " /tasks/42/stories ", + "query" => %{"opt_fields" => "gid"}, + "body" => %{"data" => %{"text" => "hello"}} + }, + tracker_settings: tracker_settings, + asana_client: fn method, path, query, body, opts -> + send(test_pid, {:asana_tool_called, method, path, query, body, opts}) + {:ok, %{status: 201, body: %{"data" => %{"gid" => "story-1"}}}} + end + ) + + assert_received {:asana_tool_called, "POST", "/tasks/42/stories", query, body, opts} + assert query == %{"opt_fields" => "gid"} + assert body == %{"data" => %{"text" => "hello"}} + assert opts == [tracker_settings: tracker_settings] + + assert response["success"] == true + assert Jason.decode!(response["output"]) == %{"status" => 201, "body" => %{"data" => %{"gid" => "story-1"}}} + assert response["contentItems"] == [%{"type" => "inputText", "text" => response["output"]}] + + failure = + AsanaAgentTool.execute( + "asana_api", + %{"method" => "GET", "path" => "/tasks/404"}, + asana_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: 404, body: %{"errors" => []}}} + end + ) + + assert failure["success"] == false + assert Jason.decode!(failure["output"]) == %{"status" => 404, "body" => %{"errors" => []}} + + Enum.each( + [ + %{"method" => "GET", "path" => "https://app.asana.com/api/1.0/users/me"}, + %{"method" => "PATCH", "path" => "/users/me"}, + %{"method" => "GET", "path" => "/users/me", "query" => false}, + %{"path" => "/users/me"} + ], + fn arguments -> + invalid = + AsanaAgentTool.execute( + "asana_api", + arguments, + asana_client: fn _method, _path, _query, _body, _opts -> + flunk("invalid asana_api arguments should not call the client") + end + ) + + assert invalid["success"] == false + end + ) + end + + test "asana_api reports unsupported tools, malformed calls, and client failures" do + unsupported = AsanaAgentTool.execute("not_asana_api", %{}, []) + assert unsupported["success"] == false + assert Jason.decode!(unsupported["output"])["error"]["supportedTools"] == ["asana_api"] + + Enum.each( + ["not-an-object", %{"method" => "GET", "path" => 123}], + fn arguments -> + invalid = + AsanaAgentTool.execute( + "asana_api", + arguments, + asana_client: fn _method, _path, _query, _body, _opts -> + flunk("malformed asana_api arguments should not call the client") + end + ) + + assert invalid["success"] == false + end + ) + + malformed_response = + AsanaAgentTool.execute( + "asana_api", + %{"method" => "GET", "path" => "/users/me"}, + asana_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: "not-an-integer", body: %{}}} + end + ) + + assert malformed_response["success"] == false + + Enum.each( + [:missing_asana_api_key, {:asana_api_request, :timeout}, :unexpected_failure], + fn reason -> + failure = + AsanaAgentTool.execute( + "asana_api", + %{"method" => "GET", "path" => "/users/me"}, + asana_client: fn _method, _path, _query, _body, _opts -> + {:error, reason} + end + ) + + assert failure["success"] == false + assert %{"error" => %{"message" => message}} = Jason.decode!(failure["output"]) + assert is_binary(message) + end + ) + + non_json_body = + AsanaAgentTool.execute( + "asana_api", + %{"method" => "GET", "path" => "/users/me"}, + asana_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: 200, body: self()}} + end + ) + + assert non_json_body["success"] + assert non_json_body["output"] =~ "#PID" + end + + test "tracker binds Asana tools and token env names from provider config" do + token_env = "SYMPHONY_ASANA_PAT_#{System.unique_integer([:positive])}" + previous_token = System.get_env(token_env) + System.put_env(token_env, "test-token") + + on_exit(fn -> restore_env(token_env, previous_token) end) + + write_asana_workflow!(Workflow.workflow_file_path(), "$#{token_env}") + + binding = Tracker.bind_agent_tools() + + assert binding.adapter == AsanaAdapter + assert binding.secret_environment_names == ["ASANA_PAT", token_env] + assert [%{"name" => "asana_api"}] = binding.tool_specs + assert :ok = Config.validate!() + end + + defp tracker_settings(provider_overrides \\ %{}) do + %{ + kind: "asana", + provider: + Map.merge( + %{ + "project_gid" => "project-1", + "api_key" => "test-token" + }, + provider_overrides + ), + active_states: ["Todo"], + terminal_states: ["Done"] + } + end + + defp raw_task(gid), do: task_in_section(gid, "Todo", "section-todo") + + defp task_in_section(gid, section_name, section_gid) do + %{ + "gid" => gid, + "name" => "Task #{gid}", + "notes" => " Notes #{gid} ", + "completed" => false, + "resource_subtype" => "default_task", + "assignee" => %{"gid" => "assignee-1"}, + "tags" => [%{"name" => " Bug "}, %{"name" => "bug"}, %{"name" => "Platform"}], + "memberships" => [ + %{ + "project" => %{"gid" => "project-1"}, + "section" => %{"gid" => section_gid, "name" => section_name} + } + ], + "permalink_url" => "https://app.asana.test/0/project-1/#{gid}", + "created_at" => "2026-01-01T00:00:00Z", + "modified_at" => "2026-01-02T00:00:00Z" + } + end + + defp out_of_project_task(gid) do + put_in(raw_task(gid), ["memberships", Access.at(0), "project", "gid"], "other-project") + end + + defp write_asana_workflow!(path, token) do + File.write!( + path, + """ + --- + tracker: + kind: asana + provider: + project_gid: "project-1" + api_key: #{Jason.encode!(token)} + active_states: ["Todo"] + terminal_states: ["Done"] + --- + + You are working on {{ issue.identifier }}. + """ + ) + + if Process.whereis(SymphonyElixir.WorkflowStore) do + assert :ok = SymphonyElixir.WorkflowStore.force_reload() + end + end +end diff --git a/elixir/test/symphony_elixir/asana_live_e2e_test.exs b/elixir/test/symphony_elixir/asana_live_e2e_test.exs new file mode 100644 index 0000000000..ef04dfb8cf --- /dev/null +++ b/elixir/test/symphony_elixir/asana_live_e2e_test.exs @@ -0,0 +1,486 @@ +defmodule SymphonyElixir.Asana.LiveE2ETest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.Asana.Client, as: AsanaClient + + @moduletag :live_e2e + @moduletag timeout: 300_000 + + @api_url "https://app.asana.com/api/1.0" + @result_file "LIVE_ASANA_E2E_RESULT.txt" + @task_fields "gid,name,notes,completed,resource_subtype,memberships.project.gid,memberships.section.gid,memberships.section.name,permalink_url,created_at,modified_at" + @live_e2e_skip_reason if(System.get_env("SYMPHONY_RUN_ASANA_LIVE_E2E") != "1", + do: "set SYMPHONY_RUN_ASANA_LIVE_E2E=1 to enable the real Asana/Codex end-to-end test" + ) + + @tag skip: @live_e2e_skip_reason + test "creates a real Asana project and completes a task through asana_api" do + workspace_gid = required_env!("SYMPHONY_LIVE_ASANA_WORKSPACE_GID") + token = required_env!("ASANA_PAT") + run_id = "symphony-asana-live-e2e-#{System.unique_integer([:positive])}" + project_name = "Symphony Asana live e2e #{run_id}" + active_name = "Symphony E2E Active #{run_id}" + done_name = "Symphony E2E Done #{run_id}" + test_root = Path.join(System.tmp_dir!(), run_id) + workflow_root = Path.join(test_root, "workflow") + workflow_file = Path.join(workflow_root, "WORKFLOW.md") + workspace_root = Path.join(test_root, "workspaces") + codex_home = isolated_codex_home!(test_root) + original_workflow_path = Workflow.workflow_file_path() + runtime_pid = Process.whereis(SymphonyElixir.AgentRuntimeSupervisor) + + File.mkdir_p!(workflow_root) + + team_gid = optional_env("SYMPHONY_LIVE_ASANA_TEAM_GID") + project = create_project!(workspace_gid, team_gid, token, project_name) + project_gid = project["gid"] + + try do + active_section = create_section!(project_gid, token, active_name) + done_section = create_section!(project_gid, token, done_name) + task = create_task!(project_gid, token, "Symphony Asana live e2e task #{run_id}") + task_gid = task["gid"] + + try do + assert :ok = add_task_to_section(active_section["gid"], task_gid, token) + + task_payload = get_task!(task_gid, token) + expected_comment = expected_comment("ASANA-#{task_gid}", run_id) + + assert %Issue{} = + issue = + AsanaClient.normalize_issue_for_test( + task_payload, + tracker_settings(project_gid, active_name, done_name) + ) + + stop_agent_runtime_if_running(runtime_pid) + Workflow.set_workflow_file_path(workflow_file) + + write_workflow!( + workflow_file, + project_gid, + active_name, + done_name, + workspace_root, + codex_home, + live_prompt(project_gid, done_section["gid"], expected_comment) + ) + + assert %Issue{id: state_issue_id} = wait_for_active_state_issue!(issue.id, active_name) + assert state_issue_id == issue.id + + assert {:ok, [%Issue{id: issue_id, identifier: identifier}]} = + Tracker.fetch_issues_by_ids([issue.id]) + + assert issue_id == issue.id + assert identifier == issue.identifier + + assert :ok = AgentRunner.run(issue, self(), max_turns: 3) + + runtime_info = receive_runtime_info!(issue.id) + tool_calls = completed_asana_tool_calls(issue.id) + + assert File.read!(Path.join(runtime_info.workspace_path, @result_file)) == + expected_result(issue.identifier, project_gid) + + final_task = get_task!(task_gid, token) + assert final_task["completed"] == true + assert task_in_section?(final_task, project_gid, done_section["gid"]) + + assert stories!(task_gid, token) + |> Enum.any?(&(&1["resource_subtype"] == "comment_added" and &1["text"] == expected_comment)) + + task_path = "/tasks/#{task_gid}" + stories_path = task_path <> "/stories" + move_path = "/sections/#{done_section["gid"]}/addTask" + + assert_tool_call_count!(tool_calls, "GET", task_path, 2) + assert_tool_call_count!(tool_calls, "GET", stories_path, 2) + assert_tool_call!(tool_calls, "POST", stories_path, %{"data" => %{"text" => expected_comment}}) + assert_tool_call!(tool_calls, "POST", move_path, %{"data" => %{"task" => task_gid}}) + assert_tool_call!(tool_calls, "PUT", task_path, %{"data" => %{"completed" => true}}) + after + task_cleanup_result = delete_task(task_gid, token) + task_readback = get_resource("/tasks/#{task_gid}", token) + assert :ok = task_cleanup_result + assert :not_found = task_readback + end + after + cleanup_result = delete_project(project_gid, token) + project_readback = get_resource("/projects/#{project_gid}", token) + Workflow.set_workflow_file_path(original_workflow_path) + restart_agent_runtime_if_needed(runtime_pid) + File.rm_rf(test_root) + assert :ok = cleanup_result + assert :not_found = project_readback + end + end + + defp tracker_settings(project_gid, active_name, done_name) do + %{ + kind: "asana", + provider: %{"project_gid" => project_gid, "api_key" => "test-token"}, + active_states: [active_name], + terminal_states: [done_name] + } + end + + defp write_workflow!(path, project_gid, active_name, done_name, workspace_root, codex_home, prompt) do + File.write!( + path, + """ + --- + tracker: + kind: asana + provider: + project_gid: #{Jason.encode!(project_gid)} + api_key: "$ASANA_PAT" + active_states: [#{Jason.encode!(active_name)}] + terminal_states: [#{Jason.encode!(done_name)}] + workspace: + root: #{Jason.encode!(workspace_root)} + agent: + max_turns: 3 + codex: + command: #{Jason.encode!("env CODEX_HOME=#{shell_escape(codex_home)} codex app-server")} + approval_policy: "never" + read_timeout_ms: 60000 + turn_timeout_ms: 600000 + stall_timeout_ms: 600000 + observability: + dashboard_enabled: false + --- + + #{prompt} + """ + ) + + assert :ok = SymphonyElixir.WorkflowStore.force_reload() + end + + defp live_prompt(project_gid, done_section_gid, expected_comment) do + task_path = "/tasks/{{ issue.id }}" + stories_path = task_path <> "/stories" + + """ + You are running a real Symphony Asana end-to-end test. + + The current working directory is the workspace root. + + Step 1: + Run exactly: + + ```sh + if [ -n "${ASANA_PAT:-}" ]; then asana_pat_present=yes; else asana_pat_present=no; fi + cat > #{@result_file} < name, "workspace" => workspace_gid} |> maybe_put("team", team_gid) + + asana_data!( + :post, + "/projects", + token, + %{"data" => data} + ) + end + + defp create_section!(project_gid, token, name) do + asana_data!( + :post, + "/projects/#{project_gid}/sections", + token, + %{"data" => %{"name" => name}} + ) + end + + defp create_task!(project_gid, token, name) do + asana_data!( + :post, + "/tasks", + token, + %{"data" => %{"name" => name, "projects" => [project_gid]}} + ) + end + + defp get_task!(task_gid, token) do + asana_data!(:get, "/tasks/#{task_gid}", token, nil, %{"opt_fields" => @task_fields}) + end + + defp stories!(task_gid, token) do + case asana_request!( + :get, + "/tasks/#{task_gid}/stories", + token, + nil, + %{"opt_fields" => "resource_subtype,text", "limit" => 100} + ).body do + %{"data" => stories} when is_list(stories) -> stories + _ -> flunk("Asana stories read returned an unexpected payload") + end + end + + defp add_task_to_section(section_gid, task_gid, token) do + case asana_request( + :post, + "/sections/#{section_gid}/addTask", + token, + %{"data" => %{"task" => task_gid}} + ) do + {:ok, %{status: status}} when status in 200..299 -> :ok + {:ok, %{status: status}} -> {:error, {:asana_add_task_status, status}} + {:error, reason} -> {:error, {:asana_add_task_request, reason}} + end + end + + defp delete_project(project_gid, token) do + case asana_request(:delete, "/projects/#{project_gid}", token, nil) do + {:ok, %{status: status}} when status in 200..299 -> :ok + {:ok, %{status: 404}} -> :ok + {:ok, %{status: status}} -> {:error, {:asana_cleanup_status, status}} + {:error, reason} -> {:error, {:asana_cleanup_request, reason}} + end + end + + defp delete_task(task_gid, token) do + case asana_request(:delete, "/tasks/#{task_gid}", token, nil) do + {:ok, %{status: status}} when status in 200..299 -> :ok + {:ok, %{status: 404}} -> :ok + {:ok, %{status: status}} -> {:error, {:asana_task_cleanup_status, status}} + {:error, reason} -> {:error, {:asana_task_cleanup_request, reason}} + end + end + + defp get_resource(path, token) do + case asana_request(:get, path, token, nil) do + {:ok, %{status: 404}} -> :not_found + {:ok, %{status: status}} when status in 200..299 -> :found + {:ok, %{status: status}} -> flunk("Asana cleanup read failed with HTTP #{status}") + {:error, reason} -> flunk("Asana cleanup read failed before a response: #{inspect(reason)}") + end + end + + defp asana_data!(method, path, token, body, query \\ %{}) do + case asana_request!(method, path, token, body, query).body do + %{"data" => %{} = data} -> data + _ -> flunk("Asana request returned an unexpected data payload") + end + end + + defp asana_request!(method, path, token, body, query) do + case asana_request(method, path, token, body, query) do + {:ok, %{status: status} = response} when status in 200..299 -> response + {:ok, %{status: status}} -> flunk("Asana request failed with HTTP #{status}") + {:error, reason} -> flunk("Asana request failed before a response: #{inspect(reason)}") + end + end + + defp asana_request(method, path, token, body, query \\ %{}) do + request_opts = [ + method: method, + url: @api_url <> path, + headers: [ + {"Accept", "application/json"}, + {"Authorization", "Bearer #{token}"} + ], + params: query, + connect_options: [timeout: 30_000] + ] + + request_opts = if is_nil(body), do: request_opts, else: Keyword.put(request_opts, :json, body) + + case Req.request(request_opts) do + {:ok, response} -> {:ok, %{status: response.status, body: response.body}} + {:error, reason} -> {:error, reason} + end + end + + defp task_in_section?(%{"memberships" => memberships}, project_gid, section_gid) + when is_list(memberships) do + Enum.any?(memberships, fn membership -> + get_in(membership, ["project", "gid"]) == project_gid and + get_in(membership, ["section", "gid"]) == section_gid + end) + end + + defp task_in_section?(_task, _project_gid, _section_gid), do: false + + defp receive_runtime_info!(issue_id) do + receive do + {:worker_runtime_info, ^issue_id, %{workspace_path: workspace_path} = runtime_info} + when is_binary(workspace_path) -> + runtime_info + + {:codex_worker_update, ^issue_id, _message} -> + receive_runtime_info!(issue_id) + after + 5_000 -> + flunk("timed out waiting for worker runtime info") + end + end + + defp completed_asana_tool_calls(issue_id, calls \\ []) do + receive do + {:codex_worker_update, ^issue_id, %{event: :tool_call_completed, payload: %{"params" => params}}} -> + completed_asana_tool_calls(issue_id, [params | calls]) + + {:codex_worker_update, ^issue_id, _message} -> + completed_asana_tool_calls(issue_id, calls) + after + 0 -> + Enum.reverse(calls) + end + end + + defp assert_tool_call!(calls, method, path, expected_body) do + found? = + Enum.any?(calls, fn params -> + tool_call_matches?(params, method, path) and + get_in(params, ["arguments", "body"]) == expected_body + end) + + assert found?, "expected completed asana_api #{method} #{path}" + end + + defp assert_tool_call_count!(calls, method, path, expected_count) do + count = Enum.count(calls, &tool_call_matches?(&1, method, path)) + assert count >= expected_count, "expected at least #{expected_count} completed asana_api #{method} #{path} calls" + end + + defp tool_call_matches?(params, method, path) do + arguments = Map.get(params, "arguments", %{}) + tool_name = Map.get(params, "name") || Map.get(params, "tool") + called_method = Map.get(arguments, "method") + called_path = Map.get(arguments, "path") + + tool_name == "asana_api" and is_binary(called_method) and + String.upcase(String.trim(called_method)) == method and + is_binary(called_path) and String.trim(called_path) == path + end + + defp wait_for_active_state_issue!(issue_id, state, attempts \\ 20) + + defp wait_for_active_state_issue!(issue_id, state, attempts) when attempts > 0 do + case Tracker.fetch_issues_by_states([state]) do + {:ok, issues} -> + case Enum.find(issues, &(&1.id == issue_id)) do + %Issue{} = issue -> + issue + + nil -> + Process.sleep(500) + wait_for_active_state_issue!(issue_id, state, attempts - 1) + end + + {:error, reason} -> + flunk("Asana state read failed: #{inspect(reason)}") + end + end + + defp wait_for_active_state_issue!(_issue_id, _state, 0) do + flunk("new Asana task did not appear in the active-state adapter read") + end + + defp isolated_codex_home!(test_root) do + codex_home = Path.join(test_root, "codex-home") + auth_json_path = Path.join(codex_home, "auth.json") + + source_auth_json = + Path.join( + System.get_env("CODEX_HOME") || Path.join(System.user_home!(), ".codex"), + "auth.json" + ) + + unless File.regular?(source_auth_json) do + flunk("live Asana e2e requires Codex auth") + end + + File.mkdir_p!(codex_home) + File.cp!(source_auth_json, auth_json_path) + File.chmod!(auth_json_path, 0o600) + codex_home + end + + defp stop_agent_runtime_if_running(runtime_pid) when is_pid(runtime_pid) do + assert :ok = + Supervisor.terminate_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) + end + + defp stop_agent_runtime_if_running(_runtime_pid), do: :ok + + defp restart_agent_runtime_if_needed(runtime_pid) when is_pid(runtime_pid) do + if is_nil(Process.whereis(SymphonyElixir.AgentRuntimeSupervisor)) do + case Supervisor.restart_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + end + end + end + + defp restart_agent_runtime_if_needed(_runtime_pid), do: :ok + + defp required_env!(name) do + case System.get_env(name) do + value when is_binary(value) and value != "" -> value + _ -> flunk("live Asana e2e requires #{name}") + end + end + + defp optional_env(name) do + case System.get_env(name) do + value when is_binary(value) and value != "" -> value + _ -> nil + end + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp shell_escape(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end +end diff --git a/elixir/test/symphony_elixir/core_test.exs b/elixir/test/symphony_elixir/core_test.exs index 400c006e4f..1263601cdc 100644 --- a/elixir/test/symphony_elixir/core_test.exs +++ b/elixir/test/symphony_elixir/core_test.exs @@ -3,6 +3,7 @@ defmodule SymphonyElixir.CoreTest do test "config defaults and validation checks" do write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "memory", tracker_api_token: nil, tracker_project_slug: nil, poll_interval_ms: nil, @@ -11,26 +12,31 @@ defmodule SymphonyElixir.CoreTest do 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 + config = Config.settings!() + assert config.polling.interval_ms == 30_000 + assert config.tracker.active_states == ["Todo", "In Progress"] + assert config.tracker.terminal_states == ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] + assert config.tracker.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 + + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "polling.interval_ms" write_workflow_file!(Workflow.workflow_file_path(), poll_interval_ms: 45_000) - assert Config.poll_interval_ms() == 45_000 + assert Config.settings!().polling.interval_ms == 45_000 write_workflow_file!(Workflow.workflow_file_path(), max_turns: 0) - assert Config.agent_max_turns() == 20 + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "agent.max_turns" write_workflow_file!(Workflow.workflow_file_path(), max_turns: 5) - assert Config.agent_max_turns() == 5 + assert Config.settings!().agent.max_turns == 5 write_workflow_file!(Workflow.workflow_file_path(), tracker_active_states: "Todo, Review,") - assert Config.linear_active_states() == ["Todo", "Review"] + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "tracker.active_states" write_workflow_file!(Workflow.workflow_file_path(), tracker_api_token: "token", @@ -39,12 +45,33 @@ defmodule SymphonyElixir.CoreTest do assert {:error, :missing_linear_project_slug} = Config.validate!() + write_workflow_file!(Workflow.workflow_file_path(), + tracker_api_token: " ", + tracker_project_slug: "project" + ) + + assert {:error, :missing_linear_api_token} = Config.validate!() + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_api_token: "token", + tracker_project_slug: "" + ) + + 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!() + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.command" + assert message =~ "can't be blank" + + write_workflow_file!(Workflow.workflow_file_path(), codex_command: " ") + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.command" + assert message =~ "can't be blank" write_workflow_file!(Workflow.workflow_file_path(), codex_command: "/bin/sh app-server") assert :ok = Config.validate!() @@ -62,18 +89,25 @@ defmodule SymphonyElixir.CoreTest do assert :ok = Config.validate!() write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: 123) - assert {:error, {:invalid_codex_approval_policy, 123}} = Config.validate!() + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.approval_policy" write_workflow_file!(Workflow.workflow_file_path(), codex_thread_sandbox: 123) - assert {:error, {:invalid_codex_thread_sandbox, 123}} = Config.validate!() + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.thread_sandbox" - write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: 123) + 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() + previous_linear_api_key = System.get_env("LINEAR_API_KEY") + on_exit(fn -> Workflow.set_workflow_file_path(original_workflow_path) end) + on_exit(fn -> restore_env("LINEAR_API_KEY", previous_linear_api_key) end) + + System.put_env("LINEAR_API_KEY", "test-linear-api-key") Workflow.clear_workflow_file_path() assert {:ok, %{config: config, prompt: prompt}} = Workflow.load() @@ -82,7 +116,7 @@ defmodule SymphonyElixir.CoreTest do 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_binary(get_in(tracker, ["provider", "project_slug"])) assert is_list(Map.get(tracker, "active_states")) assert is_list(Map.get(tracker, "terminal_states")) @@ -111,8 +145,8 @@ defmodule SymphonyElixir.CoreTest do codex_command: "/bin/sh app-server" ) - assert Config.linear_api_token() == env_api_key - assert Config.linear_project_slug() == "project" + assert Config.settings!().tracker.api_key == env_api_key + assert Config.settings!().tracker.project_slug == "project" assert :ok = Config.validate!() end @@ -129,7 +163,7 @@ defmodule SymphonyElixir.CoreTest do codex_command: "/bin/sh app-server" ) - assert Config.linear_assignee() == env_assignee + assert Config.settings!().tracker.assignee == env_assignee end test "workflow file path defaults to WORKFLOW.md in the current working directory when app env is unset" do @@ -179,32 +213,266 @@ defmodule SymphonyElixir.CoreTest do assert {:error, :workflow_front_matter_not_a_map} = Workflow.load(workflow_path) end - test "SymphonyElixir.start_link delegates to the orchestrator" do + test "SymphonyElixir.start_link starts the agent runtime" 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) + runtime_pid = Process.whereis(SymphonyElixir.AgentRuntimeSupervisor) on_exit(fn -> - if is_nil(Process.whereis(SymphonyElixir.Orchestrator)) do - case Supervisor.restart_child(SymphonyElixir.Supervisor, SymphonyElixir.Orchestrator) do + if is_nil(Process.whereis(SymphonyElixir.AgentRuntimeSupervisor)) do + case Supervisor.restart_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) 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) + if is_pid(runtime_pid) do + assert :ok = + Supervisor.terminate_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) end assert {:ok, pid} = SymphonyElixir.start_link() - assert Process.whereis(SymphonyElixir.Orchestrator) == pid + assert Process.whereis(SymphonyElixir.AgentRuntimeSupervisor) == pid + assert is_pid(Process.whereis(SymphonyElixir.TaskSupervisor)) + assert is_pid(Process.whereis(SymphonyElixir.Orchestrator)) GenServer.stop(pid) end + test "orchestrator fails startup when semantic preflight fails" do + issue_suffix = System.unique_integer([:positive]) + orchestrator_name = Module.concat(__MODULE__, "InvalidOrchestrator#{issue_suffix}") + workflow_path = Workflow.workflow_file_path() + + on_exit(fn -> + if pid = Process.whereis(orchestrator_name) do + GenServer.stop(pid) + end + + write_workflow_file!(workflow_path, tracker_kind: "memory") + + if is_nil(Process.whereis(WorkflowStore)) do + assert {:ok, _pid} = Supervisor.restart_child(SymphonyElixir.Supervisor, WorkflowStore) + end + + if is_nil(Process.whereis(SymphonyElixir.AgentRuntimeSupervisor)) do + assert {:ok, _pid} = + Supervisor.restart_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) + end + end) + + assert :ok = + Supervisor.terminate_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) + + assert :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, WorkflowStore) + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_api_token: "token", + tracker_project_slug: nil + ) + + previous_trap_exit = Process.flag(:trap_exit, true) + + assert {:error, :missing_linear_project_slug} = + Orchestrator.start_link(name: orchestrator_name) + + Process.flag(:trap_exit, previous_trap_exit) + + refute Process.whereis(orchestrator_name) + end + + test "runtime restart keeps last good settings after an invalid reload" do + issue_suffix = System.unique_integer([:positive]) + runtime_supervisor_name = Module.concat(__MODULE__, "ReloadRuntime#{issue_suffix}") + task_supervisor_name = Module.concat(__MODULE__, "ReloadTaskSupervisor#{issue_suffix}") + orchestrator_name = Module.concat(__MODULE__, "ReloadOrchestrator#{issue_suffix}") + + on_exit(fn -> + if pid = Process.whereis(runtime_supervisor_name) do + GenServer.stop(pid) + end + end) + + write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") + + assert {:ok, runtime_pid} = + SymphonyElixir.AgentRuntimeSupervisor.start_link( + name: runtime_supervisor_name, + task_supervisor_name: task_supervisor_name, + orchestrator_name: orchestrator_name + ) + + Process.unlink(runtime_pid) + original_orchestrator_pid = Process.whereis(orchestrator_name) + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "linear", + tracker_api_token: "token", + tracker_project_slug: nil + ) + + assert {:error, :missing_linear_project_slug} = Config.validate!() + assert Config.settings!().tracker.kind == "memory" + + Process.exit(original_orchestrator_pid, :kill) + + restarted_orchestrator_pid = + eventually_value(fn -> + case Process.whereis(orchestrator_name) do + pid when is_pid(pid) and pid != original_orchestrator_pid -> + case Orchestrator.snapshot(orchestrator_name, 100) do + %{} -> pid + _ -> nil + end + + _ -> + nil + end + end) + + assert is_pid(restarted_orchestrator_pid) + assert Process.whereis(orchestrator_name) == restarted_orchestrator_pid + assert Process.alive?(runtime_pid) + end + + test "restarting the orchestrator does not overlap redispatched work" do + issue_suffix = System.unique_integer([:positive]) + + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-orchestrator-restart-#{issue_suffix}" + ) + + hook_marker = Path.join(test_root, "before-run-started") + hook_fifo = Path.join(test_root, "before-run-blocker") + runtime_supervisor_name = Module.concat(__MODULE__, "AgentRuntimeSupervisor#{issue_suffix}") + task_supervisor_name = Module.concat(__MODULE__, "TaskSupervisor#{issue_suffix}") + orchestrator_name = Module.concat(__MODULE__, "RestartOrchestrator#{issue_suffix}") + + previous_memory_issues = Application.get_env(:symphony_elixir, :memory_tracker_issues) + + issue = %Issue{ + id: "issue-restart-#{issue_suffix}", + identifier: "MT-#{issue_suffix}", + title: "Restart an in-flight worker", + description: "Keep one worker active while the orchestrator restarts", + state: "In Progress", + url: "https://example.org/issues/MT-#{issue_suffix}", + labels: [], + dispatchable: true + } + + on_exit(fn -> + if pid = Process.whereis(runtime_supervisor_name) do + GenServer.stop(pid) + end + + restore_app_env(:memory_tracker_issues, previous_memory_issues) + restart_default_runtime!() + File.rm_rf(test_root) + end) + + if Process.whereis(SymphonyElixir.AgentRuntimeSupervisor) do + assert :ok = + Supervisor.terminate_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) + end + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "memory", + workspace_root: test_root, + poll_interval_ms: 10, + hook_before_run: "mkfifo \"#{hook_fifo}\"; : > \"#{hook_marker}\"; read _ < \"#{hook_fifo}\"", + hook_timeout_ms: 60_000 + ) + + Application.put_env(:symphony_elixir, :memory_tracker_issues, [issue]) + + assert {:ok, runtime_supervisor_pid} = + SymphonyElixir.AgentRuntimeSupervisor.start_link( + name: runtime_supervisor_name, + task_supervisor_name: task_supervisor_name, + orchestrator_name: orchestrator_name + ) + + Process.unlink(runtime_supervisor_pid) + + orchestrator_pid = Process.whereis(orchestrator_name) + task_supervisor_pid = Process.whereis(task_supervisor_name) + + assert is_pid(orchestrator_pid) + assert is_pid(task_supervisor_pid) + + first_worker_pid = + eventually_value(fn -> + case Task.Supervisor.children(task_supervisor_name) do + [pid] -> pid + _ -> nil + end + end) + + assert is_pid(first_worker_pid) + assert Process.alive?(first_worker_pid) + assert eventually_value(fn -> if File.exists?(hook_marker), do: true end) + + monitor_ref = Process.monitor(orchestrator_pid) + Process.exit(orchestrator_pid, :kill) + assert_receive {:DOWN, ^monitor_ref, :process, ^orchestrator_pid, :killed}, 1_000 + + restarted_pid = + eventually_value(fn -> + case Process.whereis(orchestrator_name) do + pid when is_pid(pid) and pid != orchestrator_pid -> pid + _ -> nil + end + end) + + restarted_task_supervisor_pid = + eventually_value(fn -> + case Process.whereis(task_supervisor_name) do + pid when is_pid(pid) and pid != task_supervisor_pid -> pid + _ -> nil + end + end) + + assert is_pid(restarted_pid) + assert is_pid(restarted_task_supervisor_pid) + assert is_map(GenServer.call(restarted_pid, :snapshot)) + refute Process.alive?(first_worker_pid) + + second_worker_pid = + eventually_value(fn -> + children = Task.Supervisor.children(task_supervisor_name) + assert length(children) <= 1 + + case children do + [pid] when pid != first_worker_pid -> pid + _ -> nil + end + end) + + assert is_pid(second_worker_pid) + assert Process.alive?(second_worker_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([]) + assert {:ok, []} = Client.fetch_issues_by_ids([]) end test "non-active issue state stops running agent without cleaning workspace" do @@ -270,7 +538,7 @@ defmodule SymphonyElixir.CoreTest do end end - test "terminal issue state stops running agent and cleans workspace" do + test "terminal issue state stops running agent before cleaning workspace" do test_root = Path.join( System.tmp_dir!(), @@ -280,16 +548,96 @@ defmodule SymphonyElixir.CoreTest do issue_id = "issue-2" issue_identifier = "MT-556" workspace = Path.join(test_root, issue_identifier) + worker_alive_marker = Path.join(test_root, "worker-alive") + cleanup_marker = Path.join(test_root, "cleanup-order") 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"] + tracker_terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate"], + hook_before_remove: "if [ -f \"#{worker_alive_marker}\" ]; then printf alive > \"#{cleanup_marker}\"; else printf stopped > \"#{cleanup_marker}\"; fi" ) - File.mkdir_p!(test_root) File.mkdir_p!(workspace) + {:ok, task_supervisor} = Task.Supervisor.start_link() + + {:ok, agent_pid} = + Task.Supervisor.start_child(task_supervisor, fn -> + Process.flag(:trap_exit, true) + File.write!(worker_alive_marker, "alive") + + try do + receive do + {:EXIT, _from, :shutdown} -> :ok + end + after + File.rm(worker_alive_marker) + end + end) + + assert eventually_value(fn -> if File.exists?(worker_alive_marker), do: true end) + + state = %Orchestrator.State{ + task_supervisor: task_supervisor, + 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) + assert File.read!(cleanup_marker) == "stopped" + refute File.exists?(workspace) + after + File.rm_rf(test_root) + end + end + + test "terminal cleanup uses the workspace recorded for the running issue" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-terminal-recorded-workspace-#{System.unique_integer([:positive])}" + ) + + old_root = Path.join(test_root, "old-root") + new_root = Path.join(test_root, "new-root") + issue_id = "issue-recorded-workspace" + issue_identifier = "MT-557" + old_workspace = Path.join(old_root, issue_identifier) + new_workspace = Path.join(new_root, issue_identifier) + + try do + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: old_root, + tracker_active_states: ["Todo", "In Progress", "In Review"], + tracker_terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate"] + ) + + File.mkdir_p!(old_workspace) + File.mkdir_p!(new_workspace) agent_pid = spawn(fn -> @@ -305,6 +653,7 @@ defmodule SymphonyElixir.CoreTest do ref: nil, identifier: issue_identifier, issue: %Issue{id: issue_id, state: "In Progress", identifier: issue_identifier}, + workspace_path: old_workspace, started_at: DateTime.utc_now() } }, @@ -313,6 +662,8 @@ defmodule SymphonyElixir.CoreTest do retry_attempts: %{} } + write_workflow_file!(Workflow.workflow_file_path(), workspace_root: new_root) + issue = %Issue{ id: issue_id, identifier: issue_identifier, @@ -322,13 +673,89 @@ defmodule SymphonyElixir.CoreTest do labels: [] } - updated_state = Orchestrator.reconcile_issue_states_for_test([issue], state) + _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 File.exists?(old_workspace) + assert File.exists?(new_workspace) + after + File.rm_rf(test_root) + end + end + + test "missing running issues stop active agents without cleaning the workspace" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-missing-running-reconcile-#{System.unique_integer([:positive])}" + ) + + previous_memory_issues = Application.get_env(:symphony_elixir, :memory_tracker_issues) + issue_id = "issue-missing" + issue_identifier = "MT-557" + + try do + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "memory", + workspace_root: test_root, + tracker_active_states: ["Todo", "In Progress", "In Review"], + tracker_terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate"], + poll_interval_ms: 30_000 + ) + + Application.put_env(:symphony_elixir, :memory_tracker_issues, []) + + orchestrator_name = Module.concat(__MODULE__, :MissingRunningIssueOrchestrator) + {:ok, pid} = Orchestrator.start_link(name: orchestrator_name) + + on_exit(fn -> + restore_app_env(:memory_tracker_issues, previous_memory_issues) + + if Process.alive?(pid) do + Process.exit(pid, :normal) + end + end) + + Process.sleep(50) + + assert {:ok, workspace} = + SymphonyElixir.PathSafety.canonicalize(Path.join(test_root, issue_identifier)) + + File.mkdir_p!(workspace) + + agent_pid = + spawn(fn -> + receive do + :stop -> :ok + end + end) + + initial_state = :sys.get_state(pid) + + running_entry = %{ + pid: agent_pid, + ref: nil, + identifier: issue_identifier, + issue: %Issue{id: issue_id, state: "In Progress", identifier: issue_identifier}, + 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, :tick) + Process.sleep(100) + state = :sys.get_state(pid) + + refute Map.has_key?(state.running, issue_id) + refute MapSet.member?(state.claimed, issue_id) refute Process.alive?(agent_pid) - refute File.exists?(workspace) + assert File.exists?(workspace) after + restore_app_env(:memory_tracker_issues, previous_memory_issues) File.rm_rf(test_root) end end @@ -361,7 +788,8 @@ defmodule SymphonyElixir.CoreTest do state: "In Progress", title: "Active state refresh", description: "State should be refreshed", - labels: [] + labels: [], + dispatchable: true } updated_state = Orchestrator.reconcile_issue_states_for_test([issue], state) @@ -392,7 +820,7 @@ defmodule SymphonyElixir.CoreTest do id: issue_id, identifier: "MT-561", state: "In Progress", - assigned_to_worker: true + dispatchable: true }, started_at: DateTime.utc_now() } @@ -409,7 +837,7 @@ defmodule SymphonyElixir.CoreTest do title: "Reassigned active issue", description: "Worker should stop", labels: [], - assigned_to_worker: false + dispatchable: false } updated_state = Orchestrator.reconcile_issue_states_for_test([issue], state) @@ -419,6 +847,178 @@ defmodule SymphonyElixir.CoreTest do refute Process.alive?(agent_pid) end + test "reconcile stops running issue when a required label is removed" do + write_workflow_file!(Workflow.workflow_file_path(), tracker_required_labels: ["symphony"]) + + issue_id = "issue-unlabeled" + + agent_pid = + spawn(fn -> + receive do + :stop -> :ok + end + end) + + state = %Orchestrator.State{ + running: %{ + issue_id => %{ + pid: agent_pid, + ref: nil, + identifier: "MT-562", + issue: %Issue{ + id: issue_id, + identifier: "MT-562", + state: "In Progress", + labels: ["symphony"] + }, + 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-562", + state: "In Progress", + title: "Opted out active issue", + 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) + end + + test "reconcile releases a blocked issue when a required label is removed" do + write_workflow_file!(Workflow.workflow_file_path(), tracker_required_labels: ["symphony"]) + + issue_id = "blocked-unlabeled" + + state = %Orchestrator.State{ + blocked: %{ + issue_id => %{ + identifier: "MT-564", + error: "operator input required", + worker_host: nil + } + }, + claimed: MapSet.new([issue_id]), + retry_attempts: %{} + } + + issue = %Issue{ + id: issue_id, + identifier: "MT-564", + title: "Blocked but opted out", + state: "In Progress", + labels: [] + } + + updated_state = Orchestrator.reconcile_blocked_issue_states_for_test([issue], state) + + refute Map.has_key?(updated_state.blocked, issue_id) + refute MapSet.member?(updated_state.claimed, issue_id) + end + + test "retry releases its claim when a required label is removed" do + write_workflow_file!(Workflow.workflow_file_path(), tracker_required_labels: ["symphony"]) + + issue_id = "retry-unlabeled" + + state = %Orchestrator.State{ + claimed: MapSet.new([issue_id]), + retry_attempts: %{} + } + + issue = %Issue{ + id: issue_id, + identifier: "MT-565", + title: "Retry opted out", + state: "In Progress", + labels: [] + } + + updated_state = + Orchestrator.handle_retry_issue_lookup_for_test(issue, state, issue_id, 1, %{ + identifier: issue.identifier, + error: "agent exited" + }) + + refute MapSet.member?(updated_state.claimed, issue_id) + refute Map.has_key?(updated_state.retry_attempts, issue_id) + end + + test "retry releases its claim when dispatch revalidation no longer finds the issue" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-retry-refresh-#{System.unique_integer([:positive])}" + ) + + issue_id = "retry-refreshed-issue" + + try do + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "memory", + workspace_root: test_root, + hook_before_run: "exit 1" + ) + + Application.put_env(:symphony_elixir, :memory_tracker_issues, []) + {:ok, task_supervisor} = Task.Supervisor.start_link() + + state = %Orchestrator.State{ + task_supervisor: task_supervisor, + claimed: MapSet.new([issue_id]), + retry_attempts: %{} + } + + issue = %Issue{ + id: issue_id, + identifier: "MT-566", + title: "Retry refreshed issue", + state: "In Progress", + dispatchable: true, + labels: [] + } + + updated_state = + Orchestrator.handle_retry_issue_lookup_for_test(issue, state, issue_id, 1, %{ + identifier: issue.identifier, + error: "agent exited" + }) + + refute MapSet.member?(updated_state.claimed, issue_id) + refute Map.has_key?(updated_state.running, issue_id) + refute Map.has_key?(updated_state.retry_attempts, issue_id) + after + File.rm_rf(test_root) + end + end + + test "agent runner does not continue after a required label is removed" do + write_workflow_file!(Workflow.workflow_file_path(), tracker_required_labels: ["symphony"]) + + issue = %Issue{ + id: "issue-label-continuation", + identifier: "MT-563", + title: "Stop after opt-out", + state: "In Progress", + labels: ["symphony"] + } + + refreshed_issue = %{issue | labels: []} + fetcher = fn ["issue-label-continuation"] -> {:ok, [refreshed_issue]} end + + assert {:done, ^refreshed_issue} = + AgentRunner.continue_with_issue_for_test(issue, fetcher) + end + test "normal worker exit schedules active-state continuation retry" do issue_id = "issue-resume" ref = make_ref() @@ -538,6 +1138,123 @@ defmodule SymphonyElixir.CoreTest do assert_due_in_range(due_at_ms, 9_000, 10_500) end + test "stale retry timer messages do not consume newer retry entries" do + issue_id = "issue-stale-retry" + orchestrator_name = Module.concat(__MODULE__, :StaleRetryOrchestrator) + {: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) + current_retry_token = make_ref() + stale_retry_token = make_ref() + + :sys.replace_state(pid, fn _ -> + initial_state + |> Map.put(:retry_attempts, %{ + issue_id => %{ + attempt: 2, + timer_ref: nil, + retry_token: current_retry_token, + due_at_ms: System.monotonic_time(:millisecond) + 30_000, + identifier: "MT-561", + error: "agent exited: :boom" + } + }) + end) + + send(pid, {:retry_issue, issue_id, stale_retry_token}) + Process.sleep(50) + + assert %{ + attempt: 2, + retry_token: ^current_retry_token, + identifier: "MT-561", + error: "agent exited: :boom" + } = :sys.get_state(pid).retry_attempts[issue_id] + end + + test "manual refresh coalesces repeated requests and ignores superseded ticks" do + now_ms = System.monotonic_time(:millisecond) + stale_tick_token = make_ref() + + state = %Orchestrator.State{ + poll_interval_ms: 30_000, + max_concurrent_agents: 1, + next_poll_due_at_ms: now_ms + 30_000, + poll_check_in_progress: false, + tick_timer_ref: nil, + tick_token: stale_tick_token, + codex_totals: %{input_tokens: 0, output_tokens: 0, total_tokens: 0, seconds_running: 0}, + codex_rate_limits: nil + } + + assert {:reply, %{queued: true, coalesced: false}, refreshed_state} = + Orchestrator.handle_call(:request_refresh, {self(), make_ref()}, state) + + assert is_reference(refreshed_state.tick_timer_ref) + assert is_reference(refreshed_state.tick_token) + refute refreshed_state.tick_token == stale_tick_token + assert refreshed_state.next_poll_due_at_ms <= System.monotonic_time(:millisecond) + + assert {:reply, %{queued: true, coalesced: true}, coalesced_state} = + Orchestrator.handle_call(:request_refresh, {self(), make_ref()}, refreshed_state) + + assert coalesced_state.tick_token == refreshed_state.tick_token + assert {:noreply, ^coalesced_state} = Orchestrator.handle_info({:tick, stale_tick_token}, coalesced_state) + end + + test "select_worker_host_for_test skips full ssh hosts under the shared per-host cap" do + write_workflow_file!(Workflow.workflow_file_path(), + worker_ssh_hosts: ["worker-a", "worker-b"], + worker_max_concurrent_agents_per_host: 1 + ) + + state = %Orchestrator.State{ + running: %{ + "issue-1" => %{worker_host: "worker-a"} + } + } + + assert Orchestrator.select_worker_host_for_test(state, nil) == "worker-b" + end + + test "select_worker_host_for_test returns no_worker_capacity when every ssh host is full" do + write_workflow_file!(Workflow.workflow_file_path(), + worker_ssh_hosts: ["worker-a", "worker-b"], + worker_max_concurrent_agents_per_host: 1 + ) + + state = %Orchestrator.State{ + running: %{ + "issue-1" => %{worker_host: "worker-a"}, + "issue-2" => %{worker_host: "worker-b"} + } + } + + assert Orchestrator.select_worker_host_for_test(state, nil) == :no_worker_capacity + end + + test "select_worker_host_for_test keeps the preferred ssh host when it still has capacity" do + write_workflow_file!(Workflow.workflow_file_path(), + worker_ssh_hosts: ["worker-a", "worker-b"], + worker_max_concurrent_agents_per_host: 2 + ) + + state = %Orchestrator.State{ + running: %{ + "issue-1" => %{worker_host: "worker-a"}, + "issue-2" => %{worker_host: "worker-b"} + } + } + + assert Orchestrator.select_worker_host_for_test(state, "worker-a") == "worker-a" + 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) @@ -545,6 +1262,42 @@ defmodule SymphonyElixir.CoreTest do assert remaining_ms <= max_remaining_ms end + defp restore_app_env(key, nil), do: Application.delete_env(:symphony_elixir, key) + defp restore_app_env(key, value), do: Application.put_env(:symphony_elixir, key, value) + + defp restart_default_runtime! do + if Process.whereis(SymphonyElixir.AgentRuntimeSupervisor) do + :ok = + Supervisor.terminate_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) + end + + case Supervisor.restart_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid + end + end + + defp eventually_value(fun, attempts \\ 100) + + defp eventually_value(_fun, 0), do: nil + + defp eventually_value(fun, attempts) do + case fun.() do + nil -> + Process.sleep(10) + eventually_value(fun, attempts - 1) + + value -> + value + end + end + test "fetch issues by states with empty state set is a no-op" do assert {:ok, []} = Client.fetch_issues_by_states([]) end @@ -668,7 +1421,7 @@ defmodule SymphonyElixir.CoreTest do prompt = PromptBuilder.build_prompt(issue) - assert prompt =~ "You are working on a Linear issue." + assert prompt =~ "You are working on an issue from the configured tracker." assert prompt =~ "Identifier: MT-777" assert prompt =~ "Title: Make fallback prompt useful" assert prompt =~ "Body:" @@ -729,6 +1482,11 @@ defmodule SymphonyElixir.CoreTest do test "in-repo WORKFLOW.md renders correctly" do workflow_path = Workflow.workflow_file_path() + previous_linear_api_key = System.get_env("LINEAR_API_KEY") + + on_exit(fn -> restore_env("LINEAR_API_KEY", previous_linear_api_key) end) + + System.put_env("LINEAR_API_KEY", "test-linear-api-key") Workflow.set_workflow_file_path(Path.expand("WORKFLOW.md", File.cwd!())) issue = %Issue{ @@ -751,12 +1509,12 @@ defmodule SymphonyElixir.CoreTest do 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 =~ "Only stop early for a true external 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" + assert prompt =~ "Follow-up context:" + assert prompt =~ "follow-up attempt #2" end test "prompt builder adds continuation guidance for retries" do @@ -949,6 +1707,76 @@ defmodule SymphonyElixir.CoreTest do end end + test "agent runner surfaces ssh startup failures instead of silently hopping hosts" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-agent-runner-single-host-#{System.unique_integer([:positive])}" + ) + + previous_path = System.get_env("PATH") + previous_trace = System.get_env("SYMP_TEST_SSH_TRACE") + + on_exit(fn -> + restore_env("PATH", previous_path) + restore_env("SYMP_TEST_SSH_TRACE", previous_trace) + end) + + try do + trace_file = Path.join(test_root, "ssh.trace") + fake_ssh = Path.join(test_root, "ssh") + + File.mkdir_p!(test_root) + System.put_env("SYMP_TEST_SSH_TRACE", trace_file) + System.put_env("PATH", test_root <> ":" <> (previous_path || "")) + + File.write!(fake_ssh, """ + #!/bin/sh + trace_file="${SYMP_TEST_SSH_TRACE:-/tmp/symphony-fake-ssh.trace}" + printf 'ARGV:%s\\n' "$*" >> "$trace_file" + + case "$*" in + *worker-a*"__SYMPHONY_WORKSPACE__"*) + printf '%s\\n' 'worker-a prepare failed' >&2 + exit 75 + ;; + *worker-b*"__SYMPHONY_WORKSPACE__"*) + printf '%s\\t%s\\t%s\\n' '__SYMPHONY_WORKSPACE__' '1' '/remote/home/.symphony-remote-workspaces/MT-SSH-FAILOVER' + exit 0 + ;; + *) + exit 0 + ;; + esac + """) + + File.chmod!(fake_ssh, 0o755) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: "~/.symphony-remote-workspaces", + worker_ssh_hosts: ["worker-a", "worker-b"] + ) + + issue = %Issue{ + id: "issue-ssh-failover", + identifier: "MT-SSH-FAILOVER", + title: "Do not fail over within a single worker run", + description: "Surface the startup failure to the orchestrator", + state: "In Progress" + } + + assert_raise RuntimeError, ~r/workspace_prepare_failed/, fn -> + AgentRunner.run(issue, nil, worker_host: "worker-a") + end + + trace = File.read!(trace_file) + assert trace =~ "worker-a bash -lc" + refute trace =~ "worker-b bash -lc" + 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( @@ -1034,7 +1862,8 @@ defmodule SymphonyElixir.CoreTest do identifier: "MT-247", title: "Continue until done", description: "Still active after first turn", - state: state + state: state, + dispatchable: true } ]} end @@ -1151,7 +1980,8 @@ defmodule SymphonyElixir.CoreTest do identifier: "MT-248", title: "Stop at max turns", description: "Still active", - state: "In Progress" + state: "In Progress", + dispatchable: true } ]} end @@ -1251,6 +2081,7 @@ defmodule SymphonyElixir.CoreTest do } assert {:ok, _result} = AppServer.run(workspace, "Fix workspace start args", issue) + assert {:ok, canonical_workspace} = SymphonyElixir.PathSafety.canonicalize(workspace) trace = File.read!(trace_file) lines = String.split(trace, "\n", trim: true) @@ -1278,7 +2109,7 @@ defmodule SymphonyElixir.CoreTest do 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) + get_in(payload, ["params", "cwd"]) == canonical_workspace end) else false @@ -1287,7 +2118,7 @@ defmodule SymphonyElixir.CoreTest do expected_turn_sandbox_policy = %{ "type" => "workspaceWrite", - "writableRoots" => [Path.expand(workspace)], + "writableRoots" => [canonical_workspace], "readOnlyAccess" => %{"type" => "fullAccess"}, "networkAccess" => false, "excludeTmpdirEnvVar" => false, @@ -1309,7 +2140,7 @@ defmodule SymphonyElixir.CoreTest do } payload["method"] == "turn/start" && - get_in(payload, ["params", "cwd"]) == Path.expand(workspace) && + get_in(payload, ["params", "cwd"]) == canonical_workspace && get_in(payload, ["params", "approvalPolicy"]) == expected_approval_policy && get_in(payload, ["params", "sandboxPolicy"]) == expected_turn_sandbox_policy end) @@ -1380,7 +2211,7 @@ defmodule SymphonyElixir.CoreTest do write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root, - codex_command: "#{codex_binary} --model gpt-5.3-codex app-server" + codex_command: "#{codex_binary} --config 'model=\"gpt-5.5\"' app-server" ) issue = %Issue{ @@ -1399,7 +2230,7 @@ defmodule SymphonyElixir.CoreTest do 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") + assert String.contains?(argv_line, "--config model=\"gpt-5.5\" app-server") refute String.contains?(argv_line, "--ask-for-approval never") refute String.contains?(argv_line, "--sandbox danger-full-access") after @@ -1464,6 +2295,9 @@ defmodule SymphonyElixir.CoreTest do File.chmod!(codex_binary, 0o755) + workspace_cache = Path.join(Path.expand(workspace), ".cache") + File.mkdir_p!(workspace_cache) + write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root, codex_command: "#{codex_binary} app-server", @@ -1471,7 +2305,7 @@ defmodule SymphonyElixir.CoreTest do codex_thread_sandbox: "workspace-write", codex_turn_sandbox_policy: %{ type: "workspaceWrite", - writableRoots: [Path.expand(workspace), Path.join(Path.expand(workspace_root), ".cache")] + writableRoots: [Path.expand(workspace), workspace_cache] } ) @@ -1506,7 +2340,7 @@ defmodule SymphonyElixir.CoreTest do expected_turn_policy = %{ "type" => "workspaceWrite", - "writableRoots" => [Path.expand(workspace), Path.join(Path.expand(workspace_root), ".cache")] + "writableRoots" => [Path.expand(workspace), workspace_cache] } assert Enum.any?(lines, fn line -> diff --git a/elixir/test/symphony_elixir/dynamic_tool_test.exs b/elixir/test/symphony_elixir/dynamic_tool_test.exs index a5536e0338..4d089af89d 100644 --- a/elixir/test/symphony_elixir/dynamic_tool_test.exs +++ b/elixir/test/symphony_elixir/dynamic_tool_test.exs @@ -1,7 +1,8 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do use SymphonyElixir.TestSupport - alias SymphonyElixir.Codex.DynamicTool + alias SymphonyElixir.Codex.DynamicTool, as: BoundDynamicTool + alias SymphonyElixir.Linear.AgentTool, as: DynamicTool test "tool_specs advertises the linear_graphql input contract" do assert [ @@ -23,23 +24,55 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do end test "unsupported tools return a failure payload with the supported tool list" do - response = DynamicTool.execute("not_a_real_tool", %{}) + response = DynamicTool.execute("not_a_real_tool", %{}, []) assert response["success"] == false - assert [ - %{ - "type" => "inputText", - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "error" => %{ "message" => ~s(Unsupported dynamic tool: "not_a_real_tool".), "supportedTools" => ["linear_graphql"] } } + + assert response["contentItems"] == [ + %{ + "type" => "inputText", + "text" => response["output"] + } + ] + end + + test "bound tools keep the adapter and auth snapshot from session startup" do + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "linear", + tracker_api_token: "session-token", + tracker_project_slug: "session-project" + ) + + binding = BoundDynamicTool.bind() + + write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") + assert BoundDynamicTool.bind().tool_specs == [] + + test_pid = self() + + response = + BoundDynamicTool.execute( + "linear_graphql", + %{"query" => "query Viewer { viewer { id } }"}, + binding, + linear_client: fn query, variables, opts -> + send(test_pid, {:bound_linear_client_called, query, variables, opts}) + {:ok, %{"data" => %{"viewer" => %{"id" => "usr_bound"}}}} + end + ) + + assert_received {:bound_linear_client_called, "query Viewer { viewer { id } }", %{}, [tracker_settings: tracker_settings]} + + assert tracker_settings.api_key == "session-token" + assert tracker_settings.project_slug == "session-project" + assert response["success"] == true end test "linear_graphql returns successful GraphQL responses as tool text" do @@ -61,15 +94,8 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do 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"}}} + assert Jason.decode!(response["output"]) == %{"data" => %{"viewer" => %{"id" => "usr_123"}}} + assert response["contentItems"] == [%{"type" => "inputText", "text" => response["output"]}] end test "linear_graphql accepts a raw GraphQL query string" do @@ -130,17 +156,11 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do end test "linear_graphql rejects blank raw query strings even when using the default client" do - response = DynamicTool.execute("linear_graphql", " ") + response = DynamicTool.execute("linear_graphql", " ", []) assert response["success"] == false - assert [ - %{ - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "error" => %{ "message" => "`linear_graphql` requires a non-empty `query` string." } @@ -159,14 +179,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert response["success"] == false - assert [ - %{ - "type" => "inputText", - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "data" => nil, "errors" => [%{"message" => "Unknown field `nope`"}] } @@ -197,14 +210,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert response["success"] == false - assert [ - %{ - "type" => "inputText", - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "error" => %{ "message" => "`linear_graphql` requires a non-empty `query` string." } @@ -234,13 +240,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert response["success"] == false - assert [ - %{ - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "error" => %{ "message" => "`linear_graphql` expects either a GraphQL query string or an object with `query` and optional `variables`." } @@ -259,13 +259,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert response["success"] == false - assert [ - %{ - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "error" => %{ "message" => "`linear_graphql.variables` must be a JSON object when provided." } @@ -282,15 +276,9 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert missing_token["success"] == false - assert [ - %{ - "text" => missing_token_text - } - ] = missing_token["contentItems"] - - assert Jason.decode!(missing_token_text) == %{ + assert Jason.decode!(missing_token["output"]) == %{ "error" => %{ - "message" => "Symphony is missing Linear auth. Set `linear.api_key` in `WORKFLOW.md` or export `LINEAR_API_KEY`." + "message" => "Symphony is missing Linear auth. Set `tracker.provider.api_key` in `WORKFLOW.md` or export `LINEAR_API_KEY`." } } @@ -301,13 +289,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do 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) == %{ + assert Jason.decode!(status_error["output"]) == %{ "error" => %{ "message" => "Linear GraphQL request failed with HTTP 503.", "status" => 503 @@ -321,13 +303,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do 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) == %{ + assert Jason.decode!(request_error["output"]) == %{ "error" => %{ "message" => "Linear GraphQL request failed before receiving a successful response.", "reason" => ":timeout" @@ -345,13 +321,7 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do assert response["success"] == false - assert [ - %{ - "text" => text - } - ] = response["contentItems"] - - assert Jason.decode!(text) == %{ + assert Jason.decode!(response["output"]) == %{ "error" => %{ "message" => "Linear GraphQL tool execution failed.", "reason" => ":boom" @@ -368,11 +338,6 @@ defmodule SymphonyElixir.Codex.DynamicToolTest do ) assert response["success"] == true - - assert [ - %{ - "text" => ":ok" - } - ] = response["contentItems"] + assert response["output"] == ":ok" end end diff --git a/elixir/test/symphony_elixir/extensions_test.exs b/elixir/test/symphony_elixir/extensions_test.exs index 59c8d0580b..8fb3417e5f 100644 --- a/elixir/test/symphony_elixir/extensions_test.exs +++ b/elixir/test/symphony_elixir/extensions_test.exs @@ -10,33 +10,15 @@ defmodule SymphonyElixir.ExtensionsTest do @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}) + def fetch_issues_by_ids(issue_ids) do + send(self(), {:fetch_issues_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 @@ -105,17 +87,47 @@ defmodule SymphonyElixir.ExtensionsTest 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") + write_workflow_file!(Workflow.workflow_file_path(), + prompt: "Second prompt", + poll_interval_ms: 45_000 + ) + send(WorkflowStore, :poll) assert_eventually(fn -> match?({:ok, %{prompt: "Second prompt"}}, Workflow.current()) end) + good_settings = Config.settings!() + assert good_settings.polling.interval_ms == 45_000 + File.write!(Workflow.workflow_file_path(), "---\ntracker: [\n---\nBroken prompt\n") assert {:error, _reason} = WorkflowStore.force_reload() assert {:ok, %{prompt: "Second prompt"}} = Workflow.current() + File.write!( + Workflow.workflow_file_path(), + "---\npolling:\n interval_ms: nope\n---\nTyped-invalid prompt\n" + ) + + assert {:error, {:invalid_workflow_config, message}} = WorkflowStore.force_reload() + assert message =~ "polling.interval_ms" + assert {:ok, %{prompt: "Second prompt"}} = Workflow.current() + assert Config.settings!().polling.interval_ms == good_settings.polling.interval_ms + assert {:error, {:invalid_workflow_config, _message}} = Config.validate!() + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "linear", + tracker_api_token: "token", + tracker_project_slug: nil, + prompt: "Semantic-invalid prompt" + ) + + assert {:error, :missing_linear_project_slug} = WorkflowStore.force_reload() + assert {:ok, %{prompt: "Second prompt"}} = Workflow.current() + assert Config.settings!().polling.interval_ms == good_settings.polling.interval_ms + assert {:error, :missing_linear_project_slug} = Config.validate!() + 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) @@ -123,6 +135,8 @@ defmodule SymphonyElixir.ExtensionsTest do assert :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, WorkflowStore) assert {:ok, %{prompt: "Third prompt"}} = WorkflowStore.current() + assert {:ok, settings} = WorkflowStore.settings() + assert settings.polling.interval_ms == 30_000 assert :ok = WorkflowStore.force_reload() assert {:ok, _pid} = Supervisor.restart_child(SymphonyElixir.Supervisor, WorkflowStore) end @@ -144,6 +158,9 @@ defmodule SymphonyElixir.ExtensionsTest do Workflow.set_workflow_file_path(missing_path) + assert {:error, {:missing_workflow_file, ^missing_path, :enoent}} = + WorkflowStore.settings() + assert {:error, {:missing_workflow_file, ^missing_path, :enoent}} = WorkflowStore.force_reload() @@ -171,152 +188,55 @@ defmodule SymphonyElixir.ExtensionsTest do assert removed_state.workflow.prompt == "Manual workflow prompt" assert_receive :poll, 1_100 - Process.exit(manual_pid, :normal) + assert :ok = GenServer.stop(manual_pid) + + Workflow.set_workflow_file_path(existing_path) + 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() + assert :ok = 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 Config.settings!().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"} + assert {:ok, [^issue]} = SymphonyElixir.Tracker.fetch_issues_by_ids(["issue-1"]) - 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") + binding = SymphonyElixir.Tracker.bind_agent_tools() + assert binding.adapter == Memory + assert binding.tool_specs == [] + assert binding.secret_environment_names == [] + + assert SymphonyElixir.Tracker.execute_bound_agent_tool(binding, "not_a_memory_tool", %{})[ + "success" + ] == false + + assert {:error, {:unsupported_tracker_kind, "future-tracker"}} = + SymphonyElixir.Tracker.adapter_for_kind("future-tracker") write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "linear") assert SymphonyElixir.Tracker.adapter() == Adapter + assert SymphonyElixir.Tracker.bind_agent_tools().secret_environment_names == ["LINEAR_API_KEY"] end - test "linear adapter delegates reads and validates mutation responses" do + test "linear adapter delegates reads and advertises its native agent tool" 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 {:ok, ["issue-1"]} = Adapter.fetch_issues_by_ids(["issue-1"]) + assert_receive {:fetch_issues_by_ids_called, ["issue-1"]} - assert {:error, :issue_update_failed} = Adapter.update_issue_state("issue-1", "Odd") + assert [%{"name" => "linear_graphql"}] = Adapter.agent_tool_specs() end test "phoenix observability api preserves state, issue, and refresh responses" do @@ -342,12 +262,15 @@ defmodule SymphonyElixir.ExtensionsTest do assert state_payload == %{ "generated_at" => state_payload["generated_at"], - "counts" => %{"running" => 1, "retrying" => 1}, + "counts" => %{"running" => 1, "retrying" => 1, "blocked" => 1}, "running" => [ %{ "issue_id" => "issue-http", "issue_identifier" => "MT-HTTP", + "issue_url" => "https://example.org/issues/MT-HTTP", "state" => "In Progress", + "worker_host" => nil, + "workspace_path" => nil, "session_id" => "thread-http", "turn_count" => 7, "last_event" => "notification", @@ -361,9 +284,28 @@ defmodule SymphonyElixir.ExtensionsTest do %{ "issue_id" => "issue-retry", "issue_identifier" => "MT-RETRY", + "issue_url" => "https://example.org/issues/MT-RETRY", "attempt" => 2, "due_at" => state_payload["retrying"] |> List.first() |> Map.fetch!("due_at"), - "error" => "boom" + "error" => "boom", + "worker_host" => nil, + "workspace_path" => nil + } + ], + "blocked" => [ + %{ + "issue_id" => "issue-blocked", + "issue_identifier" => "MT-BLOCKED", + "issue_url" => "https://example.org/issues/MT-BLOCKED", + "state" => "In Progress", + "error" => "codex turn requires operator input", + "worker_host" => "dm-dev2", + "workspace_path" => "/workspaces/MT-BLOCKED", + "session_id" => "thread-blocked", + "blocked_at" => state_payload["blocked"] |> List.first() |> Map.fetch!("blocked_at"), + "last_event" => "turn_input_required", + "last_message" => "turn blocked: waiting for user input", + "last_event_at" => state_payload["blocked"] |> List.first() |> Map.fetch!("last_event_at") } ], "codex_totals" => %{ @@ -382,9 +324,14 @@ defmodule SymphonyElixir.ExtensionsTest do "issue_identifier" => "MT-HTTP", "issue_id" => "issue-http", "status" => "running", - "workspace" => %{"path" => Path.join(Config.workspace_root(), "MT-HTTP")}, + "workspace" => %{ + "path" => Path.join(Config.settings!().workspace.root, "MT-HTTP"), + "host" => nil + }, "attempts" => %{"restart_count" => 0, "current_retry_attempt" => 0}, "running" => %{ + "worker_host" => nil, + "workspace_path" => nil, "session_id" => "thread-http", "turn_count" => 7, "state" => "In Progress", @@ -395,6 +342,7 @@ defmodule SymphonyElixir.ExtensionsTest do "tokens" => %{"input_tokens" => 4, "output_tokens" => 8, "total_tokens" => 12} }, "retry" => nil, + "blocked" => nil, "logs" => %{"codex_session_logs" => []}, "recent_events" => [], "last_error" => nil, @@ -406,6 +354,18 @@ defmodule SymphonyElixir.ExtensionsTest do assert %{"status" => "retrying", "retry" => %{"attempt" => 2, "error" => "boom"}} = json_response(conn, 200) + conn = get(build_conn(), "/api/v1/MT-BLOCKED") + + assert %{ + "status" => "blocked", + "last_error" => "codex turn requires operator input", + "blocked" => %{ + "session_id" => "thread-blocked", + "state" => "In Progress", + "error" => "codex turn requires operator input" + } + } = json_response(conn, 200) + conn = get(build_conn(), "/api/v1/MT-MISSING") assert json_response(conn, 404) == %{ @@ -486,7 +446,11 @@ defmodule SymphonyElixir.ExtensionsTest do start_test_endpoint(orchestrator: orchestrator_name, snapshot_timeout_ms: 50) html = html_response(get(build_conn(), "/"), 200) - assert html =~ "/dashboard.css" + assert html =~ ~r|/dashboard\.css\?v=[0-9a-f]{12}| + + assert html =~ + ~r|| + assert html =~ "/vendor/phoenix_html/phoenix_html.js" assert html =~ "/vendor/phoenix/phoenix.js" assert html =~ "/vendor/phoenix_live_view/phoenix_live_view.js" @@ -498,6 +462,11 @@ defmodule SymphonyElixir.ExtensionsTest do assert dashboard_css =~ ".status-badge-live" assert dashboard_css =~ "[data-phx-main].phx-connected .status-badge-live" assert dashboard_css =~ "[data-phx-main].phx-connected .status-badge-offline" + assert dashboard_css =~ "text-decoration-thickness: 1px" + + favicon_conn = get(build_conn(), "/favicon.png") + assert response(favicon_conn, 200) == File.read!("priv/static/favicon.png") + assert Plug.Conn.get_resp_header(favicon_conn, "content-type") == ["image/png; charset=utf-8"] phoenix_html_js = response(get(build_conn(), "/vendor/phoenix_html/phoenix_html.js"), 200) assert phoenix_html_js =~ "phoenix.link.click" @@ -533,7 +502,13 @@ defmodule SymphonyElixir.ExtensionsTest do assert html =~ "Operations Dashboard" assert html =~ "MT-HTTP" assert html =~ "MT-RETRY" + assert html =~ "MT-BLOCKED" + assert html =~ ~s(href="https://example.org/issues/MT-HTTP") + assert html =~ ~s(href="https://example.org/issues/MT-RETRY") + assert html =~ ~s(href="https://example.org/issues/MT-BLOCKED") + assert html =~ ~s(aria-label="Open MT-HTTP in the issue tracker") assert html =~ "rendered" + assert html =~ "turn blocked: waiting for user input" assert html =~ "Runtime" assert html =~ "Live" assert html =~ "Offline" @@ -551,6 +526,7 @@ defmodule SymphonyElixir.ExtensionsTest do %{ issue_id: "issue-http", identifier: "MT-HTTP", + issue_url: "javascript:alert('nope')", state: "In Progress", session_id: "thread-http", turn_count: 8, @@ -585,6 +561,8 @@ defmodule SymphonyElixir.ExtensionsTest do assert_eventually(fn -> render(view) =~ "agent message content streaming: structured update" end) + + refute render(view) =~ "javascript:alert" end test "dashboard liveview renders an unavailable state without crashing" do @@ -632,7 +610,7 @@ defmodule SymphonyElixir.ExtensionsTest do response = Req.get!("http://127.0.0.1:#{port}/api/v1/state") assert response.status == 200 - assert response.body["counts"] == %{"running" => 1, "retrying" => 1} + assert response.body["counts"] == %{"running" => 1, "retrying" => 1, "blocked" => 1} dashboard_css = Req.get!("http://127.0.0.1:#{port}/dashboard.css") assert dashboard_css.status == 200 @@ -680,6 +658,7 @@ defmodule SymphonyElixir.ExtensionsTest do %{ issue_id: "issue-http", identifier: "MT-HTTP", + issue_url: "https://example.org/issues/MT-HTTP", state: "In Progress", session_id: "thread-http", turn_count: 7, @@ -697,11 +676,32 @@ defmodule SymphonyElixir.ExtensionsTest do %{ issue_id: "issue-retry", identifier: "MT-RETRY", + issue_url: "https://example.org/issues/MT-RETRY", attempt: 2, due_in_ms: 2_000, error: "boom" } ], + blocked: [ + %{ + issue_id: "issue-blocked", + identifier: "MT-BLOCKED", + issue_url: "https://example.org/issues/MT-BLOCKED", + state: "In Progress", + error: "codex turn requires operator input", + worker_host: "dm-dev2", + workspace_path: "/workspaces/MT-BLOCKED", + session_id: "thread-blocked", + blocked_at: DateTime.utc_now(), + last_codex_event: :turn_input_required, + last_codex_message: %{ + event: :turn_input_required, + message: %{"method" => "turn/input_required"}, + timestamp: DateTime.utc_now() + }, + last_codex_timestamp: DateTime.utc_now() + } + ], codex_totals: %{input_tokens: 4, output_tokens: 8, total_tokens: 12, seconds_running: 42.5}, rate_limits: %{"primary" => %{"remaining" => 11}} } diff --git a/elixir/test/symphony_elixir/github_adapter_test.exs b/elixir/test/symphony_elixir/github_adapter_test.exs new file mode 100644 index 0000000000..09115a26f8 --- /dev/null +++ b/elixir/test/symphony_elixir/github_adapter_test.exs @@ -0,0 +1,439 @@ +defmodule SymphonyElixir.GitHub.AdapterTest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.GitHub.Adapter, as: GitHubAdapter + alias SymphonyElixir.GitHub.AgentTool, as: GitHubAgentTool + alias SymphonyElixir.GitHub.Client, as: GitHubClient + + defmodule FakeGitHubClient do + def fetch_issues_by_states(states) do + send(self(), {:github_states_called, states}) + {:ok, states} + end + + def fetch_issues_by_ids(ids) do + send(self(), {:github_ids_called, ids}) + {:ok, ids} + end + end + + setup do + github_client_module = Application.get_env(:symphony_elixir, :github_client_module) + + on_exit(fn -> + if is_nil(github_client_module) do + Application.delete_env(:symphony_elixir, :github_client_module) + else + Application.put_env(:symphony_elixir, :github_client_module, github_client_module) + end + end) + + :ok + end + + test "adapter validates GitHub config, delegates reads, and advertises github_api" do + settings = tracker_settings() + + assert :ok = GitHubAdapter.validate_config(settings) + + assert {:error, :missing_github_active_states} = + GitHubAdapter.validate_config(%{settings | active_states: nil}) + + assert {:error, :missing_github_terminal_states} = + GitHubAdapter.validate_config(%{settings | terminal_states: nil}) + + assert :ok = GitHubAdapter.validate_config(%{settings | active_states: [], terminal_states: []}) + + assert {:error, :invalid_github_states} = + GitHubAdapter.validate_config(%{settings | active_states: ["Todo"]}) + + assert {:error, :invalid_github_states} = + GitHubAdapter.validate_config(%{settings | active_states: [42]}) + + assert {:error, :invalid_github_states} = + GitHubAdapter.validate_config(%{settings | active_states: ["closed"]}) + + assert {:error, :invalid_github_states} = + GitHubAdapter.validate_config(%{settings | terminal_states: ["open"]}) + + Application.put_env(:symphony_elixir, :github_client_module, FakeGitHubClient) + + assert {:ok, ["open"]} = GitHubAdapter.fetch_issues_by_states(["open"]) + assert_receive {:github_states_called, ["open"]} + + assert {:ok, ["42"]} = GitHubAdapter.fetch_issues_by_ids(["42"]) + assert_receive {:github_ids_called, ["42"]} + + assert [%{"name" => "github_api"}] = GitHubAdapter.agent_tool_specs() + + assert GitHubAdapter.execute_agent_tool( + "github_api", + %{"method" => "GET", "path" => "/user"}, + github_client: fn _method, _path, _params, _body, _opts -> + {:ok, %{status: 200, body: %{"login" => "octocat"}}} + end + )["success"] + end + + test "client validates repository settings and declares token environments" do + assert :ok = GitHubClient.validate_settings(tracker_settings()) + + assert {:error, :missing_github_repo} = + GitHubClient.validate_settings(tracker_settings(%{"repo" => 123})) + + assert {:error, :invalid_github_repo} = + GitHubClient.validate_settings(tracker_settings(%{"repo" => "not-a-repo"})) + + assert {:error, :missing_github_token} = + GitHubClient.validate_settings(tracker_settings(%{"token" => 123})) + + assert {:error, :invalid_github_api_url} = + GitHubClient.validate_settings(tracker_settings(%{"api_url" => "not a url"})) + + assert {:error, :invalid_github_api_url} = + GitHubClient.validate_settings(tracker_settings(%{"api_url" => "http://api.github.com"})) + + assert GitHubClient.secret_environment_names(tracker_settings(%{"token" => "$SYMPHONY_GITHUB_TOKEN"})) == ["GITHUB_TOKEN", "SYMPHONY_GITHUB_TOKEN"] + end + + test "client normalizes GitHub issues without dropping provider details" do + issue = GitHubClient.normalize_issue_for_test(raw_issue(42), "octo/repo") + + assert issue.id == "42" + assert issue.identifier == "GH-42" + + assert issue.native_ref == %{ + "id" => 1_042, + "node_id" => "I_42", + "number" => 42, + "repo" => "octo/repo" + } + + assert issue.title == "Issue 42" + assert issue.description == "Body 42" + assert issue.state == "open" + assert issue.url == "https://github.test/octo/repo/issues/42" + assert issue.assignee_id == "octocat" + assert issue.labels == ["bug", "platform"] + assert issue.blocked_by == [] + assert issue.dispatchable + assert %DateTime{} = issue.created_at + assert %DateTime{} = issue.updated_at + + refute GitHubClient.normalize_issue_for_test( + Map.put(raw_issue(43), "pull_request", %{"url" => "https://api.github.test/pulls/43"}), + "octo/repo" + ).dispatchable + + assert GitHubClient.normalize_issue_for_test( + Map.put(raw_issue(44), "title", " "), + "octo/repo" + ) == nil + end + + test "client pages state reads, filters requested states, and drops malformed records" do + first_page = + Enum.map(1..97, &raw_issue/1) ++ + [ + Map.put(raw_issue(98), "pull_request", %{"url" => "https://api.github.test/pulls/98"}), + Map.put(raw_issue(99), "state", "closed"), + Map.put(raw_issue(100), "title", "") + ] + + request_fun = fn "GET", "/repos/octo/repo/issues", params, nil, settings -> + send(self(), {:github_page, params, settings}) + + body = + case params["page"] do + 1 -> first_page + 2 -> [raw_issue(101)] + end + + {:ok, %{status: 200, body: body}} + end + + log = + capture_log(fn -> + assert {:ok, issues} = + GitHubClient.fetch_issues_by_states_for_test( + [" OPEN "], + tracker_settings(), + request_fun + ) + + assert length(issues) == 99 + assert hd(issues).id == "1" + assert List.last(issues).id == "101" + refute Enum.any?(issues, &(&1.id == "99")) + assert Enum.find(issues, &(&1.id == "98")).dispatchable == false + end) + + assert log =~ "Dropping malformed GitHub issue records count=1" + + assert_receive {:github_page, + %{ + "state" => "open", + "per_page" => 100, + "page" => 1, + "sort" => "created", + "direction" => "asc" + }, %{repo: "octo/repo"}} + + assert_receive {:github_page, %{"page" => 2}, %{repo: "octo/repo"}} + + assert {:ok, []} = + GitHubClient.fetch_issues_by_states_for_test( + ["In Progress"], + tracker_settings(), + fn _method, _path, _params, _body, _settings -> + flunk("unsupported GitHub states should not make an HTTP request") + end + ) + end + + test "client refreshes numeric IDs in order, omits 404s, and rejects malformed refreshes" do + request_fun = fn "GET", path, %{}, nil, _settings -> + send(self(), {:github_id_path, path}) + + case path do + "/repos/octo/repo/issues/2" -> {:ok, %{status: 200, body: raw_issue(2)}} + "/repos/octo/repo/issues/1" -> {:ok, %{status: 200, body: raw_issue(1)}} + "/repos/octo/repo/issues/404" -> {:ok, %{status: 404, body: %{"message" => "Not Found"}}} + end + end + + assert {:ok, issues} = + GitHubClient.fetch_issues_by_ids_for_test( + ["2", "1", "404", "2"], + tracker_settings(), + request_fun + ) + + assert Enum.map(issues, & &1.id) == ["2", "1"] + assert_receive {:github_id_path, "/repos/octo/repo/issues/2"} + assert_receive {:github_id_path, "/repos/octo/repo/issues/1"} + assert_receive {:github_id_path, "/repos/octo/repo/issues/404"} + refute_receive {:github_id_path, "/repos/octo/repo/issues/2"} + + assert {:error, :invalid_github_issue_id} = + GitHubClient.fetch_issues_by_ids_for_test( + ["not-a-number"], + tracker_settings(), + request_fun + ) + + assert {:error, :github_unknown_payload} = + GitHubClient.fetch_issues_by_ids_for_test( + ["3"], + tracker_settings(), + fn _method, _path, _params, _body, _settings -> + {:ok, %{status: 200, body: Map.put(raw_issue(3), "title", "")}} + end + ) + end + + test "github_api preserves REST status and body while rejecting unsafe arguments" do + test_pid = self() + tracker_settings = tracker_settings() + + response = + GitHubAgentTool.execute( + "github_api", + %{ + "method" => "post", + "path" => " /repos/octo/repo/issues/42/comments ", + "params" => %{"per_page" => 10}, + "body" => %{"body" => "hello"} + }, + tracker_settings: tracker_settings, + github_client: fn method, path, params, body, opts -> + send(test_pid, {:github_tool_called, method, path, params, body, opts}) + {:ok, %{status: 201, body: %{"id" => 9}}} + end + ) + + assert_received {:github_tool_called, "POST", "/repos/octo/repo/issues/42/comments", %{"per_page" => 10}, %{"body" => "hello"}, [tracker_settings: ^tracker_settings]} + + assert response["success"] == true + assert Jason.decode!(response["output"]) == %{"status" => 201, "body" => %{"id" => 9}} + assert response["contentItems"] == [%{"type" => "inputText", "text" => response["output"]}] + + failure = + GitHubAgentTool.execute( + "github_api", + %{"method" => "GET", "path" => "/repos/octo/repo/issues/404"}, + github_client: fn _method, _path, _params, _body, _opts -> + {:ok, %{status: 404, body: %{"message" => "Not Found"}}} + end + ) + + assert failure["success"] == false + + assert Jason.decode!(failure["output"]) == %{ + "status" => 404, + "body" => %{"message" => "Not Found"} + } + + Enum.each( + [ + %{"method" => "GET", "path" => "https://api.github.com/user"}, + %{"method" => "GET", "path" => "/user", "params" => false}, + %{"path" => "/user"} + ], + fn arguments -> + invalid = + GitHubAgentTool.execute( + "github_api", + arguments, + github_client: fn _method, _path, _params, _body, _opts -> + flunk("invalid github_api arguments should not call the client") + end + ) + + assert invalid["success"] == false + end + ) + end + + test "github_api reports unsupported tools, malformed calls, and client failures" do + unsupported = GitHubAgentTool.execute("not_github_api", %{}, []) + assert unsupported["success"] == false + assert Jason.decode!(unsupported["output"])["error"]["supportedTools"] == ["github_api"] + + Enum.each( + [ + "not-an-object", + %{"method" => "GET", "path" => 123} + ], + fn arguments -> + invalid = + GitHubAgentTool.execute( + "github_api", + arguments, + github_client: fn _method, _path, _params, _body, _opts -> + flunk("malformed github_api arguments should not call the client") + end + ) + + assert invalid["success"] == false + end + ) + + malformed_response = + GitHubAgentTool.execute( + "github_api", + %{"method" => "GET", "path" => "/user"}, + github_client: fn _method, _path, _params, _body, _opts -> + {:ok, %{status: "not-an-integer", body: %{}}} + end + ) + + assert malformed_response["success"] == false + + Enum.each( + [ + :missing_github_token, + {:github_api_request, :timeout}, + :unexpected_failure + ], + fn reason -> + failure = + GitHubAgentTool.execute( + "github_api", + %{"method" => "GET", "path" => "/user"}, + github_client: fn _method, _path, _params, _body, _opts -> + {:error, reason} + end + ) + + assert failure["success"] == false + assert %{"error" => %{"message" => message}} = Jason.decode!(failure["output"]) + assert is_binary(message) + end + ) + + non_json_body = + GitHubAgentTool.execute( + "github_api", + %{"method" => "GET", "path" => "/user"}, + github_client: fn _method, _path, _params, _body, _opts -> + {:ok, %{status: 200, body: self()}} + end + ) + + assert non_json_body["success"] + assert non_json_body["output"] =~ "#PID" + end + + test "tracker binds GitHub tools and token env names from provider config" do + token_env = "SYMPHONY_GITHUB_TOKEN_#{System.unique_integer([:positive])}" + previous_token = System.get_env(token_env) + System.put_env(token_env, "test-token") + + on_exit(fn -> restore_env(token_env, previous_token) end) + + write_github_workflow!(Workflow.workflow_file_path(), "$#{token_env}") + + binding = Tracker.bind_agent_tools() + + assert binding.adapter == GitHubAdapter + assert binding.secret_environment_names == ["GITHUB_TOKEN", token_env] + assert [%{"name" => "github_api"}] = binding.tool_specs + assert :ok = Config.validate!() + end + + defp tracker_settings(provider_overrides \\ %{}) do + %{ + kind: "github", + provider: + Map.merge( + %{ + "repo" => "octo/repo", + "token" => "test-token" + }, + provider_overrides + ), + active_states: ["open"], + terminal_states: ["closed"] + } + end + + defp raw_issue(number) do + %{ + "number" => number, + "id" => 1_000 + number, + "node_id" => "I_#{number}", + "title" => "Issue #{number}", + "body" => "Body #{number}", + "state" => "open", + "html_url" => "https://github.test/octo/repo/issues/#{number}", + "assignee" => %{"login" => "octocat"}, + "labels" => [%{"name" => " Bug "}, %{"name" => "bug"}, %{"name" => "Platform"}], + "created_at" => "2026-01-01T00:00:00Z", + "updated_at" => "2026-01-02T00:00:00Z" + } + end + + defp write_github_workflow!(path, token) do + File.write!( + path, + """ + --- + tracker: + kind: github + provider: + repo: "octo/repo" + token: #{Jason.encode!(token)} + active_states: ["open"] + terminal_states: ["closed"] + --- + + You are working on {{ issue.identifier }}. + """ + ) + + if Process.whereis(SymphonyElixir.WorkflowStore) do + assert :ok = SymphonyElixir.WorkflowStore.force_reload() + end + end +end diff --git a/elixir/test/symphony_elixir/github_live_e2e_test.exs b/elixir/test/symphony_elixir/github_live_e2e_test.exs new file mode 100644 index 0000000000..5e95f9ab53 --- /dev/null +++ b/elixir/test/symphony_elixir/github_live_e2e_test.exs @@ -0,0 +1,402 @@ +defmodule SymphonyElixir.GitHub.LiveE2ETest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.GitHub.Client, as: GitHubClient + + @moduletag :live_e2e + @moduletag timeout: 300_000 + + @api_url "https://api.github.com" + @api_version "2022-11-28" + @result_file "LIVE_GITHUB_E2E_RESULT.txt" + @live_e2e_skip_reason if(System.get_env("SYMPHONY_RUN_GITHUB_LIVE_E2E") != "1", + do: "set SYMPHONY_RUN_GITHUB_LIVE_E2E=1 to enable the real GitHub/Codex end-to-end test" + ) + + @tag skip: @live_e2e_skip_reason + test "creates a real GitHub issue and closes it through github_api" do + repo = required_env!("SYMPHONY_LIVE_GITHUB_REPO") + token = required_env!("GITHUB_TOKEN") + run_id = "symphony-github-live-e2e-#{System.unique_integer([:positive])}" + test_root = Path.join(System.tmp_dir!(), run_id) + workflow_root = Path.join(test_root, "workflow") + workflow_file = Path.join(workflow_root, "WORKFLOW.md") + workspace_root = Path.join(test_root, "workspaces") + codex_home = isolated_codex_home!(test_root) + original_workflow_path = Workflow.workflow_file_path() + runtime_pid = Process.whereis(SymphonyElixir.AgentRuntimeSupervisor) + + File.mkdir_p!(workflow_root) + + issue_payload = + create_issue!( + repo, + token, + "Symphony GitHub live e2e #{run_id}", + "Disposable issue created by the Symphony GitHub live E2E test." + ) + + issue_number = Integer.to_string(issue_payload["number"]) + expected_comment = expected_comment("GH-#{issue_number}", run_id) + + try do + assert %Issue{} = issue = GitHubClient.normalize_issue_for_test(issue_payload, repo) + stop_agent_runtime_if_running(runtime_pid) + Workflow.set_workflow_file_path(workflow_file) + + write_workflow!( + workflow_file, + repo, + workspace_root, + codex_home, + live_prompt(repo, expected_comment) + ) + + assert %Issue{id: state_issue_id} = wait_for_open_state_issue!(issue.id) + assert state_issue_id == issue.id + + assert {:ok, [%Issue{id: issue_id, identifier: identifier}]} = + Tracker.fetch_issues_by_ids([issue.id]) + + assert issue_id == issue.id + assert identifier == issue.identifier + + assert :ok = AgentRunner.run(issue, self(), max_turns: 3) + + runtime_info = receive_runtime_info!(issue.id) + tool_calls = completed_github_tool_calls(issue.id) + + assert File.read!(Path.join(runtime_info.workspace_path, @result_file)) == + expected_result(issue.identifier, repo) + + assert %{"state" => "closed"} = get_issue!(repo, token, issue.id) + + assert comments!(repo, token, issue.id) + |> Enum.any?(&(&1["body"] == expected_comment)) + + issue_path = "/repos/#{encoded_repo(repo)}/issues/#{issue.id}" + comments_path = issue_path <> "/comments" + + assert_tool_call_count!(tool_calls, "GET", issue_path, 2) + assert_tool_call_count!(tool_calls, "GET", comments_path, 2) + assert_tool_call!(tool_calls, "POST", comments_path, %{"body" => expected_comment}) + assert_tool_call!(tool_calls, "PATCH", issue_path, %{"state" => "closed"}) + after + close_result = close_issue(repo, token, issue_number) + Workflow.set_workflow_file_path(original_workflow_path) + restart_agent_runtime_if_needed(runtime_pid) + File.rm_rf(test_root) + assert :ok = close_result + end + end + + defp write_workflow!(path, repo, workspace_root, codex_home, prompt) do + File.write!( + path, + """ + --- + tracker: + kind: github + provider: + repo: #{Jason.encode!(repo)} + token: "$GITHUB_TOKEN" + active_states: ["open"] + terminal_states: ["closed"] + workspace: + root: #{Jason.encode!(workspace_root)} + agent: + max_turns: 3 + codex: + command: #{Jason.encode!("env CODEX_HOME=#{shell_escape(codex_home)} codex app-server")} + approval_policy: "never" + read_timeout_ms: 60000 + turn_timeout_ms: 600000 + stall_timeout_ms: 600000 + observability: + dashboard_enabled: false + --- + + #{prompt} + """ + ) + + assert :ok = SymphonyElixir.WorkflowStore.force_reload() + end + + defp live_prompt(repo, expected_comment) do + issue_path = "/repos/#{encoded_repo(repo)}/issues/{{ issue.id }}" + comments_path = issue_path <> "/comments" + + """ + You are running a real Symphony GitHub end-to-end test. + + The current working directory is the workspace root. + + Step 1: + Run exactly: + + ```sh + cat > #{@result_file} < title, "body" => body} + ) + + case response.body do + %{"number" => number} = issue when is_integer(number) -> issue + _ -> flunk("GitHub issue create returned an unexpected payload") + end + end + + defp get_issue!(repo, token, issue_id) do + response = github_request!(:get, "/repos/#{encoded_repo(repo)}/issues/#{issue_id}", token) + + case response.body do + %{} = issue -> issue + _ -> flunk("GitHub issue read returned an unexpected payload") + end + end + + defp comments!(repo, token, issue_id) do + response = + github_request!( + :get, + "/repos/#{encoded_repo(repo)}/issues/#{issue_id}/comments", + token, + nil, + %{"per_page" => 100} + ) + + case response.body do + comments when is_list(comments) -> comments + _ -> flunk("GitHub issue comments read returned an unexpected payload") + end + end + + defp close_issue(repo, token, issue_id) do + case github_request( + :patch, + "/repos/#{encoded_repo(repo)}/issues/#{issue_id}", + token, + %{"state" => "closed"} + ) do + {:ok, %{status: status}} when status in 200..299 -> :ok + {:ok, %{status: status}} -> {:error, {:github_cleanup_status, status}} + {:error, reason} -> {:error, {:github_cleanup_request, reason}} + end + end + + defp github_request!(method, path, token, body \\ nil, params \\ %{}) do + case github_request(method, path, token, body, params) do + {:ok, %{status: status} = response} when status in 200..299 -> + response + + {:ok, %{status: status}} -> + flunk("GitHub request failed with HTTP #{status}") + + {:error, reason} -> + flunk("GitHub request failed before a response: #{inspect(reason)}") + end + end + + defp github_request(method, path, token, body, params \\ %{}) do + request_opts = [ + method: method, + url: @api_url <> path, + headers: [ + {"Accept", "application/vnd.github+json"}, + {"Authorization", "Bearer #{token}"}, + {"X-GitHub-Api-Version", @api_version}, + {"User-Agent", "symphony-live-e2e"} + ], + params: params, + connect_options: [timeout: 30_000] + ] + + request_opts = if is_nil(body), do: request_opts, else: Keyword.put(request_opts, :json, body) + + case Req.request(request_opts) do + {:ok, response} -> {:ok, %{status: response.status, body: response.body}} + {:error, reason} -> {:error, reason} + end + end + + defp receive_runtime_info!(issue_id) do + receive do + {:worker_runtime_info, ^issue_id, %{workspace_path: workspace_path} = runtime_info} + when is_binary(workspace_path) -> + runtime_info + + {:codex_worker_update, ^issue_id, _message} -> + receive_runtime_info!(issue_id) + after + 5_000 -> + flunk("timed out waiting for worker runtime info") + end + end + + defp completed_github_tool_calls(issue_id, calls \\ []) do + receive do + {:codex_worker_update, ^issue_id, %{event: :tool_call_completed, payload: %{"params" => params}}} -> + completed_github_tool_calls(issue_id, [params | calls]) + + {:codex_worker_update, ^issue_id, _message} -> + completed_github_tool_calls(issue_id, calls) + after + 0 -> + Enum.reverse(calls) + end + end + + defp assert_tool_call!(calls, method, path, expected_body) do + found? = + Enum.any?(calls, fn params -> + tool_call_matches?(params, method, path) and + (expected_body == :any or get_in(params, ["arguments", "body"]) == expected_body) + end) + + assert found?, "expected completed github_api #{method} #{path}" + end + + defp assert_tool_call_count!(calls, method, path, expected_count) do + count = Enum.count(calls, &tool_call_matches?(&1, method, path)) + assert count >= expected_count, "expected at least #{expected_count} completed github_api #{method} #{path} calls" + end + + defp tool_call_matches?(params, method, path) do + arguments = Map.get(params, "arguments", %{}) + tool_name = Map.get(params, "name") || Map.get(params, "tool") + called_method = Map.get(arguments, "method") + called_path = Map.get(arguments, "path") + + tool_name == "github_api" and is_binary(called_method) and + String.upcase(String.trim(called_method)) == method and + is_binary(called_path) and String.trim(called_path) == path + end + + defp wait_for_open_state_issue!(issue_id, attempts \\ 20) + + defp wait_for_open_state_issue!(issue_id, attempts) when attempts > 0 do + case Tracker.fetch_issues_by_states(["open"]) do + {:ok, issues} -> + case Enum.find(issues, &(&1.id == issue_id)) do + %Issue{} = issue -> + issue + + nil -> + Process.sleep(500) + wait_for_open_state_issue!(issue_id, attempts - 1) + end + + {:error, reason} -> + flunk("GitHub state read failed: #{inspect(reason)}") + end + end + + defp wait_for_open_state_issue!(_issue_id, 0) do + flunk("new GitHub issue did not appear in the open-state adapter read") + end + + defp isolated_codex_home!(test_root) do + codex_home = Path.join(test_root, "codex-home") + auth_json_path = Path.join(codex_home, "auth.json") + + source_auth_json = + Path.join( + System.get_env("CODEX_HOME") || Path.join(System.user_home!(), ".codex"), + "auth.json" + ) + + unless File.regular?(source_auth_json) do + flunk("live GitHub e2e requires Codex auth") + end + + File.mkdir_p!(codex_home) + File.cp!(source_auth_json, auth_json_path) + File.chmod!(auth_json_path, 0o600) + codex_home + end + + defp stop_agent_runtime_if_running(runtime_pid) when is_pid(runtime_pid) do + assert :ok = + Supervisor.terminate_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) + end + + defp stop_agent_runtime_if_running(_runtime_pid), do: :ok + + defp restart_agent_runtime_if_needed(runtime_pid) when is_pid(runtime_pid) do + if is_nil(Process.whereis(SymphonyElixir.AgentRuntimeSupervisor)) do + case Supervisor.restart_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + end + end + end + + defp restart_agent_runtime_if_needed(_runtime_pid), do: :ok + + defp required_env!(name) do + case System.get_env(name) do + value when is_binary(value) and value != "" -> value + _ -> flunk("live GitHub e2e requires #{name}") + end + end + + defp encoded_repo(repo) do + repo + |> String.split("/", parts: 2) + |> Enum.map_join("/", fn segment -> URI.encode(segment, &URI.char_unreserved?/1) end) + end + + defp shell_escape(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end +end diff --git a/elixir/test/symphony_elixir/gitlab_adapter_test.exs b/elixir/test/symphony_elixir/gitlab_adapter_test.exs new file mode 100644 index 0000000000..89b4871218 --- /dev/null +++ b/elixir/test/symphony_elixir/gitlab_adapter_test.exs @@ -0,0 +1,487 @@ +defmodule SymphonyElixir.GitLab.AdapterTest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.GitLab.Adapter, as: GitLabAdapter + alias SymphonyElixir.GitLab.AgentTool, as: GitLabAgentTool + alias SymphonyElixir.GitLab.Client, as: GitLabClient + + defmodule FakeGitLabClient do + def fetch_issues_by_states(states) do + send(self(), {:gitlab_states_called, states}) + {:ok, states} + end + + def fetch_issues_by_ids(ids) do + send(self(), {:gitlab_ids_called, ids}) + {:ok, ids} + end + end + + setup do + gitlab_client_module = Application.get_env(:symphony_elixir, :gitlab_client_module) + + on_exit(fn -> + if is_nil(gitlab_client_module) do + Application.delete_env(:symphony_elixir, :gitlab_client_module) + else + Application.put_env(:symphony_elixir, :gitlab_client_module, gitlab_client_module) + end + end) + + :ok + end + + test "adapter validates GitLab config, delegates reads, and advertises gitlab_api" do + settings = tracker_settings() + + assert :ok = GitLabAdapter.validate_config(settings) + + assert {:error, :missing_gitlab_active_states} = + GitLabAdapter.validate_config(%{settings | active_states: nil}) + + assert {:error, :missing_gitlab_terminal_states} = + GitLabAdapter.validate_config(%{settings | terminal_states: nil}) + + assert :ok = GitLabAdapter.validate_config(%{settings | active_states: [], terminal_states: []}) + + assert {:error, :invalid_gitlab_states} = + GitLabAdapter.validate_config(%{settings | active_states: ["Todo"]}) + + assert {:error, :invalid_gitlab_states} = + GitLabAdapter.validate_config(%{settings | active_states: [42]}) + + assert {:error, :invalid_gitlab_states} = + GitLabAdapter.validate_config(%{settings | active_states: ["closed"]}) + + assert {:error, :invalid_gitlab_states} = + GitLabAdapter.validate_config(%{settings | terminal_states: ["opened"]}) + + Application.put_env(:symphony_elixir, :gitlab_client_module, FakeGitLabClient) + + assert {:ok, ["opened"]} = GitLabAdapter.fetch_issues_by_states(["opened"]) + assert_receive {:gitlab_states_called, ["opened"]} + + assert {:ok, ["42"]} = GitLabAdapter.fetch_issues_by_ids(["42"]) + assert_receive {:gitlab_ids_called, ["42"]} + + assert [%{"name" => "gitlab_api"}] = GitLabAdapter.agent_tool_specs() + + assert GitLabAdapter.execute_agent_tool( + "gitlab_api", + %{"method" => "GET", "path" => "/user"}, + gitlab_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: 200, body: %{"username" => "octocat"}}} + end + )["success"] + end + + test "client validates project settings and declares token environments" do + assert :ok = GitLabClient.validate_settings(tracker_settings()) + + assert {:error, :missing_gitlab_project_path} = + GitLabClient.validate_settings(tracker_settings(%{"project_path" => 123})) + + assert {:error, :invalid_gitlab_project_path} = + GitLabClient.validate_settings(tracker_settings(%{"project_path" => "group / project"})) + + assert {:error, :missing_gitlab_api_key} = + GitLabClient.validate_settings(tracker_settings(%{"api_key" => 123})) + + assert {:error, :invalid_gitlab_api_url} = + GitLabClient.validate_settings(tracker_settings(%{"api_url" => "not a url"})) + + assert {:error, :invalid_gitlab_api_url} = + GitLabClient.validate_settings(tracker_settings(%{"api_url" => "http://gitlab.com/api/v4"})) + + assert GitLabClient.secret_environment_names(tracker_settings(%{"api_key" => "$SYMPHONY_GITLAB_TOKEN"})) == ["GITLAB_PAT", "GITLAB_ACCESS_TOKEN", "SYMPHONY_GITLAB_TOKEN"] + + assert {:ok, []} = + GitLabClient.fetch_issues_by_states_for_test( + ["opened"], + tracker_settings(%{"project_path" => " group/project ", "api_key" => " token "}), + fn "GET", "/projects/group%2Fproject/issues", _query, nil, settings -> + assert settings.project_path == "group/project" + assert settings.api_key == "token" + {:ok, %{status: 200, body: []}} + end + ) + end + + test "client normalizes GitLab issues without dropping provider details" do + issue = GitLabClient.normalize_issue_for_test(raw_issue(42), tracker_settings()) + + assert issue.id == "42" + assert issue.identifier == "GL-42" + + assert issue.native_ref == %{ + "id" => 1_042, + "iid" => 42, + "project_id" => 77, + "project_path" => "group/project", + "references" => %{"full" => "group/project#42"} + } + + assert issue.title == "Issue 42" + assert issue.description == "Body 42" + assert issue.state == "opened" + assert issue.url == "https://gitlab.test/group/project/-/issues/42" + assert issue.assignee_id == "99" + assert issue.labels == ["bug", "platform"] + assert issue.blocked_by == [] + assert issue.dispatchable + assert %DateTime{} = issue.created_at + assert %DateTime{} = issue.updated_at + + assert GitLabClient.normalize_issue_for_test( + raw_issue(43) + |> Map.put("state", "closed") + |> Map.put("confidential", true) + |> Map.put("issue_type", "incident"), + tracker_settings() + ).dispatchable + + assert GitLabClient.normalize_issue_for_test( + raw_issue(45) + |> Map.delete("assignees") + |> Map.put("assignee", %{"username" => "fallback-user"}), + tracker_settings() + ).assignee_id == "fallback-user" + + assert GitLabClient.normalize_issue_for_test( + Map.put(raw_issue(44), "title", " "), + tracker_settings() + ) == nil + end + + test "client pages state reads, maps GitLab states, and drops malformed records" do + first_page = + Enum.map(1..98, &raw_issue/1) ++ + [Map.put(raw_issue(99), "state", "closed"), Map.put(raw_issue(100), "title", "")] + + request_fun = fn "GET", "/projects/group%2Fproject/issues", query, nil, settings -> + send(self(), {:gitlab_page, query, settings}) + + body = + case query["page"] do + 1 -> first_page + 2 -> [raw_issue(101)] + end + + {:ok, %{status: 200, body: body}} + end + + log = + capture_log(fn -> + assert {:ok, issues} = + GitLabClient.fetch_issues_by_states_for_test( + [" OPENED "], + tracker_settings(), + request_fun + ) + + assert length(issues) == 99 + assert hd(issues).id == "1" + assert List.last(issues).id == "101" + refute Enum.any?(issues, &(&1.id == "99")) + end) + + assert log =~ "Dropping malformed GitLab issue records count=1" + + assert_receive {:gitlab_page, + %{ + "state" => "opened", + "per_page" => 100, + "page" => 1, + "order_by" => "created_at", + "sort" => "asc" + }, %{project_path: "group/project"}} + + assert_receive {:gitlab_page, %{"page" => 2}, %{project_path: "group/project"}} + + all_request = fn "GET", "/projects/group%2Fproject/issues", query, nil, _settings -> + send(self(), {:gitlab_all_query, query}) + {:ok, %{status: 200, body: []}} + end + + assert {:ok, []} = + GitLabClient.fetch_issues_by_states_for_test( + ["opened", "closed"], + tracker_settings(), + all_request + ) + + assert_receive {:gitlab_all_query, %{"state" => "all"}} + + assert {:ok, []} = + GitLabClient.fetch_issues_by_states_for_test( + ["Todo"], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + flunk("unsupported GitLab states should not make an HTTP request") + end + ) + + assert {:ok, []} = + GitLabClient.fetch_issues_by_states_for_test( + [], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + flunk("empty GitLab states should not make an HTTP request") + end + ) + end + + test "client refreshes IIDs in order, omits 404s, and rejects malformed refreshes" do + request_fun = fn "GET", path, %{}, nil, _settings -> + send(self(), {:gitlab_id_path, path}) + + case path do + "/projects/group%2Fproject/issues/2" -> {:ok, %{status: 200, body: raw_issue(2)}} + "/projects/group%2Fproject/issues/1" -> {:ok, %{status: 200, body: raw_issue(1)}} + "/projects/group%2Fproject/issues/404" -> {:ok, %{status: 404, body: %{"message" => "404 Not Found"}}} + end + end + + assert {:ok, issues} = + GitLabClient.fetch_issues_by_ids_for_test( + ["2", "1", "404", "2"], + tracker_settings(), + request_fun + ) + + assert Enum.map(issues, & &1.id) == ["2", "1"] + assert_receive {:gitlab_id_path, "/projects/group%2Fproject/issues/2"} + assert_receive {:gitlab_id_path, "/projects/group%2Fproject/issues/1"} + assert_receive {:gitlab_id_path, "/projects/group%2Fproject/issues/404"} + refute_receive {:gitlab_id_path, "/projects/group%2Fproject/issues/2"} + + assert {:error, :invalid_gitlab_issue_id} = + GitLabClient.fetch_issues_by_ids_for_test( + ["not-a-number"], + tracker_settings(), + request_fun + ) + + assert {:error, :gitlab_unknown_payload} = + GitLabClient.fetch_issues_by_ids_for_test( + ["3"], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + {:ok, %{status: 200, body: Map.put(raw_issue(3), "title", "")}} + end + ) + + assert {:ok, []} = + GitLabClient.fetch_issues_by_ids_for_test( + [], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + flunk("empty GitLab IDs should not make an HTTP request") + end + ) + end + + test "gitlab_api preserves REST status and body while rejecting unsafe arguments" do + test_pid = self() + tracker_settings = tracker_settings() + + response = + GitLabAgentTool.execute( + "gitlab_api", + %{ + "method" => "post", + "path" => " /projects/group%2Fproject/issues/42/notes ", + "query" => %{"per_page" => 10}, + "body" => %{"body" => "hello"} + }, + tracker_settings: tracker_settings, + gitlab_client: fn method, path, query, body, opts -> + send(test_pid, {:gitlab_tool_called, method, path, query, body, opts}) + {:ok, %{status: 201, body: %{"id" => 9}}} + end + ) + + assert_received {:gitlab_tool_called, "POST", "/projects/group%2Fproject/issues/42/notes", query, body, opts} + + assert query == %{"per_page" => 10} + assert body == %{"body" => "hello"} + assert opts == [tracker_settings: tracker_settings] + assert response["success"] == true + assert Jason.decode!(response["output"]) == %{"status" => 201, "body" => %{"id" => 9}} + assert response["contentItems"] == [%{"type" => "inputText", "text" => response["output"]}] + + failure = + GitLabAgentTool.execute( + "gitlab_api", + %{"method" => "GET", "path" => "/projects/1/issues/404"}, + gitlab_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: 404, body: %{"message" => "404 Not Found"}}} + end + ) + + assert failure["success"] == false + + assert Jason.decode!(failure["output"]) == %{ + "status" => 404, + "body" => %{"message" => "404 Not Found"} + } + + Enum.each( + [ + %{"method" => "GET", "path" => "https://gitlab.com/api/v4/user"}, + %{"method" => "PATCH", "path" => "/user"}, + %{"method" => "GET", "path" => "/user", "query" => false}, + %{"path" => "/user"} + ], + fn arguments -> + invalid = + GitLabAgentTool.execute( + "gitlab_api", + arguments, + gitlab_client: fn _method, _path, _query, _body, _opts -> + flunk("invalid gitlab_api arguments should not call the client") + end + ) + + assert invalid["success"] == false + end + ) + end + + test "gitlab_api reports unsupported tools, malformed calls, and client failures" do + unsupported = GitLabAgentTool.execute("not_gitlab_api", %{}, []) + assert unsupported["success"] == false + assert Jason.decode!(unsupported["output"])["error"]["supportedTools"] == ["gitlab_api"] + + Enum.each( + ["not-an-object", %{"method" => "GET", "path" => 123}], + fn arguments -> + invalid = + GitLabAgentTool.execute( + "gitlab_api", + arguments, + gitlab_client: fn _method, _path, _query, _body, _opts -> + flunk("malformed gitlab_api arguments should not call the client") + end + ) + + assert invalid["success"] == false + end + ) + + malformed_response = + GitLabAgentTool.execute( + "gitlab_api", + %{"method" => "GET", "path" => "/user"}, + gitlab_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: "not-an-integer", body: %{}}} + end + ) + + assert malformed_response["success"] == false + + Enum.each( + [:missing_gitlab_api_key, {:gitlab_api_request, :timeout}, :unexpected_failure], + fn reason -> + failure = + GitLabAgentTool.execute( + "gitlab_api", + %{"method" => "GET", "path" => "/user"}, + gitlab_client: fn _method, _path, _query, _body, _opts -> + {:error, reason} + end + ) + + assert failure["success"] == false + assert %{"error" => %{"message" => message}} = Jason.decode!(failure["output"]) + assert is_binary(message) + end + ) + + non_json_body = + GitLabAgentTool.execute( + "gitlab_api", + %{"method" => "GET", "path" => "/user"}, + gitlab_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: 200, body: self()}} + end + ) + + assert non_json_body["success"] + assert non_json_body["output"] =~ "#PID" + end + + test "tracker binds GitLab tools and token env names from provider config" do + token_env = "SYMPHONY_GITLAB_TOKEN_#{System.unique_integer([:positive])}" + previous_token = System.get_env(token_env) + System.put_env(token_env, "test-token") + + on_exit(fn -> restore_env(token_env, previous_token) end) + + write_gitlab_workflow!(Workflow.workflow_file_path(), "$#{token_env}") + + binding = Tracker.bind_agent_tools() + + assert binding.adapter == GitLabAdapter + assert binding.secret_environment_names == ["GITLAB_PAT", "GITLAB_ACCESS_TOKEN", token_env] + assert [%{"name" => "gitlab_api"}] = binding.tool_specs + assert :ok = Config.validate!() + end + + defp tracker_settings(provider_overrides \\ %{}) do + %{ + kind: "gitlab", + provider: + Map.merge( + %{ + "project_path" => "group/project", + "api_key" => "test-token" + }, + provider_overrides + ), + active_states: ["opened"], + terminal_states: ["closed"] + } + end + + defp raw_issue(iid) do + %{ + "iid" => iid, + "id" => 1_000 + iid, + "project_id" => 77, + "title" => "Issue #{iid}", + "description" => " Body #{iid} ", + "state" => "opened", + "web_url" => "https://gitlab.test/group/project/-/issues/#{iid}", + "assignees" => [%{"id" => 99, "username" => "octocat"}], + "assignee" => %{"username" => "octocat"}, + "labels" => [" Bug ", "bug", "Platform"], + "references" => %{"full" => "group/project##{iid}"}, + "created_at" => "2026-01-01T00:00:00Z", + "updated_at" => "2026-01-02T00:00:00Z" + } + end + + defp write_gitlab_workflow!(path, token) do + File.write!( + path, + """ + --- + tracker: + kind: gitlab + provider: + project_path: "group/project" + api_key: #{Jason.encode!(token)} + active_states: ["opened"] + terminal_states: ["closed"] + --- + + You are working on {{ issue.identifier }}. + """ + ) + + if Process.whereis(SymphonyElixir.WorkflowStore) do + assert :ok = SymphonyElixir.WorkflowStore.force_reload() + end + end +end diff --git a/elixir/test/symphony_elixir/gitlab_live_e2e_test.exs b/elixir/test/symphony_elixir/gitlab_live_e2e_test.exs new file mode 100644 index 0000000000..aa6b628135 --- /dev/null +++ b/elixir/test/symphony_elixir/gitlab_live_e2e_test.exs @@ -0,0 +1,425 @@ +defmodule SymphonyElixir.GitLab.LiveE2ETest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.GitLab.Client, as: GitLabClient + + @moduletag :live_e2e + @moduletag timeout: 300_000 + + @api_url "https://gitlab.com/api/v4" + @result_file "LIVE_GITLAB_E2E_RESULT.txt" + @live_e2e_skip_reason if(System.get_env("SYMPHONY_RUN_GITLAB_LIVE_E2E") != "1", + do: "set SYMPHONY_RUN_GITLAB_LIVE_E2E=1 to enable the real GitLab/Codex end-to-end test" + ) + + @tag skip: @live_e2e_skip_reason + test "creates a real GitLab issue and closes it through gitlab_api" do + project_id = required_env!("SYMPHONY_LIVE_GITLAB_PROJECT_ID") + token = required_env!("GITLAB_PAT") + run_id = "symphony-gitlab-live-e2e-#{System.unique_integer([:positive])}" + test_root = Path.join(System.tmp_dir!(), run_id) + workflow_root = Path.join(test_root, "workflow") + workflow_file = Path.join(workflow_root, "WORKFLOW.md") + workspace_root = Path.join(test_root, "workspaces") + codex_home = isolated_codex_home!(test_root) + original_workflow_path = Workflow.workflow_file_path() + runtime_pid = Process.whereis(SymphonyElixir.AgentRuntimeSupervisor) + + File.mkdir_p!(workflow_root) + + issue_payload = + create_issue!( + project_id, + token, + "Symphony GitLab live e2e #{run_id}", + "Disposable issue created by the Symphony GitLab live E2E test." + ) + + issue_iid = Integer.to_string(issue_payload["iid"]) + expected_comment = expected_comment("GL-#{issue_iid}", run_id) + + try do + assert %Issue{} = + issue = + GitLabClient.normalize_issue_for_test( + issue_payload, + tracker_settings(project_id) + ) + + stop_agent_runtime_if_running(runtime_pid) + Workflow.set_workflow_file_path(workflow_file) + + write_workflow!( + workflow_file, + project_id, + workspace_root, + codex_home, + live_prompt(project_id, expected_comment) + ) + + assert %Issue{id: state_issue_id} = wait_for_open_state_issue!(issue.id) + assert state_issue_id == issue.id + + assert {:ok, [%Issue{id: issue_id, identifier: identifier}]} = + Tracker.fetch_issues_by_ids([issue.id]) + + assert issue_id == issue.id + assert identifier == issue.identifier + + assert :ok = AgentRunner.run(issue, self(), max_turns: 3) + + runtime_info = receive_runtime_info!(issue.id) + tool_calls = completed_gitlab_tool_calls(issue.id) + + assert File.read!(Path.join(runtime_info.workspace_path, @result_file)) == + expected_result(issue.identifier, project_id) + + assert %{"state" => "closed"} = get_issue!(project_id, token, issue.id) + + assert notes!(project_id, token, issue.id) + |> Enum.any?(&(&1["body"] == expected_comment)) + + issue_path = "/projects/#{project_id}/issues/#{issue.id}" + notes_path = issue_path <> "/notes" + + assert_tool_call_count!(tool_calls, "GET", issue_path, 2) + assert_tool_call_count!(tool_calls, "GET", notes_path, 2) + assert_tool_call!(tool_calls, "POST", notes_path, %{"body" => expected_comment}) + assert_tool_call!(tool_calls, "PUT", issue_path, %{"state_event" => "close"}) + after + close_result = close_issue(project_id, token, issue_iid) + delete_result = delete_issue(project_id, token, issue_iid) + Workflow.set_workflow_file_path(original_workflow_path) + restart_agent_runtime_if_needed(runtime_pid) + File.rm_rf(test_root) + assert :ok = close_result + assert :ok = delete_result + assert :not_found = get_issue(project_id, token, issue_iid) + end + end + + defp tracker_settings(project_id) do + %{ + kind: "gitlab", + provider: %{"project_path" => project_id, "api_key" => "test-token"}, + active_states: ["opened"], + terminal_states: ["closed"] + } + end + + defp write_workflow!(path, project_id, workspace_root, codex_home, prompt) do + File.write!( + path, + """ + --- + tracker: + kind: gitlab + provider: + project_path: #{Jason.encode!(project_id)} + api_key: "$GITLAB_PAT" + active_states: ["opened"] + terminal_states: ["closed"] + workspace: + root: #{Jason.encode!(workspace_root)} + agent: + max_turns: 3 + codex: + command: #{Jason.encode!("env CODEX_HOME=#{shell_escape(codex_home)} codex app-server")} + approval_policy: "never" + read_timeout_ms: 60000 + turn_timeout_ms: 600000 + stall_timeout_ms: 600000 + observability: + dashboard_enabled: false + --- + + #{prompt} + """ + ) + + assert :ok = SymphonyElixir.WorkflowStore.force_reload() + end + + defp live_prompt(project_id, expected_comment) do + issue_path = "/projects/#{project_id}/issues/{{ issue.id }}" + notes_path = issue_path <> "/notes" + + """ + You are running a real Symphony GitLab end-to-end test. + + The current working directory is the workspace root. + + Step 1: + Run exactly: + + ```sh + if [ -n "${GITLAB_PAT:-}" ]; then gitlab_pat_present=yes; else gitlab_pat_present=no; fi + if [ -n "${GITLAB_ACCESS_TOKEN:-}" ]; then gitlab_access_token_present=yes; else gitlab_access_token_present=no; fi + cat > #{@result_file} < title, "description" => description} + ) + + case response.body do + %{"iid" => iid} = issue when is_integer(iid) -> issue + _ -> flunk("GitLab issue create returned an unexpected payload") + end + end + + defp get_issue!(project_id, token, issue_iid) do + case get_issue(project_id, token, issue_iid) do + %{} = issue -> issue + :not_found -> flunk("GitLab issue read unexpectedly returned 404") + end + end + + defp get_issue(project_id, token, issue_iid) do + case gitlab_request(:get, "/projects/#{project_id}/issues/#{issue_iid}", token, nil) do + {:ok, %{status: status, body: %{} = issue}} when status in 200..299 -> issue + {:ok, %{status: 404}} -> :not_found + {:ok, %{status: status}} -> flunk("GitLab issue read failed with HTTP #{status}") + {:error, reason} -> flunk("GitLab issue read failed before a response: #{inspect(reason)}") + end + end + + defp notes!(project_id, token, issue_iid) do + response = + gitlab_request!( + :get, + "/projects/#{project_id}/issues/#{issue_iid}/notes", + token, + nil, + %{"per_page" => 100} + ) + + case response.body do + notes when is_list(notes) -> notes + _ -> flunk("GitLab issue notes read returned an unexpected payload") + end + end + + defp close_issue(project_id, token, issue_iid) do + case gitlab_request( + :put, + "/projects/#{project_id}/issues/#{issue_iid}", + token, + %{"state_event" => "close"} + ) do + {:ok, %{status: status}} when status in 200..299 -> :ok + {:ok, %{status: 404}} -> :ok + {:ok, %{status: status}} -> {:error, {:gitlab_cleanup_status, status}} + {:error, reason} -> {:error, {:gitlab_cleanup_request, reason}} + end + end + + defp delete_issue(project_id, token, issue_iid) do + case gitlab_request(:delete, "/projects/#{project_id}/issues/#{issue_iid}", token, nil) do + {:ok, %{status: status}} when status in 200..299 -> :ok + {:ok, %{status: 404}} -> :ok + {:ok, %{status: status}} -> {:error, {:gitlab_delete_status, status}} + {:error, reason} -> {:error, {:gitlab_delete_request, reason}} + end + end + + defp gitlab_request!(method, path, token, body, query \\ %{}) do + case gitlab_request(method, path, token, body, query) do + {:ok, %{status: status} = response} when status in 200..299 -> response + {:ok, %{status: status}} -> flunk("GitLab request failed with HTTP #{status}") + {:error, reason} -> flunk("GitLab request failed before a response: #{inspect(reason)}") + end + end + + defp gitlab_request(method, path, token, body, query \\ %{}) do + request_opts = [ + method: method, + url: @api_url <> path, + headers: [ + {"Accept", "application/json"}, + {"PRIVATE-TOKEN", token} + ], + params: query, + connect_options: [timeout: 30_000] + ] + + request_opts = if is_nil(body), do: request_opts, else: Keyword.put(request_opts, :json, body) + + case Req.request(request_opts) do + {:ok, response} -> {:ok, %{status: response.status, body: response.body}} + {:error, reason} -> {:error, reason} + end + end + + defp receive_runtime_info!(issue_id) do + receive do + {:worker_runtime_info, ^issue_id, %{workspace_path: workspace_path} = runtime_info} + when is_binary(workspace_path) -> + runtime_info + + {:codex_worker_update, ^issue_id, _message} -> + receive_runtime_info!(issue_id) + after + 5_000 -> + flunk("timed out waiting for worker runtime info") + end + end + + defp completed_gitlab_tool_calls(issue_id, calls \\ []) do + receive do + {:codex_worker_update, ^issue_id, %{event: :tool_call_completed, payload: %{"params" => params}}} -> + completed_gitlab_tool_calls(issue_id, [params | calls]) + + {:codex_worker_update, ^issue_id, _message} -> + completed_gitlab_tool_calls(issue_id, calls) + after + 0 -> + Enum.reverse(calls) + end + end + + defp assert_tool_call!(calls, method, path, expected_body) do + found? = + Enum.any?(calls, fn params -> + tool_call_matches?(params, method, path) and + (expected_body == :any or get_in(params, ["arguments", "body"]) == expected_body) + end) + + assert found?, "expected completed gitlab_api #{method} #{path}" + end + + defp assert_tool_call_count!(calls, method, path, expected_count) do + count = Enum.count(calls, &tool_call_matches?(&1, method, path)) + assert count >= expected_count, "expected at least #{expected_count} completed gitlab_api #{method} #{path} calls" + end + + defp tool_call_matches?(params, method, path) do + arguments = Map.get(params, "arguments", %{}) + tool_name = Map.get(params, "name") || Map.get(params, "tool") + called_method = Map.get(arguments, "method") + called_path = Map.get(arguments, "path") + + tool_name == "gitlab_api" and is_binary(called_method) and + String.upcase(String.trim(called_method)) == method and + is_binary(called_path) and String.trim(called_path) == path + end + + defp wait_for_open_state_issue!(issue_id, attempts \\ 20) + + defp wait_for_open_state_issue!(issue_id, attempts) when attempts > 0 do + case Tracker.fetch_issues_by_states(["opened"]) do + {:ok, issues} -> + case Enum.find(issues, &(&1.id == issue_id)) do + %Issue{} = issue -> + issue + + nil -> + Process.sleep(500) + wait_for_open_state_issue!(issue_id, attempts - 1) + end + + {:error, reason} -> + flunk("GitLab state read failed: #{inspect(reason)}") + end + end + + defp wait_for_open_state_issue!(_issue_id, 0) do + flunk("new GitLab issue did not appear in the open-state adapter read") + end + + defp isolated_codex_home!(test_root) do + codex_home = Path.join(test_root, "codex-home") + auth_json_path = Path.join(codex_home, "auth.json") + + source_auth_json = + Path.join( + System.get_env("CODEX_HOME") || Path.join(System.user_home!(), ".codex"), + "auth.json" + ) + + unless File.regular?(source_auth_json) do + flunk("live GitLab e2e requires Codex auth") + end + + File.mkdir_p!(codex_home) + File.cp!(source_auth_json, auth_json_path) + File.chmod!(auth_json_path, 0o600) + codex_home + end + + defp stop_agent_runtime_if_running(runtime_pid) when is_pid(runtime_pid) do + assert :ok = + Supervisor.terminate_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) + end + + defp stop_agent_runtime_if_running(_runtime_pid), do: :ok + + defp restart_agent_runtime_if_needed(runtime_pid) when is_pid(runtime_pid) do + if is_nil(Process.whereis(SymphonyElixir.AgentRuntimeSupervisor)) do + case Supervisor.restart_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + end + end + end + + defp restart_agent_runtime_if_needed(_runtime_pid), do: :ok + + defp required_env!(name) do + case System.get_env(name) do + value when is_binary(value) and value != "" -> value + _ -> flunk("live GitLab e2e requires #{name}") + end + end + + defp shell_escape(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end +end diff --git a/elixir/test/symphony_elixir/jira_adapter_test.exs b/elixir/test/symphony_elixir/jira_adapter_test.exs new file mode 100644 index 0000000000..c3f40844ad --- /dev/null +++ b/elixir/test/symphony_elixir/jira_adapter_test.exs @@ -0,0 +1,609 @@ +defmodule SymphonyElixir.Jira.AdapterTest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.Jira.Adapter, as: JiraAdapter + alias SymphonyElixir.Jira.AgentTool, as: JiraAgentTool + alias SymphonyElixir.Jira.Client, as: JiraClient + + defmodule FakeJiraClient do + def fetch_issues_by_states(states) do + send(self(), {:jira_states_called, states}) + {:ok, states} + end + + def fetch_issues_by_ids(ids) do + send(self(), {:jira_ids_called, ids}) + {:ok, ids} + end + end + + setup do + jira_client_module = Application.get_env(:symphony_elixir, :jira_client_module) + + on_exit(fn -> + if is_nil(jira_client_module) do + Application.delete_env(:symphony_elixir, :jira_client_module) + else + Application.put_env(:symphony_elixir, :jira_client_module, jira_client_module) + end + end) + + :ok + end + + test "adapter validates Jira config, delegates reads, and advertises jira_rest" do + settings = tracker_settings() + + assert :ok = JiraAdapter.validate_config(settings) + assert :ok = JiraAdapter.validate_config(%{settings | active_states: [], terminal_states: []}) + + assert {:error, :missing_jira_active_states} = + JiraAdapter.validate_config(%{settings | active_states: nil}) + + assert {:error, :missing_jira_terminal_states} = + JiraAdapter.validate_config(%{settings | terminal_states: nil}) + + assert {:error, :invalid_jira_states} = + JiraAdapter.validate_config(%{settings | active_states: [" "]}) + + assert {:error, :invalid_jira_states} = + JiraAdapter.validate_config(%{settings | active_states: [42]}) + + Application.put_env(:symphony_elixir, :jira_client_module, FakeJiraClient) + + assert {:ok, ["To Do"]} = JiraAdapter.fetch_issues_by_states(["To Do"]) + assert_receive {:jira_states_called, ["To Do"]} + + assert {:ok, ["10001"]} = JiraAdapter.fetch_issues_by_ids(["10001"]) + assert_receive {:jira_ids_called, ["10001"]} + + assert [%{"name" => "jira_rest"}] = JiraAdapter.agent_tool_specs() + + assert JiraAdapter.execute_agent_tool( + "jira_rest", + %{"method" => "GET", "path" => "/rest/api/3/myself"}, + jira_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: 200, body: %{"accountId" => "user"}}} + end + )["success"] + end + + test "client validates provider settings and declares token environments" do + assert :ok = JiraClient.validate_settings(tracker_settings()) + + assert {:error, :invalid_jira_base_url} = + JiraClient.validate_settings(tracker_settings(%{"base_url" => "http://jira.test"})) + + assert {:error, :missing_jira_email} = + JiraClient.validate_settings(tracker_settings(%{"email" => 123})) + + assert {:error, :missing_jira_api_token} = + JiraClient.validate_settings(tracker_settings(%{"api_token" => 123})) + + assert {:error, :missing_jira_project_key} = + JiraClient.validate_settings(tracker_settings(%{"project_key" => 123})) + + assert JiraClient.secret_environment_names(tracker_settings(%{"api_token" => "$SYMPHONY_JIRA_TOKEN"})) == ["JIRA_API_TOKEN", "SYMPHONY_JIRA_TOKEN"] + end + + test "client normalizes Jira issue fields and projects ADF description text" do + issue = JiraClient.normalize_issue_for_test(raw_issue("10001", "SYM-1"), tracker_settings()) + + assert issue.id == "10001" + assert issue.identifier == "SYM-1" + assert issue.native_ref == nil + assert issue.title == "Issue SYM-1" + assert issue.description == "First line\nSecond line" + assert issue.priority == nil + assert issue.state == "To Do" + assert issue.branch_name == nil + assert issue.url == "https://jira.example.test/browse/SYM-1" + assert issue.assignee_id == "account-1" + assert issue.labels == ["bug", "platform"] + assert issue.blocked_by == [] + assert issue.dispatchable + assert %DateTime{} = issue.created_at + assert %DateTime{} = issue.updated_at + + assert JiraClient.normalize_issue_for_test( + put_in(raw_issue("10002", "SYM-2"), ["fields", "summary"], ""), + tracker_settings() + ) == nil + + rich_description = + %{ + "type" => "doc", + "version" => 1, + "content" => [ + %{ + "type" => "paragraph", + "content" => [ + %{"type" => "mention", "attrs" => %{"text" => "@Alex"}}, + %{"type" => "emoji", "attrs" => %{"shortName" => ":wave:"}}, + %{"type" => "status", "attrs" => %{"text" => "Ready"}}, + %{"type" => "inlineCard", "attrs" => %{"url" => "https://example.test/card"}} + ] + } + ] + } + + rich_issue = + raw_issue("10003", "SYM-3") + |> put_in(["fields", "description"], rich_description) + + assert JiraClient.normalize_issue_for_test(rich_issue, tracker_settings()).description == + "@Alex:wave:Readyhttps://example.test/card" + + panel_description = + %{ + "type" => "doc", + "version" => 1, + "content" => [ + %{ + "type" => "panel", + "attrs" => %{"panelType" => "info"}, + "content" => [ + %{"type" => "paragraph", "content" => [%{"type" => "text", "text" => "Panel text"}]} + ] + } + ] + } + + panel_issue = + raw_issue("10004", "SYM-4") + |> put_in(["fields", "description"], panel_description) + + assert JiraClient.normalize_issue_for_test(panel_issue, tracker_settings()).description == + "Panel text" + + assert %Issue{id: "10001"} = + JiraClient.normalize_issue_for_test( + raw_issue("10001", "SYM-1"), + tracker_settings(%{"project_key" => "sym"}) + ) + end + + test "client retains inward Blocks links and gates only ready Jira issues" do + active_blocker = linked_issue("20001", "SYM-10", "In Progress") + terminal_blocker = linked_issue("20002", "OTHER-20", "Ready for Release") + + issue = + raw_issue("10001", "SYM-1") + |> put_in( + [ + "fields", + "issuelinks" + ], + [ + %{"type" => %{"name" => " Blocks "}, "inwardIssue" => active_blocker}, + %{"type" => %{"name" => "blocks"}, "inwardIssue" => terminal_blocker}, + %{ + "type" => %{"name" => "Relates"}, + "inwardIssue" => linked_issue("20003", "SYM-30", "In Progress") + }, + %{ + "type" => %{"name" => "Blocks"}, + "outwardIssue" => linked_issue("20004", "SYM-40", "In Progress") + } + ] + ) + + normalized = JiraClient.normalize_issue_for_test(issue, tracker_settings()) + + assert normalized.blocked_by == [ + %{id: "20001", identifier: "SYM-10", state: "In Progress"}, + %{id: "20002", identifier: "OTHER-20", state: "Ready for Release"} + ] + + refute normalized.dispatchable + + open = + issue + |> put_in( + ["fields", "status"], + %{"name" => "Open", "statusCategory" => %{"key" => "new"}} + ) + |> JiraClient.normalize_issue_for_test(tracker_settings()) + + refute open.dispatchable + + terminal_only = + issue + |> put_in(["fields", "issuelinks"], [ + %{"type" => %{"name" => "Blocks"}, "inwardIssue" => terminal_blocker} + ]) + |> JiraClient.normalize_issue_for_test(%{ + tracker_settings() + | terminal_states: ["Ready for Release"] + }) + + assert terminal_only.dispatchable + + unknown_blocker = + issue + |> put_in(["fields", "status", "name"], "Todo") + |> put_in(["fields", "issuelinks"], [ + %{ + "type" => %{"name" => "Blocks"}, + "inwardIssue" => %{"id" => "20005"} + } + ]) + |> JiraClient.normalize_issue_for_test(tracker_settings()) + + assert unknown_blocker.blocked_by == [%{id: "20005", identifier: nil, state: nil}] + refute unknown_blocker.dispatchable + + in_progress = + issue + |> put_in( + ["fields", "status"], + %{"name" => "In Progress", "statusCategory" => %{"key" => "indeterminate"}} + ) + |> JiraClient.normalize_issue_for_test(tracker_settings()) + + assert in_progress.dispatchable + end + + test "client pages enhanced search, filters states, and drops malformed candidates" do + request_fun = fn "POST", "/rest/api/3/search/jql", %{}, body, settings -> + send(self(), {:jira_search, body, settings}) + + response_body = + case Map.get(body, "nextPageToken") do + nil -> + %{ + "issues" => [ + raw_issue("10001", "SYM-1"), + put_in(raw_issue("10002", "SYM-2"), ["fields", "summary"], ""), + raw_issue("10003", "SYM-3", "Done") + ], + "isLast" => false, + "nextPageToken" => "next-page" + } + + "next-page" -> + %{"issues" => [raw_issue("10004", "SYM-4")], "isLast" => true} + end + + {:ok, %{status: 200, body: response_body}} + end + + log = + capture_log(fn -> + assert {:ok, issues} = + JiraClient.fetch_issues_by_states_for_test( + ["To Do"], + tracker_settings(), + request_fun + ) + + assert Enum.map(issues, & &1.id) == ["10001", "10004"] + end) + + assert log =~ "Dropping malformed Jira issue records count=1" + + assert_receive {:jira_search, + %{ + "jql" => "project = \"SYM\" AND status IN (\"To Do\")", + "fields" => fields, + "maxResults" => 100 + }, %{project_key: "SYM"}} + + assert "summary" in fields + assert "issuelinks" in fields + assert_receive {:jira_search, %{"nextPageToken" => "next-page"}, %{project_key: "SYM"}} + + assert {:ok, []} = + JiraClient.fetch_issues_by_states_for_test( + [], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + flunk("empty Jira states should not make an HTTP request") + end + ) + end + + test "client errors when enhanced search pagination omits its token" do + assert {:error, :jira_missing_next_page_token} = + JiraClient.fetch_issues_by_states_for_test( + ["To Do"], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + {:ok, %{status: 200, body: %{"issues" => [], "isLast" => false}}} + end + ) + end + + test "client refreshes IDs in batches, preserves order, and omits missing scope" do + ids = Enum.map(1..101, &Integer.to_string/1) + + request_fun = fn "POST", "/rest/api/3/issue/bulkfetch", %{}, body, _settings -> + assert "issuelinks" in body["fields"] + send(self(), {:jira_bulk_ids, body["issueIdsOrKeys"]}) + issues = Enum.map(body["issueIdsOrKeys"], &raw_issue(&1, "SYM-#{&1}")) + {:ok, %{status: 200, body: %{"issues" => issues}}} + end + + assert {:ok, issues} = + JiraClient.fetch_issues_by_ids_for_test(ids, tracker_settings(), request_fun) + + assert Enum.map(issues, & &1.id) == ids + assert_receive {:jira_bulk_ids, first_batch} + assert length(first_batch) == 100 + assert_receive {:jira_bulk_ids, ["101"]} + + scoped_request_fun = fn _method, _path, _query, _body, _settings -> + {:ok, + %{ + status: 200, + body: %{ + "issues" => [ + raw_issue("1", "SYM-1"), + raw_issue("2", "OTHER-2", "To Do", "OTHER") + ] + } + }} + end + + assert {:ok, [%Issue{id: "1"}]} = + JiraClient.fetch_issues_by_ids_for_test( + ["1", "2", "404"], + tracker_settings(), + scoped_request_fun + ) + + assert {:error, :jira_unknown_payload} = + JiraClient.fetch_issues_by_ids_for_test( + ["1"], + tracker_settings(), + fn _method, _path, _query, _body, _settings -> + malformed = put_in(raw_issue("1", "SYM-1"), ["fields", "summary"], "") + {:ok, %{status: 200, body: %{"issues" => [malformed]}}} + end + ) + end + + test "client refreshes Jira blockers before dispatch" do + request_fun = fn "POST", "/rest/api/3/issue/bulkfetch", %{}, body, _settings -> + assert "issuelinks" in body["fields"] + + issue = + raw_issue("10001", "SYM-1") + |> put_in(["fields", "issuelinks"], [ + %{ + "type" => %{"name" => "Blocks"}, + "inwardIssue" => linked_issue("20001", "SYM-10", "In Progress") + } + ]) + + {:ok, %{status: 200, body: %{"issues" => [issue]}}} + end + + assert {:ok, [issue]} = + JiraClient.fetch_issues_by_ids_for_test( + ["10001"], + tracker_settings(), + request_fun + ) + + assert issue.blocked_by == [ + %{id: "20001", identifier: "SYM-10", state: "In Progress"} + ] + + refute issue.dispatchable + end + + test "jira_rest preserves REST status and body while rejecting unsafe arguments" do + test_pid = self() + tracker_settings = tracker_settings() + + response = + JiraAgentTool.execute( + "jira_rest", + %{ + "method" => "post", + "path" => " /rest/api/3/issue/10001/comment ", + "query" => %{"expand" => "renderedBody"}, + "body" => %{"body" => %{"type" => "doc"}} + }, + tracker_settings: tracker_settings, + jira_client: fn method, path, query, body, opts -> + send(test_pid, {:jira_tool_called, method, path, query, body, opts}) + {:ok, %{status: 201, body: %{"id" => "comment-1"}}} + end + ) + + expected_call = + {:jira_tool_called, "POST", "/rest/api/3/issue/10001/comment", %{"expand" => "renderedBody"}, %{"body" => %{"type" => "doc"}}, [tracker_settings: tracker_settings]} + + assert_received ^expected_call + + assert response["success"] + assert Jason.decode!(response["output"]) == %{"status" => 201, "body" => %{"id" => "comment-1"}} + + failure = + JiraAgentTool.execute( + "jira_rest", + %{"method" => "GET", "path" => "/rest/api/3/issue/404"}, + jira_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: 404, body: %{"errorMessages" => ["Not Found"]}}} + end + ) + + refute failure["success"] + + Enum.each( + [ + %{"method" => "GET", "path" => "https://jira.test/rest/api/3/myself"}, + %{"method" => "GET", "path" => "/rest/api/2/myself"}, + %{"method" => "GET", "path" => "/rest/api/3/myself", "query" => false}, + %{"path" => "/rest/api/3/myself"} + ], + fn arguments -> + invalid = + JiraAgentTool.execute( + "jira_rest", + arguments, + jira_client: fn _method, _path, _query, _body, _opts -> + flunk("invalid jira_rest arguments should not call the client") + end + ) + + refute invalid["success"] + end + ) + end + + test "jira_rest reports unsupported tools, malformed calls, and client failures" do + unsupported = JiraAgentTool.execute("not_jira_rest", %{}, []) + refute unsupported["success"] + assert Jason.decode!(unsupported["output"])["error"]["supportedTools"] == ["jira_rest"] + + Enum.each(["not-an-object", %{"method" => "GET", "path" => 123}], fn arguments -> + invalid = + JiraAgentTool.execute( + "jira_rest", + arguments, + jira_client: fn _method, _path, _query, _body, _opts -> + flunk("malformed jira_rest arguments should not call the client") + end + ) + + refute invalid["success"] + end) + + malformed_response = + JiraAgentTool.execute( + "jira_rest", + %{"method" => "GET", "path" => "/rest/api/3/myself"}, + jira_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: "bad", body: %{}}} + end + ) + + refute malformed_response["success"] + + Enum.each([:missing_jira_api_token, {:jira_api_request, :timeout}, :unexpected], fn reason -> + failure = + JiraAgentTool.execute( + "jira_rest", + %{"method" => "GET", "path" => "/rest/api/3/myself"}, + jira_client: fn _method, _path, _query, _body, _opts -> {:error, reason} end + ) + + refute failure["success"] + assert %{"error" => %{"message" => message}} = Jason.decode!(failure["output"]) + assert is_binary(message) + end) + + non_json_body = + JiraAgentTool.execute( + "jira_rest", + %{"method" => "GET", "path" => "/rest/api/3/myself"}, + jira_client: fn _method, _path, _query, _body, _opts -> + {:ok, %{status: 200, body: self()}} + end + ) + + assert non_json_body["success"] + assert non_json_body["output"] =~ "#PID" + end + + test "tracker binds Jira tools and token env names from provider config" do + token_env = "SYMPHONY_JIRA_TOKEN_#{System.unique_integer([:positive])}" + previous_token = System.get_env(token_env) + System.put_env(token_env, "test-token") + + on_exit(fn -> restore_env(token_env, previous_token) end) + + write_jira_workflow!(Workflow.workflow_file_path(), "$#{token_env}") + + binding = Tracker.bind_agent_tools() + + assert binding.adapter == JiraAdapter + assert binding.secret_environment_names == ["JIRA_API_TOKEN", token_env] + assert [%{"name" => "jira_rest"}] = binding.tool_specs + assert :ok = Config.validate!() + end + + defp tracker_settings(provider_overrides \\ %{}) do + %{ + kind: "jira", + provider: + Map.merge( + %{ + "base_url" => "https://jira.example.test", + "email" => "agent@example.test", + "api_token" => "test-token", + "project_key" => "SYM" + }, + provider_overrides + ), + active_states: ["To Do"], + terminal_states: ["Done"] + } + end + + defp raw_issue(id, key, state \\ "To Do", project_key \\ "SYM") do + %{ + "id" => id, + "key" => key, + "fields" => %{ + "summary" => "Issue #{key}", + "description" => %{ + "type" => "doc", + "version" => 1, + "content" => [ + %{ + "type" => "paragraph", + "content" => [ + %{"type" => "text", "text" => "First line"}, + %{"type" => "hardBreak"}, + %{"type" => "text", "text" => "Second line"} + ] + } + ] + }, + "status" => %{"name" => state}, + "labels" => [" Bug ", "bug", "Platform"], + "assignee" => %{"accountId" => "account-1"}, + "created" => "2026-01-01T00:00:00.000+0000", + "updated" => "2026-01-02T00:00:00.000+0000", + "project" => %{"key" => project_key} + } + } + end + + defp linked_issue(id, key, state) do + %{ + "id" => id, + "key" => key, + "fields" => %{"status" => %{"name" => state}} + } + end + + defp write_jira_workflow!(path, token) do + File.write!( + path, + """ + --- + tracker: + kind: jira + provider: + base_url: "https://jira.example.test" + email: "agent@example.test" + api_token: #{Jason.encode!(token)} + project_key: "SYM" + active_states: ["To Do"] + terminal_states: ["Done"] + --- + + You are working on {{ issue.identifier }}. + """ + ) + + if Process.whereis(SymphonyElixir.WorkflowStore) do + assert :ok = SymphonyElixir.WorkflowStore.force_reload() + end + end +end diff --git a/elixir/test/symphony_elixir/jira_live_e2e_test.exs b/elixir/test/symphony_elixir/jira_live_e2e_test.exs new file mode 100644 index 0000000000..f573a1a27c --- /dev/null +++ b/elixir/test/symphony_elixir/jira_live_e2e_test.exs @@ -0,0 +1,533 @@ +defmodule SymphonyElixir.Jira.LiveE2ETest do + use SymphonyElixir.TestSupport + + alias SymphonyElixir.Jira.Client, as: JiraClient + + @moduletag :live_e2e + @moduletag timeout: 300_000 + + @result_file "LIVE_JIRA_E2E_RESULT.txt" + @live_e2e_skip_reason if(System.get_env("SYMPHONY_RUN_JIRA_LIVE_E2E") != "1", + do: "set SYMPHONY_RUN_JIRA_LIVE_E2E=1 to enable the real Jira/Codex end-to-end test" + ) + + @tag skip: @live_e2e_skip_reason + test "creates a real Jira issue and completes it through jira_rest" do + base_url = required_env!("JIRA_BASE_URL") + email = required_env!("JIRA_EMAIL") + api_token = required_env!("JIRA_API_TOKEN") + project_key = required_env!("SYMPHONY_LIVE_JIRA_PROJECT_KEY") + run_id = "symphony-jira-live-e2e-#{System.unique_integer([:positive])}" + test_root = Path.join(System.tmp_dir!(), run_id) + workflow_root = Path.join(test_root, "workflow") + workflow_file = Path.join(workflow_root, "WORKFLOW.md") + workspace_root = Path.join(test_root, "workspaces") + codex_home = isolated_codex_home!(test_root) + original_workflow_path = Workflow.workflow_file_path() + runtime_pid = Process.whereis(SymphonyElixir.AgentRuntimeSupervisor) + issue_type_id = issue_type_id!(base_url, email, api_token, project_key) + + File.mkdir_p!(workflow_root) + + created_issue = + create_issue!( + base_url, + email, + api_token, + project_key, + issue_type_id, + "Symphony Jira live e2e #{run_id}" + ) + + issue_id = created_issue["id"] + issue_key = created_issue["key"] + expected_comment = "Symphony Jira live e2e comment #{issue_key} #{run_id}" + + try do + raw_issue = get_issue!(base_url, email, api_token, issue_id) + initial_state = get_in(raw_issue, ["fields", "status", "name"]) + + {terminal_state, terminal_transition_id} = + terminal_transition!(base_url, email, api_token, issue_id) + + assert is_binary(initial_state) and initial_state != terminal_state + + settings = + tracker_settings(base_url, email, api_token, project_key, initial_state, terminal_state) + + assert %Issue{} = issue = JiraClient.normalize_issue_for_test(raw_issue, settings) + + stop_agent_runtime_if_running(runtime_pid) + Workflow.set_workflow_file_path(workflow_file) + + write_workflow!( + workflow_file, + project_key, + initial_state, + terminal_state, + workspace_root, + codex_home, + live_prompt(issue_id, project_key, terminal_state, expected_comment) + ) + + assert %Issue{id: state_issue_id} = wait_for_state_issue!(issue.id, initial_state) + assert state_issue_id == issue.id + + assert {:ok, [%Issue{id: refreshed_id, identifier: refreshed_identifier}]} = + Tracker.fetch_issues_by_ids([issue.id]) + + assert refreshed_id == issue.id + assert refreshed_identifier == issue.identifier + + assert :ok = AgentRunner.run(issue, self(), max_turns: 3) + + runtime_info = receive_runtime_info!(issue.id) + tool_calls = completed_jira_tool_calls(issue.id) + + assert File.read!(Path.join(runtime_info.workspace_path, @result_file)) == + expected_result(issue.identifier, project_key) + + final_issue = get_issue!(base_url, email, api_token, issue.id, "status,comment") + assert get_in(final_issue, ["fields", "status", "name"]) == terminal_state + assert issue_comments(final_issue) |> Enum.any?(&(&1 == expected_comment)) + + issue_path = "/rest/api/3/issue/#{issue.id}" + comment_path = issue_path <> "/comment" + transitions_path = issue_path <> "/transitions" + + assert_tool_call_count!( + tool_calls, + "GET", + transitions_path, + 1 + ) + + assert_tool_call_count!(tool_calls, "GET", issue_path, 2) + assert_tool_call!(tool_calls, "POST", comment_path, %{"body" => comment_adf(expected_comment)}) + + assert_tool_call!( + tool_calls, + "POST", + transitions_path, + %{"transition" => %{"id" => terminal_transition_id}} + ) + after + delete_result = delete_issue(base_url, email, api_token, issue_id) + + deleted_issue_result = + jira_request( + :get, + base_url, + email, + api_token, + "/rest/api/3/issue/#{issue_id}", + %{} + ) + + Workflow.set_workflow_file_path(original_workflow_path) + restart_agent_runtime_if_needed(runtime_pid) + File.rm_rf(test_root) + assert :ok = delete_result + assert {:ok, %{status: 404}} = deleted_issue_result + end + end + + defp write_workflow!( + path, + project_key, + active_state, + terminal_state, + workspace_root, + codex_home, + prompt + ) do + File.write!( + path, + """ + --- + tracker: + kind: jira + provider: + base_url: "$JIRA_BASE_URL" + email: "$JIRA_EMAIL" + api_token: "$JIRA_API_TOKEN" + project_key: #{Jason.encode!(project_key)} + active_states: [#{Jason.encode!(active_state)}] + terminal_states: [#{Jason.encode!(terminal_state)}] + workspace: + root: #{Jason.encode!(workspace_root)} + agent: + max_turns: 3 + codex: + command: #{Jason.encode!("env CODEX_HOME=#{shell_escape(codex_home)} codex app-server")} + approval_policy: "never" + read_timeout_ms: 60000 + turn_timeout_ms: 600000 + stall_timeout_ms: 600000 + observability: + dashboard_enabled: false + --- + + #{prompt} + """ + ) + + assert :ok = SymphonyElixir.WorkflowStore.force_reload() + end + + defp live_prompt(issue_id, project_key, terminal_state, expected_comment) do + issue_path = "/rest/api/3/issue/#{issue_id}" + comment_path = issue_path <> "/comment" + transitions_path = issue_path <> "/transitions" + comment_body = %{"body" => comment_adf(expected_comment)} |> Jason.encode!() + + """ + You are running a real Symphony Jira end-to-end test. + + The current working directory is the workspace root. + + Step 1: + Create #{@result_file} with exactly: + identifier={{ issue.identifier }} + project_key=#{project_key} + + Step 2: + You must use jira_rest for every Jira operation. First GET: + - #{transitions_path}?expand=transitions.fields + - #{issue_path}?fields=status,comment + + If the exact comment below is not already present, POST it once to #{comment_path}: + #{expected_comment} + + Use this exact JSON body: + #{comment_body} + + Step 3: + Choose the transition whose destination name is #{terminal_state}, then POST its id to #{transitions_path} + with a body shaped as {"transition":{"id":"..."}}. + + Step 4: + GET #{issue_path}?fields=status,comment again. Stop only after: + 1. #{@result_file} exists with the exact two lines above + 2. the exact comment is present + 3. the Jira status name is #{terminal_state} + + Do not ask for approval. + """ + end + + defp expected_result(identifier, project_key) do + "identifier=#{identifier}\nproject_key=#{project_key}\n" + end + + defp tracker_settings(base_url, email, api_token, project_key, active_state, terminal_state) do + %{ + kind: "jira", + provider: %{ + "base_url" => base_url, + "email" => email, + "api_token" => api_token, + "project_key" => project_key + }, + active_states: [active_state], + terminal_states: [terminal_state] + } + end + + defp issue_type_id!(base_url, email, api_token, project_key) do + response = + jira_request!( + :get, + base_url, + email, + api_token, + "/rest/api/3/issue/createmeta/#{URI.encode(project_key, &URI.char_unreserved?/1)}/issuetypes", + %{} + ) + + issue_type = + response.body + |> Map.get("issueTypes", []) + |> Enum.find(&(Map.get(&1, "subtask") != true)) + + case issue_type do + %{"id" => id} when is_binary(id) -> id + _ -> flunk("Jira live e2e could not find a non-subtask issue type") + end + end + + defp terminal_transition!(base_url, email, api_token, issue_id) do + response = + jira_request!( + :get, + base_url, + email, + api_token, + "/rest/api/3/issue/#{issue_id}/transitions", + %{} + ) + + transition = + response.body + |> Map.get("transitions", []) + |> Enum.find(&(get_in(&1, ["to", "statusCategory", "key"]) == "done")) + + case transition do + %{"id" => id, "to" => %{"name" => name}} + when is_binary(id) and id != "" and is_binary(name) and name != "" -> + {name, id} + + _ -> + flunk("Jira live e2e could not find a terminal workflow transition") + end + end + + defp create_issue!(base_url, email, api_token, project_key, issue_type_id, title) do + response = + jira_request!( + :post, + base_url, + email, + api_token, + "/rest/api/3/issue", + %{}, + %{ + "fields" => %{ + "project" => %{"key" => project_key}, + "summary" => title, + "description" => comment_adf(title), + "issuetype" => %{"id" => issue_type_id} + } + } + ) + + case response.body do + %{"id" => id, "key" => key} = issue when is_binary(id) and is_binary(key) -> issue + _ -> flunk("Jira issue create returned an unexpected payload") + end + end + + defp get_issue!(base_url, email, api_token, issue_id, fields \\ "summary,description,status,labels,assignee,created,updated,project") do + response = + jira_request!( + :get, + base_url, + email, + api_token, + "/rest/api/3/issue/#{issue_id}", + %{"fields" => fields} + ) + + case response.body do + %{} = issue -> issue + _ -> flunk("Jira issue read returned an unexpected payload") + end + end + + defp issue_comments(%{"fields" => %{"comment" => %{"comments" => comments}}}) + when is_list(comments) do + Enum.map(comments, &adf_text(&1["body"])) + end + + defp issue_comments(_issue), do: [] + + defp comment_adf(text) do + %{ + "type" => "doc", + "version" => 1, + "content" => [ + %{ + "type" => "paragraph", + "content" => [%{"type" => "text", "text" => text}] + } + ] + } + end + + defp adf_text(%{"text" => text}) when is_binary(text), do: text + + defp adf_text(%{"content" => content}) when is_list(content) do + Enum.map_join(content, "", &adf_text/1) + end + + defp adf_text(_value), do: "" + + defp delete_issue(base_url, email, api_token, issue_id) do + case jira_request( + :delete, + base_url, + email, + api_token, + "/rest/api/3/issue/#{issue_id}", + %{"deleteSubtasks" => "true"} + ) do + {:ok, %{status: status}} when status in 200..299 -> :ok + {:ok, %{status: status}} -> {:error, {:jira_cleanup_status, status}} + {:error, reason} -> {:error, {:jira_cleanup_request, reason}} + end + end + + defp jira_request!(method, base_url, email, api_token, path, query, body \\ nil) do + case jira_request(method, base_url, email, api_token, path, query, body) do + {:ok, %{status: status} = response} when status in 200..299 -> + response + + {:ok, %{status: status}} -> + flunk("Jira request failed with HTTP #{status}") + + {:error, reason} -> + flunk("Jira request failed before a response: #{inspect(reason)}") + end + end + + defp jira_request(method, base_url, email, api_token, path, query, body \\ nil) do + request_opts = [ + method: method, + url: String.trim_trailing(base_url, "/") <> path, + headers: [ + {"Accept", "application/json"}, + {"Authorization", "Basic #{Base.encode64("#{email}:#{api_token}")}"} + ], + params: query, + connect_options: [timeout: 30_000] + ] + + request_opts = if is_nil(body), do: request_opts, else: Keyword.put(request_opts, :json, body) + + case Req.request(request_opts) do + {:ok, response} -> {:ok, %{status: response.status, body: response.body}} + {:error, reason} -> {:error, reason} + end + end + + defp receive_runtime_info!(issue_id) do + receive do + {:worker_runtime_info, ^issue_id, %{workspace_path: workspace_path} = runtime_info} + when is_binary(workspace_path) -> + runtime_info + + {:codex_worker_update, ^issue_id, _message} -> + receive_runtime_info!(issue_id) + after + 5_000 -> + flunk("timed out waiting for worker runtime info") + end + end + + defp completed_jira_tool_calls(issue_id, calls \\ []) do + receive do + {:codex_worker_update, ^issue_id, %{event: :tool_call_completed, payload: %{"params" => params}}} -> + completed_jira_tool_calls(issue_id, [params | calls]) + + {:codex_worker_update, ^issue_id, _message} -> + completed_jira_tool_calls(issue_id, calls) + after + 0 -> + Enum.reverse(calls) + end + end + + defp assert_tool_call!(calls, method, path, expected_body) do + found? = + Enum.any?(calls, fn params -> + tool_call_matches?(params, method, path) and + get_in(params, ["arguments", "body"]) == expected_body + end) + + assert found?, "expected completed jira_rest #{method} #{path}" + end + + defp assert_tool_call_count!(calls, method, path, expected_count) do + count = Enum.count(calls, &tool_call_matches?(&1, method, path)) + assert count >= expected_count, "expected at least #{expected_count} completed jira_rest #{method} #{path} calls" + end + + defp tool_call_matches?(params, method, path) do + arguments = Map.get(params, "arguments", %{}) + tool_name = Map.get(params, "name") || Map.get(params, "tool") + called_method = Map.get(arguments, "method") + called_path = Map.get(arguments, "path") + + tool_name == "jira_rest" and is_binary(called_method) and + String.upcase(String.trim(called_method)) == method and + is_binary(called_path) and String.trim(called_path) == path + end + + defp wait_for_state_issue!(issue_id, state, attempts \\ 20) + + defp wait_for_state_issue!(issue_id, state, attempts) when attempts > 0 do + case Tracker.fetch_issues_by_states([state]) do + {:ok, issues} -> + case Enum.find(issues, &(&1.id == issue_id)) do + %Issue{} = issue -> + issue + + nil -> + Process.sleep(500) + wait_for_state_issue!(issue_id, state, attempts - 1) + end + + {:error, reason} -> + flunk("Jira state read failed: #{inspect(reason)}") + end + end + + defp wait_for_state_issue!(_issue_id, _state, 0) do + flunk("new Jira issue did not appear in the adapter state read") + end + + defp isolated_codex_home!(test_root) do + codex_home = Path.join(test_root, "codex-home") + auth_json_path = Path.join(codex_home, "auth.json") + + source_auth_json = + Path.join( + System.get_env("CODEX_HOME") || Path.join(System.user_home!(), ".codex"), + "auth.json" + ) + + unless File.regular?(source_auth_json) do + flunk("live Jira e2e requires Codex auth") + end + + File.mkdir_p!(codex_home) + File.cp!(source_auth_json, auth_json_path) + File.chmod!(auth_json_path, 0o600) + codex_home + end + + defp stop_agent_runtime_if_running(runtime_pid) when is_pid(runtime_pid) do + assert :ok = + Supervisor.terminate_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) + end + + defp stop_agent_runtime_if_running(_runtime_pid), do: :ok + + defp restart_agent_runtime_if_needed(runtime_pid) when is_pid(runtime_pid) do + if is_nil(Process.whereis(SymphonyElixir.AgentRuntimeSupervisor)) do + case Supervisor.restart_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + end + end + end + + defp restart_agent_runtime_if_needed(_runtime_pid), do: :ok + + defp required_env!(name) do + case System.get_env(name) do + value when is_binary(value) and value != "" -> value + _ -> flunk("live Jira e2e requires #{name}") + end + end + + defp shell_escape(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end +end diff --git a/elixir/test/symphony_elixir/live_e2e_test.exs b/elixir/test/symphony_elixir/live_e2e_test.exs new file mode 100644 index 0000000000..2e6fa794d9 --- /dev/null +++ b/elixir/test/symphony_elixir/live_e2e_test.exs @@ -0,0 +1,837 @@ +defmodule SymphonyElixir.LiveE2ETest do + use SymphonyElixir.TestSupport + + require Logger + alias SymphonyElixir.SSH + + @moduletag :live_e2e + @moduletag timeout: 300_000 + + @default_team_key "SYME2E" + @docker_worker_count 2 + @docker_support_dir Path.expand("../support/live_e2e_docker", __DIR__) + @docker_compose_file Path.join(@docker_support_dir, "docker-compose.yml") + @result_file "LIVE_E2E_RESULT.txt" + @live_e2e_skip_reason if(System.get_env("SYMPHONY_RUN_LIVE_E2E") != "1", + do: "set SYMPHONY_RUN_LIVE_E2E=1 to enable the real Linear/Codex end-to-end test" + ) + + @team_query """ + query SymphonyLiveE2ETeam($key: String!) { + teams(filter: {key: {eq: $key}}, first: 1) { + nodes { + id + key + name + states(first: 50) { + nodes { + id + name + type + } + } + } + } + } + """ + + @create_project_mutation """ + mutation SymphonyLiveE2ECreateProject($name: String!, $teamIds: [String!]!) { + projectCreate(input: {name: $name, teamIds: $teamIds}) { + success + project { + id + name + slugId + url + } + } + } + """ + + @create_issue_mutation """ + mutation SymphonyLiveE2ECreateIssue( + $teamId: String! + $projectId: String! + $title: String! + $description: String! + $stateId: String + ) { + issueCreate( + input: { + teamId: $teamId + projectId: $projectId + title: $title + description: $description + stateId: $stateId + } + ) { + success + issue { + id + identifier + title + description + url + state { + name + } + } + } + } + """ + + @project_statuses_query """ + query SymphonyLiveE2EProjectStatuses { + projectStatuses(first: 50) { + nodes { + id + name + type + } + } + } + """ + + @issue_details_query """ + query SymphonyLiveE2EIssueDetails($id: String!) { + issue(id: $id) { + id + identifier + state { + name + type + } + comments(first: 20) { + nodes { + body + } + } + } + } + """ + + @complete_project_mutation """ + mutation SymphonyLiveE2ECompleteProject($id: String!, $statusId: String!, $completedAt: DateTime!) { + projectUpdate(id: $id, input: {statusId: $statusId, completedAt: $completedAt}) { + success + } + } + """ + + @tag skip: @live_e2e_skip_reason + test "creates a real Linear project and issue with a local worker" do + run_live_issue_flow!(:local) + end + + @tag skip: @live_e2e_skip_reason + test "creates a real Linear project and issue with an ssh worker" do + run_live_issue_flow!(:ssh) + end + + defp fetch_team!(team_key) do + @team_query + |> graphql_data!(%{key: team_key}) + |> get_in(["teams", "nodes"]) + |> case do + [team | _] -> + team + + _ -> + flunk("expected Linear team #{inspect(team_key)} to exist") + end + end + + defp active_state!(%{"states" => %{"nodes" => states}}) when is_list(states) do + Enum.find(states, &(&1["type"] == "started")) || + Enum.find(states, &(&1["type"] == "unstarted")) || + Enum.find(states, &(&1["type"] not in ["completed", "canceled"])) || + flunk("expected team to expose at least one non-terminal workflow state") + end + + defp terminal_state_names(%{"states" => %{"nodes" => states}}) when is_list(states) do + states + |> Enum.filter(&(&1["type"] in ["completed", "canceled"])) + |> Enum.map(& &1["name"]) + |> case do + [] -> ["Done", "Canceled", "Cancelled"] + names -> names + end + end + + defp active_state_names(%{"states" => %{"nodes" => states}}) when is_list(states) do + states + |> Enum.reject(&(&1["type"] in ["completed", "canceled"])) + |> Enum.map(& &1["name"]) + |> case do + [] -> ["Todo", "In Progress", "In Review"] + names -> names + end + end + + defp completed_project_status! do + @project_statuses_query + |> graphql_data!(%{}) + |> get_in(["projectStatuses", "nodes"]) + |> case do + statuses when is_list(statuses) -> + Enum.find(statuses, &(&1["type"] == "completed")) || + flunk("expected workspace to expose a completed project status") + + payload -> + flunk("expected project statuses list, got: #{inspect(payload)}") + end + end + + defp create_project!(team_id, name) do + @create_project_mutation + |> graphql_data!(%{teamIds: [team_id], name: name}) + |> fetch_successful_entity!("projectCreate", "project") + end + + defp create_issue!(team_id, project_id, state_id, title) do + issue = + @create_issue_mutation + |> graphql_data!(%{ + teamId: team_id, + projectId: project_id, + title: title, + description: title, + stateId: state_id + }) + |> fetch_successful_entity!("issueCreate", "issue") + + %Issue{ + id: issue["id"], + identifier: issue["identifier"], + title: issue["title"], + description: issue["description"], + state: get_in(issue, ["state", "name"]), + url: issue["url"], + labels: [], + blocked_by: [] + } + end + + defp complete_project(project_id, completed_status_id) + when is_binary(project_id) and is_binary(completed_status_id) do + update_entity( + @complete_project_mutation, + %{ + id: project_id, + statusId: completed_status_id, + completedAt: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + }, + "projectUpdate", + "project" + ) + end + + defp fetch_issue_details!(issue_id) when is_binary(issue_id) do + @issue_details_query + |> graphql_data!(%{id: issue_id}) + |> get_in(["issue"]) + |> case do + %{} = issue -> issue + payload -> flunk("expected issue details payload, got: #{inspect(payload)}") + end + end + + defp issue_completed?(%{"state" => %{"type" => type}}), do: type in ["completed", "canceled"] + defp issue_completed?(_issue), do: false + + defp issue_has_comment?(%{"comments" => %{"nodes" => comments}}, expected_body) when is_list(comments) do + Enum.any?(comments, &(&1["body"] == expected_body)) + end + + defp issue_has_comment?(_issue, _expected_body), do: false + + defp update_entity(mutation, variables, mutation_name, entity_name) do + case Client.graphql(mutation, variables) do + {:ok, %{"data" => %{^mutation_name => %{"success" => true}}}} -> + :ok + + {:ok, %{"errors" => errors}} -> + Logger.warning("Live e2e finalization failed for #{entity_name}: #{inspect(errors)}") + :ok + + {:ok, payload} -> + Logger.warning("Live e2e finalization failed for #{entity_name}: #{inspect(payload)}") + :ok + + {:error, reason} -> + Logger.warning("Live e2e finalization failed for #{entity_name}: #{inspect(reason)}") + :ok + end + end + + defp graphql_data!(query, variables) when is_binary(query) and is_map(variables) do + case Client.graphql(query, variables) do + {:ok, %{"data" => data, "errors" => errors}} when is_map(data) and is_list(errors) -> + flunk("Linear GraphQL returned partial errors: #{inspect(errors)}") + + {:ok, %{"errors" => errors}} when is_list(errors) -> + flunk("Linear GraphQL failed: #{inspect(errors)}") + + {:ok, %{"data" => data}} when is_map(data) -> + data + + {:ok, payload} -> + flunk("Linear GraphQL returned unexpected payload: #{inspect(payload)}") + + {:error, reason} -> + flunk("Linear GraphQL request failed: #{inspect(reason)}") + end + end + + defp fetch_successful_entity!(data, mutation_name, entity_name) + when is_map(data) and is_binary(mutation_name) and is_binary(entity_name) do + case data do + %{^mutation_name => %{"success" => true, ^entity_name => %{} = entity}} -> + entity + + _ -> + flunk("expected successful #{mutation_name} response, got: #{inspect(data)}") + end + end + + defp live_prompt(project_slug) do + """ + You are running a real Symphony end-to-end test. + + The current working directory is the workspace root. + + Step 1: + Create a file named #{@result_file} in the current working directory by running exactly: + + ```sh + cat > #{@result_file} <<'EOF' + identifier={{ issue.identifier }} + project_slug=#{project_slug} + EOF + ``` + + Then verify it by running: + + ```sh + cat #{@result_file} + ``` + + The file content must be exactly: + identifier={{ issue.identifier }} + project_slug=#{project_slug} + + Step 2: + You must use the `linear_graphql` tool to query the current issue by `{{ issue.id }}` and read: + - existing comments + - team workflow states + + A turn that only creates the file is incomplete. Do not stop after Step 1. + + If the exact comment body below is not already present, post exactly one comment on the current issue with this exact body: + #{expected_comment("{{ issue.identifier }}", project_slug)} + + Use these exact GraphQL operations: + + ```graphql + query IssueContext($id: String!) { + issue(id: $id) { + comments(first: 20) { + nodes { + body + } + } + team { + states(first: 50) { + nodes { + id + name + type + } + } + } + } + } + ``` + + ```graphql + mutation AddComment($issueId: String!, $body: String!) { + commentCreate(input: {issueId: $issueId, body: $body}) { + success + } + } + ``` + + Step 3: + Use the same issue-context query result to choose a workflow state whose `type` is `completed`. + Then move the current issue to that state with this exact mutation: + + ```graphql + mutation CompleteIssue($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: {stateId: $stateId}) { + success + } + } + ``` + + Step 4: + Verify all outcomes with one final `linear_graphql` query against `{{ issue.id }}`: + - the exact comment body is present + - the issue state type is `completed` + + Do not ask for approval. + Stop only after all three conditions are true: + 1. the file exists with the exact contents above + 2. the Linear comment exists with the exact body above + 3. the Linear issue is in a completed terminal state + """ + end + + defp expected_result(issue_identifier, project_slug) do + "identifier=#{issue_identifier}\nproject_slug=#{project_slug}\n" + end + + defp expected_comment(issue_identifier, project_slug) do + "Symphony live e2e comment\nidentifier=#{issue_identifier}\nproject_slug=#{project_slug}" + end + + defp receive_runtime_info!(issue_id) do + receive do + {:worker_runtime_info, ^issue_id, %{workspace_path: workspace_path} = runtime_info} + when is_binary(workspace_path) -> + runtime_info + + {:codex_worker_update, ^issue_id, _message} -> + receive_runtime_info!(issue_id) + after + 5_000 -> + flunk("timed out waiting for worker runtime info for #{inspect(issue_id)}") + end + end + + defp read_worker_result!(%{worker_host: nil, workspace_path: workspace_path}, result_file) + when is_binary(workspace_path) and is_binary(result_file) do + File.read!(Path.join(workspace_path, result_file)) + end + + defp read_worker_result!(%{worker_host: worker_host, workspace_path: workspace_path}, result_file) + when is_binary(worker_host) and is_binary(workspace_path) and is_binary(result_file) do + remote_result_path = Path.join(workspace_path, result_file) + + case SSH.run(worker_host, "cat #{shell_escape(remote_result_path)}", stderr_to_stdout: true) do + {:ok, {output, 0}} -> + output + + {:ok, {output, status}} -> + flunk("failed to read remote result from #{worker_host}:#{remote_result_path} (status #{status}): #{inspect(output)}") + + {:error, reason} -> + flunk("failed to read remote result from #{worker_host}:#{remote_result_path}: #{inspect(reason)}") + end + end + + defp shell_escape(value) when is_binary(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end + + defp run_live_issue_flow!(backend) when backend in [:local, :ssh] do + run_id = "symphony-live-e2e-#{backend}-#{System.unique_integer([:positive])}" + test_root = Path.join(System.tmp_dir!(), run_id) + workflow_root = Path.join(test_root, "workflow") + workflow_file = Path.join(workflow_root, "WORKFLOW.md") + worker_setup = live_worker_setup!(backend, run_id, test_root) + team_key = System.get_env("SYMPHONY_LIVE_LINEAR_TEAM_KEY") || @default_team_key + original_workflow_path = Workflow.workflow_file_path() + runtime_pid = Process.whereis(SymphonyElixir.AgentRuntimeSupervisor) + + File.mkdir_p!(workflow_root) + + try do + if is_pid(runtime_pid) do + assert :ok = + Supervisor.terminate_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) + end + + Workflow.set_workflow_file_path(workflow_file) + + write_workflow_file!(workflow_file, + tracker_api_token: "$LINEAR_API_KEY", + tracker_project_slug: "bootstrap", + workspace_root: worker_setup.workspace_root, + worker_ssh_hosts: worker_setup.ssh_worker_hosts, + codex_command: worker_setup.codex_command, + codex_approval_policy: "never", + observability_enabled: false + ) + + team = fetch_team!(team_key) + active_state = active_state!(team) + completed_project_status = completed_project_status!() + terminal_states = terminal_state_names(team) + + project = + create_project!( + team["id"], + "Symphony Live E2E #{backend} #{System.unique_integer([:positive])}" + ) + + try do + issue = + create_issue!( + team["id"], + project["id"], + active_state["id"], + "Symphony live e2e #{backend} issue for #{project["name"]}" + ) + + write_workflow_file!(workflow_file, + tracker_api_token: "$LINEAR_API_KEY", + tracker_project_slug: project["slugId"], + tracker_active_states: active_state_names(team), + tracker_terminal_states: terminal_states, + workspace_root: worker_setup.workspace_root, + worker_ssh_hosts: worker_setup.ssh_worker_hosts, + codex_command: worker_setup.codex_command, + codex_approval_policy: "never", + codex_turn_sandbox_policy: Map.get(worker_setup, :codex_turn_sandbox_policy), + codex_read_timeout_ms: 60_000, + codex_turn_timeout_ms: 600_000, + codex_stall_timeout_ms: 600_000, + observability_enabled: false, + prompt: live_prompt(project["slugId"]) + ) + + assert :ok = AgentRunner.run(issue, self(), max_turns: 3) + + runtime_info = receive_runtime_info!(issue.id) + + assert read_worker_result!(runtime_info, @result_file) == + expected_result(issue.identifier, project["slugId"]) + + issue_snapshot = fetch_issue_details!(issue.id) + assert issue_completed?(issue_snapshot) + assert issue_has_comment?(issue_snapshot, expected_comment(issue.identifier, project["slugId"])) + after + assert :ok = complete_project(project["id"], completed_project_status["id"]) + end + after + restart_agent_runtime_if_needed() + cleanup_live_worker_setup(worker_setup) + Workflow.set_workflow_file_path(original_workflow_path) + File.rm_rf(test_root) + end + end + + defp live_worker_setup!(:local, _run_id, test_root) when is_binary(test_root) do + codex_home = isolated_codex_home!(test_root) + + %{ + cleanup: fn -> :ok end, + codex_command: "env CODEX_HOME=#{shell_escape(codex_home)} codex app-server", + ssh_worker_hosts: [], + workspace_root: Path.join(test_root, "workspaces") + } + end + + defp live_worker_setup!(:ssh, run_id, test_root) when is_binary(run_id) and is_binary(test_root) do + case live_ssh_worker_hosts() do + [] -> + live_docker_worker_setup!(run_id, test_root) + + _hosts -> + live_ssh_worker_setup!(run_id) + end + end + + defp isolated_codex_home!(test_root) when is_binary(test_root) do + codex_home = Path.join(test_root, "codex-home") + auth_json_path = Path.join(codex_home, "auth.json") + + File.mkdir_p!(codex_home) + File.cp!(source_codex_auth_json!(), auth_json_path) + File.chmod!(auth_json_path, 0o600) + + codex_home + end + + defp source_codex_auth_json! do + codex_home = System.get_env("CODEX_HOME") || Path.join(System.user_home!(), ".codex") + auth_json_path = Path.join(codex_home, "auth.json") + + if File.regular?(auth_json_path) do + auth_json_path + else + flunk("live e2e requires Codex auth at #{auth_json_path}") + end + end + + defp cleanup_live_worker_setup(%{cleanup: cleanup}) when is_function(cleanup, 0) do + cleanup.() + end + + defp cleanup_live_worker_setup(_worker_setup), do: :ok + + defp restart_agent_runtime_if_needed do + if is_nil(Process.whereis(SymphonyElixir.AgentRuntimeSupervisor)) do + case Supervisor.restart_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + end + end + end + + defp live_ssh_worker_setup!(run_id) when is_binary(run_id) do + ssh_worker_hosts = live_ssh_worker_hosts() + remote_test_root = Path.join(shared_remote_home!(ssh_worker_hosts), ".#{run_id}") + remote_workspace_root = "~/.#{run_id}/workspaces" + + %{ + cleanup: fn -> cleanup_remote_test_root(remote_test_root, ssh_worker_hosts) end, + codex_command: "codex app-server", + ssh_worker_hosts: ssh_worker_hosts, + workspace_root: remote_workspace_root + } + end + + defp live_docker_worker_setup!(run_id, test_root) when is_binary(run_id) and is_binary(test_root) do + ssh_root = Path.join(test_root, "live-docker-ssh") + key_path = Path.join(ssh_root, "id_ed25519") + config_path = Path.join(ssh_root, "config") + auth_json_path = source_codex_auth_json!() + worker_ports = reserve_tcp_ports(@docker_worker_count) + worker_hosts = Enum.map(worker_ports, &"localhost:#{&1}") + project_name = docker_project_name(run_id) + previous_ssh_config = System.get_env("SYMPHONY_SSH_CONFIG") + + base_cleanup = fn -> + restore_env("SYMPHONY_SSH_CONFIG", previous_ssh_config) + docker_compose_down(project_name, docker_compose_env(worker_ports, auth_json_path, key_path <> ".pub")) + end + + result = + try do + File.mkdir_p!(ssh_root) + generate_ssh_keypair!(key_path) + write_docker_ssh_config!(config_path, key_path) + System.put_env("SYMPHONY_SSH_CONFIG", config_path) + + docker_compose_up!(project_name, docker_compose_env(worker_ports, auth_json_path, key_path <> ".pub")) + wait_for_ssh_hosts!(worker_hosts) + remote_test_root = Path.join(shared_remote_home!(worker_hosts), ".#{run_id}") + remote_workspace_root = "~/.#{run_id}/workspaces" + + %{ + cleanup: fn -> + cleanup_remote_test_root(remote_test_root, worker_hosts) + base_cleanup.() + end, + codex_command: "codex app-server", + codex_turn_sandbox_policy: %{type: "dangerFullAccess"}, + ssh_worker_hosts: worker_hosts, + workspace_root: remote_workspace_root + } + rescue + error -> + {:error, error, __STACKTRACE__} + catch + kind, reason -> + {:caught, kind, reason, __STACKTRACE__} + end + + case result do + %{ssh_worker_hosts: _hosts} = worker_setup -> + worker_setup + + {:error, error, stacktrace} -> + base_cleanup.() + reraise(error, stacktrace) + + {:caught, kind, reason, stacktrace} -> + base_cleanup.() + :erlang.raise(kind, reason, stacktrace) + end + end + + defp live_ssh_worker_hosts do + System.get_env("SYMPHONY_LIVE_SSH_WORKER_HOSTS", "") + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp cleanup_remote_test_root(test_root, ssh_worker_hosts) + when is_binary(test_root) and is_list(ssh_worker_hosts) do + Enum.each(ssh_worker_hosts, fn worker_host -> + _ = SSH.run(worker_host, "rm -rf #{shell_escape(test_root)}", stderr_to_stdout: true) + end) + end + + defp shared_remote_home!([first_host | rest] = worker_hosts) when is_binary(first_host) and rest != [] do + homes = + worker_hosts + |> Enum.map(fn worker_host -> {worker_host, remote_home!(worker_host)} end) + + [{_host, home} | _remaining] = homes + + if Enum.all?(homes, fn {_host, other_home} -> other_home == home end) do + home + else + flunk("expected all live SSH workers to share one home directory, got: #{inspect(homes)}") + end + end + + defp shared_remote_home!([worker_host]) when is_binary(worker_host), do: remote_home!(worker_host) + defp shared_remote_home!(_worker_hosts), do: flunk("expected at least one live SSH worker host") + + defp remote_home!(worker_host) when is_binary(worker_host) do + case SSH.run(worker_host, "printf '%s\\n' \"$HOME\"", stderr_to_stdout: true) do + {:ok, {output, 0}} -> + output + |> String.trim() + |> case do + "" -> flunk("expected non-empty remote home for #{worker_host}") + home -> home + end + + {:ok, {output, status}} -> + flunk("failed to resolve remote home for #{worker_host} (status #{status}): #{inspect(output)}") + + {:error, reason} -> + flunk("failed to resolve remote home for #{worker_host}: #{inspect(reason)}") + end + end + + defp reserve_tcp_ports(count) when is_integer(count) and count > 0 do + reserve_tcp_ports(count, MapSet.new(), []) + end + + defp reserve_tcp_ports(0, _seen, ports), do: Enum.reverse(ports) + + defp reserve_tcp_ports(remaining, seen, ports) do + port = reserve_tcp_port!() + + if MapSet.member?(seen, port) do + reserve_tcp_ports(remaining, seen, ports) + else + reserve_tcp_ports(remaining - 1, MapSet.put(seen, port), [port | ports]) + end + end + + defp reserve_tcp_port! do + {:ok, socket} = :gen_tcp.listen(0, [:binary, {:active, false}, {:reuseaddr, true}]) + {:ok, port} = :inet.port(socket) + :ok = :gen_tcp.close(socket) + port + end + + defp generate_ssh_keypair!(key_path) when is_binary(key_path) do + case System.find_executable("ssh-keygen") do + nil -> + flunk("docker worker mode requires `ssh-keygen` on PATH") + + executable -> + key_dir = Path.dirname(key_path) + File.mkdir_p!(key_dir) + File.rm_rf(key_path) + File.rm_rf(key_path <> ".pub") + + case System.cmd(executable, ["-q", "-t", "ed25519", "-N", "", "-f", key_path], stderr_to_stdout: true) do + {_output, 0} -> :ok + {output, status} -> flunk("failed to generate live docker ssh key (status #{status}): #{inspect(output)}") + end + end + end + + defp write_docker_ssh_config!(config_path, key_path) + when is_binary(config_path) and is_binary(key_path) do + config_contents = """ + Host localhost 127.0.0.1 + User root + IdentityFile #{key_path} + IdentitiesOnly yes + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + """ + + File.mkdir_p!(Path.dirname(config_path)) + File.write!(config_path, config_contents) + end + + defp docker_project_name(run_id) when is_binary(run_id) do + run_id + |> String.downcase() + |> String.replace(~r/[^a-z0-9_-]/, "-") + end + + defp docker_compose_env(worker_ports, auth_json_path, authorized_key_path) + when is_list(worker_ports) and is_binary(auth_json_path) and is_binary(authorized_key_path) do + [ + {"SYMPHONY_LIVE_DOCKER_AUTH_JSON", auth_json_path}, + {"SYMPHONY_LIVE_DOCKER_AUTHORIZED_KEY", authorized_key_path}, + {"SYMPHONY_LIVE_DOCKER_WORKER_1_PORT", Integer.to_string(Enum.at(worker_ports, 0))}, + {"SYMPHONY_LIVE_DOCKER_WORKER_2_PORT", Integer.to_string(Enum.at(worker_ports, 1))} + ] + end + + defp docker_compose_up!(project_name, env) when is_binary(project_name) and is_list(env) do + args = ["compose", "-f", @docker_compose_file, "-p", project_name, "up", "-d", "--build"] + + case System.cmd("docker", args, cd: @docker_support_dir, env: env, stderr_to_stdout: true) do + {_output, 0} -> + :ok + + {output, status} -> + flunk("failed to start live docker workers (status #{status}): #{inspect(output)}") + end + end + + defp docker_compose_down(project_name, env) when is_binary(project_name) and is_list(env) do + _ = + System.cmd( + "docker", + ["compose", "-f", @docker_compose_file, "-p", project_name, "down", "-v", "--remove-orphans"], + cd: @docker_support_dir, + env: env, + stderr_to_stdout: true + ) + + :ok + end + + defp wait_for_ssh_hosts!(worker_hosts) when is_list(worker_hosts) do + deadline = System.monotonic_time(:millisecond) + 60_000 + + Enum.each(worker_hosts, fn worker_host -> + wait_for_ssh_host!(worker_host, deadline) + end) + end + + defp wait_for_ssh_host!(worker_host, deadline_ms) when is_binary(worker_host) do + case SSH.run(worker_host, "printf ready", stderr_to_stdout: true) do + {:ok, {"ready", 0}} -> + :ok + + {:ok, {_output, _status}} -> + retry_or_flunk_ssh_host(worker_host, deadline_ms) + + {:error, _reason} -> + retry_or_flunk_ssh_host(worker_host, deadline_ms) + end + end + + defp retry_or_flunk_ssh_host(worker_host, deadline_ms) do + if System.monotonic_time(:millisecond) < deadline_ms do + Process.sleep(1_000) + wait_for_ssh_host!(worker_host, deadline_ms) + else + flunk("timed out waiting for SSH worker #{worker_host} to accept connections") + end + end +end diff --git a/elixir/test/symphony_elixir/orchestrator_status_test.exs b/elixir/test/symphony_elixir/orchestrator_status_test.exs index 14c3e1bb28..4f0da20bfa 100644 --- a/elixir/test/symphony_elixir/orchestrator_status_test.exs +++ b/elixir/test/symphony_elixir/orchestrator_status_test.exs @@ -90,6 +90,7 @@ defmodule SymphonyElixir.OrchestratorStatusTest do snapshot = GenServer.call(pid, :snapshot) assert %{running: [snapshot_entry]} = snapshot assert snapshot_entry.issue_id == issue_id + assert snapshot_entry.issue_url == "https://example.org/issues/MT-188" assert snapshot_entry.session_id == "thread-live-turn-live" assert snapshot_entry.turn_count == 1 assert snapshot_entry.last_codex_timestamp == now @@ -728,6 +729,7 @@ defmodule SymphonyElixir.OrchestratorStatusTest do timer_ref: nil, due_at_ms: System.monotonic_time(:millisecond) + 5_000, identifier: "MT-500", + issue_url: "https://example.org/issues/MT-500", error: "agent exited: :boom" } @@ -744,6 +746,7 @@ defmodule SymphonyElixir.OrchestratorStatusTest do attempt: 2, due_in_ms: due_in_ms, identifier: "MT-500", + issue_url: "https://example.org/issues/MT-500", error: "agent exited: :boom" } ] = snapshot.retrying @@ -767,6 +770,8 @@ defmodule SymphonyElixir.OrchestratorStatusTest do %{ state | poll_interval_ms: 30_000, + tick_timer_ref: nil, + tick_token: make_ref(), next_poll_due_at_ms: now_ms + 4_000, poll_check_in_progress: false } @@ -796,7 +801,7 @@ defmodule SymphonyElixir.OrchestratorStatusTest do test "orchestrator triggers an immediate poll cycle shortly after startup" do write_workflow_file!(Workflow.workflow_file_path(), - tracker_api_token: nil, + tracker_kind: "memory", poll_interval_ms: 5_000 ) @@ -848,7 +853,7 @@ defmodule SymphonyElixir.OrchestratorStatusTest do test "orchestrator poll cycle resets next refresh countdown after a check" do write_workflow_file!(Workflow.workflow_file_path(), - tracker_api_token: nil, + tracker_kind: "memory", poll_interval_ms: 50 ) @@ -897,7 +902,7 @@ defmodule SymphonyElixir.OrchestratorStatusTest do test "orchestrator restarts stalled workers with retry backoff" do write_workflow_file!(Workflow.workflow_file_path(), - tracker_api_token: nil, + tracker_kind: "memory", codex_stall_timeout_ms: 1_000 ) @@ -925,7 +930,12 @@ defmodule SymphonyElixir.OrchestratorStatusTest do pid: worker_pid, ref: make_ref(), identifier: "MT-STALL", - issue: %Issue{id: issue_id, identifier: "MT-STALL", state: "In Progress"}, + issue: %Issue{ + id: issue_id, + identifier: "MT-STALL", + state: "In Progress", + url: "https://example.org/issues/MT-STALL" + }, session_id: "thread-stall-turn-stall", last_codex_message: nil, last_codex_timestamp: stale_activity_at, @@ -933,6 +943,8 @@ defmodule SymphonyElixir.OrchestratorStatusTest do started_at: stale_activity_at } + Application.put_env(:symphony_elixir, :memory_tracker_issues, [running_entry.issue]) + :sys.replace_state(pid, fn _ -> initial_state |> Map.put(:running, %{issue_id => running_entry}) @@ -950,6 +962,7 @@ defmodule SymphonyElixir.OrchestratorStatusTest do attempt: 1, due_at_ms: due_at_ms, identifier: "MT-STALL", + issue_url: "https://example.org/issues/MT-STALL", error: "stalled for " <> _ } = state.retry_attempts[issue_id] @@ -959,6 +972,204 @@ defmodule SymphonyElixir.OrchestratorStatusTest do assert remaining_ms <= 10_500 end + test "orchestrator blocks stalled workers that are waiting on MCP elicitation" do + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "memory", + codex_stall_timeout_ms: 1_000 + ) + + issue_id = "issue-mcp-elicitation-stall" + orchestrator_name = Module.concat(__MODULE__, :McpElicitationBlockOrchestrator) + {:ok, pid} = Orchestrator.start_link(name: orchestrator_name) + + on_exit(fn -> + if Process.alive?(pid) do + Process.exit(pid, :normal) + end + end) + + worker_pid = + spawn(fn -> + receive do + :done -> :ok + end + end) + + stale_activity_at = DateTime.add(DateTime.utc_now(), -5, :second) + initial_state = :sys.get_state(pid) + + running_entry = %{ + pid: worker_pid, + ref: make_ref(), + identifier: "MT-MCP", + issue: %Issue{ + id: issue_id, + identifier: "MT-MCP", + state: "In Progress", + url: "https://example.org/issues/MT-MCP", + dispatchable: true + }, + worker_host: "dm-dev2", + workspace_path: "/workspaces/MT-MCP", + session_id: "thread-mcp-turn-mcp", + last_codex_message: %{ + event: :notification, + message: %{"method" => "mcpServer/elicitation/request"}, + timestamp: stale_activity_at + }, + last_codex_timestamp: stale_activity_at, + last_codex_event: :notification, + started_at: stale_activity_at + } + + Application.put_env(:symphony_elixir, :memory_tracker_issues, [running_entry.issue]) + + :sys.replace_state(pid, fn _ -> + initial_state + |> Map.put(:running, %{issue_id => running_entry}) + |> Map.put(:claimed, MapSet.put(initial_state.claimed, issue_id)) + end) + + send(pid, :tick) + Process.sleep(100) + state = :sys.get_state(pid) + + refute Process.alive?(worker_pid) + refute Map.has_key?(state.running, issue_id) + refute Map.has_key?(state.retry_attempts, issue_id) + assert MapSet.member?(state.claimed, issue_id) + + assert %{ + identifier: "MT-MCP", + error: "codex MCP elicitation requires operator input", + worker_host: "dm-dev2", + workspace_path: "/workspaces/MT-MCP" + } = state.blocked[issue_id] + + assert %{ + blocked: [ + %{ + identifier: "MT-MCP", + issue_url: "https://example.org/issues/MT-MCP", + error: "codex MCP elicitation requires operator input" + } + ] + } = + Orchestrator.snapshot(orchestrator_name, 1_000) + end + + test "orchestrator blocks failed workers after app-server reports input required" do + write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") + + issue_id = "issue-input-required" + orchestrator_name = Module.concat(__MODULE__, :InputRequiredBlockOrchestrator) + {:ok, pid} = Orchestrator.start_link(name: orchestrator_name) + + on_exit(fn -> + if Process.alive?(pid) do + Process.exit(pid, :normal) + end + end) + + ref = make_ref() + started_at = DateTime.utc_now() + initial_state = :sys.get_state(pid) + + running_entry = %{ + pid: self(), + ref: ref, + identifier: "MT-INPUT", + issue: %Issue{id: issue_id, identifier: "MT-INPUT", state: "In Progress", dispatchable: true}, + session_id: "thread-input-turn-input", + last_codex_message: %{ + event: :turn_input_required, + message: %{"method" => "mcpServer/elicitation/request"}, + timestamp: started_at + }, + last_codex_timestamp: started_at, + last_codex_event: :turn_input_required, + started_at: started_at + } + + Application.put_env(:symphony_elixir, :memory_tracker_issues, [running_entry.issue]) + + :sys.replace_state(pid, fn _ -> + initial_state + |> Map.put(:running, %{issue_id => running_entry}) + |> Map.put(:claimed, MapSet.put(initial_state.claimed, issue_id)) + end) + + send(pid, {:DOWN, ref, :process, self(), {:shutdown, :input_required}}) + Process.sleep(50) + state = :sys.get_state(pid) + + refute Map.has_key?(state.running, issue_id) + refute Map.has_key?(state.retry_attempts, issue_id) + assert MapSet.member?(state.claimed, issue_id) + + assert %{ + identifier: "MT-INPUT", + error: "codex turn requires operator input" + } = state.blocked[issue_id] + end + + test "orchestrator blocks normal worker exits after input required completion" do + write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") + + issue_id = "issue-input-required-normal" + orchestrator_name = Module.concat(__MODULE__, :InputRequiredNormalBlockOrchestrator) + {:ok, pid} = Orchestrator.start_link(name: orchestrator_name) + + on_exit(fn -> + if Process.alive?(pid) do + Process.exit(pid, :normal) + end + end) + + ref = make_ref() + initial_state = :sys.get_state(pid) + + running_entry = %{ + pid: self(), + ref: ref, + identifier: "MT-INPUT-NORMAL", + issue: %Issue{ + id: issue_id, + identifier: "MT-INPUT-NORMAL", + state: "In Progress", + dispatchable: true + }, + session_id: "thread-input-normal", + completion: %{outcome: :input_required}, + last_codex_message: nil, + last_codex_timestamp: nil, + last_codex_event: nil, + started_at: DateTime.utc_now() + } + + Application.put_env(:symphony_elixir, :memory_tracker_issues, [running_entry.issue]) + + :sys.replace_state(pid, fn _ -> + initial_state + |> Map.put(:running, %{issue_id => running_entry}) + |> Map.put(:claimed, MapSet.put(initial_state.claimed, issue_id)) + end) + + send(pid, {:DOWN, ref, :process, self(), :normal}) + Process.sleep(50) + state = :sys.get_state(pid) + + refute Map.has_key?(state.running, issue_id) + refute Map.has_key?(state.retry_attempts, issue_id) + refute MapSet.member?(state.completed, issue_id) + assert MapSet.member?(state.claimed, issue_id) + + assert %{ + identifier: "MT-INPUT-NORMAL", + error: "codex turn requires operator input" + } = state.blocked[issue_id] + end + test "status dashboard renders offline marker to terminal" do rendered = ExUnit.CaptureIO.capture_io(fn -> @@ -1125,19 +1336,26 @@ defmodule SymphonyElixir.OrchestratorStatusTest do test "status dashboard coalesces rapid updates to one render per interval" do dashboard_name = Module.concat(__MODULE__, :RenderDashboard) parent = self() - orchestrator_pid = Process.whereis(SymphonyElixir.Orchestrator) + runtime_pid = Process.whereis(SymphonyElixir.AgentRuntimeSupervisor) on_exit(fn -> - if is_nil(Process.whereis(SymphonyElixir.Orchestrator)) do - case Supervisor.restart_child(SymphonyElixir.Supervisor, SymphonyElixir.Orchestrator) do + if is_nil(Process.whereis(SymphonyElixir.AgentRuntimeSupervisor)) do + case Supervisor.restart_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) 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) + if is_pid(runtime_pid) do + assert :ok = + Supervisor.terminate_child( + SymphonyElixir.Supervisor, + SymphonyElixir.AgentRuntimeSupervisor + ) end {:ok, pid} = @@ -1481,23 +1699,6 @@ defmodule SymphonyElixir.OrchestratorStatusTest do assert humanized =~ "auto-approved" end - test "status dashboard formats auto-answered tool input updates from codex" do - message = %{ - event: :tool_input_auto_answered, - message: %{ - payload: %{ - "method" => "item/tool/requestUserInput", - "params" => %{"question" => "Continue?"} - }, - answer: "This is a non-interactive session. Operator input is unavailable." - } - } - - humanized = StatusDashboard.humanize_codex_message(message) - assert humanized =~ "tool requires user input" - assert humanized =~ "auto-answered" - end - test "status dashboard enriches wrapper reasoning and message streaming events with payload context" do reasoning_message = %{ event: :notification, diff --git a/elixir/test/symphony_elixir/ssh_test.exs b/elixir/test/symphony_elixir/ssh_test.exs new file mode 100644 index 0000000000..9edc94f3af --- /dev/null +++ b/elixir/test/symphony_elixir/ssh_test.exs @@ -0,0 +1,199 @@ +defmodule SymphonyElixir.SSHTest do + use ExUnit.Case, async: false + + alias SymphonyElixir.SSH + + test "run/3 keeps bracketed IPv6 host:port targets intact" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-ipv6-test-#{System.unique_integer([:positive])}") + trace_file = Path.join(test_root, "ssh.trace") + previous_path = System.get_env("PATH") + + on_exit(fn -> + restore_env("PATH", previous_path) + File.rm_rf(test_root) + end) + + install_fake_ssh!(test_root, trace_file) + + assert {:ok, {"", 0}} = + SSH.run("root@[::1]:2200", "printf ok", stderr_to_stdout: true) + + trace = File.read!(trace_file) + assert trace =~ "-T -p 2200 root@[::1] bash -lc" + assert trace =~ "printf ok" + end + + test "run/3 leaves unbracketed IPv6-style targets unchanged" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-ipv6-raw-test-#{System.unique_integer([:positive])}") + trace_file = Path.join(test_root, "ssh.trace") + previous_path = System.get_env("PATH") + + on_exit(fn -> + restore_env("PATH", previous_path) + File.rm_rf(test_root) + end) + + install_fake_ssh!(test_root, trace_file) + + assert {:ok, {"", 0}} = + SSH.run("::1:2200", "printf ok", stderr_to_stdout: true) + + trace = File.read!(trace_file) + assert trace =~ "-T ::1:2200 bash -lc" + refute trace =~ "-p 2200" + end + + test "run/3 passes host:port targets through ssh -p" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-test-#{System.unique_integer([:positive])}") + trace_file = Path.join(test_root, "ssh.trace") + previous_path = System.get_env("PATH") + previous_ssh_config = System.get_env("SYMPHONY_SSH_CONFIG") + + on_exit(fn -> + restore_env("PATH", previous_path) + restore_env("SYMPHONY_SSH_CONFIG", previous_ssh_config) + File.rm_rf(test_root) + end) + + install_fake_ssh!(test_root, trace_file) + System.put_env("SYMPHONY_SSH_CONFIG", "/tmp/symphony-test-ssh-config") + + assert {:ok, {"", 0}} = + SSH.run("localhost:2222", "echo ready", stderr_to_stdout: true) + + trace = File.read!(trace_file) + assert trace =~ "-F /tmp/symphony-test-ssh-config" + assert trace =~ "-T -p 2222 localhost bash -lc" + assert trace =~ "echo ready" + end + + test "run/3 keeps the user prefix when parsing user@host:port targets" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-user-test-#{System.unique_integer([:positive])}") + trace_file = Path.join(test_root, "ssh.trace") + previous_path = System.get_env("PATH") + + on_exit(fn -> + restore_env("PATH", previous_path) + File.rm_rf(test_root) + end) + + install_fake_ssh!(test_root, trace_file) + + assert {:ok, {"", 0}} = + SSH.run("root@127.0.0.1:2200", "printf ok", stderr_to_stdout: true) + + trace = File.read!(trace_file) + assert trace =~ "-T -p 2200 root@127.0.0.1 bash -lc" + assert trace =~ "printf ok" + end + + test "run/3 returns an error when ssh is unavailable" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-missing-test-#{System.unique_integer([:positive])}") + previous_path = System.get_env("PATH") + + on_exit(fn -> + restore_env("PATH", previous_path) + File.rm_rf(test_root) + end) + + File.mkdir_p!(test_root) + System.put_env("PATH", test_root) + + assert {:error, :ssh_not_found} = SSH.run("localhost", "printf ok") + end + + test "start_port/3 supports binary output without line mode" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-port-test-#{System.unique_integer([:positive])}") + trace_file = Path.join(test_root, "ssh.trace") + previous_path = System.get_env("PATH") + previous_ssh_config = System.get_env("SYMPHONY_SSH_CONFIG") + + on_exit(fn -> + restore_env("PATH", previous_path) + restore_env("SYMPHONY_SSH_CONFIG", previous_ssh_config) + File.rm_rf(test_root) + end) + + install_fake_ssh!(test_root, trace_file, """ + #!/bin/sh + printf 'ARGV:%s\\n' "$*" >> "#{trace_file}" + printf 'ready\\n' + exit 0 + """) + + System.delete_env("SYMPHONY_SSH_CONFIG") + + assert {:ok, port} = SSH.start_port("localhost", "printf ok") + assert is_port(port) + wait_for_trace!(trace_file) + + trace = File.read!(trace_file) + assert trace =~ "-T localhost bash -lc" + refute trace =~ " -F " + end + + test "start_port/3 supports line mode" do + test_root = Path.join(System.tmp_dir!(), "symphony-ssh-line-port-test-#{System.unique_integer([:positive])}") + trace_file = Path.join(test_root, "ssh.trace") + previous_path = System.get_env("PATH") + + on_exit(fn -> + restore_env("PATH", previous_path) + File.rm_rf(test_root) + end) + + install_fake_ssh!(test_root, trace_file, """ + #!/bin/sh + printf 'ARGV:%s\\n' "$*" >> "#{trace_file}" + printf 'ready\\n' + exit 0 + """) + + assert {:ok, port} = SSH.start_port("localhost:2222", "printf ok", line: 256) + assert is_port(port) + wait_for_trace!(trace_file) + + trace = File.read!(trace_file) + assert trace =~ "-T -p 2222 localhost bash -lc" + end + + test "remote_shell_command/1 escapes embedded single quotes" do + assert SSH.remote_shell_command("printf 'hello'") == + "bash -lc 'printf '\"'\"'hello'\"'\"''" + end + + defp install_fake_ssh!(test_root, trace_file, script \\ nil) do + fake_bin_dir = Path.join(test_root, "bin") + fake_ssh = Path.join(fake_bin_dir, "ssh") + + File.mkdir_p!(fake_bin_dir) + + File.write!( + fake_ssh, + script || + """ + #!/bin/sh + printf 'ARGV:%s\\n' "$*" >> "#{trace_file}" + exit 0 + """ + ) + + File.chmod!(fake_ssh, 0o755) + System.put_env("PATH", fake_bin_dir <> ":" <> (System.get_env("PATH") || "")) + end + + defp wait_for_trace!(trace_file, attempts \\ 20) + defp wait_for_trace!(trace_file, 0), do: flunk("timed out waiting for fake ssh trace at #{trace_file}") + + defp wait_for_trace!(trace_file, attempts) do + if File.exists?(trace_file) and File.read!(trace_file) != "" do + :ok + else + Process.sleep(25) + wait_for_trace!(trace_file, attempts - 1) + end + end + + defp restore_env(key, nil), do: System.delete_env(key) + defp restore_env(key, value), do: System.put_env(key, value) +end diff --git a/elixir/test/symphony_elixir/workspace_and_config_test.exs b/elixir/test/symphony_elixir/workspace_and_config_test.exs index 10f9f524a5..15f7c368e2 100644 --- a/elixir/test/symphony_elixir/workspace_and_config_test.exs +++ b/elixir/test/symphony_elixir/workspace_and_config_test.exs @@ -1,5 +1,8 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do use SymphonyElixir.TestSupport + alias Ecto.Changeset + alias SymphonyElixir.Config.Schema + alias SymphonyElixir.Config.Schema.{Codex, StringOrMap} alias SymphonyElixir.Linear.Client test "workspace bootstrap can be implemented in after_create hook" do @@ -50,7 +53,60 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do assert {:ok, second_workspace} = Workspace.create_for_issue("MT/Det") assert first_workspace == second_workspace - assert Path.basename(first_workspace) == "MT_Det" + assert Path.basename(first_workspace) == Workspace.workspace_key("MT/Det") + assert String.starts_with?(Path.basename(first_workspace), "MT_Det--") + end + + test "relative local workspace roots resolve from the workflow directory" do + workflow_dir = Path.dirname(Workflow.workflow_file_path()) + launcher_dir = Path.join(System.tmp_dir!(), "symphony-elixir-launcher-#{System.unique_integer([:positive])}") + original_cwd = File.cwd!() + + try do + File.mkdir_p!(launcher_dir) + write_workflow_file!(Workflow.workflow_file_path(), workspace_root: "relative-workspaces") + File.cd!(launcher_dir) + + assert {:ok, expected_workspace} = + SymphonyElixir.PathSafety.canonicalize(Path.join([workflow_dir, "relative-workspaces", "MT-REL"])) + + assert {:ok, workspace} = Workspace.create_for_issue("MT-REL") + + assert workspace == expected_workspace + refute String.starts_with?(workspace, launcher_dir <> "/") + after + File.cd!(original_cwd) + File.rm_rf(launcher_dir) + end + end + + test "workspace keys disambiguate identifiers that sanitize to the same path" do + workspace_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-workspace-collision-#{System.unique_integer([:positive])}" + ) + + try do + write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) + + slash_issue = %Issue{id: "dispatch-slash", identifier: "team/a-1"} + underscore_issue = %Issue{id: "dispatch-underscore", identifier: "team_a-1"} + + assert {:ok, slash_workspace} = Workspace.create_for_issue(slash_issue) + assert {:ok, ^slash_workspace} = Workspace.create_for_issue("team/a-1") + assert {:ok, underscore_workspace} = Workspace.create_for_issue(underscore_issue) + + refute slash_workspace == underscore_workspace + assert Path.basename(underscore_workspace) == "team_a-1" + assert String.starts_with?(Path.basename(slash_workspace), "team_a-1--") + + assert :ok = Workspace.remove_issue_workspaces("team/a-1") + refute File.exists?(slash_workspace) + assert File.exists?(underscore_workspace) + after + File.rm_rf(workspace_root) + end end test "workspace reuses existing issue directory without deleting local changes" do @@ -83,7 +139,7 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do assert File.read!(Path.join(second_workspace, "local-progress.txt")) == "in progress\n" assert File.read!(Path.join([second_workspace, "deps", "cache.txt"])) == "cached deps\n" assert File.read!(Path.join([second_workspace, "_build", "artifact.txt"])) == "compiled artifact\n" - refute File.exists?(Path.join([second_workspace, "tmp", "scratch.txt"])) + assert File.read!(Path.join([second_workspace, "tmp", "scratch.txt"])) == "remove me\n" after File.rm_rf(workspace_root) end @@ -103,8 +159,9 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) + assert {:ok, canonical_workspace} = SymphonyElixir.PathSafety.canonicalize(stale_workspace) assert {:ok, workspace} = Workspace.create_for_issue("MT-STALE") - assert workspace == stale_workspace + assert workspace == canonical_workspace assert File.dir?(workspace) after File.rm_rf(workspace_root) @@ -129,13 +186,79 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) - assert {:error, {:workspace_symlink_escape, ^symlink_path, ^workspace_root}} = + assert {:ok, canonical_outside_root} = SymphonyElixir.PathSafety.canonicalize(outside_root) + assert {:ok, canonical_workspace_root} = SymphonyElixir.PathSafety.canonicalize(workspace_root) + + assert {:error, {:workspace_outside_root, ^canonical_outside_root, ^canonical_workspace_root}} = Workspace.create_for_issue("MT-SYM") after File.rm_rf(test_root) end end + test "recorded workspace removal rejects symlink escapes before hooks" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-recorded-workspace-symlink-#{System.unique_integer([:positive])}" + ) + + try do + recorded_root = Path.join(test_root, "recorded-workspaces") + current_root = Path.join(test_root, "current-workspaces") + outside_root = Path.join(test_root, "outside") + recorded_workspace = Path.join(recorded_root, "MT-SYM") + hook_marker = Path.join(test_root, "before-remove-ran") + + File.mkdir_p!(recorded_root) + File.mkdir_p!(outside_root) + File.ln_s!(outside_root, recorded_workspace) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: current_root, + hook_before_remove: "touch \"#{hook_marker}\"" + ) + + assert {:ok, canonical_recorded_root} = + SymphonyElixir.PathSafety.canonicalize(recorded_root) + + assert {:error, {:workspace_symlink_escape, ^recorded_workspace, ^canonical_recorded_root}, ""} = + Workspace.remove_recorded(recorded_workspace, nil) + + refute File.exists?(hook_marker) + assert File.exists?(outside_root) + after + File.rm_rf(test_root) + end + end + + test "workspace canonicalizes symlinked workspace roots before creating issue directories" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-workspace-root-symlink-#{System.unique_integer([:positive])}" + ) + + try do + actual_root = Path.join(test_root, "actual-workspaces") + linked_root = Path.join(test_root, "linked-workspaces") + + File.mkdir_p!(actual_root) + File.ln_s!(actual_root, linked_root) + + write_workflow_file!(Workflow.workflow_file_path(), workspace_root: linked_root) + + assert {:ok, canonical_workspace} = + SymphonyElixir.PathSafety.canonicalize(Path.join(actual_root, "MT-LINK")) + + assert {:ok, workspace} = Workspace.create_for_issue("MT-LINK") + assert workspace == canonical_workspace + assert File.dir?(workspace) + after + File.rm_rf(test_root) + end + end + test "workspace remove rejects the workspace root itself with a distinct error" do workspace_root = Path.join( @@ -147,7 +270,10 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do File.mkdir_p!(workspace_root) write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) - assert {:error, {:workspace_equals_root, ^workspace_root, ^workspace_root}, ""} = + assert {:ok, canonical_workspace_root} = + SymphonyElixir.PathSafety.canonicalize(workspace_root) + + assert {:error, {:workspace_equals_root, ^canonical_workspace_root, ^canonical_workspace_root}, ""} = Workspace.remove(workspace_root) after File.rm_rf(workspace_root) @@ -174,6 +300,38 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do end end + test "workspace retries after_create after a failed new workspace bootstrap" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-workspace-hook-retry-#{System.unique_integer([:positive])}" + ) + + workspace_root = Path.join(test_root, "workspaces") + attempt_log = Path.join(test_root, "after-create-attempts") + + try do + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + hook_after_create: """ + if [ -f "#{attempt_log}" ]; then count=$(wc -l < "#{attempt_log}"); else count=0; fi + printf 'attempt\\n' >> "#{attempt_log}" + if [ "$count" -eq 0 ]; then printf partial > partial.txt; exit 17; fi + printf ready > READY + """ + ) + + assert {:error, {:workspace_hook_failed, "after_create", 17, _output}} = + Workspace.create_for_issue("MT-FAIL-RETRY") + + assert {:ok, workspace} = Workspace.create_for_issue("MT-FAIL-RETRY") + assert File.read!(Path.join(workspace, "READY")) == "ready" + assert String.split(String.trim(File.read!(attempt_log)), "\n") == ["attempt", "attempt"] + after + File.rm_rf(test_root) + end + end + test "workspace surfaces after_create hook timeouts" do workspace_root = Path.join( @@ -206,8 +364,9 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) workspace = Path.join(workspace_root, "MT-608") + assert {:ok, canonical_workspace} = SymphonyElixir.PathSafety.canonicalize(workspace) - assert {:ok, ^workspace} = Workspace.create_for_issue("MT-608") + assert {:ok, ^canonical_workspace} = Workspace.create_for_issue("MT-608") assert File.dir?(workspace) assert {:ok, []} = File.ls(workspace) after @@ -257,16 +416,28 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do assert :ok = Workspace.remove_issue_workspaces(nil) end - test "linear issue helpers" do + test "tracker issue helpers" do issue = %Issue{ id: "abc", labels: ["frontend", "infra"], - assigned_to_worker: false + dispatchable: false } assert Issue.label_names(issue) == ["frontend", "infra"] assert issue.labels == ["frontend", "infra"] - refute issue.assigned_to_worker + refute issue.dispatchable + end + + test "tracker issue routing requires every configured label" do + issue = %Issue{labels: [" Symphony ", "JavaScript"], dispatchable: true} + + assert Issue.routable?(issue, []) + assert Issue.routable?(issue, ["symphony"]) + assert Issue.routable?(issue, ["SYMPHONY", "javascript"]) + refute Issue.routable?(issue, ["symph"]) + refute Issue.routable?(issue, [" "]) + refute Issue.routable?(issue, ["symphony", "security"]) + refute Issue.routable?(%{issue | dispatchable: false}, ["symphony"]) end test "linear client normalizes blockers from inverse relations" do @@ -282,7 +453,7 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do "assignee" => %{ "id" => "user-1" }, - "labels" => %{"nodes" => [%{"name" => "Backend"}]}, + "labels" => %{"nodes" => [%{"name" => "Backend"}, %{"name" => " backend "}, %{"name" => " "}]}, "inverseRelations" => %{ "nodes" => [ %{ @@ -311,10 +482,44 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do assert issue.blocked_by == [%{id: "issue-2", identifier: "MT-2", state: "In Progress"}] assert issue.labels == ["backend"] + assert issue.native_ref == nil assert issue.priority == 2 assert issue.state == "Todo" assert issue.assignee_id == "user-1" - assert issue.assigned_to_worker + refute issue.dispatchable + end + + test "linear client rejects malformed issues instead of returning invalid scheduler records" do + assert Client.normalize_issue_for_test( + %{ + "id" => "issue-empty-title", + "identifier" => "MT-EMPTY", + "title" => " ", + "state" => %{"name" => "Todo"} + }, + nil + ) == nil + + graphql_fun = fn _query, _variables -> + {:ok, + %{ + "data" => %{ + "issues" => %{ + "nodes" => [ + %{ + "id" => "issue-empty-title", + "identifier" => "MT-EMPTY", + "title" => " ", + "state" => %{"name" => "Todo"} + } + ] + } + } + }} + end + + assert {:error, :linear_unknown_payload} = + Client.fetch_issues_by_ids_for_test(["issue-empty-title"], graphql_fun) end test "linear client marks explicitly unassigned issues as not routed to worker" do @@ -330,7 +535,7 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do issue = Client.normalize_issue_for_test(raw_issue, "user-1") - refute issue.assigned_to_worker + refute issue.dispatchable end test "linear client pagination merge helper preserves issue ordering" do @@ -348,6 +553,64 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do assert Enum.map(merged, & &1.identifier) == ["MT-1", "MT-2", "MT-3"] end + test "linear client paginates issue state fetches by id beyond one page" do + issue_ids = Enum.map(1..55, &"issue-#{&1}") + first_batch_ids = Enum.take(issue_ids, 50) + second_batch_ids = Enum.drop(issue_ids, 50) + + raw_issue = fn issue_id -> + suffix = String.replace_prefix(issue_id, "issue-", "") + + %{ + "id" => issue_id, + "identifier" => "MT-#{suffix}", + "title" => "Issue #{suffix}", + "description" => "Description #{suffix}", + "state" => %{"name" => "In Progress"}, + "labels" => %{"nodes" => []}, + "inverseRelations" => %{"nodes" => []} + } + end + + graphql_fun = fn query, variables -> + send(self(), {:fetch_issue_states_page, query, variables}) + + body = %{ + "data" => %{ + "issues" => %{ + "nodes" => Enum.map(variables.ids, raw_issue) + } + } + } + + {:ok, body} + end + + assert {:ok, issues} = Client.fetch_issues_by_ids_for_test(issue_ids, graphql_fun) + + assert Enum.map(issues, & &1.id) == issue_ids + + assert_receive {:fetch_issue_states_page, query, + %{ + ids: ^first_batch_ids, + projectSlug: "test-project", + first: 50, + relationFirst: 50 + }} + + assert query =~ "SymphonyLinearIssuesById" + assert query =~ "projectSlug" + assert query =~ "slugId" + + assert_receive {:fetch_issue_states_page, ^query, + %{ + ids: ^second_batch_ids, + projectSlug: "test-project", + first: 5, + relationFirst: 50 + }} + end + test "linear client logs response bodies for non-200 graphql responses" do log = ExUnit.CaptureLog.capture_log(fn -> @@ -377,6 +640,45 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do assert log =~ "Variable \\\"$ids\\\" got invalid value" end + test "linear graphql honors a bound tracker-settings snapshot without loading live config" do + parent = self() + original_workflow_path = Workflow.workflow_file_path() + workflow_store_pid = Process.whereis(WorkflowStore) + + missing_workflow_path = + Path.join(System.tmp_dir!(), "missing-bound-workflow-#{System.unique_integer([:positive])}.md") + + on_exit(fn -> + Workflow.set_workflow_file_path(original_workflow_path) + + if is_pid(workflow_store_pid) and is_nil(Process.whereis(WorkflowStore)) do + Supervisor.restart_child(SymphonyElixir.Supervisor, WorkflowStore) + end + end) + + if is_pid(Process.whereis(WorkflowStore)) do + assert :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, WorkflowStore) + end + + Workflow.set_workflow_file_path(missing_workflow_path) + + assert {:ok, %{"data" => %{"viewer" => %{"id" => "viewer-bound"}}}} = + Client.graphql( + "query Viewer { viewer { id } }", + %{}, + tracker_settings: %{ + api_key: "bound-token", + endpoint: "https://bound.example.test/graphql" + }, + request_fun: fn payload, headers -> + send(parent, {:bound_graphql_request, payload, headers}) + {:ok, %{status: 200, body: %{"data" => %{"viewer" => %{"id" => "viewer-bound"}}}}} + end + ) + + assert_receive {:bound_graphql_request, %{"query" => "query Viewer { viewer { id } }"}, [{"Authorization", "bound-token"}, {"Content-Type", "application/json"}]} + end + test "orchestrator sorts dispatch by priority then oldest created_at" do issue_same_priority_older = %Issue{ id: "issue-old-high", @@ -415,7 +717,7 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do assert Enum.map(sorted, & &1.identifier) == ["MT-200", "MT-201", "MT-199"] end - test "todo issue with non-terminal blocker is not dispatch-eligible" do + test "provider-marked blocked issue is not dispatch-eligible" do state = %Orchestrator.State{ max_concurrent_agents: 3, running: %{}, @@ -429,6 +731,7 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do identifier: "MT-1001", title: "Blocked work", state: "Todo", + dispatchable: false, blocked_by: [%{id: "blocker-1", identifier: "MT-1002", state: "In Progress"}] } @@ -451,13 +754,39 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do identifier: "MT-1007", title: "Owned elsewhere", state: "Todo", - assigned_to_worker: false + dispatchable: false + } + + refute Orchestrator.should_dispatch_issue_for_test(issue, state) + end + + test "issue without every required label is not dispatch-eligible" do + write_workflow_file!(Workflow.workflow_file_path(), + tracker_required_labels: ["symphony", "javascript"] + ) + + state = %Orchestrator.State{ + max_concurrent_agents: 3, + running: %{}, + claimed: MapSet.new(), + codex_totals: %{input_tokens: 0, output_tokens: 0, total_tokens: 0, seconds_running: 0}, + retry_attempts: %{} + } + + issue = %Issue{ + id: "unlabeled-1", + identifier: "MT-1008", + title: "Not opted in", + state: "Todo", + labels: ["symphony"], + dispatchable: true } refute Orchestrator.should_dispatch_issue_for_test(issue, state) + assert Orchestrator.should_dispatch_issue_for_test(%{issue | labels: ["Symphony", "JavaScript"]}, state) end - test "todo issue with terminal blockers remains dispatch-eligible" do + test "provider-marked ready issue remains dispatch-eligible" do state = %Orchestrator.State{ max_concurrent_agents: 3, running: %{}, @@ -471,13 +800,14 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do identifier: "MT-1003", title: "Ready work", state: "Todo", - blocked_by: [%{id: "blocker-2", identifier: "MT-1004", state: "Closed"}] + blocked_by: [%{id: "blocker-2", identifier: "MT-1004", state: "Closed"}], + dispatchable: true } assert Orchestrator.should_dispatch_issue_for_test(issue, state) end - test "dispatch revalidation skips stale todo issue once a non-terminal blocker appears" do + test "dispatch revalidation skips an issue when provider routing changes" do stale_issue = %Issue{ id: "blocked-2", identifier: "MT-1005", @@ -491,6 +821,7 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do identifier: "MT-1005", title: "Stale blocked work", state: "Todo", + dispatchable: false, blocked_by: [%{id: "blocker-3", identifier: "MT-1006", state: "In Progress"}] } @@ -503,6 +834,24 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do assert skipped_issue.blocked_by == [%{id: "blocker-3", identifier: "MT-1006", state: "In Progress"}] end + test "dispatch revalidation skips an issue after a required label is removed" do + write_workflow_file!(Workflow.workflow_file_path(), tracker_required_labels: ["symphony"]) + + stale_issue = %Issue{ + id: "unlabeled-2", + identifier: "MT-1009", + title: "Initially opted in", + state: "Todo", + labels: ["symphony"] + } + + refreshed_issue = %{stale_issue | labels: []} + fetcher = fn ["unlabeled-2"] -> {:ok, [refreshed_issue]} end + + assert {:skip, ^refreshed_issue} = + Orchestrator.revalidate_issue_for_dispatch_for_test(stale_issue, fetcher) + end + test "workspace remove returns error information for missing directory" do random_path = Path.join( @@ -533,8 +882,9 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do hook_before_remove: "echo before_remove > \"#{before_remove_marker}\"" ) - assert Config.workspace_hooks().after_create =~ "echo after_create > after_create.log" - assert Config.workspace_hooks().before_remove =~ "echo before_remove >" + config = Config.settings!() + assert config.hooks.after_create =~ "echo after_create > after_create.log" + assert config.hooks.before_remove =~ "echo before_remove >" assert {:ok, workspace} = Workspace.create_for_issue("MT-HOOKS") assert File.read!(Path.join(workspace, "after_create.log")) == "after_create\n" @@ -643,6 +993,7 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do System.delete_env("LINEAR_API_KEY") write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "memory", workspace_root: nil, max_concurrent_agents: nil, codex_approval_policy: nil, @@ -655,14 +1006,17 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do tracker_project_slug: nil ) - assert Config.linear_endpoint() == "https://api.linear.app/graphql" - assert Config.linear_api_token() == nil - assert Config.linear_project_slug() == nil - assert Config.workspace_root() == Path.join(System.tmp_dir!(), "symphony_workspaces") - assert Config.max_concurrent_agents() == 10 - assert Config.codex_command() == "codex app-server" - - assert Config.codex_approval_policy() == %{ + config = Config.settings!() + assert config.tracker.endpoint == "https://api.linear.app/graphql" + assert config.tracker.api_key == nil + assert config.tracker.project_slug == nil + assert config.tracker.required_labels == [] + assert config.workspace.root == Path.join(System.tmp_dir!(), "symphony_workspaces") + assert config.worker.max_concurrent_agents_per_host == nil + assert config.agent.max_concurrent_agents == 10 + assert config.codex.command == "codex app-server" + + assert config.codex.approval_policy == %{ "reject" => %{ "sandbox_approval" => true, "rules" => true, @@ -670,52 +1024,94 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do } } - assert Config.codex_thread_sandbox() == "workspace-write" + assert config.codex.thread_sandbox == "workspace-write" + + assert {:ok, canonical_default_workspace_root} = + SymphonyElixir.PathSafety.canonicalize(Path.join(System.tmp_dir!(), "symphony_workspaces")) assert Config.codex_turn_sandbox_policy() == %{ "type" => "workspaceWrite", - "writableRoots" => [Path.expand(Path.join(System.tmp_dir!(), "symphony_workspaces"))], + "writableRoots" => [canonical_default_workspace_root], "readOnlyAccess" => %{"type" => "fullAccess"}, "networkAccess" => false, "excludeTmpdirEnvVar" => false, "excludeSlashTmp" => false } - assert Config.codex_turn_timeout_ms() == 3_600_000 - assert Config.codex_read_timeout_ms() == 5_000 - assert Config.codex_stall_timeout_ms() == 300_000 + assert config.codex.turn_timeout_ms == 3_600_000 + assert config.codex.read_timeout_ms == 5_000 + assert config.codex.stall_timeout_ms == 300_000 + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_required_labels: [" Symphony ", "SYMPHONY", "JavaScript"] + ) + + assert Config.settings!().tracker.required_labels == ["symphony", "javascript"] + + write_workflow_file!(Workflow.workflow_file_path(), tracker_required_labels: [" "]) + assert Config.settings!().tracker.required_labels == [""] + + write_workflow_file!(Workflow.workflow_file_path(), + codex_command: "codex --config 'model=\"gpt-5.5\"' app-server" + ) + + assert Config.settings!().codex.command == + "codex --config 'model=\"gpt-5.5\"' app-server" + + explicit_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-explicit-sandbox-root-#{System.unique_integer([:positive])}" + ) - write_workflow_file!(Workflow.workflow_file_path(), codex_command: "codex app-server --model gpt-5.3-codex") - assert Config.codex_command() == "codex app-server --model gpt-5.3-codex" + explicit_workspace = Path.join(explicit_root, "MT-EXPLICIT") + explicit_cache = Path.join(explicit_workspace, "cache") + File.mkdir_p!(explicit_cache) + + on_exit(fn -> File.rm_rf(explicit_root) end) write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: explicit_root, codex_approval_policy: "on-request", codex_thread_sandbox: "workspace-write", - codex_turn_sandbox_policy: %{type: "workspaceWrite", writableRoots: ["/tmp/workspace", "/tmp/cache"]} + codex_turn_sandbox_policy: %{ + type: "workspaceWrite", + writableRoots: [explicit_workspace, explicit_cache] + } ) - assert Config.codex_approval_policy() == "on-request" - assert Config.codex_thread_sandbox() == "workspace-write" + config = Config.settings!() + assert config.codex.approval_policy == "on-request" + assert config.codex.thread_sandbox == "workspace-write" - assert Config.codex_turn_sandbox_policy() == %{ + assert Config.codex_turn_sandbox_policy(explicit_workspace) == %{ "type" => "workspaceWrite", - "writableRoots" => ["/tmp/workspace", "/tmp/cache"] + "writableRoots" => [explicit_workspace, explicit_cache] } write_workflow_file!(Workflow.workflow_file_path(), tracker_active_states: ",") - assert Config.linear_active_states() == ["Todo", "In Progress"] + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "tracker.active_states" write_workflow_file!(Workflow.workflow_file_path(), max_concurrent_agents: "bad") - assert Config.max_concurrent_agents() == 10 + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "agent.max_concurrent_agents" + + write_workflow_file!(Workflow.workflow_file_path(), worker_max_concurrent_agents_per_host: 0) + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "worker.max_concurrent_agents_per_host" write_workflow_file!(Workflow.workflow_file_path(), codex_turn_timeout_ms: "bad") - assert Config.codex_turn_timeout_ms() == 3_600_000 + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.turn_timeout_ms" write_workflow_file!(Workflow.workflow_file_path(), codex_read_timeout_ms: "bad") - assert Config.codex_read_timeout_ms() == 5_000 + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.read_timeout_ms" write_workflow_file!(Workflow.workflow_file_path(), codex_stall_timeout_ms: "bad") - assert Config.codex_stall_timeout_ms() == 300_000 + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.stall_timeout_ms" write_workflow_file!(Workflow.workflow_file_path(), tracker_active_states: %{todo: true}, @@ -732,49 +1128,19 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do server_host: 123 ) - assert Config.linear_active_states() == ["Todo", "In Progress"] - assert Config.linear_terminal_states() == ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] - assert Config.poll_interval_ms() == 30_000 - assert Config.workspace_root() == Path.join(System.tmp_dir!(), "symphony_workspaces") - assert Config.max_retry_backoff_ms() == 300_000 - assert Config.max_concurrent_agents_for_state("Todo") == 1 - assert Config.max_concurrent_agents_for_state("Review") == 10 - assert Config.hook_timeout_ms() == 60_000 - assert Config.observability_enabled?() - assert Config.observability_refresh_ms() == 1_000 - assert Config.observability_render_interval_ms() == 16 - assert Config.server_port() == nil - assert Config.server_host() == "123" + assert {:error, {:invalid_workflow_config, _message}} = Config.validate!() write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: "") - - assert Config.codex_approval_policy() == %{ - "reject" => %{ - "sandbox_approval" => true, - "rules" => true, - "mcp_elicitations" => true - } - } - - assert {:error, {:invalid_codex_approval_policy, ""}} = Config.validate!() + assert :ok = Config.validate!() + assert Config.settings!().codex.approval_policy == "" write_workflow_file!(Workflow.workflow_file_path(), codex_thread_sandbox: "") - assert Config.codex_thread_sandbox() == "workspace-write" - assert {:error, {:invalid_codex_thread_sandbox, ""}} = Config.validate!() + assert :ok = Config.validate!() + assert Config.settings!().codex.thread_sandbox == "" write_workflow_file!(Workflow.workflow_file_path(), codex_turn_sandbox_policy: "bad") - - assert Config.codex_turn_sandbox_policy() == %{ - "type" => "workspaceWrite", - "writableRoots" => [Path.expand(Path.join(System.tmp_dir!(), "symphony_workspaces"))], - "readOnlyAccess" => %{"type" => "fullAccess"}, - "networkAccess" => false, - "excludeTmpdirEnvVar" => false, - "excludeSlashTmp" => false - } - - assert {:error, {:invalid_codex_turn_sandbox_policy, {:unsupported_value, "bad"}}} = - Config.validate!() + assert {:error, {:invalid_workflow_config, message}} = Config.validate!() + assert message =~ "codex.turn_sandbox_policy" write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: "future-policy", @@ -785,18 +1151,19 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do } ) - assert Config.codex_approval_policy() == "future-policy" - assert Config.codex_thread_sandbox() == "future-sandbox" + config = Config.settings!() + assert config.codex.approval_policy == "future-policy" + assert config.codex.thread_sandbox == "future-sandbox" + + assert :ok = Config.validate!() assert Config.codex_turn_sandbox_policy() == %{ "type" => "futureSandbox", "nested" => %{"flag" => true} } - assert :ok = Config.validate!() - write_workflow_file!(Workflow.workflow_file_path(), codex_command: "codex app-server") - assert Config.codex_command() == "codex app-server" + assert Config.settings!().codex.command == "codex app-server" end test "config resolves $VAR references for env-backed secret and path values" do @@ -823,9 +1190,85 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do codex_command: "#{codex_bin} app-server" ) - assert Config.linear_api_token() == api_key - assert Config.workspace_root() == Path.expand(workspace_root) - assert Config.codex_command() == "#{codex_bin} app-server" + config = Config.settings!() + assert config.tracker.api_key == api_key + assert config.tracker.provider["api_key"] == "$#{api_key_env_var}" + assert config.tracker.secret_environment_names == ["LINEAR_API_KEY", api_key_env_var] + assert config.workspace.root == Path.expand(workspace_root) + assert config.codex.command == "#{codex_bin} app-server" + end + + test "schema preserves adapter-owned provider config while keeping linear aliases compatible" do + assert {:ok, settings} = + Schema.parse(%{ + tracker: %{ + kind: "linear", + provider: %{ + endpoint: "https://linear.example.test/graphql", + api_key: "provider-token", + project_slug: "provider-project", + extra: %{team: "platform"} + } + } + }) + + assert settings.tracker.endpoint == "https://linear.example.test/graphql" + assert settings.tracker.api_key == "provider-token" + assert settings.tracker.project_slug == "provider-project" + assert settings.tracker.secret_environment_names == ["LINEAR_API_KEY"] + + assert settings.tracker.provider == %{ + "endpoint" => "https://linear.example.test/graphql", + "api_key" => "provider-token", + "project_slug" => "provider-project", + "assignee" => nil, + "extra" => %{"team" => "platform"} + } + end + + test "linear adapter rejects invalid provider values without crashing config parsing" do + assert {:ok, invalid_secret_settings} = + Schema.parse(%{ + tracker: %{ + kind: "linear", + provider: %{api_key: 123, project_slug: "project"} + } + }) + + assert {:error, :missing_linear_api_token} = + Config.validate_settings(invalid_secret_settings) + + assert {:ok, invalid_endpoint_settings} = + Schema.parse(%{ + tracker: %{ + kind: "linear", + provider: %{api_key: "token", project_slug: "project", endpoint: 123} + } + }) + + assert {:error, :invalid_linear_endpoint} = + Config.validate_settings(invalid_endpoint_settings) + + assert {:ok, invalid_assignee_settings} = + Schema.parse(%{ + tracker: %{ + kind: "linear", + provider: %{api_key: "token", project_slug: "project", assignee: 123} + } + }) + + assert {:error, :invalid_linear_assignee} = + Config.validate_settings(invalid_assignee_settings) + end + + test "schema does not inject linear defaults before an adapter is selected" do + assert {:ok, settings} = Schema.parse(%{tracker: %{kind: "future-tracker"}}) + + assert settings.tracker.endpoint == nil + assert settings.tracker.api_key == nil + assert settings.tracker.active_states == nil + assert settings.tracker.terminal_states == nil + assert settings.tracker.provider == %{} end test "config no longer resolves legacy env: references" do @@ -850,13 +1293,16 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do workspace_root: "env:#{workspace_env_var}" ) - assert Config.linear_api_token() == "env:#{api_key_env_var}" - assert Config.workspace_root() == "env:#{workspace_env_var}" + config = Config.settings!() + assert config.tracker.api_key == "env:#{api_key_env_var}" + assert config.workspace.root == "env:#{workspace_env_var}" end test "config supports per-state max concurrent agent overrides" do workflow = """ --- + tracker: + kind: memory agent: max_concurrent_agents: 10 max_concurrent_agents_by_state: @@ -868,12 +1314,281 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do File.write!(Workflow.workflow_file_path(), workflow) - assert Config.max_concurrent_agents() == 10 + assert Config.settings!().agent.max_concurrent_agents == 10 assert Config.max_concurrent_agents_for_state("Todo") == 1 assert Config.max_concurrent_agents_for_state("In Progress") == 4 assert Config.max_concurrent_agents_for_state("In Review") == 2 assert Config.max_concurrent_agents_for_state("Closed") == 10 assert Config.max_concurrent_agents_for_state(:not_a_string) == 10 + + write_workflow_file!(Workflow.workflow_file_path(), worker_max_concurrent_agents_per_host: 2) + assert :ok = Config.validate!() + assert Config.settings!().worker.max_concurrent_agents_per_host == 2 + end + + test "schema helpers cover custom type and state limit validation" do + assert StringOrMap.type() == :map + assert StringOrMap.embed_as(:json) == :self + assert StringOrMap.equal?(%{"a" => 1}, %{"a" => 1}) + refute StringOrMap.equal?(%{"a" => 1}, %{"a" => 2}) + + assert {:ok, "value"} = StringOrMap.cast("value") + assert {:ok, %{"a" => 1}} = StringOrMap.cast(%{"a" => 1}) + assert :error = StringOrMap.cast(123) + + assert {:ok, "value"} = StringOrMap.load("value") + assert :error = StringOrMap.load(123) + + assert {:ok, %{"a" => 1}} = StringOrMap.dump(%{"a" => 1}) + assert :error = StringOrMap.dump(123) + + assert Schema.normalize_state_limits(nil) == %{} + + assert Schema.normalize_state_limits(%{" In Progress " => 2, todo: 1}) == %{ + "todo" => 1, + "in progress" => 2 + } + + changeset = + {%{}, %{limits: :map}} + |> Changeset.cast(%{limits: %{"" => 1, "todo" => 0}}, [:limits]) + |> Schema.validate_state_limits(:limits) + + assert changeset.errors == [ + limits: {"state names must not be blank", []}, + limits: {"limits must be positive integers", []} + ] + + whitespace_state_changeset = + {%{}, %{limits: :map}} + |> Changeset.cast(%{limits: %{" " => 1}}, [:limits]) + |> Schema.validate_state_limits(:limits) + + assert whitespace_state_changeset.errors == [ + limits: {"state names must not be blank", []} + ] + end + + test "schema parse normalizes policy keys and env-backed fallbacks" do + missing_workspace_env = "SYMP_MISSING_WORKSPACE_#{System.unique_integer([:positive])}" + empty_secret_env = "SYMP_EMPTY_SECRET_#{System.unique_integer([:positive])}" + missing_secret_env = "SYMP_MISSING_SECRET_#{System.unique_integer([:positive])}" + + previous_missing_workspace_env = System.get_env(missing_workspace_env) + previous_empty_secret_env = System.get_env(empty_secret_env) + previous_missing_secret_env = System.get_env(missing_secret_env) + previous_linear_api_key = System.get_env("LINEAR_API_KEY") + + System.delete_env(missing_workspace_env) + System.put_env(empty_secret_env, "") + System.delete_env(missing_secret_env) + System.put_env("LINEAR_API_KEY", "fallback-linear-token") + + on_exit(fn -> + restore_env(missing_workspace_env, previous_missing_workspace_env) + restore_env(empty_secret_env, previous_empty_secret_env) + restore_env(missing_secret_env, previous_missing_secret_env) + restore_env("LINEAR_API_KEY", previous_linear_api_key) + end) + + assert {:ok, settings} = + Schema.parse(%{ + tracker: %{kind: "linear", api_key: "$#{empty_secret_env}"}, + workspace: %{root: "$#{missing_workspace_env}"}, + codex: %{approval_policy: %{reject: %{sandbox_approval: true}}} + }) + + assert settings.tracker.api_key == nil + assert settings.workspace.root == Path.join(System.tmp_dir!(), "symphony_workspaces") + + assert settings.codex.approval_policy == %{ + "reject" => %{"sandbox_approval" => true} + } + + assert {:ok, settings} = + Schema.parse(%{ + tracker: %{kind: "linear", api_key: "$#{missing_secret_env}"}, + workspace: %{root: ""} + }) + + assert settings.tracker.api_key == "fallback-linear-token" + assert settings.workspace.root == Path.join(System.tmp_dir!(), "symphony_workspaces") + end + + test "schema resolves sandbox policies from explicit and default workspaces" do + explicit_policy = %{"type" => "workspaceWrite", "writableRoots" => ["/tmp/explicit"]} + + assert Schema.resolve_turn_sandbox_policy(%Schema{ + codex: %Codex{turn_sandbox_policy: explicit_policy}, + workspace: %Schema.Workspace{root: "/tmp/ignored"} + }) == explicit_policy + + assert Schema.resolve_turn_sandbox_policy(%Schema{ + codex: %Codex{turn_sandbox_policy: nil}, + workspace: %Schema.Workspace{root: ""} + }) == %{ + "type" => "workspaceWrite", + "writableRoots" => [Path.expand(Path.join(System.tmp_dir!(), "symphony_workspaces"))], + "readOnlyAccess" => %{"type" => "fullAccess"}, + "networkAccess" => false, + "excludeTmpdirEnvVar" => false, + "excludeSlashTmp" => false + } + + assert Schema.resolve_turn_sandbox_policy( + %Schema{ + codex: %Codex{turn_sandbox_policy: nil}, + workspace: %Schema.Workspace{root: "/tmp/ignored"} + }, + "/tmp/workspace" + ) == %{ + "type" => "workspaceWrite", + "writableRoots" => [Path.expand("/tmp/workspace")], + "readOnlyAccess" => %{"type" => "fullAccess"}, + "networkAccess" => false, + "excludeTmpdirEnvVar" => false, + "excludeSlashTmp" => false + } + end + + test "schema keeps workspace roots raw while sandbox helpers expand only for local use" do + assert {:ok, settings} = + Schema.parse(%{ + workspace: %{root: "~/.symphony-workspaces"}, + codex: %{} + }) + + assert settings.workspace.root == "~/.symphony-workspaces" + + assert Schema.resolve_turn_sandbox_policy(settings) == %{ + "type" => "workspaceWrite", + "writableRoots" => [Path.expand("~/.symphony-workspaces")], + "readOnlyAccess" => %{"type" => "fullAccess"}, + "networkAccess" => false, + "excludeTmpdirEnvVar" => false, + "excludeSlashTmp" => false + } + + assert {:ok, remote_policy} = + Schema.resolve_runtime_turn_sandbox_policy(settings, nil, remote: true) + + assert remote_policy == %{ + "type" => "workspaceWrite", + "writableRoots" => ["~/.symphony-workspaces"], + "readOnlyAccess" => %{"type" => "fullAccess"}, + "networkAccess" => false, + "excludeTmpdirEnvVar" => false, + "excludeSlashTmp" => false + } + end + + test "runtime sandbox policy resolution passes explicit policies through unchanged" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-runtime-sandbox-#{System.unique_integer([:positive])}" + ) + + try do + workspace_root = Path.join(test_root, "workspaces") + issue_workspace = Path.join(workspace_root, "MT-100") + File.mkdir_p!(issue_workspace) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + codex_turn_sandbox_policy: %{ + type: "workspaceWrite", + writableRoots: ["relative/path"], + networkAccess: true + } + ) + + assert {:ok, runtime_settings} = Config.codex_runtime_settings(issue_workspace) + + assert runtime_settings.turn_sandbox_policy == %{ + "type" => "workspaceWrite", + "writableRoots" => ["relative/path"], + "networkAccess" => true + } + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + codex_turn_sandbox_policy: %{ + type: "futureSandbox", + nested: %{flag: true} + } + ) + + assert {:ok, runtime_settings} = Config.codex_runtime_settings(issue_workspace) + + assert runtime_settings.turn_sandbox_policy == %{ + "type" => "futureSandbox", + "nested" => %{"flag" => true} + } + after + File.rm_rf(test_root) + end + end + + test "path safety returns errors for invalid path segments" do + invalid_segment = String.duplicate("a", 300) + path = Path.join(System.tmp_dir!(), invalid_segment) + expanded_path = Path.expand(path) + + assert {:error, {:path_canonicalize_failed, ^expanded_path, :enametoolong}} = + SymphonyElixir.PathSafety.canonicalize(path) + end + + test "runtime sandbox policy resolution defaults when omitted and ignores workspace for explicit policies" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-runtime-sandbox-branches-#{System.unique_integer([:positive])}" + ) + + try do + workspace_root = Path.join(test_root, "workspaces") + issue_workspace = Path.join(workspace_root, "MT-101") + + File.mkdir_p!(issue_workspace) + + write_workflow_file!(Workflow.workflow_file_path(), workspace_root: workspace_root) + + settings = Config.settings!() + + assert {:ok, canonical_workspace_root} = + SymphonyElixir.PathSafety.canonicalize(workspace_root) + + assert {:ok, default_policy} = Schema.resolve_runtime_turn_sandbox_policy(settings) + assert default_policy["type"] == "workspaceWrite" + assert default_policy["writableRoots"] == [canonical_workspace_root] + + assert {:ok, blank_workspace_policy} = + Schema.resolve_runtime_turn_sandbox_policy(settings, "") + + assert blank_workspace_policy == default_policy + + read_only_settings = %{ + settings + | codex: %{settings.codex | turn_sandbox_policy: %{"type" => "readOnly", "networkAccess" => true}} + } + + assert {:ok, %{"type" => "readOnly", "networkAccess" => true}} = + Schema.resolve_runtime_turn_sandbox_policy(read_only_settings, 123) + + future_settings = %{ + settings + | codex: %{settings.codex | turn_sandbox_policy: %{"type" => "futureSandbox", "nested" => %{"flag" => true}}} + } + + assert {:ok, %{"type" => "futureSandbox", "nested" => %{"flag" => true}}} = + Schema.resolve_runtime_turn_sandbox_policy(future_settings, 123) + + assert {:error, {:unsafe_turn_sandbox_policy, {:invalid_workspace_root, 123}}} = + Schema.resolve_runtime_turn_sandbox_policy(settings, 123) + after + File.rm_rf(test_root) + end end test "workflow prompt is used when building base prompt" do @@ -882,4 +1597,75 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do write_workflow_file!(Workflow.workflow_file_path(), prompt: workflow_prompt) assert Config.workflow_prompt() == workflow_prompt end + + test "remote workspace lifecycle uses ssh host aliases from worker config" do + test_root = + Path.join( + System.tmp_dir!(), + "symphony-elixir-remote-workspace-#{System.unique_integer([:positive])}" + ) + + previous_path = System.get_env("PATH") + previous_trace = System.get_env("SYMP_TEST_SSH_TRACE") + + on_exit(fn -> + restore_env("PATH", previous_path) + restore_env("SYMP_TEST_SSH_TRACE", previous_trace) + end) + + try do + trace_file = Path.join(test_root, "ssh.trace") + fake_ssh = Path.join(test_root, "ssh") + workspace_root = "~/.symphony-remote-workspaces" + workspace_path = "/remote/home/.symphony-remote-workspaces/MT-SSH-WS" + + File.mkdir_p!(test_root) + System.put_env("SYMP_TEST_SSH_TRACE", trace_file) + System.put_env("PATH", test_root <> ":" <> (previous_path || "")) + + File.write!(fake_ssh, """ + #!/bin/sh + trace_file="${SYMP_TEST_SSH_TRACE:-/tmp/symphony-fake-ssh.trace}" + printf 'ARGV:%s\\n' "$*" >> "$trace_file" + + case "$*" in + *"__SYMPHONY_WORKSPACE__"*) + printf '%s\\t%s\\t%s\\n' '__SYMPHONY_WORKSPACE__' '1' '#{workspace_path}' + ;; + esac + + exit 0 + """) + + File.chmod!(fake_ssh, 0o755) + + write_workflow_file!(Workflow.workflow_file_path(), + workspace_root: workspace_root, + worker_ssh_hosts: ["worker-01:2200"], + hook_before_run: "echo before-run", + hook_after_run: "echo after-run", + hook_before_remove: "echo before-remove" + ) + + assert Config.settings!().worker.ssh_hosts == ["worker-01:2200"] + assert Config.settings!().workspace.root == workspace_root + assert {:ok, ^workspace_path} = Workspace.create_for_issue("MT-SSH-WS", "worker-01:2200") + assert :ok = Workspace.run_before_run_hook(workspace_path, "MT-SSH-WS", "worker-01:2200") + assert :ok = Workspace.run_after_run_hook(workspace_path, "MT-SSH-WS", "worker-01:2200") + assert :ok = Workspace.remove_issue_workspaces("MT-SSH-WS", "worker-01:2200") + + trace = File.read!(trace_file) + assert trace =~ "-p 2200 worker-01 bash -lc" + assert trace =~ "__SYMPHONY_WORKSPACE__" + assert trace =~ "~/.symphony-remote-workspaces/MT-SSH-WS" + assert trace =~ "${workspace#\\~/}" + assert trace =~ "echo before-run" + assert trace =~ "echo after-run" + assert trace =~ "echo before-remove" + assert trace =~ "rm -rf" + assert trace =~ workspace_path + after + File.rm_rf(test_root) + end + end end