diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index 89dd5911e4..c6a08bffab 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -10,7 +10,7 @@ import { } from '@archon/core'; import { WORKFLOW_EVENT_TYPES, type WorkflowEventType } from '@archon/workflows/store'; import { configureIsolation, getIsolationProvider } from '@archon/isolation'; -import { createLogger, getArchonHome } from '@archon/paths'; +import { createLogger, getArchonHome, getProjectRoot } from '@archon/paths'; import { createWorkflowDeps } from '@archon/core/workflows/store-adapter'; import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery'; import { resolveWorkflowName } from '@archon/workflows/router'; @@ -115,14 +115,30 @@ function renderWorkflowEvent(event: WorkflowEmitterEvent, verbose: boolean): voi } } +/** + * Compute the per-project workspace search path (~/.archon/workspaces//) + * for a cwd, or undefined when the cwd has no recognizable origin remote. + * Silently skips the workspace tier in that case — two-tier behavior preserved. + * + * The returned path is the PROJECT ROOT, not the `.archon` subdir — matching + * how `globalSearchPath` works. Callers append `.archon/{workflows,commands,scripts}`. + */ +async function resolveWorkspaceSearchPath(cwd: string): Promise { + const ownerRepo = await git.parseOwnerRepoFromGitRemote(cwd); + if (!ownerRepo) return undefined; + return getProjectRoot(ownerRepo.owner, ownerRepo.repo); +} + /** * Load workflows from cwd with standardized error handling. * Returns the WorkflowLoadResult with both workflows and errors. */ async function loadWorkflows(cwd: string): Promise { try { + const workspaceSearchPath = await resolveWorkspaceSearchPath(cwd); return await discoverWorkflowsWithConfig(cwd, loadConfig, { globalSearchPath: getArchonHome(), + ...(workspaceSearchPath ? { workspaceSearchPath } : {}), }); } catch (error) { const err = error as Error; diff --git a/packages/core/src/handlers/clone.ts b/packages/core/src/handlers/clone.ts index 3dc96f499c..6ccfdf269a 100644 --- a/packages/core/src/handlers/clone.ts +++ b/packages/core/src/handlers/clone.ts @@ -6,7 +6,7 @@ import { access, rm } from 'fs/promises'; import { join, basename, resolve } from 'path'; import * as codebaseDb from '../db/codebases'; import { sanitizeError } from '../utils/credential-sanitizer'; -import { execFileAsync } from '@archon/git'; +import { execFileAsync, parseOwnerRepoFromRemoteUrl } from '@archon/git'; import { expandTilde, getCommandFolderSearchPaths, @@ -385,21 +385,14 @@ export async function registerRepository( // Extract repo name from directory name const repoName = basename(localPath); - // Try to build owner/repo name from remote URL + // Try to build owner/repo name from remote URL via the shared parser let name = repoName; let ownerName = '_local'; if (remoteUrl) { - const cleaned = remoteUrl.replace(/\.git$/, '').replace(/\/+$/, ''); - let workingRemote = cleaned; - if (cleaned.startsWith('git@github.com:')) { - workingRemote = cleaned.replace('git@github.com:', 'https://github.com/'); - } - const parts = workingRemote.split('/'); - const r = parts.pop(); - const o = parts.pop(); - if (o && r) { - name = `${o}/${r}`; - ownerName = o; + const parsed = parseOwnerRepoFromRemoteUrl(remoteUrl); + if (parsed) { + name = `${parsed.owner}/${parsed.repo}`; + ownerName = parsed.owner; } } diff --git a/packages/git/src/git.test.ts b/packages/git/src/git.test.ts index 9c3287b04b..f7b7abfad8 100644 --- a/packages/git/src/git.test.ts +++ b/packages/git/src/git.test.ts @@ -1894,4 +1894,150 @@ branch refs/heads/feature/auth ); }); }); + + describe('parseOwnerRepoFromRemoteUrl (pure)', () => { + test('parses HTTPS GitHub URL', () => { + expect(git.parseOwnerRepoFromRemoteUrl('https://github.com/owner/repo')).toEqual({ + owner: 'owner', + repo: 'repo', + }); + }); + + test('parses HTTPS GitHub URL with trailing .git', () => { + expect(git.parseOwnerRepoFromRemoteUrl('https://github.com/owner/repo.git')).toEqual({ + owner: 'owner', + repo: 'repo', + }); + }); + + test('parses SSH GitHub URL', () => { + expect(git.parseOwnerRepoFromRemoteUrl('git@github.com:owner/repo.git')).toEqual({ + owner: 'owner', + repo: 'repo', + }); + }); + + test('parses SSH URL from non-github host', () => { + expect(git.parseOwnerRepoFromRemoteUrl('git@gitlab.example.com:team/project.git')).toEqual({ + owner: 'team', + repo: 'project', + }); + }); + + test('parses ssh:// scheme', () => { + expect(git.parseOwnerRepoFromRemoteUrl('ssh://git@github.com/owner/repo.git')).toEqual({ + owner: 'owner', + repo: 'repo', + }); + }); + + test('strips trailing slashes', () => { + expect(git.parseOwnerRepoFromRemoteUrl('https://github.com/owner/repo/')).toEqual({ + owner: 'owner', + repo: 'repo', + }); + }); + + test('returns subgroup/repo for GitLab subgroups (two-level slug)', () => { + // GitLab allows nested groups. Our workspace layout is two-level, so we + // take the last two segments. This is a deliberate simplification. + expect(git.parseOwnerRepoFromRemoteUrl('https://gitlab.com/group/subgroup/repo.git')).toEqual( + { owner: 'subgroup', repo: 'repo' } + ); + }); + + test('returns null for empty string', () => { + expect(git.parseOwnerRepoFromRemoteUrl('')).toBeNull(); + expect(git.parseOwnerRepoFromRemoteUrl(' ')).toBeNull(); + }); + + test('returns null for single-segment input', () => { + expect(git.parseOwnerRepoFromRemoteUrl('repo')).toBeNull(); + }); + + // ─── Path-traversal safety (defense in depth) ───────────────────────── + // These cases simulate what a crafted `git remote set-url origin ...` + // could inject. The parser must reject any owner/repo segment that + // could steer downstream path construction outside ~/.archon/workspaces/. + + test('rejects .. as owner segment', () => { + expect(git.parseOwnerRepoFromRemoteUrl('git@host:../evil.git')).toBeNull(); + }); + + test('rejects .. as repo segment', () => { + // `git@host:owner/..` normalizes to `https://__ssh__/owner/..`, the split + // produces owner='owner' repo='..' — must be rejected. + expect(git.parseOwnerRepoFromRemoteUrl('git@host:owner/..')).toBeNull(); + }); + + test('rejects . (single-dot) segments', () => { + expect(git.parseOwnerRepoFromRemoteUrl('git@host:./repo')).toBeNull(); + expect(git.parseOwnerRepoFromRemoteUrl('git@host:owner/.')).toBeNull(); + }); + + test('rejects segments containing backslash', () => { + // An attacker-controlled remote URL could embed a backslash which on + // Windows or weird normalization paths is a separator. + expect(git.parseOwnerRepoFromRemoteUrl('git@host:ow\\ner/repo')).toBeNull(); + }); + + test('rejects segments containing whitespace', () => { + expect(git.parseOwnerRepoFromRemoteUrl('git@host:own er/repo')).toBeNull(); + expect(git.parseOwnerRepoFromRemoteUrl('git@host:owner/re\tpo')).toBeNull(); + }); + + test('rejects segments containing shell metacharacters', () => { + expect(git.parseOwnerRepoFromRemoteUrl('git@host:owner/re;po')).toBeNull(); + expect(git.parseOwnerRepoFromRemoteUrl('git@host:owner/re$po')).toBeNull(); + expect(git.parseOwnerRepoFromRemoteUrl('git@host:owner/re|po')).toBeNull(); + }); + + test('rejects segments containing null bytes', () => { + expect(git.parseOwnerRepoFromRemoteUrl('git@host:owner/re\x00po')).toBeNull(); + }); + + test('accepts common legitimate slug characters', () => { + expect(git.parseOwnerRepoFromRemoteUrl('git@host:my-org/my_repo.js.git')).toEqual({ + owner: 'my-org', + repo: 'my_repo.js', + }); + }); + }); + + describe('parseOwnerRepoFromGitRemote (async)', () => { + test('returns {owner, repo} for a real git repo with https origin', async () => { + const { execFileAsync } = git; + await execFileAsync('git', ['-C', testDir, 'init']); + await execFileAsync('git', [ + '-C', + testDir, + 'remote', + 'add', + 'origin', + 'https://github.com/test-owner/test-repo.git', + ]); + + const result = await git.parseOwnerRepoFromGitRemote(testDir); + expect(result).toEqual({ owner: 'test-owner', repo: 'test-repo' }); + }); + + test('returns null for a git repo with no origin', async () => { + const { execFileAsync } = git; + await execFileAsync('git', ['-C', testDir, 'init']); + // No remote added + const result = await git.parseOwnerRepoFromGitRemote(testDir); + expect(result).toBeNull(); + }); + + test('returns null when cwd is not a git repo', async () => { + const nonRepoDir = join(tmpdir(), `non-repo-${String(Date.now())}`); + await realMkdir(nonRepoDir, { recursive: true }); + try { + const result = await git.parseOwnerRepoFromGitRemote(nonRepoDir); + expect(result).toBeNull(); + } finally { + await rm(nonRepoDir, { recursive: true, force: true }); + } + }); + }); }); diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index 8cfdc865f7..32ac67550e 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -42,6 +42,8 @@ export { export { findRepoRoot, getRemoteUrl, + parseOwnerRepoFromGitRemote, + parseOwnerRepoFromRemoteUrl, syncWorkspace, cloneRepository, syncRepository, diff --git a/packages/git/src/repo.ts b/packages/git/src/repo.ts index 21ae8d3571..f97562eb88 100644 --- a/packages/git/src/repo.ts +++ b/packages/git/src/repo.ts @@ -38,6 +38,107 @@ export async function findRepoRoot(startPath: string): Promise } } +/** + * Safe path segment pattern for owner/repo slugs. Rejects anything that + * could escape `~/.archon/workspaces///` — path separators, + * null bytes, dot segments, and control characters. + * + * Matches the character set GitHub and GitLab both allow in user/org and + * repo names (alphanumerics plus `-`, `_`, `.`). Strings containing only + * dots (`.`, `..`) are explicitly rejected by the additional check in + * `isSafePathSegment` below. + */ +const SAFE_SLUG_PATTERN = /^[A-Za-z0-9._-]+$/; + +/** + * Return true iff `s` is safe to use as a single path segment when + * constructing a `~/.archon/workspaces///` directory path. + * + * Rejects: + * - Empty strings + * - `.` and `..` (directory traversal) + * - Strings containing path separators, control characters, or other + * characters outside the GitHub/GitLab-compatible slug character set + */ +function isSafePathSegment(s: string): boolean { + if (!s) return false; + if (s === '.' || s === '..') return false; + return SAFE_SLUG_PATTERN.test(s); +} + +/** + * Parse a git remote URL into an `{ owner, repo }` slug suitable for use + * under `~/.archon/workspaces/`. Pure function — no I/O, safe to reuse. + * + * Handles common URL shapes: + * - `https://github.com/owner/repo(.git)?` + * - `git@github.com:owner/repo(.git)?` + * - `ssh://git@host/owner/repo(.git)?` + * - `https://gitlab.com/group/subgroup/repo.git` (returns `subgroup/repo`; + * the leading group segment is dropped because the workspace layout is + * two-level: `~/.archon/workspaces///`) + * + * Returns `null` for unparseable URLs **or** for URLs whose owner/repo + * segments contain anything outside the GitHub/GitLab-compatible safe + * slug character set (see `isSafePathSegment`). This prevents a crafted + * remote URL (`git remote set-url origin git@host:../../evil`) from + * steering workspace path construction outside `~/.archon/workspaces/`. + */ +export function parseOwnerRepoFromRemoteUrl(url: string): { owner: string; repo: string } | null { + const trimmed = url.trim(); + if (!trimmed) return null; + + // Strip trailing .git and any trailing slashes + const cleaned = trimmed.replace(/\.git$/, '').replace(/\/+$/, ''); + + // SSH form `git@host:path` → normalize so a single split works + const normalized = + cleaned.startsWith('git@') && cleaned.includes(':') && !cleaned.includes('://') + ? cleaned.replace(/^[^@]+@[^:]+:/, 'https://__ssh__/') + : cleaned; + + const parts = normalized.split('/').filter(Boolean); + const repo = parts.pop(); + const owner = parts.pop(); + if (!owner || !repo) return null; + + // Defense-in-depth: reject any segment that could be used for directory + // traversal or contains unsafe characters. Callers rely on the result + // being usable as literal path components without further sanitization. + if (!isSafePathSegment(owner) || !isSafePathSegment(repo)) return null; + + return { owner, repo }; +} + +/** + * Fetch `origin` for the repo containing `cwd` and parse it into an + * `{ owner, repo }` slug suitable for use under `~/.archon/workspaces/`. + * + * Returns `null` on ANY failure (not a git repo, no origin, unparseable URL, + * git binary missing, permission denied). Callers should treat null as + * "this cwd is not a recognizable project" and fall back silently — no + * throwing, no logging at warn/error level. + * + * Unlike `getRemoteUrl`, this takes a plain string cwd (not a branded + * `RepoPath`) so it is ergonomic to call from CLI entry points and workflow + * execution where the cwd has not yet been type-validated. + */ +export async function parseOwnerRepoFromGitRemote( + cwd: string +): Promise<{ owner: string; repo: string } | null> { + let stdout: string; + try { + const result = await execFileAsync('git', ['-C', cwd, 'remote', 'get-url', 'origin'], { + timeout: 10000, + }); + stdout = result.stdout; + } catch { + // Not a repo, no remote, git missing, etc. — silent null. + return null; + } + return parseOwnerRepoFromRemoteUrl(stdout); +} + /** * Get the remote URL for origin (if it exists) * Returns null if no remote is configured diff --git a/packages/paths/src/archon-paths.ts b/packages/paths/src/archon-paths.ts index ca8ea73774..ad1a9a549e 100644 --- a/packages/paths/src/archon-paths.ts +++ b/packages/paths/src/archon-paths.ts @@ -268,6 +268,20 @@ export function getProjectLogsPath(owner: string, repo: string): string { return join(getProjectRoot(owner, repo), 'logs'); } +/** + * Get the per-project, per-user Archon config directory. + * Returns: ~/.archon/workspaces/owner/repo/.archon/ + * + * This is the third tier in workflow/command/script resolution: + * repo-local (`/.archon/`) > this > user-global (`~/.archon/.archon/`) > defaults. + * + * Lets a user keep per-project automation outside the team repo (which + * doesn't want it committed) without making it apply to every project. + */ +export function getProjectArchonDir(owner: string, repo: string): string { + return join(getProjectRoot(owner, repo), '.archon'); +} + /** * Get the artifacts directory for a specific workflow run. * Returns: ~/.archon/workspaces/owner/repo/artifacts/runs/{id}/ diff --git a/packages/paths/src/index.ts b/packages/paths/src/index.ts index 99a254f4ca..79298cc016 100644 --- a/packages/paths/src/index.ts +++ b/packages/paths/src/index.ts @@ -19,6 +19,7 @@ export { getProjectWorktreesPath, getProjectArtifactsPath, getProjectLogsPath, + getProjectArchonDir, getRunArtifactsPath, getRunLogPath, resolveProjectRootFromCwd, diff --git a/packages/workflows/package.json b/packages/workflows/package.json index 7126c5ffff..9dbf142ee3 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -19,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 && bun test src/script-discovery.test.ts && bun test src/runtime-check.test.ts && bun test src/script-node-deps.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-shared-command-global.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 && bun test src/dag-executor-script-global.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { diff --git a/packages/workflows/src/dag-executor-script-global.test.ts b/packages/workflows/src/dag-executor-script-global.test.ts new file mode 100644 index 0000000000..f465b6575f --- /dev/null +++ b/packages/workflows/src/dag-executor-script-global.test.ts @@ -0,0 +1,434 @@ +/** + * Tests for the user-global script fallback in executeScriptNode. + * + * Isolated in its own test file (and its own bun test invocation — see package.json) + * because it mocks @archon/paths differently than dag-executor.test.ts: + * this file needs getArchonHome to return the test-controlled ARCHON_HOME, + * whereas dag-executor.test.ts points getArchonHome at /nonexistent/archon-home + * to neutralize the fallback. Two files cannot mock.module() the same path + * with different implementations in one batch. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, mock } from 'bun:test'; +import { mkdir, writeFile, rm, mkdtemp } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +// --- Mock logger + @archon/paths (must come before imports under test) --- + +const mockLogFn = mock(() => {}); +const mockLogger = { + info: mockLogFn, + warn: mockLogFn, + error: mockLogFn, + debug: mockLogFn, + trace: mockLogFn, + fatal: mockLogFn, + child: mock(() => mockLogger), +}; +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), + getCommandFolderSearchPaths: (folder?: string): string[] => { + const paths = ['.archon/commands']; + if (folder) paths.unshift(folder); + return paths; + }, + getDefaultCommandsPath: (): string => '/nonexistent/defaults', + getArchonHome: (): string => { + const envHome = process.env.ARCHON_HOME; + if (!envHome) throw new Error('ARCHON_HOME not set in test'); + return envHome; + }, +})); + +// --- Imports (after mocks) --- +import { executeDagWorkflow } from './dag-executor'; +import type { ScriptNode, WorkflowRun } from './schemas'; +import type { WorkflowDeps, IWorkflowPlatform, WorkflowConfig } from './deps'; +import type { IWorkflowStore } from './store'; + +// --- Shared mock factories (duplicated from dag-executor.test.ts intentionally; +// keeping this file self-contained avoids cross-file import coupling) --- + +function createMockStore(): IWorkflowStore { + const dummyRun = { + id: 'mock-run-id', + workflow_name: 'mock', + conversation_id: 'conv-mock', + parent_conversation_id: null, + codebase_id: null, + status: 'running' as const, + user_message: 'mock message', + metadata: {}, + started_at: new Date(), + completed_at: null, + last_activity_at: null, + working_path: null, + }; + return { + createWorkflowRun: mock(() => Promise.resolve(dummyRun)), + getWorkflowRun: mock(() => Promise.resolve(null)), + getActiveWorkflowRunByPath: mock(() => Promise.resolve(null)), + failOrphanedRuns: mock(() => Promise.resolve({ count: 0 })), + findResumableRun: mock(() => Promise.resolve(null)), + resumeWorkflowRun: mock(() => Promise.resolve(dummyRun)), + updateWorkflowRun: mock(() => Promise.resolve()), + updateWorkflowActivity: mock(() => Promise.resolve()), + getWorkflowRunStatus: mock(() => Promise.resolve('running' as const)), + completeWorkflowRun: mock(() => Promise.resolve()), + failWorkflowRun: mock(() => Promise.resolve()), + pauseWorkflowRun: mock(() => Promise.resolve()), + cancelWorkflowRun: mock(() => Promise.resolve()), + createWorkflowEvent: mock(() => Promise.resolve()), + getCompletedDagNodeOutputs: mock(() => Promise.resolve(new Map())), + getCodebase: mock(() => Promise.resolve(null)), + getCodebaseEnvVars: mock(() => Promise.resolve({})), + }; +} + +const mockSendQuery = mock(function* () { + yield { type: 'assistant', content: 'not used in script tests' }; + yield { type: 'result', sessionId: 'session' }; +}); + +const mockGetAgentProvider = mock(() => ({ + sendQuery: mockSendQuery, + getType: () => 'claude', +})); + +function createMockDeps(): WorkflowDeps { + return { + store: createMockStore(), + getAgentProvider: mockGetAgentProvider, + loadConfig: mock(() => + Promise.resolve({ + assistant: 'claude' as const, + commands: {}, + defaults: { loadDefaultCommands: false, loadDefaultWorkflows: false }, + assistants: { claude: {}, codex: {} }, + }) + ), + }; +} + +function createMockPlatform(): IWorkflowPlatform { + return { + sendMessage: mock(() => Promise.resolve()), + getStreamingMode: mock(() => 'batch' as const), + getPlatformType: mock(() => 'test'), + sendStructuredEvent: mock(() => Promise.resolve()), + }; +} + +function makeRun(id: string): WorkflowRun { + return { + id, + workflow_name: 'script-global-test', + conversation_id: `conv-${id}`, + parent_conversation_id: null, + codebase_id: null, + status: 'running', + user_message: 'test', + metadata: {}, + started_at: new Date(), + completed_at: null, + last_activity_at: null, + working_path: null, + }; +} + +const minimalConfig: WorkflowConfig = { + assistant: 'claude', + assistants: { claude: {}, codex: {} }, + commands: {}, + defaults: { loadDefaultCommands: false, loadDefaultWorkflows: false }, +}; + +// --- Tests --- + +describe('executeScriptNode — user-global fallback', () => { + let repoCwd: string; + let globalHome: string; + + beforeAll(async () => { + repoCwd = await mkdtemp(join(tmpdir(), 'archon-script-repo-')); + globalHome = await mkdtemp(join(tmpdir(), 'archon-script-home-')); + process.env.ARCHON_HOME = globalHome; + // Subdirs the workflow needs + await mkdir(join(repoCwd, 'artifacts'), { recursive: true }); + await mkdir(join(repoCwd, 'logs'), { recursive: true }); + }); + + afterAll(async () => { + await rm(repoCwd, { recursive: true, force: true }); + await rm(globalHome, { recursive: true, force: true }); + delete process.env.ARCHON_HOME; + }); + + beforeEach(async () => { + // Wipe .archon dirs between tests + await rm(join(repoCwd, '.archon'), { recursive: true, force: true }); + await rm(join(globalHome, '.archon'), { recursive: true, force: true }); + }); + + it('executes a named script found only in the user-global .archon/scripts/', async () => { + // No repo-local script; only the global one + const globalScripts = join(globalHome, '.archon', 'scripts'); + await mkdir(globalScripts, { recursive: true }); + await writeFile(join(globalScripts, 'greet-global.ts'), 'console.log("hello from global")'); + + const platform = createMockPlatform(); + const node: ScriptNode = { + id: 'run-greet-global', + script: 'greet-global', + runtime: 'bun', + }; + + await executeDagWorkflow( + createMockDeps(), + platform, + 'conv-global', + repoCwd, + { name: 'script-global-test', nodes: [node] }, + makeRun('script-global-only-run'), + 'claude', + undefined, + join(repoCwd, 'artifacts'), + join(repoCwd, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + const sendMessage = platform.sendMessage as ReturnType; + const messages = sendMessage.mock.calls.map((call: unknown[]) => call[1] as string); + // No "not found" message should have been sent + const notFound = messages.find(m => m.includes('not found')); + expect(notFound).toBeUndefined(); + // No workflow-level failure message + const failed = messages.find(m => m.includes('no successful nodes')); + expect(failed).toBeUndefined(); + }); + + it('prefers the repo-local copy over the user-global copy when both exist', async () => { + // Put a working script in BOTH locations; give them distinguishable output + const repoScripts = join(repoCwd, '.archon', 'scripts'); + const globalScripts = join(globalHome, '.archon', 'scripts'); + await mkdir(repoScripts, { recursive: true }); + await mkdir(globalScripts, { recursive: true }); + await writeFile(join(repoScripts, 'shared.ts'), 'console.log("from-repo")'); + await writeFile(join(globalScripts, 'shared.ts'), 'console.log("from-global")'); + + const capturedStore = createMockStore(); + const deps: WorkflowDeps = { + ...createMockDeps(), + store: capturedStore, + }; + + const platform = createMockPlatform(); + const node: ScriptNode = { id: 'run-shared', script: 'shared', runtime: 'bun' }; + + await executeDagWorkflow( + deps, + platform, + 'conv-shared', + repoCwd, + { name: 'script-shared-test', nodes: [node] }, + makeRun('script-shared-run'), + 'claude', + undefined, + join(repoCwd, 'artifacts'), + join(repoCwd, 'logs'), + 'main', + 'docs/', + minimalConfig + ); + + // Find the node_completed event and check its captured output contains 'from-repo' + const createEvent = capturedStore.createWorkflowEvent as ReturnType; + const nodeCompletedCall = createEvent.mock.calls.find((call: unknown[]) => { + const event = call[0] as { event_type: string; step_name?: string }; + return event.event_type === 'node_completed' && event.step_name === 'run-shared'; + }); + expect(nodeCompletedCall).toBeDefined(); + if (nodeCompletedCall) { + const event = nodeCompletedCall[0] as { data?: { node_output?: string } }; + expect(event.data?.node_output).toContain('from-repo'); + expect(event.data?.node_output).not.toContain('from-global'); + } + }); + + it('fails with the updated error message when missing in both repo and global', async () => { + // Neither dir has the script + const platform = createMockPlatform(); + const node: ScriptNode = { + id: 'missing-script', + script: 'nowhere', + runtime: 'bun', + }; + + await executeDagWorkflow( + createMockDeps(), + platform, + 'conv-missing', + repoCwd, + { name: 'script-missing-test', nodes: [node] }, + makeRun('script-missing-run'), + 'claude', + undefined, + join(repoCwd, 'artifacts'), + join(repoCwd, '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 => + m.includes('not found in .archon/scripts/ (repo, workspace, or global)') + ); + expect(notFoundMsg).toBeDefined(); + }); +}); + +// ─── Workspace-in-userspace tier (repo > workspace > global) ──────────────── + +describe('executeScriptNode — workspace-in-userspace fallback', () => { + let repoCwd: string; + let globalHome: string; + // The workspace search path stands in for `~/.archon/workspaces///` + // — the PROJECT ROOT. The engine probes `/.archon/scripts/.ts`. + let workspaceSearchPath: string; + + beforeAll(async () => { + repoCwd = await mkdtemp(join(tmpdir(), 'archon-script-wsr-repo-')); + globalHome = await mkdtemp(join(tmpdir(), 'archon-script-wsr-home-')); + workspaceSearchPath = await mkdtemp(join(tmpdir(), 'archon-script-wsr-workspace-')); + process.env.ARCHON_HOME = globalHome; + await mkdir(join(repoCwd, 'artifacts'), { recursive: true }); + await mkdir(join(repoCwd, 'logs'), { recursive: true }); + }); + + afterAll(async () => { + await rm(repoCwd, { recursive: true, force: true }); + await rm(globalHome, { recursive: true, force: true }); + await rm(workspaceSearchPath, { recursive: true, force: true }); + delete process.env.ARCHON_HOME; + }); + + beforeEach(async () => { + await rm(join(repoCwd, '.archon'), { recursive: true, force: true }); + await rm(join(globalHome, '.archon'), { recursive: true, force: true }); + await rm(join(workspaceSearchPath, '.archon'), { recursive: true, force: true }); + }); + + /** + * Dispatch a workflow with a script node, passing workspaceArchonDir through + * via the last-parameter hole in executeDagWorkflow. The engine will use + * the explicitly-provided value and skip its own git-based lookup. + */ + async function runWithWorkspace( + platform: IWorkflowPlatform, + runId: string, + scriptName: string + ): Promise { + const store = createMockStore(); + const deps: WorkflowDeps = { + ...createMockDeps(), + store, + }; + const node: ScriptNode = { id: 'run-it', script: scriptName, runtime: 'bun' }; + await executeDagWorkflow( + deps, + platform, + `conv-${runId}`, + repoCwd, + { name: `script-wsr-${runId}`, nodes: [node] }, + makeRun(runId), + 'claude', + undefined, + join(repoCwd, 'artifacts'), + join(repoCwd, 'logs'), + 'main', + 'docs/', + minimalConfig, + undefined, // configuredCommandFolder + undefined, // issueContext + undefined, // priorCompletedNodes + workspaceSearchPath // workspaceSearchPath (project root) + ); + return store; + } + + it('executes a script found only in the workspace dir', async () => { + await mkdir(join(workspaceSearchPath, '.archon', 'scripts'), { recursive: true }); + await writeFile( + join(workspaceSearchPath, '.archon', 'scripts', 'ws-only.ts'), + 'console.log("from-workspace")' + ); + + const platform = createMockPlatform(); + const store = await runWithWorkspace(platform, 'ws-only-run', 'ws-only'); + + const createEvent = store.createWorkflowEvent as ReturnType; + const nodeCompleted = createEvent.mock.calls.find((call: unknown[]) => { + const event = call[0] as { event_type: string; step_name?: string }; + return event.event_type === 'node_completed' && event.step_name === 'run-it'; + }); + expect(nodeCompleted).toBeDefined(); + if (nodeCompleted) { + const event = nodeCompleted[0] as { data?: { node_output?: string } }; + expect(event.data?.node_output).toContain('from-workspace'); + } + }); + + it('prefers repo over workspace', async () => { + await mkdir(join(repoCwd, '.archon', 'scripts'), { recursive: true }); + await mkdir(join(workspaceSearchPath, '.archon', 'scripts'), { recursive: true }); + await writeFile(join(repoCwd, '.archon', 'scripts', 'dup.ts'), 'console.log("from-repo")'); + await writeFile( + join(workspaceSearchPath, '.archon', 'scripts', 'dup.ts'), + 'console.log("from-workspace")' + ); + + const platform = createMockPlatform(); + const store = await runWithWorkspace(platform, 'dup-run', 'dup'); + + const createEvent = store.createWorkflowEvent as ReturnType; + const nodeCompleted = createEvent.mock.calls.find((call: unknown[]) => { + const event = call[0] as { event_type: string; step_name?: string }; + return event.event_type === 'node_completed' && event.step_name === 'run-it'; + }); + expect(nodeCompleted).toBeDefined(); + if (nodeCompleted) { + const event = nodeCompleted[0] as { data?: { node_output?: string } }; + expect(event.data?.node_output).toContain('from-repo'); + expect(event.data?.node_output).not.toContain('from-workspace'); + } + }); + + it('prefers workspace over user-global', async () => { + await mkdir(join(workspaceSearchPath, '.archon', 'scripts'), { recursive: true }); + await mkdir(join(globalHome, '.archon', 'scripts'), { recursive: true }); + await writeFile( + join(workspaceSearchPath, '.archon', 'scripts', 'mid.ts'), + 'console.log("from-workspace")' + ); + await writeFile(join(globalHome, '.archon', 'scripts', 'mid.ts'), 'console.log("from-global")'); + + const platform = createMockPlatform(); + const store = await runWithWorkspace(platform, 'mid-run', 'mid'); + + const createEvent = store.createWorkflowEvent as ReturnType; + const nodeCompleted = createEvent.mock.calls.find((call: unknown[]) => { + const event = call[0] as { event_type: string; step_name?: string }; + return event.event_type === 'node_completed' && event.step_name === 'run-it'; + }); + expect(nodeCompleted).toBeDefined(); + if (nodeCompleted) { + const event = nodeCompleted[0] as { data?: { node_output?: string } }; + expect(event.data?.node_output).toContain('from-workspace'); + expect(event.data?.node_output).not.toContain('from-global'); + } + }); +}); diff --git a/packages/workflows/src/dag-executor.test.ts b/packages/workflows/src/dag-executor.test.ts index 77beaa3a91..8269ebb2cc 100644 --- a/packages/workflows/src/dag-executor.test.ts +++ b/packages/workflows/src/dag-executor.test.ts @@ -23,6 +23,10 @@ mock.module('@archon/paths', () => ({ return paths; }, getDefaultCommandsPath: () => '/nonexistent/defaults', + // Deliberately point at a nonexistent path so global script fallback is a no-op + // in tests that don't set up a global scripts dir. Tests that exercise the + // fallback live in dag-executor-script-global.test.ts (separate batch). + getArchonHome: () => '/nonexistent/archon-home', })); // --- Imports (after mocks) --- diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index af86b2e055..01f97cc3c0 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -42,7 +42,8 @@ import { isApprovalContext, } from './schemas'; import { formatToolCall } from './utils/tool-formatter'; -import { createLogger } from '@archon/paths'; +import { createLogger, getArchonHome, getProjectRoot } from '@archon/paths'; +import { parseOwnerRepoFromGitRemote } from '@archon/git'; import { getWorkflowEventEmitter } from './event-emitter'; import { evaluateCondition } from './condition-evaluator'; import { isClaudeModel, isModelCompatible } from './model-validation'; @@ -725,7 +726,8 @@ async function executeNodeInternal( nodeOutputs: Map, resumeSessionId: string | undefined, configuredCommandFolder?: string, - issueContext?: string + issueContext?: string, + workspaceSearchPath?: string ): Promise { const nodeStartTime = Date.now(); const nodeContext: SendMessageContext = { workflowId: workflowRun.id, nodeName: node.id }; @@ -758,7 +760,13 @@ async function executeNodeInternal( // Load prompt let rawPrompt: string; if (node.command !== undefined) { - const promptResult = await loadCommandPrompt(deps, cwd, node.command, configuredCommandFolder); + const promptResult = await loadCommandPrompt( + deps, + cwd, + node.command, + configuredCommandFolder, + workspaceSearchPath + ); if (!promptResult.success) { const errMsg = promptResult.message; getLog().error({ nodeId: node.id, error: errMsg }, 'dag_node_command_load_failed'); @@ -802,7 +810,8 @@ async function executeNodeInternal( baseBranch, docsDir, issueContext, - `dag node '${node.id}' prompt` + `dag node '${node.id}' prompt`, + workspaceSearchPath ? resolve(workspaceSearchPath, '.archon') : undefined ); } catch (error) { const err = error as Error; @@ -1314,6 +1323,7 @@ async function executeBashNode( baseBranch: string, docsDir: string, nodeOutputs: Map, + workspaceSearchPath?: string, issueContext?: string ): Promise { const nodeStartTime = Date.now(); @@ -1344,7 +1354,12 @@ async function executeBashNode( nodeName: node.id, }); - // Variable substitution on script + // Variable substitution on script. Derive $WORKSPACE_ARCHON_DIR from the + // workspace search path so bash scripts can wrap `bun run` on named workspace + // scripts via an absolute path. + const workspaceArchonDir = workspaceSearchPath + ? resolve(workspaceSearchPath, '.archon') + : undefined; const { prompt: substitutedScript } = substituteWorkflowVariables( node.bash, workflowRun.id, @@ -1352,7 +1367,10 @@ async function executeBashNode( artifactsDir, baseBranch, docsDir, - issueContext + issueContext, + undefined, + undefined, + workspaceArchonDir ); const finalScript = substituteNodeOutputRefs(substitutedScript, nodeOutputs, true); @@ -1464,6 +1482,7 @@ async function executeScriptNode( baseBranch: string, docsDir: string, nodeOutputs: Map, + workspaceSearchPath?: string, issueContext?: string ): Promise { const nodeStartTime = Date.now(); @@ -1494,7 +1513,11 @@ async function executeScriptNode( nodeName: node.id, }); - // Variable substitution on script field + // Variable substitution on script field. Derive $WORKSPACE_ARCHON_DIR so + // inline scripts can reference the workspace-tier .archon dir directly. + const workspaceArchonDir = workspaceSearchPath + ? resolve(workspaceSearchPath, '.archon') + : undefined; const { prompt: substitutedScript } = substituteWorkflowVariables( node.script, workflowRun.id, @@ -1502,7 +1525,10 @@ async function executeScriptNode( artifactsDir, baseBranch, docsDir, - issueContext + issueContext, + undefined, + undefined, + workspaceArchonDir ); const finalScript = substituteNodeOutputRefs(substitutedScript, nodeOutputs, false); @@ -1527,13 +1553,62 @@ async function executeScriptNode( args = ['run', ...withFlags, 'python', '-c', finalScript]; } } else { - // Named script — look up in .archon/scripts/ directory + // Named script — look up in .archon/scripts/ directory. + // Priority: repo-local → workspace-in-userspace → user-global. + // Mirrors the command/workflow three-tier fallback so users can keep + // per-project or per-machine script libraries outside any repo. const scriptsDir = resolve(cwd, '.archon', 'scripts'); const scripts = await discoverScripts(scriptsDir); - const scriptDef = scripts.get(finalScript); + let scriptDef = scripts.get(finalScript); + + // Tier 2: workspace-in-userspace (~/.archon/workspaces///.archon/scripts/) + if (!scriptDef && workspaceSearchPath) { + const workspaceScriptsDir = resolve(workspaceSearchPath, '.archon', 'scripts'); + if (workspaceScriptsDir !== scriptsDir) { + try { + const workspaceScripts = await discoverScripts(workspaceScriptsDir); + scriptDef = workspaceScripts.get(finalScript); + if (scriptDef) { + getLog().debug( + { nodeId: node.id, scriptName: finalScript, source: 'workspace' }, + 'dag.script_loaded_workspace' + ); + } + } catch (discoveryErr) { + getLog().warn( + { err: discoveryErr as Error, workspaceScriptsDir }, + 'dag.script_workspace_discovery_failed' + ); + } + } + } + // Tier 3: user-global (~/.archon/.archon/scripts/) if (!scriptDef) { - const errorMsg = `Script node '${node.id}': named script '${finalScript}' not found in .archon/scripts/`; + const globalScriptsDir = resolve(getArchonHome(), '.archon', 'scripts'); + if (globalScriptsDir !== scriptsDir) { + try { + const globalScripts = await discoverScripts(globalScriptsDir); + scriptDef = globalScripts.get(finalScript); + if (scriptDef) { + getLog().debug( + { nodeId: node.id, scriptName: finalScript, source: 'global' }, + 'dag.script_loaded_global' + ); + } + } catch (discoveryErr) { + // discoverScripts swallows ENOENT; surface any other error as a warning + // but still fall through to the not-found path below. + getLog().warn( + { err: discoveryErr as Error, globalScriptsDir }, + 'dag.script_global_discovery_failed' + ); + } + } + } + + if (!scriptDef) { + const errorMsg = `Script node '${node.id}': named script '${finalScript}' not found in .archon/scripts/ (repo, workspace, or global)`; getLog().error({ nodeId: node.id, scriptName: finalScript }, 'script_not_found'); await safeSendMessage(platform, conversationId, errorMsg, nodeContext); await logNodeError(logDir, workflowRun.id, node.id, errorMsg); @@ -1712,7 +1787,8 @@ async function executeLoopNode( docsDir: string, nodeOutputs: Map, config: WorkflowConfig, - issueContext?: string + issueContext?: string, + workspaceSearchPath?: string ): Promise { const loop = node.loop; const msgContext = { workflowId: workflowRun.id, nodeName: node.id }; @@ -1813,7 +1889,9 @@ async function executeLoopNode( baseBranch, docsDir, issueContext, - i === startIteration ? loopUserInput : '' + i === startIteration ? loopUserInput : '', + undefined, + workspaceSearchPath ? resolve(workspaceSearchPath, '.archon') : undefined ); const finalPrompt = substituteNodeOutputRefs(substitutedPrompt, nodeOutputs); @@ -2011,7 +2089,10 @@ async function executeLoopNode( artifactsDir, baseBranch, docsDir, - issueContext + issueContext, + undefined, + undefined, + workspaceSearchPath ? resolve(workspaceSearchPath, '.archon') : undefined ); const substitutedBash = substituteNodeOutputRefs( bashPrompt, @@ -2205,7 +2286,8 @@ async function executeApprovalNode( config: WorkflowConfig, workflowLevelOptions: WorkflowLevelOptions, configuredCommandFolder?: string, - issueContext?: string + issueContext?: string, + workspaceSearchPath?: string ): Promise { const msgContext = { workflowId: workflowRun.id, nodeName: node.id }; @@ -2263,7 +2345,8 @@ async function executeApprovalNode( docsDir, issueContext, undefined, // loopUserInput - rejectionReason + rejectionReason, + workspaceSearchPath ? resolve(workspaceSearchPath, '.archon') : undefined ); // Build a synthetic PromptNode to reuse executeNodeInternal @@ -2302,7 +2385,8 @@ async function executeApprovalNode( nodeOutputs, undefined, // fresh session configuredCommandFolder, - issueContext + issueContext, + workspaceSearchPath ); if (output.state === 'failed') { @@ -2373,9 +2457,29 @@ export async function executeDagWorkflow( config: WorkflowConfig, configuredCommandFolder?: string, issueContext?: string, - priorCompletedNodes?: Map + priorCompletedNodes?: Map, + workspaceSearchPath?: string ): Promise { const dagStartTime = Date.now(); + + // Resolve the workspace-in-userspace project root once per run. If the caller + // didn't pre-compute it, derive it from the git remote. Undefined means the + // workspace tier is silently skipped for script/command lookups in this run. + // This is the PROJECT ROOT (no `.archon` suffix), matching how + // `globalSearchPath` works at the workflow-discovery layer. + let resolvedWorkspaceSearchPath: string | undefined = workspaceSearchPath; + if (resolvedWorkspaceSearchPath === undefined) { + const ownerRepo = await parseOwnerRepoFromGitRemote(cwd); + if (ownerRepo) { + resolvedWorkspaceSearchPath = getProjectRoot(ownerRepo.owner, ownerRepo.repo); + getLog().debug( + { cwd, workspaceSearchPath: resolvedWorkspaceSearchPath }, + 'dag.workspace_search_path_resolved' + ); + } else { + getLog().debug({ cwd }, 'dag.workspace_search_path_unavailable'); + } + } const workflowLevelOptions = { effort: workflow.effort, thinking: workflow.thinking, @@ -2593,6 +2697,7 @@ export async function executeDagWorkflow( baseBranch, docsDir, nodeOutputs, + resolvedWorkspaceSearchPath, issueContext ); return { nodeId: node.id, output }; @@ -2643,7 +2748,8 @@ export async function executeDagWorkflow( docsDir, nodeOutputs, config, - issueContext + issueContext, + resolvedWorkspaceSearchPath ); return { nodeId: node.id, output }; } @@ -2667,7 +2773,8 @@ export async function executeDagWorkflow( config, workflowLevelOptions, configuredCommandFolder, - issueContext + issueContext, + resolvedWorkspaceSearchPath ); return { nodeId: node.id, output }; } @@ -2718,6 +2825,7 @@ export async function executeDagWorkflow( baseBranch, docsDir, nodeOutputs, + resolvedWorkspaceSearchPath, issueContext ); return { nodeId: node.id, output }; @@ -2769,7 +2877,8 @@ export async function executeDagWorkflow( // ensures the source is never mutated, so retries can safely resume from it. resumeSessionId, configuredCommandFolder, - issueContext + issueContext, + resolvedWorkspaceSearchPath ); if (output.state !== 'failed') break; diff --git a/packages/workflows/src/executor-shared-command-global.test.ts b/packages/workflows/src/executor-shared-command-global.test.ts new file mode 100644 index 0000000000..262a8a2d8e --- /dev/null +++ b/packages/workflows/src/executor-shared-command-global.test.ts @@ -0,0 +1,260 @@ +/** + * Tests for the user-global command fallback in loadCommandPrompt. + * + * Isolated in its own test file (and its own bun test invocation — see package.json) + * because it mocks @archon/paths differently than executor-shared.test.ts: + * this file needs getArchonHome + getCommandFolderSearchPaths to be present, + * whereas the other file only cares about createLogger. Two files cannot + * mock.module() the same path with different implementations in one batch. + */ +import { describe, it, expect, mock, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +// Mock logger + paths BEFORE importing module under test +const mockLogFn = mock(() => {}); +const mockLogger = { + info: mockLogFn, + warn: mockLogFn, + error: mockLogFn, + debug: mockLogFn, + trace: mockLogFn, + fatal: mockLogFn, + child: mock(() => mockLogger), + bindings: mock(() => ({ module: 'test' })), + isLevelEnabled: mock(() => true), + level: 'info', +}; + +// Hand-rolled partial mock of @archon/paths. Provides getArchonHome via the +// test's ARCHON_HOME env and getCommandFolderSearchPaths as a static list, +// mirroring the real implementations. +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), + getArchonHome: (): string => { + const envHome = process.env.ARCHON_HOME; + if (!envHome) throw new Error('ARCHON_HOME not set in test'); + return envHome; + }, + getCommandFolderSearchPaths: (configured?: string): string[] => { + const paths = ['.archon/commands', '.archon/commands/defaults']; + if (configured && !paths.includes(configured)) paths.push(configured); + return paths; + }, + // No-op defaults path so the app-defaults branch never hits the real filesystem + getDefaultCommandsPath: (): string => '/dev/null/nonexistent', +})); + +// Mock bundled-defaults to avoid loading the real binary build check +mock.module('./defaults/bundled-defaults', () => ({ + BUNDLED_COMMANDS: {}, + isBinaryBuild: (): boolean => false, +})); + +import { loadCommandPrompt } from './executor-shared'; +import type { WorkflowDeps } from './deps'; + +// Minimal deps — loadCommandPrompt only uses deps.loadConfig +const makeDeps = (loadDefaultCommands = true): WorkflowDeps => + ({ + loadConfig: async () => ({ defaults: { loadDefaultCommands } }), + }) as unknown as WorkflowDeps; + +describe('loadCommandPrompt — user-global fallback', () => { + let repoCwd: string; + let globalHome: string; + + beforeAll(async () => { + repoCwd = await mkdtemp(join(tmpdir(), 'archon-test-repo-')); + globalHome = await mkdtemp(join(tmpdir(), 'archon-test-home-')); + process.env.ARCHON_HOME = globalHome; + }); + + afterAll(async () => { + await rm(repoCwd, { recursive: true, force: true }); + await rm(globalHome, { recursive: true, force: true }); + delete process.env.ARCHON_HOME; + }); + + beforeEach(async () => { + // Wipe contents between tests but keep the tmpdirs themselves + await rm(join(repoCwd, '.archon'), { recursive: true, force: true }); + await rm(join(globalHome, '.archon'), { recursive: true, force: true }); + }); + + it('loads a command only present in the user-global dir', async () => { + await mkdir(join(globalHome, '.archon', 'commands'), { recursive: true }); + await writeFile( + join(globalHome, '.archon', 'commands', 'greet-global.md'), + 'You are a greeter. Say hello!' + ); + + const result = await loadCommandPrompt(makeDeps(), repoCwd, 'greet-global'); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe('You are a greeter. Say hello!'); + } + }); + + it('prefers the repo-local copy over the global copy when both exist', async () => { + await mkdir(join(repoCwd, '.archon', 'commands'), { recursive: true }); + await mkdir(join(globalHome, '.archon', 'commands'), { recursive: true }); + await writeFile(join(repoCwd, '.archon', 'commands', 'shared.md'), 'repo version'); + await writeFile(join(globalHome, '.archon', 'commands', 'shared.md'), 'global version'); + + const result = await loadCommandPrompt(makeDeps(), repoCwd, 'shared'); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe('repo version'); + } + }); + + it('returns empty_file when the global command file is empty', async () => { + await mkdir(join(globalHome, '.archon', 'commands'), { recursive: true }); + await writeFile(join(globalHome, '.archon', 'commands', 'blank.md'), ' \n\n'); + + const result = await loadCommandPrompt(makeDeps(), repoCwd, 'blank'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.reason).toBe('empty_file'); + expect(result.message).toContain('global'); + } + }); + + it('fails with not_found when absent in repo, global, and defaults', async () => { + const result = await loadCommandPrompt(makeDeps(), repoCwd, 'ghost-command'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.reason).toBe('not_found'); + // Message should mention both repo-relative and global paths + expect(result.message).toContain('.archon/commands'); + expect(result.message).toContain(globalHome); + } + }); +}); + +describe('loadCommandPrompt — workspace-in-userspace tier', () => { + let repoCwd: string; + let globalHome: string; + // workspaceSearchPath stands in for `~/.archon/workspaces///` — + // the PROJECT ROOT. The loader appends `.archon/commands/` to find files. + let workspaceSearchPath: string; + + beforeAll(async () => { + repoCwd = await mkdtemp(join(tmpdir(), 'archon-wsr-repo-')); + globalHome = await mkdtemp(join(tmpdir(), 'archon-wsr-home-')); + workspaceSearchPath = await mkdtemp(join(tmpdir(), 'archon-wsr-workspace-')); + process.env.ARCHON_HOME = globalHome; + }); + + afterAll(async () => { + await rm(repoCwd, { recursive: true, force: true }); + await rm(globalHome, { recursive: true, force: true }); + await rm(workspaceSearchPath, { recursive: true, force: true }); + delete process.env.ARCHON_HOME; + }); + + beforeEach(async () => { + await rm(join(repoCwd, '.archon'), { recursive: true, force: true }); + await rm(join(globalHome, '.archon'), { recursive: true, force: true }); + await rm(join(workspaceSearchPath, '.archon'), { recursive: true, force: true }); + }); + + it('loads a command found only in the workspace dir', async () => { + await mkdir(join(workspaceSearchPath, '.archon', 'commands'), { recursive: true }); + await writeFile( + join(workspaceSearchPath, '.archon', 'commands', 'ws-only.md'), + 'workspace content' + ); + + const result = await loadCommandPrompt( + makeDeps(), + repoCwd, + 'ws-only', + undefined, + workspaceSearchPath + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe('workspace content'); + } + }); + + it('prefers repo over workspace when both exist', async () => { + await mkdir(join(repoCwd, '.archon', 'commands'), { recursive: true }); + await mkdir(join(workspaceSearchPath, '.archon', 'commands'), { recursive: true }); + await writeFile(join(repoCwd, '.archon', 'commands', 'dup.md'), 'from-repo'); + await writeFile(join(workspaceSearchPath, '.archon', 'commands', 'dup.md'), 'from-workspace'); + + const result = await loadCommandPrompt( + makeDeps(), + repoCwd, + 'dup', + undefined, + workspaceSearchPath + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe('from-repo'); + } + }); + + it('prefers workspace over user-global when both exist', async () => { + await mkdir(join(workspaceSearchPath, '.archon', 'commands'), { recursive: true }); + await mkdir(join(globalHome, '.archon', 'commands'), { recursive: true }); + await writeFile(join(workspaceSearchPath, '.archon', 'commands', 'mid.md'), 'from-workspace'); + await writeFile(join(globalHome, '.archon', 'commands', 'mid.md'), 'from-global'); + + const result = await loadCommandPrompt( + makeDeps(), + repoCwd, + 'mid', + undefined, + workspaceSearchPath + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.content).toBe('from-workspace'); + } + }); + + it('omitting workspaceSearchPath preserves two-tier behavior', async () => { + // Stage a workspace command; omit the param so it should NOT be found + await mkdir(join(workspaceSearchPath, '.archon', 'commands'), { recursive: true }); + await writeFile( + join(workspaceSearchPath, '.archon', 'commands', 'hidden.md'), + 'should not be loaded' + ); + + const result = await loadCommandPrompt(makeDeps(), repoCwd, 'hidden'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.reason).toBe('not_found'); + } + }); + + it('not-found error message lists the workspace search paths', async () => { + const result = await loadCommandPrompt( + makeDeps(), + repoCwd, + 'nope', + undefined, + workspaceSearchPath + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.reason).toBe('not_found'); + expect(result.message).toContain(workspaceSearchPath); + } + }); +}); diff --git a/packages/workflows/src/executor-shared.test.ts b/packages/workflows/src/executor-shared.test.ts index 84346f131e..f6ab060bd9 100644 --- a/packages/workflows/src/executor-shared.test.ts +++ b/packages/workflows/src/executor-shared.test.ts @@ -92,6 +92,36 @@ describe('substituteWorkflowVariables', () => { expect(prompt).toBe('Goal: add dark mode. Args: add dark mode'); }); + it('replaces $WORKSPACE_ARCHON_DIR with the provided path', () => { + const { prompt } = substituteWorkflowVariables( + 'bun run "$WORKSPACE_ARCHON_DIR/scripts/foo.ts"', + 'run-1', + 'msg', + '/tmp', + 'main', + 'docs/', + undefined, + undefined, + undefined, + '/Users/test/.archon/workspaces/owner/repo/.archon' + ); + expect(prompt).toBe( + 'bun run "/Users/test/.archon/workspaces/owner/repo/.archon/scripts/foo.ts"' + ); + }); + + it('replaces $WORKSPACE_ARCHON_DIR with empty string when omitted', () => { + const { prompt } = substituteWorkflowVariables( + 'prefix=$WORKSPACE_ARCHON_DIR/x', + 'run-1', + 'msg', + '/tmp', + 'main', + 'docs/' + ); + expect(prompt).toBe('prefix=/x'); + }); + it('replaces $DOCS_DIR with configured path', () => { const { prompt } = substituteWorkflowVariables( 'Check $DOCS_DIR for changes', diff --git a/packages/workflows/src/executor-shared.ts b/packages/workflows/src/executor-shared.ts index 0537609417..7b6de5cc93 100644 --- a/packages/workflows/src/executor-shared.ts +++ b/packages/workflows/src/executor-shared.ts @@ -125,7 +125,8 @@ export async function loadCommandPrompt( deps: WorkflowDeps, cwd: string, commandName: string, - configuredFolder?: string + configuredFolder?: string, + workspaceSearchPath?: string ): Promise { // Validate command name first if (!isValidCommandName(commandName)) { @@ -195,6 +196,88 @@ export async function loadCommandPrompt( } } + // Then search the workspace-in-userspace dir + // (~/.archon/workspaces///.archon/commands/) if provided. + // workspaceSearchPath is the PROJECT ROOT; append the same .archon-prefixed + // folder name used for repo-local and global searches. + // This tier lives between repo and global so precedence is + // repo > workspace > global > defaults. + if (workspaceSearchPath) { + for (const folder of searchPaths) { + const filePath = join(workspaceSearchPath, folder, `${commandName}.md`); + try { + const content = await readFile(filePath, 'utf-8'); + if (!content.trim()) { + getLog().error({ commandName, source: 'workspace' }, 'command_file_empty'); + return { + success: false, + reason: 'empty_file', + message: `Command file is empty (workspace): ${commandName}.md`, + }; + } + getLog().debug({ commandName, folder, source: 'workspace' }, 'command_loaded_workspace'); + return { success: true, content }; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') continue; + if (err.code === 'EACCES') { + getLog().error({ commandName, filePath }, 'command_file_permission_denied'); + return { + success: false, + reason: 'permission_denied', + message: `Permission denied reading workspace command: ${commandName}.md`, + }; + } + getLog().error({ err, commandName, filePath }, 'command_file_read_error'); + return { + success: false, + reason: 'read_error', + message: `Error reading workspace command ${commandName}.md: ${err.message}`, + }; + } + } + } + + // Then search the user-global dir (~/.archon/.archon/commands/) + // Mirrors the global workflow discovery behavior so users can keep + // per-machine command libraries outside any repo. + const archonHome = archonPaths.getArchonHome(); + for (const folder of searchPaths) { + const filePath = join(archonHome, folder, `${commandName}.md`); + try { + const content = await readFile(filePath, 'utf-8'); + if (!content.trim()) { + getLog().error({ commandName, source: 'global' }, 'command_file_empty'); + return { + success: false, + reason: 'empty_file', + message: `Command file is empty (global): ${commandName}.md`, + }; + } + getLog().debug({ commandName, folder, source: 'global' }, 'command_loaded_global'); + return { success: true, content }; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + continue; + } + if (err.code === 'EACCES') { + getLog().error({ commandName, filePath }, 'command_file_permission_denied'); + return { + success: false, + reason: 'permission_denied', + message: `Permission denied reading global command: ${commandName}.md`, + }; + } + getLog().error({ err, commandName, filePath }, 'command_file_read_error'); + return { + success: false, + reason: 'read_error', + message: `Error reading global command ${commandName}.md: ${err.message}`, + }; + } + } + // If not found in repo and app defaults enabled, search app defaults const loadDefaultCommands = config.defaults?.loadDefaultCommands ?? true; if (loadDefaultCommands) { @@ -235,7 +318,13 @@ export async function loadCommandPrompt( } // Not found anywhere - const allSearchPaths = loadDefaultCommands ? [...searchPaths, 'app defaults'] : searchPaths; + const workspacePaths = workspaceSearchPath + ? searchPaths.map(p => `${workspaceSearchPath}/${p}`) + : []; + const globalPaths = searchPaths.map(p => `${archonHome}/${p}`); + const allSearchPaths = loadDefaultCommands + ? [...searchPaths, ...workspacePaths, ...globalPaths, 'app defaults'] + : [...searchPaths, ...workspacePaths, ...globalPaths]; getLog().error({ commandName, searchPaths: allSearchPaths }, 'command_not_found'); return { success: false, @@ -262,6 +351,11 @@ export const CONTEXT_VAR_PATTERN_STR = '\\$(?:CONTEXT|EXTERNAL_CONTEXT|ISSUE_CON * - $LOOP_USER_INPUT - User feedback from interactive loop approval. Only populated on the * first iteration of a resumed interactive loop; empty string on all other iterations. * - $REJECTION_REASON - Reviewer feedback from approval node rejection (on_reject prompts only). + * - $WORKSPACE_ARCHON_DIR - The user-global workspace-tier `.archon` directory + * (`~/.archon/workspaces///.archon`) when resolvable from the cwd, + * empty string otherwise. Useful in bash/script nodes that wrap `bun run` + * on named scripts — since worktrees may not contain `.archon/`, bash wrappers + * need an absolute path into the workspace-tier scripts directory. * * When issueContext is undefined, context variables are replaced with empty string * to avoid sending literal "$CONTEXT" to the AI. @@ -275,7 +369,8 @@ export function substituteWorkflowVariables( docsDir: string, issueContext?: string, loopUserInput?: string, - rejectionReason?: string + rejectionReason?: string, + workspaceArchonDir?: string ): { prompt: string; contextSubstituted: boolean } { // Fail fast if the prompt references $BASE_BRANCH but no base branch could be resolved if (!baseBranch && prompt.includes('$BASE_BRANCH')) { @@ -297,7 +392,8 @@ export function substituteWorkflowVariables( .replace(/\$BASE_BRANCH/g, baseBranch) .replace(/\$DOCS_DIR/g, resolvedDocsDir) .replace(/\$LOOP_USER_INPUT/g, loopUserInput ?? '') - .replace(/\$REJECTION_REASON/g, rejectionReason ?? ''); + .replace(/\$REJECTION_REASON/g, rejectionReason ?? '') + .replace(/\$WORKSPACE_ARCHON_DIR/g, workspaceArchonDir ?? ''); // Check if context variables exist (use fresh regex to avoid lastIndex issues) const hasContextVariables = new RegExp(CONTEXT_VAR_PATTERN_STR).test(result); @@ -343,7 +439,8 @@ export function buildPromptWithContext( baseBranch: string, docsDir: string, issueContext: string | undefined, - logLabel: string + logLabel: string, + workspaceArchonDir?: string ): string { const { prompt, contextSubstituted } = substituteWorkflowVariables( template, @@ -352,7 +449,10 @@ export function buildPromptWithContext( artifactsDir, baseBranch, docsDir, - issueContext + issueContext, + undefined, + undefined, + workspaceArchonDir ); if (issueContext && !contextSubstituted) { diff --git a/packages/workflows/src/loader.test.ts b/packages/workflows/src/loader.test.ts index 74b86a5977..1d9a4f9d8d 100644 --- a/packages/workflows/src/loader.test.ts +++ b/packages/workflows/src/loader.test.ts @@ -116,6 +116,61 @@ nodes: expect(workflows[0].nodes[1].id).toBe('implement'); }); + it('should load workflows from workspaceSearchPath when absent from repo', async () => { + // workspaceSearchPath stands in for ~/.archon/workspaces/// + // discoverWorkflows joins the workflow folder ('.archon/workflows') onto it. + const workspaceSearchPath = join( + tmpdir(), + `workspace-search-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + const wsWorkflowDir = join(workspaceSearchPath, '.archon', 'workflows'); + await mkdir(wsWorkflowDir, { recursive: true }); + const yaml = `name: ws-only\ndescription: workspace tier\nnodes:\n - id: n\n prompt: p\n`; + await writeFile(join(wsWorkflowDir, 'ws-only.yaml'), yaml); + + try { + const result = await discoverWorkflows(testDir, { + loadDefaults: false, + workspaceSearchPath, + }); + expect(result.workflows).toHaveLength(1); + expect(result.workflows[0].workflow.name).toBe('ws-only'); + } finally { + await rm(workspaceSearchPath, { recursive: true, force: true }); + } + }); + + it('repo workflow overrides workspace workflow with same filename', async () => { + const workspaceSearchPath = join( + tmpdir(), + `workspace-override-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + const wsWorkflowDir = join(workspaceSearchPath, '.archon', 'workflows'); + await mkdir(wsWorkflowDir, { recursive: true }); + await writeFile( + join(wsWorkflowDir, 'dup.yaml'), + `name: workspace-version\ndescription: ws\nnodes:\n - id: n\n prompt: p\n` + ); + + const repoWorkflowDir = join(testDir, '.archon', 'workflows'); + await mkdir(repoWorkflowDir, { recursive: true }); + await writeFile( + join(repoWorkflowDir, 'dup.yaml'), + `name: repo-version\ndescription: repo\nnodes:\n - id: n\n prompt: p\n` + ); + + try { + const result = await discoverWorkflows(testDir, { + loadDefaults: false, + workspaceSearchPath, + }); + expect(result.workflows).toHaveLength(1); + expect(result.workflows[0].workflow.name).toBe('repo-version'); + } finally { + await rm(workspaceSearchPath, { recursive: true, force: true }); + } + }); + it('should return empty array for YAML missing name', async () => { const workflowDir = join(testDir, '.archon', 'workflows'); await mkdir(workflowDir, { recursive: true }); diff --git a/packages/workflows/src/workflow-discovery.ts b/packages/workflows/src/workflow-discovery.ts index bcd5d531ce..3f2a2af91c 100644 --- a/packages/workflows/src/workflow-discovery.ts +++ b/packages/workflows/src/workflow-discovery.ts @@ -135,7 +135,11 @@ function loadBundledWorkflows(): DirLoadResult { */ export async function discoverWorkflows( cwd: string, - options?: { globalSearchPath?: string; loadDefaults?: boolean } + options?: { + globalSearchPath?: string; + workspaceSearchPath?: string; + loadDefaults?: boolean; + } ): Promise { // Map of filename -> workflow+source for deduplication const workflowsByFile = new Map(); @@ -211,6 +215,37 @@ export async function discoverWorkflows( } } + // 2b. Load from the workspace-in-userspace path + // (~/.archon/workspaces///.archon/workflows/). + // This tier lives between global and repo so precedence is + // repo > workspace > global > bundled. Overrides by exact filename. + if (options?.workspaceSearchPath) { + const [workflowFolderName] = archonPaths.getWorkflowFolderSearchPaths(); + const workspaceWorkflowPath = join(options.workspaceSearchPath, workflowFolderName); + getLog().debug({ workspaceWorkflowPath }, 'searching_workspace_workflows'); + try { + await access(workspaceWorkflowPath); + const workspaceResult = await loadWorkflowsFromDir(workspaceWorkflowPath); + for (const [filename, workflow] of workspaceResult.workflows) { + if (workflowsByFile.has(filename)) { + getLog().debug({ filename }, 'workspace_workflow_overrides_global'); + } + // Same scope decision as global: classified as 'project' (not a separate + // 'workspace' source badge). Can be split later if the UI needs it. + workflowsByFile.set(filename, { workflow, source: 'project' }); + } + allErrors.push(...workspaceResult.errors); + getLog().info({ count: workspaceResult.workflows.size }, 'workspace_workflows_loaded'); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'ENOENT') { + getLog().warn({ err, workspaceWorkflowPath }, 'workspace_workflows_access_error'); + } else { + getLog().debug({ workspaceWorkflowPath }, 'workspace_workflows_not_found'); + } + } + } + // 3. Load from repo's workflow folder (overrides app defaults by exact filename) const [workflowFolder] = archonPaths.getWorkflowFolderSearchPaths(); const workflowPath = join(cwd, workflowFolder); @@ -291,7 +326,7 @@ export async function discoverWorkflows( export async function discoverWorkflowsWithConfig( cwd: string, loadConfig: (cwd: string) => Promise<{ defaults?: { loadDefaultWorkflows?: boolean } }>, - options?: { globalSearchPath?: string } + options?: { globalSearchPath?: string; workspaceSearchPath?: string } ): Promise { let loadDefaults = true; try {