Skip to content

symphony: move the elixir runtime into packages/symphony#782

Merged
Wyatt Gill (wyattgill9) merged 2 commits into
mainfrom
symphony-elixir-in-tree
Jun 6, 2026
Merged

symphony: move the elixir runtime into packages/symphony#782
Wyatt Gill (wyattgill9) merged 2 commits into
mainfrom
symphony-elixir-in-tree

Conversation

@wyattgill9

@wyattgill9 Wyatt Gill (wyattgill9) commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Move the Symphony Elixir runtime into packages/symphony, absorbed from indexable-inc/symphony at c9e709208c3ae161e24f625b9f3808a288c859ed. After symphony#268 moved the room stack (room-server, the Tauri/Svelte UI) into the ix monorepo, the dedicated repo was the Elixir runtime alone; this PR gives it its long-term home in index so the standalone repo can retire. Follows up on the reverted whole-repo subtree attempt (#767#779/#780): this time only the Elixir part moves, per the plan agreed in Slack (room → ix, elixir → index).

What this adds

  • packages/symphony/: the runtime (elixir/), the engine wire fixtures (contracts/fixtures, kept beside elixir/ because the contract tests resolve ../../contracts), the bundled example pack (workflows/example), bin/run-nix, and docs. The 30 MB of .github demo media, the standalone CI workflows, and the PR template did not move; the pr_body.check mix task that validated that template is deleted as vestigial.
  • packages.<sys>.symphony: the launcher, parity with the standalone flake's packages.default (Nushell wrapper exec'ing bin/run-nix with Elixir 1.19/OTP 28, gh, git, openssh, cacert on PATH). Production deploys keep working the same way: stage source, mix deps.get, mix run --no-halt.
  • nixosModules.symphony via modules/services/symphony/: the service module, unchanged, auto-discovered under the same attr name ix imports from the symphony flake today.
  • checks.x86_64-linux.symphony-elixir: the standalone repo's required lane (mix compile --warnings-as-errors, mix format --check-formatted, mix credo, mix test; 384 tests) as a sandboxed derivation. Deps come from a fetchMixDeps fixed-output derivation; rebar is pinned; the lazy_html C++ NIF (test-only, LiveView HTML assertions) is satisfied by seeding elixir_make's artifact cache with the upstream release tarball, which elixir_make still verifies against the checksum.exs pinned in the dep. The advisory lane (dialyzer, sobelow, deps.audit, coveralls) stays a local make quality run; see packages/symphony/docs/quality.md.
  • devShells.<sys>.symphony: parity with the standalone devshell (elixir, erlang, codex, gh, git, openssh).
  • Eval assertions (tests/default.nix, symphony group) pinning the module's unit env contract (SYMPHONY_WORKFLOW_PACK default, primary-repo export, ExecStart shape, EnvironmentFile pass-through, hostRuntime gating) that ix's hil deployment and worker module read.

The room-server seam (deliberately untouched)

The symphony flake input stays exactly as #780 restored it: pinned to the last rev that still builds room-server, feeding pkgs.symphony-room-server into images/dev/symphony-codex and its eval tests. room-server's source now lives in the private ix monorepo, so the public image cannot build it once the symphony repo goes away. Resolving that seam (move the image to ix, or have ix layer room-server onto a public base image) is the remaining blocker for retiring the repo; the input comment in flake.nix now says so. Do not nix flake update symphony until then: symphony@main no longer exports room-server, so a bump breaks the image eval.

