diff --git a/.archon/commands/defaults/archon-create-pr.md b/.archon/commands/defaults/archon-create-pr.md index becbd7079e..a2c4b3138b 100644 --- a/.archon/commands/defaults/archon-create-pr.md +++ b/.archon/commands/defaults/archon-create-pr.md @@ -19,12 +19,16 @@ Extract the issue number from the current branch name or context (e.g., `fix/iss ```bash BRANCH=$(git branch --show-current) ISSUE_NUM=$(echo "$BRANCH" | grep -oE '[0-9]+' | tail -1) +# Pin all gh pr commands to the origin remote — in a fork clone, gh otherwise +# targets the upstream parent repo. Re-run this line in every new shell. +ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') ``` If an issue number was found, search for open PRs that already reference it: ```bash gh pr list \ + --repo "$ORIGIN_REPO" \ --search "Fixes #${ISSUE_NUM} OR Closes #${ISSUE_NUM}" \ --state open \ --json number,url,headRefName @@ -156,7 +160,11 @@ cat > $ARTIFACTS_DIR/pr-body.md <<'EOF' [body from above] EOF +# Fork-safe target: without --repo, gh opens the PR against the upstream parent +ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') + gh pr create \ + --repo "$ORIGIN_REPO" \ --title "[title]" \ --body-file $ARTIFACTS_DIR/pr-body.md \ --base $BASE_BRANCH @@ -165,7 +173,7 @@ gh pr create \ Or if the content is simple: ```bash -gh pr create --fill --base $BASE_BRANCH +gh pr create --repo "$ORIGIN_REPO" --fill --base $BASE_BRANCH ``` After creating the PR, capture its identifiers for downstream steps. Only write artifacts if PR creation succeeded — never persist stale data from a pre-existing PR: @@ -173,9 +181,10 @@ After creating the PR, capture its identifiers for downstream steps. Only write ```bash # After creating the PR, capture and persist the PR number for downstream steps # IMPORTANT: Only write artifacts after confirmed successful PR creation -if gh pr view --json number,url -q '.number,.url' > /dev/null 2>&1; then - PR_NUMBER=$(gh pr view --json number -q '.number') - PR_URL=$(gh pr view --json url -q '.url') +ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') +if gh pr view --repo "$ORIGIN_REPO" --json number,url -q '.number,.url' > /dev/null 2>&1; then + PR_NUMBER=$(gh pr view --repo "$ORIGIN_REPO" --json number -q '.number') + PR_URL=$(gh pr view --repo "$ORIGIN_REPO" --json url -q '.url') echo "$PR_NUMBER" > "$ARTIFACTS_DIR/.pr-number" echo "$PR_URL" > "$ARTIFACTS_DIR/.pr-url" else @@ -219,7 +228,8 @@ Nothing to create a PR for. ### Branch Already Has PR ```bash -gh pr view --web +ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') +gh pr view --repo "$ORIGIN_REPO" --web ``` Opens the existing PR instead of creating a duplicate. diff --git a/.archon/commands/defaults/archon-finalize-pr.md b/.archon/commands/defaults/archon-finalize-pr.md index a7c00e622d..7e3b2f9214 100644 --- a/.archon/commands/defaults/archon-finalize-pr.md +++ b/.archon/commands/defaults/archon-finalize-pr.md @@ -47,7 +47,10 @@ Extract: ### 1.3 Check for Existing PR ```bash -gh pr list --head $(git branch --show-current) --json number,url,state +# Pin all gh pr commands to the origin remote — in a fork clone, gh otherwise +# targets the upstream parent repo. Re-run this line in every new shell. +ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') +gh pr list --repo "$ORIGIN_REPO" --head $(git branch --show-current) --json number,url,state ``` **If PR already exists**: Will update it instead of creating new one. @@ -190,7 +193,11 @@ cat > $ARTIFACTS_DIR/pr-body.md <<'EOF' {prepared-body} EOF +# Fork-safe target: without --repo, gh opens the PR against the upstream parent +ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') + gh pr create \ + --repo "$ORIGIN_REPO" \ --title "{plan-title}" \ --body-file $ARTIFACTS_DIR/pr-body.md \ --base $BASE_BRANCH @@ -199,7 +206,8 @@ gh pr create \ **If PR already exists**, update it: ```bash -gh pr edit {pr-number} --body-file $ARTIFACTS_DIR/pr-body.md +ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') +gh pr edit {pr-number} --repo "$ORIGIN_REPO" --body-file $ARTIFACTS_DIR/pr-body.md ``` ### 3.3 Ensure Ready for Review @@ -207,13 +215,15 @@ gh pr edit {pr-number} --body-file $ARTIFACTS_DIR/pr-body.md If PR was created as draft, mark ready: ```bash -gh pr ready {pr-number} 2>/dev/null || true +ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') +gh pr ready {pr-number} --repo "$ORIGIN_REPO" 2>/dev/null || true ``` ### 3.4 Capture PR Info ```bash -gh pr view --json number,url,headRefName,baseRefName +ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') +gh pr view --repo "$ORIGIN_REPO" --json number,url,headRefName,baseRefName ``` ### 3.5 Write PR Number Registry @@ -221,8 +231,9 @@ gh pr view --json number,url,headRefName,baseRefName Write PR number for downstream review steps: ```bash -PR_NUMBER=$(gh pr view --json number -q '.number') -PR_URL=$(gh pr view --json url -q '.url') +ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') +PR_NUMBER=$(gh pr view --repo "$ORIGIN_REPO" --json number -q '.number') +PR_URL=$(gh pr view --repo "$ORIGIN_REPO" --json url -q '.url') echo "$PR_NUMBER" > $ARTIFACTS_DIR/.pr-number echo "$PR_URL" > $ARTIFACTS_DIR/.pr-url ``` @@ -387,8 +398,9 @@ Check: ``` ❌ PR not found: #{number} -The draft PR may have been closed or deleted. Create a new one: -`gh pr create --title "..." --body "..."` +The draft PR may have been closed or deleted. Create a new one +(re-run the `ORIGIN_REPO=...` resolve line first — it does not persist across shells): +`gh pr create --repo "$ORIGIN_REPO" --title "..." --body "..."` ``` ### Template Parsing diff --git a/.archon/commands/defaults/archon-fix-issue.md b/.archon/commands/defaults/archon-fix-issue.md index 080566e80c..24fe43e571 100644 --- a/.archon/commands/defaults/archon-fix-issue.md +++ b/.archon/commands/defaults/archon-fix-issue.md @@ -9,6 +9,50 @@ argument-hint: --- +## READ FIRST: you are almost certainly in a run worktree + +When this command runs inside an Archon workflow, the isolation system has **already** +created a git worktree on the correct branch. In that case: + +- **Use the current branch as-is.** Do not switch branches, do not create one, do not + fetch-and-reset. The branch you are on is the branch this work belongs to. +- **A dirty working tree is expected and is NOT a reason to stop.** Archon copies the + operator's `.archon/` directory — workflows, commands, scripts — into every run + worktree, deliberately, so a workflow can be iterated on before it is committed. + Those files are present *before* you start and are not your changes. +- **Modifications under `.archon/` are never yours to commit, stash, or remove.** + Leave them exactly as they are and commit only the files your implementation touched. + Before every commit, confirm with `git diff --cached --name-only` that nothing under + `.archon/` is staged. +- **Dirty paths outside `.archon/` are also not a reason to stop, and also not yours.** + They are either your own work from an earlier attempt at this run (resume reuses the + worktree) or something the operator left behind. Either way: leave them alone, do not + fold them into your commit, and stage your own files by name rather than with + `git add -A`. + +The clean-working-tree requirement in the decision tree below applies **only** to the +`ON $BASE_BRANCH` case — manual CLI use outside a worktree, where a stray edit really +could be lost. It does not apply in a worktree. If a skill or sub-workflow you load +imposes a stricter git precondition, **this instruction overrides it.** + +Classify the checkout before deciding anything. `git worktree list` does **not** answer +this — it lists every worktree including the primary checkout, so it looks identical +from both. Compare the two git dirs instead: + +```bash +if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then + echo "linked worktree — the rules above apply" +else + echo "primary checkout — follow the decision tree below as written" +fi +``` + +Stopping a run over pre-existing `.archon/` edits wastes the entire pipeline; it has +happened, three times. Applying the worktree exemption in the *primary* checkout is the +opposite error and can lose someone's uncommitted work. Classify first, then decide. + +--- + ## Your Mission Execute the implementation plan from `/investigate-issue`: diff --git a/.archon/commands/defaults/archon-implement-issue.md b/.archon/commands/defaults/archon-implement-issue.md index cceec6d217..e4bc9ebba6 100644 --- a/.archon/commands/defaults/archon-implement-issue.md +++ b/.archon/commands/defaults/archon-implement-issue.md @@ -348,7 +348,7 @@ EOF ## Phase 8: PR - Create Pull Request -**Before creating a PR**, check if one already exists for this issue or branch using `gh pr list`. If a PR already exists, skip creation and use the existing one. +**Before creating a PR**, check if one already exists for this issue or branch using `gh pr list --repo "$ORIGIN_REPO"` (resolve `ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##')` in the same shell — in a fork clone, gh otherwise targets the upstream parent). If a PR already exists, skip creation and use the existing one. ### 8.1 Push to Remote @@ -374,7 +374,10 @@ Look for the project's PR template at `.github/pull_request_template.md`, `.gith Write the prepared body to `$ARTIFACTS_DIR/pr-body.md`, then: ```bash -gh pr create --title "Fix: {title} (#{number})" \ +# Fork-safe target: without --repo, gh opens the PR against the upstream parent +ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') + +gh pr create --repo "$ORIGIN_REPO" --title "Fix: {title} (#{number})" \ --body-file $ARTIFACTS_DIR/pr-body.md \ --base $BASE_BRANCH ``` @@ -382,8 +385,9 @@ gh pr create --title "Fix: {title} (#{number})" \ ### 8.3 Get PR Number ```bash -PR_URL=$(gh pr view --json url -q '.url') -PR_NUMBER=$(gh pr view --json number -q '.number') +ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') +PR_URL=$(gh pr view --repo "$ORIGIN_REPO" --json url -q '.url') +PR_NUMBER=$(gh pr view --repo "$ORIGIN_REPO" --json number -q '.number') ``` **PHASE_8_CHECKPOINT:** diff --git a/.archon/maintainer-standup/direction.md b/.archon/maintainer-standup/direction.md index 3812d7ba1f..6adfd92e69 100644 --- a/.archon/maintainer-standup/direction.md +++ b/.archon/maintainer-standup/direction.md @@ -27,7 +27,8 @@ This file is **committed and shared by all maintainers**. Edit deliberately — - **Not a replacement for the AI coding agent itself.** Archon orchestrates Claude Code / Codex / Pi — it doesn't reimplement them. - **Not opinionated about the dev environment.** No mandatory editor integrations, framework lock-in, or Docker requirement beyond what users opt into. - **Not a deployment-infrastructure product.** Caddy is the single maintained reference reverse proxy (`--profile cloud`). Alternative proxies and infra recipes (Traefik, Nginx, k8s, ...) live in **docs** as community-maintained examples against the documented proxy contract (exposed port, health endpoint, `/internal/*` never proxied — see #2193), not as compose profiles Archon maintains: each maintained proxy config doubles a security-critical surface. Cite as `direction.md §deployment-recipes`. -- **Not a programming language.** The workflow YAML coordinates (gates, joins, retries, sessions, artifacts, reusable structure); `bash:`/`script:` nodes compute; prompts judge. PRs that add computation to the YAML surface conflict — see §workflow-language. +- **Not a package-manager distribution hub.** The maintained install channels are the curl/PowerShell installer, Homebrew, Docker, and the GitHub release binaries. Additional channels (Nix, AUR, Scoop, winget, apt, ...) are welcome as **community-maintained docs recipes**, or as packages upstream in the package manager's own registry (e.g. nixpkgs) where they get that ecosystem's CI and hash-update bots for free — not as in-repo manifests Archon version-bumps. Every hash-pinned channel adds a per-release chore and a surface that rots silently between releases; Homebrew already costs one. Cite as `direction.md §distribution-channels`. +- **Not a programming language.** The workflow YAML coordinates (gates, joins, retries, sessions, artifacts, reusable structure); computation lives inside nodes, not in the YAML. PRs that add computation to the YAML surface conflict — see §workflow-language. This is about the *language*, not about which node type an author picks: a `prompt:` node that computes is a legitimate choice — see §prompt-computation. ## Community providers @@ -55,7 +56,8 @@ Triage clauses — cite as `direction.md §`: - **§when-grammar** — `when:` never grows incrementally (no parentheses, functions, string ops, arithmetic). Standing answer: compute the decision in a `script:` node, gate on `$node.output.field`. Only sanctioned growth is adopting CEL wholesale in one versioned change — never home-grown operators. - **§load-time-composition** — composition/reuse features must fully resolve at load time (the executor runs a flat static DAG). Parameterization may carry **data**, never **structure**; dynamic/templated targets are declined. Runtime-resolved structure is a sub-run — a governance object with its own run record (#2121 Phase 2, co-designed with #1764) — not a language feature. -- **§workaround-triage** — repeated YAML structure or deterministic logic embedded in prompts is a signal, triaged into three buckets: missing *coordination* primitive → design it constitutionally; missing *pattern* → document the pattern (e.g. polyglot validate = detect-AI → execute-bash → fix-AI); disguised *computation* → point at script nodes. The workaround corpus decides language shape; the feature-request queue doesn't. +- **§workaround-triage** — repeated YAML structure or *recurring* deterministic logic embedded in prompts is a signal, triaged into three buckets: missing *coordination* primitive → design it constitutionally; missing *pattern* → document the pattern (e.g. polyglot validate = detect-AI → execute-bash → fix-AI); computation that has leaked *into the YAML surface* → point at script nodes. This bucket never produces a rewrite-the-prompt verdict — see §prompt-computation. The workaround corpus decides language shape; the feature-request queue doesn't. +- **§prompt-computation** — the constitution governs the **YAML surface**, not what an agent does inside a node. A `prompt:` node performing computation is a legitimate authoring choice, and the right one when the author doesn't know the rule or expects it to change — forcing an uncertain rule into a script freezes a guess into code. Script vs prompt is ordinary engineering owned by the workflow author. **Never decline or rewrite a prompt on constitutional grounds.** The only sanctioned argument for making a specific check deterministic is *reliability*: no judgment content (one correct answer) plus irreversible external consequences if it doesn't fire — argue that on its merits, not by citing the constitution. - **§schema-width** — new provider capabilities default into provider config or tier/alias presets, not new node fields; a node field is earned only by genuine per-node variance. Capability mismatches warn loudly, never silently no-op (`capabilities.ts` is the source of truth; docs derive from it — #2116). - **§implicit-behavior** — a new implicit behavior (auto-anything) must be documented in the canonical behavior list, individually defeatable, and fail-safe — convenience alone never qualifies. diff --git a/.archon/workflows/defaults/archon-architect.yaml b/.archon/workflows/defaults/archon-architect.yaml index f0fd4891fc..240d70e446 100644 --- a/.archon/workflows/defaults/archon-architect.yaml +++ b/.archon/workflows/defaults/archon-architect.yaml @@ -311,12 +311,14 @@ nodes: 1. Stage all changes and create a single commit (or verify existing commits) 2. Push the branch: `git push -u origin HEAD` - 3. Check if a PR already exists: `gh pr list --head $(git branch --show-current)` - 4. Create the PR targeting `$BASE_BRANCH` as the base branch: - `gh pr create --base $BASE_BRANCH --title "..." --body "..."` + 3. Resolve the origin repo — in a fork clone, gh otherwise targets the upstream parent: + `ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##')` + 4. Check if a PR already exists: `gh pr list --repo "$ORIGIN_REPO" --head $(git branch --show-current)` + 5. Create the PR targeting `$BASE_BRANCH` as the base branch: + `gh pr create --repo "$ORIGIN_REPO" --base $BASE_BRANCH --title "..." --body "..."` - Title: concise description of what was simplified (under 70 chars) - Body: use the format below - 5. Save the PR URL to `$ARTIFACTS_DIR/.pr-url` + 6. Save the PR URL to `$ARTIFACTS_DIR/.pr-url` ## PR Body Format @@ -362,17 +364,19 @@ nodes: - id: verify-pr-base bash: | set -euo pipefail + # Pin to the origin remote — in a fork clone, gh otherwise queries the upstream parent + ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') HEAD_BRANCH=$(git rev-parse --abbrev-ref HEAD) - PR_NUMBER=$(gh pr list --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') + PR_NUMBER=$(gh pr list --repo "$ORIGIN_REPO" --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') if [ -z "$PR_NUMBER" ]; then echo "No open PR found for branch $HEAD_BRANCH" >&2 exit 1 fi EXPECTED="$BASE_BRANCH" - ACTUAL=$(gh pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') + ACTUAL=$(gh pr view "$PR_NUMBER" --repo "$ORIGIN_REPO" --json baseRefName -q '.baseRefName') if [ "$ACTUAL" != "$EXPECTED" ]; then echo "Base mismatch on PR #$PR_NUMBER: expected=$EXPECTED actual=$ACTUAL — re-targeting" >&2 - gh pr edit "$PR_NUMBER" --base "$EXPECTED" + gh pr edit "$PR_NUMBER" --repo "$ORIGIN_REPO" --base "$EXPECTED" else echo "PR base verified: $EXPECTED" fi diff --git a/.archon/workflows/defaults/archon-feature-development.yaml b/.archon/workflows/defaults/archon-feature-development.yaml index 0b7bf74630..426051d829 100644 --- a/.archon/workflows/defaults/archon-feature-development.yaml +++ b/.archon/workflows/defaults/archon-feature-development.yaml @@ -19,17 +19,19 @@ nodes: - id: verify-pr-base bash: | set -euo pipefail + # Pin to the origin remote — in a fork clone, gh otherwise queries the upstream parent + ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') HEAD_BRANCH=$(git rev-parse --abbrev-ref HEAD) - PR_NUMBER=$(gh pr list --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') + PR_NUMBER=$(gh pr list --repo "$ORIGIN_REPO" --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') if [ -z "$PR_NUMBER" ]; then echo "No open PR found for branch $HEAD_BRANCH" >&2 exit 1 fi EXPECTED="$BASE_BRANCH" - ACTUAL=$(gh pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') + ACTUAL=$(gh pr view "$PR_NUMBER" --repo "$ORIGIN_REPO" --json baseRefName -q '.baseRefName') if [ "$ACTUAL" != "$EXPECTED" ]; then echo "Base mismatch on PR #$PR_NUMBER: expected=$EXPECTED actual=$ACTUAL — re-targeting" >&2 - gh pr edit "$PR_NUMBER" --base "$EXPECTED" + gh pr edit "$PR_NUMBER" --repo "$ORIGIN_REPO" --base "$EXPECTED" else echo "PR base verified: $EXPECTED" fi diff --git a/.archon/workflows/defaults/archon-fix-github-issue.yaml b/.archon/workflows/defaults/archon-fix-github-issue.yaml index 89075765c5..96c57ff2b7 100644 --- a/.archon/workflows/defaults/archon-fix-github-issue.yaml +++ b/.archon/workflows/defaults/archon-fix-github-issue.yaml @@ -174,24 +174,27 @@ nodes: - `$ARTIFACTS_DIR/investigation.md` or `$ARTIFACTS_DIR/plan.md` - `$ARTIFACTS_DIR/implementation.md` - `$ARTIFACTS_DIR/validation.md` - 4. Check if a PR already exists for this branch: `gh pr list --head $(git branch --show-current)` + 4. Resolve the origin repo — in a fork clone, gh otherwise targets the upstream parent: + `ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##')` + Then check if a PR already exists for this branch: `gh pr list --repo "$ORIGIN_REPO" --head $(git branch --show-current)` - If PR exists, skip creation and capture its number 5. Look for the project's PR template at `.github/pull_request_template.md`, `.github/PULL_REQUEST_TEMPLATE.md`, or `docs/PULL_REQUEST_TEMPLATE.md`. Read whichever one exists. - 6. Create a DRAFT PR: `gh pr create --draft --base $BASE_BRANCH` + 6. Create a DRAFT PR: `gh pr create --repo "$ORIGIN_REPO" --draft --base $BASE_BRANCH` - Title: concise, imperative mood, under 70 chars - Body: if a PR template was found, fill in **every section** with details from the artifacts. Don't skip sections or leave placeholders. If no template, write a body with summary, changes, validation evidence, and `Fixes #...`. - **PR body file location**: if you write the body to a file (e.g. for `--body-file`), the file MUST live at `$ARTIFACTS_DIR/pr-body.md` or under `/tmp/` — NEVER inside the worktree. Files like `.pr-body.md` at the repo root will be picked up by later commits. - Link to issue: include `Fixes #...` or `Closes #...` 7. Capture PR identifiers: ```bash + ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') HEAD_BRANCH=$(git branch --show-current) - PR_NUMBER=$(gh pr list --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') + PR_NUMBER=$(gh pr list --repo "$ORIGIN_REPO" --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') if [ -z "$PR_NUMBER" ]; then echo "No open PR found for branch $HEAD_BRANCH — PR creation failed" >&2 exit 1 fi echo "$PR_NUMBER" > "$ARTIFACTS_DIR/.pr-number" - PR_URL=$(gh pr view "$PR_NUMBER" --json url -q '.url') + PR_URL=$(gh pr view "$PR_NUMBER" --repo "$ORIGIN_REPO" --json url -q '.url') echo "$PR_URL" > "$ARTIFACTS_DIR/.pr-url" ``` depends_on: [validate] @@ -204,17 +207,19 @@ nodes: - id: verify-pr-base bash: | set -euo pipefail + # Pin to the origin remote — in a fork clone, gh otherwise queries the upstream parent + ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') HEAD_BRANCH=$(git rev-parse --abbrev-ref HEAD) - PR_NUMBER=$(gh pr list --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') + PR_NUMBER=$(gh pr list --repo "$ORIGIN_REPO" --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') if [ -z "$PR_NUMBER" ]; then echo "No open PR found for branch $HEAD_BRANCH" >&2 exit 1 fi EXPECTED="$BASE_BRANCH" - ACTUAL=$(gh pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') + ACTUAL=$(gh pr view "$PR_NUMBER" --repo "$ORIGIN_REPO" --json baseRefName -q '.baseRefName') if [ "$ACTUAL" != "$EXPECTED" ]; then echo "Base mismatch on PR #$PR_NUMBER: expected=$EXPECTED actual=$ACTUAL — re-targeting" >&2 - gh pr edit "$PR_NUMBER" --base "$EXPECTED" + gh pr edit "$PR_NUMBER" --repo "$ORIGIN_REPO" --base "$EXPECTED" else echo "PR base verified: $EXPECTED" fi diff --git a/.archon/workflows/defaults/archon-piv-loop.yaml b/.archon/workflows/defaults/archon-piv-loop.yaml index 73b66ee1e5..ad7c9d2ac1 100644 --- a/.archon/workflows/defaults/archon-piv-loop.yaml +++ b/.archon/workflows/defaults/archon-piv-loop.yaml @@ -728,8 +728,11 @@ nodes: ## Step 3: Create PR (if not already created) + Resolve the origin repo first — in a fork clone, gh otherwise targets the upstream parent: + ```bash - gh pr view HEAD --json url 2>/dev/null || echo "NO_PR" + ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') + gh pr view HEAD --repo "$ORIGIN_REPO" --json url 2>/dev/null || echo "NO_PR" ``` If no PR exists: @@ -738,7 +741,8 @@ nodes: cat .github/pull_request_template.md 2>/dev/null || echo "NO_TEMPLATE" ``` - Create with `gh pr create --draft --base $BASE_BRANCH`: + Create with `gh pr create --repo "$ORIGIN_REPO" --draft --base $BASE_BRANCH` + (re-run the `ORIGIN_REPO=...` line in the same shell — it does not persist across shells): - Title from the plan's feature name - Body summarizing the implementation - Use a HEREDOC for the body @@ -772,17 +776,19 @@ nodes: - id: verify-pr-base bash: | set -euo pipefail + # Pin to the origin remote — in a fork clone, gh otherwise queries the upstream parent + ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') HEAD_BRANCH=$(git rev-parse --abbrev-ref HEAD) - PR_NUMBER=$(gh pr list --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') + PR_NUMBER=$(gh pr list --repo "$ORIGIN_REPO" --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') if [ -z "$PR_NUMBER" ]; then echo "No open PR found for branch $HEAD_BRANCH" >&2 exit 1 fi EXPECTED="$BASE_BRANCH" - ACTUAL=$(gh pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') + ACTUAL=$(gh pr view "$PR_NUMBER" --repo "$ORIGIN_REPO" --json baseRefName -q '.baseRefName') if [ "$ACTUAL" != "$EXPECTED" ]; then echo "Base mismatch on PR #$PR_NUMBER: expected=$EXPECTED actual=$ACTUAL — re-targeting" >&2 - gh pr edit "$PR_NUMBER" --base "$EXPECTED" + gh pr edit "$PR_NUMBER" --repo "$ORIGIN_REPO" --base "$EXPECTED" else echo "PR base verified: $EXPECTED" fi diff --git a/.archon/workflows/defaults/archon-ralph-dag.yaml b/.archon/workflows/defaults/archon-ralph-dag.yaml index 6de25bd0f0..1e4e236a4d 100644 --- a/.archon/workflows/defaults/archon-ralph-dag.yaml +++ b/.archon/workflows/defaults/archon-ralph-dag.yaml @@ -536,7 +536,9 @@ nodes: If no template was found, write a summary with: problem, what changed, stories table, and validation evidence. - 3. **Create a draft PR** using `gh pr create --draft --base $BASE_BRANCH --title "feat: {PRD feature name}"` with the filled-in template as the body. Use a HEREDOC for the body. + 3. **Create a draft PR** — resolve the origin repo first (in a fork clone, gh otherwise targets the upstream parent): + `ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##')` + then `gh pr create --repo "$ORIGIN_REPO" --draft --base $BASE_BRANCH --title "feat: {PRD feature name}"` with the filled-in template as the body. Use a HEREDOC for the body. 4. **Output completion signal:** ``` @@ -659,17 +661,19 @@ nodes: - id: verify-pr-base bash: | set -euo pipefail + # Pin to the origin remote — in a fork clone, gh otherwise queries the upstream parent + ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') HEAD_BRANCH=$(git rev-parse --abbrev-ref HEAD) - PR_NUMBER=$(gh pr list --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') + PR_NUMBER=$(gh pr list --repo "$ORIGIN_REPO" --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') if [ -z "$PR_NUMBER" ]; then echo "No open PR found for branch $HEAD_BRANCH" >&2 exit 1 fi EXPECTED="$BASE_BRANCH" - ACTUAL=$(gh pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') + ACTUAL=$(gh pr view "$PR_NUMBER" --repo "$ORIGIN_REPO" --json baseRefName -q '.baseRefName') if [ "$ACTUAL" != "$EXPECTED" ]; then echo "Base mismatch on PR #$PR_NUMBER: expected=$EXPECTED actual=$ACTUAL — re-targeting" >&2 - gh pr edit "$PR_NUMBER" --base "$EXPECTED" + gh pr edit "$PR_NUMBER" --repo "$ORIGIN_REPO" --base "$EXPECTED" else echo "PR base verified: $EXPECTED" fi diff --git a/.archon/workflows/defaults/archon-refactor-safely.yaml b/.archon/workflows/defaults/archon-refactor-safely.yaml index 5f530d73a4..3cb3b9d773 100644 --- a/.archon/workflows/defaults/archon-refactor-safely.yaml +++ b/.archon/workflows/defaults/archon-refactor-safely.yaml @@ -451,11 +451,13 @@ nodes: 1. Stage all changes and create a final commit if there are uncommitted changes 2. Push the branch: `git push -u origin HEAD` - 3. Check if a PR already exists: `gh pr list --head $(git branch --show-current)` - 4. Create the PR targeting `$BASE_BRANCH` as the base branch: - `gh pr create --base $BASE_BRANCH --title "..." --body "..."`, then format + 3. Resolve the origin repo — in a fork clone, gh otherwise targets the upstream parent: + `ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##')` + 4. Check if a PR already exists: `gh pr list --repo "$ORIGIN_REPO" --head $(git branch --show-current)` + 5. Create the PR targeting `$BASE_BRANCH` as the base branch: + `gh pr create --repo "$ORIGIN_REPO" --base $BASE_BRANCH --title "..." --body "..."`, then format title/body per the template below - 5. Save the PR URL to `$ARTIFACTS_DIR/.pr-url` + 6. Save the PR URL to `$ARTIFACTS_DIR/.pr-url` ## PR Format @@ -521,17 +523,19 @@ nodes: - id: verify-pr-base bash: | set -euo pipefail + # Pin to the origin remote — in a fork clone, gh otherwise queries the upstream parent + ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') HEAD_BRANCH=$(git rev-parse --abbrev-ref HEAD) - PR_NUMBER=$(gh pr list --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') + PR_NUMBER=$(gh pr list --repo "$ORIGIN_REPO" --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') if [ -z "$PR_NUMBER" ]; then echo "No open PR found for branch $HEAD_BRANCH" >&2 exit 1 fi EXPECTED="$BASE_BRANCH" - ACTUAL=$(gh pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') + ACTUAL=$(gh pr view "$PR_NUMBER" --repo "$ORIGIN_REPO" --json baseRefName -q '.baseRefName') if [ "$ACTUAL" != "$EXPECTED" ]; then echo "Base mismatch on PR #$PR_NUMBER: expected=$EXPECTED actual=$ACTUAL — re-targeting" >&2 - gh pr edit "$PR_NUMBER" --base "$EXPECTED" + gh pr edit "$PR_NUMBER" --repo "$ORIGIN_REPO" --base "$EXPECTED" else echo "PR base verified: $EXPECTED" fi diff --git a/.archon/workflows/defaults/archon-review-block.yaml b/.archon/workflows/defaults/archon-review-block.yaml index 706f2638bd..5fb8e9e69c 100644 --- a/.archon/workflows/defaults/archon-review-block.yaml +++ b/.archon/workflows/defaults/archon-review-block.yaml @@ -13,17 +13,19 @@ nodes: - id: verify-pr-base bash: | set -euo pipefail + # Pin to the origin remote — in a fork clone, gh otherwise queries the upstream parent + ORIGIN_REPO=$(git remote get-url origin | sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#; s#\.git$##') HEAD_BRANCH=$(git rev-parse --abbrev-ref HEAD) - PR_NUMBER=$(gh pr list --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') + PR_NUMBER=$(gh pr list --repo "$ORIGIN_REPO" --head "$HEAD_BRANCH" --state open --json number -q '.[0].number') if [ -z "$PR_NUMBER" ]; then echo "No open PR found for branch $HEAD_BRANCH" >&2 exit 1 fi EXPECTED="$BASE_BRANCH" - ACTUAL=$(gh pr view "$PR_NUMBER" --json baseRefName -q '.baseRefName') + ACTUAL=$(gh pr view "$PR_NUMBER" --repo "$ORIGIN_REPO" --json baseRefName -q '.baseRefName') if [ "$ACTUAL" != "$EXPECTED" ]; then echo "Base mismatch on PR #$PR_NUMBER: expected=$EXPECTED actual=$ACTUAL — re-targeting" >&2 - gh pr edit "$PR_NUMBER" --base "$EXPECTED" + gh pr edit "$PR_NUMBER" --repo "$ORIGIN_REPO" --base "$EXPECTED" else echo "PR base verified: $EXPECTED" fi diff --git a/.claude/PRPs/issues/completed/issue-1168.md b/.claude/PRPs/issues/completed/issue-1168.md new file mode 100644 index 0000000000..a06ecd7795 --- /dev/null +++ b/.claude/PRPs/issues/completed/issue-1168.md @@ -0,0 +1,95 @@ +# Investigation: Improve cloud deployment documentation + +**Issue**: #1168 (https://github.com/coleam00/Archon/issues/1168) +**Type**: DOCUMENTATION +**Investigated**: 2026-04-21 + +### Assessment + +| Metric | Value | Reasoning | +|---|---|---| +| Priority | LOW | The VPS guide is already substantially complete; one incorrect auth snippet causes a localized setup failure. | +| Complexity | LOW | The fix is one documentation file and one example, with no runtime integration changes. | +| Confidence | HIGH | The issue is reproducible from the unescaped `$` example and contradicted by the escaped `.env.example` guidance. | + +## Problem Statement + +The cloud VPS guide already provides the manual Docker Compose deployment path and instructs operators to edit the repository `.env` directly rather than run `archon setup`. However, the Docker form-auth walkthrough still shows an unescaped bcrypt hash. Docker Compose interpolates `$` characters, so following that example can silently break authentication. + +## Analysis + +### Root Cause / Change Rationale + +The canonical form-auth walkthrough in `packages/docs-web/src/content/docs/deployment/docker.md` tells users to set `AUTH_PASSWORD_HASH=$2b$12$REPLACE_WITH_YOUR_HASH`. Compose treats `$...` as interpolation syntax. The repository `.env.example` and basic-auth examples already correctly escape each dollar sign as `$$`, but the form-auth walkthrough was not updated. + +### Evidence Chain + +WHY: Form authentication can fail when users follow the documented walkthrough. +↓ BECAUSE: The documented bcrypt hash contains single `$` characters. +Evidence: `packages/docs-web/src/content/docs/deployment/docker.md:349-355`. + +↓ BECAUSE: Docker Compose interpolates `$` values before passing environment values to the container. +Evidence: The same page's basic-auth example at `docker.md:307-311` and `.env.example:271-274` use `$$` and warn about Compose interpolation. + +↓ ROOT CAUSE: The form-auth example is missing escaped dollar signs and an explicit warning. + +### Affected Files + +| File | Lines | Action | Description | +|---|---:|---|---| +| `packages/docs-web/src/content/docs/deployment/docker.md` | 349-355 | UPDATE | Escape every `$` in the form-auth hash and explain why. | + +## Implementation Plan + +### Step 1: Clarify the Docker Compose configuration path + +**File**: `packages/docs-web/src/content/docs/deployment/cloud.md` + +Add a prominent note that this guide is for the repository's Docker Compose deployment: edit `/opt/archon/.env` directly and do not run `archon setup` on the VPS, because that wizard writes Archon-owned CLI environment scopes rather than the repository `.env` consumed by Compose. + +### Step 2: Correct the form-auth environment example + +**File**: `packages/docs-web/src/content/docs/deployment/docker.md` + +Change `AUTH_PASSWORD_HASH=$2b$12$REPLACE_WITH_YOUR_HASH` to `AUTH_PASSWORD_HASH=$$2b$$12$$REPLACE_WITH_YOUR_HASH`, followed by a sentence stating that every `$` must be written as `$$` because Docker Compose performs variable interpolation. + +### Step 3: Validate documentation consistency + +Confirm the updated snippet matches the escaped examples in `.env.example`, `Caddyfile.example`, and the basic-auth section. Run the docs package checks or repository validation available in this checkout. + +## Patterns to Follow + +Use the existing basic-auth guidance in `packages/docs-web/src/content/docs/deployment/docker.md:307-311`: + +```ini +# CADDY_BASIC_AUTH=basicauth @protected { admin $$2a$$14$$... } +``` + +The repository `.env.example:271-274` also explicitly documents Compose escaping. + +## Edge Cases & Risks + +| Risk/Edge Case | Mitigation | +|---|---| +| Users copy a hash with additional `$` segments | Tell users to escape every `$`, not only the prefix. | +| Duplicating the full Docker guide into cloud docs creates drift | Keep the fix narrow; retain the existing cross-link and single maintained Docker reference. | + +## Validation + +```bash +bun run format:check +bun run lint +``` + +Manual verification: grep the form-auth example and confirm it contains `$$` for every bcrypt `$` segment and an interpolation warning. + +## Scope Boundaries + +**IN SCOPE:** Clarify the Docker Compose environment-file path in the cloud guide; correct the Docker form-auth documentation example and explain Compose escaping. + +**OUT OF SCOPE:** Runtime auth changes, CLI setup behavior, duplicating the Docker guide into the cloud page, or unrelated deployment refactors. + +## Metadata + +- **Investigated by**: Claude +- **Artifact**: `.claude/PRPs/issues/issue-1168.md` diff --git a/.env.example b/.env.example index 22c3e9fabd..4b02195b66 100644 --- a/.env.example +++ b/.env.example @@ -208,6 +208,11 @@ TELEGRAM_STREAMING_MODE=stream # stream (default) | batch DISCORD_STREAMING_MODE=batch # batch (default) | stream SLACK_STREAMING_MODE=batch # batch (default) | stream +# Discord Mention Requirement (true | false) +# When true (default), the bot only responds in servers when @mentioned (DMs are exempt) +# Set to false to respond to any authorized server message without requiring a mention +DISCORD_REQUIRE_MENTION=true # true (default) | false + # Bot Display Name (shown in batch mode "starting" message) # Default: Archon # BOT_DISPLAY_NAME=Archon @@ -270,6 +275,15 @@ GITEA_ALLOWED_USERS= # AUTH_SERVICE_PORT=9000 # COOKIE_MAX_AGE=86400 +# ============================================ +# WSL Detection (Linux only) +# ============================================ +# WSL itself sets WSL_DISTRO_NAME= in every distro shell — Archon reads +# it to emit Windows-host-friendly vscode://vscode-remote/wsl+/... URIs +# for the "Open in IDE" button. You do not normally need to set this manually; +# only override it if you want to force a specific distro name into the URI. +# WSL_DISTRO_NAME=Ubuntu + # ============================================ # Archon Directory Configuration # ============================================ @@ -295,6 +309,15 @@ GITEA_ALLOWED_USERS= # NOT read by Archon source code. # ARCHON_USER_HOME=/opt/archon-user-home +# Docker root fallback (opt-in escape hatch for macOS bind mounts). +# On macOS VirtioFS bind mounts the entrypoint's ownership fix always fails +# (host UIDs can't be remapped to container UID 1001), so the container exits 1. +# Set to 1 to continue running as root instead. Side-effect: exports IS_SANDBOX=1, +# which bypasses the Claude provider's UID-0 safety guard — AI subprocesses run +# as root inside the container. Never auto-enabled; default (unset) fails loud. +# On Linux, fix volume ownership instead: sudo chown -R 1001:1001 +# ARCHON_ALLOW_ROOT_FALLBACK=1 + # Logging (optional) # Set log level: fatal | error | warn | info | debug | trace # Default: info diff --git a/.gitattributes b/.gitattributes index daa64b42ee..564992b781 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,11 @@ # Force LF line endings for files that run inside Linux containers *.sh text eol=lf +# The published installer mirrors scripts/install.sh byte-for-byte and a `cmp` +# assertion enforces that. It has no extension, so `*.sh` misses it and it would +# fall through to `* text=auto` -> CRLF on a Windows-default checkout, failing the +# parity check on a clean tree. +packages/docs-web/public/install text eol=lf docker-entrypoint.sh text eol=lf Caddyfile text eol=lf Caddyfile.example text eol=lf diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml new file mode 100644 index 0000000000..d74b286d7d --- /dev/null +++ b/.github/workflows/docs-build.yml @@ -0,0 +1,44 @@ +name: Docs Build + +# The docs site is only built by deploy-docs.yml, which runs on push to main. +# That means a change that breaks `bun run build:docs` can sit on dev unnoticed +# until release day, when the Pages deploy fails. This job runs the same build +# on PRs that touch the docs site (or the lockfile it resolves against), so the +# breakage surfaces before merge instead of at release. +# +# Path-filtered on purpose: a full Astro build costs ~1 minute, and there is no +# reason to pay it on the ~95% of PRs that never touch packages/docs-web. + +on: + pull_request: + paths: + - 'packages/docs-web/**' + - 'bun.lock' + - '.github/workflows/docs-build.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build docs site + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Astro's CLI runs under Node, not Bun — keep this matched to + # deploy-docs.yml so a green PR check means a green deploy. + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build docs + run: bun run build:docs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b8f7f3b49d..b44cbdd249 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -57,6 +57,8 @@ jobs: context: . platforms: linux/amd64,linux/arm64 push: true + provenance: mode=max + sbom: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6f6cecb532..cfc45f3a39 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,11 @@ concurrency: jobs: test: strategy: + # Both legs must always report. With the default fail-fast, an ubuntu + # failure cancels windows, so a windows-only break stays invisible until + # the next round — and a cancelled leg blocks a merge once it is a + # required check. + fail-fast: false matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} @@ -42,6 +47,14 @@ jobs: - name: Check formatting run: bun run format:check + - name: Run installer tests + # POSIX-shell installer only. scripts/install.sh refuses MINGW/MSYS by design + # (Windows users are directed to WSL2), and test-install.sh runs the real + # installer with only curl mocked — so on windows-latest `uname -s` reports + # MINGW64_NT, the installer exits, and `set -euo pipefail` fails the job. + if: runner.os != 'Windows' + run: bun run test:install + - name: Run tests run: bun run test diff --git a/CHANGELOG.md b/CHANGELOG.md index e92f7347f5..3943d81b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 2026-08-01 + +Runtime sub-runs (`workflow:`), the connected Studio builder, usage accounting you can trust, a repaired `curl | bash` install path, and a security batch across cloning, transport, and path resolution. + +### Added + +- **`workflow:` runtime sub-run node** — run another workflow as a governed **child** run with its own `workflow_runs` row, artifacts, approval gates, cost line, and audit trail. The child's terminal output threads back as `$.output`, and a child gate pauses the whole tree (approve the child by run id; the parent auto-resumes on completion). Slice 1 is sequential composition in a shared checkout — dynamic fan-out, per-child worktrees, `first_success` racing, and `with:` parameter mapping are reserved in the schema and rejected fail-fast. (#2121, #2169) +- **Archon Studio connected mode** — `/console/builder[/:name]` loads, saves, creates, renames, and deletes real workflows through the existing CRUD endpoints, with a project picker, explicit Save behind a dirty + navigation guard, server-tier validation surfaced in the issue panel, and bundled → Save-as. (#2051) +- **Evidence gate** — optional workflow-level `evidence_policy: { required: true }` refuses terminal `completed` unless `$ARTIFACTS_DIR/evidence.json` exists; the run is marked `failed` with a structured note, an `evidence_validation_failed` event, and the expected path named. The engine gates on file **presence** only — what counts as valid evidence is produced by the workflow's own bash/script nodes. (#2230, #2235) +- **Configurable git remote** — `worktree.remote` in `.archon/config.yaml` plus auto-detection (`origin` if present → sole remote → actionable error on ambiguity), threaded through worktrees, workspace sync, PR-state lookup, forge detection, and cleanup. A repo whose only remote isn't named `origin` previously could not use isolation at all. (#2234) +- **Database schema vintage** — installs record the schema version they were created at, and the additive-only migration rule is stated in the codebase and checkable. (#2317) +- **Forge detection** — `detectForge()` in `@archon/git` resolves a remote to GitHub / GitLab / Gitea, including self-hosted instances via `GITHUB_URL` / `GITEA_URL` / `GITLAB_URL`. Lands as the reviewed foundation for forge-agnostic adapters; no consumers wired yet, by design. (#2210) +- Per-node `settingSources` override for Claude nodes. (#2216) +- `DISCORD_REQUIRE_MENTION` lets the Discord adapter respond without an @mention. (#2209) +- Opt-in Docker root fallback (`ARCHON_ALLOW_ROOT_FALLBACK`) for macOS bind mounts. (#2228) +- Published container images carry provenance and SBOM attestations. (#2297) +- Marketplace: `archon-resolve-mr-conflicts`. (#1687) + +### Security + +- **Clone hardening.** Both clone paths now pass `GIT_TERMINAL_PROMPT=0`, so a clone with missing or invalid credentials fails fast instead of hanging indefinitely on an interactive prompt. The credential sanitizer gains `GITLAB_TOKEN` / `GITEA_TOKEN`, and URL redaction is generalized from `@github.com`-only to the userinfo of any `scheme://user[:pass]@host` form — closing a path where a failed GitLab/Gitea clone could surface an embedded token to chat platforms and logs. (#2221) +- Codebase names shaped like SSH URLs are rejected during worktree path resolution. (#1583) +- The bundled-defaults generator refuses to embed untracked files from `defaults/`, so an uncommitted local file cannot silently ship inside a binary. (#2237) + +### Changed + +- **Per-node token usage is persisted, and cumulative totals survive a resume.** Token counts are recorded per node as they are produced, and a resumed run no longer under-reports its totals by roughly the work completed before the resume. (#2347, #2353) +- **Resolved model metadata is recorded per node** — what actually ran, not only what was requested. (#2337) +- Tool timing is completed at the result boundary rather than left open. (#2336) +- `tool_result` payloads are bounded at 16 KiB at the SSE emit and message-hydration boundaries, so a multi-megabyte tool output no longer costs every viewer a full parse and full cache residency. Database writes keep the **full** output — the DB and logs remain the authoritative record. (#2244) +- Message queries carry an id tie-breaker so `LIMIT` windows are deterministic. (#2220) +- Owner/repo identity resolution is unified on `@archon/paths`. (#2231) +- The generated provider capability matrix surfaces per-cell caveats. (#2222) + +### Fixed + +- **`curl -fsSL https://archon.diy/install | bash` was broken for every user and is repaired**, along with the PowerShell mirror, which had drifted from it. Installer tests now run in CI to keep the two in sync, and Rosetta architecture detection on macOS no longer selects the wrong binary. (#2340, #2335, #2330) +- **Chat resume prefers a paused run over a newer failed one**, so approving from chat resumes the run actually waiting on you. (#2292) +- **Stale errors are cleared on resume**, so a run that succeeds after resuming no longer carries the previous failure's error text. (#2348) +- A node whose AI prompt substitution fails emits `node_failed` instead of failing quietly. (#2205) +- New conversations resolve the configured default assistant. (#2245) +- Pi sessions authenticated with an Anthropic subscription receive a default system prompt. (#2243) +- SQLite/Postgres schema parity checks compare columns, not only table names. (#2346) +- "Open in IDE" resolves correctly for workflow runs and under WSL2. (#2003, #1504) +- Workflow invocations split across message chunks parse correctly. (#1542) +- Detached re-invoke drops Bun's single-file-executable virtual `argv[1]`. (#2273) +- Bundled defaults pin `gh pr create` to the origin repo. (#2229) +- The console composer and approval input guard IME composition, so committing a candidate no longer submits the message. (#2217) +- `archon-fix-issue` no longer stops on the dirty run worktree it is expected to be working in: the clean-tree requirement is scoped to the base-branch case, and the checkout is classified with `git-dir` vs `git-common-dir` rather than `git worktree list`, which cannot distinguish them. (#2358) +- Docs: the docs build is repaired and guarded against silent rot, cloud Docker auth setup is clarified, `llms.txt` coverage is improved, and the workflow constitution is clarified as governing the YAML surface rather than prompt content. (#2301, #2259, #2066, #2067, #2300) +- Test hygiene: unit tests no longer reach the live network or a real database, and adapter tests no longer write to a real `ARCHON_HOME`. (#2303, #2307, #2310) + ## [0.6.0] - 2026-07-20 Folder projects, opt-in Docker container isolation, three new workflow-composition primitives (`include:`, `loop_group`, `loop.command`), the Archon Studio builder preview, and a large security + reliability batch spanning gates, providers, Windows, Docker, and the console. diff --git a/CLAUDE.md b/CLAUDE.md index 86f8725755..e501d6ee4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,8 @@ These are implementation constraints, not slogans. Apply them by default. - Reference: #1216 and the CLI orphan-cleanup precedent at `packages/cli/src/cli.ts:256-258`. **Workflow Language Constitution — YAML coordinates, code computes, agents judge** -- The workflow YAML expresses only what the ENGINE must see to govern a run: ordering, gates, joins, retries, sessions, artifacts, reusable structure. Computation belongs in `bash:`/`script:` nodes; judgment belongs in prompts. This boundary is what keeps load-time validation, the visual builder, resume, and audit trails possible. +- The workflow YAML expresses only what the ENGINE must see to govern a run: ordering, gates, joins, retries, sessions, artifacts, reusable structure. Computation stays out of the YAML and lives inside a node's body (the `bash:`/`script:` source or the `prompt:` text) — node fields like `when:`/`retry:` are YAML surface and stay declarative. This boundary is what keeps load-time validation, the visual builder, resume, and audit trails possible. +- **The rule governs the YAML surface, not what an agent does inside a node.** A `prompt:` node that computes is a legitimate authoring choice — often the right one when the author doesn't know the rule and wants the model to decide. Script nodes and prompt nodes are both escape hatches from the language; picking between them is ordinary engineering, not a constitutional question. Never cite the constitution to argue a prompt should become a script. (The one narrow exception is a *reliability* argument, not a constitutional one: a check with no judgment content whose failure is irreversible is better as a node that can't decline to fire.) - Admissibility test for every new YAML surface feature (field, node type, expression capability): (1) does the engine need to see it to govern? (2) is it declarative data, not evaluation? (3) could a script node + existing wiring express it today? A feature that computes rather than coordinates is rejected — point to the escape hatch instead. - `when:` never grows incrementally (no parens, no functions, no arithmetic). The answer to "when: can't express X" is a script node that computes the decision + `when:` on its structured output. If expression demand ever genuinely accumulates, adopt CEL wholesale in one versioned change — never home-grow operators. - Composition features must resolve fully at LOAD time (the executor runs a flat static DAG); include parameterization, if ever added, is data-only. Runtime-resolved structure = a sub-run (its own governance object), not a language feature. @@ -150,7 +151,7 @@ bun test packages/core/src/handlers/command-handler.test.ts # Single file **Test isolation (mock.module pollution):** Bun's `mock.module()` permanently replaces modules in the process-wide cache — `mock.restore()` does NOT undo it ([oven-sh/bun#7823](https://github.com/oven-sh/bun/issues/7823)). To prevent cross-file pollution, packages that have conflicting `mock.module()` calls split their tests into separate `bun test` invocations: `@archon/core` (20 batches), `@archon/workflows` (5), `@archon/adapters` (6), `@archon/isolation` (3). See each package's `package.json` for the exact splits. -**Do NOT run `bun test` from the repo root** — it discovers all test files across all packages and runs them in one process, causing ~135 mock pollution failures. Always use `bun run test` (which uses `bun --filter '*' test` for per-package isolation). +**Do NOT run `bun test` from the repo root** — it discovers all test files across all packages and runs them in one process, causing ~135 mock pollution failures. Always use `bun run test` (which uses `bun --filter '*' --parallel test` for per-package isolation). Note the `--parallel`: all ten package test processes run concurrently, and each one that spawns subprocesses competes for the same cores — which is why subprocess-spawning tests should stay rare and cheap (see #2306). ### Type Checking & Linting @@ -193,6 +194,16 @@ This runs `check:bundled`, `check:bundled-skill`, `check:bundled-schema`, `check - **Without `DATABASE_URL`**: Uses SQLite at `~/.archon/archon.db` (auto-initialized, recommended for most users) - **With `DATABASE_URL` set**: Uses PostgreSQL (schema auto-applied on startup; no manual `psql` needed). The Postgres adapter runs the idempotent `migrations/000_combined.sql` inside an advisory-lock transaction on first connection, so upgrades that add tables or columns converge automatically. +**Schema changes are additive-only (both dialects) — this is a hard rule, not a convention.** + +There is no migration ledger and no version gate. Both schemas are re-applied in full on every connection, by every process that opens the database — the server, *every* CLI invocation, every `--detach` child — including an **older** Archon binary that happens to be on `PATH` beside a dev checkout. Any writer may apply any vintage of the schema at any time. Therefore: + +- **Only ADD** tables, columns, and indexes. Never rename, retype, or drop anything a shipped version still reads or writes. +- **Every `ADD COLUMN ... NOT NULL` must carry a `DEFAULT`.** Without one the statement fails outright on a non-empty table, and a writer that predates the column would produce rows the newer writer rejects. This holds for all 36 `ADD COLUMN` statements in the tree today — keep it that way. +- **Adding `NOT NULL` to a column already in a `CREATE TABLE` body only binds databases created after the change.** `CREATE TABLE IF NOT EXISTS` is a no-op on existing tables and SQLite has no `ALTER COLUMN`, so the old shape survives forever. Treat such a constraint as documentation and keep application code tolerant of NULL, or do a full table rebuild in `migrateColumns()`. +- **Mirror every change into both schemas** (see the generated-files note in the Defaults section). The parity test in `sqlite.test.ts` compares **table names only** — a column present in one dialect and missing in the other passes CI. +- `remote_agent_schema_version` records which Archon build created the database and which last applied schema to it, surfaced by `archon doctor` and `GET /api/health`. It is diagnostic only — nothing gates on it, and the values come from `APP_VERSION` in `packages/core/src/db/schema-version.ts`, never from a hand-bumped number. + ### CLI (Command Line) Run workflows directly from the command line without needing the server. Workflow and isolation commands require running from within a git repository (subdirectories work - resolves to repo root). @@ -474,12 +485,12 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api'; ### Database Schema -**18 Tables (all prefixed with `remote_agent_`):** +**19 Tables (all prefixed with `remote_agent_`):** 1. **`codebases`** - Repository/project metadata and commands (JSONB); `kind` (`'repo'`/`'folder'`, default `'repo'`) discriminates git repos from **folder projects** (non-git workspaces — multi-repo roots or plain ops folders — that run in place with named `_folder//` storage; `repository_url`/`default_branch` are null) 2. **`conversations`** - Track platform conversations with titles and soft-delete support; nullable `user_id` records first creator (provenance + execution-identity **fallback** only — chat turns execute as the message sender, #1982) 3. **`sessions`** - Track AI SDK sessions with resume capability 4. **`isolation_environments`** - Isolation tracking (git worktrees AND folder-project containers — `provider` is `'worktree'`/`'container'`; container rows use a `''` `branch_name` sentinel and store `{containerId, volume, image, overlayMode, …}` in `metadata`); nullable `created_by_user_id` preserves first creator -5. **`workflow_runs`** - Workflow execution tracking and state; nullable `user_id` for per-run attribution +5. **`workflow_runs`** - Workflow execution tracking and state; nullable `user_id` for per-run attribution; nullable `parent_run_id` (self-referential FK, `ON DELETE SET NULL`) links a `workflow:` sub-run to the parent run that spawned it (#2121 Phase 2) 6. **`workflow_events`** - Step-level workflow event log (step transitions, artifacts, errors) 7. **`messages`** - Conversation message history with tool call metadata (JSONB); nullable `user_id` (NULL for assistant rows). Split write-path: the **web** adapter persists its own turns via `MessagePersistence`; the **orchestrator** persists non-web turns (Slack/Telegram/GitHub/Discord/CLI) fire-and-forget, guarded by `isWebAdapter` to avoid double-writing web turns — only AI-bound turns get a user row (deterministic-command and approval-only turns return earlier), so a `user` row always pairs with an `assistant` row 8. **`codebase_env_vars`** - Per-project env vars injected into project-scoped execution surfaces (Claude, Codex, bash/script nodes, and direct chat when codebase-scoped), managed via Web UI or `env:` in config @@ -490,6 +501,7 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api'; 13. **`user_provider_keys`** - Per-user AI-provider credentials encrypted at rest (AES-256-GCM); one row per `(user_id, provider)` (`UNIQUE(user_id, provider)`), cascades on user deletion; `kind` is `api_key` or `oauth`; resolved + injected into the **acting user's** (run starter / message sender) runs/chat env at execution time. Always available — the encryption key is auto-provisioned at `~/.archon/credential-key` when `TOKEN_ENCRYPTION_KEY` is not set. Since #1955 the `provider` column holds **vendor-canonical credential ids** (`anthropic`, `openai`, `github-copilot`, plus the Pi backend vendors) — NOT agent ids; legacy `claude`/`codex`/`copilot` rows are renamed by an idempotent startup data fix (vendor row wins on conflict), and the connectable catalog is derived from provider registrations (`acceptedCredentials` via `credentials:` on `ProviderRegistration`), never hand-listed 14. **`user_ai_prefs`** - Per-user AI preferences (Phase 3): personal model `tiers`/`aliases` (JSON-as-TEXT) + `default_provider` + `default_model` (#1998 — per-user default CHAT model, written atomically with `default_provider`; replaces the `large`-tier lookup for direct chat only when the effective provider matches — workflows still resolve `large`). NON-encrypted (model names aren't secrets — mirrors `codebase_env_vars`, not the provider-key store); one row per user (`UNIQUE(user_id)`), cascades on user deletion. Folded into `buildAiProfile` as the highest-precedence layer at the userId-aware seams (workflow executor: run starter; chat orchestrator: message **sender**-first, conversation creator only as fallback — #1982); needs a web/CLI identity but NO `TOKEN_ENCRYPTION_KEY` 15–18. **`remote_agent_auth_user` / `remote_agent_auth_session` / `remote_agent_auth_account` / `remote_agent_auth_verification`** - Better Auth tables for opt-in web login (**PostgreSQL only**; always created on Postgres via the idempotent schema apply, but populated only when web auth is enabled — `DATABASE_URL` + `BETTER_AUTH_SECRET`). Owned and shaped by Better Auth (text ids, camelCase columns); Archon never queries them directly — a session maps to the canonical `users` row via `user_identities('web', )` +19. **`schema_version`** - Diagnostic schema vintage (#2316): single row (`id = 1`) recording `created_app_version` (the Archon build that created this database — NULL, never guessed, for databases predating the table) and `app_version`/`applied_at` (the build that last applied schema). Written from `APP_VERSION` by both adapters' existing idempotent apply-on-connect path, and only when the value changes. Surfaced by `archon doctor` and `GET /api/health`; **nothing gates on it** **Key Patterns:** - Conversation ID format: Platform-specific (`thread_ts`, `chat_id`, `user/repo#123`) @@ -753,6 +765,9 @@ This ensures type compatibility with SDK updates and eliminates `as any` casts. - Clean up test data after each test **Mock isolation rules (IMPORTANT):** +- **`mock.module()` MERGES over the real module — it does NOT replace the namespace.** An export omitted from the factory keeps its REAL implementation (verified on bun 1.3.11). So adding a new export to a production module silently un-mocks it in every test that mocks that module, and those tests start doing real I/O with no signal. This is exactly how `/workflow abandon` tests began opening a real SQLite DB: `findChildRuns` was added to `db/workflows` by #2121 but never added to `command-handler.test.ts`'s factory (see #2240). **When you add an export to a module, grep for `mock.module(''` and update every factory.** +- Unit tests must not touch real external resources. A missing stub does not fail loudly — it stalls, and the only bound is Bun's 5000 ms per-test timeout, which surfaces on CI as an intermittent, hard-to-attribute timeout (#2186, #2240). To audit a suspect file, run it with `ARCHON_HOME` pointed at an empty temp dir and check whether an `archon.db` appears. +- `@archon/adapters` enforces the network half via `packages/adapters/bunfig.toml` → `src/test/no-network.ts` (other packages can adopt it with the same three lines). Two limits, both verified: it traps only `globalThis.fetch`, so axios/undici clients (`@slack/web-api`, `@discordjs/rest`) slip past it; and **Bun reads `bunfig.toml` only from cwd**, so the guard is INACTIVE in the `bun test packages/…` single-file form above — it applies to `bun run test` and to `bun test` run from inside `packages/adapters/`. That same cwd rule means the ROOT `bunfig.toml` (its `preload` and `coverage`) never applies under `bun run test` either, since `bun --filter` runs each package from its own directory. - Bun's `mock.module()` is process-global and irreversible — `mock.restore()` does NOT undo it - Do NOT add `afterAll(() => mock.restore())` for `mock.module()` cleanup — it has no effect - Use `spyOn()` for internal modules that other test files import directly (e.g., `spyOn(git, 'checkout')`) — `spy.mockRestore()` DOES work for spies @@ -827,7 +842,7 @@ async function createSession(conversationId: string, codebaseId: string) { 2. **Workflows** (YAML-based): - Stored in `.archon/workflows/` (searched recursively) - Multi-step AI execution chains, discovered at runtime - - **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured), `loop:` (iterative AI prompt until completion signal), `loop_group:` (multi-node sub-DAG body repeated per iteration until `until` signal / `until_bash` exit 0 / `max_iterations`; body is sealed for `depends_on` but may read outer outputs via `$nodeId.output` and the previous iteration via `$LOOP_PREV..output`; a failed body node fails the group immediately; group-level `model`/`provider` become body defaults), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$.output` for downstream nodes, default false), `script:` (inline TypeScript/Python or named script from `.archon/scripts/`, runs via `bun` or `uv`, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`), `include:` (load-time inlining of another workflow's nodes as a flattened, namespaced sub-DAG — each included node becomes `__`; the include node's `depends_on`/`when`/`trigger_rule` attach to the block's entry nodes, and `$includeId.output` resolves to the block's terminal (primary) sink; expansion happens at discovery so the executor sees ordinary nodes — see the "Reusing a Shared Sub-DAG" guide) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (SDK-enforced on Claude/Codex/OpenCode; best-effort prompt-augmentation + repair on Pi/Copilot — the parsed output is **validated against the declared schema for every provider**, best-effort providers (Pi/Copilot) re-ask up to 3× on a validation miss, and a node that declares `output_format` but returns no schema-valid output **fails** rather than degrading silently; `$nodeId.output.field` access is strict — a field not in the producer's schema, or a schemaless node whose output isn't JSON / lacks the key, fails the consuming node, while an author-declared-optional field resolves to `''`), `allowed_tools`/`denied_tools` for per-node tool restrictions (all providers except Codex), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (all providers except Pi, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (per-node injection on Claude/Pi/OpenCode/Copilot; Codex instead auto-discovers skills from `.agents/skills/` on the filesystem — the `skills:` list is informational for Codex nodes), `agents` for inline sub-agent definitions invokable via the Task tool (Claude only), and `effort`/`thinking` for reasoning depth (Claude/Pi/Copilot) plus the Claude-only SDK advanced options `maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` (also settable at workflow level), and `persist_session` for cross-run provider session continuity (node-level opt-in; workflow-level default via `persist_sessions: true`; requires a provider with the `sessionResume` capability), and `output_type` (any node type) for engine-written typed output sidecars — when set, the executor writes `$ARTIFACTS_DIR/nodes/.md` + `.meta.json` after the node completes (best-effort) so downstream nodes and later runs can locate output by type instead of guessing filenames + - **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured), `loop:` (iterative AI prompt until completion signal), `loop_group:` (multi-node sub-DAG body repeated per iteration until `until` signal / `until_bash` exit 0 / `max_iterations`; body is sealed for `depends_on` but may read outer outputs via `$nodeId.output` and the previous iteration via `$LOOP_PREV..output`; a failed body node fails the group immediately; group-level `model`/`provider` become body defaults), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$.output` for downstream nodes, default false), `script:` (inline TypeScript/Python or named script from `.archon/scripts/`, runs via `bun` or `uv`, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`), `include:` (load-time inlining of another workflow's nodes as a flattened, namespaced sub-DAG — each included node becomes `__`; the include node's `depends_on`/`when`/`trigger_rule` attach to the block's entry nodes, and `$includeId.output` resolves to the block's terminal (primary) sink; expansion happens at discovery so the executor sees ordinary nodes — see the "Reusing a Shared Sub-DAG" guide), `workflow:` (runtime sub-run — starts another workflow by static name as a separate governed CHILD run with its own `workflow_runs` row (`parent_run_id`), artifacts, gates, cost, and audit trail; `input:` forwards a data string (substituted like prompt bodies) as the child's `$ARGUMENTS`; the child's terminal output threads back as `$nodeId.output`; a child gate pauses the whole tree (approve the CHILD by run id — the parent auto-resumes on child completion); shared checkout only in slice 1 (`isolation: inherit`; `worktree` reserved/rejected), `with:` and `retry:` rejected, disallowed inside a `loop_group` body; abandon cascade-cancels descendants) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (SDK-enforced on Claude/Codex/OpenCode; best-effort prompt-augmentation + repair on Pi/Copilot — the parsed output is **validated against the declared schema for every provider**, best-effort providers (Pi/Copilot) re-ask up to 3× on a validation miss, and a node that declares `output_format` but returns no schema-valid output **fails** rather than degrading silently; `$nodeId.output.field` access is strict — a field not in the producer's schema, or a schemaless node whose output isn't JSON / lacks the key, fails the consuming node, while an author-declared-optional field resolves to `''`), `allowed_tools`/`denied_tools` for per-node tool restrictions (all providers except Codex), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (all providers except Pi, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (per-node injection on Claude/Pi/OpenCode/Copilot; Codex instead auto-discovers skills from `.agents/skills/` on the filesystem — the `skills:` list is informational for Codex nodes), `agents` for inline sub-agent definitions invokable via the Task tool (Claude only), and `effort`/`thinking` for reasoning depth (Claude/Pi/Copilot) plus the Claude-only SDK advanced options `maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` (also settable at workflow level), and `persist_session` for cross-run provider session continuity (node-level opt-in; workflow-level default via `persist_sessions: true`; requires a provider with the `sessionResume` capability), and `output_type` (any node type) for engine-written typed output sidecars — when set, the executor writes `$ARTIFACTS_DIR/nodes/.md` + `.meta.json` after the node completes (best-effort) so downstream nodes and later runs can locate output by type instead of guessing filenames - Workflow-level `requires: [github]` hard-blocks invocation (before any worktree/clone/AI cost) when the originating user hasn't connected their GitHub identity — enforced only when per-user GitHub is enabled (GitHub App + `TOKEN_ENCRYPTION_KEY`); a no-op for solo PAT installs - Provider inherited from `.archon/config.yaml` unless explicitly set; per-node `provider` and `model` overrides supported - Model and options can be set per workflow or inherited from config defaults @@ -845,7 +860,7 @@ async function createSession(conversationId: string, codebaseId: string) { - Source builds: Loaded from filesystem at runtime - Merged with repo-specific commands/workflows (repo overrides defaults by name) - Opt-out: Set `defaults.loadDefaultCommands: false` or `defaults.loadDefaultWorkflows: false` in `.archon/config.yaml` -- **After adding, removing, or editing a default file, run `bun run generate:bundled`** to refresh the embedded bundle. After editing `migrations/000_combined.sql`, run `bun run generate:bundled-schema` to keep the embedded schema in sync, AND mirror any new table into `createSchema()` in `packages/core/src/db/adapters/sqlite.ts` — the SQLite schema is hand-maintained separately and is NOT generated from the migration; the only intentional Postgres-only exception is the `remote_agent_auth_*` Better Auth tables, and the schema-parity test in `sqlite.test.ts` fails CI on any other drift. After a `@earendil-works/pi-ai` upgrade, run `bun run generate:pi-vendor-map` to regenerate the Pi backend → env-var map + credential specs from the installed SDK (a new upstream backend must be classified in `scripts/generate-pi-vendor-map.ts`). After changing any provider's `capabilities.ts` (or adding a provider/capability axis), run `bun run generate:capability-matrix` to refresh the canonical provider capability matrix at `packages/docs-web/src/content/docs/reference/provider-capabilities.md` — it is generated from the registry's capability constants (the same objects the dag-executor reads for ignored-capability warnings), so the docs can never drift from runtime behavior; a new `ProviderCapabilities` field fails the generator until it gets a matrix axis in `scripts/generate-capability-matrix.ts`. `bun run validate` (and CI) run `check:bundled`, `check:bundled-skill`, `check:bundled-schema`, `check:pi-vendor-map`, and `check:capability-matrix` and will fail loudly if any generated file is stale. +- **After adding, removing, or editing a default file, run `bun run generate:bundled`** to refresh the embedded bundle. A new default file must be staged in git first (`git add`); `bun run generate:bundled` (and `check:bundled`) refuse to embed untracked files in `defaults/`. After editing `migrations/000_combined.sql`, run `bun run generate:bundled-schema` to keep the embedded schema in sync, AND mirror any new table into `createSchema()` in `packages/core/src/db/adapters/sqlite.ts` — the SQLite schema is hand-maintained separately and is NOT generated from the migration; the only intentional Postgres-only exception is the `remote_agent_auth_*` Better Auth tables, and the schema-parity test in `sqlite.test.ts` fails CI on any other drift. After a `@earendil-works/pi-ai` upgrade, run `bun run generate:pi-vendor-map` to regenerate the Pi backend → env-var map + credential specs from the installed SDK (a new upstream backend must be classified in `scripts/generate-pi-vendor-map.ts`). After changing any provider's `capabilities.ts` (or adding a provider/capability axis), run `bun run generate:capability-matrix` to refresh the canonical provider capability matrix at `packages/docs-web/src/content/docs/reference/provider-capabilities.md` — it is generated from the registry's capability constants (the same objects the dag-executor reads for ignored-capability warnings), so the docs can never drift from runtime behavior; a new `ProviderCapabilities` field fails the generator until it gets a matrix axis in `scripts/generate-capability-matrix.ts`. `bun run validate` (and CI) run `check:bundled`, `check:bundled-skill`, `check:bundled-schema`, `check:pi-vendor-map`, and `check:capability-matrix` and will fail loudly if any generated file is stale. **Home-scoped ("global") workflows, commands, and scripts** (user-level, applies to every project): - Workflows: `~/.archon/workflows/` (or `$ARCHON_HOME/workflows/`) @@ -909,7 +924,7 @@ Pattern: Use `classifyIsolationError()` (from `@archon/isolation`) to map git er **Workflow Run Lifecycle:** - `POST /api/workflows/runs/{runId}/resume` - Resume a failed run from where it left off (skips already-completed DAG nodes; AI session context is not restored). -- `POST /api/workflows/runs/{runId}/abandon` - Abandon a non-terminal run (marks as cancelled) +- `POST /api/workflows/runs/{runId}/abandon` - Abandon a non-terminal run (marks as cancelled); cascade-cancels non-terminal `workflow:` sub-run descendants (#2121 Phase 2) and reports `cascadeFailures`/`blockedParentRunId` - `DELETE /api/workflows/runs/{runId}` - Delete a terminal workflow run and its events **Codebases:** diff --git a/bun.lock b/bun.lock index 34aba07727..b9a431a28e 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@opencode-ai/sdk": "^1.17.3", }, "devDependencies": { + "@archon/git": "workspace:*", "@archon/providers": "workspace:*", "@eslint/js": "^9.39.1", "@types/bun": "latest", @@ -25,7 +26,7 @@ }, "packages/adapters": { "name": "@archon/adapters", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@archon/core": "workspace:*", "@archon/git": "workspace:*", @@ -45,7 +46,7 @@ }, "packages/cli": { "name": "@archon/cli", - "version": "0.5.0", + "version": "0.6.0", "bin": { "archon": "./src/cli.ts", }, @@ -67,7 +68,7 @@ }, "packages/core": { "name": "@archon/core", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@archon/git": "workspace:*", "@archon/isolation": "workspace:*", @@ -90,7 +91,7 @@ }, "packages/docs-web": { "name": "@archon/docs-web", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@astrojs/starlight": "^0.38.0", "astro": "^6.1.0", @@ -100,7 +101,7 @@ }, "packages/git": { "name": "@archon/git", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@archon/paths": "workspace:*", }, @@ -110,7 +111,7 @@ }, "packages/isolation": { "name": "@archon/isolation", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@archon/git": "workspace:*", "@archon/paths": "workspace:*", @@ -122,7 +123,7 @@ }, "packages/paths": { "name": "@archon/paths", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "dotenv": "^17", "pino": "^9", @@ -135,7 +136,7 @@ }, "packages/providers": { "name": "@archon/providers", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.209", "@archon/paths": "workspace:*", @@ -158,7 +159,7 @@ }, "packages/server": { "name": "@archon/server", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@archon/adapters": "workspace:*", "@archon/core": "workspace:*", @@ -180,7 +181,7 @@ }, "packages/web": { "name": "@archon/web", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@dagrejs/dagre": "^2.0.4", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -233,7 +234,7 @@ }, "packages/workflows": { "name": "@archon/workflows", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@archon/git": "workspace:*", "@archon/paths": "workspace:*", diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c3fdacf7f5..cc2989db29 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -6,11 +6,22 @@ set -e # which causes the Claude subprocess to fail silently when spawned with a missing cwd. mkdir -p /.archon/workspaces /.archon/worktrees -# Determine if we need to use gosu for privilege dropping +# Determine if we need to use gosu for privilege dropping. +# Default: run commands as-is — already non-root (e.g., --user flag or +# Kubernetes), or root via the explicit ARCHON_ALLOW_ROOT_FALLBACK opt-in below. +RUNNER="" if [ "$(id -u)" = "0" ]; then # A blanket `chown -R` rewrites metadata for every inode (#1970); only files # with wrong ownership are touched. # find + chown -h leaves symlinks un-dereferenced (no-dereference by design). + # + # chown can fail when the host controls ownership: on macOS VirtioFS bind + # mounts, host UIDs cannot be remapped to appuser (1001) at all. On Linux, + # SELinux/AppArmor denials or read-only mounts produce the same failure and + # look identical from inside the container, so we cannot auto-distinguish. + # Failures are accumulated (not exited inline) so we can branch once below + # on the explicit ARCHON_ALLOW_ROOT_FALLBACK opt-in. + chown_failed=0 fix_ownership() { local err # -o is correct: we want appuser:appuser on both, not "either matches". @@ -19,7 +30,7 @@ if [ "$(id -u)" = "0" ]; then echo "$err" >&2 fi echo "ERROR: Failed to fix ownership of $1 — volume may be read-only or mounted with incompatible options" >&2 - exit 1 + chown_failed=1 fi } fix_ownership /.archon @@ -28,10 +39,20 @@ if [ "$(id -u)" = "0" ]; then # and other user-specific state survive rebuilds. On bind mounts, host UIDs # don't map to appuser (1001), so fix ownership via fix_ownership as well. fix_ownership /home/appuser - RUNNER="gosu appuser" -else - # Already running as non-root (e.g., --user flag or Kubernetes) - RUNNER="" + if [ "$chown_failed" = "0" ]; then + RUNNER="gosu appuser" + elif [ "${ARCHON_ALLOW_ROOT_FALLBACK:-0}" = "1" ]; then + # Explicit opt-in (macOS VirtioFS escape hatch): continue as root. + # IS_SANDBOX=1 satisfies ClaudeProvider's UID-0 guard + # (packages/providers/src/claude/provider.ts), which otherwise refuses + # bypassPermissions as root. Never auto-enabled — default stays fail-loud. + echo "WARNING: ARCHON_ALLOW_ROOT_FALLBACK=1 — continuing as root with IS_SANDBOX=1." >&2 + export IS_SANDBOX=1 + else + # Fail loud (default) — see the docker deployment guide for the + # ARCHON_ALLOW_ROOT_FALLBACK opt-in. + exit 1 + fi fi # Warn if vars known to be ignored inside the container were set via env_file: .env. diff --git a/homebrew/archon.rb b/homebrew/archon.rb index 597bb99f2c..44ab69ab42 100644 --- a/homebrew/archon.rb +++ b/homebrew/archon.rb @@ -7,28 +7,28 @@ class Archon < Formula desc "Remote agentic coding platform - control AI assistants from anywhere" homepage "https://github.com/coleam00/Archon" - version "0.5.0" + version "0.6.0" license "MIT" on_macos do on_arm do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-arm64" - sha256 "3258a78413f6cc0eb8fb214b3293bccbbdb670c09c973821633ee478c6d91bd1" + sha256 "c693d8c2acac75256fa2c972eb43c1ad8960946ad98046f8569f504229504fa2" end on_intel do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-x64" - sha256 "82d46f2f9f520585c7e030cb48f6d7f174c534810a94227ad8ce83bb3255ee59" + sha256 "6d968fa6ac78e7719532bc9e63bf4ed6457e6a5e82f093e53f2648b52dd400c6" end end on_linux do on_arm do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-arm64" - sha256 "74c86788121e386fe7c309dc5178417ea555795fc92804d7ebdc1f16e98d6080" + sha256 "4c3ed0e8508c8fda1a8802794a4e2c7a6a3735e84b7885c3f2fac3e0b8466ffc" end on_intel do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-x64" - sha256 "dff16b810a0736c651cadffb4f0cef8eca491f3c2137d64aa2324c07333fb455" + sha256 "add33035c28672e0eea685c9b76c38f288985b17998a4abf4aa0e9a2795b91b0" end end diff --git a/migrations/000_combined.sql b/migrations/000_combined.sql index 367833fe60..4686b10257 100644 --- a/migrations/000_combined.sql +++ b/migrations/000_combined.sql @@ -236,6 +236,7 @@ CREATE TABLE IF NOT EXISTS remote_agent_workflow_runs ( user_message TEXT NOT NULL, metadata JSONB DEFAULT '{}', parent_conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE SET NULL, + parent_run_id UUID REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL, started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), completed_at TIMESTAMP WITH TIME ZONE, last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), @@ -414,6 +415,16 @@ CREATE INDEX IF NOT EXISTS idx_conversations_user_id CREATE INDEX IF NOT EXISTS idx_workflow_runs_user_id ON remote_agent_workflow_runs(user_id) WHERE user_id IS NOT NULL; +-- Run-tree parent (#2121 Phase 2): a `workflow:` sub-run links back to the run +-- that spawned it. Self-referential FK, ON DELETE SET NULL so deleting a parent +-- orphans children rather than cascade-deleting their audit trail. First +-- self-referential FK on this table — declared identically on SQLite (sqlite.ts). +ALTER TABLE remote_agent_workflow_runs + ADD COLUMN IF NOT EXISTS parent_run_id UUID + REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_run + ON remote_agent_workflow_runs(parent_run_id) WHERE parent_run_id IS NOT NULL; + -- From PR-C: per-user GitHub user-to-server tokens (device flow), encrypted at rest. -- One row per Archon user; cascades on user deletion. github_user_id is the -- numeric anchor for the commit no-reply email (survives username changes). @@ -506,6 +517,27 @@ ALTER TABLE remote_agent_user_ai_prefs ALTER TABLE remote_agent_users ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin'; +-- ============================================================================ +-- Schema vintage (#2316) +-- ============================================================================ +-- +-- Which Archon build created this database, and which last applied schema to it. +-- Diagnostic only — nothing gates, refuses, or warns on these values. Single row +-- (id = 1); the row's VALUES are written by the adapters from APP_VERSION +-- (packages/core/src/db/schema-version.ts) so the version string has exactly one +-- source of truth. created_app_version is NULL for databases that predate this +-- table and is never back-filled with a guess. +CREATE TABLE IF NOT EXISTS remote_agent_schema_version ( + id INTEGER PRIMARY KEY CHECK (id = 1), + created_app_version VARCHAR(64), + app_version VARCHAR(64) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE remote_agent_schema_version IS + 'Diagnostic schema vintage: the Archon build that created this database and the one that last applied schema to it.'; + -- Better Auth tables (PostgreSQL only). Generated by `@better-auth/cli generate` -- against packages/server/src/auth/instance.ts (modelName-renamed to the -- `remote_agent_auth_*` prefix), then made idempotent with IF NOT EXISTS so the diff --git a/package.json b/package.json index 307af872ae..4ee73be0bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "archon", - "version": "0.6.0", + "version": "0.7.0", "private": true, "workspaces": [ "packages/*" @@ -24,6 +24,7 @@ "check:pi-vendor-map": "bun run scripts/generate-pi-vendor-map.ts --check", "generate:capability-matrix": "bun run scripts/generate-capability-matrix.ts", "check:capability-matrix": "bun run scripts/generate-capability-matrix.ts --check", + "test:install": "bash scripts/test-install.sh", "test": "bun --filter '*' --parallel test", "test:watch": "bun --filter @archon/server test:watch", "type-check": "bun --filter '*' type-check && bun x tsc --noEmit -p scripts/tsconfig.json", @@ -35,11 +36,12 @@ "build:web": "bun --filter @archon/web build", "dev:docs": "bun --filter @archon/docs-web dev", "build:docs": "bun --filter @archon/docs-web build", - "validate": "bun run check:bundled && bun run check:bundled-skill && bun run check:bundled-schema && bun run check:pi-vendor-map && bun run check:capability-matrix && bun run type-check && bun run lint --max-warnings 0 && bun run format:check && bun run test", + "validate": "bun run check:bundled && bun run check:bundled-skill && bun run check:bundled-schema && bun run check:pi-vendor-map && bun run check:capability-matrix && bun run type-check && bun run lint --max-warnings 0 && bun run format:check && bun run test:install && bun run test", "prepare": "husky", "setup-auth": "bun --filter @archon/server setup-auth" }, "devDependencies": { + "@archon/git": "workspace:*", "@archon/providers": "workspace:*", "@eslint/js": "^9.39.1", "@types/bun": "latest", diff --git a/packages/adapters/bunfig.toml b/packages/adapters/bunfig.toml new file mode 100644 index 0000000000..02a9b7b7ea --- /dev/null +++ b/packages/adapters/bunfig.toml @@ -0,0 +1,13 @@ +# Package-local Bun config. +# +# The repo-root bunfig.toml is NOT read here: `bun run test` runs +# `bun --filter '*' --parallel test`, which executes each package's script with +# cwd set to that package, and Bun only reads bunfig.toml from the cwd. So the +# guard has to be registered per package that needs it. +# +# Adapters are the one package whose unit tests instantiate production API +# clients (Octokit, bare fetch against a base URL), so they are the one place a +# forgotten stub turns into a live request and an intermittent CI timeout. +# See src/test/no-network.ts and #2186. +[test] +preload = ["./src/test/no-network.ts"] diff --git a/packages/adapters/package.json b/packages/adapters/package.json index 8583c0f022..714d10359b 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -1,6 +1,6 @@ { "name": "@archon/adapters", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", diff --git a/packages/adapters/src/community/forge/gitea/adapter.test.ts b/packages/adapters/src/community/forge/gitea/adapter.test.ts index 55263878e3..14f94e17f7 100644 --- a/packages/adapters/src/community/forge/gitea/adapter.test.ts +++ b/packages/adapters/src/community/forge/gitea/adapter.test.ts @@ -4,7 +4,7 @@ * Note: These tests focus on adapter-specific functionality without mocking * database modules to avoid test pollution issues with Bun's mock.module. */ -import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'; +import { describe, test, expect, mock, spyOn, beforeEach, afterEach } from 'bun:test'; // Mock @archon/paths to suppress noisy logger output during tests const mockLogger = { @@ -840,7 +840,17 @@ describe('GiteaAdapter', () => { }); describe('user identity resolution', () => { + // Tests here drive handleWebhook() all the way to handleMessage, which first + // calls fetchCommentHistory() — a bare `fetch` against the adapter's + // baseUrl. Left unstubbed that is a real DNS + TCP attempt to + // gitea.example.com on every run, making the test's outcome depend on an + // external host inside Bun's 5000 ms per-test budget (#2186). Stub it. + let fetchSpy: ReturnType>; + beforeEach(() => { + fetchSpy = spyOn(globalThis, 'fetch').mockImplementation( + () => Promise.resolve(new Response('[]', { status: 200 })) as ReturnType + ); mockFindOrCreateUserByPlatformIdentity.mockClear(); mockFindOrCreateUserByPlatformIdentity.mockImplementation(async () => ({ id: 'user-test-uuid', @@ -851,6 +861,10 @@ describe('GiteaAdapter', () => { })); }); + afterEach(() => { + fetchSpy.mockRestore(); + }); + function createWebhookAdapter(): GiteaAdapter { const a = new GiteaAdapter( 'https://gitea.example.com', diff --git a/packages/adapters/src/forge/github/adapter.test.ts b/packages/adapters/src/forge/github/adapter.test.ts index a22c9e3b38..b58d2f874e 100644 --- a/packages/adapters/src/forge/github/adapter.test.ts +++ b/packages/adapters/src/forge/github/adapter.test.ts @@ -3,6 +3,22 @@ * * Note: Database modules are mocked to prevent self-filtering tests from * writing phantom records (e.g., testuser/testrepo) to the real SQLite DB. + * + * ARCHON_HOME INVARIANT (#2305): this file must create nothing under + * `$ARCHON_HOME`. `mock.module()` MERGES over the real module rather than + * replacing it, so every export a factory below omits keeps its REAL + * implementation and quietly does real I/O. Three such gaps produced a real + * `archon.db`, `config.yaml` and `bin/git-credential-archon`: + * + * - `resolveDefaultAssistant` (step 6, via getOrCreateCodebaseForRepo) + * → loadGlobalConfig() CREATES ~/.archon/config.yaml when absent + * - `installCredentialHelper` (step 8, App-mode clone) + * → copies the helper script into ~/.archon/bin/ + * - `handleMessage` (step 13, orchestrator) + * → opens the real SQLite database and creates ~/.archon/workspaces/ + * + * All three are stubbed below. To re-audit, run this file with `ARCHON_HOME` + * pointed at an empty temp dir and assert nothing appears in it. */ import { describe, @@ -15,6 +31,9 @@ import { beforeEach, afterEach, } from 'bun:test'; +import { randomUUID } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; // Mock logger to suppress noisy output during tests const mockLogger = { @@ -104,15 +123,28 @@ mock.module('@archon/core/db/users', () => ({ findOrCreateUserByPlatformIdentity: mockFindOrCreateUserByPlatformIdentity, })); +// getOrCreateCodebaseForRepo passes `await resolveDefaultAssistant(path)` to +// createCodebase. The real implementation reads the global config and, when +// ~/.archon/config.yaml does not exist, WRITES a default one (see +// createDefaultConfig in @archon/core/config/config-loader). Every test that +// gets past self-filtering reaches it, so leaving it real meant this suite +// created a config file in the developer's real ~/.archon (#2305). +const mockResolveDefaultAssistant = mock(async () => 'claude' as const); +mock.module('@archon/core/config/resolve-assistant', () => ({ + resolveDefaultAssistant: mockResolveDefaultAssistant, +})); + // Mock @archon/git for ensureRepoReady integration tests const mockCloneRepository = mock(async () => ({ ok: true, value: undefined })); const mockSyncRepository = mock(async () => ({ ok: true, value: undefined })); const mockAddSafeDirectory = mock(async () => undefined); const mockIsWorktreePath = mock(async () => false); -// execFileAsync is used by installCredentialHelper (which runs after a -// successful App-mode clone). We don't need to assert against it here; it -// just has to be a no-op rather than `undefined` (which would TypeError). +// execFileAsync is exported by @archon/git and must exist on the mocked +// namespace rather than be `undefined` (which would TypeError if reached). +// Nothing in this file asserts against it: installCredentialHelper, its only +// caller on this path, is stubbed at file scope (see below), and its use of +// execFileAsync is covered in @archon/core instead. const mockExecFileAsync = mock(async () => ({ stdout: '', stderr: '' })); mock.module('@archon/git', () => ({ @@ -147,6 +179,100 @@ const mockLockManager = { }), } as unknown as ConversationLockManager; +/** + * File-wide stubs for the `@archon/core` functions `handleWebhook()` reaches in + * its final steps. `spyOn` (not `mock.module`) because `context.test.ts` already + * mock.modules `@archon/core` with a different shape, and two conflicting + * factories for one path is exactly the pollution CLAUDE.md forbids — and + * because spies are reversible. + * + * Lifetime is deliberately the WHOLE FILE, not a single describe block. The + * `handleMessage` spy used to be scoped to `webhook delivery dedup`, whose + * `afterAll` restored it before `App mode` ran — which is how the App-mode + * webhook test ended up running the real orchestrator and opening the real + * SQLite database (#2305). + */ +let handleMessageSpy: ReturnType>; +let installCredentialHelperSpy: ReturnType>; +let getLinkedIssueNumbersSpy: ReturnType>; + +beforeAll(() => { + handleMessageSpy = spyOn(core, 'handleMessage').mockImplementation(async () => {}); + installCredentialHelperSpy = spyOn(core, 'installCredentialHelper').mockImplementation( + async () => ({ kind: 'installed', helperPath: '/stub/.archon/bin/git-credential-archon' }) + ); + getLinkedIssueNumbersSpy = spyOn(core, 'getLinkedIssueNumbers').mockImplementation( + async () => [] + ); +}); + +afterAll(() => { + handleMessageSpy.mockRestore(); + installCredentialHelperSpy.mockRestore(); + getLinkedIssueNumbersSpy.mockRestore(); +}); + +/** + * A path that is guaranteed not to exist, so `ensureRepoReady` takes its CLONE + * branch rather than the "directory already exists → sync" branch. + * + * Shared literals like `/tmp/clone-test` are a collision waiting to happen, and + * the failure is silent in the wrong direction: on a machine where the path + * exists, a `not.toHaveBeenCalled()` assertion downstream of the clone branch + * passes for the wrong reason. Unique per call, and never created. + */ +function unclonedPath(): string { + return join(tmpdir(), `archon-clone-test-${randomUUID()}`); +} + +/** + * Every Octokit REST endpoint `handleWebhook()` can reach, stubbed to RESOLVE: + * `repos.get` (step 7), `issues.createComment` (postComment), `pulls.get` + * (step 10, PR payloads) and `issues.listComments` (step 12, thread context). + * + * Resolving is the point. A stub that only makes `repos.get` REJECT looks + * complete but merely stops the flow at step 7's catch-and-return, leaving + * steps 8-13 unmocked; the first payload or code change that gets past step 7 + * then silently falls into real I/O. Stub the whole surface instead. + */ +interface OctokitStubs { + reposGet: ReturnType; + listComments: ReturnType; + createComment: ReturnType; + pullsGet: ReturnType; +} + +/** + * Replaces an adapter's private Octokit client with that surface and returns + * the stubs, for the callers that assert against them. + */ +function installOctokitStubs(adapter: GitHubAdapter): OctokitStubs { + const stubs: OctokitStubs = { + reposGet: mock(async () => ({ data: { default_branch: 'main' } })), + listComments: mock(async () => ({ data: [] })), + createComment: mock(async () => ({ data: {} })), + pullsGet: mock(async () => ({ + data: { + head: { + ref: 'feature-branch', + sha: 'abc123def456', + repo: { full_name: 'testuser/testrepo' }, + }, + base: { repo: { full_name: 'testuser/testrepo' } }, + }, + })), + }; + // @ts-expect-error - replacing private Octokit client for testing + adapter.octokit = { + rest: { + repos: { get: stubs.reposGet }, + issues: { listComments: stubs.listComments, createComment: stubs.createComment }, + pulls: { get: stubs.pullsGet }, + }, + }; + return stubs; +} + /** * Helper to create a test adapter with mocked Octokit createComment method. * Reduces duplication across tests that need to verify comment posting behavior. @@ -267,7 +393,14 @@ describe('GitHubAdapter', () => { let originalAllowedUsers: string | undefined; /** - * Creates an adapter with mocked signature verification for self-filtering tests. + * Creates an adapter with mocked signature verification and the full + * resolving Octokit surface (see `installOctokitStubs`), so a payload that + * survives self-filtering runs `handleWebhook()` to completion under mocks. + * NOTHING here depends on a call failing to stop the flow early. + * + * These tests assert self-filtering and user attribution, both of which + * happen at steps 4-5b — before any of the stubbed calls — so how far the + * flow runs afterwards is deliberately not load-bearing for them. */ function createSelfFilterAdapter(botMention = 'archon'): GitHubAdapter { const adapter = new GitHubAdapter( @@ -278,6 +411,7 @@ describe('GitHubAdapter', () => { ); // @ts-expect-error - accessing private method for testing adapter.verifySignature = mock(() => true); + installOctokitStubs(adapter); return adapter; } @@ -332,11 +466,7 @@ describe('GitHubAdapter', () => { const payload = createCommentPayload('@archon help', undefined); // no comment.user // sender.login defaults to 'user123' in createCommentPayload when commentAuthor is undefined. - try { - await adapter.handleWebhook(payload, 'mock-signature'); - } catch { - // Expected — Octokit not mocked for the message path. - } + await adapter.handleWebhook(payload, 'mock-signature'); // Identity resolution must run, and must have used sender.login. const calls = mockFindOrCreateUserByPlatformIdentity.mock.calls; @@ -369,11 +499,7 @@ describe('GitHubAdapter', () => { sender: { login: 'pr-author' }, }); - try { - await adapter.handleWebhook(payload, 'mock-signature'); - } catch { - // Expected — Octokit not mocked. - } + await adapter.handleWebhook(payload, 'mock-signature'); const calls = mockFindOrCreateUserByPlatformIdentity.mock.calls; expect(calls.length).toBeGreaterThan(0); @@ -386,11 +512,9 @@ describe('GitHubAdapter', () => { mockFindOrCreateUserByPlatformIdentity.mockRejectedValueOnce(new Error('db down')); const payload = createCommentPayload('@archon help', 'user123'); - try { - await adapter.handleWebhook(payload, 'mock-signature'); - } catch { - // Octokit not mocked downstream — that's fine. - } + // Deliberately NOT wrapped in try/catch: "never throws" is the assertion. + await adapter.handleWebhook(payload, 'mock-signature'); + // The user-resolution failure was caught and warn-logged; the webhook // handler proceeded past it (DB write for the conversation still happened). expect(mockGetOrCreateConversation).toHaveBeenCalled(); @@ -421,11 +545,7 @@ describe('GitHubAdapter', () => { const payload = createCommentPayload('@archon please help', 'user123'); // handleWebhook progresses past self-filtering into DB/Octokit operations - try { - await adapter.handleWebhook(payload, 'mock-signature'); - } catch { - // Expected - Octokit API not mocked for this test - } + await adapter.handleWebhook(payload, 'mock-signature'); // Real user comments proceed to conversation creation (not self-filtered) expect(mockGetOrCreateConversation).toHaveBeenCalled(); @@ -451,11 +571,7 @@ describe('GitHubAdapter', () => { const payload = createCommentPayload('@archon fix this', 'Wirasm'); // handleWebhook progresses past self-filtering into DB/Octokit operations - try { - await adapter.handleWebhook(payload, 'mock-signature'); - } catch { - // Expected - Octokit API not mocked for this test - } + await adapter.handleWebhook(payload, 'mock-signature'); // Comment without marker proceeds to conversation creation (not self-filtered) expect(mockGetOrCreateConversation).toHaveBeenCalled(); @@ -466,11 +582,7 @@ describe('GitHubAdapter', () => { const payload = createCommentPayload('@archon help', undefined); // No user field // Should not crash on undefined user - try { - await adapter.handleWebhook(payload, 'mock-signature'); - } catch { - // Expected - Octokit API not mocked for this test - } + await adapter.handleWebhook(payload, 'mock-signature'); // Missing user should not trigger self-filtering (proceeds to conversation creation) expect(mockGetOrCreateConversation).toHaveBeenCalled(); @@ -479,24 +591,12 @@ describe('GitHubAdapter', () => { describe('webhook delivery dedup', () => { let originalAllowedUsers: string | undefined; - let handleMessageSpy: ReturnType>; - - interface DedupOctokitStubs { - reposGet: ReturnType; - listComments: ReturnType; - createComment: ReturnType; - } /** * Adapter whose private Octokit client is replaced with local stubs so * handleWebhook() runs its full downstream path without live API calls. */ - function createDedupAdapter(): { adapter: GitHubAdapter; octokit: DedupOctokitStubs } { - const octokit: DedupOctokitStubs = { - reposGet: mock(async () => ({ data: { default_branch: 'main' } })), - listComments: mock(async () => ({ data: [] })), - createComment: mock(async () => ({ data: {} })), - }; + function createDedupAdapter(): { adapter: GitHubAdapter; octokit: OctokitStubs } { const adapter = new GitHubAdapter( { kind: 'pat', token: 'fake-token-for-testing' }, 'fake-webhook-secret', @@ -505,14 +605,7 @@ describe('GitHubAdapter', () => { ); // @ts-expect-error - accessing private method for testing adapter.verifySignature = mock(() => true); - // @ts-expect-error - replacing private Octokit client for testing - adapter.octokit = { - rest: { - repos: { get: octokit.reposGet }, - issues: { listComments: octokit.listComments, createComment: octokit.createComment }, - }, - }; - return { adapter, octokit }; + return { adapter, octokit: installOctokitStubs(adapter) }; } function createIdentifiedCommentPayload( @@ -558,14 +651,6 @@ describe('GitHubAdapter', () => { await adapter.handleWebhook(payload, 'mock-signature', deliveryId); } - beforeAll(() => { - handleMessageSpy = spyOn(core, 'handleMessage').mockImplementation(async () => {}); - }); - - afterAll(() => { - handleMessageSpy.mockRestore(); - }); - beforeEach(() => { originalAllowedUsers = process.env.GITHUB_ALLOWED_USERS; delete process.env.GITHUB_ALLOWED_USERS; @@ -1450,6 +1535,7 @@ describe('GitHubAdapter', () => { mockFindCodebaseByRepoUrl.mockClear(); mockCreateCodebase.mockClear(); mockFindOrCreateUserByPlatformIdentity.mockClear(); + installCredentialHelperSpy.mockClear(); }); test('getInstallationToken called once per (owner, repo) for sendMessage', async () => { @@ -1640,19 +1726,52 @@ describe('GitHubAdapter', () => { test('credential helper install is attempted after successful App-mode clone', async () => { const { adapter } = createAppModeAdapter(); + const clonePath = unclonedPath(); try { // @ts-expect-error - calling private method - await adapter.ensureRepoReady('owner', 'repo', 'main', '/tmp/clone-test', false); + await adapter.ensureRepoReady('owner', 'repo', 'main', clonePath, false); } catch { // ensureRepoReady may throw inside addSafeDirectory on the bogus path; - // we only care that installCredentialHelper attempted its git-config call. + // we only care that installCredentialHelper was reached. } - // installCredentialHelper calls `git -C config ...`. - const gitConfigCalls = mockExecFileAsync.mock.calls.filter(call => { - const args = call[1]; - return Array.isArray(args) && args.includes('config'); - }); - expect(gitConfigCalls.length).toBeGreaterThanOrEqual(1); + // Control: prove the CLONE branch ran. Without it, a path that happens to + // exist would send ensureRepoReady down the sync branch and this test + // would report on a flow it never entered. + expect(mockCloneRepository).toHaveBeenCalled(); + // Asserted on the function itself rather than through its `git config` + // side effect. The old proxy assertion required running the REAL + // installCredentialHelper, which copies the helper script into + // $ARCHON_HOME/bin/ — a genuine write into the developer's ~/.archon from + // a unit test (#2305). What this test is actually about is the adapter's + // wiring: App-mode clone → install helper on the cloned path. The helper's + // own copy/chmod/git-config behaviour is covered directly by + // packages/core/src/github-auth/credential-helper-install.test.ts. + expect(installCredentialHelperSpy).toHaveBeenCalledWith(clonePath); + }); + + test('credential helper install is NOT attempted in PAT mode', async () => { + const patAdapter = new GitHubAdapter( + { kind: 'pat', token: 'fake-token' }, + 'fake-webhook-secret', + mockLockManager, + 'archon' + ); + try { + // @ts-expect-error - calling private method + await patAdapter.ensureRepoReady('owner', 'repo', 'main', unclonedPath(), false); + } catch { + // Same bogus-path tolerance as the App-mode case above. + } + // Control FIRST, and load-bearing here: this is a `not.toHaveBeenCalled` + // assertion, so anything that stops ensureRepoReady before the credential + // block makes it pass VACUOUSLY — the exact silent-pass shape this PR + // exists to remove. Asserting the clone branch ran means the negative can + // only be satisfied by the auth-kind guard. + expect(mockCloneRepository).toHaveBeenCalled(); + // Pins the `this.auth.kind === 'app'` guard: PAT operators never get the + // helper installed. Without this, stubbing installCredentialHelper above + // would let the guard be deleted with both tests still green. + expect(installCredentialHelperSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/adapters/src/forge/github/context.test.ts b/packages/adapters/src/forge/github/context.test.ts index 4cb560143e..21606d2b1e 100644 --- a/packages/adapters/src/forge/github/context.test.ts +++ b/packages/adapters/src/forge/github/context.test.ts @@ -6,6 +6,15 @@ * * Separated from adapter.test.ts because these require heavy module mocking * of @archon/core and database modules to test the full handleWebhook flow. + * + * ARCHON_HOME INVARIANT (#2305): this file must create nothing under + * `$ARCHON_HOME`. `mock.module()` MERGES over the real module instead of + * replacing it, so any export omitted from a factory below keeps its REAL + * implementation. Two omissions made this "fully mocked" file open a real + * SQLite database and write a real `config.yaml`; see the notes on the + * `@archon/core/db/users` and `@archon/core/config/resolve-assistant` mocks. + * To re-audit, run this file with `ARCHON_HOME` pointed at an empty temp dir + * and assert nothing appears in it. */ import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'; import { createHmac } from 'crypto'; @@ -99,6 +108,32 @@ mock.module('@archon/core/db/codebases', () => ({ updateCodebase: mock(async () => {}), })); +// handleWebhook step 5b resolves the commenting GitHub login to an Archon user. +// The adapter imports this from the `@archon/core/db/users` SUBPATH, which the +// `@archon/core` factory above does not cover — so it stayed real and every test +// here opened (and schema-initialised) a real SQLite database on disk (#2305). +const mockFindOrCreateUserByPlatformIdentity = mock(async () => ({ + id: 'user-test-uuid', + display_name: 'Test', + email: null, + created_at: new Date(), + updated_at: new Date(), +})); + +mock.module('@archon/core/db/users', () => ({ + findOrCreateUserByPlatformIdentity: mockFindOrCreateUserByPlatformIdentity, +})); + +// getOrCreateCodebaseForRepo passes `await resolveDefaultAssistant(path)` to +// createCodebase. Also a subpath import (`@archon/core/config/resolve-assistant`) +// and also left real: it calls loadGlobalConfig(), which WRITES a default +// ~/.archon/config.yaml when the file does not exist (#2305). +const mockResolveDefaultAssistant = mock(async () => 'claude' as const); + +mock.module('@archon/core/config/resolve-assistant', () => ({ + resolveDefaultAssistant: mockResolveDefaultAssistant, +})); + mock.module('child_process', () => ({ execFile: mock( ( diff --git a/packages/adapters/src/test/no-network.ts b/packages/adapters/src/test/no-network.ts new file mode 100644 index 0000000000..74fa3dee5a --- /dev/null +++ b/packages/adapters/src/test/no-network.ts @@ -0,0 +1,84 @@ +/** + * Test guard: adapter unit tests must not reach the live network VIA `fetch`. + * + * Preloaded for every `bun test` run in this package via `bunfig.toml`. + * + * Why this exists (#2186): adapter tests construct real production clients + * (Octokit, or a bare `fetch` against a configured base URL) and drive + * `handleWebhook()` end-to-end. When the client is not stubbed the request is + * actually issued — api.github.com, gitea.example.com, … — so the test's + * outcome depends on an external host resolving and responding inside Bun's + * 5000 ms per-test budget. On CI that surfaced as an intermittent ~5001 ms + * timeout that passed on a plain re-run. + * + * The guard converts that latent 5-second flake into an immediate, named + * failure: "you forgot to stub the API client, and here is the URL you hit." + * + * Adapters swallow their own network errors by design (e.g. Gitea's + * `fetchCommentHistory` catches and returns `[]`), so throwing from `fetch` + * alone would be silently absorbed. Violations are therefore RECORDED and + * re-raised from an `afterEach` hook, where nothing can catch them. + * + * Legitimately stubbing `globalThis.fetch` with `spyOn` still works — the spy + * replaces this guard for the duration of the test and `mockRestore()` puts it + * back. + * + * KNOWN LIMITS — do not read this file as proof that no adapter test touches + * the network: + * + * 1. It only traps `globalThis.fetch`. `@slack/web-api` (axios) and + * `@discordjs/rest` (undici) issue zero `fetch` calls under bun, so Slack + * and Discord tests are NOT covered. Auditing those needs `node:http`/ + * `https`/`net`/`dns` hooks (that sweep was run once against all packages + * and came back clean; it is not wired in here). + * 2. It is INACTIVE when `bun test` is run from the repo root — the + * single-file form CLAUDE.md documents. Bun reads `bunfig.toml` only from + * cwd, so `bun test packages/adapters/...` from the root loads the ROOT + * config, not this one, and the request goes live. Only the package-cwd + * form (`bun run test`, or `bun test` from `packages/adapters/`) is guarded. + * 3. Blame is per-`afterEach`. A violation raised from `beforeAll`/`afterAll` + * is attributed to a neighbouring test, and one occurring after the final + * `afterEach` of a run is dropped entirely. + */ +import { afterEach } from 'bun:test'; + +const violations: string[] = []; + +/** First parameter of the ambient `fetch` — spelled this way because the DOM + * `RequestInfo` alias is not in this package's tsconfig `lib`. */ +type FetchTarget = Parameters[0]; + +function describeTarget(input: FetchTarget): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.href; + return input.url; +} + +/** `init.method` wins (that is fetch's own precedence), then a Request's own + * method, then the default. Getting this right matters: the whole point of the + * guard is that its message identifies the offending call precisely. */ +function describeMethod(input: FetchTarget, init?: RequestInit): string { + if (init?.method !== undefined) return init.method; + if (typeof input !== 'string' && !(input instanceof URL)) return input.method; + return 'GET'; +} + +globalThis.fetch = ((input: FetchTarget, init?: RequestInit): Promise => { + const target = `${describeMethod(input, init)} ${describeTarget(input)}`; + violations.push(target); + return Promise.reject( + new Error(`Blocked live network request from an adapter unit test: ${target}`) + ); +}) as typeof globalThis.fetch; + +afterEach(() => { + if (violations.length === 0) return; + const hits = violations.splice(0, violations.length); + throw new Error( + `Adapter unit test attempted ${String(hits.length)} live network request(s):\n` + + hits.map(hit => ` - ${hit}`).join('\n') + + '\n\nStub the platform API client instead of letting the request escape ' + + '(see createDedupAdapter in forge/github/adapter.test.ts for the pattern). ' + + 'Unit tests that reach an external host time out non-deterministically on CI — see #2186.' + ); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index ba56a3d809..6155ebbb53 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@archon/cli", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./src/cli.ts", "bin": { diff --git a/packages/cli/src/commands/doctor.test.ts b/packages/cli/src/commands/doctor.test.ts index 755e5b903d..5a0f0bc274 100644 --- a/packages/cli/src/commands/doctor.test.ts +++ b/packages/cli/src/commands/doctor.test.ts @@ -380,36 +380,85 @@ describe('checkPi', () => { }); describe('checkDatabase', () => { - it('returns pass when query succeeds', async () => { - const deps: DatabaseDeps = { + const schemaVersion = { + createdAppVersion: '0.5.3', + appVersion: '0.6.0', + createdAt: '2026-01-01T00:00:00.000Z', + appliedAt: '2026-07-01T00:00:00.000Z', + }; + + // Mirrors the makeDeps() helper in the checkFolderProject block below, so each + // test states only the field it varies. + function makeDeps(over: Partial = {}): DatabaseDeps { + return { pool: { query: async () => undefined }, getDatabaseType: () => 'sqlite', + getSchemaVersion: async () => schemaVersion, + ...over, }; - const result = await checkDatabase(async () => deps); + } + + it('returns pass when query succeeds', async () => { + const result = await checkDatabase(async () => makeDeps()); expect(result.status).toBe('pass'); expect(result.message).toContain('sqlite'); }); it('reports postgres dbType when configured', async () => { - const deps: DatabaseDeps = { - pool: { query: async () => undefined }, - getDatabaseType: () => 'postgres', - }; - const result = await checkDatabase(async () => deps); + const result = await checkDatabase(async () => makeDeps({ getDatabaseType: () => 'postgres' })); expect(result.status).toBe('pass'); expect(result.message).toContain('postgres'); }); + // Schema vintage (#2316): a bug report has to be able to state which build + // created the database and which last wrote to it. + it('reports both schema vintages when recorded', async () => { + const result = await checkDatabase(async () => makeDeps()); + expect(result.message).toContain('schema created by 0.5.3'); + expect(result.message).toContain('last applied by 0.6.0'); + }); + + it('says the creation vintage is unknown rather than inventing one', async () => { + const result = await checkDatabase(async () => + makeDeps({ getSchemaVersion: async () => ({ ...schemaVersion, createdAppVersion: null }) }) + ); + expect(result.status).toBe('pass'); + expect(result.message).toContain('predates version tracking'); + expect(result.message).toContain('last applied by 0.6.0'); + }); + + it('reports an unrecorded vintage without failing the check', async () => { + const result = await checkDatabase(async () => + makeDeps({ getSchemaVersion: async () => null }) + ); + expect(result.status).toBe('pass'); + expect(result.message).toContain('schema vintage not recorded'); + }); + + it('stays "pass" when the vintage read throws — the database is still reachable', async () => { + const result = await checkDatabase(async () => + makeDeps({ + getSchemaVersion: async () => { + throw new Error('no such table: remote_agent_schema_version'); + }, + }) + ); + expect(result.status).toBe('pass'); + expect(result.message).toContain('reachable (sqlite)'); + expect(result.message).toContain('schema vintage not recorded'); + }); + it('returns fail with "not reachable" when query throws', async () => { - const deps: DatabaseDeps = { - pool: { - query: async () => { - throw new Error('connection refused'); + const result = await checkDatabase(async () => + makeDeps({ + pool: { + query: async () => { + throw new Error('connection refused'); + }, }, - }, - getDatabaseType: () => 'postgres', - }; - const result = await checkDatabase(async () => deps); + getDatabaseType: () => 'postgres', + }) + ); expect(result.status).toBe('fail'); expect(result.message).toContain('not reachable'); expect(result.message).toContain('connection refused'); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index f1db02226f..53305fcfea 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -14,7 +14,7 @@ import { resolveCodexBinaryWithSource, type CodexBinarySource, } from '@archon/providers/codex/binary-resolver'; -import type { Codebase } from '@archon/core'; +import type { Codebase, SchemaVersionInfo } from '@archon/core'; // Vendor-canonical credential id for Codex (since #1955 credentials are keyed // by vendor, not agent). A connected `openai` key signals Codex intent even @@ -339,6 +339,18 @@ export async function checkPi(env: NodeJS.ProcessEnv): Promise { export interface DatabaseDeps { pool: { query: (sql: string) => Promise }; getDatabaseType: () => string; + getSchemaVersion: () => Promise; +} + +/** + * Render the schema vintage (#2316) for the Database check. A bug report that can + * state which build created the database — and which last wrote to it — is the whole + * point, so an unknown or unrecorded vintage is reported as such rather than hidden. + */ +function describeSchemaVersion(info: SchemaVersionInfo | null): string { + if (!info) return 'schema vintage not recorded'; + const created = info.createdAppVersion ?? ''; + return `schema created by ${created}, last applied by ${info.appVersion}`; } export async function checkDatabase( @@ -364,7 +376,21 @@ export async function checkDatabase( try { const dbType = deps.getDatabaseType(); await deps.pool.query('SELECT 1'); - return { label, status: 'pass', message: `reachable (${dbType})` }; + + // The vintage is diagnostic metadata: a failure to read it must not turn a + // reachable database into a failed check. Degrade the message instead. + let schemaInfo: SchemaVersionInfo | null = null; + try { + schemaInfo = await deps.getSchemaVersion(); + } catch (err) { + getLog().warn({ err }, 'doctor.schema_version_read_failed'); + } + + return { + label, + status: 'pass', + message: `reachable (${dbType}); ${describeSchemaVersion(schemaInfo)}`, + }; } catch (err) { getLog().error({ err }, 'doctor.db_query_failed'); return { label, status: 'fail', message: `not reachable: ${(err as Error).message}` }; @@ -374,8 +400,8 @@ export async function checkDatabase( async function defaultLoadDatabaseDeps(): Promise { // Lazy import so doctor doesn't pull in the full @archon/core graph just to // print --help or run a different check. - const { pool, getDatabaseType } = await import('@archon/core'); - return { pool, getDatabaseType }; + const { pool, getDatabaseType, getSchemaVersion } = await import('@archon/core'); + return { pool, getDatabaseType, getSchemaVersion }; } type FolderCodebase = Pick; diff --git a/packages/cli/src/commands/serve.test.ts b/packages/cli/src/commands/serve.test.ts index f85934d1e2..1cf8cb8cf2 100644 --- a/packages/cli/src/commands/serve.test.ts +++ b/packages/cli/src/commands/serve.test.ts @@ -9,7 +9,7 @@ import { afterEach, spyOn, } from 'bun:test'; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -98,6 +98,85 @@ describe('parseEmbeddedChecksum', () => { }); }); +// --------------------------------------------------------------------------- +// In-process tar.gz fixture builder. +// +// downloadWebDist shells out to `tar xzf -`, so the fixture it is fed has to be +// a genuine gzipped tar — but BUILDING that fixture does not need a subprocess. +// Spawning `tar czf -` here used to make the beforeAll hook the one thing in +// this file that could hang on a child process, which is exactly how it failed +// on windows CI (#2306). Emitting the ~1.1 KB ustar archive directly is +// deterministic, platform-independent, and needs no `tar` on PATH. +// --------------------------------------------------------------------------- + +/** Write ASCII into a fixed-width header field (NUL padding comes from the zeroed buffer). */ +function writeField(header: Uint8Array, offset: number, value: string, width: number): void { + header.set(new TextEncoder().encode(value).subarray(0, width), offset); +} + +/** Write a ustar numeric field: zero-padded octal followed by a trailing NUL. */ +function writeOctalField(header: Uint8Array, offset: number, value: number, width: number): void { + writeField(header, offset, value.toString(8).padStart(width - 1, '0'), width - 1); +} + +/** One 512-byte ustar header block. `typeflag` is '0' (file) or '5' (directory). */ +function tarHeader(name: string, size: number, typeflag: '0' | '5', mode: number): Uint8Array { + const header = new Uint8Array(512); + writeField(header, 0, name, 100); + writeOctalField(header, 100, mode, 8); + writeOctalField(header, 108, 0, 8); // uid + writeOctalField(header, 116, 0, 8); // gid + writeOctalField(header, 124, size, 12); + writeOctalField(header, 136, 0, 12); // mtime — fixed so the fixture is byte-stable + header.fill(0x20, 148, 156); // checksum field reads as 8 spaces while summing + header[156] = typeflag.charCodeAt(0); + writeField(header, 257, 'ustar', 6); // magic (NUL-terminated by the zeroed buffer) + writeField(header, 263, '00', 2); // version + let checksum = 0; + for (const byte of header) checksum += byte; + writeField(header, 148, checksum.toString(8).padStart(6, '0'), 6); + header[154] = 0x00; + header[155] = 0x20; + return header; +} + +/** Concatenate blocks into one buffer. */ +function concatBytes(blocks: Uint8Array[]): Uint8Array { + const total = blocks.reduce((sum, block) => sum + block.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const block of blocks) { + out.set(block, offset); + offset += block.length; + } + return out; +} + +/** + * The exact bytes the fixture claims to carry. Every test that extracts asserts + * the file lands with THIS content, not merely that a file exists — a hand-rolled + * binary format that nothing validates is a worse trap than the hang it replaced. + * A `size` field short by a few bytes, dropped padding, or a missing terminator + * all still produce an `index.html` and a `tar` exit 0; only comparing content + * catches them. + */ +const FIXTURE_INDEX_HTML = 'ok'; + +/** `web/` + `web/index.html`, tarred and gzipped — the shape `archon serve` downloads. */ +function buildWebTarball(indexHtml: string): Uint8Array { + const body = new TextEncoder().encode(indexHtml); + const padding = new Uint8Array((512 - (body.length % 512)) % 512); + return Bun.gzipSync( + concatBytes([ + tarHeader('web/', 0, '5', 0o755), + tarHeader('web/index.html', body.length, '0', 0o644), + body, + padding, + new Uint8Array(1024), // two zero blocks terminate the archive + ]) + ); +} + describe('downloadWebDist', () => { let tmpRoot: string; let tarballBytes: Uint8Array; @@ -105,15 +184,13 @@ describe('downloadWebDist', () => { let fetchSpy: ReturnType; let consoleLogSpy: ReturnType; - beforeAll(async () => { - // Build a real tarball (one top-level dir with index.html — downloadWebDist - // extracts with --strip-components=1) and compute its true SHA-256. + beforeAll(() => { + // Fixture: a real gzipped tar with one top-level dir holding index.html — + // downloadWebDist extracts with --strip-components=1. Built in-process + // (see buildWebTarball) rather than by shelling out to `tar czf -`, so the + // hook cannot hang on a subprocess (#2306). tmpRoot = mkdtempSync(join(tmpdir(), 'serve-webdist-test-')); - const srcDir = join(tmpRoot, 'web'); - mkdirSync(srcDir); - writeFileSync(join(srcDir, 'index.html'), 'ok'); - const proc = Bun.spawn(['tar', 'czf', '-', '-C', tmpRoot, 'web'], { stdout: 'pipe' }); - tarballBytes = new Uint8Array(await new Response(proc.stdout).arrayBuffer()); + tarballBytes = buildWebTarball(FIXTURE_INDEX_HTML); const hasher = new Bun.CryptoHasher('sha256'); hasher.update(tarballBytes); tarballHash = hasher.digest('hex'); @@ -139,7 +216,9 @@ describe('downloadWebDist', () => { await downloadWebDist('9.9.9', targetDir, tarballHash); - expect(existsSync(join(targetDir, 'index.html'))).toBe(true); + // Content, not just existence — a truncated or corrupt fixture still yields + // an index.html and a `tar` exit 0, so only this assertion catches it. + expect(readFileSync(join(targetDir, 'index.html'), 'utf8')).toBe(FIXTURE_INDEX_HTML); // Only the tarball is fetched — checksums.txt must NOT be requested. expect(fetchSpy).toHaveBeenCalledTimes(1); expect(String(fetchSpy.mock.calls[0]?.[0])).toContain('archon-web.tar.gz'); @@ -169,7 +248,7 @@ describe('downloadWebDist', () => { await downloadWebDist('9.9.9', targetDir, ''); - expect(existsSync(join(targetDir, 'index.html'))).toBe(true); + expect(readFileSync(join(targetDir, 'index.html'), 'utf8')).toBe(FIXTURE_INDEX_HTML); // Remote path fetches both checksums.txt and the tarball. expect(fetchSpy).toHaveBeenCalledTimes(2); const urls = fetchSpy.mock.calls.map(call => String(call[0])); @@ -177,6 +256,29 @@ describe('downloadWebDist', () => { }); }); +// Structural conformance of the hand-rolled archive, checked against the POSIX +// ustar spec rather than against the writer itself. +// +// The extraction tests above catch a wrong *payload* (a short `size` field +// truncates the file, which the content assertions see). They do NOT catch a +// wrong *envelope*: bsdtar happily extracts an archive with no end-of-archive +// marker and no block padding, so on macOS those corruptions pass silently and +// would only surface as a platform-specific CI failure — precisely the class of +// bug this file is being changed to remove. Hence these two. +describe('buildWebTarball structural conformance', () => { + const archive = Bun.gunzipSync(buildWebTarball(FIXTURE_INDEX_HTML)); + + it('is a whole number of 512-byte blocks', () => { + expect(archive.length % 512).toBe(0); + }); + + it('ends with the two zero blocks that mark end-of-archive', () => { + const terminator = archive.subarray(archive.length - 1024); + expect(terminator.length).toBe(1024); + expect(terminator.every(byte => byte === 0)).toBe(true); + }); +}); + describe('serveCommand', () => { let consoleErrorSpy: ReturnType; diff --git a/packages/cli/src/commands/workflow.test.ts b/packages/cli/src/commands/workflow.test.ts index 5cd7b96744..f03533d324 100644 --- a/packages/cli/src/commands/workflow.test.ts +++ b/packages/cli/src/commands/workflow.test.ts @@ -165,6 +165,7 @@ mock.module('@archon/core/db/workflows', () => ({ getWorkflowRunStatus: mock(() => Promise.resolve(null)), failWorkflowRun: mock(() => Promise.resolve()), cancelWorkflowRun: mock(() => Promise.resolve({ cancelled: true })), + findChildRuns: mock(() => Promise.resolve([])), findResumableRun: mock(() => Promise.resolve(null)), resumeWorkflowRun: mock(() => Promise.resolve(null)), getWorkflowRun: mock(() => Promise.resolve(null)), @@ -1782,8 +1783,9 @@ describe('workflowRunCommand', () => { (conversationDb.getOrCreateConversation as ReturnType).mockResolvedValueOnce({ id: 'conv-123', }); - // Single-segment checkout path — resolveOwnerRepo's path heuristic throws - // for these, so the stored owner/repo name must reach the provider (#2022). + // Single-segment checkout path — the stored owner/repo name must reach the + // provider so worktrees use the registered identity instead of the + // _local/ path fallback (#2022, #2227). (codebaseDb.findCodebaseByDefaultCwd as ReturnType).mockResolvedValueOnce({ id: 'cb-123', name: 'owner/repo', @@ -3220,11 +3222,18 @@ describe('buildDetachedRunCmd', () => { expect(cmd).toContain('--conversation-id'); }); - it('binary mode: uses [execPath] only (no duplicated entry arg), slices argv(1)', () => { + // A Bun single-file executable's argv is NOT [binary, ...userArgs]. Bun + // injects a virtual entry path at argv[1] and reports argv[0] as 'bun': + // ['bun', '/$bunfs/root/archon', 'workflow', 'run', ...] + // Verified against a real `bun build --compile` artifact. The previous + // fixture modelled a compiled argv with no argv[1] at all, which is why + // #2248 (detached child dies with `Unknown command: B:/~BUN/root/...`) + // shipped green. + it('binary mode: uses [execPath] only (no duplicated entry arg), drops the Bun SFE virtual argv[1]', () => { const cmd = buildDetachedRunCmd( true, '/usr/local/bin/archon', - ['/usr/local/bin/archon', 'workflow', 'run', 'assist', 'hello', '--detach', '--json'], + ['bun', '/$bunfs/root/archon', 'workflow', 'run', 'assist', 'hello', '--detach', '--json'], '/abs/cwd', ['--branch', 'assist-123'] ); @@ -3232,6 +3241,9 @@ describe('buildDetachedRunCmd', () => { expect(cmd[0]).toBe('/usr/local/bin/archon'); // The binary path must appear exactly once — never duplicated as argv[1]. expect(cmd.filter(arg => arg === '/usr/local/bin/archon')).toHaveLength(1); + // The virtual entry path must never reach the child: cli.ts parses + // process.argv.slice(2), so a leaked argv[1] becomes the child's command. + expect(cmd.some(arg => arg.includes('$bunfs'))).toBe(false); expect(cmd[1]).toBe('workflow'); expect(cmd).not.toContain('--detach'); expect(cmd).not.toContain('--json'); @@ -3239,6 +3251,31 @@ describe('buildDetachedRunCmd', () => { expect(cmd[cwdIdx + 1]).toBe('/abs/cwd'); expect(cmd.slice(cwdIdx + 2)).toEqual(['--branch', 'assist-123']); }); + + it('binary mode: drops the Windows Bun SFE virtual argv[1] (#2248 repro)', () => { + const cmd = buildDetachedRunCmd( + true, + 'C:\\Users\\dev\\archon.exe', + [ + 'bun', + 'B:/~BUN/root/archon-windows-x64.exe', + 'workflow', + 'run', + 'assist', + 'hello', + '--detach', + '--json', + ], + 'C:\\checkout', + ['--branch', 'assist-123'] + ); + + expect(cmd[0]).toBe('C:\\Users\\dev\\archon.exe'); + // The exact token that appeared as `Unknown command: ...` in the report. + expect(cmd).not.toContain('B:/~BUN/root/archon-windows-x64.exe'); + expect(cmd[1]).toBe('workflow'); + expect(cmd[2]).toBe('run'); + }); }); describe('workflowResumeCommand', () => { diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index dac3b2ce05..13064d41c8 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -240,12 +240,18 @@ export function buildDetachedRunCmd( cwd: string, extraArgs: string[] ): string[] { - // In a compiled binary, execPath IS the archon binary and there is no - // entry-script argv[1]; in dev, execPath is bun and argv[1] is the cli entry. + // Only the command prefix differs between modes: in a compiled binary + // execPath IS the archon binary and re-invoking it needs no entry script; in + // dev, execPath is bun and argv[1] is the cli entry that bun must be handed. const baseCmd = isBinary ? [execPath] : [execPath, argv[1]]; - const userArgs = (isBinary ? argv.slice(1) : argv.slice(2)).filter( - arg => arg !== '--detach' && arg !== '--json' - ); + // User args always start at argv[2] in BOTH modes. A Bun single-file + // executable does have an argv[1] — the virtual entry path + // (`/$bunfs/root/`, `B:/~BUN/root/.exe` on Windows) — so slicing + // from 1 in binary mode leaked that path in as the child's first token and + // the child died with `Unknown command: B:/~BUN/root/archon-...exe` (#2248). + // cli.ts's own parser reads `process.argv.slice(2)` unconditionally, which is + // the contract this must match. + const userArgs = argv.slice(2).filter(arg => arg !== '--detach' && arg !== '--json'); // --cwd is appended last (parseArgs last-wins) so the child resolves the same // absolute working dir regardless of any relative --cwd the caller passed. return [...baseCmd, ...userArgs, '--cwd', cwd, ...extraArgs]; @@ -1349,8 +1355,8 @@ export async function workflowRunCommand( : undefined, baseBranch: codebaseDefaultBranch ? git.toBranchName(codebaseDefaultBranch) : undefined, codebaseId: codebase.id, - // owner/repo name lets resolveOwnerRepo skip the path heuristic, which - // throws for single-segment checkout paths like /workspace (#2022) + // owner/repo name lets resolveOwnerRepo use the registered identity + // instead of the _local/ path fallback (#2022, #2227) codebaseName: codebase.name, canonicalRepoPath: git.toRepoPath(codebase.default_cwd), description: `CLI workflow: ${workflowName}`, @@ -2373,7 +2379,7 @@ export async function workflowAbandonCommand( if (json) { try { const resolvedId = await resolveRunIdArg(runId, cwd); - const run = await abandonWorkflow(resolvedId); + const { run, cascadeFailures, blockedParentRunId } = await abandonWorkflow(resolvedId); console.log( JSON.stringify( { @@ -2382,6 +2388,8 @@ export async function workflowAbandonCommand( action: 'abandon', status: 'cancelled', workflowName: run.workflow_name, + ...(cascadeFailures > 0 ? { cascadeFailures } : {}), + ...(blockedParentRunId ? { blockedParentRunId } : {}), }, null, 2 @@ -2394,9 +2402,22 @@ export async function workflowAbandonCommand( } const resolvedId = await resolveRunIdArg(runId, cwd); - const run = await abandonWorkflow(resolvedId); + const { run, cascadeFailures, blockedParentRunId } = await abandonWorkflow(resolvedId); console.log(`Abandoned workflow run: ${resolvedId}`); console.log(`Workflow: ${run.workflow_name}`); + if (cascadeFailures > 0) { + console.log( + `Warning: ${String(cascadeFailures)} sub-run(s) could not be cancelled and may still be running — check \`archon workflow status\`.` + ); + } + if (blockedParentRunId) { + console.log( + `Warning: parent run ${blockedParentRunId} was blocked on this sub-run and stays paused.` + ); + console.log( + ` Resume it to fail the node cleanly (archon workflow resume ${blockedParentRunId}) or abandon it too.` + ); + } } /** diff --git a/packages/core/package.json b/packages/core/package.json index 5e7200351e..7a0ad82465 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@archon/core", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", @@ -25,7 +25,7 @@ "./schemas/*": "./src/schemas/*.ts" }, "scripts": { - "test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/bundled-schema.test.ts && bun test src/db/connection.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/users.test.ts src/db/workflow-events.test.ts src/db/workflow-node-sessions.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/delivery-dedup.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/utils/token-crypto.test.ts && bun test src/db/workflows.resume-cas.integration.test.ts && bun test src/db/workflow-events.since.integration.test.ts && bun test src/db/isolation-environments.metadata.integration.test.ts && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/manage-run-tool.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/post-message-reminder.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts && bun test src/github-auth/auth.test.ts && bun test src/db/user-github-token-store.test.ts && bun test src/github-auth/device-flow.test.ts && bun test src/github-auth/config.test.ts && bun test src/github-auth/connect-service.test.ts && bun test src/db/user-provider-key-store.test.ts && bun test src/db/user-ai-prefs-store.test.ts && bun test src/credentials/connect-service.test.ts && bun test src/credentials/oauth-bridge.test.ts && bun test src/credentials/config.test.ts src/credentials/delivery.test.ts src/credentials/openai-oauth.test.ts src/credentials/catalog.test.ts", + "test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/bundled-schema.test.ts && bun test src/db/connection.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/users.test.ts src/db/workflow-events.test.ts src/db/workflow-node-sessions.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/delivery-dedup.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/utils/token-crypto.test.ts && bun test src/db/workflows.resume-cas.integration.test.ts && bun test src/db/workflow-events.since.integration.test.ts && bun test src/db/messages.order.integration.test.ts && bun test src/db/isolation-environments.metadata.integration.test.ts && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/manage-run-tool.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/post-message-reminder.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts && bun test src/github-auth/auth.test.ts && bun test src/db/user-github-token-store.test.ts && bun test src/github-auth/device-flow.test.ts && bun test src/github-auth/config.test.ts && bun test src/github-auth/connect-service.test.ts && bun test src/github-auth/credential-helper-install.test.ts && bun test src/db/user-provider-key-store.test.ts && bun test src/db/user-ai-prefs-store.test.ts && bun test src/credentials/connect-service.test.ts && bun test src/credentials/oauth-bridge.test.ts && bun test src/credentials/config.test.ts src/credentials/delivery.test.ts src/credentials/openai-oauth.test.ts src/credentials/catalog.test.ts", "type-check": "bun x tsc --noEmit", "build": "echo 'No build needed - Bun runs TypeScript directly'" }, diff --git a/packages/core/src/config/config-loader.test.ts b/packages/core/src/config/config-loader.test.ts index db5d3e6bd9..77efa6ff8b 100644 --- a/packages/core/src/config/config-loader.test.ts +++ b/packages/core/src/config/config-loader.test.ts @@ -476,6 +476,59 @@ worktree: expect(config.baseBranch).toBeUndefined(); }); + test('propagates remote from repo worktree config', async () => { + const pathMatches = (path: string, pattern: string): boolean => { + const normalizedPath = path.replace(/\\/g, '/'); + return normalizedPath.includes(pattern); + }; + + mockFsReadFile.mockImplementation(async (path: string) => { + if (pathMatches(path, '/repo/.archon/config.yaml')) { + return ` +worktree: + remote: upstream +`; + } + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }); + + const config = await loadConfig('/test/repo'); + expect(config.remote).toBe('upstream'); + }); + + test('trims whitespace from remote', async () => { + const pathMatches = (path: string, pattern: string): boolean => { + const normalizedPath = path.replace(/\\/g, '/'); + return normalizedPath.includes(pattern); + }; + + mockFsReadFile.mockImplementation(async (path: string) => { + if (pathMatches(path, '/repo/.archon/config.yaml')) { + return ` +worktree: + remote: " mar " +`; + } + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }); + + const config = await loadConfig('/test/repo'); + expect(config.remote).toBe('mar'); + }); + + test('remote is undefined when not configured', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + mockFsReadFile.mockRejectedValue(error); + + const config = await loadConfig('/test/repo'); + expect(config.remote).toBeUndefined(); + }); + test('global aliases are propagated to merged config', async () => { mockFsReadFile.mockResolvedValue(` aliases: diff --git a/packages/core/src/config/config-loader.ts b/packages/core/src/config/config-loader.ts index 27246c8a8d..ef6146bb5b 100644 --- a/packages/core/src/config/config-loader.ts +++ b/packages/core/src/config/config-loader.ts @@ -569,6 +569,11 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { result.baseBranch = repo.worktree.baseBranch.trim(); } + // Pass remote to callers for git fetch/push operations + if (repo.worktree?.remote?.trim()) { + result.remote = repo.worktree.remote.trim(); + } + // Propagate docs path for $DOCS_DIR substitution in workflow commands if (repo.docs?.path !== undefined) { const trimmed = repo.docs.path.trim(); diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index 6b4692308f..c172d25aa2 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -270,6 +270,20 @@ export interface RepoConfig { * @example '.worktrees' */ path?: string; + + /** + * Git remote name for fetch/push operations. + * + * When set, all git operations (fetch, push, branch tracking) use this + * remote instead of 'origin'. Useful for repos with multiple remotes or + * non-standard naming conventions. + * + * When omitted, auto-detected: 'origin' if it exists, otherwise the sole + * remote if only one is configured. + * + * @example 'upstream' + */ + remote?: string; }; /** @@ -381,6 +395,12 @@ export interface MergedConfig { * When undefined, workflows referencing $BASE_BRANCH will fail with an error. */ baseBranch?: string; + /** + * Git remote name from repo config (worktree.remote). + * When undefined, callers auto-detect at runtime via getDefaultRemote() + * or fall back to 'origin'. + */ + remote?: string; /** * Docs directory path from repo config (docs.path). * Used for $DOCS_DIR substitution in workflow commands. diff --git a/packages/core/src/db/adapters/postgres.test.ts b/packages/core/src/db/adapters/postgres.test.ts index cbb69d718f..9bd58e052f 100644 --- a/packages/core/src/db/adapters/postgres.test.ts +++ b/packages/core/src/db/adapters/postgres.test.ts @@ -62,6 +62,9 @@ mock.module('../bundled-schema', () => ({ // ---- import after mocks are registered ------------------------------------ import { PostgresAdapter, postgresDialect } from './postgres'; +// Assert against the same constant the adapter writes, so the vintage tests stay +// correct in both source builds ('dev') and compiled binaries (the real semver). +import { APP_VERSION } from '../schema-version'; // --------------------------------------------------------------------------- @@ -388,6 +391,93 @@ describe('PostgresAdapter', () => { expect(issued[issued.length - 1]).toBe('COMMIT'); }); + /** + * Schema vintage (#2316). The upsert must live inside the same advisory-locked + * transaction as the schema SQL — outside it, two concurrent boots could + * interleave and record a vintage that doesn't match the schema they applied. + */ + test('records the schema vintage inside the schema transaction', async () => { + const issued: { sql: string; params?: unknown[] }[] = []; + mockClient = { + query: async (sql: string, params?: unknown[]) => { + issued.push({ sql, params }); + // Fresh database: the pre-existence probe finds no codebases table. + if (sql.includes('to_regclass')) return { rows: [{ exists: false }], rowCount: 1 }; + return { rows: [], rowCount: 0 }; + }, + release: () => {}, + }; + mockSchemaSQL = '-- schema sql'; + + const a = new PostgresAdapter('postgresql://localhost:5432/testdb'); + await a.query('SELECT 1'); + + const probeIdx = issued.findIndex(q => q.sql.includes('to_regclass')); + const schemaIdx = issued.findIndex(q => q.sql === '-- schema sql'); + const versionIdx = issued.findIndex(q => q.sql.includes('remote_agent_schema_version')); + const commitIdx = issued.findIndex(q => q.sql === 'COMMIT'); + + // Probe must precede the schema SQL — afterwards a pre-existing database is + // indistinguishable from a fresh one. + expect(probeIdx).toBeGreaterThan(0); + expect(probeIdx).toBeLessThan(schemaIdx); + expect(versionIdx).toBeGreaterThan(schemaIdx); + expect(versionIdx).toBeLessThan(commitIdx); + // Fresh database: creation vintage recorded, not left unknown. + expect(issued[versionIdx]?.params).toEqual([APP_VERSION, APP_VERSION]); + }); + + /** + * The vintage row is diagnostic metadata. A failure to write it must roll back + * only that statement — if it aborted initSchema, schemaInitPromise would reject + * and every later query would too, bricking the adapter over a row nothing gates on. + */ + test('a failed vintage write rolls back to the savepoint and still commits', async () => { + const issued: string[] = []; + mockClient = { + query: async (sql: string) => { + issued.push(sql); + if (sql.includes('to_regclass')) return { rows: [{ exists: false }], rowCount: 1 }; + if (sql.includes('INSERT INTO remote_agent_schema_version')) { + throw new Error('permission denied for table remote_agent_schema_version'); + } + return { rows: [], rowCount: 0 }; + }, + release: () => {}, + }; + mockSchemaSQL = '-- schema sql'; + + const a = new PostgresAdapter('postgresql://localhost:5432/testdb'); + + // The adapter must remain usable — this is the whole point of the savepoint. + await expect(a.query('SELECT 1')).resolves.toBeDefined(); + + expect(issued).toContain('SAVEPOINT schema_version'); + expect(issued).toContain('ROLLBACK TO SAVEPOINT schema_version'); + expect(issued).toContain('COMMIT'); + expect(issued).not.toContain('ROLLBACK'); + }); + + test('leaves the creation vintage unknown for a pre-existing database', async () => { + const issued: { sql: string; params?: unknown[] }[] = []; + mockClient = { + query: async (sql: string, params?: unknown[]) => { + issued.push({ sql, params }); + if (sql.includes('to_regclass')) return { rows: [{ exists: true }], rowCount: 1 }; + return { rows: [], rowCount: 0 }; + }, + release: () => {}, + }; + mockSchemaSQL = '-- schema sql'; + + const a = new PostgresAdapter('postgresql://localhost:5432/testdb'); + await a.query('SELECT 1'); + + const version = issued.find(q => q.sql.includes('remote_agent_schema_version')); + // NULL, never a guess: this database existed before vintage tracking. + expect(version?.params).toEqual([null, APP_VERSION]); + }); + test('schema SQL runs exactly once across multiple queries', async () => { let schemaSqlCallCount = 0; mockClient = { diff --git a/packages/core/src/db/adapters/postgres.ts b/packages/core/src/db/adapters/postgres.ts index 1388c4e924..59305daf1f 100644 --- a/packages/core/src/db/adapters/postgres.ts +++ b/packages/core/src/db/adapters/postgres.ts @@ -6,6 +6,7 @@ import type { PoolClient } from 'pg'; import type { DbNotificationListener, IDatabase, QueryResult, SqlDialect } from './types'; import { createLogger } from '@archon/paths'; import { getSchemaSQL } from '../bundled-schema'; +import { APP_VERSION } from '../schema-version'; /** * Postgres-only: NOTIFY `archon_dashboard_event` on every workflow_events insert, so @@ -74,9 +75,16 @@ export class PostgresAdapter implements IDatabase, DbNotificationListener { // Key 1796 is arbitrary — just needs to be stable across processes. await client.query('BEGIN'); await client.query('SELECT pg_advisory_xact_lock(1796)'); + // Probe before applying: afterwards every table exists, and a database that + // predates schema-version tracking is indistinguishable from a fresh one. + const probe = await client.query<{ exists: boolean }>( + "SELECT to_regclass('remote_agent_codebases') IS NOT NULL AS exists" + ); + const preExisting = probe.rows[0]?.exists ?? false; // The SQL is fully idempotent (CREATE TABLE IF NOT EXISTS, // ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS). await client.query(sql); + await this.recordSchemaVersion(client, preExisting); await client.query('COMMIT'); getLog().info('db.postgres_schema_init_completed'); } catch (e) { @@ -105,6 +113,40 @@ export class PostgresAdapter implements IDatabase, DbNotificationListener { await this.installNotifyTrigger(); } + /** + * Record which Archon build created this database and which last applied schema + * to it (#2316). Runs inside the caller's advisory-locked schema transaction so + * concurrent boots stay serialized. ON CONFLICT never touches created_app_version, + * so the creation vintage is written once and never revised; the DO UPDATE is a + * no-op when the app version has not changed. + * + * Behind a SAVEPOINT on purpose: the vintage row is diagnostic metadata and a + * failure to write it must not abort the schema transaction. Without this, a throw + * here would reject `schemaInitPromise` — which every query()/withTransaction() + * awaits — bricking the adapter for the life of the process over a row nothing + * gates on. Mirrors the warn-and-continue guarantee the SQLite adapter gives. + */ + private async recordSchemaVersion(client: PoolClient, preExisting: boolean): Promise { + await client.query('SAVEPOINT schema_version'); + try { + await client.query( + `INSERT INTO remote_agent_schema_version (id, created_app_version, app_version) + VALUES (1, $1, $2) + ON CONFLICT (id) DO UPDATE + SET app_version = EXCLUDED.app_version, applied_at = NOW() + WHERE remote_agent_schema_version.app_version IS DISTINCT FROM EXCLUDED.app_version`, + [preExisting ? null : APP_VERSION, APP_VERSION] + ); + await client.query('RELEASE SAVEPOINT schema_version'); + } catch (e) { + await client.query('ROLLBACK TO SAVEPOINT schema_version'); + getLog().warn( + { err: e instanceof Error ? e : new Error(String(e)) }, + 'db.postgres_schema_version_record_failed' + ); + } + } + /** * Install the Postgres-only `pg_notify` trigger on workflow_events (real-time * dashboard push). Idempotent and non-fatal — its own advisory-locked txn so diff --git a/packages/core/src/db/adapters/sqlite.test.ts b/packages/core/src/db/adapters/sqlite.test.ts index f612e854cf..b16ba74757 100644 --- a/packages/core/src/db/adapters/sqlite.test.ts +++ b/packages/core/src/db/adapters/sqlite.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, afterEach } from 'bun:test'; import { SqliteAdapter } from './sqlite'; import { getSchemaSQL } from '../bundled-schema'; +import { APP_VERSION, readSchemaVersion } from '../schema-version'; import { Database } from 'bun:sqlite'; import { unlinkSync } from 'fs'; import { join } from 'path'; @@ -373,24 +374,97 @@ describe('SqliteAdapter', () => { * * Better Auth's remote_agent_auth_* tables are intentionally Postgres-only * (web auth never runs on SQLite — see migrateColumns() and CLAUDE.md), so - * they are the one allowlisted exception. A genuinely new Postgres-only - * table must be added to this allowlist with a justifying comment. + * the parity checks exclude that prefix. The separate, exact + * remote_agent_codebases.allow_env_keys column exception is tracked by + * #2318; keep it column-specific. A genuinely new Postgres-only table + * must be added to the table allowlist with a justifying comment. + * + * Table discovery is deliberately independent of column-body parsing: a + * table that is present in the migration but missing from sqlite.ts is the + * original drift class (PR #2033), and it must stay caught even if its + * CREATE body is unparseable for any reason. */ const POSTGRES_ONLY_PREFIX = 'remote_agent_auth_'; + // #2318 owns this known dead Postgres-only residue. Keep the exception + // column-specific so every other codebases column remains protected. + const POSTGRES_ONLY_COLUMNS = new Set(['remote_agent_codebases.allow_env_keys']); + // Reverse-direction residue: declared in sqlite.ts, never added to the + // migration, and read by nothing. Harmless but real — and reverse drift is + // the works-locally / breaks-on-the-Postgres-VPS direction, so the check + // itself is worth keeping even though today it costs one entry. + const SQLITE_ONLY_COLUMNS = new Set(['remote_agent_isolation_environments.updated_at']); + const TABLE_CONSTRAINTS = new Set(['check', 'constraint', 'foreign', 'primary', 'unique']); + /** + * Floor for the number of non-auth columns actually compared. A parser bug + * that silently drops columns (rather than mismatching them) makes the + * comparison pass vacuously, which is exactly how a truncating body regex + * shipped: a `);` inside a comment cut a table from 7 columns to 3 and the + * suite stayed green. Adjust when the schema legitimately changes size — + * the failure names the count, so the intended value is never a guess. + */ + const MIN_NON_AUTH_COLUMNS = 136; - /** Extract Archon table names declared in the Postgres migration. */ + /** + * Archon table names declared by the Postgres migration. Body-independent + * on purpose — see the note above about the PR #2033 drift class. + */ function postgresArchonTables(): string[] { - const sql = getSchemaSQL(); const re = /CREATE TABLE(?:\s+IF NOT EXISTS)?\s+"?([a-z0-9_]+)"?/gi; - // All Archon tables share this prefix (CLAUDE.md); the filter also drops - // false positives — e.g. "above" captured from "...CREATE TABLE above)" - // inside a SQL comment. - const names = [...sql.matchAll(re)] + // All Archon tables share this prefix (CLAUDE.md). + const names = [...stripSqlComments(getSchemaSQL()).matchAll(re)] .map(m => m[1].toLowerCase()) .filter(name => name.startsWith('remote_agent_')); return [...new Set(names)]; } + /** Extract Archon table columns declared or added by the Postgres migration. */ + function postgresArchonColumns(): Map> { + // Comments are stripped first: `migrations/000_combined.sql` writes `);` + // inside prose comments as a matter of house style, and any paren- or + // semicolon-sensitive scan would otherwise end a table body early. + const sql = stripSqlComments(getSchemaSQL()); + const columnsByTable = new Map>(); + const createTableRe = /CREATE TABLE(?:\s+IF NOT EXISTS)?\s+"?([a-z0-9_]+)"?\s*\(/gi; + + for (const match of sql.matchAll(createTableRe)) { + const table = match[1].toLowerCase(); + if (!table.startsWith('remote_agent_')) continue; + + const columns = columnsByTable.get(table) ?? new Set(); + // Depth-tracked so nested parens in REFERENCES / CHECK / DEFAULT + // clauses cannot terminate the body or split a declaration. + const body = readBalancedParens(sql, match.index + match[0].length - 1); + for (const declaration of splitTopLevelCommas(body)) { + const identifier = declaration.trim().match(/^(?:"([^"]+)"|([a-z_][a-z0-9_]*))/i); + if (!identifier) continue; + + const column = identifier[1] ?? identifier[2].toLowerCase(); + if (!TABLE_CONSTRAINTS.has(column.toLowerCase())) columns.add(column); + } + columnsByTable.set(table, columns); + } + + const addColumnRe = + /ALTER TABLE\s+"?([a-z0-9_]+)"?\s+ADD COLUMN IF NOT EXISTS\s+"?([a-z_][a-z0-9_]*)"?/gi; + for (const match of sql.matchAll(addColumnRe)) { + const table = match[1].toLowerCase(); + if (!table.startsWith('remote_agent_')) continue; + const columns = columnsByTable.get(table) ?? new Set(); + columns.add(match[2].toLowerCase()); + columnsByTable.set(table, columns); + } + + return columnsByTable; + } + + /** Table → columns as the fresh SQLite schema (createSchema()) built them. */ + async function sqliteSchemaColumns(): Promise>> { + const result = await db.query<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ); + return new Map(result.rows.map(r => [r.name, new Set(raw_pragma(currentDbPath, r.name))])); + } + test('every non-auth Postgres table is created by the SQLite schema', async () => { db = createTestDb(); const result = await db.query<{ name: string }>( @@ -399,7 +473,7 @@ describe('SqliteAdapter', () => { const sqliteTables = new Set(result.rows.map(r => r.name)); const expected = postgresArchonTables().filter( - name => !name.startsWith(POSTGRES_ONLY_PREFIX) + table => !table.startsWith(POSTGRES_ONLY_PREFIX) ); // Sanity: the parse found the table set, including the exact table whose // absence triggered this regression — guards against the regex silently @@ -410,9 +484,313 @@ describe('SqliteAdapter', () => { const missing = expected.filter(name => !sqliteTables.has(name)).sort(); expect(missing).toEqual([]); }); + + test('every non-auth Postgres column exists in a fresh SQLite schema', async () => { + db = createTestDb(); + const postgresColumns = postgresArchonColumns(); + const sqliteColumns = await sqliteSchemaColumns(); + + // Anti-vacuity checks cover a CREATE declaration and an ALTER-only one. + // Deliberately NOT an allowlisted column: fixing a listed drift should + // require editing the allowlist and nothing else. + expect(postgresColumns.get('remote_agent_codebases')?.has('default_cwd')).toBe(true); + expect(postgresColumns.get('remote_agent_users')?.has('role')).toBe(true); + + const missing: string[] = []; + let compared = 0; + for (const table of postgresArchonTables()) { + if (table.startsWith(POSTGRES_ONLY_PREFIX)) continue; + const expectedColumns = postgresColumns.get(table) ?? new Set(); + const actualColumns = sqliteColumns.get(table) ?? new Set(); + for (const column of expectedColumns) { + compared++; + const qualifiedColumn = `${table}.${column}`; + if (!actualColumns.has(column) && !POSTGRES_ONLY_COLUMNS.has(qualifiedColumn)) { + missing.push(qualifiedColumn); + } + } + } + + // Drift FIRST. The vacuity floor below is a guard on this test's own + // reach, not a drift assertion -- and asserting it first lets it mask the + // thing you actually need to see: two legitimate column removals plus one + // real drift made the floor fire and the drift list never printed. + expect(missing.sort()).toEqual([]); + + // Anti-vacuity: if the parser silently loses columns again (it did -- an + // in-body `);` once cut workflow_events from 7 columns to 3 with the suite + // still green), `missing` stays empty because there is nothing left to + // compare. Thrown rather than expect()ed so the message explains itself: + // a bare `Expected: >= 136 / Received: 135` under this test's name reads + // as drift when it is either a parser regression or a legitimate removal. + if (compared < MIN_NON_AUTH_COLUMNS) { + throw new Error( + `Schema-parity coverage collapsed: compared ${compared} non-auth columns, ` + + `expected at least ${MIN_NON_AUTH_COLUMNS}. Either the migration parser has ` + + `silently lost columns (check the CREATE TABLE body extraction), or columns ` + + `were legitimately removed from migrations/000_combined.sql -- in which case ` + + `lower MIN_NON_AUTH_COLUMNS to the new count. No drift was detected either way.` + ); + } + }); + + test('every SQLite column exists in the Postgres migration', async () => { + db = createTestDb(); + const postgresColumns = postgresArchonColumns(); + const sqliteColumns = await sqliteSchemaColumns(); + + const extra: string[] = []; + for (const [table, actualColumns] of sqliteColumns) { + const expectedColumns = postgresColumns.get(table); + // No Postgres counterpart at all: a SQLite-only table. Nothing else + // checks this direction, so report the whole table rather than 20 + // individual column lines. + if (!expectedColumns) { + extra.push(`${table}.*`); + continue; + } + for (const column of actualColumns) { + const qualifiedColumn = `${table}.${column}`; + if (!expectedColumns.has(column) && !SQLITE_ONLY_COLUMNS.has(qualifiedColumn)) { + extra.push(qualifiedColumn); + } + } + } + + expect(extra.sort()).toEqual([]); + }); + + /** + * Self-expiring allowlists: an entry stops being an exception the moment + * the drift it names is fixed, so assert each one still describes reality. + * Fixing #2318 (dropping allow_env_keys from the migration) fails here + * until the allowlist entry is deleted — the exception cannot outlive its + * reason and quietly keep a real column unprotected. + */ + test('parity allowlists still describe real drift', async () => { + db = createTestDb(); + const postgresColumns = postgresArchonColumns(); + const sqliteColumns = await sqliteSchemaColumns(); + + const stale: string[] = []; + for (const qualifiedColumn of POSTGRES_ONLY_COLUMNS) { + const [table, column] = qualifiedColumn.split('.'); + if (!postgresColumns.get(table)?.has(column)) { + stale.push(`${qualifiedColumn} (no longer in the Postgres migration)`); + } + if (sqliteColumns.get(table)?.has(column)) { + stale.push(`${qualifiedColumn} (now exists in SQLite)`); + } + } + for (const qualifiedColumn of SQLITE_ONLY_COLUMNS) { + const [table, column] = qualifiedColumn.split('.'); + if (!sqliteColumns.get(table)?.has(column)) { + stale.push(`${qualifiedColumn} (no longer in the SQLite schema)`); + } + if (postgresColumns.get(table)?.has(column)) { + stale.push(`${qualifiedColumn} (now exists in the Postgres migration)`); + } + } + + expect(stale.sort()).toEqual([]); + }); + + test('parent_run_id index exists on a fresh SQLite schema', () => { + db = createTestDb(); + const indexes = raw_indexes(currentDbPath); + expect(indexes).toContain('idx_workflow_runs_parent_run'); + }); + }); + + /** + * Schema vintage (#2316). The value that matters most is the one the adapter + * refuses to invent: a database created before this table existed has an + * unknowable creation vintage, and must report NULL rather than today's build. + */ + describe('schema version', () => { + test('records the creating build on a fresh database', () => { + db = createTestDb(); + const rows = raw_query( + currentDbPath, + 'SELECT id, created_app_version, app_version FROM remote_agent_schema_version' + ) as { id: number; created_app_version: string | null; app_version: string }[]; + + expect(rows).toHaveLength(1); + expect(rows[0]?.id).toBe(1); + expect(rows[0]?.created_app_version).toBe(APP_VERSION); + expect(rows[0]?.app_version).toBe(APP_VERSION); + }); + + test('reopening a database does not revise the creation vintage', async () => { + db = createTestDb(); + const dbPath = currentDbPath; + await db.close(); + + // Second open of the same file: created_app_version must survive untouched, + // which is what makes it a record of the database rather than of this process. + const reopened = new SqliteAdapter(dbPath); + try { + const rows = raw_query( + dbPath, + 'SELECT created_app_version, app_version FROM remote_agent_schema_version' + ) as { created_app_version: string | null; app_version: string }[]; + + expect(rows).toHaveLength(1); + expect(rows[0]?.created_app_version).toBe(APP_VERSION); + expect(rows[0]?.app_version).toBe(APP_VERSION); + } finally { + await reopened.close(); + db = reopened; + } + }); + + test('records a NULL creation vintage for a database that predates the table', async () => { + // Simulate a pre-#2316 database: core tables already present, no version row. + currentDbPath = join( + import.meta.dir, + `.test-sqlite-adapter-${Date.now()}-${Math.random().toString(36).slice(2)}.db` + ); + const seed = new Database(currentDbPath); + seed.run( + `CREATE TABLE remote_agent_codebases ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + default_cwd TEXT NOT NULL + )` + ); + seed.close(); + + db = new SqliteAdapter(currentDbPath); + const rows = raw_query( + currentDbPath, + 'SELECT created_app_version, app_version FROM remote_agent_schema_version' + ) as { created_app_version: string | null; app_version: string }[]; + + expect(rows).toHaveLength(1); + // Never back-filled with a guess — the unknowability is the reportable fact. + expect(rows[0]?.created_app_version).toBeNull(); + expect(rows[0]?.app_version).toBe(APP_VERSION); + }); + + /** + * migrateColumns() suppresses each table's failure so one bad ALTER cannot abort + * startup — which means the schema may genuinely be incomplete. Stamping this + * build onto that database would make the vintage a wrong answer that gets + * believed, which is worse than no answer at all. + */ + test('does not record a vintage when a column migration failed', async () => { + currentDbPath = join( + import.meta.dir, + `.test-sqlite-adapter-${Date.now()}-${Math.random().toString(36).slice(2)}.db` + ); + // Seed a `remote_agent_users` whose shape makes migrateColumns' ALTER fail: + // adding a NOT NULL column with a DEFAULT is fine, so instead occupy the name + // with an incompatible object — a view cannot be ALTERed. + const seed = new Database(currentDbPath); + seed.run('CREATE TABLE remote_agent_users_backing (id TEXT PRIMARY KEY)'); + seed.run('CREATE VIEW remote_agent_users AS SELECT id FROM remote_agent_users_backing'); + seed.close(); + + db = new SqliteAdapter(currentDbPath); + + const rows = raw_query( + currentDbPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name='remote_agent_schema_version'" + ) as { name: string }[]; + // The table itself is created by createSchema(); the row must be absent. + expect(rows).toHaveLength(1); + + const versionRows = raw_query( + currentDbPath, + 'SELECT app_version FROM remote_agent_schema_version' + ) as { app_version: string }[]; + expect(versionRows).toHaveLength(0); + expect(await readSchemaVersion(db)).toBeNull(); + }); + + test('readSchemaVersion surfaces the row through the adapter', async () => { + db = createTestDb(); + const info = await readSchemaVersion(db); + + expect(info).not.toBeNull(); + expect(info?.createdAppVersion).toBe(APP_VERSION); + expect(info?.appVersion).toBe(APP_VERSION); + expect(info?.appliedAt).toBeTruthy(); + }); }); }); +/** + * Advance past a SQL string literal / quoted identifier that opens at `start`, + * returning the index of its closing quote. Doubled quotes escape. + */ +function skipQuoted(sql: string, start: number): number { + const quote = sql[start]; + for (let i = start + 1; i < sql.length; i++) { + if (sql[i] !== quote) continue; + // A doubled quote escapes itself — not the end of the literal. + if (sql[i + 1] === quote) i++; + else return i; + } + return sql.length; +} + +/** Remove SQL line and block comments, preserving quoted text. */ +function stripSqlComments(sql: string): string { + let out = ''; + let i = 0; + while (i < sql.length) { + if (sql[i] === "'" || sql[i] === '"') { + const end = skipQuoted(sql, i); + out += sql.slice(i, end + 1); + i = end + 1; + } else if (sql[i] === '-' && sql[i + 1] === '-') { + while (i < sql.length && sql[i] !== '\n') i++; + } else if (sql[i] === '/' && sql[i + 1] === '*') { + const end = sql.indexOf('*/', i + 2); + i = end === -1 ? sql.length : end + 2; + } else { + out += sql[i]; + i++; + } + } + return out; +} + +/** + * Return the text between the `(` at `openIndex` and its matching `)`, tracking + * nesting depth so `REFERENCES t(id)` / `CHECK (id = 1)` / `DEFAULT NOW()` do + * not end the body early. Throws rather than returning a truncated body — a + * silently short column list is the failure mode this whole parser guards. + */ +function readBalancedParens(sql: string, openIndex: number): string { + let depth = 0; + for (let i = openIndex; i < sql.length; i++) { + if (sql[i] === "'" || sql[i] === '"') i = skipQuoted(sql, i); + else if (sql[i] === '(') depth++; + else if (sql[i] === ')' && --depth === 0) return sql.slice(openIndex + 1, i); + } + throw new Error(`Unbalanced parentheses in schema SQL at index ${openIndex}`); +} + +/** Split a CREATE TABLE body on its top-level commas (depth- and quote-aware). */ +function splitTopLevelCommas(body: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < body.length; i++) { + if (body[i] === "'" || body[i] === '"') i = skipQuoted(body, i); + else if (body[i] === '(') depth++; + else if (body[i] === ')') depth--; + else if (body[i] === ',' && depth === 0) { + parts.push(body.slice(start, i)); + start = i + 1; + } + } + parts.push(body.slice(start)); + return parts; +} + function raw_pragma(dbPath: string, table: string): string[] { const raw = new Database(dbPath, { readonly: true }); try { diff --git a/packages/core/src/db/adapters/sqlite.ts b/packages/core/src/db/adapters/sqlite.ts index 0572c353b3..f8304125f1 100644 --- a/packages/core/src/db/adapters/sqlite.ts +++ b/packages/core/src/db/adapters/sqlite.ts @@ -6,6 +6,7 @@ import { existsSync, mkdirSync } from 'fs'; import { dirname } from 'path'; import type { IDatabase, QueryResult, SqlDialect } from './types'; import { createLogger } from '@archon/paths'; +import { APP_VERSION } from '../schema-version'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -170,8 +171,67 @@ export class SqliteAdapter implements IDatabase { * ensuring new tables from migrations are created in existing databases. */ private initSchema(): void { + // Probe BEFORE createSchema(): once CREATE TABLE IF NOT EXISTS has run there is + // no way left to tell a fresh database from one that predates version tracking. + const preExisting = this.hasAnyArchonTable(); this.createSchema(); - this.migrateColumns(); + const allApplied = this.migrateColumns(); + this.recordSchemaVersion(preExisting, allApplied); + } + + /** True when core Archon tables already exist — i.e. this is not a fresh database. */ + private hasAnyArchonTable(): boolean { + const row = this.db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('remote_agent_codebases'); + return row !== null && row !== undefined; + } + + /** + * Record which Archon build created this database and which last applied schema + * to it (#2316). Diagnostic only — nothing gates on these values. + * + * Writes only when the value actually changes, so the common case (every CLI + * invocation is a fresh process opening a fresh connection) stays read-only. + * + * Skipped entirely when `allApplied` is false: migrateColumns() suppresses each + * table's failure so one bad ALTER cannot abort startup, which means the schema + * may be genuinely incomplete. Stamping this build's version onto that database + * would turn the vintage into a wrong answer that gets believed — strictly worse + * than the "not recorded" / stale-version state a reader can act on. The next + * successful open records it. + */ + private recordSchemaVersion(preExisting: boolean, allApplied: boolean): void { + if (!allApplied) { + getLog().warn( + { appVersion: APP_VERSION }, + 'db.sqlite_schema_version_skipped_incomplete_migration' + ); + return; + } + try { + const existing = this.db + .prepare('SELECT app_version FROM remote_agent_schema_version WHERE id = 1') + .get() as { app_version: string } | null; + + if (!existing) { + this.db.run( + 'INSERT INTO remote_agent_schema_version (id, created_app_version, app_version) VALUES (1, ?, ?)', + // NULL, not a guess: a database that predates this table has an unknowable + // creation vintage, and that unknowability is the fact worth reporting. + [preExisting ? null : APP_VERSION, APP_VERSION] + ); + } else if (existing.app_version !== APP_VERSION) { + this.db.run( + "UPDATE remote_agent_schema_version SET app_version = ?, applied_at = datetime('now') WHERE id = 1", + [APP_VERSION] + ); + } + } catch (e: unknown) { + // Deliberate, logged fallback: the vintage row is diagnostic metadata and must + // never be able to stop the database from opening (e.g. a read-only DB file). + getLog().warn({ err: e as Error }, 'db.sqlite_schema_version_record_failed'); + } } /** @@ -180,7 +240,12 @@ export class SqliteAdapter implements IDatabase { * so new columns must be added via ALTER TABLE for databases created before * the columns were added to createSchema(). */ - private migrateColumns(): void { + private migrateColumns(): boolean { + // Each block below suppresses its own failure so one bad table cannot abort + // schema init. This flag carries that outcome to recordSchemaVersion(): a + // database missing a failed migration must NOT be stamped as fully applied + // by this build, or the vintage becomes a wrong answer that gets believed. + let allApplied = true; // Users columns. `role` is the web-auth identity seam (default 'admin'). // Better Auth's own tables are PostgreSQL-only — web auth is never enabled // on SQLite — so only the role column is backfilled here. @@ -194,6 +259,7 @@ export class SqliteAdapter implements IDatabase { } } catch (e: unknown) { getLog().warn({ err: e as Error }, 'db.sqlite_migration_users_columns_failed'); + allApplied = false; } // Codebases columns @@ -213,6 +279,7 @@ export class SqliteAdapter implements IDatabase { } } catch (e: unknown) { getLog().warn({ err: e as Error }, 'db.sqlite_migration_codebases_columns_failed'); + allApplied = false; } // Conversations columns @@ -245,6 +312,7 @@ export class SqliteAdapter implements IDatabase { ); } catch (e: unknown) { getLog().warn({ err: e as Error }, 'db.sqlite_migration_conversations_columns_failed'); + allApplied = false; } // Workflow runs columns @@ -269,12 +337,24 @@ export class SqliteAdapter implements IDatabase { 'ALTER TABLE remote_agent_workflow_runs ADD COLUMN user_id TEXT REFERENCES remote_agent_users(id) ON DELETE SET NULL' ); } + // Run-tree parent (#2121 Phase 2). Self-referential FK — a `workflow:` sub-run + // links back to its spawning parent. ON DELETE SET NULL so deleting a parent + // orphans children rather than cascade-deleting their audit trail. + if (!wfColNames.has('parent_run_id')) { + this.db.run( + 'ALTER TABLE remote_agent_workflow_runs ADD COLUMN parent_run_id TEXT REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL' + ); + } // Same rationale as idx_conversations_user_id above. this.db.run( 'CREATE INDEX IF NOT EXISTS idx_workflow_runs_user_id ON remote_agent_workflow_runs(user_id) WHERE user_id IS NOT NULL' ); + this.db.run( + 'CREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_run ON remote_agent_workflow_runs(parent_run_id) WHERE parent_run_id IS NOT NULL' + ); } catch (e: unknown) { getLog().warn({ err: e as Error }, 'db.sqlite_migration_workflow_runs_columns_failed'); + allApplied = false; } // Sessions columns @@ -289,6 +369,7 @@ export class SqliteAdapter implements IDatabase { } } catch (e: unknown) { getLog().warn({ err: e as Error }, 'db.sqlite_migration_session_columns_failed'); + allApplied = false; } // Messages columns @@ -304,6 +385,7 @@ export class SqliteAdapter implements IDatabase { } } catch (e: unknown) { getLog().warn({ err: e as Error }, 'db.sqlite_migration_messages_columns_failed'); + allApplied = false; } // Isolation environments columns @@ -324,6 +406,7 @@ export class SqliteAdapter implements IDatabase { { err: e as Error }, 'db.sqlite_migration_isolation_environments_columns_failed' ); + allApplied = false; } // User AI prefs columns. #1998: default_model is the per-user default @@ -340,6 +423,7 @@ export class SqliteAdapter implements IDatabase { } } catch (e: unknown) { getLog().warn({ err: e as Error }, 'db.sqlite_migration_user_ai_prefs_columns_failed'); + allApplied = false; } // #1955: credential rows are vendor-keyed (claude→anthropic, codex→openai, @@ -381,7 +465,10 @@ export class SqliteAdapter implements IDatabase { } } catch (e: unknown) { getLog().warn({ err: e as Error }, 'db.sqlite_migration_provider_key_vendor_ids_failed'); + allApplied = false; } + + return allApplied; } /** @@ -395,6 +482,18 @@ export class SqliteAdapter implements IDatabase { */ private createSchema(): void { this.db.run(` + -- Schema vintage (#2316): which Archon build created this database, and which + -- last applied schema to it. Diagnostic only — nothing gates on these values. + -- Single row (id = 1); written by recordSchemaVersion() from APP_VERSION so the + -- version string has exactly one source of truth. + CREATE TABLE IF NOT EXISTS remote_agent_schema_version ( + id INTEGER PRIMARY KEY CHECK (id = 1), + created_app_version TEXT, + app_version TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + -- Users table (Archon identity, platform-agnostic) CREATE TABLE IF NOT EXISTS remote_agent_users ( id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), @@ -559,6 +658,7 @@ export class SqliteAdapter implements IDatabase { metadata TEXT DEFAULT '{}', parent_conversation_id TEXT REFERENCES remote_agent_conversations(id) ON DELETE SET NULL, user_id TEXT REFERENCES remote_agent_users(id) ON DELETE SET NULL, + parent_run_id TEXT REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL, started_at TEXT DEFAULT (datetime('now')), completed_at TEXT, last_activity_at TEXT DEFAULT (datetime('now')), diff --git a/packages/core/src/db/bundled-schema.generated.ts b/packages/core/src/db/bundled-schema.generated.ts index 4ae48b8e3a..73e163cfee 100644 --- a/packages/core/src/db/bundled-schema.generated.ts +++ b/packages/core/src/db/bundled-schema.generated.ts @@ -10,4 +10,4 @@ * the schema on startup without filesystem access to the migrations dir. */ -export const BUNDLED_SCHEMA_SQL = "-- Remote Coding Agent - Combined Schema\n-- Version: Combined (final state after migrations 001-020)\n-- Description: Complete database schema (idempotent - safe to run multiple times)\n--\n-- 14 Tables (+ the remote_agent_auth_* Better Auth tables, listed inline below):\n-- 1. remote_agent_codebases\n-- 1b. remote_agent_codebase_env_vars\n-- 1c. remote_agent_users\n-- 1d. remote_agent_user_identities\n-- 2. remote_agent_conversations\n-- 3. remote_agent_sessions\n-- 4. remote_agent_isolation_environments\n-- 5. remote_agent_workflow_runs\n-- 6. remote_agent_workflow_events\n-- 6b. remote_agent_workflow_node_sessions\n-- 7. remote_agent_messages\n-- 8. remote_agent_user_github_tokens\n-- 9. remote_agent_user_provider_keys\n-- 10. remote_agent_user_ai_prefs\n--\n-- Dropped tables (via migrations):\n-- - remote_agent_command_templates (017)\n--\n-- Dropped columns (via migrations):\n-- - conversations.worktree_path (007)\n-- - conversations.isolation_env_id_legacy (007)\n-- - conversations.isolation_provider (007)\n\n-- ============================================================================\n-- Table 1: Codebases\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_codebases (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n name VARCHAR(255) NOT NULL,\n repository_url VARCHAR(500),\n default_cwd VARCHAR(500) NOT NULL,\n default_branch VARCHAR(255),\n ai_assistant_type VARCHAR(20) DEFAULT 'claude',\n kind VARCHAR(10) NOT NULL DEFAULT 'repo' CHECK (kind IN ('repo', 'folder')),\n allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE,\n commands JSONB DEFAULT '{}'::jsonb,\n created_at TIMESTAMP DEFAULT NOW(),\n updated_at TIMESTAMP DEFAULT NOW()\n);\n\nCOMMENT ON TABLE remote_agent_codebases IS\n 'Repository metadata: name, URL, working directory, default branch, AI assistant type, and command paths (JSONB)';\n\n-- ============================================================================\n-- Table 1b: Codebase Env Vars\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_codebase_env_vars (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n codebase_id UUID NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE,\n key VARCHAR(255) NOT NULL,\n value TEXT NOT NULL,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(codebase_id, key)\n);\n\nCREATE INDEX IF NOT EXISTS idx_codebase_env_vars_codebase_id\n ON remote_agent_codebase_env_vars(codebase_id);\n\nCOMMENT ON TABLE remote_agent_codebase_env_vars IS\n 'Per-project env vars merged into Options.env on Claude SDK calls. Managed via Web UI or config.';\n\n-- ============================================================================\n-- Table 1c: Users (Archon identity, platform-agnostic)\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n display_name VARCHAR(255),\n email VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()\n);\n\nCOMMENT ON TABLE remote_agent_users IS\n 'Archon-internal user identity. Created on first sight by any adapter; populated via per-platform user-info lookups.';\n\n-- ============================================================================\n-- Table 1d: User Identities (per-platform mapping → users.id)\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_user_identities (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n platform VARCHAR(32) NOT NULL,\n platform_user_id VARCHAR(255) NOT NULL,\n platform_display_name VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(platform, platform_user_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_user_identities_user_id\n ON remote_agent_user_identities(user_id);\n\nCOMMENT ON TABLE remote_agent_user_identities IS\n 'Maps platform-native user IDs (Slack U-ids, Telegram chat ids, GitHub logins, Discord snowflakes) to Archon user UUIDs.';\n\n-- ============================================================================\n-- Table 2: Conversations\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_conversations (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n platform_type VARCHAR(20) NOT NULL,\n platform_conversation_id VARCHAR(255) NOT NULL,\n codebase_id UUID REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,\n cwd VARCHAR(500),\n ai_assistant_type VARCHAR(20) DEFAULT 'claude',\n isolation_env_id UUID, -- FK added after isolation_environments table exists\n title VARCHAR(255),\n deleted_at TIMESTAMP WITH TIME ZONE,\n hidden BOOLEAN DEFAULT FALSE,\n created_at TIMESTAMP DEFAULT NOW(),\n updated_at TIMESTAMP DEFAULT NOW(),\n last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(platform_type, platform_conversation_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_remote_agent_conversations_codebase\n ON remote_agent_conversations(codebase_id);\nCREATE INDEX IF NOT EXISTS idx_conversations_hidden\n ON remote_agent_conversations(hidden);\nCREATE INDEX IF NOT EXISTS idx_conversations_codebase\n ON remote_agent_conversations(codebase_id) WHERE deleted_at IS NULL;\n\nCOMMENT ON COLUMN remote_agent_conversations.isolation_env_id IS\n 'UUID reference to isolation_environments table (the only isolation reference)';\n\n-- ============================================================================\n-- Table 3: Sessions\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_sessions (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,\n codebase_id UUID REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,\n ai_assistant_type VARCHAR(20) NOT NULL,\n assistant_session_id VARCHAR(255),\n active BOOLEAN DEFAULT true,\n metadata JSONB DEFAULT '{}'::jsonb,\n parent_session_id UUID REFERENCES remote_agent_sessions(id),\n transition_reason TEXT,\n ended_reason TEXT,\n started_at TIMESTAMP DEFAULT NOW(),\n ended_at TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_remote_agent_sessions_conversation\n ON remote_agent_sessions(conversation_id, active);\nCREATE INDEX IF NOT EXISTS idx_remote_agent_sessions_codebase\n ON remote_agent_sessions(codebase_id);\nCREATE INDEX IF NOT EXISTS idx_sessions_parent\n ON remote_agent_sessions(parent_session_id);\nCREATE INDEX IF NOT EXISTS idx_sessions_conversation_started\n ON remote_agent_sessions(conversation_id, started_at DESC);\n\nCOMMENT ON COLUMN remote_agent_sessions.parent_session_id IS\n 'Links to the previous session in this conversation (for audit trail)';\nCOMMENT ON COLUMN remote_agent_sessions.transition_reason IS\n 'Why this session was created: plan-to-execute, isolation-changed, reset-requested, etc.';\nCOMMENT ON COLUMN remote_agent_sessions.ended_reason IS\n 'Why this session was deactivated: reset-requested, cwd-changed, conversation-closed, etc.';\n\n-- ============================================================================\n-- Table 4: Isolation Environments\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_isolation_environments (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n codebase_id UUID NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE,\n\n -- Workflow identification (what work this is for)\n workflow_type TEXT NOT NULL, -- 'issue', 'pr', 'review', 'thread', 'task'\n workflow_id TEXT NOT NULL, -- '42', 'pr-99', 'thread-abc123'\n\n -- Implementation details\n provider TEXT NOT NULL DEFAULT 'worktree',\n working_path TEXT NOT NULL, -- Actual filesystem path\n branch_name TEXT NOT NULL, -- Git branch name\n\n -- Lifecycle\n status TEXT NOT NULL DEFAULT 'active', -- 'active', 'destroyed'\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n created_by_platform TEXT, -- 'github', 'slack', etc.\n\n -- Cross-reference metadata (for linking)\n metadata JSONB DEFAULT '{}'\n);\n\n-- Partial unique index: only active environments need uniqueness\nCREATE UNIQUE INDEX IF NOT EXISTS unique_active_workflow\n ON remote_agent_isolation_environments (codebase_id, workflow_type, workflow_id)\n WHERE status = 'active';\n\n-- Indexes for common queries\nCREATE INDEX IF NOT EXISTS idx_isolation_env_codebase\n ON remote_agent_isolation_environments(codebase_id);\nCREATE INDEX IF NOT EXISTS idx_isolation_env_status\n ON remote_agent_isolation_environments(status);\nCREATE INDEX IF NOT EXISTS idx_isolation_env_workflow\n ON remote_agent_isolation_environments(workflow_type, workflow_id);\n\n-- Add FK from conversations to isolation_environments (deferred to avoid circular dependency)\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS isolation_env_id UUID\n REFERENCES remote_agent_isolation_environments(id) ON DELETE SET NULL;\n\nCREATE INDEX IF NOT EXISTS idx_conversations_isolation_env_id\n ON remote_agent_conversations(isolation_env_id);\n\nCOMMENT ON TABLE remote_agent_isolation_environments IS\n 'Work-centric isolated environments with independent lifecycle';\nCOMMENT ON COLUMN remote_agent_isolation_environments.workflow_type IS\n 'Type of work: issue, pr, review, thread, task';\nCOMMENT ON COLUMN remote_agent_isolation_environments.workflow_id IS\n 'Identifier for the work (issue number, PR number, thread hash, etc.)';\n\n-- ============================================================================\n-- Table 5: Workflow Runs\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_workflow_runs (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n workflow_name VARCHAR(255) NOT NULL,\n conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,\n codebase_id UUID REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,\n current_step_index INTEGER,\n status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, running, completed, failed, cancelled, paused\n user_message TEXT NOT NULL,\n metadata JSONB DEFAULT '{}',\n parent_conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE SET NULL,\n started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n completed_at TIMESTAMP WITH TIME ZONE,\n last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n working_path TEXT\n);\n\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_conversation\n ON remote_agent_workflow_runs(conversation_id);\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_status\n ON remote_agent_workflow_runs(status);\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_conv\n ON remote_agent_workflow_runs(parent_conversation_id);\n\n-- Partial index for efficient staleness queries on running workflows\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_last_activity\n ON remote_agent_workflow_runs(last_activity_at)\n WHERE status = 'running';\n\nCOMMENT ON TABLE remote_agent_workflow_runs IS\n 'Tracks workflow execution state for resumption and observability';\n\n-- ============================================================================\n-- Table 6: Workflow Events\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_workflow_events (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n workflow_run_id UUID NOT NULL REFERENCES remote_agent_workflow_runs(id) ON DELETE CASCADE,\n event_type VARCHAR(50) NOT NULL,\n step_index INTEGER,\n step_name VARCHAR(255),\n data JSONB DEFAULT '{}',\n created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_workflow_events_run_id\n ON remote_agent_workflow_events(workflow_run_id);\nCREATE INDEX IF NOT EXISTS idx_workflow_events_type\n ON remote_agent_workflow_events(event_type);\n-- Global created_at index for the dashboard event poller's cross-run tail\n-- (WHERE created_at >= $1 ORDER BY created_at ASC).\nCREATE INDEX IF NOT EXISTS idx_workflow_events_created_at\n ON remote_agent_workflow_events(created_at);\n\nCOMMENT ON TABLE remote_agent_workflow_events IS\n 'Lean UI-relevant workflow events for observability (step transitions, artifacts, errors)';\n\n-- ============================================================================\n-- Workflow node sessions (persist_session opt-in across re-runs)\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_workflow_node_sessions (\n workflow_name VARCHAR(255) NOT NULL,\n node_id VARCHAR(255) NOT NULL,\n scope_key TEXT NOT NULL,\n provider VARCHAR(50) NOT NULL,\n provider_session_id TEXT NOT NULL,\n last_run_id UUID REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n PRIMARY KEY (workflow_name, node_id, scope_key, provider)\n);\n\nCREATE INDEX IF NOT EXISTS idx_workflow_node_sessions_scope\n ON remote_agent_workflow_node_sessions(scope_key);\nCREATE INDEX IF NOT EXISTS idx_workflow_node_sessions_workflow\n ON remote_agent_workflow_node_sessions(workflow_name);\n\nCOMMENT ON TABLE remote_agent_workflow_node_sessions IS\n 'Per-node provider session IDs persisted across workflow re-runs. Keyed by (workflow, node, scope, provider). Scope is typically conversation UUID. No cascade on conversation delete (soft delete + never-reused UUID = harmless orphans); a future hard-delete path must delete by scope_key.';\n\n-- ============================================================================\n-- Table 7: Messages\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_messages (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n conversation_id UUID NOT NULL REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,\n role VARCHAR(20) NOT NULL,\n content TEXT NOT NULL DEFAULT '',\n metadata JSONB DEFAULT '{}'::jsonb,\n created_at TIMESTAMP DEFAULT NOW()\n);\n\nCREATE INDEX IF NOT EXISTS idx_messages_conversation_id\n ON remote_agent_messages(conversation_id, created_at ASC);\n\n-- ============================================================================\n-- Cleanup: Drop legacy objects from older schemas\n-- ============================================================================\n\n-- Drop command_templates table (replaced by file-based commands in .archon/commands)\nDROP TABLE IF EXISTS remote_agent_command_templates;\nDROP INDEX IF EXISTS idx_remote_agent_command_templates_name;\n\n-- Drop legacy columns from conversations (if upgrading from older schema)\nALTER TABLE remote_agent_conversations DROP COLUMN IF EXISTS worktree_path;\nALTER TABLE remote_agent_conversations DROP COLUMN IF EXISTS isolation_env_id_legacy;\nALTER TABLE remote_agent_conversations DROP COLUMN IF EXISTS isolation_provider;\nDROP INDEX IF EXISTS idx_conversations_isolation;\n\n-- Drop legacy constraint from isolation_environments (if upgrading from older schema)\nALTER TABLE remote_agent_isolation_environments\n DROP CONSTRAINT IF EXISTS unique_workflow;\n\n-- ============================================================================\n-- Idempotent ALTER statements for upgrading existing databases\n-- (These are no-ops on fresh installs since columns exist in CREATE TABLE above)\n-- ============================================================================\n\n-- From migration 006: isolation_env_id + last_activity_at on conversations\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS isolation_env_id UUID\n REFERENCES remote_agent_isolation_environments(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();\n\n-- From migration 009: last_activity_at on workflow_runs\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();\n\n-- From migration 010: parent_session_id + transition_reason on sessions\nALTER TABLE remote_agent_sessions\n ADD COLUMN IF NOT EXISTS parent_session_id UUID REFERENCES remote_agent_sessions(id);\nALTER TABLE remote_agent_sessions\n ADD COLUMN IF NOT EXISTS transition_reason TEXT;\n\n-- From migration 013: title + deleted_at on conversations\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS title VARCHAR(255);\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE;\n\n-- From migration 015: parent_conversation_id + hidden\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS parent_conversation_id UUID\n REFERENCES remote_agent_conversations(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS hidden BOOLEAN DEFAULT FALSE;\n\n-- From migration 016: ended_reason on sessions\nALTER TABLE remote_agent_sessions\n ADD COLUMN IF NOT EXISTS ended_reason TEXT;\n\n-- From migration 021: allow_env_keys on codebases\nALTER TABLE remote_agent_codebases\n ADD COLUMN IF NOT EXISTS allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE;\n\n-- From migration 023: detected default branch on codebases\nALTER TABLE remote_agent_codebases\n ADD COLUMN IF NOT EXISTS default_branch VARCHAR(255);\n\n-- From migration 024: project kind discriminator ('repo' | 'folder').\n-- Folder projects are non-git workspaces (multi-repo roots or plain ops folders)\n-- that run in place with named artifact/log storage under _folder//.\nALTER TABLE remote_agent_codebases\n ADD COLUMN IF NOT EXISTS kind VARCHAR(10) NOT NULL DEFAULT 'repo';\n\n-- User identity foreign keys (nullable on the four primary tables).\n-- All FKs use ON DELETE SET NULL so future user deletion never cascades destructively.\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_messages\n ADD COLUMN IF NOT EXISTS user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_isolation_environments\n ADD COLUMN IF NOT EXISTS created_by_user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\n\nCREATE INDEX IF NOT EXISTS idx_conversations_user_id\n ON remote_agent_conversations(user_id) WHERE user_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_user_id\n ON remote_agent_workflow_runs(user_id) WHERE user_id IS NOT NULL;\n\n-- From PR-C: per-user GitHub user-to-server tokens (device flow), encrypted at rest.\n-- One row per Archon user; cascades on user deletion. github_user_id is the\n-- numeric anchor for the commit no-reply email (survives username changes).\nCREATE TABLE IF NOT EXISTS remote_agent_user_github_tokens (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n github_user_id BIGINT NOT NULL,\n github_login VARCHAR(255) NOT NULL,\n access_token_encrypted TEXT NOT NULL,\n refresh_token_encrypted TEXT,\n access_token_expires_at TIMESTAMP WITH TIME ZONE,\n refresh_token_expires_at TIMESTAMP WITH TIME ZONE,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(user_id)\n);\n\n-- Phase 2: per-user AI-provider credentials (BYO API key + subscription login),\n-- encrypted at rest with the existing token-crypto key. One row per\n-- (user_id, provider); cascades on user deletion. Exactly one of\n-- api_key_encrypted / oauth_creds_encrypted is populated per row; `kind`\n-- records which. Gated on TOKEN_ENCRYPTION_KEY at the application layer.\nCREATE TABLE IF NOT EXISTS remote_agent_user_provider_keys (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n provider VARCHAR(64) NOT NULL,\n kind VARCHAR(16) NOT NULL,\n api_key_encrypted TEXT,\n oauth_creds_encrypted TEXT,\n label VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(user_id, provider)\n);\n\n-- #1955: credential rows are vendor-keyed (claude→anthropic, codex→openai,\n-- copilot→github-copilot) so one credential can serve every agent that\n-- consumes the vendor. Idempotent data fix: where both a legacy and a vendor\n-- row exist for the same user, the vendor row wins (rare — requires having\n-- connected both ids pre-rename); then legacy rows are renamed in place.\n-- Tested on SQLite (adapters/sqlite.test.ts covers rename, conflict, and\n-- idempotency); the Postgres DML below is the same statements but is NOT\n-- covered by an automated test — verified manually on the multi-user smoke.\n-- Survivable either way: reads normalize legacy ids (normalizeCredentialVendor).\nDELETE FROM remote_agent_user_provider_keys\nWHERE provider IN ('claude', 'codex', 'copilot')\n AND EXISTS (\n SELECT 1 FROM remote_agent_user_provider_keys v\n WHERE v.user_id = remote_agent_user_provider_keys.user_id\n AND v.provider = CASE remote_agent_user_provider_keys.provider\n WHEN 'claude' THEN 'anthropic'\n WHEN 'codex' THEN 'openai'\n WHEN 'copilot' THEN 'github-copilot'\n END\n );\nUPDATE remote_agent_user_provider_keys SET provider = 'anthropic' WHERE provider = 'claude';\nUPDATE remote_agent_user_provider_keys SET provider = 'openai' WHERE provider = 'codex';\nUPDATE remote_agent_user_provider_keys SET provider = 'github-copilot' WHERE provider = 'copilot';\n\n-- Phase 3: per-user AI preferences (model tiers, @custom aliases, default\n-- assistant). NON-encrypted — model names are not secrets (mirrors\n-- codebase_env_vars, not the provider-key store). One row per user; cascades\n-- on user deletion. `tiers` / `aliases` are JSON-as-TEXT (parsed in the\n-- store layer so SQLite and Postgres behave identically).\nCREATE TABLE IF NOT EXISTS remote_agent_user_ai_prefs (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n tiers TEXT,\n aliases TEXT,\n default_provider VARCHAR(64),\n default_model VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(user_id)\n);\n\n-- #1998: per-user default CHAT model, written atomically with\n-- default_provider (a model pin is only meaningful for the provider it was\n-- set with). Idempotent upgrade for installs that created the table before\n-- this column existed.\nALTER TABLE remote_agent_user_ai_prefs\n ADD COLUMN IF NOT EXISTS default_model VARCHAR(255);\n\n-- ============================================================================\n-- Web auth (opt-in): role on the canonical user + Better Auth tables\n-- ============================================================================\n--\n-- `role` is the durable identity seam: everyone defaults to 'admin' for now;\n-- 'member' is reserved for future per-resource scoping. Visibility stays open.\nALTER TABLE remote_agent_users\n ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin';\n\n-- Better Auth tables (PostgreSQL only). Generated by `@better-auth/cli generate`\n-- against packages/server/src/auth/instance.ts (modelName-renamed to the\n-- `remote_agent_auth_*` prefix), then made idempotent with IF NOT EXISTS so the\n-- bundled-schema auto-apply on startup converges. Better Auth owns these tables\n-- and the column shape (text ids, camelCase columns) — Archon never queries them\n-- directly; a session is mapped to the canonical remote_agent_users row via\n-- user_identities('web', ). Always created on Postgres (the\n-- IF NOT EXISTS apply runs on every boot); populated only when web auth is\n-- enabled (BETTER_AUTH_SECRET + DATABASE_URL), harmless empty tables otherwise.\nCREATE TABLE IF NOT EXISTS remote_agent_auth_user (\n \"id\" text NOT NULL PRIMARY KEY,\n \"name\" text NOT NULL,\n \"email\" text NOT NULL UNIQUE,\n \"emailVerified\" boolean NOT NULL,\n \"image\" text,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS remote_agent_auth_session (\n \"id\" text NOT NULL PRIMARY KEY,\n \"expiresAt\" timestamptz NOT NULL,\n \"token\" text NOT NULL UNIQUE,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz NOT NULL,\n \"ipAddress\" text,\n \"userAgent\" text,\n \"userId\" text NOT NULL REFERENCES remote_agent_auth_user (\"id\") ON DELETE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS remote_agent_auth_account (\n \"id\" text NOT NULL PRIMARY KEY,\n \"accountId\" text NOT NULL,\n \"providerId\" text NOT NULL,\n \"userId\" text NOT NULL REFERENCES remote_agent_auth_user (\"id\") ON DELETE CASCADE,\n \"accessToken\" text,\n \"refreshToken\" text,\n \"idToken\" text,\n \"accessTokenExpiresAt\" timestamptz,\n \"refreshTokenExpiresAt\" timestamptz,\n \"scope\" text,\n \"password\" text,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS remote_agent_auth_verification (\n \"id\" text NOT NULL PRIMARY KEY,\n \"identifier\" text NOT NULL,\n \"value\" text NOT NULL,\n \"expiresAt\" timestamptz NOT NULL,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL\n);\n"; +export const BUNDLED_SCHEMA_SQL = "-- Remote Coding Agent - Combined Schema\n-- Version: Combined (final state after migrations 001-020)\n-- Description: Complete database schema (idempotent - safe to run multiple times)\n--\n-- 14 Tables (+ the remote_agent_auth_* Better Auth tables, listed inline below):\n-- 1. remote_agent_codebases\n-- 1b. remote_agent_codebase_env_vars\n-- 1c. remote_agent_users\n-- 1d. remote_agent_user_identities\n-- 2. remote_agent_conversations\n-- 3. remote_agent_sessions\n-- 4. remote_agent_isolation_environments\n-- 5. remote_agent_workflow_runs\n-- 6. remote_agent_workflow_events\n-- 6b. remote_agent_workflow_node_sessions\n-- 7. remote_agent_messages\n-- 8. remote_agent_user_github_tokens\n-- 9. remote_agent_user_provider_keys\n-- 10. remote_agent_user_ai_prefs\n--\n-- Dropped tables (via migrations):\n-- - remote_agent_command_templates (017)\n--\n-- Dropped columns (via migrations):\n-- - conversations.worktree_path (007)\n-- - conversations.isolation_env_id_legacy (007)\n-- - conversations.isolation_provider (007)\n\n-- ============================================================================\n-- Table 1: Codebases\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_codebases (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n name VARCHAR(255) NOT NULL,\n repository_url VARCHAR(500),\n default_cwd VARCHAR(500) NOT NULL,\n default_branch VARCHAR(255),\n ai_assistant_type VARCHAR(20) DEFAULT 'claude',\n kind VARCHAR(10) NOT NULL DEFAULT 'repo' CHECK (kind IN ('repo', 'folder')),\n allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE,\n commands JSONB DEFAULT '{}'::jsonb,\n created_at TIMESTAMP DEFAULT NOW(),\n updated_at TIMESTAMP DEFAULT NOW()\n);\n\nCOMMENT ON TABLE remote_agent_codebases IS\n 'Repository metadata: name, URL, working directory, default branch, AI assistant type, and command paths (JSONB)';\n\n-- ============================================================================\n-- Table 1b: Codebase Env Vars\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_codebase_env_vars (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n codebase_id UUID NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE,\n key VARCHAR(255) NOT NULL,\n value TEXT NOT NULL,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(codebase_id, key)\n);\n\nCREATE INDEX IF NOT EXISTS idx_codebase_env_vars_codebase_id\n ON remote_agent_codebase_env_vars(codebase_id);\n\nCOMMENT ON TABLE remote_agent_codebase_env_vars IS\n 'Per-project env vars merged into Options.env on Claude SDK calls. Managed via Web UI or config.';\n\n-- ============================================================================\n-- Table 1c: Users (Archon identity, platform-agnostic)\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n display_name VARCHAR(255),\n email VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()\n);\n\nCOMMENT ON TABLE remote_agent_users IS\n 'Archon-internal user identity. Created on first sight by any adapter; populated via per-platform user-info lookups.';\n\n-- ============================================================================\n-- Table 1d: User Identities (per-platform mapping → users.id)\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_user_identities (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n platform VARCHAR(32) NOT NULL,\n platform_user_id VARCHAR(255) NOT NULL,\n platform_display_name VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(platform, platform_user_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_user_identities_user_id\n ON remote_agent_user_identities(user_id);\n\nCOMMENT ON TABLE remote_agent_user_identities IS\n 'Maps platform-native user IDs (Slack U-ids, Telegram chat ids, GitHub logins, Discord snowflakes) to Archon user UUIDs.';\n\n-- ============================================================================\n-- Table 2: Conversations\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_conversations (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n platform_type VARCHAR(20) NOT NULL,\n platform_conversation_id VARCHAR(255) NOT NULL,\n codebase_id UUID REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,\n cwd VARCHAR(500),\n ai_assistant_type VARCHAR(20) DEFAULT 'claude',\n isolation_env_id UUID, -- FK added after isolation_environments table exists\n title VARCHAR(255),\n deleted_at TIMESTAMP WITH TIME ZONE,\n hidden BOOLEAN DEFAULT FALSE,\n created_at TIMESTAMP DEFAULT NOW(),\n updated_at TIMESTAMP DEFAULT NOW(),\n last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(platform_type, platform_conversation_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_remote_agent_conversations_codebase\n ON remote_agent_conversations(codebase_id);\nCREATE INDEX IF NOT EXISTS idx_conversations_hidden\n ON remote_agent_conversations(hidden);\nCREATE INDEX IF NOT EXISTS idx_conversations_codebase\n ON remote_agent_conversations(codebase_id) WHERE deleted_at IS NULL;\n\nCOMMENT ON COLUMN remote_agent_conversations.isolation_env_id IS\n 'UUID reference to isolation_environments table (the only isolation reference)';\n\n-- ============================================================================\n-- Table 3: Sessions\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_sessions (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,\n codebase_id UUID REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,\n ai_assistant_type VARCHAR(20) NOT NULL,\n assistant_session_id VARCHAR(255),\n active BOOLEAN DEFAULT true,\n metadata JSONB DEFAULT '{}'::jsonb,\n parent_session_id UUID REFERENCES remote_agent_sessions(id),\n transition_reason TEXT,\n ended_reason TEXT,\n started_at TIMESTAMP DEFAULT NOW(),\n ended_at TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_remote_agent_sessions_conversation\n ON remote_agent_sessions(conversation_id, active);\nCREATE INDEX IF NOT EXISTS idx_remote_agent_sessions_codebase\n ON remote_agent_sessions(codebase_id);\nCREATE INDEX IF NOT EXISTS idx_sessions_parent\n ON remote_agent_sessions(parent_session_id);\nCREATE INDEX IF NOT EXISTS idx_sessions_conversation_started\n ON remote_agent_sessions(conversation_id, started_at DESC);\n\nCOMMENT ON COLUMN remote_agent_sessions.parent_session_id IS\n 'Links to the previous session in this conversation (for audit trail)';\nCOMMENT ON COLUMN remote_agent_sessions.transition_reason IS\n 'Why this session was created: plan-to-execute, isolation-changed, reset-requested, etc.';\nCOMMENT ON COLUMN remote_agent_sessions.ended_reason IS\n 'Why this session was deactivated: reset-requested, cwd-changed, conversation-closed, etc.';\n\n-- ============================================================================\n-- Table 4: Isolation Environments\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_isolation_environments (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n codebase_id UUID NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE,\n\n -- Workflow identification (what work this is for)\n workflow_type TEXT NOT NULL, -- 'issue', 'pr', 'review', 'thread', 'task'\n workflow_id TEXT NOT NULL, -- '42', 'pr-99', 'thread-abc123'\n\n -- Implementation details\n provider TEXT NOT NULL DEFAULT 'worktree',\n working_path TEXT NOT NULL, -- Actual filesystem path\n branch_name TEXT NOT NULL, -- Git branch name\n\n -- Lifecycle\n status TEXT NOT NULL DEFAULT 'active', -- 'active', 'destroyed'\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n created_by_platform TEXT, -- 'github', 'slack', etc.\n\n -- Cross-reference metadata (for linking)\n metadata JSONB DEFAULT '{}'\n);\n\n-- Partial unique index: only active environments need uniqueness\nCREATE UNIQUE INDEX IF NOT EXISTS unique_active_workflow\n ON remote_agent_isolation_environments (codebase_id, workflow_type, workflow_id)\n WHERE status = 'active';\n\n-- Indexes for common queries\nCREATE INDEX IF NOT EXISTS idx_isolation_env_codebase\n ON remote_agent_isolation_environments(codebase_id);\nCREATE INDEX IF NOT EXISTS idx_isolation_env_status\n ON remote_agent_isolation_environments(status);\nCREATE INDEX IF NOT EXISTS idx_isolation_env_workflow\n ON remote_agent_isolation_environments(workflow_type, workflow_id);\n\n-- Add FK from conversations to isolation_environments (deferred to avoid circular dependency)\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS isolation_env_id UUID\n REFERENCES remote_agent_isolation_environments(id) ON DELETE SET NULL;\n\nCREATE INDEX IF NOT EXISTS idx_conversations_isolation_env_id\n ON remote_agent_conversations(isolation_env_id);\n\nCOMMENT ON TABLE remote_agent_isolation_environments IS\n 'Work-centric isolated environments with independent lifecycle';\nCOMMENT ON COLUMN remote_agent_isolation_environments.workflow_type IS\n 'Type of work: issue, pr, review, thread, task';\nCOMMENT ON COLUMN remote_agent_isolation_environments.workflow_id IS\n 'Identifier for the work (issue number, PR number, thread hash, etc.)';\n\n-- ============================================================================\n-- Table 5: Workflow Runs\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_workflow_runs (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n workflow_name VARCHAR(255) NOT NULL,\n conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,\n codebase_id UUID REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,\n current_step_index INTEGER,\n status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, running, completed, failed, cancelled, paused\n user_message TEXT NOT NULL,\n metadata JSONB DEFAULT '{}',\n parent_conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE SET NULL,\n parent_run_id UUID REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL,\n started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n completed_at TIMESTAMP WITH TIME ZONE,\n last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n working_path TEXT\n);\n\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_conversation\n ON remote_agent_workflow_runs(conversation_id);\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_status\n ON remote_agent_workflow_runs(status);\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_conv\n ON remote_agent_workflow_runs(parent_conversation_id);\n\n-- Partial index for efficient staleness queries on running workflows\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_last_activity\n ON remote_agent_workflow_runs(last_activity_at)\n WHERE status = 'running';\n\nCOMMENT ON TABLE remote_agent_workflow_runs IS\n 'Tracks workflow execution state for resumption and observability';\n\n-- ============================================================================\n-- Table 6: Workflow Events\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_workflow_events (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n workflow_run_id UUID NOT NULL REFERENCES remote_agent_workflow_runs(id) ON DELETE CASCADE,\n event_type VARCHAR(50) NOT NULL,\n step_index INTEGER,\n step_name VARCHAR(255),\n data JSONB DEFAULT '{}',\n created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_workflow_events_run_id\n ON remote_agent_workflow_events(workflow_run_id);\nCREATE INDEX IF NOT EXISTS idx_workflow_events_type\n ON remote_agent_workflow_events(event_type);\n-- Global created_at index for the dashboard event poller's cross-run tail\n-- (WHERE created_at >= $1 ORDER BY created_at ASC).\nCREATE INDEX IF NOT EXISTS idx_workflow_events_created_at\n ON remote_agent_workflow_events(created_at);\n\nCOMMENT ON TABLE remote_agent_workflow_events IS\n 'Lean UI-relevant workflow events for observability (step transitions, artifacts, errors)';\n\n-- ============================================================================\n-- Workflow node sessions (persist_session opt-in across re-runs)\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_workflow_node_sessions (\n workflow_name VARCHAR(255) NOT NULL,\n node_id VARCHAR(255) NOT NULL,\n scope_key TEXT NOT NULL,\n provider VARCHAR(50) NOT NULL,\n provider_session_id TEXT NOT NULL,\n last_run_id UUID REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n PRIMARY KEY (workflow_name, node_id, scope_key, provider)\n);\n\nCREATE INDEX IF NOT EXISTS idx_workflow_node_sessions_scope\n ON remote_agent_workflow_node_sessions(scope_key);\nCREATE INDEX IF NOT EXISTS idx_workflow_node_sessions_workflow\n ON remote_agent_workflow_node_sessions(workflow_name);\n\nCOMMENT ON TABLE remote_agent_workflow_node_sessions IS\n 'Per-node provider session IDs persisted across workflow re-runs. Keyed by (workflow, node, scope, provider). Scope is typically conversation UUID. No cascade on conversation delete (soft delete + never-reused UUID = harmless orphans); a future hard-delete path must delete by scope_key.';\n\n-- ============================================================================\n-- Table 7: Messages\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_messages (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n conversation_id UUID NOT NULL REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,\n role VARCHAR(20) NOT NULL,\n content TEXT NOT NULL DEFAULT '',\n metadata JSONB DEFAULT '{}'::jsonb,\n created_at TIMESTAMP DEFAULT NOW()\n);\n\nCREATE INDEX IF NOT EXISTS idx_messages_conversation_id\n ON remote_agent_messages(conversation_id, created_at ASC);\n\n-- ============================================================================\n-- Cleanup: Drop legacy objects from older schemas\n-- ============================================================================\n\n-- Drop command_templates table (replaced by file-based commands in .archon/commands)\nDROP TABLE IF EXISTS remote_agent_command_templates;\nDROP INDEX IF EXISTS idx_remote_agent_command_templates_name;\n\n-- Drop legacy columns from conversations (if upgrading from older schema)\nALTER TABLE remote_agent_conversations DROP COLUMN IF EXISTS worktree_path;\nALTER TABLE remote_agent_conversations DROP COLUMN IF EXISTS isolation_env_id_legacy;\nALTER TABLE remote_agent_conversations DROP COLUMN IF EXISTS isolation_provider;\nDROP INDEX IF EXISTS idx_conversations_isolation;\n\n-- Drop legacy constraint from isolation_environments (if upgrading from older schema)\nALTER TABLE remote_agent_isolation_environments\n DROP CONSTRAINT IF EXISTS unique_workflow;\n\n-- ============================================================================\n-- Idempotent ALTER statements for upgrading existing databases\n-- (These are no-ops on fresh installs since columns exist in CREATE TABLE above)\n-- ============================================================================\n\n-- From migration 006: isolation_env_id + last_activity_at on conversations\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS isolation_env_id UUID\n REFERENCES remote_agent_isolation_environments(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();\n\n-- From migration 009: last_activity_at on workflow_runs\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();\n\n-- From migration 010: parent_session_id + transition_reason on sessions\nALTER TABLE remote_agent_sessions\n ADD COLUMN IF NOT EXISTS parent_session_id UUID REFERENCES remote_agent_sessions(id);\nALTER TABLE remote_agent_sessions\n ADD COLUMN IF NOT EXISTS transition_reason TEXT;\n\n-- From migration 013: title + deleted_at on conversations\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS title VARCHAR(255);\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE;\n\n-- From migration 015: parent_conversation_id + hidden\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS parent_conversation_id UUID\n REFERENCES remote_agent_conversations(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS hidden BOOLEAN DEFAULT FALSE;\n\n-- From migration 016: ended_reason on sessions\nALTER TABLE remote_agent_sessions\n ADD COLUMN IF NOT EXISTS ended_reason TEXT;\n\n-- From migration 021: allow_env_keys on codebases\nALTER TABLE remote_agent_codebases\n ADD COLUMN IF NOT EXISTS allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE;\n\n-- From migration 023: detected default branch on codebases\nALTER TABLE remote_agent_codebases\n ADD COLUMN IF NOT EXISTS default_branch VARCHAR(255);\n\n-- From migration 024: project kind discriminator ('repo' | 'folder').\n-- Folder projects are non-git workspaces (multi-repo roots or plain ops folders)\n-- that run in place with named artifact/log storage under _folder//.\nALTER TABLE remote_agent_codebases\n ADD COLUMN IF NOT EXISTS kind VARCHAR(10) NOT NULL DEFAULT 'repo';\n\n-- User identity foreign keys (nullable on the four primary tables).\n-- All FKs use ON DELETE SET NULL so future user deletion never cascades destructively.\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_messages\n ADD COLUMN IF NOT EXISTS user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_isolation_environments\n ADD COLUMN IF NOT EXISTS created_by_user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\n\nCREATE INDEX IF NOT EXISTS idx_conversations_user_id\n ON remote_agent_conversations(user_id) WHERE user_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_user_id\n ON remote_agent_workflow_runs(user_id) WHERE user_id IS NOT NULL;\n\n-- Run-tree parent (#2121 Phase 2): a `workflow:` sub-run links back to the run\n-- that spawned it. Self-referential FK, ON DELETE SET NULL so deleting a parent\n-- orphans children rather than cascade-deleting their audit trail. First\n-- self-referential FK on this table — declared identically on SQLite (sqlite.ts).\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS parent_run_id UUID\n REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL;\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_run\n ON remote_agent_workflow_runs(parent_run_id) WHERE parent_run_id IS NOT NULL;\n\n-- From PR-C: per-user GitHub user-to-server tokens (device flow), encrypted at rest.\n-- One row per Archon user; cascades on user deletion. github_user_id is the\n-- numeric anchor for the commit no-reply email (survives username changes).\nCREATE TABLE IF NOT EXISTS remote_agent_user_github_tokens (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n github_user_id BIGINT NOT NULL,\n github_login VARCHAR(255) NOT NULL,\n access_token_encrypted TEXT NOT NULL,\n refresh_token_encrypted TEXT,\n access_token_expires_at TIMESTAMP WITH TIME ZONE,\n refresh_token_expires_at TIMESTAMP WITH TIME ZONE,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(user_id)\n);\n\n-- Phase 2: per-user AI-provider credentials (BYO API key + subscription login),\n-- encrypted at rest with the existing token-crypto key. One row per\n-- (user_id, provider); cascades on user deletion. Exactly one of\n-- api_key_encrypted / oauth_creds_encrypted is populated per row; `kind`\n-- records which. Gated on TOKEN_ENCRYPTION_KEY at the application layer.\nCREATE TABLE IF NOT EXISTS remote_agent_user_provider_keys (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n provider VARCHAR(64) NOT NULL,\n kind VARCHAR(16) NOT NULL,\n api_key_encrypted TEXT,\n oauth_creds_encrypted TEXT,\n label VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(user_id, provider)\n);\n\n-- #1955: credential rows are vendor-keyed (claude→anthropic, codex→openai,\n-- copilot→github-copilot) so one credential can serve every agent that\n-- consumes the vendor. Idempotent data fix: where both a legacy and a vendor\n-- row exist for the same user, the vendor row wins (rare — requires having\n-- connected both ids pre-rename); then legacy rows are renamed in place.\n-- Tested on SQLite (adapters/sqlite.test.ts covers rename, conflict, and\n-- idempotency); the Postgres DML below is the same statements but is NOT\n-- covered by an automated test — verified manually on the multi-user smoke.\n-- Survivable either way: reads normalize legacy ids (normalizeCredentialVendor).\nDELETE FROM remote_agent_user_provider_keys\nWHERE provider IN ('claude', 'codex', 'copilot')\n AND EXISTS (\n SELECT 1 FROM remote_agent_user_provider_keys v\n WHERE v.user_id = remote_agent_user_provider_keys.user_id\n AND v.provider = CASE remote_agent_user_provider_keys.provider\n WHEN 'claude' THEN 'anthropic'\n WHEN 'codex' THEN 'openai'\n WHEN 'copilot' THEN 'github-copilot'\n END\n );\nUPDATE remote_agent_user_provider_keys SET provider = 'anthropic' WHERE provider = 'claude';\nUPDATE remote_agent_user_provider_keys SET provider = 'openai' WHERE provider = 'codex';\nUPDATE remote_agent_user_provider_keys SET provider = 'github-copilot' WHERE provider = 'copilot';\n\n-- Phase 3: per-user AI preferences (model tiers, @custom aliases, default\n-- assistant). NON-encrypted — model names are not secrets (mirrors\n-- codebase_env_vars, not the provider-key store). One row per user; cascades\n-- on user deletion. `tiers` / `aliases` are JSON-as-TEXT (parsed in the\n-- store layer so SQLite and Postgres behave identically).\nCREATE TABLE IF NOT EXISTS remote_agent_user_ai_prefs (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n tiers TEXT,\n aliases TEXT,\n default_provider VARCHAR(64),\n default_model VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(user_id)\n);\n\n-- #1998: per-user default CHAT model, written atomically with\n-- default_provider (a model pin is only meaningful for the provider it was\n-- set with). Idempotent upgrade for installs that created the table before\n-- this column existed.\nALTER TABLE remote_agent_user_ai_prefs\n ADD COLUMN IF NOT EXISTS default_model VARCHAR(255);\n\n-- ============================================================================\n-- Web auth (opt-in): role on the canonical user + Better Auth tables\n-- ============================================================================\n--\n-- `role` is the durable identity seam: everyone defaults to 'admin' for now;\n-- 'member' is reserved for future per-resource scoping. Visibility stays open.\nALTER TABLE remote_agent_users\n ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin';\n\n-- ============================================================================\n-- Schema vintage (#2316)\n-- ============================================================================\n--\n-- Which Archon build created this database, and which last applied schema to it.\n-- Diagnostic only — nothing gates, refuses, or warns on these values. Single row\n-- (id = 1); the row's VALUES are written by the adapters from APP_VERSION\n-- (packages/core/src/db/schema-version.ts) so the version string has exactly one\n-- source of truth. created_app_version is NULL for databases that predate this\n-- table and is never back-filled with a guess.\nCREATE TABLE IF NOT EXISTS remote_agent_schema_version (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n created_app_version VARCHAR(64),\n app_version VARCHAR(64) NOT NULL,\n created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),\n applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()\n);\n\nCOMMENT ON TABLE remote_agent_schema_version IS\n 'Diagnostic schema vintage: the Archon build that created this database and the one that last applied schema to it.';\n\n-- Better Auth tables (PostgreSQL only). Generated by `@better-auth/cli generate`\n-- against packages/server/src/auth/instance.ts (modelName-renamed to the\n-- `remote_agent_auth_*` prefix), then made idempotent with IF NOT EXISTS so the\n-- bundled-schema auto-apply on startup converges. Better Auth owns these tables\n-- and the column shape (text ids, camelCase columns) — Archon never queries them\n-- directly; a session is mapped to the canonical remote_agent_users row via\n-- user_identities('web', ). Always created on Postgres (the\n-- IF NOT EXISTS apply runs on every boot); populated only when web auth is\n-- enabled (BETTER_AUTH_SECRET + DATABASE_URL), harmless empty tables otherwise.\nCREATE TABLE IF NOT EXISTS remote_agent_auth_user (\n \"id\" text NOT NULL PRIMARY KEY,\n \"name\" text NOT NULL,\n \"email\" text NOT NULL UNIQUE,\n \"emailVerified\" boolean NOT NULL,\n \"image\" text,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS remote_agent_auth_session (\n \"id\" text NOT NULL PRIMARY KEY,\n \"expiresAt\" timestamptz NOT NULL,\n \"token\" text NOT NULL UNIQUE,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz NOT NULL,\n \"ipAddress\" text,\n \"userAgent\" text,\n \"userId\" text NOT NULL REFERENCES remote_agent_auth_user (\"id\") ON DELETE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS remote_agent_auth_account (\n \"id\" text NOT NULL PRIMARY KEY,\n \"accountId\" text NOT NULL,\n \"providerId\" text NOT NULL,\n \"userId\" text NOT NULL REFERENCES remote_agent_auth_user (\"id\") ON DELETE CASCADE,\n \"accessToken\" text,\n \"refreshToken\" text,\n \"idToken\" text,\n \"accessTokenExpiresAt\" timestamptz,\n \"refreshTokenExpiresAt\" timestamptz,\n \"scope\" text,\n \"password\" text,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS remote_agent_auth_verification (\n \"id\" text NOT NULL PRIMARY KEY,\n \"identifier\" text NOT NULL,\n \"value\" text NOT NULL,\n \"expiresAt\" timestamptz NOT NULL,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL\n);\n"; diff --git a/packages/core/src/db/connection.ts b/packages/core/src/db/connection.ts index d05379f5cc..c00d2ad099 100644 --- a/packages/core/src/db/connection.ts +++ b/packages/core/src/db/connection.ts @@ -10,6 +10,7 @@ import { getArchonHome } from '@archon/paths'; import type { DbNotificationListener, IDatabase, SqlDialect, QueryResult } from './adapters/types'; import { PostgresAdapter, postgresDialect } from './adapters/postgres'; import { SqliteAdapter, sqliteDialect } from './adapters/sqlite'; +import { readSchemaVersion, type SchemaVersionInfo } from './schema-version'; import { createLogger } from '@archon/paths'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ @@ -85,6 +86,15 @@ export function getDatabaseType(): 'postgresql' | 'sqlite' { return process.env.DATABASE_URL ? 'postgresql' : 'sqlite'; } +/** + * Read the recorded schema vintage (#2316): which Archon build created this database + * and which last applied schema to it. Returns null when no row was ever written. + * Diagnostic only — no caller gates on it. + */ +export async function getSchemaVersion(): Promise { + return readSchemaVersion(getDatabase()); +} + /** Type guard: does this database implement the optional notification-listener capability? */ function isDbNotificationListener(db: IDatabase): db is IDatabase & DbNotificationListener { return typeof (db as Partial).listen === 'function'; diff --git a/packages/core/src/db/conversations.test.ts b/packages/core/src/db/conversations.test.ts index 1cfc9e08bf..54c2736be4 100644 --- a/packages/core/src/db/conversations.test.ts +++ b/packages/core/src/db/conversations.test.ts @@ -1,5 +1,9 @@ -import { mock, describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mock, describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; import { createQueryResult, mockPostgresDialect } from '../test/mocks/database'; +// spyOn (NOT mock.module) for config-loader: this file shares a `bun test` +// invocation with the real config-loader.test.ts and worktree-sync.test.ts — +// a mock.module here would poison them (pattern: worktree-sync.test.ts). +import * as configLoader from '../config/config-loader'; const mockQuery = mock(() => Promise.resolve(createQueryResult([]))); @@ -25,21 +29,16 @@ describe('conversations', () => { }); describe('getOrCreateConversation', () => { - let originalDefaultAiAssistant: string | undefined; + const mergedConfig = (assistant: string) => + ({ assistant }) as Awaited>; + let loadConfigSpy: ReturnType; beforeEach(() => { - // Save and clear env var to ensure test isolation - originalDefaultAiAssistant = process.env.DEFAULT_AI_ASSISTANT; - delete process.env.DEFAULT_AI_ASSISTANT; + loadConfigSpy = spyOn(configLoader, 'loadConfig').mockResolvedValue(mergedConfig('claude')); }); afterEach(() => { - // Restore original env var value - if (originalDefaultAiAssistant === undefined) { - delete process.env.DEFAULT_AI_ASSISTANT; - } else { - process.env.DEFAULT_AI_ASSISTANT = originalDefaultAiAssistant; - } + loadConfigSpy.mockRestore(); }); const existingConversation: Conversation = { @@ -82,6 +81,7 @@ describe('conversations', () => { const result = await getOrCreateConversation('telegram', 'chat-789'); expect(result).toEqual(newConversation); + expect(loadConfigSpy).toHaveBeenCalledTimes(1); expect(mockQuery).toHaveBeenCalledTimes(2); expect(mockQuery).toHaveBeenNthCalledWith( 2, @@ -119,11 +119,15 @@ describe('conversations', () => { 'INSERT INTO remote_agent_conversations (platform_type, platform_conversation_id, ai_assistant_type, codebase_id, cwd, user_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', ['telegram', 'chat-789', 'codex', 'codebase-123', null, null] ); + // The codebase-level assistant short-circuits the config chain. + expect(loadConfigSpy).not.toHaveBeenCalled(); }); - test('uses DEFAULT_AI_ASSISTANT env var when set', async () => { - // Set env var for this test (afterEach will restore original) - process.env.DEFAULT_AI_ASSISTANT = 'codex'; + // Harvested from PR #1826 (credit: @EugeneChan00) — the configured default + // assistant chain (config > DEFAULT_AI_ASSISTANT env > first built-in, all + // owned by loadConfig) must reach new conversations without a codebase. + test('resolves the configured default assistant when no codebase is scoped', async () => { + loadConfigSpy.mockResolvedValueOnce(mergedConfig('codex')); const newConversation: Conversation = { ...existingConversation, @@ -134,17 +138,39 @@ describe('conversations', () => { mockQuery.mockResolvedValueOnce(createQueryResult([])); mockQuery.mockResolvedValueOnce(createQueryResult([newConversation])); - const result = await getOrCreateConversation('telegram', 'chat-789'); + const result = await getOrCreateConversation('web', 'web-new-chat'); + + expect(result).toEqual(newConversation); + expect(loadConfigSpy).toHaveBeenCalledTimes(1); + expect(mockQuery).toHaveBeenNthCalledWith( + 2, + 'INSERT INTO remote_agent_conversations (platform_type, platform_conversation_id, ai_assistant_type, codebase_id, cwd, user_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', + ['web', 'web-new-chat', 'codex', null, null, null] + ); + }); + + test('falls back to claude when config load fails', async () => { + loadConfigSpy.mockRejectedValueOnce(new Error('config unavailable')); + + const newConversation: Conversation = { + ...existingConversation, + id: 'conv-new', + }; + + mockQuery.mockResolvedValueOnce(createQueryResult([])); + mockQuery.mockResolvedValueOnce(createQueryResult([newConversation])); + + const result = await getOrCreateConversation('web', 'web-new-chat'); expect(result).toEqual(newConversation); expect(mockQuery).toHaveBeenNthCalledWith( 2, 'INSERT INTO remote_agent_conversations (platform_type, platform_conversation_id, ai_assistant_type, codebase_id, cwd, user_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', - ['telegram', 'chat-789', 'codex', null, null, null] + ['web', 'web-new-chat', 'claude', null, null, null] ); }); - test('falls back to claude when codebase not found', async () => { + test('falls back to configured default when codebase not found', async () => { const newConversation: Conversation = { ...existingConversation, id: 'conv-new', @@ -160,6 +186,8 @@ describe('conversations', () => { const result = await getOrCreateConversation('telegram', 'chat-789', 'non-existent-codebase'); expect(result).toEqual(newConversation); + // Missing row → falls through to the config chain. + expect(loadConfigSpy).toHaveBeenCalledTimes(1); expect(mockQuery).toHaveBeenNthCalledWith( 3, 'INSERT INTO remote_agent_conversations (platform_type, platform_conversation_id, ai_assistant_type, codebase_id, cwd, user_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', @@ -213,6 +241,8 @@ describe('conversations', () => { 'INSERT INTO remote_agent_conversations (platform_type, platform_conversation_id, ai_assistant_type, codebase_id, cwd, user_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', ['discord', 'thread-123', 'codex', 'codebase-123', '/workspace/project', null] ); + // Parent inheritance short-circuits the config chain. + expect(loadConfigSpy).not.toHaveBeenCalled(); }); test('does not inherit when parent has no context', async () => { diff --git a/packages/core/src/db/conversations.ts b/packages/core/src/db/conversations.ts index a3c7270ac1..6aa2bff71d 100644 --- a/packages/core/src/db/conversations.ts +++ b/packages/core/src/db/conversations.ts @@ -5,6 +5,7 @@ import { pool, getDialect } from './connection'; import type { Conversation } from '../types'; import { ConversationNotFoundError } from '../types'; import { createLogger } from '@archon/paths'; +import { loadConfig } from '../config/config-loader'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -76,7 +77,7 @@ export async function getOrCreateConversation( // Check if we should inherit from a parent conversation (e.g., Discord thread inheriting from parent channel) let inheritedCodebaseId: string | null = null; let inheritedCwd: string | null = null; - let assistantType = process.env.DEFAULT_AI_ASSISTANT ?? 'claude'; + let assistantType: string | undefined; if (parentConversationId) { const parent = await pool.query( @@ -108,6 +109,30 @@ export async function getOrCreateConversation( } } + // No parent or codebase signal: resolve the configured default assistant + // instead of hard-defaulting to Claude (#2241). loadConfig() owns the + // fallback chain — explicit config (repo assistant > global defaultAssistant) + // > DEFAULT_AI_ASSISTANT env > first registered built-in provider. The + // per-user default assistant (#1998) deliberately stays OUT of this row: the + // orchestrator applies it per turn (userAiPrefs.defaultProvider ?? + // conversation.ai_assistant_type), sender-first (#1982), so a personal + // preference is never baked into a shared conversation. + if (assistantType === undefined) { + try { + const config = await loadConfig(); + assistantType = config.assistant; + } catch (err) { + // Intentional fallback: a broken config (e.g. an unregistered + // DEFAULT_AI_ASSISTANT value makes loadConfig throw) must not block + // conversation creation — the turn itself surfaces config errors. + getLog().warn( + { err: err instanceof Error ? err.message : String(err) }, + 'db.conversation_default_assistant_config_load_failed' + ); + } + } + assistantType ??= 'claude'; + const created = await pool.query( 'INSERT INTO remote_agent_conversations (platform_type, platform_conversation_id, ai_assistant_type, codebase_id, cwd, user_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', [platformType, platformId, assistantType, finalCodebaseId, inheritedCwd, userId ?? null] diff --git a/packages/core/src/db/messages.order.integration.test.ts b/packages/core/src/db/messages.order.integration.test.ts new file mode 100644 index 0000000000..85712d1131 --- /dev/null +++ b/packages/core/src/db/messages.order.integration.test.ts @@ -0,0 +1,98 @@ +/** + * Integration test: message-query ordering against a REAL bun:sqlite database. + * + * SQLite stores `created_at` at 1-second granularity, so consecutive messages + * routinely share a timestamp. Without a secondary sort key the LIMIT window of + * `ORDER BY created_at DESC LIMIT n` is undefined for tied rows and can flip + * between refetches, dropping/duplicating a boundary message (#2218). These + * tests pin the `id DESC` tie-breaker: tied rows are inserted in an order that + * differs from their id order, so scan-order luck cannot make them pass. + * + * Runs in its own `bun test` invocation (see package.json) — it mock.module's + * ./connection with a real adapter, conflicting with other db tests' fakes. + */ +import { describe, test, expect, mock } from 'bun:test'; + +mock.module('@archon/paths', () => ({ + createLogger: () => ({ + info() {}, + warn() {}, + error() {}, + debug() {}, + trace() {}, + fatal() {}, + }), +})); + +const { SqliteAdapter, sqliteDialect } = await import('./adapters/sqlite'); +const db = new SqliteAdapter(':memory:'); + +mock.module('./connection', () => ({ + pool: db, + getDialect: () => sqliteDialect, + getDatabaseType: () => 'sqlite', +})); + +const { listMessages, getRecentWorkflowResultMessages } = await import('./messages'); + +await db.query( + `INSERT INTO remote_agent_conversations (id, platform_type, platform_conversation_id) + VALUES ('conv-1', 'web', 'conv-1-platform')`, + [] +); + +async function insertMessage(id: string, createdAt: string, metadata = '{}'): Promise { + await db.query( + `INSERT INTO remote_agent_messages (id, conversation_id, role, content, metadata, created_at) + VALUES ($1, 'conv-1', 'user', $2, $3, $4)`, + [id, `content-${id}`, metadata, createdAt] + ); +} + +// Two older messages with distinct timestamps... +await insertMessage('old-1', '2026-01-01 00:00:01'); +await insertMessage('old-2', '2026-01-01 00:00:02'); +// ...and four messages sharing one created_at, inserted OUT of id order so that +// insertion (scan) order differs from the deterministic id order. +await insertMessage('tie-2', '2026-01-01 00:00:10'); +await insertMessage('tie-4', '2026-01-01 00:00:10'); +await insertMessage('tie-1', '2026-01-01 00:00:10'); +await insertMessage('tie-3', '2026-01-01 00:00:10'); + +describe('listMessages — deterministic LIMIT window on shared created_at (#2218)', () => { + test('a LIMIT cutting inside a tie group keeps the highest ids, in stable order', async () => { + // Newest 3 of the 4 tied rows: id DESC picks tie-4, tie-3, tie-2; reversed + // to chronological. Without the tie-breaker, membership follows scan order + // (tie-2, tie-4, tie-1) and this assertion fails. + const rows = await listMessages('conv-1', 3); + expect(rows.map(r => r.id)).toEqual(['tie-2', 'tie-3', 'tie-4']); + }); + + test('window membership is identical across refetches', async () => { + const first = await listMessages('conv-1', 3); + const second = await listMessages('conv-1', 3); + expect(second.map(r => r.id)).toEqual(first.map(r => r.id)); + }); + + test('distinct timestamps keep the chronological (oldest-first) contract', async () => { + const rows = await listMessages('conv-1', 10); + expect(rows.map(r => r.id)).toEqual(['old-1', 'old-2', 'tie-1', 'tie-2', 'tie-3', 'tie-4']); + }); + + test('a limit spanning the tie boundary includes the older distinct row', async () => { + const rows = await listMessages('conv-1', 5); + expect(rows.map(r => r.id)).toEqual(['old-2', 'tie-1', 'tie-2', 'tie-3', 'tie-4']); + }); +}); + +describe('getRecentWorkflowResultMessages — same tie-breaker (#2218)', () => { + test('a LIMIT cutting inside a tie group keeps the highest ids, newest-first', async () => { + const meta = '{"workflowResult":{"workflowName":"plan","runId":"run-1"}}'; + await insertMessage('wf-2', '2026-01-01 00:00:20', meta); + await insertMessage('wf-3', '2026-01-01 00:00:20', meta); + await insertMessage('wf-1', '2026-01-01 00:00:20', meta); + + const rows = await getRecentWorkflowResultMessages('conv-1', 2); + expect(rows.map(r => r.id)).toEqual(['wf-3', 'wf-2']); + }); +}); diff --git a/packages/core/src/db/messages.test.ts b/packages/core/src/db/messages.test.ts index 7d095c70ae..f795468b08 100644 --- a/packages/core/src/db/messages.test.ts +++ b/packages/core/src/db/messages.test.ts @@ -129,7 +129,7 @@ describe('messages', () => { expect(mockQuery).toHaveBeenCalledWith( `SELECT * FROM remote_agent_messages WHERE conversation_id = $1 - ORDER BY created_at DESC + ORDER BY created_at DESC, id DESC LIMIT $2`, ['conv-456', 200] ); diff --git a/packages/core/src/db/messages.ts b/packages/core/src/db/messages.ts index 274c9c1146..e94907157f 100644 --- a/packages/core/src/db/messages.ts +++ b/packages/core/src/db/messages.ts @@ -48,6 +48,8 @@ export async function addMessage( * List messages for a conversation, oldest first. * Fetches the newest `limit` messages so that the most recent history is always * returned, then reverses to preserve chronological (oldest-first) order. + * `id DESC` breaks ties between rows sharing a created_at (SQLite stores + * 1-second granularity) so the LIMIT window is stable across refetches. * conversationId is the database UUID (not platform_conversation_id). */ export async function listMessages( @@ -57,7 +59,7 @@ export async function listMessages( const result = await pool.query( `SELECT * FROM remote_agent_messages WHERE conversation_id = $1 - ORDER BY created_at DESC + ORDER BY created_at DESC, id DESC LIMIT $2`, [conversationId, limit] ); @@ -83,7 +85,8 @@ export async function getRecentWorkflowResultMessages( `SELECT id, content, metadata FROM remote_agent_messages WHERE conversation_id = $1 AND ${metadataFilter} - ORDER BY created_at DESC + -- id DESC tie-breaker: see listMessages() above for why. + ORDER BY created_at DESC, id DESC LIMIT $2`, [conversationId, limit] ); diff --git a/packages/core/src/db/schema-version.ts b/packages/core/src/db/schema-version.ts new file mode 100644 index 0000000000..4b57630b79 --- /dev/null +++ b/packages/core/src/db/schema-version.ts @@ -0,0 +1,81 @@ +/** + * Schema vintage (#2316). + * + * Archon has no migration ledger: both schemas are re-applied in full, idempotently, + * on every connection by every process that opens the database. That converges without + * operator action, but it leaves no record of *which* Archon build created a database + * or last applied schema to it — so a database whose tables predate a constraint change + * is structurally different from a fresh one, and nothing can say so. + * + * This module defines the single diagnostic row that closes that gap. It is metadata + * only: nothing in Archon gates, refuses, or warns on these values. + */ +// Imported from the `bundled-build` subpath rather than the `@archon/paths` barrel +// on purpose: 80+ test files mock '@archon/paths' with a partial surface, and both +// adapters record the vintage on construction. Going through the barrel would make +// every one of those mocks responsible for re-exporting a build constant it has no +// interest in. The subpath is a side-effect-free leaf, mirroring '@archon/paths/env-loader'. +import { BUNDLED_VERSION } from '@archon/paths/bundled-build'; +import type { IDatabase } from './adapters/types'; + +/** + * The Archon build recorded as having applied the schema. + * + * `'dev'` in source checkouts, the released semver in compiled binaries. The + * distinction is the point: it is what tells a dev checkout's writes apart from a + * released binary's against the same `~/.archon/archon.db`. + */ +export const APP_VERSION = BUNDLED_VERSION; + +export interface SchemaVersionInfo { + /** + * Archon build that created this database, or null when the database predates + * version tracking. Never back-filled with a guess — an unknown vintage is + * exactly the fact worth reporting. + */ + createdAppVersion: string | null; + /** Archon build that last applied schema to this database. */ + appVersion: string; + /** When the row was first written (ISO 8601), or null if the column is empty. */ + createdAt: string | null; + /** When `appVersion` last changed (ISO 8601) — i.e. when the upgrade happened. */ + appliedAt: string | null; +} + +/** Row shape as returned by either adapter (SQLite yields TEXT, Postgres yields Date). */ +interface SchemaVersionRow { + created_app_version: string | null; + app_version: string; + created_at: string | Date | null; + applied_at: string | Date | null; +} + +function toIso(value: string | Date | null): string | null { + if (value === null || value === undefined) return null; + return value instanceof Date ? value.toISOString() : value; +} + +/** + * Read the recorded schema vintage, or null when no row was ever written (the write + * is best-effort on SQLite, so its absence is a legitimate state). + * + * SQL errors are not swallowed — they propagate to the caller, which decides how to + * degrade. Both current callers (`archon doctor`, `GET /api/health`) must stay + * answerable when the database is unhealthy, so they log and omit rather than fail. + */ +export async function readSchemaVersion(db: IDatabase): Promise { + const result = await db.query( + `SELECT created_app_version, app_version, created_at, applied_at + FROM remote_agent_schema_version WHERE id = 1` + ); + + const row = result.rows[0]; + if (!row) return null; + + return { + createdAppVersion: row.created_app_version, + appVersion: row.app_version, + createdAt: toIso(row.created_at), + appliedAt: toIso(row.applied_at), + }; +} diff --git a/packages/core/src/db/workflow-events.test.ts b/packages/core/src/db/workflow-events.test.ts index 842b3b0d70..dc753380cd 100644 --- a/packages/core/src/db/workflow-events.test.ts +++ b/packages/core/src/db/workflow-events.test.ts @@ -29,12 +29,13 @@ import { createWorkflowEvent, listWorkflowEvents, listRecentEvents, - getCompletedDagNodeOutputs, + getDagResumeSnapshot, } from './workflow-events'; describe('workflow-events', () => { beforeEach(() => { mockQuery.mockClear(); + mockLogger.warn.mockClear(); }); const mockEvent: WorkflowEventRow = { @@ -189,20 +190,32 @@ describe('workflow-events', () => { }); }); - describe('getCompletedDagNodeOutputs', () => { - test('returns map of nodeId → output from node_completed events', async () => { + describe('getDagResumeSnapshot', () => { + test('returns outputs and summed tokens from node_completed events', async () => { mockQuery.mockResolvedValueOnce( createQueryResult([ - { step_name: 'node-a', data: { node_output: 'output A' } }, - { step_name: 'node-b', data: { node_output: 'output B' } }, + { + step_name: 'node-a', + event_type: 'node_completed', + data: { node_output: 'output A', tokens: { input: 40, output: 4 } }, + }, + { + step_name: 'node-b', + event_type: 'node_completed', + data: { node_output: 'output B', tokens: { input: 60, output: 6 } }, + }, ]) ); - const result = await getCompletedDagNodeOutputs('run-123'); + const result = await getDagResumeSnapshot('run-123'); - expect(result.size).toBe(2); - expect(result.get('node-a')).toBe('output A'); - expect(result.get('node-b')).toBe('output B'); + expect(result.completedNodeOutputs).toEqual( + new Map([ + ['node-a', 'output A'], + ['node-b', 'output B'], + ]) + ); + expect(result.tokens).toEqual({ input: 100, output: 10 }); expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('node_completed'), [ 'run-123', ]); @@ -211,16 +224,29 @@ describe('workflow-events', () => { test('returns outputs from node_skipped_prior_success events (multi-resume)', async () => { mockQuery.mockResolvedValueOnce( createQueryResult([ - { step_name: 'node-a', data: { node_output: 'output A' } }, - { step_name: 'node-b', data: { reason: 'prior_success', node_output: 'output B' } }, + { + step_name: 'node-a', + event_type: 'node_completed', + data: { node_output: 'output A', tokens: { input: 40, output: 4 } }, + }, + { + step_name: 'node-b', + event_type: 'node_skipped_prior_success', + data: { + reason: 'prior_success', + node_output: 'output B', + tokens: { input: 999, output: 999 }, + }, + }, ]) ); - const result = await getCompletedDagNodeOutputs('run-resume'); + const result = await getDagResumeSnapshot('run-resume'); - expect(result.size).toBe(2); - expect(result.get('node-a')).toBe('output A'); - expect(result.get('node-b')).toBe('output B'); + expect(result.completedNodeOutputs.size).toBe(2); + expect(result.completedNodeOutputs.get('node-a')).toBe('output A'); + expect(result.completedNodeOutputs.get('node-b')).toBe('output B'); + expect(result.tokens).toEqual({ input: 40, output: 4 }); expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining('node_skipped_prior_success'), ['run-resume'] @@ -232,92 +258,161 @@ describe('workflow-events', () => { createQueryResult([ { step_name: 'node-x', + event_type: 'node_skipped_prior_success', data: { reason: 'prior_success', node_output: 'skipped output X' }, }, { step_name: 'node-y', + event_type: 'node_skipped_prior_success', data: { reason: 'prior_success', node_output: 'skipped output Y' }, }, ]) ); - const result = await getCompletedDagNodeOutputs('run-all-skipped'); + const result = await getDagResumeSnapshot('run-all-skipped'); - expect(result.size).toBe(2); - expect(result.get('node-x')).toBe('skipped output X'); - expect(result.get('node-y')).toBe('skipped output Y'); + expect(result.completedNodeOutputs.size).toBe(2); + expect(result.completedNodeOutputs.get('node-x')).toBe('skipped output X'); + expect(result.completedNodeOutputs.get('node-y')).toBe('skipped output Y'); + expect(result.tokens).toEqual({ input: 0, output: 0 }); }); test('parses JSON string data (SQLite path)', async () => { mockQuery.mockResolvedValueOnce( createQueryResult([ - { step_name: 'node-a', data: JSON.stringify({ node_output: 'parsed output' }) }, + { + step_name: 'node-a', + event_type: 'node_completed', + data: JSON.stringify({ + node_output: 'parsed output', + tokens: { input: 8, output: 2 }, + }), + }, ]) ); - const result = await getCompletedDagNodeOutputs('run-456'); + const result = await getDagResumeSnapshot('run-456'); - expect(result.size).toBe(1); - expect(result.get('node-a')).toBe('parsed output'); + expect(result.completedNodeOutputs.get('node-a')).toBe('parsed output'); + expect(result.tokens).toEqual({ input: 8, output: 2 }); }); test('skips rows with null step_name', async () => { mockQuery.mockResolvedValueOnce( createQueryResult([ - { step_name: null, data: { node_output: 'should be skipped' } }, - { step_name: 'node-a', data: { node_output: 'kept' } }, + { + step_name: null, + event_type: 'node_completed', + data: { node_output: 'should be skipped', tokens: { input: 99, output: 99 } }, + }, + { + step_name: 'node-a', + event_type: 'node_completed', + data: { node_output: 'kept', tokens: { input: 1, output: 2 } }, + }, + ]) + ); + + const result = await getDagResumeSnapshot('run-789'); + + expect(result.completedNodeOutputs).toEqual(new Map([['node-a', 'kept']])); + expect(result.tokens).toEqual({ input: 1, output: 2 }); + }); + + test('preserves valid outputs while ignoring malformed and non-finite tokens', async () => { + mockQuery.mockResolvedValueOnce( + createQueryResult([ + { + step_name: 'node-a', + event_type: 'node_completed', + data: { node_output: 123, tokens: { input: 10, output: 1 } }, + }, + { + step_name: 'node-b', + event_type: 'node_completed', + data: { duration_ms: 500, tokens: { input: 'bad', output: 2 } }, + }, + { + step_name: 'node-c', + event_type: 'node_completed', + data: { node_output: 'valid', tokens: { input: Number.NaN, output: Infinity } }, + }, + { + step_name: 'node-d', + event_type: 'node_completed', + data: { node_output: 'also valid' }, + }, ]) ); - const result = await getCompletedDagNodeOutputs('run-789'); + const result = await getDagResumeSnapshot('run-filter'); - expect(result.size).toBe(1); - expect(result.get('node-a')).toBe('kept'); + expect(result.completedNodeOutputs).toEqual( + new Map([ + ['node-c', 'valid'], + ['node-d', 'also valid'], + ]) + ); + expect(result.tokens).toEqual({ input: 10, output: 1 }); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); }); - test('skips rows where node_output is not a string', async () => { + test('does not warn when completed events omit optional token usage', async () => { mockQuery.mockResolvedValueOnce( createQueryResult([ - { step_name: 'node-a', data: { node_output: 123 } }, - { step_name: 'node-b', data: { duration_ms: 500 } }, - { step_name: 'node-c', data: { node_output: 'valid' } }, + { + step_name: 'node-a', + event_type: 'node_completed', + data: { node_output: 'output without usage' }, + }, ]) ); - const result = await getCompletedDagNodeOutputs('run-filter'); + const result = await getDagResumeSnapshot('run-without-tokens'); - expect(result.size).toBe(1); - expect(result.get('node-c')).toBe('valid'); + expect(result.completedNodeOutputs).toEqual(new Map([['node-a', 'output without usage']])); + expect(result.tokens).toEqual({ input: 0, output: 0 }); + expect(mockLogger.warn).not.toHaveBeenCalled(); }); test('skips corrupt JSON rows without losing other rows', async () => { mockQuery.mockResolvedValueOnce( createQueryResult([ - { step_name: 'node-a', data: { node_output: 'good first' } }, - { step_name: 'node-b', data: '{bad json' }, - { step_name: 'node-c', data: { node_output: 'good last' } }, + { + step_name: 'node-a', + event_type: 'node_completed', + data: { node_output: 'good first', tokens: { input: 3, output: 1 } }, + }, + { step_name: 'node-b', event_type: 'node_completed', data: '{bad json' }, + { + step_name: 'node-c', + event_type: 'node_completed', + data: { node_output: 'good last', tokens: { input: 7, output: 2 } }, + }, ]) ); - const result = await getCompletedDagNodeOutputs('run-corrupt'); + const result = await getDagResumeSnapshot('run-corrupt'); - expect(result.size).toBe(2); - expect(result.get('node-a')).toBe('good first'); - expect(result.get('node-c')).toBe('good last'); + expect(result.completedNodeOutputs.size).toBe(2); + expect(result.completedNodeOutputs.get('node-a')).toBe('good first'); + expect(result.completedNodeOutputs.get('node-c')).toBe('good last'); + expect(result.tokens).toEqual({ input: 10, output: 3 }); }); - test('returns empty map when no events exist', async () => { + test('returns an empty snapshot when no events exist', async () => { mockQuery.mockResolvedValueOnce(createQueryResult([])); - const result = await getCompletedDagNodeOutputs('run-empty'); + const result = await getDagResumeSnapshot('run-empty'); - expect(result.size).toBe(0); + expect(result.completedNodeOutputs.size).toBe(0); + expect(result.tokens).toEqual({ input: 0, output: 0 }); }); test('throws on DB query error', async () => { mockQuery.mockRejectedValueOnce(new Error('connection refused')); - await expect(getCompletedDagNodeOutputs('run-error')).rejects.toThrow('connection refused'); + await expect(getDagResumeSnapshot('run-error')).rejects.toThrow('connection refused'); }); }); }); diff --git a/packages/core/src/db/workflow-events.ts b/packages/core/src/db/workflow-events.ts index 72b4e63a13..e8c1a397e6 100644 --- a/packages/core/src/db/workflow-events.ts +++ b/packages/core/src/db/workflow-events.ts @@ -213,23 +213,26 @@ export async function listWorkflowEventsSince( } /** - * Return a map of nodeId → output for all node_completed events in a workflow run. - * Used by the DAG executor to restore node outputs when resuming a failed run. + * Return completed node outputs and cumulative token usage for a workflow run. + * Used by the DAG executor to restore state when resuming a failed run. * Throws on DB error — caller owns the degradation policy. */ -export async function getCompletedDagNodeOutputs( - workflowRunId: string -): Promise> { +export async function getDagResumeSnapshot(workflowRunId: string): Promise<{ + completedNodeOutputs: Map; + tokens: { input: number; output: number }; +}> { const result = await pool.query<{ step_name: string | null; + event_type: 'node_completed' | 'node_skipped_prior_success'; data: string | Record; }>( - `SELECT step_name, data FROM remote_agent_workflow_events + `SELECT step_name, event_type, data FROM remote_agent_workflow_events WHERE workflow_run_id = $1 AND event_type IN ('node_completed', 'node_skipped_prior_success') ORDER BY created_at ASC`, [workflowRunId] ); - const outputs = new Map(); + const completedNodeOutputs = new Map(); + const tokens = { input: 0, output: 0 }; for (const row of result.rows) { if (!row.step_name) continue; let data: Record; @@ -243,8 +246,29 @@ export async function getCompletedDagNodeOutputs( continue; } if (typeof data.node_output === 'string') { - outputs.set(row.step_name, data.node_output); + completedNodeOutputs.set(row.step_name, data.node_output); + } + if (row.event_type === 'node_completed' && data.tokens !== undefined) { + const eventTokens = data.tokens; + if ( + typeof eventTokens === 'object' && + eventTokens !== null && + 'input' in eventTokens && + 'output' in eventTokens && + typeof eventTokens.input === 'number' && + typeof eventTokens.output === 'number' && + Number.isFinite(eventTokens.input) && + Number.isFinite(eventTokens.output) + ) { + tokens.input += eventTokens.input; + tokens.output += eventTokens.output; + } else { + getLog().warn( + { runId: workflowRunId, stepName: row.step_name, tokens: eventTokens }, + 'db.workflow_dag_node_tokens_invalid_ignored' + ); + } } } - return outputs; + return { completedNodeOutputs, tokens }; } diff --git a/packages/core/src/db/workflows.resume-cas.integration.test.ts b/packages/core/src/db/workflows.resume-cas.integration.test.ts index 8219bdafb4..c257f5f73e 100644 --- a/packages/core/src/db/workflows.resume-cas.integration.test.ts +++ b/packages/core/src/db/workflows.resume-cas.integration.test.ts @@ -60,12 +60,17 @@ await db.query( ); /** Insert a run with an explicit status and a SQL expression for last_activity_at. */ -async function seed(id: string, status: string, lastActivityExpr: string): Promise { +async function seed( + id: string, + status: string, + lastActivityExpr: string, + metadata: Record = {} +): Promise { await db.query( `INSERT INTO remote_agent_workflow_runs - (id, workflow_name, conversation_id, user_message, status, started_at, last_activity_at) - VALUES ($1, 'wf', 'conv-1', 'msg', $2, datetime('now'), ${lastActivityExpr})`, - [id, status] + (id, workflow_name, conversation_id, user_message, status, started_at, last_activity_at, metadata) + VALUES ($1, 'wf', 'conv-1', 'msg', $2, datetime('now'), ${lastActivityExpr}, $3)`, + [id, status, JSON.stringify(metadata)] ); } @@ -83,6 +88,91 @@ describe('resumeWorkflowRun — real SQLite (CAS + orphan recovery)', () => { expect((await resumeWorkflowRun('failed')).status).toBe('running'); }); + test('clears a failed run error when resuming, preserving it as an event', async () => { + // #2329: a run that failed, resumed and completed kept rendering its old + // error. #2348: for the motivating run the error lived ONLY in metadata — + // the CLI's SIGTERM handler calls failWorkflowRun and writes no event — so + // clearing it silently destroyed the only record that the run ever failed. + await seed('failed-with-error', 'failed', "datetime('now')", { + error: 'Process terminated (SIGTERM)', + unrelated: 'keep me', + }); + + const resumed = await resumeWorkflowRun('failed-with-error'); + + expect(resumed.status).toBe('running'); + const after = await getWorkflowRun('failed-with-error'); + expect(after?.metadata.error ?? null).toBeNull(); + // Merge, not replace: unrelated metadata survives the clear. + expect(after?.metadata.unrelated).toBe('keep me'); + + // ...and the cleared error is now recoverable from the audit trail. + const events = await db.query<{ data: string }>( + `SELECT data FROM remote_agent_workflow_events + WHERE workflow_run_id = $1 AND event_type = 'workflow_resumed'`, + ['failed-with-error'] + ); + expect(events.rows).toHaveLength(1); + expect(JSON.parse(events.rows[0]?.data ?? '{}')).toEqual({ + error: 'Process terminated (SIGTERM)', + }); + }); + + test('writes no event when the resumed run carried no error', async () => { + // A paused gate resumes with nothing to preserve — it must not gain a + // spurious "this run failed once" record. + await seed('paused-clean', 'paused', "datetime('now')"); + + expect((await resumeWorkflowRun('paused-clean')).status).toBe('running'); + + const events = await db.query<{ cnt: number }>( + `SELECT COUNT(*) AS cnt FROM remote_agent_workflow_events + WHERE workflow_run_id = $1 AND event_type = 'workflow_resumed'`, + ['paused-clean'] + ); + expect(Number(events.rows[0]?.cnt ?? -1)).toBe(0); + }); + + test('two concurrent resumes: exactly one wins and exactly one event lands', async () => { + // The loser read the same error but its CAS matched nothing — it must write + // nothing, or a lost race still emits an audit event for a clear it never did. + await seed('resume-race', 'failed', "datetime('now')", { error: 'boom' }); + + const outcomes = await Promise.allSettled([ + resumeWorkflowRun('resume-race'), + resumeWorkflowRun('resume-race'), + ]); + + expect(outcomes.filter(o => o.status === 'fulfilled')).toHaveLength(1); + const events = await db.query<{ cnt: number }>( + `SELECT COUNT(*) AS cnt FROM remote_agent_workflow_events + WHERE workflow_run_id = $1 AND event_type = 'workflow_resumed'`, + ['resume-race'] + ); + expect(Number(events.rows[0]?.cnt ?? -1)).toBe(1); + }); + + test('rolls back the clear when the audit-event write fails', async () => { + // The preservation is only worth anything if it cannot be skipped: a failed + // event INSERT must roll the clear back, leaving the run resumable with its + // error intact rather than erasing it with no record. + await seed('resume-atomic', 'failed', "datetime('now')", { error: 'boom' }); + + // Break the event INSERT by removing the table for the duration of the call. + await db.query('ALTER TABLE remote_agent_workflow_events RENAME TO events_stash', []); + try { + await expect(resumeWorkflowRun('resume-atomic')).rejects.toThrow( + /Failed to resume workflow run/ + ); + } finally { + await db.query('ALTER TABLE events_stash RENAME TO remote_agent_workflow_events', []); + } + + const after = await getWorkflowRun('resume-atomic'); + expect(after?.status).toBe('failed'); + expect(after?.metadata.error).toBe('boom'); + }); + test('resumes a paused run', async () => { await seed('paused', 'paused', "datetime('now')"); expect((await resumeWorkflowRun('paused')).status).toBe('running'); diff --git a/packages/core/src/db/workflows.test.ts b/packages/core/src/db/workflows.test.ts index bd87d83ca1..57e1c239f7 100644 --- a/packages/core/src/db/workflows.test.ts +++ b/packages/core/src/db/workflows.test.ts @@ -4,11 +4,17 @@ import type { WorkflowRun } from '@archon/workflows/schemas/workflow-run'; const mockQuery = mock(() => Promise.resolve(createQueryResult([]))); -// Mock the connection module before importing the module under test +// Mock the connection module before importing the module under test. +// `getDatabase().withTransaction` runs its callback against the SAME mockQuery, +// so a transactional function's statements land in mockQuery.mock.calls in +// order, exactly like the non-transactional ones. mock.module('./connection', () => ({ pool: { query: mockQuery, }, + getDatabase: () => ({ + withTransaction: (fn: (query: typeof mockQuery) => Promise): Promise => fn(mockQuery), + }), getDialect: () => mockPostgresDialect, getDatabaseType: () => 'postgresql' as const, })); @@ -24,13 +30,17 @@ import { failWorkflowRun, updateWorkflowActivity, findResumableRun, + findResumableRunByParentConversation, resumeWorkflowRun, pauseWorkflowRun, cancelWorkflowRun, failOrphanedRuns, + findChildRuns, + getRunAncestry, listWorkflowRuns, deleteOldWorkflowRuns, deleteWorkflowRun, + WorkflowNotResumableError, } from './workflows'; describe('workflows database', () => { @@ -77,6 +87,7 @@ describe('workflows database', () => { null, null, null, + null, ] ); }); @@ -108,6 +119,7 @@ describe('workflows database', () => { null, null, null, + null, ] ); }); @@ -125,7 +137,17 @@ describe('workflows database', () => { expect(result.codebase_id).toBeNull(); expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO remote_agent_workflow_runs'), - ['feature-development', 'conv-456', null, 'Add dark mode support', '{}', null, null, null] + [ + 'feature-development', + 'conv-456', + null, + 'Add dark mode support', + '{}', + null, + null, + null, + null, + ] ); }); }); @@ -615,6 +637,52 @@ describe('workflows database', () => { }); }); + describe('findResumableRunByParentConversation', () => { + test('scopes by workflow name, parent conversation and codebase', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([mockWorkflowRun])); + + const result = await findResumableRunByParentConversation('piv', 'conv-1', 'codebase-789'); + + expect(result).toEqual(mockWorkflowRun); + const [query, params] = mockQuery.mock.calls[0] as [string, unknown[]]; + expect(query).toContain('workflow_name = $1'); + expect(query).toContain('parent_conversation_id = $2'); + expect(query).toContain('codebase_id = $3'); + expect(params).toEqual(['piv', 'conv-1', 'codebase-789']); + }); + + test('prefers a paused run over a newer failed one (paused-first ordering)', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([])); + + await findResumableRunByParentConversation('piv', 'conv-1', 'cb'); + + const [query] = mockQuery.mock.calls[0] as [string, unknown[]]; + expect(query).toContain("status IN ('failed', 'paused')"); + // Status is the primary sort key: an open gate auto-resumes, while a + // failed candidate is gated behind an explicit user prompt. Ordering by + // started_at alone lets a newer failure shadow an older waiting gate. + expect(query).toContain("ORDER BY CASE WHEN status = 'paused' THEN 0 ELSE 1 END"); + // Recency still breaks ties within a status. + expect(query).toContain('started_at DESC'); + }); + + test('returns null when no run matches', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([])); + + const result = await findResumableRunByParentConversation('piv', 'conv-1', 'cb'); + + expect(result).toBeNull(); + }); + + test('throws on database error', async () => { + mockQuery.mockRejectedValueOnce(new Error('Connection refused')); + + await expect(findResumableRunByParentConversation('piv', 'conv-1', 'cb')).rejects.toThrow( + 'Failed to find resumable run by parent conversation: Connection refused' + ); + }); + }); + describe('getActiveWorkflowRunByPath', () => { test('returns active or failed run for the given working path', async () => { const activeRun = { ...mockWorkflowRun, working_path: '/repo/path' }; @@ -698,6 +766,112 @@ describe('workflows database', () => { 'Failed to get active workflow run by path: Connection refused' ); }); + + // #2121 Phase 2: the ancestor chain of a shared-checkout sub-run must not + // count as a lock — each id gets its own positional placeholder (no array + // binding, works on both dialects). + test('excludes ancestor run ids via NOT IN with positional placeholders', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([])); + const startedAt = new Date('2026-04-14T10:00:00Z'); + + await getActiveWorkflowRunByPath('/repo/path', { + id: 'child-id', + startedAt, + excludeRunIds: ['parent-id', 'grandparent-id'], + }); + + const [query, params] = mockQuery.mock.calls[0] as [string, unknown[]]; + expect(query).toContain('id != $2'); + expect(query).toContain('id NOT IN ($3, $4)'); + // The tiebreaker's id comparison must reference SELF ($2) — a positional + // back-reference once pointed it at $4 (an ancestor id) when excludeRunIds + // params landed between the self id and startedAt. + expect(query).toContain('started_at = $5::timestamptz AND id < $2'); + expect(params).toEqual([ + '/repo/path', + 'child-id', + 'parent-id', + 'grandparent-id', + startedAt.toISOString(), + ]); + }); + + test('omits the NOT IN clause when excludeRunIds is empty', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([])); + + await getActiveWorkflowRunByPath('/repo/path', { + id: 'child-id', + startedAt: new Date(), + excludeRunIds: [], + }); + + const [query] = mockQuery.mock.calls[0] as [string, unknown[]]; + expect(query).not.toContain('NOT IN'); + }); + }); + + describe('findChildRuns', () => { + test('selects by parent_run_id ordered oldest-first', async () => { + const child = { ...mockWorkflowRun, id: 'child-1', parent_run_id: 'parent-1' }; + mockQuery.mockResolvedValueOnce(createQueryResult([child])); + + const result = await findChildRuns('parent-1'); + + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe('child-1'); + const [query, params] = mockQuery.mock.calls[0] as [string, unknown[]]; + expect(query).toContain('WHERE parent_run_id = $1'); + // The executor's re-entry picks children[children.length - 1] as "most + // recent" — that only holds because this ORDER BY pins oldest-first. + expect(query).toContain('ORDER BY started_at ASC'); + expect(params).toEqual(['parent-1']); + }); + + test('throws on database error', async () => { + mockQuery.mockRejectedValueOnce(new Error('boom')); + + await expect(findChildRuns('parent-1')).rejects.toThrow( + 'Failed to find child workflow runs: boom' + ); + }); + }); + + describe('getRunAncestry', () => { + const runRow = (id: string, parentRunId: string | null) => ({ + ...mockWorkflowRun, + id, + parent_run_id: parentRunId, + }); + + test('walks parent_run_id to the root, nearest ancestor first', async () => { + // child -> parent -> root (each lookup is one getWorkflowRun query). + mockQuery.mockResolvedValueOnce(createQueryResult([runRow('child', 'parent')])); + mockQuery.mockResolvedValueOnce(createQueryResult([runRow('parent', 'root')])); + mockQuery.mockResolvedValueOnce(createQueryResult([runRow('root', null)])); + + const result = await getRunAncestry('child'); + + expect(result.map(r => r.id)).toEqual(['parent', 'root']); + }); + + test('stops on cyclic parent data instead of looping forever', async () => { + // child -> parent -> child (hand-edited/corrupt DB). + mockQuery.mockResolvedValueOnce(createQueryResult([runRow('child', 'parent')])); + mockQuery.mockResolvedValueOnce(createQueryResult([runRow('parent', 'child')])); + + const result = await getRunAncestry('child'); + + expect(result.map(r => r.id)).toEqual(['parent']); + }); + + test('ends the chain at a deleted parent (ON DELETE SET NULL orphan)', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([runRow('child', 'gone')])); + mockQuery.mockResolvedValueOnce(createQueryResult([])); + + const result = await getRunAncestry('child'); + + expect(result).toEqual([]); + }); }); describe('listWorkflowRuns', () => { @@ -775,6 +949,8 @@ describe('workflows database', () => { describe('resumeWorkflowRun', () => { test('updates run to running, clears completed_at, and returns updated row', async () => { const updatedRun = { ...mockWorkflowRun, status: 'running' as const, completed_at: null }; + // Pre-CAS read of the metadata about to be cleared (no prior error here) + mockQuery.mockResolvedValueOnce(createQueryResult([{ metadata: {} }])); // UPDATE query returns rowCount 1 mockQuery.mockResolvedValueOnce(createQueryResult([], 1)); // SELECT query returns the updated row @@ -784,17 +960,27 @@ describe('workflows database', () => { expect(result.status).toBe('running'); expect(result.completed_at).toBeNull(); - // First call: UPDATE - const [updateQuery, updateParams] = mockQuery.mock.calls[0] as [string, unknown[]]; + // First call: the row-pinning read of the error the CAS is about to clear. + const [priorQuery, priorParams] = mockQuery.mock.calls[0] as [string, unknown[]]; + expect(priorQuery).toContain('SELECT metadata'); + // Postgres row lock — without it the value read is not guaranteed to be the + // value the CAS clears, so the preserved error could be stale (#2348). + expect(priorQuery).toContain('FOR UPDATE'); + expect(priorParams).toEqual(['workflow-run-123']); + // Second call: UPDATE + const [updateQuery, updateParams] = mockQuery.mock.calls[1] as [string, unknown[]]; expect(updateQuery).toContain("status = 'running'"); expect(updateQuery).toContain('completed_at = NULL'); + expect(updateQuery).toContain('metadata = metadata || $3::jsonb'); // $1 = id, $2 = ORPHAN_RESUME_STALE_DAYS. The day param MUST be bound or the // CAS predicate's `< $2 days` references an unbound placeholder (PR #1830 C1). - expect(updateParams).toEqual(['workflow-run-123', 1]); - // Second call: SELECT - const [selectQuery, selectParams] = mockQuery.mock.calls[1] as [string, unknown[]]; + expect(updateParams).toEqual(['workflow-run-123', 1, JSON.stringify({ error: null })]); + // Third call: SELECT + const [selectQuery, selectParams] = mockQuery.mock.calls[2] as [string, unknown[]]; expect(selectQuery).toContain('SELECT *'); expect(selectParams).toEqual(['workflow-run-123']); + // No prior error → no audit event (only three statements ran). + expect(mockQuery.mock.calls).toHaveLength(3); }); test('refreshes started_at to NOW so resumed row competes fairly in the path-lock tiebreaker', async () => { @@ -802,6 +988,7 @@ describe('workflows database', () => { // hours-old) started_at and sorts ahead of any currently-active holder // in the older-wins tiebreaker — slipping past the lock and causing // two active workflows on the same working_path. + mockQuery.mockResolvedValueOnce(createQueryResult([{ metadata: {} }])); mockQuery.mockResolvedValueOnce(createQueryResult([], 1)); mockQuery.mockResolvedValueOnce( createQueryResult([{ ...mockWorkflowRun, status: 'running' as const }]) @@ -809,7 +996,7 @@ describe('workflows database', () => { await resumeWorkflowRun('workflow-run-123'); - const [updateQuery] = mockQuery.mock.calls[0] as [string, unknown[]]; + const [updateQuery] = mockQuery.mock.calls[1] as [string, unknown[]]; expect(updateQuery).toContain('started_at = NOW()'); }); @@ -817,6 +1004,7 @@ describe('workflows database', () => { // The flip to 'running' must only match a row that is still resumable — // failed/paused, or a stale 'running' orphan — so two concurrent resumers // can't both win and double-claim the worktree. + mockQuery.mockResolvedValueOnce(createQueryResult([{ metadata: {} }])); mockQuery.mockResolvedValueOnce(createQueryResult([], 1)); mockQuery.mockResolvedValueOnce( createQueryResult([{ ...mockWorkflowRun, status: 'running' as const }]) @@ -824,15 +1012,60 @@ describe('workflows database', () => { await resumeWorkflowRun('workflow-run-123'); - const [updateQuery, updateParams] = mockQuery.mock.calls[0] as [string, unknown[]]; + const [updateQuery, updateParams] = mockQuery.mock.calls[1] as [string, unknown[]]; expect(updateQuery).toContain("status IN ('failed', 'paused')"); expect(updateQuery).toContain("status = 'running' AND"); // The stale-orphan arm references $2 — it MUST be bound to the day count. expect(updateQuery).toContain('$2'); - expect(updateParams).toEqual(['workflow-run-123', 1]); + expect(updateParams).toEqual(['workflow-run-123', 1, JSON.stringify({ error: null })]); + }); + + test('preserves the cleared error as a workflow_resumed event (CAS winner only)', async () => { + // The resume clears metadata.error, which for a SIGTERM-killed CLI run is + // the ONLY record that the run ever failed — no workflow_failed/node_failed + // event is written on that path (#2348). The clear must not lose it. + mockQuery.mockResolvedValueOnce( + createQueryResult([{ metadata: { error: 'Process terminated (SIGTERM)' } }]) + ); + mockQuery.mockResolvedValueOnce(createQueryResult([], 1)); // CAS wins + mockQuery.mockResolvedValueOnce(createQueryResult([])); // event INSERT + mockQuery.mockResolvedValueOnce( + createQueryResult([{ ...mockWorkflowRun, status: 'running' as const }]) + ); + + await resumeWorkflowRun('workflow-run-123'); + + const [eventQuery, eventParams] = mockQuery.mock.calls[2] as [string, unknown[]]; + expect(eventQuery).toContain('INSERT INTO remote_agent_workflow_events'); + // [id, workflow_run_id, event_type, step_index, step_name, data] + expect(eventParams[1]).toBe('workflow-run-123'); + expect(eventParams[2]).toBe('workflow_resumed'); + expect(eventParams[5]).toBe(JSON.stringify({ error: 'Process terminated (SIGTERM)' })); + }); + + test('writes no event when the CAS loses, even though an error was read', async () => { + // A concurrent resumer already flipped the row: this caller read the error + // but its UPDATE matched nothing, so it must write NOTHING at all — + // otherwise a lost race still emits an audit event for a clear it never did. + mockQuery.mockResolvedValueOnce( + createQueryResult([{ metadata: { error: 'Process terminated (SIGTERM)' } }]) + ); + mockQuery.mockResolvedValueOnce(createQueryResult([], 0)); // CAS loses + mockQuery.mockResolvedValueOnce(createQueryResult([{ status: 'running' }])); // probe + + await expect(resumeWorkflowRun('workflow-run-123')).rejects.toThrow( + WorkflowNotResumableError + ); + + const inserts = mockQuery.mock.calls.filter(([sql]) => + String(sql).includes('INSERT INTO remote_agent_workflow_events') + ); + expect(inserts).toHaveLength(0); }); test('throws when no row matched and the run is gone (not found)', async () => { + // Pre-CAS read finds nothing (row already deleted) + mockQuery.mockResolvedValueOnce(createQueryResult([])); // UPDATE returns rowCount 0 mockQuery.mockResolvedValueOnce(createQueryResult([], 0)); // Probe SELECT finds no row → deleted @@ -844,6 +1077,7 @@ describe('workflows database', () => { }); test('throws "not resumable" when the run was concurrently activated (CAS miss)', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ metadata: {} }])); // UPDATE matches nothing because the row is already 'running' mockQuery.mockResolvedValueOnce(createQueryResult([], 0)); // Probe SELECT reveals the current status @@ -855,6 +1089,7 @@ describe('workflows database', () => { }); test('throws on database error during the disambiguation probe', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ metadata: {} }])); mockQuery.mockResolvedValueOnce(createQueryResult([], 0)); // UPDATE matched nothing mockQuery.mockRejectedValueOnce(new Error('Connection lost')); // probe fails @@ -864,6 +1099,17 @@ describe('workflows database', () => { }); test('throws on database error during UPDATE', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ metadata: {} }])); + mockQuery.mockRejectedValueOnce(new Error('Lock timeout')); + + await expect(resumeWorkflowRun('workflow-run-123')).rejects.toThrow( + 'Failed to resume workflow run: Lock timeout' + ); + }); + + test('throws on database error during the pre-CAS read', async () => { + // The read shares the CAS's try/catch — a failure there must surface as the + // same resume error, and the transaction rolls back with nothing written. mockQuery.mockRejectedValueOnce(new Error('Lock timeout')); await expect(resumeWorkflowRun('workflow-run-123')).rejects.toThrow( @@ -872,6 +1118,7 @@ describe('workflows database', () => { }); test('throws on database error during SELECT after UPDATE', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ metadata: {} }])); // UPDATE succeeds mockQuery.mockResolvedValueOnce(createQueryResult([], 1)); // SELECT fails @@ -883,6 +1130,7 @@ describe('workflows database', () => { }); test('throws when row vanishes between UPDATE and SELECT', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ metadata: {} }])); // UPDATE succeeds (rowCount 1) mockQuery.mockResolvedValueOnce(createQueryResult([], 1)); // SELECT returns nothing (row deleted between statements) diff --git a/packages/core/src/db/workflows.ts b/packages/core/src/db/workflows.ts index 2a491fd7e1..0621fe0571 100644 --- a/packages/core/src/db/workflows.ts +++ b/packages/core/src/db/workflows.ts @@ -73,6 +73,41 @@ function resumableStatusClause(dialect: SqlDialect, dayParamIndex: number): stri return `(status IN ('failed', 'paused') OR (status = 'running' AND (${staleOrphan})))`; } +/** + * `FOR UPDATE` on Postgres, empty on SQLite (which has no such syntax and does + * not need it — the adapter serializes transactions on one connection, and a + * cross-process writer that commits between our read and our write makes the + * deferred BEGIN's read→write upgrade fail with SQLITE_BUSY rather than let a + * stale snapshot through). Used by resumeWorkflowRun to pin the row across its + * read-then-CAS pair so the value it reads is the value the CAS acts on. + * Dialect-branched here rather than in SqlDialect: this is the only caller, and + * the branch mirrors unresolvedGateClause's local getDatabaseType() check. + */ +function rowLockClause(): string { + return getDatabaseType() === 'postgresql' ? ' FOR UPDATE' : ''; +} + +/** + * Extract a non-empty `metadata.error` string from a raw column value, or null + * when there is nothing worth preserving. SQLite stores metadata as JSON TEXT + * and Postgres returns a parsed object (same split normalizeWorkflowRun handles), + * so both shapes are accepted; absent / null / non-string / empty / unparseable + * all collapse to null. + */ +function readMetadataError(raw: unknown): string | null { + let metadata: unknown = raw; + if (typeof metadata === 'string') { + try { + metadata = JSON.parse(metadata); + } catch { + return null; + } + } + if (typeof metadata !== 'object' || metadata === null) return null; + const error = (metadata as Record).error; + return typeof error === 'string' && error !== '' ? error : null; +} + /** * SQL predicate matching a run whose approval gate is still OPEN: the row is * 'paused' AND metadata.approval.resolved is JSON null or absent. Dialect-aware @@ -228,6 +263,7 @@ export async function createWorkflowRun(data: { working_path?: string; parent_conversation_id?: string; user_id?: string; + parent_run_id?: string; }): Promise { // Serialize metadata with validation to catch circular references early let metadataJson: string; @@ -262,8 +298,8 @@ export async function createWorkflowRun(data: { try { const result = await pool.query( `INSERT INTO remote_agent_workflow_runs - (workflow_name, conversation_id, codebase_id, user_message, metadata, working_path, parent_conversation_id, user_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + (workflow_name, conversation_id, codebase_id, user_message, metadata, working_path, parent_conversation_id, user_id, parent_run_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [ data.workflow_name, @@ -274,6 +310,7 @@ export async function createWorkflowRun(data: { data.working_path ?? null, data.parent_conversation_id ?? null, data.user_id ?? null, + data.parent_run_id ?? null, ] ); const row = result.rows[0]; @@ -419,20 +456,24 @@ export async function getPausedWorkflowRun(conversationId: string): Promise { const isPostgres = getDatabaseType() === 'postgresql'; const stalePendingCutoff = isPostgres @@ -446,9 +487,24 @@ export async function getActiveWorkflowRunByPath( 'working_path = $1', `(status IN ('running', 'paused') OR (status = 'pending' AND started_at > ${stalePendingCutoff}))`, ]; + let selfIdParam: string | undefined; if (self !== undefined) { params.push(self.id); - clauses.push(`id != $${String(params.length)}`); + // Captured at push time — the tiebreaker below must reference THIS + // placeholder, and excludeRunIds params may land in between. + selfIdParam = `$${String(params.length)}`; + clauses.push(`id != ${selfIdParam}`); + } + // Exclude the caller's ancestor chain (#2121 Phase 2): a `workflow:` sub-run + // shares the parent's checkout, so the parent's own running/paused row on this + // path must NOT count as a lock against the child. Each id is a separate + // placeholder so both dialects bind positionally (no array binding). + if (self?.excludeRunIds && self.excludeRunIds.length > 0) { + const placeholders = self.excludeRunIds.map(id => { + params.push(id); + return `$${String(params.length)}`; + }); + clauses.push(`id NOT IN (${placeholders.join(', ')})`); } if (self !== undefined) { // Older-wins tiebreaker. (started_at, id) is a total order so both @@ -469,7 +525,11 @@ export async function getActiveWorkflowRunByPath( // comparison via SQLite's date/time functions. params.push(self.startedAt.toISOString()); const startedAtParam = `$${String(params.length)}`; - const idParam = `$${String(params.length - 1)}`; + // NOT params.length - 1: excludeRunIds placeholders may sit between the self + // id and startedAt — a positional back-reference here once pointed the id + // tiebreak at an ancestor id instead of self (caught by the SQL-shape test). + // selfIdParam is always set when `self` is (same guard above). + const idParam = selfIdParam ?? '$2'; const colExpr = isPostgres ? 'started_at' : 'datetime(started_at)'; const paramExpr = isPostgres ? `${startedAtParam}::timestamptz` : `datetime(${startedAtParam})`; clauses.push(`(${colExpr} < ${paramExpr} OR (${colExpr} = ${paramExpr} AND id < ${idParam}))`); @@ -491,6 +551,58 @@ export async function getActiveWorkflowRunByPath( } } +/** + * Find every run spawned as a child of `parentRunId` (#2121 Phase 2), oldest + * first. Callers filter further by `metadata.parent_node_id` (a parent may have + * several `workflow:` nodes) or by status (the abandon cascade cancels + * non-terminal children). + */ +export async function findChildRuns(parentRunId: string): Promise { + try { + const result = await pool.query( + 'SELECT * FROM remote_agent_workflow_runs WHERE parent_run_id = $1 ORDER BY started_at ASC', + [parentRunId] + ); + return result.rows.map(row => normalizeWorkflowRun(row)); + } catch (error) { + const err = error as Error; + getLog().error({ err, parentRunId }, 'db.workflow_run_find_children_failed'); + throw new Error(`Failed to find child workflow runs: ${err.message}`); + } +} + +/** + * Safety cap on the `parent_run_id` walk. The load-time and runtime cycle guards + * prevent creating a cyclic run tree, but a hand-edited DB must never hang the + * walk — deeper than the runtime depth cap (5) so a legitimately deep-but-bounded + * tree still resolves fully. + */ +const MAX_RUN_ANCESTRY_DEPTH = 32; + +/** + * Walk `parent_run_id` from `runId` up to the root, returning ancestors nearest + * first (the immediate parent at index 0). Depth-capped and cycle-safe (a + * repeated id stops the walk). Used by the runtime cycle guard and to build the + * path-lock exclusion set for a shared-checkout sub-run. + */ +export async function getRunAncestry(runId: string): Promise { + const ancestors: WorkflowRun[] = []; + const seen = new Set([runId]); + let current = await getWorkflowRun(runId); + let depth = 0; + while (current?.parent_run_id && depth < MAX_RUN_ANCESTRY_DEPTH) { + const parentId = current.parent_run_id; + if (seen.has(parentId)) break; // cyclic data — stop rather than loop forever + const parent = await getWorkflowRun(parentId); + if (!parent) break; // parent deleted (ON DELETE SET NULL orphan) — chain ends + ancestors.push(parent); + seen.add(parentId); + current = parent; + depth++; + } + return ancestors; +} + export async function findLatestRunByWorkingPath(workingPath: string): Promise { try { const result = await pool.query( @@ -562,6 +674,15 @@ export async function findResumableRun( * Used by the orchestrator (all platforms) to detect approved runs that need foreground resume * on the prior run's worktree. Codebase scope prevents cross-project resume on persistent * chat conversation IDs (Telegram chat_id, Slack thread, etc.). + * + * Ordering is status-first, then recency WITHIN a status — not bare recency. The two statuses + * are not interchangeable candidates for the caller: a `paused` run is an open gate that is + * legitimately waiting and gets hydrated and resumed, while a `failed` one is deliberately gated + * behind an explicit user prompt first (#1549). Ordering purely by `started_at` therefore lets a + * newer failure shadow an older open gate, and approving that gate resumes nothing. + * + * Contrast with getActiveWorkflowRunByPath below, which sorts the opposite way (older-wins) — + * it answers "who took the path lock first", a different question. */ export async function findResumableRunByParentConversation( workflowName: string, @@ -575,7 +696,7 @@ export async function findResumableRunByParentConversation( AND parent_conversation_id = $2 AND codebase_id = $3 AND status IN ('failed', 'paused') - ORDER BY started_at DESC + ORDER BY CASE WHEN status = 'paused' THEN 0 ELSE 1 END, started_at DESC LIMIT 1`, [workflowName, parentConversationId, codebaseId] ); @@ -597,7 +718,7 @@ export async function resumeWorkflowRun(id: string): Promise { // Split into UPDATE + SELECT to support both PostgreSQL and SQLite // (SQLite does not support RETURNING on UPDATE statements) // Each phase has its own try/catch to avoid string-sniffing own errors in a shared catch. - let updateResult: Awaited>; + let updateResult: { rowCount: number }; try { // Refresh started_at to NOW so the resumed row competes fairly with // currently-active rows in getActiveWorkflowRunByPath's older-wins @@ -619,15 +740,49 @@ export async function resumeWorkflowRun(id: string): Promise { // Resume + a chat re-dispatch, or the lock-less CLI path) could both flip // the same run to 'running' and double-claim the worktree. The day param is // bound at $2 (ORPHAN_RESUME_STALE_DAYS), matching findResumableRun's bind. - updateResult = await pool.query( - `UPDATE remote_agent_workflow_runs - SET status = 'running', - completed_at = NULL, - started_at = ${dialect.now()}, - last_activity_at = ${dialect.now()} - WHERE id = $1 AND ${resumableStatusClause(dialect, 2)}`, - [id, ORPHAN_RESUME_STALE_DAYS] - ); + // + // The CAS also clears `metadata.error` so a run that fails, is resumed, and + // then completes doesn't keep rendering its old failure (#2329). Because + // metadata is the ONLY place some failures are recorded — the CLI's SIGTERM + // handler calls failWorkflowRun and writes no event (#2348) — the error being + // cleared is first preserved as a `workflow_resumed` event, in the SAME + // transaction as the clear, so the audit trail can never lose it. The read, + // the CAS and the event INSERT are one transaction (mirroring + // resolveApprovalGate, #2146): the row is pinned by rowLockClause() so the + // value read is the value cleared, and the event is written ONLY by the + // caller whose CAS matched — a losing concurrent resumer writes nothing. + // Read-then-UPDATE rather than UPDATE…RETURNING because the SQLite adapter + // rejects RETURNING on UPDATE and points at exactly this pattern. + updateResult = await getDatabase().withTransaction(async query => { + const priorRows = await query<{ metadata: unknown }>( + `SELECT metadata FROM remote_agent_workflow_runs WHERE id = $1${rowLockClause()}`, + [id] + ); + const clearedError = readMetadataError(priorRows.rows[0]?.metadata); + + const result = await query( + `UPDATE remote_agent_workflow_runs + SET status = 'running', + completed_at = NULL, + started_at = ${dialect.now()}, + last_activity_at = ${dialect.now()}, + metadata = ${dialect.jsonMerge('metadata', 3)} + WHERE id = $1 AND ${resumableStatusClause(dialect, 2)}`, + [id, ORPHAN_RESUME_STALE_DAYS, JSON.stringify({ error: null })] + ); + + const rowCount = result.rowCount; + if (rowCount > 0 && clearedError !== null) { + // Same `{ error }` payload shape workflow_failed uses, so every consumer + // that already reads an error off a workflow_* event keeps working. + await insertWorkflowEvent(query, { + workflow_run_id: id, + event_type: 'workflow_resumed', + data: { error: clearedError }, + }); + } + return { rowCount }; + }); } catch (error) { const err = error as Error; getLog().error({ err, workflowRunId: id }, 'db.workflow_run_resume_failed'); @@ -888,6 +1043,7 @@ export async function pauseWorkflowRun( // null as absent (`!= null`). completionSignaled: approvalContext.completionSignaled ?? null, signaledOutput: approvalContext.signaledOutput ?? null, + signaledTokens: approvalContext.signaledTokens ?? null, onRejectPrompt: approvalContext.onRejectPrompt ?? null, onRejectMaxAttempts: approvalContext.onRejectMaxAttempts ?? null, captureResponse: approvalContext.captureResponse ?? null, @@ -895,6 +1051,10 @@ export async function pauseWorkflowRun( sessionId: approvalContext.sessionId ?? null, sessionProvider: approvalContext.sessionProvider ?? null, commandSnapshot: approvalContext.commandSnapshot ?? null, + // #2121 Phase 2: the child_workflow gate's target child. Reset explicitly + // like every other optional sub-field so a prior gate's childRunId can't + // leak into a later non-child gate via SQLite json_patch deep-merge. + childRunId: approvalContext.childRunId ?? null, }, // Fold caller-supplied run-level metadata (e.g. `pending_writeback`) into the // SAME atomic write so there is no window where the run is paused without it (M3). diff --git a/packages/core/src/github-auth/credential-helper-install.test.ts b/packages/core/src/github-auth/credential-helper-install.test.ts new file mode 100644 index 0000000000..7d86b58feb --- /dev/null +++ b/packages/core/src/github-auth/credential-helper-install.test.ts @@ -0,0 +1,132 @@ +/** + * Direct coverage for installCredentialHelper. + * + * This behaviour used to be asserted only indirectly, from the GitHub adapter's + * App-mode test, through the `git config` call the helper makes. That forced a + * unit test in @archon/adapters to run the real copy into the developer's + * `~/.archon/bin/` (#2305). The write is legitimate — it is what the function is + * for — so it is tested here instead, at the layer that owns it, against an + * ARCHON_HOME this file creates and removes itself. + * + * `execFileAsync` is stubbed with spyOn (reversible; no mock.module pollution) + * so no real `git` process runs and the registered helper path can be asserted + * exactly. + * + * KNOWN UNCOVERED BRANCH — the `skipped` discriminant + * (`reason: 'source-script-not-on-disk'`, the compiled-binary path where + * `scripts/` does not ship). Reaching it requires `existsSync(sourceScriptPath())` + * to be false, and `sourceScriptPath()` resolves from `import.meta.dir` with no + * injection seam. The only ways in are (a) `mock.module('node:fs', …)`, which is + * process-global and irreversible in Bun and would poison every other test in + * this package's batch, or (b) adding a parameter to production code purely for + * testability. Neither is worth it for a branch whose entire effect is which log + * line the caller emits. Left deliberately uncovered rather than faked. + */ +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { mkdtemp, rm, readFile, writeFile, mkdir, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as git from '@archon/git'; +import { installCredentialHelper } from './credential-helper-install'; + +describe('installCredentialHelper', () => { + let archonHome: string; + let originalArchonHome: string | undefined; + let execSpy: ReturnType>; + + beforeEach(async () => { + originalArchonHome = process.env.ARCHON_HOME; + archonHome = await mkdtemp(join(tmpdir(), 'archon-credhelper-')); + process.env.ARCHON_HOME = archonHome; + execSpy = spyOn(git, 'execFileAsync').mockImplementation(async () => ({ + stdout: '', + stderr: '', + })); + }); + + afterEach(async () => { + execSpy.mockRestore(); + if (originalArchonHome === undefined) { + delete process.env.ARCHON_HOME; + } else { + process.env.ARCHON_HOME = originalArchonHome; + } + await rm(archonHome, { recursive: true, force: true }); + }); + + test('copies the helper into $ARCHON_HOME/bin and registers it on the worktree', async () => { + const result = await installCredentialHelper('/tmp/some-worktree'); + + expect(result.kind).toBe('installed'); + const helperPath = join(archonHome, 'bin', 'git-credential-archon'); + if (result.kind !== 'installed') throw new Error('unreachable'); + expect(result.helperPath).toBe(helperPath); + + // The copy really happened, and it is the real script (not an empty file). + const contents = await readFile(helperPath, 'utf8'); + expect(contents).toContain('git-credential'); + expect(contents.length).toBeGreaterThan(0); + + // Registered under the exact git config key the credential protocol reads. + expect(execSpy).toHaveBeenCalledWith( + 'git', + ['-C', '/tmp/some-worktree', 'config', 'credential.https://github.com.helper', helperPath], + { timeout: 5000 } + ); + }); + + /** + * POSIX ONLY. Windows has no execute permission bit — Node reports 0o666 for + * every regular file there, so `mode & 0o111` is unconditionally 0 and the + * assertion cannot hold. Asserting it unguarded is what turned windows-latest + * red on the first push of #2307; scoping it as its own skipped test makes + * the platform dependency visible in the run output rather than hidden inside + * an `if` in a longer test. + * + * Pins the OUTCOME, not the mechanism: it holds both because copyFileSync + * preserves the source script's 0755 and because of the explicit chmodSync, + * so removing the chmod alone would not fail this. + */ + test.skipIf(process.platform === 'win32')( + 'installed helper is executable (POSIX only)', + async () => { + const result = await installCredentialHelper('/tmp/some-worktree'); + expect(result.kind).toBe('installed'); + + const helperPath = join(archonHome, 'bin', 'git-credential-archon'); + const mode = (await stat(helperPath)).mode & 0o777; + expect(mode & 0o111).not.toBe(0); + } + ); + + test('is idempotent — an existing helper is not overwritten but is re-registered', async () => { + const binDir = join(archonHome, 'bin'); + await mkdir(binDir, { recursive: true }); + const helperPath = join(binDir, 'git-credential-archon'); + await writeFile(helperPath, '#!/bin/sh\n# pre-existing\n', { mode: 0o755 }); + + const result = await installCredentialHelper('/tmp/another-worktree'); + + expect(result.kind).toBe('installed'); + // Copy skipped: the sentinel survives. + expect(await readFile(helperPath, 'utf8')).toContain('pre-existing'); + // Registration still runs — every cloned worktree needs its own config entry. + expect(execSpy).toHaveBeenCalledWith( + 'git', + ['-C', '/tmp/another-worktree', 'config', 'credential.https://github.com.helper', helperPath], + { timeout: 5000 } + ); + }); + + test('returns failed instead of throwing when the git config write fails', async () => { + execSpy.mockImplementation(() => Promise.reject(new Error('not a git repository'))); + + const result = await installCredentialHelper('/tmp/not-a-repo'); + + // Callers treat this as non-fatal and log it; a throw here would abort the + // clone path for an optional convenience. + expect(result.kind).toBe('failed'); + if (result.kind !== 'failed') throw new Error('unreachable'); + expect(result.error.message).toContain('not a git repository'); + }); +}); diff --git a/packages/core/src/handlers/clone.test.ts b/packages/core/src/handlers/clone.test.ts index 76e0fce44e..ae78de5839 100644 --- a/packages/core/src/handlers/clone.test.ts +++ b/packages/core/src/handlers/clone.test.ts @@ -492,6 +492,28 @@ describe('cloneRepository', () => { }); }); + // ── GIT_TERMINAL_PROMPT fail-fast (salvaged from PR #1404, credit @mlnchk) ─ + describe('fail-fast env', () => { + test('passes GIT_TERMINAL_PROMPT=0 to the git clone subprocess', async () => { + mockCreateCodebase.mockResolvedValueOnce(makeCodebase() as ReturnType); + + await cloneRepository('https://github.com/owner/repo'); + + const cloneCall = ( + spyExecFileAsync.mock.calls as [string, string[], { env?: NodeJS.ProcessEnv }][] + ).find(args => args[0] === 'git' && args[1]?.[0] === 'clone'); + expect(cloneCall).toBeDefined(); + const env = cloneCall?.[2]?.env ?? {}; + expect(env.GIT_TERMINAL_PROMPT).toBe('0'); + // The rest of the environment must be inherited, not stripped. On + // Windows the key can be 'Path' — spreading process.env keeps the + // original casing — so locate the path key case-insensitively. + const pathKey = Object.keys(env).find(k => k.toLowerCase() === 'path'); + expect(pathKey).toBeDefined(); + expect(env[pathKey!]).toBe(process.env[pathKey!]); + }); + }); + // ── resolveForgeAuth unit tests ────────────────────────────────────────── describe('resolveForgeAuth', () => { const { resolveForgeAuth } = require('./clone'); diff --git a/packages/core/src/handlers/clone.ts b/packages/core/src/handlers/clone.ts index 1f6ffee718..f9b9cf1da9 100644 --- a/packages/core/src/handlers/clone.ts +++ b/packages/core/src/handlers/clone.ts @@ -381,7 +381,11 @@ export async function cloneRepository(repoUrl: string): Promise } try { - await execFileAsync('git', ['clone', cloneUrl, targetPath]); + // GIT_TERMINAL_PROMPT=0 turns any missing-creds scenario into an + // immediate, readable error instead of a hung stdin credential prompt. + await execFileAsync('git', ['clone', cloneUrl, targetPath], { + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); } catch (error) { const safeErr = sanitizeError(error as Error); throw new Error(`Failed to clone repository: ${safeErr.message}`); diff --git a/packages/core/src/handlers/command-handler.test.ts b/packages/core/src/handlers/command-handler.test.ts index b5b2b2ee9a..8704528793 100644 --- a/packages/core/src/handlers/command-handler.test.ts +++ b/packages/core/src/handlers/command-handler.test.ts @@ -38,6 +38,13 @@ const mockGetWorkflowRun = mock(() => Promise.resolve(null)); const mockResumeWorkflowRun = mock(() => Promise.resolve({ id: 'run-id', status: 'running' })); const mockFailWorkflowRun = mock(() => Promise.resolve()); const mockUpdateWorkflowRun = mock(() => Promise.resolve()); +// /workflow abandon cascade-cancels the sub-run tree (#2121 Phase 2), walking it +// via findChildRuns. This entry is load-bearing: mock.module MERGES over the real +// module rather than replacing the namespace, so an omitted export keeps its REAL +// implementation. While this was missing, every abandon test ran the real +// findChildRuns → pool.query → created and schema-initialised a real SQLite +// database on disk, in a test that reads as fully mocked (#2240). +const mockFindChildRuns = mock(() => Promise.resolve([])); // CAS gate resolvers (#2113) — approve/reject stamp the resolution atomically here // instead of via updateWorkflowRun. resolveAndCancelApprovalGate is the atomic // resolve+cancel for terminal reject outcomes. Default to "won the race". @@ -94,6 +101,7 @@ mock.module('../db/workflows', () => ({ cancelWorkflowRun: mockCancelWorkflowRun, listWorkflowRuns: mockListWorkflowRuns, getWorkflowRun: mockGetWorkflowRun, + findChildRuns: mockFindChildRuns, resumeWorkflowRun: mockResumeWorkflowRun, failWorkflowRun: mockFailWorkflowRun, updateWorkflowRun: mockUpdateWorkflowRun, @@ -1775,6 +1783,13 @@ describe('CommandHandler', () => { expect(result.message).toContain('Abandoned'); expect(result.message).toContain('implement'); expect(mockCancelWorkflowRun).toHaveBeenCalledWith('run-123'); + // The cascade walk must actually run against the mock, not merely be + // survived. cascadeCancelChildren swallows its own errors into a failure + // count, so a cascade that is broken — or one silently talking to a real + // database — still reports "Abandoned"; the only visible tell is this + // warning suffix. Assert both halves so the stub cannot regress unnoticed. + expect(result.message).not.toContain('could not be cancelled'); + expect(mockFindChildRuns).toHaveBeenCalledWith('run-123'); }); test('should reject abandon of already-terminal run', async () => { diff --git a/packages/core/src/handlers/command-handler.ts b/packages/core/src/handlers/command-handler.ts index 5c015109b0..4506803a9c 100644 --- a/packages/core/src/handlers/command-handler.ts +++ b/packages/core/src/handlers/command-handler.ts @@ -802,11 +802,15 @@ async function handleWorkflowCommand( }; } try { - const run = await abandonWorkflow(runId); - return { - success: true, - message: `Abandoned workflow run \`${run.workflow_name}\` (${runId})`, - }; + const { run, cascadeFailures, blockedParentRunId } = await abandonWorkflow(runId); + let message = `Abandoned workflow run \`${run.workflow_name}\` (${runId})`; + if (cascadeFailures > 0) { + message += `\n⚠️ ${String(cascadeFailures)} sub-run(s) could not be cancelled and may still be running — check /workflow status.`; + } + if (blockedParentRunId) { + message += `\n⚠️ Parent run ${blockedParentRunId} was blocked on this sub-run and stays paused. Resume it to fail the node cleanly, or abandon it too.`; + } + return { success: true, message }; } catch (error) { const err = error as Error; getLog().error({ err, runId }, 'cmd.workflow_abandon_failed'); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e7f67cfc99..134654d618 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,10 +39,12 @@ export { getDialect, getDatabaseType, getDbNotificationListener, + getSchemaVersion, closeDatabase, resetDatabase, } from './db/connection'; export type { IDatabase, SqlDialect, DbNotificationListener } from './db/adapters/types'; +export type { SchemaVersionInfo } from './db/schema-version'; // Namespaced db modules for explicit access export * as conversationDb from './db/conversations'; diff --git a/packages/core/src/operations/workflow-operations.test.ts b/packages/core/src/operations/workflow-operations.test.ts index a5149d8f0e..e8b3b97661 100644 --- a/packages/core/src/operations/workflow-operations.test.ts +++ b/packages/core/src/operations/workflow-operations.test.ts @@ -8,6 +8,7 @@ const mockGetWorkflowRun = mock(() => Promise.resolve(null)); const mockListWorkflowRuns = mock(() => Promise.resolve([])); const mockUpdateWorkflowRun = mock(() => Promise.resolve()); const mockCancelWorkflowRun = mock(() => Promise.resolve({ cancelled: true })); +const mockFindChildRuns = mock((): Promise => Promise.resolve([])); // CAS gate resolvers (#2113): default to "won the race". Tests that simulate a // concurrent loser override with mockResolvedValueOnce({ resolved: false }). // resolveApprovalGate = stay-paused resolution (approve, reject stage-rework); @@ -20,6 +21,7 @@ mock.module('../db/workflows', () => ({ listWorkflowRuns: mockListWorkflowRuns, updateWorkflowRun: mockUpdateWorkflowRun, cancelWorkflowRun: mockCancelWorkflowRun, + findChildRuns: mockFindChildRuns, resolveApprovalGate: mockResolveApprovalGate, resolveAndCancelApprovalGate: mockResolveAndCancelApprovalGate, })); @@ -106,6 +108,10 @@ describe('approveWorkflow', () => { mockCreateWorkflowEvent.mockClear(); mockUpdateWorkflowRun.mockClear(); mockResolveApprovalGate.mockClear(); + mockCancelWorkflowRun.mockClear(); + mockCancelWorkflowRun.mockResolvedValue({ cancelled: true }); + mockFindChildRuns.mockClear(); + mockFindChildRuns.mockResolvedValue([]); }); test('approves standard approval gate — writes node_completed + approval_received', async () => { @@ -388,6 +394,28 @@ describe('approveWorkflow', () => { const casEvents = mockResolveApprovalGate.mock.calls[0][2] as Array>; expect(casEvents.every(e => e.event_type !== 'node_completed')).toBe(true); }); + + test('refuses a child_workflow-blocked parent — redirects to the child run, writes nothing', async () => { + const run = makePausedRun({ + metadata: { + approval: { + nodeId: 'implement-qa', + message: 'Blocked on sub-run', + type: 'child_workflow', + childRunId: 'child-run-9', + }, + }, + }); + mockGetWorkflowRun.mockResolvedValueOnce(run); + + await expect(approveWorkflow('run-1')).rejects.toThrow( + /waiting on sub-run child-run-9.*approve child-run-9/i + ); + // Nothing resolved, nothing stamped — a fall-through here would write a bogus + // node_completed for the workflow node and orphan the paused child. + expect(mockResolveApprovalGate).not.toHaveBeenCalled(); + expect(mockCreateWorkflowEvent).not.toHaveBeenCalled(); + }); }); describe('rejectWorkflow', () => { @@ -603,6 +631,28 @@ describe('rejectWorkflow', () => { "Cannot reject run with status 'completed'" ); }); + + test('refuses a child_workflow-blocked parent — redirects to the child run, cancels nothing', async () => { + const run = makePausedRun({ + metadata: { + approval: { + nodeId: 'implement-qa', + message: 'Blocked on sub-run', + type: 'child_workflow', + childRunId: 'child-run-9', + }, + }, + }); + mockGetWorkflowRun.mockResolvedValueOnce(run); + + await expect(rejectWorkflow('run-1')).rejects.toThrow( + /waiting on sub-run child-run-9.*reject child-run-9/i + ); + // A fall-through would cancel the parent and silently orphan the paused child. + expect(mockResolveApprovalGate).not.toHaveBeenCalled(); + expect(mockResolveAndCancelApprovalGate).not.toHaveBeenCalled(); + expect(mockCancelWorkflowRun).not.toHaveBeenCalled(); + }); }); describe('getWorkflowStatus', () => { @@ -667,16 +717,138 @@ describe('abandonWorkflow', () => { mockCancelWorkflowRun.mockImplementation(() => Promise.resolve({ cancelled: true })); mockReclaimContainerEnv.mockClear(); mockReclaimContainerEnv.mockImplementation(() => Promise.resolve()); + mockFindChildRuns.mockClear(); + mockFindChildRuns.mockImplementation(() => Promise.resolve([])); }); test('cancels a non-terminal run', async () => { mockGetWorkflowRun.mockResolvedValueOnce(makePausedRun({ status: 'running' })); - const run = await abandonWorkflow('run-1'); + const { run, cascadeFailures, blockedParentRunId } = await abandonWorkflow('run-1'); expect(run.id).toBe('run-1'); + expect(cascadeFailures).toBe(0); + expect(blockedParentRunId).toBeNull(); expect(mockCancelWorkflowRun).toHaveBeenCalledWith('run-1'); }); + // #2121 Phase 2 (D7): abandoning a parent cascade-cancels its non-terminal + // sub-run descendants (children AND grandchildren), skipping already-terminal ones. + test('cascade-cancels non-terminal sub-run descendants', async () => { + mockGetWorkflowRun.mockResolvedValueOnce(makePausedRun({ status: 'running' })); + // run-1 → [child-a (paused), child-done (completed)]; child-a → [grandchild (running)]. + mockFindChildRuns.mockImplementation((parentId: unknown) => { + if (parentId === 'run-1') { + return Promise.resolve([ + { id: 'child-a', status: 'paused' }, + { id: 'child-done', status: 'completed' }, + ]); + } + if (parentId === 'child-a') { + return Promise.resolve([{ id: 'grandchild', status: 'running' }]); + } + return Promise.resolve([]); + }); + + await abandonWorkflow('run-1'); + + const cancelled = mockCancelWorkflowRun.mock.calls.map(c => c[0]); + expect(cancelled).toContain('run-1'); // the parent itself + expect(cancelled).toContain('child-a'); // non-terminal child + expect(cancelled).toContain('grandchild'); // non-terminal grandchild + expect(cancelled).not.toContain('child-done'); // already terminal — skipped + }); + + // Best-effort resilience: one descendant's cancel throwing must not abort the + // walk — siblings still get cancelled, and the failure count is surfaced. + test('cascade continues past a failing descendant and reports the failure count', async () => { + mockGetWorkflowRun.mockResolvedValueOnce(makePausedRun({ status: 'running' })); + mockFindChildRuns.mockImplementation((parentId: unknown) => { + if (parentId === 'run-1') { + return Promise.resolve([ + { id: 'child-a', status: 'running' }, + { id: 'child-b', status: 'paused' }, + { id: 'child-c', status: 'running' }, + ]); + } + return Promise.resolve([]); + }); + mockCancelWorkflowRun.mockImplementation((id: unknown) => + id === 'child-b' ? Promise.reject(new Error('db blip')) : Promise.resolve({ cancelled: true }) + ); + + const { cascadeFailures } = await abandonWorkflow('run-1'); + + expect(cascadeFailures).toBe(1); + const cancelled = mockCancelWorkflowRun.mock.calls.map(c => c[0]); + expect(cancelled).toContain('child-a'); + expect(cancelled).toContain('child-c'); // sibling AFTER the failure still cancelled + }); + + // S1: an unbounded-deep tree hits the MAX_CASCADE_RUNS cap. Truncation must be + // REPORTED (non-zero cascadeFailures + a log), not silently returned as all-clear — + // otherwise the caller tells the user "abandoned, 0 failures" while descendants live. + test('reports truncation (does not silently stop) when the cascade hits its cap', async () => { + mockGetWorkflowRun.mockResolvedValueOnce(makePausedRun({ status: 'running' })); + // Infinite chain: every run has exactly one new, unique, non-terminal child. Only + // the cap terminates the walk — the test completing at all proves the bound holds. + mockFindChildRuns.mockImplementation((parentId: unknown) => + Promise.resolve([{ id: `${String(parentId)}::c`, status: 'running' }]) + ); + + const { cascadeFailures } = await abandonWorkflow('run-1'); + + // Unreached descendants surface via the failures channel. + expect(cascadeFailures).toBeGreaterThan(0); + // The walk was bounded (never looped forever) — findChildRuns was called a + // finite number of times despite the infinite chain. + expect(mockFindChildRuns.mock.calls.length).toBeLessThanOrEqual(501); + expect(mockFindChildRuns.mock.calls.length).toBeGreaterThan(1); + }); + + // Abandoning a CHILD directly strands a parent paused on it — the op surfaces + // the blocked parent's id so callers can point the user at it. + test('surfaces the parent run id when abandoning a child its parent is blocked on', async () => { + const child = makePausedRun({ + id: 'child-1', + status: 'running', + parent_run_id: 'parent-1', + }); + const parent = makePausedRun({ + id: 'parent-1', + status: 'paused', + metadata: { + approval: { + nodeId: 'sub', + message: 'Blocked on sub-run', + type: 'child_workflow', + childRunId: 'child-1', + }, + }, + }); + mockGetWorkflowRun.mockImplementation((id: unknown) => + Promise.resolve(id === 'child-1' ? child : id === 'parent-1' ? parent : null) + ); + + const { blockedParentRunId } = await abandonWorkflow('child-1'); + expect(blockedParentRunId).toBe('parent-1'); + + // Parent paused on a DIFFERENT child → not blocked on us → null. + (parent.metadata as { approval: { childRunId: string } }).approval.childRunId = 'other-child'; + const second = await abandonWorkflow('child-1'); + expect(second.blockedParentRunId).toBeNull(); + mockGetWorkflowRun.mockReset(); + mockGetWorkflowRun.mockImplementation(() => Promise.resolve(null)); + }); + + // The cascade only runs when OUR cancel won the CAS (`cancelled: true`). + test('does not cascade when the parent cancel loses the race', async () => { + mockGetWorkflowRun.mockResolvedValueOnce(makePausedRun({ status: 'paused' })); + mockCancelWorkflowRun.mockImplementationOnce(() => Promise.resolve({ cancelled: false })); + await abandonWorkflow('run-1'); + // findChildRuns is never consulted (no cascade) when the CAS was lost. + expect(mockFindChildRuns).not.toHaveBeenCalled(); + }); + // M2 — abandoning a CONTAINER run reclaims its container + volume in the SHARED op // (so web/chat/manage_run/Slack, not just the CLI, free the resources immediately). test('reclaims a container run’s env on abandon', async () => { @@ -718,7 +890,7 @@ describe('abandonWorkflow', () => { }) ); mockReclaimContainerEnv.mockImplementationOnce(() => Promise.reject(new Error('docker down'))); - const run = await abandonWorkflow('run-1'); // resolves despite the reclaim throw + const { run } = await abandonWorkflow('run-1'); // resolves despite the reclaim throw expect(run.id).toBe('run-1'); expect(mockCancelWorkflowRun).toHaveBeenCalledWith('run-1'); }); @@ -726,8 +898,10 @@ describe('abandonWorkflow', () => { test('cancels a failed run', async () => { mockGetWorkflowRun.mockResolvedValueOnce(makePausedRun({ status: 'failed' })); - const run = await abandonWorkflow('run-1'); + const { run, cascadeFailures, blockedParentRunId } = await abandonWorkflow('run-1'); expect(run.id).toBe('run-1'); + expect(cascadeFailures).toBe(0); + expect(blockedParentRunId).toBeNull(); expect(mockCancelWorkflowRun).toHaveBeenCalledWith('run-1'); }); diff --git a/packages/core/src/operations/workflow-operations.ts b/packages/core/src/operations/workflow-operations.ts index 308f86fd93..3a8970d638 100644 --- a/packages/core/src/operations/workflow-operations.ts +++ b/packages/core/src/operations/workflow-operations.ts @@ -9,6 +9,7 @@ import { RESUMABLE_WORKFLOW_STATUSES, isApprovalContext, isGateResolved, + isRunBlockedOnChild, } from '@archon/workflows/schemas/workflow-run'; import type { WorkflowRun, @@ -67,6 +68,90 @@ export interface RejectionOperationResult { // Helpers // --------------------------------------------------------------------------- +/** Safety bound on the abandon cascade walk (guards against corrupted run trees). */ +const MAX_CASCADE_RUNS = 500; + +/** + * Cascade-cancel the `workflow:` sub-run tree under `rootId` (#2121 Phase 2 / D7). + * A child sub-run shares the parent's conversation and runs in-process, so + * abandoning the parent must flip every non-terminal DESCENDANT to cancelled — not + * just direct children (a child may itself spawn grandchildren). Cooperative: each + * cancelled run's executor between-layer status poll then aborts it (~10s; there is + * no hard subprocess kill in slice 1). Best-effort — a per-run failure is logged, + * never thrown, so the parent abandon always succeeds; the failure COUNT is + * returned so callers can tell the user part of the tree may still be alive. + */ +async function cascadeCancelChildren(rootId: string): Promise<{ failures: number }> { + const queue: string[] = [rootId]; + const seen = new Set([rootId]); + let processed = 0; + let failures = 0; + while (queue.length > 0 && processed < MAX_CASCADE_RUNS) { + const parentId = queue.shift(); + if (parentId === undefined) break; + processed++; + let children: WorkflowRun[]; + try { + children = await workflowDb.findChildRuns(parentId); + } catch (err) { + getLog().warn({ err, parentId }, 'operations.workflow_abandon_cascade_lookup_failed'); + failures++; + continue; + } + for (const child of children) { + if (seen.has(child.id)) continue; + seen.add(child.id); + queue.push(child.id); // traverse deeper even under an already-terminal child + if (child.status === 'completed' || child.status === 'cancelled') continue; + try { + await workflowDb.cancelWorkflowRun(child.id); + } catch (err) { + getLog().warn( + { err, childId: child.id }, + 'operations.workflow_abandon_cascade_cancel_failed' + ); + failures++; + } + } + } + // Truncation is NOT silent success: if we hit the cap with the queue non-empty, + // an unbounded-deep/wide tree still has live descendants we never reached. Surface + // it via the same `failures` channel (caller reports "part of the tree may still be + // alive") AND a distinct log line, rather than returning a false all-clear. + if (queue.length > 0) { + getLog().warn( + { rootId, cap: MAX_CASCADE_RUNS, unreached: queue.length }, + 'operations.workflow_abandon_cascade_truncated' + ); + failures += queue.length; + } + return { failures }; +} + +/** + * If `run` is a `workflow:` sub-run whose PARENT is currently paused blocked on it, + * return the parent's run id — abandoning the child strands that parent (nothing + * re-fires the auto-resume hook for a terminal-via-abandon child), so callers must + * tell the user to resume (fails the node cleanly) or abandon the parent too. + * Best-effort: lookup failures are logged and read as "no blocked parent". + */ +async function findParentBlockedOn(run: WorkflowRun): Promise { + if (!run.parent_run_id) return null; + try { + const parent = await workflowDb.getWorkflowRun(run.parent_run_id); + // Shared invariant (isRunBlockedOnChild) — same predicate the auto-resume hook + // uses, so the two can't drift if the child_workflow gate shape changes. + if (parent && isRunBlockedOnChild(parent, run.id)) return parent.id; + return null; + } catch (err) { + getLog().warn( + { err, runId: run.id, parentRunId: run.parent_run_id }, + 'operations.workflow_abandon_parent_lookup_failed' + ); + return null; + } +} + async function getRunOrThrow(runId: string, logEvent: string): Promise { let run: WorkflowRun | null; try { @@ -111,6 +196,22 @@ export async function resumeWorkflow(runId: string): Promise { return run; } +export interface AbandonWorkflowResult { + run: WorkflowRun; + /** + * Number of sub-run descendants the cascade failed to cancel (best-effort walk; + * failures are also logged). Non-zero means part of the tree may still be alive. + */ + cascadeFailures: number; + /** + * When the abandoned run was itself a `workflow:` sub-run and its parent is + * paused blocked on it: the parent's run id. Nothing auto-resumes that parent + * (the hook only fires from inside the child's own execution) — the user should + * resume it (fails the node cleanly) or abandon it too. + */ + blockedParentRunId: string | null; +} + /** * Abandon a workflow run (marks it as cancelled). * @@ -119,7 +220,7 @@ export async function resumeWorkflow(runId: string): Promise { * to discard it — hence the inline check here intentionally diverges from that * constant and blocks only the two non-resumable terminal states. */ -export async function abandonWorkflow(runId: string): Promise { +export async function abandonWorkflow(runId: string): Promise { const run = await getRunOrThrow(runId, 'operations.workflow_abandon_lookup_failed'); if (run.status === 'completed' || run.status === 'cancelled') { throw new Error( @@ -137,6 +238,17 @@ export async function abandonWorkflow(runId: string): Promise { ); throw new Error(`Failed to abandon workflow run ${runId}: ${err.message}`); } + // Cascade-cancel the sub-run tree — ONLY when OUR cancel won the CAS (same guard as + // the container reclaim below): a false `cancelled` means a concurrent transition + // already took the run terminal, so its children are not ours to cancel. + let cascadeFailures = 0; + if (cancelled) { + ({ failures: cascadeFailures } = await cascadeCancelChildren(runId)); + } + // Abandoning a CHILD strands a parent paused on it (the auto-resume hook only + // fires from inside the child's own execution) — detect and surface that so the + // caller can point the user at the blocked parent. + const blockedParentRunId = cancelled ? await findParentBlockedOn(run) : null; // M2 — reclaim a container run's container + upper volume immediately, in the SHARED // op so EVERY abandon surface (CLI, web API, chat, manage_run, Slack-cancel) frees the // resources now rather than waiting for the scheduled reaper. Best-effort: a reclaim @@ -162,7 +274,7 @@ export async function abandonWorkflow(runId: string): Promise { getLog().warn({ err, runId }, 'operations.workflow_abandon_container_reclaim_failed'); } } - return run; + return { run, cascadeFailures, blockedParentRunId }; } /** @@ -191,6 +303,19 @@ export async function approveWorkflow( if (!approval?.nodeId) { throw new Error('Workflow run is paused but missing approval context.'); } + if (approval.type === 'child_workflow') { + // A parent blocked on a `workflow:` sub-run has no approvable gate of its + // own — the pause resolves automatically when the child run completes. + // Falling through to the generic branch would stamp a node_completed for the + // parent's workflow node with empty output (the child's real output is then + // discarded on resume) and orphan the still-paused child. Redirect the + // operator to the child run, where the actual gate lives. + throw new Error( + `Run ${runId} is paused waiting on sub-run ${approval.childRunId ?? ''} ` + + `('workflow:' node '${approval.nodeId}'). Approve or reject the child run instead` + + (approval.childRunId ? `: /workflow approve ${approval.childRunId}` : '.') + ); + } if (isGateResolved(approval)) { // Fast-path friendly error for the common (sequential) case. The run stays // 'paused' after a resolution, so the status check alone no longer blocks a @@ -331,6 +456,18 @@ export async function rejectWorkflow( const approval: ApprovalContext | undefined = isApprovalContext(rawApproval) ? rawApproval : undefined; + if (approval?.type === 'child_workflow') { + // Same redirect as approveWorkflow: the parent's pause is not a rejectable + // gate — cancelling the parent here would silently orphan the still-paused + // child run. Reject the child (its own gate) or abandon the parent (which + // cascade-cancels the subtree) instead. + throw new Error( + `Run ${runId} is paused waiting on sub-run ${approval.childRunId ?? ''} ` + + `('workflow:' node '${approval.nodeId}'). Reject the child run instead` + + (approval.childRunId ? `: /workflow reject ${approval.childRunId}` : '.') + + ' To discard the whole tree, abandon this run.' + ); + } if (approval && isGateResolved(approval)) { // Fast-path friendly error, same as approveWorkflow — the run stays 'paused' // after a resolution, so status alone no longer blocks a second reject. The diff --git a/packages/core/src/orchestrator/manage-run-tool.test.ts b/packages/core/src/orchestrator/manage-run-tool.test.ts index f0eef5b564..41893710e4 100644 --- a/packages/core/src/orchestrator/manage-run-tool.test.ts +++ b/packages/core/src/orchestrator/manage-run-tool.test.ts @@ -17,7 +17,13 @@ mock.module('../db/workflows', () => ({ listDashboardRuns: mockListDashboardRuns, })); -const mockAbandon = mock((_id: string) => Promise.resolve({ id: 'r1abcdef', workflow_name: 'wf' })); +const mockAbandon = mock((_id: string) => + Promise.resolve({ + run: { id: 'r1abcdef', workflow_name: 'wf' }, + cascadeFailures: 0, + blockedParentRunId: null, + }) +); const mockApprove = mock((_id: string, _c?: string) => Promise.resolve({ workflowName: 'wf', type: 'approval_gate' as const }) ); @@ -291,7 +297,11 @@ describe('manage_run — destructive confirmation gate', () => { test('cancel with confirm cancels the run using the verified full id', async () => { mockFindByPrefix.mockResolvedValue([makeRun()]); - mockAbandon.mockResolvedValue({ id: 'r1abcdef-1234', workflow_name: 'archon-assist' }); + mockAbandon.mockResolvedValue({ + run: { id: 'r1abcdef-1234', workflow_name: 'archon-assist' }, + cascadeFailures: 0, + blockedParentRunId: null, + }); const tool = buildManageRunTool({ codebaseId: CODEBASE_ID }); const out = await tool.handler({ action: 'cancel', runId: 'r1abcdef', confirm: true }); expect(out).toContain('Cancelled'); diff --git a/packages/core/src/orchestrator/manage-run-tool.ts b/packages/core/src/orchestrator/manage-run-tool.ts index 7457011245..084763cbb0 100644 --- a/packages/core/src/orchestrator/manage-run-tool.ts +++ b/packages/core/src/orchestrator/manage-run-tool.ts @@ -342,8 +342,15 @@ async function handleWrite( } case 'cancel': case 'abandon': { - const cancelled = await abandonWorkflow(id); - return `Cancelled run ${cancelled.id.slice(0, 8)} (${cancelled.workflow_name}).`; + const { run: cancelled, cascadeFailures, blockedParentRunId } = await abandonWorkflow(id); + let msg = `Cancelled run ${cancelled.id.slice(0, 8)} (${cancelled.workflow_name}).`; + if (cascadeFailures > 0) { + msg += ` Warning: ${String(cascadeFailures)} sub-run(s) could not be cancelled and may still be running.`; + } + if (blockedParentRunId) { + msg += ` Parent run ${blockedParentRunId.slice(0, 8)} was blocked on this sub-run and stays paused — resume it to fail the node cleanly, or abandon it too.`; + } + return msg; } case 'approve': { // accept=true forces the finalize path (#2074): no feedback reaches the gate, diff --git a/packages/core/src/orchestrator/orchestrator-agent.test.ts b/packages/core/src/orchestrator/orchestrator-agent.test.ts index e02b9c174f..a0731acebb 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.test.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.test.ts @@ -34,6 +34,10 @@ const mockSyncWorkspace = mock(() => ); // Identity passthrough — strips branded type for test simplicity; empty-string guard not needed here const mockToRepoPath = mock((p: string) => p); +// Remote auto-detection defaults to 'origin' (standard repos) +const mockGetDefaultRemote = mock(() => Promise.resolve('origin' as string | null)); +// Repo config defaults to empty (no worktree.remote configured) +const mockLoadRepoConfig = mock(() => Promise.resolve({} as Record)); const mockGetOrCreateConversation = mock(() => Promise.resolve(null as unknown)); const mockGetCodebase = mock(() => Promise.resolve(null as unknown)); const mockExecuteWorkflow = mock(() => Promise.resolve()); @@ -208,6 +212,7 @@ mock.module('../db/workflow-events', () => ({ mock.module('../config/config-loader', () => ({ loadConfig: mockLoadConfig, + loadRepoConfig: mockLoadRepoConfig, })); const mockGenerateAndSetTitle = mock(() => Promise.resolve()); @@ -255,6 +260,7 @@ mock.module('../utils/worktree-sync', () => ({ })); mock.module('@archon/git', () => ({ + getDefaultRemote: mockGetDefaultRemote, syncWorkspace: mockSyncWorkspace, toRepoPath: mockToRepoPath, toBranchName: mock((b: string) => b), @@ -1065,6 +1071,10 @@ describe('discoverAllWorkflows — remote sync', () => { beforeEach(() => { mockSyncWorkspace.mockClear(); mockToRepoPath.mockClear(); + mockGetDefaultRemote.mockClear(); + mockGetDefaultRemote.mockImplementation(() => Promise.resolve('origin')); + mockLoadRepoConfig.mockClear(); + mockLoadRepoConfig.mockImplementation(() => Promise.resolve({})); mockGetOrCreateConversation.mockReset(); mockGetCodebase.mockReset(); mockListCodebases.mockReset(); @@ -1095,8 +1105,11 @@ describe('discoverAllWorkflows — remote sync', () => { const platform = makePlatform(); await handleMessage(platform, 'conv-1', 'What is the latest commit?'); - // Non-destructive default sync (#1864): 2-arg call, no explicit reset mode. - expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', undefined); + // Non-destructive default sync (#1864): no explicit reset mode, only the + // resolved remote rides in the options. + expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', undefined, { + remote: 'origin', + }); // cwd resolution behavior — scoped chat runs the provider in the repo's // default_cwd (not the workspaces root) and skips ensureArchonWorkspacesPath // — is covered by the 'provider cwd resolution' describe block (issue #1179). @@ -1117,7 +1130,8 @@ describe('discoverAllWorkflows — remote sync', () => { expect(mockSyncWorkspace).toHaveBeenCalledWith( '/home/test/.archon/workspaces/owner/repo/source', - undefined + undefined, + { remote: 'origin' } ); }); @@ -1130,7 +1144,47 @@ describe('discoverAllWorkflows — remote sync', () => { const platform = makePlatform(); await handleMessage(platform, 'conv-1', 'What is the latest commit?'); - expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', 'develop'); + expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', 'develop', { + remote: 'origin', + }); + }); + + test('passes configured worktree.remote through to syncWorkspace', async () => { + const conversation = makeConversation({ codebase_id: 'codebase-1' }); + const codebase = makeCodebaseForSync(); + mockGetOrCreateConversation.mockReturnValueOnce(Promise.resolve(conversation)); + mockGetCodebase.mockReturnValueOnce(Promise.resolve(codebase)); + mockListCodebases.mockReturnValueOnce(Promise.resolve([codebase])); + mockLoadRepoConfig.mockResolvedValueOnce({ worktree: { remote: 'mar' } }); + + const platform = makePlatform(); + await handleMessage(platform, 'conv-1', 'What is the latest commit?'); + + expect(mockSyncWorkspace).toHaveBeenCalledWith( + '/repos/test-repo', + undefined, + expect.objectContaining({ remote: 'mar' }) + ); + // Explicit config wins — auto-detection must not run + expect(mockGetDefaultRemote).not.toHaveBeenCalled(); + }); + + test('auto-detects the remote when worktree.remote is not configured', async () => { + const conversation = makeConversation({ codebase_id: 'codebase-1' }); + const codebase = makeCodebaseForSync(); + mockGetOrCreateConversation.mockReturnValueOnce(Promise.resolve(conversation)); + mockGetCodebase.mockReturnValueOnce(Promise.resolve(codebase)); + mockListCodebases.mockReturnValueOnce(Promise.resolve([codebase])); + mockGetDefaultRemote.mockResolvedValueOnce('upstream'); + + const platform = makePlatform(); + await handleMessage(platform, 'conv-1', 'What is the latest commit?'); + + expect(mockSyncWorkspace).toHaveBeenCalledWith( + '/repos/test-repo', + undefined, + expect.objectContaining({ remote: 'upstream' }) + ); }); test('proceeds without throwing when syncWorkspace rejects', async () => { @@ -1146,7 +1200,9 @@ describe('discoverAllWorkflows — remote sync', () => { await expect( handleMessage(platform, 'conv-1', 'What is the latest commit?') ).resolves.toBeUndefined(); - expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', undefined); + expect(mockSyncWorkspace).toHaveBeenCalledWith('/repos/test-repo', undefined, { + remote: 'origin', + }); }); test('does not call syncWorkspace when conversation has no codebase_id', async () => { @@ -3590,6 +3646,45 @@ describe('per-user AI prefs in chat + tier-fallback nudge', () => { expect(mockGetUserAiPrefsDb).toHaveBeenCalledWith('creator-1'); }); + test("per-user default provider wins over the conversation's stored assistant (#2241 chain)", async () => { + // Chain order: user pref → conversation row (creation-time default). The + // row says claude; the user's personal default assistant must win. + mockGetOrCreateConversation.mockReturnValueOnce( + Promise.resolve(makeConversation({ user_id: 'user-9' } as Partial)) + ); + mockGetUserAiPrefsDb.mockImplementation(async () => ({ defaultProvider: 'codex' })); + mockLogger.debug.mockClear(); + + const platform = makePlatform(); + await handleMessage(platform, 'conv-1', 'Hello'); + + const sendingLog = mockLogger.debug.mock.calls.find(c => c[1] === 'sending_to_ai') as + | [Record, string] + | undefined; + expect(sendingLog).toBeDefined(); + expect(sendingLog?.[0].assistantType).toBe('claude'); + expect(sendingLog?.[0].resolvedAssistantType).toBe('codex'); + }); + + test("without an identity the conversation's stored assistant drives the turn (#2241 chain)", async () => { + // No sender and no creator: the creation-time default on the conversation + // row (resolved from config since #2241) is what executes. + mockGetOrCreateConversation.mockReturnValueOnce( + Promise.resolve(makeConversation({ ai_assistant_type: 'codex' })) + ); + mockLogger.debug.mockClear(); + + const platform = makePlatform(); + await handleMessage(platform, 'conv-1', 'Hello'); + + expect(mockGetUserAiPrefsDb).not.toHaveBeenCalled(); + const sendingLog = mockLogger.debug.mock.calls.find(c => c[1] === 'sending_to_ai') as + | [Record, string] + | undefined; + expect(sendingLog).toBeDefined(); + expect(sendingLog?.[0].resolvedAssistantType).toBe('codex'); + }); + test('structurally invalid stored prefs degrade to config-only (chat still answers)', async () => { mockGetOrCreateConversation.mockReturnValueOnce( Promise.resolve(makeConversation({ user_id: 'user-9' } as Partial)) diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 5d34c4ccbc..a4b2b4559f 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -29,7 +29,14 @@ import { getAgentProvider, getProviderCapabilities } from '@archon/providers'; import { buildManageRunTool } from './manage-run-tool'; import { getArchonWorkspacesPath, ensureArchonWorkspacesPath } from '@archon/paths'; import { syncArchonToWorktree } from '../utils/worktree-sync'; -import { execFileAsync, findRepoRoot, syncWorkspace, toBranchName, toRepoPath } from '@archon/git'; +import { + execFileAsync, + findRepoRoot, + getDefaultRemote, + syncWorkspace, + toBranchName, + toRepoPath, +} from '@archon/git'; import type { WorkspaceSyncResult } from '@archon/git'; import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery'; import { findWorkflow, resolveWorkflowName } from '@archon/workflows/router'; @@ -52,7 +59,7 @@ import { deliverCredential } from '../credentials/delivery'; import { listDecryptedUserProviderCredentials } from '../db/user-provider-key-store'; import { getUserAiPrefs, type UserAiPrefs } from '../db/user-ai-prefs-store'; import { createWorkflowDeps } from '../workflows/store-adapter'; -import { loadConfig } from '../config/config-loader'; +import { loadConfig, loadRepoConfig } from '../config/config-loader'; import type { MergedConfig } from '../config/config-types'; import { generateAndSetTitle } from '../services/title-generator'; import { validateAndResolveIsolation, dispatchBackgroundWorkflow } from './orchestrator'; @@ -981,6 +988,8 @@ interface DiscoverResult { syncError?: string; config?: MergedConfig; codebase?: Codebase | null; + /** Remote name used for the workspace sync (undefined when no sync ran). */ + remote?: string; } /** Discover global + repo-specific workflows, merge by name (repo overrides global) */ @@ -991,6 +1000,7 @@ async function discoverAllWorkflows(conversation: Conversation): Promise ws.workflow); if (workflowErrors.length > 0) { @@ -1368,7 +1387,7 @@ export async function handleMessage( } else if (syncResult?.state === 'diverged' && platform.sendStructuredEvent) { await platform.sendStructuredEvent(conversationId, { type: 'system', - content: `Local source/ has diverged from origin/${syncResult.branch} \u2014 manual merge or rebase needed`, + content: `Local source/ has diverged from ${syncRemote ?? 'origin'}/${syncResult.branch} \u2014 manual merge or rebase needed`, }); } else if ( syncResult?.state === 'in_sync' && @@ -1377,7 +1396,7 @@ export async function handleMessage( ) { await platform.sendStructuredEvent(conversationId, { type: 'system', - content: `Fast-forwarded to origin/${syncResult.branch} \u2014 ${syncResult.previousHead} \u2192 ${syncResult.newHead}`, + content: `Fast-forwarded to ${syncRemote ?? 'origin'}/${syncResult.branch} \u2014 ${syncResult.previousHead} \u2192 ${syncResult.newHead}`, }); } diff --git a/packages/core/src/orchestrator/orchestrator-isolation.test.ts b/packages/core/src/orchestrator/orchestrator-isolation.test.ts index e1b020e84e..ee775eb417 100644 --- a/packages/core/src/orchestrator/orchestrator-isolation.test.ts +++ b/packages/core/src/orchestrator/orchestrator-isolation.test.ts @@ -22,6 +22,18 @@ mock.module('@archon/paths', () => ({ getProjectWorktreesPath: mock( (owner: string, repo: string) => `/home/test/.archon/workspaces/${owner}/${repo}/worktrees` ), + // Required by @archon/git worktree.ts (shared identity resolution, #2227). + parseOwnerRepo: mock((name: string) => { + const parts = name.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; + return { owner: parts[0], repo: parts[1] }; + }), + resolveRepoProjectIdentity: mock((name: string, cwd: string) => { + const parts = name.split('/'); + if (parts.length === 2 && parts[0] && parts[1]) return { owner: parts[0], repo: parts[1] }; + const repo = cwd.split('/').filter(Boolean).pop() ?? ''; + return repo === '' || repo === '.' || repo === '..' ? null : { owner: '_local', repo }; + }), })); // DB mocks diff --git a/packages/core/src/orchestrator/orchestrator.test.ts b/packages/core/src/orchestrator/orchestrator.test.ts index 044105fcd1..4d3ad4744d 100644 --- a/packages/core/src/orchestrator/orchestrator.test.ts +++ b/packages/core/src/orchestrator/orchestrator.test.ts @@ -1041,6 +1041,32 @@ describe('orchestrator-agent handleMessage', () => { // Workflow is still dispatched expect(mockValidateAndResolveIsolation).toHaveBeenCalled(); }); + + test('dispatches workflow when command body arrives after /invoke-workflow detection', async () => { + mockListCodebases.mockResolvedValue([mockCodebase]); + mockDiscoverWorkflows.mockResolvedValue({ workflows: testWorkflows, errors: [] }); + mockFindWorkflow.mockImplementation( + (name: string, workflows: readonly WorkflowDefinition[]) => + workflows.find(w => w.name === name) + ); + + mockClient.sendQuery.mockImplementation(async function* () { + yield { type: 'assistant', content: '/invoke-workflow ' }; + yield { type: 'assistant', content: 'fix-bug ' }; + yield { type: 'assistant', content: '--project test-project' }; + yield { type: 'result', sessionId: 'session-id' }; + }); + + await handleMessage(platform, 'chat-456', 'fix the bug'); + + expect( + platform.sendMessage.mock.calls.some( + ([id, content]) => + id === 'chat-456' && typeof content === 'string' && content.includes('/invoke-workflow') + ) + ).toBe(false); + expect(mockValidateAndResolveIsolation).toHaveBeenCalled(); + }); }); // ─── Batch Mode ──────────────────────────────────────────────────────── @@ -1176,6 +1202,26 @@ describe('orchestrator-agent handleMessage', () => { expect(mockValidateAndResolveIsolation).toHaveBeenCalled(); }); + test('batch mode dispatches workflow when command body arrives after detection', async () => { + platform.getStreamingMode.mockReturnValue('batch'); + mockClient.sendQuery.mockImplementation(async function* () { + yield { type: 'assistant', content: '/invoke-workflow ' }; + yield { type: 'assistant', content: 'fix-bug ' }; + yield { type: 'assistant', content: '--project test-project' }; + yield { type: 'result', sessionId: 'session-id' }; + }); + + await handleMessage(platform, 'chat-456', 'fix the bug'); + + expect(mockValidateAndResolveIsolation).toHaveBeenCalled(); + expect( + platform.sendMessage.mock.calls.some( + ([id, content]) => + id === 'chat-456' && typeof content === 'string' && content.includes('/invoke-workflow') + ) + ).toBe(false); + }); + test('passes synthesizedPrompt to workflow dispatch instead of original message', async () => { platform.getStreamingMode.mockReturnValue('batch'); const synthesized = 'Analyze the orchestrator module architecture in detail'; diff --git a/packages/core/src/services/cleanup-service.test.ts b/packages/core/src/services/cleanup-service.test.ts index 2a6d1d7cf0..b2cd984d93 100644 --- a/packages/core/src/services/cleanup-service.test.ts +++ b/packages/core/src/services/cleanup-service.test.ts @@ -357,6 +357,38 @@ describe('cleanup-service', () => { expect(mockUpdateStatus).toHaveBeenCalledWith(envId, 'destroyed'); }); + test('passes configured worktree.remote to provider.destroy for remote branch deletion', async () => { + const envId = 'env-remote-custom'; + + mockGetById.mockResolvedValueOnce({ + id: envId, + codebase_id: 'codebase-123', + workflow_type: 'pr', + workflow_id: '99', + provider: 'worktree', + working_path: '/workspace/worktrees/pr-99', + branch_name: 'feature-branch', + status: 'active', + created_at: new Date(), + created_by_platform: 'github', + metadata: {}, + }); + + mockGetCodebase.mockResolvedValueOnce({ + id: 'codebase-123', + name: 'test-repo', + default_cwd: '/workspace/repo', + }); + mockLoadRepoConfig.mockResolvedValueOnce({ worktree: { remote: 'upstream' } }); + + await removeEnvironment(envId, { deleteRemoteBranch: true }); + + expect(mockDestroy).toHaveBeenCalledWith( + '/workspace/worktrees/pr-99', + expect.objectContaining({ deleteRemoteBranch: true, remote: 'upstream' }) + ); + }); + test('does not pass deleteRemoteBranch when not specified', async () => { const envId = 'env-no-remote-delete'; @@ -958,6 +990,9 @@ describe('getWorktreeStatusBreakdown', () => { expect(breakdown.total).toBe(4); expect(breakdown.merged).toBe(1); + // No worktree.remote configured — default-branch detection gets undefined + // (getDefaultBranch falls back to 'origin' internally) + expect(mockGetDefaultBranch).toHaveBeenCalledWith('/workspace/repo', undefined); expect(breakdown.stale).toBe(1); // env-2 is stale (30 days), env-4 is Telegram so not counted as stale expect(breakdown.active).toBe(2); // env-3 active, env-4 Telegram (counted as active, not stale) }); @@ -985,7 +1020,7 @@ describe('getWorktreeStatusBreakdown', () => { test('returns empty breakdown for empty codebase', async () => { mockListByCodebaseWithAge.mockResolvedValueOnce([]); - // resolveBaseBranch returns 'main' (no config → getDefaultBranch fallback, default from beforeEach) + // resolveRepoGitContext returns 'main' (no config → getDefaultBranch fallback, default from beforeEach) const breakdown = await getWorktreeStatusBreakdown('codebase-1', '/workspace/repo'); @@ -994,6 +1029,15 @@ describe('getWorktreeStatusBreakdown', () => { expect(breakdown.stale).toBe(0); expect(breakdown.active).toBe(0); }); + + test('detects the default branch on the configured worktree.remote', async () => { + mockLoadRepoConfig.mockResolvedValue({ worktree: { remote: 'upstream' } }); + mockListByCodebaseWithAge.mockResolvedValueOnce([]); + + await getWorktreeStatusBreakdown('codebase-1', '/workspace/repo'); + + expect(mockGetDefaultBranch).toHaveBeenCalledWith('/workspace/repo', 'upstream'); + }); }); describe('cleanupMergedWorktrees', () => { @@ -1059,6 +1103,56 @@ describe('cleanupMergedWorktrees', () => { ); }); + test('threads worktree.remote from repo config to getDefaultBranch and getPrState', async () => { + mockLoadRepoConfig.mockResolvedValue({ worktree: { remote: 'upstream' } }); + mockListByCodebase.mockResolvedValueOnce([ + { + id: 'env-remote', + branch_name: 'feature-branch', + working_path: '/workspace/repo/worktrees/feature-branch', + status: 'active', + }, + ]); + // Not merged, not patch-equivalent → falls through to the PR-state check + mockIsBranchMerged.mockResolvedValueOnce(false); + mockIsPatchEquivalent.mockResolvedValueOnce(false); + mockGetPrState.mockResolvedValueOnce('NONE'); + + await cleanupMergedWorktrees('codebase-1', '/workspace/repo'); + + // Default-branch detection uses the configured remote (no baseBranch set) + expect(mockGetDefaultBranch).toHaveBeenCalledWith('/workspace/repo', 'upstream'); + // PR-state lookup receives the configured remote + expect(mockGetPrState).toHaveBeenCalledWith( + 'feature-branch', + '/workspace/repo', + expect.any(Map), + 'upstream' + ); + }); + + test('logs a warn before skipping an environment when the merge check fails', async () => { + mockListByCodebase.mockResolvedValueOnce([ + { + id: 'env-flaky', + branch_name: 'flaky-branch', + working_path: '/workspace/repo/worktrees/flaky-branch', + status: 'active', + }, + ]); + mockIsBranchMerged.mockRejectedValueOnce(new Error('network unreachable')); + + const result = await cleanupMergedWorktrees('codebase-1', '/workspace/repo'); + + expect(result.skipped).toEqual([ + { branchName: 'flaky-branch', reason: 'merge check failed: network unreachable' }, + ]); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ branchName: 'flaky-branch', repoPath: '/workspace/repo' }), + 'cleanup.merge_check_failed' + ); + }); + test('skips merged branches with uncommitted changes', async () => { mockListByCodebase.mockResolvedValueOnce([ { @@ -1371,7 +1465,7 @@ describe('resolveBaseBranch via runScheduledCleanup (issue #1419)', () => { await runScheduledCleanup(); - expect(mockGetDefaultBranch).toHaveBeenCalledWith('/workspace/mainrepo'); + expect(mockGetDefaultBranch).toHaveBeenCalledWith('/workspace/mainrepo', undefined); expect(mockIsBranchMerged).toHaveBeenCalledWith('/workspace/mainrepo', 'feature/bar', 'main'); }); @@ -1401,7 +1495,7 @@ describe('resolveBaseBranch via runScheduledCleanup (issue #1419)', () => { await runScheduledCleanup(); - expect(mockGetDefaultBranch).toHaveBeenCalledWith('/workspace/repo3'); + expect(mockGetDefaultBranch).toHaveBeenCalledWith('/workspace/repo3', undefined); }); }); diff --git a/packages/core/src/services/cleanup-service.ts b/packages/core/src/services/cleanup-service.ts index 60ce620980..6b38cfc097 100644 --- a/packages/core/src/services/cleanup-service.ts +++ b/packages/core/src/services/cleanup-service.ts @@ -35,17 +35,27 @@ function getLog(): ReturnType { return cachedLog; } -// Resolve the base branch for a repo, preferring worktree.baseBranch from -// .archon/config.yaml before falling back to runtime git detection. Repos -// that use 'master' as default and don't have origin/HEAD set will fail -// getDefaultBranch — reading the config first avoids that error. -async function resolveBaseBranch(repoPath: RepoPath, cwd: string): Promise { +/** Git context for a repo's cleanup operations, resolved from repo config. */ +interface RepoGitContext { + mainBranch: BranchName; + /** Configured remote name (worktree.remote); undefined means 'origin' downstream. */ + remote?: string; +} + +// Resolve the base branch and remote for a repo, preferring worktree.baseBranch / +// worktree.remote from .archon/config.yaml before falling back to runtime git +// detection. Repos that use 'master' as default and don't have /HEAD set +// will fail getDefaultBranch — reading the config first avoids that error. +// loadRepoConfig never throws (returns {} on missing/broken config), so a config +// problem degrades to git detection instead of failing cleanup. +async function resolveRepoGitContext(repoPath: RepoPath, cwd: string): Promise { const repoConfig = await loadRepoConfig(cwd); + const remote = repoConfig.worktree?.remote?.trim() || undefined; const configured = repoConfig.worktree?.baseBranch?.trim(); if (configured) { - return toBranchName(configured); + return { mainBranch: toBranchName(configured), remote }; } - return getDefaultBranch(repoPath); + return { mainBranch: await getDefaultBranch(repoPath, remote), remote }; } // Configuration constants (configurable via env vars) @@ -323,9 +333,16 @@ export async function removeEnvironment( // Get canonical repo path from codebase for branch cleanup let canonicalRepoPath: RepoPath | undefined; + let configuredRemote: string | undefined; if (env.codebase_id) { const codebase = await codebaseDb.getCodebase(env.codebase_id); canonicalRepoPath = codebase?.default_cwd ? toRepoPath(codebase.default_cwd) : undefined; + // Resolve the configured remote only when remote-branch deletion is requested — + // that's the one destroy path that pushes to a remote. + if (options?.deleteRemoteBranch && codebase?.default_cwd) { + const repoConfig = await loadRepoConfig(codebase.default_cwd); + configuredRemote = repoConfig.worktree?.remote?.trim() || undefined; + } } // Check if directory exists before attempting removal @@ -350,6 +367,7 @@ export async function removeEnvironment( branchName: toBranchName(env.branch_name), canonicalRepoPath, deleteRemoteBranch: options?.deleteRemoteBranch, + remote: configuredRemote, }); // Log warnings from partial failures @@ -463,7 +481,7 @@ export async function runScheduledCleanup(): Promise { // Check if branch is merged const mainRepoPath = toRepoPath(env.codebase_default_cwd); - const mainBranch = await resolveBaseBranch(mainRepoPath, env.codebase_default_cwd); + const { mainBranch } = await resolveRepoGitContext(mainRepoPath, env.codebase_default_cwd); const merged = await isBranchMerged( mainRepoPath, toBranchName(env.branch_name), @@ -617,7 +635,7 @@ export async function getWorktreeStatusBreakdown( activeEnvs: [], }; - const mainBranch = await resolveBaseBranch(repoPath, mainRepoPath); + const { mainBranch } = await resolveRepoGitContext(repoPath, mainRepoPath); for (const env of environments) { // Skip Telegram (never shown as stale) @@ -715,7 +733,8 @@ async function isSafeToRemove( branchName: BranchName, mainBranch: BranchName, prStateCache: Map, - includeClosed: boolean + includeClosed: boolean, + remote?: string ): Promise<{ safe: boolean; openPr: boolean }> { // (a) Fast path — fast-forward / merge-commit ancestry if (await isBranchMerged(repoPath, branchName, mainBranch)) { @@ -726,7 +745,7 @@ async function isSafeToRemove( return { safe: true, openPr: false }; } // (c) GitHub PR state - const prState = await getPrState(branchName, repoPath, prStateCache); + const prState = await getPrState(branchName, repoPath, prStateCache, remote); if (prState === 'MERGED') return { safe: true, openPr: false }; if (prState === 'CLOSED') return { safe: includeClosed, openPr: false }; if (prState === 'OPEN') return { safe: false, openPr: true }; @@ -745,7 +764,7 @@ export async function cleanupMergedWorktrees( const result: CleanupOperationResult = { removed: [], skipped: [] }; const environments = await isolationEnvDb.listByCodebase(codebaseId); const repoPath = toRepoPath(mainRepoPath); - const mainBranch = await resolveBaseBranch(repoPath, mainRepoPath); + const { mainBranch, remote } = await resolveRepoGitContext(repoPath, mainRepoPath); const includeClosed = options.includeClosed ?? false; const prStateCache = new Map(); @@ -760,12 +779,19 @@ export async function cleanupMergedWorktrees( branchName, mainBranch, prStateCache, - includeClosed + includeClosed, + remote ); safe = decision.safe; openPr = decision.openPr; } catch (error) { const err = error as Error; + // Log before skipping — silent skips make transient git/network failures + // impossible to debug from the cleanup report alone. + getLog().warn( + { err, branchName: env.branch_name, repoPath: mainRepoPath }, + 'cleanup.merge_check_failed' + ); result.skipped.push({ branchName: env.branch_name, reason: `merge check failed: ${err.message}`, diff --git a/packages/core/src/utils/credential-sanitizer.test.ts b/packages/core/src/utils/credential-sanitizer.test.ts index 19b6d03afb..12d76e77e5 100644 --- a/packages/core/src/utils/credential-sanitizer.test.ts +++ b/packages/core/src/utils/credential-sanitizer.test.ts @@ -30,6 +30,48 @@ describe('credential-sanitizer', () => { const input = 'https://unknown_token@github.com/user/repo'; expect(sanitizeCredentials(input)).toBe('https://[REDACTED]@github.com/user/repo'); }); + + // Salvaged from PR #1404 (credit @mlnchk), adapted to the generalized + // userinfo redaction that preserves the host. + it('should replace GITLAB_TOKEN value in string', () => { + process.env.GITLAB_TOKEN = 'glpat-abc123'; + const input = 'fatal: auth failed using token glpat-abc123'; + const result = sanitizeCredentials(input); + expect(result).not.toContain('glpat-abc123'); + expect(result).toContain('[REDACTED]'); + }); + + it('should replace GITEA_TOKEN value in string', () => { + process.env.GITEA_TOKEN = 'gitea-secret-456'; + const input = 'fatal: auth failed using token gitea-secret-456'; + const result = sanitizeCredentials(input); + expect(result).not.toContain('gitea-secret-456'); + expect(result).toContain('[REDACTED]'); + }); + + it('should sanitize oauth2-style URL credentials on any host', () => { + process.env.GITLAB_TOKEN = ''; // Clear env token so only URL regex matches + const input = + 'fatal: clone failed for https://oauth2:glpat-secret@gitlab.example.com/owner/repo.git'; + const result = sanitizeCredentials(input); + expect(result).not.toContain('glpat-secret'); + expect(result).toContain('https://[REDACTED]@gitlab.example.com/owner/repo.git'); + }); + + it('should sanitize bare-token URL credentials on any host', () => { + const input = 'fatal: unable to access https://gitea-token@gitea.example.com/owner/repo.git'; + const result = sanitizeCredentials(input); + expect(result).not.toContain('gitea-token'); + expect(result).toBe( + 'fatal: unable to access https://[REDACTED]@gitea.example.com/owner/repo.git' + ); + }); + + it('should not alter URLs without embedded credentials', () => { + const input = + 'see https://gitlab.example.com/owner/repo and https://example.com/docs/a@b for details'; + expect(sanitizeCredentials(input)).toBe(input); + }); }); describe('sanitizeError', () => { diff --git a/packages/core/src/utils/credential-sanitizer.ts b/packages/core/src/utils/credential-sanitizer.ts index 71f3f27b66..8ee906ab9c 100644 --- a/packages/core/src/utils/credential-sanitizer.ts +++ b/packages/core/src/utils/credential-sanitizer.ts @@ -3,7 +3,7 @@ * Removes sensitive values from strings to prevent credential leaks */ -const SENSITIVE_ENV_VARS = ['GH_TOKEN', 'GITHUB_TOKEN']; +const SENSITIVE_ENV_VARS = ['GH_TOKEN', 'GITHUB_TOKEN', 'GITLAB_TOKEN', 'GITEA_TOKEN']; function escapeRegExp(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -19,8 +19,13 @@ export function sanitizeCredentials(input: string): string { } } - // Catch any URL-embedded credentials we might have missed - result = result.replace(/https:\/\/[^@\s]+@github\.com/g, 'https://[REDACTED]@github.com'); + // Catch any URL-embedded credentials we might have missed. Since #1658 + // clone URLs can embed tokens on ANY host (oauth2:@gitlab.example.com, + // @gitea.example.com), so redact the whole userinfo (user[:pass]) of + // any scheme://userinfo@host form — the username itself can be the token — + // while keeping scheme and host for debugging. `[^@/\s]+` cannot cross a + // `/`, so URLs without embedded credentials are left untouched. + result = result.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^@/\s]+@/g, '$1[REDACTED]@'); return result; } diff --git a/packages/core/src/workflows/store-adapter.test.ts b/packages/core/src/workflows/store-adapter.test.ts index d3b99b04d9..f36172d6e1 100644 --- a/packages/core/src/workflows/store-adapter.test.ts +++ b/packages/core/src/workflows/store-adapter.test.ts @@ -35,10 +35,15 @@ mock.module('../db/workflows', () => ({ })); const mockCreateWorkflowEvent = mock(() => Promise.resolve()); -const mockGetCompletedDagNodeOutputs = mock(() => Promise.resolve(new Map())); +const mockGetDagResumeSnapshot = mock(() => + Promise.resolve({ + completedNodeOutputs: new Map(), + tokens: { input: 0, output: 0 }, + }) +); mock.module('../db/workflow-events', () => ({ createWorkflowEvent: mockCreateWorkflowEvent, - getCompletedDagNodeOutputs: mockGetCompletedDagNodeOutputs, + getDagResumeSnapshot: mockGetDagResumeSnapshot, })); const mockGetCodebase = mock(() => Promise.resolve(null)); @@ -122,7 +127,7 @@ describe('createWorkflowStore', () => { 'releaseWritebackClaim', 'cancelWorkflowRun', 'createWorkflowEvent', - 'getCompletedDagNodeOutputs', + 'getDagResumeSnapshot', 'getCodebase', 'getCodebaseEnvVars', ]; @@ -160,13 +165,16 @@ describe('createWorkflowStore', () => { ).resolves.toBeUndefined(); }); - test('delegates getCompletedDagNodeOutputs to DB', async () => { - const expected = new Map([['step1', 'output text']]); - mockGetCompletedDagNodeOutputs.mockResolvedValueOnce(expected); + test('delegates getDagResumeSnapshot to DB', async () => { + const expected = { + completedNodeOutputs: new Map([['step1', 'output text']]), + tokens: { input: 40, output: 4 }, + }; + mockGetDagResumeSnapshot.mockResolvedValueOnce(expected); const store = createWorkflowStore(); - const result = await store.getCompletedDagNodeOutputs('run-123'); + const result = await store.getDagResumeSnapshot('run-123'); expect(result).toBe(expected); - expect(mockGetCompletedDagNodeOutputs).toHaveBeenCalledWith('run-123'); + expect(mockGetDagResumeSnapshot).toHaveBeenCalledWith('run-123'); }); test('delegates cancelWorkflowRun to DB', async () => { diff --git a/packages/core/src/workflows/store-adapter.ts b/packages/core/src/workflows/store-adapter.ts index dc4236b8bb..3e669c2e0e 100644 --- a/packages/core/src/workflows/store-adapter.ts +++ b/packages/core/src/workflows/store-adapter.ts @@ -43,6 +43,8 @@ export function createWorkflowStore(): IWorkflowStore { return { createWorkflowRun: workflowDb.createWorkflowRun, getWorkflowRun: workflowDb.getWorkflowRun, + findChildRuns: workflowDb.findChildRuns, + getRunAncestry: workflowDb.getRunAncestry, getActiveWorkflowRunByPath: workflowDb.getActiveWorkflowRunByPath, findResumableRun: workflowDb.findResumableRun, failOrphanedRuns: workflowDb.failOrphanedRuns, @@ -72,7 +74,7 @@ export function createWorkflowStore(): IWorkflowStore { ); } }, - getCompletedDagNodeOutputs: workflowEventDb.getCompletedDagNodeOutputs, + getDagResumeSnapshot: workflowEventDb.getDagResumeSnapshot, getCodebase: codebaseDb.getCodebase, getCodebaseEnvVars: envVarDb.getCodebaseEnvVars, getWorkflowNodeSession: workflowNodeSessionDb.getWorkflowNodeSession, diff --git a/packages/docs-web/astro.config.mjs b/packages/docs-web/astro.config.mjs index a2cbd6c051..9a29c7d2cd 100644 --- a/packages/docs-web/astro.config.mjs +++ b/packages/docs-web/astro.config.mjs @@ -18,6 +18,15 @@ export default defineConfig({ tag: 'script', content: `if(!localStorage.getItem('archon-theme-init')){localStorage.setItem('archon-theme-init','1');localStorage.setItem('starlight-theme','dark');document.documentElement.dataset.theme='dark';}`, }, + { + tag: 'link', + attrs: { + rel: 'llms', + type: 'text/plain', + href: '/llms.txt', + title: 'LLM-optimized documentation index', + }, + }, ], social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/coleam00/Archon' }], editLink: { @@ -63,37 +72,70 @@ export default defineConfig({ 'AI workflow engine -- package your coding workflows as YAML, run them anywhere.', details: `Archon lets you define multi-step AI coding workflows (code review, bug fixes, features) in YAML and run them from CLI, Web UI, Slack, Telegram, GitHub, or Discord. Each workflow runs in an isolated git worktree.`, - // Make llms-small.txt actually small - core concepts only - exclude: [ - 'adapters/community/**', // Community adapters are reference material - 'deployment/**', // Deployment is advanced - 'contributing/**', // Not needed for using Archon - 'reference/security', // Deep reference - 'book/**', // Long-form content - ], + // No exclusions - include all Starlight docs for maximum sitemap coverage + exclude: [], - // Topic-based subsets for selective ingestion + // Topic-based subsets for selective ingestion - cover all major doc sections customSets: [ { label: 'Quick Start', description: 'Essential docs to get running with Archon', paths: ['index', 'getting-started/**'], }, + { + label: 'The Book', + description: 'Tutorials and conceptual guides', + paths: ['book/**'], + }, + { + label: 'Guides', + description: 'How-to guides for workflows, commands, and nodes', + paths: ['guides/**'], + }, { label: 'Adapters', description: 'Platform integrations (GitHub, Slack, Discord, etc.)', paths: ['adapters/**'], }, + { + label: 'Deployment', + description: 'Deployment guides for Docker, cloud, and local setups', + paths: ['deployment/**'], + }, { label: 'Reference', description: 'CLI commands, configuration, and API reference', paths: ['reference/**'], }, + { + label: 'Contributing', + description: 'Contributor guides for developers', + paths: ['contributing/**'], + }, + ], + + // Links to non-Starlight pages in the sitemap + optionalLinks: [ + { + label: 'Home', + url: 'https://archon.diy/', + description: 'Archon homepage', + }, + { + label: 'Roadmap', + url: 'https://archon.diy/roadmap/', + description: 'Project roadmap and planned features', + }, + { + label: 'Workflow Marketplace', + url: 'https://archon.diy/workflows/', + description: 'Browse and discover community workflows', + }, ], // Control ordering - essentials first - promote: ['index', 'getting-started/**', 'guides/first-workflow'], - demote: ['reference/changelog', 'contributing/**'], + promote: ['index', 'getting-started/**', 'guides/authoring-workflows'], + demote: ['contributing/**'], // Aggressive minification for small version minify: { diff --git a/packages/docs-web/package.json b/packages/docs-web/package.json index 773fb5355e..c701537c41 100644 --- a/packages/docs-web/package.json +++ b/packages/docs-web/package.json @@ -1,12 +1,13 @@ { "name": "@archon/docs-web", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "private": true, "scripts": { "dev": "astro dev", "build": "astro build && bun run scripts/normalize-llms-txt.js", - "preview": "astro preview" + "preview": "astro preview", + "type-check": "astro sync && bun x tsc --noEmit" }, "dependencies": { "astro": "^6.1.0", diff --git a/packages/docs-web/public/install b/packages/docs-web/public/install index 86dae124cd..b9d5c12487 100644 --- a/packages/docs-web/public/install +++ b/packages/docs-web/public/install @@ -64,6 +64,14 @@ detect_platform() { ;; esac + # Rosetta reports x86_64 even on Apple Silicon. Ask macOS for the physical + # architecture before selecting a release asset. + if [ "$os" = "darwin" ] \ + && { [ "$arch" = "x86_64" ] || [ "$arch" = "amd64" ]; } \ + && [ "$(sysctl -in sysctl.proc_translated 2>/dev/null || true)" = "1" ]; then + arch="arm64" + fi + case "$arch" in x86_64|amd64) arch="x64" @@ -202,6 +210,16 @@ main() { # Make executable chmod +x "$binary_path" + # Confirm the release can execute before replacing an existing installation. + info "Verifying downloaded binary..." + local version_output + if ! version_output=$("$binary_path" version 2>&1); then + error "Downloaded binary failed its version check:" + echo "$version_output" >&2 + error "Existing installation was left unchanged." + exit 1 + fi + # Install info "Installing to $INSTALL_DIR/$BINARY_NAME..." @@ -221,19 +239,20 @@ main() { success "Installed to $INSTALL_DIR/$BINARY_NAME" - # Verify installation - echo "" - info "Verifying installation..." - local version_output - if version_output=$("$INSTALL_DIR/$BINARY_NAME" version 2>&1); then - echo "$version_output" - success "Installation complete!" - else - warn "Binary installed but version check failed:" - echo "$version_output" - warn "The binary may not work correctly. Please verify manually with: $INSTALL_DIR/$BINARY_NAME version" + # Re-run the version check against the INSTALLED path. The probe above ran on the + # temp download and its output was cached; printing that after `mv` would report + # success without ever executing the file the user will actually invoke — which is + # exactly the "installed fine but won't run" failure #2295 reported. See #2338. + local installed_output + if ! installed_output=$("$INSTALL_DIR/$BINARY_NAME" version 2>&1); then + error "Installed binary failed its version check at $INSTALL_DIR/$BINARY_NAME:" + echo "$installed_output" >&2 + exit 1 fi + echo "$installed_output" + success "Installation complete!" + # Check if in PATH if ! command -v "$BINARY_NAME" >/dev/null 2>&1; then echo "" @@ -251,4 +270,10 @@ main() { echo "" } -main "$@" +# `${BASH_SOURCE[0]:-$0}` — NOT bare `${BASH_SOURCE[0]}`. Under `curl … | bash` the +# script arrives on stdin, where BASH_SOURCE[0] is unbound; with `set -u` (above) +# a bare reference aborts before main() ever runs, so the documented install path +# fails for every user on every platform. See #2338. +if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then + main "$@" +fi diff --git a/packages/docs-web/public/install.ps1 b/packages/docs-web/public/install.ps1 index 3ba6bb316f..20834f6ec5 100644 --- a/packages/docs-web/public/install.ps1 +++ b/packages/docs-web/public/install.ps1 @@ -188,7 +188,7 @@ function Confirm-Checksum { function Add-ToUserPath { param([string]$Dir) $currentPath = [Environment]::GetEnvironmentVariable('Path', 'User') - $pathParts = $currentPath -split ';' | Where-Object { $_ -ne '' } + $pathParts = @($currentPath -split ';' | Where-Object { $_ -ne '' }) if ($Dir -notin $pathParts) { $pathParts += $Dir diff --git a/packages/docs-web/src/content/docs/adapters/community/discord.md b/packages/docs-web/src/content/docs/adapters/community/discord.md index 253a1ddb3f..fd211d396a 100644 --- a/packages/docs-web/src/content/docs/adapters/community/discord.md +++ b/packages/docs-web/src/content/docs/adapters/community/discord.md @@ -89,11 +89,21 @@ DISCORD_STREAMING_MODE=batch # batch (default) | stream For streaming mode details, see [Configuration](/getting-started/configuration/). +## Configure Mention Requirement (Optional) + +By default the bot only activates in servers when @mentioned (DMs are exempt). On single-user or private servers you can opt out so the bot responds to any authorized message: + +```ini +DISCORD_REQUIRE_MENTION=false # true (default) | false +``` + +Only the literal value `false` disables the mention requirement. Mentions that are present are still stripped from the message. + ## Usage The bot responds to: - **Direct Messages**: Just send messages directly -- **Server Channels**: @mention the bot (e.g., `@YourBotName help me with this code`) +- **Server Channels**: @mention the bot (e.g., `@YourBotName help me with this code`) — or any authorized message when `DISCORD_REQUIRE_MENTION=false` - **Threads**: Bot maintains context in thread conversations ## Further Reading diff --git a/packages/docs-web/src/content/docs/adapters/github-app-setup.md b/packages/docs-web/src/content/docs/adapters/github-app-setup.md index 8218a2468b..8695ba2db3 100644 --- a/packages/docs-web/src/content/docs/adapters/github-app-setup.md +++ b/packages/docs-web/src/content/docs/adapters/github-app-setup.md @@ -176,7 +176,7 @@ Archon enforces this at startup: with App mode active and the server bound to a Example Caddy snippet that drops `/internal/*` (bare-metal): -```caddyfile +```txt example.com { @internal path /internal/* respond @internal 404 @@ -203,7 +203,7 @@ The `!override` tag (compose-spec) replaces the base file's `ports` list instead Insert this `handle` block before the fallthrough `handle { }` block: -```caddyfile +```txt handle /internal/* { respond "Not Found" 404 } diff --git a/packages/docs-web/src/content/docs/book/first-command.md b/packages/docs-web/src/content/docs/book/first-command.md index ccc3060697..2606d7770f 100644 --- a/packages/docs-web/src/content/docs/book/first-command.md +++ b/packages/docs-web/src/content/docs/book/first-command.md @@ -79,7 +79,7 @@ argument-hint: ### Step 3: Write the Instructions -```markdown +````markdown # Run Tests **Module**: $ARGUMENTS @@ -117,7 +117,7 @@ If you can't find test files for `$ARGUMENTS`, say so clearly and list the files - [ ] Results reported with pass/fail counts - [ ] Failing tests identified with error messages - [ ] Clear recommendation for next step -``` +```` ### Step 4: Test It diff --git a/packages/docs-web/src/content/docs/book/quick-reference.md b/packages/docs-web/src/content/docs/book/quick-reference.md index 2486c79c60..7dee72b626 100644 --- a/packages/docs-web/src/content/docs/book/quick-reference.md +++ b/packages/docs-web/src/content/docs/book/quick-reference.md @@ -128,6 +128,7 @@ All nodes share these base fields: | `approval` | One of | object | Pause for human review; see [Approval Nodes](/guides/approval-nodes/) | | `cancel` | One of | string | Reason string; terminates the run with `cancelled` status (not `failed`). Usually gated with `when:` | | `include` | One of | string | Name of another workflow whose nodes are inlined at discovery as a namespaced sub-DAG; see [Reusing a Shared Sub-DAG](/guides/authoring-workflows/#reusing-a-shared-sub-dag-with-include) | +| `workflow` | One of | string | Name of another workflow run at execution time as a separate governed CHILD run (own run record, gates, artifacts, cost); see [Composing a Governed Sub-Run](/guides/authoring-workflows/#composing-a-governed-sub-run-with-workflow) | | `depends_on` | No | string[] | Node IDs that must complete before this node runs | | `when` | No | string | Condition expression; node is skipped if false | | `trigger_rule` | No | string | Join semantics when multiple upstreams exist (see Trigger Rules) | @@ -152,6 +153,15 @@ All nodes share these base fields: | `deps` | No | string[] | Python dependencies for `uv run --with`. Ignored for bun (bun auto-installs) | | `timeout` | No | number | Hard kill in ms. Default: 120000 (2 min). Same semantics as `bash` timeout | +**Workflow (sub-run)-specific fields** (when `workflow:` is set): + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `input` | No | string | Data string forwarded as the child's `$ARGUMENTS`. Substituted like a `prompt:` body (`$nodeId.output`, workflow variables) | +| `isolation` | No | `'inherit'` | Only `'inherit'` (shared checkout) is supported; `'worktree'` is reserved and rejected at load time (slice 2) | + +`retry` is rejected on `workflow:` nodes, and `workflow:` is rejected inside a `loop_group` body. The child's terminal output threads back as `$nodeId.output`; a child approval gate pauses the whole tree — approve the **child** by run id and the parent auto-resumes. + **Approval-specific fields** (required when `approval:` is set): | Field | Required | Type | Description | diff --git a/packages/docs-web/src/content/docs/deployment/cloud.md b/packages/docs-web/src/content/docs/deployment/cloud.md index e0b7309617..85668edb76 100644 --- a/packages/docs-web/src/content/docs/deployment/cloud.md +++ b/packages/docs-web/src/content/docs/deployment/cloud.md @@ -13,6 +13,8 @@ sidebar: Deploy Archon to a cloud VPS for 24/7 operation with automatic HTTPS and persistent uptime. +> **Docker Compose deployment:** This guide uses the repository's Compose files. Edit `/opt/archon/.env` directly; do **not** run `archon setup` on the VPS. That wizard writes Archon-owned CLI environment files, not the repository `.env` consumed by Docker Compose. + **Navigation:** [Prerequisites](#prerequisites) | [Server Setup](#1-server-provisioning--initial-setup) | [DNS Configuration](#2-dns-configuration) | [Repository Setup](#3-clone-repository) | [Environment Config](#4-environment-configuration) | [Database Migration](#5-database-migration) | [Caddy Setup](#6-caddy-configuration) | [Start Services](#7-start-services) | [Verify](#8-verify-deployment) --- @@ -269,9 +271,8 @@ DATABASE_URL=postgresql://user:password@host:5432/dbname GH_TOKEN=ghp_your_token_here GITHUB_TOKEN=ghp_your_token_here -# Server settings -PORT=3090 -ARCHON_HOME=/tmp/archon # Override base directory (optional) +# Server settings (Docker Compose defaults to port 3000) +PORT=3000 ``` **GitHub Token Setup:** @@ -505,9 +506,30 @@ DOMAIN=archon.yourdomain.com - Automatically obtains SSL certificates from Let's Encrypt - Handles HTTPS (443) and HTTP (80) -> HTTPS redirect -- Proxies requests to app container on port 3090 +- Proxies requests to the app container - Renews certificates automatically +### Optional: Form-Based Authentication + +For a styled login page, use the `auth-service` profile. Generate a bcrypt hash and cookie secret: + +```bash +docker compose --profile auth run --rm auth-service \ + node -e "require('bcryptjs').hash('YOUR_PASSWORD', 12).then(h => console.log(h))" +docker run --rm node:22-alpine \ + node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +Add the results to `/opt/archon/.env`: + +```ini +AUTH_USERNAME=admin +AUTH_PASSWORD_HASH=$$2b$$12$$REPLACE_WITH_YOUR_HASH +COOKIE_SECRET=REPLACE_WITH_64_HEX_CHARS +``` + +Escape **every** `$` in the bcrypt hash as `$$`; otherwise Docker Compose treats it as variable interpolation. In `Caddyfile`, uncomment the `Option A` form-auth blocks and comment out the default no-auth `handle` block. Start with `--profile cloud --profile auth` (and add `--profile with-db` when using the local PostgreSQL container). + --- ## 7. Start Services @@ -548,7 +570,7 @@ docker compose --profile with-db --profile cloud logs -f postgres ### Monitor Startup ```bash -# Watch logs for successful startup (use --profile with-db for local PostgreSQL) +# Watch logs for successful startup (add --profile with-db for local PostgreSQL) docker compose --profile cloud logs -f app # Look for: @@ -572,13 +594,12 @@ docker compose --profile cloud logs -f app curl https://archon.yourdomain.com/api/health # Expected: {"status":"ok"} -# Database connectivity -curl https://archon.yourdomain.com/api/health/db -# Expected: {"status":"ok","database":"connected"} +# Confirms the server is responding and exposes concurrency status +# Expected: {"status":"ok", ...} + +# Optional: verify database connectivity directly +psql "$DATABASE_URL" -c 'SELECT 1' -# Concurrency status -curl https://archon.yourdomain.com/api/health/concurrency -# Expected: {"status":"ok","active":0,"queued":0,"maxConcurrent":10} ``` ### Check SSL Certificate diff --git a/packages/docs-web/src/content/docs/deployment/docker.md b/packages/docs-web/src/content/docs/deployment/docker.md index 52fe63f73e..7d07fc1538 100644 --- a/packages/docs-web/src/content/docs/deployment/docker.md +++ b/packages/docs-web/src/content/docs/deployment/docker.md @@ -128,7 +128,7 @@ docker compose --profile with-db up -d ``` Then add to `.env`: -```env +```ini DATABASE_URL=postgresql://postgres:postgres@postgres:5432/remote_coding_agent ``` @@ -350,10 +350,12 @@ An alternative to basic auth that serves a styled HTML login form instead of the ```ini AUTH_USERNAME=admin - AUTH_PASSWORD_HASH=$2b$12$REPLACE_WITH_YOUR_HASH + AUTH_PASSWORD_HASH=$$2b$$12$$REPLACE_WITH_YOUR_HASH COOKIE_SECRET=REPLACE_WITH_64_HEX_CHARS ``` + Escape every `$` in the bcrypt hash as `$$`; otherwise Docker Compose treats it as variable interpolation. + 4. Update `Caddyfile` (copy from `Caddyfile.example` if not done yet): - **Uncomment** the "Option A" form auth block (the `handle /login`, `handle /logout`, and `handle { forward_auth ... }` blocks) @@ -512,6 +514,28 @@ PI_CODING_AGENT_DIR=/.archon/pi This must be set before the container starts; the Pi SDK reads the variable on each file path lookup. +### Root fallback for macOS bind mounts (opt-in) + +On every start the entrypoint fixes ownership of `/.archon` and `/home/appuser` so they are writable by `appuser` (UID 1001), then drops privileges. On **macOS bind mounts (VirtioFS)** this ownership fix always fails — the host controls file ownership and refuses to remap host UIDs to the container's UID 1001 — so the container exits 1 and crash-loops. Read-only mounts and SELinux/AppArmor denials on Linux fail the same way. + +`ARCHON_ALLOW_ROOT_FALLBACK` is the explicit escape hatch for this case: + +```ini +# .env — opt in to running as root when the ownership fix fails +ARCHON_ALLOW_ROOT_FALLBACK=1 +``` + +| Value | Behavior when the ownership fix fails | +|-------|----------------------------------------| +| unset / anything but `1` (default) | Print the underlying `chown` error and exit 1 (fail loud — unchanged) | +| `1` | Print a warning, `export IS_SANDBOX=1`, and continue running as **root** (privileges are not dropped to `appuser`) | + +The variable has **no effect** when the ownership fix succeeds — Linux setups with correct volume ownership are untouched. + +:::caution +This is a deliberate security tradeoff and is **never auto-enabled**. Running as root also sets `IS_SANDBOX=1`, which bypasses the Claude provider's UID-0 safety guard (it otherwise refuses `bypassPermissions` as root) — so AI subprocesses run as root inside the container. That is acceptable on a single-operator macOS dev machine where the bind mount already scopes what the container can touch; it is the wrong fix on Linux, where the failure means the volume ownership is actually broken — run `sudo chown -R 1001:1001 ` on the host instead of opting in. +::: + ### Folder-project container isolation (`--container`) is unavailable in Docker The folder-project **container backend** (`archon workflow run … --container`) launches a @@ -729,12 +753,16 @@ When using `--profile with-db`, ensure: ### Permission errors in `/.archon/` -The container runs as `appuser` (UID 1001). If using bind mounts instead of Docker volumes: +The container runs as `appuser` (UID 1001). The entrypoint tries to fix ownership of `/.archon` and `/home/appuser` on every start and exits 1 (with the underlying `chown` error) when it can't. + +**On Linux** with bind mounts instead of Docker volumes, fix the ownership on the host: ```bash sudo chown -R 1001:1001 /path/to/archon-data ``` +**On macOS** (Docker Desktop / VirtioFS bind mounts), host `chown` does **not** help — the host refuses to remap ownership to the container's UID 1001 no matter what the files are owned by on the host. For that case (and other failures `chown` can't fix, like read-only mounts or SELinux/AppArmor denials), see [Root fallback for macOS bind mounts (opt-in)](#root-fallback-for-macos-bind-mounts-opt-in). + ### Port conflicts Default Docker port is 3000 (local dev is 3090). Change in `.env`: diff --git a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md index 6c3b7e8d81..6f083a7e0f 100644 --- a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md +++ b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md @@ -144,7 +144,7 @@ assistants: # claudeBinaryPath: /absolute/path/to/claude ``` -The `settingSources` option controls which `CLAUDE.md`, skill, command, and agent files the Claude Code SDK loads. The default is `['project', 'user']`, which loads both the project-level `/.claude/` and your personal `~/.claude/`. Set it to `['project']` if you want to scope a workflow to project-only resources. +The `settingSources` option controls which `CLAUDE.md`, skill, command, and agent files the Claude Code SDK loads. The default is `['project', 'user']`, which loads both the project-level `/.claude/` and your personal `~/.claude/`. Set it to `['project']` if you want to scope a workflow to project-only resources. Individual workflow nodes can override this with a per-node `settingSources:` field (e.g. `settingSources: []` for a lean node that loads no setting sources at all) — see [Claude SDK Advanced Options](/guides/authoring-workflows/#claude-sdk-advanced-options). ### Set as Default (Optional) diff --git a/packages/docs-web/src/content/docs/getting-started/configuration.md b/packages/docs-web/src/content/docs/getting-started/configuration.md index 869b91088d..843b98eeeb 100644 --- a/packages/docs-web/src/content/docs/getting-started/configuration.md +++ b/packages/docs-web/src/content/docs/getting-started/configuration.md @@ -26,6 +26,7 @@ Set these in your shell or `.env` file: | `GITEA_TOKEN` | No | Gitea/Forgejo access token — used to authenticate when cloning private Gitea/Forgejo repos (also used by the Gitea adapter) | | `LOG_LEVEL` | No | `debug`, `info` (default), `warn`, `error` | | `PORT` | No | Server port (default: 3090, Docker: 3000) | +| `WSL_DISTRO_NAME` | No (WSL sets it automatically) | WSL distro name; Archon reads it to emit Windows-host-friendly `vscode://vscode-remote/wsl+/...` "Open in IDE" links. Override only to force a specific distro name into the URI. | ## Project Configuration diff --git a/packages/docs-web/src/content/docs/guides/authoring-commands.md b/packages/docs-web/src/content/docs/guides/authoring-commands.md index f3c7cfd2f3..f4522f5d24 100644 --- a/packages/docs-web/src/content/docs/guides/authoring-commands.md +++ b/packages/docs-web/src/content/docs/guides/authoring-commands.md @@ -28,6 +28,8 @@ A command is a **markdown file** that serves as a detailed instruction set for a Commands live in `.archon/commands/` relative to the working directory and are loaded at runtime. +> **`defaults/` is maintainer-territory:** `.archon/commands/defaults/` is reserved for commands shipped with Archon itself (embedded into the binary at build time). For your own commands use `.archon/commands/` (project-scoped) or `~/.archon/commands/` (home-scoped). Every file under `defaults/` must be committed in git — `bun run validate` will error if untracked files are found there. + > **CLI vs Server:** The CLI reads commands from wherever you run it (sees uncommitted changes). The server reads from `~/.archon/workspaces/owner/repo/`, which only syncs from the remote before worktree creation — so changes must be committed and pushed for the server to pick them up. Commands use this structure: diff --git a/packages/docs-web/src/content/docs/guides/authoring-workflows.md b/packages/docs-web/src/content/docs/guides/authoring-workflows.md index d78f985427..acf767b8dd 100644 --- a/packages/docs-web/src/content/docs/guides/authoring-workflows.md +++ b/packages/docs-web/src/content/docs/guides/authoring-workflows.md @@ -35,12 +35,14 @@ nodes: context: fresh ``` -> **Using defaults as templates:** Archon ships default workflows in `.archon/workflows/defaults/` (12 bundled into the binary, plus additional ones available on disk in source builds). Browse them for real-world examples, then copy and modify: +> **Using defaults as templates:** Archon ships default workflows in `.archon/workflows/defaults/` (21 bundled into the binary; source builds also load them from disk). Browse them for real-world examples, then copy and modify: > ```bash > cp .archon/workflows/defaults/archon-fix-github-issue.yaml .archon/workflows/my-fix-issue.yaml > ``` > Same-named files in `.archon/workflows/` override the bundled defaults. +> **`defaults/` is maintainer-territory:** `.archon/workflows/defaults/` and `.archon/commands/defaults/` are reserved for workflows/commands shipped with Archon itself — they are embedded into the binary at build time and every file there must be committed in git. For your own drafts use `.archon/workflows/` (project-scoped, committed to your repo) or `~/.archon/workflows/` (home-scoped, personal). Running `bun run generate:bundled` (or `bun run validate`) will exit with an error if it finds any untracked files in `defaults/`. + --- ## File Location @@ -191,6 +193,7 @@ nodes: | `approval` | object | Pauses workflow for human review. See [Approval Nodes](/guides/approval-nodes/) | | `cancel` | string | Terminates the workflow run with a reason string. Uses existing cancellation plumbing — in-flight parallel nodes are stopped | | `include` | string | Name of another workflow whose nodes are inlined into this DAG at load time as a namespaced sub-DAG. See [Reusing a Shared Sub-DAG](#reusing-a-shared-sub-dag-with-include) | +| `workflow` | string | Name of another workflow to run as a governed **child sub-run** at execution time — its own run record, gates, artifacts, and cost. Optional `input` (data string). See [Composing a Governed Sub-Run](#composing-a-governed-sub-run-with-workflow) | **Common fields** — apply to all node types: @@ -226,10 +229,11 @@ nodes: | `fallbackModel` | string | — | Model to use if primary model fails. Claude only. Also settable at workflow level | | `betas` | string[] | — | SDK beta feature flags (e.g., `'context-1m-2025-08-07'`). Claude only. Also settable at workflow level | | `sandbox` | object | — | OS-level filesystem/network restrictions for the Claude subprocess. Claude only. Also settable at workflow level | +| `settingSources` | (`'project'`\|`'user'`)[] | inherited | Which filesystem setting sources Claude loads (CLAUDE.md, skills, commands, agents). Overrides the assistant-level default; unset everywhere = `['project', 'user']`. `[]` loads none. Claude only. Per-node only | ### Claude SDK Advanced Options -These fields map directly to Claude Agent SDK options. `maxBudgetUsd`, `systemPrompt`, `fallbackModel`, `betas`, and `sandbox` are Claude-only — Codex and other providers emit a warning and ignore them. `effort` and `thinking` also apply to Pi and Copilot, which map them to their own reasoning controls (Codex uses `modelReasoningEffort` instead; OpenCode configures reasoning via `opencode.json`). They can be set **per-node** or at the **workflow level** as defaults (per-node takes precedence). `maxBudgetUsd` and `systemPrompt` are per-node only. +These fields map directly to Claude Agent SDK options. `maxBudgetUsd`, `systemPrompt`, `fallbackModel`, `betas`, `sandbox`, and `settingSources` are Claude-only — Codex and other providers emit a warning and ignore them. `effort` and `thinking` also apply to Pi and Copilot, which map them to their own reasoning controls (Codex uses `modelReasoningEffort` instead; OpenCode configures reasoning via `opencode.json`). They can be set **per-node** or at the **workflow level** as defaults (per-node takes precedence). `maxBudgetUsd`, `systemPrompt`, and `settingSources` are per-node only (`settingSources` also has an assistant-level default in `.archon/config.yaml`). **effort** — reasoning depth: @@ -295,6 +299,20 @@ These fields map directly to Claude Agent SDK options. `maxBudgetUsd`, `systemPr denyWrite: ['/etc', '/usr'] ``` +**settingSources** — control which filesystem setting sources the Claude SDK loads (project `CLAUDE.md`/`.claude/` skills, commands, agents vs the user-level `~/.claude/`). Loading fewer sources gives a leaner context and a faster node start — a lean reviewer node can skip project context entirely while a writer node in the same workflow keeps it: + +```yaml +- id: lean-review + command: review + settingSources: [] # load no CLAUDE.md / skills / commands / agents + +- id: implement + command: implement + settingSources: ['project'] # project sources only, skip ~/.claude/ +``` + +Omitting the field inherits the assistant-level `assistants.claude.settingSources` from `.archon/config.yaml`; if that is also unset, the default is `['project', 'user']`. + **Workflow-level defaults** (inherited by all Claude nodes unless overridden per-node): ```yaml @@ -861,6 +879,90 @@ for a standalone run. --- +## Composing a Governed Sub-Run with `workflow:` + +A `workflow:` node runs another workflow as a **child sub-run** — a genuinely separate +`workflow_runs` record with its own artifacts directory, its own approval gates, its own +cost line, and its own audit trail. The child's terminal output threads back into the +parent as `$.output`, exactly like any other node. + +```yaml +nodes: + - id: plan + prompt: "Plan the change described in $ARGUMENTS." + context: fresh + + # `workflow:` names any discovered workflow (bundled / global / repo) to run as a + # child sub-run; `qa-block` here is a placeholder for your own workflow file. + # Its terminal output becomes $implement-qa.output. + - id: implement-qa + workflow: qa-block + input: "$plan.output" + depends_on: [plan] + + - id: summarize + prompt: "Summarize the sub-run result:\n\n$implement-qa.output" + depends_on: [implement-qa] + context: fresh +``` + +### `include:` vs `workflow:` — which to use + +Both reuse another workflow. They differ in **governance**, not syntax: + +| | `include:` (load-time) | `workflow:` (run-time) | +|---|---|---| +| Run record | One — the block's nodes flatten into the parent's run | Two — the child gets its own `workflow_runs` row | +| Artifacts / cost / resume | Shared with the parent | The child's own, separate | +| Approval gate | The parent's single gate | The child pauses at **its own** gate, approved by the child's run id | +| Output access | `$includeId.output` (terminal only) | `$nodeId.output` (child's terminal) | +| When to reach for it | Textual reuse of a shared block (e.g. a review sub-DAG) | The block must be a separate **governance object** — separately auditable, gated, and cost-tracked | + +Rule of thumb: **`include:` for reuse, `workflow:` for a governed, separately-auditable +sub-pipeline.** + +### Shared checkout, gates, and resume + +- **Shared checkout.** In this first slice the child runs in the **parent's checkout** + (`isolation: inherit`, the only accepted value — `isolation: worktree` is reserved and + rejected at load time). This is correct for sequential composition (plan → implement → + QA in one working tree). Per-child worktrees, parallel fan-out, and racing are a later + slice. +- **Gates pause the whole tree.** When the child hits an approval gate, the child run + pauses **and** the parent pauses "blocked on child". A reviewer approves the **child** by + its own run id (`/workflow approve ` — shown in the pause message). When the + child completes, the parent **auto-resumes** in-process, re-runs the `workflow:` node, + finds the child finished, and threads its output onward. Because the parent pauses at a + gate, mark a parent that contains a `workflow:` node with `interactive: true` so it runs + in the foreground on the web UI. +- **Failure & recovery.** A failed child fails the node and the parent run. Recovery is + resume-through-parent: `/workflow resume ` re-drives the failed child once. + `retry:` is **not** allowed on a `workflow:` node (a retry would orphan the first child + run). +- **Cancel cascade.** Abandoning the parent cancels its non-terminal descendants + cooperatively (their executors abort at the next status check, within ~10s — there is no + hard subprocess kill yet). +- **Cost roll-up.** The child's total cost rolls up into the `workflow:` node's cost and + the parent's aggregate, and `parent_run_id` on the child row makes the run tree visible + in `archon workflow runs` and the console. + +### Non-goals (this slice) + +- **No `with:` named-parameter mapping** — use `input:` (a single data string). A + `workflow:` node with a `with:` key is rejected with a clear error. +- **No dynamic fan-out / variable N**, **no `isolation: worktree`**, and **no racing** — + all reserved for a later slice. +- **Not inside a `loop_group` body** — rejected at load time. +- **Static target only.** `workflow:` takes a literal workflow name — no + `workflow: $something`. Self-reference and ancestor cycles (`A` → `B` → `A`) are rejected + at run time, and the sub-run tree is depth-capped. +- **One blocking child gate at a time.** Two `workflow:` nodes in the same DAG layer + whose children both pause contend for the parent run's single approval slot — the + second pause fails its node. Sequence gated sub-runs with `depends_on` until a later + slice adds real concurrent gating. + +--- + ## Model Configuration Workflows can configure AI models and provider-specific options at the workflow level. diff --git a/packages/docs-web/src/content/docs/reference/api.md b/packages/docs-web/src/content/docs/reference/api.md index 971a557df1..3e77d7095e 100644 --- a/packages/docs-web/src/content/docs/reference/api.md +++ b/packages/docs-web/src/content/docs/reference/api.md @@ -266,9 +266,9 @@ Only user-defined workflows can be deleted. Bundled defaults cannot be removed. | GET | `/api/workflows/runs/by-worker/{platformId}` | Look up a run by worker conversation ID | | POST | `/api/workflows/runs/{runId}/cancel` | Cancel a running workflow | | POST | `/api/workflows/runs/{runId}/resume` | Resume a failed workflow | -| POST | `/api/workflows/runs/{runId}/abandon` | Abandon a run (running, paused, or failed) | -| POST | `/api/workflows/runs/{runId}/approve` | Approve a paused workflow | -| POST | `/api/workflows/runs/{runId}/reject` | Reject a paused workflow | +| POST | `/api/workflows/runs/{runId}/abandon` | Abandon a run (running, paused, or failed); cascade-cancels non-terminal `workflow:` sub-run descendants | +| POST | `/api/workflows/runs/{runId}/approve` | Approve a paused workflow (400 if paused blocked on a `workflow:` child — approve the child) | +| POST | `/api/workflows/runs/{runId}/reject` | Reject a paused workflow (400 if paused blocked on a `workflow:` child — reject the child) | | DELETE | `/api/workflows/runs/{runId}` | Delete a terminal run and its events | #### Run a Workflow @@ -317,6 +317,8 @@ curl -X POST http://localhost:3090/api/workflows/runs/{runId}/reject \ -d '{"reason": "Please add error handling first"}' ``` +**Sub-run child gates (#2121 Phase 2):** when a `workflow:` sub-run pauses at its own gate, its parent run pauses "blocked on child". Approve/reject the **child** run (its id is in the parent's block message) — the parent auto-resumes when the child completes. Calling approve/reject on the *parent's* id while it is blocked on a child returns **400** with a redirect to the child id. `abandon` on a parent cascade-cancels its non-terminal sub-run descendants; the response's `cascadeFailures` is non-zero if part of the tree could not be reached, and `blockedParentRunId` is set when the abandoned run was itself a child stranding a paused parent. + --- ## Commands diff --git a/packages/docs-web/src/content/docs/reference/architecture.md b/packages/docs-web/src/content/docs/reference/architecture.md index 42c0a324da..be8e1c7222 100644 --- a/packages/docs-web/src/content/docs/reference/architecture.md +++ b/packages/docs-web/src/content/docs/reference/architecture.md @@ -347,7 +347,9 @@ export type MessageChunk = cost?: number; stopReason?: string; numTurns?: number; - modelUsage?: Record; + // Concrete provider-reported model. Omitted for providers such as Codex + // whose SDK completion events do not expose the resolved model. + resolvedModel?: ResolvedModel; // Session-resume outcome: true = restored, false = requested but fell back // to a fresh session, omitted = no resume requested. Set only when // resumeSessionId was passed (stamp it via withResumedOutcome). @@ -1111,6 +1113,7 @@ remote_agent_workflow_runs ├── workflow_name (VARCHAR) ├── status (VARCHAR) -- 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' ├── parent_conversation_id (UUID) -- Parent chat that dispatched this run +├── parent_run_id (UUID -> remote_agent_workflow_runs.id, ON DELETE SET NULL) -- Run-tree parent for a workflow: sub-run (#2121); null for top-level ├── user_id (UUID -> remote_agent_users.id, ON DELETE SET NULL) -- User who triggered the run └── metadata (JSONB) diff --git a/packages/docs-web/src/content/docs/reference/archon-directories.md b/packages/docs-web/src/content/docs/reference/archon-directories.md index 92b0ac0d30..5973fdb4dd 100644 --- a/packages/docs-web/src/content/docs/reference/archon-directories.md +++ b/packages/docs-web/src/content/docs/reference/archon-directories.md @@ -114,6 +114,27 @@ function isDocker(): boolean { } ``` +### WSL Detection + +```typescript +function isWSL(): boolean { + // Either signal is sufficient: + // - WSL_DISTRO_NAME env var is set (always true inside a WSL distro) + // - /proc/sys/kernel/osrelease contains "microsoft" (lower-cased) + // The /proc read is wrapped in try/catch: on environments without a + // readable /proc (macOS, Windows, restricted sandboxes) it conservatively + // returns false. +} + +function getWSLDistroName(): string | undefined { + // Returns the WSL_DISTRO_NAME env var if present, otherwise undefined. + // Only reads the env var — isWSL() may still be true via the /proc + // fallback while this returns undefined. +} +``` + +Used to build Windows-host-friendly `vscode://vscode-remote/wsl+/...` IDE URIs when Archon runs inside WSL (surfaced as `is_wsl` / `wsl_distro` on `/api/health`). + ### Platform-Specific Paths | Platform | `getArchonHome()` | diff --git a/packages/docs-web/src/content/docs/reference/cli.md b/packages/docs-web/src/content/docs/reference/cli.md index 9c8b43a47b..36ff85ba93 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -298,10 +298,14 @@ archon workflow abandon archon workflow abandon --json ``` +**Sub-run trees (#2121 Phase 2):** abandoning a parent that spawned `workflow:` sub-runs cascade-cancels every non-terminal descendant (children and grandchildren; already-terminal runs are left alone). The cancel is cooperative — each child's executor aborts at its next status check (~10s; no hard subprocess kill). If part of the tree could not be reached, the command reports the count so you know descendants may still be alive. Conversely, abandoning a **child** that its parent is paused-and-blocked on strands that parent (nothing re-fires the auto-resume hook); the command surfaces the blocked parent's run id so you can `resume` it (which fails the sub-run node cleanly) or abandon it too. + ### `workflow approve` Approve a paused workflow run at an interactive approval gate. Optionally provide a comment that is available to the workflow via `$LOOP_USER_INPUT`. +**Sub-run child gates (#2121 Phase 2):** when a `workflow:` sub-run pauses at its own gate, the parent run pauses "blocked on child". Approve (or reject) the **child** by its own run id — the id shown in the parent's block message — not the parent's; the parent auto-resumes when the child completes. `approve`/`reject` against the parent's id while it's blocked on a child are refused with a redirect to the child id. + **Interactive-loop gates — finalize vs iterate:** when the gate paused on an iteration that emitted the loop's completion signal (`workflow get --json` → `.metadata.approval.completionSignaled` is `true`), approving with **no comment** accepts the completion — the node finalizes from the already-computed output on resume, with no re-run. Approving **with** a comment runs another iteration using it as `$LOOP_USER_INPUT`. On a non-signaled gate, both forms run another iteration. ```bash diff --git a/packages/docs-web/src/content/docs/reference/configuration.md b/packages/docs-web/src/content/docs/reference/configuration.md index 7160f8f2ae..2dbb8f7016 100644 --- a/packages/docs-web/src/content/docs/reference/configuration.md +++ b/packages/docs-web/src/content/docs/reference/configuration.md @@ -148,6 +148,8 @@ worktree: # /.worktrees/ instead of under # ~/.archon/workspaces///worktrees/. # Must be relative; no absolute, no `..` segments. + remote: origin # Optional: git remote name for fetch/push. Auto-detected + # when omitted (origin if it exists, sole remote otherwise). # Documentation directory docs: @@ -237,9 +239,14 @@ worktree: **Submodule behavior:** When a repo contains `.gitmodules`, submodules are initialized in new worktrees by default (git's `worktree add` does not do this). The check is a cheap filesystem probe — repos without submodules pay zero cost. Submodule init failure throws a classified error (credentials, network, timeout) rather than silently producing a worktree with empty submodule directories. Set `worktree.initSubmodules: false` to opt out. +**Remote behavior:** By default, all git operations (fetch, push, branch tracking) use the `origin` remote. If your repo uses a different remote name, configure `worktree.remote`. Resolution order: +1. If `worktree.remote` is set: Uses the configured remote name for all operations. +2. If omitted: Auto-detects — `origin` if it exists, otherwise the sole remote if only one is configured. +3. If multiple remotes exist and none is named `origin`: Worktree creation **fails with an actionable error** listing the available remotes and suggesting the config fix. + **Base branch behavior:** Before creating a worktree, the canonical workspace is synced to the latest code. Resolution order: -1. If `worktree.baseBranch` is set: Uses the configured branch. **Fails with an error** if the branch doesn't exist on remote (no silent fallback). -2. If omitted: Auto-detects the default branch via `git remote show origin`. Works without any config for standard repos. +1. If `worktree.baseBranch` is set: Uses the configured branch. **Fails with an error** if the branch doesn't exist on the resolved remote (no silent fallback). +2. If omitted: Auto-detects the default branch via `git symbolic-ref` on the resolved remote. Works without any config for standard repos. 3. If auto-detection fails and a workflow references `$BASE_BRANCH`: Fails with an error explaining the resolution chain. **Docs path behavior:** The `docs.path` setting controls where the `$DOCS_DIR` variable points. When not configured, `$DOCS_DIR` defaults to `docs/`. Unlike `$BASE_BRANCH`, this variable always has a safe default and never throws an error. Configure it when your documentation lives outside the standard `docs/` directory (e.g., `packages/docs-web/src/content/docs`). @@ -311,6 +318,7 @@ Environment variables override all other configuration. They are organized by ca | `SESSION_RETENTION_DAYS` | Delete inactive sessions older than N days | `30` | | `ARCHON_VERBOSE_BOOT` | When set to `1`, prints `[archon] loaded N keys from …` lines to stderr at boot. Also enabled by `LOG_LEVEL=debug` or `LOG_LEVEL=trace`. Silent by default to avoid interleaving with interactive command output. | -- | | `ARCHON_BASH_PATH` | Override the bash executable path used by `bash` nodes and loop `until_bash`. Eagerly validated at resolution time — typos surface immediately instead of as opaque ENOENTs inside the first bash-node fire. | `bash` on Linux/macOS; on Windows, the first existing of the common Git-Bash locations: `%ProgramFiles%\Git\bin\bash.exe`, `%ProgramFiles%\Git\usr\bin\bash.exe`, `%ProgramFiles(x86)%\Git\bin\bash.exe`, `%LOCALAPPDATA%\Programs\Git\bin\bash.exe`, `%USERPROFILE%\scoop\apps\git\current\bin\bash.exe` | +| `WSL_DISTRO_NAME` | Set automatically by WSL in every distro shell. Archon reads it (via `/api/health`) to emit Windows-host-friendly `vscode://vscode-remote/wsl+/...` "Open in IDE" URIs. You do not normally set this yourself; override it only to force a specific distro name into the URI. | -- (unset outside WSL) | ### AI Providers -- Claude @@ -366,6 +374,7 @@ The Copilot provider also reads `assistants.copilot.{model, modelReasoningEffort | `DISCORD_BOT_TOKEN` | Discord bot token from Developer Portal | -- | | `DISCORD_ALLOWED_USER_IDS` | Comma-separated Discord user IDs for whitelist | Open access | | `DISCORD_STREAMING_MODE` | Streaming mode (`stream` or `batch`) | `batch` | +| `DISCORD_REQUIRE_MENTION` | Require @mention to activate in servers (`true` or `false`); DMs never require a mention | `true` | ### Platform Adapters -- GitHub diff --git a/packages/docs-web/src/content/docs/reference/database.md b/packages/docs-web/src/content/docs/reference/database.md index d1fadb24f0..f0e69ae934 100644 --- a/packages/docs-web/src/content/docs/reference/database.md +++ b/packages/docs-web/src/content/docs/reference/database.md @@ -93,9 +93,10 @@ The database has 18 tables, all prefixed with `remote_agent_`: 5. **`remote_agent_workflow_runs`** - Workflow execution tracking - Tracks active workflows per conversation - - Locks concurrent execution per `working_path`: a second dispatch on a path with an active run (status `pending`/`running`/`paused`) is auto-cancelled with an actionable message. Stale `pending` rows older than 5 minutes are treated as orphaned and ignored. + - Locks concurrent execution per `working_path`: a second dispatch on a path with an active run (status `pending`/`running`/`paused`) is auto-cancelled with an actionable message. Stale `pending` rows older than 5 minutes are treated as orphaned and ignored. A `workflow:` sub-run shares its parent's checkout, so the path-lock excludes the run's ancestor chain (via `parent_run_id`). - Stores workflow state, step progress, and parent conversation linkage - Nullable `user_id` records which user triggered the run + - Nullable `parent_run_id` (#2121 Phase 2) — self-referential FK (`ON DELETE SET NULL`) linking a `workflow:` sub-run to the run that spawned it; null for top-level runs. Makes the run tree walkable (`findChildRuns`/`getRunAncestry`) for the abandon cascade and cost roll-up. 6. **`remote_agent_workflow_events`** - Step-level workflow event log - Records step transitions, artifacts, and errors per workflow run diff --git a/packages/docs-web/src/content/docs/reference/provider-capabilities.md b/packages/docs-web/src/content/docs/reference/provider-capabilities.md index f493c4ae21..410e2c6237 100644 --- a/packages/docs-web/src/content/docs/reference/provider-capabilities.md +++ b/packages/docs-web/src/content/docs/reference/provider-capabilities.md @@ -38,8 +38,8 @@ per-node YAML field for that provider; a ❌ means the field is accepted but ign | Session resume | ✅ | ✅ | ✅ | ✅ | ✅ | | MCP servers (`mcp:`) | ✅ | ✅ | ✅ | ❌ | ✅ | | Hooks (`hooks:`) | ✅ | ❌ | ❌ | ❌ | ❌ | -| Skills (`skills:`) | ✅ | ✅ | ✅ | ✅ | ✅ | -| Inline sub-agents (`agents:`) | ✅ | ❌ | ✅ | ❌ | ✅ | +| Skills (`skills:`) | ✅ | ✅¹ | ✅ | ✅ | ✅ | +| Inline sub-agents (`agents:`) | ✅ | ❌ | ✅² | ❌ | ✅ | | Tool restrictions (`allowed_tools`/`denied_tools`) | ✅ | ❌ | ✅ | ✅ | ✅ | | Structured output (`output_format`) | **enforced** | **enforced** | **enforced** | best-effort | best-effort | | Env injection (`env:`) | ✅ | ✅ | ✅ | ✅ | ✅ | @@ -48,12 +48,20 @@ per-node YAML field for that provider; a ❌ means the field is accepted but ign | Thinking control (`thinking`) | ✅ | ❌ | ❌ | ✅ | ✅ | | Fallback model (`fallbackModel`) | ✅ | ❌ | ❌ | ❌ | ❌ | | Sandbox (`sandbox`) | ✅ | ❌ | ❌ | ❌ | ❌ | +| Setting sources (`settingSources`) | ✅ | ❌ | ❌ | ❌ | ❌ | | In-process native tools | ✅ | ❌ | ❌ | ✅ | ❌ | | Container exec (folder-project container backend) | ✅ | ❌ | ❌ | ❌ | ❌ | +## Caveats + +- ¹ `codex` — Skills (`skills:`) — Filesystem auto-discovery from `.agents/skills/` — per-node `skills:` lists are informational; use `provider: claude` for node-scoped skills. +- ² `opencode` — Inline sub-agents (`agents:`) — Config-file-based agent selection (named agents from `opencode.json`) with per-call model/tools overrides — not inline sub-agent definitions. + ## Legend - **✅ / ❌** — the per-node field is wired for this provider, or accepted-but-ignored. +- **✅¹ (superscript)** — supported, but with semantics that differ from the headline + meaning of the axis — see [Caveats](#caveats). - **Structured output** — `enforced` (the SDK/backend grammar-constrains decoding), `best-effort` (schema appended to the prompt, then validated + re-asked up to 3×), or ❌ (unsupported). See [AI Assistants → Structured output guarantees](/getting-started/ai-assistants/#structured-output-guarantees). diff --git a/packages/docs-web/src/content/docs/reference/variables.md b/packages/docs-web/src/content/docs/reference/variables.md index 11e5482400..5cdd5564b1 100644 --- a/packages/docs-web/src/content/docs/reference/variables.md +++ b/packages/docs-web/src/content/docs/reference/variables.md @@ -12,7 +12,7 @@ Archon substitutes variables in command files, inline prompts, bash scripts, and ## Workflow Variables -These variables are substituted by the workflow executor in all node types (`command:`, `prompt:`, `bash:`, `script:`, `loop:`, `loop_group:`). +These variables are substituted by the workflow executor in all node types (`command:`, `prompt:`, `bash:`, `script:`, `loop:`, `loop_group:`, and a `workflow:` node's `input:` field — which behaves like a `prompt:` body, not a bash-escaped one). | Variable | Resolves to | Notes | |----------|-------------|-------| diff --git a/packages/docs-web/src/content/docs/reference/workflow-language-constitution.md b/packages/docs-web/src/content/docs/reference/workflow-language-constitution.md index ee6a336b14..b450251e2d 100644 --- a/packages/docs-web/src/content/docs/reference/workflow-language-constitution.md +++ b/packages/docs-web/src/content/docs/reference/workflow-language-constitution.md @@ -16,7 +16,18 @@ Archon's workflow YAML is deliberately held on the right side of that line. This > **YAML coordinates. Code computes. Agents judge.** -The workflow YAML exists to express what the **engine** must see in order to govern a run: ordering, gating, retrying, joining, pausing for humans, session identity, artifact identity, and reusable structure. Everything that *computes a value or transforms data* belongs in a `bash:`/`script:` node. Everything that *requires judgment* belongs in a prompt. The YAML is the wiring between them — nothing more. +The workflow YAML exists to express what the **engine** must see in order to govern a run: ordering, gating, retrying, joining, pausing for humans, session identity, artifact identity, and reusable structure. Everything that *computes a value or transforms data* stays out of the YAML and lives inside a node's **body** — the `bash:`/`script:` source or the `prompt:` text. The surrounding node fields (`when:`, `retry:`, `output_format:`, …) are YAML surface and stay declarative. The YAML is the wiring between nodes — nothing more. + +**What this rule is about, and what it is not.** The partition governs **what may enter the YAML surface**, not what an agent may do inside a node. "Code computes, agents judge" is shorthand for *the language does not compute* — it is not a prohibition on a prompt performing computation. + +A prompt that computes is a **legitimate authoring choice**, and frequently the right one. `bash:`/`script:` nodes and `prompt:` nodes are both escape hatches from the language; choosing between them is an ordinary engineering decision the workflow author owns, not a constitutional question: + +- Reach for a **script node** when the rule is known, fixed, and cheap to state — parsing JSON, arithmetic, comparing versions, reshaping a list. +- Reach for a **prompt** when the author does not know the rule, or knows it will not survive contact with real inputs, and wants the model to decide. Models are capable; forcing an uncertain rule into a script only freezes a guess into code. + +Neither choice touches the language, so neither is the constitution's business. + +**The one narrow case that argues for determinism** — and it is a reliability argument, not a constitutional one: a check with **no judgment content** (a boolean with exactly one correct answer, like "does this file exist") whose failure has **irreversible external consequences** is better expressed as a node that cannot decline to fire. Not because a prompt cannot evaluate it, but because the cost of it not firing is unrecoverable. Cite reliability when you make that argument; do not cite this page. This is not an aesthetic preference. The declarative surface is what makes Archon's core promises possible: load-time validation, the visual builder, resumability, audit trails, and approval gates all depend on the engine being able to *statically see* the workflow's structure. Every unit of computation that leaks into the YAML is a unit the engine can no longer validate, render, resume, or audit — and a unit that a script node would have handled better. @@ -38,7 +49,8 @@ If a feature computes rather than coordinates, it is rejected — with the point | `loop:` / `loop_group:` | ✅ admitted | Iteration structure the engine must own for events, gates, and cost accounting | | `include:` (load-time inlining, [#2121](https://github.com/coleam00/Archon/issues/2121)) | ✅ admitted | Textual composition, zero new runtime semantics — the engine sees a flat DAG | | `first_success` racing join (proposed, [#1764](https://github.com/coleam00/Archon/issues/1764)) | ✅ admissible | A join rule — coordination | -| Runtime sub-runs (`workflow:`, #2121 Phase 2) | ✅ admissible | A sub-run is a governance object (own run record, own audit trail) | +| Runtime sub-runs (`workflow:`, #2121 Phase 2) | ✅ shipped (slice 1) | A sub-run is a governance object (own run record, own gates, own audit trail). Slice 1: shared checkout, `input:` string, gate-aware pause/resume; fan-out, `worktree` isolation, racing, and `with:` remain deferred | +| `evidence_policy` terminal-success gate ([#2230](https://github.com/coleam00/Archon/issues/2230)) | ✅ admitted (thin slice) | A run-status transition (sibling of `approval:`) — the engine gates on `evidence.json` PRESENCE only; computing/validating the evidence stays in the workflow's script/bash nodes. The full typed-schema + reality-verification surface of PR #1601 was rejected as computation | | Arithmetic / string functions / regex in `when:` | ❌ rejected | Computation. A script node computes the decision; `when:` gates on its output | | Parentheses & nested boolean grouping in `when:` | ❌ rejected (see policy below) | The first step of home-growing an expression language | | Templating (Jinja-style interpolation, computed node ids) | ❌ rejected | Evaluation inside declaration — the Helm road | @@ -71,7 +83,9 @@ These are the specific mechanisms by which workflow languages rot. Each is liste **Archon today (observed).** The defaults audit found a 9-node review block copy-pasted into five workflows and a byte-identical bash node in up to nine — precisely because composition was missing. That evidence produced `include:` (#2121), a constitutional feature. The same audit found the opposite failure too: deterministic validation suites narrated as AI prose because authors lacked a polyglot pattern — resolved not with a YAML feature but with a *pattern* (detect with AI → execute with bash → fix with AI). -**Lever — audit the workarounds, not the requests.** Periodically audit real workflows (bundled and user-reported) for repeated structure and embedded logic. Each finding gets classified: missing *coordination* primitive → design it constitutionally; missing *pattern* → document the pattern; missing *computation* → point to script nodes. The workaround corpus, not the feature-request queue, decides what the language needs. +**Lever — audit the workarounds, not the requests.** Periodically audit real workflows (bundled and user-reported) for repeated structure and embedded logic. Each finding gets classified: missing *coordination* primitive → design it constitutionally; missing *pattern* → document the pattern; computation that has leaked *into the YAML surface* → point to script nodes (this bucket is about the language, never about rewriting an author's prompt — see *Read "prompt-embedded logic" carefully* below). The workaround corpus, not the feature-request queue, decides what the language needs. + +**Read "prompt-embedded logic" carefully.** It is a signal for *language design* — evidence that a coordination primitive or a documented pattern may be missing. It is **not** a finding against the workflow, and not a mandate to refactor authored prompts into script nodes. A prompt doing deterministic work becomes a smell when the same shape **recurs** — across workflows, or across nodes within one workflow — because recurrence is what indicates a missing primitive or an undocumented pattern. What is *not* a smell is one author choosing a prompt for one computation: that is the author exercising a legitimate choice (see *The rule*), and it is a finding about the language only when it repeats. ### 4. Schema width (the parameter matrix is a symptom) @@ -93,6 +107,6 @@ These are the specific mechanisms by which workflow languages rot. Each is liste For **contributors**: cite this page in `feat(workflows)` PRs that touch the YAML surface. A reviewer's first question is the admissibility test, not the implementation. -For **workflow authors**: if you're fighting the YAML — wanting arithmetic in `when:`, string manipulation in a field, cleverness in structure — the language is telling you the logic belongs one level down. Compute in a script node, decide in a prompt, and let the YAML do what it's for: wiring the pieces the engine governs. +For **workflow authors**: if you're fighting the YAML — wanting arithmetic in `when:`, string manipulation in a field, cleverness in structure — the language is telling you the logic belongs one level down — into a `script:`/`bash:` node or a `prompt:`, whichever fits the problem (see *The rule*: that choice is yours, not the constitution's) — leaving the YAML to do what it's for: wiring the pieces the engine governs. For **the roadmap**: the constitution is why Archon can keep its declarative surface while workflows-as-code frameworks exist. The trade — auditability, the visual builder, non-engineer operators — stays won exactly as long as the YAML stays a coordination language. The day it computes, it loses to both alternatives at once. diff --git a/packages/docs-web/src/data/marketplace.ts b/packages/docs-web/src/data/marketplace.ts index 4c436639cf..4cca1e7e37 100644 --- a/packages/docs-web/src/data/marketplace.ts +++ b/packages/docs-web/src/data/marketplace.ts @@ -131,6 +131,18 @@ export const marketplaceEntries: MarketplaceEntry[] = [ tags: ['review', 'automation'], archonVersionCompat: '>=0.3.0', }, + { + slug: 'archon-resolve-mr-conflicts', + name: 'Resolve GitLab MR Conflicts', + author: 'lraphael', + description: + 'GitLab counterpart to archon-resolve-conflicts. Rebases an MR onto its target branch, auto-resolves simple conflicts (additions, imports, formatting, dependency-list merges), presents options for complex conflicts, validates the resolution (ruff/pytest/tsc/go), and force-pushes with --force-with-lease.', + sourceUrl: + 'https://github.com/lraphael/archon-gitlab-workflows/tree/6e39b359e1b02329ebf63f7d1699e6bbc8cb001f/archon-resolve-mr-conflicts', + sha: '6e39b359e1b02329ebf63f7d1699e6bbc8cb001f', + tags: ['automation'], + archonVersionCompat: '>=0.3.0', + }, { slug: 'archon-comprehensive-mr-review', name: 'Comprehensive GitLab MR Review', diff --git a/packages/git/package.json b/packages/git/package.json index 6ee5ee2434..37c823c32d 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -1,6 +1,6 @@ { "name": "@archon/git", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", diff --git a/packages/git/src/branch.ts b/packages/git/src/branch.ts index 02ebf1dc1d..f31bc32eae 100644 --- a/packages/git/src/branch.ts +++ b/packages/git/src/branch.ts @@ -14,23 +14,26 @@ function getLog(): ReturnType { * Get the default branch name for a repository * Uses git symbolic-ref to get the remote HEAD reference * - * Fallback chain: symbolic-ref -> origin/main -> throw - * Note: Throws if neither origin/HEAD nor origin/main can be resolved. + * Fallback chain: symbolic-ref -> /main -> throw + * Note: Throws if neither /HEAD nor /main can be resolved. * Callers can set worktree.baseBranch in .archon/config.yaml as a manual override. * * Only falls back for expected git errors (ref not found, branch not found). * Throws for unexpected errors (permission denied, git corruption, etc.) + * + * @param repoPath - Path to the git repository + * @param remote - Remote name to check (default: 'origin') */ -export async function getDefaultBranch(repoPath: RepoPath): Promise { +export async function getDefaultBranch(repoPath: RepoPath, remote = 'origin'): Promise { // Try to get from remote HEAD try { const { stdout } = await execFileAsync( 'git', - ['-C', repoPath, 'symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], + ['-C', repoPath, 'symbolic-ref', `refs/remotes/${remote}/HEAD`, '--short'], { timeout: 10000 } ); // stdout is like "origin/main" - extract just the branch name - return toBranchName(stdout.trim().replace('origin/', '')); + return toBranchName(stdout.trim().replace(`${remote}/`, '')); } catch (error) { const err = error as Error & { stderr?: string }; const errorText = `${err.message} ${err.stderr ?? ''}`; @@ -40,17 +43,20 @@ export async function getDefaultBranch(repoPath: RepoPath): Promise errorText.includes('not a symbolic ref') || errorText.includes('No such file or directory') ) { - getLog().debug({ repoPath, err }, 'symbolic_ref_fallback'); + getLog().debug({ repoPath, remote, err }, 'symbolic_ref_fallback'); } else { // Unexpected error (permission denied, git corruption, etc.) - surface it - getLog().error({ repoPath, err, stderr: err.stderr }, 'default_branch_symbolic_ref_failed'); + getLog().error( + { repoPath, remote, err, stderr: err.stderr }, + 'default_branch_symbolic_ref_failed' + ); throw new Error(`Failed to get default branch for ${repoPath}: ${err.message}`); } } - // Fallback: check if origin/main exists, otherwise throw + // Fallback: check if /main exists, otherwise throw try { - await execFileAsync('git', ['-C', repoPath, 'rev-parse', '--verify', 'origin/main'], { + await execFileAsync('git', ['-C', repoPath, 'rev-parse', '--verify', `${remote}/main`], { timeout: 10000, }); return toBranchName('main'); @@ -58,21 +64,21 @@ export async function getDefaultBranch(repoPath: RepoPath): Promise const err = error as Error & { stderr?: string }; const errorText = `${err.message} ${err.stderr ?? ''}`; - // Expected: origin/main doesn't exist — no safe default, fail fast + // Expected: /main doesn't exist — no safe default, fail fast if ( errorText.includes('Not a valid object name') || errorText.includes('Needed a single revision') || errorText.includes('unknown revision') ) { - getLog().warn({ repoPath }, 'default_branch_detection_failed'); + getLog().warn({ repoPath, remote }, 'default_branch_detection_failed'); throw new Error( - `Cannot detect default branch for ${repoPath}: neither origin/HEAD nor origin/main exist. ` + + `Cannot detect default branch for ${repoPath}: neither ${remote}/HEAD nor ${remote}/main exist. ` + 'Set worktree.baseBranch in .archon/config.yaml to specify the branch explicitly.' ); } // Unexpected error - surface it - getLog().error({ repoPath, err, stderr: err.stderr }, 'verify_origin_main_failed'); + getLog().error({ repoPath, remote, err, stderr: err.stderr }, 'verify_origin_main_failed'); throw new Error(`Failed to get default branch for ${repoPath}: ${err.message}`); } } diff --git a/packages/git/src/forge.test.ts b/packages/git/src/forge.test.ts new file mode 100644 index 0000000000..7927ae39a1 --- /dev/null +++ b/packages/git/src/forge.test.ts @@ -0,0 +1,142 @@ +import { describe, test, expect, beforeEach, afterEach, spyOn, type Mock } from 'bun:test'; +import * as repo from './repo'; +import { detectForge } from './forge'; +import { toRepoPath } from './types'; + +const testRepo = toRepoPath('/tmp/test-repo'); + +const FORGE_ENV_VARS = ['GITHUB_URL', 'GITEA_URL', 'GITLAB_URL'] as const; + +describe('detectForge', () => { + let getRemoteUrlSpy: Mock; + const savedEnv: Record = {}; + + beforeEach(() => { + getRemoteUrlSpy = spyOn(repo, 'getRemoteUrl'); + // Save + clear the env vars the detector reads so ambient values can't + // leak into assertions + for (const key of FORGE_ENV_VARS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + getRemoteUrlSpy.mockRestore(); + for (const [key, val] of Object.entries(savedEnv)) { + if (val === undefined) delete process.env[key]; + else process.env[key] = val; + } + }); + + test('detects GitHub from HTTPS remote', async () => { + getRemoteUrlSpy.mockResolvedValue('https://github.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('github'); + expect(info.apiBase).toBe('https://api.github.com'); + }); + + test('detects GitHub from SSH remote', async () => { + getRemoteUrlSpy.mockResolvedValue('git@github.com:owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('github'); + expect(info.apiBase).toBe('https://api.github.com'); + }); + + test('forwards a custom remote name to getRemoteUrl', async () => { + getRemoteUrlSpy.mockResolvedValue('https://github.com/owner/repo.git'); + const info = await detectForge(testRepo, 'upstream'); + expect(info.type).toBe('github'); + expect(getRemoteUrlSpy).toHaveBeenCalledWith(testRepo, 'upstream'); + }); + + test('detects GitHub Enterprise when GITHUB_URL env matches remote hostname', async () => { + process.env.GITHUB_URL = 'https://github.corp.com'; + getRemoteUrlSpy.mockResolvedValue('https://github.corp.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('github'); + expect(info.apiBase).toBe('https://github.corp.com/api/v3'); + }); + + test('detects Gitea when GITEA_URL env matches remote hostname', async () => { + process.env.GITEA_URL = 'https://gitea.example.com'; + getRemoteUrlSpy.mockResolvedValue('https://gitea.example.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitea'); + expect(info.apiBase).toBe('https://gitea.example.com/api/v1'); + }); + + test('detects Gitea with trailing slash in GITEA_URL', async () => { + process.env.GITEA_URL = 'https://gitea.example.com/'; + getRemoteUrlSpy.mockResolvedValue('https://gitea.example.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitea'); + expect(info.apiBase).toBe('https://gitea.example.com/api/v1'); + }); + + test('detects Gitea from SSH remote', async () => { + process.env.GITEA_URL = 'https://gitea.example.com'; + getRemoteUrlSpy.mockResolvedValue('git@gitea.example.com:owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitea'); + expect(info.apiBase).toBe('https://gitea.example.com/api/v1'); + }); + + test('detects GitLab from gitlab.com remote', async () => { + getRemoteUrlSpy.mockResolvedValue('https://gitlab.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitlab'); + expect(info.apiBase).toBe('https://gitlab.com/api/v4'); + }); + + test('detects GitLab from SSH remote', async () => { + getRemoteUrlSpy.mockResolvedValue('git@gitlab.com:owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitlab'); + expect(info.apiBase).toBe('https://gitlab.com/api/v4'); + }); + + test('detects self-hosted GitLab when GITLAB_URL env matches', async () => { + process.env.GITLAB_URL = 'https://gitlab.corp.com'; + getRemoteUrlSpy.mockResolvedValue('https://gitlab.corp.com/team/project.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitlab'); + expect(info.apiBase).toBe('https://gitlab.corp.com/api/v4'); + }); + + test('returns unknown for unrecognized remote', async () => { + getRemoteUrlSpy.mockResolvedValue('https://bitbucket.org/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('unknown'); + expect(info.apiBase).toBe(''); + }); + + test('returns unknown for a remote with no parseable hostname', async () => { + getRemoteUrlSpy.mockResolvedValue('/local/bare/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('unknown'); + expect(info.apiBase).toBe(''); + }); + + test('defaults to github when no remote exists', async () => { + getRemoteUrlSpy.mockResolvedValue(null); + const info = await detectForge(testRepo); + expect(info.type).toBe('github'); + expect(info.apiBase).toBe('https://api.github.com'); + }); + + test('ignores invalid GITEA_URL env value', async () => { + process.env.GITEA_URL = 'not-a-url'; + getRemoteUrlSpy.mockResolvedValue('https://gitea.example.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('unknown'); + expect(info.apiBase).toBe(''); + }); + + test('Gitea detection is case-insensitive on hostname', async () => { + process.env.GITEA_URL = 'https://Gitea.Example.COM'; + getRemoteUrlSpy.mockResolvedValue('https://gitea.example.com/owner/repo.git'); + const info = await detectForge(testRepo); + expect(info.type).toBe('gitea'); + }); +}); diff --git a/packages/git/src/forge.ts b/packages/git/src/forge.ts new file mode 100644 index 0000000000..b42c34ae28 --- /dev/null +++ b/packages/git/src/forge.ts @@ -0,0 +1,105 @@ +// Forge detection: identify the hosting platform (GitHub, Gitea, GitLab) +// behind a repository's remote (default `origin`), plus its REST API base URL. + +import { getRemoteUrl } from './repo'; +import type { RepoPath } from './types'; + +/** Forge platform detected from a repository's remote */ +export type ForgeType = 'github' | 'gitea' | 'gitlab' | 'unknown'; + +/** Result of forge detection: platform type + REST API base URL */ +export interface ForgeInfo { + type: ForgeType; + /** REST API base URL for the forge; empty string when the forge is unknown */ + apiBase: string; +} + +/** + * Extract the hostname from a git remote URL. + * Handles HTTPS (`https://github.com/owner/repo.git`) and + * SSH (`git@github.com:owner/repo.git`) formats. + */ +function extractHostname(remoteUrl: string): string | null { + try { + return new URL(remoteUrl).hostname.toLowerCase(); + } catch { + // Not a standard URL — fall through to SSH scp-like syntax + } + const sshMatch = /^[^@]+@([^:]+):/.exec(remoteUrl); + const host = sshMatch?.[1]; + return host ? host.toLowerCase() : null; +} + +/** + * Match a remote hostname against a self-hosted forge base URL taken from an + * env var (e.g. `GITEA_URL`). Returns the base URL with trailing slashes + * stripped when the hostnames match; null when the env var is unset, not a + * valid URL, or points at a different host. An invalid env value is ignored + * rather than misdetecting — the caller falls through to the next rule. + */ +function matchSelfHostedForge(envVar: string, hostname: string): string | null { + const value = process.env[envVar]; + if (!value) return null; + let envHostname: string; + try { + envHostname = new URL(value).hostname.toLowerCase(); + } catch { + return null; + } + if (envHostname !== hostname) return null; + return value.replace(/\/+$/, ''); +} + +/** + * Detect the forge platform behind a repository's remote (default: `origin`). + * + * Detection order: + * 1. `github.com` hostname → GitHub (`https://api.github.com`) + * 2. `GITHUB_URL` env hostname match → GitHub Enterprise (`/api/v3`) + * 3. `GITEA_URL` env hostname match → Gitea (`/api/v1`) + * 4. `gitlab.com` hostname → GitLab (`https://gitlab.com/api/v4`) + * 5. `GITLAB_URL` env hostname match → self-hosted GitLab (`/api/v4`) + * 6. No match → `unknown` (empty apiBase) + * + * A repository without the requested remote defaults to GitHub for backwards + * compatibility with existing GitHub-only callers. + * + * @param repoPath - Path to the git repository + * @param remote - Remote name to inspect (default: 'origin') + */ +export async function detectForge(repoPath: RepoPath, remote = 'origin'): Promise { + const remoteUrl = await getRemoteUrl(repoPath, remote); + if (!remoteUrl) { + return { type: 'github', apiBase: 'https://api.github.com' }; + } + + const hostname = extractHostname(remoteUrl); + if (!hostname) { + return { type: 'unknown', apiBase: '' }; + } + + if (hostname === 'github.com') { + return { type: 'github', apiBase: 'https://api.github.com' }; + } + + const githubEnterpriseBase = matchSelfHostedForge('GITHUB_URL', hostname); + if (githubEnterpriseBase) { + return { type: 'github', apiBase: `${githubEnterpriseBase}/api/v3` }; + } + + const giteaBase = matchSelfHostedForge('GITEA_URL', hostname); + if (giteaBase) { + return { type: 'gitea', apiBase: `${giteaBase}/api/v1` }; + } + + if (hostname === 'gitlab.com') { + return { type: 'gitlab', apiBase: 'https://gitlab.com/api/v4' }; + } + + const gitlabBase = matchSelfHostedForge('GITLAB_URL', hostname); + if (gitlabBase) { + return { type: 'gitlab', apiBase: `${gitlabBase}/api/v4` }; + } + + return { type: 'unknown', apiBase: '' }; +} diff --git a/packages/git/src/git.test.ts b/packages/git/src/git.test.ts index 27905f8963..37abc3eb26 100644 --- a/packages/git/src/git.test.ts +++ b/packages/git/src/git.test.ts @@ -2,13 +2,19 @@ import { describe, test, expect, beforeEach, afterEach, mock, spyOn, type Mock } import { writeFile, mkdir as realMkdir, rm } from 'fs/promises'; import { join } from 'path'; import { tmpdir, homedir } from 'os'; +// Loaded BEFORE mock.module replaces the module in the registry, so these are +// the REAL identity validators — the mock re-exports them (no drift possible). +import { parseOwnerRepo, resolveRepoProjectIdentity } from '@archon/paths'; // --------------------------------------------------------------------------- // Mock @archon/paths: suppress logger, pass-through path functions // --------------------------------------------------------------------------- -// Re-implement the path helpers inline so the mock doesn't depend on the real -// module (mock.module replaces the *entire* module). The path functions are -// trivial join() wrappers driven by env-vars, so duplication is acceptable. +// Re-implement the *path* helpers inline so the mock doesn't depend on the +// real module's env handling (mock.module replaces the *entire* module). The +// path functions are trivial join() wrappers driven by env-vars, so +// duplication is acceptable. The identity validators (parseOwnerRepo, +// resolveRepoProjectIdentity) are pure, so the mock passes the real ones +// through instead of mirroring them. // --------------------------------------------------------------------------- interface MockLogger { fatal: ReturnType; @@ -53,6 +59,8 @@ mock.module('@archon/paths', () => ({ getArchonWorkspacesPath: () => join(getArchonHome(), 'workspaces'), getProjectWorktreesPath: (owner: string, repo: string) => join(getArchonHome(), 'workspaces', owner, repo, 'worktrees'), + parseOwnerRepo, + resolveRepoProjectIdentity, })); // --------------------------------------------------------------------------- @@ -196,15 +204,16 @@ describe('git utilities', () => { test('returns workspace-scoped base for a local non-workspace repo (via path fallback)', () => { // New-model invariant: every repo resolves to workspace-scoped. For a repo - // living outside ~/.archon/workspaces/, owner/repo is derived from the last - // two path segments (extractOwnerRepo) so the worktree base is still stable. + // living outside ~/.archon/workspaces/, the identity is the shared + // _local/ fallback (resolveRepoProjectIdentity) — the same + // identity registration and log/artifact resolution use (#2227). delete process.env.WORKTREE_BASE; delete process.env.WORKSPACE_PATH; delete process.env.ARCHON_HOME; delete process.env.ARCHON_DOCKER; const result = git.getWorktreeBase('/workspace/my-repo'); expect(result).toEqual({ - base: join(homedir(), '.archon', 'workspaces', 'workspace', 'my-repo', 'worktrees'), + base: join(homedir(), '.archon', 'workspaces', '_local', 'my-repo', 'worktrees'), layout: 'workspace-scoped', }); }); @@ -216,7 +225,7 @@ describe('git utilities', () => { process.env.ARCHON_HOME = '/custom/archon'; const result = git.getWorktreeBase('/workspace/my-repo'); expect(result).toEqual({ - base: join('/custom/archon', 'workspaces', 'workspace', 'my-repo', 'worktrees'), + base: join('/custom/archon', 'workspaces', '_local', 'my-repo', 'worktrees'), layout: 'workspace-scoped', }); }); @@ -226,7 +235,7 @@ describe('git utilities', () => { process.env.ARCHON_DOCKER = 'true'; const result = git.getWorktreeBase('/workspace/my-repo'); expect(result).toEqual({ - base: join('/', '.archon', 'workspaces', 'workspace', 'my-repo', 'worktrees'), + base: join('/', '.archon', 'workspaces', '_local', 'my-repo', 'worktrees'), layout: 'workspace-scoped', }); }); @@ -281,19 +290,69 @@ describe('git utilities', () => { }); }); - test('ignores invalid codebaseName and falls back to path-derived owner/repo', () => { + test('ignores invalid codebaseName and falls back to _local/', () => { // "invalid-no-slash" doesn't parse as owner/repo; the layout still resolves - // to workspace-scoped using the last two segments of the repoPath. + // to workspace-scoped using the shared _local/ identity. delete process.env.WORKSPACE_PATH; delete process.env.ARCHON_DOCKER; delete process.env.ARCHON_HOME; const result = git.getWorktreeBase('/local/repo', 'invalid-no-slash'); expect(result).toEqual({ - base: join(homedir(), '.archon', 'workspaces', 'local', 'repo', 'worktrees'), + base: join(homedir(), '.archon', 'workspaces', '_local', 'repo', 'worktrees'), layout: 'workspace-scoped', }); }); + test('ignores SSH-URL-shaped codebaseName (contains ":" / "@") and falls back to _local', () => { + // Regression guard (PR #1583): a name like "git@host.example:org/repo" + // used to be split naively at the last slash — the colon smuggled into + // the owner path segment broke docker-compose short-form volume specs + // (`HOST:CONTAINER:OPT`) inside devcontainers. + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + delete process.env.ARCHON_HOME; + mockLogger.warn.mockClear(); + const result = git.getWorktreeBase( + '/srv/projects/widget-app', + 'git@git.example.net:acme/widget-app' + ); + expect(result).toEqual({ + base: join(homedir(), '.archon', 'workspaces', '_local', 'widget-app', 'worktrees'), + layout: 'workspace-scoped', + }); + // Rejection must stay observable — operators spot misconfigured + // codebases through this warn. + expect(mockLogger.warn).toHaveBeenCalledWith( + { codebaseName: 'git@git.example.net:acme/widget-app' }, + 'worktree.invalid_codebase_name_format' + ); + // Check only the path below homedir — on Windows the home directory + // itself contains ":" in the drive letter (e.g. C:\Users\...). + const relativeToHome = result.base.slice(homedir().length); + expect(relativeToHome).not.toContain(':'); + expect(relativeToHome).not.toContain('@'); + }); + + test('resolves single-segment checkout paths via _local fallback (no throw)', () => { + // The historical last-two-segments heuristic threw for paths like + // /workspace (#2022); the shared fallback handles them. + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + delete process.env.ARCHON_HOME; + const result = git.getWorktreeBase('/workspace'); + expect(result).toEqual({ + base: join(homedir(), '.archon', 'workspaces', '_local', 'workspace', 'worktrees'), + layout: 'workspace-scoped', + }); + }); + + test('throws for a degenerate repo path with no usable basename', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + delete process.env.ARCHON_HOME; + expect(() => git.getWorktreeBase('/')).toThrow('Cannot derive a project identity'); + }); + test('repoLocal override wins over workspace-scoped default', () => { delete process.env.WORKSPACE_PATH; delete process.env.ARCHON_DOCKER; @@ -382,37 +441,6 @@ describe('git utilities', () => { }); }); - describe('extractOwnerRepo', () => { - test('extracts owner and repo from a multi-segment path', () => { - const result = git.extractOwnerRepo(git.toRepoPath('/home/user/owner/repo')); - expect(result).toEqual({ owner: 'owner', repo: 'repo' }); - }); - - test('extracts owner and repo from exactly 2-segment path', () => { - const result = git.extractOwnerRepo(git.toRepoPath('/owner/repo')); - expect(result).toEqual({ owner: 'owner', repo: 'repo' }); - }); - - test('extracts owner and repo from Windows-style path', () => { - const result = git.extractOwnerRepo( - 'C:\\Users\\dev\\owner\\repo' as ReturnType - ); - expect(result).toEqual({ owner: 'owner', repo: 'repo' }); - }); - - test('throws when repoPath has fewer than 2 segments', () => { - expect(() => git.extractOwnerRepo(git.toRepoPath('/repo'))).toThrow( - 'Cannot extract owner/repo from path "/repo"' - ); - }); - - test('throws when repoPath is empty', () => { - expect(() => git.extractOwnerRepo('' as ReturnType)).toThrow( - 'Cannot extract owner/repo from path ""' - ); - }); - }); - describe('worktreeExists', () => { test('returns true when path and .git exist', async () => { await realMkdir(join(testDir, 'worktree-test'), { recursive: true }); @@ -792,6 +820,38 @@ branch refs/heads/feature/auth expect(result).toBe('master'); }); + test('uses custom remote for symbolic-ref lookup and prefix stripping', async () => { + execSpy.mockResolvedValue({ stdout: 'upstream/main\n', stderr: '' }); + + const result = await git.getDefaultBranch('/workspace/repo', 'upstream'); + + expect(result).toBe('main'); + expect(execSpy).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace/repo', 'symbolic-ref', 'refs/remotes/upstream/HEAD', '--short'], + expect.any(Object) + ); + }); + + test('falls back to /main and names the remote in the failure error', async () => { + execSpy.mockImplementation(async (_cmd: string, args: string[]) => { + if (args.includes('symbolic-ref')) { + throw new Error('fatal: ref refs/remotes/mar/HEAD is not a symbolic ref'); + } + throw new Error('fatal: Needed a single revision'); + }); + + await expect(git.getDefaultBranch('/workspace/repo', 'mar')).rejects.toThrow( + 'neither mar/HEAD nor mar/main exist' + ); + // Verify the fallback probed mar/main, not origin/main + expect(execSpy).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace/repo', 'rev-parse', '--verify', 'mar/main'], + expect.any(Object) + ); + }); + test('returns non-standard branch from symbolic-ref (origin/develop)', async () => { execSpy.mockResolvedValue({ stdout: 'origin/develop\n', stderr: '' }); @@ -1600,7 +1660,7 @@ branch refs/heads/feature/auth newHead: 'abc12345', updated: false, }); - expect(getDefaultBranchSpy).toHaveBeenCalledWith('/workspace/repo'); + expect(getDefaultBranchSpy).toHaveBeenCalledWith('/workspace/repo', 'origin'); }); test('throws actionable error when configured branch not found on remote', async () => { @@ -1861,6 +1921,139 @@ branch refs/heads/feature/auth 'workspace.merge_base_check_failed' ); }); + + test('fetches and resets from custom remote when provided in options', async () => { + execSpy.mockResolvedValue({ stdout: '', stderr: '' }); + + await git.syncWorkspace('/workspace/repo', 'main', { mode: 'reset', remote: 'mar' }); + + expect(execSpy).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace/repo', 'fetch', 'mar', 'main'], + expect.any(Object) + ); + + const resetCalls = execSpy.mock.calls.filter((call: unknown[]) => { + const args = call[1] as string[]; + return args.includes('reset'); + }); + expect(resetCalls).toHaveLength(1); + expect(resetCalls[0][1]).toEqual(['-C', '/workspace/repo', 'reset', '--hard', 'mar/main']); + }); + + test('classifies state against the custom remote ref in fast-forward mode', async () => { + execSpy.mockImplementation(async (_cmd: string, args: string[]) => { + if (args.includes('status')) return { stdout: '', stderr: '' }; + if (args.includes('rev-parse') && args.includes('--short=8')) { + return { stdout: 'abc12345\n', stderr: '' }; + } + if (args.includes('rev-parse') && args.includes('HEAD')) { + return { stdout: 'abc12345abcdef\n', stderr: '' }; + } + if (args.includes('rev-parse') && args.includes('upstream/main')) { + return { stdout: 'abc12345abcdef\n', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }); + + const result = await git.syncWorkspace('/workspace/repo', 'main', { remote: 'upstream' }); + + expect(result.state).toBe('in_sync'); + // The state classification must rev-parse upstream/main, not origin/main + expect(execSpy).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace/repo', 'rev-parse', 'upstream/main'], + expect.any(Object) + ); + }); + + test('passes custom remote to getDefaultBranch when baseBranch not provided', async () => { + execSpy.mockResolvedValue({ stdout: '', stderr: '' }); + getDefaultBranchSpy.mockResolvedValue('develop'); + + await git.syncWorkspace('/workspace/repo', undefined, { remote: 'upstream' }); + + expect(getDefaultBranchSpy).toHaveBeenCalledWith('/workspace/repo', 'upstream'); + }); + + test('includes custom remote name in fetch error message', async () => { + execSpy.mockImplementation(async (_cmd: string, args: string[]) => { + if (args.includes('fetch')) { + throw new Error("fatal: 'mar' does not appear to be a git repository"); + } + return { stdout: '', stderr: '' }; + }); + + await expect(git.syncWorkspace('/workspace/repo', 'main', { remote: 'mar' })).rejects.toThrow( + 'Sync fetch from mar/main failed' + ); + }); + + test('names the custom remote in the configured-branch-missing error', async () => { + execSpy.mockImplementation(async (_cmd: string, args: string[]) => { + if (args.includes('fetch')) { + throw new Error("fatal: couldn't find remote ref does-not-exist"); + } + return { stdout: '', stderr: '' }; + }); + + await expect( + git.syncWorkspace('/workspace/repo', 'does-not-exist', { remote: 'mar' }) + ).rejects.toThrow("Configured base branch 'does-not-exist' not found on remote 'mar'"); + }); + }); + + describe('getDefaultRemote', () => { + let execSpy: Mock; + + beforeEach(() => { + execSpy = spyOn(git, 'execFileAsync'); + }); + + afterEach(() => { + execSpy.mockRestore(); + }); + + test('returns origin when it exists among multiple remotes', async () => { + execSpy.mockResolvedValue({ stdout: 'upstream\norigin\n', stderr: '' }); + + const result = await git.getDefaultRemote('/workspace/repo'); + expect(result).toBe('origin'); + }); + + test('returns sole remote when only one is configured', async () => { + execSpy.mockResolvedValue({ stdout: 'mar\n', stderr: '' }); + + const result = await git.getDefaultRemote('/workspace/repo'); + expect(result).toBe('mar'); + }); + + test('returns null when multiple non-origin remotes exist', async () => { + execSpy.mockResolvedValue({ stdout: 'jan\nfeb\nmar\n', stderr: '' }); + + const result = await git.getDefaultRemote('/workspace/repo'); + expect(result).toBeNull(); + }); + + test('returns null when no remotes are configured', async () => { + execSpy.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await git.getDefaultRemote('/workspace/repo'); + expect(result).toBeNull(); + }); + + test('propagates git errors instead of swallowing them', async () => { + execSpy.mockRejectedValue(new Error('not a git repository')); + + await expect(git.getDefaultRemote('/workspace/repo')).rejects.toThrow('not a git repository'); + }); + + test('handles CRLF line endings from git output', async () => { + execSpy.mockResolvedValue({ stdout: 'origin\r\nupstream\r\n', stderr: '' }); + + const result = await git.getDefaultRemote('/workspace/repo'); + expect(result).toBe('origin'); + }); }); describe('cloneRepository', () => { @@ -1883,10 +2076,28 @@ branch refs/heads/feature/auth expect(execSpy).toHaveBeenCalledWith( 'git', ['clone', 'https://github.com/owner/repo.git', '/tmp/target'], - { timeout: 120000 } + { + timeout: 120000, + env: expect.objectContaining({ GIT_TERMINAL_PROMPT: '0' }) as NodeJS.ProcessEnv, + } ); }); + test('passes GIT_TERMINAL_PROMPT=0 to the git clone subprocess', async () => { + execSpy.mockResolvedValue({ stdout: '', stderr: '' }); + + await git.cloneRepository('https://github.com/owner/repo.git', '/tmp/target'); + + const env = execSpy.mock.calls[0]![2]?.env ?? {}; + expect(env.GIT_TERMINAL_PROMPT).toBe('0'); + // The rest of the environment must be inherited, not stripped. On + // Windows the key can be 'Path' — spreading process.env keeps the + // original casing — so locate the path key case-insensitively. + const pathKey = Object.keys(env).find(k => k.toLowerCase() === 'path'); + expect(pathKey).toBeDefined(); + expect(env[pathKey!]).toBe(process.env[pathKey!]); + }); + test('constructs authenticated URL with token', async () => { execSpy.mockResolvedValue({ stdout: '', stderr: '' }); @@ -1979,6 +2190,22 @@ branch refs/heads/feature/auth }); }); + test('fetches and resets using a custom remote', async () => { + execSpy.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await git.syncRepository('/workspace/repo', 'main', 'upstream'); + + expect(result).toEqual({ ok: true, value: undefined }); + expect(execSpy).toHaveBeenCalledWith('git', ['fetch', 'upstream'], { + cwd: '/workspace/repo', + timeout: 60000, + }); + expect(execSpy).toHaveBeenCalledWith('git', ['reset', '--hard', 'upstream/main'], { + cwd: '/workspace/repo', + timeout: 30000, + }); + }); + test('skips reset if fetch fails', async () => { execSpy.mockRejectedValue(new Error('fatal: unable to access')); @@ -2210,6 +2437,21 @@ branch refs/heads/feature/auth expect(result).toBe('https://github.com/owner/repo.git'); }); + test('queries a custom remote when provided', async () => { + execSpy.mockResolvedValue({ + stdout: 'https://github.com/owner/repo.git\n', + stderr: '', + }); + + await git.getRemoteUrl('/workspace/repo', 'upstream'); + + expect(execSpy).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace/repo', 'remote', 'get-url', 'upstream'], + expect.any(Object) + ); + }); + test('returns null when no remote configured', async () => { execSpy.mockRejectedValue(new Error('fatal: No such remote')); diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index c10844a203..c7c59cbcdd 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -17,7 +17,6 @@ export { execFileAsync, mkdirAsync, resolveBashPath } from './exec'; // Worktree operations export { - extractOwnerRepo, getWorktreeBase, isProjectScopedWorktreeBase, worktreeExists, @@ -44,9 +43,14 @@ export { getLastCommitDate, } from './branch'; +// Forge detection +export { detectForge } from './forge'; +export type { ForgeType, ForgeInfo } from './forge'; + // Repository operations export { findRepoRoot, + getDefaultRemote, getRemoteUrl, listChildRepos, syncWorkspace, diff --git a/packages/git/src/repo.ts b/packages/git/src/repo.ts index 8fd2d704aa..2d94caebe0 100644 --- a/packages/git/src/repo.ts +++ b/packages/git/src/repo.ts @@ -75,12 +75,37 @@ export async function listChildRepos(rootPath: string): Promise { } /** - * Get the remote URL for origin (if it exists) - * Returns null if no remote is configured + * Detect the default remote name for a repository. + * + * Resolution order: + * 1. 'origin' — if it exists (standard Git convention) + * 2. The sole remote — if only one is configured + * 3. null — ambiguous (multiple non-origin remotes) or no remotes at all + * + * Callers can override via `worktree.remote` in `.archon/config.yaml`. + * Git errors (not a repo, permission denied) propagate — a null return + * always means "no unambiguous remote", never a swallowed failure. */ -export async function getRemoteUrl(repoPath: RepoPath): Promise { +export async function getDefaultRemote(repoPath: RepoPath): Promise { + const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote'], { timeout: 10000 }); + // Split on LF or CRLF (Windows git) and trim each entry defensively + const remotes = stdout + .split(/\r?\n/) + .map(r => r.trim()) + .filter(r => r.length > 0); + if (remotes.length === 0) return null; + if (remotes.includes('origin')) return 'origin'; + if (remotes.length === 1) return remotes[0]; + return null; +} + +/** + * Get the URL configured for a git remote (default: 'origin'). + * Returns null if the remote does not exist. + */ +export async function getRemoteUrl(repoPath: RepoPath, remote = 'origin'): Promise { try { - const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], { + const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote', 'get-url', remote], { timeout: 10000, }); return stdout.trim() || null; @@ -88,7 +113,7 @@ export async function getRemoteUrl(repoPath: RepoPath): Promise { const err = error as Error & { stderr?: string }; const errorText = `${err.message} ${err.stderr ?? ''}`; - // Expected: no remote named origin + // Expected: no remote with that name if ( errorText.includes('No such remote') || errorText.includes('does not have a url configured') @@ -97,19 +122,19 @@ export async function getRemoteUrl(repoPath: RepoPath): Promise { } // Unexpected error - surface it - getLog().error({ repoPath, err, stderr: err.stderr }, 'get_remote_url_failed'); - throw new Error(`Failed to get remote URL for ${repoPath}: ${err.message}`); + getLog().error({ repoPath, remote, err, stderr: err.stderr }, 'get_remote_url_failed'); + throw new Error(`Failed to get remote URL for ${repoPath} (remote: ${remote}): ${err.message}`); } } /** - * Sync workspace with remote origin. - * Fetches the base branch from origin, then updates local state according to mode. + * Sync workspace with its remote. + * Fetches the base branch from the remote, then updates local state according to mode. * * Modes: * - fast-forward (default): fetch, classify state, and fast-forward only when safe. * - fetch-only: fetch and classify without touching the working tree. - * - reset: fetch and hard-reset to origin/. This is destructive and must be + * - reset: fetch and hard-reset to /. This is destructive and must be * requested explicitly by callers that own the checkout. * * Branch resolution: @@ -119,21 +144,24 @@ export async function getRemoteUrl(repoPath: RepoPath): Promise { * * @param workspacePath - Path to the workspace (canonical repo, not worktree) * @param baseBranch - Optional base branch name (e.g., 'main', 'develop'). If omitted, auto-detects default branch - * @param options - Optional sync mode. Defaults to non-destructive fast-forward. + * @param options - Optional settings: + * - `mode`: sync mode. Defaults to non-destructive fast-forward. + * - `remote` (default 'origin'): git remote name to fetch from. * @returns Branch used plus whether sync was performed * @throws Error with actionable message if configured branch doesn't exist */ export async function syncWorkspace( workspacePath: RepoPath, baseBranch?: BranchName, - options?: { mode?: WorkspaceSyncMode } + options?: { mode?: WorkspaceSyncMode; remote?: string } ): Promise { const mode = options?.mode ?? 'fast-forward'; - const branchToSync = baseBranch ?? (await getDefaultBranch(workspacePath)); + const remote = options?.remote ?? 'origin'; + const branchToSync = baseBranch ?? (await getDefaultBranch(workspacePath, remote)); - // Fetch from origin to ensure origin/ is up-to-date + // Fetch from the remote to ensure / is up-to-date try { - await execFileAsync('git', ['-C', workspacePath, 'fetch', 'origin', branchToSync], { + await execFileAsync('git', ['-C', workspacePath, 'fetch', remote, branchToSync], { timeout: 60000, }); } catch (error) { @@ -146,18 +174,18 @@ export async function syncWorkspace( (errorMessage.includes("couldn't find remote ref") || errorMessage.includes('not found')) ) { throw new Error( - `Configured base branch '${baseBranch}' not found on remote. ` + + `Configured base branch '${baseBranch}' not found on remote '${remote}'. ` + 'Either create the branch, update worktree.baseBranch in .archon/config.yaml, ' + 'or remove the setting to use the auto-detected default branch.' ); } - throw new Error(`Sync fetch from origin/${branchToSync} failed: ${err.message}`); + throw new Error(`Sync fetch from ${remote}/${branchToSync} failed: ${err.message}`); } const previousHead = await readShortSha(workspacePath, 'HEAD'); if (mode !== 'reset') { - const state = await classifyWorkspaceState(workspacePath, branchToSync); + const state = await classifyWorkspaceState(workspacePath, branchToSync, remote); if (mode === 'fetch-only' || state !== 'behind') { return unchangedSyncResult(branchToSync, mode, state, previousHead); @@ -171,14 +199,14 @@ export async function syncWorkspace( try { await execFileAsync( 'git', - ['-C', workspacePath, 'merge', '--ff-only', `origin/${branchToSync}`], + ['-C', workspacePath, 'merge', '--ff-only', `${remote}/${branchToSync}`], { timeout: 30000, } ); } catch (error) { const err = error as Error; - throw new Error(`Fast-forward to origin/${branchToSync} failed: ${err.message}`); + throw new Error(`Fast-forward to ${remote}/${branchToSync} failed: ${err.message}`); } const newHead = await readShortSha(workspacePath, 'HEAD'); @@ -193,15 +221,19 @@ export async function syncWorkspace( }; } - // Hard-reset local working tree to match origin — only safe for Archon-managed + // Hard-reset local working tree to match the remote — only safe for Archon-managed // clones, never for a user's local working directory. try { - await execFileAsync('git', ['-C', workspacePath, 'reset', '--hard', `origin/${branchToSync}`], { - timeout: 30000, - }); + await execFileAsync( + 'git', + ['-C', workspacePath, 'reset', '--hard', `${remote}/${branchToSync}`], + { + timeout: 30000, + } + ); } catch (error) { const err = error as Error; - throw new Error(`Reset to origin/${branchToSync} failed: ${err.message}`); + throw new Error(`Reset to ${remote}/${branchToSync} failed: ${err.message}`); } const newHead = await readShortSha(workspacePath, 'HEAD'); @@ -306,14 +338,15 @@ async function isAncestor( async function classifyWorkspaceState( workspacePath: RepoPath, - branchToSync: BranchName + branchToSync: BranchName, + remote = 'origin' ): Promise { if (await hasTrackedModifications(workspacePath)) { return 'dirty'; } const localSha = await readSha(workspacePath, 'HEAD'); - const remoteRef = `origin/${branchToSync}`; + const remoteRef = `${remote}/${branchToSync}`; const remoteSha = await readSha(workspacePath, remoteRef); if (localSha === remoteSha) { @@ -351,7 +384,12 @@ export async function cloneRepository( cloneUrl = parsed.toString(); } - await execFileAsync('git', ['clone', cloneUrl, targetPath], { timeout: 120000 }); + // GIT_TERMINAL_PROMPT=0 turns any missing-creds scenario into an + // immediate, readable error instead of a hung stdin credential prompt. + await execFileAsync('git', ['clone', cloneUrl, targetPath], { + timeout: 120000, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); return { ok: true, value: undefined }; } catch (error) { const err = error as Error; @@ -386,18 +424,20 @@ export async function cloneRepository( * * @param repoPath - Path to the local repository * @param branch - Branch to sync to (e.g., 'main') + * @param remote - Remote name to fetch from (default: 'origin') * @returns GitResult */ export async function syncRepository( repoPath: RepoPath, - branch: BranchName + branch: BranchName, + remote = 'origin' ): Promise> { try { - await execFileAsync('git', ['fetch', 'origin'], { cwd: repoPath, timeout: 60000 }); + await execFileAsync('git', ['fetch', remote], { cwd: repoPath, timeout: 60000 }); } catch (error) { const err = error as Error & { stderr?: string }; const errorText = `${err.message} ${err.stderr ?? ''}`.toLowerCase(); - getLog().error({ err, repoPath, branch }, 'sync_repository_fetch_failed'); + getLog().error({ err, repoPath, branch, remote }, 'sync_repository_fetch_failed'); if (errorText.includes('not a git repository')) { return { ok: false, error: { code: 'not_a_repo', path: repoPath } }; @@ -412,7 +452,7 @@ export async function syncRepository( } try { - await execFileAsync('git', ['reset', '--hard', `origin/${branch}`], { + await execFileAsync('git', ['reset', '--hard', `${remote}/${branch}`], { cwd: repoPath, timeout: 30000, }); @@ -424,7 +464,7 @@ export async function syncRepository( return { ok: false, error: { code: 'branch_not_found', branch } }; } - getLog().error({ err, repoPath, branch }, 'sync_repository_reset_failed'); + getLog().error({ err, repoPath, branch, remote }, 'sync_repository_reset_failed'); return { ok: false, error: { code: 'unknown', message: `Reset failed: ${err.message}` } }; } diff --git a/packages/git/src/worktree.ts b/packages/git/src/worktree.ts index 32ad2dbbc8..07c0d6cc72 100644 --- a/packages/git/src/worktree.ts +++ b/packages/git/src/worktree.ts @@ -1,6 +1,12 @@ import { readFile, access } from 'fs/promises'; import { join, resolve } from 'path'; -import { createLogger, getArchonWorkspacesPath, getProjectWorktreesPath } from '@archon/paths'; +import { + createLogger, + getArchonWorkspacesPath, + getProjectWorktreesPath, + parseOwnerRepo, + resolveRepoProjectIdentity, +} from '@archon/paths'; import { execFileAsync } from './exec'; import type { RepoPath, BranchName, WorktreePath, WorktreeInfo } from './types'; import { toRepoPath, toBranchName, toWorktreePath } from './types'; @@ -43,23 +49,28 @@ export interface WorktreeBaseOverride { * Resolve the `{ owner, repo }` identity used to scope archon-managed worktrees. * * Precedence: - * 1. Explicit `codebaseName` in `owner/repo` format (from the database / web UI) + * 1. Explicit `codebaseName` in strict `owner/repo` format (from the database / + * web UI), validated by the shared `parseOwnerRepo()` * 2. Path segments when `repoPath` is already under `~/.archon/workspaces/owner/repo/` - * 3. Last two path segments of `repoPath` (works for any local checkout) + * 3. The shared project-identity fallback: `_local/` + * (`resolveRepoProjectIdentity()` in `@archon/paths`) * - * The third fallback is what lets non-cloned / locally-registered repos still - * land in the workspace-scoped layout — every repo gets a stable owner/repo - * identity derived from its filesystem path. + * Identity decisions are delegated to `@archon/paths` so the worktree base + * always agrees with the storage identity that registration writes to disk and + * that log/artifact path resolution uses (#2227). Historically the fallback + * derived owner/repo from the last two path segments, inventing a junk "owner" + * from the checkout's parent directory and disagreeing with the `_local/` + * storage tree (#2132). */ function resolveOwnerRepo( repoPath: RepoPath, codebaseName?: string ): { owner: string; repo: string } { if (codebaseName) { - const parts = codebaseName.split('/'); - if (parts.length === 2 && parts[0] && parts[1]) { - return { owner: parts[0], repo: parts[1] }; - } + // Reject names containing ':' or '@' — they would become path segments and + // break docker-compose short-form volume specs (HOST:CONTAINER:OPT) (#1583). + const parsed = parseOwnerRepo(codebaseName); + if (parsed) return parsed; getLog().warn({ codebaseName }, 'worktree.invalid_codebase_name_format'); } const workspacesPath = getArchonWorkspacesPath(); @@ -70,10 +81,18 @@ function resolveOwnerRepo( return { owner: parts[0], repo: parts[1] }; } } - // Fallback: derive from path basename/parent-basename — covers local-registered - // repos that never lived under workspaces/. Delegates to extractOwnerRepo() - // which throws on pathologically short paths. - return extractOwnerRepo(repoPath); + // Fallback: the shared storage-identity resolver — the same + // `_local/` identity that registration creates on disk and that + // the workflow executor uses for logs/artifacts, so all project paths agree. + // The name (if any) was already rejected above, so this resolves purely from + // the path. + const identity = resolveRepoProjectIdentity(codebaseName ?? '', repoPath); + if (!identity) { + throw new Error( + `Cannot derive a project identity from path "${repoPath}": basename is empty or a dot segment` + ); + } + return identity; } /** @@ -377,17 +396,3 @@ export async function verifyWorktreeOwnership( ); } } - -/** - * Extract owner and repo name from the last two segments of a repository path. - * Throws if the path has fewer than 2 non-empty segments. - */ -export function extractOwnerRepo(repoPath: RepoPath): { owner: string; repo: string } { - const parts = repoPath.split(/[/\\]/).filter(p => p.length > 0); - if (parts.length < 2) { - throw new Error( - `Cannot extract owner/repo from path "${repoPath}": expected at least 2 path segments` - ); - } - return { owner: parts[parts.length - 2], repo: parts[parts.length - 1] }; -} diff --git a/packages/isolation/package.json b/packages/isolation/package.json index aad67ad249..062659793b 100644 --- a/packages/isolation/package.json +++ b/packages/isolation/package.json @@ -1,6 +1,6 @@ { "name": "@archon/isolation", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", diff --git a/packages/isolation/src/container/overlay-scripts.test.ts b/packages/isolation/src/container/overlay-scripts.test.ts index cfdf47d7ed..ca78755d6f 100644 --- a/packages/isolation/src/container/overlay-scripts.test.ts +++ b/packages/isolation/src/container/overlay-scripts.test.ts @@ -11,6 +11,7 @@ */ import { describe, test, expect } from 'bun:test'; import { execFileSync } from 'child_process'; +import { resolveBashPath } from '@archon/git'; import { mkdtempSync, mkdirSync, @@ -43,6 +44,14 @@ const hasMkfifo = (() => { } })(); +// Resolved ONCE, via the same helper every production bash spawn uses. A bare +// `execFileSync('bash', …)` is the one thing this file must not do on Windows: +// CreateProcess searches System32 before PATH, so it resolves to the WSL +// launcher (`C:\Windows\System32\bash.exe`) rather than Git-Bash — the exact +// trap resolveBashPath() was written for (#1326). Resolving eagerly also keeps +// the per-test cost to the spawn itself. +const bashPath = resolveBashPath(); + /** Run a walk script under bash; returns NUL-split records + raw stdout/stderr. */ function runScript( script: string, @@ -54,7 +63,7 @@ function runScript( let stderr = ''; let code = 0; try { - stdout = execFileSync('bash', ['-c', script, 'archon-overlay', upper, other, ws], { + stdout = execFileSync(bashPath, ['-c', script, 'archon-overlay', upper, other, ws], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/packages/isolation/src/pr-state.test.ts b/packages/isolation/src/pr-state.test.ts index 4dd67ab182..717f85a2c6 100644 --- a/packages/isolation/src/pr-state.test.ts +++ b/packages/isolation/src/pr-state.test.ts @@ -83,6 +83,19 @@ describe('getPrState', () => { expect(result).toBe('NONE'); }); + test('queries the custom remote when provided', async () => { + setupGhResponse('https://github.com/owner/repo.git', '[{"state":"MERGED"}]'); + + const result = await getPrState(BRANCH, REPO, undefined, 'upstream'); + + expect(result).toBe('MERGED'); + expect(mockExecFileAsync).toHaveBeenCalledWith( + 'git', + ['-C', REPO, 'remote', 'get-url', 'upstream'], + expect.any(Object) + ); + }); + test('uses cache on subsequent lookups for same branch', async () => { setupGhResponse('https://github.com/owner/repo.git', '[{"state":"MERGED"}]'); const cache = new Map(); diff --git a/packages/isolation/src/pr-state.ts b/packages/isolation/src/pr-state.ts index 2f4e3c87c3..a9e0ecc838 100644 --- a/packages/isolation/src/pr-state.ts +++ b/packages/isolation/src/pr-state.ts @@ -26,11 +26,13 @@ export type PrState = 'MERGED' | 'CLOSED' | 'OPEN' | 'NONE'; * - 'NONE' if no PR exists, gh is unavailable, or the remote is not GitHub * * The optional `cache` map dedupes lookups within a single cleanup invocation. + * The optional `remote` selects which git remote to inspect (default: 'origin'). */ export async function getPrState( branch: BranchName, repoPath: RepoPath, - cache?: Map + cache?: Map, + remote = 'origin' ): Promise { const cached = cache?.get(branch); if (cached !== undefined) { @@ -40,7 +42,7 @@ export async function getPrState( // Check whether the remote is on GitHub. Non-GitHub remotes are out of scope. let remoteUrl = ''; try { - const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], { + const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote', 'get-url', remote], { timeout: 10000, }); remoteUrl = stdout.trim(); diff --git a/packages/isolation/src/providers/worktree.test.ts b/packages/isolation/src/providers/worktree.test.ts index b5c9d42cc0..bb539e0bf0 100644 --- a/packages/isolation/src/providers/worktree.test.ts +++ b/packages/isolation/src/providers/worktree.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach, spyOn, mock, type Mock } from 'bun:test'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; // Fixed test home — path assertions use this constant; no duplication of production isDocker() logic. const TEST_ARCHON_HOME = '/test/.archon'; @@ -22,6 +22,41 @@ mock.module('@archon/paths', () => ({ getProjectWorktreesPath: (owner: string, repo: string) => join(TEST_ARCHON_HOME, 'workspaces', owner, repo, 'worktrees'), isDocker: () => false, + // Mirrors of the real @archon/paths identity helpers (worktree.ts delegates + // owner/repo resolution to these — #2227). + parseOwnerRepo: (name: string): { owner: string; repo: string } | null => { + const parts = name.split('/'); + if (parts.length !== 2) return null; + const [owner, repo] = parts; + if (!owner || !repo) return null; + if (owner === '.' || owner === '..' || repo === '.' || repo === '..') return null; + const SAFE_NAME = /^[a-zA-Z0-9._-]+$/; + if (!SAFE_NAME.test(owner) || !SAFE_NAME.test(repo)) return null; + return { owner, repo }; + }, + resolveRepoProjectIdentity: ( + name: string, + cwd: string + ): { owner: string; repo: string } | null => { + const parts = name.split('/'); + if (parts.length === 2 && parts[0] && parts[1]) { + const SAFE_NAME = /^[a-zA-Z0-9._-]+$/; + const [owner, repo] = parts; + if ( + owner !== '.' && + owner !== '..' && + repo !== '.' && + repo !== '..' && + SAFE_NAME.test(owner) && + SAFE_NAME.test(repo) + ) { + return { owner, repo }; + } + } + const repo = basename(cwd); + if (repo === '' || repo === '.' || repo === '..') return null; + return { owner: '_local', repo }; + }, })); import * as git from '@archon/git'; @@ -30,6 +65,7 @@ import type { IsolationRequest, PRIsolationRequest, RepoConfigLoader } from '../ // Track sync function calls for testing let getDefaultBranchSpy: Mock; +let getDefaultRemoteSpy: Mock; let syncWorkspaceSpy: Mock; // Mock fs.promises.access for destroy() existence check @@ -64,6 +100,7 @@ describe('WorktreeProvider', () => { findWorktreeByBranchSpy = spyOn(git, 'findWorktreeByBranch'); getCanonicalRepoPathSpy = spyOn(git, 'getCanonicalRepoPath'); getDefaultBranchSpy = spyOn(git, 'getDefaultBranch'); + getDefaultRemoteSpy = spyOn(git, 'getDefaultRemote'); syncWorkspaceSpy = spyOn(git, 'syncWorkspace'); // Default mocks @@ -89,6 +126,7 @@ describe('WorktreeProvider', () => { // Default mocks for workspace sync getDefaultBranchSpy.mockResolvedValue('main'); + getDefaultRemoteSpy.mockResolvedValue('origin'); syncWorkspaceSpy.mockResolvedValue({ branch: 'main', synced: true, @@ -108,6 +146,7 @@ describe('WorktreeProvider', () => { findWorktreeByBranchSpy.mockRestore(); getCanonicalRepoPathSpy.mockRestore(); getDefaultBranchSpy.mockRestore(); + getDefaultRemoteSpy.mockRestore(); syncWorkspaceSpy.mockRestore(); mockAccess.mockClear(); mockReadFile.mockClear(); @@ -2287,6 +2326,7 @@ describe('WorktreeProvider', () => { // syncWorkspace called with undefined → triggers auto-detect via getDefaultBranch expect(syncWorkspaceSpy).toHaveBeenCalledWith('/workspace/owner/repo', undefined, { mode: 'fast-forward', + remote: 'origin', }); }); @@ -2311,6 +2351,7 @@ describe('WorktreeProvider', () => { expect(syncWorkspaceSpy).toHaveBeenCalledWith('/workspace/owner/repo', 'develop', { mode: 'fast-forward', + remote: 'origin', }); expect(execSpy).toHaveBeenCalledWith( 'git', @@ -2339,6 +2380,7 @@ describe('WorktreeProvider', () => { expect(syncWorkspaceSpy).toHaveBeenCalledWith('/workspace/owner/repo', 'main', { mode: 'fast-forward', + remote: 'origin', }); }); @@ -2355,7 +2397,7 @@ describe('WorktreeProvider', () => { expect(syncWorkspaceSpy).toHaveBeenCalledWith( '/test/.archon/workspaces/owner/repo/source', undefined, - { mode: 'reset' } + { mode: 'reset', remote: 'origin' } ); }); @@ -2376,6 +2418,7 @@ describe('WorktreeProvider', () => { // fromBranch is the start-point for the branch, not for sync — sync auto-detects expect(syncWorkspaceSpy).toHaveBeenCalledWith('/workspace/owner/repo', undefined, { mode: 'fast-forward', + remote: 'origin', }); }); @@ -2395,6 +2438,7 @@ describe('WorktreeProvider', () => { expect(syncWorkspaceSpy).toHaveBeenCalledWith('/workspace/owner/repo', 'main', { mode: 'fast-forward', + remote: 'origin', }); }); @@ -2414,6 +2458,7 @@ describe('WorktreeProvider', () => { // fromBranch is ignored for non-task types, so syncWorkspace gets undefined → auto-detect expect(syncWorkspaceSpy).toHaveBeenCalledWith('/workspace/owner/repo', undefined, { mode: 'fast-forward', + remote: 'origin', }); }); @@ -2428,6 +2473,7 @@ describe('WorktreeProvider', () => { expect(syncWorkspaceSpy).toHaveBeenCalledWith('/workspace/owner/repo', 'develop', { mode: 'fast-forward', + remote: 'origin', }); expect(getDefaultBranchSpy).not.toHaveBeenCalled(); }); @@ -2437,7 +2483,7 @@ describe('WorktreeProvider', () => { worktreeExistsSpy.mockResolvedValue(false); await expect(provider.create(baseRequest)).rejects.toThrow( - 'Failed to fetch base branch from origin' + "Failed to fetch base branch from 'origin'" ); }); @@ -2480,30 +2526,36 @@ describe('WorktreeProvider', () => { syncWorkspaceSpy.mockRejectedValue(new Error('Network timeout')); await expect(provider.create(baseRequest)).rejects.toThrow( - 'Failed to fetch base branch from origin' + "Failed to fetch base branch from 'origin'" ); }); }); describe('cross-platform path handling', () => { - test('getWorktreePath handles Unix-style paths', () => { + test('getWorktreePath resolves non-workspace Unix paths via _local fallback', () => { + // Path outside the workspaces tree with no codebaseName — resolves to the + // shared _local/ storage identity (#2227), not the historical + // last-two-segments heuristic. const request: IsolationRequest = { codebaseId: 'cb-123', - canonicalRepoPath: '/home/dev/.archon/workspaces/owner/repo', + canonicalRepoPath: '/home/dev/projects/repo', workflowType: 'issue', identifier: '42', }; const branchName = provider.generateBranchName(request); const path = provider.getWorktreePath(request, branchName); - expect(path).toContain('owner'); - expect(path).toContain('repo'); + expect(path).toBe( + join(TEST_ARCHON_HOME, 'workspaces', '_local', 'repo', 'worktrees', branchName) + ); expect(path).toContain('issue-42'); }); - test('getWorktreePath handles Windows-style paths', () => { + test('getWorktreePath handles Windows-style separators under workspaces/', () => { + // The workspaces-prefix branch splits on both / and \ so a Windows-style + // repo path under the workspaces tree still yields owner/repo. const request: IsolationRequest = { codebaseId: 'cb-123', - canonicalRepoPath: 'C:\\Users\\dev\\.archon\\workspaces\\owner\\repo', + canonicalRepoPath: `${join(TEST_ARCHON_HOME, 'workspaces')}\\owner\\repo`, workflowType: 'issue', identifier: '42', }; @@ -2514,10 +2566,10 @@ describe('WorktreeProvider', () => { expect(path).toContain('issue-42'); }); - test('getWorktreePath handles mixed separator paths', () => { + test('getWorktreePath handles mixed separator paths under workspaces/', () => { const request: IsolationRequest = { codebaseId: 'cb-123', - canonicalRepoPath: 'C:/Users/dev\\.archon/workspaces\\owner/repo', + canonicalRepoPath: `${join(TEST_ARCHON_HOME, 'workspaces')}/owner\\repo`, workflowType: 'issue', identifier: '42', }; @@ -2528,7 +2580,9 @@ describe('WorktreeProvider', () => { expect(path).toContain('issue-42'); }); - test('getWorktreePath throws when repoPath has fewer than 2 segments', () => { + test('getWorktreePath resolves single-segment repo paths via _local fallback', () => { + // The historical last-two-segments heuristic threw for these (#2022); + // the shared fallback resolves them like any other checkout. const request: IsolationRequest = { codebaseId: 'cb-123', canonicalRepoPath: '/repo', // only one segment @@ -2536,8 +2590,21 @@ describe('WorktreeProvider', () => { identifier: '42', }; const branchName = provider.generateBranchName(request); + expect(provider.getWorktreePath(request, branchName)).toBe( + join(TEST_ARCHON_HOME, 'workspaces', '_local', 'repo', 'worktrees', branchName) + ); + }); + + test('getWorktreePath throws for a degenerate repo path with no basename', () => { + const request: IsolationRequest = { + codebaseId: 'cb-123', + canonicalRepoPath: '/', + workflowType: 'issue', + identifier: '42', + }; + const branchName = provider.generateBranchName(request); expect(() => provider.getWorktreePath(request, branchName)).toThrow( - 'Cannot extract owner/repo from path "/repo"' + 'Cannot derive a project identity' ); }); @@ -2978,4 +3045,192 @@ describe('WorktreeProvider', () => { expect(worktreeExistsSpy).toHaveBeenCalledTimes(1); }); }); + + describe('custom remote support', () => { + const baseRequest: IsolationRequest = { + codebaseId: 'cb-123', + canonicalRepoPath: '/workspace/repo', + workflowType: 'issue', + identifier: '42', + }; + + beforeEach(() => { + worktreeExistsSpy.mockResolvedValue(false); + }); + + test('uses configured remote from worktree config', async () => { + const customProvider = new WorktreeProvider(async () => ({ + baseBranch: 'main', + remote: 'mar', + })); + + await customProvider.create(baseRequest); + + // syncWorkspace receives the configured remote + expect(syncWorkspaceSpy).toHaveBeenCalledWith( + '/workspace/repo', + 'main', + expect.objectContaining({ remote: 'mar' }) + ); + // Explicit config wins — no auto-detection call + expect(getDefaultRemoteSpy).not.toHaveBeenCalled(); + + // worktree add uses mar/main as the start-point + expect(execSpy).toHaveBeenCalledWith( + 'git', + expect.arrayContaining(['worktree', 'add', '-b', 'archon/issue-42', 'mar/main']), + expect.any(Object) + ); + }); + + test('auto-detects remote when not configured', async () => { + getDefaultRemoteSpy.mockResolvedValue('upstream'); + const autoProvider = new WorktreeProvider(async () => ({ baseBranch: 'main' })); + + await autoProvider.create(baseRequest); + + expect(syncWorkspaceSpy).toHaveBeenCalledWith( + '/workspace/repo', + 'main', + expect.objectContaining({ remote: 'upstream' }) + ); + }); + + test('fromBranch start-point is not remote-prefixed (task workflow)', async () => { + const taskRequest: IsolationRequest = { + ...baseRequest, + workflowType: 'task', + identifier: 'my-feature', + fromBranch: 'develop', + }; + + const customProvider = new WorktreeProvider(async () => ({ + baseBranch: 'main', + remote: 'upstream', + })); + + await customProvider.create(taskRequest); + + // fromBranch overrides / as the start-point + expect(execSpy).toHaveBeenCalledWith( + 'git', + expect.arrayContaining(['worktree', 'add', '-b', 'archon/task-my-feature', 'develop']), + expect.any(Object) + ); + }); + + test('throws actionable error when remote is ambiguous', async () => { + getDefaultRemoteSpy.mockResolvedValue(null); + execSpy.mockImplementation(async (_cmd: string, args: string[]) => { + // `git remote` listing for the error message + if (args.includes('remote') && !args.includes('get-url')) { + return { stdout: 'jan\nfeb\nmar\n', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }); + + const ambiguousProvider = new WorktreeProvider(async () => ({ baseBranch: 'main' })); + + await expect(ambiguousProvider.create(baseRequest)).rejects.toThrow( + /Cannot determine git remote.*jan, feb, mar.*Set worktree\.remote/s + ); + // No sync attempted from an unknown remote + expect(syncWorkspaceSpy).not.toHaveBeenCalled(); + }); + + test('uses custom remote for same-repo PR fetch and tracking', async () => { + const prRequest: PRIsolationRequest = { + codebaseId: 'cb-123', + canonicalRepoPath: '/workspace/repo', + workflowType: 'pr', + identifier: '42', + prBranch: 'feature/auth', + isForkPR: false, + }; + + const customProvider = new WorktreeProvider(async () => ({ + baseBranch: 'main', + remote: 'upstream', + })); + + await customProvider.create(prRequest); + + // Fetch uses the custom remote + expect(execSpy).toHaveBeenCalledWith( + 'git', + expect.arrayContaining(['-C', '/workspace/repo', 'fetch', 'upstream', 'feature/auth']), + expect.any(Object) + ); + + // Branch tracking uses the custom remote + expect(execSpy).toHaveBeenCalledWith( + 'git', + expect.arrayContaining(['branch', '--set-upstream-to', 'upstream/feature/auth']), + expect.any(Object) + ); + }); + + test('uses custom remote for fork PR fetch', async () => { + const forkPrRequest: PRIsolationRequest = { + codebaseId: 'cb-123', + canonicalRepoPath: '/workspace/repo', + workflowType: 'pr', + identifier: '42', + prBranch: 'feature/auth', + isForkPR: true, + }; + + const customProvider = new WorktreeProvider(async () => ({ + baseBranch: 'main', + remote: 'upstream', + })); + + await customProvider.create(forkPrRequest); + + expect(execSpy).toHaveBeenCalledWith( + 'git', + expect.arrayContaining([ + '-C', + '/workspace/repo', + 'fetch', + 'upstream', + 'pull/42/head:pr-42-review', + ]), + expect.any(Object) + ); + }); + + test('uses custom remote for remote branch deletion', async () => { + mockAccess.mockResolvedValue(undefined); + + await provider.destroy('worktree-path', { + branchName: git.toBranchName('archon/issue-42'), + canonicalRepoPath: git.toRepoPath('/workspace/repo'), + deleteRemoteBranch: true, + remote: 'upstream', + }); + + expect(execSpy).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace/repo', 'push', 'upstream', '--delete', 'archon/issue-42'], + expect.any(Object) + ); + }); + + test('defaults remote branch deletion to origin when no remote passed', async () => { + mockAccess.mockResolvedValue(undefined); + + await provider.destroy('worktree-path', { + branchName: git.toBranchName('archon/issue-42'), + canonicalRepoPath: git.toRepoPath('/workspace/repo'), + deleteRemoteBranch: true, + }); + + expect(execSpy).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace/repo', 'push', 'origin', '--delete', 'archon/issue-42'], + expect.any(Object) + ); + }); + }); }); diff --git a/packages/isolation/src/providers/worktree.ts b/packages/isolation/src/providers/worktree.ts index 509fd507b3..c6eedda3a6 100644 --- a/packages/isolation/src/providers/worktree.ts +++ b/packages/isolation/src/providers/worktree.ts @@ -13,6 +13,7 @@ import { execFileAsync, findWorktreeByBranch, getCanonicalRepoPath, + getDefaultRemote, getWorktreeBase, listWorktrees, mkdirAsync, @@ -292,7 +293,8 @@ export class WorktreeProvider implements IIsolationProvider { result.remoteBranchDeleted = await this.deleteRemoteBranchTracked( repoPath, options.branchName, - result + result, + options.remote ); } } @@ -381,10 +383,11 @@ export class WorktreeProvider implements IIsolationProvider { private async deleteRemoteBranchTracked( repoPath: string, branchName: string, - result: DestroyResult + result: DestroyResult, + remote = 'origin' ): Promise { try { - await execFileAsync('git', ['-C', repoPath, 'push', 'origin', '--delete', branchName], { + await execFileAsync('git', ['-C', repoPath, 'push', remote, '--delete', branchName], { timeout: GIT_OPERATION_TIMEOUT_MS, }); getLog().debug({ repoPath, branchName }, 'remote_branch_deleted'); @@ -704,11 +707,14 @@ export class WorktreeProvider implements IIsolationProvider { ): Promise<{ warnings: string[] }> { const repoPath = request.canonicalRepoPath; + // Resolve git remote name: explicit config > auto-detect > actionable error + const remote = await this.resolveRemote(repoPath, worktreeConfig?.remote); + // Sync uses explicit repo config first, then the registered codebase's // default branch (request.baseBranch), then auto-detects via getDefaultBranch. // request.fromBranch is the start-point for worktree creation, not a sync target. const preferredBaseBranch = worktreeConfig?.baseBranch ?? request.baseBranch; - const baseBranch = await this.syncWorkspaceBeforeCreate(repoPath, preferredBaseBranch); + const baseBranch = await this.syncWorkspaceBeforeCreate(repoPath, preferredBaseBranch, remote); const override: WorktreeBaseOverride = { repoLocal: resolveRepoLocalOverride(worktreeConfig?.path, repoPath), @@ -720,10 +726,10 @@ export class WorktreeProvider implements IIsolationProvider { if (isPRIsolationRequest(request)) { // For PRs: fetch and checkout the PR branch (actual or synthetic) - await this.createFromPR(request, worktreePath); + await this.createFromPR(request, worktreePath, remote); } else { // For issues, tasks, threads: create new branch - await this.createNewBranch(request, repoPath, worktreePath, branchName, baseBranch); + await this.createNewBranch(request, repoPath, worktreePath, branchName, baseBranch, remote); } // Stamp the originating user's git identity on this worktree so workflow @@ -782,6 +788,45 @@ export class WorktreeProvider implements IIsolationProvider { } } + /** + * Resolve the git remote name to use for all fetch/push operations. + * + * Resolution order: explicit config (worktree.remote) > auto-detect via + * getDefaultRemote() > actionable error when ambiguous. + */ + private async resolveRemote(repoPath: RepoPath, configuredRemote?: string): Promise { + const configured = configuredRemote?.trim(); + if (configured) { + getLog().debug({ repoPath, remote: configured }, 'worktree.remote_from_config'); + return configured; + } + + const detected = await getDefaultRemote(repoPath); + if (detected) { + if (detected !== 'origin') { + // Non-standard remote picked up automatically — log at info so the + // choice is visible when debugging fetch/push behavior. + getLog().info({ repoPath, remote: detected }, 'worktree.remote_auto_detected'); + } + return detected; + } + + // Ambiguous (multiple non-origin remotes) — list them for an actionable error + let remoteList = ''; + try { + const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote'], { timeout: 10000 }); + remoteList = stdout.trim().split(/\r?\n/).join(', '); + } catch { + // Best-effort for error message only + } + + throw new Error( + `Cannot determine git remote for ${repoPath}: no 'origin' remote found and ` + + `multiple remotes exist (${remoteList}). ` + + 'Set worktree.remote in .archon/config.yaml to specify which remote to use.' + ); + } + /** * Sync workspace with remote before creating a new worktree * Ensures new work starts from the latest code on the base branch. @@ -802,11 +847,12 @@ export class WorktreeProvider implements IIsolationProvider { */ private async syncWorkspaceBeforeCreate( repoPath: RepoPath, - configuredBaseBranch?: string + configuredBaseBranch?: string, + remote = 'origin' ): Promise { try { getLog().debug( - { repoPath, branch: configuredBaseBranch ?? 'auto-detect' }, + { repoPath, branch: configuredBaseBranch ?? 'auto-detect', remote }, 'workspace_sync_starting' ); // Only hard-reset for Archon-managed clones when creating isolated worktrees. @@ -817,9 +863,9 @@ export class WorktreeProvider implements IIsolationProvider { const { branch } = await syncWorkspace( repoPath, configuredBaseBranch ? toBranchName(configuredBaseBranch) : undefined, - { mode: isManagedClone ? 'reset' : 'fast-forward' } + { mode: isManagedClone ? 'reset' : 'fast-forward', remote } ); - getLog().debug({ repoPath, branch }, 'workspace_synced'); + getLog().debug({ repoPath, branch, remote }, 'workspace_synced'); return branch; } catch (error) { const err = error as Error & { code?: string }; @@ -842,8 +888,8 @@ export class WorktreeProvider implements IIsolationProvider { } else { // Network errors, timeouts — cannot guarantee correct start-point throw new Error( - `Failed to fetch base branch from origin: ${err.message}. ` + - 'Check your network connection and try again.' + `Failed to fetch base branch from '${remote}': ${err.message}. ` + + 'Check your network connection and remote configuration.' ); } } @@ -924,7 +970,11 @@ export class WorktreeProvider implements IIsolationProvider { * When prSha is provided, the worktree is initially created at the specific * commit (detached HEAD), then a local tracking branch is created. */ - private async createFromPR(request: PRIsolationRequest, worktreePath: string): Promise { + private async createFromPR( + request: PRIsolationRequest, + worktreePath: string, + remote = 'origin' + ): Promise { // Clean up any orphan directory before creating worktree await this.cleanOrphanDirectoryIfExists(worktreePath); @@ -934,10 +984,10 @@ export class WorktreeProvider implements IIsolationProvider { try { if (!request.isForkPR) { // Same-repo PR: Use the actual branch so changes push directly to PR - await this.createFromSameRepoPR(repoPath, worktreePath, request.prBranch); + await this.createFromSameRepoPR(repoPath, worktreePath, request.prBranch, remote); } else { // Fork PR: Use synthetic review branch - await this.createFromForkPR(repoPath, worktreePath, prNumber, request.prSha); + await this.createFromForkPR(repoPath, worktreePath, prNumber, remote, request.prSha); } } catch (error) { // Clean up orphaned git-registered worktree from partial failure @@ -954,10 +1004,11 @@ export class WorktreeProvider implements IIsolationProvider { private async createFromSameRepoPR( repoPath: string, worktreePath: string, - prBranch: string + prBranch: string, + remote = 'origin' ): Promise { // Fetch the PR's actual branch - await execFileAsync('git', ['-C', repoPath, 'fetch', 'origin', prBranch], { + await execFileAsync('git', ['-C', repoPath, 'fetch', remote, prBranch], { timeout: GIT_OPERATION_TIMEOUT_MS, }); @@ -966,7 +1017,7 @@ export class WorktreeProvider implements IIsolationProvider { // If branch doesn't exist locally, create it tracking remote await execFileAsync( 'git', - ['-C', repoPath, 'worktree', 'add', worktreePath, '-b', prBranch, `origin/${prBranch}`], + ['-C', repoPath, 'worktree', 'add', worktreePath, '-b', prBranch, `${remote}/${prBranch}`], { timeout: GIT_OPERATION_TIMEOUT_MS } ); } catch (error) { @@ -985,7 +1036,7 @@ export class WorktreeProvider implements IIsolationProvider { try { await execFileAsync( 'git', - ['-C', worktreePath, 'branch', '--set-upstream-to', `origin/${prBranch}`], + ['-C', worktreePath, 'branch', '--set-upstream-to', `${remote}/${prBranch}`], { timeout: GIT_OPERATION_TIMEOUT_MS } ); } catch (trackingError) { @@ -1004,13 +1055,14 @@ export class WorktreeProvider implements IIsolationProvider { repoPath: string, worktreePath: string, prNumber: string, + remote = 'origin', prSha?: string ): Promise { const reviewBranch = `pr-${prNumber}-review`; if (prSha) { // SHA provided: create at specific commit for reproducible reviews - await execFileAsync('git', ['-C', repoPath, 'fetch', 'origin', `pull/${prNumber}/head`], { + await execFileAsync('git', ['-C', repoPath, 'fetch', remote, `pull/${prNumber}/head`], { timeout: GIT_OPERATION_TIMEOUT_MS, }); @@ -1034,7 +1086,7 @@ export class WorktreeProvider implements IIsolationProvider { () => execFileAsync( 'git', - ['-C', repoPath, 'fetch', 'origin', `pull/${prNumber}/head:${reviewBranch}`], + ['-C', repoPath, 'fetch', remote, `pull/${prNumber}/head:${reviewBranch}`], { timeout: GIT_OPERATION_TIMEOUT_MS } ), reviewBranch @@ -1079,7 +1131,8 @@ export class WorktreeProvider implements IIsolationProvider { repoPath: string, worktreePath: string, branchName: string, - baseBranch: string + baseBranch: string, + remote = 'origin' ): Promise { // Clean up any orphan directory before creating worktree await this.cleanOrphanDirectoryIfExists(worktreePath); @@ -1088,7 +1141,7 @@ export class WorktreeProvider implements IIsolationProvider { const startPoint = request.workflowType === 'task' && request.fromBranch ? request.fromBranch - : `origin/${baseBranch}`; + : `${remote}/${baseBranch}`; try { // `--no-track` keeps `branch..merge` unset; otherwise `gh pr view` diff --git a/packages/isolation/src/types.ts b/packages/isolation/src/types.ts index 0bb16952ec..fa0b1a5dc6 100644 --- a/packages/isolation/src/types.ts +++ b/packages/isolation/src/types.ts @@ -166,6 +166,8 @@ export interface WorktreeDestroyOptions extends DestroyOptions { canonicalRepoPath?: RepoPath; /** Delete the remote branch (best-effort, e.g., after PR merge) */ deleteRemoteBranch?: boolean; + /** Git remote name for remote branch deletion (default: 'origin') */ + remote?: string; } /** @@ -293,6 +295,23 @@ export interface WorktreeCreateConfig { * @example '.worktrees' */ path?: string; + /** + * Git remote name to use for fetch/push operations. + * + * When set, all git operations (fetch, push, branch tracking) use this + * remote instead of 'origin'. Useful for repos with multiple remotes or + * non-standard naming conventions. + * + * When omitted, auto-detected via `getDefaultRemote()`: + * 1. 'origin' if it exists + * 2. The sole remote if only one is configured + * 3. null when ambiguous — worktree creation then fails with an + * actionable error listing the available remotes + * + * Sourced from `.archon/config.yaml > worktree.remote` in the repo. + * @example 'upstream' + */ + remote?: string; } export type RepoConfigLoader = (repoPath: string) => Promise; diff --git a/packages/paths/package.json b/packages/paths/package.json index 03ce49dbec..0606340d43 100644 --- a/packages/paths/package.json +++ b/packages/paths/package.json @@ -1,11 +1,12 @@ { "name": "@archon/paths", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", "exports": { ".": "./src/index.ts", + "./bundled-build": "./src/bundled-build.ts", "./strip-cwd-env": "./src/strip-cwd-env.ts", "./strip-cwd-env-boot": "./src/strip-cwd-env-boot.ts", "./env-loader": "./src/env-loader.ts" diff --git a/packages/paths/src/archon-paths.test.ts b/packages/paths/src/archon-paths.test.ts index 86576af9b3..253d456bce 100644 --- a/packages/paths/src/archon-paths.test.ts +++ b/packages/paths/src/archon-paths.test.ts @@ -1,13 +1,15 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { homedir, tmpdir } from 'os'; import { join } from 'path'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { mkdir, rm, writeFile, lstat, readlink, symlink as fsSymlink } from 'fs/promises'; const isWindows = process.platform === 'win32'; import { isDocker, + isWSL, + getWSLDistroName, getArchonHome, getArchonWorkspacesPath, ensureArchonWorkspacesPath, @@ -49,7 +51,14 @@ import { } from './archon-paths'; /** All env vars that path functions depend on */ -const ENV_VARS = ['WORKSPACE_PATH', 'WORKTREE_BASE', 'ARCHON_HOME', 'ARCHON_DOCKER', 'HOME']; +const ENV_VARS = [ + 'WORKSPACE_PATH', + 'WORKTREE_BASE', + 'ARCHON_HOME', + 'ARCHON_DOCKER', + 'HOME', + 'WSL_DISTRO_NAME', +]; /** * Save and restore environment variables around each test. @@ -88,6 +97,47 @@ describe('archon-paths', () => { }); }); + describe('isWSL', () => { + test('returns true when WSL_DISTRO_NAME is set', () => { + process.env.WSL_DISTRO_NAME = 'Ubuntu'; + expect(isWSL()).toBe(true); + }); + + test('falls back to /proc/sys/kernel/osrelease when WSL_DISTRO_NAME is unset', () => { + delete process.env.WSL_DISTRO_NAME; + // Derive the expectation from the same source as the implementation: + // real Linux CI → no "microsoft" → false; WSL2 host → "microsoft" → true. + let expected = false; + try { + expected = readFileSync('/proc/sys/kernel/osrelease', 'utf8') + .toLowerCase() + .includes('microsoft'); + } catch { + expected = false; + } + expect(isWSL()).toBe(expected); + }); + }); + + describe('getWSLDistroName', () => { + test('returns the WSL_DISTRO_NAME env var when set', () => { + process.env.WSL_DISTRO_NAME = 'Debian'; + expect(getWSLDistroName()).toBe('Debian'); + }); + + test('returns undefined when WSL_DISTRO_NAME is unset', () => { + delete process.env.WSL_DISTRO_NAME; + expect(getWSLDistroName()).toBeUndefined(); + }); + + test('returns the empty string when WSL_DISTRO_NAME is set but empty', () => { + // Pins current behaviour: '' passes through (callers filter falsy values), + // so a future `|| undefined` refactor would change observable behaviour. + process.env.WSL_DISTRO_NAME = ''; + expect(getWSLDistroName()).toBe(''); + }); + }); + describe('isDocker', () => { test('returns true when WORKSPACE_PATH is /workspace', () => { process.env.WORKSPACE_PATH = '/workspace'; diff --git a/packages/paths/src/archon-paths.ts b/packages/paths/src/archon-paths.ts index 6460c4be24..95f38f6897 100644 --- a/packages/paths/src/archon-paths.ts +++ b/packages/paths/src/archon-paths.ts @@ -17,6 +17,7 @@ import { join, dirname, normalize, basename } from 'path'; import { homedir } from 'os'; import { access, mkdir, symlink, lstat, readdir, readlink, realpath, rm, stat } from 'fs/promises'; +import { readFileSync } from 'fs'; import { createLogger } from './logger'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ @@ -48,6 +49,47 @@ export function isDocker(): boolean { ); } +/** + * Detect if running inside WSL (Windows Subsystem for Linux). + * + * Two signals (either is sufficient): + * - `WSL_DISTRO_NAME` env var is set (always true inside a WSL distro) + * - `/proc/sys/kernel/osrelease` contains "microsoft" (lower-cased) + * + * Used by callers that need to emit Windows-host-friendly URIs + * (`vscode://vscode-remote/wsl+/...` instead of `vscode://file/...`) + * when the server is inside WSL but the browser is on the Windows host. + */ +export function isWSL(): boolean { + if (process.env.WSL_DISTRO_NAME) return true; + + try { + const release = readFileSync('/proc/sys/kernel/osrelease', 'utf8').toLowerCase(); + return release.includes('microsoft'); + } catch { + // Unable to read the fallback signal; return false conservatively. This + // can be a false negative (WSL with the env var absent and an unreadable + // /proc/sys/kernel/osrelease), in which case callers fall back to the + // plain vscode://file/... URI. + return false; + } +} + +/** + * Return the configured `WSL_DISTRO_NAME` value (`Ubuntu`, `Debian`, …) if + * present, otherwise `undefined`. WSL sets this env var in every distro + * shell; it may also be set manually to opt into the WSL URI path. + * + * Note this only reads the env var — `isWSL()` may still be true via the + * `/proc` fallback while this returns `undefined`. Without the env var we + * don't know what distro to put into a + * `vscode://vscode-remote/wsl+/...` URI, and guessing is worse than + * a sentinel that callers can fall back on. + */ +export function getWSLDistroName(): string | undefined { + return process.env.WSL_DISTRO_NAME ?? undefined; +} + /** * Get the Archon home directory * - Docker: /.archon @@ -395,9 +437,11 @@ export function parseOwnerRepo(name: string): { owner: string; repo: string } | /** * Resolve the `{ owner, repo }` storage identity for a registered *repo*-kind * codebase. This is the single source of truth that keeps `registerRepository()` - * (which creates the on-disk `owner/repo` tree) and the log/artifact path - * resolvers in agreement — a mismatch between the two dropped no-remote repos' - * logs/artifacts into `/.archon` instead of `ARCHON_HOME` (#2132). + * (which creates the on-disk `owner/repo` tree), the log/artifact path + * resolvers, and the worktree base (`getWorktreeBase()` in `@archon/git`) in + * agreement — a mismatch between them dropped no-remote repos' logs/artifacts + * into `/.archon` instead of `ARCHON_HOME` (#2132), and later split + * worktrees and storage across two different workspace trees (#2227). * * - A `name` in exact `owner/repo` form (clones, web-registered repos) → that * owner/repo. diff --git a/packages/paths/src/index.ts b/packages/paths/src/index.ts index 9429025dd1..b7d1020f2a 100644 --- a/packages/paths/src/index.ts +++ b/packages/paths/src/index.ts @@ -2,6 +2,8 @@ export { expandTilde, isDocker, + isWSL, + getWSLDistroName, getArchonHome, getArchonWorkspacesPath, ensureArchonWorkspacesPath, diff --git a/packages/paths/src/telemetry.ts b/packages/paths/src/telemetry.ts index 48e7fe0cd4..90b2e78c89 100644 --- a/packages/paths/src/telemetry.ts +++ b/packages/paths/src/telemetry.ts @@ -586,7 +586,13 @@ export interface ChatTurnProperties { } /** Categorical terminal exit reason — a fixed enum, never raw error text. */ -export type WorkflowExitReason = 'no_nodes_completed' | 'node_error' | 'unhandled_error'; +export type WorkflowExitReason = + | 'no_nodes_completed' + | 'node_error' + | 'unhandled_error' + // Evidence gate (#2230): all nodes succeeded but `evidence_policy.required` + // found no `$ARTIFACTS_DIR/evidence.json`, so the run was marked failed. + | 'evidence_missing'; /** * Categorical failure class derived from the engine's error classifier diff --git a/packages/providers/package.json b/packages/providers/package.json index c51a7f63a6..3d82e83f0e 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -1,6 +1,6 @@ { "name": "@archon/providers", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", diff --git a/packages/providers/src/claude/capabilities.ts b/packages/providers/src/claude/capabilities.ts index 106372a73b..9c6decc1f7 100644 --- a/packages/providers/src/claude/capabilities.ts +++ b/packages/providers/src/claude/capabilities.ts @@ -63,6 +63,7 @@ export const CLAUDE_CAPABILITIES: ProviderCapabilities = { thinkingControl: true, fallbackModel: true, sandbox: true, + settingSources: true, // per-node override of the SDK's settingSources option nativeTools: true, containerExec: true, // spawns the CLI in-container via spawnClaudeCodeProcess }; diff --git a/packages/providers/src/claude/provider.test.ts b/packages/providers/src/claude/provider.test.ts index 54fb3d91d8..04febd4ea3 100644 --- a/packages/providers/src/claude/provider.test.ts +++ b/packages/providers/src/claude/provider.test.ts @@ -132,6 +132,7 @@ describe('ClaudeProvider', () => { thinkingControl: true, fallbackModel: true, sandbox: true, + settingSources: true, nativeTools: true, }); }); @@ -235,7 +236,7 @@ describe('ClaudeProvider', () => { }); }); - test('yields result with cost, stopReason, numTurns, modelUsage when SDK provides them', async () => { + test('yields result with cost, stopReason, numTurns, and a resolved model when SDK provides them', async () => { mockQuery.mockImplementation(async function* () { yield { type: 'result', @@ -243,11 +244,11 @@ describe('ClaudeProvider', () => { total_cost_usd: 0.0042, stop_reason: 'end_turn', num_turns: 3, - model_usage: { + modelUsage: { 'claude-sonnet-4-6': { - input_tokens: 100, - output_tokens: 50, - cache_read_input_tokens: 10, + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 10, }, }, }; @@ -265,17 +266,65 @@ describe('ClaudeProvider', () => { cost: 0.0042, stopReason: 'end_turn', numTurns: 3, - modelUsage: { - 'claude-sonnet-4-6': { - input_tokens: 100, - output_tokens: 50, - cache_read_input_tokens: 10, + resolvedModel: { id: 'claude-sonnet-4-6' }, + }); + // Single-model usage is unambiguous — no ambiguity warning. + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + test('picks the greatest-output-token model and warns when modelUsage has multiple keys', async () => { + // A subagent pinned via `agents:` (or a fallbackModel takeover) puts more + // than one model in the record, and key order carries no guarantee — the + // main model here is deliberately NOT first. + mockQuery.mockImplementation(async function* () { + yield { + type: 'result', + session_id: 'sid-multi-model', + modelUsage: { + 'claude-haiku-4-5-20251001': { + inputTokens: 400, + outputTokens: 20, + cacheReadInputTokens: 0, + }, + 'claude-sonnet-5': { + inputTokens: 120, + outputTokens: 900, + cacheReadInputTokens: 10, + }, }, + }; + }); + + const chunks = []; + for await (const chunk of client.sendQuery('test', '/workspace')) { + chunks.push(chunk); + } + + expect(chunks[0]).toMatchObject({ resolvedModel: { id: 'claude-sonnet-5' } }); + expect(mockLogger.warn).toHaveBeenCalledWith( + { + models: ['claude-haiku-4-5-20251001', 'claude-sonnet-5'], + selected: 'claude-sonnet-5', }, + 'claude.resolved_model_ambiguous' + ); + }); + + test('omits resolvedModel when modelUsage is an empty record', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', session_id: 'sid-empty-usage', modelUsage: {} }; }); + + const chunks = []; + for await (const chunk of client.sendQuery('test', '/workspace')) { + chunks.push(chunk); + } + + expect(chunks[0]).not.toHaveProperty('resolvedModel'); + expect(mockLogger.warn).not.toHaveBeenCalled(); }); - test('omits cost, stopReason, numTurns, modelUsage when SDK result has none', async () => { + test('omits cost, stopReason, numTurns, and resolvedModel when SDK result has none', async () => { mockQuery.mockImplementation(async function* () { yield { type: 'result', session_id: 'sid-bare' }; }); @@ -288,7 +337,7 @@ describe('ClaudeProvider', () => { expect(chunks[0]).not.toHaveProperty('cost'); expect(chunks[0]).not.toHaveProperty('stopReason'); expect(chunks[0]).not.toHaveProperty('numTurns'); - expect(chunks[0]).not.toHaveProperty('modelUsage'); + expect(chunks[0]).not.toHaveProperty('resolvedModel'); }); test('omits stopReason when stop_reason is null', async () => { @@ -1208,6 +1257,41 @@ describe('ClaudeProvider', () => { expect(callArgs.options.settingSources).toEqual(['project']); }); + test('per-node settingSources override wins over the assistant default', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', session_id: 'test-session' }; + }); + + for await (const _ of client.sendQuery('test', '/tmp', undefined, { + nodeConfig: { settingSources: ['project'] }, + assistantConfig: { settingSources: ['project', 'user'] }, + })) { + // consume + } + + expect(mockQuery).toHaveBeenCalledTimes(1); + const callArgs = mockQuery.mock.calls[0][0] as { options: Record }; + expect(callArgs.options.settingSources).toEqual(['project']); + }); + + test('per-node settingSources applies when no assistant default is set', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', session_id: 'test-session' }; + }); + + for await (const _ of client.sendQuery('test', '/tmp', undefined, { + nodeConfig: { settingSources: [] }, + })) { + // consume + } + + expect(mockQuery).toHaveBeenCalledTimes(1); + const callArgs = mockQuery.mock.calls[0][0] as { options: Record }; + // An explicit empty array is a valid opt-out of ALL setting sources — + // it must not fall through to the ['project', 'user'] default. + expect(callArgs.options.settingSources).toEqual([]); + }); + test('passes env from requestOptions into SDK options', async () => { mockQuery.mockImplementation(async function* () { yield { type: 'result', session_id: 'sid' }; diff --git a/packages/providers/src/claude/provider.ts b/packages/providers/src/claude/provider.ts index 7847b0f2d0..8720cc4e87 100644 --- a/packages/providers/src/claude/provider.ts +++ b/packages/providers/src/claude/provider.ts @@ -34,7 +34,8 @@ import { type HookCallback, type HookCallbackMatcher, type SDKAssistantMessageError, - type TerminalReason, + type SDKResultMessage, + type ModelUsage, } from '@anthropic-ai/claude-agent-sdk'; import type { IAgentProvider, @@ -88,6 +89,44 @@ function normalizeClaudeUsage(usage?: { }; } +/** + * Pick the concrete model that did the bulk of a turn's work from the SDK's + * per-model usage record. + * + * More than one entry is reachable for a single turn: a subagent pinned to + * another model via `agents:`, or a `fallbackModel` takeover. Key insertion + * order happens to put the main model first today, but nothing in the SDK + * guarantees it — so select by greatest output-token count (the main model + * produces the bulk of the output) and WARN whenever the record is ambiguous, + * so a multi-model turn is visible instead of silently collapsed. + * + * `modelUsage` is non-optional in the SDK types but arrives over an IPC + * boundary, so the absent/empty cases stay guarded — absence yields undefined + * and the caller omits `resolvedModel` entirely rather than inventing a value. + * On a tie (or output counts the SDK didn't send) the first key wins, which is + * exactly the pre-#2314 behavior — safe, and the warning still fires. + */ +function selectResolvedModelId( + modelUsage: Record | undefined +): string | undefined { + if (!modelUsage) return undefined; + const entries = Object.entries(modelUsage); + if (entries.length === 0) return undefined; + if (entries.length === 1) return entries[0][0]; + + const outputTokensOf = (usage: ModelUsage): number => + Number.isFinite(usage.outputTokens) ? usage.outputTokens : 0; + let selected = entries[0]; + for (const entry of entries.slice(1)) { + if (outputTokensOf(entry[1]) > outputTokensOf(selected[1])) selected = entry; + } + getLog().warn( + { models: entries.map(([id]) => id), selected: selected[0] }, + 'claude.resolved_model_ambiguous' + ); + return selected[0]; +} + /** * Build environment for Claude subprocess. * @@ -702,7 +741,10 @@ function buildBaseClaudeOptions( permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, systemPrompt: requestOptions?.systemPrompt ?? { type: 'preset', preset: 'claude_code' }, - settingSources: assistantDefaults.settingSources ?? ['project', 'user'], + // Per-node override wins over the assistant-level default; the final + // fallback stays ['project', 'user'] (the SDK-loading default Archon ships). + settingSources: requestOptions?.nodeConfig?.settingSources ?? + assistantDefaults.settingSources ?? ['project', 'user'], hooks: buildToolCaptureHooks(toolResultQueue), stderr: (data: string): void => { const output = data.trim(); @@ -988,40 +1030,19 @@ async function* streamClaudeMessages( getLog().warn({ rateLimitInfo: rateLimitMsg.rate_limit_info }, 'claude.rate_limit_event'); yield { type: 'rate_limit', rateLimitInfo: rateLimitMsg.rate_limit_info ?? {} }; } else if (event.type === 'result') { - const resultMsg = msg as { - session_id?: string; - is_error?: boolean; - subtype?: string; - usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number }; - structured_output?: unknown; - total_cost_usd?: number; - stop_reason?: string | null; - num_turns?: number; - errors?: string[]; - result?: string; - terminal_reason?: TerminalReason; - api_error_status?: number | null; - model_usage?: Record< - string, - { - input_tokens: number; - output_tokens: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - } - >; - }; + const resultMsg = msg as SDKResultMessage; + const resolvedModelId = selectResolvedModelId(resultMsg.modelUsage); // The terminal result resolves any recorded synthetic error message. const syntheticError = pendingSdkError; pendingSdkError = undefined; const tokens = normalizeClaudeUsage(resultMsg.usage); - const sdkErrors = Array.isArray(resultMsg.errors) ? resultMsg.errors : undefined; + const sdkErrors = 'errors' in resultMsg ? resultMsg.errors : undefined; // `is_error: true` + `subtype: 'success'` is ambiguous: it is BOTH the // SDK's stop-sequence termination encoding (#1425, a legitimate success) // AND its API-failure-as-text encoding (#1797 — auth/billing/rate-limit // errors that even set stop_reason: 'stop_sequence'). - const isSuccessWithErrorFlag = resultMsg.is_error === true && resultMsg.subtype === 'success'; + const isSuccessWithErrorFlag = resultMsg.is_error && resultMsg.subtype === 'success'; // Disambiguate structurally: a preceding synthetic error message // (primary, typed signal), or the typed terminal_reason 'api_error' @@ -1054,7 +1075,7 @@ async function* streamClaudeMessages( // Fail-safe (never observed in practice): a synthetic error message // followed by a non-error result. Yield the withheld text late rather // than silently swallowing content. - if (syntheticError !== undefined && resultMsg.is_error !== true) { + if (syntheticError !== undefined && !resultMsg.is_error) { getLog().warn( { sessionId: resultMsg.session_id, errorCode: syntheticError.code }, 'claude.synthetic_error_not_confirmed' @@ -1068,7 +1089,7 @@ async function* streamClaudeMessages( // subtype: 'success' — its encoding of "non-default termination, not a // failure". Treat that pair as a clean success so downstream consumers // (which gate failure on isError) don't misclassify it. - const isRealError = resultMsg.is_error === true && !isSuccessWithErrorFlag; + const isRealError = resultMsg.is_error && !isSuccessWithErrorFlag; if (isRealError) { getLog().error( { @@ -1092,7 +1113,7 @@ async function* streamClaudeMessages( type: 'result', sessionId: resultMsg.session_id, ...(tokens ? { tokens } : {}), - ...(resultMsg.structured_output !== undefined + ...('structured_output' in resultMsg && resultMsg.structured_output !== undefined ? { structuredOutput: resultMsg.structured_output } : {}), ...(isRealError ? { isError: true, errorSubtype: resultMsg.subtype } : {}), @@ -1100,9 +1121,7 @@ async function* streamClaudeMessages( ...(resultMsg.total_cost_usd !== undefined ? { cost: resultMsg.total_cost_usd } : {}), ...(resultMsg.stop_reason != null ? { stopReason: resultMsg.stop_reason } : {}), ...(resultMsg.num_turns !== undefined ? { numTurns: resultMsg.num_turns } : {}), - ...(resultMsg.model_usage - ? { modelUsage: resultMsg.model_usage as Record } - : {}), + ...(resolvedModelId ? { resolvedModel: { id: resolvedModelId } } : {}), }; } } diff --git a/packages/providers/src/codex/capabilities.ts b/packages/providers/src/codex/capabilities.ts index 83b3fc04ce..4b764758d6 100644 --- a/packages/providers/src/codex/capabilities.ts +++ b/packages/providers/src/codex/capabilities.ts @@ -14,6 +14,7 @@ export const CODEX_CAPABILITIES: ProviderCapabilities = { thinkingControl: false, fallbackModel: false, sandbox: false, + settingSources: false, // Claude Agent SDK-only knob (which setting sources the agent loads) nativeTools: false, containerExec: false, // no in-container spawn path yet (fail-fast source of truth) }; diff --git a/packages/providers/src/codex/provider.test.ts b/packages/providers/src/codex/provider.test.ts index 1554f78b27..6bc46a6b0a 100644 --- a/packages/providers/src/codex/provider.test.ts +++ b/packages/providers/src/codex/provider.test.ts @@ -87,6 +87,7 @@ describe('CodexProvider', () => { thinkingControl: false, fallbackModel: false, sandbox: false, + settingSources: false, nativeTools: false, containerExec: false, }); diff --git a/packages/providers/src/community/copilot/capabilities.ts b/packages/providers/src/community/copilot/capabilities.ts index acd161e78b..c990f1204a 100644 --- a/packages/providers/src/community/copilot/capabilities.ts +++ b/packages/providers/src/community/copilot/capabilities.ts @@ -25,6 +25,7 @@ export const COPILOT_CAPABILITIES: ProviderCapabilities = { thinkingControl: true, fallbackModel: false, sandbox: false, + settingSources: false, // Claude Agent SDK-only knob (which setting sources the agent loads) nativeTools: false, containerExec: false, // no in-container spawn path yet (fail-fast source of truth) }; diff --git a/packages/providers/src/community/opencode/capabilities.ts b/packages/providers/src/community/opencode/capabilities.ts index 9596c536f2..1ff40a8441 100644 --- a/packages/providers/src/community/opencode/capabilities.ts +++ b/packages/providers/src/community/opencode/capabilities.ts @@ -36,6 +36,7 @@ export const OPENCODE_CAPABILITIES: ProviderCapabilities = { thinkingControl: false, // OpenCode handles effort/thinking via opencode.json agent config, not prompt body fallbackModel: false, sandbox: false, + settingSources: false, // Claude Agent SDK-only knob (which setting sources the agent loads) nativeTools: false, containerExec: false, // no in-container spawn path yet (fail-fast source of truth) }; diff --git a/packages/providers/src/community/opencode/provider.test.ts b/packages/providers/src/community/opencode/provider.test.ts index 992de8ba26..e059f810b9 100644 --- a/packages/providers/src/community/opencode/provider.test.ts +++ b/packages/providers/src/community/opencode/provider.test.ts @@ -304,12 +304,7 @@ describe('OpencodeProvider', () => { tokens: { input: 11, output: 7, total: 21, cost: 0.42 }, cost: 0.42, stopReason: 'stop', - modelUsage: { - providerID: 'anthropic', - modelID: 'claude-sonnet', - reasoning: 3, - cache: 1, - }, + resolvedModel: { id: 'claude-sonnet' }, }, ]); }); @@ -427,12 +422,6 @@ describe('OpencodeProvider', () => { type: 'result', sessionId: 'session-1', structuredOutput: { answer: 'ok', confidence: 0.9 }, - modelUsage: { - providerID: undefined, - modelID: undefined, - reasoning: undefined, - cache: undefined, - }, }, ]); }); @@ -476,12 +465,6 @@ describe('OpencodeProvider', () => { { type: 'result', sessionId: 'session-1', - modelUsage: { - providerID: undefined, - modelID: undefined, - reasoning: undefined, - cache: undefined, - }, }, ]); expect(mockLogger.warn).toHaveBeenCalledTimes(1); diff --git a/packages/providers/src/community/opencode/session.ts b/packages/providers/src/community/opencode/session.ts index 6a0739f03a..357407a200 100644 --- a/packages/providers/src/community/opencode/session.ts +++ b/packages/providers/src/community/opencode/session.ts @@ -266,19 +266,9 @@ export async function* streamOpencodeSession( ...(typeof latestAssistantInfo?.finish === 'string' ? { stopReason: latestAssistantInfo.finish } : {}), - ...(latestAssistantInfo - ? { - modelUsage: { - providerID: latestAssistantInfo.providerID, - modelID: latestAssistantInfo.modelID, - reasoning: isRecord(latestAssistantInfo.tokens) - ? latestAssistantInfo.tokens.reasoning - : undefined, - cache: isRecord(latestAssistantInfo.tokens) - ? latestAssistantInfo.tokens.cache - : undefined, - }, - } + ...(typeof latestAssistantInfo?.modelID === 'string' && + latestAssistantInfo.modelID.length > 0 + ? { resolvedModel: { id: latestAssistantInfo.modelID } } : {}), }; resultYielded = true; diff --git a/packages/providers/src/community/pi/capabilities.ts b/packages/providers/src/community/pi/capabilities.ts index 9f5cecf28f..3ceaba1a0a 100644 --- a/packages/providers/src/community/pi/capabilities.ts +++ b/packages/providers/src/community/pi/capabilities.ts @@ -30,6 +30,7 @@ export const PI_CAPABILITIES: ProviderCapabilities = { thinkingControl: true, fallbackModel: false, sandbox: false, + settingSources: false, // Claude Agent SDK-only knob (which setting sources the agent loads) nativeTools: true, containerExec: false, // no in-container spawn path yet (fail-fast source of truth) }; diff --git a/packages/providers/src/community/pi/event-bridge.test.ts b/packages/providers/src/community/pi/event-bridge.test.ts index 61536aa2e7..4a707993a9 100644 --- a/packages/providers/src/community/pi/event-bridge.test.ts +++ b/packages/providers/src/community/pi/event-bridge.test.ts @@ -179,6 +179,27 @@ describe('buildResultChunk', () => { } }); + test('records responseModel rather than the requested model', () => { + const chunk = buildResultChunk([ + { + role: 'assistant', + model: 'large', + responseModel: 'claude-opus-5', + usage, + stopReason: 'stop', + content: [], + }, + ]); + expect(chunk).toMatchObject({ type: 'result', resolvedModel: { id: 'claude-opus-5' } }); + }); + + test('omits resolvedModel when Pi does not report a responseModel', () => { + const chunk = buildResultChunk([ + { role: 'assistant', model: 'large', usage, stopReason: 'stop', content: [] }, + ]); + expect(chunk).not.toHaveProperty('resolvedModel'); + }); + test('flags isError for stopReason=error and surfaces errorMessage', () => { const chunk = buildResultChunk([ { role: 'assistant', usage, stopReason: 'error', errorMessage: 'auth', content: [] }, diff --git a/packages/providers/src/community/pi/event-bridge.ts b/packages/providers/src/community/pi/event-bridge.ts index d43429cfdc..b1bbae16c4 100644 --- a/packages/providers/src/community/pi/event-bridge.ts +++ b/packages/providers/src/community/pi/event-bridge.ts @@ -168,6 +168,9 @@ export function buildResultChunk(messages: readonly unknown[]): MessageChunk { tokens, ...(tokens.cost !== undefined ? { cost: tokens.cost } : {}), ...(last.stopReason ? { stopReason: last.stopReason } : {}), + ...(typeof last.responseModel === 'string' && last.responseModel.length > 0 + ? { resolvedModel: { id: last.responseModel } } + : {}), ...(isError ? { isError: true, diff --git a/packages/providers/src/community/pi/provider.test.ts b/packages/providers/src/community/pi/provider.test.ts index 53429fca88..89de93df69 100644 --- a/packages/providers/src/community/pi/provider.test.ts +++ b/packages/providers/src/community/pi/provider.test.ts @@ -85,7 +85,9 @@ const mockGetApiKey = mock(async (providerId: string): Promise ({ @@ -185,7 +187,7 @@ mock.module('@earendil-works/pi-coding-agent', () => ({ })); // Import AFTER mocks are set — module resolution freezes the mocks. -import { PiProvider } from './provider'; +import { ARCHON_PI_ANTHROPIC_OAUTH_SYSTEM_PROMPT, PiProvider } from './provider'; import { PI_CAPABILITIES } from './capabilities'; // Same module instance the provider dynamic-imports, so clearing this cache // resets the loader the provider reuses across calls (issue #1877). @@ -1305,6 +1307,134 @@ describe('PiProvider', () => { expect(loaderArgs?.systemPrompt).toBeUndefined(); }); + test('invalid request-level systemPrompt does not mask valid node-level prompt', async () => { + // Regression: a non-string request-level prompt (preset object) must NOT win + // via `??` and shadow a valid node-level string — each level is validated + // independently before precedence applies. + process.env.GEMINI_API_KEY = 'sk-test'; + resetScript(scriptedAgentEnd()); + + await consume( + new PiProvider().sendQuery('hi', '/tmp', undefined, { + model: 'google/gemini-2.5-pro', + systemPrompt: { + type: 'preset', + preset: 'claude_code', + append: 'extra', + } as unknown as string, + nodeConfig: { systemPrompt: 'node-level prompt' }, + }) + ); + + // The dropped request-level object is reported, tagged with its source. + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ systemPromptType: 'object', systemPromptSource: 'request' }), + 'pi.system_prompt_dropped_non_string' + ); + + // The valid node-level string is used. + const loaderArgs = MockDefaultResourceLoader.mock.calls[0]?.[0] as + | Record + | undefined; + expect(loaderArgs?.systemPrompt).toBe('node-level prompt'); + }); + + // ─── Anthropic subscription-OAuth default system prompt (#1831) ─────── + + test('Anthropic OAuth session (env token) falls back to the OAuth-safe default prompt', async () => { + // A subscription token (sk-ant-oat*) with no explicit systemPrompt must + // suppress Pi's built-in prompt — Anthropic's OAuth endpoint 400s it. + resetScript(scriptedAgentEnd()); + + await consume( + new PiProvider().sendQuery('hi', '/tmp', undefined, { + model: 'anthropic/claude-haiku-4-5', + env: { ANTHROPIC_OAUTH_TOKEN: 'sk-ant-oat01-bearer' }, + }) + ); + + const loaderArgs = MockDefaultResourceLoader.mock.calls[0]?.[0] as + | Record + | undefined; + expect(loaderArgs?.systemPrompt).toBe(ARCHON_PI_ANTHROPIC_OAUTH_SYSTEM_PROMPT); + }); + + test('Anthropic OAuth session (auth.json subscription cred) falls back to the default prompt', async () => { + // Same detection via the `pi /login` path: getApiKey resolves the stored + // OAuth access token (sk-ant-oat*), no env var involved. + fileCreds.anthropic = { type: 'oauth' }; + resetScript(scriptedAgentEnd()); + + await consume( + new PiProvider().sendQuery('hi', '/tmp', undefined, { + model: 'anthropic/claude-haiku-4-5', + }) + ); + + const loaderArgs = MockDefaultResourceLoader.mock.calls[0]?.[0] as + | Record + | undefined; + expect(loaderArgs?.systemPrompt).toBe(ARCHON_PI_ANTHROPIC_OAUTH_SYSTEM_PROMPT); + }); + + test('Anthropic API-key session keeps Pi built-in prompt (systemPrompt undefined)', async () => { + // Narrowed scope: API-key auth is not affected by the OAuth classifier, so + // Pi's built-in prompt (with its dynamic tool list) must stay intact. + process.env.ANTHROPIC_API_KEY = 'sk-ant-api03-key'; + resetScript(scriptedAgentEnd()); + + await consume( + new PiProvider().sendQuery('hi', '/tmp', undefined, { + model: 'anthropic/claude-haiku-4-5', + }) + ); + + const loaderArgs = MockDefaultResourceLoader.mock.calls[0]?.[0] as + | Record + | undefined; + expect(loaderArgs?.systemPrompt).toBeUndefined(); + }); + + test('non-Anthropic backend keeps Pi built-in prompt (systemPrompt undefined)', async () => { + process.env.GEMINI_API_KEY = 'sk-test'; + resetScript(scriptedAgentEnd()); + + await consume( + new PiProvider().sendQuery('hi', '/tmp', undefined, { model: 'google/gemini-2.5-pro' }) + ); + + const loaderArgs = MockDefaultResourceLoader.mock.calls[0]?.[0] as + | Record + | undefined; + expect(loaderArgs?.systemPrompt).toBeUndefined(); + }); + + test('explicit systemPrompt wins over the OAuth default on an OAuth session', async () => { + resetScript(scriptedAgentEnd()); + + await consume( + new PiProvider().sendQuery('hi', '/tmp', undefined, { + model: 'anthropic/claude-haiku-4-5', + env: { ANTHROPIC_OAUTH_TOKEN: 'sk-ant-oat01-bearer' }, + nodeConfig: { systemPrompt: 'node-level custom prompt' }, + }) + ); + + const loaderArgs = MockDefaultResourceLoader.mock.calls[0]?.[0] as + | Record + | undefined; + expect(loaderArgs?.systemPrompt).toBe('node-level custom prompt'); + }); + + test('ARCHON_PI_ANTHROPIC_OAUTH_SYSTEM_PROMPT carries no third-party "pi harness" tell', () => { + // Regression guard: the default must never reintroduce the self-referential + // vocabulary that trips Anthropic's subscription-OAuth detector. + const p = ARCHON_PI_ANTHROPIC_OAUTH_SYSTEM_PROMPT.toLowerCase(); + expect(p).not.toContain('pi documentation'); + expect(p).not.toContain('coding agent harness'); + expect(p).not.toContain('operating inside pi'); + }); + test('capabilities reflect v2 wiring', () => { const caps = new PiProvider().getCapabilities(); expect(caps.thinkingControl).toBe(true); diff --git a/packages/providers/src/community/pi/provider.ts b/packages/providers/src/community/pi/provider.ts index 5abf7dd074..6194b5942f 100644 --- a/packages/providers/src/community/pi/provider.ts +++ b/packages/providers/src/community/pi/provider.ts @@ -13,6 +13,7 @@ import type { MessageChunk, ProviderCapabilities, SendQueryOptions, + SystemPromptInput, } from '../../types'; import { PI_CAPABILITIES } from './capabilities'; @@ -227,6 +228,58 @@ function getLog(): ReturnType { import { augmentPromptForJsonSchema } from '../../shared/structured-output'; export { augmentPromptForJsonSchema }; +/** + * Anthropic subscription OAuth access tokens are `sk-ant-oat…` (API keys are + * `sk-ant-api…`). This is the same content-shape discriminator pi-ai's + * createClient uses to pick OAuth vs API-key auth downstream, so Archon's + * detection can never disagree with the SDK's. + */ +function isAnthropicOAuthToken(token: string | null | undefined): boolean { + return typeof token === 'string' && token.startsWith('sk-ant-oat'); +} + +/** + * Archon's default system prompt for Pi sessions that authenticate to + * Anthropic with a SUBSCRIPTION OAuth token (Claude Pro/Max, `sk-ant-oat*`). + * + * WHY THIS EXISTS (load-bearing — do not drop without re-reading): + * Pi's built-in coding-agent system prompt (pi-coding-agent's + * `buildSystemPrompt`) embeds a self-referential "Pi documentation" block + * ("...read only when the user asks about pi itself, its SDK, extensions, + * themes, skills, or TUI...") plus an "operating inside pi, a coding agent + * harness" identity line. That block is dense with third-party-coding-tool + * vocabulary, and Anthropic's post-2026-04-04 subscription-OAuth enforcement + * classifies any request carrying it as a third-party app — returning + * `400 invalid_request_error "You're out of extra usage"` for Pro/Max OAuth + * tokens, even though the same token works for first-party Claude Code. + * + * Supplying ANY custom system prompt makes pi-coding-agent take its + * `customPrompt` branch, which omits the incriminating block entirely. pi-ai + * still prepends the OAuth-required "You are Claude Code, Anthropic's official + * CLI for Claude." block as system[0], so subscription tokens are accepted. + * Verified at the wire level (PR #1831): [CC, this-prompt] → HTTP 200; + * [CC, pi-default-with-docs-block] → HTTP 400. + * + * Scope is deliberately narrow: the fallback applies ONLY when the session + * will use Anthropic subscription-OAuth auth. API-key sessions and + * non-Anthropic backends keep Pi's built-in prompt (with its dynamic tool + * list) — there is no benefit to replacing it there. Workflow- or + * request-level `systemPrompt` still wins (see sendQuery step 4c). + */ +export const ARCHON_PI_ANTHROPIC_OAUTH_SYSTEM_PROMPT = `You are an expert coding assistant. You help users by reading files, executing commands, editing code, and writing new files. + +Use the available tools to accomplish the task: +- read: examine file contents instead of cat/sed +- bash: run shell commands (ls, grep, find, build, test) +- edit: make precise, minimal text replacements; each match must be unique +- write: create new files or fully rewrite existing ones + +Guidelines: +- Prefer reading files before editing them. +- Keep edits small and targeted; do not pad with unchanged context. +- Be concise in your responses. +- Show file paths clearly when working with files.`; + /** * Pi community provider — wraps `@earendil-works/pi-coding-agent`'s full * coding-agent harness. Each `sendQuery()` call creates a fresh session @@ -389,8 +442,15 @@ export class PiProvider implements IAgentProvider { // Auth validation deferred for extension providers — they manage credentials // outside Pi's AuthStorage (e.g. kiro uses AWS SSO/OIDC via ~/.aws/sso/cache/). // Only validate early for static-catalog models where we can give actionable hints. + // The resolved credential is also kept for the Anthropic subscription-OAuth + // detection in step 4c; for 'anthropic' we resolve even when the model is + // deferred to extensions (AuthStorage reads are cheap and side-effect-free) + // so a catalog miss can never skip the OAuth-safe default prompt. + let resolvedKey: Awaited> | undefined; + if (model || parsed.provider === 'anthropic') { + resolvedKey = await authStorage.getApiKey(parsed.provider); + } if (model) { - const resolvedKey = await authStorage.getApiKey(parsed.provider); if (!resolvedKey) { if (envVarName) { // Name the OAuth var first when the backend has one — a subscription @@ -454,15 +514,39 @@ export class PiProvider implements IAgentProvider { // 4c. systemPrompt: request-level (AgentRequestOptions) wins over // node-level; either overrides Pi's default. - // Pi only supports string system prompts; ignore structured preset objects. - const rawSystemPrompt = requestOptions?.systemPrompt ?? nodeConfig?.systemPrompt; - const systemPrompt = typeof rawSystemPrompt === 'string' ? rawSystemPrompt : undefined; - if (rawSystemPrompt !== undefined && systemPrompt === undefined) { + // Pi only supports string system prompts; structured preset objects + // and string[] are dropped. Validate each level INDEPENDENTLY before + // applying precedence — a non-string request-level value (e.g. a + // preset object) must not win via `??` and mask a valid node-level + // string. + const coerceStringPrompt = ( + value: SystemPromptInput | undefined, + source: 'request' | 'node' + ): string | undefined => { + if (value === undefined) return undefined; + if (typeof value === 'string') return value; getLog().warn( - { systemPromptType: typeof rawSystemPrompt }, + { systemPromptType: typeof value, systemPromptSource: source }, 'pi.system_prompt_dropped_non_string' ); - } + return undefined; + }; + const explicitSystemPrompt = + coerceStringPrompt(requestOptions?.systemPrompt, 'request') ?? + coerceStringPrompt(nodeConfig?.systemPrompt, 'node'); + + // When no explicit prompt is set AND this session authenticates to + // Anthropic with a subscription OAuth token, fall back to + // ARCHON_PI_ANTHROPIC_OAUTH_SYSTEM_PROMPT — Anthropic's OAuth + // endpoint hard-400s Pi's self-identifying built-in prompt (see the + // constant's doc comment). Every other session (API-key auth, + // non-Anthropic backends) keeps `undefined` so Pi's built-in prompt, + // with its dynamic tool list, stays intact. + const usesAnthropicOAuth = + parsed.provider === 'anthropic' && isAnthropicOAuthToken(resolvedKey); + const systemPrompt = + explicitSystemPrompt ?? + (usesAnthropicOAuth ? ARCHON_PI_ANTHROPIC_OAUTH_SYSTEM_PROMPT : undefined); // 4d. skills: Archon uses name references (e.g. `skills: [agent-browser]`). // Resolve each name against .agents/skills and .claude/skills (project @@ -620,7 +704,12 @@ export class PiProvider implements IAgentProvider { cwd, thinkingLevel, toolCount: filteredTools?.length, - hasSystemPrompt: systemPrompt !== undefined, + systemPromptSource: + explicitSystemPrompt !== undefined + ? 'explicit' + : systemPrompt !== undefined + ? 'anthropic-oauth-default' + : 'pi-builtin', skillCount: skillPaths.length, missingSkillCount: missingSkills.length, extensionsEnabled: enableExtensions, diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts index 8fc6d4e060..7a07ee17b9 100644 --- a/packages/providers/src/types.ts +++ b/packages/providers/src/types.ts @@ -171,6 +171,11 @@ export interface TokenUsage { cost?: number; } +/** Concrete model identifier reported by a provider after a request completes. */ +export interface ResolvedModel { + id: string; +} + /** * Message chunk from AI assistant. * Discriminated union with per-type required fields for type safety. @@ -199,7 +204,8 @@ export type MessageChunk = cost?: number; stopReason?: string; numTurns?: number; - modelUsage?: Record; + /** Concrete model reported by the provider; omitted when its SDK does not expose one. */ + resolvedModel?: ResolvedModel; /** * Outcome of a session-resume attempt, so a failed resume is observable * instead of silently continuing with a fresh (cold) session: @@ -511,6 +517,14 @@ export interface NodeConfig { maxBudgetUsd?: number; systemPrompt?: SystemPromptInput; fallbackModel?: string; + /** + * Per-node override for Claude Code settingSources — which filesystem + * setting sources the SDK loads (CLAUDE.md, skills, commands, agents). + * Overrides the assistant-level default; falls back to ['project', 'user'] + * when neither is set. Claude-only; other providers ignore it (the + * dag-executor warns via the settingSources capability axis). + */ + settingSources?: ('project' | 'user')[]; idle_timeout?: number; /** * Per-node override for Claude's `agentProgressSummaries` flag (Phase 4 of #975). @@ -590,6 +604,13 @@ export interface ProviderCapabilities { thinkingControl: boolean; fallbackModel: boolean; sandbox: boolean; + /** + * Whether the provider honors the per-node `settingSources` override (which + * filesystem setting sources the agent loads: CLAUDE.md, skills, commands, + * agents). `true` for Claude only — the Claude Agent SDK's `settingSources` + * option; other providers have no equivalent knob. + */ + settingSources: boolean; /** Whether the provider can register in-process `NativeTool`s for a turn. */ nativeTools: boolean; /** diff --git a/packages/server/package.json b/packages/server/package.json index 9bca24fe03..27df5cf85b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,12 +1,12 @@ { "name": "@archon/server", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./src/index.ts", "scripts": { "dev": "bun --watch src/index.ts", "start": "bun src/index.ts", - "test": "bun test src/routes/api.workflows.test.ts && bun test src/routes/api.conversations.test.ts && bun test src/routes/api.codebases.test.ts && bun test src/routes/api.messages.test.ts && bun test src/routes/api.health.test.ts && bun test src/routes/api.workflow-runs.test.ts && bun test src/routes/api.providers.test.ts && bun test src/adapters/web/transport.test.ts && bun test src/adapters/web/persistence.test.ts && bun test src/adapters/web/dashboard-event-poller.test.ts && bun test src/adapters/web/pg-notify-listener.test.ts && bun test src/adapters/web/workflow-bridge.test.ts && bun test src/resolve-user-id.test.ts && bun test src/boot/claude-auth-posture.test.ts && bun test src/github-auth-bootstrap.test.ts && bun test src/routes/auth-poll-status.test.ts && bun test src/auth/config.test.ts && bun test src/routes/api.auth.test.ts && bun test src/routes/api.provider-keys.test.ts && bun test src/routes/api.user-ai-prefs.test.ts && bun test src/routes/webhooks.test.ts", + "test": "bun test src/routes/api.workflows.test.ts && bun test src/routes/api.conversations.test.ts && bun test src/routes/api.codebases.test.ts && bun test src/routes/api.messages.test.ts && bun test src/routes/api.health.test.ts && bun test src/routes/api.workflow-runs.test.ts && bun test src/routes/api.providers.test.ts && bun test src/adapters/web/transport.test.ts && bun test src/adapters/web/persistence.test.ts && bun test src/adapters/web/truncate.test.ts && bun test src/adapters/web.test.ts && bun test src/adapters/web/dashboard-event-poller.test.ts && bun test src/adapters/web/pg-notify-listener.test.ts && bun test src/adapters/web/workflow-bridge.test.ts && bun test src/resolve-user-id.test.ts && bun test src/boot/claude-auth-posture.test.ts && bun test src/github-auth-bootstrap.test.ts && bun test src/routes/auth-poll-status.test.ts && bun test src/auth/config.test.ts && bun test src/routes/api.auth.test.ts && bun test src/routes/api.provider-keys.test.ts && bun test src/routes/api.user-ai-prefs.test.ts && bun test src/routes/webhooks.test.ts && bun test src/discord-mention.test.ts", "type-check": "bun x tsc --noEmit", "setup-auth": "bun src/scripts/setup-auth.ts" }, diff --git a/packages/server/src/adapters/web.test.ts b/packages/server/src/adapters/web.test.ts new file mode 100644 index 0000000000..8d3a13bf2c --- /dev/null +++ b/packages/server/src/adapters/web.test.ts @@ -0,0 +1,127 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test'; + +// Mock logger before importing any module that transitively imports @archon/paths +const mockLogger = { + fatal: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + info: mock(() => undefined), + debug: mock(() => undefined), + trace: mock(() => undefined), + child: mock(function (this: unknown) { + return this; + }), + bindings: mock(() => ({ module: 'test' })), + isLevelEnabled: mock(() => true), + level: 'info' as const, +}; + +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), +})); + +import { WebAdapter } from './web'; +import { MAX_TOOL_OUTPUT_CHARS } from './web/truncate'; +import type { SSETransport } from './web/transport'; +import type { MessagePersistence } from './web/persistence'; +import type { WorkflowEventBridge } from './web/workflow-bridge'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeAdapter(): { + adapter: WebAdapter; + emitted: string[]; + appendToolResultCalls: unknown[][]; +} { + const emitted: string[] = []; + const appendToolResultCalls: unknown[][] = []; + + const mockTransport = { + emit: mock(async (_id: string, event: string) => { + emitted.push(event); + }), + } as unknown as SSETransport; + + const mockPersistence = { + appendToolResult: mock((_id: string, name: string, output: string, duration: number) => { + appendToolResultCalls.push([_id, name, output, duration]); + }), + appendToolCall: mock(() => {}), + appendText: mock(() => {}), + flush: mock(async () => {}), + finalizeRunningTools: mock(() => {}), + } as unknown as MessagePersistence; + + const mockBridge = { + emitOutput: mock(() => {}), + registerOutputCallback: mock(() => {}), + removeOutputCallback: mock(() => {}), + setStepTransitionCallback: mock(() => {}), + start: mock(() => {}), + stop: mock(() => {}), + bridgeWorkerEvents: mock(() => () => {}), + } as unknown as WorkflowEventBridge; + + const adapter = new WebAdapter(mockTransport, mockPersistence, mockBridge); + return { adapter, emitted, appendToolResultCalls }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + mockLogger.warn.mockClear(); + mockLogger.error.mockClear(); +}); + +describe('WebAdapter.sendStructuredEvent — tool_result output bounding', () => { + test('truncates SSE event output when toolOutput exceeds the cap', async () => { + const { adapter, emitted } = makeAdapter(); + const largeOutput = 'x'.repeat(MAX_TOOL_OUTPUT_CHARS + 50_000); + + await adapter.sendStructuredEvent('conv-1', { + type: 'tool_result', + toolName: 'bash', + toolOutput: largeOutput, + }); + + expect(emitted.length).toBe(1); + const parsed = JSON.parse(emitted[0]!) as { output: string }; + expect(parsed.output.length).toBeLessThan(largeOutput.length); + expect(parsed.output).toContain('[truncated'); + expect(parsed.output).toContain('full output preserved on the server'); + }); + + test('passes SSE event output through unchanged when within the cap', async () => { + const { adapter, emitted } = makeAdapter(); + const smallOutput = 'small tool output'; + + await adapter.sendStructuredEvent('conv-1', { + type: 'tool_result', + toolName: 'bash', + toolOutput: smallOutput, + }); + + expect(emitted.length).toBe(1); + const parsed = JSON.parse(emitted[0]!) as { output: string }; + expect(parsed.output).toBe(smallOutput); + }); + + test('persists full untruncated output to DB regardless of the SSE cap', async () => { + const { adapter, appendToolResultCalls } = makeAdapter(); + const largeOutput = 'z'.repeat(MAX_TOOL_OUTPUT_CHARS + 50_000); + + await adapter.sendStructuredEvent('conv-1', { + type: 'tool_result', + toolName: 'bash', + toolOutput: largeOutput, + }); + + expect(appendToolResultCalls.length).toBe(1); + // Third argument to appendToolResult is the output — must be the full string + expect(appendToolResultCalls[0]![2]).toBe(largeOutput); + }); +}); diff --git a/packages/server/src/adapters/web.ts b/packages/server/src/adapters/web.ts index 50d3c0e5f3..6c7c6f8388 100644 --- a/packages/server/src/adapters/web.ts +++ b/packages/server/src/adapters/web.ts @@ -7,6 +7,7 @@ import type { MessageChunk } from '@archon/providers/types'; import { createLogger } from '@archon/paths'; import { MessagePersistence } from './web/persistence'; import { SSETransport, type SSEWriter } from './web/transport'; +import { truncateToolOutput } from './web/truncate'; import { WorkflowEventBridge } from './web/workflow-bridge'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ @@ -188,11 +189,12 @@ export class WebAdapter implements IWebPlatformAdapter { } catch (e: unknown) { getLog().error({ conversationId, err: e }, 'tool_result_persist_failed'); } + // Bound the SSE payload only — the DB write above keeps the full output event = JSON.stringify({ type: 'tool_result', toolCallId: matchedToolCallId, name: chunk.toolName, - output: chunk.toolOutput, + output: truncateToolOutput(chunk.toolOutput), duration, timestamp: now, }); diff --git a/packages/server/src/adapters/web/truncate.test.ts b/packages/server/src/adapters/web/truncate.test.ts new file mode 100644 index 0000000000..3945473ef8 --- /dev/null +++ b/packages/server/src/adapters/web/truncate.test.ts @@ -0,0 +1,108 @@ +import { describe, test, expect } from 'bun:test'; +import { truncateToolOutput, boundMetadataToolOutputs, MAX_TOOL_OUTPUT_CHARS } from './truncate'; + +describe('truncateToolOutput', () => { + test('returns empty string unchanged', () => { + expect(truncateToolOutput('')).toBe(''); + }); + + test('returns output shorter than the cap unchanged', () => { + const short = 'hello world\nline two'; + expect(truncateToolOutput(short)).toBe(short); + }); + + test('returns output at exactly the cap unchanged', () => { + const atCap = 'a'.repeat(MAX_TOOL_OUTPUT_CHARS); + expect(truncateToolOutput(atCap)).toBe(atCap); + }); + + test('truncates output one char over the cap', () => { + const overCap = 'x'.repeat(MAX_TOOL_OUTPUT_CHARS + 1); + const result = truncateToolOutput(overCap); + expect(result.startsWith('x'.repeat(MAX_TOOL_OUTPUT_CHARS))).toBe(true); + expect(result).toContain('[truncated'); + expect(result).toContain('full output preserved on the server'); + }); + + test('truncates large output and embeds KB sizes in the marker', () => { + const large = 'y'.repeat(MAX_TOOL_OUTPUT_CHARS + 200_000); + const result = truncateToolOutput(large); + expect(result.length).toBeLessThan(large.length); + expect(result.startsWith('y'.repeat(MAX_TOOL_OUTPUT_CHARS))).toBe(true); + // Marker must contain KB numbers so users know how much was cut + expect(result).toMatch(/\d+ KB of \d+ KB total/); + }); +}); + +describe('boundMetadataToolOutputs', () => { + test('truncates oversized tool outputs in toolCalls', () => { + const large = 'z'.repeat(MAX_TOOL_OUTPUT_CHARS + 10_000); + const meta = JSON.stringify({ + toolCalls: [{ name: 'bash', input: { command: 'cat big.txt' }, output: large, duration: 5 }], + }); + const bounded = JSON.parse(boundMetadataToolOutputs(meta)) as { + toolCalls: Array<{ name: string; output: string; duration: number }>; + }; + expect(bounded.toolCalls[0]!.output.length).toBeLessThan(large.length); + expect(bounded.toolCalls[0]!.output).toContain('[truncated'); + // Sibling fields on the tool call survive + expect(bounded.toolCalls[0]!.name).toBe('bash'); + expect(bounded.toolCalls[0]!.duration).toBe(5); + }); + + test('truncates only the oversized output in a mixed toolCalls array', () => { + const large = 'q'.repeat(MAX_TOOL_OUTPUT_CHARS + 1); + const meta = JSON.stringify({ + toolCalls: [ + { name: 'read', output: 'small' }, + { name: 'bash', output: large }, + ], + }); + const bounded = JSON.parse(boundMetadataToolOutputs(meta)) as { + toolCalls: Array<{ output: string }>; + }; + expect(bounded.toolCalls[0]!.output).toBe('small'); + expect(bounded.toolCalls[1]!.output).toContain('[truncated'); + }); + + test('preserves non-toolCall metadata fields alongside truncated outputs', () => { + const large = 'w'.repeat(MAX_TOOL_OUTPUT_CHARS + 1); + const meta = JSON.stringify({ + toolCalls: [{ name: 'bash', output: large }], + workflowDispatch: { workflowName: 'implement', workerConversationId: 'wc-1' }, + }); + const bounded = JSON.parse(boundMetadataToolOutputs(meta)) as { + workflowDispatch: { workflowName: string; workerConversationId: string }; + }; + expect(bounded.workflowDispatch).toEqual({ + workflowName: 'implement', + workerConversationId: 'wc-1', + }); + }); + + test('returns metadata without toolCalls byte-for-byte unchanged', () => { + const meta = JSON.stringify({ workflowResult: { runId: 'abc', status: 'completed' } }); + expect(boundMetadataToolOutputs(meta)).toBe(meta); + }); + + test('returns metadata with all outputs within the cap byte-for-byte unchanged', () => { + const meta = JSON.stringify({ + toolCalls: [{ name: 'bash', output: 'ok' }, { name: 'read' }], + }); + expect(boundMetadataToolOutputs(meta)).toBe(meta); + }); + + test('returns invalid JSON unchanged', () => { + expect(boundMetadataToolOutputs('not json {')).toBe('not json {'); + }); + + test('returns JSON null / non-object values unchanged', () => { + expect(boundMetadataToolOutputs('null')).toBe('null'); + expect(boundMetadataToolOutputs('"a string"')).toBe('"a string"'); + }); + + test('leaves non-object and outputless entries in toolCalls untouched', () => { + const meta = JSON.stringify({ toolCalls: [null, 'weird', { name: 'bash' }] }); + expect(boundMetadataToolOutputs(meta)).toBe(meta); + }); +}); diff --git a/packages/server/src/adapters/web/truncate.ts b/packages/server/src/adapters/web/truncate.ts new file mode 100644 index 0000000000..38cf73f625 --- /dev/null +++ b/packages/server/src/adapters/web/truncate.ts @@ -0,0 +1,71 @@ +/** + * Maximum characters of a single tool output sent across a server → browser + * boundary (SSE tool_result events and message-hydration metadata). + * + * The console renderer displays at most 2000 chars of a tool result + * (ToolCallItem.tsx), so 16 KiB is invisible to display behavior while leaving + * ~8x headroom for future renderer changes. The full output stays in the + * database and on-disk logs — this cap is transport hygiene only. + */ +export const MAX_TOOL_OUTPUT_CHARS = 16_384; + +/** + * Bound tool output to MAX_TOOL_OUTPUT_CHARS for browser transport. + * Returns the string unchanged when within the cap; otherwise returns a head + * slice plus a human-readable size marker (mirroring the renderer's own + * head-slice + "(N more chars)" cap semantics) so users know truncation + * occurred and where the full output lives. + * + * Apply at SSE emit time and at message-history hydration time. + * Do NOT apply before writing to the database — the DB is the authoritative record. + */ +export function truncateToolOutput(output: string): string { + if (output.length <= MAX_TOOL_OUTPUT_CHARS) return output; + const totalKB = Math.round(output.length / 1024); + const truncatedKB = Math.round((output.length - MAX_TOOL_OUTPUT_CHARS) / 1024); + return ( + output.slice(0, MAX_TOOL_OUTPUT_CHARS) + + `\n\n… [truncated ${String(truncatedKB)} KB of ${String(totalKB)} KB total; full output preserved on the server]` + ); +} + +/** + * Parse message metadata JSON, apply truncateToolOutput to every toolCalls[] + * output, and return the re-serialized JSON string. + * + * Truncates ONLY tool_result content — all other metadata fields + * (workflowDispatch, workflowResult, file uploads, …) pass through untouched. + * Returns the input string byte-for-byte unchanged when: + * - the string is not valid JSON + * - the parsed value has no toolCalls array + * - every tool output is already within the cap + */ +export function boundMetadataToolOutputs(metaJson: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(metaJson); + } catch { + return metaJson; + } + if ( + parsed === null || + typeof parsed !== 'object' || + !Array.isArray((parsed as Record).toolCalls) + ) { + return metaJson; + } + const meta = parsed as { toolCalls: unknown[] } & Record; + let truncatedAny = false; + const boundedToolCalls = meta.toolCalls.map(tc => { + if (tc === null || typeof tc !== 'object') return tc; + const toolCall = tc as Record; + if (typeof toolCall.output !== 'string' || toolCall.output.length <= MAX_TOOL_OUTPUT_CHARS) { + return tc; + } + truncatedAny = true; + return { ...toolCall, output: truncateToolOutput(toolCall.output) }; + }); + // Avoid re-serialization churn when nothing was truncated + if (!truncatedAny) return metaJson; + return JSON.stringify({ ...meta, toolCalls: boundedToolCalls }); +} diff --git a/packages/server/src/discord-mention.test.ts b/packages/server/src/discord-mention.test.ts new file mode 100644 index 0000000000..5303f5bdda --- /dev/null +++ b/packages/server/src/discord-mention.test.ts @@ -0,0 +1,22 @@ +import { describe, test, expect } from 'bun:test'; +import { isDiscordMentionRequired } from './discord-mention'; + +describe('isDiscordMentionRequired', () => { + test('true by default when DISCORD_REQUIRE_MENTION is unset (guild messages stay gated)', () => { + expect(isDiscordMentionRequired({})).toBe(true); + }); + + test('false when DISCORD_REQUIRE_MENTION=false (un-mentioned guild messages activate the bot)', () => { + expect(isDiscordMentionRequired({ DISCORD_REQUIRE_MENTION: 'false' })).toBe(false); + }); + + test('true when DISCORD_REQUIRE_MENTION=true', () => { + expect(isDiscordMentionRequired({ DISCORD_REQUIRE_MENTION: 'true' })).toBe(true); + }); + + test("only the literal string 'false' opts out ('FALSE', '0', '' keep the gate on)", () => { + expect(isDiscordMentionRequired({ DISCORD_REQUIRE_MENTION: 'FALSE' })).toBe(true); + expect(isDiscordMentionRequired({ DISCORD_REQUIRE_MENTION: '0' })).toBe(true); + expect(isDiscordMentionRequired({ DISCORD_REQUIRE_MENTION: '' })).toBe(true); + }); +}); diff --git a/packages/server/src/discord-mention.ts b/packages/server/src/discord-mention.ts new file mode 100644 index 0000000000..643e1ad48d --- /dev/null +++ b/packages/server/src/discord-mention.ts @@ -0,0 +1,11 @@ +/** + * Whether Discord guild (server) messages require an explicit @mention of the + * bot to activate it. On by default; `DISCORD_REQUIRE_MENTION=false` opts out + * so the bot responds to any authorized guild message. Only the literal string + * 'false' disables the gate. DMs never require a mention regardless of this + * setting, and mention stripping stays unconditional (a mention that is + * present is still removed from the message content). + */ +export function isDiscordMentionRequired(env: NodeJS.ProcessEnv = process.env): boolean { + return env.DISCORD_REQUIRE_MENTION !== 'false'; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3998ed96a7..db3e798c0d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -114,6 +114,7 @@ import { captureArchonActive, } from '@archon/paths'; import { selectGitHubAuthMode, parseGitCredentialPath } from './github-auth-bootstrap'; +import { isDiscordMentionRequired } from './discord-mention'; import { getAuth, closeAuth, @@ -520,6 +521,7 @@ export async function startServer(opts: ServerOptions = {}): Promise { | 'batch'; discord = new DiscordAdapter(process.env.DISCORD_BOT_TOKEN, discordStreamingMode); const discordAdapter = discord; // Capture for use in callback + const discordRequireMention = isDiscordMentionRequired(); // Register message handler discordAdapter.onMessage(async ({ message, platformUserId, displayName }) => { @@ -529,10 +531,11 @@ export async function startServer(opts: ServerOptions = {}): Promise { // Skip if no content if (!message.content) return; - // Check if bot was mentioned (required for activation) - // Exception: DMs don't require mention + // Check if bot was mentioned (required for activation unless + // DISCORD_REQUIRE_MENTION=false opts out of the gate) + // Exception: DMs never require mention const isDM = !message.guild; - if (!isDM && !discordAdapter.isBotMentioned(message)) { + if (!isDM && discordRequireMention && !discordAdapter.isBotMentioned(message)) { return; // Ignore messages that don't mention the bot } diff --git a/packages/server/src/routes/api.health.test.ts b/packages/server/src/routes/api.health.test.ts index aebcb050de..fd93794c93 100644 --- a/packages/server/src/routes/api.health.test.ts +++ b/packages/server/src/routes/api.health.test.ts @@ -17,7 +17,15 @@ const mockLoadConfig = mock(async () => ({ worktree: { baseBranch: 'main' }, })); const mockGetDatabaseType = mock(() => 'sqlite' as const); +const mockGetSchemaVersion = mock(async () => ({ + createdAppVersion: '0.5.3' as string | null, + appVersion: '0.6.0', + createdAt: '2026-01-01T00:00:00.000Z' as string | null, + appliedAt: '2026-07-01T00:00:00.000Z' as string | null, +})); const mockIsDocker = mock(() => false); +const mockIsWSL = mock(() => false); +const mockGetWSLDistroName = mock((): string | undefined => undefined); const mockGetStats = mock(() => ({ active: 1, queuedTotal: 2, @@ -29,6 +37,7 @@ const mockGetStats = mock(() => ({ mock.module('@archon/core', () => ({ handleMessage: mock(async () => {}), getDatabaseType: mockGetDatabaseType, + getSchemaVersion: mockGetSchemaVersion, loadConfig: mockLoadConfig, cloneRepository: mock(async () => ({ codebaseId: 'x', alreadyExisted: false })), registerRepository: mock(async () => ({ codebaseId: 'x', alreadyExisted: false })), @@ -79,6 +88,8 @@ mock.module('@archon/paths', () => ({ getDefaultWorkflowsPath: mock(() => '/tmp/.archon-test-nonexistent/workflows/defaults'), getArchonWorkspacesPath: () => '/tmp/.archon/workspaces', isDocker: mockIsDocker, + isWSL: mockIsWSL, + getWSLDistroName: mockGetWSLDistroName, })); mock.module('@archon/workflows/workflow-discovery', makeDiscoverWorkflowsMock); @@ -201,6 +212,9 @@ describe('GET /api/health', () => { mockGetStats.mockReset(); mockGetRunningWorkflows.mockReset(); mockIsDocker.mockClear(); // preserve base () => false implementation; only clear call records + mockIsWSL.mockClear(); + mockGetWSLDistroName.mockClear(); + mockGetSchemaVersion.mockClear(); }); test('returns status ok with adapter and concurrency info', async () => { @@ -236,6 +250,81 @@ describe('GET /api/health', () => { expect(body.version.length).toBeGreaterThan(0); }); + // Schema vintage (#2316): a bug report needs to be able to state which build + // created this database and which last applied schema to it. + test('reports the schema vintage', async () => { + mockGetStats.mockImplementationOnce(() => ({ + active: 0, + queuedTotal: 0, + queuedByConversation: [], + maxConcurrent: 10, + activeConversationIds: [], + })); + mockGetRunningWorkflows.mockImplementationOnce(async () => []); + + const app = makeApp(); + const response = await app.request('/api/health'); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + schema?: { createdAppVersion: string | null; appVersion: string; appliedAt: string | null }; + }; + expect(body.schema).toEqual({ + createdAppVersion: '0.5.3', + appVersion: '0.6.0', + appliedAt: '2026-07-01T00:00:00.000Z', + }); + }); + + test('reports a null creation vintage rather than omitting it', async () => { + mockGetStats.mockImplementationOnce(() => ({ + active: 0, + queuedTotal: 0, + queuedByConversation: [], + maxConcurrent: 10, + activeConversationIds: [], + })); + mockGetRunningWorkflows.mockImplementationOnce(async () => []); + + mockGetSchemaVersion.mockImplementationOnce(async () => ({ + createdAppVersion: null, + appVersion: '0.6.0', + createdAt: null, + appliedAt: null, + })); + + const app = makeApp(); + const body = (await (await app.request('/api/health')).json()) as { + schema?: { createdAppVersion: string | null }; + }; + expect(body.schema).toBeDefined(); + expect(body.schema?.createdAppVersion).toBeNull(); + }); + + test('omits schema and still answers 200 when the vintage read fails', async () => { + mockGetStats.mockImplementationOnce(() => ({ + active: 0, + queuedTotal: 0, + queuedByConversation: [], + maxConcurrent: 10, + activeConversationIds: [], + })); + mockGetRunningWorkflows.mockImplementationOnce(async () => []); + + mockGetSchemaVersion.mockImplementationOnce(async () => { + throw new Error('no such table: remote_agent_schema_version'); + }); + + const app = makeApp(); + const response = await app.request('/api/health'); + // Health is public and must stay answerable when the DB is degraded. + expect(response.status).toBe(200); + + const body = (await response.json()) as { status: string; schema?: unknown }; + expect(body.status).toBe('ok'); + expect(body.schema).toBeUndefined(); + }); + test('includes running background workflows in concurrency.active count', async () => { mockGetStats.mockImplementationOnce(() => ({ active: 0, @@ -364,6 +453,48 @@ describe('GET /api/health', () => { const body = (await response.json()) as { is_docker: boolean }; expect(body.is_docker).toBe(true); }); + + test('includes is_wsl: false and omits wsl_distro in non-WSL environment', async () => { + mockGetStats.mockImplementationOnce(() => ({ + active: 0, + queuedTotal: 0, + queuedByConversation: [], + maxConcurrent: 10, + activeConversationIds: [], + })); + mockGetRunningWorkflows.mockImplementationOnce(async () => []); + mockIsWSL.mockReturnValueOnce(false); + mockGetWSLDistroName.mockReturnValueOnce(undefined); + + const app = makeApp(); + const response = await app.request('/api/health'); + expect(response.status).toBe(200); + + const body = (await response.json()) as { is_wsl: boolean; wsl_distro?: string }; + expect(body.is_wsl).toBe(false); + expect('wsl_distro' in body).toBe(false); + }); + + test('includes is_wsl: true and wsl_distro in WSL environment', async () => { + mockGetStats.mockImplementationOnce(() => ({ + active: 0, + queuedTotal: 0, + queuedByConversation: [], + maxConcurrent: 10, + activeConversationIds: [], + })); + mockGetRunningWorkflows.mockImplementationOnce(async () => []); + mockIsWSL.mockReturnValueOnce(true); + mockGetWSLDistroName.mockReturnValueOnce('Ubuntu'); + + const app = makeApp(); + const response = await app.request('/api/health'); + expect(response.status).toBe(200); + + const body = (await response.json()) as { is_wsl: boolean; wsl_distro?: string }; + expect(body.is_wsl).toBe(true); + expect(body.wsl_distro).toBe('Ubuntu'); + }); }); // --------------------------------------------------------------------------- @@ -510,5 +641,14 @@ describe('GET /api/openapi.json', () => { expect(schemas['Conversation']).toBeDefined(); expect(schemas['Codebase']).toBeDefined(); expect(schemas['WorkflowEvent']).toBeDefined(); + // The WSL fields on /api/health are consumed by the generated web client + // types — assert the schema actually exposes them. + const health = schemas['HealthResponse'] as + | { properties?: Record; required?: string[] } + | undefined; + expect(health?.properties?.['is_wsl']).toBeDefined(); + expect(health?.properties?.['wsl_distro']).toBeDefined(); + expect(health?.required).toContain('is_wsl'); + expect(health?.required).not.toContain('wsl_distro'); }); }); diff --git a/packages/server/src/routes/api.messages.test.ts b/packages/server/src/routes/api.messages.test.ts index 2688f4bd24..88513d8512 100644 --- a/packages/server/src/routes/api.messages.test.ts +++ b/packages/server/src/routes/api.messages.test.ts @@ -4,6 +4,7 @@ import type { ConversationLockManager } from '@archon/core'; import type { WebAdapter } from '../adapters/web'; import { validationErrorHook } from './openapi-defaults'; import { mockAllWorkflowModules } from '../test/workflow-mock-factories'; +import { MAX_TOOL_OUTPUT_CHARS } from '../adapters/web/truncate'; // --------------------------------------------------------------------------- // Mock setup — must be before dynamic imports of mocked modules @@ -470,6 +471,102 @@ describe('GET /api/conversations/:id/messages', () => { }); }); +// --------------------------------------------------------------------------- +// Tests: GET /api/conversations/:id/messages — tool output bounding (#2236) +// --------------------------------------------------------------------------- + +describe('GET /api/conversations/:id/messages — tool output bounding', () => { + beforeEach(() => { + mockFindConversationByPlatformId.mockReset(); + mockListMessages.mockReset(); + }); + + test('truncates large tool output in hydration metadata without touching the DB value', async () => { + const largeOutput = 'y'.repeat(MAX_TOOL_OUTPUT_CHARS + 10_000); + const storedMetadata = JSON.stringify({ + toolCalls: [ + { + name: 'bash', + input: { command: 'cat file.txt' }, + output: largeOutput, + duration: 500, + }, + ], + }); + + mockFindConversationByPlatformId.mockImplementationOnce(async () => MOCK_CONV); + mockListMessages.mockImplementationOnce(async () => [ + { + id: 'msg-tool', + conversation_id: MOCK_CONV.id, + role: 'assistant' as const, + content: '', + metadata: storedMetadata, + created_at: new Date().toISOString(), + }, + ]); + + const { app } = makeApp(); + const response = await app.request('/api/conversations/web-test-abc/messages'); + expect(response.status).toBe(200); + + const body = (await response.json()) as Array<{ metadata: string }>; + const returnedMeta = JSON.parse(body[0]!.metadata) as { + toolCalls: Array<{ output: string }>; + }; + + // Returned tool output must be bounded, with the truncation marker appended + expect(returnedMeta.toolCalls[0]!.output.length).toBeLessThan(largeOutput.length); + expect(returnedMeta.toolCalls[0]!.output).toContain('[truncated'); + expect(returnedMeta.toolCalls[0]!.output).toContain('full output preserved on the server'); + }); + + test('preserves tool output within the cap in hydration', async () => { + const smallOutput = 'short output from tool'; + const metadata = JSON.stringify({ + toolCalls: [{ name: 'bash', input: {}, output: smallOutput, duration: 10 }], + }); + + mockFindConversationByPlatformId.mockImplementationOnce(async () => MOCK_CONV); + mockListMessages.mockImplementationOnce(async () => [ + { + id: 'msg-small', + conversation_id: MOCK_CONV.id, + role: 'assistant' as const, + content: '', + metadata, + created_at: new Date().toISOString(), + }, + ]); + + const { app } = makeApp(); + const response = await app.request('/api/conversations/web-test-abc/messages'); + const body = (await response.json()) as Array<{ metadata: string }>; + expect(body[0]!.metadata).toBe(metadata); + }); + + test('returns non-toolCall metadata (workflowResult etc.) unchanged', async () => { + const metadata = JSON.stringify({ workflowResult: { runId: 'abc' } }); + + mockFindConversationByPlatformId.mockImplementationOnce(async () => MOCK_CONV); + mockListMessages.mockImplementationOnce(async () => [ + { + id: 'msg-no-tools', + conversation_id: MOCK_CONV.id, + role: 'assistant' as const, + content: 'Done.', + metadata, + created_at: new Date().toISOString(), + }, + ]); + + const { app } = makeApp(); + const response = await app.request('/api/conversations/web-test-abc/messages'); + const body = (await response.json()) as Array<{ metadata: string }>; + expect(body[0]!.metadata).toBe(metadata); + }); +}); + // --------------------------------------------------------------------------- // Tests: PATCH /api/conversations/:id // --------------------------------------------------------------------------- diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index cfe6984049..3975d3b4a3 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -6,6 +6,7 @@ import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; import { streamSSE } from 'hono/streaming'; import { cors } from 'hono/cors'; import type { WebAdapter } from '../adapters/web'; +import { boundMetadataToolOutputs } from '../adapters/web/truncate'; import { rm, readFile, writeFile, unlink, mkdir, readdir, stat } from 'fs/promises'; import { existsSync, readFileSync } from 'fs'; import { normalize, join, sep, basename } from 'path'; @@ -18,10 +19,12 @@ import type { GlobalConfig, TiersPatch, UserRole, + SchemaVersionInfo, } from '@archon/core'; import { handleMessage, getDatabaseType, + getSchemaVersion, loadConfig, loadRepoConfig, toSafeConfig, @@ -72,6 +75,8 @@ import { getRunArtifactsPath, getArchonHome, isDocker, + isWSL, + getWSLDistroName, checkForUpdate, BUNDLED_IS_BINARY, BUNDLED_VERSION, @@ -107,6 +112,7 @@ import * as workflowEventDb from '@archon/core/db/workflow-events'; import * as messageDb from '@archon/core/db/messages'; import * as userDb from '@archon/core/db/users'; import { + abandonWorkflow, approveWorkflow, rejectWorkflow, resetWorkflowNodeSessions, @@ -1291,7 +1297,19 @@ const getHealthRoute = createRoute({ runningWorkflows: z.number(), version: z.string().optional(), is_docker: z.boolean(), + is_wsl: z.boolean(), + wsl_distro: z.string().optional(), activePlatforms: z.array(z.string()).optional(), + // Schema vintage (#2316) so a bug report can state which Archon build + // created this database and which last applied schema to it. Omitted + // when unrecorded or unreadable — health must answer regardless. + schema: z + .object({ + createdAppVersion: z.string().nullable(), + appVersion: z.string(), + appliedAt: z.string().nullable(), + }) + .optional(), }) .openapi('HealthResponse'), }, @@ -2273,7 +2291,9 @@ export function registerApiRoutes( metadata = '{}'; } } - return { ...row, metadata }; + // Bound tool_result outputs in hydration responses — the DB keeps the full + // value; only the browser-bound payload is capped (see #2236). + return { ...row, metadata: boundMetadataToolOutputs(metadata) }; } function toApiWorkflowRun(row: WorkflowRun): ApiWorkflowRun { @@ -3229,8 +3249,8 @@ export function registerApiRoutes( } // A `failed` run is terminal per TERMINAL_WORKFLOW_STATUSES but remains // resumable, so the user must be able to discard it — only the two - // non-resumable terminal states are blocked. Mirrors abandonWorkflow in - // workflow-operations.ts so the HTTP route agrees with CLI/chat (#1887). + // non-resumable terminal states are blocked (the 400 mapping lives here; + // abandonWorkflow re-validates). if (run.status === 'completed' || run.status === 'cancelled') { return apiError( c, @@ -3238,8 +3258,18 @@ export function registerApiRoutes( `Cannot abandon run with status '${run.status}'. Only running, paused, or failed runs can be abandoned.` ); } - await workflowDb.cancelWorkflowRun(runId); - return c.json({ success: true, message: `Abandoned workflow: ${run.workflow_name}` }); + // Delegate to the SHARED op — a raw cancelWorkflowRun here previously skipped + // the sub-run cascade cancel AND the container reclaim (M2), so a web abandon + // orphaned children that CLI/chat abandons cleaned up. + const { cascadeFailures, blockedParentRunId } = await abandonWorkflow(runId); + let message = `Abandoned workflow: ${run.workflow_name}`; + if (cascadeFailures > 0) { + message += ` — warning: ${String(cascadeFailures)} sub-run(s) could not be cancelled and may still be running`; + } + if (blockedParentRunId) { + message += ` — parent run ${blockedParentRunId} was blocked on this sub-run and stays paused; resume it to fail the node cleanly or abandon it too`; + } + return c.json({ success: true, message }); } catch (error) { getLog().error({ err: error, runId }, 'api.workflow_run_abandon_failed'); return apiError(c, 500, 'Failed to abandon workflow run'); @@ -3262,6 +3292,16 @@ export function registerApiRoutes( if (!approval?.nodeId) { return apiError(c, 400, 'Workflow run is paused but missing approval context'); } + if (approval.type === 'child_workflow') { + // Not an approvable gate — the parent resumes automatically when the child + // completes. approveWorkflow throws the same redirect; map it to a 400 + // here so the console gets the message instead of an opaque 500. + return apiError( + c, + 400, + `Run is paused waiting on sub-run ${approval.childRunId ?? ''}. Approve or reject the child run instead.` + ); + } if (isGateResolved(approval)) { // Post-#2075 the run stays 'paused' after approval, so status alone no // longer distinguishes "awaiting the human" from "awaiting resume". @@ -3332,6 +3372,15 @@ export function registerApiRoutes( } const approvalRaw = run.metadata.approval; const approval = isApprovalContext(approvalRaw) ? approvalRaw : undefined; + if (approval?.type === 'child_workflow') { + // Mirror of the approve route's guard — rejectWorkflow throws the same + // redirect; map it to a 400 with the child pointer. + return apiError( + c, + 400, + `Run is paused waiting on sub-run ${approval.childRunId ?? ''}. Reject the child run instead, or abandon this run to discard the whole tree.` + ); + } if (approval && isGateResolved(approval)) { return apiError( c, @@ -4281,6 +4330,27 @@ export function registerApiRoutes( .map(r => r.conversation_id) .filter(id => !lockActiveSet.has(id)); const allActiveIds = [...stats.activeConversationIds, ...backgroundConversationIds]; + const wslDistro = getWSLDistroName(); + + // Health is public (PUBLIC_API_GATE_PREFIXES) and must stay answerable when the + // database is degraded, so a failed vintage read is logged and the key omitted + // rather than turning the healthcheck into a 500. `createdAt` is deliberately not + // exposed — the two version strings plus applied_at are what a bug report needs. + let schema: + | Pick + | undefined; + try { + const info = await getSchemaVersion(); + if (info) { + schema = { + createdAppVersion: info.createdAppVersion, + appVersion: info.appVersion, + appliedAt: info.appliedAt, + }; + } + } catch (err) { + getLog().warn({ err }, 'api.schema_version_read_failed'); + } return c.json({ status: 'ok', @@ -4293,7 +4363,10 @@ export function registerApiRoutes( runningWorkflows: runningWorkflowRows.length, version: appVersion, is_docker: isDocker(), + is_wsl: isWSL(), + ...(wslDistro ? { wsl_distro: wslDistro } : {}), activePlatforms: activePlatforms ? [...activePlatforms] : ['Web'], + ...(schema ? { schema } : {}), }); }); diff --git a/packages/server/src/routes/api.workflow-runs.test.ts b/packages/server/src/routes/api.workflow-runs.test.ts index ed1506f9ad..ec5a5368eb 100644 --- a/packages/server/src/routes/api.workflow-runs.test.ts +++ b/packages/server/src/routes/api.workflow-runs.test.ts @@ -202,11 +202,13 @@ const mockResolveApprovalGate = mock(async (_id: string, _md: unknown, _events?: const mockResolveAndCancelApprovalGate = mock(async (_id: string, _events?: unknown) => ({ resolved: true, })); +const mockFindChildRuns = mock(async (_parentRunId: string): Promise => []); mock.module('@archon/core/db/workflows', () => ({ listWorkflowRuns: mockListWorkflowRuns, listDashboardRuns: mockListDashboardRuns, getWorkflowRun: mockGetWorkflowRun, + findChildRuns: mockFindChildRuns, cancelWorkflowRun: mockCancelWorkflowRun, deleteWorkflowRun: mockDeleteWorkflowRun, updateWorkflowRun: mockUpdateWorkflowRun, @@ -1278,6 +1280,11 @@ describe('POST /api/workflows/runs/:runId/abandon', () => { beforeEach(() => { mockGetWorkflowRun.mockReset(); mockCancelWorkflowRun.mockReset(); + // The shared abandonWorkflow op destructures { cancelled } from this call — + // a bare mockReset() would make it return undefined and 500 the route. + mockCancelWorkflowRun.mockImplementation(async (_id: string) => ({ cancelled: true })); + mockFindChildRuns.mockReset(); + mockFindChildRuns.mockImplementation(async (_parentRunId: string): Promise => []); }); test('returns 404 when run not found', async () => { @@ -1318,7 +1325,8 @@ describe('POST /api/workflows/runs/:runId/abandon', () => { }); test('returns 200 and calls cancelWorkflowRun for running run', async () => { - mockGetWorkflowRun.mockResolvedValueOnce(MOCK_RUNNING_RUN); + // Two lookups now: the route's pre-check + the shared abandonWorkflow op's own. + mockGetWorkflowRun.mockResolvedValue(MOCK_RUNNING_RUN); const { app } = makeApp(); const response = await app.request('/api/workflows/runs/run-uuid-1/abandon', { method: 'POST', @@ -1333,7 +1341,8 @@ describe('POST /api/workflows/runs/:runId/abandon', () => { // #1887: a failed run is terminal but resumable, so it must remain // abandonable — the HTTP route previously rejected it, contradicting CLI/chat. test('returns 200 and calls cancelWorkflowRun for failed run', async () => { - mockGetWorkflowRun.mockResolvedValueOnce(MOCK_FAILED_RUN); + // Two lookups now: the route's pre-check + the shared abandonWorkflow op's own. + mockGetWorkflowRun.mockResolvedValue(MOCK_FAILED_RUN); const { app } = makeApp(); const response = await app.request('/api/workflows/runs/run-uuid-4/abandon', { method: 'POST', @@ -1448,6 +1457,35 @@ describe('POST /api/workflows/runs/:runId/approve', () => { expect(response.status).toBe(400); }); + // #2121 Phase 2: a parent paused blocked on a `workflow:` child has no approvable + // gate of its own — approving the PARENT must 400 with a redirect to the child id, + // never stamp a spurious node_completed for the parent's sub-run node. + test('returns 400 redirecting to the child when the parent is blocked on a sub-run', async () => { + mockGetWorkflowRun.mockResolvedValueOnce({ + ...MOCK_PAUSED_RUN, + id: 'parent-blocked-1', + metadata: { + approval: { + type: 'child_workflow', + nodeId: 'sub', + message: 'Blocked on sub-run', + childRunId: 'child-xyz', + }, + }, + }); + const { app } = makeApp(); + const response = await app.request('/api/workflows/runs/parent-blocked-1/approve', { + method: 'POST', + body: JSON.stringify({}), + headers: { 'Content-Type': 'application/json' }, + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(body.error).toContain('child-xyz'); + // No gate mutation happened. + expect(mockResolveApprovalGate).not.toHaveBeenCalled(); + }); + test('returns 400 when the gate is already resolved (double-approve guard)', async () => { // Post-#2075 an approved run stays 'paused' with approval.resolved set — // the status check alone no longer blocks a second approve. @@ -1648,6 +1686,33 @@ describe('POST /api/workflows/runs/:runId/reject', () => { expect(response.status).toBe(400); }); + // #2121 Phase 2: rejecting a parent blocked on a `workflow:` child must 400 with a + // redirect to the child id, not cancel the parent or stamp its sub-run node. + test('returns 400 redirecting to the child when the parent is blocked on a sub-run', async () => { + mockGetWorkflowRun.mockResolvedValueOnce({ + ...MOCK_PAUSED_RUN, + id: 'parent-blocked-2', + metadata: { + approval: { + type: 'child_workflow', + nodeId: 'sub', + message: 'Blocked on sub-run', + childRunId: 'child-abc', + }, + }, + }); + const { app } = makeApp(); + const response = await app.request('/api/workflows/runs/parent-blocked-2/reject', { + method: 'POST', + body: JSON.stringify({ reason: 'no' }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(body.error).toContain('child-abc'); + expect(mockResolveAndCancelApprovalGate).not.toHaveBeenCalled(); + }); + test('cancels immediately when no on_reject configured', async () => { mockGetWorkflowRun.mockResolvedValue(MOCK_PAUSED_RUN); const { app } = makeApp(); diff --git a/packages/web/package.json b/packages/web/package.json index 72cd97b66c..afbe10952f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@archon/web", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "scripts": { diff --git a/packages/web/src/components/chat/ChatInterface.tsx b/packages/web/src/components/chat/ChatInterface.tsx index d68aa9ccdc..16871d067e 100644 --- a/packages/web/src/components/chat/ChatInterface.tsx +++ b/packages/web/src/components/chat/ChatInterface.tsx @@ -38,6 +38,7 @@ import { } from '@/lib/message-cache'; import { useProject } from '@/contexts/ProjectContext'; import { ensureUtc } from '@/lib/format'; +import { resolveChatHeaderPath } from '@/lib/chat-header'; function mapMessageRow(row: MessageResponse): ChatMessage { let meta: { @@ -99,9 +100,13 @@ function mapMessageRow(row: MessageResponse): ChatMessage { interface ChatInterfaceProps { conversationId: string; + cwdOverride?: string | null; } -export function ChatInterface({ conversationId }: ChatInterfaceProps): React.ReactElement { +export function ChatInterface({ + conversationId, + cwdOverride, +}: ChatInterfaceProps): React.ReactElement { const navigate = useNavigate(); const queryClient = useQueryClient(); const { selectedProjectId } = useProject(); @@ -136,6 +141,8 @@ export function ChatInterface({ conversationId }: ChatInterfaceProps): React.Rea }); // Default to true (hide button) until server confirms non-Docker — prevents broken vscode:// links const isDocker = health?.is_docker ?? true; + const isWsl = health?.is_wsl ?? false; + const wslDistro = health?.wsl_distro; // Sync messages to cache for persistence across navigation useEffect(() => { @@ -278,7 +285,7 @@ export function ChatInterface({ conversationId }: ChatInterfaceProps): React.Rea ? codebases?.find(cb => cb.id === selectedProjectId) : undefined; const headerTitle = currentConv?.title ?? 'Chat'; - const headerSubtitle = currentConv?.cwd ?? undefined; + const headerSubtitle = resolveChatHeaderPath(currentConv?.cwd, cwdOverride); const nextId = (): string => { messageIdCounter.current += 1; @@ -703,6 +710,8 @@ export function ChatInterface({ conversationId }: ChatInterfaceProps): React.Rea projectName={currentCodebase?.name ?? contextCodebase?.name} connected={isNewChat ? undefined : connected} isDocker={isDocker} + isWsl={isWsl} + wslDistro={wslDistro} /> {(conversationsError || codebasesError) && (
diff --git a/packages/web/src/components/dashboard/WorkflowRunCard.tsx b/packages/web/src/components/dashboard/WorkflowRunCard.tsx index f67d57cf30..4a2e58f29e 100644 --- a/packages/web/src/components/dashboard/WorkflowRunCard.tsx +++ b/packages/web/src/components/dashboard/WorkflowRunCard.tsx @@ -19,6 +19,7 @@ import { } from 'lucide-react'; import type { DashboardRunResponse } from '@/lib/api'; import { cn } from '@/lib/utils'; +import { ideUri } from '@/lib/ide-uri'; import { formatDuration } from '@/lib/format'; import { useWorkflowStore } from '@/stores/workflow-store'; import type { WorkflowState } from '@/lib/types'; @@ -27,6 +28,8 @@ import { ConfirmRunActionDialog } from './ConfirmRunActionDialog'; interface WorkflowRunCardProps { run: DashboardRunResponse; isDocker?: boolean; + isWsl?: boolean; + wslDistro?: string; onCancel: (runId: string) => void; onResume?: (runId: string) => void; onAbandon?: (runId: string) => void; @@ -137,6 +140,8 @@ function NodeCountsSummary({ counts }: { counts: NodeCounts }): React.ReactEleme export function WorkflowRunCard({ run, isDocker, + isWsl, + wslDistro, onCancel, onResume, onAbandon, @@ -297,7 +302,7 @@ export function WorkflowRunCard({ )} {run.working_path && !isDocker && ( void; onResume?: (runId: string) => void; onAbandon?: (runId: string) => void; @@ -19,6 +21,8 @@ export function WorkflowRunGroup({ parentPlatformId, runs, isDocker, + isWsl, + wslDistro, onCancel, onResume, onAbandon, @@ -54,6 +58,8 @@ export function WorkflowRunGroup({ key={run.id} run={run} isDocker={isDocker} + isWsl={isWsl} + wslDistro={wslDistro} onCancel={onCancel} onResume={onResume} onAbandon={onAbandon} diff --git a/packages/web/src/components/layout/Header.tsx b/packages/web/src/components/layout/Header.tsx index 391ecd6732..369ace9508 100644 --- a/packages/web/src/components/layout/Header.tsx +++ b/packages/web/src/components/layout/Header.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { ExternalLink, Copy, Check } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { ideUri } from '@/lib/ide-uri'; interface HeaderProps { title: string; @@ -8,6 +9,8 @@ interface HeaderProps { projectName?: string; connected?: boolean; isDocker?: boolean; + isWsl?: boolean; + wslDistro?: string; } function smartPath(fullPath: string): string { @@ -22,14 +25,14 @@ export function Header({ projectName, connected, isDocker, + isWsl, + wslDistro, }: HeaderProps): React.ReactElement { const [copied, setCopied] = useState(false); const openInVSCode = (): void => { if (subtitle) { - // Normalize backslashes to forward slashes for the vscode:// URI - const normalizedPath = subtitle.replace(/\\/g, '/'); - window.open(`vscode://file/${normalizedPath}`, '_blank'); + window.open(ideUri(subtitle, { is_wsl: isWsl, wsl_distro: wslDistro }), '_blank'); } }; diff --git a/packages/web/src/components/workflows/WorkflowExecution.tsx b/packages/web/src/components/workflows/WorkflowExecution.tsx index 815ebd3353..53600c8191 100644 --- a/packages/web/src/components/workflows/WorkflowExecution.tsx +++ b/packages/web/src/components/workflows/WorkflowExecution.tsx @@ -48,6 +48,7 @@ interface WorkflowRunQueryData { workerPlatformId: string | null; parentPlatformId: string | null; conversationPlatformId: string | null; + workingPath: string | null; codebaseId: string | null; events: WorkflowEventResponse[]; } @@ -199,6 +200,7 @@ export function WorkflowExecution({ runId }: WorkflowExecutionProps): React.Reac workerPlatformId: data.run.worker_platform_id ?? null, parentPlatformId: data.run.parent_platform_id ?? null, conversationPlatformId: data.run.conversation_platform_id ?? null, + workingPath: data.run.working_path ?? null, codebaseId: data.run.codebase_id ?? null, events: data.events, }; @@ -215,6 +217,7 @@ export function WorkflowExecution({ runId }: WorkflowExecutionProps): React.Reac const workerPlatformId = queryData?.workerPlatformId ?? null; const parentPlatformId = queryData?.parentPlatformId ?? null; const conversationPlatformId = queryData?.conversationPlatformId ?? null; + const workingPath = queryData?.workingPath ?? null; const error = queryError ? queryError instanceof Error ? queryError.message @@ -607,7 +610,7 @@ export function WorkflowExecution({ runId }: WorkflowExecutionProps): React.Reac if (isDag && activeView === 'chat' && parentPlatformId) { return (
- +
); } diff --git a/packages/web/src/experiments/console/ConsoleApp.tsx b/packages/web/src/experiments/console/ConsoleApp.tsx index f6bc2e0781..719ab05a99 100644 --- a/packages/web/src/experiments/console/ConsoleApp.tsx +++ b/packages/web/src/experiments/console/ConsoleApp.tsx @@ -4,7 +4,7 @@ import { ProjectRail } from './components/ProjectRail'; import { AddProjectDialog } from './components/AddProjectDialog'; import { ProjectPalette } from './components/ProjectPalette'; import { KeymapHelp } from './components/KeymapHelp'; -import { BuilderRoute } from './builder/BuilderRoute'; +import { BuilderConnected } from './builder/BuilderConnected'; import { RunsPage } from './routes/RunsPage'; import { RunDetailPage } from './routes/RunDetailPage'; import { ChatPage } from './routes/ChatPage'; @@ -74,7 +74,8 @@ export function ConsoleApp(): ReactElement { } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/packages/web/src/experiments/console/README.md b/packages/web/src/experiments/console/README.md index 48c981351b..e75f3e5470 100644 --- a/packages/web/src/experiments/console/README.md +++ b/packages/web/src/experiments/console/README.md @@ -10,6 +10,8 @@ Mounted at `/console/*`. Not part of the shipped product. Validates the mental m - `/console` → Runs view (scope = `all`) - `/console/settings` → Settings (assistant config, system health, GitHub identity) — global +- `/console/builder` → Workflow builder (project picker + open a workflow) — global +- `/console/builder/:name` → Workflow builder editing `:name` (deep-link with `?project=`) - `/console/p/:projectId` → Runs view scoped to a project - `/console/p/:projectId/chat` → Project-scoped agent chat - `/console/p/:projectId/r/:runId` → Run detail @@ -52,6 +54,7 @@ Client-only view preferences. All reads are try/catch-guarded and fall back to t | `archon.console.runNodeFilter` | `all` | Run detail | Node filter (`all` or a nodeId); auto-resets when the node is absent from the open run | | `archon.console.railWidth` | — | Project rail | Persisted sidebar width | | `archon.console.lastWorkflow` | — | Chat / dispatch | Last-used workflow | +| `archon.console.builderProject` | — | Workflow builder | Selected project (`cwd`); also mirrored as `?project=` | ## Status @@ -60,7 +63,8 @@ that scaffolded this surface has been completed; ongoing work is driven by user feedback during dogfooding rather than a milestone roadmap. Issues and ideas land via the PR template's UX Journey section. -In progress: the `builder/` subtree (Archon Studio workflow builder). PR-1 -ships the data layer — types, variant registry, round-trip model, validation — -with no route mount; PR-2 adds the canvas UI and PR-3 wires saving through the -workflow API. See `builder/README.md`. +The `builder/` subtree (Archon Studio workflow builder): PR-1 (data layer — +types, variant registry, round-trip model, validation) and PR-2 (the canvas UI) +are merged; PR-3 wires connected mode (`/console/builder[/:name]`, project +picker, load/save/rename/delete through the workflow API, dirty + nav guard, +bundled Save-as) and is in review. See `builder/README.md`. diff --git a/packages/web/src/experiments/console/builder/BuilderConnected.tsx b/packages/web/src/experiments/console/builder/BuilderConnected.tsx new file mode 100644 index 0000000000..8b3ddcf431 --- /dev/null +++ b/packages/web/src/experiments/console/builder/BuilderConnected.tsx @@ -0,0 +1,606 @@ +/** + * Connected workflow builder route (PR-3). Replaces the fixture-backed + * `BuilderRoute`: it resolves a `:name` param + the selected project (`cwd`), + * loads a real workflow via the `loadWorkflow` skill verb, renders the + * controlled `BuilderPage`, and persists edits through `saveWorkflow` with full + * create / rename / delete. Bundled workflows open read-only and Save-as writes + * a project override. + * + * Nav guard: the app is a non-data ``, so `useBlocker` is + * unavailable. We use `beforeunload` (reload/close) plus a + * `confirmIfDirty` wrapper around this header's OWN navigation controls. The + * browser Back button and `ProjectRail` clicks are NOT intercepted — a known + * limitation; a data-router migration is out of scope for PR-3. + * + * House rules: no `console.*`; all failures surface as `Issue[]` in the panel. + */ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type ReactNode, +} from 'react'; +import { useNavigate, useParams, useLocation, useSearchParams } from 'react-router'; +import { BuilderPage } from './BuilderPage'; +import { fromWorkflowDefinition, toWorkflowDefinition } from './model'; +import { runValidation } from './validation'; +import { makeIssue } from './validation/make-issue'; +import { useBuilderProject } from './connect/use-builder-project'; +import { + blockingErrors, + clientIssue, + errorDetail, + errorToIssues, + isReadOnlySource, + isValidWorkflowName, + planRename, + renameReasonMessage, + saveTargetFor, + validationFailureToIssues, +} from './connect/save-logic'; +import type { BuilderWorkflow, Issue, WireWorkflowDefinition } from './types'; +import { + loadWorkflow, + saveWorkflow, + deleteWorkflow, + validateWorkflow, + listWorkflows, + type LoadedWorkflow, + type WorkflowSource, +} from '../skills/workflows'; +import { listProjects, type WorkflowListResult } from '../skills'; +import { useEntity, invalidate } from '../store/cache'; +import { K } from '../store/keys'; +import { HttpError } from '../lib/http'; +import type { Project } from '../primitives/project'; + +/** Router navigation state carried into the connected route. */ +interface BuilderNavState { + /** A freshly-seeded workflow for create mode (no server load). */ + createSeed?: BuilderWorkflow; + /** Non-fatal notices to seed the panel after a navigation (e.g. rename delete-failed). */ + notices?: Issue[]; +} + +const IDLE_LIST_KEY = 'workflows:idle'; + +function EmptyState({ children }: { children: ReactNode }): ReactElement { + return ( +
+
{children}
+
+ ); +} + +export function BuilderConnected(): ReactElement { + const { name } = useParams<{ name?: string }>(); + const navigate = useNavigate(); + const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const { projectId, setProjectId } = useBuilderProject(); + + const navState = location.state as BuilderNavState | null; + const createSeed = + navState?.createSeed !== undefined && navState.createSeed.name === name + ? navState.createSeed + : undefined; + const isCreateMode = createSeed !== undefined && name !== undefined; + + const projectsView = useEntity(K.projects, () => listProjects()); + const projects = projectsView.data ?? []; + const selectedProject = projects.find(p => p.id === projectId); + const cwd = selectedProject?.path; + + // Keep `?project=` in sync with the selected project so a deep-link reload + // restores the cwd. Guarded to only write when it actually differs (no loop). + useEffect(() => { + if (projectId !== undefined && searchParams.get('project') !== projectId) { + const next = new URLSearchParams(searchParams); + next.set('project', projectId); + setSearchParams(next, { replace: true }); + } + }, [projectId, searchParams, setSearchParams]); + + // Workflow list for the open-picker + rename collision checks. + const listKey = cwd !== undefined ? K.workflows(cwd) : IDLE_LIST_KEY; + const listView = useEntity(listKey, () => + cwd !== undefined ? listWorkflows(cwd) : Promise.resolve({ workflows: [], recommended: [] }) + ); + const existingNames = useMemo( + () => (listView.data?.workflows ?? []).map(w => w.name), + [listView.data] + ); + + // Single-workflow load (skipped in create mode — the seed is authoritative). + const idle = name === undefined || cwd === undefined || isCreateMode; + const loadKey = + idle || cwd === undefined || name === undefined ? 'builder:idle' : K.workflow(cwd, name); + const loadView = useEntity(loadKey, () => + idle || cwd === undefined || name === undefined + ? Promise.resolve(null) + : loadWorkflow(name, cwd) + ); + + // Resolve the workflow under edit (seed in create mode, else the server load). + // Memoized on stable inputs (location-state seed, cache object) so it does NOT + // produce a fresh identity every render — that would thrash the reset effect. + const loadedSource: WorkflowSource = isCreateMode + ? 'project' + : (loadView.data?.source ?? 'project'); + + const imported = useMemo<{ workflow: BuilderWorkflow; issues: Issue[] } | null>(() => { + if (isCreateMode && createSeed !== undefined) return { workflow: createSeed, issues: [] }; + if (loadView.data?.definition !== undefined) { + return fromWorkflowDefinition(loadView.data.definition); + } + return null; + }, [isCreateMode, createSeed, loadView.data]); + + // Editing state — reset whenever the imported workflow changes (workflow switch). + const [currentWorkflow, setCurrentWorkflow] = useState(null); + const [dirty, setDirty] = useState(false); + const [serverIssues, setServerIssues] = useState([]); + // Source can flip after a bundled Save-as (bundled → project override). + const [sourceOverride, setSourceOverride] = useState(null); + const [busy, setBusy] = useState(false); + + const effectiveSource = sourceOverride ?? loadedSource; + const readOnly = isReadOnlySource(effectiveSource); + + // Initialize editing state ONCE per editor identity (cwd + name + mode), not on + // every `imported` identity change. A successful Save invalidates the workflow + // cache, which re-runs the loader and yields a fresh `imported` for the SAME + // workflow; resetting on that refetch would clobber live edits and silently + // clear `dirty` in the window between Save resolving and the refetch landing. + // BuilderPage is keyed by the same identity, so it remounts in lockstep. When + // `imported` becomes null (picker view / still loading) the ref is cleared so + // the next load re-initializes cleanly. + const editorKey = `${cwd ?? ''}:${name ?? ''}:${String(isCreateMode)}`; + const initedKeyRef = useRef(null); + useEffect(() => { + if (imported === null) { + // No workflow open (picker view, or still loading) — return to a clean + // closed state so the dirty flag / beforeunload guard don't linger. + initedKeyRef.current = null; + setCurrentWorkflow(null); + setDirty(false); + setSourceOverride(null); + return; + } + if (initedKeyRef.current === editorKey) return; // same workflow refetched — keep edits + initedKeyRef.current = editorKey; + setCurrentWorkflow(imported.workflow); + setDirty(isCreateMode); + setSourceOverride(null); + setServerIssues(navState?.notices ?? []); + }, [imported, editorKey, isCreateMode, navState?.notices]); + + // beforeunload guard (reload/tab-close) — armed only while dirty. + useEffect(() => { + if (!dirty) return undefined; + const handler = (e: BeforeUnloadEvent): void => { + e.preventDefault(); + e.returnValue = ''; + }; + window.addEventListener('beforeunload', handler); + return (): void => { + window.removeEventListener('beforeunload', handler); + }; + }, [dirty]); + + const confirmIfDirty = useCallback( + (action: () => void): void => { + if (dirty && !window.confirm('You have unsaved changes. Discard them?')) return; + action(); + }, + [dirty] + ); + + const handleChange = useCallback((bw: BuilderWorkflow): void => { + setCurrentWorkflow(bw); + setDirty(true); + }, []); + + const extraIssues = useMemo( + () => [...(imported?.issues ?? []), ...serverIssues], + [imported, serverIssues] + ); + + const projectQuery = `?project=${encodeURIComponent(projectId ?? '')}`; + + // --- Save ---------------------------------------------------------------- + const doSave = useCallback(async (): Promise => { + if (currentWorkflow === null || cwd === undefined || name === undefined) return; + // Force the in-YAML name to match the route name so filename and `name:` stay in sync. + const definition: WireWorkflowDefinition = { ...toWorkflowDefinition(currentWorkflow), name }; + + const blocking = blockingErrors(runValidation(currentWorkflow)); + if (blocking.length > 0) { + setServerIssues([ + clientIssue( + 'save.blocked', + `Cannot save: fix ${String(blocking.length)} blocking error(s) first.` + ), + ]); + return; + } + + setBusy(true); + try { + const validation = await validateWorkflow(definition); + if (!validation.valid) { + setServerIssues(validationFailureToIssues(validation.errors)); + return; + } + const saved = await saveWorkflow(name, definition, { + cwd, + source: saveTargetFor(effectiveSource), + }); + setServerIssues([]); + setDirty(false); + setSourceOverride(saved.source); + invalidate(K.workflows(cwd)); + invalidate(K.workflow(cwd, name)); + // A just-created workflow now exists on disk — drop the create-mode seed + // from the route so it reloads as a normal project workflow (enabling + // Rename/Delete). `replace` keeps history clean. + if (isCreateMode) { + navigate(`/console/builder/${encodeURIComponent(name)}${projectQuery}`, { replace: true }); + } + } catch (e) { + setServerIssues(errorToIssues(e, 'save.failed', 'Save failed (unknown error).')); + } finally { + setBusy(false); + } + }, [currentWorkflow, cwd, name, effectiveSource, isCreateMode, navigate, projectQuery]); + + // --- Delete -------------------------------------------------------------- + const doDelete = useCallback(async (): Promise => { + if (name === undefined || cwd === undefined) return; + if (!window.confirm(`Delete workflow "${name}"? This removes the YAML file.`)) return; + setBusy(true); + try { + await deleteWorkflow(name, { cwd, source: saveTargetFor(effectiveSource) }); + invalidate(K.workflows(cwd)); + invalidate(K.workflow(cwd, name)); + navigate(`/console/builder${projectQuery}`); + } catch (e) { + setServerIssues(errorToIssues(e, 'delete.failed', 'Delete failed (unknown error).')); + } finally { + // The success path navigates away, but this route reuses one component + // instance across `builder` and `builder/:name`, so clear busy either way. + setBusy(false); + } + }, [name, cwd, effectiveSource, navigate, projectQuery]); + + // --- Rename -------------------------------------------------------------- + const doRename = useCallback(async (): Promise => { + if (name === undefined || cwd === undefined || currentWorkflow === null) return; + const raw = window.prompt('Rename workflow to:', name); + if (raw === null) return; + const to = raw.trim(); + const plan = planRename({ from: name, to, existingNames }); + if (!plan.ok) { + setServerIssues([clientIssue('rename.blocked', renameReasonMessage(plan.reason, to))]); + return; + } + + setBusy(true); + try { + const definition: WireWorkflowDefinition = { + ...toWorkflowDefinition(currentWorkflow), + name: to, + }; + const validation = await validateWorkflow(definition); + if (!validation.valid) { + setServerIssues(validationFailureToIssues(validation.errors)); + return; + } + // New-then-old: the new file is authoritative even if the old delete fails. + await saveWorkflow(to, definition, { cwd, source: saveTargetFor(effectiveSource) }); + let notices: Issue[] = []; + try { + await deleteWorkflow(name, { cwd, source: saveTargetFor(effectiveSource) }); + } catch (delErr) { + notices = [ + makeIssue({ + rule: 'rename.delete.failed', + severity: 'warning', + source: 'server', + message: `Renamed to "${to}", but removing the old file "${name}" failed (${errorDetail(delErr)}). Delete it manually.`, + path: {}, + }), + ]; + } + invalidate(K.workflows(cwd)); + invalidate(K.workflow(cwd, name)); + invalidate(K.workflow(cwd, to)); + setDirty(false); + navigate(`/console/builder/${encodeURIComponent(to)}${projectQuery}`, { + state: notices.length > 0 ? ({ notices } satisfies BuilderNavState) : undefined, + }); + } catch (e) { + setServerIssues(errorToIssues(e, 'rename.failed', 'Rename failed (unknown error).')); + } finally { + setBusy(false); + } + }, [name, cwd, currentWorkflow, existingNames, effectiveSource, navigate, projectQuery]); + + // --- New ----------------------------------------------------------------- + const doNew = useCallback((): void => { + if (cwd === undefined) return; + const raw = window.prompt('New workflow name:', ''); + if (raw === null) return; + const nm = raw.trim(); + if (!isValidWorkflowName(nm)) { + setServerIssues([clientIssue('new.invalid-name', renameReasonMessage('invalid-name', nm))]); + return; + } + if (existingNames.includes(nm)) { + setServerIssues([clientIssue('new.collision', renameReasonMessage('collision', nm))]); + return; + } + const seed: BuilderWorkflow = { + name: nm, + description: 'New workflow.', + meta: {}, + nodes: [ + { + id: 'step-1', + variant: 'prompt', + base: {}, + data: { prompt: 'Describe what this step should do.' }, + }, + ], + }; + navigate(`/console/builder/${encodeURIComponent(nm)}${projectQuery}`, { + state: { createSeed: seed } satisfies BuilderNavState, + }); + }, [cwd, existingNames, navigate, projectQuery]); + + // --- Navigation controls (dirty-guarded) --------------------------------- + const onPickProject = useCallback( + (id: string): void => { + confirmIfDirty(() => { + // The "Select a project…" option has value ""; normalize it to the + // hook's no-selection contract (`undefined`) and omit `?project=`. + const next = id === '' ? undefined : id; + setProjectId(next); + navigate( + next === undefined + ? '/console/builder' + : `/console/builder?project=${encodeURIComponent(next)}` + ); + }); + }, + [confirmIfDirty, setProjectId, navigate] + ); + + const onOpenWorkflow = useCallback( + (wf: string): void => { + if (wf === '' || wf === name) return; + confirmIfDirty(() => { + navigate(`/console/builder/${encodeURIComponent(wf)}${projectQuery}`); + }); + }, + [confirmIfDirty, navigate, projectQuery, name] + ); + + // Ensure the currently-open name is selectable even when it isn't in the list + // yet — e.g. a create-mode name (no file on disk), or before the list fetch + // resolves. Dedupe. + const openOptions = useMemo(() => { + const names = new Set(existingNames); + if (name !== undefined) names.add(name); + return [...names].sort((a, b) => a.localeCompare(b)); + }, [existingNames, name]); + + const saveLabel = readOnly ? 'Save as' : 'Save'; + const canSave = !busy && (readOnly || dirty || isCreateMode); + const workflowOpen = name !== undefined && imported !== null; + // Split a genuine 404 (workflow doesn't exist → offer New) from other load + // failures (500/403/network) — the latter must NOT masquerade as "not found", + // which would mislead and invite a duplicate via "Create a new workflow". + const loadError = name !== undefined && !isCreateMode ? loadView.error : undefined; + const notFound = loadError instanceof HttpError && loadError.status === 404; + const workflowLoadError = loadError !== undefined && !notFound ? loadError : undefined; + // Surface list-load failures instead of masking them as empty states — a + // transient backend error otherwise reads as "no projects / no workflows". + const listFetchError = projectsView.error ?? listView.error; + + return ( +
+
+

Workflow Builder

+ + beta + + + + + {cwd !== undefined ? ( + + ) : null} + + {cwd !== undefined ? ( + + ) : null} + +
+ + {workflowOpen ? ( +
+ {dirty ? ( + + ) : null} + + {!isCreateMode ? ( + + ) : null} + {!readOnly && !isCreateMode ? ( + + ) : null} +
+ ) : null} +
+ + {listFetchError !== undefined ? ( +
+ Failed to load: {listFetchError.message} +
+ ) : null} + + {workflowOpen && readOnly ? ( +
+ Bundled workflow — read-only. Save as writes a + project override that shadows the bundled default. +
+ ) : null} + +
+ {selectedProject === undefined ? ( + + Select a project to load its workflows. Workflows are discovered and saved per project + (the project's cwd). + + ) : workflowLoadError !== undefined ? ( + +

+ Failed to load {name}:{' '} + {errorDetail(workflowLoadError)} +

+

+ The workflow may still exist — retry once the server is reachable. +

+
+ ) : notFound ? ( + +

+ No workflow named {name} was found in{' '} + {selectedProject.name}. +

+

+ It may live in a subfolder (not loadable via the single-name route) or not exist yet. +

+ +
+ ) : name === undefined ? ( + existingNames.length === 0 ? ( + +

+ {selectedProject.name} has no project workflows + yet. +

+ +
+ ) : ( + + Pick a workflow from the Workflow menu above to + start editing, or create a new one. + + ) + ) : imported !== null && currentWorkflow !== null ? ( + + ) : ( + Loading workflow… + )} +
+
+ ); +} diff --git a/packages/web/src/experiments/console/builder/BuilderPage.tsx b/packages/web/src/experiments/console/builder/BuilderPage.tsx index 8a8471f072..ceb698194d 100644 --- a/packages/web/src/experiments/console/builder/BuilderPage.tsx +++ b/packages/web/src/experiments/console/builder/BuilderPage.tsx @@ -47,6 +47,13 @@ import { Toolbar } from './components/Toolbar'; interface BuilderPageProps { initialWorkflow: BuilderWorkflow; onChange?: (bw: BuilderWorkflow) => void; + /** + * Issues produced outside the client validation tiers — import issues from the + * round-trip and server-tier validation/save errors (PR-3). Merged into the + * panel alongside the debounced `runValidation` output and deduped by id, so a + * re-validation never clobbers a server/import issue. + */ + extraIssues?: readonly Issue[]; } const VALIDATION_DEBOUNCE_MS = 300; @@ -58,7 +65,11 @@ type UnstampedAction = EditorAction extends infer A : never : never; -export function BuilderPage({ initialWorkflow, onChange }: BuilderPageProps): ReactElement { +export function BuilderPage({ + initialWorkflow, + onChange, + extraIssues, +}: BuilderPageProps): ReactElement { const [state, dispatch] = useReducer(editorReducer, initialWorkflow, createEditorState); const [issues, setIssues] = useState(() => runValidation(initialWorkflow)); const [helpOpen, setHelpOpen] = useState(false); @@ -116,6 +127,16 @@ export function BuilderPage({ initialWorkflow, onChange }: BuilderPageProps): Re [state.workflow] ); + // Merge client-tier issues with import/server issues (`extraIssues`), deduped + // by stable id. Kept separate in state so the debounced client re-validation + // (which calls setIssues) can never clobber a persisted server/import issue. + const mergedIssues = useMemo(() => { + const byId = new Map(); + for (const issue of issues) byId.set(issue.id, issue); + for (const issue of extraIssues ?? []) byId.set(issue.id, issue); + return [...byId.values()]; + }, [issues, extraIssues]); + const selectedNode = state.selectedNodes.size === 1 ? (state.workflow.nodes.find(n => state.selectedNodes.has(n.id)) ?? null) @@ -380,7 +401,7 @@ export function BuilderPage({ initialWorkflow, onChange }: BuilderPageProps): Re
{ dispatch({ type: 'set-selection', diff --git a/packages/web/src/experiments/console/builder/BuilderRoute.tsx b/packages/web/src/experiments/console/builder/BuilderRoute.tsx deleted file mode 100644 index 4054bc8160..0000000000 --- a/packages/web/src/experiments/console/builder/BuilderRoute.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Fixture-backed `/console/builder` route. Renders `BuilderPage` seeded from - * PR-1's typed fixtures with a switcher — **no skill verbs, no - * `store/cache.ts`, no server I/O, no route params**. PR-3 replaces the - * fixture seed with `loadWorkflow` and adds the `:name` param; this thin - * component is exactly the surface it swaps out. - */ -import { useState, type ChangeEvent, type ReactElement } from 'react'; -import { FIXTURES } from './fixtures'; -import { importWorkflowDefinition } from './model'; -import { BuilderPage } from './BuilderPage'; - -const FIXTURE_KEYS = Object.keys(FIXTURES); - -export function BuilderRoute(): ReactElement { - // Default to the richest fixture (multi-node DAG with when/meta coverage). - const [fixtureKey, setFixtureKey] = useState('mixed'); - const definition = FIXTURES[fixtureKey]; - - return ( -
-
-

Workflow Builder

- - beta - - - Editing a local fixture — load/save lands in the next milestone. - -
- -
- -
- {definition !== undefined ? ( - // Key by fixture so switching remounts the editor with fresh state. - - ) : null} -
-
- ); -} diff --git a/packages/web/src/experiments/console/builder/CONTEXT.md b/packages/web/src/experiments/console/builder/CONTEXT.md new file mode 100644 index 0000000000..78717433ae --- /dev/null +++ b/packages/web/src/experiments/console/builder/CONTEXT.md @@ -0,0 +1,29 @@ +# Archon Studio Builder + +The in-console visual workflow builder and its surrounding capabilities (authoring, +persistence, marketplace submission, and AI-assisted authoring). This glossary fixes the +language used across the builder PR series so the same concept never travels under two names. + +## Language + +**Marketplace Submission**: +The act of publishing a workflow you authored to the community marketplace registry so other +users can discover and install it. A submission results in a registry entry pointing at the +workflow's source, frozen to a specific version. +_Avoid_: publish, share, upload, contribute (these all appear in CONTRIBUTING.md for the same act — "Marketplace Submission" is canonical here) + +**Builder Copilot**: +The AI chat assistant embedded in the workflow builder. It converses with the author and emits +Proposed Edits against the workflow currently on the canvas. It does not edit autonomously. +_Avoid_: builder agent, AI builder, workflow bot + +**Proposed Edit**: +A single structured change the Builder Copilot suggests (add a node, connect two nodes, set a +field, rename, remove), expressed in the builder's own mutation vocabulary so it can be applied +through the same reducer a manual edit uses. +_Avoid_: op, action, command, mutation (these are the implementation names; "Proposed Edit" is the domain term) + +**Proposal**: +The atomic batch of Proposed Edits the Builder Copilot returns from one turn. The author Accepts +or Rejects a Proposal as a whole; a Proposal is never partially applied. +_Avoid_: suggestion set, change set, batch diff --git a/packages/web/src/experiments/console/builder/README.md b/packages/web/src/experiments/console/builder/README.md index c6116f17c7..1d1fda3b90 100644 --- a/packages/web/src/experiments/console/builder/README.md +++ b/packages/web/src/experiments/console/builder/README.md @@ -8,7 +8,7 @@ The in-console workflow builder. Ported from the standalone builder can't represent — `loop`, `approval`, `cancel`, `script` — plus the three existing kinds (`prompt`, `bash`, `command`), round-trippable with pure-function validation and typed fixtures. -- **PR-2 (this layer): the visual editor.** React-Flow canvas, custom node +- **PR-2 (merged): the visual editor.** React-Flow canvas, custom node rendering, palette, inspector (with `when:` builder), validation panel, read-only syntax-highlighted YAML preview (reusing the console's existing `react-markdown` + `rehype-highlight` stack — no new highlighting dep), and @@ -18,13 +18,18 @@ The in-console workflow builder. Ported from the standalone in-memory `BuilderWorkflow`, plus a **fixture-backed** `/console/builder` route (sidebar "Workflow Builder" entry with a Beta pill) and a section on `/console/_preview`. -- **PR-3 (next): connected mode.** `loadWorkflow`/`saveWorkflow` skill verbs, - the `:name` route param, cache wiring, server-tier validation. - -**Nothing in PR-2 performs server I/O.** `BuilderPage` takes -`initialWorkflow: BuilderWorkflow` as a prop and reports edits via `onChange`; -the route seeds it from PR-1 fixtures only. That seam is exactly what PR-3 -wraps — reviewable and revertable by construction. +- **PR-3 (shipped): connected mode.** `loadWorkflow`/`saveWorkflow`/ + `deleteWorkflow`/`validateWorkflow` skill verbs, the `:name` route param, a + project picker (workflows are discovered/saved per-codebase `cwd`), an explicit + Save flow with a dirty indicator + nav guard, server-tier validation surfaced + into the issue panel, and full CRUD — with bundled workflows opening read-only + and saving as a project override. See **PR-3 specifics** below. + +`BuilderPage` stays a **controlled component**: it takes +`initialWorkflow: BuilderWorkflow` as a prop and reports edits via `onChange`. +PR-3's only additive change to it is an optional `extraIssues?: Issue[]` prop +(import + server issues merged into the panel). All server I/O, dirty/nav logic, +and CRUD live in `BuilderConnected` + `connect/*`. ## What's here @@ -46,8 +51,9 @@ builder/ │ # reducer (state.ts), keymap bindings (console useKeymap) ├── components/ # PR-2: canvas, node view, palette, inspector (+ per-variant │ # sub-forms), WhenBuilder, IssueList, YamlPreview, Toolbar -├── BuilderPage.tsx # PR-2: the controlled assembly (the PR-3 seam) -├── BuilderRoute.tsx # PR-2: fixture-backed /console/builder route +├── BuilderPage.tsx # PR-2: the controlled assembly (+ PR-3 extraIssues prop) +├── BuilderConnected.tsx # PR-3: connected /console/builder[/:name] route +├── connect/ # PR-3: pure save/rename/issue logic + selected-project hook └── **/*.test.ts # bun:test units (pure logic only — no DOM, no mock.module) ``` @@ -76,12 +82,13 @@ PR-2: flow/ yaml/ editor/ (pure: PR-1 + xyflow/dagre only) ↑ BuilderPage.tsx (controlled assembly; initialWorkflow prop) ↑ - BuilderRoute.tsx · routes/PreviewPage.tsx (fixture-backed surfaces) + BuilderConnected.tsx (PR-3: skills + store/cache + react-router) + routes/PreviewPage.tsx (fixture-backed visual surface) ``` -Lower layers never import upper layers, and nothing here imports skill verbs -or `store/cache.ts` — that wiring is PR-3. Each module compiles in isolation — -reviewable by construction. +PR-1/PR-2 layers never import skill verbs or `store/cache.ts` — only +`BuilderConnected.tsx` + `connect/*` (the PR-3 wiring) do. Each module compiles +in isolation — reviewable by construction. ## PR-2 specifics @@ -104,6 +111,46 @@ reviewable by construction. positions live in editor state and are stripped by `flowToBuilder`, keeping PR-1's round-trip byte-identical. +## PR-3 specifics (connected mode) + +- **Routes.** `/console/builder` (picker + open-a-workflow) and + `/console/builder/:name` (load + edit), both mounted to `BuilderConnected`. + The route `:name` is the filename; on every save the in-YAML `name:` is forced + equal to it, so filename and `name:` stay in sync (one name drives both). +- **Project picker.** Workflows are discovered/saved per-codebase `cwd`, so a + project must be selected first. Selection persists in + `archon.console.builderProject` (localStorage, try/catch-guarded) and is + reflected as a `?project=` search param, so a deep-link reload restores the + cwd. This is a deliberate divergence from the console's `/p/:projectId` + path-scoping (used for Runs/Chat) — the builder uses a global route + picker. +- **Save flow.** Explicit Save = client-validate (`runValidation`, blocking + errors gate the save) → server-validate (`POST /api/workflows/validate`, which + returns HTTP 200 even when invalid — branch on `valid`) → `PUT`, then invalidate + the workflow + list caches. A dirty dot shows unsaved edits. +- **Nav guard.** The app is a non-data ``, so `useBlocker` is + unavailable. The guard is a `beforeunload` listener (reload/tab-close) plus a + `confirmIfDirty` wrapper around the header's OWN controls (project change, + open-another, New). **Known limitation:** the browser Back button and + `ProjectRail` clicks are NOT intercepted; a data-router migration is out of + scope. +- **Bundled = read-only → Save-as.** `source === 'bundled'` opens read-only; the + Save button becomes "Save as" and writes a project override (the server also + 400s a bundled delete, so Delete is hidden for bundled). +- **CRUD.** New (seed a minimal single-prompt workflow, then create-on-save), + Rename (collision-guarded, new-then-old so a failed delete still leaves the new + file authoritative — surfaced as a non-fatal warning issue), Delete (confirm → + remove → navigate away). +- **Issues panel.** Client + import + server/save issues all flow through the + existing `IssueList` via `BuilderPage`'s `extraIssues` prop, deduped by id. +- **Save normalizes YAML key order.** The round-trip is **lossless but not + byte-identical** for real files — the model emits a normalized key order, so a + save can produce a slightly larger-than-expected (but correct) git diff. Dirty + detection is therefore on `BuilderWorkflow` identity from `onChange`, never on a + serialized-YAML string compare (which would falsely flag every load as dirty). +- **Subdir limitation (known).** `GET /api/workflows/:name` does not recurse into + `.archon/workflows//`; subfoldered workflows won't load via the + single-name route and surface a "not found" empty state (offers New). + ## Round-trip contract `toWorkflowDefinition(fromWorkflowDefinition(fixture))` deep-equals `fixture` for diff --git a/packages/web/src/experiments/console/builder/connect/save-logic.test.ts b/packages/web/src/experiments/console/builder/connect/save-logic.test.ts new file mode 100644 index 0000000000..9c1949fc0f --- /dev/null +++ b/packages/web/src/experiments/console/builder/connect/save-logic.test.ts @@ -0,0 +1,221 @@ +import { describe, test, expect } from 'bun:test'; +import { + serverErrorToIssues, + serverValidationToIssues, + validationFailureToIssues, + clientIssue, + errorToIssues, + errorDetail, + blockingErrors, + isReadOnlySource, + saveTargetFor, + isValidWorkflowName, + renameReasonMessage, + planRename, +} from './save-logic'; +import { makeIssue } from '../validation/make-issue'; +import { HttpError } from '../../lib/http'; +import type { Issue } from '../types'; + +describe('serverErrorToIssues', () => { + test('parses apiError JSON into error: detail and tags source:server', () => { + const err = new HttpError( + 400, + '/api/workflows/foo', + JSON.stringify({ error: 'Workflow definition is invalid', detail: 'dangling depends_on' }) + ); + const issues = serverErrorToIssues(err); + expect(issues).toHaveLength(1); + expect(issues[0]?.source).toBe('server'); + expect(issues[0]?.severity).toBe('error'); + expect(issues[0]?.message).toBe('Workflow definition is invalid: dangling depends_on'); + }); + + test('uses error alone when no detail', () => { + const err = new HttpError( + 400, + '/api/workflows/foo', + JSON.stringify({ error: 'Cannot delete bundled default workflow: foo' }) + ); + expect(serverErrorToIssues(err)[0]?.message).toBe( + 'Cannot delete bundled default workflow: foo' + ); + }); + + test('falls back to the raw snippet when the body is truncated / non-JSON', () => { + const err = new HttpError(400, '/api/workflows/foo', '{"error":"Workflow definition is inval'); + expect(serverErrorToIssues(err)[0]?.message).toBe('{"error":"Workflow definition is inval'); + }); + + test('falls back to a verb-neutral status message when the snippet is empty', () => { + const err = new HttpError(500, '/api/workflows/foo', ''); + expect(serverErrorToIssues(err)[0]?.message).toBe('Request failed (500)'); + }); + + test('empty parsed error field falls back to the raw snippet (not the empty field)', () => { + const body = JSON.stringify({ error: '', detail: 'internal context' }); + const err = new HttpError(400, '/api/workflows/foo', body); + expect(serverErrorToIssues(err)[0]?.message).toBe(body); + }); +}); + +describe('serverValidationToIssues', () => { + test('maps each error string into a source:server error issue', () => { + const issues = serverValidationToIssues([ + "Missing required field 'description'", + 'Unknown node id', + ]); + expect(issues).toHaveLength(2); + expect(issues.every(i => i.source === 'server' && i.severity === 'error')).toBe(true); + expect(issues[0]?.message).toBe("Missing required field 'description'"); + }); + + test('empty errors → no issues', () => { + expect(serverValidationToIssues([])).toEqual([]); + }); +}); + +describe('validationFailureToIssues', () => { + test('maps present errors through', () => { + const issues = validationFailureToIssues(['boom']); + expect(issues).toHaveLength(1); + expect(issues[0]?.message).toBe('boom'); + }); + + test('guarantees a fallback issue when errors is empty or undefined (never silent)', () => { + for (const input of [[], undefined]) { + const issues = validationFailureToIssues(input); + expect(issues).toHaveLength(1); + expect(issues[0]?.source).toBe('server'); + expect(issues[0]?.severity).toBe('error'); + expect(issues[0]?.message).toContain('no error details'); + } + }); +}); + +describe('clientIssue', () => { + test('mints a client-instant error issue', () => { + const issue = clientIssue('save.blocked', 'nope'); + expect(issue.source).toBe('client-instant'); + expect(issue.severity).toBe('error'); + expect(issue.rule).toBe('save.blocked'); + expect(issue.message).toBe('nope'); + }); +}); + +describe('errorToIssues', () => { + test('HttpError delegates to serverErrorToIssues', () => { + const err = new HttpError(400, '/api/workflows/foo', JSON.stringify({ error: 'bad' })); + const issues = errorToIssues(err, 'save.failed', 'fallback'); + expect(issues[0]?.source).toBe('server'); + expect(issues[0]?.message).toBe('bad'); + }); + + test('generic Error uses e.message and the caller-supplied rule', () => { + const issues = errorToIssues(new TypeError('Failed to fetch'), 'save.failed', 'unknown'); + expect(issues[0]?.message).toBe('Failed to fetch'); + expect(issues[0]?.rule).toBe('save.failed'); + }); + + test('non-Error thrown value uses the fallback string', () => { + const issues = errorToIssues('string literal', 'save.failed', 'unknown error'); + expect(issues[0]?.message).toBe('unknown error'); + }); +}); + +describe('errorDetail', () => { + test('HttpError → parsed server message', () => { + const err = new HttpError(403, '/api/workflows/foo', JSON.stringify({ error: 'denied' })); + expect(errorDetail(err)).toBe('denied'); + }); + + test('generic Error → message; non-Error → String()', () => { + expect(errorDetail(new Error('boom'))).toBe('boom'); + expect(errorDetail(42)).toBe('42'); + }); +}); + +describe('blockingErrors', () => { + test('keeps only severity:error', () => { + const issues: Issue[] = [ + makeIssue({ rule: 'a', severity: 'error', source: 'server', message: 'boom', path: {} }), + makeIssue({ rule: 'b', severity: 'warning', source: 'server', message: 'meh', path: {} }), + makeIssue({ rule: 'c', severity: 'info', source: 'server', message: 'fyi', path: {} }), + ]; + const blocking = blockingErrors(issues); + expect(blocking).toHaveLength(1); + expect(blocking[0]?.rule).toBe('a'); + }); + + test('empty in → empty out', () => { + expect(blockingErrors([])).toEqual([]); + }); +}); + +describe('isReadOnlySource / saveTargetFor', () => { + test('only bundled is read-only', () => { + expect(isReadOnlySource('bundled')).toBe(true); + expect(isReadOnlySource('project')).toBe(false); + expect(isReadOnlySource('global')).toBe(false); + }); + + test('bundled saves as project override, project stays project, global stays global', () => { + expect(saveTargetFor('bundled')).toBe('project'); + expect(saveTargetFor('project')).toBe('project'); + expect(saveTargetFor('global')).toBe('global'); + }); +}); + +describe('isValidWorkflowName', () => { + test('accepts a kebab name and a dotted mid-name (server accepts a.b)', () => { + expect(isValidWorkflowName('my-flow')).toBe(true); + expect(isValidWorkflowName('a.b')).toBe(true); + }); + + test('rejects path traversal, separators, leading dot, and empty', () => { + expect(isValidWorkflowName('../x')).toBe(false); + expect(isValidWorkflowName('a/b')).toBe(false); + expect(isValidWorkflowName('a\\b')).toBe(false); + expect(isValidWorkflowName('.hidden')).toBe(false); + expect(isValidWorkflowName('')).toBe(false); + }); +}); + +describe('renameReasonMessage', () => { + test('collision interpolates the target name', () => { + expect(renameReasonMessage('collision', 'my-flow')).toContain('"my-flow"'); + }); + + test('invalid-name interpolates the name and lists the constraints (incl. backslash)', () => { + const msg = renameReasonMessage('invalid-name', '../evil'); + expect(msg).toContain('"../evil"'); + expect(msg).toContain('..'); + expect(msg).toContain('\\'); + }); + + test('noop is a fixed string', () => { + expect(renameReasonMessage('noop', 'x')).toBe('The new name is the same as the current one.'); + }); +}); + +describe('planRename', () => { + test('valid distinct non-colliding rename → ok', () => { + const plan = planRename({ from: 'old', to: 'new', existingNames: ['old', 'other'] }); + expect(plan).toEqual({ ok: true }); + }); + + test('collision against an existing name is blocked', () => { + const plan = planRename({ from: 'old', to: 'other', existingNames: ['old', 'other'] }); + expect(plan).toEqual({ ok: false, reason: 'collision' }); + }); + + test('no-op rename (to === from) is blocked, even when the name is in the list', () => { + const plan = planRename({ from: 'old', to: 'old', existingNames: ['old', 'other'] }); + expect(plan).toEqual({ ok: false, reason: 'noop' }); + }); + + test('invalid target name is blocked, and takes precedence over collision', () => { + const plan = planRename({ from: 'old', to: '../evil', existingNames: ['old', '../evil'] }); + expect(plan).toEqual({ ok: false, reason: 'invalid-name' }); + }); +}); diff --git a/packages/web/src/experiments/console/builder/connect/save-logic.ts b/packages/web/src/experiments/console/builder/connect/save-logic.ts new file mode 100644 index 0000000000..a660cb0f0a --- /dev/null +++ b/packages/web/src/experiments/console/builder/connect/save-logic.ts @@ -0,0 +1,147 @@ +/** + * Pure save/rename/issue logic for the connected builder route. No I/O here — + * every function is a deterministic transform so it can be unit-tested without + * `fetch`. `BuilderConnected.tsx` owns the actual skill calls and wires these in. + */ +import type { Issue } from '../types'; +import { makeIssue } from '../validation/make-issue'; +import { HttpError } from '../../lib/http'; +import type { WorkflowSource, WorkflowSaveSource } from '../../skills/workflows'; + +/** A client-side instant error for the panel (name validation, save gate). */ +export function clientIssue(rule: string, message: string): Issue { + return makeIssue({ rule, severity: 'error', source: 'client-instant', message, path: {} }); +} + +/** A server-tier error issue for the panel. */ +function serverIssue(rule: string, message: string): Issue { + return makeIssue({ rule, severity: 'error', source: 'server', message, path: {} }); +} + +/** + * Map a failed `PUT`/`DELETE` (`HttpError`) into a single `source:'server'` + * issue for the panel. + * + * `HttpError.bodySnippet` is apiError's JSON `{ error, detail? }`, but it is the + * server body capped at 200 chars of content (a `...` suffix is appended when + * cut off, so up to ~203 chars) — `JSON.parse` may therefore throw on a + * truncated body. Guard it and fall back to the raw snippet. + */ +export function serverErrorToIssues(err: HttpError): Issue[] { + let message = err.bodySnippet || `Request failed (${String(err.status)})`; + try { + const parsed = JSON.parse(err.bodySnippet) as { error?: string; detail?: string }; + if (parsed.error) { + message = parsed.detail ? `${parsed.error}: ${parsed.detail}` : parsed.error; + } + } catch { + /* truncated/non-JSON body — keep the raw snippet */ + } + return [serverIssue('server.validation', message)]; +} + +/** + * Map the `errors[]` of a `POST /api/workflows/validate` response (HTTP 200 with + * `valid:false`) into `source:'server'` issues. One issue per error string. + */ +export function serverValidationToIssues(errors: readonly string[]): Issue[] { + return errors.map(message => serverIssue('server.validation', message)); +} + +/** + * Issues for a rejected server validation. Guarantees at least one issue so the + * panel is never silently cleared when the server returns `valid:false` with no + * `errors` — otherwise Save/Rename would re-enable with no explanation. + */ +export function validationFailureToIssues(errors: readonly string[] | undefined): Issue[] { + const issues = serverValidationToIssues(errors ?? []); + if (issues.length > 0) return issues; + return [ + serverIssue( + 'server.validation', + 'The server rejected the workflow but returned no error details.' + ), + ]; +} + +/** Best-effort human detail from a thrown error (the server message for an HttpError). */ +export function errorDetail(e: unknown): string { + if (e instanceof HttpError) return serverErrorToIssues(e)[0]?.message ?? e.bodySnippet; + if (e instanceof Error) return e.message; + return String(e); +} + +/** Map a thrown error to panel issues: HttpError → server detail, else a fallback. */ +export function errorToIssues(e: unknown, rule: string, fallback: string): Issue[] { + if (e instanceof HttpError) return serverErrorToIssues(e); + return [serverIssue(rule, e instanceof Error ? e.message : fallback)]; +} + +/** The subset of issues that must block a save (severity `'error'`). */ +export function blockingErrors(issues: readonly Issue[]): Issue[] { + return issues.filter(i => i.severity === 'error'); +} + +/** Bundled workflows open read-only; everything else is editable in place. */ +export function isReadOnlySource(source: WorkflowSource): boolean { + return source === 'bundled'; +} + +/** + * The write scope for a save. A `global` workflow saves back to global; a + * `project` workflow stays project; a `bundled` (read-only) workflow saves as a + * project override (Save-as). + */ +export function saveTargetFor(source: WorkflowSource): WorkflowSaveSource { + return source === 'global' ? 'global' : 'project'; +} + +/** + * Mirror of the server's `isValidCommandName` (`command-validation.ts`) — + * EXACTLY, not a stricter kebab/alnum regex: reject only names containing `/`, + * `\`, or `..`, empty names, or names starting with `.`. Dots mid-name (`a.b`) + * are allowed because the server accepts them. + */ +export function isValidWorkflowName(name: string): boolean { + if (name.includes('/') || name.includes('\\') || name.includes('..')) return false; + if (name === '' || name.startsWith('.')) return false; + return true; +} + +/** Human-readable reason for a blocked rename / rejected new name. */ +export function renameReasonMessage( + reason: 'collision' | 'invalid-name' | 'noop', + to: string +): string { + switch (reason) { + case 'collision': + return `A workflow named "${to}" already exists in this project.`; + case 'invalid-name': + return `"${to}" is not a valid workflow name (no "/", "\\", "..", leading dot, or empty).`; + case 'noop': + return 'The new name is the same as the current one.'; + } +} + +/** A planned rename decision. On success the caller PUTs the new name, then DELETEs the old. */ +export type RenamePlan = + | { ok: true } + | { ok: false; reason: 'collision' | 'invalid-name' | 'noop' }; + +/** + * Decide whether a rename from `from` → `to` can proceed. Blocks an invalid + * target name, a no-op (`to === from`), and a collision (`to` already exists in + * `existingNames`). The caller executes new-then-old so a failed delete still + * leaves the authoritative new file on disk. + */ +export function planRename(input: { + from: string; + to: string; + existingNames: readonly string[]; +}): RenamePlan { + const { from, to, existingNames } = input; + if (!isValidWorkflowName(to)) return { ok: false, reason: 'invalid-name' }; + if (to === from) return { ok: false, reason: 'noop' }; + if (existingNames.includes(to)) return { ok: false, reason: 'collision' }; + return { ok: true }; +} diff --git a/packages/web/src/experiments/console/builder/connect/use-builder-project.ts b/packages/web/src/experiments/console/builder/connect/use-builder-project.ts new file mode 100644 index 0000000000..a68d080ac2 --- /dev/null +++ b/packages/web/src/experiments/console/builder/connect/use-builder-project.ts @@ -0,0 +1,62 @@ +/** + * Selected-project state for the connected builder, backed by localStorage and + * seeded from the `?project=` search param when present (so a deep-link reload + * restores the cwd). Every storage access is try/catch-guarded — a + * disabled/over-quota store falls back to `undefined`, never throws (mirrors + * `ProjectRail`'s railWidth guard). + */ +import { useCallback, useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router'; + +const STORAGE_KEY = 'archon.console.builderProject'; + +function readStored(): string | undefined { + try { + return localStorage.getItem(STORAGE_KEY) ?? undefined; + } catch { + return undefined; + } +} + +function writeStored(id: string | undefined): void { + try { + if (id === undefined) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, id); + } catch { + /* ignore */ + } +} + +export interface BuilderProjectState { + projectId: string | undefined; + setProjectId: (id: string | undefined) => void; +} + +export function useBuilderProject(): BuilderProjectState { + const [searchParams] = useSearchParams(); + // Treat an absent OR empty `?project=` as "no selection" so a bare `project=` + // never shadows the persisted default. + const paramProject = searchParams.get('project') || undefined; + + // Seed from the deep-link param, else the persisted default. + const [projectId, setProjectIdState] = useState( + () => paramProject ?? readStored() + ); + + // Follow later `?project=` changes while the route stays mounted (browser + // back/forward, or a link to another builder project). Without this the hook + // would keep the stale seed and `BuilderConnected`'s sync effect would push it + // back into the URL, fighting the navigation. + useEffect((): void => { + if (paramProject === undefined || paramProject === projectId) return; + setProjectIdState(paramProject); + writeStored(paramProject); + }, [paramProject, projectId]); + + const setProjectId = useCallback((id: string | undefined): void => { + setProjectIdState(id); + writeStored(id); + }, []); + + return { projectId, setProjectId }; +} diff --git a/packages/web/src/experiments/console/components/ActiveRunCard.tsx b/packages/web/src/experiments/console/components/ActiveRunCard.tsx index 01c4b65824..52e6429c1b 100644 --- a/packages/web/src/experiments/console/components/ActiveRunCard.tsx +++ b/packages/web/src/experiments/console/components/ActiveRunCard.tsx @@ -7,7 +7,7 @@ import { ApprovalPanel } from './ApprovalPanel'; import { ApprovalContext } from './ApprovalContext'; import type { Run } from '../primitives/run'; import { shortRunId, formatElapsed, elapsedSince, formatCost } from '../lib/format'; -import { useIsDocker, openInIde } from '../lib/health'; +import { useIsDocker, useIdeEnv, openInIde } from '../lib/health'; import { statusTextClass, statusLabel } from '../lib/run-status'; /** Present + non-empty — narrows `string | null | undefined` to `string`. */ @@ -48,6 +48,7 @@ export function ActiveRunCard({ }: ActiveRunCardProps): ReactElement { const navigate = useNavigate(); const isDocker = useIsDocker(); + const ideEnv = useIdeEnv(); const elapsed = formatElapsed(elapsedSince(run.startedAt)); const canOpen = run.projectId !== null && !run.id.startsWith('demo-'); const canOpenIde = @@ -129,7 +130,7 @@ export function ActiveRunCard({ type="button" onClick={e => { e.stopPropagation(); - if (run.workingPath !== null) openInIde(run.workingPath); + if (run.workingPath !== null) openInIde(run.workingPath, ideEnv); }} title={`Open ${run.workingPath} in IDE`} aria-label="Open in IDE" diff --git a/packages/web/src/experiments/console/components/ApprovalPanel.tsx b/packages/web/src/experiments/console/components/ApprovalPanel.tsx index df5256aae9..6359fcb42d 100644 --- a/packages/web/src/experiments/console/components/ApprovalPanel.tsx +++ b/packages/web/src/experiments/console/components/ApprovalPanel.tsx @@ -95,6 +95,9 @@ export function ApprovalPanel({ run }: ApprovalPanelProps): ReactElement { const onApproveKey = (e: ReactKeyboardEvent): void => { stopPropagation(e); + // Don't submit while an IME composition is in progress (Japanese, + // Chinese, Korean, etc. — the first Enter accepts a candidate). + if (e.nativeEvent.isComposing || e.keyCode === 229) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void approve(); diff --git a/packages/web/src/experiments/console/components/ChatComposer.tsx b/packages/web/src/experiments/console/components/ChatComposer.tsx index 08905fe01c..c3cd98071c 100644 --- a/packages/web/src/experiments/console/components/ChatComposer.tsx +++ b/packages/web/src/experiments/console/components/ChatComposer.tsx @@ -102,6 +102,9 @@ export function ChatComposer({ }; const onKeyDown = (e: KeyboardEvent): void => { + // Don't submit while an IME composition is in progress (Japanese, + // Chinese, Korean, etc. — the first Enter accepts a candidate). + if (e.nativeEvent.isComposing || e.keyCode === 229) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); diff --git a/packages/web/src/experiments/console/components/RecentRunRow.tsx b/packages/web/src/experiments/console/components/RecentRunRow.tsx index abfd9bd4d6..c697165dc3 100644 --- a/packages/web/src/experiments/console/components/RecentRunRow.tsx +++ b/packages/web/src/experiments/console/components/RecentRunRow.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router'; import { OriginBadge } from './OriginBadge'; import type { Run } from '../primitives/run'; import { shortRunId, formatElapsed, elapsedSince, formatCost } from '../lib/format'; -import { useIsDocker, openInIde } from '../lib/health'; +import { useIsDocker, useIdeEnv, openInIde } from '../lib/health'; import { statusTextClass } from '../lib/run-status'; interface RecentRunRowProps { @@ -32,6 +32,7 @@ export function RecentRunRow({ }: RecentRunRowProps): ReactElement { const navigate = useNavigate(); const isDocker = useIsDocker(); + const ideEnv = useIdeEnv(); const elapsed = formatElapsed(elapsedSince(run.startedAt, run.finishedAt ?? undefined)); const canOpen = run.projectId !== null && !run.id.startsWith('demo-'); const canOpenIde = @@ -99,6 +100,14 @@ export function RecentRunRow({ {run.workflow} + {run.parentRunId ? ( + + ↳ child + + ) : null} {run.userMessage !== '' ? ( { e.stopPropagation(); - if (run.workingPath !== null) openInIde(run.workingPath); + if (run.workingPath !== null) openInIde(run.workingPath, ideEnv); }} title={`Open ${run.workingPath} in IDE`} aria-label="Open in IDE" diff --git a/packages/web/src/experiments/console/components/RunDetailHeader.tsx b/packages/web/src/experiments/console/components/RunDetailHeader.tsx index 2e029fb2fe..4751764a32 100644 --- a/packages/web/src/experiments/console/components/RunDetailHeader.tsx +++ b/packages/web/src/experiments/console/components/RunDetailHeader.tsx @@ -4,7 +4,7 @@ import { LiveDot } from './LiveDot'; import { OriginBadge } from './OriginBadge'; import type { Run } from '../primitives/run'; import { shortRunId, formatElapsed, elapsedSince, formatCost } from '../lib/format'; -import { useIsDocker, openInIde } from '../lib/health'; +import { useIsDocker, useIdeEnv, openInIde } from '../lib/health'; import { statusLabel, statusTextClass } from '../lib/run-status'; interface RunDetailHeaderProps { @@ -36,6 +36,7 @@ export function RunDetailHeader({ const isPaused = run.status === 'paused'; const isRunning = run.status === 'running'; const isDocker = useIsDocker(); + const ideEnv = useIdeEnv(); const canOpenIde = !isDocker && run.workingPath !== null && run.workingPath !== ''; const copyRunId = async (): Promise => { @@ -126,7 +127,7 @@ export function RunDetailHeader({