mirror of
https://github.com/coleam00/Archon
synced 2026-04-21 13:37:41 +00:00
Compare commits
95 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ea321419f | ||
|
|
ba4b9b47e6 | ||
|
|
08de8ee5c6 | ||
|
|
5ed38dc765 | ||
|
|
7be4d0a35e | ||
|
|
cc78071ff6 | ||
|
|
235a8ce202 | ||
|
|
39a05b762f | ||
|
|
c5e11ea8f5 | ||
|
|
cb44b96f7b | ||
|
|
45682bd2c8 | ||
|
|
52eebf995a | ||
|
|
28908f0c75 | ||
|
|
8ae4a56193 | ||
|
|
eb730c0b82 | ||
|
|
c495175d94 | ||
|
|
ec5e5a5cf9 | ||
|
|
fb73a500d7 | ||
|
|
83c119af78 | ||
|
|
60eeb00e42 | ||
|
|
4c6ddd994f | ||
|
|
d89bc767d2 | ||
|
|
c864d8e427 | ||
|
|
4e56991b72 | ||
|
|
922edbbac0 | ||
|
|
a7337d6977 | ||
|
|
301a139e5a | ||
|
|
bed36ca4ad | ||
|
|
df828594d7 | ||
|
|
75427c7cdd | ||
|
|
b7b445bd31 | ||
|
|
9dd57b2f3c | ||
|
|
86e4c8d605 | ||
|
|
d535c832e3 | ||
|
|
f1c5dcb231 | ||
|
|
47be699e00 | ||
|
|
2682430543 | ||
|
|
7d38716f1f | ||
|
|
367de7a625 | ||
|
|
18681701b3 | ||
|
|
1c600f2b62 | ||
|
|
bf9091159c | ||
|
|
4c259e7a0a | ||
|
|
d666b3c7ca | ||
|
|
7d9090678e | ||
|
|
7721259bdc | ||
|
|
818854474f | ||
|
|
64bdd30ef4 | ||
|
|
a5e5d5ceeb | ||
|
|
da1f8b7d97 | ||
|
|
2732288f07 | ||
|
|
b100cd4b48 | ||
|
|
5acf5640c8 | ||
|
|
68ecb75f0f | ||
|
|
51b8652d43 | ||
|
|
3dedc22537 | ||
|
|
882fc58f7c | ||
|
|
5c8c39e5c9 | ||
|
|
f61d576a4d | ||
|
|
c4ab0a2333 | ||
|
|
73d9240eb3 | ||
|
|
28b258286f | ||
|
|
81859d6842 | ||
|
|
33d31c44f1 | ||
|
|
5a4541b391 | ||
|
|
fd3f043125 | ||
|
|
af9ed84157 | ||
|
|
d6e24f5075 | ||
|
|
b5c5f81c8a | ||
|
|
bf20063e5a | ||
|
|
a8ac3f057b | ||
|
|
c9c6ab47cb | ||
|
|
37aeadb8c8 | ||
|
|
6a6740af38 | ||
|
|
c1ed76524b | ||
|
|
eb75ab60e5 | ||
|
|
39c6f05bad | ||
|
|
a4242e6b49 | ||
|
|
a7b3b94388 | ||
|
|
b9a70a5d17 | ||
|
|
91c184af57 | ||
|
|
c2089117fa | ||
|
|
b620c04e27 | ||
|
|
bf8bc8e4ae | ||
|
|
4292c3a24b | ||
|
|
e4555a769b | ||
|
|
dbe559efd1 | ||
|
|
3e3ddf25d5 | ||
|
|
4ee5232da3 | ||
|
|
16b47d3dde | ||
|
|
5685b41d18 | ||
|
|
b8e367f35d | ||
|
|
25757b8f56 | ||
|
|
7cae3a10d4 | ||
|
|
69193512e7 |
265 changed files with 22061 additions and 6536 deletions
|
|
@ -131,28 +131,30 @@ git status
|
|||
|
||||
### 3.2 Decision Tree
|
||||
|
||||
```
|
||||
```text
|
||||
┌─ IN WORKTREE?
|
||||
│ └─ YES → Use it (assume it's for this work)
|
||||
│ Log: "Using worktree at {path}"
|
||||
│ └─ YES → Use current branch AS-IS. Do NOT switch branches. Do NOT create
|
||||
│ new branches. The isolation system has already set up the correct
|
||||
│ branch; any deviation operates on the wrong code.
|
||||
│ Log: "Using worktree at {path} on branch {branch}"
|
||||
│
|
||||
├─ ON MAIN/MASTER?
|
||||
├─ ON $BASE_BRANCH? (main, master, or configured base branch)
|
||||
│ └─ Q: Working directory clean?
|
||||
│ ├─ YES → Create branch: fix/issue-{number}-{slug}
|
||||
│ │ git checkout -b fix/issue-{number}-{slug}
|
||||
│ └─ NO → Warn user:
|
||||
│ "Working directory has uncommitted changes.
|
||||
│ Please commit or stash before proceeding."
|
||||
│ STOP
|
||||
│ │ (only applies outside a worktree — e.g., manual CLI usage)
|
||||
│ └─ NO → STOP: "Uncommitted changes on $BASE_BRANCH.
|
||||
│ Please commit or stash before proceeding."
|
||||
│
|
||||
├─ ON FEATURE/FIX BRANCH?
|
||||
│ └─ Use it (assume it's for this work)
|
||||
├─ ON OTHER BRANCH?
|
||||
│ └─ Use it AS-IS (assume it was set up for this work).
|
||||
│ Do NOT switch to another branch (e.g., one shown by `git branch` but
|
||||
│ not currently checked out).
|
||||
│ If branch name doesn't contain issue number:
|
||||
│ Warn: "Branch '{name}' may not be for issue #{number}"
|
||||
│
|
||||
└─ DIRTY STATE?
|
||||
└─ Warn and suggest: git stash or git commit
|
||||
STOP
|
||||
└─ STOP: "Uncommitted changes. Please commit or stash first."
|
||||
```
|
||||
|
||||
### 3.3 Ensure Up-to-Date
|
||||
|
|
|
|||
|
|
@ -132,28 +132,30 @@ git status
|
|||
|
||||
### 3.2 Decision Tree
|
||||
|
||||
```
|
||||
```text
|
||||
┌─ IN WORKTREE?
|
||||
│ └─ YES → Use it (assume it's for this work)
|
||||
│ Log: "Using worktree at {path}"
|
||||
│ └─ YES → Use current branch AS-IS. Do NOT switch branches. Do NOT create
|
||||
│ new branches. The isolation system has already set up the correct
|
||||
│ branch; any deviation operates on the wrong code.
|
||||
│ Log: "Using worktree at {path} on branch {branch}"
|
||||
│
|
||||
├─ ON MAIN/MASTER?
|
||||
├─ ON $BASE_BRANCH? (main, master, or configured base branch)
|
||||
│ └─ Q: Working directory clean?
|
||||
│ ├─ YES → Create branch: fix/issue-{number}-{slug}
|
||||
│ │ git checkout -b fix/issue-{number}-{slug}
|
||||
│ └─ NO → Warn user:
|
||||
│ "Working directory has uncommitted changes.
|
||||
│ Please commit or stash before proceeding."
|
||||
│ STOP
|
||||
│ │ (only applies outside a worktree — e.g., manual CLI usage)
|
||||
│ └─ NO → STOP: "Uncommitted changes on $BASE_BRANCH.
|
||||
│ Please commit or stash before proceeding."
|
||||
│
|
||||
├─ ON FEATURE/FIX BRANCH?
|
||||
│ └─ Use it (assume it's for this work)
|
||||
├─ ON OTHER BRANCH?
|
||||
│ └─ Use it AS-IS (assume it was set up for this work).
|
||||
│ Do NOT switch to another branch (e.g., one shown by `git branch` but
|
||||
│ not currently checked out).
|
||||
│ If branch name doesn't contain issue number:
|
||||
│ Warn: "Branch '{name}' may not be for issue #{number}"
|
||||
│
|
||||
└─ DIRTY STATE?
|
||||
└─ Warn and suggest: git stash or git commit
|
||||
STOP
|
||||
└─ STOP: "Uncommitted changes. Please commit or stash first."
|
||||
```
|
||||
|
||||
### 3.3 Ensure Up-to-Date
|
||||
|
|
|
|||
|
|
@ -93,19 +93,40 @@ Provide a valid plan path or GitHub issue containing the plan.
|
|||
### 2.1 Check Current State
|
||||
|
||||
```bash
|
||||
# What branch are we on?
|
||||
git branch --show-current
|
||||
git status --porcelain
|
||||
|
||||
# Are we in a worktree?
|
||||
git rev-parse --show-toplevel
|
||||
git worktree list
|
||||
|
||||
# Is working directory clean?
|
||||
git status --porcelain
|
||||
```
|
||||
|
||||
### 2.2 Branch Decision
|
||||
|
||||
| Current State | Action |
|
||||
| ----------------- | ---------------------------------------------------- |
|
||||
| In worktree | Use it (log: "Using worktree") |
|
||||
| On base branch, clean | Create branch: `git checkout -b feature/{plan-slug}` |
|
||||
| On base branch, dirty | STOP: "Stash or commit changes first" |
|
||||
| On feature branch | Use it (log: "Using existing branch") |
|
||||
```text
|
||||
┌─ IN WORKTREE?
|
||||
│ └─ YES → Use current branch AS-IS. Do NOT switch branches. Do NOT create
|
||||
│ new branches. The isolation system has already set up the correct
|
||||
│ branch; any deviation operates on the wrong code.
|
||||
│ Log: "Using worktree at {path} on branch {branch}"
|
||||
│
|
||||
├─ ON $BASE_BRANCH? (main, master, or configured base branch)
|
||||
│ └─ Q: Working directory clean?
|
||||
│ ├─ YES → Create branch: git checkout -b feature/{plan-slug}
|
||||
│ │ (only applies outside a worktree — e.g., manual CLI usage)
|
||||
│ └─ NO → STOP: "Stash or commit changes first"
|
||||
│
|
||||
├─ ON OTHER BRANCH?
|
||||
│ └─ Use it AS-IS. Do NOT switch to another branch (e.g., one shown by
|
||||
│ `git branch` but not currently checked out).
|
||||
│ Log: "Using existing branch {name}"
|
||||
│
|
||||
└─ DIRTY STATE?
|
||||
└─ STOP: "Stash or commit changes first"
|
||||
```
|
||||
|
||||
### 2.3 Sync with Remote
|
||||
|
||||
|
|
@ -116,7 +137,7 @@ git pull --rebase origin $BASE_BRANCH 2>/dev/null || true
|
|||
|
||||
**PHASE_2_CHECKPOINT:**
|
||||
|
||||
- [ ] On correct branch (not base branch with uncommitted work)
|
||||
- [ ] On correct branch (not $BASE_BRANCH with uncommitted work)
|
||||
- [ ] Working directory ready
|
||||
- [ ] Up to date with remote
|
||||
|
||||
|
|
|
|||
|
|
@ -112,13 +112,26 @@ gh repo view --json nameWithOwner -q .nameWithOwner
|
|||
|
||||
### 2.3 Branch Decision
|
||||
|
||||
| Current State | Action |
|
||||
|---------------|--------|
|
||||
| Already on correct feature branch | Use it, log "Using existing branch: {name}" |
|
||||
| On base branch, clean working directory | Create and checkout: `git checkout -b {branch-name}` |
|
||||
| On base branch, dirty working directory | STOP with error: "Uncommitted changes on base branch. Stash or commit first." |
|
||||
| On different feature branch | STOP with error: "On branch {X}, expected {Y}. Switch branches or adjust plan." |
|
||||
| In a worktree | Use the worktree's branch, log "Using worktree branch: {name}" |
|
||||
Evaluate in order (first matching case wins):
|
||||
|
||||
```text
|
||||
┌─ IN WORKTREE?
|
||||
│ └─ YES → Use current branch AS-IS. Do NOT switch branches. Do NOT create
|
||||
│ new branches. The isolation system has already set up the correct
|
||||
│ branch; any deviation operates on the wrong code.
|
||||
│ Log: "Using worktree branch: {name}"
|
||||
│
|
||||
├─ ON $BASE_BRANCH? (main, master, or configured base branch)
|
||||
│ └─ Q: Working directory clean?
|
||||
│ ├─ YES → Create and checkout: `git checkout -b {branch-name}`
|
||||
│ │ (only applies outside a worktree — e.g., manual CLI usage)
|
||||
│ └─ NO → STOP: "Uncommitted changes on $BASE_BRANCH. Stash or commit first."
|
||||
│
|
||||
└─ ON OTHER BRANCH?
|
||||
└─ Q: Does it match the expected branch for this plan?
|
||||
├─ YES → Use it, log "Using existing branch: {name}"
|
||||
└─ NO → STOP: "On branch {X}, expected {Y}. Switch branches or adjust plan."
|
||||
```
|
||||
|
||||
### 2.4 Sync with Remote
|
||||
|
||||
|
|
|
|||
13
.archon/commands/e2e-echo-command.md
Normal file
13
.archon/commands/e2e-echo-command.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
description: E2E test command — echoes back the user message
|
||||
argument-hint: <any text>
|
||||
---
|
||||
|
||||
# E2E Echo Command
|
||||
|
||||
You are a simple echo agent for testing. Your ONLY job is to repeat back the user's message.
|
||||
|
||||
User message: $ARGUMENTS
|
||||
|
||||
Respond with EXACTLY this format and nothing else:
|
||||
command-echo: <the user message above>
|
||||
3
.archon/scripts/echo-args.js
Normal file
3
.archon/scripts/echo-args.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Simple script node test — echoes input as JSON
|
||||
const input = process.argv[2] ?? 'no-input';
|
||||
console.log(JSON.stringify({ echoed: input, timestamp: new Date().toISOString() }));
|
||||
7
.archon/scripts/echo-py.py
Normal file
7
.archon/scripts/echo-py.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""Simple script node test — echoes input as JSON (uv/Python runtime)."""
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
input_val = sys.argv[1] if len(sys.argv) > 1 else "no-input"
|
||||
print(json.dumps({"echoed": input_val, "timestamp": datetime.now(timezone.utc).isoformat()}))
|
||||
26
.archon/workflows/e2e-claude-smoke.yaml
Normal file
26
.archon/workflows/e2e-claude-smoke.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# E2E smoke test — Claude provider
|
||||
# Verifies: Claude connectivity (sendQuery), $nodeId.output refs
|
||||
# Design: Only uses allowed_tools: [] (no tool use) and no output_format (no structured output)
|
||||
# because the Claude CLI subprocess is slow with those features in CI.
|
||||
name: e2e-claude-smoke
|
||||
description: "Smoke test for Claude provider. Verifies prompt response."
|
||||
provider: claude
|
||||
model: haiku
|
||||
|
||||
nodes:
|
||||
# 1. Simple prompt — verifies Claude API connectivity via sendQuery
|
||||
- id: simple
|
||||
prompt: "What is 2+2? Answer with just the number, nothing else."
|
||||
allowed_tools: []
|
||||
idle_timeout: 30000
|
||||
|
||||
# 2. Assert non-empty output — fails CI if Claude returned nothing
|
||||
- id: assert
|
||||
bash: |
|
||||
output="$simple.output"
|
||||
if [ -z "$output" ]; then
|
||||
echo "FAIL: simple node returned empty output"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: simple=$output"
|
||||
depends_on: [simple]
|
||||
40
.archon/workflows/e2e-codex-smoke.yaml
Normal file
40
.archon/workflows/e2e-codex-smoke.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# E2E smoke test — Codex provider
|
||||
# Verifies: provider selection, sendQuery, structured output
|
||||
name: e2e-codex-smoke
|
||||
description: "E2E smoke test for Codex provider. Runs a simple prompt + structured output node."
|
||||
provider: codex
|
||||
model: gpt-5.2
|
||||
|
||||
nodes:
|
||||
- id: simple
|
||||
prompt: "What is 2+2? Answer with just the number, nothing else."
|
||||
idle_timeout: 30000
|
||||
|
||||
- id: structured
|
||||
prompt: "Classify this input as 'math' or 'text': '2+2=4'. Return JSON only."
|
||||
output_format:
|
||||
type: object
|
||||
properties:
|
||||
category:
|
||||
type: string
|
||||
enum: ["math", "text"]
|
||||
required: ["category"]
|
||||
additionalProperties: false
|
||||
idle_timeout: 30000
|
||||
depends_on: [simple]
|
||||
|
||||
# Assert both nodes returned output
|
||||
- id: assert
|
||||
bash: |
|
||||
simple_out="$simple.output"
|
||||
structured_out="$structured.output"
|
||||
if [ -z "$simple_out" ]; then
|
||||
echo "FAIL: simple node returned empty output"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$structured_out" ]; then
|
||||
echo "FAIL: structured node returned empty output"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: simple=$simple_out structured=$structured_out"
|
||||
depends_on: [simple, structured]
|
||||
66
.archon/workflows/e2e-deterministic.yaml
Normal file
66
.archon/workflows/e2e-deterministic.yaml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# E2E smoke test — deterministic nodes (no AI, no API calls)
|
||||
# Verifies: bash nodes, script nodes (bun + uv), $nodeId.output substitution,
|
||||
# when conditions, trigger_rule join semantics
|
||||
name: e2e-deterministic
|
||||
description: "Pure DAG engine test. Exercises bash, script (bun/uv), conditions, and trigger rules with zero API calls."
|
||||
|
||||
nodes:
|
||||
# Layer 0 — parallel deterministic nodes
|
||||
- id: bash-echo
|
||||
bash: "echo '{\"status\":\"ok\",\"value\":42}'"
|
||||
|
||||
- id: script-bun
|
||||
script: echo-args
|
||||
runtime: bun
|
||||
timeout: 30000
|
||||
|
||||
- id: script-python
|
||||
script: echo-py
|
||||
runtime: uv
|
||||
timeout: 30000
|
||||
|
||||
# Layer 1 — test $nodeId.output substitution from bash
|
||||
- id: bash-read-output
|
||||
bash: "echo 'upstream-status: $bash-echo.output'"
|
||||
depends_on: [bash-echo]
|
||||
|
||||
# Layer 1 — conditional branches (only one should run)
|
||||
- id: branch-true
|
||||
bash: "echo 'branch-true-ran'"
|
||||
depends_on: [bash-echo]
|
||||
when: "$bash-echo.output.status == 'ok'"
|
||||
|
||||
- id: branch-false
|
||||
bash: "echo 'branch-false-ran'"
|
||||
depends_on: [bash-echo]
|
||||
when: "$bash-echo.output.status == 'fail'"
|
||||
|
||||
# Layer 2 — trigger_rule merge (one_success: branch-false will be skipped)
|
||||
- id: merge-node
|
||||
bash: "echo 'merge-ok: true=$branch-true.output false=$branch-false.output'"
|
||||
depends_on: [branch-true, branch-false]
|
||||
trigger_rule: one_success
|
||||
|
||||
# Layer 3 — final verification: assert all outputs are non-empty
|
||||
- id: verify-all
|
||||
bash: |
|
||||
fail=0
|
||||
for name in bash-echo script-bun script-python bash-read-output branch-true merge-node; do
|
||||
echo "$name output received"
|
||||
done
|
||||
bash_echo="$bash-echo.output"
|
||||
script_bun="$script-bun.output"
|
||||
script_python="$script-python.output"
|
||||
bash_read="$bash-read-output.output"
|
||||
branch_t="$branch-true.output"
|
||||
merge="$merge-node.output"
|
||||
if [ -z "$bash_echo" ]; then echo "FAIL: bash-echo empty"; fail=1; fi
|
||||
if [ -z "$script_bun" ]; then echo "FAIL: script-bun empty"; fail=1; fi
|
||||
if [ -z "$script_python" ]; then echo "FAIL: script-python empty"; fail=1; fi
|
||||
if [ -z "$bash_read" ]; then echo "FAIL: bash-read-output empty"; fail=1; fi
|
||||
if [ -z "$branch_t" ]; then echo "FAIL: branch-true empty"; fail=1; fi
|
||||
if [ -z "$merge" ]; then echo "FAIL: merge-node empty"; fail=1; fi
|
||||
if [ "$fail" -eq 1 ]; then exit 1; fi
|
||||
echo "PASS: all deterministic nodes produced output"
|
||||
depends_on: [bash-read-output, script-bun, script-python, merge-node]
|
||||
trigger_rule: all_success
|
||||
38
.archon/workflows/e2e-mixed-providers.yaml
Normal file
38
.archon/workflows/e2e-mixed-providers.yaml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# E2E smoke test — mixed providers (Claude + Codex in same workflow)
|
||||
# Verifies: per-node provider override, cross-provider $nodeId.output refs
|
||||
name: e2e-mixed-providers
|
||||
description: "Tests Claude and Codex providers in the same workflow with cross-provider output refs."
|
||||
|
||||
# Default provider is claude
|
||||
provider: claude
|
||||
model: haiku
|
||||
|
||||
nodes:
|
||||
# 1. Claude node — default provider
|
||||
- id: claude-node
|
||||
prompt: "Say 'claude-ok' and nothing else."
|
||||
allowed_tools: []
|
||||
idle_timeout: 30000
|
||||
|
||||
# 2. Codex node — provider override (runs parallel with claude-node, different providers)
|
||||
- id: codex-node
|
||||
prompt: "Say 'codex-ok' and nothing else."
|
||||
provider: codex
|
||||
model: gpt-5.2
|
||||
idle_timeout: 30000
|
||||
|
||||
# 3. Assert both providers returned output
|
||||
- id: assert
|
||||
bash: |
|
||||
claude_out="$claude-node.output"
|
||||
codex_out="$codex-node.output"
|
||||
if [ -z "$claude_out" ]; then
|
||||
echo "FAIL: claude-node returned empty output"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$codex_out" ]; then
|
||||
echo "FAIL: codex-node returned empty output"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: claude=$claude_out codex=$codex_out"
|
||||
depends_on: [claude-node, codex-node]
|
||||
105
.archon/workflows/e2e-pi-all-nodes-smoke.yaml
Normal file
105
.archon/workflows/e2e-pi-all-nodes-smoke.yaml
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# E2E smoke test — Pi provider, every node type
|
||||
# Covers: prompt, command, loop (AI node types) + bash, script bun/uv
|
||||
# (deterministic node types) + depends_on / when / trigger_rule / $nodeId.output
|
||||
# (DAG features).
|
||||
# Skipped: `approval:` — pauses for human input, incompatible with CI.
|
||||
# Auth: ANTHROPIC_API_KEY (CI) or your local `pi /login` OAuth.
|
||||
# Expected runtime: ~10s on haiku (3 AI round-trips + deterministic nodes).
|
||||
name: e2e-pi-all-nodes-smoke
|
||||
description: 'Pi provider smoke across every CI-compatible node type.'
|
||||
provider: pi
|
||||
model: anthropic/claude-haiku-4-5
|
||||
|
||||
nodes:
|
||||
# ─── AI node types ──────────────────────────────────────────────────────
|
||||
|
||||
# 1. prompt: inline prompt (simplest AI node)
|
||||
- id: prompt-node
|
||||
prompt: "Reply with exactly the single word 'ok' and nothing else."
|
||||
allowed_tools: []
|
||||
effort: low
|
||||
idle_timeout: 30000
|
||||
|
||||
# 2. command: named command file (.archon/commands/e2e-echo-command.md)
|
||||
# The command echoes back $ARGUMENTS (the workflow invocation message).
|
||||
- id: command-node
|
||||
command: e2e-echo-command
|
||||
allowed_tools: []
|
||||
idle_timeout: 30000
|
||||
|
||||
# 3. loop: iterative AI prompt until completion signal
|
||||
# Bounded by max_iterations: 2 so a misbehaving model can't hang CI.
|
||||
- id: loop-node
|
||||
loop:
|
||||
prompt: "Reply with exactly 'DONE' and nothing else."
|
||||
until: 'DONE'
|
||||
max_iterations: 2
|
||||
allowed_tools: []
|
||||
effort: low
|
||||
idle_timeout: 60000
|
||||
|
||||
# ─── Deterministic node types (no AI) ───────────────────────────────────
|
||||
|
||||
# 4. bash: shell script with JSON output (enables $nodeId.output.status
|
||||
# dot-access downstream)
|
||||
- id: bash-json-node
|
||||
bash: 'echo ''{"status":"ok"}'''
|
||||
|
||||
# 5. script: bun (TypeScript/JavaScript runtime)
|
||||
- id: script-bun-node
|
||||
script: echo-args
|
||||
runtime: bun
|
||||
timeout: 30000
|
||||
|
||||
# 6. script: uv (Python runtime)
|
||||
- id: script-python-node
|
||||
script: echo-py
|
||||
runtime: uv
|
||||
timeout: 30000
|
||||
|
||||
# ─── DAG features ───────────────────────────────────────────────────────
|
||||
|
||||
# 7. depends_on + $nodeId.output substitution
|
||||
- id: downstream
|
||||
bash: "echo 'downstream got: $prompt-node.output'"
|
||||
depends_on: [prompt-node]
|
||||
|
||||
# 8. when: conditional (JSON dot-access on upstream output)
|
||||
- id: gated
|
||||
bash: "echo 'gated-ok'"
|
||||
depends_on: [bash-json-node]
|
||||
when: "$bash-json-node.output.status == 'ok'"
|
||||
|
||||
# 9. trigger_rule: merge multiple deps (all_success semantics)
|
||||
- id: merge
|
||||
bash: "echo 'merge-ok'"
|
||||
depends_on: [downstream, gated, script-bun-node, script-python-node]
|
||||
trigger_rule: all_success
|
||||
|
||||
# ─── Final assertion ────────────────────────────────────────────────────
|
||||
|
||||
# 10. Verify every upstream node produced non-empty output.
|
||||
- id: assert
|
||||
bash: |
|
||||
fail=0
|
||||
check() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
if [ -z "$value" ]; then
|
||||
echo "FAIL: $name produced empty output"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
check prompt-node "$prompt-node.output"
|
||||
check command-node "$command-node.output"
|
||||
check loop-node "$loop-node.output"
|
||||
check bash-json-node "$bash-json-node.output"
|
||||
check script-bun-node "$script-bun-node.output"
|
||||
check script-python-node "$script-python-node.output"
|
||||
check downstream "$downstream.output"
|
||||
check gated "$gated.output"
|
||||
check merge "$merge.output"
|
||||
if [ "$fail" -eq 1 ]; then exit 1; fi
|
||||
echo "PASS: all 9 node types produced output"
|
||||
depends_on: [merge, loop-node, command-node]
|
||||
trigger_rule: all_success
|
||||
35
.archon/workflows/e2e-pi-smoke.yaml
Normal file
35
.archon/workflows/e2e-pi-smoke.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# E2E smoke test — Pi community provider
|
||||
# Verifies: Pi connectivity, $nodeId.output refs, async-queue bridge,
|
||||
# and v2 wiring (thinkingLevel, allowed_tools).
|
||||
# Design: mirrors e2e-claude-smoke.yaml structure. The `allowed_tools: []`
|
||||
# idiom disables Pi's built-in read/bash/edit/write so the smoke stays
|
||||
# fast (no tool-use round-trips). `thinking: minimal` exercises the
|
||||
# thinkingLevel translation path.
|
||||
# Auth: picks up either ANTHROPIC_API_KEY env var (CI) or your local
|
||||
# `pi /login` OAuth credentials from ~/.pi/agent/auth.json.
|
||||
name: e2e-pi-smoke
|
||||
description: 'Smoke test for Pi community provider. Verifies prompt response via sendQuery.'
|
||||
provider: pi
|
||||
model: anthropic/claude-haiku-4-5
|
||||
|
||||
nodes:
|
||||
# 1. Simple prompt — verifies Pi harness starts, AsyncQueue bridge yields
|
||||
# assistant chunks, and agent_end produces a result chunk. v2 wiring:
|
||||
# allowed_tools: [] disables all Pi tools (LLM-only); effort: low is
|
||||
# translated to Pi's thinkingLevel by options-translator.ts.
|
||||
- id: simple
|
||||
prompt: 'What is 2+2? Answer with just the number, nothing else.'
|
||||
allowed_tools: []
|
||||
effort: low
|
||||
idle_timeout: 30000
|
||||
|
||||
# 2. Assert non-empty output — fails CI if Pi returned nothing
|
||||
- id: assert
|
||||
bash: |
|
||||
output="$simple.output"
|
||||
if [ -z "$output" ]; then
|
||||
echo "FAIL: simple node returned empty output"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: simple=$output"
|
||||
depends_on: [simple]
|
||||
34
.archon/workflows/e2e-worktree-disabled.yaml
Normal file
34
.archon/workflows/e2e-worktree-disabled.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# E2E smoke test — workflow-level worktree.enabled: false
|
||||
# Verifies: when a workflow pins worktree.enabled: false, runs happen in the
|
||||
# live repo checkout (no worktree created, cwd == repo root). Zero AI calls.
|
||||
name: e2e-worktree-disabled
|
||||
description: "Pinned-isolation-off smoke. Asserts cwd is the repo root rather than a worktree path, regardless of how the workflow is invoked."
|
||||
|
||||
worktree:
|
||||
enabled: false
|
||||
|
||||
nodes:
|
||||
# Print cwd so the operator can eyeball it, and capture for the assertion node.
|
||||
- id: print-cwd
|
||||
bash: "pwd"
|
||||
|
||||
# Assertion: cwd must NOT contain '/.archon/workspaces/' — if it does, the
|
||||
# policy was ignored and a worktree was created anyway. We also assert the
|
||||
# cwd ends with a git repo (has a .git directory or file visible).
|
||||
- id: assert-live-checkout
|
||||
bash: |
|
||||
cwd="$(pwd)"
|
||||
echo "assert-live-checkout cwd=$cwd"
|
||||
case "$cwd" in
|
||||
*/.archon/workspaces/*/worktrees/*)
|
||||
echo "FAIL: workflow ran inside a worktree ($cwd) despite worktree.enabled: false"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ ! -e "$cwd/.git" ]; then
|
||||
echo "FAIL: cwd $cwd is not a git checkout root (.git missing)"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: ran in live checkout (no worktree created by policy)"
|
||||
depends_on: [print-cwd]
|
||||
trigger_rule: all_success
|
||||
1588
.archon/workflows/repo-triage.yaml
Normal file
1588
.archon/workflows/repo-triage.yaml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -23,7 +23,7 @@ Restate the feature request in your own words. Identify:
|
|||
3. **Scope boundaries** — What is explicitly in scope vs. out of scope?
|
||||
4. **Package impact** — Which of the 8 packages are affected? (`paths`, `git`, `isolation`,
|
||||
`workflows`, `core`, `adapters`, `server`, `web`)
|
||||
5. **Interface changes** — Does this touch `IPlatformAdapter`, `IAssistantClient`,
|
||||
5. **Interface changes** — Does this touch `IPlatformAdapter`, `IAgentProvider`,
|
||||
`IDatabase`, or `IWorkflowStore`? New interfaces needed?
|
||||
|
||||
---
|
||||
|
|
@ -85,7 +85,7 @@ Before writing tasks, reason through:
|
|||
**Interface design:**
|
||||
- Prefer extending existing narrow interfaces over creating fat ones.
|
||||
- New interface methods only if they have a concrete current caller.
|
||||
- Avoid adding methods to `IPlatformAdapter` or `IAssistantClient` unless essential.
|
||||
- Avoid adding methods to `IPlatformAdapter` or `IAgentProvider` unless essential.
|
||||
|
||||
**Test isolation strategy:**
|
||||
- `mock.module()` is process-global and permanent in Bun — plan test file placement carefully.
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ Read `packages/core/src/state/session-transitions.ts` in full — `TransitionTri
|
|||
|
||||
### 5. Understand AI Client Patterns
|
||||
|
||||
List clients:
|
||||
!`ls packages/core/src/clients/`
|
||||
List providers:
|
||||
!`ls packages/core/src/providers/`
|
||||
|
||||
Read `packages/core/src/clients/factory.ts` for provider selection logic.
|
||||
Read `packages/core/src/clients/claude.ts` first 50 lines — `IAssistantClient` implementation
|
||||
Read `packages/core/src/providers/factory.ts` for provider selection logic.
|
||||
Read `packages/core/src/providers/claude.ts` first 50 lines — `IAgentProvider` implementation
|
||||
with streaming event loop pattern.
|
||||
|
||||
### 6. Understand Database Layer
|
||||
|
|
@ -52,7 +52,7 @@ List DB modules:
|
|||
!`ls packages/core/src/db/`
|
||||
|
||||
Read `packages/core/src/types/index.ts` (or the main types file) first 60 lines for key
|
||||
interfaces: `IPlatformAdapter`, `IAssistantClient`, `Conversation`, `Session`.
|
||||
interfaces: `IPlatformAdapter`, `IAgentProvider`, `Conversation`, `Session`.
|
||||
|
||||
### 7. Understand the Server
|
||||
|
||||
|
|
@ -81,9 +81,9 @@ Summarize (under 250 words):
|
|||
- `TransitionTrigger` values and their behaviors
|
||||
- Only `plan-to-execute` immediately creates a new session; others deactivate first
|
||||
|
||||
### AI Clients
|
||||
- `ClaudeClient` (claude-agent-sdk) and `CodexClient` (codex-sdk)
|
||||
- `IAssistantClient` streaming pattern: `for await (const event of events)`
|
||||
### AI Providers
|
||||
- `ClaudeProvider` (claude-agent-sdk) and `CodexProvider` (codex-sdk)
|
||||
- `IAgentProvider` streaming pattern: `for await (const event of events)`
|
||||
|
||||
### Key Database Tables
|
||||
- conversations, sessions, codebases, isolation_environments, workflow_runs, workflow_events, messages
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ bridges these to SSE via `WorkflowEventBridge`.
|
|||
### 7. Understand Dependency Injection
|
||||
|
||||
Read `packages/workflows/src/deps.ts` — `WorkflowDeps` type: `IWorkflowPlatform`,
|
||||
`IWorkflowAssistantClient`, `IWorkflowStore` injected at runtime. No direct DB or AI imports
|
||||
`IWorkflowAgentProvider`, `IWorkflowStore` injected at runtime. No direct DB or AI imports
|
||||
inside this package.
|
||||
|
||||
### 8. See What Workflows Are Available
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ Provide a concise summary (under 300 words) covering:
|
|||
|
||||
### Architecture
|
||||
- Package dependency order and each package's responsibility
|
||||
- Key interfaces: `IPlatformAdapter`, `IAssistantClient`, `IDatabase`, `IWorkflowStore`
|
||||
- Message flow: platform adapter → orchestrator-agent → command handler OR AI client
|
||||
- Key interfaces: `IPlatformAdapter`, `IAgentProvider`, `IDatabase`, `IWorkflowStore`
|
||||
- Message flow: platform adapter → orchestrator-agent → command handler OR AI provider
|
||||
- Workflow execution: `discoverWorkflows` → router → `executeWorkflow` (steps / loop / DAG)
|
||||
|
||||
### Current State
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ Runs `tsc --noEmit` across all 8 packages via `bun --filter '*' type-check`.
|
|||
|
||||
**What to look for:**
|
||||
- Missing return types (explicit return types required on all functions)
|
||||
- Incorrect interface implementations (`IPlatformAdapter`, `IAssistantClient`, etc.)
|
||||
- Incorrect interface implementations (`IPlatformAdapter`, `IAgentProvider`, etc.)
|
||||
- Import type errors (use `import type` for type-only imports)
|
||||
- Package boundary violations (e.g., `@archon/workflows` importing from `@archon/core`)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ Slack event
|
|||
→ Otherwise → buildOrchestratorPrompt() (prompt-builder.ts:116)
|
||||
→ Prompt includes: registered projects, discovered workflows, /invoke-workflow format
|
||||
→ sessionDb.getActiveSession() → transitionSession('first-message') if none (orchestrator-agent.ts:462)
|
||||
→ getAssistantClient(conversation.ai_assistant_type) (orchestrator-agent.ts:470)
|
||||
→ getAgentProvider(conversation.ai_assistant_type) (orchestrator-agent.ts:470)
|
||||
→ cwd = getArchonWorkspacesPath() (orchestrator-agent.ts:458)
|
||||
→ handleBatchMode() or handleStreamMode() based on getStreamingMode()
|
||||
|
||||
|
|
@ -313,7 +313,7 @@ Narrows `IPlatformAdapter` to `WebAdapter` for web-specific methods: `setConvers
|
|||
| Message entry | `adapters/src/chat/slack/adapter.ts`, `server/src/index.ts` |
|
||||
| Orchestration | `core/src/orchestrator/orchestrator-agent.ts`, `core/src/orchestrator/orchestrator.ts` |
|
||||
| Locking | `core/src/utils/conversation-lock.ts` |
|
||||
| AI clients | `core/src/clients/claude.ts`, `core/src/clients/factory.ts` |
|
||||
| AI providers | `core/src/providers/claude.ts`, `core/src/providers/factory.ts` |
|
||||
| Commands | `core/src/handlers/command-handler.ts` |
|
||||
| Sessions | `core/src/db/sessions.ts`, `core/src/state/session-transitions.ts` |
|
||||
| Workflows | `workflows/src/executor.ts`, `workflows/src/dag-executor.ts`, `workflows/src/loader.ts` |
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "packages/adapters/**/*.ts"
|
||||
---
|
||||
|
||||
# Adapters Conventions
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Auth is inside adapters** — every adapter checks authorization before calling `onMessage()`. Silent rejection (no error response), log with masked user ID: `userId.slice(0, 4) + '***'`.
|
||||
- **Whitelist parsing in constructor** — parse env var (`SLACK_ALLOWED_USER_IDS`, `TELEGRAM_ALLOWED_USER_IDS`, `GITHUB_ALLOWED_USERS`) using a co-located `parseAllowedUserIds()` / `parseAllowedUsers()` function. Empty list = open access.
|
||||
- **Lazy logger pattern** — ALL adapter files use a module-level `cachedLog` + `getLog()` getter so test mocks intercept `createLogger` before the logger is instantiated. Never initialize logger at module scope.
|
||||
- **Two handler patterns** (both valid):
|
||||
- **Chat adapters** (Slack, Telegram, Discord): `onMessage(handler)` — adapter owns the event loop (polling/WebSocket), fires registered callback. Lock manager lives in the server's callback closure. Errors handled by caller via `createMessageErrorHandler`.
|
||||
- **Forge adapters** (GitHub, Gitea): `handleWebhook(payload, signature)` — server HTTP route calls directly, returns 200 immediately. Full pipeline inside adapter (signature verification, repo cloning, command loading, context building). Lock manager injected in constructor. Errors caught internally and posted to issue/PR.
|
||||
- **Message splitting** — use shared `splitIntoParagraphChunks(message, maxLength)` from `../../utils/message-splitting`. Two-pass: paragraph breaks first, then line breaks. Limits: Slack 12000, Telegram 4096, GitHub 65000.
|
||||
- **`ensureThread()` is often a no-op** — Slack returns the same ID (already encoded as `channel:ts`), Telegram has no threads, GitHub issues are inherently threaded.
|
||||
|
||||
## Conversation ID Formats
|
||||
|
||||
| Platform | Format | Example |
|
||||
|----------|--------|---------|
|
||||
| Slack | `channel:thread_ts` | `C123ABC:1234567890.123456` |
|
||||
| Telegram | numeric chat ID as string | `"1234567890"` |
|
||||
| GitHub | `owner/repo#number` | `"acme/api#42"` |
|
||||
| Web | user-provided string | `"my-chat"` |
|
||||
| Discord | channel ID string | `"987654321098765432"` |
|
||||
|
||||
## Architecture
|
||||
|
||||
- All chat adapters implement `IPlatformAdapter` from `@archon/core`
|
||||
- GitHub adapter is webhook-based (no polling); Slack/Telegram/Discord use polling
|
||||
- GitHub adapter holds its own `ConversationLockManager` (injected in constructor)
|
||||
- Slack conversation ID encodes both channel and thread: `sendMessage()` splits on `:` to extract `thread_ts`
|
||||
- GitHub adapter adds `<!-- archon-bot-response -->` marker to prevent self-triggering loops
|
||||
- GitHub only responds to `issue_comment.created` events — NOT `issues.opened` / `pull_request.opened` (descriptions contain documentation, not commands; see #96)
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Never put auth logic outside the adapter (no auth middleware in server routes)
|
||||
- Never throw from `onMessage` handlers; errors surface to the caller
|
||||
- Never call `sendMessage()` with a raw token or credential string in the message
|
||||
- Never use the generic `exec` — always use `execFileAsync` for subprocess calls
|
||||
- Never add a new adapter method to `IPlatformAdapter` unless ALL adapters need it; use optional methods (`sendStructuredEvent?`) for platform-specific capabilities
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "packages/cli/**/*.ts"
|
||||
---
|
||||
|
||||
# CLI Conventions
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Workflow commands (require git repo)
|
||||
bun run cli workflow list [--json]
|
||||
bun run cli workflow run <name> [message] [--branch <branch>] [--from-branch <base>] [--no-worktree] [--resume]
|
||||
bun run cli workflow status [runId]
|
||||
|
||||
# Isolation commands
|
||||
bun run cli isolation list
|
||||
bun run cli isolation cleanup [days] # default: 7 days
|
||||
bun run cli isolation cleanup --merged # removes merged branches + remote refs
|
||||
bun run cli complete <branch-name> [--force] # full lifecycle: worktree + local/remote branches
|
||||
|
||||
# Interactive
|
||||
bun run cli chat [--cwd <path>]
|
||||
|
||||
# Setup
|
||||
bun run cli setup
|
||||
bun run cli version
|
||||
```
|
||||
|
||||
## Startup Behavior
|
||||
|
||||
1. `@archon/paths/strip-cwd-env-boot` (first import) removes all Bun-auto-loaded CWD `.env` keys from `process.env`
|
||||
2. Loads `~/.archon/.env` with `override: true` (Archon config wins over shell-inherited vars)
|
||||
3. Smart Claude auth default: if no `CLAUDE_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`, sets `CLAUDE_USE_GLOBAL_AUTH=true`
|
||||
4. Imports all commands AFTER dotenv setup
|
||||
|
||||
## WorkflowRunOptions Interface
|
||||
|
||||
```typescript
|
||||
interface WorkflowRunOptions {
|
||||
branchName?: string; // Explicit branch name for the worktree
|
||||
fromBranch?: string; // Override base branch (start-point for worktree)
|
||||
noWorktree?: boolean; // Opt out of isolation, run in live checkout
|
||||
resume?: boolean; // Reuse worktree from last failed run
|
||||
}
|
||||
```
|
||||
|
||||
**Default behavior**: Creates worktree with auto-generated branch name (`archon/task-{workflow}-{timestamp}`).
|
||||
|
||||
**Mutually exclusive** (enforced in both `cli.ts` pre-flight and `workflowRunCommand`):
|
||||
- `--branch` + `--no-worktree`
|
||||
- `--from` + `--no-worktree`
|
||||
- `--resume` + `--branch`
|
||||
|
||||
- `--branch feature-auth` → creates/reuses worktree for that branch
|
||||
- (no flags) → creates worktree with auto-generated `archon/task-*` branch (isolation by default)
|
||||
- `--no-worktree` → runs directly in live checkout (opt-out of isolation)
|
||||
- `--from dev` → overrides the start-point for new worktree (works with or without `--branch`)
|
||||
- `--resume` → resumes last run for this conversation (mutually exclusive with `--branch`)
|
||||
|
||||
## Git Repo Requirement
|
||||
|
||||
Workflow and isolation commands resolve CWD to the git repo root. Run from within a git repository (subdirectories work). The CLI calls `git rev-parse --show-toplevel` to find the root.
|
||||
|
||||
## Conversation ID Format
|
||||
|
||||
CLI generates: `cli-{timestamp}-{random6}` (e.g., `cli-1703123456789-a7f3bc`)
|
||||
|
||||
## Port Allocation
|
||||
|
||||
Worktree-aware: same hash-based algorithm as server (3190–4089 range). Running `bun dev` in a worktree auto-allocates a unique port. Same worktree always gets same port.
|
||||
|
||||
## CLIAdapter
|
||||
|
||||
The `CLIAdapter` implements `IPlatformAdapter`. It streams output to stdout. `getStreamingMode()` defaults to `'batch'` (configurable via constructor options). No auth needed — CLI is local only.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `@archon/cli` depends on `@archon/core`, `@archon/workflows`, `@archon/git`, `@archon/isolation`, `@archon/paths`
|
||||
- Uses `createWorkflowDeps()` from `@archon/core/workflows/store-adapter` to build workflow deps
|
||||
- Database shared with server (same `~/.archon/archon.db` or `DATABASE_URL`)
|
||||
- Conversation lifecycle: create → run workflow → persist messages (same DB as web UI)
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Never run CLI commands without being inside a git repository (workflow/isolation commands will fail)
|
||||
- Never set `DATABASE_URL` in `~/.archon/.env` to point at a target app's database
|
||||
- Never use `--force` on `complete` unless branch is truly safe to delete (skips uncommitted check)
|
||||
- Never add interactive prompts inside CLI commands — use flags for all options (non-interactive tool)
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "packages/core/src/db/**/*.ts"
|
||||
- "migrations/**/*.sql"
|
||||
---
|
||||
|
||||
# Database Conventions
|
||||
|
||||
## 7 Tables (all prefixed `remote_agent_`)
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `remote_agent_conversations` | Platform conversations, soft-delete (`deleted_at`), title, `hidden` flag |
|
||||
| `remote_agent_sessions` | AI SDK sessions with `parent_session_id` audit chain, `transition_reason` |
|
||||
| `remote_agent_codebases` | Repository metadata, `commands` JSONB |
|
||||
| `remote_agent_isolation_environments` | Git worktree tracking, `workflow_type`, `workflow_id` |
|
||||
| `remote_agent_workflow_runs` | Execution state, `working_path`, `last_activity_at` |
|
||||
| `remote_agent_workflow_events` | Step-level event log per run |
|
||||
| `remote_agent_messages` | Conversation history, tool call metadata as JSONB |
|
||||
|
||||
## IDatabase Interface
|
||||
|
||||
Auto-detects at startup: PostgreSQL if `DATABASE_URL` set, SQLite (`~/.archon/archon.db`) otherwise.
|
||||
|
||||
```typescript
|
||||
import { pool, getDialect } from './connection'; // pool = IDatabase instance
|
||||
|
||||
// $1, $2 placeholders work for both PostgreSQL and SQLite
|
||||
const result = await pool.query<Conversation>(
|
||||
'SELECT * FROM remote_agent_conversations WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
const row = result.rows[0]; // rows is readonly T[]
|
||||
```
|
||||
|
||||
Use `getDialect()` for dialect-specific expressions: `dialect.generateUuid()`, `dialect.now()`, `dialect.jsonMerge(col, paramIdx)`, `dialect.jsonArrayContains(col, path, paramIdx)`, `dialect.nowMinusDays(paramIdx)`.
|
||||
|
||||
## Import Pattern — Namespaced Exports
|
||||
|
||||
```typescript
|
||||
// Use namespace imports for DB modules (consistent project-wide pattern)
|
||||
import * as conversationDb from '@archon/core/db/conversations';
|
||||
import * as sessionDb from '@archon/core/db/sessions';
|
||||
import * as codebaseDb from '@archon/core/db/codebases';
|
||||
import * as workflowDb from '@archon/core/db/workflows';
|
||||
import * as messageDb from '@archon/core/db/messages';
|
||||
```
|
||||
|
||||
## INSERT Error Handling
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await pool.query('INSERT INTO remote_agent_conversations ...', params);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
log.error({ err: error, params }, 'db_insert_failed');
|
||||
throw new Error('Failed to create conversation');
|
||||
}
|
||||
```
|
||||
|
||||
## UPDATE with rowCount Verification
|
||||
|
||||
`updateConversation()` and similar throw `ConversationNotFoundError` / `SessionNotFoundError` when `rowCount === 0`. Callers must handle:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await db.updateConversation(conversationId, { codebase_id: codebaseId });
|
||||
} catch (error) {
|
||||
if (error instanceof ConversationNotFoundError) {
|
||||
// Handle missing conversation specifically
|
||||
}
|
||||
throw error; // Re-throw unexpected errors
|
||||
}
|
||||
```
|
||||
|
||||
## Session Audit Trail
|
||||
|
||||
Sessions are immutable. Every new session links back: `parent_session_id` → previous session, `transition_reason: TransitionTrigger`. Query the chain to understand history. `active = true` means the current session.
|
||||
|
||||
## Soft Delete
|
||||
|
||||
Conversations use soft-delete: `deleted_at IS NULL` filter should be included in all user-facing queries. `hidden = true` conversations are worker conversations (background workflows) — excluded from UI listings.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Never `SELECT *` in production queries on large tables — select specific columns
|
||||
- Never write raw SQL strings in application code outside `packages/core/src/db/` modules
|
||||
- Never bypass the `IDatabase` interface to call database drivers directly from other packages
|
||||
- Never assume `rows[0]` exists without null-checking — queries can return empty arrays
|
||||
- Never use `RETURNING *` in UPDATE when only checking success — check `rowCount` instead
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
# DX Quirks
|
||||
|
||||
## Bun Log Elision
|
||||
|
||||
When running `bun dev` from repo root, `--filter` truncates logs to `[N lines elided]`.
|
||||
To see full logs: `cd packages/server && bun --watch src/index.ts` or `bun --cwd packages/server run dev`.
|
||||
|
||||
## mock.module() Pollution
|
||||
|
||||
`mock.module()` is process-global and irreversible — `mock.restore()` does NOT undo it.
|
||||
Never add `afterAll(() => mock.restore())` for `mock.module()` cleanup.
|
||||
Use `spyOn()` for internal modules (spy.mockRestore() DOES work).
|
||||
When adding tests with `mock.module()`, ensure package.json runs it in a separate `bun test` invocation.
|
||||
|
||||
## Worktree Port Allocation
|
||||
|
||||
Worktrees auto-allocate ports (3190-4089 range, hash-based on path). Same worktree always gets same port.
|
||||
Main repo defaults to 3090. Override: `PORT=4000 bun dev`.
|
||||
|
||||
## bun run test vs bun test
|
||||
|
||||
NEVER run `bun test` from repo root — it discovers all test files across packages in one process, causing ~135 mock pollution failures. Always use `bun run test` (which uses `bun --filter '*' test` for per-package isolation).
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
# Isolation Architecture Patterns
|
||||
|
||||
## Core Design
|
||||
|
||||
- ALL isolation logic is centralized in the orchestrator — adapters are thin
|
||||
- Every @mention auto-creates a worktree (simplicity > efficiency; worktrees are cheap)
|
||||
- Data model is work-centric (`isolation_environments` table), enabling cross-platform sharing
|
||||
- Cleanup is a separate service using git-first checks
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
~/.archon/workspaces/owner/repo/
|
||||
├── source/ # Clone or symlink to local path
|
||||
├── worktrees/ # Git worktrees for this project
|
||||
├── artifacts/ # Workflow artifacts (NEVER in git)
|
||||
│ ├── runs/{id}/ # Per-run artifacts ($ARTIFACTS_DIR)
|
||||
│ └── uploads/{convId}/ # Web UI file uploads (ephemeral)
|
||||
└── logs/ # Workflow execution logs
|
||||
```
|
||||
|
||||
## Resolution Flow
|
||||
|
||||
1. Adapter provides `IsolationHints` (conversationId, workflowId, branch preference)
|
||||
2. Orchestrator's `validateAndResolveIsolation()` resolves hints → environment
|
||||
3. WorktreeProvider creates worktree if needed, syncs with origin first
|
||||
4. Environment tracked in `isolation_environments` table
|
||||
|
||||
## Key Packages
|
||||
|
||||
- `@archon/isolation` (`packages/isolation/src/`) — types, providers, resolver, error classifiers
|
||||
- `@archon/git` (`packages/git/src/`) — branch, worktree, repo operations
|
||||
- `@archon/paths` (`packages/paths/src/`) — path resolution utilities
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- NEVER run `git clean -fd` — permanently deletes untracked files
|
||||
- Use `classifyIsolationError()` to map git errors to user-friendly messages
|
||||
- Trust git's natural guardrails (refuse to remove worktree with uncommitted changes)
|
||||
- Use `execFileAsync` (not `exec`) when calling git directly
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "packages/isolation/**/*.ts"
|
||||
- "packages/git/**/*.ts"
|
||||
---
|
||||
|
||||
# Isolation & Git Conventions
|
||||
|
||||
## Branded Types (packages/git/src/types.ts)
|
||||
|
||||
Always use the branded constructors — they reject empty strings at runtime and prevent passing the wrong string type:
|
||||
|
||||
```typescript
|
||||
import { toRepoPath, toBranchName, toWorktreePath } from '@archon/git';
|
||||
import type { RepoPath, BranchName, WorktreePath } from '@archon/git';
|
||||
|
||||
const repo = toRepoPath('/home/user/owner/repo'); // RepoPath
|
||||
const branch = toBranchName('feature-auth'); // BranchName
|
||||
const wt = toWorktreePath('/home/.archon/worktrees/x'); // WorktreePath
|
||||
```
|
||||
|
||||
Git operations return `GitResult<T>` discriminated union: `{ ok: true; value: T }` or `{ ok: false; error: GitError }`. Always check `.ok` before accessing `.value`.
|
||||
|
||||
## IsolationResolver — 7-Step Resolution Order
|
||||
|
||||
1. **Existing env** — use `existingEnvId` if worktree still exists on disk
|
||||
2. **No codebase** — skip isolation entirely, return `status: 'none'`
|
||||
3. **Workflow reuse** — find active env with same `(codebaseId, workflowType, workflowId)`
|
||||
4. **Linked issue sharing** — PR can reuse the worktree from a linked issue
|
||||
5. **PR branch adoption** — find existing worktree by branch name (`findWorktreeByBranch`)
|
||||
6. **Limit check + auto-cleanup** — if at `maxWorktrees` (default 25), try `makeRoom()` first
|
||||
7. **Create new** — call `provider.create(isolationRequest)` then `store.create()`
|
||||
|
||||
If `store.create()` fails after `provider.create()` succeeds, the orphaned worktree is cleaned up best-effort before re-throwing.
|
||||
|
||||
## Error Handling Pattern
|
||||
|
||||
```typescript
|
||||
import { classifyIsolationError, isKnownIsolationError } from '@archon/isolation';
|
||||
|
||||
try {
|
||||
await provider.create(request);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
if (!isKnownIsolationError(err)) {
|
||||
throw err; // Unknown = programming bug, propagate as crash
|
||||
}
|
||||
const userMessage = classifyIsolationError(err); // Maps to friendly message
|
||||
// ...send userMessage to platform, return blocked resolution
|
||||
}
|
||||
```
|
||||
|
||||
Known error patterns: `permission denied`, `eacces`, `timeout`, `no space left`, `enospc`, `not a git repository`, `branch not found`.
|
||||
|
||||
`IsolationBlockedError` signals ALL message handling should stop — the user has already been notified.
|
||||
|
||||
## Git Safety Rules
|
||||
|
||||
- **NEVER run `git clean -fd`** — permanently deletes untracked files. Use `git checkout .` instead.
|
||||
- **Always use `execFileAsync`** (from `@archon/git/exec`), never `exec` or `execSync`
|
||||
- `hasUncommittedChanges()` returns `true` on unexpected errors (conservative — prevents data loss)
|
||||
- Worktree paths follow project-scoped layout: `~/.archon/workspaces/{owner}/{repo}/worktrees/{branch}`
|
||||
|
||||
## Architecture
|
||||
|
||||
- `@archon/git` — zero `@archon/*` dependencies; only branded types and `execFileAsync` wrapper
|
||||
- `@archon/isolation` — depends only on `@archon/git` + `@archon/paths`
|
||||
- `IIsolationStore` interface injected into `IsolationResolver` — never call DB directly from git package
|
||||
- `IIsolationProvider` interface — `WorktreeProvider` is the only implementation
|
||||
- Stale env cleanup is best-effort: `markDestroyedBestEffort()` logs errors but never throws
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Never call `git` via `exec()` or shell string — always `execFileAsync('git', [...args])`
|
||||
- Never treat `IsolationBlockedError` as recoverable — it means user was notified, stop processing
|
||||
- Never use a plain `string` where `RepoPath` / `BranchName` / `WorktreePath` is expected
|
||||
- Never skip the `isKnownIsolationError()` check — unknown errors must propagate as crashes
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "packages/core/src/orchestrator/**/*.ts"
|
||||
- "packages/core/src/handlers/**/*.ts"
|
||||
- "packages/core/src/state/**/*.ts"
|
||||
---
|
||||
|
||||
# Orchestrator Conventions
|
||||
|
||||
## Message Flow — Routing Agent Architecture
|
||||
|
||||
```
|
||||
Platform message
|
||||
→ ConversationLockManager.acquireLock()
|
||||
→ handleMessage() (orchestrator-agent.ts:383)
|
||||
→ inheritThreadContext() — copy parent's codebase/cwd if child thread
|
||||
→ Deterministic gate: 10 commands (help, status, reset, workflow, register-project, update-project, remove-project, commands, init, worktree)
|
||||
→ Everything else → AI routing call:
|
||||
→ listCodebases() + discoverAllWorkflows()
|
||||
→ buildFullPrompt() → buildOrchestratorPrompt() or buildProjectScopedPrompt()
|
||||
→ AI responds with natural language ± /invoke-workflow or /register-project
|
||||
→ parseOrchestratorCommands() extracts structured commands from AI response
|
||||
→ If /invoke-workflow found → dispatchOrchestratorWorkflow()
|
||||
→ If /register-project found → handleRegisterProject()
|
||||
→ Otherwise → send AI text to user
|
||||
```
|
||||
|
||||
Lock manager returns `{ status: 'started' | 'queued-conversation' | 'queued-capacity' }`. Always use the return value to decide whether to emit a "queued" notice — never call `isActive()` separately (TOCTOU race).
|
||||
|
||||
## Deterministic Commands (command-handler.ts)
|
||||
|
||||
Only **10 commands** are handled deterministically:
|
||||
|
||||
| Command | Behavior |
|
||||
|---------|----------|
|
||||
| `/help` | Show available commands |
|
||||
| `/status` | Show conversation/session state |
|
||||
| `/reset` | Deactivate current session |
|
||||
| `/workflow` | Subcommands: `list`, `run`, `status`, `cancel`, `reload` |
|
||||
| `/register-project` | Handled inline — creates codebase DB record |
|
||||
| `/update-project` | Handled inline — updates codebase path |
|
||||
| `/remove-project` | Handled inline — deletes codebase DB record |
|
||||
| `/commands` | List registered codebase commands |
|
||||
| `/init` | Scaffold `.archon/` in current repo |
|
||||
| `/worktree` | Worktree subcommands |
|
||||
|
||||
**All other slash commands fall through to the AI router.** Unrecognized commands return an "Unknown command" error.
|
||||
|
||||
## Routing AI — Prompt Building (prompt-builder.ts)
|
||||
|
||||
The choice between prompts depends on whether the conversation has an attached project:
|
||||
|
||||
- **No project** → `buildOrchestratorPrompt()` (prompt-builder.ts:116) — lists all projects equally, asks user to clarify if ambiguous
|
||||
- **Has project** → `buildProjectScopedPrompt()` (prompt-builder.ts:153) — active project shown first, ambiguous requests default to it
|
||||
|
||||
Both prompts include: registered projects, discovered workflows, and the `/invoke-workflow` + `/register-project` format specification.
|
||||
|
||||
### `/invoke-workflow` Protocol
|
||||
|
||||
The AI emits: `/invoke-workflow <name> --project <project> --prompt "user's intent"`
|
||||
|
||||
`parseOrchestratorCommands()` (orchestrator-agent.ts:90) parses this with:
|
||||
- Workflow name validated against discovered workflows via `findWorkflow()`
|
||||
- Project name validated via `findCodebaseByName()` — case-insensitive, supports partial path segment match (e.g., `"repo"` matches `"owner/repo"`)
|
||||
- `--project` must appear before `--prompt`
|
||||
|
||||
### `filterToolIndicators()` (orchestrator-agent.ts:163)
|
||||
|
||||
Batch mode only. Strips paragraphs starting with emoji tool indicators (🔧💭📝✏️🗑️📂🔍) from accumulated AI response before sending to user.
|
||||
|
||||
## Session Transitions
|
||||
|
||||
Sessions are **immutable** — never mutated, only deactivated and replaced. The audit trail is via `parent_session_id` + `transition_reason`.
|
||||
|
||||
**Only `plan-to-execute` immediately creates a new session.** All other triggers only deactivate; the new session is created on the next AI message.
|
||||
|
||||
```typescript
|
||||
import { getTriggerForCommand, shouldCreateNewSession } from '../state/session-transitions';
|
||||
|
||||
const trigger = getTriggerForCommand('reset'); // 'reset-requested'
|
||||
if (shouldCreateNewSession(trigger)) {
|
||||
// plan-to-execute only
|
||||
}
|
||||
```
|
||||
|
||||
`TransitionTrigger` values: `'first-message'`, `'plan-to-execute'`, `'isolation-changed'`, `'reset-requested'`, `'worktree-removed'`, `'conversation-closed'`.
|
||||
|
||||
## Isolation Resolution
|
||||
|
||||
`validateAndResolveIsolation()` (orchestrator.ts:108) delegates to `IsolationResolver` and handles:
|
||||
- Sending contextual messages to the platform (e.g., "Reusing worktree from issue #42")
|
||||
- Updating the DB (`conversation.isolation_env_id`, `conversation.cwd`)
|
||||
- Retrying once when a stale reference is found (`stale_cleaned`)
|
||||
- Throwing `IsolationBlockedError` after platform notification when blocked
|
||||
|
||||
When isolation is blocked, **stop all further processing** — `IsolationBlockedError` means the user was already notified.
|
||||
|
||||
## Background Workflow Dispatch (Web only)
|
||||
|
||||
`dispatchBackgroundWorkflow()` (orchestrator.ts:256) creates a hidden worker conversation (`web-worker-{timestamp}-{random}`), sets up event bridging from worker SSE → parent SSE, pre-creates the workflow run row (prevents 404 on immediate UI navigation), and fires-and-forgets `executeWorkflow()`. On completion, surfaces `result.summary` to the parent conversation.
|
||||
|
||||
## Lazy Logger Pattern
|
||||
|
||||
All files in this area use the deferred logger pattern — NEVER initialize at module scope:
|
||||
|
||||
```typescript
|
||||
let cachedLog: ReturnType<typeof createLogger> | undefined;
|
||||
function getLog(): ReturnType<typeof createLogger> {
|
||||
if (!cachedLog) cachedLog = createLogger('orchestrator');
|
||||
return cachedLog;
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Never call `isActive()` and then `acquireLock()` — race condition, use the lock return value
|
||||
- Never access `conversation.isolation_env_id` directly without going through the resolver
|
||||
- Never skip `IsolationBlockedError` — it must propagate to stop all further message handling
|
||||
- Never add platform-specific logic to the orchestrator; it uses `IPlatformAdapter` interface only
|
||||
- Never transition sessions by mutating them; always deactivate and create a new linked session
|
||||
- Never assume a slash command is deterministic — only the 10 listed above bypass the AI router
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "packages/server/**/*.ts"
|
||||
---
|
||||
|
||||
# Server API Conventions
|
||||
|
||||
## Hono Framework
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import { cors } from 'hono/cors';
|
||||
|
||||
// CORS: allow-all for single-developer tool (override with WEB_UI_ORIGIN)
|
||||
app.use('/api/*', cors({ origin: process.env.WEB_UI_ORIGIN || '*' }));
|
||||
|
||||
// Error response helper pattern
|
||||
function apiError(c: Context, status: 400 | 404 | 500, message: string): Response {
|
||||
return c.json({ error: message }, status);
|
||||
}
|
||||
```
|
||||
|
||||
## SSE Streaming
|
||||
|
||||
Always check `stream.closed` before writing. Use `stream.onAbort()` for cleanup. Hono's `streamSSE` callback receives an SSE writer:
|
||||
|
||||
```typescript
|
||||
app.get('/api/stream/:id', (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
stream.onAbort(() => {
|
||||
transport.removeStream(conversationId, writer);
|
||||
});
|
||||
// Write events:
|
||||
if (!stream.closed) {
|
||||
await stream.writeSSE({ data: JSON.stringify(event) });
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
`SSETransport` in `src/adapters/web/transport.ts` manages the stream registry. `removeStream()` accepts an `expectedStream` reference to prevent race conditions (StrictMode double-mount).
|
||||
|
||||
## Webhook Signature Verification
|
||||
|
||||
```typescript
|
||||
// ALWAYS use c.req.text() for raw webhook body — JSON.parse separately
|
||||
const payload = await c.req.text();
|
||||
const signature = c.req.header('X-Hub-Signature-256') ?? '';
|
||||
|
||||
// timingSafeEqual prevents timing attacks
|
||||
const hmac = createHmac('sha256', webhookSecret);
|
||||
const digest = 'sha256=' + hmac.update(payload).digest('hex');
|
||||
const isValid = timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
|
||||
```
|
||||
|
||||
Return 200 immediately for webhook events; process async. Never log the full signature.
|
||||
|
||||
## Auto Port Allocation (Worktrees)
|
||||
|
||||
`getPort()` from `@archon/core` returns:
|
||||
- Main repo: `PORT` env var or `3090`
|
||||
- Worktrees: hash-based port in range 3190–4089 (deterministic per worktree path)
|
||||
|
||||
Same worktree always gets same port. Override with `PORT=4000` env var.
|
||||
|
||||
## Static SPA Fallback
|
||||
|
||||
```typescript
|
||||
// Serve web dist; fall back to index.html for client-side routing
|
||||
app.use('/*', serveStatic({ root: path.join(import.meta.dir, '../../web/dist') }));
|
||||
app.get('*', (c) => c.html(/* index.html */));
|
||||
```
|
||||
|
||||
Use `import.meta.dir` (absolute) NOT relative paths — `bun --filter @archon/server start` changes CWD to `packages/server/`.
|
||||
|
||||
## Graceful Shutdown
|
||||
|
||||
```typescript
|
||||
process.on('SIGTERM', () => {
|
||||
stopCleanupScheduler();
|
||||
void pool.close();
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Key API Routes
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/conversations` | List conversations |
|
||||
| POST | `/api/conversations` | Create conversation |
|
||||
| POST | `/api/conversations/:id/message` | Send message |
|
||||
| GET | `/api/stream/:id` | SSE stream |
|
||||
| GET | `/api/workflows` | List workflows |
|
||||
| POST | `/api/workflows/validate` | Validate YAML (in-memory) |
|
||||
| GET | `/api/workflows/:name` | Get single workflow |
|
||||
| PUT | `/api/workflows/:name` | Save workflow |
|
||||
| DELETE | `/api/workflows/:name` | Delete workflow |
|
||||
| GET | `/api/commands` | List commands |
|
||||
| POST | `/webhooks/github` | GitHub webhook |
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Never use `c.req.json()` for webhooks — signature must be verified against raw body
|
||||
- Never expose API keys in JSON error responses
|
||||
- Never serve static files with relative paths (use `import.meta.dir`)
|
||||
- Never skip the `stream.closed` check before writing SSE
|
||||
- Never call platform adapters directly from route handlers — use `handleMessage()` + lock manager
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "**/*.test.ts"
|
||||
- "**/*.spec.ts"
|
||||
---
|
||||
|
||||
# Testing Conventions
|
||||
|
||||
## CRITICAL: mock.module() Pollution Rules
|
||||
|
||||
`mock.module()` permanently replaces modules in the **process-wide module cache**. `mock.restore()` does NOT undo it ([oven-sh/bun#7823](https://github.com/oven-sh/bun/issues/7823)).
|
||||
|
||||
**Rules:**
|
||||
1. **Never add `afterAll(() => mock.restore())` for `mock.module()` calls** — it has no effect
|
||||
2. **Never have two test files `mock.module()` the same path with different implementations in the same `bun test` invocation**
|
||||
3. **Use `spyOn()` for internal modules** — `spy.mockRestore()` DOES work for spies
|
||||
|
||||
```typescript
|
||||
// CORRECT: spy (restorable)
|
||||
import * as git from '@archon/git';
|
||||
const spy = spyOn(git, 'checkout');
|
||||
spy.mockImplementation(async () => ({ ok: true, value: undefined }));
|
||||
// afterEach:
|
||||
spy.mockRestore();
|
||||
|
||||
// CORRECT: mock.module() for external deps (not restorable — isolate in separate test file)
|
||||
mock.module('@slack/bolt', () => ({ App: mock(() => mockApp), LogLevel: { INFO: 'info' } }));
|
||||
```
|
||||
|
||||
## Test Batching Per Package
|
||||
|
||||
Each package splits tests into separate `bun test` invocations to prevent pollution:
|
||||
|
||||
| Package | Batches |
|
||||
|---------|---------|
|
||||
| `@archon/core` | 7 batches (clients, handlers, db+utils, path-validation, cleanup-service, title-generator, workflows, orchestrator) |
|
||||
| `@archon/workflows` | 5 batches |
|
||||
| `@archon/adapters` | 3 batches (chat+community+forge-auth, github-adapter, github-context) |
|
||||
| `@archon/isolation` | 3 batches |
|
||||
|
||||
**Never run `bun test` from the repo root** — causes ~135 mock pollution failures. Always use:
|
||||
|
||||
```bash
|
||||
bun run test # Correct: per-package isolation via bun --filter '*' test
|
||||
bun run test --watch # Watch mode (single package)
|
||||
```
|
||||
|
||||
## Mock Pattern for Lazy Loggers
|
||||
|
||||
All adapter/db/orchestrator files use lazy logger pattern. Mock before import:
|
||||
|
||||
```typescript
|
||||
// MUST come before import of the module under test
|
||||
const mockLogger = {
|
||||
fatal: mock(() => undefined), error: mock(() => undefined),
|
||||
warn: mock(() => undefined), info: mock(() => undefined),
|
||||
debug: mock(() => undefined), trace: mock(() => undefined),
|
||||
};
|
||||
mock.module('@archon/paths', () => ({ createLogger: mock(() => mockLogger) }));
|
||||
|
||||
import { SlackAdapter } from './adapter'; // Import AFTER mock
|
||||
```
|
||||
|
||||
## Database Test Mocking
|
||||
|
||||
```typescript
|
||||
import { createQueryResult, mockPostgresDialect } from '../test/mocks/database';
|
||||
|
||||
const mockQuery = mock(() => Promise.resolve(createQueryResult([])));
|
||||
mock.module('./connection', () => ({
|
||||
pool: { query: mockQuery },
|
||||
getDialect: () => mockPostgresDialect,
|
||||
}));
|
||||
|
||||
// In tests:
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([existingRow]));
|
||||
mockQuery.mockClear(); // in beforeEach
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
```typescript
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
describe('ComponentName', () => {
|
||||
beforeEach(() => {
|
||||
mockFn.mockClear(); // Reset call counts
|
||||
});
|
||||
|
||||
test('does thing when condition', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([fixture]));
|
||||
const result = await functionUnderTest(input);
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Never `import` a module before all `mock.module()` calls for its dependencies
|
||||
- Never use `afterAll(() => mock.restore())` for `mock.module()` — it silently does nothing
|
||||
- Never test with real database or filesystem in unit tests — always mock
|
||||
- Never run `bun test` from the repo root
|
||||
- Never add a new test file with conflicting `mock.module()` to an existing batch — create a new batch in the package's `package.json` test script
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "packages/web/**/*.tsx"
|
||||
- "packages/web/**/*.ts"
|
||||
- "packages/web/**/*.css"
|
||||
---
|
||||
|
||||
# Web Frontend Conventions
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 19 + Vite 6 + TypeScript
|
||||
- Tailwind CSS v4 (CSS-first config)
|
||||
- shadcn/ui components
|
||||
- TanStack Query v5 for REST data
|
||||
- React Router v7 (`react-router`, NOT `react-router-dom`)
|
||||
- Manual `EventSource` for SSE streaming (no library)
|
||||
- **Dark theme only** — no light mode toggle
|
||||
|
||||
## Tailwind v4 Critical Differences
|
||||
|
||||
```css
|
||||
/* CORRECT: CSS-first import */
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css'; /* NOT tailwindcss-animate */
|
||||
|
||||
/* CORRECT: theme variables in @theme inline block */
|
||||
@theme inline {
|
||||
--color-surface: var(--surface);
|
||||
--color-accent-bright: var(--accent-bright);
|
||||
}
|
||||
|
||||
/* WRONG: never use @tailwind base/components/utilities */
|
||||
```
|
||||
|
||||
Plugin in `vite.config.ts`: `import tailwindcss from '@tailwindcss/vite'` — uses Vite plugin, **not PostCSS**. `components.json` has blank `tailwind.config` for v4.
|
||||
|
||||
## Color Palette (oklch)
|
||||
|
||||
All custom colors are OKLCH. Key tokens (defined in `:root` in `index.css`):
|
||||
- `--surface` (0.18): main surface
|
||||
- `--surface-elevated` (0.22): cards, popovers
|
||||
- `--background` (0.14): page background
|
||||
- `--primary` / `--ring`: blue accent at oklch(0.65 0.18 250)
|
||||
- `--text-primary` (0.93), `--text-secondary` (0.65), `--text-tertiary` (0.45)
|
||||
- `--success` (green 155), `--warning` (yellow 75), `--error` (red 25)
|
||||
|
||||
Use CSS variables via Tailwind utilities: `bg-surface`, `text-text-primary`, `border-border`, `text-accent-bright`, etc.
|
||||
|
||||
## SSE Streaming Pattern
|
||||
|
||||
`useSSE()` in `src/hooks/useSSE.ts` is the single SSE consumer. It:
|
||||
- Opens `EventSource` to `/api/stream/{conversationId}`
|
||||
- Batches text events (50ms flush timer) to reduce re-renders
|
||||
- Flushes immediately before `tool_call`, `tool_result`, `workflow_dispatch` events
|
||||
- Marks disconnected only on `CLOSED` state (not `CONNECTING` — avoids flicker)
|
||||
- `handlersRef` pattern ensures stable EventSource with fresh handlers
|
||||
|
||||
Event types: `text`, `tool_call`, `tool_result`, `error`, `conversation_lock`, `session_info`, `workflow_step`, `workflow_status`, `parallel_agent`, `workflow_artifact`, `dag_node`, `workflow_dispatch`, `workflow_output_preview`, `warning`, `retract`, `heartbeat`.
|
||||
|
||||
## Routing
|
||||
|
||||
```tsx
|
||||
// CORRECT
|
||||
import { BrowserRouter, Routes, Route } from 'react-router';
|
||||
// WRONG
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
```
|
||||
|
||||
Routes: `/` (Dashboard), `/chat`, `/chat/*`, `/workflows`, `/workflows/builder`, `/workflows/runs/:runId`, `/settings`.
|
||||
|
||||
## API Client Pattern
|
||||
|
||||
```typescript
|
||||
// src/lib/api.ts exports SSE_BASE_URL and REST functions
|
||||
import { SSE_BASE_URL } from '@/lib/api';
|
||||
// In dev: Vite proxies /api/* to localhost:{VITE_API_PORT}
|
||||
// API port injected at build time: import.meta.env.VITE_API_PORT
|
||||
```
|
||||
|
||||
TanStack Query `staleTime: 10_000`, `refetchOnWindowFocus: true`.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Never add a light mode — dark-only is intentional
|
||||
- Never use `react-router-dom` — use `react-router` (v7)
|
||||
- Never configure Tailwind in `tailwind.config.js/ts` — v4 is CSS-first
|
||||
- Never use `tailwindcss-animate` — use `tw-animate-css`
|
||||
- Never open a second `EventSource` per conversation — `useSSE()` handles it
|
||||
- Never pass inline style objects for theme colors — use Tailwind classes with CSS variables
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
---
|
||||
paths:
|
||||
- "packages/workflows/**/*.ts"
|
||||
- ".archon/workflows/**/*.yaml"
|
||||
- ".archon/commands/**/*.md"
|
||||
---
|
||||
|
||||
# Workflows Conventions
|
||||
|
||||
## DAG Workflow Format
|
||||
|
||||
All workflows use the DAG (Directed Acyclic Graph) format with `nodes:`. Loop nodes are supported as a node type within DAGs.
|
||||
|
||||
```yaml
|
||||
nodes:
|
||||
- id: classify
|
||||
prompt: "Is this a bug or feature? Answer JSON: {type: 'BUG'|'FEATURE'}"
|
||||
output_format: {type: object, properties: {type: {type: string}}}
|
||||
- id: implement
|
||||
command: execute
|
||||
depends_on: [classify]
|
||||
when: "$classify.output.type == 'FEATURE'"
|
||||
- id: run_lint
|
||||
bash: "bun run lint"
|
||||
depends_on: [implement]
|
||||
- id: iterate
|
||||
loop:
|
||||
until: "COMPLETE"
|
||||
max_iterations: 10
|
||||
prompt: "Iterate until the tests pass. Signal COMPLETE when done."
|
||||
depends_on: [run_lint]
|
||||
```
|
||||
|
||||
## Variable Substitution
|
||||
|
||||
| Variable | Resolved to |
|
||||
|----------|-------------|
|
||||
| `$1`, `$2`, `$3` | Positional arguments from user message |
|
||||
| `$ARGUMENTS` | All user arguments as single string |
|
||||
| `$ARTIFACTS_DIR` | Pre-created external artifacts directory |
|
||||
| `$WORKFLOW_ID` | Current workflow run ID |
|
||||
| `$BASE_BRANCH` | Base branch from config or auto-detected |
|
||||
| `$DOCS_DIR` | Documentation directory path (default: `docs/`) |
|
||||
| `$nodeId.output` | Captured stdout/AI output from completed DAG node |
|
||||
|
||||
## WorkflowDeps — Dependency Injection
|
||||
|
||||
`@archon/workflows` has ZERO `@archon/core` dependency. Everything is injected:
|
||||
|
||||
```typescript
|
||||
interface WorkflowDeps {
|
||||
store: IWorkflowStore; // DB abstraction
|
||||
getAssistantClient: AssistantClientFactory; // Returns claude or codex client
|
||||
loadConfig: (cwd: string) => Promise<WorkflowConfig>;
|
||||
}
|
||||
|
||||
// Core creates the adapter:
|
||||
import { createWorkflowDeps } from '@archon/core/workflows/store-adapter';
|
||||
const deps = createWorkflowDeps();
|
||||
await executeWorkflow(deps, platform, conversationId, cwd, workflow, ...);
|
||||
```
|
||||
|
||||
## DAG Node Types
|
||||
|
||||
- `command:` — named file from `.archon/commands/`, AI-executed
|
||||
- `prompt:` — inline prompt string, AI-executed
|
||||
- `bash:` — shell script, no AI; stdout captured as `$nodeId.output`; default timeout 120000ms
|
||||
- `script:` — inline code or named file from `.archon/scripts/`, runs via `runtime: bun` (`.ts`/`.js`) or `runtime: uv` (`.py`), no AI; stdout captured as `$nodeId.output`; supports `deps:` for dependency installation and `timeout:` (ms); runtime availability checked at load time with a warning if binary is missing
|
||||
|
||||
DAG node options: `depends_on`, `when` (condition expression), `trigger_rule` (`all_success` | `one_success` | `none_failed_min_one_success` | `all_done`), `output_format` (JSON Schema, Claude only), `allowed_tools` / `denied_tools` (Claude only), `idle_timeout` (ms), `context: 'fresh'`, per-node `provider` and `model`, `deps` (script nodes only — dependency list), `runtime` (script nodes only — `'bun'` or `'uv'`).
|
||||
|
||||
## Event Emitter for Observability
|
||||
|
||||
```typescript
|
||||
import { getWorkflowEventEmitter } from '@archon/workflows';
|
||||
|
||||
const emitter = getWorkflowEventEmitter();
|
||||
emitter.registerRun(runId, conversationId);
|
||||
|
||||
// Subscribe (returns unsubscribe fn)
|
||||
const unsubscribe = emitter.subscribeForConversation(conversationId, (event) => {
|
||||
// event.type: 'step_started' | 'step_completed' | 'node_started' | ...
|
||||
});
|
||||
```
|
||||
|
||||
Listener errors never propagate to the executor — fire-and-forget with internal catch.
|
||||
|
||||
## Architecture
|
||||
|
||||
- Model validation at load time — invalid provider/model combinations fail `parseWorkflow()` with clear error
|
||||
- Resilient discovery — one broken YAML doesn't abort `discoverWorkflows()`; errors returned in `WorkflowLoadResult.errors`
|
||||
- Bundled defaults embedded in binary builds; loaded from filesystem in source builds
|
||||
- Repo workflows override bundled defaults by name
|
||||
- Router fallback: if no `/invoke-workflow` produced → falls back to `archon-assist`; raw AI response only when `archon-assist` unavailable
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Never import `@archon/core` from `@archon/workflows` (circular dependency)
|
||||
- Never add `clearContext: true` to every step — context continuity is valuable; use sparingly
|
||||
- Never put `output_format` on Codex nodes — it logs a warning and is ignored
|
||||
- Never set `allowed_tools: undefined` expecting "no tools" — use `allowed_tools: []` for that
|
||||
|
|
@ -119,9 +119,11 @@ If Bun was just installed in Prerequisites (macOS/Linux), use `~/.bun/bin/bun` i
|
|||
3. Verify: `archon version`
|
||||
4. Check Claude is installed: `which claude`, then `claude /login` if needed
|
||||
|
||||
> **Note — Claude Code binary path.** Archon does not bundle Claude Code. In compiled Archon binaries (quick install, Homebrew), the Claude Code SDK needs `CLAUDE_BIN_PATH` set to the absolute path of its `cli.js`. The `archon setup` wizard in Step 4 auto-detects this via `npm root -g` and writes it to `~/.archon/.env` — no manual action needed in the typical case. Source installs (`bun run`) don't need this; the SDK finds `cli.js` via `node_modules` automatically.
|
||||
|
||||
## Step 4: Configure Credentials
|
||||
|
||||
The CLI loads infrastructure config (database, tokens) from `~/.archon/.env` only. This prevents conflicts with project `.env` files that may contain different database URLs.
|
||||
Archon loads infrastructure config (database, tokens) from two archon-owned files — `~/.archon/.env` (user scope) and `<cwd>/.archon/.env` (repo scope, overrides user). The project's own `<cwd>/.env` is stripped at boot so it cannot leak into Archon; `archon setup` never writes to it.
|
||||
|
||||
Credential configuration runs in a separate terminal so your API keys stay private — the AI assistant won't see them.
|
||||
|
||||
|
|
@ -144,7 +146,7 @@ Tell the user:
|
|||
> 2. AI assistant configuration (Claude and/or Codex)
|
||||
> 3. Platform tokens for any integrations you selected
|
||||
>
|
||||
> It saves configuration to both `~/.archon/.env` and the repo `.env`."
|
||||
> By default it saves to `~/.archon/.env` (user scope). Re-run with `archon setup --scope project` to write `<repo>/.archon/.env` instead (project overrides user for this repo). Existing values are preserved — a timestamped backup is written before every rewrite."
|
||||
|
||||
**If the terminal opened automatically**, add:
|
||||
> "Complete the wizard in the new terminal window that just opened."
|
||||
|
|
@ -158,7 +160,7 @@ Both paths are normal — the manual path is not an error.
|
|||
|
||||
Wait for the user to confirm they've completed the setup wizard before proceeding.
|
||||
|
||||
### 5c: Verify Configuration
|
||||
### 4c: Verify Configuration
|
||||
|
||||
After the user confirms setup is complete:
|
||||
|
||||
|
|
@ -170,7 +172,7 @@ Should show:
|
|||
- `Database: sqlite` (default, zero setup) or `Database: postgresql` (if DATABASE_URL was configured)
|
||||
- No errors about missing configuration
|
||||
|
||||
### 5d: Run Database Migrations (PostgreSQL only)
|
||||
### 4d: Run Database Migrations (PostgreSQL only)
|
||||
|
||||
**SQLite users: skip this step.** SQLite is auto-initialized on first run with zero setup.
|
||||
|
||||
|
|
@ -299,16 +301,21 @@ For advanced users — these are not needed for basic setup:
|
|||
|
||||
### Environment Files (`.env`)
|
||||
|
||||
Infrastructure config (database URL, platform tokens) is stored in `.env` files:
|
||||
Archon's env model is scoped by directory ownership: `.archon/` is archon-owned, anything else belongs to you.
|
||||
|
||||
| Location | Used by | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `~/.archon/.env` | **CLI** | Global infrastructure config — database, AI tokens |
|
||||
| `<archon-repo>/.env` | **Server** | Platform tokens for Telegram/Slack/GitHub/Discord |
|
||||
| Path | Stripped at boot? | Archon loads? | `archon setup` writes? |
|
||||
|------|-------------------|---------------|------------------------|
|
||||
| `<cwd>/.env` | **yes** (safety guard) | never | never |
|
||||
| `<cwd>/.archon/.env` | no | yes (project scope, overrides user scope) | yes iff `--scope project` |
|
||||
| `~/.archon/.env` | no | yes (user scope) | yes iff `--scope home` (default) |
|
||||
|
||||
**Best practice**: Use `~/.archon/.env` as the single source of truth. Symlink or copy to `<archon-repo>/.env` if running the server.
|
||||
**Which should I use?**
|
||||
|
||||
**Note**: The CLI does NOT load `.env` from the current working directory. This prevents conflicts when running Archon from projects that have their own database configurations.
|
||||
- `~/.archon/.env` — defaults that apply everywhere (your personal `SLACK_WEBHOOK`, `DATABASE_URL`, bot tokens).
|
||||
- `<cwd>/.archon/.env` — per-project overrides (different webhook per repo, different DB per environment).
|
||||
- `<cwd>/.env` — your app's env file; archon strips these keys at boot so nothing leaks between your app and archon.
|
||||
|
||||
`archon setup` writes to exactly one archon-owned file chosen by `--scope` (default `home`), merges into existing content so user-added keys survive, and writes a timestamped backup before every rewrite. Use `--force` to opt into wholesale overwrite (backup still written).
|
||||
|
||||
### Config Files (YAML)
|
||||
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ Command/prompt nodes only:
|
|||
required: [issue_type]
|
||||
```
|
||||
|
||||
Enables `$classify.output.issue_type` field access. Works with Claude and Codex.
|
||||
Enables `$classify.output.issue_type` field access. SDK-enforced on Claude and Codex; best-effort on Pi (schema is appended to the prompt and JSON is parsed out of the result text).
|
||||
|
||||
## Per-Node Provider and Model
|
||||
|
||||
|
|
|
|||
|
|
@ -222,7 +222,23 @@ git commit -q --allow-empty -m init
|
|||
|
||||
### Test 3 — SDK path works (assist workflow)
|
||||
|
||||
In the same `$TESTREPO`:
|
||||
**Prerequisite.** Compiled binaries require Claude Code installed on the host and a configured binary path. Before running this test, ensure one of:
|
||||
|
||||
```bash
|
||||
# Option A — env var (easy for ad-hoc testing)
|
||||
# After the native installer (Anthropic's default):
|
||||
export CLAUDE_BIN_PATH="$HOME/.local/bin/claude"
|
||||
# Or after npm global install:
|
||||
export CLAUDE_BIN_PATH="$(npm root -g)/@anthropic-ai/claude-code/cli.js"
|
||||
|
||||
# Option B — config file (persistent)
|
||||
# Add to ~/.archon/config.yaml:
|
||||
# assistants:
|
||||
# claude:
|
||||
# claudeBinaryPath: /absolute/path/to/claude
|
||||
```
|
||||
|
||||
Then in the same `$TESTREPO`:
|
||||
|
||||
```bash
|
||||
"$BINARY" workflow run assist "say hello and nothing else" 2>&1 | tee /tmp/archon-test-assist.log
|
||||
|
|
@ -232,15 +248,34 @@ In the same `$TESTREPO`:
|
|||
|
||||
- Exit code 0
|
||||
- The Claude subprocess spawns successfully (no `spawn EACCES`, `ENOENT`, or `process exited with code 1` in the early output)
|
||||
- No `Claude Code CLI not found` error (that means the resolver rejected the configured path — verify the cli.js actually exists)
|
||||
- A response is produced (any response — even just "hello" — proves the SDK round-trip works)
|
||||
|
||||
**Common failures:**
|
||||
|
||||
- `Claude Code not found` → `CLAUDE_BIN_PATH` / `claudeBinaryPath` is unset or points at a non-existent file. Fix the path and re-run.
|
||||
- `Module not found "/Users/runner/..."` → regression of #1210: the resolver was bypassed and the SDK's `import.meta.url` fallback leaked a build-host path. Investigate `packages/providers/src/claude/provider.ts` and the resolver.
|
||||
- `Credit balance is too low` → auth is pointing at an exhausted API key (check `CLAUDE_USE_GLOBAL_AUTH` and `~/.archon/.env`)
|
||||
- `unable to determine transport target for "pino-pretty"` → #960 regression, binary crashes on TTY
|
||||
- `package.json not found (bad installation?)` → #961 regression, `isBinaryBuild` detection broken
|
||||
- Process exits before producing output → generic spawn failure, capture stderr
|
||||
|
||||
### Test 3b — Resolver error path (run without `CLAUDE_BIN_PATH`)
|
||||
|
||||
Quickly verify the resolver fails loud when nothing is configured:
|
||||
|
||||
```bash
|
||||
(unset CLAUDE_BIN_PATH; "$BINARY" workflow run assist "hello" 2>&1 | tee /tmp/archon-test-no-path.log)
|
||||
```
|
||||
|
||||
**Pass criteria (when no `~/.archon/config.yaml` configures `claudeBinaryPath`):**
|
||||
|
||||
- Error message contains `Claude Code not found`
|
||||
- Error message mentions both `CLAUDE_BIN_PATH` and `claudeBinaryPath` as remediation options
|
||||
- No `Module not found` stack traces referencing the CI filesystem
|
||||
|
||||
If you *do* have `claudeBinaryPath` set globally, skip this test or temporarily rename `~/.archon/config.yaml`.
|
||||
|
||||
### Test 4 — Env-leak gate refuses a leaky .env (optional, for releases including #1036/#1038/#983)
|
||||
|
||||
Create a second throwaway repo with a fake sensitive key:
|
||||
|
|
|
|||
53
.env.example
53
.env.example
|
|
@ -14,6 +14,20 @@ CLAUDE_USE_GLOBAL_AUTH=true
|
|||
# CLAUDE_CODE_OAUTH_TOKEN=...
|
||||
# CLAUDE_API_KEY=...
|
||||
|
||||
# Claude Code executable path (REQUIRED for compiled Archon binaries)
|
||||
# Archon does not bundle Claude Code — install it separately and point us at it.
|
||||
# Dev mode (`bun run`) auto-resolves via node_modules.
|
||||
# Alternatively, set `assistants.claude.claudeBinaryPath` in ~/.archon/config.yaml.
|
||||
#
|
||||
# Install (Anthropic's recommended native installer):
|
||||
# macOS/Linux: curl -fsSL https://claude.ai/install.sh | bash
|
||||
# Windows: irm https://claude.ai/install.ps1 | iex
|
||||
#
|
||||
# Then:
|
||||
# CLAUDE_BIN_PATH=$HOME/.local/bin/claude (native installer)
|
||||
# CLAUDE_BIN_PATH=$(npm root -g)/@anthropic-ai/claude-code/cli.js (npm alternative)
|
||||
# CLAUDE_BIN_PATH=
|
||||
|
||||
# Codex Authentication (get from ~/.codex/auth.json after running 'codex login')
|
||||
# Required if using Codex as AI assistant
|
||||
# On Linux/Mac: cat ~/.codex/auth.json
|
||||
|
|
@ -24,8 +38,27 @@ CODEX_REFRESH_TOKEN=
|
|||
CODEX_ACCOUNT_ID=
|
||||
# CODEX_BIN_PATH= # Optional: path to Codex native binary (binary builds only)
|
||||
|
||||
# Default AI Assistant (claude | codex)
|
||||
# Used for new conversations when no codebase specified
|
||||
# Pi (community provider — @mariozechner/pi-coding-agent)
|
||||
# One adapter, ~20 LLM backends. Archon's Pi adapter picks up credentials
|
||||
# you've already configured via the Pi CLI (`pi /login` writes to
|
||||
# ~/.pi/agent/auth.json), plus these env vars for backends you haven't
|
||||
# logged into via OAuth. Env vars override auth.json per-request.
|
||||
#
|
||||
# Use by setting `provider: pi` and `model: <pi-provider-id>/<model-id>` in
|
||||
# workflow YAML or `.archon/config.yaml` (e.g. model: google/gemini-2.5-pro).
|
||||
#
|
||||
# ANTHROPIC_API_KEY= # Pi provider id: anthropic
|
||||
# OPENAI_API_KEY= # Pi provider id: openai
|
||||
# GEMINI_API_KEY= # Pi provider id: google
|
||||
# GROQ_API_KEY= # Pi provider id: groq
|
||||
# MISTRAL_API_KEY= # Pi provider id: mistral
|
||||
# CEREBRAS_API_KEY= # Pi provider id: cerebras
|
||||
# XAI_API_KEY= # Pi provider id: xai
|
||||
# OPENROUTER_API_KEY= # Pi provider id: openrouter
|
||||
# HUGGINGFACE_API_KEY= # Pi provider id: huggingface
|
||||
|
||||
# Default AI Assistant (must match a registered provider, e.g. claude, codex, pi)
|
||||
# Used for new conversations when no codebase specified — errors on unknown values
|
||||
DEFAULT_AI_ASSISTANT=claude
|
||||
|
||||
# Title Generation Model (optional)
|
||||
|
|
@ -119,7 +152,7 @@ GITEA_ALLOWED_USERS=
|
|||
# GITEA_BOT_MENTION=archon
|
||||
|
||||
# Server
|
||||
PORT=3000
|
||||
# PORT=3090 # Default: 3090. Uncomment to override — must match between server and Vite proxy.
|
||||
# HOST=0.0.0.0 # Bind address (default: 0.0.0.0). Set to 127.0.0.1 to restrict to localhost only.
|
||||
|
||||
# Cloud Deployment (for --profile cloud with Caddy reverse proxy)
|
||||
|
|
@ -173,3 +206,17 @@ MAX_CONCURRENT_CONVERSATIONS=10 # Maximum concurrent AI conversations (default:
|
|||
|
||||
# Session Retention
|
||||
# SESSION_RETENTION_DAYS=30 # Delete inactive sessions older than N days (default: 30)
|
||||
|
||||
# Anonymous Telemetry (optional)
|
||||
# Archon sends anonymous workflow-invocation events to PostHog so maintainers
|
||||
# can see which workflows get real usage. No PII — workflow name/description +
|
||||
# platform + Archon version + a random install UUID. No identities, no prompts,
|
||||
# no paths, no code. See README "Telemetry" for the full list.
|
||||
#
|
||||
# Opt out (any one disables telemetry):
|
||||
# ARCHON_TELEMETRY_DISABLED=1
|
||||
# DO_NOT_TRACK=1 (de facto standard)
|
||||
#
|
||||
# Point at a self-hosted PostHog or a different project:
|
||||
# POSTHOG_API_KEY=phc_yourKeyHere
|
||||
# POSTHOG_HOST=https://eu.i.posthog.com (default: https://us.i.posthog.com)
|
||||
|
|
|
|||
123
.github/workflows/e2e-smoke.yml
vendored
Normal file
123
.github/workflows/e2e-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
name: E2E Smoke Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ─── Tier 1: Deterministic (no API keys needed) ────────────────────────
|
||||
e2e-deterministic:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
|
||||
- name: Setup uv (for Python script nodes)
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run deterministic workflow
|
||||
run: bun run cli workflow run e2e-deterministic --no-worktree "smoke test"
|
||||
|
||||
# ─── Tier 2a: Claude provider ──────────────────────────────────────────
|
||||
e2e-claude:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
run: |
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run Claude smoke test
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_BIN_PATH: ~/.local/bin/claude
|
||||
run: bun run cli workflow run e2e-claude-smoke --no-worktree "smoke test"
|
||||
|
||||
# ─── Tier 2b: Codex provider ───────────────────────────────────────────
|
||||
e2e-codex:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install Codex CLI
|
||||
run: npm install -g @openai/codex
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run Codex smoke test
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
CODEX_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: bun run cli workflow run e2e-codex-smoke --no-worktree "smoke test"
|
||||
|
||||
# ─── Tier 3: Mixed providers ───────────────────────────────────────────
|
||||
e2e-mixed:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs: [e2e-claude, e2e-codex]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
run: |
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install Codex CLI
|
||||
run: npm install -g @openai/codex
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run mixed providers test
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
CODEX_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
CLAUDE_BIN_PATH: ~/.local/bin/claude
|
||||
run: bun run cli workflow run e2e-mixed-providers --no-worktree "smoke test"
|
||||
77
.github/workflows/release.yml
vendored
77
.github/workflows/release.yml
vendored
|
|
@ -124,6 +124,83 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Smoke-test Claude binary-path resolver (negative case)
|
||||
if: matrix.target == 'bun-linux-x64' && runner.os == 'Linux'
|
||||
run: |
|
||||
# With no CLAUDE_BIN_PATH and no config, running a Claude workflow must
|
||||
# fail with a clear, user-facing error — NOT with "Module not found
|
||||
# /Users/runner/..." which would indicate the resolver was bypassed.
|
||||
BIN="$PWD/dist/${{ matrix.binary }}"
|
||||
TMP_REPO=$(mktemp -d)
|
||||
cd "$TMP_REPO"
|
||||
git init -q
|
||||
git -c user.email=ci@example.com -c user.name=ci commit --allow-empty -q -m init
|
||||
|
||||
# Run without CLAUDE_BIN_PATH set. Expect a clean resolver error.
|
||||
# Capture both stdout and stderr; we only care that the resolver message is present.
|
||||
set +e
|
||||
OUTPUT=$(env -u CLAUDE_BIN_PATH "$BIN" workflow run archon-assist "hello" 2>&1)
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
echo "$OUTPUT"
|
||||
|
||||
if echo "$OUTPUT" | grep -qE 'Module not found.*Users/runner'; then
|
||||
echo "::error::Resolver was bypassed — SDK hit the import.meta.url fallback (regression of #1210)"
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$OUTPUT" | grep -q "Claude Code not found"; then
|
||||
echo "::error::Expected 'Claude Code not found' error when CLAUDE_BIN_PATH is unset"
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$OUTPUT" | grep -q "CLAUDE_BIN_PATH"; then
|
||||
echo "::error::Error message does not reference CLAUDE_BIN_PATH remediation"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Resolver error path works (exit code: $EXIT_CODE)"
|
||||
|
||||
- name: Smoke-test Claude subprocess spawn (positive case)
|
||||
if: matrix.target == 'bun-linux-x64' && runner.os == 'Linux'
|
||||
run: |
|
||||
# Install Claude Code via the native installer (Anthropic's recommended
|
||||
# default) and run a workflow with CLAUDE_BIN_PATH set. The subprocess
|
||||
# must spawn cleanly. We do NOT require the query to succeed (no auth
|
||||
# in CI — an auth error is fine and expected); we only fail if the SDK
|
||||
# can't find the executable, which would indicate a resolver regression.
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
CLI_PATH="$HOME/.local/bin/claude"
|
||||
if [ ! -x "$CLI_PATH" ]; then
|
||||
echo "::error::Claude Code binary not found after curl install at $CLI_PATH"
|
||||
ls -la "$HOME/.local/bin/" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Using CLAUDE_BIN_PATH=$CLI_PATH"
|
||||
|
||||
BIN="$PWD/dist/${{ matrix.binary }}"
|
||||
TMP_REPO=$(mktemp -d)
|
||||
cd "$TMP_REPO"
|
||||
git init -q
|
||||
git -c user.email=ci@example.com -c user.name=ci commit --allow-empty -q -m init
|
||||
|
||||
set +e
|
||||
OUTPUT=$(CLAUDE_BIN_PATH="$CLI_PATH" "$BIN" workflow run archon-assist "hello" 2>&1)
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
echo "$OUTPUT"
|
||||
|
||||
if echo "$OUTPUT" | grep -qE 'Module not found.*(cli\.js|Users/runner)'; then
|
||||
echo "::error::Subprocess could not find the executable (resolver regression)"
|
||||
exit 1
|
||||
fi
|
||||
if echo "$OUTPUT" | grep -q "Claude Code not found"; then
|
||||
echo "::error::Resolver failed even though CLAUDE_BIN_PATH was set to an existing file"
|
||||
exit 1
|
||||
fi
|
||||
# Any of these outcomes are acceptable — they prove the subprocess spawned:
|
||||
# - auth error ("credit balance", "unauthorized", "authentication")
|
||||
# - rate-limit / API error
|
||||
# - successful query (if auth was injected via some other mechanism)
|
||||
echo "::notice::Claude subprocess spawn path is healthy (exit code: $EXIT_CODE)"
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
|
|
|||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
|
|
@ -27,6 +27,9 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Check bundled defaults
|
||||
run: bun run check:bundled
|
||||
|
||||
- name: Type check
|
||||
run: bun run type-check
|
||||
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -45,6 +45,9 @@ e2e-screenshots/
|
|||
.archon/logs/
|
||||
.archon/artifacts/
|
||||
|
||||
# Cross-run workflow state (e.g. issue-triage memory)
|
||||
.archon/state/
|
||||
|
||||
# Agent artifacts (generated, local only)
|
||||
.agents/
|
||||
.agents/rca-reports/
|
||||
|
|
@ -54,6 +57,7 @@ e2e-screenshots/
|
|||
.claude/archon/
|
||||
.claude/mockups/
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
e2e-testing-findings-session2.md
|
||||
|
||||
# Local workspace
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ workspace/
|
|||
# Lock files (auto-generated)
|
||||
package-lock.json
|
||||
|
||||
# Auto-generated source (regenerated by scripts/generate-bundled-defaults.ts)
|
||||
**/*.generated.ts
|
||||
|
||||
# Agent commands and documentation (user-managed)
|
||||
.agents/
|
||||
.claude/
|
||||
|
|
|
|||
55
CHANGELOG.md
55
CHANGELOG.md
|
|
@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Home-scoped commands at `~/.archon/commands/`** — personal command helpers now reusable across every repo. Resolution precedence: `<repoRoot>/.archon/commands/` > `~/.archon/commands/` > bundled defaults. Surfaced in the Web UI workflow-builder node palette under a dedicated "Global (~/.archon/commands/)" section.
|
||||
- **Home-scoped scripts at `~/.archon/scripts/`** — personal Bun/uv scripts now reusable across every repo. Script nodes (`script: my-helper`) resolve via `<repoRoot>/.archon/scripts/` first, then `~/.archon/scripts/`. Repo-scoped scripts with the same name override home-scoped ones silently; within a single scope, duplicate basenames across extensions still throw (unchanged from prior behavior).
|
||||
- **1-level subfolder support for workflows, commands, and scripts.** Files can live one folder deep under their respective `.archon/` root (e.g. `.archon/workflows/triage/foo.yaml`) and resolve by name or filename regardless of subfolder. Matches the existing `defaults/` convention. Deeper nesting is ignored silently — see docs for the full convention.
|
||||
- **`'global'` variant on `WorkflowSource`** — workflows at `~/.archon/workflows/` and commands at `~/.archon/commands/` now render with a distinct source label (no longer coerced to `'project'`). Web UI badges updated.
|
||||
- **`getHomeWorkflowsPath()`, `getHomeCommandsPath()`, `getHomeScriptsPath()`, `getLegacyHomeWorkflowsPath()`** helpers in `@archon/paths`, exported for both internal discovery and external callers that want to target the home scope directly.
|
||||
- **`discoverScriptsForCwd(cwd)`** in `@archon/workflows/script-discovery` — merges home-scoped + repo-scoped scripts with repo winning on name collisions. Used by the DAG executor and validator; callers no longer need to know about the two-scope shape.
|
||||
- **Workflow-level worktree policy (`worktree.enabled` in workflow YAML).** A workflow can now pin whether its runs use isolation regardless of how they were invoked: `worktree.enabled: false` always runs in the live checkout (CLI `--branch` / `--from` hard-error; web/chat/orchestrator short-circuits `validateAndResolveIsolation`), `worktree.enabled: true` requires isolation (CLI `--no-worktree` hard-errors). Omit the block to let the caller decide (current default). First consumer: `.archon/workflows/repo-triage.yaml` pinned to `enabled: false` since it's read-only.
|
||||
- **Per-project worktree path (`worktree.path` in `.archon/config.yaml`).** Opt-in repo-relative directory (e.g. `.worktrees`) where Archon places worktrees for that repo, instead of the default `~/.archon/workspaces/<owner>/<repo>/worktrees/`. Co-locates worktrees with the project so they appear in the IDE file tree. Validated as a safe relative path (no absolute, no `..`); malformed values fail loudly at worktree creation. Users opting in are responsible for `.gitignore`ing the directory themselves — no automatic file mutation. Credits @joelsb for surfacing the need in #1117.
|
||||
- **Three-path env model with operator-visible log lines.** The CLI and server now load env vars from `~/.archon/.env` (user scope) and `<cwd>/.archon/.env` (repo scope, overrides user) at boot, both with `override: true`. A new `[archon] loaded N keys from <path>` line is emitted per source (only when N > 0). `[archon] stripped N keys from <cwd> (...)` now also prints when stripCwdEnv removes target-repo env keys, replacing the misleading `[dotenv@17.3.1] injecting env (0) from .env` preamble that always reported 0. The `quiet: true` flag suppresses dotenv's own output. (#1302)
|
||||
- **`archon setup --scope home|project` and `--force` flags.** Default is `--scope home` (writes `~/.archon/.env`). `--scope project` targets `<cwd>/.archon/.env` instead. `--force` overwrites the target wholesale rather than merging; a timestamped backup is still written. (#1303)
|
||||
- **Merge-only setup writes with timestamped backups.** `archon setup` now reads the existing target file, preserves non-empty values, carries user-added custom keys forward, and writes a `<target>.archon-backup-<ISO-ts>` before every rewrite. Fixes silent PostgreSQL→SQLite downgrade and silent token loss on re-run. (#1303)
|
||||
- **`getArchonEnvPath()` and `getRepoArchonEnvPath(cwd)`** helpers in `@archon/paths`, plus a new `@archon/paths/env-loader` subpath exporting `loadArchonEnv(cwd)` shared by the CLI and server entry points.
|
||||
|
||||
- **Inline sub-agent definitions on DAG nodes (`agents:`).** Define Claude Agent SDK `AgentDefinition`s directly in workflow YAML, keyed by kebab-case agent ID. The main agent can spawn them in parallel via the `Task` tool — useful for map-reduce patterns where a cheap model (e.g. Haiku) briefs items and a stronger model reduces. Removes the need to author `.claude/agents/*.md` files for workflow-scoped helpers. Claude only; Codex and community providers that don't support inline agents emit a capability warning and ignore the field. Merges with the internal `dag-node-skills` wrapper set by `skills:` on the same node — user-defined agents win on ID collision (a warning is logged). (#1276)
|
||||
- **Pi community provider (`@mariozechner/pi-coding-agent`).** First community provider under the Phase 2 registry (`builtIn: false`). One adapter exposes ~20 LLM backends (Anthropic, OpenAI, Google, Groq, Mistral, Cerebras, xAI, OpenRouter, Hugging Face, and more) via a `<pi-provider-id>/<model-id>` model format. Reads credentials from `~/.pi/agent/auth.json` (populated by running `pi /login` for OAuth subscriptions like Claude Pro/Max, ChatGPT Plus, GitHub Copilot) AND from env vars (env vars take priority per-request). Per-node workflow options supported: `effort`/`thinking` → Pi `thinkingLevel`; `allowed_tools`/`denied_tools` → filter Pi's 7 built-in coding tools; `skills` → resolved against `.agents/skills`, `.claude/skills` (project + user-global); `systemPrompt`; codebase env vars; session resume via `sessionId` round-trip. Unsupported fields (MCP, hooks, structured output, cost limits, fallback model, sandbox) trigger an explicit dag-executor warning rather than silently dropping. Use in workflow YAML: `provider: pi` + `model: anthropic/claude-haiku-4-5`. (#1270)
|
||||
- **`registerCommunityProviders()` aggregator** in `@archon/providers`. Process entrypoints (CLI, server, config-loader) now call one function to register every bundled community provider. Adding a new community provider is a single-line edit to this aggregator rather than touching each entrypoint — makes the Phase 2 "community providers are a localized addition" promise real.
|
||||
- **`contributing/adding-a-community-provider.md` guide** — contributor-facing walkthrough of the Phase 2 registry pattern using Pi as the reference implementation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`archon setup` no longer writes to `<repo>/.env`.** Prior versions unconditionally wrote the generated config to both `~/.archon/.env` and `<repo>/.env`, destroying user-added secrets and silently downgrading PostgreSQL configs to SQLite when re-run in "Add" mode. The write side now targets exactly one archon-owned file (home or project scope via `--scope`), merges into existing content by default, and writes a timestamped backup. `<repo>/.env` is never touched — it belongs to the user's target project. (#1303)
|
||||
- **CLI and server no longer silently lose repo-local env vars.** Previously, env vars in `<repo>/.env` were parsed, deleted from `process.env` by `stripCwdEnv()`, and the only output operators saw was `[dotenv@17.3.1] injecting env (0) from .env` — which read as "file was empty." Workflows that needed `SLACK_WEBHOOK` or similar had no way to recover without knowing to use `~/.archon/.env`. The new `<cwd>/.archon/.env` path + archon-owned log lines make the load state observable and recoverable. (#1302)
|
||||
|
||||
- **Server startup no longer marks actively-running workflows as failed.** The `failOrphanedRuns()` call has been removed from `packages/server/src/index.ts` to match the CLI precedent (`packages/cli/src/cli.ts:256-258`). Per the new CLAUDE.md principle "No Autonomous Lifecycle Mutation Across Process Boundaries", a stuck `running` row is now transitioned explicitly by the user: via the per-row Cancel/Abandon buttons on the dashboard workflow card, or `archon workflow abandon <run-id>` from the CLI. (`archon workflow cleanup` is a separate command that deletes OLD terminal runs for disk hygiene — it does not handle stuck `running` rows.) Closes #1216.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Home-scoped workflow location moved to `~/.archon/workflows/`** (was `~/.archon/.archon/workflows/` — a double-nested path left over from reusing the repo-relative discovery helper for home scope). The new path sits next to `~/.archon/workspaces/`, `archon.db`, and `config.yaml`, matching the rest of the `~/.archon/` convention. If Archon detects workflows at the old location, it emits a one-time WARN per process with the exact migration command: `mv ~/.archon/.archon/workflows ~/.archon/workflows && rmdir ~/.archon/.archon`. The old path is no longer read — users must migrate manually (clean cut, no deprecation window). Rollback caveat: if you downgrade after migrating, move the directory back to the old location.
|
||||
- **Workflow discovery no longer takes a `globalSearchPath` option.** `discoverWorkflows()` and `discoverWorkflowsWithConfig()` now consult `~/.archon/workflows/` automatically — every caller gets home-scoped discovery for free. Previously-missed call sites in the chat command handler (`command-handler.ts`), the Web UI workflow picker (`api.ts GET /api/workflows`), and the orchestrator's single-codebase resolve path now see home-scoped workflows without needing a maintainer patch at every new call site. Closes #1136; supersedes that PR (credits @jonasvanderhaegen for surfacing the bug class).
|
||||
- **Dashboard nav tab** now shows a numeric count of running workflows instead of a binary pulse dot. Reads from the existing `/api/dashboard/runs` `counts.running` field; same 10s polling interval.
|
||||
- **Workflow run destructive actions** (Abandon, Cancel, Delete, Reject) now use a proper confirmation dialog matching the codebase-delete UX, replacing the browser's native `window.confirm()` popups. Each dialog includes context-appropriate copy describing what the action does to the run record.
|
||||
|
||||
- **Claude Code binary resolution** (breaking for compiled binary users): Archon no longer embeds the Claude Code SDK into compiled binaries. In compiled builds, you must install Claude Code separately (`curl -fsSL https://claude.ai/install.sh | bash` on macOS/Linux, `irm https://claude.ai/install.ps1 | iex` on Windows, or `npm install -g @anthropic-ai/claude-code`) and point Archon at the executable via `CLAUDE_BIN_PATH` env var or `assistants.claude.claudeBinaryPath` in `.archon/config.yaml`. The Claude Agent SDK accepts either the native compiled binary (from the curl/PowerShell installer at `~/.local/bin/claude`) or a JS `cli.js` (from the npm install). Dev mode (`bun run`) is unaffected — the SDK resolves via `node_modules` as before. The Docker image ships Claude Code pre-installed with `CLAUDE_BIN_PATH` pre-set, so `docker run` still works out of the box. Resolves silent "Module not found /Users/runner/..." failures on macOS (#1210) and Windows (#1087).
|
||||
|
||||
### Added
|
||||
|
||||
- **`CLAUDE_BIN_PATH` environment variable** — highest-precedence override for the Claude Code SDK `cli.js` path (#1176)
|
||||
- **`assistants.claude.claudeBinaryPath` config option** — durable config-file alternative to the env var (#1176)
|
||||
- **Release-workflow Claude subprocess smoke test** — the release CI now installs Claude Code on the Linux runner and exercises the resolver + subprocess spawn, catching binary-resolution regressions before they ship
|
||||
|
||||
### Removed
|
||||
|
||||
- **`globalSearchPath` option** from `discoverWorkflows()` and `discoverWorkflowsWithConfig()`. Callers that previously passed `{ globalSearchPath: getArchonHome() }` should drop the argument; home-scoped discovery is now automatic.
|
||||
- **`@anthropic-ai/claude-agent-sdk/embed` import** — the Bun `with { type: 'file' }` asset-embedding path and its `$bunfs` extraction logic. The embed was a bundler-dependent optimization that failed silently when Bun couldn't produce a usable virtual FS path (#1210, #1087); it is replaced by explicit binary-path resolution.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cross-clone worktree isolation**: prevent workflows in one local clone from silently adopting worktrees or DB state owned by another local clone of the same remote. Two clones sharing a remote previously resolved to the same `codebase_id`, causing the isolation resolver's DB-driven paths (`findReusable`, `findLinkedIssueEnv`, `tryBranchAdoption`) to return the other clone's environment. All adoption paths now verify the worktree's `.git` pointer matches the requesting clone and throw a classified error on mismatch. `archon-implement` prompt was also tightened to stop AI agents from adopting unrelated branches they see via `git branch`. Thanks to @halindrome for the three-issue root-cause mapping. (#1193, #1188, #1183, #1198, #1206)
|
||||
|
||||
## [0.3.6] - 2026-04-12
|
||||
|
||||
Web UI workflow experience improvements, CWD environment leak protection, and bug fixes.
|
||||
|
|
@ -179,7 +230,7 @@ Chat-first navigation redesign, DAG graph viewer, per-node MCP and skills, and e
|
|||
- Idle timeout not detecting stuck tool calls during execution (#649)
|
||||
- `commitAllChanges` failing on empty commits (#745)
|
||||
- Explicit base branch config now required for worktree creation (#686)
|
||||
- Subprocess-level retry added to CodexClient (#641)
|
||||
- Subprocess-level retry added to CodexProvider (#641)
|
||||
- Validate `cwd` query param against registered codebases (#630)
|
||||
- Server-internal paths redacted from `/api/config` response (#632)
|
||||
- SQLite conversations index missing `WHERE deleted_at IS NULL` (#629)
|
||||
|
|
@ -231,7 +282,7 @@ DAG hardening, security fixes, validate-pr workflow, and worktree lifecycle mana
|
|||
- **`--json` flag for `workflow list`** — machine-readable workflow output (#594)
|
||||
- **`archon-validate-pr` workflow** with per-node idle timeout support (#635)
|
||||
- **Typed SessionMetadata** with Zod validation for safer metadata handling (#600)
|
||||
- **`persistSession: false`** in ClaudeClient to avoid disk pollution from session transcripts (#626)
|
||||
- **`persistSession: false`** in ClaudeProvider to avoid disk pollution from session transcripts (#626)
|
||||
- **DAG workflow for GitHub issue resolution** with structured node pipeline
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
88
CLAUDE.md
88
CLAUDE.md
|
|
@ -68,7 +68,7 @@ These are implementation constraints, not slogans. Apply them by default.
|
|||
|
||||
**SRP + ISP — Single Responsibility + Interface Segregation**
|
||||
- Keep each module and package focused on one concern
|
||||
- Extend behavior by implementing existing narrow interfaces (`IPlatformAdapter`, `IAssistantClient`, `IDatabase`, `IWorkflowStore`) whenever possible
|
||||
- Extend behavior by implementing existing narrow interfaces (`IPlatformAdapter`, `IAgentProvider`, `IDatabase`, `IWorkflowStore`) whenever possible
|
||||
- Avoid fat interfaces and "god modules" that mix policy, transport, and storage
|
||||
- Do not add unrelated methods to an existing interface — define a new one
|
||||
|
||||
|
|
@ -77,6 +77,12 @@ These are implementation constraints, not slogans. Apply them by default.
|
|||
- Never silently broaden permissions or capabilities
|
||||
- Document fallback behavior with a comment when a fallback is intentional and safe; otherwise throw
|
||||
|
||||
**No Autonomous Lifecycle Mutation Across Process Boundaries**
|
||||
- When a process cannot reliably distinguish "actively running elsewhere" from "orphaned by a crash" — typically because the work was started by a different process or input source (CLI, adapter, webhook, web UI, cron) — it must not autonomously mark that work as failed/cancelled/abandoned based on a timer or staleness guess.
|
||||
- Surface the ambiguous state to the user and provide a one-click action.
|
||||
- Heuristics for *recoverable* operations (retry backoff, subprocess timeouts, hygiene cleanup of terminal-status data) remain appropriate; the rule is about destructive mutation of *non-terminal* state owned by an unknowable other party.
|
||||
- Reference: #1216 and the CLI orphan-cleanup precedent at `packages/cli/src/cli.ts:256-258`.
|
||||
|
||||
**Determinism + Reproducibility**
|
||||
- Prefer reproducible commands and locked dependency behavior in CI-sensitive paths
|
||||
- Keep tests deterministic — no flaky timing or network dependence without guardrails
|
||||
|
|
@ -122,7 +128,7 @@ bun test --watch # Watch mode (single package)
|
|||
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` (7 batches), `@archon/workflows` (5), `@archon/adapters` (4), `@archon/isolation` (3). See each package's `package.json` for the exact splits.
|
||||
**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` (7 batches), `@archon/workflows` (5), `@archon/adapters` (3), `@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).
|
||||
|
||||
|
|
@ -144,7 +150,7 @@ bun run format:check
|
|||
bun run validate
|
||||
```
|
||||
|
||||
This runs type-check, lint, format check, and tests. All four must pass for CI to succeed.
|
||||
This runs `check:bundled`, type-check, lint, format check, and tests. All five must pass for CI to succeed.
|
||||
|
||||
### ESLint Guidelines
|
||||
|
||||
|
|
@ -198,10 +204,6 @@ bun run cli workflow run implement --branch feature-auth "Add auth"
|
|||
# Opt out of isolation (run in live checkout)
|
||||
bun run cli workflow run quick-fix --no-worktree "Fix typo"
|
||||
|
||||
# Grant env-leak-gate consent during auto-registration (for repos whose .env
|
||||
# contains sensitive keys). Audit-logged with actor: 'user-cli'.
|
||||
bun run cli workflow run plan --cwd /path/to/leaky/repo --allow-env-keys "..."
|
||||
|
||||
# Show running workflows
|
||||
bun run cli workflow status
|
||||
|
||||
|
|
@ -266,9 +268,17 @@ packages/
|
|||
│ ├── adapters/ # CLI adapter (stdout output)
|
||||
│ ├── commands/ # CLI command implementations
|
||||
│ └── cli.ts # CLI entry point
|
||||
├── providers/ # @archon/providers - AI agent providers (SDK deps live here)
|
||||
│ └── src/
|
||||
│ ├── types.ts # Contract layer (IAgentProvider, SendQueryOptions, MessageChunk — ZERO SDK deps)
|
||||
│ ├── registry.ts # Typed provider registry (ProviderRegistration records)
|
||||
│ ├── errors.ts # UnknownProviderError
|
||||
│ ├── claude/ # ClaudeProvider + parseClaudeConfig + MCP/hooks/skills translation
|
||||
│ ├── codex/ # CodexProvider + parseCodexConfig + binary-resolver
|
||||
│ ├── community/pi/ # PiProvider (builtIn: false) — @mariozechner/pi-coding-agent, ~20 LLM backends
|
||||
│ └── index.ts # Package exports
|
||||
├── core/ # @archon/core - Shared business logic
|
||||
│ └── src/
|
||||
│ ├── clients/ # AI SDK clients (Claude, Codex)
|
||||
│ ├── config/ # YAML config loading
|
||||
│ ├── db/ # Database connection, queries
|
||||
│ ├── handlers/ # Command handler (slash commands)
|
||||
|
|
@ -289,7 +299,7 @@ packages/
|
|||
│ ├── executor.ts # Workflow execution orchestrator (executeWorkflow)
|
||||
│ ├── dag-executor.ts # DAG-specific execution logic
|
||||
│ ├── store.ts # IWorkflowStore interface (database abstraction)
|
||||
│ ├── deps.ts # WorkflowDeps injection types (IWorkflowPlatform, IWorkflowAssistantClient)
|
||||
│ ├── deps.ts # WorkflowDeps injection types (IWorkflowPlatform, imports from @archon/providers/types)
|
||||
│ ├── event-emitter.ts # Workflow observability events
|
||||
│ ├── logger.ts # JSONL file logger
|
||||
│ ├── validator.ts # Resource validation (command files, MCP configs, skill dirs)
|
||||
|
|
@ -383,7 +393,7 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api';
|
|||
5. **`workflow_runs`** - Workflow execution tracking and state
|
||||
6. **`workflow_events`** - Step-level workflow event log (step transitions, artifacts, errors)
|
||||
7. **`messages`** - Conversation message history with tool call metadata (JSONB)
|
||||
8. **`codebase_env_vars`** - Per-project env vars injected into Claude SDK subprocess env (managed via Web UI or `env:` in config)
|
||||
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
|
||||
|
||||
**Key Patterns:**
|
||||
- Conversation ID format: Platform-specific (`thread_ts`, `chat_id`, `user/repo#123`)
|
||||
|
|
@ -401,10 +411,11 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api';
|
|||
**Package Split:**
|
||||
- **@archon/paths**: Path resolution utilities, Pino logger factory, web dist cache path (`getWebDistDir`), CWD env stripper (`stripCwdEnv`, `strip-cwd-env-boot`) (no @archon/* deps; `pino` and `dotenv` are allowed external deps)
|
||||
- **@archon/git**: Git operations - worktrees, branches, repos, exec wrappers (depends only on @archon/paths)
|
||||
- **@archon/providers**: AI agent providers (Claude, Codex, Pi community) — owns SDK deps, `IAgentProvider` interface, `sendQuery()` contract, and provider-specific option translation. `@archon/providers/types` is the contract subpath (zero SDK deps, zero runtime side effects) that `@archon/workflows` imports from. Providers receive raw `nodeConfig` + `assistantConfig` and translate to SDK-specific options internally. Core providers live under `claude/` and `codex/`; community providers live under `community/` (currently `community/pi/`, registered with `builtIn: false`).
|
||||
- **@archon/isolation**: Worktree isolation types, providers, resolver, error classifiers (depends only on @archon/git + @archon/paths)
|
||||
- **@archon/workflows**: Workflow engine - loader, router, executor, DAG, logger, bundled defaults (depends only on @archon/git + @archon/paths + @hono/zod-openapi + zod; DB/AI/config injected via `WorkflowDeps`)
|
||||
- **@archon/workflows**: Workflow engine - loader, router, executor, DAG, logger, bundled defaults (depends only on @archon/git + @archon/paths + @archon/providers/types + @hono/zod-openapi + zod; DB/AI/config injected via `WorkflowDeps`)
|
||||
- **@archon/cli**: Command-line interface for running workflows and starting the web UI server (depends on @archon/server + @archon/adapters for the serve command)
|
||||
- **@archon/core**: Business logic, database, orchestration, AI clients (provides `createWorkflowStore()` adapter bridging core DB → `IWorkflowStore`)
|
||||
- **@archon/core**: Business logic, database, orchestration (depends on @archon/providers for AI; provides `createWorkflowStore()` adapter bridging core DB → `IWorkflowStore`)
|
||||
- **@archon/adapters**: Platform adapters for Slack, Telegram, GitHub, Discord (depends on @archon/core)
|
||||
- **@archon/server**: OpenAPIHono HTTP server (Zod + OpenAPI spec generation via `@hono/zod-openapi`), Web adapter (SSE), API routes, Web UI static serving (depends on @archon/adapters)
|
||||
- **@archon/web**: React frontend (Vite + Tailwind v4 + shadcn/ui + Zustand), SSE streaming to server. `WorkflowRunStatus`, `WorkflowDefinition`, and `DagNode` are all derived from `src/lib/api.generated.d.ts` (generated from the OpenAPI spec via `bun generate:types`; never import from `@archon/workflows`)
|
||||
|
|
@ -429,7 +440,8 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api';
|
|||
|
||||
**2. Command Handler** (`packages/core/src/handlers/`)
|
||||
- Process slash commands (deterministic, no AI)
|
||||
- Commands: `/command-set`, `/load-commands`, `/clone`, `/getcwd`, `/setcwd`, `/repos`, `/repo`, `/repo-remove`, `/worktree`, `/workflow`, `/status`, `/commands`, `/help`, `/reset`, `/reset-context`, `/init`
|
||||
- The orchestrator treats only these top-level commands as deterministic: `/help`, `/status`, `/reset`, `/workflow`, `/register-project`, `/update-project`, `/remove-project`, `/commands`, `/init`, `/worktree`
|
||||
- `/workflow` handles subcommands like `list`, `run`, `status`, `cancel`, `resume`, `abandon`, `approve`, `reject`
|
||||
- Update database, perform operations, return responses
|
||||
|
||||
**3. Orchestrator** (`packages/core/src/orchestrator/`)
|
||||
|
|
@ -439,10 +451,11 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api';
|
|||
- Session management: Create new or resume existing
|
||||
- Stream AI responses to platform
|
||||
|
||||
**4. AI Assistant Clients** (`packages/core/src/clients/`)
|
||||
- Implement `IAssistantClient` interface
|
||||
- **ClaudeClient**: `@anthropic-ai/claude-agent-sdk`
|
||||
- **CodexClient**: `@openai/codex-sdk`
|
||||
**4. AI Agent Providers** (`packages/providers/src/`)
|
||||
- Implement `IAgentProvider` interface
|
||||
- **ClaudeProvider**: `@anthropic-ai/claude-agent-sdk`
|
||||
- **CodexProvider**: `@openai/codex-sdk`
|
||||
- **PiProvider** (community, `builtIn: false`): `@mariozechner/pi-coding-agent` — one harness for ~20 LLM backends via `<provider>/<model>` refs (e.g. `anthropic/claude-haiku-4-5`, `openrouter/qwen/qwen3-coder`); supports extensions, skills, tool restrictions, thinking level, best-effort structured output. See `packages/docs-web/src/content/docs/getting-started/ai-assistants.md` for setup, capability matrix, and extension config.
|
||||
- Streaming: `for await (const event of events) { await platform.send(event) }`
|
||||
|
||||
### Configuration
|
||||
|
|
@ -463,6 +476,11 @@ assistants:
|
|||
settingSources: # Controls which CLAUDE.md files Claude SDK loads
|
||||
- project # Default: only project-level CLAUDE.md
|
||||
- user # Optional: also load ~/.claude/CLAUDE.md
|
||||
claudeBinaryPath: /absolute/path/to/claude # Optional: Claude Code executable.
|
||||
# Native binary (curl installer at
|
||||
# ~/.local/bin/claude) or npm cli.js.
|
||||
# Required in compiled binaries if
|
||||
# CLAUDE_BIN_PATH env var is not set.
|
||||
codex:
|
||||
model: gpt-5.3-codex
|
||||
modelReasoningEffort: medium # 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
||||
|
|
@ -530,7 +548,7 @@ curl http://localhost:3637/api/conversations/<conversationId>/messages
|
|||
```
|
||||
~/.archon/
|
||||
├── workspaces/owner/repo/ # Project-centric layout
|
||||
│ ├── source/ # Clone (from /clone) or symlink → local path
|
||||
│ ├── source/ # Cloned repo or symlink → local path
|
||||
│ ├── worktrees/ # Git worktrees for this project
|
||||
│ ├── artifacts/ # Workflow artifacts (NEVER in git)
|
||||
│ │ ├── runs/{id}/ # Per-run artifacts ($ARTIFACTS_DIR)
|
||||
|
|
@ -549,6 +567,7 @@ curl http://localhost:3637/api/conversations/<conversationId>/messages
|
|||
├── commands/ # Custom commands
|
||||
├── workflows/ # Workflow definitions (YAML files)
|
||||
├── scripts/ # Named scripts for script: nodes (.ts/.js for bun, .py for uv)
|
||||
├── state/ # Cross-run workflow state (gitignored — never in git)
|
||||
└── config.yaml # Repo-specific configuration
|
||||
```
|
||||
|
||||
|
|
@ -561,7 +580,7 @@ curl http://localhost:3637/api/conversations/<conversationId>/messages
|
|||
|
||||
**Quick reference:**
|
||||
- **Platform Adapters**: Implement `IPlatformAdapter`, handle auth, polling/webhooks
|
||||
- **AI Clients**: Implement `IAssistantClient`, session management, streaming
|
||||
- **AI Providers**: Implement `IAgentProvider`, session management, streaming
|
||||
- **Slash Commands**: Add to command-handler.ts, update database, no AI
|
||||
- **Database Operations**: Use `IDatabase` interface (supports PostgreSQL and SQLite via adapters)
|
||||
|
||||
|
|
@ -675,13 +694,13 @@ async function createSession(conversationId: string, codebaseId: string) {
|
|||
|
||||
1. **Codebase Commands** (per-repo):
|
||||
- Stored in `.archon/commands/` (plain text/markdown)
|
||||
- Auto-detected via `/clone` or `/load-commands <folder>`
|
||||
- Loaded by `/clone` or `/load-commands`, invoked by AI via orchestrator routing
|
||||
- Discovered from the repository `.archon/commands/` directory
|
||||
- Surfaced via `GET /api/commands` for the workflow builder and invoked by workflow `command:` nodes
|
||||
|
||||
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), `loop:` (iterative AI prompt until completion signal), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$<node-id>.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, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (Claude and Codex), `allowed_tools`/`denied_tools` for per-node tool restrictions (Claude only), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (Claude only, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (Claude only), and `effort`/`thinking`/`maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` for Claude SDK advanced options (Claude only, also settable at workflow level)
|
||||
- **`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), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$<node-id>.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`) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (Claude and Codex via SDK enforcement; Pi best-effort via prompt augmentation + JSON extraction), `allowed_tools`/`denied_tools` for per-node tool restrictions (Claude only), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (Claude only, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (Claude only), `agents` for inline sub-agent definitions invokable via the Task tool (Claude only), and `effort`/`thinking`/`maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` for Claude SDK advanced options (Claude only, also settable at workflow level)
|
||||
- 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
|
||||
- `interactive: true` at the workflow level forces foreground execution on web (required for approval-gate workflows in the web UI)
|
||||
|
|
@ -694,14 +713,21 @@ async function createSession(conversationId: string, codebaseId: string) {
|
|||
|
||||
**Defaults:**
|
||||
- Bundled in `.archon/commands/defaults/` and `.archon/workflows/defaults/`
|
||||
- Binary builds: Embedded at compile time (no filesystem access needed)
|
||||
- Binary builds: Embedded at compile time (no filesystem access needed) via `packages/workflows/src/defaults/bundled-defaults.generated.ts`
|
||||
- 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. `bun run validate` (and CI) run `check:bundled` and will fail loudly if the generated file is stale.
|
||||
|
||||
**Global workflows** (user-level, applies to every project):
|
||||
- Path: `~/.archon/.archon/workflows/` (or `$ARCHON_HOME/.archon/workflows/`)
|
||||
- Load priority: bundled < global < repo-specific (repo overrides global by filename)
|
||||
**Home-scoped ("global") workflows, commands, and scripts** (user-level, applies to every project):
|
||||
- Workflows: `~/.archon/workflows/` (or `$ARCHON_HOME/workflows/`)
|
||||
- Commands: `~/.archon/commands/` (or `$ARCHON_HOME/commands/`)
|
||||
- Scripts: `~/.archon/scripts/` (or `$ARCHON_HOME/scripts/`)
|
||||
- Source label: `source: 'global'` on workflows and commands (scripts don't have a source label)
|
||||
- Load priority: bundled < global < project (repo overrides global by filename or script name)
|
||||
- Subfolders: supported 1 level deep (e.g. `~/.archon/workflows/triage/foo.yaml`). Deeper nesting is ignored silently.
|
||||
- Discovery is automatic — `discoverWorkflowsWithConfig(cwd, loadConfig)` and `discoverScriptsForCwd(cwd)` both read home-scoped paths unconditionally; no caller option needed
|
||||
- **Migration from pre-0.x `~/.archon/.archon/workflows/`**: if Archon detects files at the old location it emits a one-time WARN with the exact `mv` command and does NOT load from there. Move with: `mv ~/.archon/.archon/workflows ~/.archon/workflows && rmdir ~/.archon/.archon`
|
||||
- See the docs site at `packages/docs-web/` for details
|
||||
|
||||
### Error Handling
|
||||
|
|
@ -759,9 +785,11 @@ Pattern: Use `classifyIsolationError()` (from `@archon/isolation`) to map git er
|
|||
|
||||
**Codebases:**
|
||||
- `GET /api/codebases` / `GET /api/codebases/:id` - List / fetch codebases
|
||||
- `POST /api/codebases` - Register a codebase (clone or local path); body accepts `allowEnvKeys` for the env-leak gate
|
||||
- `PATCH /api/codebases/:id` - Flip the `allow_env_keys` consent bit; body: `{ allowEnvKeys: boolean }`. Audit-logged at `warn` level on every grant/revoke (`env_leak_consent_granted` / `env_leak_consent_revoked`) with `codebaseId`, `path`, `files`, `keys`, `scanStatus`, `actor`
|
||||
- `POST /api/codebases` - Register a codebase (clone or local path)
|
||||
- `DELETE /api/codebases/:id` - Delete a codebase and clean up resources
|
||||
- `GET /api/codebases/:id/env` - List env var keys for a codebase (never returns values)
|
||||
- `PUT /api/codebases/:id/env` / `DELETE /api/codebases/:id/env/:key` - Upsert / delete a single codebase env var
|
||||
- `GET /api/codebases/:id/environments` - List tracked isolation environments for a codebase
|
||||
|
||||
**Artifact Files:**
|
||||
- `GET /api/artifacts/:runId/*` - Serve a workflow artifact file by run ID and relative path; returns `text/markdown` for `.md` files, `text/plain` otherwise; 400 on path traversal (`..`), 404 if run or file not found
|
||||
|
|
@ -769,7 +797,11 @@ Pattern: Use `classifyIsolationError()` (from `@archon/isolation`) to map git er
|
|||
**Command Listing:**
|
||||
- `GET /api/commands` - List available command names (bundled + project-defined); optional `?cwd=`; returns `{ commands: [{ name, source: 'bundled' | 'project' }] }`
|
||||
|
||||
**Providers:**
|
||||
- `GET /api/providers` - List registered AI providers; returns `{ providers: [{ id, displayName, capabilities, builtIn }] }`
|
||||
|
||||
**System:**
|
||||
- `GET /api/health` - Health check with adapter/system status
|
||||
- `GET /api/update-check` - Check for available updates; returns `{ updateAvailable, currentVersion, latestVersion, releaseUrl }`; skips GitHub API call for non-binary builds
|
||||
|
||||
**OpenAPI Spec:**
|
||||
|
|
|
|||
|
|
@ -17,15 +17,20 @@ Thank you for your interest in contributing to Archon!
|
|||
Before submitting a PR, ensure:
|
||||
|
||||
```bash
|
||||
bun run type-check # TypeScript types
|
||||
bun run lint # ESLint
|
||||
bun run format # Prettier
|
||||
bun run test # All tests (per-package isolation)
|
||||
bun run check:bundled # Bundled defaults are up to date (see note below)
|
||||
bun run type-check # TypeScript types
|
||||
bun run lint # ESLint
|
||||
bun run format # Prettier
|
||||
bun run test # All tests (per-package isolation)
|
||||
|
||||
# Or run the full validation suite:
|
||||
bun run validate
|
||||
```
|
||||
|
||||
**Bundled defaults**: If you added, removed, or edited a file under
|
||||
`.archon/commands/defaults/` or `.archon/workflows/defaults/`, run
|
||||
`bun run generate:bundled` to refresh the embedded bundle before committing.
|
||||
|
||||
**Important:** Use `bun run test` (not `bun test` from the repo root) to avoid mock pollution across packages.
|
||||
|
||||
### Commit Messages
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -24,6 +24,7 @@ COPY packages/docs-web/package.json ./packages/docs-web/
|
|||
COPY packages/git/package.json ./packages/git/
|
||||
COPY packages/isolation/package.json ./packages/isolation/
|
||||
COPY packages/paths/package.json ./packages/paths/
|
||||
COPY packages/providers/package.json ./packages/providers/
|
||||
COPY packages/server/package.json ./packages/server/
|
||||
COPY packages/web/package.json ./packages/web/
|
||||
COPY packages/workflows/package.json ./packages/workflows/
|
||||
|
|
@ -107,6 +108,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \
|
|||
# Point agent-browser to system Chromium (avoids ~400MB Chrome for Testing download)
|
||||
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
# Pre-configure the Claude Code SDK cli.js path for any consumer that runs
|
||||
# a compiled Archon binary inside (or extending) this image. In source mode
|
||||
# (the default `bun run start` ENTRYPOINT), BUNDLED_IS_BINARY is false and
|
||||
# this variable is ignored — the SDK resolves cli.js via node_modules. Kept
|
||||
# here so extenders don't need to rediscover the path.
|
||||
# Path matches the hoisted layout produced by `bun install --linker=hoisted`.
|
||||
ENV CLAUDE_BIN_PATH=/app/node_modules/@anthropic-ai/claude-agent-sdk/cli.js
|
||||
|
||||
# Create non-root user for running Claude Code
|
||||
# Claude Code refuses to run with --dangerously-skip-permissions as root for security
|
||||
RUN useradd -m -u 1001 -s /bin/bash appuser \
|
||||
|
|
@ -130,6 +139,7 @@ COPY packages/docs-web/package.json ./packages/docs-web/
|
|||
COPY packages/git/package.json ./packages/git/
|
||||
COPY packages/isolation/package.json ./packages/isolation/
|
||||
COPY packages/paths/package.json ./packages/paths/
|
||||
COPY packages/providers/package.json ./packages/providers/
|
||||
COPY packages/server/package.json ./packages/server/
|
||||
COPY packages/web/package.json ./packages/web/
|
||||
COPY packages/workflows/package.json ./packages/workflows/
|
||||
|
|
@ -144,6 +154,7 @@ COPY packages/core/ ./packages/core/
|
|||
COPY packages/git/ ./packages/git/
|
||||
COPY packages/isolation/ ./packages/isolation/
|
||||
COPY packages/paths/ ./packages/paths/
|
||||
COPY packages/providers/ ./packages/providers/
|
||||
COPY packages/server/ ./packages/server/
|
||||
COPY packages/workflows/ ./packages/workflows/
|
||||
|
||||
|
|
|
|||
43
README.md
43
README.md
|
|
@ -171,6 +171,22 @@ irm https://archon.diy/install.ps1 | iex
|
|||
brew install coleam00/archon/archon
|
||||
```
|
||||
|
||||
> **Compiled binaries need a `CLAUDE_BIN_PATH`.** The quick-install binaries
|
||||
> don't bundle Claude Code. Install it separately, then point Archon at it:
|
||||
>
|
||||
> ```bash
|
||||
> # macOS / Linux / WSL
|
||||
> curl -fsSL https://claude.ai/install.sh | bash
|
||||
> export CLAUDE_BIN_PATH="$HOME/.local/bin/claude"
|
||||
>
|
||||
> # Windows (PowerShell)
|
||||
> irm https://claude.ai/install.ps1 | iex
|
||||
> $env:CLAUDE_BIN_PATH = "$env:USERPROFILE\.local\bin\claude.exe"
|
||||
> ```
|
||||
>
|
||||
> Or set `assistants.claude.claudeBinaryPath` in `~/.archon/config.yaml`.
|
||||
> The Docker image ships Claude Code pre-installed. See [AI Assistants → Binary path configuration](https://archon.diy/docs/getting-started/ai-assistants/#binary-path-configuration-compiled-binaries-only) for details.
|
||||
|
||||
### Start Using Archon
|
||||
|
||||
Once you've completed either setup path, go to your project and start working:
|
||||
|
|
@ -254,7 +270,7 @@ The Web UI and CLI work out of the box. Optionally connect a chat platform for r
|
|||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Platform Adapters (Web UI, CLI, Telegram, Slack, │
|
||||
│ Discord, GitHub) │
|
||||
│ Discord, GitHub) │
|
||||
└──────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
|
|
@ -268,7 +284,7 @@ The Web UI and CLI work out of the box. Optionally connect a chat platform for r
|
|||
▼ ▼ ▼ ▼
|
||||
┌───────────┐ ┌────────────┐ ┌──────────────────────────┐
|
||||
│ Command │ │ Workflow │ │ AI Assistant Clients │
|
||||
│ Handler │ │ Executor │ │ (Claude / Codex) │
|
||||
│ Handler │ │ Executor │ │ (Claude / Codex / Pi) │
|
||||
│ (Slash) │ │ (YAML) │ │ │
|
||||
└───────────┘ └────────────┘ └──────────────────────────┘
|
||||
│ │ │
|
||||
|
|
@ -294,17 +310,38 @@ Full documentation is available at **[archon.diy](https://archon.diy)**.
|
|||
| [Authoring Workflows](https://archon.diy/guides/authoring-workflows/) | Create custom YAML workflows |
|
||||
| [Authoring Commands](https://archon.diy/guides/authoring-commands/) | Create reusable AI commands |
|
||||
| [Configuration](https://archon.diy/reference/configuration/) | All config options, env vars, YAML settings |
|
||||
| [AI Assistants](https://archon.diy/getting-started/ai-assistants/) | Claude and Codex setup details |
|
||||
| [AI Assistants](https://archon.diy/getting-started/ai-assistants/) | Claude, Codex, and Pi setup details |
|
||||
| [Deployment](https://archon.diy/deployment/) | Docker, VPS, production setup |
|
||||
| [Architecture](https://archon.diy/reference/architecture/) | System design and internals |
|
||||
| [Troubleshooting](https://archon.diy/reference/troubleshooting/) | Common issues and fixes |
|
||||
|
||||
## Telemetry
|
||||
|
||||
Archon sends a single anonymous event — `workflow_invoked` — each time a workflow starts, so maintainers can see which workflows get real usage and prioritize accordingly. **No PII, ever.**
|
||||
|
||||
**What's collected:** the workflow name, the workflow description (both authored by you in YAML), the platform that triggered it (`cli`, `web`, `slack`, etc.), the Archon version, and a random install UUID stored at `~/.archon/telemetry-id`. Nothing else.
|
||||
|
||||
**What's *not* collected:** your code, prompts, messages, git remotes, file paths, usernames, tokens, AI output, workflow node details — none of it.
|
||||
|
||||
**Opt out:** set any of these in your environment:
|
||||
|
||||
```bash
|
||||
ARCHON_TELEMETRY_DISABLED=1
|
||||
DO_NOT_TRACK=1 # de facto standard honored by Astro, Bun, Prisma, Nuxt, etc.
|
||||
```
|
||||
|
||||
Self-host PostHog or use a different project by setting `POSTHOG_API_KEY` and `POSTHOG_HOST`.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! See the open [issues](https://github.com/coleam00/Archon/issues) for things to work on.
|
||||
|
||||
Please read [CONTRIBUTING.md](CONTRIBUTING.md) before submitting a pull request.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/?repos=coleam00%2FArchon&type=date&legend=top-left)
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ TELEGRAM_BOT_TOKEN=123456789:ABC...
|
|||
# ============================================
|
||||
# Optional
|
||||
# ============================================
|
||||
PORT=3000
|
||||
PORT=3000 # Docker deployment default (the included compose/Caddy configs target :3000). For local dev (no Docker), omit PORT — server and Vite proxy both default to 3090.
|
||||
# TELEGRAM_STREAMING_MODE=stream
|
||||
# DISCORD_STREAMING_MODE=batch
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Ensure required subdirectories exist.
|
||||
# Named volumes inherit these from the image layer on first run; bind mounts do not,
|
||||
# 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
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
# Running as root: fix volume permissions, then drop to appuser
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ export default tseslint.config(
|
|||
'worktrees/**',
|
||||
'.claude/worktrees/**',
|
||||
'.claude/skills/**',
|
||||
'**/*.generated.ts', // Auto-generated source files (content inlined via JSON.stringify)
|
||||
'**/*.js',
|
||||
'*.mjs',
|
||||
'**/*.test.ts',
|
||||
'**/src/test/**', // Test helper files (mock factories, fixtures)
|
||||
'*.d.ts', // Root-level declaration files (not in tsconfig project scope)
|
||||
'**/*.generated.d.ts', // Auto-generated declaration files (e.g. openapi-typescript output)
|
||||
'packages/web/vite.config.ts', // Vite config doesn't need type-checked linting
|
||||
|
|
@ -40,7 +42,7 @@ export default tseslint.config(
|
|||
|
||||
// Project-specific settings
|
||||
{
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'],
|
||||
files: ['packages/*/src/**/*.{ts,tsx}', 'scripts/**/*.ts'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
|
|
|
|||
|
|
@ -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.3.5"
|
||||
version "0.3.6"
|
||||
license "MIT"
|
||||
|
||||
on_macos do
|
||||
on_arm do
|
||||
url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-arm64"
|
||||
sha256 "2c2065e580a085baaea02504cb5451be3f68e0d9fdb13a364cd45194d5b22de1"
|
||||
sha256 "96b6dac50b046eece9eddbb988a0c39b4f9a0e2faac66e49b977ba6360069e86"
|
||||
end
|
||||
on_intel do
|
||||
url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-x64"
|
||||
sha256 "515aca3b2bc30d3b5d4dfb67c04648f70b66e8ed345ea6ab039e76e6578e82fe"
|
||||
sha256 "09f1dbe12417b4300b7b07b531eb7391a286305f8d4eafc11e7f61f5d26eb8eb"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
on_arm do
|
||||
url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-arm64"
|
||||
sha256 "96920d98ae0d4dc7ef78e6de4f9018a9ba2031b9c2b010fd5d748d9513c49f60"
|
||||
sha256 "80b06a6ff699ec57cd4a3e49cfe7b899a3e8212688d70285f5a887bf10086731"
|
||||
end
|
||||
on_intel do
|
||||
url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-x64"
|
||||
sha256 "80e7d115da424d5ee47b7db773382c9b8d0db728408f9815c05081872da6b74f"
|
||||
sha256 "09f5dac6db8037ed6f3e5b7e9c5eb8e37f19822a4ed2bf4cd7e654780f9d00de"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@
|
|||
"build": "bun --filter '*' build",
|
||||
"build:binaries": "bash scripts/build-binaries.sh",
|
||||
"build:checksums": "bash scripts/checksums.sh",
|
||||
"generate:bundled": "bun run scripts/generate-bundled-defaults.ts",
|
||||
"check:bundled": "bun run scripts/generate-bundled-defaults.ts --check",
|
||||
"test": "bun --filter '*' --parallel test",
|
||||
"test:watch": "bun --filter @archon/server test:watch",
|
||||
"type-check": "bun --filter '*' type-check",
|
||||
"type-check": "bun --filter '*' type-check && bun x tsc --noEmit -p scripts/tsconfig.json",
|
||||
"lint": "bun x eslint . --cache",
|
||||
"lint:fix": "bun x eslint . --cache --fix",
|
||||
"format": "bun x prettier --write .",
|
||||
|
|
@ -25,7 +27,7 @@
|
|||
"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 type-check && bun run lint --max-warnings 0 && bun run format:check && bun run test",
|
||||
"validate": "bun run check:bundled && bun run type-check && bun run lint --max-warnings 0 && bun run format:check && bun run test",
|
||||
"prepare": "husky",
|
||||
"setup-auth": "bun --filter @archon/server setup-auth"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
"@octokit/rest": "^22.0.0",
|
||||
"@slack/bolt": "^4.6.0",
|
||||
"discord.js": "^14.16.0",
|
||||
"telegraf": "^4.16.0",
|
||||
"grammy": "^1.36.0",
|
||||
"telegramify-markdown": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ describe('TelegramAdapter', () => {
|
|||
const adapter = new TelegramAdapter('fake-token-for-testing');
|
||||
const bot = adapter.getBot();
|
||||
expect(bot).toBeDefined();
|
||||
expect(bot.telegram).toBeDefined();
|
||||
expect(bot.api).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -64,9 +64,8 @@ describe('TelegramAdapter', () => {
|
|||
adapter = new TelegramAdapter('fake-token-for-testing');
|
||||
mockSendMessage = mock(() => Promise.resolve());
|
||||
// Override bot's sendMessage
|
||||
(
|
||||
adapter.getBot().telegram as unknown as { sendMessage: Mock<() => Promise<void>> }
|
||||
).sendMessage = mockSendMessage;
|
||||
(adapter.getBot().api as unknown as { sendMessage: Mock<() => Promise<void>> }).sendMessage =
|
||||
mockSendMessage;
|
||||
});
|
||||
|
||||
test('should send with MarkdownV2 parse_mode', async () => {
|
||||
|
|
@ -172,7 +171,7 @@ describe('TelegramAdapter', () => {
|
|||
const adapter = new TelegramAdapter('fake-token-for-testing');
|
||||
const ctx = {
|
||||
chat: { id: 12345 },
|
||||
} as unknown as import('telegraf').Context;
|
||||
} as unknown as import('grammy').Context;
|
||||
|
||||
expect(adapter.getConversationId(ctx)).toBe('12345');
|
||||
});
|
||||
|
|
@ -181,7 +180,7 @@ describe('TelegramAdapter', () => {
|
|||
const adapter = new TelegramAdapter('fake-token-for-testing');
|
||||
const ctx = {
|
||||
chat: { id: -987654321 },
|
||||
} as unknown as import('telegraf').Context;
|
||||
} as unknown as import('grammy').Context;
|
||||
|
||||
expect(adapter.getConversationId(ctx)).toBe('-987654321');
|
||||
});
|
||||
|
|
@ -190,7 +189,7 @@ describe('TelegramAdapter', () => {
|
|||
const adapter = new TelegramAdapter('fake-token-for-testing');
|
||||
const ctx = {
|
||||
chat: { id: -1001234567890 },
|
||||
} as unknown as import('telegraf').Context;
|
||||
} as unknown as import('grammy').Context;
|
||||
|
||||
expect(adapter.getConversationId(ctx)).toBe('-1001234567890');
|
||||
});
|
||||
|
|
@ -199,7 +198,7 @@ describe('TelegramAdapter', () => {
|
|||
const adapter = new TelegramAdapter('fake-token-for-testing');
|
||||
const ctx = {
|
||||
chat: undefined,
|
||||
} as unknown as import('telegraf').Context;
|
||||
} as unknown as import('grammy').Context;
|
||||
|
||||
expect(() => adapter.getConversationId(ctx)).toThrow('No chat in context');
|
||||
});
|
||||
|
|
@ -208,7 +207,7 @@ describe('TelegramAdapter', () => {
|
|||
const adapter = new TelegramAdapter('fake-token-for-testing');
|
||||
const ctx = {
|
||||
chat: null,
|
||||
} as unknown as import('telegraf').Context;
|
||||
} as unknown as import('grammy').Context;
|
||||
|
||||
expect(() => adapter.getConversationId(ctx)).toThrow('No chat in context');
|
||||
});
|
||||
|
|
@ -235,6 +234,16 @@ describe('TelegramAdapter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('stop()', () => {
|
||||
test('should call bot.stop()', () => {
|
||||
const adapter = new TelegramAdapter('fake-token-for-testing');
|
||||
const mockStop = mock(() => undefined);
|
||||
(adapter.getBot() as unknown as { stop: typeof mockStop }).stop = mockStop;
|
||||
adapter.stop();
|
||||
expect(mockStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start()', () => {
|
||||
beforeEach(() => {
|
||||
mockLogger.warn.mockClear();
|
||||
|
|
@ -243,14 +252,20 @@ describe('TelegramAdapter', () => {
|
|||
|
||||
test('should retry on 409 and succeed on second attempt', async () => {
|
||||
const adapter = new TelegramAdapter('fake-token-for-testing');
|
||||
const mockLaunch = mock<() => Promise<void>>()
|
||||
// grammY's start() resolves when bot stops, not when started — onStart fires on startup
|
||||
const mockStart = mock<
|
||||
(opts?: { drop_pending_updates?: boolean; onStart?: () => void }) => Promise<void>
|
||||
>()
|
||||
.mockRejectedValueOnce(new Error('409: Conflict: terminated by other getUpdates request'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
(adapter.getBot() as unknown as { launch: typeof mockLaunch }).launch = mockLaunch;
|
||||
.mockImplementationOnce(opts => {
|
||||
opts?.onStart?.();
|
||||
return new Promise(() => {});
|
||||
});
|
||||
(adapter.getBot() as unknown as { start: typeof mockStart }).start = mockStart;
|
||||
|
||||
await adapter.start({ retryDelayMs: 0 });
|
||||
|
||||
expect(mockLaunch).toHaveBeenCalledTimes(2);
|
||||
expect(mockStart).toHaveBeenCalledTimes(2);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ attempt: 1, maxAttempts: 3 }),
|
||||
'telegram.start_conflict_retrying'
|
||||
|
|
@ -260,41 +275,48 @@ describe('TelegramAdapter', () => {
|
|||
|
||||
test('should throw immediately on non-409 error', async () => {
|
||||
const adapter = new TelegramAdapter('fake-token-for-testing');
|
||||
const mockLaunch = mock<() => Promise<void>>().mockRejectedValueOnce(
|
||||
new Error('401: Unauthorized')
|
||||
);
|
||||
(adapter.getBot() as unknown as { launch: typeof mockLaunch }).launch = mockLaunch;
|
||||
const mockStart = mock<
|
||||
(opts?: { drop_pending_updates?: boolean; onStart?: () => void }) => Promise<void>
|
||||
>().mockRejectedValueOnce(new Error('401: Unauthorized'));
|
||||
(adapter.getBot() as unknown as { start: typeof mockStart }).start = mockStart;
|
||||
|
||||
await expect(adapter.start({ retryDelayMs: 0 })).rejects.toThrow('401: Unauthorized');
|
||||
expect(mockLaunch).toHaveBeenCalledTimes(1);
|
||||
expect(mockStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should retry twice on 409 and succeed on third attempt', async () => {
|
||||
const adapter = new TelegramAdapter('fake-token-for-testing');
|
||||
const conflictError = new Error('409: Conflict: terminated by other getUpdates request');
|
||||
const mockLaunch = mock<() => Promise<void>>()
|
||||
const mockStart = mock<
|
||||
(opts?: { drop_pending_updates?: boolean; onStart?: () => void }) => Promise<void>
|
||||
>()
|
||||
.mockRejectedValueOnce(conflictError)
|
||||
.mockRejectedValueOnce(conflictError)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
(adapter.getBot() as unknown as { launch: typeof mockLaunch }).launch = mockLaunch;
|
||||
.mockImplementationOnce(opts => {
|
||||
opts?.onStart?.();
|
||||
return new Promise(() => {});
|
||||
});
|
||||
(adapter.getBot() as unknown as { start: typeof mockStart }).start = mockStart;
|
||||
|
||||
await adapter.start({ retryDelayMs: 0 });
|
||||
|
||||
expect(mockLaunch).toHaveBeenCalledTimes(3);
|
||||
expect(mockStart).toHaveBeenCalledTimes(3);
|
||||
expect(mockLogger.warn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should throw after exhausting all 409 retry attempts', async () => {
|
||||
const adapter = new TelegramAdapter('fake-token-for-testing');
|
||||
const conflictError = new Error('409: Conflict: terminated by other getUpdates request');
|
||||
const mockLaunch = mock<() => Promise<void>>()
|
||||
const mockStart = mock<
|
||||
(opts?: { drop_pending_updates?: boolean; onStart?: () => void }) => Promise<void>
|
||||
>()
|
||||
.mockRejectedValueOnce(conflictError)
|
||||
.mockRejectedValueOnce(conflictError)
|
||||
.mockRejectedValueOnce(conflictError);
|
||||
(adapter.getBot() as unknown as { launch: typeof mockLaunch }).launch = mockLaunch;
|
||||
(adapter.getBot() as unknown as { start: typeof mockStart }).start = mockStart;
|
||||
|
||||
await expect(adapter.start({ retryDelayMs: 0 })).rejects.toThrow('409');
|
||||
expect(mockLaunch).toHaveBeenCalledTimes(3);
|
||||
expect(mockStart).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Telegram platform adapter using Telegraf SDK
|
||||
* Telegram platform adapter using grammY SDK
|
||||
* Handles message sending with 4096 character limit splitting
|
||||
*/
|
||||
import { Telegraf, Context } from 'telegraf';
|
||||
import { Bot, Context } from 'grammy';
|
||||
import type { IPlatformAdapter, MessageMetadata } from '@archon/core';
|
||||
import { createLogger } from '@archon/paths';
|
||||
import { parseAllowedUserIds, isUserAuthorized } from './auth';
|
||||
|
|
@ -20,17 +20,14 @@ function getLog(): ReturnType<typeof createLogger> {
|
|||
const MAX_LENGTH = 4096;
|
||||
|
||||
export class TelegramAdapter implements IPlatformAdapter {
|
||||
private bot: Telegraf;
|
||||
private bot: Bot;
|
||||
private streamingMode: 'stream' | 'batch';
|
||||
private allowedUserIds: number[];
|
||||
private messageHandler: ((ctx: TelegramMessageContext) => Promise<void>) | null = null;
|
||||
|
||||
constructor(token: string, mode: 'stream' | 'batch' = 'stream') {
|
||||
// Disable handler timeout to support long-running AI operations
|
||||
// Default is 90 seconds which is too short for complex coding tasks
|
||||
this.bot = new Telegraf(token, {
|
||||
handlerTimeout: Infinity,
|
||||
});
|
||||
// grammY does not impose a handler timeout by default (unlike Telegraf's 90s limit)
|
||||
this.bot = new Bot(token);
|
||||
this.streamingMode = mode;
|
||||
|
||||
// Parse Telegram user whitelist (optional - empty = open access)
|
||||
|
|
@ -87,20 +84,20 @@ export class TelegramAdapter implements IPlatformAdapter {
|
|||
let subChunk = '';
|
||||
for (const line of lines) {
|
||||
if (subChunk.length + line.length + 1 > MAX_LENGTH - 100) {
|
||||
if (subChunk) await this.bot.telegram.sendMessage(id, subChunk);
|
||||
if (subChunk) await this.bot.api.sendMessage(id, subChunk);
|
||||
subChunk = line;
|
||||
} else {
|
||||
subChunk += (subChunk ? '\n' : '') + line;
|
||||
}
|
||||
}
|
||||
if (subChunk) await this.bot.telegram.sendMessage(id, subChunk);
|
||||
if (subChunk) await this.bot.api.sendMessage(id, subChunk);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try MarkdownV2 formatting
|
||||
const formatted = convertToTelegramMarkdown(chunk);
|
||||
try {
|
||||
await this.bot.telegram.sendMessage(id, formatted, { parse_mode: 'MarkdownV2' });
|
||||
await this.bot.api.sendMessage(id, formatted, { parse_mode: 'MarkdownV2' });
|
||||
getLog().debug({ chunkLength: chunk.length }, 'telegram.markdownv2_chunk_sent');
|
||||
} catch (error) {
|
||||
// Fallback to stripped plain text for this chunk
|
||||
|
|
@ -113,14 +110,14 @@ export class TelegramAdapter implements IPlatformAdapter {
|
|||
},
|
||||
'telegram.markdownv2_failed'
|
||||
);
|
||||
await this.bot.telegram.sendMessage(id, stripMarkdown(chunk));
|
||||
await this.bot.api.sendMessage(id, stripMarkdown(chunk));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Telegraf bot instance
|
||||
* Get the grammY bot instance
|
||||
*/
|
||||
getBot(): Telegraf {
|
||||
getBot(): Bot {
|
||||
return this.bot;
|
||||
}
|
||||
|
||||
|
|
@ -171,17 +168,15 @@ export class TelegramAdapter implements IPlatformAdapter {
|
|||
*/
|
||||
async start(options?: { retryDelayMs?: number }): Promise<void> {
|
||||
// Register message handler before launch
|
||||
this.bot.on('message', ctx => {
|
||||
if (!('text' in ctx.message)) return;
|
||||
|
||||
this.bot.on('message:text', ctx => {
|
||||
const message = ctx.message.text;
|
||||
if (!message) return;
|
||||
|
||||
// Authorization check - verify sender is in whitelist
|
||||
const userId = ctx.from.id;
|
||||
const userId = ctx.from?.id;
|
||||
if (!isUserAuthorized(userId, this.allowedUserIds)) {
|
||||
// Log unauthorized attempt (mask user ID for privacy)
|
||||
const maskedId = `${String(userId).slice(0, 4)}***`;
|
||||
const maskedId = userId !== undefined ? `${String(userId).slice(0, 4)}***` : 'unknown';
|
||||
getLog().info({ maskedUserId: maskedId }, 'telegram.unauthorized_message');
|
||||
return; // Silent rejection
|
||||
}
|
||||
|
|
@ -190,6 +185,11 @@ export class TelegramAdapter implements IPlatformAdapter {
|
|||
const conversationId = this.getConversationId(ctx);
|
||||
// Fire-and-forget - errors handled by caller
|
||||
void this.messageHandler({ conversationId, message, userId });
|
||||
} else {
|
||||
// Intentional: message dropped silently if handler not registered yet.
|
||||
// In production the server always calls onMessage() before start(); this
|
||||
// path only surfaces during development or misconfiguration.
|
||||
getLog().debug({ chatId: ctx.chat?.id }, 'telegram.message_dropped_no_handler');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -200,9 +200,26 @@ export class TelegramAdapter implements IPlatformAdapter {
|
|||
const RETRY_DELAY_MS = options?.retryDelayMs ?? 60_000;
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
// dropPendingUpdates: true — discard queued messages from while the bot was offline
|
||||
// drop_pending_updates: true — discard queued messages from while the bot was offline
|
||||
// to avoid reprocessing stale commands after a container restart.
|
||||
await this.bot.launch({ dropPendingUpdates: true });
|
||||
// grammY's start() resolves only when the bot stops; use onStart callback to detect
|
||||
// successful launch and return immediately while the bot continues running in background.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.bot
|
||||
.start({
|
||||
drop_pending_updates: true,
|
||||
onStart: () => {
|
||||
resolve();
|
||||
},
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
// Log post-startup crashes — after onStart fires the reject() below is a no-op
|
||||
// (Promise already settled), but the error should still be observable in logs.
|
||||
getLog().error({ err: error }, 'telegram.bot_runtime_error');
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
getLog().info('telegram.bot_started');
|
||||
return;
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"@archon/git": "workspace:*",
|
||||
"@archon/isolation": "workspace:*",
|
||||
"@archon/paths": "workspace:*",
|
||||
"@archon/providers": "workspace:*",
|
||||
"@archon/server": "workspace:*",
|
||||
"@archon/workflows": "workspace:*",
|
||||
"@clack/prompts": "^1.0.0",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ describe('CLI argument parsing', () => {
|
|||
spawn: { type: 'boolean' },
|
||||
quiet: { type: 'boolean', short: 'q' },
|
||||
verbose: { type: 'boolean', short: 'v' },
|
||||
scope: { type: 'string' },
|
||||
force: { type: 'boolean' },
|
||||
},
|
||||
allowPositionals: true,
|
||||
strict: false,
|
||||
|
|
@ -165,6 +167,35 @@ describe('CLI argument parsing', () => {
|
|||
expect(result.positionals).toContain('/path'); // /path becomes positional
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup --scope and --force flags (#1303)', () => {
|
||||
it('parses --scope home', () => {
|
||||
const result = parseCliArgs(['setup', '--scope', 'home']);
|
||||
expect(result.values.scope).toBe('home');
|
||||
});
|
||||
|
||||
it('parses --scope project', () => {
|
||||
const result = parseCliArgs(['setup', '--scope', 'project']);
|
||||
expect(result.values.scope).toBe('project');
|
||||
});
|
||||
|
||||
it('defaults --scope to undefined when not provided', () => {
|
||||
const result = parseCliArgs(['setup']);
|
||||
expect(result.values.scope).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses --force as boolean', () => {
|
||||
const result = parseCliArgs(['setup', '--force']);
|
||||
expect(result.values.force).toBe(true);
|
||||
});
|
||||
|
||||
it('captures an invalid --scope value verbatim for caller validation', () => {
|
||||
// parseArgs itself does not validate the enum; cli.ts validates and
|
||||
// exits on unknown scope values. The test documents the contract.
|
||||
const result = parseCliArgs(['setup', '--scope', 'nonsense']);
|
||||
expect(result.values.scope).toBe('nonsense');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conversation ID generation', () => {
|
||||
|
|
|
|||
|
|
@ -10,26 +10,16 @@
|
|||
// Must be the very first import — strips Bun-auto-loaded CWD .env keys before
|
||||
// any module reads process.env at init time (e.g. @archon/paths/logger reads LOG_LEVEL).
|
||||
import '@archon/paths/strip-cwd-env-boot';
|
||||
// Then load archon-owned env from ~/.archon/.env (user scope) and
|
||||
// <cwd>/.archon/.env (repo scope, wins over user). Both with override: true.
|
||||
// See packages/paths/src/env-loader.ts and the three-path model (#1302 / #1303).
|
||||
import { loadArchonEnv } from '@archon/paths/env-loader';
|
||||
loadArchonEnv(process.cwd());
|
||||
|
||||
import { parseArgs } from 'util';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
// Load ~/.archon/.env with override: true — Archon-specific config must win
|
||||
// over shell-inherited env vars (e.g. PORT, LOG_LEVEL from shell profile).
|
||||
// CWD .env keys are already gone (stripCwdEnv above), so override only
|
||||
// affects shell-inherited values, which is the intended behavior.
|
||||
const globalEnvPath = resolve(process.env.HOME ?? '~', '.archon', '.env');
|
||||
if (existsSync(globalEnvPath)) {
|
||||
const result = config({ path: globalEnvPath, override: true });
|
||||
if (result.error) {
|
||||
// Logger may not be available yet (early startup), so use console for user-facing error
|
||||
console.error(`Error loading .env from ${globalEnvPath}: ${result.error.message}`);
|
||||
console.error('Hint: Check for syntax errors in your .env file.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// CLAUDECODE=1 warning is emitted inside stripCwdEnv() (boot import above)
|
||||
// BEFORE the marker is deleted from process.env. No duplicate warning here.
|
||||
|
||||
|
|
@ -43,6 +33,11 @@ if (!process.env.CLAUDE_API_KEY && !process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|||
|
||||
// DATABASE_URL is no longer required - SQLite will be used as default
|
||||
|
||||
// Bootstrap provider registry before any provider lookups
|
||||
import { registerBuiltinProviders, registerCommunityProviders } from '@archon/providers';
|
||||
registerBuiltinProviders();
|
||||
registerCommunityProviders();
|
||||
|
||||
// Import commands after dotenv is loaded
|
||||
import { versionCommand } from './commands/version';
|
||||
import {
|
||||
|
|
@ -76,6 +71,7 @@ import {
|
|||
checkForUpdate,
|
||||
BUNDLED_IS_BINARY,
|
||||
BUNDLED_VERSION,
|
||||
shutdownTelemetry,
|
||||
} from '@archon/paths';
|
||||
import * as git from '@archon/git';
|
||||
|
||||
|
|
@ -125,9 +121,6 @@ Options:
|
|||
--json Output machine-readable JSON (for workflow list)
|
||||
--workflow <name> Workflow to run for 'continue' (default: archon-assist)
|
||||
--no-context Skip context injection for 'continue'
|
||||
--allow-env-keys Grant env-key consent during auto-registration
|
||||
(bypasses the env-leak gate for this codebase;
|
||||
logs an audit entry)
|
||||
--port <port> Override server port for 'serve' (default: 3090)
|
||||
--download-only Download web UI without starting the server
|
||||
|
||||
|
|
@ -207,9 +200,10 @@ async function main(): Promise<number> {
|
|||
reason: { type: 'string' },
|
||||
workflow: { type: 'string' },
|
||||
'no-context': { type: 'boolean' },
|
||||
'allow-env-keys': { type: 'boolean' },
|
||||
port: { type: 'string' },
|
||||
'download-only': { type: 'boolean' },
|
||||
scope: { type: 'string' },
|
||||
force: { type: 'boolean' },
|
||||
},
|
||||
allowPositionals: true,
|
||||
strict: false, // Allow unknown flags to pass through
|
||||
|
|
@ -231,8 +225,6 @@ async function main(): Promise<number> {
|
|||
const resumeFlag = values.resume as boolean | undefined;
|
||||
const spawnFlag = values.spawn as boolean | undefined;
|
||||
const jsonFlag = values.json as boolean | undefined;
|
||||
const allowEnvKeysFlag = values['allow-env-keys'] as boolean | undefined;
|
||||
|
||||
// Handle help flag
|
||||
if (values.help) {
|
||||
printUsage();
|
||||
|
|
@ -298,9 +290,30 @@ async function main(): Promise<number> {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'setup':
|
||||
await setupCommand({ spawn: spawnFlag, repoPath: cwd });
|
||||
case 'setup': {
|
||||
const rawScope = values.scope as string | undefined;
|
||||
if (rawScope !== undefined && rawScope !== 'home' && rawScope !== 'project') {
|
||||
console.error(`Error: Invalid --scope: "${rawScope}". Must be "home" or "project".`);
|
||||
return 1;
|
||||
}
|
||||
const scope: 'home' | 'project' = rawScope ?? 'home';
|
||||
const forceFlag = (values.force as boolean | undefined) ?? false;
|
||||
// For --scope project, resolve to the git repo root so running from a
|
||||
// subdirectory writes to <repo-root>/.archon/.env (what loadArchonEnv
|
||||
// reads at boot) — not <subdir>/.archon/.env.
|
||||
let repoPath = cwd;
|
||||
if (scope === 'project') {
|
||||
const repoRoot = await git.findRepoRoot(cwd);
|
||||
if (!repoRoot) {
|
||||
console.error('Error: --scope project requires running from inside a git repository.');
|
||||
console.error('Run from the repo root, pass --cwd <repo>, or use --scope home.');
|
||||
return 1;
|
||||
}
|
||||
repoPath = repoRoot;
|
||||
}
|
||||
await setupCommand({ spawn: spawnFlag, repoPath, scope, force: forceFlag });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow':
|
||||
switch (subcommand) {
|
||||
|
|
@ -344,7 +357,6 @@ async function main(): Promise<number> {
|
|||
fromBranch,
|
||||
noWorktree,
|
||||
resume: resumeFlag,
|
||||
allowEnvKeys: allowEnvKeysFlag,
|
||||
quiet: values.quiet as boolean | undefined,
|
||||
verbose: values.verbose as boolean | undefined,
|
||||
};
|
||||
|
|
@ -576,6 +588,9 @@ async function main(): Promise<number> {
|
|||
}
|
||||
return 1;
|
||||
} finally {
|
||||
// Flush queued telemetry events before the CLI process exits.
|
||||
// Short-lived CLI commands lose buffered events if shutdown() is skipped.
|
||||
await shutdownTelemetry();
|
||||
// Always close database connection
|
||||
await closeDb();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ mock.module('@archon/core/db/workflows', () => ({
|
|||
getActiveWorkflowRunByPath: mockGetActiveWorkflowRunByPath,
|
||||
}));
|
||||
|
||||
const mockRemoveEnvironment = mock(() => Promise.resolve());
|
||||
const mockRemoveEnvironment = mock(() =>
|
||||
Promise.resolve({ worktreeRemoved: true, branchDeleted: true, warnings: [] })
|
||||
);
|
||||
const mockCleanupMergedWorktrees = mock(() => Promise.resolve({ removed: [], skipped: [] }));
|
||||
|
||||
mock.module('@archon/core/services/cleanup-service', () => ({
|
||||
|
|
@ -136,7 +138,11 @@ describe('isolationCompleteCommand', () => {
|
|||
|
||||
it('completes a branch when env is found and all checks pass', async () => {
|
||||
mockFindActiveByBranchName.mockResolvedValueOnce(mockEnv);
|
||||
mockRemoveEnvironment.mockResolvedValueOnce(undefined);
|
||||
mockRemoveEnvironment.mockResolvedValueOnce({
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await isolationCompleteCommand(['feature-branch'], { force: false, deleteRemote: true });
|
||||
|
||||
|
|
@ -309,7 +315,11 @@ describe('isolationCompleteCommand', () => {
|
|||
|
||||
it('skips PR check with warning when gh CLI is not available', async () => {
|
||||
mockFindActiveByBranchName.mockResolvedValueOnce(mockEnv);
|
||||
mockRemoveEnvironment.mockResolvedValueOnce(undefined);
|
||||
mockRemoveEnvironment.mockResolvedValueOnce({
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
warnings: [],
|
||||
});
|
||||
mockExecFileAsync.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'gh') {
|
||||
const err = Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' });
|
||||
|
|
@ -335,7 +345,11 @@ describe('isolationCompleteCommand', () => {
|
|||
id: 'run-abc',
|
||||
workflow_name: 'implement',
|
||||
});
|
||||
mockRemoveEnvironment.mockResolvedValueOnce(undefined);
|
||||
mockRemoveEnvironment.mockResolvedValueOnce({
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await isolationCompleteCommand(['dirty-branch'], { force: true, deleteRemote: true });
|
||||
|
||||
|
|
@ -368,7 +382,7 @@ describe('isolationCompleteCommand', () => {
|
|||
.mockResolvedValueOnce(null) // not found: branch-2
|
||||
.mockResolvedValueOnce(mockEnv); // found: branch-3 (will fail)
|
||||
mockRemoveEnvironment
|
||||
.mockResolvedValueOnce(undefined) // branch-1 succeeds
|
||||
.mockResolvedValueOnce({ worktreeRemoved: true, branchDeleted: true, warnings: [] }) // branch-1 succeeds
|
||||
.mockRejectedValueOnce(new Error('some error')); // branch-3 fails
|
||||
|
||||
await isolationCompleteCommand(['branch-1', 'branch-2', 'branch-3'], {
|
||||
|
|
@ -378,6 +392,59 @@ describe('isolationCompleteCommand', () => {
|
|||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('\nComplete: 1 completed, 1 failed, 1 not found');
|
||||
});
|
||||
it('counts as failed when removeEnvironment returns skippedReason (ghost worktree)', async () => {
|
||||
mockFindActiveByBranchName.mockResolvedValueOnce(mockEnv);
|
||||
mockRemoveEnvironment.mockResolvedValueOnce({
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
skippedReason: 'has uncommitted changes',
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await isolationCompleteCommand(['ghost-branch'], { force: true, deleteRemote: true });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
' Blocked: ghost-branch — has uncommitted changes'
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(' Use --force to override.');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('\nComplete: 0 completed, 1 failed, 0 not found');
|
||||
});
|
||||
|
||||
it('counts as failed when removeEnvironment returns partial (worktree not removed, branch deleted)', async () => {
|
||||
mockFindActiveByBranchName.mockResolvedValueOnce(mockEnv);
|
||||
mockRemoveEnvironment.mockResolvedValueOnce({
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: true,
|
||||
warnings: ['Some warning'],
|
||||
skippedReason: undefined,
|
||||
});
|
||||
|
||||
await isolationCompleteCommand(['partial-branch'], { force: true, deleteRemote: true });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
' Partial: partial-branch — worktree was not removed from disk (branch deleted, DB updated)'
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(' ⚠ Some warning');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('\nComplete: 0 completed, 1 failed, 0 not found');
|
||||
});
|
||||
|
||||
it('surfaces warnings from removeEnvironment result', async () => {
|
||||
mockFindActiveByBranchName.mockResolvedValueOnce(mockEnv);
|
||||
mockRemoveEnvironment.mockResolvedValueOnce({
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: false,
|
||||
warnings: ["Cannot delete branch 'feature-branch': checked out elsewhere"],
|
||||
});
|
||||
|
||||
await isolationCompleteCommand(['feature-branch'], { force: true, deleteRemote: true });
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
" Warning: Cannot delete branch 'feature-branch': checked out elsewhere"
|
||||
);
|
||||
// Should still count as completed since worktree was removed
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(' Completed: feature-branch');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('\nComplete: 1 completed, 0 failed, 0 not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isolationCleanupMergedCommand', () => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ import {
|
|||
getDefaultBranch,
|
||||
} from '@archon/git';
|
||||
import { getIsolationProvider } from '@archon/isolation';
|
||||
import { removeEnvironment } from '@archon/core/services/cleanup-service';
|
||||
import {
|
||||
removeEnvironment,
|
||||
type RemoveEnvironmentResult,
|
||||
} from '@archon/core/services/cleanup-service';
|
||||
import {
|
||||
listEnvironments,
|
||||
cleanupMergedEnvironments,
|
||||
|
|
@ -298,12 +301,37 @@ export async function isolationCompleteCommand(
|
|||
}
|
||||
|
||||
try {
|
||||
await removeEnvironment(env.id, {
|
||||
const result: RemoveEnvironmentResult = await removeEnvironment(env.id, {
|
||||
force: options.force,
|
||||
deleteRemoteBranch: options.deleteRemote ?? true,
|
||||
});
|
||||
console.log(` Completed: ${branch}`);
|
||||
completed++;
|
||||
|
||||
// Surface warnings from partial cleanup
|
||||
for (const warning of result.warnings) {
|
||||
console.warn(` Warning: ${warning}`);
|
||||
}
|
||||
|
||||
if (result.skippedReason) {
|
||||
console.error(` Blocked: ${branch} — ${result.skippedReason}`);
|
||||
if (result.skippedReason === 'has uncommitted changes') {
|
||||
console.error(' Use --force to override.');
|
||||
}
|
||||
failed++;
|
||||
} else if (!result.worktreeRemoved) {
|
||||
const parts: string[] = [];
|
||||
if (result.branchDeleted) parts.push('branch deleted');
|
||||
parts.push('DB updated');
|
||||
console.error(
|
||||
` Partial: ${branch} — worktree was not removed from disk (${parts.join(', ')})`
|
||||
);
|
||||
for (const warning of result.warnings) {
|
||||
console.error(` ⚠ ${warning}`);
|
||||
}
|
||||
failed++;
|
||||
} else {
|
||||
console.log(` Completed: ${branch}`);
|
||||
completed++;
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
getLog().warn({ err, branch, envId: env.id }, 'isolation.complete_failed');
|
||||
|
|
|
|||
|
|
@ -85,29 +85,33 @@ async function downloadWebDist(version: string, targetDir: string): Promise<void
|
|||
log.info({ version, targetDir }, 'web_dist.download_started');
|
||||
console.log(`Web UI not found locally — downloading from release v${version}...`);
|
||||
|
||||
// Download checksums
|
||||
const checksumsRes = await fetch(checksumsUrl).catch((err: unknown) => {
|
||||
throw new Error(
|
||||
`Network error fetching checksums from ${checksumsUrl}: ${(err as Error).message}`
|
||||
);
|
||||
});
|
||||
// Download checksums and tarball in parallel
|
||||
console.log(`Downloading ${tarballUrl}...`);
|
||||
const [checksumsRes, tarballRes] = await Promise.all([
|
||||
fetch(checksumsUrl).catch((err: unknown) => {
|
||||
throw new Error(
|
||||
`Network error fetching checksums from ${checksumsUrl}: ${(err as Error).message}`
|
||||
);
|
||||
}),
|
||||
fetch(tarballUrl).catch((err: unknown) => {
|
||||
throw new Error(
|
||||
`Network error fetching tarball from ${tarballUrl}: ${(err as Error).message}`
|
||||
);
|
||||
}),
|
||||
]);
|
||||
if (!checksumsRes.ok) {
|
||||
throw new Error(
|
||||
`Failed to download checksums: ${checksumsRes.status} ${checksumsRes.statusText}`
|
||||
);
|
||||
}
|
||||
const checksumsText = await checksumsRes.text();
|
||||
const expectedHash = parseChecksum(checksumsText, 'archon-web.tar.gz');
|
||||
|
||||
// Download tarball
|
||||
console.log(`Downloading ${tarballUrl}...`);
|
||||
const tarballRes = await fetch(tarballUrl).catch((err: unknown) => {
|
||||
throw new Error(`Network error fetching tarball from ${tarballUrl}: ${(err as Error).message}`);
|
||||
});
|
||||
if (!tarballRes.ok) {
|
||||
throw new Error(`Failed to download web UI: ${tarballRes.status} ${tarballRes.statusText}`);
|
||||
}
|
||||
const tarballBuffer = await tarballRes.arrayBuffer();
|
||||
const [checksumsText, tarballBuffer] = await Promise.all([
|
||||
checksumsRes.text(),
|
||||
tarballRes.arrayBuffer(),
|
||||
]);
|
||||
const expectedHash = parseChecksum(checksumsText, 'archon-web.tar.gz');
|
||||
|
||||
// Verify checksum
|
||||
const hasher = new Bun.CryptoHasher('sha256');
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@ import {
|
|||
generateWebhookSecret,
|
||||
spawnTerminalWithSetup,
|
||||
copyArchonSkill,
|
||||
detectClaudeExecutablePath,
|
||||
writeScopedEnv,
|
||||
serializeEnv,
|
||||
resolveScopedEnvPath,
|
||||
} from './setup';
|
||||
import * as setupModule from './setup';
|
||||
import { parse as parseDotenv } from 'dotenv';
|
||||
|
||||
// Test directory for file operations
|
||||
const TEST_DIR = join(tmpdir(), 'archon-setup-test-' + Date.now());
|
||||
|
|
@ -148,7 +154,9 @@ CODEX_ACCOUNT_ID=account1
|
|||
expect(content).toContain('# Using SQLite (default)');
|
||||
expect(content).toContain('CLAUDE_USE_GLOBAL_AUTH=true');
|
||||
expect(content).toContain('DEFAULT_AI_ASSISTANT=claude');
|
||||
expect(content).toContain('PORT=3000');
|
||||
// PORT is intentionally commented out — server and Vite both default to 3090 when unset (#1152).
|
||||
expect(content).toContain('# PORT=3090');
|
||||
expect(content).not.toMatch(/^PORT=/m);
|
||||
expect(content).not.toContain('DATABASE_URL=');
|
||||
});
|
||||
|
||||
|
|
@ -176,6 +184,41 @@ CODEX_ACCOUNT_ID=account1
|
|||
expect(content).toContain('CLAUDE_API_KEY=sk-test-key');
|
||||
});
|
||||
|
||||
it('emits CLAUDE_BIN_PATH when claudeBinaryPath is configured', () => {
|
||||
const content = generateEnvContent({
|
||||
database: { type: 'sqlite' },
|
||||
ai: {
|
||||
claude: true,
|
||||
claudeAuthType: 'global',
|
||||
claudeBinaryPath: '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js',
|
||||
codex: false,
|
||||
defaultAssistant: 'claude',
|
||||
},
|
||||
platforms: { github: false, telegram: false, slack: false, discord: false },
|
||||
botDisplayName: 'Archon',
|
||||
});
|
||||
|
||||
expect(content).toContain(
|
||||
'CLAUDE_BIN_PATH=/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js'
|
||||
);
|
||||
});
|
||||
|
||||
it('omits CLAUDE_BIN_PATH when not configured', () => {
|
||||
const content = generateEnvContent({
|
||||
database: { type: 'sqlite' },
|
||||
ai: {
|
||||
claude: true,
|
||||
claudeAuthType: 'global',
|
||||
codex: false,
|
||||
defaultAssistant: 'claude',
|
||||
},
|
||||
platforms: { github: false, telegram: false, slack: false, discord: false },
|
||||
botDisplayName: 'Archon',
|
||||
});
|
||||
|
||||
expect(content).not.toContain('CLAUDE_BIN_PATH=');
|
||||
});
|
||||
|
||||
it('should include platform configurations', () => {
|
||||
const content = generateEnvContent({
|
||||
database: { type: 'sqlite' },
|
||||
|
|
@ -418,3 +461,278 @@ CODEX_ACCOUNT_ID=account1
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectClaudeExecutablePath probe order', () => {
|
||||
// Use spies on the exported probe wrappers so each tier can be controlled
|
||||
// independently without touching the real filesystem or shell.
|
||||
let fileExistsSpy: ReturnType<typeof spyOn>;
|
||||
let npmRootSpy: ReturnType<typeof spyOn>;
|
||||
let whichSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fileExistsSpy = spyOn(setupModule, 'probeFileExists').mockReturnValue(false);
|
||||
npmRootSpy = spyOn(setupModule, 'probeNpmRoot').mockReturnValue(null);
|
||||
whichSpy = spyOn(setupModule, 'probeWhichClaude').mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fileExistsSpy.mockRestore();
|
||||
npmRootSpy.mockRestore();
|
||||
whichSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns the native installer path when present (tier 1 wins)', () => {
|
||||
// Native path exists; subsequent probes must not be called.
|
||||
fileExistsSpy.mockImplementation(
|
||||
(p: string) => p.includes('.local/bin/claude') || p.includes('.local\\bin\\claude')
|
||||
);
|
||||
const result = detectClaudeExecutablePath();
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toMatch(/\.local[\\/]bin[\\/]claude/);
|
||||
// Tier 2 / 3 must not have been consulted.
|
||||
expect(npmRootSpy).not.toHaveBeenCalled();
|
||||
expect(whichSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through to npm cli.js when native is missing (tier 2 wins)', () => {
|
||||
// Use path.join so the expected result matches whatever separator the
|
||||
// production code produces on the current platform (backslash on Windows,
|
||||
// forward slash elsewhere).
|
||||
const npmRoot = join('fake', 'npm', 'root');
|
||||
const expectedCliJs = join(npmRoot, '@anthropic-ai', 'claude-code', 'cli.js');
|
||||
npmRootSpy.mockReturnValue(npmRoot);
|
||||
fileExistsSpy.mockImplementation((p: string) => p === expectedCliJs);
|
||||
const result = detectClaudeExecutablePath();
|
||||
expect(result).toBe(expectedCliJs);
|
||||
// Tier 3 must not have been consulted.
|
||||
expect(whichSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through to which/where when native and npm probes both miss (tier 3 wins)', () => {
|
||||
npmRootSpy.mockReturnValue('/fake/npm/root');
|
||||
// Native miss, npm cli.js miss, but `which claude` returns a path that exists.
|
||||
whichSpy.mockReturnValue('/opt/homebrew/bin/claude');
|
||||
fileExistsSpy.mockImplementation((p: string) => p === '/opt/homebrew/bin/claude');
|
||||
const result = detectClaudeExecutablePath();
|
||||
expect(result).toBe('/opt/homebrew/bin/claude');
|
||||
});
|
||||
|
||||
it('returns null when every probe misses', () => {
|
||||
// All defaults already return false/null; nothing to override.
|
||||
expect(detectClaudeExecutablePath()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not return a which-resolved path that fails the existsSync check', () => {
|
||||
// `which` returns a path string but the file is not actually present
|
||||
// (stale PATH entry, dangling symlink, etc.) — must not be returned.
|
||||
npmRootSpy.mockReturnValue('/fake/npm/root');
|
||||
whichSpy.mockReturnValue('/stale/path/claude');
|
||||
fileExistsSpy.mockReturnValue(false);
|
||||
expect(detectClaudeExecutablePath()).toBeNull();
|
||||
});
|
||||
|
||||
it('skips npm tier when probeNpmRoot returns null (e.g. npm not installed)', () => {
|
||||
// npm probe fails; tier 3 must still run.
|
||||
whichSpy.mockReturnValue('/usr/local/bin/claude');
|
||||
fileExistsSpy.mockImplementation((p: string) => p === '/usr/local/bin/claude');
|
||||
const result = detectClaudeExecutablePath();
|
||||
expect(result).toBe('/usr/local/bin/claude');
|
||||
expect(npmRootSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for the three-path env write model (#1303).
|
||||
*
|
||||
* Invariants:
|
||||
* - <repo>/.env is NEVER written.
|
||||
* - Default write targets ~/.archon/.env (home scope) with merge preserving
|
||||
* existing non-empty values.
|
||||
* - --scope project writes to <repo>/.archon/.env.
|
||||
* - --force overwrites the target wholesale, still writes a backup.
|
||||
* - Merge preserves user-added keys not in the proposed content.
|
||||
*/
|
||||
describe('writeScopedEnv (#1303)', () => {
|
||||
const ROOT = join(tmpdir(), 'archon-write-scoped-env-test-' + Date.now());
|
||||
const HOME_DIR = join(ROOT, 'archon-home');
|
||||
const REPO_DIR = join(ROOT, 'repo');
|
||||
let originalArchonHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(HOME_DIR, { recursive: true });
|
||||
mkdirSync(REPO_DIR, { recursive: true });
|
||||
originalArchonHome = process.env.ARCHON_HOME;
|
||||
process.env.ARCHON_HOME = HOME_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalArchonHome === undefined) delete process.env.ARCHON_HOME;
|
||||
else process.env.ARCHON_HOME = originalArchonHome;
|
||||
rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fresh home scope writes content with no backup', () => {
|
||||
const result = writeScopedEnv('DATABASE_URL=sqlite:local\nPORT=3090\n', {
|
||||
scope: 'home',
|
||||
repoPath: REPO_DIR,
|
||||
force: false,
|
||||
});
|
||||
expect(result.targetPath).toBe(join(HOME_DIR, '.env'));
|
||||
expect(result.backupPath).toBeNull();
|
||||
expect(result.preservedKeys).toEqual([]);
|
||||
expect(readFileSync(result.targetPath, 'utf-8')).toContain('DATABASE_URL=sqlite:local');
|
||||
});
|
||||
|
||||
it('merge preserves user-added custom keys across re-runs', () => {
|
||||
// First write
|
||||
writeScopedEnv('DATABASE_URL=sqlite:local\n', {
|
||||
scope: 'home',
|
||||
repoPath: REPO_DIR,
|
||||
force: false,
|
||||
});
|
||||
// User adds a custom var
|
||||
const envPath = join(HOME_DIR, '.env');
|
||||
writeFileSync(envPath, readFileSync(envPath, 'utf-8') + 'MY_CUSTOM_SECRET=preserve-me\n');
|
||||
// Second setup run (proposes a different-shape config)
|
||||
const result = writeScopedEnv('DATABASE_URL=sqlite:local\nPORT=3090\n', {
|
||||
scope: 'home',
|
||||
repoPath: REPO_DIR,
|
||||
force: false,
|
||||
});
|
||||
const merged = parseDotenv(readFileSync(result.targetPath, 'utf-8'));
|
||||
expect(merged.MY_CUSTOM_SECRET).toBe('preserve-me');
|
||||
expect(merged.PORT).toBe('3090');
|
||||
expect(result.backupPath).not.toBeNull();
|
||||
});
|
||||
|
||||
it('merge preserves existing PostgreSQL DATABASE_URL when proposed is SQLite', () => {
|
||||
const envPath = join(HOME_DIR, '.env');
|
||||
writeFileSync(envPath, 'DATABASE_URL=postgresql://localhost:5432/mydb\n');
|
||||
const result = writeScopedEnv(
|
||||
'# Using SQLite (default) - no DATABASE_URL needed\nDATABASE_URL=\n',
|
||||
{ scope: 'home', repoPath: REPO_DIR, force: false }
|
||||
);
|
||||
const merged = parseDotenv(readFileSync(result.targetPath, 'utf-8'));
|
||||
expect(merged.DATABASE_URL).toBe('postgresql://localhost:5432/mydb');
|
||||
expect(result.preservedKeys).toContain('DATABASE_URL');
|
||||
});
|
||||
|
||||
it('merge preserves existing bot tokens', () => {
|
||||
const envPath = join(HOME_DIR, '.env');
|
||||
writeFileSync(
|
||||
envPath,
|
||||
'SLACK_BOT_TOKEN=xoxb-existing\nCLAUDE_CODE_OAUTH_TOKEN=sk-ant-existing\n'
|
||||
);
|
||||
// Proposed content has these keys with different/empty values
|
||||
writeScopedEnv('SLACK_BOT_TOKEN=xoxb-new-placeholder\nCLAUDE_CODE_OAUTH_TOKEN=\n', {
|
||||
scope: 'home',
|
||||
repoPath: REPO_DIR,
|
||||
force: false,
|
||||
});
|
||||
const merged = parseDotenv(readFileSync(join(HOME_DIR, '.env'), 'utf-8'));
|
||||
expect(merged.SLACK_BOT_TOKEN).toBe('xoxb-existing');
|
||||
expect(merged.CLAUDE_CODE_OAUTH_TOKEN).toBe('sk-ant-existing');
|
||||
});
|
||||
|
||||
it('--force overwrites wholesale but writes a timestamped backup', () => {
|
||||
const envPath = join(HOME_DIR, '.env');
|
||||
writeFileSync(envPath, 'OLD_KEY=old\nDATABASE_URL=postgresql://legacy\n');
|
||||
const result = writeScopedEnv('DATABASE_URL=sqlite:local\nNEW_KEY=new\n', {
|
||||
scope: 'home',
|
||||
repoPath: REPO_DIR,
|
||||
force: true,
|
||||
});
|
||||
expect(result.forced).toBe(true);
|
||||
expect(result.backupPath).not.toBeNull();
|
||||
expect(result.backupPath).toMatch(/\.archon-backup-\d{4}-\d{2}-\d{2}T/);
|
||||
// Backup has the old content
|
||||
expect(readFileSync(result.backupPath as string, 'utf-8')).toContain('OLD_KEY=old');
|
||||
// Target has the new content only — OLD_KEY is gone
|
||||
const newContent = readFileSync(result.targetPath, 'utf-8');
|
||||
expect(newContent).toContain('DATABASE_URL=sqlite:local');
|
||||
expect(newContent).toContain('NEW_KEY=new');
|
||||
expect(newContent).not.toContain('OLD_KEY');
|
||||
});
|
||||
|
||||
it('--force on a non-existent target writes cleanly with no backup', () => {
|
||||
const result = writeScopedEnv('PORT=3090\n', {
|
||||
scope: 'home',
|
||||
repoPath: REPO_DIR,
|
||||
force: true,
|
||||
});
|
||||
expect(result.backupPath).toBeNull();
|
||||
expect(result.forced).toBe(false); // no existing file means force was effectively a no-op
|
||||
});
|
||||
|
||||
it('--scope project writes to <repo>/.archon/.env, creating the directory', () => {
|
||||
expect(existsSync(join(REPO_DIR, '.archon'))).toBe(false);
|
||||
const result = writeScopedEnv('FOO=bar\n', {
|
||||
scope: 'project',
|
||||
repoPath: REPO_DIR,
|
||||
force: false,
|
||||
});
|
||||
expect(result.targetPath).toBe(join(REPO_DIR, '.archon', '.env'));
|
||||
expect(existsSync(result.targetPath)).toBe(true);
|
||||
expect(existsSync(join(HOME_DIR, '.env'))).toBe(false);
|
||||
});
|
||||
|
||||
it('<repo>/.env is never touched by writeScopedEnv in any scope/mode', () => {
|
||||
const repoEnvPath = join(REPO_DIR, '.env');
|
||||
const sentinel = 'USER_SECRET=do-not-touch\n';
|
||||
writeFileSync(repoEnvPath, sentinel);
|
||||
// Home scope, merge
|
||||
writeScopedEnv('FOO=bar\n', { scope: 'home', repoPath: REPO_DIR, force: false });
|
||||
// Home scope, force
|
||||
writeScopedEnv('FOO=baz\n', { scope: 'home', repoPath: REPO_DIR, force: true });
|
||||
// Project scope, merge
|
||||
writeScopedEnv('FOO=qux\n', { scope: 'project', repoPath: REPO_DIR, force: false });
|
||||
// Project scope, force
|
||||
writeScopedEnv('FOO=xyz\n', { scope: 'project', repoPath: REPO_DIR, force: true });
|
||||
expect(readFileSync(repoEnvPath, 'utf-8')).toBe(sentinel);
|
||||
});
|
||||
|
||||
it('resolveScopedEnvPath returns the archon-owned path for each scope', () => {
|
||||
expect(resolveScopedEnvPath('home', REPO_DIR)).toBe(join(HOME_DIR, '.env'));
|
||||
expect(resolveScopedEnvPath('project', REPO_DIR)).toBe(join(REPO_DIR, '.archon', '.env'));
|
||||
});
|
||||
|
||||
it('serializeEnv round-trips through dotenv.parse', () => {
|
||||
const entries = {
|
||||
SIMPLE: 'value',
|
||||
WITH_SPACE: 'hello world',
|
||||
WITH_HASH: 'value#not-a-comment',
|
||||
EMPTY: '',
|
||||
};
|
||||
const serialized = serializeEnv(entries);
|
||||
const parsed = parseDotenv(serialized);
|
||||
expect(parsed.SIMPLE).toBe('value');
|
||||
expect(parsed.WITH_SPACE).toBe('hello world');
|
||||
expect(parsed.WITH_HASH).toBe('value#not-a-comment');
|
||||
expect(parsed.EMPTY).toBe('');
|
||||
});
|
||||
|
||||
it('serializeEnv escapes \\r so bare CRs survive round-trip', () => {
|
||||
const entries = { WITH_CR: 'line1\rline2', WITH_CRLF: 'a\r\nb' };
|
||||
const serialized = serializeEnv(entries);
|
||||
const parsed = parseDotenv(serialized);
|
||||
expect(parsed.WITH_CR).toBe('line1\rline2');
|
||||
expect(parsed.WITH_CRLF).toBe('a\r\nb');
|
||||
});
|
||||
|
||||
it('merge treats whitespace-only existing values as empty (replaces them)', () => {
|
||||
const envPath = join(HOME_DIR, '.env');
|
||||
writeFileSync(envPath, 'API_KEY= \nNORMAL=keep-me\n');
|
||||
const result = writeScopedEnv('API_KEY=real-token\nNORMAL=from-wizard\n', {
|
||||
scope: 'home',
|
||||
repoPath: REPO_DIR,
|
||||
force: false,
|
||||
});
|
||||
const merged = parseDotenv(readFileSync(result.targetPath, 'utf-8'));
|
||||
// Whitespace-only API_KEY was replaced by the proposed value.
|
||||
expect(merged.API_KEY).toBe('real-token');
|
||||
// Non-empty NORMAL was preserved and reported.
|
||||
expect(merged.NORMAL).toBe('keep-me');
|
||||
expect(result.preservedKeys).toContain('NORMAL');
|
||||
expect(result.preservedKeys).not.toContain('API_KEY');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,17 @@
|
|||
* - AI assistants (Claude and/or Codex)
|
||||
* - Platform connections (GitHub, Telegram, Slack, Discord)
|
||||
*
|
||||
* Writes configuration to both ~/.archon/.env and <repo>/.env
|
||||
* Writes configuration to one archon-owned env file, chosen by --scope:
|
||||
* - 'home' (default) → ~/.archon/.env
|
||||
* - 'project' → <repo>/.archon/.env
|
||||
*
|
||||
* Never writes to <repo>/.env — that file is stripped at boot by stripCwdEnv()
|
||||
* (see #1302 / #1303 three-path model). Writing there would be incoherent
|
||||
* (values would be silently deleted on the next run).
|
||||
*
|
||||
* Writes are merge-only by default: existing non-empty values are preserved,
|
||||
* user-added custom keys survive, and a timestamped backup is written before
|
||||
* every rewrite. `--force` skips the merge (proposed wins) but still backs up.
|
||||
*/
|
||||
import {
|
||||
intro,
|
||||
|
|
@ -22,12 +32,18 @@ import {
|
|||
cancel,
|
||||
log,
|
||||
} from '@clack/prompts';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, chmodSync } from 'fs';
|
||||
import { parse as parseDotenv } from 'dotenv';
|
||||
import { join, dirname } from 'path';
|
||||
import { BUNDLED_SKILL_FILES } from '../bundled-skill';
|
||||
import { homedir } from 'os';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { spawn, execSync, type ChildProcess } from 'child_process';
|
||||
import { getRegisteredProviders } from '@archon/providers';
|
||||
import {
|
||||
getArchonEnvPath as pathsGetArchonEnvPath,
|
||||
getRepoArchonEnvPath as pathsGetRepoArchonEnvPath,
|
||||
} from '@archon/paths';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
|
|
@ -43,9 +59,12 @@ interface SetupConfig {
|
|||
claudeAuthType?: 'global' | 'apiKey' | 'oauthToken';
|
||||
claudeApiKey?: string;
|
||||
claudeOauthToken?: string;
|
||||
/** Absolute path to Claude Code SDK's cli.js. Written as CLAUDE_BIN_PATH
|
||||
* in ~/.archon/.env. Required in compiled Archon binaries; harmless in dev. */
|
||||
claudeBinaryPath?: string;
|
||||
codex: boolean;
|
||||
codexTokens?: CodexTokens;
|
||||
defaultAssistant: 'claude' | 'codex';
|
||||
defaultAssistant: string;
|
||||
};
|
||||
platforms: {
|
||||
github: boolean;
|
||||
|
|
@ -105,6 +124,10 @@ interface ExistingConfig {
|
|||
interface SetupOptions {
|
||||
spawn?: boolean;
|
||||
repoPath: string;
|
||||
/** Which archon-owned file to target. Default: 'home'. */
|
||||
scope?: 'home' | 'project';
|
||||
/** Skip merge and overwrite the target wholesale (backup still written). Default: false. */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
interface SpawnResult {
|
||||
|
|
@ -159,6 +182,85 @@ function isCommandAvailable(command: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe wrappers — exported so tests can spy on each tier independently.
|
||||
* Direct imports of `existsSync` and `execSync` cannot be intercepted by
|
||||
* `spyOn` (esm rebinding limitation), so we route the probes through these
|
||||
* thin wrappers and let the test mock them in isolation.
|
||||
*/
|
||||
export function probeFileExists(path: string): boolean {
|
||||
return existsSync(path);
|
||||
}
|
||||
|
||||
export function probeNpmRoot(): string | null {
|
||||
try {
|
||||
const out = execSync('npm root -g', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
return out || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function probeWhichClaude(): string | null {
|
||||
try {
|
||||
const checkCmd = process.platform === 'win32' ? 'where' : 'which';
|
||||
const resolved = execSync(`${checkCmd} claude`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
// On Windows, `where` can return multiple lines — take the first.
|
||||
const first = resolved.split(/\r?\n/)[0]?.trim();
|
||||
return first ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to locate the Claude Code executable on disk.
|
||||
*
|
||||
* Compiled Archon binaries need an explicit path because the Claude Agent
|
||||
* SDK's `import.meta.url` resolution is frozen to the build host's filesystem.
|
||||
* The SDK's `pathToClaudeCodeExecutable` accepts either:
|
||||
* - A native compiled binary (from the curl/PowerShell/winget installers — current default)
|
||||
* - A JS `cli.js` (from `npm install -g @anthropic-ai/claude-code` — older path)
|
||||
*
|
||||
* We probe the well-known install locations in order:
|
||||
* 1. Native installer (`~/.local/bin/claude` on macOS/Linux, `%USERPROFILE%\.local\bin\claude.exe` on Windows)
|
||||
* 2. npm global `cli.js`
|
||||
* 3. `which claude` / `where claude` — fallback if the user installed via Homebrew, winget, or a custom layout
|
||||
*
|
||||
* Returns null on total failure so the caller can prompt the user.
|
||||
* Detection is best-effort; the caller should let users override.
|
||||
*
|
||||
* Exported so the probe order can be tested directly by spying on the
|
||||
* tier wrappers above (`probeFileExists`, `probeNpmRoot`, `probeWhichClaude`).
|
||||
*/
|
||||
export function detectClaudeExecutablePath(): string | null {
|
||||
// 1. Native installer default location (primary Anthropic-recommended path)
|
||||
const nativePath =
|
||||
process.platform === 'win32'
|
||||
? join(homedir(), '.local', 'bin', 'claude.exe')
|
||||
: join(homedir(), '.local', 'bin', 'claude');
|
||||
if (probeFileExists(nativePath)) return nativePath;
|
||||
|
||||
// 2. npm global cli.js
|
||||
const npmRoot = probeNpmRoot();
|
||||
if (npmRoot) {
|
||||
const npmCliJs = join(npmRoot, '@anthropic-ai', 'claude-code', 'cli.js');
|
||||
if (probeFileExists(npmCliJs)) return npmCliJs;
|
||||
}
|
||||
|
||||
// 3. Fallback: resolve via `which` / `where` (Homebrew, winget, custom layouts)
|
||||
const fromPath = probeWhichClaude();
|
||||
if (fromPath && probeFileExists(fromPath)) return fromPath;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Node.js version if installed, or null if not
|
||||
*/
|
||||
|
|
@ -209,7 +311,7 @@ After installation, run: claude /login`,
|
|||
Install using one of these methods:
|
||||
|
||||
Recommended for macOS (no Node.js required):
|
||||
brew install --cask codex
|
||||
brew install codex
|
||||
|
||||
Or via npm (requires Node.js 18+):
|
||||
npm install -g @openai/codex
|
||||
|
|
@ -226,16 +328,19 @@ After installation, run 'codex' to authenticate.`,
|
|||
};
|
||||
|
||||
/**
|
||||
* Check for existing configuration at ~/.archon/.env
|
||||
* Check for existing configuration at the selected scope's archon-owned env
|
||||
* file. Defaults to home scope for backward compatibility — callers writing to
|
||||
* project scope must pass a path so the Add/Update/Fresh decision reflects the
|
||||
* actual target.
|
||||
*/
|
||||
export function checkExistingConfig(): ExistingConfig | null {
|
||||
const envPath = join(getArchonHome(), '.env');
|
||||
export function checkExistingConfig(envPath?: string): ExistingConfig | null {
|
||||
const path = envPath ?? join(getArchonHome(), '.env');
|
||||
|
||||
if (!existsSync(envPath)) {
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
|
||||
return {
|
||||
hasDatabase: hasEnvValue(content, 'DATABASE_URL'),
|
||||
|
|
@ -352,6 +457,62 @@ function tryReadCodexAuth(): CodexTokens | null {
|
|||
/**
|
||||
* Collect Claude authentication method
|
||||
*/
|
||||
/**
|
||||
* Resolve the Claude Code executable path for CLAUDE_BIN_PATH.
|
||||
* Auto-detects common install locations and falls back to prompting the user.
|
||||
* Returns undefined if the user declines to configure (setup continues; the
|
||||
* compiled binary will error with clear instructions on first Claude query).
|
||||
*/
|
||||
async function collectClaudeBinaryPath(): Promise<string | undefined> {
|
||||
const detected = detectClaudeExecutablePath();
|
||||
|
||||
if (detected) {
|
||||
const useDetected = await confirm({
|
||||
message: `Found Claude Code at ${detected}. Write this to CLAUDE_BIN_PATH?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (isCancel(useDetected)) {
|
||||
cancel('Setup cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
if (useDetected) return detected;
|
||||
}
|
||||
|
||||
const nativeExample =
|
||||
process.platform === 'win32' ? '%USERPROFILE%\\.local\\bin\\claude.exe' : '~/.local/bin/claude';
|
||||
|
||||
note(
|
||||
'Compiled Archon binaries need CLAUDE_BIN_PATH set to the Claude Code executable.\n' +
|
||||
'In dev (`bun run`) this is ignored — the SDK resolves it via node_modules.\n\n' +
|
||||
'Recommended (Anthropic default — native installer):\n' +
|
||||
` macOS/Linux: ${nativeExample}\n` +
|
||||
' Windows: %USERPROFILE%\\.local\\bin\\claude.exe\n\n' +
|
||||
'Alternative (npm global install):\n' +
|
||||
' $(npm root -g)/@anthropic-ai/claude-code/cli.js',
|
||||
'Claude binary path'
|
||||
);
|
||||
|
||||
const customPath = await text({
|
||||
message: 'Absolute path to the Claude Code executable (leave blank to skip):',
|
||||
placeholder: nativeExample,
|
||||
});
|
||||
|
||||
if (isCancel(customPath)) {
|
||||
cancel('Setup cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const trimmed = (customPath ?? '').trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
if (!existsSync(trimmed)) {
|
||||
log.warning(
|
||||
`Path does not exist: ${trimmed}. Saving anyway — the compiled binary will error on first use until this is correct.`
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function collectClaudeAuth(): Promise<{
|
||||
authType: 'global' | 'apiKey' | 'oauthToken';
|
||||
apiKey?: string;
|
||||
|
|
@ -534,7 +695,8 @@ async function collectCodexAuth(): Promise<CodexTokens | null> {
|
|||
*/
|
||||
async function collectAIConfig(): Promise<SetupConfig['ai']> {
|
||||
const assistants = await multiselect({
|
||||
message: 'Which AI assistant(s) will you use? (↑↓ navigate, space select, enter confirm)',
|
||||
message:
|
||||
'Which built-in AI assistant(s) will you use? (↑↓ navigate, space select, enter confirm)',
|
||||
options: [
|
||||
{ value: 'claude', label: 'Claude (Recommended)', hint: 'Anthropic Claude Code SDK' },
|
||||
{ value: 'codex', label: 'Codex', hint: 'OpenAI Codex SDK' },
|
||||
|
|
@ -653,13 +815,14 @@ After upgrading, run 'archon setup' again.`,
|
|||
return {
|
||||
claude: false,
|
||||
codex: false,
|
||||
defaultAssistant: 'claude',
|
||||
defaultAssistant: getRegisteredProviders().find(p => p.builtIn)?.id ?? 'claude',
|
||||
};
|
||||
}
|
||||
|
||||
let claudeAuthType: 'global' | 'apiKey' | 'oauthToken' | undefined;
|
||||
let claudeApiKey: string | undefined;
|
||||
let claudeOauthToken: string | undefined;
|
||||
let claudeBinaryPath: string | undefined;
|
||||
let codexTokens: CodexTokens | undefined;
|
||||
|
||||
// Collect Claude auth if selected
|
||||
|
|
@ -668,6 +831,7 @@ After upgrading, run 'archon setup' again.`,
|
|||
claudeAuthType = claudeAuth.authType;
|
||||
claudeApiKey = claudeAuth.apiKey;
|
||||
claudeOauthToken = claudeAuth.oauthToken;
|
||||
claudeBinaryPath = await collectClaudeBinaryPath();
|
||||
}
|
||||
|
||||
// Collect Codex auth if selected
|
||||
|
|
@ -676,16 +840,21 @@ After upgrading, run 'archon setup' again.`,
|
|||
codexTokens = tokens ?? undefined;
|
||||
}
|
||||
|
||||
// Determine default assistant
|
||||
let defaultAssistant: 'claude' | 'codex' = 'claude';
|
||||
// Determine default assistant — use the registry, but keep setup/auth flows built-in only.
|
||||
// Default to first registered built-in provider rather than hardcoding 'claude'.
|
||||
let defaultAssistant = getRegisteredProviders().find(p => p.builtIn)?.id ?? 'claude';
|
||||
|
||||
if (hasClaude && hasCodex) {
|
||||
const providerChoices = getRegisteredProviders()
|
||||
.filter(p => p.builtIn)
|
||||
.map(p => ({
|
||||
value: p.id,
|
||||
label: p.id === 'claude' ? `${p.displayName} (Recommended)` : p.displayName,
|
||||
}));
|
||||
|
||||
const defaultChoice = await select({
|
||||
message: 'Which should be the default AI assistant?',
|
||||
options: [
|
||||
{ value: 'claude', label: 'Claude (Recommended)' },
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
],
|
||||
options: providerChoices,
|
||||
});
|
||||
|
||||
if (isCancel(defaultChoice)) {
|
||||
|
|
@ -703,6 +872,7 @@ After upgrading, run 'archon setup' again.`,
|
|||
claudeAuthType,
|
||||
claudeApiKey,
|
||||
claudeOauthToken,
|
||||
...(claudeBinaryPath !== undefined ? { claudeBinaryPath } : {}),
|
||||
codex: hasCodex,
|
||||
codexTokens,
|
||||
defaultAssistant,
|
||||
|
|
@ -1063,6 +1233,9 @@ export function generateEnvContent(config: SetupConfig): string {
|
|||
lines.push('CLAUDE_USE_GLOBAL_AUTH=false');
|
||||
lines.push(`CLAUDE_CODE_OAUTH_TOKEN=${config.ai.claudeOauthToken}`);
|
||||
}
|
||||
if (config.ai.claudeBinaryPath) {
|
||||
lines.push(`CLAUDE_BIN_PATH=${config.ai.claudeBinaryPath}`);
|
||||
}
|
||||
} else {
|
||||
lines.push('# Claude not configured');
|
||||
}
|
||||
|
|
@ -1139,8 +1312,12 @@ export function generateEnvContent(config: SetupConfig): string {
|
|||
}
|
||||
|
||||
// Server
|
||||
// PORT is intentionally omitted: both the Hono server (packages/core/src/utils/port-allocation.ts)
|
||||
// and the Vite dev proxy (packages/web/vite.config.ts) default to 3090 when unset, which keeps
|
||||
// them in sync. Writing a fixed PORT here risked a mismatch if ~/.archon/.env leaks a PORT that
|
||||
// the Vite proxy (which only reads repo-local .env) never sees — see #1152.
|
||||
lines.push('# Server');
|
||||
lines.push('PORT=3000');
|
||||
lines.push('# PORT=3090 # Default: 3090. Uncomment to override.');
|
||||
lines.push('');
|
||||
|
||||
// Concurrency
|
||||
|
|
@ -1151,28 +1328,120 @@ export function generateEnvContent(config: SetupConfig): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Write .env files to both global and repo locations
|
||||
* Resolve the target path for the selected scope. Delegates to `@archon/paths`
|
||||
* so Docker (`/.archon`), the `ARCHON_HOME` override, and the "undefined"
|
||||
* literal guard behave identically to the loader. Never resolves to
|
||||
* `<repoPath>/.env` — that path belongs to the user.
|
||||
*/
|
||||
function writeEnvFiles(
|
||||
content: string,
|
||||
repoPath: string
|
||||
): { globalPath: string; repoEnvPath: string } {
|
||||
const archonHome = getArchonHome();
|
||||
const globalPath = join(archonHome, '.env');
|
||||
const repoEnvPath = join(repoPath, '.env');
|
||||
export function resolveScopedEnvPath(scope: 'home' | 'project', repoPath: string): string {
|
||||
if (scope === 'project') return pathsGetRepoArchonEnvPath(repoPath);
|
||||
return pathsGetArchonEnvPath();
|
||||
}
|
||||
|
||||
// Create ~/.archon/ if needed
|
||||
if (!existsSync(archonHome)) {
|
||||
mkdirSync(archonHome, { recursive: true });
|
||||
/**
|
||||
* Serialize a key/value map back to `KEY=value` lines. Values with whitespace,
|
||||
* `#`, `"`, `'`, `\n`, or `\r` are double-quoted with `\\`, `"`, `\n`, `\r`
|
||||
* escaped so round-tripping through dotenv.parse is stable.
|
||||
*/
|
||||
export function serializeEnv(entries: Record<string, string>): string {
|
||||
const lines: string[] = [];
|
||||
for (const [key, rawValue] of Object.entries(entries)) {
|
||||
const value = rawValue;
|
||||
const needsQuoting = /[\s#"'\n\r]/.test(value) || value === '';
|
||||
if (needsQuoting) {
|
||||
const escaped = value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r');
|
||||
lines.push(`${key}="${escaped}"`);
|
||||
} else {
|
||||
lines.push(`${key}=${value}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n') + (lines.length > 0 ? '\n' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a filesystem-safe ISO timestamp (no `:` or `.` characters).
|
||||
*/
|
||||
function backupTimestamp(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-');
|
||||
}
|
||||
|
||||
interface WriteScopedEnvResult {
|
||||
targetPath: string;
|
||||
backupPath: string | null;
|
||||
/** Keys present in the existing file that were preserved against the proposed set. */
|
||||
preservedKeys: string[];
|
||||
/** True when `--force` overrode the merge. */
|
||||
forced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write env content to exactly one archon-owned file, selected by scope.
|
||||
* Merge-only by default (existing non-empty values win, user-added keys
|
||||
* survive). Backs up the existing file (if any) before every rewrite, even
|
||||
* when `--force` is set.
|
||||
*/
|
||||
export function writeScopedEnv(
|
||||
content: string,
|
||||
options: { scope: 'home' | 'project'; repoPath: string; force: boolean }
|
||||
): WriteScopedEnvResult {
|
||||
const targetPath = resolveScopedEnvPath(options.scope, options.repoPath);
|
||||
const parentDir = dirname(targetPath);
|
||||
if (!existsSync(parentDir)) {
|
||||
mkdirSync(parentDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write to global location
|
||||
writeFileSync(globalPath, content);
|
||||
const exists = existsSync(targetPath);
|
||||
let backupPath: string | null = null;
|
||||
if (exists) {
|
||||
backupPath = `${targetPath}.archon-backup-${backupTimestamp()}`;
|
||||
copyFileSync(targetPath, backupPath);
|
||||
// Backups carry tokens/secrets — match the 0o600 we set on the live file.
|
||||
chmodSync(backupPath, 0o600);
|
||||
}
|
||||
|
||||
// Write to repo location
|
||||
writeFileSync(repoEnvPath, content);
|
||||
const preservedKeys: string[] = [];
|
||||
let finalContent: string;
|
||||
|
||||
return { globalPath, repoEnvPath };
|
||||
if (options.force || !exists) {
|
||||
finalContent = content;
|
||||
if (options.force && backupPath) {
|
||||
process.stderr.write(
|
||||
`[archon] --force: overwriting ${targetPath} (backup at ${backupPath})\n`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Merge: existing non-empty values win; proposed-only keys are added;
|
||||
// existing-only keys (user customizations) are preserved verbatim.
|
||||
const existingRaw = readFileSync(targetPath, 'utf-8');
|
||||
const existing = parseDotenv(existingRaw);
|
||||
const proposed = parseDotenv(content);
|
||||
const merged: Record<string, string> = { ...existing };
|
||||
for (const [key, value] of Object.entries(proposed)) {
|
||||
const prior = existing[key];
|
||||
// Treat whitespace-only existing values as empty — otherwise a
|
||||
// copy-paste stray ` ` would silently defeat the wizard's update for
|
||||
// that key forever.
|
||||
const priorIsEmpty = prior === undefined || prior.trim() === '';
|
||||
if (!(key in existing) || priorIsEmpty) {
|
||||
merged[key] = value;
|
||||
} else {
|
||||
preservedKeys.push(key);
|
||||
}
|
||||
}
|
||||
finalContent = serializeEnv(merged);
|
||||
}
|
||||
|
||||
// 0o600 — env files hold secrets. Prevents group/world-readable writes on a
|
||||
// permissive umask. writeFileSync's default mode is 0o666 & ~umask.
|
||||
writeFileSync(targetPath, finalContent, { mode: 0o600 });
|
||||
// writeFileSync preserves mode for existing files; chmod guarantees 0o600
|
||||
// even when overwriting a file that pre-existed with looser permissions.
|
||||
chmodSync(targetPath, 0o600);
|
||||
return { targetPath, backupPath, preservedKeys, forced: options.force && exists };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1203,7 +1472,7 @@ export function copyArchonSkill(targetPath: string): void {
|
|||
function trySpawn(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { detached: boolean; stdio: 'ignore'; shell?: boolean }
|
||||
options: { detached: boolean; stdio: 'ignore' }
|
||||
): boolean {
|
||||
try {
|
||||
const child: ChildProcess = spawn(command, args, options);
|
||||
|
|
@ -1238,7 +1507,6 @@ function spawnWindowsTerminal(repoPath: string): SpawnResult {
|
|||
trySpawn('cmd.exe', ['/c', 'start', '""', '/D', repoPath, 'cmd', '/k', 'archon setup'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
shell: true,
|
||||
})
|
||||
) {
|
||||
return { success: true };
|
||||
|
|
@ -1366,8 +1634,28 @@ export async function setupCommand(options: SetupOptions): Promise<void> {
|
|||
// Interactive setup flow
|
||||
intro('Archon Setup Wizard');
|
||||
|
||||
// Check for existing configuration
|
||||
const existing = checkExistingConfig();
|
||||
// Resolve scope + target path up-front so everything downstream (existing-
|
||||
// config check, merge, write) agrees on which file we're touching.
|
||||
const scope: 'home' | 'project' = options.scope ?? 'home';
|
||||
const force = options.force ?? false;
|
||||
const targetEnvPath = resolveScopedEnvPath(scope, options.repoPath);
|
||||
|
||||
// If a pre-existing <repo>/.env is present, tell the operator once that
|
||||
// archon does NOT manage it — avoids confusion for users upgrading from
|
||||
// versions that used to write there.
|
||||
const legacyRepoEnv = join(options.repoPath, '.env');
|
||||
if (existsSync(legacyRepoEnv)) {
|
||||
log.info(
|
||||
`Note: ${legacyRepoEnv} exists but is not managed by archon.\n` +
|
||||
' Values there are stripped from the archon process at runtime (safety guard).\n' +
|
||||
' Put archon env vars in ~/.archon/.env (home scope) or ' +
|
||||
`${join(options.repoPath, '.archon', '.env')} (project scope).`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for existing configuration at the selected scope (not unconditionally
|
||||
// ~/.archon/.env) so the Add/Update/Fresh decision reflects the actual target.
|
||||
const existing = checkExistingConfig(targetEnvPath);
|
||||
|
||||
type SetupMode = 'fresh' | 'add' | 'update';
|
||||
let mode: SetupMode = 'fresh';
|
||||
|
|
@ -1420,7 +1708,7 @@ export async function setupCommand(options: SetupOptions): Promise<void> {
|
|||
ai: {
|
||||
claude: existing?.hasClaude ?? false,
|
||||
codex: existing?.hasCodex ?? false,
|
||||
defaultAssistant: 'claude',
|
||||
defaultAssistant: getRegisteredProviders().find(p => p.builtIn)?.id ?? 'claude',
|
||||
},
|
||||
platforms: {
|
||||
github: existing?.platforms.github ?? false,
|
||||
|
|
@ -1489,13 +1777,41 @@ export async function setupCommand(options: SetupOptions): Promise<void> {
|
|||
config.botDisplayName = await collectBotDisplayName();
|
||||
}
|
||||
|
||||
// Generate and write configuration
|
||||
s.start('Writing configuration files...');
|
||||
// Generate and write configuration. Wrap in try/catch so any fs exception
|
||||
// (permission denied, read-only FS, backup copy failure, etc.) stops the
|
||||
// spinner cleanly and surfaces an actionable error instead of a raw stack
|
||||
// trace after the user has filled out the entire wizard.
|
||||
s.start('Writing configuration...');
|
||||
|
||||
const envContent = generateEnvContent(config);
|
||||
const { globalPath, repoEnvPath } = writeEnvFiles(envContent, options.repoPath);
|
||||
let writeResult: ReturnType<typeof writeScopedEnv>;
|
||||
try {
|
||||
writeResult = writeScopedEnv(envContent, {
|
||||
scope,
|
||||
repoPath: options.repoPath,
|
||||
force,
|
||||
});
|
||||
} catch (error) {
|
||||
s.stop('Failed to write configuration');
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
const code = err.code ? ` (${err.code})` : '';
|
||||
cancel(`Could not write ${targetEnvPath}${code}: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
s.stop('Configuration files written');
|
||||
s.stop('Configuration written');
|
||||
|
||||
// Tell the operator exactly what happened — especially that <repo>/.env was
|
||||
// NOT touched, because prior versions wrote there and this is the biggest
|
||||
// behavior change for returning users.
|
||||
if (writeResult.preservedKeys.length > 0) {
|
||||
log.info(
|
||||
`Preserved ${writeResult.preservedKeys.length} existing value(s) (use --force to overwrite): ${writeResult.preservedKeys.join(', ')}`
|
||||
);
|
||||
}
|
||||
if (writeResult.backupPath) {
|
||||
log.info(`Backup written to ${writeResult.backupPath}`);
|
||||
}
|
||||
|
||||
// Offer to install the Archon skill
|
||||
const shouldCopySkill = await confirm({
|
||||
|
|
@ -1596,9 +1912,8 @@ export async function setupCommand(options: SetupOptions): Promise<void> {
|
|||
`Default: ${config.ai.defaultAssistant}`,
|
||||
`Platforms: ${configuredPlatforms.length > 0 ? configuredPlatforms.join(', ') : 'None'}`,
|
||||
'',
|
||||
'Files written:',
|
||||
` ${globalPath}`,
|
||||
` ${repoEnvPath}`,
|
||||
`File written (${scope} scope):`,
|
||||
` ${writeResult.targetPath}`,
|
||||
];
|
||||
|
||||
if (config.platforms.github && config.github) {
|
||||
|
|
@ -1619,7 +1934,7 @@ export async function setupCommand(options: SetupOptions): Promise<void> {
|
|||
// Additional options note
|
||||
note(
|
||||
'Other settings you can customize in ~/.archon/.env:\n' +
|
||||
' - PORT (default: 3000)\n' +
|
||||
' - PORT (default: 3090)\n' +
|
||||
' - MAX_CONCURRENT_CONVERSATIONS (default: 10)\n' +
|
||||
' - *_STREAMING_MODE (stream | batch per platform)\n\n' +
|
||||
'These defaults work well for most users.',
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ export async function validateWorkflowsCommand(
|
|||
json?: boolean
|
||||
): Promise<number> {
|
||||
const config = await buildValidationConfig(cwd);
|
||||
const mergedConfig = await loadConfig(cwd);
|
||||
const defaultProvider = mergedConfig.assistant;
|
||||
const { workflows: workflowEntries, errors: loadErrors } = await discoverWorkflowsWithConfig(
|
||||
cwd,
|
||||
loadConfig
|
||||
|
|
@ -105,7 +107,7 @@ export async function validateWorkflowsCommand(
|
|||
|
||||
// Validate successfully parsed workflows (Level 3)
|
||||
for (const { workflow } of workflowEntries) {
|
||||
const issues = await validateWorkflowResources(workflow, cwd, config);
|
||||
const issues = await validateWorkflowResources(workflow, cwd, config, defaultProvider);
|
||||
results.push(makeWorkflowResult(workflow.name, issues));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ describe('workflowListCommand', () => {
|
|||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Found 1 workflow(s)'));
|
||||
});
|
||||
|
||||
it('passes globalSearchPath to discoverWorkflowsWithConfig', async () => {
|
||||
it('calls discoverWorkflowsWithConfig with (cwd, loadConfig) — home scope is internal', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [],
|
||||
|
|
@ -319,11 +319,9 @@ describe('workflowListCommand', () => {
|
|||
|
||||
await workflowListCommand('/test/path');
|
||||
|
||||
expect(discoverWorkflowsWithConfig).toHaveBeenCalledWith(
|
||||
'/test/path',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ globalSearchPath: '/home/test/.archon' })
|
||||
);
|
||||
// After the globalSearchPath refactor, discovery reads ~/.archon/workflows/
|
||||
// on every call with no option — every caller inherits home-scope for free.
|
||||
expect(discoverWorkflowsWithConfig).toHaveBeenCalledWith('/test/path', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should throw error when discoverWorkflows fails', async () => {
|
||||
|
|
@ -867,6 +865,146 @@ describe('workflowRunCommand', () => {
|
|||
expect(createCallsAfter).toBe(createCallsBefore);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Workflow-level `worktree.enabled` policy
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('skips isolation when workflow YAML pins worktree.enabled: false', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
const { executeWorkflow } = await import('@archon/workflows/executor');
|
||||
const conversationDb = await import('@archon/core/db/conversations');
|
||||
const codebaseDb = await import('@archon/core/db/codebases');
|
||||
const isolation = await import('@archon/isolation');
|
||||
|
||||
const getIsolationProviderMock = isolation.getIsolationProvider as ReturnType<typeof mock>;
|
||||
const providerBefore = getIsolationProviderMock.mock.results.at(-1)?.value as
|
||||
| { create: ReturnType<typeof mock> }
|
||||
| undefined;
|
||||
const createCallsBefore = providerBefore?.create.mock.calls.length ?? 0;
|
||||
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [
|
||||
makeTestWorkflowWithSource({
|
||||
name: 'triage',
|
||||
description: 'Read-only triage',
|
||||
worktree: { enabled: false },
|
||||
}),
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
id: 'conv-123',
|
||||
});
|
||||
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
id: 'cb-123',
|
||||
default_cwd: '/test/path',
|
||||
});
|
||||
(conversationDb.updateConversation as ReturnType<typeof mock>).mockResolvedValueOnce(undefined);
|
||||
(executeWorkflow as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
success: true,
|
||||
workflowRunId: 'run-123',
|
||||
});
|
||||
|
||||
// No flags — policy alone should disable isolation
|
||||
await workflowRunCommand('/test/path', 'triage', 'go', {});
|
||||
|
||||
const providerAfter = getIsolationProviderMock.mock.results.at(-1)?.value as
|
||||
| { create: ReturnType<typeof mock> }
|
||||
| undefined;
|
||||
const createCallsAfter = providerAfter?.create.mock.calls.length ?? 0;
|
||||
expect(createCallsAfter).toBe(createCallsBefore);
|
||||
});
|
||||
|
||||
it('throws when workflow pins worktree.enabled: false but caller passes --branch', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [
|
||||
makeTestWorkflowWithSource({
|
||||
name: 'triage',
|
||||
description: 'Read-only triage',
|
||||
worktree: { enabled: false },
|
||||
}),
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
workflowRunCommand('/test/path', 'triage', 'go', { branchName: 'feat-x' })
|
||||
).rejects.toThrow(/worktree\.enabled: false/);
|
||||
});
|
||||
|
||||
it('throws when workflow pins worktree.enabled: false but caller passes --from', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [
|
||||
makeTestWorkflowWithSource({
|
||||
name: 'triage',
|
||||
description: 'Read-only triage',
|
||||
worktree: { enabled: false },
|
||||
}),
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
workflowRunCommand('/test/path', 'triage', 'go', { fromBranch: 'dev' })
|
||||
).rejects.toThrow(/worktree\.enabled: false/);
|
||||
});
|
||||
|
||||
it('accepts worktree.enabled: false + --no-worktree as redundant (no error)', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
const { executeWorkflow } = await import('@archon/workflows/executor');
|
||||
const conversationDb = await import('@archon/core/db/conversations');
|
||||
const codebaseDb = await import('@archon/core/db/codebases');
|
||||
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [
|
||||
makeTestWorkflowWithSource({
|
||||
name: 'triage',
|
||||
description: 'Read-only triage',
|
||||
worktree: { enabled: false },
|
||||
}),
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
id: 'conv-123',
|
||||
});
|
||||
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
id: 'cb-123',
|
||||
default_cwd: '/test/path',
|
||||
});
|
||||
(conversationDb.updateConversation as ReturnType<typeof mock>).mockResolvedValueOnce(undefined);
|
||||
(executeWorkflow as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
success: true,
|
||||
workflowRunId: 'run-123',
|
||||
});
|
||||
|
||||
// Should not throw — redundant, not contradictory
|
||||
await workflowRunCommand('/test/path', 'triage', 'go', { noWorktree: true });
|
||||
});
|
||||
|
||||
it('throws when workflow pins worktree.enabled: true but caller passes --no-worktree', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [
|
||||
makeTestWorkflowWithSource({
|
||||
name: 'build',
|
||||
description: 'Requires a worktree',
|
||||
worktree: { enabled: true },
|
||||
}),
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
workflowRunCommand('/test/path', 'build', 'go', { noWorktree: true })
|
||||
).rejects.toThrow(/worktree\.enabled: true/);
|
||||
});
|
||||
|
||||
it('throws when isolation cannot be created due to missing codebase', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
const conversationDb = await import('@archon/core/db/conversations');
|
||||
|
|
@ -975,6 +1113,249 @@ describe('workflowRunCommand', () => {
|
|||
consoleWarnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends dispatch message before executeWorkflow with correct metadata', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
const { executeWorkflow } = await import('@archon/workflows/executor');
|
||||
const conversationDb = await import('@archon/core/db/conversations');
|
||||
const codebaseDb = await import('@archon/core/db/codebases');
|
||||
const messagesDb = await import('@archon/core/db/messages');
|
||||
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
|
||||
errors: [],
|
||||
});
|
||||
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
id: 'conv-123',
|
||||
});
|
||||
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce(null);
|
||||
(conversationDb.updateConversation as ReturnType<typeof mock>).mockResolvedValueOnce(undefined);
|
||||
|
||||
// Track call order for assistant messages only (user message is added first via addMessage directly)
|
||||
const callOrder: string[] = [];
|
||||
(messagesDb.addMessage as ReturnType<typeof mock>).mockImplementation(
|
||||
async (_dbId: unknown, role: unknown, content: unknown) => {
|
||||
if (role === 'assistant') {
|
||||
callOrder.push(`addMessage:${String(content)}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
(executeWorkflow as ReturnType<typeof mock>).mockImplementation(async () => {
|
||||
callOrder.push('executeWorkflow');
|
||||
return { success: true, workflowRunId: 'run-1' };
|
||||
});
|
||||
|
||||
await workflowRunCommand('/test/path', 'assist', 'hello', { noWorktree: true });
|
||||
|
||||
// Dispatch assistant message fires before executeWorkflow
|
||||
expect(callOrder[0]).toContain('Dispatching workflow');
|
||||
expect(callOrder[1]).toBe('executeWorkflow');
|
||||
|
||||
// Correct metadata shape
|
||||
expect(messagesDb.addMessage).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'assistant',
|
||||
'Dispatching workflow: **assist**',
|
||||
expect.objectContaining({
|
||||
category: 'workflow_dispatch_status',
|
||||
workflowDispatch: expect.objectContaining({
|
||||
workflowName: 'assist',
|
||||
workerConversationId: expect.stringMatching(/^cli-/),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sends result card when executeWorkflow returns a summary', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
const { executeWorkflow } = await import('@archon/workflows/executor');
|
||||
const conversationDb = await import('@archon/core/db/conversations');
|
||||
const codebaseDb = await import('@archon/core/db/codebases');
|
||||
const messagesDb = await import('@archon/core/db/messages');
|
||||
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
|
||||
errors: [],
|
||||
});
|
||||
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
id: 'conv-123',
|
||||
});
|
||||
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce(null);
|
||||
(conversationDb.updateConversation as ReturnType<typeof mock>).mockResolvedValueOnce(undefined);
|
||||
(executeWorkflow as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
success: true,
|
||||
workflowRunId: 'run-42',
|
||||
summary: 'All steps completed. Branch pushed.',
|
||||
});
|
||||
(messagesDb.addMessage as ReturnType<typeof mock>).mockClear();
|
||||
|
||||
await workflowRunCommand('/test/path', 'assist', 'hello', { noWorktree: true });
|
||||
|
||||
expect(messagesDb.addMessage).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'assistant',
|
||||
'All steps completed. Branch pushed.',
|
||||
expect.objectContaining({
|
||||
category: 'workflow_result',
|
||||
workflowResult: { workflowName: 'assist', runId: 'run-42' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send result card when executeWorkflow has no summary', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
const { executeWorkflow } = await import('@archon/workflows/executor');
|
||||
const conversationDb = await import('@archon/core/db/conversations');
|
||||
const codebaseDb = await import('@archon/core/db/codebases');
|
||||
const messagesDb = await import('@archon/core/db/messages');
|
||||
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
|
||||
errors: [],
|
||||
});
|
||||
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
id: 'conv-123',
|
||||
});
|
||||
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce(null);
|
||||
(conversationDb.updateConversation as ReturnType<typeof mock>).mockResolvedValueOnce(undefined);
|
||||
(executeWorkflow as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
success: true,
|
||||
workflowRunId: 'run-1',
|
||||
// no summary field
|
||||
});
|
||||
(messagesDb.addMessage as ReturnType<typeof mock>).mockClear();
|
||||
|
||||
await workflowRunCommand('/test/path', 'assist', 'hello', { noWorktree: true });
|
||||
|
||||
// Only dispatch addMessage call, no result card
|
||||
const resultCalls = (messagesDb.addMessage as ReturnType<typeof mock>).mock.calls.filter(
|
||||
(args: unknown[]) => {
|
||||
const meta = args[3] as Record<string, unknown> | undefined;
|
||||
return meta?.category === 'workflow_result';
|
||||
}
|
||||
);
|
||||
expect(resultCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not throw and logs warn when result message DB persist fails', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
const { executeWorkflow } = await import('@archon/workflows/executor');
|
||||
const conversationDb = await import('@archon/core/db/conversations');
|
||||
const codebaseDb = await import('@archon/core/db/codebases');
|
||||
const messagesDb = await import('@archon/core/db/messages');
|
||||
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
|
||||
errors: [],
|
||||
});
|
||||
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
id: 'conv-123',
|
||||
});
|
||||
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce(null);
|
||||
(conversationDb.updateConversation as ReturnType<typeof mock>).mockResolvedValueOnce(undefined);
|
||||
(executeWorkflow as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
success: true,
|
||||
workflowRunId: 'run-1',
|
||||
summary: 'Done.',
|
||||
});
|
||||
// addMessage is called three times: user message persist, dispatch, result
|
||||
// CLIAdapter internally catches DB errors — it logs 'cli_message_persist_failed' and does not throw.
|
||||
// Verify workflowRunCommand does not throw even when the result DB write fails.
|
||||
(messagesDb.addMessage as ReturnType<typeof mock>)
|
||||
.mockResolvedValueOnce(undefined) // user message persist succeeds
|
||||
.mockResolvedValueOnce(undefined) // dispatch succeeds
|
||||
.mockRejectedValueOnce(new Error('DB gone')); // result fails (caught inside CLIAdapter)
|
||||
|
||||
// Should not throw — the CLIAdapter swallows the DB error and logs a warn
|
||||
await expect(
|
||||
workflowRunCommand('/test/path', 'assist', 'hello', { noWorktree: true })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// CLIAdapter logs 'cli_message_persist_failed' when addMessage throws internally
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'cli_message_persist_failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw and continues to executeWorkflow when dispatch sendMessage fails', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
const { executeWorkflow } = await import('@archon/workflows/executor');
|
||||
const conversationDb = await import('@archon/core/db/conversations');
|
||||
const codebaseDb = await import('@archon/core/db/codebases');
|
||||
const messagesDb = await import('@archon/core/db/messages');
|
||||
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
|
||||
errors: [],
|
||||
});
|
||||
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
id: 'conv-123',
|
||||
});
|
||||
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce(null);
|
||||
(conversationDb.updateConversation as ReturnType<typeof mock>).mockResolvedValueOnce(undefined);
|
||||
(executeWorkflow as ReturnType<typeof mock>).mockClear();
|
||||
(executeWorkflow as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
success: true,
|
||||
workflowRunId: 'run-1',
|
||||
});
|
||||
// First addMessage (user message persist) succeeds, second (dispatch) fails
|
||||
(messagesDb.addMessage as ReturnType<typeof mock>)
|
||||
.mockResolvedValueOnce(undefined) // user message persist succeeds
|
||||
.mockRejectedValueOnce(new Error('DB gone')); // dispatch fails (caught inside CLIAdapter)
|
||||
|
||||
// Should not throw — dispatch failure must not block workflow execution
|
||||
await expect(
|
||||
workflowRunCommand('/test/path', 'assist', 'hello', { noWorktree: true })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// executeWorkflow was still called despite dispatch failure
|
||||
expect(executeWorkflow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not send result card when workflow is paused even with summary', async () => {
|
||||
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
|
||||
const { executeWorkflow } = await import('@archon/workflows/executor');
|
||||
const conversationDb = await import('@archon/core/db/conversations');
|
||||
const codebaseDb = await import('@archon/core/db/codebases');
|
||||
const messagesDb = await import('@archon/core/db/messages');
|
||||
|
||||
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
workflows: [makeTestWorkflowWithSource({ name: 'assist', description: 'Help' })],
|
||||
errors: [],
|
||||
});
|
||||
(conversationDb.getOrCreateConversation as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
id: 'conv-123',
|
||||
});
|
||||
(codebaseDb.findCodebaseByDefaultCwd as ReturnType<typeof mock>).mockResolvedValueOnce(null);
|
||||
(conversationDb.updateConversation as ReturnType<typeof mock>).mockResolvedValueOnce(undefined);
|
||||
(executeWorkflow as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
success: true,
|
||||
workflowRunId: 'run-paused',
|
||||
paused: true,
|
||||
summary: 'Steps completed so far.',
|
||||
});
|
||||
(messagesDb.addMessage as ReturnType<typeof mock>).mockClear();
|
||||
|
||||
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
await workflowRunCommand('/test/path', 'assist', 'hello', { noWorktree: true });
|
||||
|
||||
// Paused guard fires before summary check — no result card despite having a summary
|
||||
const resultCalls = (messagesDb.addMessage as ReturnType<typeof mock>).mock.calls.filter(
|
||||
(args: unknown[]) => {
|
||||
const meta = args[3] as Record<string, unknown> | undefined;
|
||||
return meta?.category === 'workflow_result';
|
||||
}
|
||||
);
|
||||
expect(resultCalls).toHaveLength(0);
|
||||
|
||||
// Confirm paused message was printed
|
||||
expect(consoleSpy).toHaveBeenCalledWith('\nWorkflow paused — waiting for approval.');
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflowStatusCommand', () => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from '@archon/core';
|
||||
import { WORKFLOW_EVENT_TYPES, type WorkflowEventType } from '@archon/workflows/store';
|
||||
import { configureIsolation, getIsolationProvider } from '@archon/isolation';
|
||||
import { createLogger, getArchonHome } from '@archon/paths';
|
||||
import { createLogger } from '@archon/paths';
|
||||
import { createWorkflowDeps } from '@archon/core/workflows/store-adapter';
|
||||
import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery';
|
||||
import { resolveWorkflowName } from '@archon/workflows/router';
|
||||
|
|
@ -62,8 +62,6 @@ export interface WorkflowRunOptions {
|
|||
noWorktree?: boolean;
|
||||
resume?: boolean;
|
||||
codebaseId?: string; // Passed by resume/approve to skip path-based lookup
|
||||
/** When true, skip the env-leak-gate during auto-registration. */
|
||||
allowEnvKeys?: boolean;
|
||||
quiet?: boolean;
|
||||
verbose?: boolean;
|
||||
/** Platform conversation ID (e.g. `cli-{ts}-{rand}`), NOT a DB UUID. */
|
||||
|
|
@ -121,9 +119,9 @@ function renderWorkflowEvent(event: WorkflowEmitterEvent, verbose: boolean): voi
|
|||
*/
|
||||
async function loadWorkflows(cwd: string): Promise<WorkflowLoadResult> {
|
||||
try {
|
||||
return await discoverWorkflowsWithConfig(cwd, loadConfig, {
|
||||
globalSearchPath: getArchonHome(),
|
||||
});
|
||||
// Home-scoped workflows at ~/.archon/workflows/ are discovered automatically —
|
||||
// no option needed since the discovery helper reads them unconditionally.
|
||||
return await discoverWorkflowsWithConfig(cwd, loadConfig);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
throw new Error(
|
||||
|
|
@ -180,7 +178,7 @@ export async function workflowListCommand(cwd: string, json?: boolean): Promise<
|
|||
}
|
||||
|
||||
if (workflowEntries.length > 0) {
|
||||
console.log(`\nFound ${String(workflowEntries.length)} workflow(s):\n`);
|
||||
console.log(`\nFound ${workflowEntries.length} workflow(s):\n`);
|
||||
|
||||
for (const { workflow } of workflowEntries) {
|
||||
console.log(` ${workflow.name}`);
|
||||
|
|
@ -193,7 +191,7 @@ export async function workflowListCommand(cwd: string, json?: boolean): Promise<
|
|||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log(`\n${String(errors.length)} workflow(s) failed to load:\n`);
|
||||
console.log(`\n${errors.length} workflow(s) failed to load:\n`);
|
||||
for (const e of errors) {
|
||||
console.log(` ${e.filename}: ${e.error}`);
|
||||
}
|
||||
|
|
@ -263,6 +261,37 @@ export async function workflowRunCommand(
|
|||
);
|
||||
}
|
||||
|
||||
// Reconcile workflow-level worktree policy with invocation flags.
|
||||
// The workflow YAML's `worktree.enabled` pins isolation regardless of caller —
|
||||
// a mismatch between policy and flags is a user error we surface loudly
|
||||
// rather than silently applying one side and ignoring the other.
|
||||
const pinnedEnabled = workflow.worktree?.enabled;
|
||||
if (pinnedEnabled === false) {
|
||||
if (options.branchName !== undefined) {
|
||||
throw new Error(
|
||||
`Workflow '${workflow.name}' sets worktree.enabled: false (runs in live checkout).\n` +
|
||||
' --branch requires an isolated worktree.\n' +
|
||||
" Drop --branch or change the workflow's worktree.enabled."
|
||||
);
|
||||
}
|
||||
if (options.fromBranch !== undefined) {
|
||||
throw new Error(
|
||||
`Workflow '${workflow.name}' sets worktree.enabled: false (runs in live checkout).\n` +
|
||||
' --from/--from-branch only applies when a worktree is created.\n' +
|
||||
" Drop --from or change the workflow's worktree.enabled."
|
||||
);
|
||||
}
|
||||
// --no-worktree is redundant but not contradictory — silently accept.
|
||||
} else if (pinnedEnabled === true) {
|
||||
if (options.noWorktree) {
|
||||
throw new Error(
|
||||
`Workflow '${workflow.name}' sets worktree.enabled: true (requires a worktree).\n` +
|
||||
' --no-worktree conflicts with the workflow policy.\n' +
|
||||
" Drop --no-worktree or change the workflow's worktree.enabled."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Running workflow: ${workflowName}`);
|
||||
console.log(`Working directory: ${cwd}`);
|
||||
console.log('');
|
||||
|
|
@ -325,7 +354,7 @@ export async function workflowRunCommand(
|
|||
const repoRoot = await git.findRepoRoot(cwd);
|
||||
if (repoRoot) {
|
||||
try {
|
||||
const result = await registerRepository(repoRoot, options.allowEnvKeys, 'register-cli');
|
||||
const result = await registerRepository(repoRoot);
|
||||
codebase = await codebaseDb.getCodebase(result.codebaseId);
|
||||
if (!result.alreadyExisted) {
|
||||
getLog().info({ name: result.name }, 'cli.codebase_auto_registered');
|
||||
|
|
@ -405,8 +434,14 @@ export async function workflowRunCommand(
|
|||
console.log('');
|
||||
}
|
||||
|
||||
// Default to worktree isolation unless --no-worktree or --resume
|
||||
const wantsIsolation = !options.resume && !options.noWorktree;
|
||||
// Default to worktree isolation unless --no-worktree or --resume.
|
||||
// Workflow YAML `worktree.enabled` pins the decision — mismatches with CLI
|
||||
// flags are rejected above, so by this point the policy (if set) and flags
|
||||
// agree. `--resume` reuses an existing worktree and takes precedence over
|
||||
// the pinned policy to avoid disturbing a paused run.
|
||||
const flagWantsIsolation = !options.resume && !options.noWorktree;
|
||||
const wantsIsolation =
|
||||
!options.resume && pinnedEnabled !== undefined ? pinnedEnabled : flagWantsIsolation;
|
||||
|
||||
if (wantsIsolation && codebase) {
|
||||
// Auto-generate branch identifier from workflow name + timestamp when --branch not provided
|
||||
|
|
@ -591,6 +626,24 @@ export async function workflowRunCommand(
|
|||
renderWorkflowEvent(event, verbose ?? false);
|
||||
});
|
||||
|
||||
// Notify Web UI that a workflow is dispatching.
|
||||
// Mirrors the orchestrator dispatch message structure (category/segment/workflowDispatch),
|
||||
// but omits the rocket emoji and "(background)" qualifier since the CLI runs synchronously.
|
||||
// In the CLI path there is no separate worker conversation — the CLI itself
|
||||
// is both the dispatcher and the executor, so workerConversationId === conversationId.
|
||||
try {
|
||||
await adapter.sendMessage(conversationId, `Dispatching workflow: **${workflow.name}**`, {
|
||||
category: 'workflow_dispatch_status',
|
||||
segment: 'new',
|
||||
workflowDispatch: { workerConversationId: conversationId, workflowName: workflow.name },
|
||||
});
|
||||
} catch (dispatchError) {
|
||||
getLog().warn(
|
||||
{ err: dispatchError as Error, conversationId },
|
||||
'cli.workflow_dispatch_surface_failed'
|
||||
);
|
||||
}
|
||||
|
||||
// Execute workflow with workingCwd (may be worktree path)
|
||||
let result: Awaited<ReturnType<typeof executeWorkflow>>;
|
||||
try {
|
||||
|
|
@ -612,6 +665,22 @@ export async function workflowRunCommand(
|
|||
if (result.success && 'paused' in result && result.paused) {
|
||||
console.log('\nWorkflow paused — waiting for approval.');
|
||||
} else if (result.success) {
|
||||
// Surface workflow result to Web UI as a result card (mirrors orchestrator.ts result message).
|
||||
// Paused workflows are handled in the branch above and intentionally do not get a result card.
|
||||
if ('summary' in result && result.summary) {
|
||||
try {
|
||||
await adapter.sendMessage(conversationId, result.summary, {
|
||||
category: 'workflow_result',
|
||||
segment: 'new',
|
||||
workflowResult: { workflowName: workflow.name, runId: result.workflowRunId },
|
||||
});
|
||||
} catch (surfaceError) {
|
||||
getLog().warn(
|
||||
{ err: surfaceError as Error, conversationId },
|
||||
'cli.workflow_result_surface_failed'
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log('\nWorkflow completed successfully.');
|
||||
} else {
|
||||
throw new Error(`Workflow failed: ${result.error}`);
|
||||
|
|
@ -630,25 +699,25 @@ function formatAge(startedAt: Date | string): string {
|
|||
if (Number.isNaN(date.getTime())) return 'unknown';
|
||||
const ms = Date.now() - date.getTime();
|
||||
const secs = Math.floor(ms / 1000);
|
||||
if (secs < 60) return `${String(secs)}s`;
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${String(mins)}m`;
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${String(hours)}h ${String(mins % 60)}m`;
|
||||
if (hours < 24) return `${hours}h ${mins % 60}m`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${String(days)}d ${String(hours % 24)}h`;
|
||||
return `${days}d ${hours % 24}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in milliseconds as a compact string.
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${String(ms)}ms`;
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const secs = Math.round(ms / 100) / 10;
|
||||
if (secs < 60) return `${String(secs)}s`;
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
const remSecs = Math.round(secs % 60);
|
||||
return `${String(mins)}m${String(remSecs)}s`;
|
||||
return `${mins}m${remSecs}s`;
|
||||
}
|
||||
|
||||
interface NodeSummary {
|
||||
|
|
@ -732,20 +801,16 @@ export async function workflowStatusCommand(json?: boolean, verbose?: boolean):
|
|||
}
|
||||
|
||||
if (json) {
|
||||
let runsOutput: unknown[] = runs;
|
||||
if (verbose) {
|
||||
const eventsPerRun = await Promise.all(
|
||||
runs.map(run =>
|
||||
workflowEventsDb.listWorkflowEvents(run.id).catch(() => [] as WorkflowEventRow[])
|
||||
)
|
||||
);
|
||||
const runsWithEvents = runs.map((run, i) => ({
|
||||
...run,
|
||||
events: eventsPerRun[i],
|
||||
}));
|
||||
console.log(JSON.stringify({ runs: runsWithEvents }, null, 2));
|
||||
} else {
|
||||
console.log(JSON.stringify({ runs }, null, 2));
|
||||
runsOutput = runs.map((run, i) => ({ ...run, events: eventsPerRun[i] }));
|
||||
}
|
||||
console.log(JSON.stringify({ runs: runsOutput }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -754,7 +819,7 @@ export async function workflowStatusCommand(json?: boolean, verbose?: boolean):
|
|||
return;
|
||||
}
|
||||
|
||||
console.log(`\nActive workflows (${String(runs.length)}):\n`);
|
||||
console.log(`\nActive workflows (${runs.length}):\n`);
|
||||
for (const run of runs) {
|
||||
const age = formatAge(run.started_at);
|
||||
console.log(` ID: ${run.id}`);
|
||||
|
|
@ -968,9 +1033,9 @@ export async function workflowCleanupCommand(days: number): Promise<void> {
|
|||
try {
|
||||
const { count } = await workflowDb.deleteOldWorkflowRuns(days);
|
||||
if (count === 0) {
|
||||
console.log(`No workflow runs older than ${String(days)} days to clean up.`);
|
||||
console.log(`No workflow runs older than ${days} days to clean up.`);
|
||||
} else {
|
||||
console.log(`Deleted ${String(count)} workflow run(s) older than ${String(days)} days.`);
|
||||
console.log(`Deleted ${count} workflow run(s) older than ${days} days.`);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
"./types": "./src/types/index.ts",
|
||||
"./db": "./src/db/index.ts",
|
||||
"./db/*": "./src/db/*.ts",
|
||||
"./clients": "./src/clients/index.ts",
|
||||
"./operations": "./src/operations/index.ts",
|
||||
"./operations/*": "./src/operations/*.ts",
|
||||
"./workflows": "./src/workflows/index.ts",
|
||||
|
|
@ -23,17 +22,16 @@
|
|||
"./state/*": "./src/state/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test src/clients/codex-binary-guard.test.ts && bun test src/utils/codex-binary-resolver.test.ts && bun test src/utils/codex-binary-resolver-dev.test.ts && bun test src/clients/claude.test.ts src/clients/codex.test.ts src/clients/factory.test.ts && 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/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.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/workflow-events.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/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/utils/env-leak-scanner.test.ts src/config/ src/state/ && 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/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.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/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/workflow-events.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/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/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/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts",
|
||||
"type-check": "bun x tsc --noEmit",
|
||||
"build": "echo 'No build needed - Bun runs TypeScript directly'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.89",
|
||||
"@archon/git": "workspace:*",
|
||||
"@archon/isolation": "workspace:*",
|
||||
"@archon/paths": "workspace:*",
|
||||
"@archon/providers": "workspace:*",
|
||||
"@archon/workflows": "workspace:*",
|
||||
"@openai/codex-sdk": "^0.116.0",
|
||||
"pg": "^8.11.0",
|
||||
"zod": "^3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,657 +0,0 @@
|
|||
/**
|
||||
* Claude Agent SDK wrapper
|
||||
* Provides async generator interface for streaming Claude responses
|
||||
*
|
||||
* Type Safety Pattern:
|
||||
* - Uses `Options` type from SDK for query configuration
|
||||
* - SDK message types (SDKMessage, SDKAssistantMessage, etc.) have strict
|
||||
* type checking that requires explicit type handling for content blocks
|
||||
* - Content blocks are typed via inline assertions for clarity
|
||||
*
|
||||
* Authentication:
|
||||
* - CLAUDE_USE_GLOBAL_AUTH=true: Use global auth from `claude /login`, filter env tokens
|
||||
* - CLAUDE_USE_GLOBAL_AUTH=false: Use explicit tokens from env vars
|
||||
* - Not set: Auto-detect - use tokens if present in env, otherwise global auth
|
||||
*/
|
||||
import {
|
||||
query,
|
||||
type Options,
|
||||
type HookCallback,
|
||||
type HookCallbackMatcher,
|
||||
} from '@anthropic-ai/claude-agent-sdk';
|
||||
// The `/embed` entry point uses `import ... with { type: 'file' }` to embed
|
||||
// the SDK's `cli.js` into the compiled binary's $bunfs virtual filesystem,
|
||||
// then extracts it to a temp path at runtime so the subprocess can exec it.
|
||||
// Without this, the SDK falls back to resolving `cli.js` from
|
||||
// `import.meta.url` of its own module — which bun freezes at build time to
|
||||
// the build host's absolute node_modules path, producing a "Module not found
|
||||
// /Users/runner/..." error on any machine other than the CI runner.
|
||||
// Safe in dev too: resolves to the real on-disk cli.js.
|
||||
import cliPath from '@anthropic-ai/claude-agent-sdk/embed';
|
||||
import {
|
||||
type AssistantRequestOptions,
|
||||
type IAssistantClient,
|
||||
type MessageChunk,
|
||||
type TokenUsage,
|
||||
} from '../types';
|
||||
import { createLogger } from '@archon/paths';
|
||||
// No env filtering here — process.env is already clean:
|
||||
// stripCwdEnv() at entry point stripped CWD .env keys + CLAUDECODE markers,
|
||||
// then ~/.archon/.env was loaded as the trusted source. All keys the user sets
|
||||
// in ~/.archon/.env are intentional and pass through to the subprocess.
|
||||
import { scanPathForSensitiveKeys, EnvLeakError } from '../utils/env-leak-scanner';
|
||||
import * as codebaseDb from '../db/codebases';
|
||||
import { loadConfig } from '../config/config-loader';
|
||||
|
||||
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
|
||||
let cachedLog: ReturnType<typeof createLogger> | undefined;
|
||||
function getLog(): ReturnType<typeof createLogger> {
|
||||
if (!cachedLog) cachedLog = createLogger('client.claude');
|
||||
return cachedLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block type for assistant messages
|
||||
* Represents text or tool_use blocks from Claude API responses
|
||||
*/
|
||||
interface ContentBlock {
|
||||
type: 'text' | 'tool_use';
|
||||
text?: string;
|
||||
name?: string;
|
||||
input?: Record<string, unknown>;
|
||||
/** Stable Anthropic `tool_use_id` — used to pair `tool_call`/`tool_result` events. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
function normalizeClaudeUsage(usage?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
}): TokenUsage | undefined {
|
||||
if (!usage) return undefined;
|
||||
const input = usage.input_tokens;
|
||||
const output = usage.output_tokens;
|
||||
if (typeof input !== 'number' || typeof output !== 'number') return undefined;
|
||||
const total = usage.total_tokens;
|
||||
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
...(typeof total === 'number' ? { total } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment for Claude subprocess.
|
||||
*
|
||||
* process.env is already clean at this point:
|
||||
* - stripCwdEnv() at entry point removed CWD .env keys + CLAUDECODE markers
|
||||
* - ~/.archon/.env loaded with override:true as the trusted source
|
||||
*
|
||||
* Auth mode is determined by the SDK based on what tokens are present:
|
||||
* - Tokens in env → SDK uses them (explicit auth)
|
||||
* - No tokens → SDK uses `claude /login` credentials (global auth)
|
||||
* - User controls this by what they put in ~/.archon/.env
|
||||
*
|
||||
* We log the detected mode for diagnostics but don't filter — the user's
|
||||
* config is trusted. See coleam00/Archon#1067 for design rationale.
|
||||
*/
|
||||
function buildSubprocessEnv(): NodeJS.ProcessEnv {
|
||||
const hasExplicitTokens = Boolean(
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN ?? process.env.CLAUDE_API_KEY
|
||||
);
|
||||
const authMode = hasExplicitTokens ? 'explicit' : 'global';
|
||||
getLog().info(
|
||||
{ authMode },
|
||||
authMode === 'global' ? 'using_global_auth' : 'using_explicit_tokens'
|
||||
);
|
||||
|
||||
return { ...process.env };
|
||||
}
|
||||
|
||||
/** Max retries for transient subprocess failures (3 = 4 total attempts).
|
||||
* SDK subprocess crashes (exit code 1) are often intermittent — AJV schema validation
|
||||
* regressions, stale HTTP/2 connections, and other transient SDK issues typically
|
||||
* succeed on retry 3 or 4. See: anthropics/claude-code#22973, claude-code-action#853 */
|
||||
const MAX_SUBPROCESS_RETRIES = 3;
|
||||
|
||||
/** Delay between retries in milliseconds */
|
||||
const RETRY_BASE_DELAY_MS = 2000;
|
||||
|
||||
/** Patterns indicating rate limiting in stderr/error messages */
|
||||
const RATE_LIMIT_PATTERNS = ['rate limit', 'too many requests', '429', 'overloaded'];
|
||||
|
||||
/** Patterns indicating auth issues in stderr/error messages */
|
||||
const AUTH_PATTERNS = [
|
||||
'credit balance',
|
||||
'unauthorized',
|
||||
'authentication',
|
||||
'invalid token',
|
||||
'401',
|
||||
'403',
|
||||
];
|
||||
|
||||
/** Patterns indicating the subprocess crashed (transient, worth retrying) */
|
||||
const SUBPROCESS_CRASH_PATTERNS = [
|
||||
'exited with code',
|
||||
'killed',
|
||||
'signal',
|
||||
// "Operation aborted" can appear when the SDK's PostToolUse hook tries to write()
|
||||
// back to a subprocess pipe that was closed by an abort signal. This is a race
|
||||
// condition in SDK cleanup — safe to classify as a crash and retry.
|
||||
'operation aborted',
|
||||
];
|
||||
|
||||
function classifySubprocessError(
|
||||
errorMessage: string,
|
||||
stderrOutput: string
|
||||
): 'rate_limit' | 'auth' | 'crash' | 'unknown' {
|
||||
const combined = `${errorMessage} ${stderrOutput}`.toLowerCase();
|
||||
if (RATE_LIMIT_PATTERNS.some(p => combined.includes(p))) return 'rate_limit';
|
||||
if (AUTH_PATTERNS.some(p => combined.includes(p))) return 'auth';
|
||||
if (SUBPROCESS_CRASH_PATTERNS.some(p => combined.includes(p))) return 'crash';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Default timeout for first SDK message (ms). Configurable via env var. */
|
||||
function getFirstEventTimeoutMs(): number {
|
||||
const raw = process.env.ARCHON_CLAUDE_FIRST_EVENT_TIMEOUT_MS;
|
||||
if (raw) {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return 60_000;
|
||||
}
|
||||
|
||||
/** Build a diagnostic payload for claude.first_event_timeout log */
|
||||
function buildFirstEventHangDiagnostics(
|
||||
subprocessEnv: Record<string, string>,
|
||||
model: string | undefined
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
subprocessEnvKeys: Object.keys(subprocessEnv),
|
||||
parentClaudeKeys: Object.keys(process.env).filter(
|
||||
k => k === 'CLAUDECODE' || k.startsWith('CLAUDE_CODE_') || k.startsWith('ANTHROPIC_')
|
||||
),
|
||||
model,
|
||||
platform: process.platform,
|
||||
uid: getProcessUid(),
|
||||
isTTY: process.stdout.isTTY ?? false,
|
||||
claudeCode: process.env.CLAUDECODE,
|
||||
claudeCodeEntrypoint: process.env.CLAUDE_CODE_ENTRYPOINT,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sentinel error class to identify timeout rejections in withFirstMessageTimeout. */
|
||||
class FirstEventTimeoutError extends Error {}
|
||||
|
||||
/**
|
||||
* Wraps an async generator so that the first call to .next() must resolve
|
||||
* within `timeoutMs`. If it doesn't, aborts the controller and throws a
|
||||
* descriptive error. Subsequent .next() calls are forwarded directly.
|
||||
*
|
||||
* Uses Promise.race() — not just AbortController — because the pathological
|
||||
* case is "SDK ignores abort", so we need an independent unblocking mechanism.
|
||||
*/
|
||||
export async function* withFirstMessageTimeout<T>(
|
||||
gen: AsyncGenerator<T>,
|
||||
controller: AbortController,
|
||||
timeoutMs: number,
|
||||
diagnostics: Record<string, unknown>
|
||||
): AsyncGenerator<T> {
|
||||
// Race first event against timeout
|
||||
let timerId: ReturnType<typeof setTimeout> | undefined;
|
||||
let firstValue: IteratorResult<T>;
|
||||
try {
|
||||
firstValue = await Promise.race([
|
||||
gen.next(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timerId = setTimeout(() => {
|
||||
reject(new FirstEventTimeoutError());
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} catch (err) {
|
||||
if (err instanceof FirstEventTimeoutError) {
|
||||
controller.abort();
|
||||
getLog().error({ ...diagnostics, timeoutMs }, 'claude.first_event_timeout');
|
||||
throw new Error(
|
||||
'Claude Code subprocess produced no output within ' +
|
||||
timeoutMs +
|
||||
'ms. ' +
|
||||
'See logs for claude.first_event_timeout diagnostic dump. ' +
|
||||
'Details: https://github.com/coleam00/Archon/issues/1067'
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
|
||||
if (firstValue.done) return;
|
||||
yield firstValue.value;
|
||||
|
||||
// Forward remaining events directly
|
||||
yield* gen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current process UID, or undefined on platforms that don't support it (e.g. Windows).
|
||||
* Exported for testing — spyOn(claudeModule, 'getProcessUid') works cross-platform.
|
||||
*/
|
||||
export function getProcessUid(): number | undefined {
|
||||
return typeof process.getuid === 'function' ? process.getuid() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude AI assistant client
|
||||
* Implements generic IAssistantClient interface
|
||||
*/
|
||||
export class ClaudeClient implements IAssistantClient {
|
||||
private readonly retryBaseDelayMs: number;
|
||||
|
||||
constructor(options?: { retryBaseDelayMs?: number }) {
|
||||
// Claude Code SDK silently rejects bypassPermissions when running as root (UID 0).
|
||||
// Check once at construction time so the error surfaces early, not on first query.
|
||||
// IS_SANDBOX=1 bypasses this check — the SDK itself honours this env var in sandboxed
|
||||
// environments (Docker, VPS, CI) where running as root is expected.
|
||||
if (getProcessUid() === 0 && process.env.IS_SANDBOX !== '1') {
|
||||
throw new Error(
|
||||
'Claude Code SDK does not support bypassPermissions when running as root (UID 0). ' +
|
||||
'Run as a non-root user, set IS_SANDBOX=1, or use the Dockerfile which creates a non-root appuser.'
|
||||
);
|
||||
}
|
||||
this.retryBaseDelayMs = options?.retryBaseDelayMs ?? RETRY_BASE_DELAY_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a query to Claude and stream responses.
|
||||
* Includes retry logic for transient failures (up to 3 retries with exponential backoff).
|
||||
* Enriches errors with stderr context and classification.
|
||||
*/
|
||||
async *sendQuery(
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
resumeSessionId?: string,
|
||||
requestOptions?: AssistantRequestOptions
|
||||
): AsyncGenerator<MessageChunk> {
|
||||
// Pre-spawn: check for env key leak if codebase is not explicitly consented.
|
||||
// Use prefix lookup so worktree paths (e.g. .../worktrees/feature-branch) still
|
||||
// match the registered source cwd (e.g. .../source).
|
||||
const codebase =
|
||||
(await codebaseDb.findCodebaseByDefaultCwd(cwd)) ??
|
||||
(await codebaseDb.findCodebaseByPathPrefix(cwd));
|
||||
if (codebase && !codebase.allow_env_keys) {
|
||||
// Fail-closed: a config load failure (corrupt YAML, permission denied)
|
||||
// must NOT silently bypass the gate. Catch, log, and treat as
|
||||
// `allowTargetRepoKeys = false` so the scanner still runs.
|
||||
let allowTargetRepoKeys = false;
|
||||
try {
|
||||
const merged = await loadConfig(cwd);
|
||||
allowTargetRepoKeys = merged.allowTargetRepoKeys;
|
||||
} catch (configErr) {
|
||||
getLog().warn({ err: configErr, cwd }, 'env_leak_gate.config_load_failed_gate_enforced');
|
||||
}
|
||||
if (!allowTargetRepoKeys) {
|
||||
const report = scanPathForSensitiveKeys(cwd);
|
||||
if (report.findings.length > 0) {
|
||||
throw new EnvLeakError(report, 'spawn-existing');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: If subprocess crashes mid-stream after yielding chunks, those chunks
|
||||
// are already consumed by the caller. Retry starts a fresh subprocess, so the
|
||||
// caller may receive partial output from the failed attempt followed by full
|
||||
// output from the retry. This is a known limitation of async generator retries.
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_SUBPROCESS_RETRIES; attempt++) {
|
||||
// Check if already aborted before starting attempt
|
||||
if (requestOptions?.abortSignal?.aborted) {
|
||||
throw new Error('Query aborted');
|
||||
}
|
||||
|
||||
const stderrLines: string[] = [];
|
||||
const toolResultQueue: { toolName: string; toolOutput: string; toolCallId?: string }[] = [];
|
||||
|
||||
// Create per-attempt abort controller and wire to caller's signal
|
||||
const controller = new AbortController();
|
||||
if (requestOptions?.abortSignal) {
|
||||
requestOptions.abortSignal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
controller.abort();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
const options: Options = {
|
||||
cwd,
|
||||
pathToClaudeCodeExecutable: cliPath,
|
||||
env: requestOptions?.env
|
||||
? { ...buildSubprocessEnv(), ...requestOptions.env }
|
||||
: buildSubprocessEnv(),
|
||||
model: requestOptions?.model,
|
||||
abortController: controller,
|
||||
...(requestOptions?.tools !== undefined ? { tools: requestOptions.tools } : {}),
|
||||
...(requestOptions?.disallowedTools !== undefined
|
||||
? { disallowedTools: requestOptions.disallowedTools }
|
||||
: {}),
|
||||
// Pass outputFormat for json_schema structured output (Claude Agent SDK v0.2.45+)
|
||||
...(requestOptions?.outputFormat !== undefined
|
||||
? { outputFormat: requestOptions.outputFormat }
|
||||
: {}),
|
||||
// Note: hooks are merged below (line with `hooks: { ... }`) — not spread here
|
||||
// Pass MCP servers for per-node MCP support (Claude Agent SDK v0.2.74+)
|
||||
...(requestOptions?.mcpServers !== undefined
|
||||
? { mcpServers: requestOptions.mcpServers }
|
||||
: {}),
|
||||
// Pass allowedTools for MCP tool wildcards (e.g., 'mcp__github__*')
|
||||
...(requestOptions?.allowedTools !== undefined
|
||||
? { allowedTools: requestOptions.allowedTools }
|
||||
: {}),
|
||||
// Pass agents/agent for per-node skill scoping via AgentDefinition wrapping
|
||||
...(requestOptions?.agents !== undefined ? { agents: requestOptions.agents } : {}),
|
||||
...(requestOptions?.agent !== undefined ? { agent: requestOptions.agent } : {}),
|
||||
// Skip writing session transcripts to ~/.claude/projects/ — Archon manages its own
|
||||
// session persistence. persistSession: false reduces disk I/O and keeps the session
|
||||
// directory clean. Claude Agent SDK v0.2.74+.
|
||||
...(requestOptions?.persistSession !== undefined
|
||||
? { persistSession: requestOptions.persistSession }
|
||||
: {}),
|
||||
// When forkSession is true, the SDK copies the prior session's history into a new
|
||||
// session file, leaving the original untouched — safe to use on retries.
|
||||
...(requestOptions?.forkSession !== undefined
|
||||
? { forkSession: requestOptions.forkSession }
|
||||
: {}),
|
||||
// Forward Claude-only SDK options (effort, thinking, maxBudgetUsd, fallbackModel, betas, sandbox)
|
||||
...(requestOptions?.effort !== undefined ? { effort: requestOptions.effort } : {}),
|
||||
...(requestOptions?.thinking !== undefined ? { thinking: requestOptions.thinking } : {}),
|
||||
...(requestOptions?.maxBudgetUsd !== undefined
|
||||
? { maxBudgetUsd: requestOptions.maxBudgetUsd }
|
||||
: {}),
|
||||
...(requestOptions?.fallbackModel !== undefined
|
||||
? { fallbackModel: requestOptions.fallbackModel }
|
||||
: {}),
|
||||
// betas: string[] from user config; SDK expects SdkBeta[] (string literal union).
|
||||
// User-provided values are validated upstream — cast is safe.
|
||||
...(requestOptions?.betas !== undefined
|
||||
? { betas: requestOptions.betas as Options['betas'] }
|
||||
: {}),
|
||||
...(requestOptions?.sandbox !== undefined ? { sandbox: requestOptions.sandbox } : {}),
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
systemPrompt: requestOptions?.systemPrompt ?? { type: 'preset', preset: 'claude_code' },
|
||||
settingSources: requestOptions?.settingSources ?? ['project'],
|
||||
// Merge user-provided hooks with our PostToolUse capture hook
|
||||
hooks: {
|
||||
...(requestOptions?.hooks ?? {}),
|
||||
PostToolUse: [
|
||||
...((requestOptions?.hooks?.PostToolUse ?? []) as HookCallbackMatcher[]),
|
||||
{
|
||||
hooks: [
|
||||
(async (input: Record<string, unknown>): Promise<{ continue: true }> => {
|
||||
const toolName = (input as { tool_name?: string }).tool_name ?? 'unknown';
|
||||
const toolUseId = (input as { tool_use_id?: string }).tool_use_id;
|
||||
const toolResponse = (input as { tool_response?: unknown }).tool_response;
|
||||
const output =
|
||||
typeof toolResponse === 'string'
|
||||
? toolResponse
|
||||
: JSON.stringify(toolResponse ?? '');
|
||||
// Truncate large outputs (e.g., file reads) to prevent DB bloat
|
||||
const maxLen = 10_000;
|
||||
toolResultQueue.push({
|
||||
toolName,
|
||||
toolOutput: output.length > maxLen ? output.slice(0, maxLen) + '...' : output,
|
||||
...(toolUseId !== undefined ? { toolCallId: toolUseId } : {}),
|
||||
});
|
||||
return { continue: true };
|
||||
}) as HookCallback,
|
||||
],
|
||||
},
|
||||
],
|
||||
// Without this, errored / interrupted / permission-denied tools never produce
|
||||
// a paired tool_result chunk and the corresponding UI card spins forever.
|
||||
// SDK type: PostToolUseFailureHookInput { tool_name, tool_use_id, error, is_interrupt? }
|
||||
PostToolUseFailure: [
|
||||
...((requestOptions?.hooks?.PostToolUseFailure ?? []) as HookCallbackMatcher[]),
|
||||
{
|
||||
hooks: [
|
||||
(async (input: Record<string, unknown>): Promise<{ continue: true }> => {
|
||||
// Always return { continue: true } even on internal errors so a
|
||||
// malformed SDK payload can never crash the hook dispatch silently.
|
||||
try {
|
||||
const toolName = (input as { tool_name?: string }).tool_name ?? 'unknown';
|
||||
const toolUseId = (input as { tool_use_id?: string }).tool_use_id;
|
||||
const rawError = (input as { error?: string }).error;
|
||||
if (rawError === undefined) {
|
||||
getLog().debug({ input }, 'claude.post_tool_use_failure_no_error_field');
|
||||
}
|
||||
const errorText = rawError ?? 'tool failed';
|
||||
const isInterrupt = (input as { is_interrupt?: boolean }).is_interrupt === true;
|
||||
const prefix = isInterrupt ? '⚠️ Interrupted' : '❌ Error';
|
||||
toolResultQueue.push({
|
||||
toolName,
|
||||
toolOutput: `${prefix}: ${errorText}`,
|
||||
...(toolUseId !== undefined ? { toolCallId: toolUseId } : {}),
|
||||
});
|
||||
} catch (e) {
|
||||
getLog().error({ err: e, input }, 'claude.post_tool_use_failure_hook_error');
|
||||
}
|
||||
return { continue: true };
|
||||
}) as HookCallback,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
stderr: (data: string) => {
|
||||
const output = data.trim();
|
||||
if (!output) return;
|
||||
|
||||
// Always capture stderr for diagnostics — previous filtering discarded
|
||||
// useful SDK startup output, leaving stderrContext empty on crashes.
|
||||
stderrLines.push(output);
|
||||
|
||||
const isError =
|
||||
output.toLowerCase().includes('error') ||
|
||||
output.toLowerCase().includes('fatal') ||
|
||||
output.toLowerCase().includes('failed') ||
|
||||
output.toLowerCase().includes('exception') ||
|
||||
output.includes('at ') ||
|
||||
output.includes('Error:');
|
||||
|
||||
const isInfoMessage =
|
||||
output.includes('Spawning Claude Code') ||
|
||||
output.includes('--output-format') ||
|
||||
output.includes('--permission-mode');
|
||||
|
||||
if (isError && !isInfoMessage) {
|
||||
getLog().error({ stderr: output }, 'subprocess_error');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (resumeSessionId) {
|
||||
options.resume = resumeSessionId;
|
||||
getLog().debug(
|
||||
{ sessionId: resumeSessionId, forkSession: requestOptions?.forkSession },
|
||||
'resuming_session'
|
||||
);
|
||||
} else {
|
||||
getLog().debug({ cwd, attempt }, 'starting_new_session');
|
||||
}
|
||||
|
||||
try {
|
||||
const rawEvents = query({ prompt, options });
|
||||
const timeoutMs = getFirstEventTimeoutMs();
|
||||
const diagnostics = buildFirstEventHangDiagnostics(
|
||||
options.env as Record<string, string>,
|
||||
options.model
|
||||
);
|
||||
const events = withFirstMessageTimeout(rawEvents, controller, timeoutMs, diagnostics);
|
||||
for await (const msg of events) {
|
||||
// Drain tool results captured by PostToolUse hook before processing the next message
|
||||
while (toolResultQueue.length > 0) {
|
||||
const tr = toolResultQueue.shift();
|
||||
if (tr) {
|
||||
yield {
|
||||
type: 'tool_result',
|
||||
toolName: tr.toolName,
|
||||
toolOutput: tr.toolOutput,
|
||||
...(tr.toolCallId !== undefined ? { toolCallId: tr.toolCallId } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'assistant') {
|
||||
const message = msg as { message: { content: ContentBlock[] } };
|
||||
const content = message.message.content;
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
yield { type: 'assistant', content: block.text };
|
||||
} else if (block.type === 'tool_use' && block.name) {
|
||||
yield {
|
||||
type: 'tool',
|
||||
toolName: block.name,
|
||||
toolInput: block.input ?? {},
|
||||
...(block.id !== undefined ? { toolCallId: block.id } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'system') {
|
||||
// Check MCP server connection status from system/init
|
||||
const sysMsg = msg as {
|
||||
subtype?: string;
|
||||
mcp_servers?: { name: string; status: string }[];
|
||||
};
|
||||
if (sysMsg.subtype === 'init' && sysMsg.mcp_servers) {
|
||||
const failed = sysMsg.mcp_servers.filter(s => s.status !== 'connected');
|
||||
if (failed.length > 0) {
|
||||
const names = failed.map(s => `${s.name} (${s.status})`).join(', ');
|
||||
yield { type: 'system', content: `MCP server connection failed: ${names}` };
|
||||
}
|
||||
} else {
|
||||
getLog().debug({ subtype: sysMsg.subtype }, 'claude.system_message_unhandled');
|
||||
}
|
||||
} else if (msg.type === 'rate_limit_event') {
|
||||
const rateLimitMsg = msg as { rate_limit_info?: Record<string, unknown> };
|
||||
getLog().warn(
|
||||
{ rateLimitInfo: rateLimitMsg.rate_limit_info },
|
||||
'claude.rate_limit_event'
|
||||
);
|
||||
yield { type: 'rate_limit', rateLimitInfo: rateLimitMsg.rate_limit_info ?? {} };
|
||||
} else if (msg.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;
|
||||
model_usage?: Record<
|
||||
string,
|
||||
{
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
const tokens = normalizeClaudeUsage(resultMsg.usage);
|
||||
yield {
|
||||
type: 'result',
|
||||
sessionId: resultMsg.session_id,
|
||||
...(tokens ? { tokens } : {}),
|
||||
...(resultMsg.structured_output !== undefined
|
||||
? { structuredOutput: resultMsg.structured_output }
|
||||
: {}),
|
||||
...(resultMsg.is_error ? { isError: true, errorSubtype: resultMsg.subtype } : {}),
|
||||
...(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<string, unknown> }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Drain any remaining tool results from the hook queue.
|
||||
// Must mirror the in-loop drain — PostToolUseFailure results commonly land
|
||||
// here (they fire just before the SDK's terminal `result` message), so
|
||||
// dropping toolCallId here would defeat the stable-pairing fix.
|
||||
while (toolResultQueue.length > 0) {
|
||||
const tr = toolResultQueue.shift();
|
||||
if (tr) {
|
||||
yield {
|
||||
type: 'tool_result',
|
||||
toolName: tr.toolName,
|
||||
toolOutput: tr.toolOutput,
|
||||
...(tr.toolCallId !== undefined ? { toolCallId: tr.toolCallId } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return; // Success - exit retry loop
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
||||
// Don't retry aborted queries
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error('Query aborted');
|
||||
}
|
||||
|
||||
const stderrContext = stderrLines.join('\n');
|
||||
const errorClass = classifySubprocessError(err.message, stderrContext);
|
||||
|
||||
getLog().error(
|
||||
{ err, stderrContext, errorClass, attempt, maxRetries: MAX_SUBPROCESS_RETRIES },
|
||||
'query_error'
|
||||
);
|
||||
|
||||
// Don't retry auth errors - they won't resolve
|
||||
if (errorClass === 'auth') {
|
||||
const enrichedError = new Error(
|
||||
`Claude Code auth error: ${err.message}${stderrContext ? ` (${stderrContext})` : ''}`
|
||||
);
|
||||
enrichedError.cause = error;
|
||||
throw enrichedError;
|
||||
}
|
||||
|
||||
// Retry transient failures (rate limit, crash)
|
||||
if (
|
||||
attempt < MAX_SUBPROCESS_RETRIES &&
|
||||
(errorClass === 'rate_limit' || errorClass === 'crash')
|
||||
) {
|
||||
const delayMs = this.retryBaseDelayMs * Math.pow(2, attempt);
|
||||
getLog().info({ attempt, delayMs, errorClass }, 'retrying_subprocess');
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
lastError = err;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Final failure - enrich and throw
|
||||
const enrichedMessage = stderrContext
|
||||
? `Claude Code ${errorClass}: ${err.message} (stderr: ${stderrContext})`
|
||||
: `Claude Code ${errorClass}: ${err.message}`;
|
||||
const enrichedError = new Error(enrichedMessage);
|
||||
enrichedError.cause = error;
|
||||
throw enrichedError;
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here, but handle defensively
|
||||
throw lastError ?? new Error('Claude Code query failed after retries');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the assistant type identifier
|
||||
*/
|
||||
getType(): string {
|
||||
return 'claude';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,581 +0,0 @@
|
|||
/**
|
||||
* Codex SDK wrapper
|
||||
* Provides async generator interface for streaming Codex responses
|
||||
*
|
||||
* With Bun runtime, we can directly import ESM packages without the
|
||||
* dynamic import workaround that was needed for CommonJS/Node.js.
|
||||
*/
|
||||
import {
|
||||
Codex,
|
||||
type ThreadOptions,
|
||||
type TurnOptions,
|
||||
type TurnCompletedEvent,
|
||||
} from '@openai/codex-sdk';
|
||||
import {
|
||||
type AssistantRequestOptions,
|
||||
type IAssistantClient,
|
||||
type MessageChunk,
|
||||
type TokenUsage,
|
||||
} from '../types';
|
||||
import { createLogger } from '@archon/paths';
|
||||
import { scanPathForSensitiveKeys, EnvLeakError } from '../utils/env-leak-scanner';
|
||||
import * as codebaseDb from '../db/codebases';
|
||||
import { loadConfig } from '../config/config-loader';
|
||||
import { resolveCodexBinaryPath } from '../utils/codex-binary-resolver';
|
||||
|
||||
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
|
||||
let cachedLog: ReturnType<typeof createLogger> | undefined;
|
||||
function getLog(): ReturnType<typeof createLogger> {
|
||||
if (!cachedLog) cachedLog = createLogger('client.codex');
|
||||
return cachedLog;
|
||||
}
|
||||
|
||||
// Singleton Codex instance (async because binary path resolution is async)
|
||||
let codexInstance: Codex | null = null;
|
||||
let codexInitPromise: Promise<Codex> | null = null;
|
||||
|
||||
/** Reset singleton state. Exported for tests only. */
|
||||
export function resetCodexSingleton(): void {
|
||||
codexInstance = null;
|
||||
codexInitPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create Codex SDK instance.
|
||||
* Async because in compiled binary mode, binary path resolution is async.
|
||||
* Once initialized, the binary path is fixed for the process lifetime.
|
||||
*/
|
||||
async function getCodex(configCodexBinaryPath?: string): Promise<Codex> {
|
||||
if (codexInstance) return codexInstance;
|
||||
|
||||
// Prevent concurrent initialization race
|
||||
if (!codexInitPromise) {
|
||||
codexInitPromise = (async (): Promise<Codex> => {
|
||||
const codexPathOverride = await resolveCodexBinaryPath(configCodexBinaryPath);
|
||||
const instance = new Codex({ codexPathOverride });
|
||||
codexInstance = instance;
|
||||
return instance;
|
||||
})().catch(err => {
|
||||
// Clear promise so next call can retry (e.g. after user installs Codex)
|
||||
codexInitPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return codexInitPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build thread options for Codex SDK
|
||||
* Extracted to avoid duplication across thread creation paths
|
||||
*/
|
||||
function buildThreadOptions(cwd: string, options?: AssistantRequestOptions): ThreadOptions {
|
||||
return {
|
||||
workingDirectory: cwd,
|
||||
skipGitRepoCheck: true,
|
||||
sandboxMode: 'danger-full-access', // Full filesystem access (needed for git worktree operations)
|
||||
networkAccessEnabled: true, // Allow network calls (GitHub CLI, HTTP requests)
|
||||
approvalPolicy: 'never', // Auto-approve all operations without user confirmation
|
||||
model: options?.model,
|
||||
modelReasoningEffort: options?.modelReasoningEffort,
|
||||
webSearchMode: options?.webSearchMode,
|
||||
additionalDirectories: options?.additionalDirectories,
|
||||
};
|
||||
}
|
||||
|
||||
const CODEX_MODEL_FALLBACKS: Record<string, string> = {
|
||||
'gpt-5.3-codex': 'gpt-5.2-codex',
|
||||
};
|
||||
|
||||
function isModelAccessError(errorMessage: string): boolean {
|
||||
const m = errorMessage.toLowerCase();
|
||||
const hasModel = m.includes('model');
|
||||
const hasAvailabilitySignal =
|
||||
m.includes('not available') || m.includes('not found') || m.includes('access denied');
|
||||
return hasModel && hasAvailabilitySignal;
|
||||
}
|
||||
|
||||
function buildModelAccessMessage(model?: string): string {
|
||||
const normalizedModel = model?.trim();
|
||||
const selectedModel = normalizedModel || 'the configured model';
|
||||
const suggested = normalizedModel ? CODEX_MODEL_FALLBACKS[normalizedModel] : undefined;
|
||||
|
||||
const fixLine = suggested
|
||||
? `To fix: update your model in ~/.archon/config.yaml:\n assistants:\n codex:\n model: ${suggested}`
|
||||
: 'To fix: update your model in ~/.archon/config.yaml to one your account can access.';
|
||||
|
||||
const workflowLine = suggested
|
||||
? `Or set it per-workflow with \`model: ${suggested}\` in workflow YAML.`
|
||||
: 'Or set it per-workflow with a valid `model:` in workflow YAML.';
|
||||
|
||||
return `❌ Model "${selectedModel}" is not available for your account.\n\n${fixLine}\n\n${workflowLine}`;
|
||||
}
|
||||
|
||||
/** Max retries for transient failures (3 = 4 total attempts).
|
||||
* Mirrors ClaudeClient retry logic — Codex process crashes are similarly intermittent. */
|
||||
const MAX_SUBPROCESS_RETRIES = 3;
|
||||
|
||||
/** Delay between retries in milliseconds */
|
||||
const RETRY_BASE_DELAY_MS = 2000;
|
||||
|
||||
/** Patterns indicating rate limiting in error messages */
|
||||
const RATE_LIMIT_PATTERNS = ['rate limit', 'too many requests', '429', 'overloaded'];
|
||||
|
||||
/** Patterns indicating auth issues in error messages */
|
||||
const AUTH_PATTERNS = [
|
||||
'credit balance',
|
||||
'unauthorized',
|
||||
'authentication',
|
||||
'invalid token',
|
||||
'401',
|
||||
'403',
|
||||
];
|
||||
|
||||
/** Patterns indicating a transient process crash (worth retrying) */
|
||||
const SUBPROCESS_CRASH_PATTERNS = ['exited with code', 'killed', 'signal', 'codex exec'];
|
||||
|
||||
function classifyCodexError(
|
||||
errorMessage: string
|
||||
): 'rate_limit' | 'auth' | 'crash' | 'model_access' | 'unknown' {
|
||||
if (isModelAccessError(errorMessage)) return 'model_access';
|
||||
const m = errorMessage.toLowerCase();
|
||||
if (RATE_LIMIT_PATTERNS.some(p => m.includes(p))) return 'rate_limit';
|
||||
if (AUTH_PATTERNS.some(p => m.includes(p))) return 'auth';
|
||||
if (SUBPROCESS_CRASH_PATTERNS.some(p => m.includes(p))) return 'crash';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function extractUsageFromCodexEvent(event: TurnCompletedEvent): TokenUsage {
|
||||
if (!event.usage) {
|
||||
getLog().warn({ eventType: event.type }, 'codex.usage_null_on_turn_completed');
|
||||
return { input: 0, output: 0 };
|
||||
}
|
||||
return {
|
||||
input: event.usage.input_tokens,
|
||||
output: event.usage.output_tokens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex AI assistant client
|
||||
* Implements generic IAssistantClient interface
|
||||
*/
|
||||
export class CodexClient implements IAssistantClient {
|
||||
private readonly retryBaseDelayMs: number;
|
||||
|
||||
constructor(options?: { retryBaseDelayMs?: number }) {
|
||||
this.retryBaseDelayMs = options?.retryBaseDelayMs ?? RETRY_BASE_DELAY_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a query to Codex and stream responses
|
||||
* @param prompt - User message or prompt
|
||||
* @param cwd - Working directory for Codex
|
||||
* @param resumeSessionId - Optional thread ID to resume
|
||||
*/
|
||||
async *sendQuery(
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
resumeSessionId?: string,
|
||||
options?: AssistantRequestOptions
|
||||
): AsyncGenerator<MessageChunk> {
|
||||
// Load config once — used for env-leak gate and (on first call) codexBinaryPath resolution.
|
||||
let mergedConfig: Awaited<ReturnType<typeof loadConfig>> | undefined;
|
||||
try {
|
||||
mergedConfig = await loadConfig(cwd);
|
||||
} catch (configErr) {
|
||||
// Fail-closed: config load failure enforces the env-leak gate (allowTargetRepoKeys stays false)
|
||||
getLog().warn({ err: configErr, cwd }, 'env_leak_gate.config_load_failed_gate_enforced');
|
||||
}
|
||||
|
||||
// Pre-spawn: check for env key leak if codebase is not explicitly consented.
|
||||
// Use prefix lookup so worktree paths (e.g. .../worktrees/feature-branch) still
|
||||
// match the registered source cwd (e.g. .../source).
|
||||
const codebase =
|
||||
(await codebaseDb.findCodebaseByDefaultCwd(cwd)) ??
|
||||
(await codebaseDb.findCodebaseByPathPrefix(cwd));
|
||||
if (codebase && !codebase.allow_env_keys) {
|
||||
// Fail-closed: a config load failure must NOT silently bypass the gate.
|
||||
const allowTargetRepoKeys = mergedConfig?.allowTargetRepoKeys ?? false;
|
||||
if (!allowTargetRepoKeys) {
|
||||
const report = scanPathForSensitiveKeys(cwd);
|
||||
if (report.findings.length > 0) {
|
||||
throw new EnvLeakError(report, 'spawn-existing');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Codex SDK with binary path override (resolved from env/config/vendor).
|
||||
// In dev mode, resolveCodexBinaryPath returns undefined and the SDK uses node_modules.
|
||||
// In binary mode, it resolves from env/config/vendor or throws with install instructions.
|
||||
const codex = await getCodex(mergedConfig?.assistants.codex.codexBinaryPath);
|
||||
const threadOptions = buildThreadOptions(cwd, options);
|
||||
|
||||
// Check if already aborted before starting
|
||||
if (options?.abortSignal?.aborted) {
|
||||
throw new Error('Query aborted');
|
||||
}
|
||||
|
||||
// Track if we fell back from a failed resume (to notify user)
|
||||
let sessionResumeFailed = false;
|
||||
|
||||
// Get or create thread (synchronous operations!)
|
||||
let thread;
|
||||
if (resumeSessionId) {
|
||||
getLog().debug({ sessionId: resumeSessionId }, 'resuming_thread');
|
||||
try {
|
||||
// NOTE: resumeThread is synchronous, not async
|
||||
// IMPORTANT: Must pass options when resuming!
|
||||
thread = codex.resumeThread(resumeSessionId, threadOptions);
|
||||
} catch (error) {
|
||||
getLog().error({ err: error, sessionId: resumeSessionId }, 'resume_thread_failed');
|
||||
// Fall back to creating new thread
|
||||
try {
|
||||
thread = codex.startThread(threadOptions);
|
||||
} catch (startError) {
|
||||
const err = startError as Error;
|
||||
if (isModelAccessError(err.message)) {
|
||||
throw new Error(buildModelAccessMessage(options?.model));
|
||||
}
|
||||
throw new Error(`Codex query failed: ${err.message}`);
|
||||
}
|
||||
sessionResumeFailed = true;
|
||||
}
|
||||
} else {
|
||||
getLog().debug({ cwd }, 'starting_new_thread');
|
||||
// NOTE: startThread is synchronous, not async
|
||||
try {
|
||||
thread = codex.startThread(threadOptions);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (isModelAccessError(err.message)) {
|
||||
throw new Error(buildModelAccessMessage(options?.model));
|
||||
}
|
||||
throw new Error(`Codex query failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify user if session resume failed (don't silently lose context)
|
||||
if (sessionResumeFailed) {
|
||||
yield {
|
||||
type: 'system',
|
||||
content: '⚠️ Could not resume previous session. Starting fresh conversation.',
|
||||
};
|
||||
}
|
||||
|
||||
let lastTodoListSignature: string | undefined;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_SUBPROCESS_RETRIES; attempt++) {
|
||||
// Check abort signal before each attempt
|
||||
if (options?.abortSignal?.aborted) {
|
||||
throw new Error('Query aborted');
|
||||
}
|
||||
|
||||
// On retries, create a fresh thread (crashed thread is invalid)
|
||||
if (attempt > 0) {
|
||||
getLog().debug({ cwd, attempt }, 'starting_new_thread');
|
||||
try {
|
||||
thread = codex.startThread(threadOptions);
|
||||
} catch (startError) {
|
||||
const err = startError as Error;
|
||||
if (isModelAccessError(err.message)) {
|
||||
throw new Error(buildModelAccessMessage(options?.model));
|
||||
}
|
||||
throw new Error(`Codex query failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Build per-turn options (structured output schema, abort signal)
|
||||
const turnOptions: TurnOptions = {};
|
||||
if (options?.outputFormat) {
|
||||
turnOptions.outputSchema = options.outputFormat.schema;
|
||||
}
|
||||
if (options?.abortSignal) {
|
||||
turnOptions.signal = options.abortSignal;
|
||||
}
|
||||
|
||||
// Run streamed query (this IS async)
|
||||
const result = await thread.runStreamed(prompt, turnOptions);
|
||||
|
||||
// Process streaming events
|
||||
for await (const event of result.events) {
|
||||
// Check abort signal between events
|
||||
if (options?.abortSignal?.aborted) {
|
||||
getLog().info('query_aborted_between_events');
|
||||
break;
|
||||
}
|
||||
|
||||
// Log progress for item.started (visibility fix for Codex appearing to hang)
|
||||
if (event.type === 'item.started') {
|
||||
const item = event.item;
|
||||
getLog().debug(
|
||||
{ eventType: event.type, itemType: item.type, itemId: item.id },
|
||||
'item_started'
|
||||
);
|
||||
}
|
||||
|
||||
// Handle error events
|
||||
if (event.type === 'error') {
|
||||
getLog().error({ message: event.message }, 'stream_error');
|
||||
// Don't send MCP timeout errors (they're optional)
|
||||
if (!event.message.includes('MCP client')) {
|
||||
yield { type: 'system', content: `⚠️ ${event.message}` };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle turn failed events
|
||||
if (event.type === 'turn.failed') {
|
||||
const errorObj = event.error as { message?: string } | undefined;
|
||||
const errorMessage = errorObj?.message ?? 'Unknown error';
|
||||
getLog().error({ errorMessage }, 'turn_failed');
|
||||
yield {
|
||||
type: 'system',
|
||||
content: `❌ Turn failed: ${errorMessage}`,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle item.completed events - map to MessageChunk types
|
||||
if (event.type === 'item.completed') {
|
||||
const item = event.item;
|
||||
|
||||
// Log progress with context for debugging
|
||||
const logContext: Record<string, unknown> = {
|
||||
eventType: event.type,
|
||||
itemType: item.type,
|
||||
itemId: item.id,
|
||||
};
|
||||
if (item.type === 'command_execution' && item.command) {
|
||||
logContext.command = item.command;
|
||||
}
|
||||
getLog().debug(logContext, 'item_completed');
|
||||
|
||||
switch (item.type) {
|
||||
case 'agent_message':
|
||||
// Agent text response
|
||||
if (item.text) {
|
||||
yield { type: 'assistant', content: item.text };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'command_execution':
|
||||
// Tool/command execution. The Codex SDK only emits item.completed
|
||||
// once the command has fully run, so we emit the start + result
|
||||
// back-to-back to close the UI's tool card immediately. Without
|
||||
// the paired tool_result, the card spins forever until lock release.
|
||||
if (item.command) {
|
||||
yield { type: 'tool', toolName: item.command };
|
||||
const exitSuffix =
|
||||
item.exit_code != null && item.exit_code !== 0
|
||||
? `\n[exit code: ${item.exit_code}]`
|
||||
: '';
|
||||
yield {
|
||||
type: 'tool_result',
|
||||
toolName: item.command,
|
||||
toolOutput: (item.aggregated_output ?? '') + exitSuffix,
|
||||
};
|
||||
} else {
|
||||
getLog().warn({ itemId: item.id }, 'command_execution_missing_command');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reasoning':
|
||||
// Agent reasoning/thinking
|
||||
if (item.text) {
|
||||
yield { type: 'thinking', content: item.text };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'web_search':
|
||||
if (item.query) {
|
||||
const searchToolName = `🔍 Searching: ${item.query}`;
|
||||
yield { type: 'tool', toolName: searchToolName };
|
||||
// Web search items only fire on completion, so close the card immediately.
|
||||
yield { type: 'tool_result', toolName: searchToolName, toolOutput: '' };
|
||||
} else {
|
||||
getLog().debug({ itemId: item.id }, 'web_search_missing_query');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'todo_list':
|
||||
if (Array.isArray(item.items) && item.items.length > 0) {
|
||||
const normalizedItems = item.items.map(t => ({
|
||||
text: typeof t.text === 'string' ? t.text : '(unnamed task)',
|
||||
completed: t.completed ?? false,
|
||||
}));
|
||||
const signature = JSON.stringify(normalizedItems);
|
||||
if (signature !== lastTodoListSignature) {
|
||||
lastTodoListSignature = signature;
|
||||
const taskList = normalizedItems
|
||||
.map(t => `${t.completed ? '✅' : '⬜'} ${t.text}`)
|
||||
.join('\n');
|
||||
yield { type: 'system', content: `📋 Tasks:\n${taskList}` };
|
||||
}
|
||||
} else {
|
||||
getLog().debug({ itemId: item.id }, 'todo_list_empty_or_invalid');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file_change': {
|
||||
const statusIcon = item.status === 'failed' ? '❌' : '✅';
|
||||
const rawError = 'error' in item ? (item as { error?: unknown }).error : undefined;
|
||||
const fileErrorMessage =
|
||||
typeof rawError === 'string'
|
||||
? rawError
|
||||
: typeof rawError === 'object' && rawError !== null && 'message' in rawError
|
||||
? String((rawError as { message: unknown }).message)
|
||||
: undefined;
|
||||
|
||||
if (Array.isArray(item.changes) && item.changes.length > 0) {
|
||||
const changeList = item.changes
|
||||
.map(c => {
|
||||
const icon = c.kind === 'add' ? '➕' : c.kind === 'delete' ? '➖' : '📝';
|
||||
return `${icon} ${c.path ?? '(unknown file)'}`;
|
||||
})
|
||||
.join('\n');
|
||||
const errorSuffix =
|
||||
item.status === 'failed' && fileErrorMessage ? `\n${fileErrorMessage}` : '';
|
||||
yield {
|
||||
type: 'system',
|
||||
content: `${statusIcon} File changes:\n${changeList}${errorSuffix}`,
|
||||
};
|
||||
} else if (item.status === 'failed') {
|
||||
getLog().warn(
|
||||
{ itemId: item.id, status: item.status },
|
||||
'file_change_failed_no_changes'
|
||||
);
|
||||
const failMsg = fileErrorMessage
|
||||
? `❌ File change failed: ${fileErrorMessage}`
|
||||
: '❌ File change failed';
|
||||
yield { type: 'system', content: failMsg };
|
||||
} else {
|
||||
getLog().debug(
|
||||
{ itemId: item.id, status: item.status },
|
||||
'file_change_no_changes'
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mcp_tool_call': {
|
||||
const toolInfo =
|
||||
item.server && item.tool
|
||||
? `${item.server}/${item.tool}`
|
||||
: (item.tool ?? item.server ?? 'MCP tool');
|
||||
const mcpToolName = `🔌 MCP: ${toolInfo}`;
|
||||
|
||||
// Always emit start+result so the UI card closes. item.completed
|
||||
// fires once the call is final (completed or failed).
|
||||
yield { type: 'tool', toolName: mcpToolName };
|
||||
|
||||
if (item.status === 'failed') {
|
||||
getLog().warn(
|
||||
{ server: item.server, tool: item.tool, error: item.error, itemId: item.id },
|
||||
'mcp_tool_call_failed'
|
||||
);
|
||||
const errMsg = item.error?.message
|
||||
? `❌ Error: ${item.error.message}`
|
||||
: '❌ Error: MCP tool failed';
|
||||
yield { type: 'tool_result', toolName: mcpToolName, toolOutput: errMsg };
|
||||
} else {
|
||||
// status === 'completed' (or 'in_progress', which shouldn't reach
|
||||
// item.completed but is closed defensively).
|
||||
let toolOutput = '';
|
||||
if (item.result?.content) {
|
||||
if (Array.isArray(item.result.content)) {
|
||||
toolOutput = JSON.stringify(item.result.content);
|
||||
} else {
|
||||
getLog().warn(
|
||||
{
|
||||
itemId: item.id,
|
||||
server: item.server,
|
||||
tool: item.tool,
|
||||
resultType: typeof item.result.content,
|
||||
},
|
||||
'mcp_tool_call_unexpected_result_shape'
|
||||
);
|
||||
}
|
||||
}
|
||||
yield { type: 'tool_result', toolName: mcpToolName, toolOutput };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Other item types are ignored (like file edits, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle turn.completed event
|
||||
if (event.type === 'turn.completed') {
|
||||
getLog().debug('turn_completed');
|
||||
// Yield result with thread ID for persistence
|
||||
const usage = extractUsageFromCodexEvent(event);
|
||||
yield {
|
||||
type: 'result',
|
||||
sessionId: thread.id ?? undefined,
|
||||
tokens: usage,
|
||||
};
|
||||
// CRITICAL: Break out of event loop - turn is complete!
|
||||
// Without this, the loop waits for stream to end (causes 90s timeout)
|
||||
break;
|
||||
}
|
||||
}
|
||||
return; // Success - exit retry loop
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
||||
// Don't retry aborted queries
|
||||
if (options?.abortSignal?.aborted) {
|
||||
throw new Error('Query aborted');
|
||||
}
|
||||
|
||||
const errorClass = classifyCodexError(err.message);
|
||||
getLog().error(
|
||||
{ err, errorClass, attempt, maxRetries: MAX_SUBPROCESS_RETRIES },
|
||||
'query_error'
|
||||
);
|
||||
|
||||
// Model access errors are never retryable
|
||||
if (errorClass === 'model_access') {
|
||||
throw new Error(buildModelAccessMessage(options?.model));
|
||||
}
|
||||
|
||||
// Auth errors won't resolve on retry
|
||||
if (errorClass === 'auth') {
|
||||
const enrichedError = new Error(`Codex auth error: ${err.message}`);
|
||||
enrichedError.cause = error;
|
||||
throw enrichedError;
|
||||
}
|
||||
|
||||
// Retry transient failures (rate limit, crash)
|
||||
if (
|
||||
attempt < MAX_SUBPROCESS_RETRIES &&
|
||||
(errorClass === 'rate_limit' || errorClass === 'crash')
|
||||
) {
|
||||
const delayMs = this.retryBaseDelayMs * Math.pow(2, attempt);
|
||||
getLog().info({ attempt, delayMs, errorClass }, 'retrying_query');
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
lastError = err;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Final failure - enrich and throw
|
||||
const enrichedError = new Error(`Codex ${errorClass}: ${err.message}`);
|
||||
enrichedError.cause = error;
|
||||
throw enrichedError;
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here, but handle defensively
|
||||
throw lastError ?? new Error('Codex query failed after retries');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the assistant type identifier
|
||||
*/
|
||||
getType(): string {
|
||||
return 'codex';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { describe, test, expect } from 'bun:test';
|
||||
import { getAssistantClient } from './factory';
|
||||
|
||||
describe('factory', () => {
|
||||
describe('getAssistantClient', () => {
|
||||
test('returns ClaudeClient for claude type', () => {
|
||||
const client = getAssistantClient('claude');
|
||||
|
||||
expect(client).toBeDefined();
|
||||
expect(client.getType()).toBe('claude');
|
||||
expect(typeof client.sendQuery).toBe('function');
|
||||
});
|
||||
|
||||
test('returns CodexClient for codex type', () => {
|
||||
const client = getAssistantClient('codex');
|
||||
|
||||
expect(client).toBeDefined();
|
||||
expect(client.getType()).toBe('codex');
|
||||
expect(typeof client.sendQuery).toBe('function');
|
||||
});
|
||||
|
||||
test('throws error for unknown type', () => {
|
||||
expect(() => getAssistantClient('unknown')).toThrow(
|
||||
"Unknown assistant type: unknown. Supported types: 'claude', 'codex'"
|
||||
);
|
||||
});
|
||||
|
||||
test('throws error for empty string', () => {
|
||||
expect(() => getAssistantClient('')).toThrow(
|
||||
"Unknown assistant type: . Supported types: 'claude', 'codex'"
|
||||
);
|
||||
});
|
||||
|
||||
test('is case sensitive - Claude throws', () => {
|
||||
expect(() => getAssistantClient('Claude')).toThrow(
|
||||
"Unknown assistant type: Claude. Supported types: 'claude', 'codex'"
|
||||
);
|
||||
});
|
||||
|
||||
test('each call returns new instance', () => {
|
||||
const client1 = getAssistantClient('claude');
|
||||
const client2 = getAssistantClient('claude');
|
||||
|
||||
// Each call should return a new instance
|
||||
expect(client1).not.toBe(client2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
/**
|
||||
* AI Assistant Client Factory
|
||||
*
|
||||
* Dynamically instantiates the appropriate AI assistant client based on type string.
|
||||
* Supports Claude and Codex assistants.
|
||||
*/
|
||||
import type { IAssistantClient } from '../types';
|
||||
import { ClaudeClient } from './claude';
|
||||
import { CodexClient } from './codex';
|
||||
import { createLogger } from '@archon/paths';
|
||||
|
||||
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
|
||||
let cachedLog: ReturnType<typeof createLogger> | undefined;
|
||||
function getLog(): ReturnType<typeof createLogger> {
|
||||
if (!cachedLog) cachedLog = createLogger('client.factory');
|
||||
return cachedLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate AI assistant client based on type
|
||||
*
|
||||
* @param type - Assistant type identifier ('claude' or 'codex')
|
||||
* @returns Instantiated assistant client
|
||||
* @throws Error if assistant type is unknown
|
||||
*/
|
||||
export function getAssistantClient(type: string): IAssistantClient {
|
||||
switch (type) {
|
||||
case 'claude':
|
||||
getLog().debug({ provider: 'claude' }, 'client_selected');
|
||||
return new ClaudeClient();
|
||||
case 'codex':
|
||||
getLog().debug({ provider: 'codex' }, 'client_selected');
|
||||
return new CodexClient();
|
||||
default:
|
||||
throw new Error(`Unknown assistant type: ${type}. Supported types: 'claude', 'codex'`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
/**
|
||||
* AI Assistant Clients
|
||||
*
|
||||
* Prefer importing from '@archon/core' for most use cases:
|
||||
* import { ClaudeClient, getAssistantClient } from '@archon/core';
|
||||
*
|
||||
* Use this submodule path when you only need client-specific code:
|
||||
* import { ClaudeClient } from '@archon/core/clients';
|
||||
*/
|
||||
|
||||
export { ClaudeClient } from './claude';
|
||||
export { CodexClient } from './codex';
|
||||
export { getAssistantClient } from './factory';
|
||||
|
||||
// Re-export types for consumers importing from this submodule directly
|
||||
export type { IAssistantClient, MessageChunk } from '../types';
|
||||
|
|
@ -224,7 +224,11 @@ concurrency:
|
|||
const config = await loadConfig();
|
||||
|
||||
expect(config.assistant).toBe('claude');
|
||||
expect(config.assistants).toEqual({ claude: {}, codex: {} });
|
||||
// Built-ins always present; community providers (like `pi`) are
|
||||
// seeded dynamically from the registry — check the built-ins
|
||||
// explicitly rather than asserting an exhaustive shape.
|
||||
expect(config.assistants.claude).toEqual({});
|
||||
expect(config.assistants.codex).toEqual({});
|
||||
expect(config.streaming.telegram).toBe('stream');
|
||||
expect(config.concurrency.maxConversations).toBe(10);
|
||||
});
|
||||
|
|
@ -245,6 +249,31 @@ streaming:
|
|||
expect(config.streaming.telegram).toBe('batch');
|
||||
});
|
||||
|
||||
test('throws on unknown DEFAULT_AI_ASSISTANT env var', async () => {
|
||||
mockReadConfigFile.mockResolvedValue('');
|
||||
process.env.DEFAULT_AI_ASSISTANT = 'nonexistent-provider';
|
||||
|
||||
await expect(loadConfig()).rejects.toThrow(/not a registered provider/);
|
||||
});
|
||||
|
||||
test('throws on unknown defaultAssistant in global config', async () => {
|
||||
mockReadConfigFile.mockResolvedValue('defaultAssistant: nonexistent-provider');
|
||||
|
||||
await expect(loadConfig()).rejects.toThrow(/not a registered provider/);
|
||||
});
|
||||
|
||||
test('throws on unknown assistant in repo config', async () => {
|
||||
mockReadConfigFile.mockImplementation(async (path: string) => {
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
if (normalized.includes('/tmp/test-repo/.archon/config.yaml')) {
|
||||
return 'assistant: nonexistent-provider';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
await expect(loadConfig('/tmp/test-repo')).rejects.toThrow(/not a registered provider/);
|
||||
});
|
||||
|
||||
test('repo config overrides global config', async () => {
|
||||
// Helper to check path in cross-platform way (handles both / and \ separators)
|
||||
const pathMatches = (path: string, pattern: string): boolean => {
|
||||
|
|
|
|||
|
|
@ -28,8 +28,99 @@ export async function writeConfigFile(
|
|||
): Promise<void> {
|
||||
await writeFile(path, content, { encoding: 'utf-8', ...options });
|
||||
}
|
||||
import type { GlobalConfig, RepoConfig, MergedConfig, SafeConfig } from './config-types';
|
||||
import type {
|
||||
GlobalConfig,
|
||||
RepoConfig,
|
||||
MergedConfig,
|
||||
SafeConfig,
|
||||
AssistantDefaults,
|
||||
AssistantDefaultsConfig,
|
||||
} from './config-types';
|
||||
import { createLogger } from '@archon/paths';
|
||||
import {
|
||||
isRegisteredProvider,
|
||||
getRegisteredProviders,
|
||||
registerBuiltinProviders,
|
||||
registerCommunityProviders,
|
||||
} from '@archon/providers';
|
||||
|
||||
/**
|
||||
* Pure read of registered provider IDs. Registration is guaranteed by
|
||||
* `loadConfig()`'s bootstrap call before any consumer can observe the
|
||||
* registry, so this helper must NOT trigger side-effecting registration
|
||||
* itself — that hid the ordering coupling and surprised readers.
|
||||
*/
|
||||
function getRegisteredProviderNames(): string[] {
|
||||
return getRegisteredProviders().map(p => p.id);
|
||||
}
|
||||
|
||||
function mergeAssistantDefaults(
|
||||
base: AssistantDefaults,
|
||||
overrides?: AssistantDefaultsConfig
|
||||
): AssistantDefaults {
|
||||
// Deep-copy every provider slot present in base. No per-provider listing —
|
||||
// adding a new community provider must not require editing this function.
|
||||
const merged: AssistantDefaults = { ...base };
|
||||
for (const [providerId, providerDefaults] of Object.entries(base)) {
|
||||
if (providerDefaults && typeof providerDefaults === 'object') {
|
||||
merged[providerId] = { ...providerDefaults };
|
||||
}
|
||||
}
|
||||
|
||||
if (!overrides) return merged;
|
||||
|
||||
for (const [providerId, providerDefaults] of Object.entries(overrides)) {
|
||||
if (!providerDefaults || typeof providerDefaults !== 'object') continue;
|
||||
merged[providerId] = {
|
||||
...(merged[providerId] ?? {}),
|
||||
...providerDefaults,
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-provider allowlist of fields safe to expose to web clients.
|
||||
*
|
||||
* **Allowlist (not denylist) by design.** Any field not listed here is
|
||||
* dropped on its way out. New sensitive fields on a provider default
|
||||
* config (binary paths, credentials, absolute filesystem paths, etc.)
|
||||
* are hidden by default — you have to opt in to expose them.
|
||||
*
|
||||
* Unknown provider IDs (community providers not listed below) fall back
|
||||
* to the generic empty allowlist: the web UI sees the provider exists,
|
||||
* but none of its defaults. Providers whose defaults are safe to surface
|
||||
* register their fields here.
|
||||
*/
|
||||
const SAFE_ASSISTANT_FIELDS: Record<string, readonly string[]> = {
|
||||
claude: ['model'],
|
||||
codex: ['model', 'modelReasoningEffort', 'webSearchMode'],
|
||||
// community providers — list each field we're confident is safe to
|
||||
// show in the web UI. Unknown providers fall through with no fields.
|
||||
pi: ['model'],
|
||||
};
|
||||
|
||||
function toSafeAssistantDefaults(assistants: AssistantDefaults): SafeConfig['assistants'] {
|
||||
const safeAssistants: SafeConfig['assistants'] = {};
|
||||
|
||||
for (const [providerId, providerDefaults] of Object.entries(assistants)) {
|
||||
if (!providerDefaults || typeof providerDefaults !== 'object') continue;
|
||||
|
||||
const allowed = SAFE_ASSISTANT_FIELDS[providerId] ?? [];
|
||||
const safeDefaults: Record<string, unknown> = {};
|
||||
for (const field of allowed) {
|
||||
const value = (providerDefaults as Record<string, unknown>)[field];
|
||||
if (value !== undefined) {
|
||||
safeDefaults[field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
safeAssistants[providerId] = safeDefaults;
|
||||
}
|
||||
|
||||
return safeAssistants;
|
||||
}
|
||||
|
||||
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
|
||||
let cachedLog: ReturnType<typeof createLogger> | undefined;
|
||||
|
|
@ -38,24 +129,6 @@ function getLog(): ReturnType<typeof createLogger> {
|
|||
return cachedLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which env-leak-gate-disabled sources have already warned in this
|
||||
* process. `loadConfig()` is called once per pre-spawn check (per workflow
|
||||
* step), so without this guard the warn would flood logs and break alert
|
||||
* rate-limiting downstream.
|
||||
*/
|
||||
const envLeakGateDisabledWarnedSources = new Set<'global_config' | 'repo_config'>();
|
||||
function warnEnvLeakGateDisabledOnce(source: 'global_config' | 'repo_config'): void {
|
||||
if (envLeakGateDisabledWarnedSources.has(source)) return;
|
||||
envLeakGateDisabledWarnedSources.add(source);
|
||||
getLog().warn({ source }, 'env_leak_gate_disabled');
|
||||
}
|
||||
|
||||
// Test-only: reset the warn-once state so unit tests can re-trigger the log.
|
||||
export function resetEnvLeakGateWarnedSourcesForTests(): void {
|
||||
envLeakGateDisabledWarnedSources.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML using Bun's native YAML parser
|
||||
*/
|
||||
|
|
@ -75,7 +148,7 @@ const DEFAULT_CONFIG_CONTENT = `# Archon Global Configuration
|
|||
# Bot display name (shown in messages)
|
||||
# botName: Archon
|
||||
|
||||
# Default AI assistant (claude or codex)
|
||||
# Default AI assistant (must match a registered provider, e.g. claude, codex)
|
||||
# defaultAssistant: claude
|
||||
|
||||
# Assistant defaults
|
||||
|
|
@ -188,13 +261,24 @@ export async function loadRepoConfig(repoPath: string): Promise<RepoConfig> {
|
|||
* Get default configuration
|
||||
*/
|
||||
function getDefaults(): MergedConfig {
|
||||
// Seed one empty entry per registered provider — built-in OR community.
|
||||
// No per-provider listing here: adding a new provider must not require
|
||||
// editing this function. `registerBuiltinProviders()` + any community
|
||||
// registrations run at process bootstrap (see `packages/providers/src/
|
||||
// registry.ts#registerCommunityProviders`), so by the time this runs the
|
||||
// registry is populated.
|
||||
const providers = getRegisteredProviders();
|
||||
const registeredAssistants: AssistantDefaults = { claude: {}, codex: {} };
|
||||
for (const provider of providers) {
|
||||
if (!(provider.id in registeredAssistants)) {
|
||||
registeredAssistants[provider.id] = {};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
botName: 'Archon',
|
||||
assistant: 'claude',
|
||||
assistants: {
|
||||
claude: {},
|
||||
codex: {},
|
||||
},
|
||||
assistant: providers.find(p => p.builtIn)?.id ?? 'claude',
|
||||
assistants: registeredAssistants,
|
||||
streaming: {
|
||||
telegram: 'stream',
|
||||
discord: 'batch',
|
||||
|
|
@ -216,7 +300,6 @@ function getDefaults(): MergedConfig {
|
|||
loadDefaultCommands: true,
|
||||
loadDefaultWorkflows: true,
|
||||
},
|
||||
allowTargetRepoKeys: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -230,10 +313,17 @@ function applyEnvOverrides(config: MergedConfig): MergedConfig {
|
|||
config.botName = envBotName;
|
||||
}
|
||||
|
||||
// Assistant override
|
||||
// Assistant override — validate against registry, error on unknown provider
|
||||
const envAssistant = process.env.DEFAULT_AI_ASSISTANT;
|
||||
if (envAssistant === 'claude' || envAssistant === 'codex') {
|
||||
config.assistant = envAssistant;
|
||||
if (envAssistant && envAssistant.length > 0) {
|
||||
if (isRegisteredProvider(envAssistant)) {
|
||||
config.assistant = envAssistant;
|
||||
} else {
|
||||
throw new Error(
|
||||
`DEFAULT_AI_ASSISTANT='${envAssistant}' is not a registered provider. ` +
|
||||
`Available providers: ${getRegisteredProviderNames().join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming overrides
|
||||
|
|
@ -274,10 +364,7 @@ function applyEnvOverrides(config: MergedConfig): MergedConfig {
|
|||
function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): MergedConfig {
|
||||
const result: MergedConfig = {
|
||||
...defaults,
|
||||
assistants: {
|
||||
claude: { ...defaults.assistants.claude },
|
||||
codex: { ...defaults.assistants.codex },
|
||||
},
|
||||
assistants: mergeAssistantDefaults(defaults.assistants),
|
||||
};
|
||||
|
||||
// Bot name preference
|
||||
|
|
@ -285,23 +372,19 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged
|
|||
result.botName = global.botName;
|
||||
}
|
||||
|
||||
// Assistant preference
|
||||
// Assistant preference — validate against registry
|
||||
if (global.defaultAssistant) {
|
||||
result.assistant = global.defaultAssistant;
|
||||
if (isRegisteredProvider(global.defaultAssistant)) {
|
||||
result.assistant = global.defaultAssistant;
|
||||
} else {
|
||||
throw new Error(
|
||||
`defaultAssistant: '${global.defaultAssistant}' in global config (~/.archon/config.yaml) ` +
|
||||
`is not a registered provider. Available: ${getRegisteredProviderNames().join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (global.assistants?.claude?.model) {
|
||||
result.assistants.claude.model = global.assistants.claude.model;
|
||||
}
|
||||
if (global.assistants?.claude?.settingSources) {
|
||||
result.assistants.claude.settingSources = global.assistants.claude.settingSources;
|
||||
}
|
||||
if (global.assistants?.codex) {
|
||||
result.assistants.codex = {
|
||||
...result.assistants.codex,
|
||||
...global.assistants.codex,
|
||||
};
|
||||
}
|
||||
result.assistants = mergeAssistantDefaults(result.assistants, global.assistants);
|
||||
|
||||
// Streaming preferences
|
||||
if (global.streaming) {
|
||||
|
|
@ -321,12 +404,6 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged
|
|||
result.concurrency.maxConversations = global.concurrency.maxConversations;
|
||||
}
|
||||
|
||||
// Env-leak gate bypass (global)
|
||||
if (global.allow_target_repo_keys === true) {
|
||||
result.allowTargetRepoKeys = true;
|
||||
warnEnvLeakGateDisabledOnce('global_config');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -336,29 +413,22 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged
|
|||
function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig {
|
||||
const result: MergedConfig = {
|
||||
...merged,
|
||||
assistants: {
|
||||
claude: { ...merged.assistants.claude },
|
||||
codex: { ...merged.assistants.codex },
|
||||
},
|
||||
assistants: mergeAssistantDefaults(merged.assistants),
|
||||
};
|
||||
|
||||
// Assistant override (repo-level takes precedence)
|
||||
// Assistant override (repo-level takes precedence) — validate against registry
|
||||
if (repo.assistant) {
|
||||
result.assistant = repo.assistant;
|
||||
if (isRegisteredProvider(repo.assistant)) {
|
||||
result.assistant = repo.assistant;
|
||||
} else {
|
||||
throw new Error(
|
||||
`assistant: '${repo.assistant}' in repo config (.archon/config.yaml) ` +
|
||||
`is not a registered provider. Available: ${getRegisteredProviderNames().join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (repo.assistants?.claude?.model) {
|
||||
result.assistants.claude.model = repo.assistants.claude.model;
|
||||
}
|
||||
if (repo.assistants?.claude?.settingSources) {
|
||||
result.assistants.claude.settingSources = repo.assistants.claude.settingSources;
|
||||
}
|
||||
if (repo.assistants?.codex) {
|
||||
result.assistants.codex = {
|
||||
...result.assistants.codex,
|
||||
...repo.assistants.codex,
|
||||
};
|
||||
}
|
||||
result.assistants = mergeAssistantDefaults(result.assistants, repo.assistants);
|
||||
|
||||
// Commands config
|
||||
if (repo.commands) {
|
||||
|
|
@ -400,14 +470,6 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig {
|
|||
result.envVars = { ...result.envVars, ...repo.env };
|
||||
}
|
||||
|
||||
// Repo-level env-leak gate override (wins over global)
|
||||
if (repo.allow_target_repo_keys !== undefined) {
|
||||
result.allowTargetRepoKeys = repo.allow_target_repo_keys;
|
||||
if (repo.allow_target_repo_keys) {
|
||||
warnEnvLeakGateDisabledOnce('repo_config');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -418,6 +480,9 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig {
|
|||
* @returns Merged configuration with all overrides applied
|
||||
*/
|
||||
export async function loadConfig(repoPath?: string): Promise<MergedConfig> {
|
||||
registerBuiltinProviders();
|
||||
registerCommunityProviders();
|
||||
|
||||
// 1. Start with defaults
|
||||
let config = getDefaults();
|
||||
|
||||
|
|
@ -476,10 +541,10 @@ export async function updateGlobalConfig(updates: Partial<GlobalConfig>): Promis
|
|||
if (updates.defaultAssistant !== undefined) merged.defaultAssistant = updates.defaultAssistant;
|
||||
|
||||
if (updates.assistants) {
|
||||
merged.assistants = {
|
||||
claude: { ...current.assistants?.claude, ...updates.assistants.claude },
|
||||
codex: { ...current.assistants?.codex, ...updates.assistants.codex },
|
||||
};
|
||||
merged.assistants = mergeAssistantDefaults(
|
||||
mergeAssistantDefaults(getDefaults().assistants, current.assistants),
|
||||
updates.assistants
|
||||
);
|
||||
}
|
||||
|
||||
if (updates.streaming) {
|
||||
|
|
@ -520,16 +585,7 @@ export function toSafeConfig(config: MergedConfig): SafeConfig {
|
|||
return {
|
||||
botName: config.botName,
|
||||
assistant: config.assistant,
|
||||
assistants: {
|
||||
claude: {
|
||||
model: config.assistants.claude.model,
|
||||
},
|
||||
codex: {
|
||||
model: config.assistants.codex.model,
|
||||
modelReasoningEffort: config.assistants.codex.modelReasoningEffort,
|
||||
webSearchMode: config.assistants.codex.webSearchMode,
|
||||
},
|
||||
},
|
||||
assistants: toSafeAssistantDefaults(config.assistants),
|
||||
streaming: {
|
||||
telegram: config.streaming.telegram,
|
||||
discord: config.streaming.discord,
|
||||
|
|
|
|||
|
|
@ -10,25 +10,54 @@
|
|||
* Global configuration (non-secret user preferences)
|
||||
* Located at ~/.archon/config.yaml
|
||||
*/
|
||||
import type { ModelReasoningEffort, WebSearchMode } from '../types';
|
||||
|
||||
export interface AssistantDefaults {
|
||||
model?: string;
|
||||
modelReasoningEffort?: ModelReasoningEffort;
|
||||
webSearchMode?: WebSearchMode;
|
||||
additionalDirectories?: string[];
|
||||
/** Path to the Codex CLI binary. Overrides auto-detection in compiled Archon builds.
|
||||
* Only relevant for the Codex provider; ignored for Claude. */
|
||||
codexBinaryPath?: string;
|
||||
}
|
||||
// Provider config defaults — canonical definitions live in @archon/providers/types.
|
||||
// Imported and re-exported here so existing consumers don't break.
|
||||
import type {
|
||||
ClaudeProviderDefaults,
|
||||
CodexProviderDefaults,
|
||||
PiProviderDefaults,
|
||||
ProviderDefaultsMap,
|
||||
} from '@archon/providers/types';
|
||||
|
||||
export interface ClaudeAssistantDefaults {
|
||||
model?: string;
|
||||
/** Claude Code settingSources — controls which CLAUDE.md files are loaded.
|
||||
* @default ['project']
|
||||
* @see https://github.com/anthropics/claude-agent-sdk */
|
||||
settingSources?: ('project' | 'user')[];
|
||||
}
|
||||
export type {
|
||||
ClaudeProviderDefaults,
|
||||
CodexProviderDefaults,
|
||||
PiProviderDefaults,
|
||||
ProviderDefaultsMap,
|
||||
};
|
||||
|
||||
/**
|
||||
* Intersection type: generic `ProviderDefaultsMap` (any string key) with
|
||||
* typed built-in entries.
|
||||
*
|
||||
* The built-in entries exist ONLY to give call sites like
|
||||
* `config.assistants.claude.model` IDE autocomplete without `as` casts.
|
||||
* They do NOT provide parser safety (each provider's `parseXxxConfig`
|
||||
* already takes `Record<string, unknown>` and defends itself).
|
||||
*
|
||||
* Community providers should NOT be added here — they live behind the
|
||||
* generic `[string]` index. Adding a new community provider must not
|
||||
* require a core-package type change; that's the whole point of Phase 2.
|
||||
*/
|
||||
export type AssistantDefaultsConfig = ProviderDefaultsMap & {
|
||||
claude?: ClaudeProviderDefaults;
|
||||
codex?: CodexProviderDefaults;
|
||||
};
|
||||
|
||||
/**
|
||||
* Required variant — built-ins are always present after `loadConfig`.
|
||||
*
|
||||
* `getDefaults()` seeds every registered provider (built-in + community)
|
||||
* with `{}`, so community providers appear in the map too — just typed as
|
||||
* `ProviderDefaults` via the generic index rather than a specific shape.
|
||||
* `registerBuiltinProviders()` is called before `loadConfig()` at every
|
||||
* process entrypoint, so claude/codex are guaranteed present.
|
||||
*/
|
||||
export type AssistantDefaults = ProviderDefaultsMap & {
|
||||
claude: ClaudeProviderDefaults;
|
||||
codex: CodexProviderDefaults;
|
||||
};
|
||||
|
||||
export interface GlobalConfig {
|
||||
/**
|
||||
|
|
@ -41,15 +70,12 @@ export interface GlobalConfig {
|
|||
* Default AI assistant when no codebase-specific preference
|
||||
* @default 'claude'
|
||||
*/
|
||||
defaultAssistant?: 'claude' | 'codex';
|
||||
defaultAssistant?: string;
|
||||
|
||||
/**
|
||||
* Assistant-specific defaults (model, reasoning effort, etc.)
|
||||
*/
|
||||
assistants?: {
|
||||
claude?: ClaudeAssistantDefaults;
|
||||
codex?: AssistantDefaults;
|
||||
};
|
||||
assistants?: AssistantDefaultsConfig;
|
||||
|
||||
/**
|
||||
* Platform streaming preferences (can be overridden per conversation)
|
||||
|
|
@ -87,20 +113,6 @@ export interface GlobalConfig {
|
|||
*/
|
||||
maxConversations?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bypass the env-leak gate globally. When true, Archon will not refuse to
|
||||
* register or spawn subprocesses for codebases whose auto-loaded .env files
|
||||
* contain sensitive keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc).
|
||||
*
|
||||
* WARNING: Weakens the env-leak gate. Keys in the target repo's .env will
|
||||
* be auto-loaded by Bun subprocesses (Claude/Codex) and bypass Archon's
|
||||
* env allowlist. Use only on trusted machines.
|
||||
*
|
||||
* YAML key: `allow_target_repo_keys`
|
||||
* @default false
|
||||
*/
|
||||
allow_target_repo_keys?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -112,15 +124,12 @@ export interface RepoConfig {
|
|||
* AI assistant preference for this repository
|
||||
* Overrides global default
|
||||
*/
|
||||
assistant?: 'claude' | 'codex';
|
||||
assistant?: string;
|
||||
|
||||
/**
|
||||
* Assistant-specific defaults for this repository
|
||||
*/
|
||||
assistants?: {
|
||||
claude?: ClaudeAssistantDefaults;
|
||||
codex?: AssistantDefaults;
|
||||
};
|
||||
assistants?: AssistantDefaultsConfig;
|
||||
|
||||
/**
|
||||
* Commands configuration
|
||||
|
|
@ -155,6 +164,41 @@ export interface RepoConfig {
|
|||
* @example [".env", ".archon", "data/fixtures/"]
|
||||
*/
|
||||
copyFiles?: string[];
|
||||
|
||||
/**
|
||||
* Initialize git submodules in new worktrees.
|
||||
* Runs `git submodule update --init --recursive` after worktree creation
|
||||
* when the repo contains a `.gitmodules` file. Repos without submodules
|
||||
* pay zero cost (the check short-circuits).
|
||||
*
|
||||
* Set to `false` to skip submodule init (e.g., when submodules are not
|
||||
* needed by any workflow or when fetch cost is prohibitive).
|
||||
* @default true
|
||||
*/
|
||||
initSubmodules?: boolean;
|
||||
|
||||
/**
|
||||
* Per-project worktree directory (relative to repo root). When set,
|
||||
* worktrees are created at `<repoRoot>/<path>/<branch>` instead of under
|
||||
* `~/.archon/worktrees/` or the workspaces layout.
|
||||
*
|
||||
* Opt-in — co-locates worktrees with the repo so they appear in the IDE
|
||||
* file tree. The user is responsible for adding the directory to their
|
||||
* `.gitignore` (no automatic file mutation).
|
||||
*
|
||||
* Path resolution precedence (highest to lowest):
|
||||
* 1. this `worktree.path` (repo-local)
|
||||
* 2. global `paths.worktrees` (absolute override in `~/.archon/config.yaml`)
|
||||
* 3. auto-detected project-scoped (`~/.archon/workspaces/owner/repo/...`)
|
||||
* 4. default global (`~/.archon/worktrees/`)
|
||||
*
|
||||
* Must be a safe relative path: no leading `/`, no `..` segments. Absolute
|
||||
* or escaping values fail loudly at worktree creation (Fail Fast — no silent
|
||||
* fallback).
|
||||
*
|
||||
* @example '.worktrees'
|
||||
*/
|
||||
path?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -175,12 +219,6 @@ export interface RepoConfig {
|
|||
*/
|
||||
env?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Per-repo override for the env-leak gate bypass. Repo value wins over global.
|
||||
* YAML key: `allow_target_repo_keys`
|
||||
*/
|
||||
allow_target_repo_keys?: boolean;
|
||||
|
||||
/**
|
||||
* Default commands/workflows configuration
|
||||
*/
|
||||
|
|
@ -215,11 +253,8 @@ export interface RepoConfig {
|
|||
*/
|
||||
export interface MergedConfig {
|
||||
botName: string;
|
||||
assistant: 'claude' | 'codex';
|
||||
assistants: {
|
||||
claude: ClaudeAssistantDefaults;
|
||||
codex: AssistantDefaults;
|
||||
};
|
||||
assistant: string;
|
||||
assistants: AssistantDefaults;
|
||||
streaming: {
|
||||
telegram: 'stream' | 'batch';
|
||||
discord: 'stream' | 'batch';
|
||||
|
|
@ -263,14 +298,6 @@ export interface MergedConfig {
|
|||
* Undefined when no env vars are configured.
|
||||
*/
|
||||
envVars?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Effective value of the env-leak gate bypass. When true, the env scanner
|
||||
* is skipped during registration and pre-spawn. Repo-level override wins
|
||||
* over global (explicit `false` at repo level re-enables the gate).
|
||||
* @default false
|
||||
*/
|
||||
allowTargetRepoKeys: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -279,11 +306,8 @@ export interface MergedConfig {
|
|||
*/
|
||||
export interface SafeConfig {
|
||||
botName: string;
|
||||
assistant: 'claude' | 'codex';
|
||||
assistants: {
|
||||
claude: Pick<ClaudeAssistantDefaults, 'model'>;
|
||||
codex: Pick<AssistantDefaults, 'model' | 'modelReasoningEffort' | 'webSearchMode'>;
|
||||
};
|
||||
assistant: string;
|
||||
assistants: ProviderDefaultsMap;
|
||||
streaming: {
|
||||
telegram: 'stream' | 'batch';
|
||||
discord: 'stream' | 'batch';
|
||||
|
|
|
|||
|
|
@ -135,4 +135,46 @@ describe('SqliteAdapter', () => {
|
|||
).rejects.toThrow('does not support RETURNING clause on UPDATE/DELETE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('datetime() chronological vs lexical comparison', () => {
|
||||
// Documents the SQLite-specific bug fixed in getActiveWorkflowRunByPath.
|
||||
// `started_at` is TEXT in "YYYY-MM-DD HH:MM:SS" format. Comparing it
|
||||
// directly to an ISO param "YYYY-MM-DDTHH:MM:SS.mmmZ" with `<` is
|
||||
// LEXICAL: char 11 is space (0x20) in the column vs T (0x54) in the
|
||||
// param, so every column value lex-sorts before every ISO param,
|
||||
// making the comparison ALWAYS true regardless of actual time.
|
||||
//
|
||||
// Wrapping both sides in datetime() forces chronological comparison.
|
||||
|
||||
test('lexical comparison gives wrong answer for SQLite stored format vs ISO param', async () => {
|
||||
db = createTestDb();
|
||||
// Column-format value (afternoon) is chronologically AFTER the ISO
|
||||
// param (morning), but lex compares char-11 (space < T) → wrong.
|
||||
const result = await db.query<{ broken: number }>(
|
||||
`SELECT ('2026-04-14 12:00:00' < $1) AS broken`,
|
||||
['2026-04-14T10:00:00.000Z']
|
||||
);
|
||||
// Expected by chronology: FALSE. Lex says: TRUE.
|
||||
expect(result.rows[0].broken).toBe(1);
|
||||
});
|
||||
|
||||
test('datetime() wrap on both sides gives chronological comparison', async () => {
|
||||
db = createTestDb();
|
||||
const result = await db.query<{ correct: number }>(
|
||||
`SELECT (datetime('2026-04-14 12:00:00') < datetime($1)) AS correct`,
|
||||
['2026-04-14T10:00:00.000Z']
|
||||
);
|
||||
// 12:00 < 10:00 is FALSE — datetime() comparison agrees with reality.
|
||||
expect(result.rows[0].correct).toBe(0);
|
||||
});
|
||||
|
||||
test('datetime() handles equality across formats', async () => {
|
||||
db = createTestDb();
|
||||
const result = await db.query<{ equal: number }>(
|
||||
`SELECT (datetime('2026-04-14 10:00:00') = datetime($1)) AS equal`,
|
||||
['2026-04-14T10:00:00.000Z']
|
||||
);
|
||||
expect(result.rows[0].equal).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -215,22 +215,6 @@ export class SqliteAdapter implements IDatabase {
|
|||
} catch (e: unknown) {
|
||||
getLog().warn({ err: e as Error }, 'db.sqlite_migration_session_columns_failed');
|
||||
}
|
||||
|
||||
// Codebases columns (added in #983 — env-leak gate consent bit)
|
||||
try {
|
||||
const cbCols = this.db.prepare("PRAGMA table_info('remote_agent_codebases')").all() as {
|
||||
name: string;
|
||||
}[];
|
||||
const cbColNames = new Set(cbCols.map(c => c.name));
|
||||
|
||||
if (!cbColNames.has('allow_env_keys')) {
|
||||
this.db.run(
|
||||
'ALTER TABLE remote_agent_codebases ADD COLUMN allow_env_keys INTEGER DEFAULT 0'
|
||||
);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
getLog().warn({ err: e as Error }, 'db.sqlite_migration_codebases_columns_failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -252,7 +236,6 @@ export class SqliteAdapter implements IDatabase {
|
|||
default_cwd TEXT NOT NULL,
|
||||
default_branch TEXT DEFAULT 'main',
|
||||
ai_assistant_type TEXT DEFAULT 'claude',
|
||||
allow_env_keys INTEGER DEFAULT 0,
|
||||
commands TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import {
|
|||
findCodebaseByDefaultCwd,
|
||||
findCodebaseByName,
|
||||
updateCodebase,
|
||||
updateCodebaseAllowEnvKeys,
|
||||
deleteCodebase,
|
||||
} from './codebases';
|
||||
|
||||
|
|
@ -37,7 +36,6 @@ describe('codebases', () => {
|
|||
repository_url: 'https://github.com/user/repo',
|
||||
default_cwd: '/workspace/test-project',
|
||||
ai_assistant_type: 'claude',
|
||||
allow_env_keys: false,
|
||||
commands: { plan: { path: '.claude/commands/plan.md', description: 'Plan feature' } },
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
@ -56,8 +54,8 @@ describe('codebases', () => {
|
|||
|
||||
expect(result).toEqual(mockCodebase);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type, allow_env_keys) VALUES ($1, $2, $3, $4, $5) RETURNING *',
|
||||
['test-project', 'https://github.com/user/repo', '/workspace/test-project', 'claude', false]
|
||||
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
['test-project', 'https://github.com/user/repo', '/workspace/test-project', 'claude']
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -75,8 +73,8 @@ describe('codebases', () => {
|
|||
|
||||
expect(result).toEqual(codebaseWithoutOptional);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type, allow_env_keys) VALUES ($1, $2, $3, $4, $5) RETURNING *',
|
||||
['test-project', null, '/workspace/test-project', 'claude', false]
|
||||
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
['test-project', null, '/workspace/test-project', 'claude']
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -191,6 +189,22 @@ describe('codebases', () => {
|
|||
// Original frozen object should be unchanged
|
||||
expect(frozenCommands).not.toHaveProperty('new-command');
|
||||
});
|
||||
|
||||
test('throws on corrupt JSON string (SQLite TEXT column)', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([{ commands: '{not valid json' }]));
|
||||
|
||||
await expect(getCodebaseCommands('codebase-123')).rejects.toThrow(
|
||||
/Corrupt commands JSON for codebase codebase-123/
|
||||
);
|
||||
});
|
||||
|
||||
test('parses valid JSON string from SQLite TEXT column', async () => {
|
||||
const commands = { plan: { path: 'plan.md', description: 'Plan' } };
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([{ commands: JSON.stringify(commands) }]));
|
||||
|
||||
const result = await getCodebaseCommands('codebase-123');
|
||||
expect(result).toEqual(commands);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerCommand', () => {
|
||||
|
|
@ -299,7 +313,6 @@ describe('codebases', () => {
|
|||
name: 'test-repo',
|
||||
default_cwd: '/workspace/test-repo',
|
||||
ai_assistant_type: 'claude',
|
||||
allow_env_keys: false,
|
||||
repository_url: null,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
|
|
@ -399,26 +412,6 @@ describe('codebases', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('updateCodebaseAllowEnvKeys', () => {
|
||||
test('flips the consent bit', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([], 1));
|
||||
|
||||
await updateCodebaseAllowEnvKeys('codebase-123', true);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'UPDATE remote_agent_codebases SET allow_env_keys = $1, updated_at = NOW() WHERE id = $2',
|
||||
[true, 'codebase-123']
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when codebase not found', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([], 0));
|
||||
await expect(updateCodebaseAllowEnvKeys('missing', false)).rejects.toThrow(
|
||||
'Codebase missing not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCodebase', () => {
|
||||
test('should unlink sessions, conversations, and delete codebase', async () => {
|
||||
// First call: unlink sessions
|
||||
|
|
|
|||
|
|
@ -17,13 +17,11 @@ export async function createCodebase(data: {
|
|||
repository_url?: string;
|
||||
default_cwd: string;
|
||||
ai_assistant_type?: string;
|
||||
allow_env_keys?: boolean;
|
||||
}): Promise<Codebase> {
|
||||
const assistantType = data.ai_assistant_type ?? 'claude';
|
||||
const allowEnvKeys = data.allow_env_keys ?? false;
|
||||
const result = await pool.query<Codebase>(
|
||||
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type, allow_env_keys) VALUES ($1, $2, $3, $4, $5) RETURNING *',
|
||||
[data.name, data.repository_url ?? null, data.default_cwd, assistantType, allowEnvKeys]
|
||||
'INSERT INTO remote_agent_codebases (name, repository_url, default_cwd, ai_assistant_type) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[data.name, data.repository_url ?? null, data.default_cwd, assistantType]
|
||||
);
|
||||
if (!result.rows[0]) {
|
||||
throw new Error('Failed to create codebase: INSERT succeeded but no row returned');
|
||||
|
|
@ -61,9 +59,12 @@ export async function getCodebaseCommands(
|
|||
if (typeof raw === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
getLog().error({ codebaseId: id, raw }, 'db.codebase_commands_json_parse_failed');
|
||||
return {};
|
||||
} catch (err) {
|
||||
getLog().error({ codebaseId: id, raw, err }, 'db.codebase_commands_json_parse_failed');
|
||||
throw new Error(
|
||||
`Corrupt commands JSON for codebase ${id}: unable to parse stored data. ` +
|
||||
`Run UPDATE remote_agent_codebases SET commands = '{}' WHERE id = '${id}' to reset.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
parsed = raw ?? {};
|
||||
|
|
@ -158,21 +159,6 @@ export async function updateCodebase(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip the `allow_env_keys` consent bit for an existing codebase.
|
||||
* Throws when the codebase does not exist.
|
||||
*/
|
||||
export async function updateCodebaseAllowEnvKeys(id: string, allowEnvKeys: boolean): Promise<void> {
|
||||
const dialect = getDialect();
|
||||
const result = await pool.query(
|
||||
`UPDATE remote_agent_codebases SET allow_env_keys = $1, updated_at = ${dialect.now()} WHERE id = $2`,
|
||||
[allowEnvKeys, id]
|
||||
);
|
||||
if ((result.rowCount ?? 0) === 0) {
|
||||
throw new Error(`Codebase ${id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCodebases(): Promise<readonly Codebase[]> {
|
||||
const result = await pool.query<Codebase>(
|
||||
'SELECT * FROM remote_agent_codebases ORDER BY name ASC'
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { createQueryResult, mockPostgresDialect } from '../test/mocks/database';
|
|||
import type { MessageRow } from './messages';
|
||||
|
||||
const mockQuery = mock(() => Promise.resolve(createQueryResult([])));
|
||||
const mockGetDatabaseType = mock(() => 'postgresql' as const);
|
||||
|
||||
// Mock the connection module before importing the module under test
|
||||
mock.module('./connection', () => ({
|
||||
|
|
@ -10,9 +11,22 @@ mock.module('./connection', () => ({
|
|||
query: mockQuery,
|
||||
},
|
||||
getDialect: () => mockPostgresDialect,
|
||||
getDatabaseType: mockGetDatabaseType,
|
||||
}));
|
||||
|
||||
import { addMessage, listMessages } from './messages';
|
||||
// Mock @archon/paths to avoid lazy logger initialization issues in tests
|
||||
mock.module('@archon/paths', () => ({
|
||||
createLogger: mock(() => ({
|
||||
fatal: mock(() => undefined),
|
||||
error: mock(() => undefined),
|
||||
warn: mock(() => undefined),
|
||||
info: mock(() => undefined),
|
||||
debug: mock(() => undefined),
|
||||
trace: mock(() => undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { addMessage, listMessages, getRecentWorkflowResultMessages } from './messages';
|
||||
|
||||
describe('messages', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -121,4 +135,76 @@ describe('messages', () => {
|
|||
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['conv-456', 50]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecentWorkflowResultMessages', () => {
|
||||
beforeEach(() => {
|
||||
mockGetDatabaseType.mockClear();
|
||||
});
|
||||
|
||||
test('uses PostgreSQL JSON extraction syntax when dbType is postgresql', async () => {
|
||||
mockGetDatabaseType.mockReturnValueOnce('postgresql');
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
await getRecentWorkflowResultMessages('conv-1');
|
||||
|
||||
const sql = mockQuery.mock.calls[0]?.[0] as string;
|
||||
expect(sql).toContain("metadata->>'workflowResult'");
|
||||
expect(sql).not.toContain('json_extract');
|
||||
});
|
||||
|
||||
test('uses SQLite JSON extraction syntax when dbType is sqlite', async () => {
|
||||
mockGetDatabaseType.mockReturnValueOnce('sqlite');
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
await getRecentWorkflowResultMessages('conv-1');
|
||||
|
||||
const sql = mockQuery.mock.calls[0]?.[0] as string;
|
||||
expect(sql).toContain("json_extract(metadata, '$.workflowResult')");
|
||||
expect(sql).not.toContain("->>'" + 'workflowResult');
|
||||
});
|
||||
|
||||
test('passes correct parameters: conversationId and limit', async () => {
|
||||
mockGetDatabaseType.mockReturnValueOnce('postgresql');
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
await getRecentWorkflowResultMessages('conv-42', 5);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['conv-42', 5]);
|
||||
});
|
||||
|
||||
test('default limit is 3', async () => {
|
||||
mockGetDatabaseType.mockReturnValueOnce('postgresql');
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
await getRecentWorkflowResultMessages('conv-1');
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['conv-1', 3]);
|
||||
});
|
||||
|
||||
test('returns empty array on query error (non-throwing contract)', async () => {
|
||||
mockGetDatabaseType.mockReturnValueOnce('postgresql');
|
||||
mockQuery.mockRejectedValueOnce(new Error('connection refused'));
|
||||
|
||||
const result = await getRecentWorkflowResultMessages('conv-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns rows from successful query', async () => {
|
||||
const row: MessageRow = {
|
||||
id: 'msg-1',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant',
|
||||
content: 'Workflow summary here.',
|
||||
metadata: '{"workflowResult":{"workflowName":"plan","runId":"run-1"}}',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
mockGetDatabaseType.mockReturnValueOnce('postgresql');
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([row]));
|
||||
|
||||
const result = await getRecentWorkflowResultMessages('conv-1');
|
||||
|
||||
expect(result).toEqual([row]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Database operations for conversation messages (Web UI history)
|
||||
* Database operations for conversation messages (Web UI history and orchestrator prompt enrichment)
|
||||
*/
|
||||
import { pool, getDialect } from './connection';
|
||||
import { pool, getDialect, getDatabaseType } from './connection';
|
||||
import { createLogger } from '@archon/paths';
|
||||
|
||||
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
|
||||
|
|
@ -16,7 +16,7 @@ export interface MessageRow {
|
|||
conversation_id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
metadata: string; // JSON string - parsed by frontend
|
||||
metadata: string; // JSON string - parsed by frontend and server-side (orchestrator prompt enrichment)
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
|
@ -64,3 +64,34 @@ export async function listMessages(
|
|||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent messages with workflowResult metadata for a conversation.
|
||||
* Used to inject workflow context into the orchestrator prompt.
|
||||
* Non-throwing — returns empty array on error.
|
||||
*/
|
||||
export async function getRecentWorkflowResultMessages(
|
||||
conversationId: string,
|
||||
limit = 3
|
||||
): Promise<readonly MessageRow[]> {
|
||||
const dbType = getDatabaseType();
|
||||
const metadataFilter =
|
||||
dbType === 'postgresql'
|
||||
? "(metadata->>'workflowResult') IS NOT NULL"
|
||||
: "json_extract(metadata, '$.workflowResult') IS NOT NULL";
|
||||
try {
|
||||
const result = await pool.query<Pick<MessageRow, 'id' | 'content' | 'metadata'>>(
|
||||
`SELECT id, content, metadata FROM remote_agent_messages
|
||||
WHERE conversation_id = $1
|
||||
AND ${metadataFilter}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[conversationId, limit]
|
||||
);
|
||||
return result.rows as MessageRow[];
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
getLog().warn({ err, conversationId }, 'db.workflow_result_messages_query_failed');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -559,6 +559,60 @@ describe('workflows database', () => {
|
|||
expect(params).toEqual(['/repo/path']);
|
||||
});
|
||||
|
||||
test('includes pending rows within the stale-pending age window', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
await getActiveWorkflowRunByPath('/repo/path');
|
||||
|
||||
const [query] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
// Fresh `pending` counts as active so the lock is held immediately
|
||||
// after pre-create — without this, two near-simultaneous dispatches
|
||||
// both pass the guard.
|
||||
expect(query).toContain("status = 'pending'");
|
||||
// Age window cutoff prevents orphaned pending rows (from crashed
|
||||
// dispatches) from permanently blocking a path.
|
||||
expect(query).toMatch(/started_at >.*INTERVAL.*milliseconds/);
|
||||
});
|
||||
|
||||
test('excludes self and applies older-wins tiebreaker when self is provided', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
const startedAt = new Date('2026-04-14T10:00:00Z');
|
||||
|
||||
await getActiveWorkflowRunByPath('/repo/path', { id: 'self-id', startedAt });
|
||||
|
||||
const [query, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(query).toContain('id != $2');
|
||||
// PostgreSQL branch: explicit `::timestamptz` cast on the param so
|
||||
// the comparison is chronological, not lexical. SQLite branch wraps
|
||||
// both sides in datetime() — covered by tests in adapters/sqlite.test.ts
|
||||
// because this suite mocks getDatabaseType as 'postgresql'.
|
||||
expect(query).toContain('started_at < $3::timestamptz');
|
||||
expect(query).toContain('started_at = $3::timestamptz AND id < $2');
|
||||
// selfStartedAt serialized to ISO — bun:sqlite rejects Date bindings.
|
||||
expect(params).toEqual(['/repo/path', 'self-id', startedAt.toISOString()]);
|
||||
});
|
||||
|
||||
test('skips self exclusion + tiebreaker when self is omitted (no caller context)', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
await getActiveWorkflowRunByPath('/repo/path');
|
||||
|
||||
const [query, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
// Without `self`, neither the id-exclusion nor the tiebreaker apply.
|
||||
expect(query).not.toContain('id !=');
|
||||
expect(query).not.toContain('started_at <');
|
||||
expect(params).toEqual(['/repo/path']);
|
||||
});
|
||||
|
||||
test('orders by (started_at ASC, id ASC) so older-wins is deterministic', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
await getActiveWorkflowRunByPath('/repo/path');
|
||||
|
||||
const [query] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(query).toContain('ORDER BY started_at ASC, id ASC');
|
||||
});
|
||||
|
||||
test('returns null when no active run on path', async () => {
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([]));
|
||||
|
||||
|
|
@ -671,6 +725,22 @@ describe('workflows database', () => {
|
|||
expect(selectParams).toEqual(['workflow-run-123']);
|
||||
});
|
||||
|
||||
test('refreshes started_at to NOW so resumed row competes fairly in the path-lock tiebreaker', async () => {
|
||||
// Without this refresh, a resumed row carries its original (potentially
|
||||
// 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([], 1));
|
||||
mockQuery.mockResolvedValueOnce(
|
||||
createQueryResult([{ ...mockWorkflowRun, status: 'running' as const }])
|
||||
);
|
||||
|
||||
await resumeWorkflowRun('workflow-run-123');
|
||||
|
||||
const [updateQuery] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(updateQuery).toContain('started_at = NOW()');
|
||||
});
|
||||
|
||||
test('throws when no row matched (run not found)', async () => {
|
||||
// UPDATE returns rowCount 0
|
||||
mockQuery.mockResolvedValueOnce(createQueryResult([], 0));
|
||||
|
|
|
|||
|
|
@ -184,13 +184,76 @@ export async function getPausedWorkflowRun(conversationId: string): Promise<Work
|
|||
}
|
||||
}
|
||||
|
||||
export async function getActiveWorkflowRunByPath(workingPath: string): Promise<WorkflowRun | null> {
|
||||
/**
|
||||
* Find the workflow run currently holding the lock on `workingPath`.
|
||||
*
|
||||
* The lock is held by any row in `(running, paused)` or `pending` younger
|
||||
* than `STALE_PENDING_AGE_MS` (orphaned pre-creates beyond that window are
|
||||
* ignored — they're from crashed or resume-replaced dispatches).
|
||||
*
|
||||
* When called from a dispatch that already pre-created its own row, pass
|
||||
* `excludeId` and `selfStartedAt` so:
|
||||
* 1. Self is never returned.
|
||||
* 2. If two dispatches both have rows, the deterministic older-wins
|
||||
* tiebreaker `(started_at, id)` ensures both agree on which is "first."
|
||||
* The newer dispatch sees the older row and aborts; the older dispatch
|
||||
* sees nothing.
|
||||
*
|
||||
* Returns the holding row, or null if the path is free.
|
||||
*/
|
||||
export const STALE_PENDING_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export async function getActiveWorkflowRunByPath(
|
||||
workingPath: string,
|
||||
self?: { id: string; startedAt: Date }
|
||||
): Promise<WorkflowRun | null> {
|
||||
const isPostgres = getDatabaseType() === 'postgresql';
|
||||
const stalePendingCutoff = isPostgres
|
||||
? `NOW() - INTERVAL '${String(STALE_PENDING_AGE_MS)} milliseconds'`
|
||||
: `datetime('now', '-${String(Math.floor(STALE_PENDING_AGE_MS / 1000))} seconds')`;
|
||||
|
||||
// Build params + clauses dynamically. Self exclusion + tiebreaker travel
|
||||
// together — the tiebreaker references both ids and timestamps.
|
||||
const params: unknown[] = [workingPath];
|
||||
const clauses: string[] = [
|
||||
'working_path = $1',
|
||||
`(status IN ('running', 'paused') OR (status = 'pending' AND started_at > ${stalePendingCutoff}))`,
|
||||
];
|
||||
if (self !== undefined) {
|
||||
params.push(self.id);
|
||||
clauses.push(`id != $${String(params.length)}`);
|
||||
}
|
||||
if (self !== undefined) {
|
||||
// Older-wins tiebreaker. (started_at, id) is a total order so both
|
||||
// dispatches always agree on which is "first." Without this, two rows
|
||||
// with similar timestamps could mutually see each other and both abort.
|
||||
//
|
||||
// Serialize Date to ISO string — bun:sqlite rejects Date bindings.
|
||||
//
|
||||
// Format-aware comparison:
|
||||
// PostgreSQL: started_at is TIMESTAMPTZ; cast the ISO param to
|
||||
// timestamptz so the comparison is chronological, not lexical.
|
||||
// SQLite: started_at is TEXT in "YYYY-MM-DD HH:MM:SS" format. Our
|
||||
// ISO param has "YYYY-MM-DDTHH:MM:SS.mmmZ". Lexical comparison is
|
||||
// WRONG: char 11 is space (0x20) in the column vs T (0x54) in the
|
||||
// param, so every column value lex-sorts before every ISO param —
|
||||
// making `started_at < $param` always TRUE regardless of actual
|
||||
// time. Wrap both sides in datetime() to force chronological
|
||||
// comparison via SQLite's date/time functions.
|
||||
params.push(self.startedAt.toISOString());
|
||||
const startedAtParam = `$${String(params.length)}`;
|
||||
const idParam = `$${String(params.length - 1)}`;
|
||||
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}))`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query<WorkflowRun>(
|
||||
`SELECT * FROM remote_agent_workflow_runs
|
||||
WHERE working_path = $1 AND status IN ('running', 'paused')
|
||||
ORDER BY started_at DESC LIMIT 1`,
|
||||
[workingPath]
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
ORDER BY started_at ASC, id ASC LIMIT 1`,
|
||||
params
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? normalizeWorkflowRun(row) : null;
|
||||
|
|
@ -309,9 +372,23 @@ export async function resumeWorkflowRun(id: string): Promise<WorkflowRun> {
|
|||
// Each phase has its own try/catch to avoid string-sniffing own errors in a shared catch.
|
||||
let updateResult: Awaited<ReturnType<typeof pool.query>>;
|
||||
try {
|
||||
// Refresh started_at to NOW so the resumed row competes fairly with
|
||||
// currently-active rows in getActiveWorkflowRunByPath's older-wins
|
||||
// tiebreaker. Without this, a resumed row carries its original
|
||||
// (potentially hours-old) started_at and would sort ahead of any
|
||||
// currently-running holder, slipping past the path lock and causing
|
||||
// two active workflows on the same working_path.
|
||||
//
|
||||
// We accept losing the original creation time here — `started_at` for
|
||||
// an active row semantically means "when did this active phase start."
|
||||
// The original creation time can be recovered from workflow_events
|
||||
// history if needed for analytics.
|
||||
updateResult = await pool.query(
|
||||
`UPDATE remote_agent_workflow_runs
|
||||
SET status = 'running', completed_at = NULL, last_activity_at = ${dialect.now()}
|
||||
SET status = 'running',
|
||||
completed_at = NULL,
|
||||
started_at = ${dialect.now()},
|
||||
last_activity_at = ${dialect.now()}
|
||||
WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ const mockCreateCodebase = mock(() =>
|
|||
repository_url: 'https://github.com/owner/repo',
|
||||
default_cwd: '/home/test/.archon/workspaces/owner/repo/source',
|
||||
ai_assistant_type: 'claude',
|
||||
allow_env_keys: false,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
@ -67,20 +66,6 @@ mock.module('../utils/commands', () => ({
|
|||
findMarkdownFilesRecursive: mockFindMarkdownFilesRecursive,
|
||||
}));
|
||||
|
||||
// ── env-leak-scanner mock ───────────────────────────────────────────────────
|
||||
class MockEnvLeakError extends Error {
|
||||
constructor(public report: unknown) {
|
||||
super('Cannot add codebase — /test/path contains keys that will leak into AI subprocesses');
|
||||
this.name = 'EnvLeakError';
|
||||
}
|
||||
}
|
||||
|
||||
const mockScanPathForSensitiveKeys = mock(() => ({ path: '', findings: [] }));
|
||||
mock.module('../utils/env-leak-scanner', () => ({
|
||||
scanPathForSensitiveKeys: mockScanPathForSensitiveKeys,
|
||||
EnvLeakError: MockEnvLeakError,
|
||||
}));
|
||||
|
||||
// ── Import module under test AFTER mocks are registered ────────────────────
|
||||
import { cloneRepository, registerRepository } from './clone';
|
||||
|
||||
|
|
@ -118,7 +103,6 @@ function clearMocks(): void {
|
|||
mockFindCodebaseByName.mockReset();
|
||||
mockUpdateCodebase.mockReset();
|
||||
mockFindMarkdownFilesRecursive.mockReset();
|
||||
mockScanPathForSensitiveKeys.mockReset();
|
||||
mockLogger.info.mockClear();
|
||||
mockLogger.debug.mockClear();
|
||||
mockLogger.warn.mockClear();
|
||||
|
|
@ -132,7 +116,6 @@ function clearMocks(): void {
|
|||
mockFindCodebaseByName.mockResolvedValue(null);
|
||||
mockUpdateCodebase.mockResolvedValue(undefined);
|
||||
mockFindMarkdownFilesRecursive.mockResolvedValue([]);
|
||||
mockScanPathForSensitiveKeys.mockReturnValue({ path: '', findings: [] });
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
|
|
@ -157,7 +140,6 @@ function makeCodebase(
|
|||
repository_url: 'https://github.com/owner/repo',
|
||||
default_cwd: '/home/test/.archon/workspaces/owner/repo/source',
|
||||
ai_assistant_type: 'claude',
|
||||
allow_env_keys: false,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
@ -948,33 +930,4 @@ describe('RegisterResult shape', () => {
|
|||
expect(result.alreadyExisted).toBe(true);
|
||||
expect(result.commandCount).toBe(0);
|
||||
});
|
||||
|
||||
describe('env leak gate', () => {
|
||||
test('throws EnvLeakError when scanner finds sensitive keys and allowEnvKeys is false', async () => {
|
||||
mockScanPathForSensitiveKeys.mockReturnValueOnce({
|
||||
path: '/home/test/.archon/workspaces/owner/repo/source',
|
||||
findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }],
|
||||
});
|
||||
|
||||
await expect(cloneRepository('https://github.com/owner/repo')).rejects.toThrow(
|
||||
'Cannot add codebase'
|
||||
);
|
||||
});
|
||||
|
||||
test('does not throw when allowEnvKeys is true, even with scanner findings present', async () => {
|
||||
mockCreateCodebase.mockResolvedValueOnce(makeCodebase() as ReturnType<typeof makeCodebase>);
|
||||
// Scanner is still called for the audit-log payload (files/keys), but the
|
||||
// gate must NOT throw — the per-call grant is the bypass.
|
||||
mockScanPathForSensitiveKeys.mockReturnValueOnce({
|
||||
path: '/home/test/.archon/workspaces/owner/repo/source',
|
||||
findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }],
|
||||
});
|
||||
|
||||
const result = await cloneRepository('https://github.com/owner/repo', true);
|
||||
|
||||
expect(result.codebaseId).toBe('codebase-uuid-1');
|
||||
// Scanner is called once — for the audit log, not as a gate
|
||||
expect(mockScanPathForSensitiveKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,12 +16,6 @@ import {
|
|||
parseOwnerRepo,
|
||||
} from '@archon/paths';
|
||||
import { findMarkdownFilesRecursive } from '../utils/commands';
|
||||
import {
|
||||
scanPathForSensitiveKeys,
|
||||
EnvLeakError,
|
||||
type LeakErrorContext,
|
||||
} from '../utils/env-leak-scanner';
|
||||
import { loadConfig } from '../config/config-loader';
|
||||
import { createLogger } from '@archon/paths';
|
||||
|
||||
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
|
||||
|
|
@ -46,55 +40,14 @@ export interface RegisterResult {
|
|||
async function registerRepoAtPath(
|
||||
targetPath: string,
|
||||
name: string,
|
||||
repositoryUrl: string | null,
|
||||
allowEnvKeys = false,
|
||||
context: LeakErrorContext = 'register-ui'
|
||||
repositoryUrl: string | null
|
||||
): Promise<RegisterResult> {
|
||||
// Scan for sensitive keys in auto-loaded .env files before registering.
|
||||
// Two bypass paths exist (in order of precedence):
|
||||
// 1. Per-call `allowEnvKeys=true` (Web UI checkbox or CLI --allow-env-keys)
|
||||
// 2. Config-level `allow_target_repo_keys: true` (global YAML)
|
||||
// When the per-call bypass is used we still emit an audit-log entry so the
|
||||
// grant has a permanent breadcrumb (parity with the PATCH route's
|
||||
// `env_leak_consent_granted` log).
|
||||
if (!allowEnvKeys) {
|
||||
const merged = await loadConfig(targetPath);
|
||||
if (!merged.allowTargetRepoKeys) {
|
||||
const report = scanPathForSensitiveKeys(targetPath);
|
||||
if (report.findings.length > 0) {
|
||||
throw new EnvLeakError(report, context);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Per-call grant — emit audit log mirroring the PATCH route shape so the
|
||||
// CLI/UI add-with-consent paths leave the same breadcrumbs.
|
||||
let files: string[] = [];
|
||||
let keys: string[] = [];
|
||||
let scanStatus: 'ok' | 'skipped' = 'ok';
|
||||
try {
|
||||
const report = scanPathForSensitiveKeys(targetPath);
|
||||
files = report.findings.map(f => f.file);
|
||||
keys = Array.from(new Set(report.findings.flatMap(f => f.keys)));
|
||||
} catch (scanErr) {
|
||||
scanStatus = 'skipped';
|
||||
getLog().warn({ err: scanErr, path: targetPath }, 'env_leak_consent_scan_skipped');
|
||||
}
|
||||
const actor = context === 'register-cli' ? 'user-cli' : 'user-ui';
|
||||
getLog().warn(
|
||||
{
|
||||
name,
|
||||
path: targetPath,
|
||||
files,
|
||||
keys,
|
||||
scanStatus,
|
||||
actor,
|
||||
},
|
||||
'env_leak_consent_granted'
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-detect assistant type based on folder structure
|
||||
let suggestedAssistant = 'claude';
|
||||
// Auto-detect assistant type based on SDK folder conventions.
|
||||
// Built-in providers use well-known folders (.claude/, .codex/).
|
||||
// Falls back to first registered built-in provider if no folder detected.
|
||||
const { getRegisteredProviders } = await import('@archon/providers');
|
||||
const defaultProvider = getRegisteredProviders().find(p => p.builtIn)?.id ?? 'claude';
|
||||
let suggestedAssistant = defaultProvider;
|
||||
const codexFolder = join(targetPath, '.codex');
|
||||
const claudeFolder = join(targetPath, '.claude');
|
||||
|
||||
|
|
@ -108,7 +61,7 @@ async function registerRepoAtPath(
|
|||
suggestedAssistant = 'claude';
|
||||
getLog().debug({ path: claudeFolder }, 'assistant_detected_claude');
|
||||
} catch {
|
||||
getLog().debug('assistant_default_claude');
|
||||
getLog().debug({ provider: defaultProvider }, 'assistant_default_from_registry');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,7 +126,6 @@ async function registerRepoAtPath(
|
|||
repository_url: repositoryUrl ?? undefined,
|
||||
default_cwd: targetPath,
|
||||
ai_assistant_type: suggestedAssistant,
|
||||
allow_env_keys: allowEnvKeys,
|
||||
});
|
||||
|
||||
// Auto-load commands if found
|
||||
|
|
@ -242,15 +194,11 @@ function normalizeRepoUrl(rawUrl: string): {
|
|||
* Local paths (starting with /, ~, or .) are delegated to registerRepository
|
||||
* to avoid wrong owner/repo naming. See #383 for broader rethink.
|
||||
*/
|
||||
export async function cloneRepository(
|
||||
repoUrl: string,
|
||||
allowEnvKeys?: boolean,
|
||||
context: LeakErrorContext = 'register-ui'
|
||||
): Promise<RegisterResult> {
|
||||
export async function cloneRepository(repoUrl: string): Promise<RegisterResult> {
|
||||
// Local paths should be registered (symlink), not cloned (copied)
|
||||
if (repoUrl.startsWith('/') || repoUrl.startsWith('~') || repoUrl.startsWith('.')) {
|
||||
const resolvedPath = repoUrl.startsWith('~') ? expandTilde(repoUrl) : resolve(repoUrl);
|
||||
return registerRepository(resolvedPath, allowEnvKeys, context);
|
||||
return registerRepository(resolvedPath);
|
||||
}
|
||||
|
||||
const { workingUrl, ownerName, repoName, targetPath } = normalizeRepoUrl(repoUrl);
|
||||
|
|
@ -331,13 +279,7 @@ export async function cloneRepository(
|
|||
await execFileAsync('git', ['config', '--global', '--add', 'safe.directory', targetPath]);
|
||||
getLog().debug({ path: targetPath }, 'safe_directory_added');
|
||||
|
||||
const result = await registerRepoAtPath(
|
||||
targetPath,
|
||||
`${ownerName}/${repoName}`,
|
||||
workingUrl,
|
||||
allowEnvKeys,
|
||||
context
|
||||
);
|
||||
const result = await registerRepoAtPath(targetPath, `${ownerName}/${repoName}`, workingUrl);
|
||||
getLog().info({ url: workingUrl, targetPath }, 'clone_completed');
|
||||
return result;
|
||||
}
|
||||
|
|
@ -345,11 +287,7 @@ export async function cloneRepository(
|
|||
/**
|
||||
* Register an existing local repository in the database (no git clone).
|
||||
*/
|
||||
export async function registerRepository(
|
||||
localPath: string,
|
||||
allowEnvKeys?: boolean,
|
||||
context: LeakErrorContext = 'register-ui'
|
||||
): Promise<RegisterResult> {
|
||||
export async function registerRepository(localPath: string): Promise<RegisterResult> {
|
||||
// Validate path exists and is a git repo
|
||||
try {
|
||||
await execFileAsync('git', ['-C', localPath, 'rev-parse', '--git-dir']);
|
||||
|
|
@ -415,5 +353,5 @@ export async function registerRepository(
|
|||
);
|
||||
|
||||
// default_cwd is the real local path (not the symlink)
|
||||
return registerRepoAtPath(localPath, name, remoteUrl, allowEnvKeys, context);
|
||||
return registerRepoAtPath(localPath, name, remoteUrl);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -511,7 +511,6 @@ describe('CommandHandler', () => {
|
|||
repository_url: 'https://github.com/user/my-repo',
|
||||
default_cwd: '/workspace/my-repo',
|
||||
ai_assistant_type: 'claude',
|
||||
allow_env_keys: false,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
@ -567,7 +566,6 @@ describe('CommandHandler', () => {
|
|||
repository_url: 'https://github.com/owner/repo',
|
||||
default_cwd: '/workspace/repo',
|
||||
ai_assistant_type: 'claude',
|
||||
allow_env_keys: false,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
@ -606,7 +604,6 @@ describe('CommandHandler', () => {
|
|||
repository_url: 'https://github.com/owner/orphaned-repo',
|
||||
default_cwd: '/workspace/orphaned-repo',
|
||||
ai_assistant_type: 'claude',
|
||||
allow_env_keys: false,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
@ -721,7 +718,6 @@ describe('CommandHandler', () => {
|
|||
repository_url: 'https://github.com/user/my-repo',
|
||||
default_cwd: '/workspace/my-repo',
|
||||
ai_assistant_type: 'claude',
|
||||
allow_env_keys: false,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ export {
|
|||
type IWebPlatformAdapter,
|
||||
isWebAdapter,
|
||||
type MessageMetadata,
|
||||
type MessageChunk,
|
||||
type IAssistantClient,
|
||||
} from './types';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -52,13 +50,6 @@ export * as messageDb from './db/messages';
|
|||
// Re-export SessionNotFoundError for error handling
|
||||
export { SessionNotFoundError } from './db/sessions';
|
||||
|
||||
// =============================================================================
|
||||
// AI Clients
|
||||
// =============================================================================
|
||||
export { ClaudeClient } from './clients/claude';
|
||||
export { CodexClient } from './clients/codex';
|
||||
export { getAssistantClient } from './clients/factory';
|
||||
|
||||
// =============================================================================
|
||||
// Workflows
|
||||
// =============================================================================
|
||||
|
|
@ -145,15 +136,6 @@ export { toError } from './utils/error';
|
|||
// Credential sanitization
|
||||
export { sanitizeCredentials, sanitizeError } from './utils/credential-sanitizer';
|
||||
|
||||
// Env leak scanner
|
||||
export {
|
||||
EnvLeakError,
|
||||
scanPathForSensitiveKeys,
|
||||
formatLeakError,
|
||||
type LeakReport,
|
||||
type LeakErrorContext,
|
||||
} from './utils/env-leak-scanner';
|
||||
|
||||
// GitHub GraphQL
|
||||
export { getLinkedIssueNumbers } from './utils/github-graphql';
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,17 @@ const mockExecuteWorkflow = mock(() => Promise.resolve());
|
|||
const mockHandleCommand = mock(() =>
|
||||
Promise.resolve({ success: true, message: 'ok', workflow: undefined })
|
||||
);
|
||||
const mockSendQuery = mock(async function* () {
|
||||
yield { type: 'assistant', content: 'test response' };
|
||||
yield { type: 'result', sessionId: 'session-1' };
|
||||
});
|
||||
const mockGetCodebaseEnvVars = mock(() => Promise.resolve({}));
|
||||
const mockLoadConfig = mock(() =>
|
||||
Promise.resolve({
|
||||
assistants: { claude: {}, codex: {} },
|
||||
envVars: {},
|
||||
})
|
||||
);
|
||||
|
||||
const mockLogger = createMockLogger();
|
||||
|
||||
|
|
@ -93,11 +104,17 @@ mock.module('@archon/workflows/executor', () => ({
|
|||
executeWorkflow: mockExecuteWorkflow,
|
||||
}));
|
||||
|
||||
mock.module('../clients/factory', () => ({
|
||||
getAssistantClient: mock(() => ({
|
||||
sendQuery: mock(async function* () {}),
|
||||
mock.module('@archon/providers', () => ({
|
||||
getAgentProvider: mock(() => ({
|
||||
sendQuery: mockSendQuery,
|
||||
getType: mock(() => 'claude'),
|
||||
getCapabilities: mock(() => ({})),
|
||||
})),
|
||||
getProviderCapabilities: mock(() => ({ envInjection: true })),
|
||||
}));
|
||||
|
||||
mock.module('../db/env-vars', () => ({
|
||||
getCodebaseEnvVars: mockGetCodebaseEnvVars,
|
||||
}));
|
||||
|
||||
mock.module('../utils/error-formatter', () => ({
|
||||
|
|
@ -126,7 +143,7 @@ mock.module('../db/workflow-events', () => ({
|
|||
}));
|
||||
|
||||
mock.module('../config/config-loader', () => ({
|
||||
loadConfig: mock(() => Promise.resolve({})),
|
||||
loadConfig: mockLoadConfig,
|
||||
}));
|
||||
|
||||
mock.module('../services/title-generator', () => ({
|
||||
|
|
@ -142,6 +159,16 @@ mock.module('./orchestrator', () => ({
|
|||
mock.module('./prompt-builder', () => ({
|
||||
buildOrchestratorPrompt: mock(() => 'orchestrator system prompt'),
|
||||
buildProjectScopedPrompt: mock(() => 'project scoped system prompt'),
|
||||
formatWorkflowContextSection: mock((results: unknown[]) =>
|
||||
results.length > 0 ? '## Recent Workflow Results\n\n...' : ''
|
||||
),
|
||||
}));
|
||||
|
||||
const mockGetRecentWorkflowResultMessages = mock(() => Promise.resolve([]));
|
||||
mock.module('../db/messages', () => ({
|
||||
addMessage: mock(() => Promise.resolve()),
|
||||
listMessages: mock(() => Promise.resolve([])),
|
||||
getRecentWorkflowResultMessages: mockGetRecentWorkflowResultMessages,
|
||||
}));
|
||||
|
||||
mock.module('@archon/isolation', () => ({
|
||||
|
|
@ -181,7 +208,6 @@ function makeCodebase(name: string, id = `id-${name}`): Codebase {
|
|||
repository_url: null,
|
||||
default_cwd: `/repos/${name}`,
|
||||
ai_assistant_type: 'claude',
|
||||
allow_env_keys: false,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
@ -805,7 +831,6 @@ function makeCodebaseForSync() {
|
|||
repository_url: 'https://github.com/test/repo',
|
||||
default_cwd: '/repos/test-repo',
|
||||
ai_assistant_type: 'claude',
|
||||
allow_env_keys: false,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
@ -874,9 +899,19 @@ describe('discoverAllWorkflows — remote sync', () => {
|
|||
mockToRepoPath.mockClear();
|
||||
mockGetOrCreateConversation.mockReset();
|
||||
mockGetCodebase.mockReset();
|
||||
mockSendQuery.mockClear();
|
||||
mockGetCodebaseEnvVars.mockReset();
|
||||
mockLoadConfig.mockReset();
|
||||
// Reset mocks between tests in this suite and restore safe defaults
|
||||
mockGetOrCreateConversation.mockImplementation(() => Promise.resolve(null));
|
||||
mockGetCodebase.mockImplementation(() => Promise.resolve(null));
|
||||
mockGetCodebaseEnvVars.mockImplementation(() => Promise.resolve({}));
|
||||
mockLoadConfig.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
assistants: { claude: {}, codex: {} },
|
||||
envVars: {},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('calls syncWorkspace with codebase.default_cwd when conversation has codebase_id', async () => {
|
||||
|
|
@ -955,6 +990,59 @@ describe('discoverAllWorkflows — remote sync', () => {
|
|||
'workspace.sync_failed'
|
||||
);
|
||||
});
|
||||
|
||||
test('passes merged repo and DB env vars to provider for codebase-scoped chat', async () => {
|
||||
const conversation = makeConversation({ codebase_id: 'codebase-1' });
|
||||
const codebase = makeCodebaseForSync();
|
||||
mockGetOrCreateConversation.mockReturnValueOnce(Promise.resolve(conversation));
|
||||
mockGetCodebase.mockReturnValueOnce(Promise.resolve(codebase));
|
||||
mockGetCodebaseEnvVars.mockResolvedValueOnce({ DB_SECRET: 'db-value' });
|
||||
mockLoadConfig.mockResolvedValueOnce({
|
||||
assistants: { claude: {}, codex: {} },
|
||||
envVars: { FILE_SECRET: 'file-value' },
|
||||
});
|
||||
|
||||
const platform = makePlatform();
|
||||
await handleMessage(platform, 'conv-1', 'What is the latest commit?');
|
||||
|
||||
expect(mockSendQuery).toHaveBeenCalled();
|
||||
const requestOptions = mockSendQuery.mock.calls[0][3] as Record<string, unknown>;
|
||||
expect(requestOptions.env).toEqual({
|
||||
FILE_SECRET: 'file-value',
|
||||
DB_SECRET: 'db-value',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not load codebase env vars when conversation has no codebase_id', async () => {
|
||||
mockGetOrCreateConversation.mockReturnValueOnce(Promise.resolve(makeConversation()));
|
||||
|
||||
const platform = makePlatform();
|
||||
await handleMessage(platform, 'conv-1', 'Hello');
|
||||
|
||||
expect(mockGetCodebaseEnvVars).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('falls back to config env when codebase env loading fails', async () => {
|
||||
const conversation = makeConversation({ codebase_id: 'codebase-1' });
|
||||
const codebase = makeCodebaseForSync();
|
||||
mockGetOrCreateConversation.mockReturnValueOnce(Promise.resolve(conversation));
|
||||
mockGetCodebase.mockReturnValueOnce(Promise.resolve(codebase));
|
||||
mockGetCodebaseEnvVars.mockRejectedValueOnce(new Error('db unavailable'));
|
||||
mockLoadConfig.mockResolvedValueOnce({
|
||||
assistants: { claude: {}, codex: {} },
|
||||
envVars: { FILE_SECRET: 'file-value' },
|
||||
});
|
||||
|
||||
const platform = makePlatform();
|
||||
await handleMessage(platform, 'conv-1', 'What is the latest commit?');
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ codebaseId: 'codebase-1' }),
|
||||
'codebase_env_vars_load_failed'
|
||||
);
|
||||
const requestOptions = mockSendQuery.mock.calls[0][3] as Record<string, unknown>;
|
||||
expect(requestOptions.env).toEqual({ FILE_SECRET: 'file-value' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Workflow dispatch routing — interactive flag ─────────────────────────────
|
||||
|
|
@ -971,7 +1059,6 @@ describe('workflow dispatch routing — interactive flag', () => {
|
|||
repository_url: null,
|
||||
default_cwd: '/repos/test-repo',
|
||||
ai_assistant_type: 'claude' as const,
|
||||
allow_env_keys: false,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
@ -1072,7 +1159,6 @@ describe('natural-language approval routing', () => {
|
|||
repository_url: null,
|
||||
default_cwd: '/repos/test-repo',
|
||||
ai_assistant_type: 'claude' as const,
|
||||
allow_env_keys: false,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
@ -1407,3 +1493,76 @@ describe('discoverAllWorkflows — merge repo workflows over global', () => {
|
|||
expect(mockDiscoverWorkflowsWithConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── handleMessage — workflow context injection ───────────────────────────────
|
||||
|
||||
describe('handleMessage — workflow context injection', () => {
|
||||
beforeEach(() => {
|
||||
mockGetRecentWorkflowResultMessages.mockClear();
|
||||
mockGetOrCreateConversation.mockReset();
|
||||
mockListCodebases.mockReset();
|
||||
mockDiscoverWorkflowsWithConfig.mockReset();
|
||||
mockLogger.warn.mockClear();
|
||||
|
||||
mockGetOrCreateConversation.mockImplementation(() => Promise.resolve(makeConversation()));
|
||||
mockListCodebases.mockImplementation(() => Promise.resolve([]));
|
||||
mockDiscoverWorkflowsWithConfig.mockImplementation(() =>
|
||||
Promise.resolve({ workflows: [], errors: [] })
|
||||
);
|
||||
mockGetRecentWorkflowResultMessages.mockImplementation(() => Promise.resolve([]));
|
||||
});
|
||||
|
||||
test('calls getRecentWorkflowResultMessages for the conversation', async () => {
|
||||
const platform = makePlatform();
|
||||
await handleMessage(platform, 'conv-1', 'What happened?');
|
||||
|
||||
expect(mockGetRecentWorkflowResultMessages).toHaveBeenCalledWith('conv-1', 3);
|
||||
});
|
||||
|
||||
test('does not throw when getRecentWorkflowResultMessages returns empty array', async () => {
|
||||
mockGetRecentWorkflowResultMessages.mockResolvedValueOnce([]);
|
||||
const platform = makePlatform();
|
||||
|
||||
await expect(handleMessage(platform, 'conv-1', 'Hello')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles malformed metadata JSON without throwing', async () => {
|
||||
const badRow = {
|
||||
id: 'msg-1',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant' as const,
|
||||
content: 'Summary.',
|
||||
metadata: 'not-valid-json',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
mockGetRecentWorkflowResultMessages.mockResolvedValueOnce([badRow]);
|
||||
const platform = makePlatform();
|
||||
|
||||
await expect(
|
||||
handleMessage(platform, 'conv-1', 'What did the workflow do?')
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles metadata with missing workflowResult key gracefully', async () => {
|
||||
const rowNoWorkflowResult = {
|
||||
id: 'msg-2',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant' as const,
|
||||
content: 'Summary.',
|
||||
metadata: '{"someOtherKey":"value"}',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
mockGetRecentWorkflowResultMessages.mockResolvedValueOnce([rowNoWorkflowResult]);
|
||||
const platform = makePlatform();
|
||||
|
||||
await expect(handleMessage(platform, 'conv-1', 'Follow-up')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('continues without workflow context when outer fetch throws', async () => {
|
||||
mockGetRecentWorkflowResultMessages.mockRejectedValueOnce(new Error('unexpected'));
|
||||
const platform = makePlatform();
|
||||
|
||||
// Non-critical path — must not block message handling
|
||||
await expect(handleMessage(platform, 'conv-1', 'Hello')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import type {
|
|||
HandleMessageContext,
|
||||
Conversation,
|
||||
Codebase,
|
||||
AssistantRequestOptions,
|
||||
AttachedFile,
|
||||
} from '../types';
|
||||
import type { SendQueryOptions } from '@archon/providers/types';
|
||||
import { ConversationNotFoundError } from '../types';
|
||||
import * as db from '../db/conversations';
|
||||
import * as codebaseDb from '../db/codebases';
|
||||
|
|
@ -24,8 +24,8 @@ import * as commandHandler from '../handlers/command-handler';
|
|||
import { formatToolCall } from '@archon/workflows/utils/tool-formatter';
|
||||
import { classifyAndFormatError } from '../utils/error-formatter';
|
||||
import { toError } from '../utils/error';
|
||||
import { getAssistantClient } from '../clients/factory';
|
||||
import { getArchonHome, getArchonWorkspacesPath } from '@archon/paths';
|
||||
import { getAgentProvider, getProviderCapabilities } from '@archon/providers';
|
||||
import { getArchonWorkspacesPath } from '@archon/paths';
|
||||
import { syncArchonToWorktree } from '../utils/worktree-sync';
|
||||
import { syncWorkspace, toRepoPath } from '@archon/git';
|
||||
import type { WorkspaceSyncResult } from '@archon/git';
|
||||
|
|
@ -43,9 +43,16 @@ import type { MergedConfig } from '../config/config-types';
|
|||
import { generateAndSetTitle } from '../services/title-generator';
|
||||
import { validateAndResolveIsolation, dispatchBackgroundWorkflow } from './orchestrator';
|
||||
import { IsolationBlockedError } from '@archon/isolation';
|
||||
import { buildOrchestratorPrompt, buildProjectScopedPrompt } from './prompt-builder';
|
||||
import {
|
||||
buildOrchestratorPrompt,
|
||||
buildProjectScopedPrompt,
|
||||
formatWorkflowContextSection,
|
||||
} from './prompt-builder';
|
||||
import type { WorkflowResultContext } from './prompt-builder';
|
||||
import * as messageDb from '../db/messages';
|
||||
import * as workflowDb from '../db/workflows';
|
||||
import * as workflowEventDb from '../db/workflow-events';
|
||||
import { getCodebaseEnvVars } from '../db/env-vars';
|
||||
import type { ApprovalContext } from '@archon/workflows/schemas/workflow-run';
|
||||
|
||||
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
|
||||
|
|
@ -221,31 +228,43 @@ async function dispatchOrchestratorWorkflow(
|
|||
codebase_id: codebase.id,
|
||||
});
|
||||
|
||||
// Validate and resolve isolation
|
||||
// Validate and resolve isolation.
|
||||
// A workflow with `worktree.enabled: false` short-circuits the resolver entirely
|
||||
// and runs in the live checkout — no worktree creation, no env row. This is the
|
||||
// declarative equivalent of CLI `--no-worktree` for workflows that should always
|
||||
// run live (e.g. read-only triage, docs generation on the main checkout).
|
||||
let cwd: string;
|
||||
try {
|
||||
const result = await validateAndResolveIsolation(
|
||||
{ ...conversation, codebase_id: codebase.id },
|
||||
codebase,
|
||||
platform,
|
||||
conversationId,
|
||||
isolationHints
|
||||
if (workflow.worktree?.enabled === false) {
|
||||
getLog().info(
|
||||
{ workflowName: workflow.name, conversationId, codebaseId: codebase.id },
|
||||
'workflow.worktree_disabled_by_policy'
|
||||
);
|
||||
cwd = result.cwd;
|
||||
} catch (error) {
|
||||
if (error instanceof IsolationBlockedError) {
|
||||
getLog().warn(
|
||||
{
|
||||
reason: error.reason,
|
||||
conversationId,
|
||||
codebaseId: codebase.id,
|
||||
workflowName: workflow.name,
|
||||
},
|
||||
'isolation_blocked'
|
||||
cwd = codebase.default_cwd;
|
||||
} else {
|
||||
try {
|
||||
const result = await validateAndResolveIsolation(
|
||||
{ ...conversation, codebase_id: codebase.id },
|
||||
codebase,
|
||||
platform,
|
||||
conversationId,
|
||||
isolationHints
|
||||
);
|
||||
return;
|
||||
cwd = result.cwd;
|
||||
} catch (error) {
|
||||
if (error instanceof IsolationBlockedError) {
|
||||
getLog().warn(
|
||||
{
|
||||
reason: error.reason,
|
||||
conversationId,
|
||||
codebaseId: codebase.id,
|
||||
workflowName: workflow.name,
|
||||
},
|
||||
'isolation_blocked'
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Dispatch workflow
|
||||
|
|
@ -381,9 +400,9 @@ async function discoverAllWorkflows(conversation: Conversation): Promise<Discove
|
|||
let config: MergedConfig | undefined;
|
||||
|
||||
try {
|
||||
const result = await discoverWorkflowsWithConfig(getArchonWorkspacesPath(), loadConfig, {
|
||||
globalSearchPath: getArchonHome(),
|
||||
});
|
||||
// Home-scoped workflows at ~/.archon/workflows/ are discovered automatically
|
||||
// by discoverWorkflowsWithConfig — no option needed.
|
||||
const result = await discoverWorkflowsWithConfig(getArchonWorkspacesPath(), loadConfig);
|
||||
workflows = [...result.workflows];
|
||||
allErrors.push(...result.errors);
|
||||
} catch (error) {
|
||||
|
|
@ -451,7 +470,8 @@ function buildFullPrompt(
|
|||
message: string,
|
||||
issueContext: string | undefined,
|
||||
threadContext: string | undefined,
|
||||
attachedFiles?: AttachedFile[]
|
||||
attachedFiles?: AttachedFile[],
|
||||
workflowContext?: string
|
||||
): string {
|
||||
const scopedCodebase = conversation.codebase_id
|
||||
? codebases.find(c => c.id === conversation.codebase_id)
|
||||
|
|
@ -471,11 +491,14 @@ function buildFullPrompt(
|
|||
.join('\n')
|
||||
: '';
|
||||
|
||||
const workflowContextSuffix = workflowContext ? '\n\n---\n\n' + workflowContext : '';
|
||||
|
||||
if (threadContext) {
|
||||
return (
|
||||
systemPrompt +
|
||||
'\n\n---\n\n## Thread Context (previous messages)\n\n' +
|
||||
threadContext +
|
||||
workflowContextSuffix +
|
||||
'\n\n---\n\n## Current Request\n\n' +
|
||||
message +
|
||||
contextSuffix +
|
||||
|
|
@ -483,7 +506,14 @@ function buildFullPrompt(
|
|||
);
|
||||
}
|
||||
|
||||
return systemPrompt + '\n\n---\n\n## User Message\n\n' + message + contextSuffix + fileSuffix;
|
||||
return (
|
||||
systemPrompt +
|
||||
workflowContextSuffix +
|
||||
'\n\n---\n\n## User Message\n\n' +
|
||||
message +
|
||||
contextSuffix +
|
||||
fileSuffix
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Handler ───────────────────────────────────────────────────────────
|
||||
|
|
@ -731,6 +761,44 @@ export async function handleMessage(
|
|||
});
|
||||
}
|
||||
|
||||
// Build workflow context for follow-up awareness
|
||||
let workflowContext: string | undefined;
|
||||
try {
|
||||
const recentResultMessages = await messageDb.getRecentWorkflowResultMessages(
|
||||
conversation.id,
|
||||
3
|
||||
);
|
||||
if (recentResultMessages.length > 0) {
|
||||
const workflowResults: WorkflowResultContext[] = recentResultMessages.map(msg => {
|
||||
let workflowName = 'unknown';
|
||||
let runId = 'unknown';
|
||||
try {
|
||||
const parsed =
|
||||
typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata;
|
||||
const meta = parsed as {
|
||||
workflowResult?: { workflowName?: string; runId?: string };
|
||||
};
|
||||
workflowName = meta.workflowResult?.workflowName ?? 'unknown';
|
||||
runId = meta.workflowResult?.runId ?? 'unknown';
|
||||
} catch (metaErr) {
|
||||
// Malformed metadata — use defaults
|
||||
getLog().warn(
|
||||
{ err: metaErr as Error, conversationId, messageId: msg.id },
|
||||
'orchestrator.workflow_result_metadata_parse_failed'
|
||||
);
|
||||
}
|
||||
return { workflowName, runId, summary: msg.content };
|
||||
});
|
||||
workflowContext = formatWorkflowContextSection(workflowResults);
|
||||
}
|
||||
} catch (error) {
|
||||
getLog().warn(
|
||||
{ err: error as Error, conversationId },
|
||||
'orchestrator.workflow_context_fetch_failed'
|
||||
);
|
||||
// Non-critical — continue without context
|
||||
}
|
||||
|
||||
const fullPrompt = buildFullPrompt(
|
||||
conversation,
|
||||
codebases,
|
||||
|
|
@ -738,7 +806,8 @@ export async function handleMessage(
|
|||
message,
|
||||
issueContext,
|
||||
threadContext,
|
||||
attachedFiles
|
||||
attachedFiles,
|
||||
workflowContext
|
||||
);
|
||||
const cwd = getArchonWorkspacesPath();
|
||||
|
||||
|
|
@ -751,17 +820,41 @@ export async function handleMessage(
|
|||
});
|
||||
}
|
||||
|
||||
// 5. Send to AI client
|
||||
const aiClient = getAssistantClient(conversation.ai_assistant_type);
|
||||
// 5. Send to AI provider
|
||||
const aiClient = getAgentProvider(conversation.ai_assistant_type);
|
||||
getLog().debug({ assistantType: conversation.ai_assistant_type }, 'sending_to_ai');
|
||||
|
||||
// Reuse the config already loaded during workflow discovery (avoids a second disk read).
|
||||
// Fall back to loadConfig only when no codebase is scoped (discoveredConfig is undefined).
|
||||
const config = discoveredConfig ?? (await loadConfig());
|
||||
const requestOptions: AssistantRequestOptions = {
|
||||
...(conversation.ai_assistant_type === 'claude' && config.assistants.claude.settingSources
|
||||
? { settingSources: config.assistants.claude.settingSources }
|
||||
: {}),
|
||||
const providerKey = conversation.ai_assistant_type;
|
||||
let dbEnvVars: Record<string, string> = {};
|
||||
if (conversation.codebase_id) {
|
||||
try {
|
||||
dbEnvVars = await getCodebaseEnvVars(conversation.codebase_id);
|
||||
} catch (error) {
|
||||
getLog().warn(
|
||||
{ err: error as Error, codebaseId: conversation.codebase_id },
|
||||
'codebase_env_vars_load_failed'
|
||||
);
|
||||
}
|
||||
}
|
||||
const effectiveEnv = { ...(config.envVars ?? {}), ...dbEnvVars };
|
||||
|
||||
// Warn if provider doesn't support env injection but env vars are configured
|
||||
if (Object.keys(effectiveEnv).length > 0) {
|
||||
const providerCaps = getProviderCapabilities(providerKey);
|
||||
if (!providerCaps.envInjection) {
|
||||
getLog().warn(
|
||||
{ provider: providerKey, envVarCount: Object.keys(effectiveEnv).length },
|
||||
'orchestrator.unsupported_env_injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const requestOptions: SendQueryOptions = {
|
||||
assistantConfig: config.assistants[providerKey] ?? {},
|
||||
env: Object.keys(effectiveEnv).length > 0 ? effectiveEnv : undefined,
|
||||
};
|
||||
|
||||
const mode = platform.getStreamingMode();
|
||||
|
|
@ -824,14 +917,14 @@ async function handleStreamMode(
|
|||
originalMessage: string,
|
||||
codebases: readonly Codebase[],
|
||||
workflows: readonly WorkflowDefinition[],
|
||||
aiClient: ReturnType<typeof getAssistantClient>,
|
||||
aiClient: ReturnType<typeof getAgentProvider>,
|
||||
fullPrompt: string,
|
||||
cwd: string,
|
||||
session: { id: string; assistant_session_id: string | null },
|
||||
isolationHints: HandleMessageContext['isolationHints'],
|
||||
conversation: Conversation,
|
||||
issueContext?: string,
|
||||
requestOptions?: AssistantRequestOptions
|
||||
requestOptions?: SendQueryOptions
|
||||
): Promise<void> {
|
||||
const allMessages: string[] = [];
|
||||
let newSessionId: string | undefined;
|
||||
|
|
@ -873,8 +966,19 @@ async function handleStreamMode(
|
|||
if (!commandDetected && platform.sendStructuredEvent) {
|
||||
await platform.sendStructuredEvent(conversationId, msg);
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.sessionId) {
|
||||
newSessionId = msg.sessionId;
|
||||
} else if (msg.type === 'result') {
|
||||
if (msg.sessionId) {
|
||||
newSessionId = msg.sessionId;
|
||||
}
|
||||
if (msg.isError) {
|
||||
getLog().warn({ conversationId, errorSubtype: msg.errorSubtype }, 'ai_result_error');
|
||||
const syntheticError = new Error(msg.errorSubtype ?? 'AI result error');
|
||||
await platform.sendMessage(conversationId, classifyAndFormatError(syntheticError));
|
||||
if (newSessionId) {
|
||||
await tryPersistSessionId(session.id, newSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!commandDetected && platform.sendStructuredEvent) {
|
||||
await platform.sendStructuredEvent(conversationId, msg);
|
||||
}
|
||||
|
|
@ -940,14 +1044,14 @@ async function handleBatchMode(
|
|||
originalMessage: string,
|
||||
codebases: readonly Codebase[],
|
||||
workflows: readonly WorkflowDefinition[],
|
||||
aiClient: ReturnType<typeof getAssistantClient>,
|
||||
aiClient: ReturnType<typeof getAgentProvider>,
|
||||
fullPrompt: string,
|
||||
cwd: string,
|
||||
session: { id: string; assistant_session_id: string | null },
|
||||
isolationHints: HandleMessageContext['isolationHints'],
|
||||
conversation: Conversation,
|
||||
issueContext?: string,
|
||||
requestOptions?: AssistantRequestOptions
|
||||
requestOptions?: SendQueryOptions
|
||||
): Promise<void> {
|
||||
const allChunks: { type: string; content: string }[] = [];
|
||||
const assistantMessages: string[] = [];
|
||||
|
|
@ -985,8 +1089,19 @@ async function handleBatchMode(
|
|||
allChunks.push({ type: 'tool', content: toolMessage });
|
||||
getLog().debug({ toolName: msg.toolName }, 'tool_call');
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.sessionId) {
|
||||
newSessionId = msg.sessionId;
|
||||
} else if (msg.type === 'result') {
|
||||
if (msg.sessionId) {
|
||||
newSessionId = msg.sessionId;
|
||||
}
|
||||
if (msg.isError) {
|
||||
getLog().warn({ conversationId, errorSubtype: msg.errorSubtype }, 'ai_result_error');
|
||||
const syntheticError = new Error(msg.errorSubtype ?? 'AI result error');
|
||||
await platform.sendMessage(conversationId, classifyAndFormatError(syntheticError));
|
||||
if (newSessionId) {
|
||||
await tryPersistSessionId(session.id, newSessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!commandDetected && allChunks.length > MAX_BATCH_TOTAL_CHUNKS) {
|
||||
|
|
@ -1189,11 +1304,12 @@ async function handleRegisterProject(
|
|||
return `Project "${projectName}" is already registered (path: ${alreadyExists.default_cwd}).`;
|
||||
}
|
||||
|
||||
// Create codebase record
|
||||
// Use config default provider instead of hardcoding 'claude'
|
||||
const config = await loadConfig();
|
||||
const codebase = await codebaseDb.createCodebase({
|
||||
name: projectName,
|
||||
default_cwd: projectPath,
|
||||
ai_assistant_type: 'claude',
|
||||
ai_assistant_type: config.assistant,
|
||||
});
|
||||
|
||||
getLog().info(
|
||||
|
|
|
|||
|
|
@ -50,14 +50,14 @@ mock.module('../handlers/command-handler', () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
mock.module('../clients/factory', () => ({
|
||||
getAssistantClient: mock(() => null),
|
||||
mock.module('@archon/providers', () => ({
|
||||
getAgentProvider: mock(() => null),
|
||||
}));
|
||||
|
||||
mock.module('../workflows/store-adapter', () => ({
|
||||
createWorkflowDeps: mock(() => ({
|
||||
store: {},
|
||||
getAssistantClient: () => ({}),
|
||||
getAgentProvider: () => ({}),
|
||||
loadConfig: async () => ({}),
|
||||
})),
|
||||
}));
|
||||
|
|
@ -176,7 +176,6 @@ function makeCodebase(overrides?: Partial<Codebase>): Codebase {
|
|||
id: 'cb-1',
|
||||
name: 'test-repo',
|
||||
default_cwd: '/workspace/test-repo',
|
||||
allow_env_keys: false,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
|
|||
|
|
@ -79,11 +79,11 @@ mock.module('../handlers/command-handler', () => ({
|
|||
parseCommand: mockParseCommand,
|
||||
}));
|
||||
|
||||
// AI client mock
|
||||
const mockGetAssistantClient = mock(() => null);
|
||||
// AI provider mock
|
||||
const mockGetAgentProvider = mock(() => null);
|
||||
|
||||
mock.module('../clients/factory', () => ({
|
||||
getAssistantClient: mockGetAssistantClient,
|
||||
mock.module('@archon/providers', () => ({
|
||||
getAgentProvider: mockGetAgentProvider,
|
||||
}));
|
||||
|
||||
// Workflow mocks
|
||||
|
|
@ -96,7 +96,7 @@ const mockFindWorkflow = mock((name: string, workflows: readonly WorkflowDefinit
|
|||
mock.module('../workflows/store-adapter', () => ({
|
||||
createWorkflowDeps: mock(() => ({
|
||||
store: {},
|
||||
getAssistantClient: () => ({}),
|
||||
getAgentProvider: () => ({}),
|
||||
loadConfig: async () => ({}),
|
||||
})),
|
||||
}));
|
||||
|
|
@ -216,7 +216,6 @@ const mockCodebase: Codebase = {
|
|||
repository_url: 'https://github.com/user/repo',
|
||||
default_cwd: '/workspace/test-project',
|
||||
ai_assistant_type: 'claude',
|
||||
allow_env_keys: false,
|
||||
commands: {},
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
|
|
@ -274,7 +273,7 @@ function clearAllMocks(): void {
|
|||
mockTransitionSession.mockClear();
|
||||
mockHandleCommand.mockClear();
|
||||
mockParseCommand.mockClear();
|
||||
mockGetAssistantClient.mockClear();
|
||||
mockGetAgentProvider.mockClear();
|
||||
mockDiscoverWorkflows.mockClear();
|
||||
mockExecuteWorkflow.mockClear();
|
||||
mockFindWorkflow.mockClear();
|
||||
|
|
@ -457,7 +456,7 @@ describe('orchestrator-agent handleMessage', () => {
|
|||
mockGetActiveSession.mockResolvedValue(null);
|
||||
mockCreateSession.mockResolvedValue(mockSession);
|
||||
mockTransitionSession.mockResolvedValue(mockSession);
|
||||
mockGetAssistantClient.mockReturnValue(mockClient);
|
||||
mockGetAgentProvider.mockReturnValue(mockClient);
|
||||
mockDiscoverWorkflows.mockResolvedValue({ workflows: [], errors: [] });
|
||||
mockParseCommand.mockImplementation((message: string) => {
|
||||
const parts = message.split(/\s+/);
|
||||
|
|
@ -479,7 +478,7 @@ describe('orchestrator-agent handleMessage', () => {
|
|||
|
||||
expect(mockHandleCommand).toHaveBeenCalled();
|
||||
expect(platform.sendMessage).toHaveBeenCalledWith('chat-456', 'Status info');
|
||||
expect(mockGetAssistantClient).not.toHaveBeenCalled();
|
||||
expect(mockGetAgentProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('delegates /help to command handler', async () => {
|
||||
|
|
@ -676,7 +675,7 @@ describe('orchestrator-agent handleMessage', () => {
|
|||
await handleMessage(platform, 'chat-456', 'hello');
|
||||
|
||||
expect(mockTransitionSession).not.toHaveBeenCalled();
|
||||
// Should pass existing assistant_session_id to AI client
|
||||
// Should pass existing assistant_session_id to AI provider
|
||||
expect(mockClient.sendQuery).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
|
|
@ -699,8 +698,8 @@ describe('orchestrator-agent handleMessage', () => {
|
|||
|
||||
// ─── settingSources forwarding ────────────────────────────────────────
|
||||
|
||||
describe('settingSources forwarding', () => {
|
||||
test('passes settingSources from config to AI client for claude', async () => {
|
||||
describe('assistantConfig forwarding', () => {
|
||||
test('passes assistantConfig with settingSources for claude', async () => {
|
||||
mockLoadConfig.mockResolvedValueOnce({
|
||||
botName: 'Archon',
|
||||
assistant: 'claude',
|
||||
|
|
@ -725,11 +724,13 @@ describe('orchestrator-agent handleMessage', () => {
|
|||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ settingSources: ['project', 'user'] })
|
||||
expect.objectContaining({
|
||||
assistantConfig: expect.objectContaining({ settingSources: ['project', 'user'] }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('does not pass settingSources for non-claude assistant', async () => {
|
||||
test('passes codex assistantConfig for codex assistant', async () => {
|
||||
const codexConversation: Conversation = {
|
||||
...mockConversation,
|
||||
ai_assistant_type: 'codex',
|
||||
|
|
@ -754,15 +755,16 @@ describe('orchestrator-agent handleMessage', () => {
|
|||
yield { type: 'result', sessionId: 'codex-session' };
|
||||
}),
|
||||
};
|
||||
mockGetAssistantClient.mockReturnValueOnce(codexClient);
|
||||
mockGetAgentProvider.mockReturnValueOnce(codexClient);
|
||||
|
||||
await handleMessage(platform, 'chat-456', 'hello');
|
||||
|
||||
// settingSources should NOT be in requestOptions since assistant type is codex
|
||||
// Should pass codex assistantConfig, not claude's
|
||||
const callArgs = codexClient.sendQuery.mock.calls[0];
|
||||
const requestOptions = callArgs?.[3] as Record<string, unknown> | undefined;
|
||||
expect(requestOptions).toBeDefined();
|
||||
expect(requestOptions).not.toHaveProperty('settingSources');
|
||||
expect(requestOptions?.assistantConfig).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1151,10 +1153,11 @@ describe('orchestrator-agent handleMessage', () => {
|
|||
|
||||
await handleMessage(platform, 'chat-456', 'help');
|
||||
|
||||
// Discovery is called positionally with (cwd, loadConfig) — no options arg.
|
||||
// Home-scoped workflows (~/.archon/workflows/) are discovered internally.
|
||||
expect(mockDiscoverWorkflows).toHaveBeenCalledWith(
|
||||
'/home/test/.archon/workspaces',
|
||||
expect.any(Function),
|
||||
{ globalSearchPath: '/home/test/.archon' }
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, test, expect } from 'bun:test';
|
||||
import { buildRoutingRulesWithProject } from './prompt-builder';
|
||||
import { buildRoutingRulesWithProject, formatWorkflowContextSection } from './prompt-builder';
|
||||
|
||||
describe('buildRoutingRulesWithProject', () => {
|
||||
test('routing rules include --prompt in invocation format', () => {
|
||||
|
|
@ -31,3 +31,42 @@ describe('buildRoutingRulesWithProject', () => {
|
|||
expect(rules).toContain('NO knowledge of the conversation history');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatWorkflowContextSection', () => {
|
||||
test('returns empty string for empty results array', () => {
|
||||
expect(formatWorkflowContextSection([])).toBe('');
|
||||
});
|
||||
|
||||
test('includes section header for non-empty results', () => {
|
||||
const result = formatWorkflowContextSection([
|
||||
{ workflowName: 'plan', runId: 'run-1', summary: 'Created implementation plan.' },
|
||||
]);
|
||||
expect(result).toContain('## Recent Workflow Results');
|
||||
expect(result).toContain('Use this context to answer follow-up questions');
|
||||
});
|
||||
|
||||
test('formats each result with workflowName and runId', () => {
|
||||
const result = formatWorkflowContextSection([
|
||||
{ workflowName: 'implement', runId: 'abc-123', summary: 'Added auth module.' },
|
||||
]);
|
||||
expect(result).toContain('**implement** (run: abc-123)');
|
||||
expect(result).toContain('Added auth module.');
|
||||
});
|
||||
|
||||
test('formats multiple results sequentially', () => {
|
||||
const results = [
|
||||
{ workflowName: 'plan', runId: 'run-1', summary: 'Plan done.' },
|
||||
{ workflowName: 'implement', runId: 'run-2', summary: 'Implement done.' },
|
||||
];
|
||||
const result = formatWorkflowContextSection(results);
|
||||
expect(result).toContain('**plan**');
|
||||
expect(result).toContain('**implement**');
|
||||
});
|
||||
|
||||
test('output does not end with trailing whitespace', () => {
|
||||
const result = formatWorkflowContextSection([
|
||||
{ workflowName: 'assist', runId: 'r-1', summary: 'Done.' },
|
||||
]);
|
||||
expect(result).toBe(result.trimEnd());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,34 @@ export function formatWorkflowSection(workflows: readonly WorkflowDefinition[]):
|
|||
return section;
|
||||
}
|
||||
|
||||
/** WorkflowResult type for prompt context injection */
|
||||
export interface WorkflowResultContext {
|
||||
workflowName: string;
|
||||
runId: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format recent workflow results for injection into the orchestrator prompt.
|
||||
* Returns empty string when there are no results; buildFullPrompt checks for
|
||||
* a non-empty string before including the section in the prompt.
|
||||
*/
|
||||
export function formatWorkflowContextSection(results: readonly WorkflowResultContext[]): string {
|
||||
if (results.length === 0) return '';
|
||||
|
||||
let section = '## Recent Workflow Results\n\n';
|
||||
section +=
|
||||
'The following workflows recently ran in this conversation. ' +
|
||||
'Use this context to answer follow-up questions.\n\n';
|
||||
|
||||
for (const r of results) {
|
||||
section += `**${r.workflowName}** (run: ${r.runId})\n`;
|
||||
section += r.summary + '\n\n';
|
||||
}
|
||||
|
||||
return section.trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the routing rules section of the prompt.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ describe('cleanup-service', () => {
|
|||
|
||||
// worktreeExists returns false (default)
|
||||
|
||||
await removeEnvironment(envId);
|
||||
const result = await removeEnvironment(envId);
|
||||
|
||||
// Should call destroy with branchName and canonicalRepoPath for cleanup
|
||||
expect(mockDestroy).toHaveBeenCalledWith('/path/that/does/not/exist', {
|
||||
|
|
@ -163,6 +163,9 @@ describe('cleanup-service', () => {
|
|||
});
|
||||
// Should mark as destroyed
|
||||
expect(mockUpdateStatus).toHaveBeenCalledWith(envId, 'destroyed');
|
||||
// Should return success result
|
||||
expect(result.worktreeRemoved).toBe(true);
|
||||
expect(result.skippedReason).toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles git worktree remove failure for missing path', async () => {
|
||||
|
|
@ -316,6 +319,86 @@ describe('cleanup-service', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('returns skippedReason when worktree has uncommitted changes without force', async () => {
|
||||
const envId = 'env-uncommitted';
|
||||
|
||||
mockGetById.mockResolvedValueOnce({
|
||||
id: envId,
|
||||
codebase_id: 'codebase-123',
|
||||
workflow_type: 'issue',
|
||||
workflow_id: '42',
|
||||
provider: 'worktree',
|
||||
working_path: '/workspace/worktrees/issue-42',
|
||||
branch_name: 'issue-42',
|
||||
status: 'active',
|
||||
created_at: new Date(),
|
||||
created_by_platform: 'github',
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
mockGetCodebase.mockResolvedValueOnce({
|
||||
id: 'codebase-123',
|
||||
name: 'test-repo',
|
||||
default_cwd: '/workspace/repo',
|
||||
});
|
||||
|
||||
// worktreeExists returns true (path exists)
|
||||
mockWorktreeExists.mockResolvedValueOnce(true);
|
||||
// hasUncommittedChanges returns true
|
||||
mockHasUncommittedChanges.mockResolvedValueOnce(true);
|
||||
|
||||
const result = await removeEnvironment(envId);
|
||||
|
||||
// Should NOT call destroy or mark as destroyed
|
||||
expect(mockDestroy).not.toHaveBeenCalled();
|
||||
expect(mockUpdateStatus).not.toHaveBeenCalled();
|
||||
// Should return skipped result
|
||||
expect(result.worktreeRemoved).toBe(false);
|
||||
expect(result.branchDeleted).toBe(false);
|
||||
expect(result.skippedReason).toBe('has uncommitted changes');
|
||||
});
|
||||
|
||||
test('returns warnings from partial destroy', async () => {
|
||||
const envId = 'env-partial';
|
||||
|
||||
mockGetById.mockResolvedValueOnce({
|
||||
id: envId,
|
||||
codebase_id: 'codebase-123',
|
||||
workflow_type: 'issue',
|
||||
workflow_id: '42',
|
||||
provider: 'worktree',
|
||||
working_path: '/workspace/worktrees/issue-42',
|
||||
branch_name: 'issue-42',
|
||||
status: 'active',
|
||||
created_at: new Date(),
|
||||
created_by_platform: 'github',
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
mockGetCodebase.mockResolvedValueOnce({
|
||||
id: 'codebase-123',
|
||||
name: 'test-repo',
|
||||
default_cwd: '/workspace/repo',
|
||||
});
|
||||
|
||||
// worktreeExists returns false (default)
|
||||
|
||||
mockDestroy.mockResolvedValueOnce({
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: false,
|
||||
remoteBranchDeleted: null,
|
||||
directoryClean: true,
|
||||
warnings: ["Cannot delete branch 'issue-42': checked out elsewhere"],
|
||||
});
|
||||
|
||||
const result = await removeEnvironment(envId);
|
||||
|
||||
expect(result.worktreeRemoved).toBe(true);
|
||||
expect(result.branchDeleted).toBe(false);
|
||||
expect(result.warnings).toEqual(["Cannot delete branch 'issue-42': checked out elsewhere"]);
|
||||
expect(result.skippedReason).toBeUndefined();
|
||||
});
|
||||
|
||||
test('re-throws non-directory errors from provider.destroy', async () => {
|
||||
const envId = 'env-real-error';
|
||||
|
||||
|
|
@ -626,10 +709,33 @@ describe('runScheduledCleanup', () => {
|
|||
metadata: {},
|
||||
},
|
||||
]);
|
||||
// First env: internal worktreeExists returns false
|
||||
mockExecFileAsync.mockRejectedValueOnce(new Error('not a git repo'));
|
||||
// Second env: internal worktreeExists returns false
|
||||
mockExecFileAsync.mockRejectedValueOnce(new Error('not a git repo'));
|
||||
// worktreeExists returns false for both (already default)
|
||||
// env-error: removeEnvironment needs getById + getCodebase
|
||||
mockGetById.mockResolvedValueOnce({
|
||||
id: 'env-error',
|
||||
codebase_id: 'codebase-1',
|
||||
working_path: '/bad/path',
|
||||
branch_name: 'bad-branch',
|
||||
status: 'active',
|
||||
});
|
||||
mockGetCodebase.mockResolvedValueOnce({
|
||||
id: 'codebase-1',
|
||||
name: 'test-repo',
|
||||
default_cwd: '/workspace/repo',
|
||||
});
|
||||
// env-good: removeEnvironment needs getById + getCodebase
|
||||
mockGetById.mockResolvedValueOnce({
|
||||
id: 'env-good',
|
||||
codebase_id: 'codebase-1',
|
||||
working_path: '/workspace/repo/worktrees/pr-1',
|
||||
branch_name: 'pr-1',
|
||||
status: 'active',
|
||||
});
|
||||
mockGetCodebase.mockResolvedValueOnce({
|
||||
id: 'codebase-1',
|
||||
name: 'test-repo',
|
||||
default_cwd: '/workspace/repo',
|
||||
});
|
||||
|
||||
const report = await runScheduledCleanup();
|
||||
|
||||
|
|
|
|||
|
|
@ -128,22 +128,42 @@ export interface RemoveEnvironmentOptions {
|
|||
deleteRemoteBranch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from removeEnvironment indicating what actually happened
|
||||
*/
|
||||
export interface RemoveEnvironmentResult {
|
||||
/** Whether the worktree was removed from disk */
|
||||
worktreeRemoved: boolean;
|
||||
/** Whether the branch was deleted (null if branch cleanup was not attempted) */
|
||||
branchDeleted: boolean | null;
|
||||
/** If the operation was a no-op, why it was skipped */
|
||||
skippedReason?: string;
|
||||
/** Warnings from partial cleanup (e.g., branch couldn't be deleted) */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific environment
|
||||
*/
|
||||
export async function removeEnvironment(
|
||||
envId: string,
|
||||
options?: RemoveEnvironmentOptions
|
||||
): Promise<void> {
|
||||
): Promise<RemoveEnvironmentResult> {
|
||||
const noopResult: RemoveEnvironmentResult = {
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const env = await isolationEnvDb.getById(envId);
|
||||
if (!env) {
|
||||
getLog().debug({ envId }, 'env_not_found');
|
||||
return;
|
||||
return { ...noopResult, skippedReason: 'environment not found' };
|
||||
}
|
||||
|
||||
if (env.status === 'destroyed') {
|
||||
getLog().debug({ envId }, 'env_already_destroyed');
|
||||
return;
|
||||
return { ...noopResult, skippedReason: 'already destroyed' };
|
||||
}
|
||||
|
||||
// Get canonical repo path from codebase for branch cleanup
|
||||
|
|
@ -164,7 +184,7 @@ export async function removeEnvironment(
|
|||
const hasChanges = await hasUncommittedChanges(toWorktreePath(env.working_path));
|
||||
if (hasChanges) {
|
||||
getLog().warn({ envId, workingPath: env.working_path }, 'env_has_uncommitted_changes');
|
||||
return;
|
||||
return { ...noopResult, skippedReason: 'has uncommitted changes' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +206,12 @@ export async function removeEnvironment(
|
|||
await isolationEnvDb.updateStatus(envId, 'destroyed');
|
||||
|
||||
getLog().info({ envId, workingPath: env.working_path }, 'env_removed');
|
||||
|
||||
return {
|
||||
worktreeRemoved: destroyResult.worktreeRemoved,
|
||||
branchDeleted: destroyResult.branchDeleted,
|
||||
warnings: destroyResult.warnings,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error & { code?: string; stderr?: string };
|
||||
const errorText = `${err.message} ${err.stderr ?? ''}`;
|
||||
|
|
@ -202,7 +228,7 @@ export async function removeEnvironment(
|
|||
if (isPathNotFoundError) {
|
||||
await isolationEnvDb.updateStatus(envId, 'destroyed');
|
||||
getLog().info({ envId }, 'env_removed_externally');
|
||||
return;
|
||||
return { worktreeRemoved: true, branchDeleted: false, warnings: [] };
|
||||
}
|
||||
|
||||
getLog().error({ err, envId }, 'env_remove_failed');
|
||||
|
|
@ -271,8 +297,12 @@ export async function runScheduledCleanup(): Promise<CleanupReport> {
|
|||
const pathExists = await worktreeExists(toWorktreePath(env.working_path));
|
||||
if (!pathExists) {
|
||||
// Path doesn't exist - call removeEnvironment to clean up branch and mark as destroyed
|
||||
await removeEnvironment(env.id, { force: false });
|
||||
report.removed.push(`${env.id} (path missing)`);
|
||||
const removeResult = await removeEnvironment(env.id, { force: false });
|
||||
if (removeResult.skippedReason) {
|
||||
report.skipped.push({ id: env.id, reason: removeResult.skippedReason });
|
||||
} else {
|
||||
report.removed.push(`${env.id} (path missing)`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -301,8 +331,15 @@ export async function runScheduledCleanup(): Promise<CleanupReport> {
|
|||
}
|
||||
|
||||
// Safe to remove merged branch (also delete remote branch)
|
||||
await removeEnvironment(env.id, { force: false, deleteRemoteBranch: true });
|
||||
report.removed.push(`${env.id} (merged)`);
|
||||
const mergedResult = await removeEnvironment(env.id, {
|
||||
force: false,
|
||||
deleteRemoteBranch: true,
|
||||
});
|
||||
if (mergedResult.skippedReason) {
|
||||
report.skipped.push({ id: env.id, reason: mergedResult.skippedReason });
|
||||
} else {
|
||||
report.removed.push(`${env.id} (merged)`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -328,8 +365,12 @@ export async function runScheduledCleanup(): Promise<CleanupReport> {
|
|||
continue;
|
||||
}
|
||||
|
||||
await removeEnvironment(env.id, { force: false });
|
||||
report.removed.push(`${env.id} (stale)`);
|
||||
const staleResult = await removeEnvironment(env.id, { force: false });
|
||||
if (staleResult.skippedReason) {
|
||||
report.skipped.push({ id: env.id, reason: staleResult.skippedReason });
|
||||
} else {
|
||||
report.removed.push(`${env.id} (stale)`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
|
@ -490,8 +531,12 @@ export async function cleanupStaleWorktrees(
|
|||
|
||||
// Safe to remove
|
||||
try {
|
||||
await removeEnvironment(env.id);
|
||||
result.removed.push(env.branch_name);
|
||||
const removeResult = await removeEnvironment(env.id);
|
||||
if (removeResult.skippedReason) {
|
||||
result.skipped.push({ branchName: env.branch_name, reason: removeResult.skippedReason });
|
||||
} else {
|
||||
result.removed.push(env.branch_name);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
result.skipped.push({ branchName: env.branch_name, reason: err.message });
|
||||
|
|
@ -591,8 +636,12 @@ export async function cleanupMergedWorktrees(
|
|||
|
||||
// Safe to remove (also delete remote branch since it's merged)
|
||||
try {
|
||||
await removeEnvironment(env.id, { deleteRemoteBranch: true });
|
||||
result.removed.push(env.branch_name);
|
||||
const removeResult = await removeEnvironment(env.id, { deleteRemoteBranch: true });
|
||||
if (removeResult.skippedReason) {
|
||||
result.skipped.push({ branchName: env.branch_name, reason: removeResult.skippedReason });
|
||||
} else {
|
||||
result.removed.push(env.branch_name);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
result.skipped.push({ branchName: env.branch_name, reason: err.message });
|
||||
|
|
|
|||
|
|
@ -31,13 +31,13 @@ const mockSendQuery = mock(async function* (): AsyncGenerator<MessageChunk> {
|
|||
) => AsyncGenerator<MessageChunk>
|
||||
>;
|
||||
|
||||
const mockGetAssistantClient = mock(() => ({
|
||||
const mockGetAgentProvider = mock(() => ({
|
||||
sendQuery: mockSendQuery,
|
||||
getType: () => 'claude',
|
||||
}));
|
||||
|
||||
mock.module('../clients/factory', () => ({
|
||||
getAssistantClient: mockGetAssistantClient,
|
||||
mock.module('@archon/providers', () => ({
|
||||
getAgentProvider: mockGetAgentProvider,
|
||||
}));
|
||||
|
||||
// ─── Import module under test (AFTER all mocks) ─────────────────────────────
|
||||
|
|
@ -50,7 +50,7 @@ describe('title-generator', () => {
|
|||
beforeEach(() => {
|
||||
mockUpdateConversationTitle.mockClear();
|
||||
mockSendQuery.mockClear();
|
||||
mockGetAssistantClient.mockClear();
|
||||
mockGetAgentProvider.mockClear();
|
||||
|
||||
// Reset to default happy-path behavior
|
||||
mockSendQuery.mockImplementation(async function* (): AsyncGenerator<MessageChunk> {
|
||||
|
|
@ -58,7 +58,7 @@ describe('title-generator', () => {
|
|||
yield { type: 'result' };
|
||||
});
|
||||
|
||||
mockGetAssistantClient.mockImplementation(() => ({
|
||||
mockGetAgentProvider.mockImplementation(() => ({
|
||||
sendQuery: mockSendQuery,
|
||||
getType: () => 'claude',
|
||||
}));
|
||||
|
|
@ -167,11 +167,14 @@ describe('title-generator', () => {
|
|||
expect(optionsArg.model).toBeUndefined();
|
||||
});
|
||||
|
||||
test('passes tools: [] to disable tool access', async () => {
|
||||
test('passes nodeConfig with allowed_tools: [] to disable tool access', async () => {
|
||||
await generateAndSetTitle('conv-11', 'Some message', 'claude', '/tmp');
|
||||
|
||||
const optionsArg = mockSendQuery.mock.calls[0][3] as { model?: string; tools?: string[] };
|
||||
expect(optionsArg.tools).toEqual([]);
|
||||
const optionsArg = mockSendQuery.mock.calls[0][3] as {
|
||||
model?: string;
|
||||
nodeConfig?: { allowed_tools?: string[] };
|
||||
};
|
||||
expect(optionsArg.nodeConfig?.allowed_tools).toEqual([]);
|
||||
});
|
||||
|
||||
test('handles double failure gracefully (AI fails + fallback DB write fails)', async () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* Optionally uses TITLE_GENERATION_MODEL env var for a cheaper/faster model.
|
||||
* Designed to be fire-and-forget — never throws, all errors logged internally.
|
||||
*/
|
||||
import { getAssistantClient } from '../clients/factory';
|
||||
import { getAgentProvider } from '@archon/providers';
|
||||
import * as conversationDb from '../db/conversations';
|
||||
import { createLogger } from '@archon/paths';
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ const MAX_TITLE_LENGTH = 100;
|
|||
*
|
||||
* @param conversationDbId - Database UUID of the conversation
|
||||
* @param userMessage - The user's message to generate a title from
|
||||
* @param assistantType - 'claude' or 'codex'
|
||||
* @param assistantType - Provider identifier (e.g. 'claude', 'codex')
|
||||
* @param cwd - Working directory for the AI client
|
||||
* @param workflowName - Optional workflow name for additional context
|
||||
*/
|
||||
|
|
@ -47,12 +47,12 @@ export async function generateAndSetTitle(
|
|||
const titlePrompt = buildTitlePrompt(userMessage, workflowName);
|
||||
|
||||
// Use the configured AI client with no tools (pure text generation)
|
||||
const client = getAssistantClient(assistantType);
|
||||
const client = getAgentProvider(assistantType);
|
||||
let generatedTitle = '';
|
||||
|
||||
for await (const chunk of client.sendQuery(titlePrompt, cwd, undefined, {
|
||||
model: titleModel,
|
||||
tools: [], // No tool access — pure text generation
|
||||
nodeConfig: { allowed_tools: [] }, // No tool access — pure text generation
|
||||
})) {
|
||||
if (chunk.type === 'assistant') {
|
||||
generatedTitle += chunk.content;
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
import { mock, type Mock } from 'bun:test';
|
||||
|
||||
export interface StreamEvent {
|
||||
type: 'text' | 'tool' | 'error' | 'complete';
|
||||
content?: string;
|
||||
toolName?: string;
|
||||
toolInput?: Record<string, unknown>;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export async function* createMockStream(events: StreamEvent[]): AsyncGenerator<StreamEvent> {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
export const createMockAssistantClient = (
|
||||
events: StreamEvent[] = []
|
||||
): {
|
||||
sendMessage: Mock<() => AsyncGenerator<StreamEvent>>;
|
||||
getType: Mock<() => string>;
|
||||
resumeSession: Mock<() => AsyncGenerator<StreamEvent>>;
|
||||
} => ({
|
||||
sendMessage: mock(async function* () {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
}),
|
||||
getType: mock(() => 'claude'),
|
||||
resumeSession: mock(async function* () {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
|
@ -3,9 +3,11 @@
|
|||
*/
|
||||
import type { TransitionTrigger } from '../state/session-transitions';
|
||||
import type { WorkflowDefinition } from '@archon/workflows/schemas/workflow';
|
||||
import type { McpServerConfig, AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { z } from 'zod';
|
||||
|
||||
// MessageChunk imported for use in IPlatformAdapter/IWebPlatformAdapter below
|
||||
import type { MessageChunk } from '@archon/providers/types';
|
||||
|
||||
/**
|
||||
* Custom error for when a conversation is not found during update operations
|
||||
* Allows callers to programmatically handle this specific error case
|
||||
|
|
@ -57,7 +59,6 @@ export interface Codebase {
|
|||
repository_url: string | null;
|
||||
default_cwd: string;
|
||||
ai_assistant_type: string;
|
||||
allow_env_keys: boolean;
|
||||
commands: Record<string, { path: string; description: string }>;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
|
|
@ -182,53 +183,7 @@ export function isWebAdapter(adapter: IPlatformAdapter): adapter is IWebPlatform
|
|||
return adapter.getPlatformType() === 'web';
|
||||
}
|
||||
|
||||
/**
|
||||
* Message chunk from AI assistant.
|
||||
* Discriminated union with per-type required fields for type safety.
|
||||
*/
|
||||
export interface TokenUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
total?: number;
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
export type MessageChunk =
|
||||
| { type: 'assistant'; content: string }
|
||||
| { type: 'system'; content: string }
|
||||
| { type: 'thinking'; content: string }
|
||||
| {
|
||||
type: 'result';
|
||||
sessionId?: string;
|
||||
tokens?: TokenUsage;
|
||||
structuredOutput?: unknown;
|
||||
isError?: boolean;
|
||||
errorSubtype?: string;
|
||||
cost?: number;
|
||||
stopReason?: string;
|
||||
numTurns?: number;
|
||||
modelUsage?: Record<string, unknown>;
|
||||
}
|
||||
| { type: 'rate_limit'; rateLimitInfo: Record<string, unknown> }
|
||||
| {
|
||||
type: 'tool';
|
||||
toolName: string;
|
||||
toolInput?: Record<string, unknown>;
|
||||
/** Stable per-call ID from the underlying SDK (e.g. Claude `tool_use_id`).
|
||||
* When present, the platform adapter uses it directly instead of generating
|
||||
* one — guarantees `tool_call`/`tool_result` pair correctly even when
|
||||
* multiple tools with the same name run concurrently. */
|
||||
toolCallId?: string;
|
||||
}
|
||||
| {
|
||||
type: 'tool_result';
|
||||
toolName: string;
|
||||
toolOutput: string;
|
||||
/** Matching ID for the originating `tool` chunk. See `tool` variant above. */
|
||||
toolCallId?: string;
|
||||
}
|
||||
| { type: 'workflow_dispatch'; workerConversationId: string; workflowName: string };
|
||||
|
||||
// Re-export workflow schema types for config-types.ts compatibility
|
||||
import type { ModelReasoningEffort, WebSearchMode } from '@archon/workflows/schemas/workflow';
|
||||
export type { ModelReasoningEffort, WebSearchMode };
|
||||
import type {
|
||||
|
|
@ -237,147 +192,3 @@ import type {
|
|||
SandboxSettings,
|
||||
} from '@archon/workflows/schemas/dag-node';
|
||||
export type { EffortLevel, ThinkingConfig, SandboxSettings };
|
||||
|
||||
export interface AssistantRequestOptions {
|
||||
model?: string;
|
||||
modelReasoningEffort?: ModelReasoningEffort;
|
||||
webSearchMode?: WebSearchMode;
|
||||
additionalDirectories?: string[];
|
||||
/**
|
||||
* Restrict the set of built-in tools available to the assistant.
|
||||
* - `[]` — disable all built-in tools (Claude SDK only; Codex ignores this field)
|
||||
* - `string[]` — restrict to the named tools
|
||||
* Omit entirely to use the assistant's default tool set.
|
||||
* Note: `undefined` (omitted) and `[]` have different semantics — do not confuse them.
|
||||
*/
|
||||
tools?: string[];
|
||||
/**
|
||||
* Remove specific tools from the assistant's available set.
|
||||
* Applied after `tools` whitelist (if both are set, denied tools are removed from the whitelist result).
|
||||
* Claude SDK only — Codex ignores this field.
|
||||
*/
|
||||
disallowedTools?: string[];
|
||||
/**
|
||||
* Structured output schema.
|
||||
* Claude: passed as outputFormat option to Claude Agent SDK.
|
||||
* Codex: passed as outputSchema in TurnOptions to Codex SDK (v0.116.0+).
|
||||
* Shape: { type: 'json_schema', schema: <JSON Schema object> }
|
||||
*/
|
||||
outputFormat?: { type: 'json_schema'; schema: Record<string, unknown> };
|
||||
/** SDK hooks configuration. Passed directly to Claude Agent SDK Options.hooks. Claude only — ignored for Codex. */
|
||||
hooks?: Partial<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
matcher?: string;
|
||||
hooks: ((
|
||||
input: unknown,
|
||||
toolUseID: string | undefined,
|
||||
options: { signal: AbortSignal }
|
||||
) => Promise<unknown>)[];
|
||||
timeout?: number;
|
||||
}[]
|
||||
>
|
||||
>;
|
||||
/**
|
||||
* MCP server configuration passed to Claude Agent SDK Options.mcpServers.
|
||||
* Uses SDK type directly — @archon/core already depends on the SDK.
|
||||
* Claude only — Codex ignores this.
|
||||
*/
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
/** Tools to auto-allow without permission prompts (e.g., MCP tool wildcards).
|
||||
* Passed to Claude Agent SDK Options.allowedTools. Claude only. */
|
||||
allowedTools?: string[];
|
||||
/** Custom subagent definitions passed to Claude Agent SDK Options.agents.
|
||||
* Used for per-node skill scoping via AgentDefinition wrapping. Claude only. */
|
||||
agents?: Record<string, AgentDefinition>;
|
||||
/** Name of agent definition for the main thread. References a key in `agents`. Claude only. */
|
||||
agent?: string;
|
||||
/**
|
||||
* Abort signal for cancelling in-flight AI requests.
|
||||
* When aborted, the AI client should terminate the subprocess/query gracefully.
|
||||
*/
|
||||
abortSignal?: AbortSignal;
|
||||
/**
|
||||
* When false (default), skips writing session transcript to ~/.claude/projects/.
|
||||
* Claude Agent SDK v0.2.74+. The SDK default is true, but Archon overrides it to false
|
||||
* to avoid disk pollution. Set to true only when session persistence is explicitly needed.
|
||||
*/
|
||||
persistSession?: boolean;
|
||||
/**
|
||||
* When true, the SDK copies the prior session's history into a new session file
|
||||
* before appending, leaving the original untouched. Use with `resume` to safely
|
||||
* preserve conversation context without risk of corrupting the source session.
|
||||
* Claude only — ignored for Codex.
|
||||
*/
|
||||
forkSession?: boolean;
|
||||
/**
|
||||
* Claude Code settingSources — controls which CLAUDE.md files are loaded.
|
||||
* Passed directly to Claude Agent SDK Options.settingSources.
|
||||
* Claude only — ignored for Codex.
|
||||
* @default ['project']
|
||||
*/
|
||||
settingSources?: ('project' | 'user')[];
|
||||
/**
|
||||
* Additional env vars merged into Claude subprocess environment after buildSubprocessEnv().
|
||||
* Final env: { ...buildSubprocessEnv(), ...env } (auth tokens conditionally filtered).
|
||||
* Claude only — Codex SDK does not support env injection.
|
||||
*/
|
||||
env?: Record<string, string>;
|
||||
/**
|
||||
* Controls reasoning depth for Claude. Claude only — ignored for Codex.
|
||||
*/
|
||||
effort?: EffortLevel;
|
||||
/**
|
||||
* Controls Claude's thinking/reasoning behavior. Claude only — ignored for Codex.
|
||||
*/
|
||||
thinking?: ThinkingConfig;
|
||||
/**
|
||||
* Maximum USD cost budget. SDK returns error_max_budget_usd result if exceeded.
|
||||
* Claude only — ignored for Codex.
|
||||
*/
|
||||
maxBudgetUsd?: number;
|
||||
/**
|
||||
* Per-node system prompt string. Overrides the default claude_code preset.
|
||||
* Claude only — ignored for Codex.
|
||||
*/
|
||||
systemPrompt?: string;
|
||||
/**
|
||||
* Fallback model if primary fails. Claude only — ignored for Codex.
|
||||
*/
|
||||
fallbackModel?: string;
|
||||
/**
|
||||
* SDK beta feature flags. Claude only — ignored for Codex.
|
||||
*/
|
||||
betas?: string[];
|
||||
/**
|
||||
* OS-level sandbox settings passed to Claude subprocess.
|
||||
* Claude only — ignored for Codex.
|
||||
*/
|
||||
sandbox?: SandboxSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic AI assistant client interface
|
||||
* Allows supporting multiple AI assistants (Claude, Codex, etc.)
|
||||
*/
|
||||
export interface IAssistantClient {
|
||||
/**
|
||||
* Send a message and get streaming response
|
||||
* @param prompt - User message or prompt
|
||||
* @param cwd - Working directory for the assistant
|
||||
* @param resumeSessionId - Optional session ID to resume
|
||||
* @param options - Optional request options (model, provider-specific settings)
|
||||
*/
|
||||
sendQuery(
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
resumeSessionId?: string,
|
||||
options?: AssistantRequestOptions
|
||||
): AsyncGenerator<MessageChunk>;
|
||||
|
||||
/**
|
||||
* Get the assistant type identifier
|
||||
*/
|
||||
getType(): string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,18 @@ import { join, basename } from 'path';
|
|||
/**
|
||||
* Recursively find all .md files in a directory and its subdirectories.
|
||||
* Skips hidden directories and node_modules.
|
||||
*
|
||||
* `maxDepth` caps how many folders deep the walk descends. Default is
|
||||
* `Infinity` (no cap) so callers that copy arbitrary subtrees (e.g.
|
||||
* `packages/core/src/handlers/clone.ts`) preserve existing behavior.
|
||||
*/
|
||||
export async function findMarkdownFilesRecursive(
|
||||
rootPath: string,
|
||||
relativePath = ''
|
||||
relativePath = '',
|
||||
options?: { maxDepth?: number }
|
||||
): Promise<{ commandName: string; relativePath: string }[]> {
|
||||
const maxDepth = options?.maxDepth ?? Infinity;
|
||||
const currentDepth = relativePath ? relativePath.split(/[/\\]/).filter(Boolean).length : 0;
|
||||
const results: { commandName: string; relativePath: string }[] = [];
|
||||
const fullPath = join(rootPath, relativePath);
|
||||
|
||||
|
|
@ -23,7 +30,12 @@ export async function findMarkdownFilesRecursive(
|
|||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subResults = await findMarkdownFilesRecursive(rootPath, join(relativePath, entry.name));
|
||||
if (currentDepth >= maxDepth) continue;
|
||||
const subResults = await findMarkdownFilesRecursive(
|
||||
rootPath,
|
||||
join(relativePath, entry.name),
|
||||
options
|
||||
);
|
||||
results.push(...subResults);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
results.push({
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue