diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e1a253aed8..41a97ea7a5 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -162,6 +162,7 @@ Options: --quiet, -q Reduce log verbosity to warnings and errors only --verbose, -v Show debug-level output --json Output machine-readable JSON (list/status/get/runs/approve/reject/abandon/resume) + --events For verbose JSON status/get: output raw event rows instead of node summaries --detach Run 'workflow run' in a detached background child (returns immediately) --all For 'workflow runs': list across all projects (ignore cwd scope) --status For 'workflow runs': filter to one status (running, completed, failed, ...) @@ -286,6 +287,7 @@ async function main(): Promise { quiet: { type: 'boolean', short: 'q' }, verbose: { type: 'boolean', short: 'v' }, json: { type: 'boolean' }, + events: { type: 'boolean' }, 'run-id': { type: 'string' }, type: { type: 'string' }, data: { type: 'string' }, @@ -585,13 +587,17 @@ async function main(): Promise { } case 'status': - await workflowStatusCommand(jsonFlag, values.verbose as boolean | undefined); + await workflowStatusCommand( + jsonFlag, + values.verbose as boolean | undefined, + values.events as boolean | undefined + ); break; case 'get': { const getRunId = positionals[2]; if (!getRunId) { - console.error('Usage: archon workflow get [--json] [--verbose]'); + console.error('Usage: archon workflow get [--json] [--verbose] [--events]'); return 1; } // Propagate the command's exit code so `get && ...` and CI @@ -600,7 +606,8 @@ async function main(): Promise { getRunId, jsonFlag, values.verbose as boolean | undefined, - effectiveCwd + effectiveCwd, + values.events as boolean | undefined ); } diff --git a/packages/cli/src/commands/workflow.test.ts b/packages/cli/src/commands/workflow.test.ts index 6aa10074ec..ace7d9e902 100644 --- a/packages/cli/src/commands/workflow.test.ts +++ b/packages/cli/src/commands/workflow.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { WorkflowEmitterEvent } from '@archon/workflows/event-emitter'; import { makeTestWorkflow, makeTestWorkflowWithSource } from '@archon/workflows/test-utils'; +import type { WorkflowEventRow } from '@archon/core/schemas/workflow-event'; import { workflowListCommand, workflowRunCommand, @@ -23,6 +24,7 @@ import { maybePrintTierNotice, resolveContainerBackendConfig, hasUnresolvedWriteback, + buildNodeSummaries, } from './workflow'; const mockLogger = { @@ -2436,6 +2438,94 @@ describe('workflowRunCommand', () => { }); }); +const VERBOSE_EVENTS_FIXTURE: WorkflowEventRow[] = [ + { + id: 'event-without-node', + workflow_run_id: 'run-verbose-json', + event_type: 'tool_completed', + step_name: null, + step_index: null, + data: {}, + created_at: '2026-08-03T10:00:00.000Z', + }, + { + id: 'zeta-started', + workflow_run_id: 'run-verbose-json', + event_type: 'node_started', + step_name: 'zeta', + step_index: 0, + data: {}, + created_at: '2026-08-03T10:00:01.000Z', + }, + { + id: 'alpha-started', + workflow_run_id: 'run-verbose-json', + event_type: 'node_started', + step_name: 'alpha', + step_index: 1, + data: {}, + created_at: '2026-08-03T10:00:02.000Z', + }, + { + id: 'middle-skipped', + workflow_run_id: 'run-verbose-json', + event_type: 'node_skipped_prior_success', + step_name: 'middle', + step_index: 2, + data: {}, + created_at: '2026-08-03T10:00:03.000Z', + }, + { + id: 'zeta-completed', + workflow_run_id: 'run-verbose-json', + event_type: 'node_completed', + step_name: 'zeta', + step_index: 0, + data: { node_output: 'x'.repeat(200) }, + created_at: '2026-08-03T10:00:04.000Z', + }, + { + id: 'alpha-failed', + workflow_run_id: 'run-verbose-json', + event_type: 'node_failed', + step_name: 'alpha', + step_index: 1, + data: {}, + created_at: '2026-08-03T10:00:07.000Z', + }, + { + id: 'beta-started', + workflow_run_id: 'run-verbose-json', + event_type: 'node_started', + step_name: 'beta', + step_index: 3, + data: {}, + created_at: '2026-08-03T10:00:08.000Z', + }, + { + id: 'orphan-completed', + workflow_run_id: 'run-verbose-json', + event_type: 'node_completed', + step_name: 'orphan', + step_index: 4, + data: { node_output: 'y'.repeat(201) }, + created_at: '2026-08-03T10:00:09.000Z', + }, + { + id: 'plain-skipped', + workflow_run_id: 'run-verbose-json', + event_type: 'node_skipped', + step_name: 'skip-plain', + step_index: 5, + data: {}, + created_at: '2026-08-03T10:00:10.000Z', + }, +]; + +const EXPECTED_VERBOSE_NODES = JSON.parse( + JSON.stringify(buildNodeSummaries(VERBOSE_EVENTS_FIXTURE)) +) as Array>; + describe('workflowStatusCommand', () => { let consoleSpy: ReturnType; @@ -2596,37 +2686,113 @@ describe('workflowStatusCommand', () => { expect(calls.some(c => c.includes('Nodes:'))).toBe(false); }); - it('should include events in JSON verbose output', async () => { + it('emits the shared ordered node summaries in verbose JSON by default', async () => { const workflowDb = await import('@archon/core/db/workflows'); const workflowEventsDb = await import('@archon/core/db/workflow-events'); (workflowDb.listWorkflowRuns as ReturnType).mockResolvedValueOnce([ { - id: 'run-json', + id: 'run-verbose-json', workflow_name: 'implement', working_path: '/path/to/worktree', status: 'running', started_at: new Date(), }, ]); - const fakeEvent = { - id: 'ev1', - workflow_run_id: 'run-json', - event_type: 'node_started', - step_name: 'plan', - step_index: null, - data: {}, - created_at: new Date().toISOString(), + (workflowEventsDb.listWorkflowEvents as ReturnType).mockResolvedValueOnce( + VERBOSE_EVENTS_FIXTURE + ); + + await workflowStatusCommand(true, true); + + const jsonOutput = consoleSpy.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(jsonOutput) as { + runs: Array<{ nodes: Array>; events?: unknown[] }>; }; - (workflowEventsDb.listWorkflowEvents as ReturnType).mockResolvedValueOnce([ - fakeEvent, + expect(parsed.runs[0]?.nodes).toEqual(EXPECTED_VERBOSE_NODES); + expect(parsed.runs[0]?.events).toBeUndefined(); + expect(parsed.runs[0]?.nodes.map(node => node.nodeId)).toEqual([ + 'zeta', + 'alpha', + 'middle', + 'beta', + 'orphan', + 'skip-plain', + ]); + + const [zeta, alpha, middle, beta, orphan] = parsed.runs[0]?.nodes ?? []; + expect(zeta).toMatchObject({ + state: 'completed', + startedAt: '2026-08-03T10:00:01.000Z', + durationMs: 3_000, + outputPreview: 'x'.repeat(200), + }); + expect(alpha).toMatchObject({ + state: 'failed', + startedAt: '2026-08-03T10:00:02.000Z', + durationMs: 5_000, + error: 'Unknown error', + }); + expect(middle?.startedAt).toBeUndefined(); + expect(middle?.durationMs).toBeUndefined(); + expect(beta).toMatchObject({ + state: 'running', + startedAt: '2026-08-03T10:00:08.000Z', + }); + expect(beta?.durationMs).toBeUndefined(); + expect(orphan?.startedAt).toBeUndefined(); + expect(orphan?.durationMs).toBeUndefined(); + expect(orphan?.outputPreview).toBe(`${'y'.repeat(200)}...`); + expect(String(orphan?.outputPreview)).not.toContain('…'); + }); + + it('emits raw events in verbose JSON when events=true', async () => { + const workflowDb = await import('@archon/core/db/workflows'); + const workflowEventsDb = await import('@archon/core/db/workflow-events'); + (workflowDb.listWorkflowRuns as ReturnType).mockResolvedValueOnce([ + { + id: 'run-verbose-json', + workflow_name: 'implement', + working_path: '/path/to/worktree', + status: 'running', + started_at: new Date(), + }, + ]); + (workflowEventsDb.listWorkflowEvents as ReturnType).mockResolvedValueOnce( + VERBOSE_EVENTS_FIXTURE + ); + + await workflowStatusCommand(true, true, true); + + const parsed = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string) as { + runs: Array<{ events: WorkflowEventRow[]; nodes?: unknown[] }>; + }; + expect(parsed.runs[0]?.events).toEqual(VERBOSE_EVENTS_FIXTURE); + expect(parsed.runs[0]?.nodes).toBeUndefined(); + }); + + it('degrades a verbose JSON event-query failure to an empty node payload', async () => { + const workflowDb = await import('@archon/core/db/workflows'); + const workflowEventsDb = await import('@archon/core/db/workflow-events'); + (workflowDb.listWorkflowRuns as ReturnType).mockResolvedValueOnce([ + { + id: 'run-unavailable', + workflow_name: 'implement', + working_path: '/path/to/worktree', + status: 'running', + started_at: new Date(), + }, ]); + (workflowEventsDb.listWorkflowEvents as ReturnType).mockRejectedValueOnce( + new Error('events unavailable') + ); await workflowStatusCommand(true, true); - const jsonOutput = consoleSpy.mock.calls[0]?.[0] as string; - const parsed = JSON.parse(jsonOutput) as { runs: Array<{ events: unknown[] }> }; - expect(parsed.runs[0].events).toHaveLength(1); + const parsed = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string) as { + runs: Array<{ nodes: unknown[] }>; + }; + expect(parsed.runs[0]?.nodes).toEqual([]); }); }); @@ -2795,7 +2961,7 @@ describe('workflowGetCommand', () => { ); }); - it('attaches events in verbose JSON mode', async () => { + it('emits the same shared node summaries in verbose JSON by default', async () => { const workflowDb = await import('@archon/core/db/workflows'); const eventsDb = await import('@archon/core/db/workflow-events'); (workflowDb.getWorkflowRun as ReturnType).mockResolvedValueOnce({ @@ -2806,20 +2972,67 @@ describe('workflowGetCommand', () => { started_at: new Date(), metadata: {}, }); - (eventsDb.listWorkflowEvents as ReturnType).mockResolvedValueOnce([ - { - event_type: 'node_started', - step_name: 'plan', - created_at: new Date().toISOString(), - data: {}, - }, - ]); + (eventsDb.listWorkflowEvents as ReturnType).mockResolvedValueOnce( + VERBOSE_EVENTS_FIXTURE + ); await workflowGetCommand('run-v', true, true); + const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) as { + nodes: Array>; + events?: unknown[]; + }; + expect(parsed.nodes).toEqual(EXPECTED_VERBOSE_NODES); + expect(parsed.nodes.map(node => node.state)).toEqual( + buildNodeSummaries(VERBOSE_EVENTS_FIXTURE).map(node => node.state) + ); + expect(parsed.events).toBeUndefined(); + }); + + it('emits raw events in verbose JSON when events=true', async () => { + const workflowDb = await import('@archon/core/db/workflows'); + const eventsDb = await import('@archon/core/db/workflow-events'); + (workflowDb.getWorkflowRun as ReturnType).mockResolvedValueOnce({ + id: 'run-v', + workflow_name: 'implement', + status: 'running', + working_path: '/tmp/wt', + started_at: new Date(), + metadata: {}, + }); + (eventsDb.listWorkflowEvents as ReturnType).mockResolvedValueOnce( + VERBOSE_EVENTS_FIXTURE + ); + + await workflowGetCommand('run-v', true, true, undefined, true); + + const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) as { + events: WorkflowEventRow[]; + nodes?: unknown[]; + }; + expect(parsed.events).toEqual(VERBOSE_EVENTS_FIXTURE); + expect(parsed.nodes).toBeUndefined(); + }); + + it('degrades a raw verbose JSON event-query failure to an empty events payload', async () => { + const workflowDb = await import('@archon/core/db/workflows'); + const eventsDb = await import('@archon/core/db/workflow-events'); + (workflowDb.getWorkflowRun as ReturnType).mockResolvedValueOnce({ + id: 'run-v', + workflow_name: 'implement', + status: 'running', + working_path: '/tmp/wt', + started_at: new Date(), + metadata: {}, + }); + (eventsDb.listWorkflowEvents as ReturnType).mockRejectedValueOnce( + new Error('events unavailable') + ); + + await workflowGetCommand('run-v', true, true, undefined, true); + const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) as { events: unknown[] }; - expect(Array.isArray(parsed.events)).toBe(true); - expect(parsed.events).toHaveLength(1); + expect(parsed.events).toEqual([]); }); }); diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index 0ddbc17cb3..44916d1d5f 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -97,6 +97,16 @@ function readDetachedLogTail(path: string): string | null { } } +function closeLogFile(logFd: number | undefined): void { + if (logFd === undefined) return; + + try { + closeSync(logFd); + } catch { + /* fd already closed/invalid — nothing to clean up */ + } +} + function detachedStartupExitError( code: number | null, signal: NodeJS.Signals | null, @@ -337,13 +347,7 @@ async function spawnDetachedWorkflowRun( `\n--- detached workflow invocation: ${conversationId} at ${new Date().toISOString()} ---\n` ); } catch (error) { - if (logFd !== undefined) { - try { - closeSync(logFd); - } catch { - /* fd already closed/invalid — nothing to clean up */ - } - } + closeLogFile(logFd); getLog().warn({ err: error as Error }, 'cli.detached_run_log_open_failed'); logPath = null; logFd = undefined; @@ -382,13 +386,7 @@ async function spawnDetachedWorkflowRun( } finally { // The child inherits its own dup of the log fd; close the parent's copy so a // synchronous spawn failure (bad execPath, invalid cwd) doesn't leak it. - if (logFd !== undefined) { - try { - closeSync(logFd); - } catch { - /* fd already closed/invalid — nothing to clean up */ - } - } + closeLogFile(logFd); } return logPath; } @@ -1983,9 +1981,10 @@ function formatDuration(ms: number): string { return `${mins}m${remSecs}s`; } -interface NodeSummary { +export interface NodeSummary { nodeId: string; state: 'running' | 'completed' | 'failed' | 'skipped'; + startedAt?: string; durationMs?: number; outputPreview?: string; error?: string; @@ -1995,7 +1994,7 @@ interface NodeSummary { * Derive per-node summaries from a run's workflow events. * Processes node_started / node_completed / node_failed / node_skipped* events. */ -function buildNodeSummaries(events: WorkflowEventRow[]): NodeSummary[] { +export function buildNodeSummaries(events: WorkflowEventRow[]): NodeSummary[] { const startTimes = new Map(); const summaries = new Map(); @@ -2006,8 +2005,11 @@ function buildNodeSummaries(events: WorkflowEventRow[]): NodeSummary[] { switch (event.event_type) { case 'node_started': { startTimes.set(nodeId, new Date(event.created_at).getTime()); - if (!summaries.has(nodeId)) { - summaries.set(nodeId, { nodeId, state: 'running' }); + const existing = summaries.get(nodeId); + if (existing) { + existing.startedAt = event.created_at; + } else { + summaries.set(nodeId, { nodeId, state: 'running', startedAt: event.created_at }); } break; } @@ -2019,6 +2021,7 @@ function buildNodeSummaries(events: WorkflowEventRow[]): NodeSummary[] { summaries.set(nodeId, { nodeId, state: 'completed', + startedAt: summaries.get(nodeId)?.startedAt, durationMs: started !== undefined ? endTime - started : undefined, outputPreview: output !== undefined @@ -2033,6 +2036,7 @@ function buildNodeSummaries(events: WorkflowEventRow[]): NodeSummary[] { summaries.set(nodeId, { nodeId, state: 'failed', + startedAt: summaries.get(nodeId)?.startedAt, durationMs: started !== undefined ? endTime - started : undefined, error: typeof event.data.error === 'string' ? event.data.error : 'Unknown error', }); @@ -2054,7 +2058,7 @@ function buildNodeSummaries(events: WorkflowEventRow[]): NodeSummary[] { * abort the command (the run summary itself is still useful), but it must NOT be * indistinguishable from "this run has no events" — so log a warn and flag the * failure to the caller, which prints a visible note. (In `--json` mode logs are - * silenced; the empty `events` array is the documented signal there.) + * silenced; an empty derived/raw payload is the documented signal there.) */ async function fetchVerboseEvents( runId: string @@ -2099,7 +2103,11 @@ function printVerboseNodes(events: WorkflowEventRow[]): void { /** * Show status of all running workflow runs. */ -export async function workflowStatusCommand(json?: boolean, verbose?: boolean): Promise { +export async function workflowStatusCommand( + json?: boolean, + verbose?: boolean, + events?: boolean +): Promise { let runs: WorkflowRun[]; try { const result = await getWorkflowStatus(); @@ -2113,12 +2121,13 @@ export async function workflowStatusCommand(json?: boolean, verbose?: boolean): if (json) { let runsOutput: unknown[] = runs; if (verbose) { - const eventsPerRun = await Promise.all( - runs.map(run => - workflowEventsDb.listWorkflowEvents(run.id).catch(() => [] as WorkflowEventRow[]) - ) - ); - runsOutput = runs.map((run, i) => ({ ...run, events: eventsPerRun[i] })); + const fetchedPerRun = await Promise.all(runs.map(run => fetchVerboseEvents(run.id))); + runsOutput = runs.map((run, i) => { + const runEvents = fetchedPerRun[i]?.events ?? []; + return events + ? { ...run, events: runEvents } + : { ...run, nodes: buildNodeSummaries(runEvents) }; + }); } console.log(JSON.stringify({ runs: runsOutput }, null, 2)); return; @@ -2155,8 +2164,8 @@ export async function workflowStatusCommand(json?: boolean, verbose?: boolean): * * Unlike `status` (active runs only), this resolves one run regardless of * status — so an agent can answer "did the review pass?" for a completed/failed - * run. `--verbose` adds the per-node event summary; `--json` emits the raw run - * (plus an `events` array when verbose). + * run. `--verbose` adds the per-node summary; `--json` emits the raw run plus a + * `nodes` array when verbose (`--events` selects raw event rows instead). * * `runId` may be the short id printed by `workflow runs` (see resolveRunIdArg). */ @@ -2164,7 +2173,8 @@ export async function workflowGetCommand( runId: string, json?: boolean, verbose?: boolean, - cwd?: string + cwd?: string, + rawEvents?: boolean ): Promise { let run: WorkflowRun | null; try { @@ -2204,7 +2214,15 @@ export async function workflowGetCommand( } if (json) { - const output = verbose ? { ...run, events: events ?? [] } : run; + const verboseEvents = events ?? []; + let output: unknown; + if (!verbose) { + output = run; + } else if (rawEvents) { + output = { ...run, events: verboseEvents }; + } else { + output = { ...run, nodes: buildNodeSummaries(verboseEvents) }; + } console.log(JSON.stringify(output, null, 2)); return 0; } diff --git a/packages/core/src/db/workflow-events.since.integration.test.ts b/packages/core/src/db/workflow-events.since.integration.test.ts index 0b55e62ebe..f0f5c1792e 100644 --- a/packages/core/src/db/workflow-events.since.integration.test.ts +++ b/packages/core/src/db/workflow-events.since.integration.test.ts @@ -32,7 +32,8 @@ mock.module('./connection', () => ({ getDatabaseType: () => 'sqlite', })); -const { listWorkflowEventsSince, createWorkflowEvent } = await import('./workflow-events'); +const { listWorkflowEvents, listWorkflowEventsSince, createWorkflowEvent } = + await import('./workflow-events'); // workflow_events.workflow_run_id has an enforced FK (PRAGMA foreign_keys = ON) — seed parents. await db.query( @@ -50,6 +51,22 @@ await db.query( const minuteAgo = (): Date => new Date(Date.now() - 60_000); describe('listWorkflowEventsSince — real SQLite (catches the C1 datetime mismatch)', () => { + test('orders events with identical timestamps by ID', async () => { + const createdAt = '2025-01-01 00:00:00'; + await db.query( + `INSERT INTO remote_agent_workflow_events (id, workflow_run_id, event_type, data, created_at) + VALUES + ('tied-event-b', 'run-1', 'node_started', '{}', $1), + ('tied-event-a', 'run-1', 'node_started', '{}', $1)`, + [createdAt] + ); + + const rows = await listWorkflowEvents('run-1'); + const tiedRows = rows.filter(row => row.created_at === createdAt); + + expect(tiedRows.map(row => row.id)).toEqual(['tied-event-a', 'tied-event-b']); + }); + test('returns an event stored via datetime() when queried with an ISO Date cursor', async () => { await createWorkflowEvent({ workflow_run_id: 'run-1', diff --git a/packages/core/src/db/workflow-events.test.ts b/packages/core/src/db/workflow-events.test.ts index dc753380cd..65671abd24 100644 --- a/packages/core/src/db/workflow-events.test.ts +++ b/packages/core/src/db/workflow-events.test.ts @@ -117,7 +117,7 @@ describe('workflow-events', () => { expect(mockQuery).toHaveBeenCalledWith( `SELECT * FROM remote_agent_workflow_events WHERE workflow_run_id = $1 - ORDER BY created_at ASC`, + ORDER BY created_at ASC, id ASC`, ['run-456'] ); }); @@ -167,7 +167,7 @@ describe('workflow-events', () => { expect(mockQuery).toHaveBeenCalledWith( `SELECT * FROM remote_agent_workflow_events WHERE workflow_run_id = $1 - ORDER BY created_at ASC`, + ORDER BY created_at ASC, id ASC`, ['run-456'] ); }); diff --git a/packages/core/src/db/workflow-events.ts b/packages/core/src/db/workflow-events.ts index e8c1a397e6..e5e0274a3a 100644 --- a/packages/core/src/db/workflow-events.ts +++ b/packages/core/src/db/workflow-events.ts @@ -116,14 +116,14 @@ export async function createWorkflowEvent(data: WorkflowEventInput): Promise { try { const result = await pool.query( `SELECT * FROM remote_agent_workflow_events WHERE workflow_run_id = $1 - ORDER BY created_at ASC`, + ORDER BY created_at ASC, id ASC`, [workflowRunId] ); return [...result.rows].map(row => ({ diff --git a/packages/docs-web/src/content/docs/reference/cli.md b/packages/docs-web/src/content/docs/reference/cli.md index 915ad7c6b5..1a72cc17bb 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -292,6 +292,7 @@ Show **active** workflow runs (running and paused) across all worktrees. For ful archon workflow status archon workflow status --json archon workflow status --verbose # add a per-node summary for each run +archon workflow status --json --verbose ``` ### `workflow runs` @@ -317,9 +318,20 @@ Show detail for a single run by ID, regardless of status (unlike `status`, which ```bash archon workflow get archon workflow get --json -archon workflow get --verbose # add the per-node event summary +archon workflow get --verbose # add the per-node summary +archon workflow get --json --verbose ``` +For both commands, `--json --verbose` adds a `nodes` array. Nodes are ordered by the +first appearance of each node in the event stream. Every entry includes `nodeId` and +`state`; nodes with a start event include the original ISO `startedAt`, and terminal +nodes with both start and end events include `durationMs`. Completed nodes may include +an `outputPreview`, truncated after 200 characters with ASCII `...`, while failed nodes +include `error` (or `Unknown error` when none was recorded). + +Add `--events` to `--json --verbose` to return raw `events` rows instead of `nodes` for +debugging. Raw events are not the recommended integration surface. + ### `workflow resume` Resume a failed workflow run. Re-executes the workflow, automatically skipping nodes that completed in the prior run.