diff --git a/.archon/workflows/defaults/archon-interactive-prd.yaml b/.archon/workflows/defaults/archon-interactive-prd.yaml index ccb08cb411..79024445d6 100644 --- a/.archon/workflows/defaults/archon-interactive-prd.yaml +++ b/.archon/workflows/defaults/archon-interactive-prd.yaml @@ -52,6 +52,7 @@ nodes: - id: foundation-gate approval: message: "Answer the foundation questions above. Your answers will guide the research phase." + capture_response: true depends_on: [initiate] # ═══════════════════════════════════════════════════════════════ @@ -106,6 +107,7 @@ nodes: - id: deepdive-gate approval: message: "Answer the deep dive questions above (vision, primary user, JTBD, constraints). Add any adjustments from the research." + capture_response: true depends_on: [research] # ═══════════════════════════════════════════════════════════════ @@ -172,6 +174,7 @@ nodes: - id: scope-gate approval: message: "Answer the scope questions above (MVP, must-haves, hypothesis, exclusions). This is the final input before PRD generation." + capture_response: true depends_on: [technical] # ═══════════════════════════════════════════════════════════════ @@ -188,11 +191,11 @@ nodes: **Deep dive answers**: $deepdive-gate.output **Scope answers**: $scope-gate.output - Generate a complete PRD file at `.claude/PRPs/prds/{kebab-case-name}.prd.md`. + Generate a complete PRD file at `$ARTIFACTS_DIR/prds/{kebab-case-name}.prd.md`. First create the directory: ```bash - mkdir -p .claude/PRPs/prds + mkdir -p $ARTIFACTS_DIR/prds ``` **First principles rule**: Before writing the Technical Approach section, READ the @@ -243,9 +246,9 @@ nodes: Read the PRD file that was just generated. The generate node output the file path: $generate.output - Find the PRD file — check `.claude/PRPs/prds/` for the most recently created `.prd.md` file: + Find the PRD file — check `$ARTIFACTS_DIR/prds/` for the most recently created `.prd.md` file: ```bash - ls -t .claude/PRPs/prds/*.prd.md | head -1 + ls -t $ARTIFACTS_DIR/prds/*.prd.md | head -1 ``` Read the entire PRD, then verify EVERY technical claim against the actual codebase: diff --git a/.claude/PRPs/issues/issue-1001-1002-1003.md b/.claude/PRPs/issues/issue-1001-1002-1003.md new file mode 100644 index 0000000000..1c3fa92929 --- /dev/null +++ b/.claude/PRPs/issues/issue-1001-1002-1003.md @@ -0,0 +1,417 @@ +# Investigation: Interactive PRD Workflow — Three Related Bugs + +**Issues**: #1001, #1002, #1003 +**URLs**: +- https://github.com/coleam00/Archon/issues/1001 +- https://github.com/coleam00/Archon/issues/1002 +- https://github.com/coleam00/Archon/issues/1003 +**Type**: BUG (all three) +**Investigated**: 2026-04-09 + +### Assessment + +| Metric | Value | Reasoning | +|------------|--------|-----------| +| Severity | HIGH | The interactive-prd workflow is completely non-functional: user answers are lost (#1001), output file can't be written (#1002), and CLI creates fragmented conversations (#1003). No workaround exists. | +| Complexity | MEDIUM | 3 files affected, but changes are isolated — YAML config fix, one return type addition, one conversation ID lookup. No architectural changes. | +| Confidence | HIGH | All three root causes are confirmed with code evidence. The fixes are straightforward and well-understood. | + +--- + +## Problem Statement + +The `archon-interactive-prd` workflow has three bugs that together make it completely non-functional: +1. Approval gates don't capture user responses, so all downstream nodes receive empty strings +2. The generate node writes to `.claude/` which the Claude SDK blocks +3. CLI `workflow approve` creates new conversations instead of reusing the original, fragmenting the Web UI + +--- + +## Analysis + +### Bug 1: Missing `capture_response: true` (#1001) + +**Evidence Chain:** + +WHY: Downstream nodes receive empty `$foundation-gate.output`, `$deepdive-gate.output`, `$scope-gate.output` +↓ BECAUSE: `approveWorkflow()` stores empty string as node output +Evidence: `packages/core/src/operations/workflow-operations.ts:176` +```typescript +const nodeOutput = approval.captureResponse === true ? approvalComment : ''; +``` +↓ BECAUSE: `captureResponse` is `undefined` (falsy) in the pause metadata +Evidence: `packages/workflows/src/dag-executor.ts:2337` +```typescript +captureResponse: node.approval.capture_response, // undefined when not set in YAML +``` +↓ ROOT CAUSE: All three approval gates in the YAML lack `capture_response: true` +Evidence: `.archon/workflows/defaults/archon-interactive-prd.yaml:52-55, 106-109, 172-175` + +**Git History:** +- **Introduced**: `9de8cf2c` (Mar 30) — workflow created before `capture_response` existed +- **Feature added**: #936 (Apr 2) — added `capture_response` support +- **Never updated**: The workflow was not retrofitted after #936 + +### Bug 2: Generate node writes to blocked `.claude/` path (#1002) + +**Evidence Chain:** + +WHY: `generate` node fails or produces truncated output +↓ BECAUSE: Claude SDK blocks writes to `.claude/` directory (hardcoded safety boundary) +↓ ROOT CAUSE: The prompt instructs AI to write to `.claude/PRPs/prds/` +Evidence: `.archon/workflows/defaults/archon-interactive-prd.yaml:191-196` +```yaml +Generate a complete PRD file at `.claude/PRPs/prds/{kebab-case-name}.prd.md`. + +First create the directory: +```bash +mkdir -p .claude/PRPs/prds +``` +``` + +The `validate` node also reads from the same blocked path at line 246-249. + +`$ARTIFACTS_DIR` is pre-created by the executor at `packages/workflows/src/executor.ts:468` and is always writable (resolves to `~/.archon/workspaces/{owner}/{repo}/artifacts/runs/{runId}/`). + +### Bug 3: CLI approve creates new conversations (#1003) + +**Evidence Chain:** + +WHY: Each approval creates a separate conversation in the Web UI +↓ BECAUSE: `workflowRunCommand` always generates a new conversation ID +Evidence: `packages/cli/src/commands/workflow.ts:272` +```typescript +const conversationId = generateConversationId(); // always new cli-{ts}-{rand} +``` +↓ BECAUSE: `workflowApproveCommand` calls `workflowRunCommand` without passing the original conversation ID +Evidence: `packages/cli/src/commands/workflow.ts:865` +```typescript +await workflowRunCommand(result.workingPath, result.workflowName, result.userMessage ?? '', { + resume: true, + codebaseId: result.codebaseId ?? undefined, +}); +``` +↓ ROOT CAUSE: `ApprovalOperationResult` doesn't include `conversationId`, so the approve command can't pass it through +Evidence: `packages/core/src/operations/workflow-operations.ts:32-38` +```typescript +export interface ApprovalOperationResult { + workflowName: string; + workingPath: string | null; + userMessage: string | null; + codebaseId: string | null; + type: 'interactive_loop' | 'approval_gate'; +} +``` + +The workflow run DOES store `conversation_id` (DB UUID) — `packages/core/src/db/workflows.ts:52`. And `getConversationById(id)` at `packages/core/src/db/conversations.ts:19` can retrieve the `platform_conversation_id` from the DB UUID. + +### Affected Files + +| File | Lines | Action | Description | +|------|-------|--------|-------------| +| `.archon/workflows/defaults/archon-interactive-prd.yaml` | 52-55, 106-109, 172-175, 191-196, 246-249 | UPDATE | Add `capture_response: true` to gates; change `.claude/PRPs/prds/` to `$ARTIFACTS_DIR/prds/` | +| `packages/core/src/operations/workflow-operations.ts` | 32-38, 202-208 | UPDATE | Add `conversationId` to `ApprovalOperationResult` and return it from `approveWorkflow` | +| `packages/cli/src/commands/workflow.ts` | 60-69, 272, 849-880 | UPDATE | Add `conversationId` to `WorkflowRunOptions`, accept it, pass it through from approve | + +### Integration Points + +- `packages/core/src/operations/workflow-operations.ts:202` returns result consumed by CLI approve command +- `packages/cli/src/commands/workflow.ts:865` calls `workflowRunCommand` with the result +- `packages/cli/src/commands/workflow.ts:272` generates conversation ID (needs to accept override) +- `packages/core/src/db/conversations.ts:19` `getConversationById()` — needed to look up platform ID from DB UUID +- `packages/workflows/src/dag-executor.ts:2337` reads `capture_response` from node YAML +- `packages/core/src/operations/workflow-operations.ts:176` uses `captureResponse` to decide output +- `RejectionOperationResult` at line 40-49 should also get `conversationId` for consistency (same pattern) + +--- + +## Implementation Plan + +### Step 1: Add `capture_response: true` to all three approval gates + +**File**: `.archon/workflows/defaults/archon-interactive-prd.yaml` +**Action**: UPDATE + +**Current code (line 52-55):** +```yaml + - id: foundation-gate + approval: + message: "Answer the foundation questions above. Your answers will guide the research phase." + depends_on: [initiate] +``` + +**Required change:** +```yaml + - id: foundation-gate + approval: + message: "Answer the foundation questions above. Your answers will guide the research phase." + capture_response: true + depends_on: [initiate] +``` + +**Same change for `deepdive-gate` (line 106-109) and `scope-gate` (line 172-175).** + +--- + +### Step 2: Change output path from `.claude/PRPs/prds/` to `$ARTIFACTS_DIR/prds/` + +**File**: `.archon/workflows/defaults/archon-interactive-prd.yaml` +**Action**: UPDATE + +**In `generate` node (line 191):** + +Change: +``` +Generate a complete PRD file at `.claude/PRPs/prds/{kebab-case-name}.prd.md`. + +First create the directory: +```bash +mkdir -p .claude/PRPs/prds +``` +``` + +To: +``` +Generate a complete PRD file at `$ARTIFACTS_DIR/prds/{kebab-case-name}.prd.md`. + +First create the directory: +```bash +mkdir -p $ARTIFACTS_DIR/prds +``` +``` + +**In `validate` node (line 246-249):** + +Change: +``` +Find the PRD file — check `.claude/PRPs/prds/` for the most recently created `.prd.md` file: +```bash +ls -t .claude/PRPs/prds/*.prd.md | head -1 +``` +``` + +To: +``` +Find the PRD file — check `$ARTIFACTS_DIR/prds/` for the most recently created `.prd.md` file: +```bash +ls -t $ARTIFACTS_DIR/prds/*.prd.md | head -1 +``` +``` + +--- + +### Step 3: Add `conversationId` to `ApprovalOperationResult` and `RejectionOperationResult` + +**File**: `packages/core/src/operations/workflow-operations.ts` +**Action**: UPDATE + +**Current (line 32-38):** +```typescript +export interface ApprovalOperationResult { + workflowName: string; + workingPath: string | null; + userMessage: string | null; + codebaseId: string | null; + type: 'interactive_loop' | 'approval_gate'; +} +``` + +**Required change:** +```typescript +export interface ApprovalOperationResult { + workflowName: string; + workingPath: string | null; + userMessage: string | null; + codebaseId: string | null; + conversationId: string; + type: 'interactive_loop' | 'approval_gate'; +} +``` + +**Same for `RejectionOperationResult` (line 40-49).** + +--- + +### Step 4: Return `conversationId` from `approveWorkflow` + +**File**: `packages/core/src/operations/workflow-operations.ts` +**Action**: UPDATE + +**Current return (line 166-172 for interactive_loop path):** +```typescript +return { + workflowName: run.workflow_name, + workingPath: run.working_path, + userMessage: run.user_message, + codebaseId: run.codebase_id, + type: 'interactive_loop', +}; +``` + +**Required change:** +```typescript +return { + workflowName: run.workflow_name, + workingPath: run.working_path, + userMessage: run.user_message, + codebaseId: run.codebase_id, + conversationId: run.conversation_id, + type: 'interactive_loop', +}; +``` + +**Same for the approval_gate return at line 202-208.** + +Also update `rejectWorkflow` return values (both paths) to include `conversationId: run.conversation_id`. + +--- + +### Step 5: Look up original platform conversation ID and pass it through in CLI + +**File**: `packages/cli/src/commands/workflow.ts` +**Action**: UPDATE + +**5a. Add `conversationId` to `WorkflowRunOptions` (line 55-69):** +```typescript +interface WorkflowRunOptions { + branchName?: string; + fromBranch?: string; + noWorktree?: boolean; + resume?: boolean; + codebaseId?: string; + allowEnvKeys?: boolean; + quiet?: boolean; + verbose?: boolean; + conversationId?: string; // Reuse existing conversation (e.g., from approve) +} +``` + +**5b. Use provided `conversationId` instead of generating new one (line 272):** + +Change: +```typescript +const conversationId = generateConversationId(); +``` + +To: +```typescript +const conversationId = options.conversationId ?? generateConversationId(); +``` + +**5c. Look up platform conversation ID in `workflowApproveCommand` and pass it (line 849-880):** + +After `const result = await approveWorkflow(runId, comment);` (line 850), add a lookup: + +```typescript +// Look up the original platform conversation ID to keep all messages in one thread +const originalConversation = await conversationDb.getConversationById(result.conversationId); +const platformConversationId = originalConversation?.platform_conversation_id; +``` + +Then pass it to `workflowRunCommand`: +```typescript +await workflowRunCommand(result.workingPath, result.workflowName, result.userMessage ?? '', { + resume: true, + codebaseId: result.codebaseId ?? undefined, + conversationId: platformConversationId ?? undefined, +}); +``` + +**5d. Same pattern for `workflowRejectCommand` — when the rejection triggers a retry (not cancellation), pass the original conversation ID through.** + +--- + +### Step 6: Update tests + +**Tests to verify:** + +1. **YAML validation**: Run `bun run cli validate workflows archon-interactive-prd` to confirm the YAML is valid after changes +2. **Type check**: `bun run type-check` to verify the interface changes compile +3. **Existing tests**: Run `bun run test` to ensure no regressions + +**Test cases to consider adding:** +- Unit test for `approveWorkflow` verifying `conversationId` is included in return value +- Integration test that `workflowApproveCommand` passes conversation ID through (may require mocking) + +--- + +## Patterns to Follow + +**From codebase — approval gate with capture_response:** +The `capture_response` field is already used elsewhere and the schema supports it: +```typescript +// SOURCE: packages/workflows/src/schemas/dag-node.ts:250 +capture_response: z.boolean().optional(), +``` + +**From codebase — conversation lookup by DB UUID:** +```typescript +// SOURCE: packages/core/src/db/conversations.ts:19-24 +export async function getConversationById(id: string): Promise { + const result = await pool.query( + 'SELECT * FROM remote_agent_conversations WHERE id = $1', + [id] + ); + return result.rows[0] ?? null; +} +``` + +**From codebase — $ARTIFACTS_DIR usage in other workflows:** +`$ARTIFACTS_DIR` is already used in other default workflows and is pre-created by the executor. + +--- + +## Edge Cases & Risks + +| Risk/Edge Case | Mitigation | +|----------------|------------| +| Original conversation deleted between run start and approve | `getConversationById` returns null → fall back to generating new ID (graceful degradation) | +| Existing paused runs from before this fix | They work fine — `capture_response` defaults to `undefined`/false, same as current behavior. New runs will capture properly. | +| `$ARTIFACTS_DIR` substitution in YAML | Already supported by the executor's variable substitution. Verified in `executor.ts:178-210`. | +| Reject command also creates new conversation | Fixed in Step 5d — same pattern applied to `workflowRejectCommand` | + +--- + +## Validation + +### Automated Checks + +```bash +bun run type-check +bun run test +bun run lint +bun run cli validate workflows archon-interactive-prd +``` + +### Manual Verification + +1. Run `bun run cli workflow run archon-interactive-prd "Build a todo app"` — verify it pauses at foundation-gate +2. Run `bun run cli workflow approve "My answers..."` — verify the approve reuses the same conversation +3. Check that the `research` node receives the user's answers in `$foundation-gate.output` +4. Complete all gates and verify the PRD is written to `$ARTIFACTS_DIR/prds/` (not `.claude/PRPs/prds/`) + +--- + +## Scope Boundaries + +**IN SCOPE:** +- Adding `capture_response: true` to three approval gates in `archon-interactive-prd.yaml` +- Changing output path from `.claude/PRPs/prds/` to `$ARTIFACTS_DIR/prds/` in generate and validate nodes +- Adding `conversationId` to `ApprovalOperationResult` and `RejectionOperationResult` +- Returning `conversation_id` from approve/reject operations +- Looking up and passing through original conversation ID in CLI approve/reject commands + +**OUT OF SCOPE (do not touch):** +- Other workflows or default commands +- The approval gate mechanism itself (it works correctly when `capture_response` is set) +- The executor's variable substitution logic +- Database schema changes (no migration needed) +- Chat platform approve commands (Slack/Telegram already handle conversation continuity differently) + +--- + +## Metadata + +- **Investigated by**: Claude +- **Timestamp**: 2026-04-09 +- **Artifact**: `.claude/PRPs/issues/issue-1001-1002-1003.md` diff --git a/.claude/PRPs/issues/issue-978.md b/.claude/PRPs/issues/issue-978.md new file mode 100644 index 0000000000..1d11f53601 --- /dev/null +++ b/.claude/PRPs/issues/issue-978.md @@ -0,0 +1,538 @@ +# Investigation: One-command web UI install via `archon serve` + +**Issue**: #978 (https://github.com/coleam00/Archon/issues/978) +**Type**: ENHANCEMENT +**Investigated**: 2026-04-09T12:00:00Z + +### Assessment + +| Metric | Value | Reasoning | +| ---------- | ------ | --------------------------------------------------------------------------------------------------------------------------------- | +| Priority | MEDIUM | High user value (removes clone+build friction), but existing Docker path and clone path work; not blocking other work | +| Complexity | HIGH | 8+ files across CLI, server, CI, build scripts; server refactor is the hardest part — `main()` is 600 lines with no library API | +| Confidence | HIGH | Clear codebase analysis, all integration points mapped, no unknowns in the download/extract path; server refactor scope is bounded | + +--- + +## Problem Statement + +The compiled Archon CLI binary includes only `packages/cli/src/cli.ts` — no server, no web UI, no `archon serve` command. Users who want the web UI must clone the entire monorepo, install Bun, run `bun install` (2274 packages), and `bun dev`. There is no one-command path to get a working web UI from the binary install. + +--- + +## Analysis + +### Change Rationale + +The web UI is the most discoverable part of the product, but it's behind the highest friction install path. The proposed approach — lazy-fetching a pre-built web UI tarball from GitHub releases on first `archon serve` — keeps the CLI binary small for CLI-only users while giving web UI users a one-command experience: `brew install coleam00/archon/archon && archon serve`. + +### Key Design Decision: Server as Library vs Embedded Mini-Server + +The current server (`packages/server/src/index.ts`) is a 721-line script with a monolithic `main()` function (line 129-718). It has no `startServer()` export and cannot be imported as a library. Two approaches: + +**Option A: Full server refactor** — Extract `main()` into an exported `startServer(opts)` function, make `@archon/server` a dependency of `@archon/cli`, compile the full server into the binary. Binary grows from ~50MB to ~65MB. All platform adapters (Slack, Telegram, GitHub, Discord) would be compiled in. + +**Option B: Minimal embedded server** — Create a lightweight Hono server in `packages/cli/src/commands/serve.ts` that only registers API routes + static serving. No platform adapters. Binary stays closer to current size. Uses `registerApiRoutes()` (already exported from `packages/server/src/routes/api.ts:837`) as the core building block. + +**Recommendation: Option A (full refactor)** because: +- Option B would duplicate server initialization logic and diverge over time +- Platform adapters are only instantiated when env vars are present (all conditional, see `index.ts:296-459`) — zero cost if not configured +- The binary size increase (~15MB) is acceptable +- Users get the full server experience, not a subset + +### Affected Files + +| File | Lines | Action | Description | +|------|-------|--------|-------------| +| `packages/cli/src/commands/serve.ts` | NEW | CREATE | `archon serve` command: download web-dist, start server | +| `packages/cli/src/cli.ts` | 57-82, 231, 266+ | UPDATE | Add `'serve'` to `noGitCommands`, add `case 'serve'` | +| `packages/cli/package.json` | deps | UPDATE | Add `@archon/server` and `@archon/adapters` as dependencies | +| `packages/server/src/index.ts` | 129-718 | UPDATE | Extract `main()` into exported `startServer(opts)` | +| `packages/server/src/index.ts` | 579-593 | UPDATE | Accept `webDistPath` parameter instead of computing from `import.meta.dir` | +| `.github/workflows/release.yml` | 140-173 | UPDATE | Add web UI build + tarball upload step | +| `scripts/build-binaries.sh` | — | NONE | No change needed — `bun build --compile` follows imports automatically | +| `packages/paths/src/archon-paths.ts` | — | UPDATE | Add `getWebDistPath(version)` helper | +| Tests | NEW | CREATE | Cover download, checksum, extraction, server startup from CLI | + +### Integration Points + +- `packages/cli/src/cli.ts:57-82` imports all commands after dotenv setup +- `packages/server/src/routes/api.ts:837` exports `registerApiRoutes(app, webAdapter, lockManager)` — the only reusable server building block +- `packages/paths/src/bundled-build.ts` provides `BUNDLED_VERSION` for constructing release URLs +- `packages/paths/src/archon-paths.ts:56-74` provides `getArchonHome()` for cache location +- `packages/server/src/index.ts:581-593` resolves `webDistPath` from `import.meta.dir` — needs parameterization +- `.github/workflows/release.yml:163-173` publishes release assets via `softprops/action-gh-release@v2` + +### Git History + +- **Server last touched**: `4b2bcb0e` (env-leak-gate polish) — active development area +- **CLI last touched**: `dddff870` (embed git commit hash in version) — recent changes +- **Build scripts**: `9adc54af` (wire release workflow to build-binaries.sh) — recently stabilized + +--- + +## Implementation Plan + +### Step 1: Extract `startServer(opts)` from server's `main()` + +**File**: `packages/server/src/index.ts` +**Lines**: 129-718 +**Action**: UPDATE + +**Current code (simplified):** +```typescript +async function main(): Promise { + // 600 lines of initialization, adapter creation, route registration, Bun.serve() +} + +main().catch(error => { ... process.exit(1); }); +``` + +**Required change:** + +```typescript +export interface ServerOptions { + /** Override the web dist path (for CLI binary with downloaded web-dist) */ + webDistPath?: string; + /** Override the port */ + port?: number; + /** Skip platform adapter initialization (CLI serve mode) */ + skipPlatformAdapters?: boolean; +} + +export async function startServer(opts: ServerOptions = {}): Promise { + // Move entire main() body here + // Replace webDistPath computation (lines 584-588) with: + // opts.webDistPath ?? pathModule.join(pathModule.dirname(pathModule.dirname(import.meta.dir)), 'web', 'dist') + // Replace port with: opts.port ?? getPort() + // Wrap platform adapter blocks with: if (!opts.skipPlatformAdapters) { ... } +} + +// Keep backward compat: script entry point still works +if (import.meta.main) { + startServer().catch(error => { + getLog().fatal({ error: error instanceof Error ? error.message : String(error) }, 'startup_failed'); + process.exit(1); + }); +} +``` + +**Why**: Makes the server importable as a library. `import.meta.main` guard ensures the file still works as a standalone script for `bun dev`. + +--- + +### Step 2: Add `getWebDistDir()` path helper + +**File**: `packages/paths/src/archon-paths.ts` +**Action**: UPDATE + +**Add function:** +```typescript +/** + * Returns the path to the cached web UI distribution for a given version. + * Example: ~/.archon/web-dist/v0.3.2/ + */ +export function getWebDistDir(version: string): string { + return join(getArchonHome(), 'web-dist', version); +} +``` + +**Why**: Centralizes the cache location logic, consistent with existing `getArchonHome()` patterns. + +--- + +### Step 3: Create `archon serve` command + +**File**: `packages/cli/src/commands/serve.ts` +**Action**: CREATE + +```typescript +import { existsSync } from 'fs'; +import { createLogger, getWebDistDir } from '@archon/paths'; +import { BUNDLED_IS_BINARY, BUNDLED_VERSION } from '@archon/paths/bundled-build'; + +const log = createLogger('cli.serve'); + +const GITHUB_REPO = 'coleam00/Archon'; + +interface ServeOptions { + port?: number; + downloadOnly?: boolean; +} + +export async function serveCommand(opts: ServeOptions): Promise { + const version = BUNDLED_IS_BINARY ? BUNDLED_VERSION : 'dev'; + + if (version === 'dev') { + console.error('Error: `archon serve` is for compiled binaries only.'); + console.error('For development, use: bun run dev'); + return 1; + } + + const webDistDir = getWebDistDir(version); + + if (!existsSync(webDistDir)) { + await downloadWebDist(version, webDistDir); + } + + if (opts.downloadOnly) { + log.info({ webDistDir }, 'web_dist.download_completed'); + console.log(`Web UI downloaded to: ${webDistDir}`); + return 0; + } + + // Import server and start + const { startServer } = await import('@archon/server'); + await startServer({ + webDistPath: webDistDir, + port: opts.port, + skipPlatformAdapters: false, // Start all configured adapters + }); + + // Server runs until SIGINT/SIGTERM — never returns + return 0; +} + +async function downloadWebDist(version: string, targetDir: string): Promise { + const tarballUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/archon-web.tar.gz`; + const checksumsUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/checksums.txt`; + + console.log(`Web UI not found locally — downloading from release v${version}...`); + + // Download checksums + const checksumsRes = await fetch(checksumsUrl); + 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); + if (!tarballRes.ok) { + throw new Error(`Failed to download web UI: ${tarballRes.status} ${tarballRes.statusText}`); + } + const tarballBuffer = await tarballRes.arrayBuffer(); + + // Verify checksum + const hasher = new Bun.CryptoHasher('sha256'); + hasher.update(new Uint8Array(tarballBuffer)); + const actualHash = hasher.digest('hex'); + + if (actualHash !== expectedHash) { + throw new Error(`Checksum mismatch: expected ${expectedHash}, got ${actualHash}`); + } + console.log('Checksum verified.'); + + // Extract to temp dir, then atomic rename + const tmpDir = `${targetDir}.tmp`; + const { mkdirSync, renameSync, rmSync } = await import('fs'); + + // Clean up any previous failed attempt + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + + // Extract tarball using tar (available on macOS/Linux) + const proc = Bun.spawn(['tar', 'xzf', '-', '-C', tmpDir, '--strip-components=1'], { + stdin: new Uint8Array(tarballBuffer), + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + rmSync(tmpDir, { recursive: true, force: true }); + throw new Error(`tar extraction failed with exit code ${exitCode}`); + } + + // Atomic move + renameSync(tmpDir, targetDir); + console.log(`Extracted to ${targetDir}`); +} + +function parseChecksum(checksums: string, filename: string): string { + for (const line of checksums.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 2 && parts[1] === filename) { + return parts[0]; + } + } + throw new Error(`Checksum not found for ${filename} in checksums.txt`); +} +``` + +**Why**: Self-contained command following existing CLI patterns. Atomic extraction prevents half-broken state. Checksum verification prevents supply chain attacks. + +--- + +### Step 4: Wire `serve` into CLI command dispatch + +**File**: `packages/cli/src/cli.ts` +**Lines**: 57-82, 231, 266+ +**Action**: UPDATE + +**Change 1** — Add import (after line 82): +```typescript +import { serveCommand } from './commands/serve.js'; +``` + +**Change 2** — Add to `noGitCommands` (line 231): +```typescript +const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue', 'serve']; +``` + +**Change 3** — Add case in switch (after the existing `case 'continue'` block): +```typescript +case 'serve': { + const servePort = values.port ? Number(values.port) : undefined; + const downloadOnly = Boolean(values['download-only']); + return await serveCommand({ port: servePort, downloadOnly }); +} +``` + +**Change 4** — Add `--port` and `--download-only` to `parseArgs` options: +```typescript +port: { type: 'string' }, +'download-only': { type: 'boolean', default: false }, +``` + +**Change 5** — Update `printUsage()` to include `serve`: +``` + serve Start the web UI server (downloads web UI on first run) + --port Override server port (default: 3090) + --download-only Download web UI without starting the server +``` + +**Why**: Follows exact patterns of existing commands. `serve` doesn't need a git repo. + +--- + +### Step 5: Add `@archon/server` dependency to CLI package + +**File**: `packages/cli/package.json` +**Action**: UPDATE + +Add to `dependencies`: +```json +"@archon/server": "workspace:*", +"@archon/adapters": "workspace:*" +``` + +**Why**: The CLI needs to import `startServer` from `@archon/server`. `@archon/adapters` is a transitive dependency of `@archon/server` and should be explicit. + +--- + +### Step 6: Update release CI to build and publish web UI tarball + +**File**: `.github/workflows/release.yml` +**Action**: UPDATE + +**Add new job** (or add steps to existing `release` job, after artifact download): + +```yaml + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build web UI + run: bun --filter @archon/web run build + + - name: Package web dist + run: | + tar czf dist/archon-web.tar.gz -C packages/web/dist . + + - name: Generate checksums + run: | + cd dist + sha256sum archon-* archon-web.tar.gz > checksums.txt + cat checksums.txt +``` + +**Update** the `files:` block in the release step: +```yaml + files: | + dist/archon-* + dist/archon-web.tar.gz + dist/checksums.txt +``` + +**Why**: Publishes a single platform-independent web UI tarball alongside the existing per-platform binaries. Checksums cover all artifacts. + +--- + +### Step 7: Add/Update Tests + +**File**: `packages/cli/src/commands/serve.test.ts` +**Action**: CREATE + +**Test cases to add:** + +```typescript +describe('serveCommand', () => { + it('should reject in dev mode (non-binary)', () => { + // Mock BUNDLED_IS_BINARY = false + // Expect exit code 1 with "compiled binaries only" message + }); + + it('should download web-dist when not cached', () => { + // Mock fetch to return tarball + checksums + // Verify extraction to correct path + }); + + it('should skip download when already cached', () => { + // Pre-create the web-dist dir + // Verify no fetch calls + }); + + it('should fail on checksum mismatch', () => { + // Mock fetch with wrong checksum + // Expect error, no leftover .tmp dir + }); + + it('should handle network failure gracefully', () => { + // Mock fetch to throw + // Expect actionable error message + }); + + it('should support --download-only', () => { + // Mock fetch, run with downloadOnly: true + // Verify no startServer call + }); +}); + +describe('parseChecksum', () => { + it('should extract hash for matching filename', () => { + // Known checksums.txt format + }); + + it('should throw for missing filename', () => { + // checksums.txt without the expected entry + }); +}); +``` + +--- + +## Patterns to Follow + +**From codebase — mirror these exactly:** + +```typescript +// SOURCE: packages/cli/src/commands/version.ts:79-88 +// Pattern for binary detection +if (BUNDLED_IS_BINARY) { + version = BUNDLED_VERSION; + gitCommit = BUNDLED_GIT_COMMIT; +} else { + const devInfo = await getDevVersion(); + version = devInfo.version; + gitCommit = await getDevGitCommit(); +} +``` + +```typescript +// SOURCE: packages/paths/src/archon-paths.ts:56-74 +// Pattern for path resolution with ARCHON_HOME override +export function getArchonHome(): string { + if (isDocker()) { + return '/.archon'; + } + const envHome = process.env.ARCHON_HOME; + if (envHome) { /* ... */ return expandTilde(envHome); } + return join(homedir(), '.archon'); +} +``` + +```typescript +// SOURCE: packages/server/src/index.ts:579-593 +// Pattern for static file serving (to be parameterized) +if (process.env.NODE_ENV === 'production' || !process.env.WEB_UI_DEV) { + const { serveStatic } = await import('hono/bun'); + app.use('/assets/*', serveStatic({ root: webDistPath })); + app.get('*', serveStatic({ root: webDistPath, path: 'index.html' })); +} +``` + +--- + +## Edge Cases & Risks + +| Risk/Edge Case | Mitigation | +|---------------|------------| +| Server refactor breaks `bun dev` | `import.meta.main` guard keeps script-mode working; test both paths | +| Binary size bloat from including server | Monitor: current ~50MB, expected ~65MB. Acceptable for the value. | +| Tarball extraction fails (permissions, disk space) | Atomic extraction (`.tmp` → rename); clean up on failure; clear error message | +| GitHub release rate limiting | `fetch` will return 403 — surface the error with retry suggestion | +| Air-gapped environments | `--download-only` allows pre-caching; future `--web-dist ` for offline | +| Version mismatch (binary v0.3.2 but no release exists yet) | Fail with "release not found" — only happens if someone builds from source with wrong version | +| `tar` not available on system | Available on all macOS/Linux; for Windows, use Bun's built-in tar or `decompress` | +| Concurrent `archon serve` calls during first download | Atomic rename prevents corruption; second process sees complete dir or retries | +| `@archon/server` import increases CLI startup time | Use dynamic `await import()` in serve command only — other commands unaffected | + +--- + +## Validation + +### Automated Checks + +```bash +bun run type-check +bun run test +bun run lint +bun run validate # Full pre-PR validation +``` + +### Manual Verification + +1. Run `bun run dev` — verify server still starts normally (script mode preserved) +2. Build binary: `VERSION=test scripts/build-binaries.sh` — verify it compiles +3. Run binary with `archon serve` — verify download + extraction + server start +4. Run binary with `archon serve --download-only` — verify download without server +5. Run binary with `archon serve` a second time — verify cached (no download) +6. Run `archon workflow list` — verify no startup time regression from server dep +7. Verify `archon serve --port 4000` — verify port override works + +--- + +## Scope Boundaries + +**IN SCOPE:** +- Server library refactor (extract `startServer()`) +- `archon serve` CLI command with download + checksum + extract +- `--port` and `--download-only` flags +- Release CI changes to build and publish `archon-web.tar.gz` +- Path helper for web-dist cache location +- Tests for download/extract/checksum logic + +**OUT OF SCOPE (do not touch):** +- `bun dev` workflow — stays as-is for contributors +- Docker image — orthogonal, not affected +- CDN mirroring — GitHub releases sufficient for now +- `archon serve --web-version=latest` — defer to future issue +- `archon serve --offline --web-dist=./path` — defer (can add later) +- Homebrew formula changes — just update docs, no formula change needed +- Auto-update of cached web-dist — version-keyed dirs handle this naturally +- Deprecating clone-and-bun-dev — keep for contributors +- Platform adapter lazy loading optimization — all adapters already conditional on env vars + +--- + +## Implementation Order + +The steps have a strict dependency chain: + +1. **Step 2** (path helper) — no deps, can go first +2. **Step 1** (server refactor) — the hardest part, do early +3. **Step 5** (CLI package.json dep) — needed before Step 3 +4. **Step 3** (serve command) — depends on Steps 1, 2, 5 +5. **Step 4** (CLI wiring) — depends on Step 3 +6. **Step 7** (tests) — depends on Steps 3, 4 +7. **Step 6** (CI changes) — independent, can be done in parallel with 3-7 + +--- + +## Metadata + +- **Investigated by**: Claude +- **Timestamp**: 2026-04-09T12:00:00Z +- **Artifact**: `.claude/PRPs/issues/issue-978.md` diff --git a/.claude/rules/workflows.md b/.claude/rules/workflows.md index 8437d39bb9..99cf6f8913 100644 --- a/.claude/rules/workflows.md +++ b/.claude/rules/workflows.md @@ -65,8 +65,9 @@ await executeWorkflow(deps, platform, conversationId, cwd, workflow, ...); - `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`. +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 diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 4649987076..4b0e197282 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -186,12 +186,22 @@ git pull origin main git push origin dev ``` +> **Important**: This sync ensures dev has the merge commit from main. Without it, +> dev and main diverge. The CI `update-homebrew` job only pushes the formula +> commit to dev — it does not bring the PR merge commit onto dev. This manual +> `git pull origin main` is what ensures dev has the merge commit. + The GitHub Release is distinct from the git tag — without it, the release won't appear on the repository's Releases page. Always create it. If the user merges the PR themselves and comes back, still offer to tag, release, and sync. ### Step 10: Wait for Release Workflow and Update Homebrew Formula +> **Note**: The `update-homebrew` CI job in `.github/workflows/release.yml` runs automatically +> after the release job and handles the formula update + push to dev (part of Step 10). +> Step 11 (tap sync to `coleam00/homebrew-archon`) is always manual. Check the Actions tab +> before running Step 10 manually. + After the tag is pushed, `.github/workflows/release.yml` builds platform binaries and uploads them to the GitHub release. This takes 5-10 minutes. The Homebrew formula SHA256 values cannot be known until these binaries exist. **Wait for all assets to appear on the release:** @@ -200,16 +210,16 @@ After the tag is pushed, `.github/workflows/release.yml` builds platform binarie echo "Waiting for release workflow to finish uploading binaries..." for i in {1..30}; do ASSET_COUNT=$(gh release view "vx.y.z" --repo coleam00/Archon --json assets --jq '.assets | length') - # Expect 6 assets: 5 binaries (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64.exe) + checksums.txt - if [ "$ASSET_COUNT" -ge 6 ]; then + # Expect 7 assets: 5 binaries (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64.exe) + archon-web.tar.gz + checksums.txt + if [ "$ASSET_COUNT" -ge 7 ]; then echo "All $ASSET_COUNT assets uploaded" break fi - echo " Assets so far: $ASSET_COUNT/6 — waiting 30s (attempt $i/30)..." + echo " Assets so far: $ASSET_COUNT/7 — waiting 30s (attempt $i/30)..." sleep 30 done -if [ "$ASSET_COUNT" -lt 6 ]; then +if [ "$ASSET_COUNT" -lt 7 ]; then echo "ERROR: Release workflow did not finish uploading assets after 15 minutes" echo "Check https://github.com/coleam00/Archon/actions for the release workflow run" exit 1 diff --git a/.env.example b/.env.example index 77981f515a..325e49a6fb 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,7 @@ CODEX_ID_TOKEN= CODEX_ACCESS_TOKEN= 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1de8f29034..c8e49e2e1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,10 +145,25 @@ jobs: path: dist merge-multiple: true + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build web UI + run: bun --filter @archon/web run build + + - name: Package web dist + run: | + tar czf dist/archon-web.tar.gz -C packages/web/dist . + - name: Generate checksums run: | cd dist - sha256sum archon-* > checksums.txt + sha256sum archon-* archon-web.tar.gz > checksums.txt cat checksums.txt - name: Get version @@ -170,6 +185,7 @@ jobs: generate_release_notes: true files: | dist/archon-* + dist/archon-web.tar.gz dist/checksums.txt body: | ## Installation diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c430f0f2c..b259551bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.3] - 2026-04-10 + +Binary distribution improvements, new workflow node type, and a batch of bug fixes. + +### Added + +- **`archon serve` command**: one-command way for compiled binary users to start the web UI server. Downloads a pre-built web UI tarball from GitHub releases on first run, verifies SHA-256 checksum, caches locally, then starts the full server (#1011) +- **Automatic update check**: binary users see a notification when a newer version is available on GitHub. Non-blocking, cached for 24 hours (#1039) +- **Script node type for DAG workflows**: `script:` nodes run inline TypeScript/Python or named scripts from `.archon/scripts/` via `bun` or `uv` runtimes. Supports `deps:` for dependency installation and `timeout:` in milliseconds (#999) +- **Codex native binary auto-resolution**: compiled builds now locate the Codex CLI binary automatically instead of requiring a manual `CODEX_CLI_PATH` override (#995, #1012) + +### Fixed + +- **Workflow reject ignores positional reason**: `archon workflow reject ` now correctly passes the reason argument to the rejection handler +- **Windows script path separators**: normalize backslashes to forward slashes in script node paths for cross-platform compatibility +- **PowerShell `Add-ToUserPath` corruption**: installer no longer corrupts `PATH` when only a single entry exists (#1000) +- **Validator `Promise.any` race condition**: script runtime checks no longer fail intermittently due to a `Promise.any` edge case (#1007, #1010) +- **Interactive-prd workflow bugs**: fixes to loop gate handling, variable substitution, and node ordering (#1001, #1002, #1003, #1005) +- **Community forge adapter exports**: added explicit export entries for Gitea and GitLab adapters so they resolve correctly in compiled builds (#1041) +- **Workflow graph view without codebase**: the web UI workflow graph now loads correctly even when no codebase is selected (#958) + ## [0.3.2] - 2026-04-08 Critical hotfix: compiled binaries could not spawn Claude. Also fixes an env-leak gate false-positive for unregistered working directories. diff --git a/CLAUDE.md b/CLAUDE.md index fa00b0fb04..f38cb29a98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,6 +244,11 @@ bun run cli validate commands my-command # Single command bun run cli complete bun run cli complete --force # Skip uncommitted-changes check +# Start the web UI server (compiled binary only, downloads web UI on first run) +bun run cli serve +bun run cli serve --port 4000 +bun run cli serve --download-only # Download without starting + # Show version bun run cli version ``` @@ -394,11 +399,11 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api'; ### Architecture Layers **Package Split:** -- **@archon/paths**: Path resolution utilities and Pino logger factory (no @archon/* deps) +- **@archon/paths**: Path resolution utilities, Pino logger factory, web dist cache path (`getWebDistDir`) (no @archon/* deps) - **@archon/git**: Git operations - worktrees, branches, repos, exec wrappers (depends only on @archon/paths) - **@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/cli**: Command-line interface for running workflows +- **@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/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) @@ -464,6 +469,7 @@ assistants: webSearchMode: live # 'disabled' | 'cached' | 'live' additionalDirectories: - /absolute/path/to/other/repo + codexBinaryPath: /usr/local/bin/codex # Optional: custom Codex CLI binary path # docs: # path: docs # Optional: default is docs/ @@ -530,6 +536,9 @@ curl http://localhost:3637/api/conversations//messages │ │ ├── runs/{id}/ # Per-run artifacts ($ARTIFACTS_DIR) │ │ └── uploads/{convId}/ # Web UI file uploads (ephemeral) │ └── logs/ # Workflow execution logs +├── vendor/codex/ # Codex native binary (binary builds, user-placed) +├── web-dist// # Cached web UI dist (archon serve, binary only) +├── update-check.json # Update check cache (binary builds, 24h TTL) ├── archon.db # SQLite database (when DATABASE_URL not set) └── config.yaml # Global configuration (non-secrets) ``` @@ -539,6 +548,7 @@ curl http://localhost:3637/api/conversations//messages .archon/ ├── commands/ # Custom commands ├── workflows/ # Workflow definitions (YAML files) +├── scripts/ # Named scripts for script: nodes (.ts/.js for bun, .py for uv) └── config.yaml # Repo-specific configuration ``` @@ -671,7 +681,7 @@ async function createSession(conversationId: string, codebaseId: string) { 2. **Workflows** (YAML-based): - Stored in `.archon/workflows/` (searched recursively) - Multi-step AI execution chains, discovered at runtime - - **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI), `loop:` (iterative AI prompt until completion signal) . 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), `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 `$.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) - 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) @@ -759,6 +769,9 @@ 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' }] }` +**System:** +- `GET /api/update-check` - Check for available updates; returns `{ updateAvailable, currentVersion, latestVersion, releaseUrl }`; skips GitHub API call for non-binary builds + **OpenAPI Spec:** - `GET /api/openapi.json` - Generated OpenAPI 3.0 spec for all Zod-validated routes diff --git a/README.md b/README.md index cd13758c71..6c4c827783 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ The coding agent handles workflow selection, branch naming, and worktree isolati ## Web UI -Archon includes a web dashboard for chatting with your coding agent, running workflows, and monitoring activity. To start it, ask your coding agent to run the frontend from the Archon repo, or run `bun run dev` from the repo root yourself. +Archon includes a web dashboard for chatting with your coding agent, running workflows, and monitoring activity. Binary installs: run `archon serve` to download and start the web UI in one step. From source: ask your coding agent to run the frontend from the Archon repo, or run `bun run dev` from the repo root yourself. Register a project by clicking **+** next to "Project" in the chat sidebar - enter a GitHub URL or local path. Then start a conversation, invoke workflows, and watch progress in real time. diff --git a/bun.lock b/bun.lock index d7cbe31a1b..8c00855c14 100644 --- a/bun.lock +++ b/bun.lock @@ -46,10 +46,12 @@ "archon": "./src/cli.ts", }, "dependencies": { + "@archon/adapters": "workspace:*", "@archon/core": "workspace:*", "@archon/git": "workspace:*", "@archon/isolation": "workspace:*", "@archon/paths": "workspace:*", + "@archon/server": "workspace:*", "@archon/workflows": "workspace:*", "@clack/prompts": "^1.0.0", "dotenv": "^17.2.3", diff --git a/homebrew/archon.rb b/homebrew/archon.rb index 340cc6fd7f..b61cbb50ee 100644 --- a/homebrew/archon.rb +++ b/homebrew/archon.rb @@ -7,28 +7,28 @@ class Archon < Formula desc "Remote agentic coding platform - control AI assistants from anywhere" homepage "https://github.com/coleam00/Archon" - version "0.3.1" + version "0.3.2" license "MIT" on_macos do on_arm do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-arm64" - sha256 "2538346f483d9351d6738a0ef9299e0f475d402005c61286c2557ce41d8a47b9" + sha256 "aa71b295bf7bb7addfc9372629ac154411075405639d699382a75537ed08cf43" end on_intel do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-x64" - sha256 "2eb2c2b8a502270c5d049316f4a2e1314348df3e9112f36953132e3c2a2c67ce" + sha256 "b9ac05db31bd9caf37f23412882bd5549f132005161c195179ec97ddcb0a659e" end end on_linux do on_arm do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-arm64" - sha256 "12c135688310ba0c0c334832f69418d93a13d50c4013ad84803b05cb776f14be" + sha256 "4db2ca27161011aa8c109ca7c658bb4cfe1a5ee8061b65e5c68054a7a9ff4cfd" end on_intel do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-x64" - sha256 "071d8d1bb64e30a60a2c1514d581747a8f27d2fbe1cc33bcc66426b8265f3a65" + sha256 "f853efa7058ef8d8e064e2b0be9eabebc4b58ee4d123872cffcf9220d1daf22e" end end diff --git a/package.json b/package.json index c93183dfef..d34fcbe257 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "archon", - "version": "0.3.2", + "version": "0.3.3", "private": true, "workspaces": [ "packages/*" diff --git a/packages/adapters/package.json b/packages/adapters/package.json index 150048d903..fb07baaa8b 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -6,7 +6,9 @@ "types": "./src/index.ts", "exports": { ".": "./src/index.ts", - "./*": "./src/*" + "./*": "./src/*", + "./community/forge/gitea": "./src/community/forge/gitea/index.ts", + "./community/forge/gitlab": "./src/community/forge/gitlab/index.ts" }, "scripts": { "test": "bun test src/chat/ src/community/chat/ src/community/forge/gitlab/auth.test.ts src/forge/github/auth.test.ts src/utils/ && bun test src/forge/github/adapter.test.ts && bun test src/forge/github/context.test.ts && bun test src/community/forge/gitea/adapter.test.ts && bun test src/community/forge/gitlab/adapter.test.ts", diff --git a/packages/adapters/src/community/forge/gitlab/adapter.test.ts b/packages/adapters/src/community/forge/gitlab/adapter.test.ts index 8513ad91ac..272f66b0ca 100644 --- a/packages/adapters/src/community/forge/gitlab/adapter.test.ts +++ b/packages/adapters/src/community/forge/gitlab/adapter.test.ts @@ -80,6 +80,10 @@ mock.module('@archon/isolation', () => ({ IsolationHints: {}, })); +// Mock global fetch to prevent real HTTP calls (gitlab.example.com hangs on CI Linux) +const mockFetch = mock(() => Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))); +globalThis.fetch = mockFetch as typeof globalThis.fetch; + // Now import the adapter (after all mocks) const { GitLabAdapter } = await import('./adapter'); const { ConversationLockManager } = await import('@archon/core'); @@ -158,6 +162,7 @@ describe('GitLabAdapter', () => { beforeEach(() => { mockHandleMessage.mockClear(); mockOnConversationClosed.mockClear(); + mockFetch.mockClear(); // Reset env delete process.env.GITLAB_ALLOWED_USERS; }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 517abb2680..b5574f4ce2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,14 +8,16 @@ }, "scripts": { "cli": "bun src/cli.ts", - "test": "bun test src/commands/version.test.ts src/commands/setup.test.ts && bun test src/commands/workflow.test.ts && bun test src/commands/isolation.test.ts && bun test src/commands/chat.test.ts", + "test": "bun test src/commands/version.test.ts src/commands/setup.test.ts && bun test src/commands/workflow.test.ts && bun test src/commands/isolation.test.ts && bun test src/commands/chat.test.ts && bun test src/commands/serve.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { + "@archon/adapters": "workspace:*", "@archon/core": "workspace:*", "@archon/git": "workspace:*", "@archon/isolation": "workspace:*", "@archon/paths": "workspace:*", + "@archon/server": "workspace:*", "@archon/workflows": "workspace:*", "@clack/prompts": "^1.0.0", "dotenv": "^17.2.3" diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8852dbc657..c32863d271 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -78,8 +78,15 @@ import { continueCommand } from './commands/continue'; import { chatCommand } from './commands/chat'; import { setupCommand } from './commands/setup'; import { validateWorkflowsCommand, validateCommandsCommand } from './commands/validate'; +import { serveCommand } from './commands/serve'; import { closeDatabase } from '@archon/core'; -import { setLogLevel, createLogger } from '@archon/paths'; +import { + setLogLevel, + createLogger, + checkForUpdate, + BUNDLED_IS_BINARY, + BUNDLED_VERSION, +} from '@archon/paths'; import * as git from '@archon/git'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ @@ -110,6 +117,7 @@ Commands: isolation cleanup --merged Remove environments with branches merged into main continue [msg] Continue work on an existing worktree with prior context complete [...] Complete branch lifecycle (remove worktree + branches) + serve Start the web UI server (downloads web UI on first run) validate workflows [name] Validate workflow definitions and their references validate commands [name] Validate command files version Show version info @@ -130,6 +138,8 @@ Options: --allow-env-keys Grant env-key consent during auto-registration (bypasses the env-leak gate for this codebase; logs an audit entry) + --port Override server port for 'serve' (default: 3090) + --download-only Download web UI without starting the server Examples: archon chat "What does the orchestrator do?" @@ -155,6 +165,20 @@ async function closeDb(): Promise { } } +async function printUpdateNotice(quiet: boolean | undefined): Promise { + if (quiet || !BUNDLED_IS_BINARY) return; + try { + const result = await checkForUpdate(BUNDLED_VERSION); + if (result?.updateAvailable) { + process.stderr.write( + `Update available: v${result.currentVersion} → v${result.latestVersion} — ${result.releaseUrl}\n` + ); + } + } catch (err) { + getLog().debug({ err }, 'update_check.notice_failed'); + } +} + /** * Main CLI entry point * Returns exit code (0 = success, non-zero = failure) @@ -194,6 +218,8 @@ async function main(): Promise { workflow: { type: 'string' }, 'no-context': { type: 'boolean' }, 'allow-env-keys': { type: 'boolean' }, + port: { type: 'string' }, + 'download-only': { type: 'boolean' }, }, allowPositionals: true, strict: false, // Allow unknown flags to pass through @@ -228,7 +254,7 @@ async function main(): Promise { const subcommand = positionals[1]; // Commands that don't require git repo validation - const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue']; + const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue', 'serve']; const requiresGitRepo = !noGitCommands.includes(command ?? ''); try { @@ -376,10 +402,11 @@ async function main(): Promise { case 'reject': { const rejectRunId = positionals[2]; if (!rejectRunId) { - console.error('Usage: archon workflow reject [--reason "..."]'); + console.error('Usage: archon workflow reject [reason]'); return 1; } - const rejectReason = values.reason as string | undefined; + const rejectReason = + (values.reason as string | undefined) || positionals.slice(3).join(' ') || undefined; await workflowRejectCommand(rejectRunId, rejectReason); break; } @@ -534,6 +561,12 @@ async function main(): Promise { break; } + case 'serve': { + const servePort = values.port !== undefined ? Number(values.port) : undefined; + const downloadOnly = Boolean(values['download-only']); + return await serveCommand({ port: servePort, downloadOnly }); + } + default: if (command === undefined) { console.error('Missing command'); @@ -543,6 +576,7 @@ async function main(): Promise { printUsage(); return 1; } + await printUpdateNotice(values.quiet as boolean | undefined); return 0; } catch (error) { const err = error as Error; diff --git a/packages/cli/src/commands/serve.test.ts b/packages/cli/src/commands/serve.test.ts new file mode 100644 index 0000000000..df1dead454 --- /dev/null +++ b/packages/cli/src/commands/serve.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test'; + +// Mock @archon/paths BEFORE importing the module under test. +// This sets BUNDLED_IS_BINARY = false (dev mode) so serveCommand rejects. +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), + getWebDistDir: mock((version: string) => `/tmp/test-archon/web-dist/${version}`), + BUNDLED_IS_BINARY: false, + BUNDLED_VERSION: 'dev', +})); + +import { serveCommand, parseChecksum } from './serve'; + +describe('parseChecksum', () => { + const validHash = 'a'.repeat(64); + + it('should extract hash for matching filename', () => { + const checksums = [ + `${'b'.repeat(64)} archon-linux-x64`, + `${validHash} archon-web.tar.gz`, + `${'c'.repeat(64)} archon-darwin-arm64`, + ].join('\n'); + + expect(parseChecksum(checksums, 'archon-web.tar.gz')).toBe(validHash); + }); + + it('should handle single-space separator', () => { + const checksums = `${validHash} archon-web.tar.gz\n`; + expect(parseChecksum(checksums, 'archon-web.tar.gz')).toBe(validHash); + }); + + it('should throw for missing filename', () => { + const checksums = `${validHash} archon-linux-x64\n`; + expect(() => parseChecksum(checksums, 'archon-web.tar.gz')).toThrow( + 'Checksum not found for archon-web.tar.gz' + ); + }); + + it('should throw for empty checksums text', () => { + expect(() => parseChecksum('', 'archon-web.tar.gz')).toThrow('Checksum not found'); + }); + + it('should skip blank lines', () => { + const checksums = `\n${validHash} archon-web.tar.gz\n\n`; + expect(parseChecksum(checksums, 'archon-web.tar.gz')).toBe(validHash); + }); + + it('should throw for malformed hash (not 64 hex chars)', () => { + const checksums = 'short_hash archon-web.tar.gz\n'; + expect(() => parseChecksum(checksums, 'archon-web.tar.gz')).toThrow( + 'Malformed checksum entry for archon-web.tar.gz' + ); + }); + + it('should throw for uppercase hex hash', () => { + const checksums = `${'A'.repeat(64)} archon-web.tar.gz\n`; + expect(() => parseChecksum(checksums, 'archon-web.tar.gz')).toThrow( + 'Malformed checksum entry for archon-web.tar.gz' + ); + }); +}); + +describe('serveCommand', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should reject in dev mode (non-binary)', async () => { + const exitCode = await serveCommand({}); + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error: `archon serve` is for compiled binaries only.' + ); + }); + + it('should reject with downloadOnly in dev mode', async () => { + const exitCode = await serveCommand({ downloadOnly: true }); + expect(exitCode).toBe(1); + }); + + it('should reject invalid port (NaN)', async () => { + const exitCode = await serveCommand({ port: NaN }); + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('--port must be an integer between 1 and 65535') + ); + }); + + it('should reject port out of range', async () => { + const exitCode = await serveCommand({ port: 99999 }); + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('--port must be an integer between 1 and 65535') + ); + }); + + it('should reject port 0', async () => { + const exitCode = await serveCommand({ port: 0 }); + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('--port must be an integer between 1 and 65535') + ); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts new file mode 100644 index 0000000000..2db1d468c1 --- /dev/null +++ b/packages/cli/src/commands/serve.ts @@ -0,0 +1,177 @@ +import { dirname } from 'path'; +import { existsSync, mkdirSync, renameSync, rmSync } from 'fs'; +import { createLogger, getWebDistDir, BUNDLED_IS_BINARY, BUNDLED_VERSION } from '@archon/paths'; + +const log = createLogger('cli.serve'); + +const GITHUB_REPO = 'coleam00/Archon'; + +function toError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +export interface ServeOptions { + /** TCP port to bind. Ignored when downloadOnly is true. Range: 1–65535. */ + port?: number; + /** Download the web UI and exit without starting the server. */ + downloadOnly?: boolean; +} + +export async function serveCommand(opts: ServeOptions): Promise { + if ( + opts.port !== undefined && + (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535) + ) { + console.error(`Error: --port must be an integer between 1 and 65535, got: ${opts.port}`); + return 1; + } + + if (!BUNDLED_IS_BINARY) { + console.error('Error: `archon serve` is for compiled binaries only.'); + console.error('For development, use: bun run dev'); + return 1; + } + + const version = BUNDLED_VERSION; + const webDistDir = getWebDistDir(version); + + if (!existsSync(webDistDir)) { + try { + await downloadWebDist(version, webDistDir); + } catch (err) { + const error = toError(err); + log.error({ err: error, version, webDistDir }, 'web_dist.download_failed'); + console.error(`Error: Failed to download web UI: ${error.message}`); + return 1; + } + } else { + log.info({ webDistDir }, 'web_dist.cache_hit'); + } + + if (opts.downloadOnly) { + log.info({ webDistDir }, 'web_dist.download_completed'); + console.log(`Web UI downloaded to: ${webDistDir}`); + return 0; + } + + // Import server and start (dynamic import keeps CLI startup fast for other commands) + try { + const { startServer } = await import('@archon/server'); + await startServer({ + webDistPath: webDistDir, + port: opts.port, + skipPlatformAdapters: true, + }); + } catch (err) { + const error = toError(err); + log.error({ err: error, version, webDistDir, port: opts.port }, 'server.start_failed'); + console.error(`Error: Server failed to start: ${error.message}`); + return 1; + } + + // Server runs until SIGINT/SIGTERM — never returns + return 0; +} + +async function downloadWebDist(version: string, targetDir: string): Promise { + const tarballUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/archon-web.tar.gz`; + const checksumsUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/checksums.txt`; + + 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}` + ); + }); + 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(); + + // Verify checksum + const hasher = new Bun.CryptoHasher('sha256'); + hasher.update(new Uint8Array(tarballBuffer)); + const actualHash = hasher.digest('hex'); + + if (actualHash !== expectedHash) { + throw new Error(`Checksum mismatch: expected ${expectedHash}, got ${actualHash}`); + } + console.log('Checksum verified.'); + + // Extract to temp dir, then atomic rename + const tmpDir = `${targetDir}.tmp`; + + // Clean up any previous failed attempt + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + + // Extract tarball using tar (available on macOS/Linux) + const proc = Bun.spawn(['tar', 'xzf', '-', '-C', tmpDir, '--strip-components=1'], { + stdin: new Uint8Array(tarballBuffer), + stderr: 'pipe', + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderrText = await new Response(proc.stderr).text(); + cleanupAndThrow(tmpDir, `tar extraction failed (exit ${exitCode}): ${stderrText.trim()}`); + } + + // Verify extraction produced expected layout + if (!existsSync(`${tmpDir}/index.html`)) { + cleanupAndThrow( + tmpDir, + 'Extraction produced unexpected layout — index.html not found in extracted dir' + ); + } + + // Atomic move into place + mkdirSync(dirname(targetDir), { recursive: true }); + try { + renameSync(tmpDir, targetDir); + } catch (err) { + cleanupAndThrow( + tmpDir, + `Failed to move extracted web UI from ${tmpDir} to ${targetDir}: ${(err as Error).message}` + ); + } + console.log(`Extracted to ${targetDir}`); +} + +function cleanupAndThrow(tmpDir: string, message: string): never { + rmSync(tmpDir, { recursive: true, force: true }); + throw new Error(message); +} + +/** + * Parse a SHA-256 checksum from a checksums.txt file (sha256sum format). + * Format: ` ` or ` ` + */ +export function parseChecksum(checksums: string, filename: string): string { + for (const line of checksums.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 2 && parts[1] === filename) { + const hash = parts[0]; + if (!/^[0-9a-f]{64}$/.test(hash)) { + throw new Error(`Malformed checksum entry for ${filename}: "${line.trim()}"`); + } + return hash; + } + } + throw new Error(`Checksum not found for ${filename} in checksums.txt`); +} diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 57dd899d33..d82a0211a7 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -8,7 +8,9 @@ import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discover import { validateWorkflowResources, validateCommand, + validateScript, discoverAvailableCommands, + discoverAvailableScripts, findSimilar, makeWorkflowResult, } from '@archon/workflows/validator'; @@ -16,6 +18,7 @@ import type { ValidationIssue, WorkflowValidationResult, ValidationConfig, + ScriptValidationResult, } from '@archon/workflows/validator'; import { loadConfig, loadRepoConfig } from '@archon/core'; @@ -52,22 +55,22 @@ function formatIssue(issue: ValidationIssue, indent = ' '): string { return line; } -function formatWorkflowResult(result: WorkflowValidationResult): string { - const errors = result.issues.filter(i => i.level === 'error'); - const warnings = result.issues.filter(i => i.level === 'warning'); - - const statusLabel = errors.length > 0 ? 'ERRORS' : warnings.length > 0 ? 'WARNINGS' : 'ok'; - - const namePad = result.workflowName.padEnd(40, ' '); - let output = ` ${namePad} ${statusLabel}`; +function formatValidationResult(displayName: string, issues: ValidationIssue[]): string { + const hasErrors = issues.some(i => i.level === 'error'); + const hasWarnings = issues.some(i => i.level === 'warning'); + const statusLabel = hasErrors ? 'ERRORS' : hasWarnings ? 'WARNINGS' : 'ok'; - for (const issue of result.issues) { + let output = ` ${displayName.padEnd(40, ' ')} ${statusLabel}`; + for (const issue of issues) { output += '\n' + formatIssue(issue); } - return output; } +function formatWorkflowResult(result: WorkflowValidationResult): string { + return formatValidationResult(result.workflowName, result.issues); +} + // ============================================================================= // Workflow validation command // ============================================================================= @@ -175,11 +178,16 @@ export async function validateWorkflowsCommand( } // ============================================================================= -// Command validation command +// Command and script validation command // ============================================================================= +function formatScriptResult(result: ScriptValidationResult): string { + return formatValidationResult(`[script] ${result.scriptName}`, result.issues); +} + /** * Validate all commands or a specific command. + * Also validates scripts from .archon/scripts/ alongside commands. * Returns exit code: 0 = all valid, 1 = errors found. */ export async function validateCommandsCommand( @@ -208,41 +216,50 @@ export async function validateCommandsCommand( // Validate all commands const allCommands = await discoverAvailableCommands(cwd, config); + const commandResults = await Promise.all( + allCommands.map(cmd => validateCommand(cmd, cwd, config)) + ); - if (allCommands.length === 0) { - if (jsonOutput) { - console.log(JSON.stringify({ results: [], summary: { total: 0, valid: 0, errors: 0 } })); - } else { - console.log('\nNo commands found.'); - } - return 0; - } - - const results = await Promise.all(allCommands.map(cmd => validateCommand(cmd, cwd, config))); + // Validate all scripts + const allScripts = await discoverAvailableScripts(cwd); + const scriptResults = await Promise.all(allScripts.map(s => validateScript(s.name, cwd))); - const totalErrors = results.filter(r => !r.valid).length; + const totalCommandErrors = commandResults.filter(r => !r.valid).length; + const totalScriptErrors = scriptResults.filter(r => !r.valid).length; + const totalErrors = totalCommandErrors + totalScriptErrors; if (jsonOutput) { console.log( JSON.stringify({ - results, + results: commandResults, + scripts: scriptResults, summary: { - total: results.length, - valid: results.length - totalErrors, + total: commandResults.length + scriptResults.length, + valid: commandResults.length + scriptResults.length - totalErrors, errors: totalErrors, }, }) ); } else { - console.log(`\nValidating commands in ${cwd}\n`); - for (const result of results) { + if (commandResults.length === 0 && scriptResults.length === 0) { + console.log('\nNo commands or scripts found.'); + return 0; + } + + console.log(`\nValidating commands and scripts in ${cwd}\n`); + for (const result of commandResults) { const statusLabel = result.valid ? 'ok' : 'ERRORS'; console.log(` ${result.commandName.padEnd(40, ' ')} ${statusLabel}`); for (const issue of result.issues) { console.log(formatIssue(issue)); } } - console.log(`\nResults: ${results.length - totalErrors} valid, ${totalErrors} with errors`); + for (const result of scriptResults) { + console.log(formatScriptResult(result)); + } + console.log( + `\nResults: ${commandResults.length + scriptResults.length - totalErrors} valid, ${totalErrors} with errors` + ); } return totalErrors > 0 ? 1 : 0; diff --git a/packages/cli/src/commands/workflow.test.ts b/packages/cli/src/commands/workflow.test.ts index 0a050cf26f..7f13f8d83f 100644 --- a/packages/cli/src/commands/workflow.test.ts +++ b/packages/cli/src/commands/workflow.test.ts @@ -107,6 +107,7 @@ mock.module('@archon/core/db/conversations', () => ({ getOrCreateConversation: mock(() => Promise.resolve({ id: 'conv-123', platform_type: 'cli', platform_conversation_id: 'cli-123' }) ), + getConversationById: mock(() => Promise.resolve(null)), updateConversation: mock(() => Promise.resolve()), })); @@ -1381,6 +1382,58 @@ describe('workflowApproveCommand', () => { expect(codebaseDb.getCodebase).toHaveBeenCalledWith('cb-existing'); }); + + it('should pass original platform conversation ID through to workflowRunCommand', async () => { + const workflowDb = await import('@archon/core/db/workflows'); + const codebaseDb = await import('@archon/core/db/codebases'); + const conversationsDb = await import('@archon/core/db/conversations'); + const workflowDiscovery = await import('@archon/workflows/workflow-discovery'); + const core = await import('@archon/core'); + + (workflowDb.getWorkflowRun as ReturnType).mockResolvedValueOnce({ + id: 'run-approve-conv', + workflow_name: 'implement', + status: 'paused', + user_message: 'add auth', + working_path: '/tmp/test-worktree', + codebase_id: 'cb-existing', + conversation_id: 'db-uuid-original', + metadata: { approval: { nodeId: 'review-node', message: 'Approve?' } }, + }); + + // Return a conversation with the original platform ID + (conversationsDb.getConversationById as ReturnType).mockResolvedValueOnce({ + id: 'db-uuid-original', + platform_type: 'cli', + platform_conversation_id: 'cli-original-123', + }); + + ( + workflowDiscovery.discoverWorkflowsWithConfig as ReturnType + ).mockResolvedValueOnce({ + workflows: [makeTestWorkflowWithSource({ name: 'implement' })], + errors: [], + }); + + (codebaseDb.getCodebase as ReturnType).mockResolvedValueOnce({ + id: 'cb-existing', + name: 'owner/repo', + default_cwd: '/path/to/main-checkout', + }); + + // Clear call history before our test so we can assert precisely + (conversationsDb.getOrCreateConversation as ReturnType).mockClear(); + + try { + await workflowApproveCommand('run-approve-conv'); + } catch { + // downstream failure is acceptable — we only need to reach getOrCreateConversation + } + + // Verify the original platform conversation ID was passed through + expect(conversationsDb.getConversationById).toHaveBeenCalledWith('db-uuid-original'); + expect(conversationsDb.getOrCreateConversation).toHaveBeenCalledWith('cli', 'cli-original-123'); + }); }); describe('workflowAbandonCommand', () => { @@ -1566,6 +1619,61 @@ describe('workflowRejectCommand', () => { expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Rejected workflow')); }); + it('should pass original platform conversation ID through on reject-resume', async () => { + const workflowDb = await import('@archon/core/db/workflows'); + const conversationsDb = await import('@archon/core/db/conversations'); + const workflowDiscovery = await import('@archon/workflows/workflow-discovery'); + + const runData = { + id: 'run-reject-conv', + workflow_name: 'my-wf', + status: 'paused', + user_message: 'build it', + working_path: '/repo', + codebase_id: null, + conversation_id: 'db-uuid-reject', + metadata: { + approval: { + type: 'approval', + nodeId: 'gate', + message: 'Approve?', + onRejectPrompt: 'Fix: $REJECTION_REASON', + onRejectMaxAttempts: 3, + }, + rejection_count: 0, + }, + }; + // rejectWorkflow reads the run twice internally (getRunOrThrow + updateWorkflowRun check) + (workflowDb.getWorkflowRun as ReturnType).mockResolvedValueOnce(runData); + + // Return a conversation with the original platform ID + (conversationsDb.getConversationById as ReturnType).mockResolvedValueOnce({ + id: 'db-uuid-reject', + platform_type: 'cli', + platform_conversation_id: 'cli-reject-456', + }); + + ( + workflowDiscovery.discoverWorkflowsWithConfig as ReturnType + ).mockResolvedValueOnce({ + workflows: [makeTestWorkflowWithSource({ name: 'my-wf' })], + errors: [], + }); + + // Clear call history before our test so we can assert precisely + (conversationsDb.getOrCreateConversation as ReturnType).mockClear(); + + try { + await workflowRejectCommand('run-reject-conv', 'needs work'); + } catch { + // downstream workflowRunCommand failure is acceptable — we only need to reach getOrCreateConversation + } + + // Verify the original platform conversation ID was passed through + expect(conversationsDb.getConversationById).toHaveBeenCalledWith('db-uuid-reject'); + expect(conversationsDb.getOrCreateConversation).toHaveBeenCalledWith('cli', 'cli-reject-456'); + }); + it('cancels when max attempts reached', async () => { const workflowDb = await import('@archon/core/db/workflows'); const core = await import('@archon/core'); diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index 99edcb0bfe..89dd5911e4 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -66,6 +66,8 @@ export interface WorkflowRunOptions { allowEnvKeys?: boolean; quiet?: boolean; verbose?: boolean; + /** Platform conversation ID (e.g. `cli-{ts}-{rand}`), NOT a DB UUID. */ + conversationId?: string; } /** @@ -269,7 +271,7 @@ export async function workflowRunCommand( const adapter = new CLIAdapter(); // Generate conversation ID - const conversationId = generateConversationId(); + const conversationId = options.conversationId ?? generateConversationId(); // Get or create conversation in database let conversation; @@ -861,10 +863,30 @@ export async function workflowApproveCommand(runId: string, comment?: string): P console.log(''); console.log('Resuming workflow...'); + // Look up the original platform conversation ID to keep all messages in one thread + let platformConversationId: string | undefined; + try { + const originalConversation = await conversationDb.getConversationById(result.conversationId); + platformConversationId = originalConversation?.platform_conversation_id ?? undefined; + if (!originalConversation) { + getLog().info( + { runId, conversationId: result.conversationId }, + 'cli.workflow_approve_conversation_not_found' + ); + } + } catch (error) { + const err = error as Error; + getLog().warn( + { err, runId, conversationId: result.conversationId }, + 'cli.workflow_approve_conversation_lookup_failed' + ); + } + try { await workflowRunCommand(result.workingPath, result.workflowName, result.userMessage ?? '', { resume: true, codebaseId: result.codebaseId ?? undefined, + conversationId: platformConversationId, }); } catch (error) { const err = error as Error; @@ -900,10 +922,31 @@ export async function workflowRejectCommand(runId: string, reason?: string): Pro } console.log(`Rejected workflow: ${result.workflowName}`); console.log('Resuming with on_reject prompt...'); + + // Look up the original platform conversation ID to keep all messages in one thread + let platformConversationId: string | undefined; + try { + const originalConversation = await conversationDb.getConversationById(result.conversationId); + platformConversationId = originalConversation?.platform_conversation_id ?? undefined; + if (!originalConversation) { + getLog().info( + { runId, conversationId: result.conversationId }, + 'cli.workflow_reject_conversation_not_found' + ); + } + } catch (error) { + const err = error as Error; + getLog().warn( + { err, runId, conversationId: result.conversationId }, + 'cli.workflow_reject_conversation_lookup_failed' + ); + } + try { await workflowRunCommand(result.workingPath, result.workflowName, result.userMessage ?? '', { resume: true, codebaseId: result.codebaseId ?? undefined, + conversationId: platformConversationId, }); } catch (error) { const err = error as Error; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index e8f5faa3d0..5bfc6ab9f4 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -3,14 +3,31 @@ "compilerOptions": { "noEmit": true, "paths": { + "@archon/adapters": ["../adapters/src"], + "@archon/adapters/*": ["../adapters/src/*"], "@archon/core": ["../core/src"], "@archon/core/*": ["../core/src/*"], + "@archon/server": ["../server/src"], + "@archon/server/*": ["../server/src/*"], "@archon/workflows": ["../workflows/src"], "@archon/workflows/*": ["../workflows/src/*"], "@archon/paths": ["../paths/src"], "@archon/git": ["../git/src"] } }, - "include": ["src/**/*", "../core/src/**/*.ts", "../workflows/src/defaults/text-imports.d.ts"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "../core/src/**/*.test.ts"] + "include": [ + "src/**/*", + "../core/src/**/*.ts", + "../server/src/**/*.ts", + "../adapters/src/**/*.ts", + "../workflows/src/defaults/text-imports.d.ts" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "../core/src/**/*.test.ts", + "../server/src/**/*.test.ts", + "../adapters/src/**/*.test.ts" + ] } diff --git a/packages/core/package.json b/packages/core/package.json index 44adcc4a7a..94205ee871 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,7 @@ "./state/*": "./src/state/*.ts" }, "scripts": { - "test": "bun test src/clients/ && 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-allowlist.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/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-allowlist.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", "type-check": "bun x tsc --noEmit", "build": "echo 'No build needed - Bun runs TypeScript directly'" }, diff --git a/packages/core/src/clients/codex-binary-guard.test.ts b/packages/core/src/clients/codex-binary-guard.test.ts new file mode 100644 index 0000000000..c235caf5fd --- /dev/null +++ b/packages/core/src/clients/codex-binary-guard.test.ts @@ -0,0 +1,179 @@ +/** + * Tests for Codex binary resolution in compiled binary mode. + * + * Separate file because mock.module('@archon/paths') with BUNDLED_IS_BINARY=true + * conflicts with codex.test.ts which mocks it without BUNDLED_IS_BINARY. + * Must run in its own bun test invocation (see package.json test script). + */ +import { describe, test, expect, mock, beforeEach } from 'bun:test'; +import { createMockLogger } from '../test/mocks/logger'; + +const mockLogger = createMockLogger(); + +// Mock @archon/paths with BUNDLED_IS_BINARY = true (simulates compiled binary) +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), + BUNDLED_IS_BINARY: true, + getArchonHome: mock(() => '/tmp/test-archon'), +})); + +// Track what path override is passed to the Codex constructor +let capturedOptions: { codexPathOverride?: string } | undefined; + +const mockStartThread = mock(() => ({ + id: 'test-thread', + runStreamed: mock(() => + Promise.resolve({ + events: (async function* () { + yield { + type: 'turn.completed', + usage: { input_tokens: 10, cached_input_tokens: 0, output_tokens: 5 }, + }; + })(), + }) + ), +})); + +const MockCodex = mock((opts?: { codexPathOverride?: string }) => { + capturedOptions = opts; + return { + startThread: mockStartThread, + resumeThread: mock(() => ({})), + }; +}); +mock.module('@openai/codex-sdk', () => ({ + Codex: MockCodex, +})); + +// Mock resolver — controls binary resolution behavior per test +const mockResolveCodexBinaryPath = mock( + (_configPath?: string): Promise => + Promise.resolve('/tmp/test-archon/vendor/codex/codex') +); +mock.module('../utils/codex-binary-resolver', () => ({ + resolveCodexBinaryPath: mockResolveCodexBinaryPath, +})); + +// Config mock with configurable return value +const mockLoadConfig = mock(() => + Promise.resolve({ + allowTargetRepoKeys: false, + assistants: { codex: {} }, + }) +); + +// Mock db and config dependencies to prevent real DB access +mock.module('../db/codebases', () => ({ + findCodebaseByDefaultCwd: mock(() => Promise.resolve(null)), + findCodebaseByPathPrefix: mock(() => Promise.resolve(null)), +})); +mock.module('../config/config-loader', () => ({ + loadConfig: mockLoadConfig, +})); +mock.module('../utils/env-leak-scanner', () => ({ + scanPathForSensitiveKeys: mock(() => ({ findings: [] })), + EnvLeakError: class extends Error {}, +})); + +import { CodexClient, resetCodexSingleton } from './codex'; + +describe('CodexClient binary mode resolution', () => { + beforeEach(() => { + resetCodexSingleton(); + MockCodex.mockClear(); + mockStartThread.mockClear(); + mockResolveCodexBinaryPath.mockClear(); + mockLoadConfig.mockClear(); + capturedOptions = undefined; + + // Restore default mock implementations + mockResolveCodexBinaryPath.mockImplementation(() => + Promise.resolve('/tmp/test-archon/vendor/codex/codex') + ); + mockLoadConfig.mockImplementation(() => + Promise.resolve({ + allowTargetRepoKeys: false, + assistants: { codex: {} }, + }) + ); + }); + + test('passes resolved binary path to Codex constructor via codexPathOverride', async () => { + mockResolveCodexBinaryPath.mockResolvedValueOnce('/custom/path/to/codex'); + + const client = new CodexClient(); + const generator = client.sendQuery('test prompt', '/tmp/test'); + + // Consume events to trigger initialization + for await (const _chunk of generator) { + // drain + } + + expect(mockResolveCodexBinaryPath).toHaveBeenCalledTimes(1); + expect(capturedOptions?.codexPathOverride).toBe('/custom/path/to/codex'); + }); + + test('propagates resolver errors as clear failures', async () => { + mockResolveCodexBinaryPath.mockRejectedValueOnce( + new Error('Codex native binary not found at /tmp/test-archon/vendor/codex/codex') + ); + + const client = new CodexClient(); + const generator = client.sendQuery('test prompt', '/tmp/test'); + + await expect(generator.next()).rejects.toThrow('Codex native binary not found'); + }); + + test('retries initialization after first failure (rejected promise not cached)', async () => { + mockResolveCodexBinaryPath + .mockRejectedValueOnce(new Error('Codex CLI binary not found')) + .mockResolvedValueOnce('/tmp/test-archon/vendor/codex/codex'); + + const client = new CodexClient(); + + // First call fails + await expect(client.sendQuery('test prompt', '/tmp/test').next()).rejects.toThrow( + 'Codex CLI binary not found' + ); + + // Reset singleton so second call can retry + resetCodexSingleton(); + + // Second call succeeds (promise was cleared on failure) + const generator = client.sendQuery('test prompt', '/tmp/test'); + for await (const _chunk of generator) { + // drain + } + expect(mockResolveCodexBinaryPath).toHaveBeenCalledTimes(2); + }); + + test('does not pass codexPathOverride when resolver returns undefined', async () => { + mockResolveCodexBinaryPath.mockResolvedValueOnce(undefined); + + const client = new CodexClient(); + const generator = client.sendQuery('test prompt', '/tmp/test'); + + for await (const _chunk of generator) { + // drain + } + + expect(capturedOptions).toBeDefined(); + expect(capturedOptions?.codexPathOverride).toBeUndefined(); + }); + + test('passes config codexBinaryPath to resolver', async () => { + mockLoadConfig.mockResolvedValueOnce({ + allowTargetRepoKeys: false, + assistants: { codex: { codexBinaryPath: '/user/custom/codex' } }, + }); + + const client = new CodexClient(); + const generator = client.sendQuery('test prompt', '/tmp/test'); + + for await (const _chunk of generator) { + // drain + } + + expect(mockResolveCodexBinaryPath).toHaveBeenCalledWith('/user/custom/codex'); + }); +}); diff --git a/packages/core/src/clients/codex.ts b/packages/core/src/clients/codex.ts index 110f35d2b2..e6e9d1dd09 100644 --- a/packages/core/src/clients/codex.ts +++ b/packages/core/src/clients/codex.ts @@ -21,6 +21,7 @@ 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 | undefined; @@ -29,18 +30,38 @@ function getLog(): ReturnType { return cachedLog; } -// Singleton Codex instance +// Singleton Codex instance (async because binary path resolution is async) let codexInstance: Codex | null = null; +let codexInitPromise: Promise | null = null; + +/** Reset singleton state. Exported for tests only. */ +export function resetCodexSingleton(): void { + codexInstance = null; + codexInitPromise = null; +} /** - * Get or create Codex SDK instance - * Synchronous now that we have direct ESM import + * 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. */ -function getCodex(): Codex { - if (!codexInstance) { - codexInstance = new Codex(); +async function getCodex(configCodexBinaryPath?: string): Promise { + if (codexInstance) return codexInstance; + + // Prevent concurrent initialization race + if (!codexInitPromise) { + codexInitPromise = (async (): Promise => { + 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 codexInstance; + return codexInitPromise; } /** @@ -157,6 +178,15 @@ export class CodexClient implements IAssistantClient { resumeSessionId?: string, options?: AssistantRequestOptions ): AsyncGenerator { + // Load config once — used for env-leak gate and (on first call) codexBinaryPath resolution. + let mergedConfig: Awaited> | 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). @@ -165,13 +195,7 @@ export class CodexClient implements IAssistantClient { (await codebaseDb.findCodebaseByPathPrefix(cwd)); if (codebase && !codebase.allow_env_keys) { // Fail-closed: a config load failure must NOT silently bypass the gate. - 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'); - } + const allowTargetRepoKeys = mergedConfig?.allowTargetRepoKeys ?? false; if (!allowTargetRepoKeys) { const report = scanPathForSensitiveKeys(cwd); if (report.findings.length > 0) { @@ -180,7 +204,10 @@ export class CodexClient implements IAssistantClient { } } - const codex = getCodex(); + // 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 diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index f3bbdf41cf..3baa3dfdca 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -17,6 +17,9 @@ export interface AssistantDefaults { 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; } export interface ClaudeAssistantDefaults { diff --git a/packages/core/src/operations/workflow-operations.ts b/packages/core/src/operations/workflow-operations.ts index 8639ff1654..2b9092e649 100644 --- a/packages/core/src/operations/workflow-operations.ts +++ b/packages/core/src/operations/workflow-operations.ts @@ -34,6 +34,8 @@ export interface ApprovalOperationResult { workingPath: string | null; userMessage: string | null; codebaseId: string | null; + /** Internal DB UUID — resolve via getConversationById() to get platform_conversation_id. */ + conversationId: string; type: 'interactive_loop' | 'approval_gate'; } @@ -42,6 +44,8 @@ export interface RejectionOperationResult { workingPath: string | null; userMessage: string | null; codebaseId: string | null; + /** Internal DB UUID — resolve via getConversationById() to get platform_conversation_id. */ + conversationId: string; /** true = run cancelled; false = transitioning to failed for retry (has onRejectPrompt) */ cancelled: boolean; /** true when cancelled specifically because max rejection attempts were reached */ @@ -168,6 +172,7 @@ export async function approveWorkflow( workingPath: run.working_path, userMessage: run.user_message, codebaseId: run.codebase_id, + conversationId: run.conversation_id, type: 'interactive_loop', }; } @@ -204,6 +209,7 @@ export async function approveWorkflow( workingPath: run.working_path, userMessage: run.user_message, codebaseId: run.codebase_id, + conversationId: run.conversation_id, type: 'approval_gate', }; } @@ -248,6 +254,7 @@ export async function rejectWorkflow( workingPath: run.working_path, userMessage: run.user_message, codebaseId: run.codebase_id, + conversationId: run.conversation_id, cancelled: true, maxAttemptsReached: true, }; @@ -261,6 +268,7 @@ export async function rejectWorkflow( workingPath: run.working_path, userMessage: run.user_message, codebaseId: run.codebase_id, + conversationId: run.conversation_id, cancelled: false, maxAttemptsReached: false, }; @@ -280,6 +288,7 @@ export async function rejectWorkflow( workingPath: run.working_path, userMessage: run.user_message, codebaseId: run.codebase_id, + conversationId: run.conversation_id, cancelled: true, maxAttemptsReached: false, }; diff --git a/packages/core/src/utils/codex-binary-resolver-dev.test.ts b/packages/core/src/utils/codex-binary-resolver-dev.test.ts new file mode 100644 index 0000000000..ac8761ee02 --- /dev/null +++ b/packages/core/src/utils/codex-binary-resolver-dev.test.ts @@ -0,0 +1,26 @@ +/** + * Tests for the Codex binary resolver in dev mode (BUNDLED_IS_BINARY=false). + * Separate file because binary-mode tests mock BUNDLED_IS_BINARY=true. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { createMockLogger } from '../test/mocks/logger'; + +mock.module('@archon/paths', () => ({ + createLogger: mock(() => createMockLogger()), + BUNDLED_IS_BINARY: false, + getArchonHome: mock(() => '/tmp/test-archon-home'), +})); + +import { resolveCodexBinaryPath } from './codex-binary-resolver'; + +describe('resolveCodexBinaryPath (dev mode)', () => { + test('returns undefined when BUNDLED_IS_BINARY is false', async () => { + const result = await resolveCodexBinaryPath(); + expect(result).toBeUndefined(); + }); + + test('returns undefined even with config path set', async () => { + const result = await resolveCodexBinaryPath('/some/custom/path'); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/core/src/utils/codex-binary-resolver.test.ts b/packages/core/src/utils/codex-binary-resolver.test.ts new file mode 100644 index 0000000000..3425a6fa17 --- /dev/null +++ b/packages/core/src/utils/codex-binary-resolver.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for the Codex binary resolver in binary mode. + * + * Must run in its own bun test invocation because it mocks @archon/paths + * with BUNDLED_IS_BINARY=true, which conflicts with other test files. + */ +import { describe, test, expect, mock, beforeEach, afterAll, spyOn } from 'bun:test'; +import { createMockLogger } from '../test/mocks/logger'; + +const mockLogger = createMockLogger(); + +// Mock @archon/paths with BUNDLED_IS_BINARY = true (binary mode) +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), + BUNDLED_IS_BINARY: true, + getArchonHome: mock(() => '/tmp/test-archon-home'), +})); + +import * as resolver from './codex-binary-resolver'; + +describe('resolveCodexBinaryPath (binary mode)', () => { + const originalEnv = process.env.CODEX_BIN_PATH; + let fileExistsSpy: ReturnType; + + beforeEach(() => { + delete process.env.CODEX_BIN_PATH; + fileExistsSpy?.mockRestore(); + mockLogger.info.mockClear(); + }); + + afterAll(() => { + if (originalEnv !== undefined) { + process.env.CODEX_BIN_PATH = originalEnv; + } else { + delete process.env.CODEX_BIN_PATH; + } + fileExistsSpy?.mockRestore(); + }); + + test('uses CODEX_BIN_PATH env var when set and file exists', async () => { + process.env.CODEX_BIN_PATH = '/usr/local/bin/codex'; + fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true); + + const result = await resolver.resolveCodexBinaryPath(); + expect(result).toBe('/usr/local/bin/codex'); + }); + + test('throws when CODEX_BIN_PATH is set but file does not exist', async () => { + process.env.CODEX_BIN_PATH = '/nonexistent/codex'; + fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false); + + await expect(resolver.resolveCodexBinaryPath()).rejects.toThrow('does not exist'); + }); + + test('uses config codexBinaryPath when file exists', async () => { + fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true); + + const result = await resolver.resolveCodexBinaryPath('/custom/codex/path'); + expect(result).toBe('/custom/codex/path'); + }); + + test('throws when config codexBinaryPath file does not exist', async () => { + fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false); + + await expect(resolver.resolveCodexBinaryPath('/nonexistent/codex')).rejects.toThrow( + 'does not exist' + ); + }); + + test('env var takes precedence over config path', async () => { + process.env.CODEX_BIN_PATH = '/env/codex'; + fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true); + + const result = await resolver.resolveCodexBinaryPath('/config/codex'); + expect(result).toBe('/env/codex'); + }); + + test('checks vendor directory when no env or config path', async () => { + fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation((path: string) => { + const normalized = path.replace(/\\/g, '/'); + return normalized.includes('vendor/codex'); + }); + + const result = await resolver.resolveCodexBinaryPath(); + expect(typeof result).toBe('string'); + const normalized = result!.replace(/\\/g, '/'); + expect(normalized).toContain('/tmp/test-archon-home/vendor/codex/'); + }); + + test('throws with install instructions when binary not found anywhere', async () => { + fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false); + + await expect(resolver.resolveCodexBinaryPath()).rejects.toThrow('Codex CLI binary not found'); + }); +}); diff --git a/packages/core/src/utils/codex-binary-resolver.ts b/packages/core/src/utils/codex-binary-resolver.ts new file mode 100644 index 0000000000..e927918c95 --- /dev/null +++ b/packages/core/src/utils/codex-binary-resolver.ts @@ -0,0 +1,110 @@ +/** + * Codex binary resolver for compiled (bun --compile) archon binaries. + * + * The @openai/codex-sdk uses `createRequire(import.meta.url)` to locate the + * native Codex CLI binary, which breaks in compiled binaries where + * `import.meta.url` is frozen to the build host's path. + * + * This module resolves an alternative path and passes it to the SDK's + * `codexPathOverride` constructor option, bypassing the broken resolution. + * + * Resolution order: + * 1. `CODEX_BIN_PATH` environment variable + * 2. `assistants.codex.codexBinaryPath` in config + * 3. `~/.archon/vendor/codex/` (user-placed) + * 4. Throw with install instructions + * + * In dev mode (BUNDLED_IS_BINARY=false), returns undefined so the SDK + * uses its normal node_modules-based resolution. + */ +import { existsSync as _existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { BUNDLED_IS_BINARY, getArchonHome, createLogger } from '@archon/paths'; + +/** Wrapper for existsSync — enables spyOn in tests (direct imports can't be spied on). */ +export function fileExists(path: string): boolean { + return _existsSync(path); +} + +/** Lazy-initialized logger */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('codex-binary'); + return cachedLog; +} + +const CODEX_VENDOR_DIR = 'vendor/codex'; + +const SUPPORTED_PLATFORMS = ['darwin', 'linux', 'win32']; + +/** Returns the vendor binary filename for the current platform, or undefined if unsupported. */ +function getVendorBinaryName(): string | undefined { + if (!SUPPORTED_PLATFORMS.includes(process.platform)) return undefined; + if (process.arch !== 'x64' && process.arch !== 'arm64') return undefined; + return process.platform === 'win32' ? 'codex.exe' : 'codex'; +} + +/** + * Resolve the path to the Codex native binary. + * + * In dev mode: returns undefined (let SDK resolve via node_modules). + * In binary mode: resolves from env/config/vendor dir, or throws with install instructions. + */ +export async function resolveCodexBinaryPath( + configCodexBinaryPath?: string +): Promise { + if (!BUNDLED_IS_BINARY) return undefined; + + // 1. Environment variable override + const envPath = process.env.CODEX_BIN_PATH; + if (envPath) { + if (!fileExists(envPath)) { + throw new Error( + `CODEX_BIN_PATH is set to "${envPath}" but the file does not exist.\n` + + 'Please verify the path points to the Codex CLI binary.' + ); + } + getLog().info({ binaryPath: envPath, source: 'env' }, 'codex.binary_resolved'); + return envPath; + } + + // 2. Config file override + if (configCodexBinaryPath) { + if (!fileExists(configCodexBinaryPath)) { + throw new Error( + `assistants.codex.codexBinaryPath is set to "${configCodexBinaryPath}" but the file does not exist.\n` + + 'Please verify the path in .archon/config.yaml points to the Codex CLI binary.' + ); + } + getLog().info({ binaryPath: configCodexBinaryPath, source: 'config' }, 'codex.binary_resolved'); + return configCodexBinaryPath; + } + + // 3. Check vendor directory (user-placed binary) + const binaryName = getVendorBinaryName(); + if (binaryName) { + const archonHome = getArchonHome(); + const vendorBinaryPath = join(archonHome, CODEX_VENDOR_DIR, binaryName); + + if (fileExists(vendorBinaryPath)) { + getLog().info({ binaryPath: vendorBinaryPath, source: 'vendor' }, 'codex.binary_resolved'); + return vendorBinaryPath; + } + } + + // 4. Not found — throw with install instructions + const vendorPath = `~/.archon/${CODEX_VENDOR_DIR}/`; + throw new Error( + 'Codex CLI binary not found. The Codex provider requires a native binary\n' + + 'that cannot be resolved automatically in compiled Archon builds.\n\n' + + 'To fix, choose one of:\n' + + ' 1. Install globally: npm install -g @openai/codex\n' + + ' Then set: CODEX_BIN_PATH=$(which codex)\n\n' + + ` 2. Place the binary at: ${vendorPath}\n\n` + + ' 3. Set the path in config:\n' + + ' # .archon/config.yaml\n' + + ' assistants:\n' + + ' codex:\n' + + ' codexBinaryPath: /path/to/codex\n' + ); +} diff --git a/packages/core/src/utils/port-allocation.ts b/packages/core/src/utils/port-allocation.ts index c69d011ad5..efb34d3198 100644 --- a/packages/core/src/utils/port-allocation.ts +++ b/packages/core/src/utils/port-allocation.ts @@ -22,7 +22,7 @@ function getLog(): ReturnType { */ export function calculatePortOffset(path: string): number { const hash = createHash('md5').update(path).digest(); - // 100-999 range: Offset starts at 100 to avoid default port 3000, results in ports 3100-3999 + // 100-999 range: offset starts at 100; produces ports 3190-4089 when added to basePort (3090) return (hash.readUInt16BE(0) % 900) + 100; } diff --git a/packages/docs-web/src/content/docs/reference/api.md b/packages/docs-web/src/content/docs/reference/api.md index 0df9cbbfd0..0e2fa8aa37 100644 --- a/packages/docs-web/src/content/docs/reference/api.md +++ b/packages/docs-web/src/content/docs/reference/api.md @@ -352,6 +352,16 @@ curl -X PATCH http://localhost:3090/api/config/assistants \ --- +## System + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/update-check` | Check for available updates (binary builds only) | + +Returns `{ updateAvailable, currentVersion, latestVersion, releaseUrl }`. For non-binary (source) builds, always returns `updateAvailable: false` without making external requests. + +--- + ## SSE Streaming | Path | Description | diff --git a/packages/docs-web/src/content/docs/reference/archon-directories.md b/packages/docs-web/src/content/docs/reference/archon-directories.md index a3125b1c2a..a718824c3a 100644 --- a/packages/docs-web/src/content/docs/reference/archon-directories.md +++ b/packages/docs-web/src/content/docs/reference/archon-directories.md @@ -31,6 +31,8 @@ Archon provides a unified directory and configuration system with: │ ├── source/ # Clone or symlink -> local path │ └── worktrees/ # Git worktrees for this project ├── worktrees/ # Legacy global worktrees (for repos not in workspaces/) +├── web-dist// # Cached web UI dist (archon serve, binary only) +├── update-check.json # Update check cache (binary builds only, 24h TTL) └── config.yaml # Global user configuration ``` @@ -86,6 +88,10 @@ getArchonWorktreesPath(): string getArchonConfigPath(): string // Returns: ${ARCHON_HOME}/config.yaml +// Get cached web UI distribution directory for a given version +getWebDistDir(version: string): string +// Returns: ${ARCHON_HOME}/web-dist/${version} + // Get command folder search paths (priority order) getCommandFolderSearchPaths(configuredFolder?: string): string[] // Returns: ['.archon/commands'] + configuredFolder if specified diff --git a/packages/docs-web/src/content/docs/reference/cli.md b/packages/docs-web/src/content/docs/reference/cli.md index 6148fb8bc6..d51244380a 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -50,7 +50,7 @@ archon workflow run plan --cwd /path/to/repo --branch feature-auth "Add OAuth su archon workflow run assist --cwd /path/to/repo --no-worktree "Quick question" ``` -**Note:** Workflow and isolation commands require running from within a git repository. Running from subdirectories automatically resolves to the repo root. The `version`, `help`, `chat`, and `setup` commands work anywhere. +**Note:** Workflow and isolation commands require running from within a git repository. Running from subdirectories automatically resolves to the repo root. The `version`, `help`, `chat`, `setup`, and `serve` commands work anywhere. ## Commands @@ -303,6 +303,32 @@ archon complete feature-auth --force # bypass uncommitted-changes check Use this after a PR is merged and you no longer need the worktree or branches. Accepts multiple branch names in one call. +### `serve` + +Start the web UI server. On first run, downloads a pre-built web UI tarball from the matching GitHub release, verifies the SHA-256 checksum, and extracts it. Subsequent runs use the cached copy. + +**Binary installs only** — in development, use `bun run dev` instead. + +```bash +# Start web UI server (downloads on first run) +archon serve + +# Override the default port +archon serve --port 4000 + +# Download the web UI without starting the server +archon serve --download-only +``` + +**Flags:** + +| Flag | Effect | +|------|--------| +| `--port ` | Override server port (default: 3090, range: 1–65535) | +| `--download-only` | Download and cache the web UI, then exit without starting the server | + +The cached web UI is stored at `~/.archon/web-dist//`. Each version is cached independently, so upgrading the binary automatically downloads the matching web UI. + ### `version` Show version, build type, and database info. diff --git a/packages/paths/src/archon-paths.ts b/packages/paths/src/archon-paths.ts index 45fddc3292..ca8ea73774 100644 --- a/packages/paths/src/archon-paths.ts +++ b/packages/paths/src/archon-paths.ts @@ -198,6 +198,14 @@ export function getDefaultWorkflowsPath(): string { return join(getAppArchonBasePath(), 'workflows', 'defaults'); } +/** + * Returns the path to the cached web UI distribution for a given version. + * Example: ~/.archon/web-dist/v0.3.2/ + */ +export function getWebDistDir(version: string): string { + return join(getArchonHome(), 'web-dist', version); +} + // ============================================================================= // Project-centric path functions // ============================================================================= diff --git a/packages/paths/src/index.ts b/packages/paths/src/index.ts index 3c3fd89618..99a254f4ca 100644 --- a/packages/paths/src/index.ts +++ b/packages/paths/src/index.ts @@ -25,6 +25,7 @@ export { ensureProjectStructure, createProjectSourceSymlink, findMarkdownFilesRecursive, + getWebDistDir, } from './archon-paths'; // Logger @@ -33,3 +34,12 @@ export type { Logger } from './logger'; // Build-time constants (rewritten by scripts/build-binaries.sh) export { BUNDLED_IS_BINARY, BUNDLED_VERSION, BUNDLED_GIT_COMMIT } from './bundled-build'; + +// Update check +export { + checkForUpdate, + getCachedUpdateCheck, + isNewerVersion, + parseLatestRelease, +} from './update-check'; +export type { UpdateCheckResult } from './update-check'; diff --git a/packages/paths/src/update-check.test.ts b/packages/paths/src/update-check.test.ts new file mode 100644 index 0000000000..cdfd4b7a30 --- /dev/null +++ b/packages/paths/src/update-check.test.ts @@ -0,0 +1,269 @@ +import { describe, test, expect, spyOn, beforeEach, afterEach } from 'bun:test'; +import { join } from 'path'; +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { + isNewerVersion, + parseLatestRelease, + checkForUpdate, + getCachedUpdateCheck, +} from './update-check'; + +// ─── isNewerVersion ────────────────────────────────────────────────── + +describe('isNewerVersion', () => { + test('returns true when latest minor is higher', () => { + expect(isNewerVersion('0.3.2', '0.4.0')).toBe(true); + }); + + test('returns true when latest patch is higher', () => { + expect(isNewerVersion('0.3.2', '0.3.3')).toBe(true); + }); + + test('returns false when current is higher', () => { + expect(isNewerVersion('0.4.0', '0.3.9')).toBe(false); + }); + + test('returns false when versions are equal', () => { + expect(isNewerVersion('0.3.2', '0.3.2')).toBe(false); + }); + + test('handles major version differences', () => { + expect(isNewerVersion('0.99.99', '1.0.0')).toBe(true); + }); + + test('handles double-digit segments correctly (not string comparison)', () => { + expect(isNewerVersion('0.9.0', '0.10.0')).toBe(true); + }); +}); + +// ─── parseLatestRelease ────────────────────────────────────────────── + +describe('parseLatestRelease', () => { + test('parses valid response with v prefix', () => { + const result = parseLatestRelease({ + tag_name: 'v0.4.0', + html_url: 'https://github.com/coleam00/Archon/releases/tag/v0.4.0', + }); + expect(result).toEqual({ + version: '0.4.0', + url: 'https://github.com/coleam00/Archon/releases/tag/v0.4.0', + }); + }); + + test('parses tag_name without v prefix', () => { + const result = parseLatestRelease({ + tag_name: '0.4.0', + html_url: 'https://example.com', + }); + expect(result.version).toBe('0.4.0'); + }); + + test('throws on missing tag_name', () => { + expect(() => parseLatestRelease({})).toThrow('Missing tag_name'); + }); + + test('returns empty url when html_url is missing', () => { + const result = parseLatestRelease({ tag_name: 'v1.0.0' }); + expect(result.url).toBe(''); + }); +}); + +// ─── checkForUpdate (with mocked fetch) ────────────────────────────── + +describe('checkForUpdate', () => { + const testDir = join(tmpdir(), `archon-update-check-test-${Date.now()}`); + let originalArchonHome: string | undefined; + + beforeEach(() => { + originalArchonHome = process.env.ARCHON_HOME; + process.env.ARCHON_HOME = testDir; + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (originalArchonHome !== undefined) { + process.env.ARCHON_HOME = originalArchonHome; + } else { + delete process.env.ARCHON_HOME; + } + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + test('returns result from fresh cache without fetching', async () => { + const cache = { + latestVersion: '0.5.0', + releaseUrl: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0', + checkedAt: Date.now(), + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache)); + + const fetchSpy = spyOn(globalThis, 'fetch'); + const result = await checkForUpdate('0.4.0'); + + expect(result).toEqual({ + updateAvailable: true, + currentVersion: '0.4.0', + latestVersion: '0.5.0', + releaseUrl: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + test('fetches from GitHub when no cache exists', async () => { + const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + tag_name: 'v0.5.0', + html_url: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0', + }), + { status: 200 } + ) + ); + + const result = await checkForUpdate('0.4.0'); + + expect(result).toEqual({ + updateAvailable: true, + currentVersion: '0.4.0', + latestVersion: '0.5.0', + releaseUrl: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0', + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Verify cache was written with correct content + const cacheRaw = JSON.parse(readFileSync(join(testDir, 'update-check.json'), 'utf-8')); + expect(cacheRaw.latestVersion).toBe('0.5.0'); + expect(cacheRaw.releaseUrl).toBe('https://github.com/coleam00/Archon/releases/tag/v0.5.0'); + expect(typeof cacheRaw.checkedAt).toBe('number'); + fetchSpy.mockRestore(); + }); + + test('returns null on network error', async () => { + const fetchSpy = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error')); + + const result = await checkForUpdate('0.4.0'); + + expect(result).toBeNull(); + fetchSpy.mockRestore(); + }); + + test('returns null on non-200 HTTP response', async () => { + const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('{"message":"rate limit exceeded"}', { status: 403 }) + ); + + const result = await checkForUpdate('0.4.0'); + + expect(result).toBeNull(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + fetchSpy.mockRestore(); + }); + + test('returns updateAvailable: false when current matches latest', async () => { + const cache = { + latestVersion: '0.4.0', + releaseUrl: 'https://github.com/coleam00/Archon/releases/tag/v0.4.0', + checkedAt: Date.now(), + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache)); + + const result = await checkForUpdate('0.4.0'); + + expect(result?.updateAvailable).toBe(false); + }); + + test('fetches when cache is stale', async () => { + const staleCache = { + latestVersion: '0.4.0', + releaseUrl: 'https://example.com', + checkedAt: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(staleCache)); + + const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + tag_name: 'v0.5.0', + html_url: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0', + }), + { status: 200 } + ) + ); + + const result = await checkForUpdate('0.4.0'); + + expect(result?.latestVersion).toBe('0.5.0'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + fetchSpy.mockRestore(); + }); +}); + +// ─── getCachedUpdateCheck ──────────────────────────────────────────── + +describe('getCachedUpdateCheck', () => { + const testDir = join(tmpdir(), `archon-cached-check-test-${Date.now()}`); + let originalArchonHome: string | undefined; + + beforeEach(() => { + originalArchonHome = process.env.ARCHON_HOME; + process.env.ARCHON_HOME = testDir; + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (originalArchonHome !== undefined) { + process.env.ARCHON_HOME = originalArchonHome; + } else { + delete process.env.ARCHON_HOME; + } + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + test('returns null when no cache file', () => { + expect(getCachedUpdateCheck('0.4.0')).toBeNull(); + }); + + test('returns result from cache file', () => { + const cache = { + latestVersion: '0.5.0', + releaseUrl: 'https://example.com', + checkedAt: Date.now(), + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache)); + + const result = getCachedUpdateCheck('0.4.0'); + expect(result?.updateAvailable).toBe(true); + expect(result?.latestVersion).toBe('0.5.0'); + }); + + test('returns null for corrupt cache file', () => { + writeFileSync(join(testDir, 'update-check.json'), 'not json'); + expect(getCachedUpdateCheck('0.4.0')).toBeNull(); + }); + + test('returns null for stale cache', () => { + const staleCache = { + latestVersion: '0.5.0', + releaseUrl: 'https://example.com', + checkedAt: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(staleCache)); + expect(getCachedUpdateCheck('0.4.0')).toBeNull(); + }); + + test('returns null when checkedAt is missing', () => { + const cache = { latestVersion: '0.5.0', releaseUrl: 'https://example.com' }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache)); + expect(getCachedUpdateCheck('0.4.0')).toBeNull(); + }); +}); diff --git a/packages/paths/src/update-check.ts b/packages/paths/src/update-check.ts new file mode 100644 index 0000000000..26156ae605 --- /dev/null +++ b/packages/paths/src/update-check.ts @@ -0,0 +1,153 @@ +import { join } from 'path'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { getArchonHome } from './archon-paths'; +import { createLogger } from './logger'; + +const log = createLogger('update-check'); + +interface UpdateCheckCache { + latestVersion: string; + releaseUrl: string; + checkedAt: number; // Date.now() ms +} + +export interface UpdateCheckResult { + updateAvailable: boolean; + currentVersion: string; + latestVersion: string; + releaseUrl: string; +} + +const CACHE_FILE = 'update-check.json'; +const STALENESS_MS = 24 * 60 * 60 * 1000; // 24 hours +const FETCH_TIMEOUT_MS = 3000; // 3 seconds +const GITHUB_API_URL = 'https://api.github.com/repos/coleam00/Archon/releases/latest'; + +function getCachePath(): string { + return join(getArchonHome(), CACHE_FILE); +} + +function readCache(): UpdateCheckCache | null { + const cachePath = getCachePath(); + try { + if (!existsSync(cachePath)) return null; + const raw = readFileSync(cachePath, 'utf-8'); + const data = JSON.parse(raw) as UpdateCheckCache; + if (!data.latestVersion || !data.releaseUrl || typeof data.checkedAt !== 'number') { + return null; + } + if (Date.now() - data.checkedAt > STALENESS_MS) { + return null; + } + return data; + } catch (err) { + log.debug({ err, cachePath }, 'update_check.cache_read_failed'); + return null; + } +} + +function writeCache(cache: UpdateCheckCache): void { + try { + const home = getArchonHome(); + mkdirSync(home, { recursive: true }); + writeFileSync(getCachePath(), JSON.stringify(cache), 'utf-8'); + } catch (err) { + log.debug({ err }, 'update_check.cache_write_failed'); + } +} + +/** + * Compare semver strings: returns true if latest > current. + * Expects plain MAJOR.MINOR.PATCH (no `v` prefix). + */ +export function isNewerVersion(current: string, latest: string): boolean { + const c = current.split('.').map(Number); + const l = latest.split('.').map(Number); + for (let i = 0; i < 3; i++) { + const cv = c[i] ?? 0; + const lv = l[i] ?? 0; + if (lv > cv) return true; + if (lv < cv) return false; + } + return false; +} + +/** + * Parse tag_name and html_url from GitHub API /releases/latest response. + * Strips `v` prefix from tag_name. + */ +export function parseLatestRelease(json: unknown): { version: string; url: string } { + const obj = json as Record; + const tagName = obj.tag_name; + if (typeof tagName !== 'string' || !tagName) { + throw new Error('Missing tag_name in GitHub release response'); + } + const version = tagName.startsWith('v') ? tagName.slice(1) : tagName; + const url = typeof obj.html_url === 'string' ? obj.html_url : ''; + return { version, url }; +} + +/** + * Full update check: read cache → fetch if stale → write cache → return result. + * Network errors are swallowed (returns null). + * Only call when BUNDLED_IS_BINARY is true. + */ +export async function checkForUpdate(currentVersion: string): Promise { + try { + // Try cache first + const cached = readCache(); + if (cached) { + return { + updateAvailable: isNewerVersion(currentVersion, cached.latestVersion), + currentVersion, + latestVersion: cached.latestVersion, + releaseUrl: cached.releaseUrl, + }; + } + + // Fetch from GitHub with timeout + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, FETCH_TIMEOUT_MS); + try { + const res = await fetch(GITHUB_API_URL, { + signal: controller.signal, + headers: { 'User-Agent': 'archon-update-check' }, + }); + if (!res.ok) return null; + const json: unknown = await res.json(); + const { version, url } = parseLatestRelease(json); + + // Write cache + writeCache({ latestVersion: version, releaseUrl: url, checkedAt: Date.now() }); + + return { + updateAvailable: isNewerVersion(currentVersion, version), + currentVersion, + latestVersion: version, + releaseUrl: url, + }; + } finally { + clearTimeout(timeout); + } + } catch (err) { + log.debug({ err }, 'update_check.fetch_failed'); + return null; + } +} + +/** + * Sync-only: read cache, compare, return result. No fetch. + * Returns null for stale or corrupt cache entries. + */ +export function getCachedUpdateCheck(currentVersion: string): UpdateCheckResult | null { + const cached = readCache(); + if (!cached) return null; + return { + updateAvailable: isNewerVersion(currentVersion, cached.latestVersion), + currentVersion, + latestVersion: cached.latestVersion, + releaseUrl: cached.releaseUrl, + }; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4d405b63ba..04633bc8ad 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -126,7 +126,19 @@ export function handleUnhandledRejection(reason: unknown): void { process.exit(1); } -async function main(): Promise { +export interface ServerOptions { + /** + * Override the web dist path (for CLI binary with downloaded web-dist). + * Only effective in production mode (NODE_ENV=production or WEB_UI_DEV unset). + */ + webDistPath?: string; + /** Override the port. Range: 1–65535. */ + port?: number; + /** Run in standalone web-only mode (no Telegram/Slack/GitHub/Discord adapters). */ + skipPlatformAdapters?: boolean; +} + +export async function startServer(opts: ServerOptions = {}): Promise { getLog().info('server_starting'); // Database auto-detected: SQLite (default) or PostgreSQL (if DATABASE_URL set) @@ -278,189 +290,197 @@ async function main(): Promise { await webAdapter.start(); persistence.startPeriodicFlush(); - // Check that at least one platform is configured - const hasTelegram = Boolean(process.env.TELEGRAM_BOT_TOKEN); - const hasDiscord = Boolean(process.env.DISCORD_BOT_TOKEN); - const hasGitHub = Boolean(process.env.GITHUB_TOKEN && process.env.WEBHOOK_SECRET); - const hasGitea = Boolean( - process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET - ); - const hasGitLab = Boolean(process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET); - - if (!hasTelegram && !hasDiscord && !hasGitHub && !hasGitea && !hasGitLab) { - getLog().warn('no_platform_adapters_configured'); - } - - // Initialize GitHub adapter (conditional) + // Platform adapters (skipped in CLI serve mode or when not configured) let github: GitHubAdapter | null = null; - if (process.env.GITHUB_TOKEN && process.env.WEBHOOK_SECRET) { - const botMention = - process.env.GITHUB_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; - github = new GitHubAdapter( - process.env.GITHUB_TOKEN, - process.env.WEBHOOK_SECRET, - lockManager, - botMention - ); - await github.start(); - } else { - getLog().info('github_adapter_skipped'); - } - - // Initialize Gitea adapter (conditional) let gitea: GiteaAdapter | null = null; - if (process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET) { - const giteaBotMention = - process.env.GITEA_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; - gitea = new GiteaAdapter( - process.env.GITEA_URL, - process.env.GITEA_TOKEN, - process.env.GITEA_WEBHOOK_SECRET, - lockManager, - giteaBotMention - ); - await gitea.start(); - } else { - getLog().info('gitea_adapter_skipped'); - } - - // Initialize GitLab adapter (conditional) let gitlab: GitLabAdapter | null = null; - if (process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET) { - const gitlabBotMention = - process.env.GITLAB_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; - gitlab = new GitLabAdapter( - process.env.GITLAB_TOKEN, - process.env.GITLAB_WEBHOOK_SECRET, - lockManager, - process.env.GITLAB_URL || undefined, - gitlabBotMention + let discord: DiscordAdapter | null = null; + let slack: SlackAdapter | null = null; + + if (!opts.skipPlatformAdapters) { + // Check that at least one platform is configured + const hasTelegram = Boolean(process.env.TELEGRAM_BOT_TOKEN); + const hasDiscord = Boolean(process.env.DISCORD_BOT_TOKEN); + const hasGitHub = Boolean(process.env.GITHUB_TOKEN && process.env.WEBHOOK_SECRET); + const hasGitea = Boolean( + process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET ); - await gitlab.start(); - } else { - getLog().info('gitlab_adapter_skipped'); - } + const hasGitLab = Boolean(process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET); - // Initialize Discord adapter (conditional) - let discord: DiscordAdapter | null = null; - if (process.env.DISCORD_BOT_TOKEN) { - const discordStreamingMode = (process.env.DISCORD_STREAMING_MODE ?? 'batch') as - | 'stream' - | 'batch'; - discord = new DiscordAdapter(process.env.DISCORD_BOT_TOKEN, discordStreamingMode); - const discordAdapter = discord; // Capture for use in callback - - // Register message handler - discordAdapter.onMessage(async message => { - // Get initial conversation ID - let conversationId = discordAdapter.getConversationId(message); - - // Skip if no content - if (!message.content) return; - - // Check if bot was mentioned (required for activation) - // Exception: DMs don't require mention - const isDM = !message.guild; - if (!isDM && !discordAdapter.isBotMentioned(message)) { - return; // Ignore messages that don't mention the bot - } + if (!hasTelegram && !hasDiscord && !hasGitHub && !hasGitea && !hasGitLab) { + getLog().warn('no_platform_adapters_configured'); + } - // Strip the bot mention from the message - const content = discordAdapter.stripBotMention(message); - if (!content) return; // Message was only a mention with no content + // Initialize GitHub adapter (conditional) + if (process.env.GITHUB_TOKEN && process.env.WEBHOOK_SECRET) { + const botMention = + process.env.GITHUB_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; + github = new GitHubAdapter( + process.env.GITHUB_TOKEN, + process.env.WEBHOOK_SECRET, + lockManager, + botMention + ); + await github.start(); + } else { + getLog().info('github_adapter_skipped'); + } - // Ensure we're responding in a thread - creates one if needed - conversationId = await discordAdapter.ensureThread(conversationId, message); + // Initialize Gitea adapter (conditional) + if (process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET) { + const giteaBotMention = + process.env.GITEA_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; + gitea = new GiteaAdapter( + process.env.GITEA_URL, + process.env.GITEA_TOKEN, + process.env.GITEA_WEBHOOK_SECRET, + lockManager, + giteaBotMention + ); + await gitea.start(); + } else { + getLog().info('gitea_adapter_skipped'); + } - // Check for thread context (now we're guaranteed to be in a thread if applicable) - let threadContext: string | undefined; - let parentConversationId: string | undefined; + // Initialize GitLab adapter (conditional) + if (process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET) { + const gitlabBotMention = + process.env.GITLAB_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; + gitlab = new GitLabAdapter( + process.env.GITLAB_TOKEN, + process.env.GITLAB_WEBHOOK_SECRET, + lockManager, + process.env.GITLAB_URL || undefined, + gitlabBotMention + ); + await gitlab.start(); + } else { + getLog().info('gitlab_adapter_skipped'); + } - if (discordAdapter.isThread(message)) { - // Fetch thread history for context (exclude current message) - const history = await discordAdapter.fetchThreadHistory(message); - if (history.length > 1) { - threadContext = history.slice(0, -1).join('\n'); + // Initialize Discord adapter (conditional) + if (process.env.DISCORD_BOT_TOKEN) { + const discordStreamingMode = (process.env.DISCORD_STREAMING_MODE ?? 'batch') as + | 'stream' + | 'batch'; + discord = new DiscordAdapter(process.env.DISCORD_BOT_TOKEN, discordStreamingMode); + const discordAdapter = discord; // Capture for use in callback + + // Register message handler + discordAdapter.onMessage(async message => { + // Get initial conversation ID + let conversationId = discordAdapter.getConversationId(message); + + // Skip if no content + if (!message.content) return; + + // Check if bot was mentioned (required for activation) + // Exception: DMs don't require mention + const isDM = !message.guild; + if (!isDM && !discordAdapter.isBotMentioned(message)) { + return; // Ignore messages that don't mention the bot } - // Get parent channel ID for context inheritance - parentConversationId = discordAdapter.getParentChannelId(message) ?? undefined; - } + // Strip the bot mention from the message + const content = discordAdapter.stripBotMention(message); + if (!content) return; // Message was only a mention with no content - // Fire-and-forget: handler returns immediately, processing happens async - lockManager - .acquireLock(conversationId, async () => { - await handleMessage(discordAdapter, conversationId, content, { - threadContext, - parentConversationId, - isolationHints: { workflowType: 'thread', workflowId: conversationId }, - }); - }) - .catch(createMessageErrorHandler('Discord', discordAdapter, conversationId)); - }); + // Ensure we're responding in a thread - creates one if needed + conversationId = await discordAdapter.ensureThread(conversationId, message); - await discord.start(); - } else { - getLog().info('discord_adapter_skipped'); - } + // Check for thread context (now we're guaranteed to be in a thread if applicable) + let threadContext: string | undefined; + let parentConversationId: string | undefined; - // Initialize Slack adapter (conditional) - let slack: SlackAdapter | null = null; - if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) { - const slackStreamingMode = (process.env.SLACK_STREAMING_MODE ?? 'batch') as 'stream' | 'batch'; - slack = new SlackAdapter( - process.env.SLACK_BOT_TOKEN, - process.env.SLACK_APP_TOKEN, - slackStreamingMode - ); - const slackAdapter = slack; // Capture for use in callback + if (discordAdapter.isThread(message)) { + // Fetch thread history for context (exclude current message) + const history = await discordAdapter.fetchThreadHistory(message); + if (history.length > 1) { + threadContext = history.slice(0, -1).join('\n'); + } - // Register message handler - slackAdapter.onMessage(async event => { - const conversationId = slackAdapter.getConversationId(event); + // Get parent channel ID for context inheritance + parentConversationId = discordAdapter.getParentChannelId(message) ?? undefined; + } - // Skip if no text - if (!event.text) return; + // Fire-and-forget: handler returns immediately, processing happens async + lockManager + .acquireLock(conversationId, async () => { + await handleMessage(discordAdapter, conversationId, content, { + threadContext, + parentConversationId, + isolationHints: { workflowType: 'thread', workflowId: conversationId }, + }); + }) + .catch(createMessageErrorHandler('Discord', discordAdapter, conversationId)); + }); - // Strip the bot mention from the message - const content = slackAdapter.stripBotMention(event.text); - if (!content) return; // Message was only a mention with no content + await discord.start(); + } else { + getLog().info('discord_adapter_skipped'); + } - // Check for thread context - let threadContext: string | undefined; - let parentConversationId: string | undefined; + // Initialize Slack adapter (conditional) + if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) { + const slackStreamingMode = (process.env.SLACK_STREAMING_MODE ?? 'batch') as + | 'stream' + | 'batch'; + slack = new SlackAdapter( + process.env.SLACK_BOT_TOKEN, + process.env.SLACK_APP_TOKEN, + slackStreamingMode + ); + const slackAdapter = slack; // Capture for use in callback - if (slackAdapter.isThread(event)) { - // Fetch thread history for context (exclude current message) - const history = await slackAdapter.fetchThreadHistory(event); - if (history.length > 1) { - threadContext = history.slice(0, -1).join('\n'); - } + // Register message handler + slackAdapter.onMessage(async event => { + const conversationId = slackAdapter.getConversationId(event); - // Get parent conversation ID for context inheritance - parentConversationId = slackAdapter.getParentConversationId(event) ?? undefined; - } + // Skip if no text + if (!event.text) return; - // Fire-and-forget: handler returns immediately, processing happens async - lockManager - .acquireLock(conversationId, async () => { - await handleMessage(slackAdapter, conversationId, content, { - threadContext, - parentConversationId, - isolationHints: { workflowType: 'thread', workflowId: conversationId }, - }); - }) - .catch(createMessageErrorHandler('Slack', slackAdapter, conversationId)); - }); + // Strip the bot mention from the message + const content = slackAdapter.stripBotMention(event.text); + if (!content) return; // Message was only a mention with no content - await slack.start(); + // Check for thread context + let threadContext: string | undefined; + let parentConversationId: string | undefined; + + if (slackAdapter.isThread(event)) { + // Fetch thread history for context (exclude current message) + const history = await slackAdapter.fetchThreadHistory(event); + if (history.length > 1) { + threadContext = history.slice(0, -1).join('\n'); + } + + // Get parent conversation ID for context inheritance + parentConversationId = slackAdapter.getParentConversationId(event) ?? undefined; + } + + // Fire-and-forget: handler returns immediately, processing happens async + lockManager + .acquireLock(conversationId, async () => { + await handleMessage(slackAdapter, conversationId, content, { + threadContext, + parentConversationId, + isolationHints: { workflowType: 'thread', workflowId: conversationId }, + }); + }) + .catch(createMessageErrorHandler('Slack', slackAdapter, conversationId)); + }); + + await slack.start(); + } else { + getLog().info('slack_adapter_skipped'); + } } else { - getLog().info('slack_adapter_skipped'); + getLog().info('platform_adapters_skipped'); } // Setup Hono server const app = new OpenAPIHono({ defaultHook: validationErrorHook }); - const port = await getPort(); + const port = opts.port ?? (await getPort()); // Global error handler for unhandled exceptions app.onError((err, c) => { @@ -581,11 +601,9 @@ async function main(): Promise { if (process.env.NODE_ENV === 'production' || !process.env.WEB_UI_DEV) { const { serveStatic } = await import('hono/bun'); const pathModule = await import('path'); - const webDistPath = pathModule.join( - pathModule.dirname(pathModule.dirname(import.meta.dir)), - 'web', - 'dist' - ); + const webDistPath = + opts.webDistPath ?? + pathModule.join(pathModule.dirname(pathModule.dirname(import.meta.dir)), 'web', 'dist'); app.use('/assets/*', serveStatic({ root: webDistPath })); // SPA fallback - serve index.html for unmatched routes (after all API routes) @@ -601,9 +619,9 @@ async function main(): Promise { }); getLog().info({ port: server.port, hostname }, 'server_listening'); - // Initialize Telegram adapter (conditional) + // Initialize Telegram adapter (conditional, skipped in CLI serve mode) let telegram: TelegramAdapter | null = null; - if (process.env.TELEGRAM_BOT_TOKEN) { + if (!opts.skipPlatformAdapters && process.env.TELEGRAM_BOT_TOKEN) { const streamingMode = (process.env.TELEGRAM_STREAMING_MODE ?? 'stream') as 'stream' | 'batch'; telegram = new TelegramAdapter(process.env.TELEGRAM_BOT_TOKEN, streamingMode); const telegramAdapter = telegram; // Capture for use in callback @@ -627,7 +645,7 @@ async function main(): Promise { getLog().error({ err: error, errorType: error.constructor.name }, 'telegram.start_failed'); telegram = null; // Don't include in active platforms or shutdown } - } else { + } else if (!opts.skipPlatformAdapters) { getLog().info('telegram_adapter_skipped'); } @@ -714,8 +732,10 @@ async function checkGhAuth(): Promise { } } -// Run the application -main().catch(error => { - getLog().fatal({ err: error }, 'startup_failed'); - process.exit(1); -}); +// Run the application when executed directly (not imported as a library) +if (import.meta.main) { + startServer().catch(error => { + getLog().fatal({ err: error }, 'startup_failed'); + process.exit(1); + }); +} diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index 81afc6db3d..cfade2c012 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -41,6 +41,8 @@ import { getRunArtifactsPath, getArchonHome, isDocker, + checkForUpdate, + BUNDLED_IS_BINARY, } from '@archon/paths'; import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery'; import { parseWorkflow } from '@archon/workflows/loader'; @@ -67,6 +69,7 @@ import * as workflowDb from '@archon/core/db/workflows'; import * as workflowEventDb from '@archon/core/db/workflow-events'; import * as messageDb from '@archon/core/db/messages'; import { errorSchema } from './schemas/common.schemas'; +import { updateCheckResponseSchema } from './schemas/system.schemas'; import { workflowListResponseSchema, validateWorkflowBodySchema, @@ -831,6 +834,23 @@ const getHealthRoute = createRoute({ }, }); +const getUpdateCheckRoute = createRoute({ + method: 'get', + path: '/api/update-check', + tags: ['System'], + summary: 'Check for available updates', + responses: { + 200: { + content: { + 'application/json': { + schema: updateCheckResponseSchema, + }, + }, + description: 'Update check result', + }, + }, +}); + /** * Register all /api/* routes on the Hono app. */ @@ -2589,4 +2609,16 @@ export function registerApiRoutes( is_docker: isDocker(), }); }); + + registerOpenApiRoute(getUpdateCheckRoute, async c => { + const noUpdate = { + updateAvailable: false, + currentVersion: appVersion, + latestVersion: appVersion, + releaseUrl: '', + }; + if (!BUNDLED_IS_BINARY) return c.json(noUpdate); + const result = await checkForUpdate(appVersion); + return c.json(result ?? noUpdate); + }); } diff --git a/packages/server/src/routes/schemas/system.schemas.ts b/packages/server/src/routes/schemas/system.schemas.ts new file mode 100644 index 0000000000..7ee20ae361 --- /dev/null +++ b/packages/server/src/routes/schemas/system.schemas.ts @@ -0,0 +1,10 @@ +import { z } from '@hono/zod-openapi'; + +export const updateCheckResponseSchema = z + .object({ + updateAvailable: z.boolean(), + currentVersion: z.string(), + latestVersion: z.string(), + releaseUrl: z.string(), + }) + .openapi('UpdateCheckResponse'); diff --git a/packages/web/src/components/layout/TopNav.tsx b/packages/web/src/components/layout/TopNav.tsx index 4f0a100b5b..45924f5004 100644 --- a/packages/web/src/components/layout/TopNav.tsx +++ b/packages/web/src/components/layout/TopNav.tsx @@ -1,7 +1,7 @@ import { NavLink, Link } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { LayoutDashboard, MessageSquare, Workflow, Settings } from 'lucide-react'; -import { listWorkflowRuns } from '@/lib/api'; +import { listWorkflowRuns, getUpdateCheck } from '@/lib/api'; import { cn } from '@/lib/utils'; const tabs = [ @@ -19,6 +19,14 @@ export function TopNav(): React.ReactElement { }); const hasRunning = (runningRuns?.length ?? 0) > 0; + const { data: updateCheck } = useQuery({ + queryKey: ['update-check'], + queryFn: getUpdateCheck, + staleTime: 60 * 60 * 1000, + refetchInterval: 60 * 60 * 1000, + retry: false, + }); + return ( ); diff --git a/packages/web/src/lib/api.generated.d.ts b/packages/web/src/lib/api.generated.d.ts index afaf22a9ef..193c619588 100644 --- a/packages/web/src/lib/api.generated.d.ts +++ b/packages/web/src/lib/api.generated.d.ts @@ -310,7 +310,10 @@ export interface paths { }; get?: never; put?: never; - /** Send a message to a conversation */ + /** + * Send a message (JSON or multipart with file uploads) + * @description Accepts `application/json` with `{ message: string }` or `multipart/form-data` with a `message` field and optional file attachments (max 5 files, 10 MB each). + */ post: { parameters: { query?: never; @@ -320,11 +323,7 @@ export interface paths { }; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['SendMessageBody']; - }; - }; + requestBody?: never; responses: { /** @description Accepted */ 200: { @@ -476,13 +475,528 @@ export interface paths { }; requestBody?: never; responses: { - /** @description Codebase */ + /** @description Codebase */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Codebase']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + put?: never; + post?: never; + /** Delete a codebase and clean up associated resources */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DeleteCodebaseResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + options?: never; + head?: never; + /** Update codebase consent flags (e.g. allow_env_keys) */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateCodebaseBody']; + }; + }; + responses: { + /** @description Updated codebase */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Codebase']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + trace?: never; + }; + '/api/codebases/{id}/env': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List env vars for a codebase */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Env vars for codebase */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CodebaseEnvVarsResponse']; + }; + }; + /** @description Codebase not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** Set (upsert) an env var for a codebase */ + put: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['SetEnvVarBody']; + }; + }; + responses: { + /** @description Env var set */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['EnvVarMutationResponse']; + }; + }; + /** @description Codebase not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/codebases/{id}/env/{key}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete an env var from a codebase */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Env var deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['EnvVarMutationResponse']; + }; + }; + /** @description Codebase not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/workflows': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List available workflows */ + get: { + parameters: { + query?: { + cwd?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['WorkflowListResponse']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/workflows/{name}/run': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Run a workflow via the orchestrator */ + post: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['RunWorkflowBody']; + }; + }; + responses: { + /** @description Accepted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DispatchResponse']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/dashboard/runs': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List enriched workflow runs for the Command Center dashboard */ + get: { + parameters: { + query?: { + status?: string; + codebaseId?: string; + search?: string; + after?: string; + before?: string; + limit?: string; + offset?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DashboardRunsResponse']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/workflows/runs/{runId}/cancel': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Cancel a workflow run */ + post: { + parameters: { + query?: never; + header?: never; + path: { + runId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cancelled */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CancelWorkflowRunResponse']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/workflows/runs/{runId}/resume': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resume a failed workflow run (re-run auto-resumes from completed nodes) */ + post: { + parameters: { + query?: never; + header?: never; + path: { + runId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Resumed */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['Codebase']; + 'application/json': components['schemas']['WorkflowRunActionResponse']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; }; }; /** @description Not found */ @@ -505,27 +1019,49 @@ export interface paths { }; }; }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/workflows/runs/{runId}/abandon': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; put?: never; - post?: never; - /** Delete a codebase and clean up associated resources */ - delete: { + /** Abandon a workflow run (mark as failed) */ + post: { parameters: { query?: never; header?: never; path: { - id: string; + runId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Deleted */ + /** @description Abandoned */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['DeleteCodebaseResponse']; + 'application/json': components['schemas']['WorkflowRunActionResponse']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; }; }; /** @description Not found */ @@ -548,37 +1084,44 @@ export interface paths { }; }; }; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/workflows': { + '/api/workflows/runs/{runId}/approve': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List available workflows */ - get: { + get?: never; + put?: never; + /** Approve a paused workflow run */ + post: { parameters: { - query?: { - cwd?: string; - }; + query?: never; header?: never; - path?: never; + path: { + runId: string; + }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + 'application/json': components['schemas']['ApproveWorkflowRunBody']; + }; + }; responses: { - /** @description OK */ + /** @description Approved */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['WorkflowListResponse']; + 'application/json': components['schemas']['WorkflowRunActionResponse']; }; }; /** @description Bad request */ @@ -590,6 +1133,15 @@ export interface paths { 'application/json': components['schemas']['Error']; }; }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; /** @description Server error */ 500: { headers: { @@ -601,15 +1153,13 @@ export interface paths { }; }; }; - put?: never; - post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/workflows/{name}/run': { + '/api/workflows/runs/{runId}/reject': { parameters: { query?: never; header?: never; @@ -618,29 +1168,29 @@ export interface paths { }; get?: never; put?: never; - /** Run a workflow via the orchestrator */ + /** Reject a paused workflow run */ post: { parameters: { query?: never; header?: never; path: { - name: string; + runId: string; }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - 'application/json': components['schemas']['RunWorkflowBody']; + 'application/json': components['schemas']['RejectWorkflowRunBody']; }; }; responses: { - /** @description Accepted */ + /** @description Rejected */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['DispatchResponse']; + 'application/json': components['schemas']['WorkflowRunActionResponse']; }; }; /** @description Bad request */ @@ -652,6 +1202,15 @@ export interface paths { 'application/json': components['schemas']['Error']; }; }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; /** @description Server error */ 500: { headers: { @@ -669,38 +1228,41 @@ export interface paths { patch?: never; trace?: never; }; - '/api/dashboard/runs': { + '/api/workflows/runs/{runId}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List enriched workflow runs for the Command Center dashboard */ + /** Get workflow run details with events */ get: { parameters: { - query?: { - status?: string; - codebaseId?: string; - search?: string; - after?: string; - before?: string; - limit?: string; - offset?: string; - }; + query?: never; header?: never; - path?: never; + path: { + runId: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ + /** @description Workflow run detail */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['DashboardRunsResponse']; + 'application/json': components['schemas']['WorkflowRunDetail']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; }; }; /** @description Server error */ @@ -716,23 +1278,8 @@ export interface paths { }; put?: never; post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/workflows/runs/{runId}/cancel': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Cancel a workflow run */ - post: { + /** Delete a workflow run and its events */ + delete: { parameters: { query?: never; header?: never; @@ -743,13 +1290,13 @@ export interface paths { }; requestBody?: never; responses: { - /** @description Cancelled */ + /** @description Deleted */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CancelWorkflowRunResponse']; + 'application/json': components['schemas']['WorkflowRunActionResponse']; }; }; /** @description Bad request */ @@ -781,7 +1328,6 @@ export interface paths { }; }; }; - delete?: never; options?: never; head?: never; patch?: never; @@ -893,62 +1439,6 @@ export interface paths { patch?: never; trace?: never; }; - '/api/workflows/runs/{runId}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get workflow run details with events */ - get: { - parameters: { - query?: never; - header?: never; - path: { - runId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Workflow run detail */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['WorkflowRunDetail']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Error']; - }; - }; - /** @description Server error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Error']; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; '/api/workflows/validate': { parameters: { query?: never; @@ -1422,6 +1912,42 @@ export interface paths { patch?: never; trace?: never; }; + '/api/update-check': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check for available updates */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Update check result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UpdateCheckResponse']; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1474,9 +2000,6 @@ export interface components { accepted: boolean; status: string; }; - SendMessageBody: { - message: string; - }; CodebaseCommand: { path: string; description: string; @@ -1487,6 +2010,7 @@ export interface components { repository_url: string | null; default_cwd: string; ai_assistant_type: string; + allow_env_keys: boolean; commands: { [key: string]: components['schemas']['CodebaseCommand']; }; @@ -1497,10 +2021,24 @@ export interface components { AddCodebaseBody: { url?: string; path?: string; + allowEnvKeys?: boolean; + }; + UpdateCodebaseBody: { + allowEnvKeys: boolean; }; DeleteCodebaseResponse: { success: boolean; }; + CodebaseEnvVarsResponse: { + keys: string[]; + }; + EnvVarMutationResponse: { + success: boolean; + }; + SetEnvVarBody: { + key: string; + value: string; + }; DagNode: { id: string; depends_on?: string[]; @@ -1675,6 +2213,55 @@ export interface components { }; mcp?: string; skills?: string[]; + /** @enum {string} */ + effort?: 'low' | 'medium' | 'high' | 'max'; + thinking?: + | { + /** @enum {string} */ + type: 'adaptive'; + } + | { + /** @enum {string} */ + type: 'enabled'; + budgetTokens?: number; + } + | { + /** @enum {string} */ + type: 'disabled'; + }; + maxBudgetUsd?: number; + systemPrompt?: string; + fallbackModel?: string; + betas?: string[]; + sandbox?: { + enabled?: boolean; + autoAllowBashIfSandboxed?: boolean; + allowUnsandboxedCommands?: boolean; + network?: { + allowedDomains?: string[]; + allowManagedDomainsOnly?: boolean; + allowUnixSockets?: string[]; + allowAllUnixSockets?: boolean; + allowLocalBinding?: boolean; + httpProxyPort?: number; + socksProxyPort?: number; + }; + filesystem?: { + allowWrite?: string[]; + denyWrite?: string[]; + denyRead?: string[]; + }; + ignoreViolations?: { + [key: string]: string[]; + }; + enableWeakerNestedSandbox?: boolean; + enableWeakerNetworkIsolation?: boolean; + excludedCommands?: string[]; + ripgrep?: { + command: string; + args?: string[]; + }; + }; command?: string; prompt?: string; bash?: string; @@ -1685,7 +2272,22 @@ export interface components { /** @default false */ fresh_context: boolean; until_bash?: string; + interactive?: boolean; + gate_message?: string; }; + approval?: { + message: string; + capture_response?: boolean; + on_reject?: { + prompt: string; + max_attempts?: number; + }; + }; + cancel?: string; + script?: string; + /** @enum {string} */ + runtime?: 'bun' | 'uv'; + deps?: string[]; timeout?: number; }; WorkflowDefinition: { @@ -1699,6 +2301,54 @@ export interface components { /** @enum {string} */ webSearchMode?: 'disabled' | 'cached' | 'live'; additionalDirectories?: string[]; + interactive?: boolean; + /** @enum {string} */ + effort?: 'low' | 'medium' | 'high' | 'max'; + thinking?: + | { + /** @enum {string} */ + type: 'adaptive'; + } + | { + /** @enum {string} */ + type: 'enabled'; + budgetTokens?: number; + } + | { + /** @enum {string} */ + type: 'disabled'; + }; + fallbackModel?: string; + betas?: string[]; + sandbox?: { + enabled?: boolean; + autoAllowBashIfSandboxed?: boolean; + allowUnsandboxedCommands?: boolean; + network?: { + allowedDomains?: string[]; + allowManagedDomainsOnly?: boolean; + allowUnixSockets?: string[]; + allowAllUnixSockets?: boolean; + allowLocalBinding?: boolean; + httpProxyPort?: number; + socksProxyPort?: number; + }; + filesystem?: { + allowWrite?: string[]; + denyWrite?: string[]; + denyRead?: string[]; + }; + ignoreViolations?: { + [key: string]: string[]; + }; + enableWeakerNestedSandbox?: boolean; + enableWeakerNetworkIsolation?: boolean; + excludedCommands?: string[]; + ripgrep?: { + command: string; + args?: string[]; + }; + }; nodes: components['schemas']['DagNode'][]; }; /** @enum {string} */ @@ -1762,12 +2412,23 @@ export interface components { failed: number; cancelled: number; pending: number; + paused: number; }; }; CancelWorkflowRunResponse: { success: boolean; message: string; }; + WorkflowRunActionResponse: { + success: boolean; + message: string; + }; + ApproveWorkflowRunBody: { + comment?: string; + }; + RejectWorkflowRunBody: { + reason?: string; + }; WorkflowRunListResponse: { runs: components['schemas']['WorkflowRun'][]; }; @@ -1846,8 +2507,6 @@ export interface components { discord: 'stream' | 'batch'; /** @enum {string} */ slack: 'stream' | 'batch'; - /** @enum {string} */ - github: 'stream' | 'batch'; }; concurrency: { maxConversations: number; @@ -1896,6 +2555,14 @@ export interface components { [key: string]: unknown; }; runningWorkflows: number; + version?: string; + is_docker: boolean; + }; + UpdateCheckResponse: { + updateAvailable: boolean; + currentVersion: string; + latestVersion: string; + releaseUrl: string; }; }; responses: never; diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index f13034f274..6c81aa66b1 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -495,3 +495,9 @@ export async function deleteCodebaseEnvVar( export async function getHealth(): Promise { return fetchJSON('/api/health'); } + +export type UpdateCheckResult = components['schemas']['UpdateCheckResponse']; + +export async function getUpdateCheck(): Promise { + return fetchJSON('/api/update-check'); +} diff --git a/packages/workflows/package.json b/packages/workflows/package.json index 22e093476f..bc0dac7720 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -11,6 +11,7 @@ "./deps": "./src/deps.ts", "./event-emitter": "./src/event-emitter.ts", "./workflow-discovery": "./src/workflow-discovery.ts", + "./script-discovery": "./src/script-discovery.ts", "./command-validation": "./src/command-validation.ts", "./defaults": "./src/defaults/bundled-defaults.ts", "./validator": "./src/validator.ts", @@ -18,7 +19,7 @@ "./test-utils": "./src/test-utils.ts" }, "scripts": { - "test": "bun test src/dag-executor.test.ts && bun test src/loader.test.ts && bun test src/logger.test.ts && bun test src/condition-evaluator.test.ts && bun test src/event-emitter.test.ts && bun test src/executor-shared.test.ts && bun test src/executor.test.ts && bun test src/executor-preamble.test.ts && bun test src/defaults/ src/model-validation.test.ts src/router.test.ts src/utils/ src/hooks.test.ts && bun test src/validation-parser.test.ts src/schemas.test.ts src/command-validation.test.ts && bun test src/validator.test.ts", + "test": "bun test src/dag-executor.test.ts && bun test src/loader.test.ts && bun test src/logger.test.ts && bun test src/condition-evaluator.test.ts && bun test src/event-emitter.test.ts && bun test src/executor-shared.test.ts && bun test src/executor.test.ts && bun test src/executor-preamble.test.ts && bun test src/defaults/ src/model-validation.test.ts src/router.test.ts src/utils/ src/hooks.test.ts && bun test src/validation-parser.test.ts src/schemas.test.ts src/command-validation.test.ts && bun test src/validator.test.ts && bun test src/script-discovery.test.ts && bun test src/runtime-check.test.ts && bun test src/script-node-deps.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { diff --git a/packages/workflows/src/dag-executor.test.ts b/packages/workflows/src/dag-executor.test.ts index b07e69388a..150ea4eeb7 100644 --- a/packages/workflows/src/dag-executor.test.ts +++ b/packages/workflows/src/dag-executor.test.ts @@ -33,7 +33,7 @@ import { executeDagWorkflow, loadMcpConfig, } from './dag-executor'; -import type { DagNode, BashNode, NodeOutput, WorkflowRun } from './schemas'; +import type { DagNode, BashNode, ScriptNode, NodeOutput, WorkflowRun } from './schemas'; import { discoverWorkflows } from './workflow-discovery'; import { parseWorkflow } from './loader'; import type { WorkflowDeps, IWorkflowPlatform, WorkflowConfig } from './deps'; @@ -4823,3 +4823,390 @@ describe('executeDagWorkflow -- cost tracking', () => { expect(completeCalls[0][1]).toMatchObject({ total_cost_usd: 0.004 }); }); }); + +describe('executeDagWorkflow -- script nodes', () => { + let testDir: string; + + beforeEach(async () => { + testDir = join( + tmpdir(), + `dag-script-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + await mkdir(testDir, { recursive: true }); + + mockSendQueryDag.mockClear(); + mockGetAssistantClientDag.mockClear(); + + mockSendQueryDag.mockImplementation(function* () { + yield { type: 'assistant', content: 'DAG AI response' }; + yield { type: 'result', sessionId: 'dag-session-id' }; + }); + + mockGetAssistantClientDag.mockImplementation(() => ({ + sendQuery: mockSendQueryDag, + getType: () => 'claude', + })); + }); + + afterEach(async () => { + try { + await rm(testDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + it('inline bun script executes and captures stdout', async () => { + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun('script-test-run-id', { + workflow_name: 'script-test', + conversation_id: 'conv-script', + user_message: 'script test message', + }); + + const scriptNode: ScriptNode = { + id: 'inline-bun', + script: 'console.log("hello from bun")', + runtime: 'bun', + }; + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-script', + testDir, + { name: 'script-inline-bun-test', nodes: [scriptNode] }, + workflowRun, + 'claude', + undefined, + join(testDir, 'artifacts'), + join(testDir, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + // Script node should NOT invoke AI client + expect(mockSendQueryDag.mock.calls.length).toBe(0); + }); + + it('inline bun script output available for downstream substitution', async () => { + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun('script-test-run-id', { + workflow_name: 'script-test', + conversation_id: 'conv-script', + user_message: 'script test message', + }); + + // Write a command file for the downstream AI node + const commandsDir = join(testDir, '.archon', 'commands'); + await mkdir(commandsDir, { recursive: true }); + await writeFile(join(commandsDir, 'use-result.md'), 'Use: $compute.output'); + + const nodes: DagNode[] = [ + { id: 'compute', script: 'console.log("42")', runtime: 'bun' }, + { id: 'use', command: 'use-result', depends_on: ['compute'] }, + ]; + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-script', + testDir, + { name: 'script-subst-test', nodes }, + workflowRun, + 'claude', + undefined, + join(testDir, 'artifacts'), + join(testDir, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + // AI client called for the downstream AI node + expect(mockSendQueryDag.mock.calls.length).toBe(1); + const prompt = mockSendQueryDag.mock.calls[0][0] as string; + expect(prompt).toContain('42'); + }); + + it('inline uv script executes and captures stdout', async () => { + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun('script-uv-run-id', { + workflow_name: 'script-uv-test', + conversation_id: 'conv-script-uv', + user_message: 'uv test message', + }); + + const scriptNode: ScriptNode = { + id: 'inline-uv', + script: 'print("hello from python")', + runtime: 'uv', + }; + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-script-uv', + testDir, + { name: 'script-inline-uv-test', nodes: [scriptNode] }, + workflowRun, + 'claude', + undefined, + join(testDir, 'artifacts'), + join(testDir, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + // Script node should NOT invoke AI client + expect(mockSendQueryDag.mock.calls.length).toBe(0); + }); + + it('named bun script executes from .archon/scripts/', async () => { + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun('script-named-run-id', { + workflow_name: 'script-named-test', + conversation_id: 'conv-named', + user_message: 'named test', + }); + + // Create a named script + const scriptsDir = join(testDir, '.archon', 'scripts'); + await mkdir(scriptsDir, { recursive: true }); + await writeFile(join(scriptsDir, 'greet.ts'), 'console.log("named script output")'); + + const scriptNode: ScriptNode = { + id: 'run-greet', + script: 'greet', + runtime: 'bun', + }; + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-named', + testDir, + { name: 'named-script-test', nodes: [scriptNode] }, + workflowRun, + 'claude', + undefined, + join(testDir, 'artifacts'), + join(testDir, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + expect(mockSendQueryDag.mock.calls.length).toBe(0); + }); + + it('non-zero exit code results in failed state', async () => { + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun('script-fail-run-id', { + workflow_name: 'script-fail-test', + conversation_id: 'conv-fail', + user_message: 'fail test', + }); + + const scriptNode: ScriptNode = { + id: 'fail-script', + script: 'process.exit(1)', + runtime: 'bun', + }; + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-fail', + testDir, + { name: 'script-fail-test', nodes: [scriptNode] }, + workflowRun, + 'claude', + undefined, + join(testDir, 'artifacts'), + join(testDir, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + const sendMessage = platform.sendMessage as ReturnType; + const messages = sendMessage.mock.calls.map((call: unknown[]) => call[1] as string); + const failMsg = messages.find((m: string) => m.includes('no successful nodes')); + expect(failMsg).toBeDefined(); + }); + + it('timeout kills subprocess', async () => { + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun('script-timeout-run-id', { + workflow_name: 'script-timeout-test', + conversation_id: 'conv-timeout', + user_message: 'timeout test', + }); + + const scriptNode: ScriptNode = { + id: 'slow-script', + // Bun inline script that sleeps longer than the timeout + script: 'await new Promise(r => setTimeout(r, 30000))', + runtime: 'bun', + timeout: 500, + }; + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-timeout', + testDir, + { name: 'script-timeout-test', nodes: [scriptNode] }, + workflowRun, + 'claude', + undefined, + join(testDir, 'artifacts'), + join(testDir, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + const sendMessage = platform.sendMessage as ReturnType; + const messages = sendMessage.mock.calls.map((call: unknown[]) => call[1] as string); + // Workflow fails because the only node failed (timeout) + const failMsg = messages.find((m: string) => m.includes('no successful nodes')); + expect(failMsg).toBeDefined(); + }, 10000); + + it('stderr output is sent to the user', async () => { + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun('script-stderr-run-id', { + workflow_name: 'script-stderr-test', + conversation_id: 'conv-stderr', + user_message: 'stderr test', + }); + + const scriptNode: ScriptNode = { + id: 'stderr-script', + // Write to both stderr and stdout + script: 'process.stderr.write("error detail\\n"); console.log("done")', + runtime: 'bun', + }; + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-stderr', + testDir, + { name: 'script-stderr-test', nodes: [scriptNode] }, + workflowRun, + 'claude', + undefined, + join(testDir, 'artifacts'), + join(testDir, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + const sendMessage = platform.sendMessage as ReturnType; + const messages = sendMessage.mock.calls.map((call: unknown[]) => call[1] as string); + const stderrMsg = messages.find((m: string) => m.includes('error detail')); + expect(stderrMsg).toBeDefined(); + expect(stderrMsg).toContain('stderr-script'); + }); + + it('$WORKFLOW_ID and $ARTIFACTS_DIR are substituted into script text', async () => { + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun('wf-subst-run-id', { + workflow_name: 'script-subst-test', + conversation_id: 'conv-subst', + user_message: 'subst test', + }); + + const artifactsDir = join(testDir, 'artifacts'); + + // Write a downstream command so we can inspect the substituted prompt + const commandsDir = join(testDir, '.archon', 'commands'); + await mkdir(commandsDir, { recursive: true }); + await writeFile(join(commandsDir, 'check-output.md'), 'Got: $script-out.output'); + + const nodes: DagNode[] = [ + { + id: 'script-out', + // Print the run ID and artifacts dir — after substitution these are real values + script: 'console.log("id=$WORKFLOW_ID artifacts=$ARTIFACTS_DIR")', + runtime: 'bun', + }, + { id: 'check', command: 'check-output', depends_on: ['script-out'] }, + ]; + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-subst', + testDir, + { name: 'script-subst-vars', nodes }, + workflowRun, + 'claude', + undefined, + artifactsDir, + join(testDir, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + // The downstream AI node should have received the substituted output + expect(mockSendQueryDag.mock.calls.length).toBe(1); + const prompt = mockSendQueryDag.mock.calls[0][0] as string; + // The script output should contain the actual run ID (not the literal variable name) + expect(prompt).toContain('wf-subst-run-id'); + expect(prompt).not.toContain('$WORKFLOW_ID'); + }); + + it('named script not found at runtime results in failed state and platform message', async () => { + const mockDeps = createMockDeps(); + const platform = createMockPlatform(); + const workflowRun = makeWorkflowRun('script-notfound-run-id', { + workflow_name: 'script-notfound-test', + conversation_id: 'conv-notfound', + user_message: 'notfound test', + }); + + // Do NOT create .archon/scripts/missing.ts — the script should fail to resolve + const scriptNode: ScriptNode = { + id: 'gone-script', + script: 'missing', + runtime: 'bun', + }; + + await executeDagWorkflow( + mockDeps, + platform, + 'conv-notfound', + testDir, + { name: 'script-notfound-test', nodes: [scriptNode] }, + workflowRun, + 'claude', + undefined, + join(testDir, 'artifacts'), + join(testDir, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + const sendMessage = platform.sendMessage as ReturnType; + const messages = sendMessage.mock.calls.map((call: unknown[]) => call[1] as string); + const notFoundMsg = messages.find((m: string) => m.includes('not found in .archon/scripts/')); + expect(notFoundMsg).toBeDefined(); + }); +}); diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index f52ea5fe74..5427c1974f 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -8,6 +8,7 @@ import { readFile } from 'fs/promises'; import { resolve, isAbsolute } from 'path'; import { execFileAsync } from '@archon/git'; +import { discoverScripts } from './script-discovery'; import type { WorkflowAssistantOptions, IWorkflowPlatform, @@ -23,6 +24,7 @@ import type { CommandNode, PromptNode, LoopNode, + ScriptNode, NodeOutput, TriggerRule, WorkflowRun, @@ -31,7 +33,14 @@ import type { ThinkingConfig, SandboxSettings, } from './schemas'; -import { isBashNode, isLoopNode, isApprovalNode, isCancelNode, isApprovalContext } from './schemas'; +import { + isBashNode, + isLoopNode, + isApprovalNode, + isCancelNode, + isScriptNode, + isApprovalContext, +} from './schemas'; import { formatToolCall } from './utils/tool-formatter'; import { createLogger } from '@archon/paths'; import { getWorkflowEventEmitter } from './event-emitter'; @@ -56,6 +65,7 @@ import { buildPromptWithContext, detectCompletionSignal, stripCompletionTags, + isInlineScript, } from './executor-shared'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ @@ -1284,8 +1294,8 @@ async function executeNodeInternal( } } -/** Default timeout for bash nodes: 2 minutes */ -const BASH_DEFAULT_TIMEOUT = 120_000; +/** Default timeout for subprocess nodes (bash, script): 2 minutes */ +const SUBPROCESS_DEFAULT_TIMEOUT = 120_000; /** * Execute a bash (shell script) DAG node. @@ -1346,7 +1356,7 @@ async function executeBashNode( ); const finalScript = substituteNodeOutputRefs(substitutedScript, nodeOutputs, true); - const timeout = node.timeout ?? BASH_DEFAULT_TIMEOUT; + const timeout = node.timeout ?? SUBPROCESS_DEFAULT_TIMEOUT; try { const { stdout, stderr } = await execFileAsync('bash', ['-c', finalScript], { @@ -1437,6 +1447,221 @@ async function executeBashNode( } } +/** + * Execute a script (TypeScript via bun or Python via uv) DAG node. + * Supports both inline code snippets and named scripts discovered from .archon/scripts/. + * stdout is captured and trimmed as the node output; stderr is logged as a warning. + */ +async function executeScriptNode( + deps: WorkflowDeps, + platform: IWorkflowPlatform, + conversationId: string, + cwd: string, + workflowRun: WorkflowRun, + node: ScriptNode, + artifactsDir: string, + logDir: string, + baseBranch: string, + docsDir: string, + nodeOutputs: Map, + issueContext?: string +): Promise { + const nodeStartTime = Date.now(); + const nodeContext: SendMessageContext = { workflowId: workflowRun.id, nodeName: node.id }; + + getLog().info({ nodeId: node.id, type: 'script', runtime: node.runtime }, 'dag_node_started'); + await logNodeStart(logDir, workflowRun.id, node.id, '