diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 4f90f70978..1844336f2f 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -64,9 +64,15 @@ if [ -f scripts/build-binaries.sh ] && [ -f packages/cli/src/cli.ts ]; then packages/cli/src/cli.ts # Smoke test: the binary must start and exit 0 on a safe, non-interactive command. - # `version` or `--help` are both acceptable — pick one that does NOT touch the - # network, database, or require env vars. - if ! "$TMP_BINARY" version > /tmp/archon-preflight.log 2>&1; then + # Use `--help` (NOT `version`). The `version` command's compiled-binary code + # path depends on BUNDLED_IS_BINARY=true, which is set by scripts/build-binaries.sh + # — but we're doing a bare `bun build --compile` here to keep the smoke fast, + # so BUNDLED_IS_BINARY is still `false`. That sends `version` down the dev + # branch of version.ts which tries to read package.json from a path that only + # exists in node_modules, producing a false-positive ENOENT. `--help` has no + # such dev/binary branch and exercises the same module-init graph we're + # actually testing. Must NOT touch network, database, or require env vars. + if ! "$TMP_BINARY" --help > /tmp/archon-preflight.log 2>&1; then echo "ERROR: compiled binary crashed at startup" cat /tmp/archon-preflight.log echo "" diff --git a/.claude/skills/test-release/SKILL.md b/.claude/skills/test-release/SKILL.md index 31029014ea..c93d0c5bee 100644 --- a/.claude/skills/test-release/SKILL.md +++ b/.claude/skills/test-release/SKILL.md @@ -79,6 +79,8 @@ About to test: Path: brew (Homebrew tap on macOS) Version: 0.3.1 (expected) Cleanup: will uninstall after tests (brew uninstall + untap) + If `archon-stable` symlink is detected in Phase 2, it will be + restored at the end of Phase 5 by reinstalling the tap formula. Proceed? (y/N) ``` @@ -112,6 +114,18 @@ gh release view v --repo coleam00/Archon --json tagName,assets --jq '{t If the release does not exist or has no assets, abort with a clear message. Do not proceed to install a non-existent release. +4. **Detect persistent `archon-stable` install (brew path only).** If the user has renamed a prior brew install to `archon-stable` (the dual-homebrew pattern — see `~/.config/fish/functions/brew-upgrade-archon.fish`), Phase 5's `brew uninstall` will wipe it. Capture the state so Phase 5b can restore it: + +```bash +ARCHON_STABLE_WAS_INSTALLED="" +if [ -L /opt/homebrew/bin/archon-stable ] || [ -L /usr/local/bin/archon-stable ]; then + ARCHON_STABLE_WAS_INSTALLED="yes" + echo "Detected persistent archon-stable — will restore after Phase 5 uninstall." +fi +``` + +Export `ARCHON_STABLE_WAS_INSTALLED` into the environment used by Phase 5b. Only applies to the `brew` path — `curl-mac` and `curl-vps` don't go through brew and don't disturb `archon-stable`. + ## Phase 3 — Install ### Path: brew @@ -352,6 +366,25 @@ archon version | head -1 # should match the dev version captured in Phase 2 ``` +**Restore `archon-stable` if it existed before the test** (dual-homebrew pattern — see Phase 2 item 4): + +```bash +if [ -n "$ARCHON_STABLE_WAS_INSTALLED" ]; then + echo "Restoring archon-stable (detected before test)..." + brew tap coleam00/archon + brew install coleam00/archon/archon + BREW_BIN="$(brew --prefix)/bin" + if [ -e "$BREW_BIN/archon" ]; then + mv "$BREW_BIN/archon" "$BREW_BIN/archon-stable" + echo "archon-stable restored: $(archon-stable version 2>/dev/null | head -1)" + else + echo "WARNING: brew install succeeded but $BREW_BIN/archon missing — check formula" + fi +fi +``` + +> **Note on the restored version**: this reinstalls from whatever the tap currently ships, which is typically the release you just tested (so `archon-stable` ends up at the newly-tested version). That's usually what the operator wants — you just verified the new release works, and you want `archon-stable` pointed at it. If you were testing an older version for back-version QA, the restored `archon-stable` will be the *current* tap formula, not the pre-test version. For that rare case, the operator should re-run `brew-upgrade-archon` manually after the test. + ### Path: curl-mac ```bash diff --git a/homebrew/archon.rb b/homebrew/archon.rb index 0bac58a339..d8f4c45c18 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.6" + version "0.3.9" license "MIT" on_macos do on_arm do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-arm64" - sha256 "96b6dac50b046eece9eddbb988a0c39b4f9a0e2faac66e49b977ba6360069e86" + sha256 "b617f85a2181938b793b25ad816a9f6b3149d184f64b2e9e2ea2430f27778d64" end on_intel do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-x64" - sha256 "09f1dbe12417b4300b7b07b531eb7391a286305f8d4eafc11e7f61f5d26eb8eb" + sha256 "5a928af5e0e67ffe084159161a9ea3994a9304cc39bd06132719cd89cc715e86" end end on_linux do on_arm do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-arm64" - sha256 "80b06a6ff699ec57cd4a3e49cfe7b899a3e8212688d70285f5a887bf10086731" + sha256 "567bfca9175e10d9b4fd748e3862bbd34141a234766a7ecf0a714d9c27b8c92e" end on_intel do url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-x64" - sha256 "09f5dac6db8037ed6f3e5b7e9c5eb8e37f19822a4ed2bf4cd7e654780f9d00de" + sha256 "c918218df2f0f853d107e6b1727dcd9accc034b183ffbccea93a331d8d376ed8" end end diff --git a/packages/providers/src/claude/binary-resolver.test.ts b/packages/providers/src/claude/binary-resolver.test.ts index f87e78f36d..4c56ba1214 100644 --- a/packages/providers/src/claude/binary-resolver.test.ts +++ b/packages/providers/src/claude/binary-resolver.test.ts @@ -76,7 +76,52 @@ describe('resolveClaudeBinaryPath (binary mode)', () => { expect(result).toBe('/env/cli.js'); }); - test('throws with install instructions when nothing configured', async () => { + test('autodetects native installer path when env and config are unset', async () => { + const home = process.env.HOME ?? '/Users/test'; + const expected = + process.platform === 'win32' + ? `${home}\\.local\\bin\\claude.exe` + : `${home}/.local/bin/claude`; + // File exists only at the native-installer path. + fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation( + (path: string) => path === expected + ); + + const result = await resolver.resolveClaudeBinaryPath(); + expect(result).toBe(expected); + // Log must mark this as autodetect, not 'env' or 'config' — the source + // string is load-bearing for debug triage. + expect(mockLogger.info).toHaveBeenCalledWith( + { binaryPath: expected, source: 'autodetect' }, + 'claude.binary_resolved' + ); + }); + + test('env var takes precedence over autodetect when both would match', async () => { + process.env.CLAUDE_BIN_PATH = '/custom/env/claude'; + fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true); + + const result = await resolver.resolveClaudeBinaryPath(); + expect(result).toBe('/custom/env/claude'); + expect(mockLogger.info).toHaveBeenCalledWith( + { binaryPath: '/custom/env/claude', source: 'env' }, + 'claude.binary_resolved' + ); + }); + + test('config takes precedence over autodetect when both would match', async () => { + fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true); + + const result = await resolver.resolveClaudeBinaryPath('/custom/config/claude'); + expect(result).toBe('/custom/config/claude'); + expect(mockLogger.info).toHaveBeenCalledWith( + { binaryPath: '/custom/config/claude', source: 'config' }, + 'claude.binary_resolved' + ); + }); + + test('throws with install instructions when nothing is configured and autodetect misses', async () => { + // Every probe returns false — env unset, config unset, native path absent. fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false); const promise = resolver.resolveClaudeBinaryPath(); diff --git a/packages/providers/src/claude/binary-resolver.ts b/packages/providers/src/claude/binary-resolver.ts index f236acb277..c2273d85d2 100644 --- a/packages/providers/src/claude/binary-resolver.ts +++ b/packages/providers/src/claude/binary-resolver.ts @@ -9,13 +9,16 @@ * Resolution order (binary mode only): * 1. `CLAUDE_BIN_PATH` environment variable * 2. `assistants.claude.claudeBinaryPath` in config - * 3. Throw with install instructions + * 3. Autodetect canonical install path (native installer default) + * 4. Throw with install instructions * * In dev mode (BUNDLED_IS_BINARY=false), returns undefined so the caller * omits `pathToClaudeCodeExecutable` entirely and the SDK resolves via its * normal node_modules lookup. */ import { existsSync as _existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { BUNDLED_IS_BINARY, createLogger } from '@archon/paths'; /** Wrapper for existsSync — enables spyOn in tests (direct imports can't be spied on). */ @@ -89,6 +92,25 @@ export async function resolveClaudeBinaryPath( return configClaudeBinaryPath; } - // 3. Not found — throw with install instructions + // 3. Autodetect — the Anthropic native installer + // (`curl -fsSL https://claude.ai/install.sh | bash` on macOS/Linux, + // `irm https://claude.ai/install.ps1 | iex` on Windows) writes the + // executable to a fixed location relative to $HOME. Users who follow + // the recommended install path don't need any env var or config entry; + // users who deviate (npm global, custom path, etc.) still set one of + // the higher-priority sources above. + const nativeInstallerPath = + process.platform === 'win32' + ? join(homedir(), '.local', 'bin', 'claude.exe') + : join(homedir(), '.local', 'bin', 'claude'); + if (fileExists(nativeInstallerPath)) { + getLog().info( + { binaryPath: nativeInstallerPath, source: 'autodetect' }, + 'claude.binary_resolved' + ); + return nativeInstallerPath; + } + + // 4. Not found — throw with install instructions throw new Error(INSTALL_INSTRUCTIONS); } diff --git a/packages/providers/src/codex/binary-resolver.test.ts b/packages/providers/src/codex/binary-resolver.test.ts index 1df4e7c6f6..a121e4c204 100644 --- a/packages/providers/src/codex/binary-resolver.test.ts +++ b/packages/providers/src/codex/binary-resolver.test.ts @@ -87,7 +87,70 @@ describe('resolveCodexBinaryPath (binary mode)', () => { expect(normalized).toContain('/tmp/test-archon-home/vendor/codex/'); }); + test('autodetects npm global install at ~/.npm-global/bin/codex (POSIX)', async () => { + if (process.platform === 'win32') return; // POSIX-only probe + const home = process.env.HOME ?? '/Users/test'; + const expected = `${home}/.npm-global/bin/codex`; + fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation( + (path: string) => path === expected + ); + + const result = await resolver.resolveCodexBinaryPath(); + expect(result).toBe(expected); + expect(mockLogger.info).toHaveBeenCalledWith( + { binaryPath: expected, source: 'autodetect' }, + 'codex.binary_resolved' + ); + }); + + test('autodetects homebrew install on Apple Silicon', async () => { + if (process.platform !== 'darwin' || process.arch !== 'arm64') { + // `/opt/homebrew/bin/codex` is only probed on darwin-arm64; on other + // hosts this test has nothing to assert (the probe list excludes it). + return; + } + fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation( + (path: string) => path === '/opt/homebrew/bin/codex' + ); + + const result = await resolver.resolveCodexBinaryPath(); + expect(result).toBe('/opt/homebrew/bin/codex'); + expect(mockLogger.info).toHaveBeenCalledWith( + { binaryPath: '/opt/homebrew/bin/codex', source: 'autodetect' }, + 'codex.binary_resolved' + ); + }); + + test('autodetects system install at /usr/local/bin/codex', async () => { + if (process.platform === 'win32') { + // /usr/local/bin is not probed on Windows. + return; + } + fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation( + (path: string) => path === '/usr/local/bin/codex' + ); + + const result = await resolver.resolveCodexBinaryPath(); + expect(result).toBe('/usr/local/bin/codex'); + }); + + test('vendor directory takes precedence over autodetect', async () => { + // Both vendor and npm-global would match; vendor must win (lower tier #). + fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation((path: string) => { + const normalized = path.replace(/\\/g, '/'); + return normalized.includes('vendor/codex') || normalized.includes('.npm-global'); + }); + + const result = await resolver.resolveCodexBinaryPath(); + expect(result!.replace(/\\/g, '/')).toContain('/vendor/codex/'); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ source: 'vendor' }), + 'codex.binary_resolved' + ); + }); + test('throws with install instructions when binary not found anywhere', async () => { + // Env unset, config unset, vendor dir empty, every autodetect path missing. fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false); await expect(resolver.resolveCodexBinaryPath()).rejects.toThrow('Codex CLI binary not found'); diff --git a/packages/providers/src/codex/binary-resolver.ts b/packages/providers/src/codex/binary-resolver.ts index a1e0f01a5b..1ac8e57cfb 100644 --- a/packages/providers/src/codex/binary-resolver.ts +++ b/packages/providers/src/codex/binary-resolver.ts @@ -9,12 +9,14 @@ * 1. `CODEX_BIN_PATH` environment variable * 2. `assistants.codex.codexBinaryPath` in config * 3. `~/.archon/vendor/codex/` (user-placed) - * 4. Throw with install instructions + * 4. Autodetect canonical install paths (npm prefix defaults per platform) + * 5. 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 { homedir } from 'node:os'; import { join } from 'node:path'; import { BUNDLED_IS_BINARY, getArchonHome, createLogger } from '@archon/paths'; @@ -89,7 +91,19 @@ export async function resolveCodexBinaryPath( } } - // 4. Not found — throw with install instructions + // 4. Autodetect — probe the handful of paths Codex typically lands at + // when installed via the documented package managers. Users who install + // somewhere else (custom npm prefix, etc.) still set one of the higher- + // priority sources above. Order: most specific → least specific. + const autodetectPaths = getAutodetectPaths(); + for (const probePath of autodetectPaths) { + if (fileExists(probePath)) { + getLog().info({ binaryPath: probePath, source: 'autodetect' }, 'codex.binary_resolved'); + return probePath; + } + } + + // 5. 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' + @@ -105,3 +119,47 @@ export async function resolveCodexBinaryPath( ' codexBinaryPath: /path/to/codex\n' ); } + +/** + * Canonical install locations probed by tier 4 autodetect. Grounded in + * the official @openai/codex README and the npm global-install contract + * (npm writes the binary to `{npm_prefix}/bin/` on POSIX and + * `{npm_prefix}\.cmd` on Windows). The probes cover the npm prefix + * a default install lands at on each platform: + * + * - `$HOME/.npm-global/bin/codex` — common when the user ran + * `npm config set prefix ~/.npm-global` to avoid root writes + * - `/opt/homebrew/bin/codex` — mac Apple Silicon with homebrew-node + * (homebrew sets npm prefix to /opt/homebrew) + * - `/usr/local/bin/codex` — mac Intel with homebrew-node, or linux + * with system-installed node (npm prefix defaults to /usr/local) + * - `%AppData%\npm\codex.cmd` — Windows npm global default + * + * Not covered (explicit override required via CODEX_BIN_PATH or config): + * - users with other custom npm prefixes — `npm root -g` would spawn + * a subprocess per resolve, too heavy for a probe helper + * - Homebrew cask install (`brew install --cask codex`) — cask layout + * isn't a PATH binary; users should symlink or set the path + * - manual GitHub Releases extract — placement is user-determined + */ +function getAutodetectPaths(): string[] { + const paths: string[] = []; + + if (process.platform === 'win32') { + const appData = process.env.APPDATA; + if (appData) paths.push(join(appData, 'npm', 'codex.cmd')); + paths.push(join(homedir(), '.npm-global', 'codex.cmd')); + return paths; + } + + // POSIX (macOS + Linux) + paths.push(join(homedir(), '.npm-global', 'bin', 'codex')); + + if (process.platform === 'darwin' && process.arch === 'arm64') { + paths.push('/opt/homebrew/bin/codex'); + } + + paths.push('/usr/local/bin/codex'); + + return paths; +} diff --git a/packages/providers/src/community/pi/provider.test.ts b/packages/providers/src/community/pi/provider.test.ts index 17e6de417d..40ffcec80f 100644 --- a/packages/providers/src/community/pi/provider.test.ts +++ b/packages/providers/src/community/pi/provider.test.ts @@ -209,6 +209,21 @@ describe('PiProvider', () => { expect(new PiProvider().getCapabilities()).toEqual(PI_CAPABILITIES); }); + test('sendQuery installs PI_PACKAGE_DIR shim before Pi SDK loads', async () => { + // Runtime-safety regression: Pi's config.js reads `getPackageJsonPath()` at + // its module init, which resolves to a non-existent path inside compiled + // archon binaries. The shim writes a stub package.json to tmpdir and sets + // PI_PACKAGE_DIR so Pi's short-circuit kicks in. Must run BEFORE the + // dynamic imports in sendQuery — we verify by calling the fast-fail "no + // model" path (which returns before any Pi SDK logic executes) and + // asserting the env var was set regardless. + delete process.env.PI_PACKAGE_DIR; + expect(process.env.PI_PACKAGE_DIR).toBeUndefined(); + await consume(new PiProvider().sendQuery('hi', '/tmp')); + expect(process.env.PI_PACKAGE_DIR).toBeDefined(); + expect(process.env.PI_PACKAGE_DIR).toContain('archon-pi-shim'); + }); + test('throws when no model is configured', async () => { const { error } = await consume(new PiProvider().sendQuery('hi', '/tmp')); expect(error?.message).toContain('Pi provider requires a model'); diff --git a/packages/providers/src/community/pi/provider.ts b/packages/providers/src/community/pi/provider.ts index e4b6804762..610bcd56ab 100644 --- a/packages/providers/src/community/pi/provider.ts +++ b/packages/providers/src/community/pi/provider.ts @@ -1,3 +1,7 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { createLogger } from '@archon/paths'; import type { Api, Model } from '@mariozechner/pi-ai'; @@ -24,6 +28,44 @@ import { parsePiModelRef } from './model-ref'; // All Pi SDK value bindings and Pi-dependent helper modules are dynamically // imported inside `sendQuery()` below, which runs only when a Pi workflow is // actually invoked. Type-only imports above are fine — TS erases them. +// +// Lazy-loading defers the crash from boot-time to sendQuery-time — but the +// crash still happens when Pi is actually used. `ensurePiPackageDirShim()` +// (see below) fixes the *runtime* half: before any dynamic Pi import in +// sendQuery, write a stub package.json to tmpdir and point Pi at it via +// its own documented `PI_PACKAGE_DIR` escape hatch. + +/** + * Write a minimal package.json to a stable tmpdir and set `PI_PACKAGE_DIR` + * so Pi's `config.js` short-circuits its `dirname(process.execPath)` walk + * (which fails inside a compiled archon binary). Pi only reads three + * optional fields from that package.json — `piConfig.name`, `piConfig.configDir`, + * and `version` — so the stub is genuinely minimal. Idempotent: the file is + * only written once per host (existsSync check), and the env var is set on + * every call so multiple PiProvider instances stay consistent. + * + * Done on each sendQuery rather than at module load so (a) the file write + * is paid only when Pi is actually used, and (b) the env var can't get + * clobbered between registration and invocation. + */ +function ensurePiPackageDirShim(): void { + const shimDir = join(tmpdir(), 'archon-pi-shim'); + const shimPkgJson = join(shimDir, 'package.json'); + if (!existsSync(shimPkgJson)) { + mkdirSync(shimDir, { recursive: true }); + // `piConfig: {}` is explicit so Pi's defaults (`name: 'pi'`, + // `configDir: '.pi'`) kick in — matches Pi's standalone behavior. + writeFileSync( + shimPkgJson, + JSON.stringify({ + name: 'archon-pi-shim', + version: '0.0.0', + piConfig: {}, + }) + ); + } + process.env.PI_PACKAGE_DIR = shimDir; +} /** * Map Pi provider id → env var name used by pi-ai's getEnvApiKey(). @@ -115,6 +157,13 @@ export class PiProvider implements IAgentProvider { resumeSessionId?: string, requestOptions?: SendQueryOptions ): AsyncGenerator { + // Install the PI_PACKAGE_DIR shim BEFORE the dynamic imports below: Pi's + // config.js runs `readFileSync(getPackageJsonPath())` at its own module + // init, and getPackageJsonPath() checks process.env.PI_PACKAGE_DIR first. + // Without this, the dynamic import below would crash with ENOENT on + // `dirname(process.execPath)/package.json` inside a compiled binary. + ensurePiPackageDirShim(); + // Lazy-load Pi SDK and all Pi-dependent helper modules here. Must not move // these imports to module scope — see the header comment for the failure // mode (archon compiled binary crashes at startup when Pi's config.js diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index 928a8f35cd..56cd736d95 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -6,7 +6,7 @@ import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; import { streamSSE } from 'hono/streaming'; import { cors } from 'hono/cors'; import type { WebAdapter } from '../adapters/web'; -import { rm, readFile, writeFile, unlink, mkdir } from 'fs/promises'; +import { rm, readFile, writeFile, unlink, mkdir, readdir, stat } from 'fs/promises'; import { readFileSync } from 'fs'; import { normalize, join, sep, basename } from 'path'; import { randomUUID } from 'crypto'; @@ -37,6 +37,7 @@ import { getDefaultWorkflowsPath, getArchonWorkspacesPath, getHomeCommandsPath, + getHomeWorkflowsPath, getRunArtifactsPath, getArchonHome, isDocker, @@ -2260,7 +2261,53 @@ export function registerApiRoutes( } } - // 2. Fall back to bundled defaults (binary: embedded map; dev: also check filesystem) + // 2. Try home-scoped global workflow (~/.archon/workflows/.yaml), + // mirroring discovery's 1-level subfolder support (e.g. ~/.archon/workflows/group/foo.yaml). + const globalBase = getHomeWorkflowsPath(); + const globalCandidates: string[] = [join(globalBase, filename)]; + try { + const entries = await readdir(globalBase); + for (const entry of entries) { + const entryPath = join(globalBase, entry); + try { + const entryStat = await stat(entryPath); + if (entryStat.isDirectory()) globalCandidates.push(join(entryPath, filename)); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + getLog().error({ err, entryPath, name }, 'workflow.global_entry_stat_failed'); + return apiError(c, 500, 'Failed to inspect global workflow directory'); + } + // Entry disappeared between readdir and stat — safe to skip. + } + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + getLog().error({ err, globalBase, name }, 'workflow.global_dir_list_failed'); + return apiError(c, 500, 'Failed to list global workflows'); + } + // global dir doesn't exist — globalCandidates stays as [direct path] + } + for (const globalFilePath of globalCandidates) { + try { + const content = await readFile(globalFilePath, 'utf-8'); + const result = parseWorkflow(content, filename); + if (result.error) { + return apiError(c, 500, `Global workflow file is invalid: ${result.error.error}`); + } + return c.json({ + workflow: result.workflow, + filename, + source: 'global' as WorkflowSource, + }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + getLog().error({ err, name }, 'workflow.fetch_global_failed'); + return apiError(c, 500, 'Failed to read global workflow'); + } + } + } + + // 3. Fall back to bundled defaults (binary: embedded map; dev: also check filesystem) if (Object.hasOwn(BUNDLED_WORKFLOWS, name)) { const bundledContent = BUNDLED_WORKFLOWS[name]; const result = parseWorkflow(bundledContent, filename); diff --git a/packages/server/src/routes/api.workflows.test.ts b/packages/server/src/routes/api.workflows.test.ts index e50b252640..c1c5d04425 100644 --- a/packages/server/src/routes/api.workflows.test.ts +++ b/packages/server/src/routes/api.workflows.test.ts @@ -253,6 +253,141 @@ describe('GET /api/workflows/:name', () => { } }); + test('returns global workflow with source:global when file exists in ~/.archon/workflows/', async () => { + const previousArchonHome = process.env.ARCHON_HOME; + const testArchonHome = join(tmpdir(), `archon-home-global-get-${Date.now()}`); + const globalWorkflowDir = join(testArchonHome, 'workflows'); + await mkdir(globalWorkflowDir, { recursive: true }); + await writeFile( + join(globalWorkflowDir, 'global-custom.yaml'), + 'name: global-custom\ndescription: Global workflow\nnodes:\n - id: run\n command: assist\n' + ); + process.env.ARCHON_HOME = testArchonHome; + + try { + const app = createTestApp(); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + + mockListCodebases.mockImplementationOnce(async () => []); + const response = await app.request('/api/workflows/global-custom'); + expect(response.status).toBe(200); + const body = (await response.json()) as { + source: string; + filename: string; + workflow: unknown; + }; + expect(body.source).toBe('global'); + expect(body.filename).toBe('global-custom.yaml'); + expect(body.workflow).toBeDefined(); + } finally { + if (previousArchonHome !== undefined) { + process.env.ARCHON_HOME = previousArchonHome; + } else { + delete process.env.ARCHON_HOME; + } + await rm(testArchonHome, { recursive: true, force: true }); + } + }); + + test('returns global workflow from one-level subfolder (e.g. ~/.archon/workflows/group/foo.yaml)', async () => { + const previousArchonHome = process.env.ARCHON_HOME; + const testArchonHome = join(tmpdir(), `archon-home-global-sub-${Date.now()}`); + const globalSubDir = join(testArchonHome, 'workflows', 'group'); + await mkdir(globalSubDir, { recursive: true }); + await writeFile( + join(globalSubDir, 'sub-workflow.yaml'), + 'name: sub-workflow\ndescription: Subfolder workflow\nnodes:\n - id: run\n command: assist\n' + ); + process.env.ARCHON_HOME = testArchonHome; + + try { + const app = createTestApp(); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + + mockListCodebases.mockImplementationOnce(async () => []); + const response = await app.request('/api/workflows/sub-workflow'); + expect(response.status).toBe(200); + const body = (await response.json()) as { source: string; filename: string }; + expect(body.source).toBe('global'); + expect(body.filename).toBe('sub-workflow.yaml'); + } finally { + if (previousArchonHome !== undefined) { + process.env.ARCHON_HOME = previousArchonHome; + } else { + delete process.env.ARCHON_HOME; + } + await rm(testArchonHome, { recursive: true, force: true }); + } + }); + + test('global workflow takes priority over bundled defaults', async () => { + const previousArchonHome = process.env.ARCHON_HOME; + const testArchonHome = join(tmpdir(), `archon-home-priority-${Date.now()}`); + const globalWorkflowDir = join(testArchonHome, 'workflows'); + await mkdir(globalWorkflowDir, { recursive: true }); + await writeFile( + join(globalWorkflowDir, 'archon-assist.yaml'), + 'name: archon-assist\ndescription: Overridden globally\nnodes:\n - id: run\n command: assist\n' + ); + process.env.ARCHON_HOME = testArchonHome; + + try { + const app = createTestApp(); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + + mockListCodebases.mockImplementationOnce(async () => []); + const response = await app.request('/api/workflows/archon-assist'); + expect(response.status).toBe(200); + const body = (await response.json()) as { source: string }; + expect(body.source).toBe('global'); + } finally { + if (previousArchonHome !== undefined) { + process.env.ARCHON_HOME = previousArchonHome; + } else { + delete process.env.ARCHON_HOME; + } + await rm(testArchonHome, { recursive: true, force: true }); + } + }); + + test('project workflow takes priority over global workflow', async () => { + const previousArchonHome = process.env.ARCHON_HOME; + const testArchonHome = join(tmpdir(), `archon-home-proj-priority-${Date.now()}`); + const globalWorkflowDir = join(testArchonHome, 'workflows'); + const testProjectDir = join(tmpdir(), `wf-proj-priority-${Date.now()}`); + const projectWorkflowDir = join(testProjectDir, '.archon', 'workflows'); + await mkdir(globalWorkflowDir, { recursive: true }); + await mkdir(projectWorkflowDir, { recursive: true }); + await writeFile( + join(globalWorkflowDir, 'shared.yaml'), + 'name: shared\ndescription: Global version\nnodes:\n - id: run\n command: assist\n' + ); + await writeFile( + join(projectWorkflowDir, 'shared.yaml'), + 'name: shared\ndescription: Project version\nnodes:\n - id: run\n command: assist\n' + ); + process.env.ARCHON_HOME = testArchonHome; + + try { + const app = createTestApp(); + registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager); + + mockListCodebases.mockImplementationOnce(async () => [{ default_cwd: testProjectDir }]); + const response = await app.request(`/api/workflows/shared?cwd=${testProjectDir}`); + expect(response.status).toBe(200); + const body = (await response.json()) as { source: string }; + expect(body.source).toBe('project'); + } finally { + if (previousArchonHome !== undefined) { + process.env.ARCHON_HOME = previousArchonHome; + } else { + delete process.env.ARCHON_HOME; + } + await rm(testArchonHome, { recursive: true, force: true }); + await rm(testProjectDir, { recursive: true, force: true }); + } + }); + test('returns WorkflowDefinition shape with expected top-level fields', async () => { const app = createTestApp(); registerApiRoutes(app, {} as WebAdapter, {} as ConversationLockManager);