diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000000..8cb8cbbdd0 --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,45 @@ +name: release-build + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build-release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up mise tools + uses: jdx/mise-action@v3 + with: + install: true + cache: true + working_directory: elixir + + - name: Install Mix dependencies + working-directory: elixir + run: | + mix local.hex --force + mix local.rebar --force + mix deps.get + + - name: Package release artifacts + run: ./scripts/package-release.sh Linux x86_64 + + - name: Publish GitHub release assets + 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/* --title "$TAG" --generate-notes + fi diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000000..9346ab7a0c --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,55 @@ +name: release-tag + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + create-tag: + runs-on: ubuntu-latest + defaults: + run: + working-directory: elixir + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up mise tools + uses: jdx/mise-action@v3 + with: + install: true + cache: true + working_directory: elixir + + - name: Install Mix dependencies + run: | + mix local.hex --force + mix local.rebar --force + mix deps.get + + - name: Resolve release version + id: version + run: | + version=$(sed -n 's/.*version: "\([^"]*\)".*/\1/p' mix.exs | head -n 1) + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Create tag when version is new + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + tag="v$VERSION" + + if git show-ref --tags --verify --quiet "refs/tags/$tag"; then + echo "Tag $tag already exists; skipping." + exit 0 + fi + + git tag "$tag" "$GITHUB_SHA" + git push origin "$tag" diff --git a/README.md b/README.md index 4c0264e692..cba6cb0dcd 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,19 @@ _In this [demo video](.github/media/symphony-demo.mp4), Symphony monitors a GitH > [!WARNING] > Symphony is a low-key engineering preview for testing in trusted environments. +## Quick install + +On Linux x86_64, install the latest packaged Symphony release into your user profile with: + +```bash +curl -fsSL https://raw.githubusercontent.com/BetterAndBetterII/symphony/main/scripts/install.sh | sh +``` + +After installation, run `symphony` from any project directory. It will create `WORKFLOW.md` when +missing and then boot the service from that directory. + +To pin a specific release, export `SYMPHONY_VERSION` before invoking the installer. + ## Running Symphony ### Requirements @@ -23,7 +36,7 @@ moving from managing coding agents to managing work that needs to get done. Tell your favorite coding agent to build Symphony in a programming language of your choice: > Implement Symphony according to the following spec: -> https://github.com/openai/symphony/blob/main/SPEC.md +> https://github.com/BetterAndBetterII/symphony/blob/main/SPEC.md ### Option 2. Use our experimental reference implementation @@ -32,7 +45,7 @@ and run the Elixir-based Symphony implementation. You can also ask your favorite help with the setup: > Set up Symphony for my repository based on -> https://github.com/openai/symphony/blob/main/elixir/README.md +> https://github.com/BetterAndBetterII/symphony/blob/main/elixir/README.md --- diff --git a/SPEC.md b/SPEC.md index f833f90a7b..9640092c7e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2105,6 +2105,9 @@ Use the same validation profiles as Section 17: exposes the baseline endpoints/error semantics in Section 13.7 if shipped. - Optional `github_graphql` client-side tool extension exposes raw GitHub GraphQL access through the app-server session using configured Symphony auth. +- Recommended distribution profile: ship a self-contained release artifact plus a user-level + installer script so that end users can install and run Symphony without language toolchains + installed (see Section 19). - TODO: Persist retry queue and session metadata across process restarts. - TODO: Make observability settings configurable in workflow front matter without prescribing UI implementation details. @@ -2118,3 +2121,115 @@ Use the same validation profiles as Section 17: - 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 bind expectations on the target environment. + +## 19. Distribution and Installation Profile (Recommended) + +This section defines a recommended "single artifact you can run" distribution profile for Symphony +implementations. + +This profile is intended to: + +- Avoid requiring end users to install language toolchains (for example Elixir/Mix). +- Provide a stable, versioned download surface (GitHub Releases). +- Provide a one-line installer suitable for automation (`curl | sh`), without requiring root. + +### 19.1 Release Artifact Contract + +An implementation that ships this profile SHOULD publish a release artifact per supported platform. + +Recommended contract: + +- Archive format: `.tar.gz` +- Asset naming: `-v--.tar.gz` + - Example: `symphony-v0.1.0-linux-amd64.tar.gz` +- Archive contents: a self-contained runtime directory that can be extracted and executed directly. +- The extracted payload MUST include everything required to run Symphony on that platform, except: + - an issue tracker token (provided via `WORKFLOW.md` or environment variables), and + - the coding-agent executable itself (for example `codex`). + +### 19.2 Installer Script Contract + +An implementation that ships this profile SHOULD provide a user-level installer script that: + +- Can be executed via a single command line, for example: + - `curl -fsSL | sh` +- Installs into user-writable directories (no `sudo`). +- Installs an executable named `symphony` into a directory intended for user binaries. + - Recommended default: `${XDG_BIN_HOME:-$HOME/.local/bin}` +- Installs the runtime payload into a versioned directory intended for user data. + - Recommended default: `${XDG_DATA_HOME:-$HOME/.local/share}/symphony//` +- Supports selecting a specific version via an environment variable (for example `SYMPHONY_VERSION`), + defaulting to the latest GitHub Release when unset. +- Detects platform + architecture (`uname -s`, `uname -m`) and selects the correct asset name. +- Fails with an actionable error message when the platform is unsupported or download/extract fails. + +Security and safety recommendations: + +- Prefer downloading from `releases/latest/download/` to avoid JSON parsing dependencies. +- Optionally publish a `sha256` checksum file per release and verify downloads in the installer when + `sha256sum` is available. + +### 19.3 Default WORKFLOW.md Bootstrap Contract + +To support quick starts, an implementation that ships this profile SHOULD make `symphony` runnable +from any directory: + +- If `./WORKFLOW.md` exists: start Symphony using that workflow. +- If `./WORKFLOW.md` is missing: create a default `WORKFLOW.md` in the current directory, then + start Symphony using that newly created file. + +The default `WORKFLOW.md` SHOULD: + +- Include valid YAML front matter with a complete baseline configuration. +- Default to environment-backed tokens (for example `tracker.api_key: $GITHUB_TOKEN`). +- Use conservative defaults for sandboxing/approvals where applicable. +- Include clear inline comments or prompt text that indicates where to customize project-specific + settings (project owner/number, clone URL, etc). + +### 19.4 GitHub Release Automation + +For GitHub-hosted repos, the recommended automation is: + +- On each push to `main`, read the repo version (for example from a version constant). +- If the corresponding tag `v` does not exist, create: + - a Git tag `v` pointing at that commit, and + - a GitHub Release for that tag. +- Build a release artifact for each supported platform and upload it to that GitHub Release. + +Implementation note (Elixir reference implementation): + +- Use `mix release` with `include_erts: true` so the target host does not need Elixir/Mix installed. + +### 19.5 Milestones and Validation (Suggested) + +Suggested implementation milestones for this profile: + +1. Build a self-contained release artifact + - Add a release definition (for example `mix release`) that bundles the runtime for the target + platform. + - Produce `.tar.gz` assets using the naming contract in Section 19.1. +2. Provide a stable installer entrypoint + - Add an install script (for example `scripts/install.sh`) that installs the runtime payload into + user-writable locations and exposes the `symphony` executable in `${XDG_BIN_HOME:-$HOME/.local/bin}`. +3. Provide a first-run bootstrap for `WORKFLOW.md` + - Ensure `symphony` creates `./WORKFLOW.md` if missing, using a default template that is valid + and runnable once required credentials are present. +4. Automate releases on GitHub Actions + - Add workflows that create `v` tags and publish GitHub Releases with the built assets + attached. +5. Update documentation + - Document the installer, supported platforms, and required runtime dependencies (token, `git`, + `curl`, `tar`, and the coding-agent executable). + +Suggested validation for this profile: + +- Local validation: + - Build the release artifact and start `symphony` in a clean directory that does not contain a + `WORKFLOW.md`; verify that it creates the file and starts the service. + - Verify that the installed `symphony` works when Elixir/Mix are not present on `$PATH` (for + example by running in a minimal container/VM). +- CI validation: + - Ensure the release workflow builds at least one target and uploads an asset to a GitHub Release. +- Failure-mode validation: + - Run the installer on an unsupported `uname -s` / `uname -m` combination and confirm it fails + with a clear error message. diff --git a/elixir/README.md b/elixir/README.md index c2a21e2307..0b41c7530f 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -41,7 +41,7 @@ Symphony stops the active agent for that issue and cleans up matching workspaces - Configure the Project field used as "status" (default: `Status`). - Ensure the Project field values match your expected states (for example: `Todo`, `In Progress`, `In Review`, `Merging`, `Done`, `Rework`). -6. Follow the instructions below to install the required runtime dependencies and start the service. +6. Either install the packaged release (no Elixir/Mix required on the target host) or use the source workflow below for local development. ## Prerequisites @@ -52,16 +52,33 @@ mise install mise exec -- elixir --version ``` -## Run +## Install packaged release + +For Linux x86_64 hosts, install the latest release into your user profile without Elixir or Mix: + +```bash +curl -fsSL https://raw.githubusercontent.com/BetterAndBetterII/symphony/main/scripts/install.sh | sh +``` + +To install a specific version instead of the latest release: + +```bash +SYMPHONY_VERSION=0.1.0 curl -fsSL https://raw.githubusercontent.com/BetterAndBetterII/symphony/main/scripts/install.sh | sh +``` + +The installer places the user-facing `symphony` command in `${XDG_BIN_HOME:-$HOME/.local/bin}` and +keeps the versioned runtime payload under `${XDG_DATA_HOME:-$HOME/.local/share}/symphony/`. + +## Run from source ```bash -git clone https://github.com/openai/symphony +git clone https://github.com/BetterAndBetterII/symphony cd symphony/elixir mise trust -y mise install mise exec -- mix setup mise exec -- mix build -mise exec -- ./bin/symphony --i-understand-that-this-will-be-running-without-the-usual-guardrails ./WORKFLOW.md +mise exec -- ./bin/symphony ``` ## Configuration @@ -72,7 +89,7 @@ Pass a custom workflow file path to `./bin/symphony` when starting the service: ./bin/symphony /path/to/custom/WORKFLOW.md ``` -If no path is passed, Symphony defaults to `./WORKFLOW.md`. +If no path is passed, Symphony defaults to `./WORKFLOW.md` and creates a default template there when the file is missing. Passing an explicit path still requires that file to exist. Optional flags: @@ -145,7 +162,7 @@ codex: command: "$CODEX_BIN app-server --model gpt-5.3-codex" ``` -- If `WORKFLOW.md` is missing or has invalid YAML, startup and scheduling are halted until fixed. +- If the default `./WORKFLOW.md` is missing, Symphony creates a starter template on first run. Missing explicit workflow paths or invalid YAML still halt startup until fixed. - `server.port` or CLI `--port` enables the optional Phoenix LiveView dashboard and JSON API at `/`, `/api/v1/state`, `/api/v1/`, and `/api/v1/refresh`. diff --git a/elixir/lib/symphony_elixir/cli.ex b/elixir/lib/symphony_elixir/cli.ex index d5c7eb1833..92aa8f5a5d 100644 --- a/elixir/lib/symphony_elixir/cli.ex +++ b/elixir/lib/symphony_elixir/cli.ex @@ -3,14 +3,15 @@ defmodule SymphonyElixir.CLI do Escript entrypoint for running Symphony with an explicit WORKFLOW.md path. """ - alias SymphonyElixir.LogFile + alias SymphonyElixir.{DefaultWorkflow, LogFile} - @acknowledgement_switch :i_understand_that_this_will_be_running_without_the_usual_guardrails - @switches [{@acknowledgement_switch, :boolean}, logs_root: :string, port: :integer] + @switches [logs_root: :string, port: :integer] @type ensure_started_result :: {:ok, [atom()]} | {:error, term()} @type deps :: %{ file_regular?: (String.t() -> boolean()), + write_default_workflow: (String.t() -> :ok | {:error, term()}), + notify: (String.t() -> term()), set_workflow_file_path: (String.t() -> :ok | {:error, term()}), set_logs_root: (String.t() -> :ok | {:error, term()}), set_server_port_override: (non_neg_integer() | nil -> :ok | {:error, term()}), @@ -33,17 +34,15 @@ defmodule SymphonyElixir.CLI do def evaluate(args, deps \\ runtime_deps()) do case OptionParser.parse(args, strict: @switches) do {opts, [], []} -> - with :ok <- require_guardrails_acknowledgement(opts), - :ok <- maybe_set_logs_root(opts, deps), + with :ok <- maybe_set_logs_root(opts, deps), :ok <- maybe_set_server_port(opts, deps) do - run(Path.expand("WORKFLOW.md"), deps) + run(Path.expand("WORKFLOW.md"), deps, bootstrap?: true) end {opts, [workflow_path], []} -> - with :ok <- require_guardrails_acknowledgement(opts), - :ok <- maybe_set_logs_root(opts, deps), + with :ok <- maybe_set_logs_root(opts, deps), :ok <- maybe_set_server_port(opts, deps) do - run(workflow_path, deps) + run(workflow_path, deps, bootstrap?: false) end _ -> @@ -51,13 +50,13 @@ defmodule SymphonyElixir.CLI do end end - @spec run(String.t(), deps()) :: :ok | {:error, String.t()} - def run(workflow_path, deps) do + @spec run(String.t(), deps(), keyword()) :: :ok | {:error, String.t()} + def run(workflow_path, deps, opts \\ []) do expanded_path = Path.expand(workflow_path) + bootstrap? = Keyword.get(opts, :bootstrap?, false) - if deps.file_regular?.(expanded_path) do - :ok = deps.set_workflow_file_path.(expanded_path) - + with :ok <- ensure_workflow_file(expanded_path, bootstrap?, deps), + :ok <- deps.set_workflow_file_path.(expanded_path) do case deps.ensure_all_started.() do {:ok, _started_apps} -> :ok @@ -65,8 +64,6 @@ defmodule SymphonyElixir.CLI do {:error, reason} -> {:error, "Failed to start Symphony with workflow #{expanded_path}: #{inspect(reason)}"} end - else - {:error, "Workflow file not found: #{expanded_path}"} end end @@ -79,6 +76,8 @@ defmodule SymphonyElixir.CLI do defp runtime_deps do %{ file_regular?: &File.regular?/1, + write_default_workflow: &DefaultWorkflow.write/1, + notify: &IO.puts/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, @@ -86,6 +85,33 @@ defmodule SymphonyElixir.CLI do } end + defp ensure_workflow_file(path, true, deps) do + if deps.file_regular?.(path) do + :ok + else + case deps.write_default_workflow.(path) do + :ok -> + deps.notify.(bootstrap_message(path)) + :ok + + {:error, reason} -> + {:error, "Failed to initialize workflow file #{path}: #{inspect(reason)}"} + end + end + end + + defp ensure_workflow_file(path, false, deps) do + if deps.file_regular?.(path) do + :ok + else + {:error, "Workflow file not found: #{path}"} + end + end + + defp bootstrap_message(path) do + "Created default WORKFLOW.md at #{path}. Update GITHUB_TOKEN, GITHUB_PROJECT_OWNER, GITHUB_PROJECT_NUMBER, SOURCE_REPO_URL, and SYMPHONY_WORKSPACE_ROOT for your repo." + end + defp maybe_set_logs_root(opts, deps) do case Keyword.get_values(opts, :logs_root) do [] -> @@ -102,47 +128,6 @@ defmodule SymphonyElixir.CLI do end end - defp require_guardrails_acknowledgement(opts) do - if Keyword.get(opts, @acknowledgement_switch, false) do - :ok - else - {:error, acknowledgement_banner()} - end - end - - @spec acknowledgement_banner() :: String.t() - defp acknowledgement_banner do - lines = [ - "This Symphony implementation is a low key engineering preview.", - "Codex will run without any guardrails.", - "SymphonyElixir is not a supported product and is presented as-is.", - "To proceed, start with `--i-understand-that-this-will-be-running-without-the-usual-guardrails` CLI argument" - ] - - width = Enum.max(Enum.map(lines, &String.length/1)) - border = String.duplicate("─", width + 2) - top = "╭" <> border <> "╮" - bottom = "╰" <> border <> "╯" - spacer = "│ " <> String.duplicate(" ", width) <> " │" - - content = - [ - top, - spacer - | Enum.map(lines, fn line -> - "│ " <> String.pad_trailing(line, width) <> " │" - end) - ] ++ [spacer, bottom] - - [ - IO.ANSI.red(), - IO.ANSI.bright(), - Enum.join(content, "\n"), - IO.ANSI.reset() - ] - |> IO.iodata_to_binary() - end - defp set_logs_root(logs_root) do Application.put_env(:symphony_elixir, :log_file, LogFile.default_log_file(logs_root)) :ok diff --git a/elixir/lib/symphony_elixir/config.ex b/elixir/lib/symphony_elixir/config.ex index e1fff8b142..52a1bf2ad9 100644 --- a/elixir/lib/symphony_elixir/config.ex +++ b/elixir/lib/symphony_elixir/config.ex @@ -806,35 +806,45 @@ defmodule SymphonyElixir.Config do defp parse_integer(_value), do: :error defp parse_integer_string(value, depth) when is_binary(value) and depth < 5 do - if value == "" do + value + |> String.trim() + |> parse_integer_token(depth) + end + + defp parse_integer_string(_value, _depth), do: :error + + defp parse_integer_token("", _depth), do: :error + + defp parse_integer_token(value, depth) do + case env_reference_name(value) do + {:ok, env_name} -> parse_integer_env_reference(env_name, value, depth) + :error -> parse_integer_literal(value) + end + end + + defp parse_integer_env_reference(env_name, value, depth) do + case resolve_env_token(env_name) do + env_value when is_binary(env_value) -> parse_integer_env_value(env_value, value, depth) + :missing -> :error + end + end + + defp parse_integer_env_value(env_value, original_value, depth) do + env_trimmed = String.trim(env_value) + + if env_trimmed in ["", original_value] do :error else - case env_reference_name(value) do - {:ok, env_name} -> - case resolve_env_token(env_name) do - :missing -> - :error - - env_value when is_binary(env_value) -> - env_trimmed = String.trim(env_value) - - cond do - env_trimmed == "" -> :error - env_trimmed == value -> :error - true -> parse_integer_string(env_trimmed, depth + 1) - end - end - - :error -> - case Integer.parse(value) do - {parsed, _} -> {:ok, parsed} - :error -> :error - end - end + parse_integer_string(env_trimmed, depth + 1) end end - defp parse_integer_string(_value, _depth), do: :error + defp parse_integer_literal(value) do + case Integer.parse(value) do + {parsed, _} -> {:ok, parsed} + :error -> :error + end + end defp parse_positive_integer(value) do case parse_integer(value) do diff --git a/elixir/lib/symphony_elixir/default_workflow.ex b/elixir/lib/symphony_elixir/default_workflow.ex new file mode 100644 index 0000000000..17fe627301 --- /dev/null +++ b/elixir/lib/symphony_elixir/default_workflow.ex @@ -0,0 +1,23 @@ +defmodule SymphonyElixir.DefaultWorkflow do + @moduledoc """ + Provides the default `WORKFLOW.md` template used for first-run bootstrap. + """ + + @template_path Path.expand("../../priv/default_workflow.md", __DIR__) + @external_resource @template_path + @template_contents File.read!(@template_path) + + @spec contents() :: String.t() + def contents do + @template_contents + end + + @spec write(Path.t()) :: :ok | {:error, term()} + def write(path) when is_binary(path) do + expanded_path = Path.expand(path) + + with :ok <- File.mkdir_p(Path.dirname(expanded_path)) do + File.write(expanded_path, contents()) + end + end +end diff --git a/elixir/mix.exs b/elixir/mix.exs index f40efcbd36..ae90bf64d9 100644 --- a/elixir/mix.exs +++ b/elixir/mix.exs @@ -49,6 +49,7 @@ defmodule SymphonyElixir.MixProject do plt_add_apps: [:mix] ], escript: escript(), + releases: releases(), aliases: aliases(), deps: deps() ] @@ -97,4 +98,14 @@ defmodule SymphonyElixir.MixProject do path: "bin/symphony" ] end + + defp releases do + [ + symphony: [ + applications: [symphony_elixir: :permanent], + include_erts: true, + include_executables_for: [:unix] + ] + ] + end end diff --git a/elixir/priv/default_workflow.md b/elixir/priv/default_workflow.md new file mode 100644 index 0000000000..891e0797f6 --- /dev/null +++ b/elixir/priv/default_workflow.md @@ -0,0 +1,57 @@ +--- +tracker: + kind: github_project + endpoint: https://api.github.com/graphql + api_key: $GITHUB_TOKEN + project_owner: $GITHUB_PROJECT_OWNER + project_number: $GITHUB_PROJECT_NUMBER + project_field_status: Status + active_states: + - Todo + - In Progress + - Merging + - Rework + terminal_states: + - Closed + - Cancelled + - Canceled + - Duplicate + - Done +polling: + interval_ms: 5000 +workspace: + root: $SYMPHONY_WORKSPACE_ROOT +hooks: + after_create: | + git clone --depth 1 "$SOURCE_REPO_URL" . +agent: + max_concurrent_agents: 10 + max_turns: 20 +codex: + command: codex app-server + approval_policy: untrusted + thread_sandbox: workspace-write +server: + port: 0 + host: 127.0.0.1 +--- + +Customize these environment variables before running Symphony against your repo: + +- `GITHUB_TOKEN`: GitHub token with access to the project and repository. +- `GITHUB_PROJECT_OWNER`: GitHub org or user that owns the ProjectV2 board. +- `GITHUB_PROJECT_NUMBER`: numeric ProjectV2 number. +- `SOURCE_REPO_URL`: repository clone URL used for new workspaces. +- `SYMPHONY_WORKSPACE_ROOT`: directory for local issue workspaces. + +You are working on an issue from the configured tracker. + +Identifier: {{ issue.identifier }} +Title: {{ issue.title }} + +Body: +{% if issue.description %} +{{ issue.description }} +{% else %} +No description provided. +{% endif %} diff --git a/elixir/test/symphony_elixir/cli_test.exs b/elixir/test/symphony_elixir/cli_test.exs index 4e42147936..3e0b6c421c 100644 --- a/elixir/test/symphony_elixir/cli_test.exs +++ b/elixir/test/symphony_elixir/cli_test.exs @@ -3,56 +3,37 @@ defmodule SymphonyElixir.CLITest do alias SymphonyElixir.CLI - @ack_flag "--i-understand-that-this-will-be-running-without-the-usual-guardrails" - - test "returns the guardrails acknowledgement banner when the flag is missing" do + test "defaults to WORKFLOW.md when workflow path is missing and bootstraps it" do parent = self() deps = %{ - file_regular?: fn _path -> - send(parent, :file_checked) - true + file_regular?: fn path -> + send(parent, {:workflow_checked, path}) + false end, - set_workflow_file_path: fn _path -> - send(parent, :workflow_set) + write_default_workflow: fn path -> + send(parent, {:workflow_bootstrapped, path}) :ok end, - set_logs_root: fn _path -> - send(parent, :logs_root_set) - :ok + notify: fn message -> + send(parent, {:notified, message}) end, - set_server_port_override: fn _port -> - send(parent, :port_set) + set_workflow_file_path: fn path -> + send(parent, {:workflow_set, path}) :ok end, - ensure_all_started: fn -> - send(parent, :started) - {:ok, [:symphony_elixir]} - end - } - - assert {:error, banner} = CLI.evaluate(["WORKFLOW.md"], deps) - assert banner =~ "This Symphony implementation is a low key engineering preview." - assert banner =~ "Codex will run without any guardrails." - assert banner =~ "SymphonyElixir is not a supported product and is presented as-is." - assert banner =~ @ack_flag - refute_received :file_checked - refute_received :workflow_set - refute_received :logs_root_set - refute_received :port_set - refute_received :started - end - - test "defaults to WORKFLOW.md when workflow path is missing" do - deps = %{ - file_regular?: fn path -> Path.basename(path) == "WORKFLOW.md" end, - set_workflow_file_path: fn _path -> :ok end, set_logs_root: fn _path -> :ok end, set_server_port_override: fn _port -> :ok end, ensure_all_started: fn -> {:ok, [:symphony_elixir]} end } - assert :ok = CLI.evaluate([@ack_flag], deps) + assert :ok = CLI.evaluate([], deps) + assert_received {:workflow_checked, expanded_path} + assert_received {:workflow_bootstrapped, ^expanded_path} + assert_received {:workflow_set, ^expanded_path} + assert_received {:notified, message} + assert expanded_path == Path.expand("WORKFLOW.md") + assert message =~ "Created default WORKFLOW.md" end test "uses an explicit workflow path override when provided" do @@ -65,6 +46,13 @@ defmodule SymphonyElixir.CLITest do send(parent, {:workflow_checked, path}) path == expanded_path end, + write_default_workflow: fn _path -> + send(parent, :unexpected_bootstrap) + :ok + end, + notify: fn _message -> + send(parent, :unexpected_notify) + end, set_workflow_file_path: fn path -> send(parent, {:workflow_set, path}) :ok @@ -74,9 +62,11 @@ defmodule SymphonyElixir.CLITest do ensure_all_started: fn -> {:ok, [:symphony_elixir]} end } - assert :ok = CLI.evaluate([@ack_flag, workflow_path], deps) + assert :ok = CLI.evaluate([workflow_path], deps) assert_received {:workflow_checked, ^expanded_path} assert_received {:workflow_set, ^expanded_path} + refute_received :unexpected_bootstrap + refute_received :unexpected_notify end test "accepts --logs-root and passes an expanded root to runtime deps" do @@ -84,6 +74,8 @@ defmodule SymphonyElixir.CLITest do deps = %{ file_regular?: fn _path -> true end, + write_default_workflow: fn _path -> :ok end, + notify: fn _message -> :ok end, set_workflow_file_path: fn _path -> :ok end, set_logs_root: fn path -> send(parent, {:logs_root, path}) @@ -93,34 +85,38 @@ defmodule SymphonyElixir.CLITest do ensure_all_started: fn -> {:ok, [:symphony_elixir]} end } - assert :ok = CLI.evaluate([@ack_flag, "--logs-root", "tmp/custom-logs", "WORKFLOW.md"], deps) + assert :ok = CLI.evaluate(["--logs-root", "tmp/custom-logs", "WORKFLOW.md"], deps) assert_received {:logs_root, expanded_path} assert expanded_path == Path.expand("tmp/custom-logs") end - test "returns not found when workflow file does not exist" do + test "returns not found when an explicit workflow file does not exist" do deps = %{ file_regular?: fn _path -> false end, + write_default_workflow: fn _path -> :ok end, + notify: fn _message -> :ok end, set_workflow_file_path: fn _path -> :ok end, set_logs_root: fn _path -> :ok end, set_server_port_override: fn _port -> :ok end, ensure_all_started: fn -> {:ok, [:symphony_elixir]} end } - assert {:error, message} = CLI.evaluate([@ack_flag, "WORKFLOW.md"], deps) + assert {:error, message} = CLI.evaluate(["WORKFLOW.md"], deps) assert message =~ "Workflow file not found:" end test "returns startup error when app cannot start" do deps = %{ file_regular?: fn _path -> true end, + write_default_workflow: fn _path -> :ok end, + notify: fn _message -> :ok end, set_workflow_file_path: fn _path -> :ok end, set_logs_root: fn _path -> :ok end, set_server_port_override: fn _port -> :ok end, ensure_all_started: fn -> {:error, :boom} end } - assert {:error, message} = CLI.evaluate([@ack_flag, "WORKFLOW.md"], deps) + assert {:error, message} = CLI.evaluate(["WORKFLOW.md"], deps) assert message =~ "Failed to start Symphony with workflow" assert message =~ ":boom" end @@ -128,12 +124,14 @@ defmodule SymphonyElixir.CLITest do test "returns ok when workflow exists and app starts" do deps = %{ file_regular?: fn _path -> true end, + write_default_workflow: fn _path -> :ok end, + notify: fn _message -> :ok end, set_workflow_file_path: fn _path -> :ok end, set_logs_root: fn _path -> :ok end, set_server_port_override: fn _port -> :ok end, ensure_all_started: fn -> {:ok, [:symphony_elixir]} end } - assert :ok = CLI.evaluate([@ack_flag, "WORKFLOW.md"], deps) + assert :ok = CLI.evaluate(["WORKFLOW.md"], deps) end end diff --git a/elixir/test/symphony_elixir/core_test.exs b/elixir/test/symphony_elixir/core_test.exs index 749d252f4d..09a697a740 100644 --- a/elixir/test/symphony_elixir/core_test.exs +++ b/elixir/test/symphony_elixir/core_test.exs @@ -893,7 +893,7 @@ defmodule SymphonyElixir.CoreTest do assert prompt =~ "标题: Use rich templates for WORKFLOW.md" assert prompt =~ "当前状态: In Progress" assert prompt =~ "https://example.org/issues/MT-616/use-rich-templates-for-workflowmd" - assert prompt =~ "这是一个无人值守的编排会话。" + assert prompt =~ "这是一个无人值守的编排会话" assert prompt =~ "只有在真正阻塞(缺少必需的鉴权/权限/密钥)时才可以提前停止" assert prompt =~ "不要包含“用户下一步”" assert prompt =~ "打开并遵循 `.codex/skills/land/SKILL.md`" diff --git a/elixir/test/symphony_elixir/default_workflow_test.exs b/elixir/test/symphony_elixir/default_workflow_test.exs new file mode 100644 index 0000000000..0fb4360950 --- /dev/null +++ b/elixir/test/symphony_elixir/default_workflow_test.exs @@ -0,0 +1,45 @@ +defmodule SymphonyElixir.DefaultWorkflowTest do + use ExUnit.Case, async: false + + alias SymphonyElixir.{Config, DefaultWorkflow, Workflow} + + test "writes a valid default workflow once required env vars are present" do + original_workflow_path = Workflow.workflow_file_path() + workflow_path = Path.join(System.tmp_dir!(), "default-workflow-#{System.unique_integer([:positive])}.md") + + env_keys = [ + "GITHUB_TOKEN", + "GITHUB_PROJECT_OWNER", + "GITHUB_PROJECT_NUMBER", + "SOURCE_REPO_URL", + "SYMPHONY_WORKSPACE_ROOT" + ] + + previous_env = Map.new(env_keys, fn key -> {key, System.get_env(key)} end) + + on_exit(fn -> + Workflow.set_workflow_file_path(original_workflow_path) + File.rm(workflow_path) + + Enum.each(previous_env, fn + {key, nil} -> System.delete_env(key) + {key, value} -> System.put_env(key, value) + end) + end) + + System.put_env("GITHUB_TOKEN", "token-123") + System.put_env("GITHUB_PROJECT_OWNER", "example-org") + System.put_env("GITHUB_PROJECT_NUMBER", "42") + System.put_env("SOURCE_REPO_URL", "git@github.com:example-org/example-repo.git") + System.put_env("SYMPHONY_WORKSPACE_ROOT", Path.join(System.tmp_dir!(), "default-workflow-root")) + + assert :ok = DefaultWorkflow.write(workflow_path) + assert File.read!(workflow_path) == DefaultWorkflow.contents() + + assert :ok = Workflow.set_workflow_file_path(workflow_path) + assert {:ok, workflow} = Workflow.current() + assert get_in(workflow, [:config, "tracker", "kind"]) == "github_project" + assert workflow.prompt =~ "You are working on an issue from the configured tracker." + assert :ok = Config.validate!() + end +end diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000000..8a8728ba35 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,149 @@ +#!/bin/sh +set -eu + +APP_NAME="Symphony" +DEFAULT_REPO="BetterAndBetterII/symphony" + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +info() { + printf '==> %s\n' "$*" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" +} + +normalize_os() { + case "$1" in + Linux|linux) + echo "linux" + ;; + *) + return 1 + ;; + esac +} + +normalize_arch() { + case "$1" in + x86_64|amd64) + echo "amd64" + ;; + *) + return 1 + ;; + esac +} + +normalize_version() { + case "$1" in + v*) + echo "$1" + ;; + *) + echo "v$1" + ;; + esac +} + +write_wrapper() { + wrapper_path=$1 + + cat > "$wrapper_path" <<'WRAPPER' +#!/bin/sh +set -eu + +data_root=${SYMPHONY_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/symphony} +release_bin="$data_root/current/release/bin/symphony" + +if [ ! -x "$release_bin" ]; then + echo "Symphony is not installed under $data_root/current. Re-run the installer." >&2 + exit 1 +fi + +exec "$release_bin" eval 'SymphonyElixir.CLI.main(System.argv())' "$@" +WRAPPER + + chmod +x "$wrapper_path" +} + +need_cmd curl +need_cmd tar +need_cmd mktemp +need_cmd dirname +need_cmd mkdir +need_cmd ln +need_cmd rm +need_cmd mv +need_cmd cat +need_cmd find +need_cmd head + +repo=${SYMPHONY_REPO:-$DEFAULT_REPO} +release_base_url=${SYMPHONY_RELEASE_BASE_URL:-"https://github.com/${repo}/releases"} +os=$(normalize_os "$(uname -s)") || fail "unsupported platform: $(uname -s) (supported: Linux x86_64)" +arch=$(normalize_arch "$(uname -m)") || fail "unsupported architecture: $(uname -m) (supported: Linux x86_64)" +bin_dir=${SYMPHONY_BIN_DIR:-${XDG_BIN_HOME:-$HOME/.local/bin}} +data_root=${SYMPHONY_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/symphony} + +if [ -n "${SYMPHONY_VERSION:-}" ]; then + version_tag=$(normalize_version "$SYMPHONY_VERSION") + requested_label=$version_tag + asset_name="symphony-${version_tag}-${os}-${arch}.tar.gz" + download_url="${release_base_url}/download/${version_tag}/${asset_name}" +else + requested_label="latest" + asset_name="symphony-${os}-${arch}.tar.gz" + download_url="${release_base_url}/latest/download/${asset_name}" +fi + +tmp_root=$(mktemp -d) +archive_path="$tmp_root/$asset_name" +cleanup() { + rm -rf "$tmp_root" +} +trap cleanup EXIT INT TERM + +info "Downloading ${APP_NAME} ${requested_label} for ${os}/${arch}" +curl -fsSL "$download_url" -o "$archive_path" || fail "download failed: $download_url" + +info "Extracting release payload" +tar -xzf "$archive_path" -C "$tmp_root" + +package_dir=$(find "$tmp_root" -mindepth 1 -maxdepth 1 -type d | head -n 1) +[ -n "$package_dir" ] || fail "release archive did not contain an installable payload" +[ -f "$package_dir/VERSION" ] || fail "release payload is missing VERSION metadata" + +version=$(cat "$package_dir/VERSION") +[ -n "$version" ] || fail "release payload VERSION file is empty" + +target_dir="$data_root/$version" +release_bin="$target_dir/release/bin/symphony" + +info "Installing ${APP_NAME} ${version} into $target_dir" +mkdir -p "$data_root" +rm -rf "$target_dir" +mv "$package_dir" "$target_dir" +ln -sfn "$target_dir" "$data_root/current" + +mkdir -p "$bin_dir" +write_wrapper "$bin_dir/symphony" + +[ -x "$release_bin" ] || fail "installed payload is missing release/bin/symphony" + +info "Installed binary: $bin_dir/symphony" +info "Current release: $data_root/current" + +case ":$PATH:" in + *":$bin_dir:"*) + ;; + *) + printf 'Note: add %s to PATH to run `symphony` from new shells.\n' "$bin_dir" + ;; +esac + +printf 'Run `symphony` from any project directory. It will create `WORKFLOW.md` when missing.\n' diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100755 index 0000000000..1258742772 --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,98 @@ +#!/bin/sh +set -eu + +fail() { + echo "error: $*" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" +} + +normalize_os() { + case "$1" in + Linux|linux) + echo "linux" + ;; + *) + return 1 + ;; + esac +} + +normalize_arch() { + case "$1" in + x86_64|amd64) + echo "amd64" + ;; + *) + return 1 + ;; + esac +} + +SCRIPT_DIR=$(CDPATH='' cd "$(dirname "$0")" && pwd -P) +REPO_ROOT=$(CDPATH='' cd "$SCRIPT_DIR/.." && pwd -P) +ELIXIR_DIR="$REPO_ROOT/elixir" +OUTPUT_DIR=${OUTPUT_DIR:-"$REPO_ROOT/dist"} +OS_INPUT=${1:-$(uname -s)} +ARCH_INPUT=${2:-$(uname -m)} + +need_cmd mix +need_cmd tar +need_cmd mktemp +need_cmd cp +need_cmd rm +need_cmd find +need_cmd sed +need_cmd head +need_cmd sha256sum + +os=$(normalize_os "$OS_INPUT") || fail "unsupported release OS: $OS_INPUT (supported: Linux)" +arch=$(normalize_arch "$ARCH_INPUT") || fail "unsupported release architecture: $ARCH_INPUT (supported: x86_64)" + +version=$(sed -n 's/.*version: "\([^"]*\)".*/\1/p' "$ELIXIR_DIR/mix.exs" | head -n 1) +[ -n "$version" ] || fail "failed to resolve Mix project version" + +artifact_root="symphony-v${version}-${os}-${arch}" +versioned_asset="${artifact_root}.tar.gz" +latest_asset="symphony-${os}-${arch}.tar.gz" +checksum_file="symphony-${os}-${arch}.sha256" + +build_root=$(mktemp -d) +release_root="$build_root/release" +package_root="$build_root/$artifact_root" +cleanup() { + rm -rf "$build_root" +} +trap cleanup EXIT INT TERM + +mkdir -p "$OUTPUT_DIR" +rm -f "$OUTPUT_DIR/$versioned_asset" "$OUTPUT_DIR/$latest_asset" "$OUTPUT_DIR/$checksum_file" + +( + cd "$ELIXIR_DIR" + MIX_ENV=prod mix release symphony --overwrite --path "$release_root" +) + +[ -x "$release_root/bin/symphony" ] || fail "release build did not produce release/bin/symphony" + +mkdir -p "$package_root/release" +printf '%s\n' "$version" > "$package_root/VERSION" +cp -R "$release_root/." "$package_root/release/" + +( + cd "$build_root" + tar -czf "$OUTPUT_DIR/$versioned_asset" "$artifact_root" +) +cp "$OUTPUT_DIR/$versioned_asset" "$OUTPUT_DIR/$latest_asset" + +( + cd "$OUTPUT_DIR" + sha256sum "$versioned_asset" "$latest_asset" > "$checksum_file" +) + +printf 'Built %s\n' "$OUTPUT_DIR/$versioned_asset" +printf 'Built %s\n' "$OUTPUT_DIR/$latest_asset" +printf 'Built %s\n' "$OUTPUT_DIR/$checksum_file"