ix follow-up (after this merges)

  • inputs.symphony.packages.<sys>.defaultinputs.index.packages.<sys>.symphony in nix/modules/services/host/symphony/module.nix and symphony-runtime/module.nix
  • inputs.symphony.nixosModules.symphonyinputs.index.nixosModules.symphony
  • inputs.symphony.packages.<sys>.codexpkgs.codex (the symphony flake's codex output was a plain re-export for pin visibility)
  • drop ix's symphony input and the inputs.symphony.follows line on its index input
  • then resolve the image seam, drop index's symphony input, and archive indexable-inc/symphony

Validation

  • nix build .#checks.x86_64-linux.symphony-elixir (384 tests, 0 failures, sandboxed)
  • nix build .#ciChecks.x86_64-linux.eval (aggregate includes the new ix-test-symphony and the untouched ix-test-symphony-codex)
  • nix build .#packages.x86_64-linux.symphony and .#devShells.x86_64-linux.symphony
  • nix eval .#nixosModules --apply builtins.attrNames lists symphony
  • nix run .#lint (nixfmt, statix, deadnix, ast-grep, ast-grep-test all green)
  • git diff --check clean

🤖 Generated with Claude Code

Note

Move the Symphony Elixir runtime into packages/symphony

  • Relocates the Symphony Elixir runtime into packages/symphony/elixir, establishing it as a self-contained Mix project (symphony_elixir v0.2.0) with its own toolchain, Nix derivation, and CI quality gate.
  • Adds a full OTP application with role-based supervision: control-plane nodes boot the web endpoint, workflow/skill catalogs, cron/Slack triggers, GitHub App token management, and the IR runtime; worker nodes boot a minimal Slipstream client.
  • Introduces the IR execution layer: RunGraph, Node, Attempt, Store (JSON-backed persistence), Materializer, Graph (ready-node scheduling and failure propagation), Recovery, and a DynamicSupervisor that resumes pending runs after restart.
  • Adds a DSL pipeline — Lexer, Parser, Interpreter, and Schema — that compiles .sym workflow files into IR nodes, with WorkflowCatalog hot-reloading them from disk.
  • Exposes a Phoenix web layer with LiveView dashboards for runs, workflows, skills, and statistics, plus a JSON API at /api/v1 with run controls and webhook receivers for GitHub, Linear, and Slack.
  • Adds a NixOS module at modules/services/symphony/default.nix and a bin/run-nix entrypoint for production deployment, with a new symphony developer shell in lib/per-system.nix.

Macroscope summarized 8b5445b.

The room stack moved to the ix monorepo (symphony#268), leaving
indexable-inc/symphony as the Elixir runtime alone; absorb it at
c9e7092 so the dedicated repo can retire. The launcher ships as
packages.<sys>.symphony, the NixOS module as nixosModules.symphony
(the same attr ix imports from the symphony flake today), and the
required quality lane runs sandboxed as checks.<sys>.symphony-elixir.
The symphony flake input stays pinned as the room-server provider
for images/dev/symphony-codex until that seam moves to ix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Blast radius

4 of 1107 checks would rebuild between base 104bf2e and head 0dbc0fd.

1 added, 0 removed

pie showData title Rebuilt checks by category
  "blast" : 1
  "eval" : 1
  "lint" : 1
  "symphony" : 1
Loading
flowchart LR
  c0["blast-radius-test"]
  c1["lint"]
  c2["eval"]
Loading
changed checks (3)
  • blast-radius-test
  • eval
  • lint

@wyattgill9 Wyatt Gill (wyattgill9) added this pull request to the merge queue Jun 6, 2026
Merged via the queue into main with commit f2f60db Jun 6, 2026
11 of 12 checks passed
@wyattgill9 Wyatt Gill (wyattgill9) deleted the symphony-elixir-in-tree branch June 6, 2026 18:08

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review found issues in this pull request.

Verdict: patch is incorrect
Confidence: 0.89

The new runtime has security and correctness flaws around unauthenticated control paths, worker identity, Slack replay protection, and cancellation semantics that can leave secret-backed automation running or reachable when it should not be.

  • P1 packages/symphony/elixir/lib/symphony_elixir/runtime.ex:202 Cancel does not stop running work
  • P1 packages/symphony/elixir/lib/symphony_elixir_web/router.ex:42 Manual run APIs are exposed without authentication
  • P1 packages/symphony/elixir/lib/symphony_elixir_web/channels/worker_socket.ex:36 Worker socket falls back to self-asserted identity
  • P2 packages/symphony/elixir/lib/symphony_elixir_web/controllers/slack_events_controller.ex:121 Slack signatures can be replayed indefinitely

Comment on lines +202 to +216
def handle_call({:cancel, actor}, _from, state) do
Enum.each(Map.keys(state.tasks), &Process.demonitor(&1, [:flush]))

cancelled =
Enum.reduce(state.graph.nodes, state.graph, fn {id, node}, acc ->
if Node.terminal?(node), do: acc, else: transition(acc, id, :cancelled)
end)

finished =
%{cancelled | status: :cancelled}
|> RunGraph.append_audit(:cancel, nil, actor, %{})

persist(finished, state)
release_placement(state)
{:stop, :normal, :ok, %{state | graph: finished, tasks: %{}, node_refs: %{}}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel does not stop running work

cancel/2 only demonitores the task refs before marking the graph cancelled and releasing placement. The Task.Supervisor child pids are not stored or terminated, so an in-flight agent turn or exec script keeps running after the operator sees the run as cancelled; any commits, pushes, or filesystem writes then happen invisibly against a released/cancelled run. Store the task pids and explicitly terminate the running tasks/processes before finalizing cancellation.

Comment on lines +42 to +56
scope "/api/v1", SymphonyElixirWeb do
pipe_through(:api)

# The manual-trigger producer onto the IR runtime.
post("/runs", ApiController, :enqueue_run)

# IR runs (the RunGraph model).
get("/ir/schema", IRRunController, :schema)
get("/ir/runs", IRRunController, :index)
post("/ir/runs", IRRunController, :create)
get("/ir/runs/:run_id", IRRunController, :show)
post("/ir/runs/:run_id/cancel", IRRunController, :cancel)
post("/ir/runs/:run_id/rerun", IRRunController, :rerun)
post("/ir/runs/:run_id/clear-failed", IRRunController, :clear_failed)
post("/ir/runs/:run_id/nodes/:node_id/retry", IRRunController, :retry_node)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Manual run APIs are exposed without authentication

This public API scope contains manual run creation and operator controls, but it is only piped through :api; the HMAC checks live inside the webhook controllers and do not protect /runs or /ir/runs/*. A deployment that exposes this server for GitHub/Linear/Slack webhooks can also let arbitrary callers start secret-backed workflows with attacker-controlled input, read run details, or cancel/rerun jobs. Split signed webhook routes from operator routes or add a fail-closed authentication plug for the non-webhook API.

Comment on lines +36 to +40
# The mTLS-verified CN nginx forwards is authoritative; the connect param is
# the dev/test fallback when the socket is not behind mTLS.
defp worker_id(params, connect_info) do
header_cn(connect_info) || empty_to_nil(params["worker_id"])
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Worker socket falls back to self-asserted identity

The socket documentation says production identity comes from the mTLS-forwarded x-worker-cn, but the implementation accepts the worker_id query parameter whenever that header is absent. If /worker is reachable without the proxy header, any client can register as a worker with chosen labels/address and receive provision payloads containing runtime env and bot tokens. Make the query-param fallback explicit to dev/test or require a signed token/mTLS identity in all production connections.

Comment on lines +121 to +136
timestamp = conn |> Plug.Conn.get_req_header("x-slack-request-timestamp") |> List.first()
provided = conn |> Plug.Conn.get_req_header("x-slack-signature") |> List.first()
expected = expected_signature(secret, timestamp, conn.assigns.raw_body)

cond do
is_nil(timestamp) or is_nil(provided) ->
{:error, :unauthorized, "missing Slack signature headers"}

byte_size(provided) != byte_size(expected) ->
{:error, :unauthorized, "signature mismatch"}

not Plug.Crypto.secure_compare(provided, expected) ->
{:error, :unauthorized, "signature mismatch"}

true ->
:ok

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Slack signatures can be replayed indefinitely

The verifier includes Slack's timestamp in the HMAC but never checks its freshness. A captured valid Slack request can be replayed later with the same timestamp/signature and will enqueue another app-mention run once the original run is no longer active. Parse x-slack-request-timestamp and reject requests outside Slack's replay window before accepting the signature.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant