import assert from 'node:assert/strict'; import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import test from 'node:test'; import { main } from '../src/cli/bootstrap.js'; import { parseCli } from '../src/cli/args.js'; import { executeGoals } from '../src/cli/commands/goals.js'; import { configSchema } from '../src/domain/config.js'; import type { AutomationDependencies } from '../src/application/automation.js'; import { FileIterationStore } from '../src/adapters/storage/iteration-store.js'; import { FileCandidateStore } from '../src/adapters/storage/candidate-store.js'; import { candidateFromSignal } from '../src/domain/candidate.js'; import { Store } from '../src/adapters/storage/file-store.js'; import type { GoalState } from '../src/domain/iteration.js'; test('goal inspection and pause work under a controller lock and enforce repository isolation', async () => { await mkdir('.cache/tests', { recursive: true }); const root = await mkdtemp(resolve('.cache/tests/iteration-cli-')), config = resolve(root, 'config.json'); await writeFile(config, JSON.stringify({ repository: 'owner/repo', dataDir: root })); const goals = new FileIterationStore(root), id = 'a'.repeat(24); const state: GoalState = { schemaVersion: 1, id, repository: 'owner/repo', configHash: 'old-config', spec: { title: 'Example goal', objective: 'Implement a feature', mode: 'feature', acceptance: [{ id: 'accept', text: 'Expected behavior' }], allowedPaths: ['src'], evaluation: { suite: 'core-suite', case: 'example-case', profile: 'codex-default' } }, branch: 'main', sha: 'a'.repeat(40), status: 'planned', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', steps: [], completed: [], changes: [], reports: [], rounds: 0, calls: 0, tokens: 0, elapsedMs: 0, notes: [] }; await goals.save(state); await goals.save({ ...state, id: 'b'.repeat(24), repository: 'other/repo' }); const candidates = new FileCandidateStore(root), candidate = candidateFromSignal({ kind: 'issue', repository: 'owner/repo', identity: [7], evidence: ['issue'], commit: 'a'.repeat(40), branch: 'main', issue: 7, title: 'Reject negative quantities', detail: 'Negative quantities must fail.', priority: 70, priorityReasons: ['trusted'] }); await candidates.save(candidate); await goals.remember({ id, goalId: id, repository: state.repository, sha: state.sha, configHash: state.configHash, outcome: state.status, completed: [], reportIds: [], notes: [], createdAt: state.createdAt }); const lines: string[] = [], output = { write: (line: string) => lines.push(line), error: (_line: string) => {} }; const release = await new Store(root).acquire(); try { assert.equal(await main(['goals', 'list', '--config', config], output), 0); assert.equal(JSON.parse(lines.at(-1)!).total, 1); assert.equal(await main(['goals', 'show', id, '--config', config], output), 0); assert.equal(JSON.parse(lines.at(-1)!).configHash, 'old-config'); assert.equal(await main(['goals', 'graph', id, '--config', config], output), 0); assert.deepEqual(JSON.parse(lines.at(-1)!).steps, []); assert.deepEqual(JSON.parse(lines.at(-1)!).waveHistory, []); assert.deepEqual(JSON.parse(lines.at(-1)!).serialReplay, []); assert.equal(await main(['goals', 'pause', id, '--config', config], output), 0); assert.equal(await goals.paused(id), true); assert.equal((await goals.read(id))!.status, 'planned'); await assert.rejects(main(['goals', 'pause', 'b'.repeat(24), '--config', config], output), /this repository/); assert.equal(await goals.paused('b'.repeat(24)), false); assert.equal(await main(['experiences', '--config', config], output), 0); assert.equal(JSON.parse(lines.at(-1)!).experiences[0].goalId, id); assert.equal(await main(['evals', '--suite', 'core-suite', '--config', config], output), 0); assert.equal(JSON.parse(lines.at(-1)!).records[0].case, 'example-case'); assert.equal(await main(['evals', 'gate', '--suite', 'core-suite', '--baseline', 'baseline', '--candidate', 'codex-default', '--config', config], output), 1); assert.equal(JSON.parse(lines.at(-1)!).eligible, false); assert.equal(await main(['candidates', 'list', '--config', config], output), 0); assert.equal(JSON.parse(lines.at(-1)!).candidates[0].id, candidate.id); assert.equal(await main(['candidates', 'show', candidate.id, '--config', config], output), 0); assert.equal(JSON.parse(lines.at(-1)!).evidenceDigest, candidate.evidenceDigest); await assert.rejects(main(['candidates', 'approve', candidate.id, '--config', config], output), /--expected/); await assert.rejects(main(['candidates', 'show', 'b'.repeat(24), '--config', config], output), /this repository/); await assert.rejects(main(['goals', 'list', '--limit', '0', '--config', config], output), /pagination/); await assert.rejects(main(['goals', 'list', '--status', 'unknown', '--config', config], output), /status/); await assert.rejects(main(['goals', 'plan', '--config', config], output), /--spec/); await assert.rejects(main(['goals', 'run', '--config', config], output), /ID is required/); await assert.rejects(main(['discover', '--apply', '--config', config], output), /--expected/); await assert.rejects(main(['discover', '--expected', 'token', '--config', config], output), /--apply/); } finally { await release(); } }); test('goal specification and queue flags are parsed without ambiguity', () => { const result = parseCli(['goals', 'plan', '--spec', 'goal.json', '--config', 'local.json']); assert.equal(result.values.spec, 'goal.json'); assert.deepEqual(result.positionals, ['goals', 'plan']); assert.equal(parseCli(['iterate', '--once']).values.once, true); assert.deepEqual(parseCli(['goals', 'graph', 'a'.repeat(24)]).positionals, ['goals', 'graph', 'a'.repeat(24)]); assert.equal(parseCli(['evals', 'replay', '--profile', 'new-strategy']).values.profile, 'new-strategy'); }); test('iteration polling exits on cancellation and refuses an unconfigured loop', async () => { const abort = new AbortController(), output = { write: (_line: string) => {}, error: (_line: string) => {} }; // No adapter may be touched before validating the polling configuration or cancellation. const deps = { signal: abort.signal, config: configSchema.parse({ repository: 'owner/repo' }) } as AutomationDependencies; await assert.rejects(executeGoals('iterate', undefined, undefined, { once: true }, deps, output), /iteration.queue or iteration.maintenance/); abort.abort(); assert.equal(await executeGoals('iterate', undefined, undefined, {}, deps, output), 0); }); test('strategy gate defers new Issue candidates without bypassing their state machine', async () => { await mkdir('.cache/tests', { recursive: true }); const root = await mkdtemp(resolve('.cache/tests/gated-intake-')); const lines: string[] = [], output = { write: (line: string) => lines.push(line), error: (_line: string) => {} }; const config = configSchema.parse({ repository: 'owner/repo', iteration: { queue: { labels: ['agent-ready'], trustedAuthors: ['maintainer'], allowedPaths: ['src'] }, strategyGate: { suite: 'core-suite', baselineProfile: 'baseline', candidateProfile: 'candidate' } } }); const deps = { signal: new AbortController().signal, config, goals: { list: async () => [] }, candidates: new FileCandidateStore(root), github: { target: async () => ({ branch: 'main', sha: 'a'.repeat(40) }), issues: async () => [{ number: 7, title: 'Reject negative quantities', body: 'Negative quantities must fail.', state: 'open', user: { login: 'maintainer' }, labels: [{ name: 'agent-ready' }] }] } } as unknown as AutomationDependencies & { candidates: FileCandidateStore }; assert.equal(await executeGoals('iterate', undefined, undefined, { once: true }, deps, output), 1); assert.equal(JSON.parse(lines.at(-1)!).strategyGate.eligible, false); assert.equal((await deps.candidates.list())[0]?.status, 'observed'); });