diff --git a/migrations/000_combined.sql b/migrations/000_combined.sql index 4686b10257..c28543edb8 100644 --- a/migrations/000_combined.sql +++ b/migrations/000_combined.sql @@ -265,6 +265,7 @@ COMMENT ON TABLE remote_agent_workflow_runs IS CREATE TABLE IF NOT EXISTS remote_agent_workflow_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workflow_run_id UUID NOT NULL REFERENCES remote_agent_workflow_runs(id) ON DELETE CASCADE, + event_order BIGINT, event_type VARCHAR(50) NOT NULL, step_index INTEGER, step_name VARCHAR(255), @@ -280,6 +281,9 @@ CREATE INDEX IF NOT EXISTS idx_workflow_events_type -- (WHERE created_at >= $1 ORDER BY created_at ASC). CREATE INDEX IF NOT EXISTS idx_workflow_events_created_at ON remote_agent_workflow_events(created_at); +CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_events_run_order + ON remote_agent_workflow_events(workflow_run_id, event_order) + WHERE event_order IS NOT NULL; COMMENT ON TABLE remote_agent_workflow_events IS 'Lean UI-relevant workflow events for observability (step transitions, artifacts, errors)'; @@ -517,6 +521,32 @@ ALTER TABLE remote_agent_user_ai_prefs ALTER TABLE remote_agent_users ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin'; +-- Lifecycle ordering (#2359 follow-up): timestamps can tie, especially on +-- SQLite (one-second precision), so a database-assigned order breaks the tie and +-- preserves event chronology. `id` cannot serve this role — it is a random UUID, +-- not monotonic. +-- +-- Deliberately a plain column plus a sequence DEFAULT, NOT `GENERATED ... AS +-- IDENTITY`. Adding an identity column REWRITES the whole table under ACCESS +-- EXCLUSIVE (verified on postgres:18: relfilenode changes), and this is the +-- largest table in the schema while the schema auto-applies on startup — that is +-- a boot-time stall proportional to event history. ADD COLUMN with no default is +-- metadata-only, and SET DEFAULT afterwards applies to future inserts only. +-- +-- It also keeps both databases honest: existing rows stay NULL on Postgres AND +-- SQLite, so the COALESCE(event_order, 0) fallback in read queries behaves +-- identically. An identity column would have back-filled Postgres rows (1, 2, +-- 3...) while SQLite left them NULL. +ALTER TABLE remote_agent_workflow_events + ADD COLUMN IF NOT EXISTS event_order BIGINT; +CREATE SEQUENCE IF NOT EXISTS remote_agent_workflow_events_event_order_seq + OWNED BY remote_agent_workflow_events.event_order; +ALTER TABLE remote_agent_workflow_events + ALTER COLUMN event_order SET DEFAULT nextval('remote_agent_workflow_events_event_order_seq'); +CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_events_run_order + ON remote_agent_workflow_events(workflow_run_id, event_order) + WHERE event_order IS NOT NULL; + -- ============================================================================ -- Schema vintage (#2316) -- ============================================================================ diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f404ead29d..1a1f78dc6b 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 beebed61ba..952436e7ce 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 = { @@ -2465,6 +2467,135 @@ describe('workflowRunCommand', () => { }); }); +const VERBOSE_EVENTS_FIXTURE: WorkflowEventRow[] = [ + { + id: 'event-without-node', + workflow_run_id: 'run-verbose-json', + event_type: 'tool_completed', + step_name: 'ignored-tool', + 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('buildNodeSummaries', () => { + it('resets a retried node to its current running attempt', () => { + const summaries = buildNodeSummaries([ + { + id: 'retry-start-1', + workflow_run_id: 'run-retry', + event_type: 'node_started', + step_index: 0, + step_name: 'build', + data: {}, + created_at: '2026-08-03T10:00:00.000Z', + event_order: 1, + }, + { + id: 'retry-failed', + workflow_run_id: 'run-retry', + event_type: 'node_failed', + step_index: 0, + step_name: 'build', + data: { error: 'temporary failure' }, + created_at: '2026-08-03T10:00:01.000Z', + event_order: 2, + }, + { + id: 'retry-start-2', + workflow_run_id: 'run-retry', + event_type: 'node_started', + step_index: 0, + step_name: 'build', + data: {}, + created_at: '2026-08-03T10:00:02.000Z', + event_order: 3, + }, + ]); + + expect(summaries).toEqual([ + { nodeId: 'build', state: 'running', startedAt: '2026-08-03T10:00:02.000Z' }, + ]); + }); +}); + describe('workflowStatusCommand', () => { let consoleSpy: ReturnType; let stdoutSpy: ReturnType; @@ -2631,37 +2762,112 @@ 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 parsed = JSON.parse(firstJsonPayload(stdoutSpy)) 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(firstJsonPayload(stdoutSpy)) 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 = stdoutSpy.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(firstJsonPayload(stdoutSpy)) as { + runs: Array<{ nodes: unknown[] }>; + }; + expect(parsed.runs[0]?.nodes).toEqual([]); }); }); @@ -2833,7 +3039,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({ @@ -2844,20 +3050,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(stdoutSpy.mock.calls[0][0] as string) as { events: unknown[] }; - expect(Array.isArray(parsed.events)).toBe(true); - expect(parsed.events).toHaveLength(1); + const parsed = JSON.parse(firstJsonPayload(stdoutSpy)) 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(firstJsonPayload(stdoutSpy)) 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(firstJsonPayload(stdoutSpy)) as { events: unknown[] }; + expect(parsed.events).toEqual([]); }); }); diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index 047c8eb884..e89ba872fc 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -1978,9 +1978,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; @@ -1990,7 +1991,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(); @@ -2001,9 +2002,9 @@ 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' }); - } + // A retry is a new active attempt, so stale terminal details must not + // leak into the compact current-state summary. + summaries.set(nodeId, { nodeId, state: 'running', startedAt: event.created_at }); break; } case 'node_completed': { @@ -2014,6 +2015,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 @@ -2028,6 +2030,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', }); @@ -2049,7 +2052,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 @@ -2094,7 +2097,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, + rawEvents?: boolean +): Promise { let runs: WorkflowRun[]; try { const result = await getWorkflowStatus(); @@ -2106,15 +2113,18 @@ 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] })); + if (!verbose) { + await writeJsonLine({ runs }); + return; } + + const fetchedPerRun = await Promise.all(runs.map(run => fetchVerboseEvents(run.id))); + const runsOutput = runs.map((run, i) => { + const runEvents = fetchedPerRun[i]?.events ?? []; + return rawEvents + ? { ...run, events: runEvents } + : { ...run, nodes: buildNodeSummaries(runEvents) }; + }); await writeJsonLine({ runs: runsOutput }); return; } @@ -2150,8 +2160,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). */ @@ -2159,7 +2169,8 @@ export async function workflowGetCommand( runId: string, json?: boolean, verbose?: boolean, - cwd?: string + cwd?: string, + rawEvents?: boolean ): Promise { let run: WorkflowRun | null; try { @@ -2199,7 +2210,15 @@ export async function workflowGetCommand( } if (json) { - const output = verbose ? { ...run, events: events ?? [] } : run; + if (!verbose) { + await writeJsonLine(run); + return 0; + } + + const verboseEvents = events ?? []; + const output = rawEvents + ? { ...run, events: verboseEvents } + : { ...run, nodes: buildNodeSummaries(verboseEvents) }; await writeJsonLine(output); return 0; } diff --git a/packages/core/src/db/adapters/sqlite.ts b/packages/core/src/db/adapters/sqlite.ts index f8304125f1..6594694db2 100644 --- a/packages/core/src/db/adapters/sqlite.ts +++ b/packages/core/src/db/adapters/sqlite.ts @@ -426,6 +426,38 @@ export class SqliteAdapter implements IDatabase { allApplied = false; } + // Lifecycle ordering: SQLite timestamps have one-second precision. A trigger + // assigns a durable, monotonically increasing value for each inserted event. + try { + const cols = this.db.prepare("PRAGMA table_info('remote_agent_workflow_events')").all() as { + name: string; + }[]; + if (!new Set(cols.map(c => c.name)).has('event_order')) { + this.db.run('ALTER TABLE remote_agent_workflow_events ADD COLUMN event_order INTEGER'); + } + this.db.run( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_events_run_order + ON remote_agent_workflow_events(workflow_run_id, event_order) + WHERE event_order IS NOT NULL` + ); + this.db.run( + `CREATE TRIGGER IF NOT EXISTS remote_agent_workflow_events_assign_order + AFTER INSERT ON remote_agent_workflow_events + WHEN NEW.event_order IS NULL + BEGIN + UPDATE remote_agent_workflow_events + SET event_order = ( + SELECT COALESCE(MAX(event_order), 0) + 1 + FROM remote_agent_workflow_events + ) + WHERE rowid = NEW.rowid; + END` + ); + } catch (e: unknown) { + getLog().warn({ err: e as Error }, 'db.sqlite_migration_workflow_events_columns_failed'); + allApplied = false; + } + // #1955: credential rows are vendor-keyed (claude→anthropic, codex→openai, // copilot→github-copilot). Idempotent data fix mirroring // migrations/000_combined.sql: where both a legacy and a vendor row exist @@ -669,6 +701,7 @@ export class SqliteAdapter implements IDatabase { CREATE TABLE IF NOT EXISTS remote_agent_workflow_events ( id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), workflow_run_id TEXT NOT NULL REFERENCES remote_agent_workflow_runs(id) ON DELETE CASCADE, + event_order INTEGER, event_type TEXT NOT NULL, step_index INTEGER, step_name TEXT, @@ -712,6 +745,20 @@ export class SqliteAdapter implements IDatabase { CREATE INDEX IF NOT EXISTS idx_workflow_events_run_id ON remote_agent_workflow_events(workflow_run_id); CREATE INDEX IF NOT EXISTS idx_workflow_events_type ON remote_agent_workflow_events(event_type); CREATE INDEX IF NOT EXISTS idx_workflow_events_created_at ON remote_agent_workflow_events(created_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_events_run_order + ON remote_agent_workflow_events(workflow_run_id, event_order) + WHERE event_order IS NOT NULL; + CREATE TRIGGER IF NOT EXISTS remote_agent_workflow_events_assign_order + AFTER INSERT ON remote_agent_workflow_events + WHEN NEW.event_order IS NULL + BEGIN + UPDATE remote_agent_workflow_events + SET event_order = ( + SELECT COALESCE(MAX(event_order), 0) + 1 + FROM remote_agent_workflow_events + ) + WHERE rowid = NEW.rowid; + END; CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON remote_agent_messages(conversation_id, created_at ASC); CREATE INDEX IF NOT EXISTS idx_workflow_node_sessions_scope ON remote_agent_workflow_node_sessions(scope_key); CREATE INDEX IF NOT EXISTS idx_workflow_node_sessions_workflow ON remote_agent_workflow_node_sessions(workflow_name); diff --git a/packages/core/src/db/bundled-schema.generated.ts b/packages/core/src/db/bundled-schema.generated.ts index 73e163cfee..aac923fccb 100644 --- a/packages/core/src/db/bundled-schema.generated.ts +++ b/packages/core/src/db/bundled-schema.generated.ts @@ -10,4 +10,4 @@ * the schema on startup without filesystem access to the migrations dir. */ -export const BUNDLED_SCHEMA_SQL = "-- Remote Coding Agent - Combined Schema\n-- Version: Combined (final state after migrations 001-020)\n-- Description: Complete database schema (idempotent - safe to run multiple times)\n--\n-- 14 Tables (+ the remote_agent_auth_* Better Auth tables, listed inline below):\n-- 1. remote_agent_codebases\n-- 1b. remote_agent_codebase_env_vars\n-- 1c. remote_agent_users\n-- 1d. remote_agent_user_identities\n-- 2. remote_agent_conversations\n-- 3. remote_agent_sessions\n-- 4. remote_agent_isolation_environments\n-- 5. remote_agent_workflow_runs\n-- 6. remote_agent_workflow_events\n-- 6b. remote_agent_workflow_node_sessions\n-- 7. remote_agent_messages\n-- 8. remote_agent_user_github_tokens\n-- 9. remote_agent_user_provider_keys\n-- 10. remote_agent_user_ai_prefs\n--\n-- Dropped tables (via migrations):\n-- - remote_agent_command_templates (017)\n--\n-- Dropped columns (via migrations):\n-- - conversations.worktree_path (007)\n-- - conversations.isolation_env_id_legacy (007)\n-- - conversations.isolation_provider (007)\n\n-- ============================================================================\n-- Table 1: Codebases\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_codebases (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n name VARCHAR(255) NOT NULL,\n repository_url VARCHAR(500),\n default_cwd VARCHAR(500) NOT NULL,\n default_branch VARCHAR(255),\n ai_assistant_type VARCHAR(20) DEFAULT 'claude',\n kind VARCHAR(10) NOT NULL DEFAULT 'repo' CHECK (kind IN ('repo', 'folder')),\n allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE,\n commands JSONB DEFAULT '{}'::jsonb,\n created_at TIMESTAMP DEFAULT NOW(),\n updated_at TIMESTAMP DEFAULT NOW()\n);\n\nCOMMENT ON TABLE remote_agent_codebases IS\n 'Repository metadata: name, URL, working directory, default branch, AI assistant type, and command paths (JSONB)';\n\n-- ============================================================================\n-- Table 1b: Codebase Env Vars\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_codebase_env_vars (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n codebase_id UUID NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE,\n key VARCHAR(255) NOT NULL,\n value TEXT NOT NULL,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(codebase_id, key)\n);\n\nCREATE INDEX IF NOT EXISTS idx_codebase_env_vars_codebase_id\n ON remote_agent_codebase_env_vars(codebase_id);\n\nCOMMENT ON TABLE remote_agent_codebase_env_vars IS\n 'Per-project env vars merged into Options.env on Claude SDK calls. Managed via Web UI or config.';\n\n-- ============================================================================\n-- Table 1c: Users (Archon identity, platform-agnostic)\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n display_name VARCHAR(255),\n email VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()\n);\n\nCOMMENT ON TABLE remote_agent_users IS\n 'Archon-internal user identity. Created on first sight by any adapter; populated via per-platform user-info lookups.';\n\n-- ============================================================================\n-- Table 1d: User Identities (per-platform mapping → users.id)\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_user_identities (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n platform VARCHAR(32) NOT NULL,\n platform_user_id VARCHAR(255) NOT NULL,\n platform_display_name VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(platform, platform_user_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_user_identities_user_id\n ON remote_agent_user_identities(user_id);\n\nCOMMENT ON TABLE remote_agent_user_identities IS\n 'Maps platform-native user IDs (Slack U-ids, Telegram chat ids, GitHub logins, Discord snowflakes) to Archon user UUIDs.';\n\n-- ============================================================================\n-- Table 2: Conversations\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_conversations (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n platform_type VARCHAR(20) NOT NULL,\n platform_conversation_id VARCHAR(255) NOT NULL,\n codebase_id UUID REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,\n cwd VARCHAR(500),\n ai_assistant_type VARCHAR(20) DEFAULT 'claude',\n isolation_env_id UUID, -- FK added after isolation_environments table exists\n title VARCHAR(255),\n deleted_at TIMESTAMP WITH TIME ZONE,\n hidden BOOLEAN DEFAULT FALSE,\n created_at TIMESTAMP DEFAULT NOW(),\n updated_at TIMESTAMP DEFAULT NOW(),\n last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(platform_type, platform_conversation_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_remote_agent_conversations_codebase\n ON remote_agent_conversations(codebase_id);\nCREATE INDEX IF NOT EXISTS idx_conversations_hidden\n ON remote_agent_conversations(hidden);\nCREATE INDEX IF NOT EXISTS idx_conversations_codebase\n ON remote_agent_conversations(codebase_id) WHERE deleted_at IS NULL;\n\nCOMMENT ON COLUMN remote_agent_conversations.isolation_env_id IS\n 'UUID reference to isolation_environments table (the only isolation reference)';\n\n-- ============================================================================\n-- Table 3: Sessions\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_sessions (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,\n codebase_id UUID REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,\n ai_assistant_type VARCHAR(20) NOT NULL,\n assistant_session_id VARCHAR(255),\n active BOOLEAN DEFAULT true,\n metadata JSONB DEFAULT '{}'::jsonb,\n parent_session_id UUID REFERENCES remote_agent_sessions(id),\n transition_reason TEXT,\n ended_reason TEXT,\n started_at TIMESTAMP DEFAULT NOW(),\n ended_at TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_remote_agent_sessions_conversation\n ON remote_agent_sessions(conversation_id, active);\nCREATE INDEX IF NOT EXISTS idx_remote_agent_sessions_codebase\n ON remote_agent_sessions(codebase_id);\nCREATE INDEX IF NOT EXISTS idx_sessions_parent\n ON remote_agent_sessions(parent_session_id);\nCREATE INDEX IF NOT EXISTS idx_sessions_conversation_started\n ON remote_agent_sessions(conversation_id, started_at DESC);\n\nCOMMENT ON COLUMN remote_agent_sessions.parent_session_id IS\n 'Links to the previous session in this conversation (for audit trail)';\nCOMMENT ON COLUMN remote_agent_sessions.transition_reason IS\n 'Why this session was created: plan-to-execute, isolation-changed, reset-requested, etc.';\nCOMMENT ON COLUMN remote_agent_sessions.ended_reason IS\n 'Why this session was deactivated: reset-requested, cwd-changed, conversation-closed, etc.';\n\n-- ============================================================================\n-- Table 4: Isolation Environments\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_isolation_environments (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n codebase_id UUID NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE,\n\n -- Workflow identification (what work this is for)\n workflow_type TEXT NOT NULL, -- 'issue', 'pr', 'review', 'thread', 'task'\n workflow_id TEXT NOT NULL, -- '42', 'pr-99', 'thread-abc123'\n\n -- Implementation details\n provider TEXT NOT NULL DEFAULT 'worktree',\n working_path TEXT NOT NULL, -- Actual filesystem path\n branch_name TEXT NOT NULL, -- Git branch name\n\n -- Lifecycle\n status TEXT NOT NULL DEFAULT 'active', -- 'active', 'destroyed'\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n created_by_platform TEXT, -- 'github', 'slack', etc.\n\n -- Cross-reference metadata (for linking)\n metadata JSONB DEFAULT '{}'\n);\n\n-- Partial unique index: only active environments need uniqueness\nCREATE UNIQUE INDEX IF NOT EXISTS unique_active_workflow\n ON remote_agent_isolation_environments (codebase_id, workflow_type, workflow_id)\n WHERE status = 'active';\n\n-- Indexes for common queries\nCREATE INDEX IF NOT EXISTS idx_isolation_env_codebase\n ON remote_agent_isolation_environments(codebase_id);\nCREATE INDEX IF NOT EXISTS idx_isolation_env_status\n ON remote_agent_isolation_environments(status);\nCREATE INDEX IF NOT EXISTS idx_isolation_env_workflow\n ON remote_agent_isolation_environments(workflow_type, workflow_id);\n\n-- Add FK from conversations to isolation_environments (deferred to avoid circular dependency)\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS isolation_env_id UUID\n REFERENCES remote_agent_isolation_environments(id) ON DELETE SET NULL;\n\nCREATE INDEX IF NOT EXISTS idx_conversations_isolation_env_id\n ON remote_agent_conversations(isolation_env_id);\n\nCOMMENT ON TABLE remote_agent_isolation_environments IS\n 'Work-centric isolated environments with independent lifecycle';\nCOMMENT ON COLUMN remote_agent_isolation_environments.workflow_type IS\n 'Type of work: issue, pr, review, thread, task';\nCOMMENT ON COLUMN remote_agent_isolation_environments.workflow_id IS\n 'Identifier for the work (issue number, PR number, thread hash, etc.)';\n\n-- ============================================================================\n-- Table 5: Workflow Runs\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_workflow_runs (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n workflow_name VARCHAR(255) NOT NULL,\n conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,\n codebase_id UUID REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,\n current_step_index INTEGER,\n status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, running, completed, failed, cancelled, paused\n user_message TEXT NOT NULL,\n metadata JSONB DEFAULT '{}',\n parent_conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE SET NULL,\n parent_run_id UUID REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL,\n started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n completed_at TIMESTAMP WITH TIME ZONE,\n last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n working_path TEXT\n);\n\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_conversation\n ON remote_agent_workflow_runs(conversation_id);\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_status\n ON remote_agent_workflow_runs(status);\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_conv\n ON remote_agent_workflow_runs(parent_conversation_id);\n\n-- Partial index for efficient staleness queries on running workflows\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_last_activity\n ON remote_agent_workflow_runs(last_activity_at)\n WHERE status = 'running';\n\nCOMMENT ON TABLE remote_agent_workflow_runs IS\n 'Tracks workflow execution state for resumption and observability';\n\n-- ============================================================================\n-- Table 6: Workflow Events\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_workflow_events (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n workflow_run_id UUID NOT NULL REFERENCES remote_agent_workflow_runs(id) ON DELETE CASCADE,\n event_type VARCHAR(50) NOT NULL,\n step_index INTEGER,\n step_name VARCHAR(255),\n data JSONB DEFAULT '{}',\n created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_workflow_events_run_id\n ON remote_agent_workflow_events(workflow_run_id);\nCREATE INDEX IF NOT EXISTS idx_workflow_events_type\n ON remote_agent_workflow_events(event_type);\n-- Global created_at index for the dashboard event poller's cross-run tail\n-- (WHERE created_at >= $1 ORDER BY created_at ASC).\nCREATE INDEX IF NOT EXISTS idx_workflow_events_created_at\n ON remote_agent_workflow_events(created_at);\n\nCOMMENT ON TABLE remote_agent_workflow_events IS\n 'Lean UI-relevant workflow events for observability (step transitions, artifacts, errors)';\n\n-- ============================================================================\n-- Workflow node sessions (persist_session opt-in across re-runs)\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_workflow_node_sessions (\n workflow_name VARCHAR(255) NOT NULL,\n node_id VARCHAR(255) NOT NULL,\n scope_key TEXT NOT NULL,\n provider VARCHAR(50) NOT NULL,\n provider_session_id TEXT NOT NULL,\n last_run_id UUID REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n PRIMARY KEY (workflow_name, node_id, scope_key, provider)\n);\n\nCREATE INDEX IF NOT EXISTS idx_workflow_node_sessions_scope\n ON remote_agent_workflow_node_sessions(scope_key);\nCREATE INDEX IF NOT EXISTS idx_workflow_node_sessions_workflow\n ON remote_agent_workflow_node_sessions(workflow_name);\n\nCOMMENT ON TABLE remote_agent_workflow_node_sessions IS\n 'Per-node provider session IDs persisted across workflow re-runs. Keyed by (workflow, node, scope, provider). Scope is typically conversation UUID. No cascade on conversation delete (soft delete + never-reused UUID = harmless orphans); a future hard-delete path must delete by scope_key.';\n\n-- ============================================================================\n-- Table 7: Messages\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_messages (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n conversation_id UUID NOT NULL REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,\n role VARCHAR(20) NOT NULL,\n content TEXT NOT NULL DEFAULT '',\n metadata JSONB DEFAULT '{}'::jsonb,\n created_at TIMESTAMP DEFAULT NOW()\n);\n\nCREATE INDEX IF NOT EXISTS idx_messages_conversation_id\n ON remote_agent_messages(conversation_id, created_at ASC);\n\n-- ============================================================================\n-- Cleanup: Drop legacy objects from older schemas\n-- ============================================================================\n\n-- Drop command_templates table (replaced by file-based commands in .archon/commands)\nDROP TABLE IF EXISTS remote_agent_command_templates;\nDROP INDEX IF EXISTS idx_remote_agent_command_templates_name;\n\n-- Drop legacy columns from conversations (if upgrading from older schema)\nALTER TABLE remote_agent_conversations DROP COLUMN IF EXISTS worktree_path;\nALTER TABLE remote_agent_conversations DROP COLUMN IF EXISTS isolation_env_id_legacy;\nALTER TABLE remote_agent_conversations DROP COLUMN IF EXISTS isolation_provider;\nDROP INDEX IF EXISTS idx_conversations_isolation;\n\n-- Drop legacy constraint from isolation_environments (if upgrading from older schema)\nALTER TABLE remote_agent_isolation_environments\n DROP CONSTRAINT IF EXISTS unique_workflow;\n\n-- ============================================================================\n-- Idempotent ALTER statements for upgrading existing databases\n-- (These are no-ops on fresh installs since columns exist in CREATE TABLE above)\n-- ============================================================================\n\n-- From migration 006: isolation_env_id + last_activity_at on conversations\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS isolation_env_id UUID\n REFERENCES remote_agent_isolation_environments(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();\n\n-- From migration 009: last_activity_at on workflow_runs\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();\n\n-- From migration 010: parent_session_id + transition_reason on sessions\nALTER TABLE remote_agent_sessions\n ADD COLUMN IF NOT EXISTS parent_session_id UUID REFERENCES remote_agent_sessions(id);\nALTER TABLE remote_agent_sessions\n ADD COLUMN IF NOT EXISTS transition_reason TEXT;\n\n-- From migration 013: title + deleted_at on conversations\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS title VARCHAR(255);\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE;\n\n-- From migration 015: parent_conversation_id + hidden\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS parent_conversation_id UUID\n REFERENCES remote_agent_conversations(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS hidden BOOLEAN DEFAULT FALSE;\n\n-- From migration 016: ended_reason on sessions\nALTER TABLE remote_agent_sessions\n ADD COLUMN IF NOT EXISTS ended_reason TEXT;\n\n-- From migration 021: allow_env_keys on codebases\nALTER TABLE remote_agent_codebases\n ADD COLUMN IF NOT EXISTS allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE;\n\n-- From migration 023: detected default branch on codebases\nALTER TABLE remote_agent_codebases\n ADD COLUMN IF NOT EXISTS default_branch VARCHAR(255);\n\n-- From migration 024: project kind discriminator ('repo' | 'folder').\n-- Folder projects are non-git workspaces (multi-repo roots or plain ops folders)\n-- that run in place with named artifact/log storage under _folder//.\nALTER TABLE remote_agent_codebases\n ADD COLUMN IF NOT EXISTS kind VARCHAR(10) NOT NULL DEFAULT 'repo';\n\n-- User identity foreign keys (nullable on the four primary tables).\n-- All FKs use ON DELETE SET NULL so future user deletion never cascades destructively.\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_messages\n ADD COLUMN IF NOT EXISTS user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_isolation_environments\n ADD COLUMN IF NOT EXISTS created_by_user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\n\nCREATE INDEX IF NOT EXISTS idx_conversations_user_id\n ON remote_agent_conversations(user_id) WHERE user_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_user_id\n ON remote_agent_workflow_runs(user_id) WHERE user_id IS NOT NULL;\n\n-- Run-tree parent (#2121 Phase 2): a `workflow:` sub-run links back to the run\n-- that spawned it. Self-referential FK, ON DELETE SET NULL so deleting a parent\n-- orphans children rather than cascade-deleting their audit trail. First\n-- self-referential FK on this table — declared identically on SQLite (sqlite.ts).\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS parent_run_id UUID\n REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL;\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_run\n ON remote_agent_workflow_runs(parent_run_id) WHERE parent_run_id IS NOT NULL;\n\n-- From PR-C: per-user GitHub user-to-server tokens (device flow), encrypted at rest.\n-- One row per Archon user; cascades on user deletion. github_user_id is the\n-- numeric anchor for the commit no-reply email (survives username changes).\nCREATE TABLE IF NOT EXISTS remote_agent_user_github_tokens (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n github_user_id BIGINT NOT NULL,\n github_login VARCHAR(255) NOT NULL,\n access_token_encrypted TEXT NOT NULL,\n refresh_token_encrypted TEXT,\n access_token_expires_at TIMESTAMP WITH TIME ZONE,\n refresh_token_expires_at TIMESTAMP WITH TIME ZONE,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(user_id)\n);\n\n-- Phase 2: per-user AI-provider credentials (BYO API key + subscription login),\n-- encrypted at rest with the existing token-crypto key. One row per\n-- (user_id, provider); cascades on user deletion. Exactly one of\n-- api_key_encrypted / oauth_creds_encrypted is populated per row; `kind`\n-- records which. Gated on TOKEN_ENCRYPTION_KEY at the application layer.\nCREATE TABLE IF NOT EXISTS remote_agent_user_provider_keys (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n provider VARCHAR(64) NOT NULL,\n kind VARCHAR(16) NOT NULL,\n api_key_encrypted TEXT,\n oauth_creds_encrypted TEXT,\n label VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(user_id, provider)\n);\n\n-- #1955: credential rows are vendor-keyed (claude→anthropic, codex→openai,\n-- copilot→github-copilot) so one credential can serve every agent that\n-- consumes the vendor. Idempotent data fix: where both a legacy and a vendor\n-- row exist for the same user, the vendor row wins (rare — requires having\n-- connected both ids pre-rename); then legacy rows are renamed in place.\n-- Tested on SQLite (adapters/sqlite.test.ts covers rename, conflict, and\n-- idempotency); the Postgres DML below is the same statements but is NOT\n-- covered by an automated test — verified manually on the multi-user smoke.\n-- Survivable either way: reads normalize legacy ids (normalizeCredentialVendor).\nDELETE FROM remote_agent_user_provider_keys\nWHERE provider IN ('claude', 'codex', 'copilot')\n AND EXISTS (\n SELECT 1 FROM remote_agent_user_provider_keys v\n WHERE v.user_id = remote_agent_user_provider_keys.user_id\n AND v.provider = CASE remote_agent_user_provider_keys.provider\n WHEN 'claude' THEN 'anthropic'\n WHEN 'codex' THEN 'openai'\n WHEN 'copilot' THEN 'github-copilot'\n END\n );\nUPDATE remote_agent_user_provider_keys SET provider = 'anthropic' WHERE provider = 'claude';\nUPDATE remote_agent_user_provider_keys SET provider = 'openai' WHERE provider = 'codex';\nUPDATE remote_agent_user_provider_keys SET provider = 'github-copilot' WHERE provider = 'copilot';\n\n-- Phase 3: per-user AI preferences (model tiers, @custom aliases, default\n-- assistant). NON-encrypted — model names are not secrets (mirrors\n-- codebase_env_vars, not the provider-key store). One row per user; cascades\n-- on user deletion. `tiers` / `aliases` are JSON-as-TEXT (parsed in the\n-- store layer so SQLite and Postgres behave identically).\nCREATE TABLE IF NOT EXISTS remote_agent_user_ai_prefs (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n tiers TEXT,\n aliases TEXT,\n default_provider VARCHAR(64),\n default_model VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(user_id)\n);\n\n-- #1998: per-user default CHAT model, written atomically with\n-- default_provider (a model pin is only meaningful for the provider it was\n-- set with). Idempotent upgrade for installs that created the table before\n-- this column existed.\nALTER TABLE remote_agent_user_ai_prefs\n ADD COLUMN IF NOT EXISTS default_model VARCHAR(255);\n\n-- ============================================================================\n-- Web auth (opt-in): role on the canonical user + Better Auth tables\n-- ============================================================================\n--\n-- `role` is the durable identity seam: everyone defaults to 'admin' for now;\n-- 'member' is reserved for future per-resource scoping. Visibility stays open.\nALTER TABLE remote_agent_users\n ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin';\n\n-- ============================================================================\n-- Schema vintage (#2316)\n-- ============================================================================\n--\n-- Which Archon build created this database, and which last applied schema to it.\n-- Diagnostic only — nothing gates, refuses, or warns on these values. Single row\n-- (id = 1); the row's VALUES are written by the adapters from APP_VERSION\n-- (packages/core/src/db/schema-version.ts) so the version string has exactly one\n-- source of truth. created_app_version is NULL for databases that predate this\n-- table and is never back-filled with a guess.\nCREATE TABLE IF NOT EXISTS remote_agent_schema_version (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n created_app_version VARCHAR(64),\n app_version VARCHAR(64) NOT NULL,\n created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),\n applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()\n);\n\nCOMMENT ON TABLE remote_agent_schema_version IS\n 'Diagnostic schema vintage: the Archon build that created this database and the one that last applied schema to it.';\n\n-- Better Auth tables (PostgreSQL only). Generated by `@better-auth/cli generate`\n-- against packages/server/src/auth/instance.ts (modelName-renamed to the\n-- `remote_agent_auth_*` prefix), then made idempotent with IF NOT EXISTS so the\n-- bundled-schema auto-apply on startup converges. Better Auth owns these tables\n-- and the column shape (text ids, camelCase columns) — Archon never queries them\n-- directly; a session is mapped to the canonical remote_agent_users row via\n-- user_identities('web', ). Always created on Postgres (the\n-- IF NOT EXISTS apply runs on every boot); populated only when web auth is\n-- enabled (BETTER_AUTH_SECRET + DATABASE_URL), harmless empty tables otherwise.\nCREATE TABLE IF NOT EXISTS remote_agent_auth_user (\n \"id\" text NOT NULL PRIMARY KEY,\n \"name\" text NOT NULL,\n \"email\" text NOT NULL UNIQUE,\n \"emailVerified\" boolean NOT NULL,\n \"image\" text,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS remote_agent_auth_session (\n \"id\" text NOT NULL PRIMARY KEY,\n \"expiresAt\" timestamptz NOT NULL,\n \"token\" text NOT NULL UNIQUE,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz NOT NULL,\n \"ipAddress\" text,\n \"userAgent\" text,\n \"userId\" text NOT NULL REFERENCES remote_agent_auth_user (\"id\") ON DELETE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS remote_agent_auth_account (\n \"id\" text NOT NULL PRIMARY KEY,\n \"accountId\" text NOT NULL,\n \"providerId\" text NOT NULL,\n \"userId\" text NOT NULL REFERENCES remote_agent_auth_user (\"id\") ON DELETE CASCADE,\n \"accessToken\" text,\n \"refreshToken\" text,\n \"idToken\" text,\n \"accessTokenExpiresAt\" timestamptz,\n \"refreshTokenExpiresAt\" timestamptz,\n \"scope\" text,\n \"password\" text,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS remote_agent_auth_verification (\n \"id\" text NOT NULL PRIMARY KEY,\n \"identifier\" text NOT NULL,\n \"value\" text NOT NULL,\n \"expiresAt\" timestamptz NOT NULL,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL\n);\n"; +export const BUNDLED_SCHEMA_SQL = "-- Remote Coding Agent - Combined Schema\n-- Version: Combined (final state after migrations 001-020)\n-- Description: Complete database schema (idempotent - safe to run multiple times)\n--\n-- 14 Tables (+ the remote_agent_auth_* Better Auth tables, listed inline below):\n-- 1. remote_agent_codebases\n-- 1b. remote_agent_codebase_env_vars\n-- 1c. remote_agent_users\n-- 1d. remote_agent_user_identities\n-- 2. remote_agent_conversations\n-- 3. remote_agent_sessions\n-- 4. remote_agent_isolation_environments\n-- 5. remote_agent_workflow_runs\n-- 6. remote_agent_workflow_events\n-- 6b. remote_agent_workflow_node_sessions\n-- 7. remote_agent_messages\n-- 8. remote_agent_user_github_tokens\n-- 9. remote_agent_user_provider_keys\n-- 10. remote_agent_user_ai_prefs\n--\n-- Dropped tables (via migrations):\n-- - remote_agent_command_templates (017)\n--\n-- Dropped columns (via migrations):\n-- - conversations.worktree_path (007)\n-- - conversations.isolation_env_id_legacy (007)\n-- - conversations.isolation_provider (007)\n\n-- ============================================================================\n-- Table 1: Codebases\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_codebases (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n name VARCHAR(255) NOT NULL,\n repository_url VARCHAR(500),\n default_cwd VARCHAR(500) NOT NULL,\n default_branch VARCHAR(255),\n ai_assistant_type VARCHAR(20) DEFAULT 'claude',\n kind VARCHAR(10) NOT NULL DEFAULT 'repo' CHECK (kind IN ('repo', 'folder')),\n allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE,\n commands JSONB DEFAULT '{}'::jsonb,\n created_at TIMESTAMP DEFAULT NOW(),\n updated_at TIMESTAMP DEFAULT NOW()\n);\n\nCOMMENT ON TABLE remote_agent_codebases IS\n 'Repository metadata: name, URL, working directory, default branch, AI assistant type, and command paths (JSONB)';\n\n-- ============================================================================\n-- Table 1b: Codebase Env Vars\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_codebase_env_vars (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n codebase_id UUID NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE,\n key VARCHAR(255) NOT NULL,\n value TEXT NOT NULL,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(codebase_id, key)\n);\n\nCREATE INDEX IF NOT EXISTS idx_codebase_env_vars_codebase_id\n ON remote_agent_codebase_env_vars(codebase_id);\n\nCOMMENT ON TABLE remote_agent_codebase_env_vars IS\n 'Per-project env vars merged into Options.env on Claude SDK calls. Managed via Web UI or config.';\n\n-- ============================================================================\n-- Table 1c: Users (Archon identity, platform-agnostic)\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n display_name VARCHAR(255),\n email VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()\n);\n\nCOMMENT ON TABLE remote_agent_users IS\n 'Archon-internal user identity. Created on first sight by any adapter; populated via per-platform user-info lookups.';\n\n-- ============================================================================\n-- Table 1d: User Identities (per-platform mapping → users.id)\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_user_identities (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n platform VARCHAR(32) NOT NULL,\n platform_user_id VARCHAR(255) NOT NULL,\n platform_display_name VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(platform, platform_user_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_user_identities_user_id\n ON remote_agent_user_identities(user_id);\n\nCOMMENT ON TABLE remote_agent_user_identities IS\n 'Maps platform-native user IDs (Slack U-ids, Telegram chat ids, GitHub logins, Discord snowflakes) to Archon user UUIDs.';\n\n-- ============================================================================\n-- Table 2: Conversations\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_conversations (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n platform_type VARCHAR(20) NOT NULL,\n platform_conversation_id VARCHAR(255) NOT NULL,\n codebase_id UUID REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,\n cwd VARCHAR(500),\n ai_assistant_type VARCHAR(20) DEFAULT 'claude',\n isolation_env_id UUID, -- FK added after isolation_environments table exists\n title VARCHAR(255),\n deleted_at TIMESTAMP WITH TIME ZONE,\n hidden BOOLEAN DEFAULT FALSE,\n created_at TIMESTAMP DEFAULT NOW(),\n updated_at TIMESTAMP DEFAULT NOW(),\n last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(platform_type, platform_conversation_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_remote_agent_conversations_codebase\n ON remote_agent_conversations(codebase_id);\nCREATE INDEX IF NOT EXISTS idx_conversations_hidden\n ON remote_agent_conversations(hidden);\nCREATE INDEX IF NOT EXISTS idx_conversations_codebase\n ON remote_agent_conversations(codebase_id) WHERE deleted_at IS NULL;\n\nCOMMENT ON COLUMN remote_agent_conversations.isolation_env_id IS\n 'UUID reference to isolation_environments table (the only isolation reference)';\n\n-- ============================================================================\n-- Table 3: Sessions\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_sessions (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,\n codebase_id UUID REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,\n ai_assistant_type VARCHAR(20) NOT NULL,\n assistant_session_id VARCHAR(255),\n active BOOLEAN DEFAULT true,\n metadata JSONB DEFAULT '{}'::jsonb,\n parent_session_id UUID REFERENCES remote_agent_sessions(id),\n transition_reason TEXT,\n ended_reason TEXT,\n started_at TIMESTAMP DEFAULT NOW(),\n ended_at TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_remote_agent_sessions_conversation\n ON remote_agent_sessions(conversation_id, active);\nCREATE INDEX IF NOT EXISTS idx_remote_agent_sessions_codebase\n ON remote_agent_sessions(codebase_id);\nCREATE INDEX IF NOT EXISTS idx_sessions_parent\n ON remote_agent_sessions(parent_session_id);\nCREATE INDEX IF NOT EXISTS idx_sessions_conversation_started\n ON remote_agent_sessions(conversation_id, started_at DESC);\n\nCOMMENT ON COLUMN remote_agent_sessions.parent_session_id IS\n 'Links to the previous session in this conversation (for audit trail)';\nCOMMENT ON COLUMN remote_agent_sessions.transition_reason IS\n 'Why this session was created: plan-to-execute, isolation-changed, reset-requested, etc.';\nCOMMENT ON COLUMN remote_agent_sessions.ended_reason IS\n 'Why this session was deactivated: reset-requested, cwd-changed, conversation-closed, etc.';\n\n-- ============================================================================\n-- Table 4: Isolation Environments\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_isolation_environments (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n codebase_id UUID NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE,\n\n -- Workflow identification (what work this is for)\n workflow_type TEXT NOT NULL, -- 'issue', 'pr', 'review', 'thread', 'task'\n workflow_id TEXT NOT NULL, -- '42', 'pr-99', 'thread-abc123'\n\n -- Implementation details\n provider TEXT NOT NULL DEFAULT 'worktree',\n working_path TEXT NOT NULL, -- Actual filesystem path\n branch_name TEXT NOT NULL, -- Git branch name\n\n -- Lifecycle\n status TEXT NOT NULL DEFAULT 'active', -- 'active', 'destroyed'\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n created_by_platform TEXT, -- 'github', 'slack', etc.\n\n -- Cross-reference metadata (for linking)\n metadata JSONB DEFAULT '{}'\n);\n\n-- Partial unique index: only active environments need uniqueness\nCREATE UNIQUE INDEX IF NOT EXISTS unique_active_workflow\n ON remote_agent_isolation_environments (codebase_id, workflow_type, workflow_id)\n WHERE status = 'active';\n\n-- Indexes for common queries\nCREATE INDEX IF NOT EXISTS idx_isolation_env_codebase\n ON remote_agent_isolation_environments(codebase_id);\nCREATE INDEX IF NOT EXISTS idx_isolation_env_status\n ON remote_agent_isolation_environments(status);\nCREATE INDEX IF NOT EXISTS idx_isolation_env_workflow\n ON remote_agent_isolation_environments(workflow_type, workflow_id);\n\n-- Add FK from conversations to isolation_environments (deferred to avoid circular dependency)\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS isolation_env_id UUID\n REFERENCES remote_agent_isolation_environments(id) ON DELETE SET NULL;\n\nCREATE INDEX IF NOT EXISTS idx_conversations_isolation_env_id\n ON remote_agent_conversations(isolation_env_id);\n\nCOMMENT ON TABLE remote_agent_isolation_environments IS\n 'Work-centric isolated environments with independent lifecycle';\nCOMMENT ON COLUMN remote_agent_isolation_environments.workflow_type IS\n 'Type of work: issue, pr, review, thread, task';\nCOMMENT ON COLUMN remote_agent_isolation_environments.workflow_id IS\n 'Identifier for the work (issue number, PR number, thread hash, etc.)';\n\n-- ============================================================================\n-- Table 5: Workflow Runs\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_workflow_runs (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n workflow_name VARCHAR(255) NOT NULL,\n conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,\n codebase_id UUID REFERENCES remote_agent_codebases(id) ON DELETE SET NULL,\n current_step_index INTEGER,\n status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, running, completed, failed, cancelled, paused\n user_message TEXT NOT NULL,\n metadata JSONB DEFAULT '{}',\n parent_conversation_id UUID REFERENCES remote_agent_conversations(id) ON DELETE SET NULL,\n parent_run_id UUID REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL,\n started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n completed_at TIMESTAMP WITH TIME ZONE,\n last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n working_path TEXT\n);\n\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_conversation\n ON remote_agent_workflow_runs(conversation_id);\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_status\n ON remote_agent_workflow_runs(status);\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_conv\n ON remote_agent_workflow_runs(parent_conversation_id);\n\n-- Partial index for efficient staleness queries on running workflows\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_last_activity\n ON remote_agent_workflow_runs(last_activity_at)\n WHERE status = 'running';\n\nCOMMENT ON TABLE remote_agent_workflow_runs IS\n 'Tracks workflow execution state for resumption and observability';\n\n-- ============================================================================\n-- Table 6: Workflow Events\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_workflow_events (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n workflow_run_id UUID NOT NULL REFERENCES remote_agent_workflow_runs(id) ON DELETE CASCADE,\n event_order BIGINT,\n event_type VARCHAR(50) NOT NULL,\n step_index INTEGER,\n step_name VARCHAR(255),\n data JSONB DEFAULT '{}',\n created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE INDEX IF NOT EXISTS idx_workflow_events_run_id\n ON remote_agent_workflow_events(workflow_run_id);\nCREATE INDEX IF NOT EXISTS idx_workflow_events_type\n ON remote_agent_workflow_events(event_type);\n-- Global created_at index for the dashboard event poller's cross-run tail\n-- (WHERE created_at >= $1 ORDER BY created_at ASC).\nCREATE INDEX IF NOT EXISTS idx_workflow_events_created_at\n ON remote_agent_workflow_events(created_at);\nCREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_events_run_order\n ON remote_agent_workflow_events(workflow_run_id, event_order)\n WHERE event_order IS NOT NULL;\n\nCOMMENT ON TABLE remote_agent_workflow_events IS\n 'Lean UI-relevant workflow events for observability (step transitions, artifacts, errors)';\n\n-- ============================================================================\n-- Workflow node sessions (persist_session opt-in across re-runs)\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_workflow_node_sessions (\n workflow_name VARCHAR(255) NOT NULL,\n node_id VARCHAR(255) NOT NULL,\n scope_key TEXT NOT NULL,\n provider VARCHAR(50) NOT NULL,\n provider_session_id TEXT NOT NULL,\n last_run_id UUID REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n PRIMARY KEY (workflow_name, node_id, scope_key, provider)\n);\n\nCREATE INDEX IF NOT EXISTS idx_workflow_node_sessions_scope\n ON remote_agent_workflow_node_sessions(scope_key);\nCREATE INDEX IF NOT EXISTS idx_workflow_node_sessions_workflow\n ON remote_agent_workflow_node_sessions(workflow_name);\n\nCOMMENT ON TABLE remote_agent_workflow_node_sessions IS\n 'Per-node provider session IDs persisted across workflow re-runs. Keyed by (workflow, node, scope, provider). Scope is typically conversation UUID. No cascade on conversation delete (soft delete + never-reused UUID = harmless orphans); a future hard-delete path must delete by scope_key.';\n\n-- ============================================================================\n-- Table 7: Messages\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS remote_agent_messages (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n conversation_id UUID NOT NULL REFERENCES remote_agent_conversations(id) ON DELETE CASCADE,\n role VARCHAR(20) NOT NULL,\n content TEXT NOT NULL DEFAULT '',\n metadata JSONB DEFAULT '{}'::jsonb,\n created_at TIMESTAMP DEFAULT NOW()\n);\n\nCREATE INDEX IF NOT EXISTS idx_messages_conversation_id\n ON remote_agent_messages(conversation_id, created_at ASC);\n\n-- ============================================================================\n-- Cleanup: Drop legacy objects from older schemas\n-- ============================================================================\n\n-- Drop command_templates table (replaced by file-based commands in .archon/commands)\nDROP TABLE IF EXISTS remote_agent_command_templates;\nDROP INDEX IF EXISTS idx_remote_agent_command_templates_name;\n\n-- Drop legacy columns from conversations (if upgrading from older schema)\nALTER TABLE remote_agent_conversations DROP COLUMN IF EXISTS worktree_path;\nALTER TABLE remote_agent_conversations DROP COLUMN IF EXISTS isolation_env_id_legacy;\nALTER TABLE remote_agent_conversations DROP COLUMN IF EXISTS isolation_provider;\nDROP INDEX IF EXISTS idx_conversations_isolation;\n\n-- Drop legacy constraint from isolation_environments (if upgrading from older schema)\nALTER TABLE remote_agent_isolation_environments\n DROP CONSTRAINT IF EXISTS unique_workflow;\n\n-- ============================================================================\n-- Idempotent ALTER statements for upgrading existing databases\n-- (These are no-ops on fresh installs since columns exist in CREATE TABLE above)\n-- ============================================================================\n\n-- From migration 006: isolation_env_id + last_activity_at on conversations\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS isolation_env_id UUID\n REFERENCES remote_agent_isolation_environments(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();\n\n-- From migration 009: last_activity_at on workflow_runs\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();\n\n-- From migration 010: parent_session_id + transition_reason on sessions\nALTER TABLE remote_agent_sessions\n ADD COLUMN IF NOT EXISTS parent_session_id UUID REFERENCES remote_agent_sessions(id);\nALTER TABLE remote_agent_sessions\n ADD COLUMN IF NOT EXISTS transition_reason TEXT;\n\n-- From migration 013: title + deleted_at on conversations\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS title VARCHAR(255);\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE;\n\n-- From migration 015: parent_conversation_id + hidden\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS parent_conversation_id UUID\n REFERENCES remote_agent_conversations(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS hidden BOOLEAN DEFAULT FALSE;\n\n-- From migration 016: ended_reason on sessions\nALTER TABLE remote_agent_sessions\n ADD COLUMN IF NOT EXISTS ended_reason TEXT;\n\n-- From migration 021: allow_env_keys on codebases\nALTER TABLE remote_agent_codebases\n ADD COLUMN IF NOT EXISTS allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE;\n\n-- From migration 023: detected default branch on codebases\nALTER TABLE remote_agent_codebases\n ADD COLUMN IF NOT EXISTS default_branch VARCHAR(255);\n\n-- From migration 024: project kind discriminator ('repo' | 'folder').\n-- Folder projects are non-git workspaces (multi-repo roots or plain ops folders)\n-- that run in place with named artifact/log storage under _folder//.\nALTER TABLE remote_agent_codebases\n ADD COLUMN IF NOT EXISTS kind VARCHAR(10) NOT NULL DEFAULT 'repo';\n\n-- User identity foreign keys (nullable on the four primary tables).\n-- All FKs use ON DELETE SET NULL so future user deletion never cascades destructively.\nALTER TABLE remote_agent_conversations\n ADD COLUMN IF NOT EXISTS user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_messages\n ADD COLUMN IF NOT EXISTS user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\nALTER TABLE remote_agent_isolation_environments\n ADD COLUMN IF NOT EXISTS created_by_user_id UUID\n REFERENCES remote_agent_users(id) ON DELETE SET NULL;\n\nCREATE INDEX IF NOT EXISTS idx_conversations_user_id\n ON remote_agent_conversations(user_id) WHERE user_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_user_id\n ON remote_agent_workflow_runs(user_id) WHERE user_id IS NOT NULL;\n\n-- Run-tree parent (#2121 Phase 2): a `workflow:` sub-run links back to the run\n-- that spawned it. Self-referential FK, ON DELETE SET NULL so deleting a parent\n-- orphans children rather than cascade-deleting their audit trail. First\n-- self-referential FK on this table — declared identically on SQLite (sqlite.ts).\nALTER TABLE remote_agent_workflow_runs\n ADD COLUMN IF NOT EXISTS parent_run_id UUID\n REFERENCES remote_agent_workflow_runs(id) ON DELETE SET NULL;\nCREATE INDEX IF NOT EXISTS idx_workflow_runs_parent_run\n ON remote_agent_workflow_runs(parent_run_id) WHERE parent_run_id IS NOT NULL;\n\n-- From PR-C: per-user GitHub user-to-server tokens (device flow), encrypted at rest.\n-- One row per Archon user; cascades on user deletion. github_user_id is the\n-- numeric anchor for the commit no-reply email (survives username changes).\nCREATE TABLE IF NOT EXISTS remote_agent_user_github_tokens (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n github_user_id BIGINT NOT NULL,\n github_login VARCHAR(255) NOT NULL,\n access_token_encrypted TEXT NOT NULL,\n refresh_token_encrypted TEXT,\n access_token_expires_at TIMESTAMP WITH TIME ZONE,\n refresh_token_expires_at TIMESTAMP WITH TIME ZONE,\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(user_id)\n);\n\n-- Phase 2: per-user AI-provider credentials (BYO API key + subscription login),\n-- encrypted at rest with the existing token-crypto key. One row per\n-- (user_id, provider); cascades on user deletion. Exactly one of\n-- api_key_encrypted / oauth_creds_encrypted is populated per row; `kind`\n-- records which. Gated on TOKEN_ENCRYPTION_KEY at the application layer.\nCREATE TABLE IF NOT EXISTS remote_agent_user_provider_keys (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n provider VARCHAR(64) NOT NULL,\n kind VARCHAR(16) NOT NULL,\n api_key_encrypted TEXT,\n oauth_creds_encrypted TEXT,\n label VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(user_id, provider)\n);\n\n-- #1955: credential rows are vendor-keyed (claude→anthropic, codex→openai,\n-- copilot→github-copilot) so one credential can serve every agent that\n-- consumes the vendor. Idempotent data fix: where both a legacy and a vendor\n-- row exist for the same user, the vendor row wins (rare — requires having\n-- connected both ids pre-rename); then legacy rows are renamed in place.\n-- Tested on SQLite (adapters/sqlite.test.ts covers rename, conflict, and\n-- idempotency); the Postgres DML below is the same statements but is NOT\n-- covered by an automated test — verified manually on the multi-user smoke.\n-- Survivable either way: reads normalize legacy ids (normalizeCredentialVendor).\nDELETE FROM remote_agent_user_provider_keys\nWHERE provider IN ('claude', 'codex', 'copilot')\n AND EXISTS (\n SELECT 1 FROM remote_agent_user_provider_keys v\n WHERE v.user_id = remote_agent_user_provider_keys.user_id\n AND v.provider = CASE remote_agent_user_provider_keys.provider\n WHEN 'claude' THEN 'anthropic'\n WHEN 'codex' THEN 'openai'\n WHEN 'copilot' THEN 'github-copilot'\n END\n );\nUPDATE remote_agent_user_provider_keys SET provider = 'anthropic' WHERE provider = 'claude';\nUPDATE remote_agent_user_provider_keys SET provider = 'openai' WHERE provider = 'codex';\nUPDATE remote_agent_user_provider_keys SET provider = 'github-copilot' WHERE provider = 'copilot';\n\n-- Phase 3: per-user AI preferences (model tiers, @custom aliases, default\n-- assistant). NON-encrypted — model names are not secrets (mirrors\n-- codebase_env_vars, not the provider-key store). One row per user; cascades\n-- on user deletion. `tiers` / `aliases` are JSON-as-TEXT (parsed in the\n-- store layer so SQLite and Postgres behave identically).\nCREATE TABLE IF NOT EXISTS remote_agent_user_ai_prefs (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE,\n tiers TEXT,\n aliases TEXT,\n default_provider VARCHAR(64),\n default_model VARCHAR(255),\n created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n UNIQUE(user_id)\n);\n\n-- #1998: per-user default CHAT model, written atomically with\n-- default_provider (a model pin is only meaningful for the provider it was\n-- set with). Idempotent upgrade for installs that created the table before\n-- this column existed.\nALTER TABLE remote_agent_user_ai_prefs\n ADD COLUMN IF NOT EXISTS default_model VARCHAR(255);\n\n-- ============================================================================\n-- Web auth (opt-in): role on the canonical user + Better Auth tables\n-- ============================================================================\n--\n-- `role` is the durable identity seam: everyone defaults to 'admin' for now;\n-- 'member' is reserved for future per-resource scoping. Visibility stays open.\nALTER TABLE remote_agent_users\n ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin';\n\n-- Lifecycle ordering (#2359 follow-up): timestamps can tie, especially on\n-- SQLite (one-second precision), so a database-assigned order breaks the tie and\n-- preserves event chronology. `id` cannot serve this role — it is a random UUID,\n-- not monotonic.\n--\n-- Deliberately a plain column plus a sequence DEFAULT, NOT `GENERATED ... AS\n-- IDENTITY`. Adding an identity column REWRITES the whole table under ACCESS\n-- EXCLUSIVE (verified on postgres:18: relfilenode changes), and this is the\n-- largest table in the schema while the schema auto-applies on startup — that is\n-- a boot-time stall proportional to event history. ADD COLUMN with no default is\n-- metadata-only, and SET DEFAULT afterwards applies to future inserts only.\n--\n-- It also keeps both databases honest: existing rows stay NULL on Postgres AND\n-- SQLite, so the COALESCE(event_order, 0) fallback in read queries behaves\n-- identically. An identity column would have back-filled Postgres rows (1, 2,\n-- 3...) while SQLite left them NULL.\nALTER TABLE remote_agent_workflow_events\n ADD COLUMN IF NOT EXISTS event_order BIGINT;\nCREATE SEQUENCE IF NOT EXISTS remote_agent_workflow_events_event_order_seq\n OWNED BY remote_agent_workflow_events.event_order;\nALTER TABLE remote_agent_workflow_events\n ALTER COLUMN event_order SET DEFAULT nextval('remote_agent_workflow_events_event_order_seq');\nCREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_events_run_order\n ON remote_agent_workflow_events(workflow_run_id, event_order)\n WHERE event_order IS NOT NULL;\n\n-- ============================================================================\n-- Schema vintage (#2316)\n-- ============================================================================\n--\n-- Which Archon build created this database, and which last applied schema to it.\n-- Diagnostic only — nothing gates, refuses, or warns on these values. Single row\n-- (id = 1); the row's VALUES are written by the adapters from APP_VERSION\n-- (packages/core/src/db/schema-version.ts) so the version string has exactly one\n-- source of truth. created_app_version is NULL for databases that predate this\n-- table and is never back-filled with a guess.\nCREATE TABLE IF NOT EXISTS remote_agent_schema_version (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n created_app_version VARCHAR(64),\n app_version VARCHAR(64) NOT NULL,\n created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),\n applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()\n);\n\nCOMMENT ON TABLE remote_agent_schema_version IS\n 'Diagnostic schema vintage: the Archon build that created this database and the one that last applied schema to it.';\n\n-- Better Auth tables (PostgreSQL only). Generated by `@better-auth/cli generate`\n-- against packages/server/src/auth/instance.ts (modelName-renamed to the\n-- `remote_agent_auth_*` prefix), then made idempotent with IF NOT EXISTS so the\n-- bundled-schema auto-apply on startup converges. Better Auth owns these tables\n-- and the column shape (text ids, camelCase columns) — Archon never queries them\n-- directly; a session is mapped to the canonical remote_agent_users row via\n-- user_identities('web', ). Always created on Postgres (the\n-- IF NOT EXISTS apply runs on every boot); populated only when web auth is\n-- enabled (BETTER_AUTH_SECRET + DATABASE_URL), harmless empty tables otherwise.\nCREATE TABLE IF NOT EXISTS remote_agent_auth_user (\n \"id\" text NOT NULL PRIMARY KEY,\n \"name\" text NOT NULL,\n \"email\" text NOT NULL UNIQUE,\n \"emailVerified\" boolean NOT NULL,\n \"image\" text,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS remote_agent_auth_session (\n \"id\" text NOT NULL PRIMARY KEY,\n \"expiresAt\" timestamptz NOT NULL,\n \"token\" text NOT NULL UNIQUE,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz NOT NULL,\n \"ipAddress\" text,\n \"userAgent\" text,\n \"userId\" text NOT NULL REFERENCES remote_agent_auth_user (\"id\") ON DELETE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS remote_agent_auth_account (\n \"id\" text NOT NULL PRIMARY KEY,\n \"accountId\" text NOT NULL,\n \"providerId\" text NOT NULL,\n \"userId\" text NOT NULL REFERENCES remote_agent_auth_user (\"id\") ON DELETE CASCADE,\n \"accessToken\" text,\n \"refreshToken\" text,\n \"idToken\" text,\n \"accessTokenExpiresAt\" timestamptz,\n \"refreshTokenExpiresAt\" timestamptz,\n \"scope\" text,\n \"password\" text,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS remote_agent_auth_verification (\n \"id\" text NOT NULL PRIMARY KEY,\n \"identifier\" text NOT NULL,\n \"value\" text NOT NULL,\n \"expiresAt\" timestamptz NOT NULL,\n \"createdAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,\n \"updatedAt\" timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL\n);\n"; 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..62df412be6 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 { listWorkflowEventsSince, createWorkflowEvent, listWorkflowEvents } = + 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,36 @@ await db.query( const minuteAgo = (): Date => new Date(Date.now() - 60_000); describe('listWorkflowEventsSince — real SQLite (catches the C1 datetime mismatch)', () => { + test('preserves insertion chronology for lifecycle events sharing a timestamp', async () => { + await createWorkflowEvent({ + workflow_run_id: 'run-1', + event_type: 'node_started', + step_name: 'build', + }); + await createWorkflowEvent({ + workflow_run_id: 'run-1', + event_type: 'node_completed', + step_name: 'build', + }); + await db.query( + `UPDATE remote_agent_workflow_events + SET created_at = '2026-01-01 00:00:01' + WHERE workflow_run_id = 'run-1' AND step_name = 'build'`, + [] + ); + + const first = await listWorkflowEventsSince(new Date('2026-01-01T00:00:00.000Z'), 100); + const second = await listWorkflowEventsSince(new Date('2026-01-01T00:00:00.000Z'), 100); + const lifecycleTypes = (rows: typeof first): string[] => + rows.filter(row => row.step_name === 'build').map(row => row.event_type); + + expect(lifecycleTypes(first)).toEqual(['node_started', 'node_completed']); + expect(lifecycleTypes(second)).toEqual(lifecycleTypes(first)); + + const stored = await listWorkflowEvents('run-1'); + expect(lifecycleTypes(stored)).toEqual(['node_started', 'node_completed']); + }); + 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..7a3c27d670 100644 --- a/packages/core/src/db/workflow-events.test.ts +++ b/packages/core/src/db/workflow-events.test.ts @@ -23,6 +23,7 @@ mock.module('./connection', () => ({ query: mockQuery, }, getDialect: () => mockPostgresDialect, + getDatabaseType: () => 'postgresql', })); import { @@ -117,7 +118,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, COALESCE(event_order, 0) ASC, id ASC`, ['run-456'] ); }); @@ -151,7 +152,7 @@ describe('workflow-events', () => { expect(mockQuery).toHaveBeenCalledWith( `SELECT * FROM remote_agent_workflow_events WHERE workflow_run_id = $1 AND created_at > $2 - ORDER BY created_at ASC`, + ORDER BY created_at ASC, COALESCE(event_order, 0) ASC, id ASC`, ['run-456', since.toISOString()] ); }); @@ -167,7 +168,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, COALESCE(event_order, 0) 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..6c2e6eebbc 100644 --- a/packages/core/src/db/workflow-events.ts +++ b/packages/core/src/db/workflow-events.ts @@ -116,14 +116,15 @@ 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, COALESCE(event_order, 0) ASC, id ASC`, [workflowRunId] ); return [...result.rows].map(row => ({ @@ -148,7 +149,7 @@ export async function listRecentEvents( const result = await pool.query( `SELECT * FROM remote_agent_workflow_events WHERE workflow_run_id = $1 AND created_at > $2 - ORDER BY created_at ASC`, + ORDER BY created_at ASC, COALESCE(event_order, 0) ASC, id ASC`, [workflowRunId, since.toISOString()] ); return [...result.rows].map(row => ({ @@ -199,7 +200,7 @@ export async function listWorkflowEventsSince( const result = await pool.query( `SELECT * FROM remote_agent_workflow_events WHERE created_at >= $1${typeClause} - ORDER BY created_at ASC + ORDER BY created_at ASC, COALESCE(event_order, 0) ASC, id ASC LIMIT ${limitParam}`, params ); @@ -228,7 +229,7 @@ export async function getDagResumeSnapshot(workflowRunId: string): Promise<{ }>( `SELECT step_name, event_type, data FROM remote_agent_workflow_events WHERE workflow_run_id = $1 AND event_type IN ('node_completed', 'node_skipped_prior_success') - ORDER BY created_at ASC`, + ORDER BY created_at ASC, COALESCE(event_order, 0) ASC, id ASC`, [workflowRunId] ); const completedNodeOutputs = new Map(); diff --git a/packages/core/src/schemas/workflow-event.ts b/packages/core/src/schemas/workflow-event.ts index da963de7dc..937862bae5 100644 --- a/packages/core/src/schemas/workflow-event.ts +++ b/packages/core/src/schemas/workflow-event.ts @@ -15,6 +15,8 @@ export const workflowEventRowSchema = z.object({ step_name: z.string().nullable(), data: z.record(z.string(), z.unknown()), created_at: z.string(), + // Null for lifecycle rows written before event ordering was introduced. + event_order: z.number().int().nullable().optional(), }); export type WorkflowEventRow = z.infer; diff --git a/packages/docs-web/src/content/docs/reference/cli.md b/packages/docs-web/src/content/docs/reference/cli.md index 915ad7c6b5..1100de3cb0 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,21 @@ 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 deterministically ordered 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. @@ -556,6 +569,7 @@ archon version | `--quiet`, `-q` | Reduce log verbosity to warnings and errors only | | `--verbose`, `-v` | Show debug-level output | | `--json` | Output machine-readable JSON (workflow `list`, `status`, `runs`, `get`, and the write commands `approve`/`reject`/`abandon`/`resume`). Implies log suppression so stdout is exactly the JSON payload. | +| `--events` | With verbose JSON workflow `status`/`get`, return raw event rows instead of ordered node summaries. | | `--help`, `-h` | Show help message | ## Working Directory