diff --git a/council/ts/src/adapters/runstore/index.ts b/council/ts/src/adapters/runstore/index.ts index 326e6fb..67e9476 100644 --- a/council/ts/src/adapters/runstore/index.ts +++ b/council/ts/src/adapters/runstore/index.ts @@ -1,8 +1,10 @@ export { + FsLiveRunDirReader, FsRunStoreAdapter, normalizeLegacyRunDir, } from '../../contexts/runstore/adapters/fs/index.js' export type { FsRunStoreOptions, + WorkerSupervisorSnapshot, WorkerResult, } from '../../contexts/runstore/adapters/fs/index.js' diff --git a/council/ts/src/cli/index.test.ts b/council/ts/src/cli/index.test.ts index b1ab0ab..bcb3539 100644 --- a/council/ts/src/cli/index.test.ts +++ b/council/ts/src/cli/index.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it } from 'vitest' import { commandRegistry, runCli, type CliCommand, type CliResult, type CommandSpec } from './index.js' +import { CouncilApp as RealCouncilApp } from '../app/index.js' import { PreFanoutGateError } from '../workflows/fanout.js' import type { ConfigCommandInput, ConfigPaths, CouncilApp, + CouncilAppLiveStatusInput, + CouncilAppTailInput, + CouncilTailTickerInput, + CouncilTailTickerPort, CouncilAppFanoutInput, CouncilAppFleetInput, EvalWorkflowInput, @@ -14,6 +19,15 @@ import type { SuperviseInput, TriageWorkflowInput, } from '../app/index.js' +import type { ClockPort, LiveRunArtifacts, WorkerResult } from '../ports/index.js' +import type { Task } from '../shared-kernel/index.js' +import type { + StatusWatchTickerPort, + TailWorkflowFrame, + TailWorkflowLogReadInput, + TailWorkflowLogReaderPort, + TailWorkflowLogStatInput, +} from '../workflows/index.js' interface ReviewPackInput { readonly gate: '1' | 'design' | '2' @@ -24,16 +38,25 @@ interface StatusInput { readonly runDir: string } +interface RecordedLiveStatusInput { + readonly intervalMs?: number + readonly json?: boolean + readonly once?: boolean + readonly runDir: string +} + type AppCall = | { readonly input: ConfigCommandInput; readonly method: 'config' } | { readonly input: EvalWorkflowInput; readonly method: 'eval' } | { readonly input: CouncilAppFanoutInput; readonly method: 'fanout' } | { readonly input: CouncilAppFleetInput; readonly method: 'fleet' } + | { readonly input: RecordedLiveStatusInput; readonly method: 'liveStatus' } | { readonly input: PlanInput | undefined; readonly method: 'plan' } | { readonly input: RecommendInput; readonly method: 'recommend' } | { readonly input: ReviewPackInput; readonly method: 'readReviewPack' } | { readonly input: StatusInput; readonly method: 'status' } | { readonly input: SuperviseInput; readonly method: 'supervise' } + | { readonly input: CouncilAppTailInput; readonly method: 'tail' } | { readonly input: TriageWorkflowInput; readonly method: 'triage' } interface RecordingAppOptions { @@ -100,6 +123,11 @@ class RecordingApp { return Promise.resolve({ input, method: 'status' }) } + async liveStatus(input: CouncilAppLiveStatusInput): Promise { + this.calls.push({ input: recordedLiveStatusInput(input), method: 'liveStatus' }) + await input.writer?.write(`live:${liveStatusMode(input)}\n`) + } + readReviewPack(input: ReviewPackInput): Promise { this.calls.push({ input, method: 'readReviewPack' }) return Promise.resolve({ input, method: 'readReviewPack' }) @@ -110,6 +138,12 @@ class RecordingApp { return Promise.resolve({ input, method: 'supervise' }) } + tail(input: CouncilAppTailInput): Promise { + this.calls.push({ input, method: 'tail' }) + const stream = input.stream ?? 'stdout' + return Promise.resolve([tailFrame(input.taskId, stream, `tail:${input.taskId}:${stream}\n`)]) + } + triage(input: TriageWorkflowInput): Promise { this.calls.push({ input, method: 'triage' }) return Promise.resolve({ input, method: 'triage', route: 'program' }) @@ -133,6 +167,21 @@ function rejectingThenable(reason: unknown): Promise { return thenable as unknown as Promise } +function recordedLiveStatusInput(input: CouncilAppLiveStatusInput): RecordedLiveStatusInput { + return { + ...(input.intervalMs === undefined ? {} : { intervalMs: input.intervalMs }), + ...(input.json === undefined ? {} : { json: input.json }), + ...(input.once === undefined ? {} : { once: input.once }), + runDir: input.runDir, + } +} + +function liveStatusMode(input: CouncilAppLiveStatusInput): string { + if (input.json === true) return 'json' + if (input.once === true) return 'once' + return 'watch' +} + function recordingDispatchResult( method: 'fanout' | 'fleet', input: CouncilAppFanoutInput | CouncilAppFleetInput, @@ -149,6 +198,176 @@ function recordingDispatchResult( } } +function realStatusApp(reads: readonly LiveRunArtifacts[]): CouncilApp { + return new RealCouncilApp({ + clock: fixedClock('2026-07-03T12:00:00.000Z'), + liveRunDirReader: recordingLiveReader(reads), + statusTicker: finiteStatusTicker([]), + }) +} + +function fixedClock(iso: string): ClockPort { + return { + monotonicMs: () => 0, + now: () => new Date(iso), + sleep: () => Promise.resolve(), + } +} + +function recordingLiveReader(reads: readonly LiveRunArtifacts[]): { + readonly calls: readonly string[] + readonly readRunDir: (runDir: string) => Promise +} { + const calls: string[] = [] + let index = 0 + return { + calls, + readRunDir(runDir) { + calls.push(runDir) + const current = reads[index] ?? reads.at(-1) + index += 1 + if (current === undefined) throw new Error('missing live status artifact') + return Promise.resolve(current) + }, + } +} + +function finiteStatusTicker(ticks: readonly unknown[]): StatusWatchTickerPort & { + readonly intervals: readonly number[] +} { + const intervals: number[] = [] + return { + intervals, + ticks(input) { + intervals.push(input.intervalMs) + let index = 0 + const iterable: AsyncIterable & AsyncIterator = { + [Symbol.asyncIterator](): AsyncIterator { + return iterable + }, + next(): Promise> { + const value = ticks[index] + index += 1 + return Promise.resolve(value === undefined ? { done: true, value } : { done: false, value }) + }, + } + return iterable + }, + } +} + +function finiteTailTicker(onTicks: readonly (() => void)[]): CouncilTailTickerPort & { + readonly intervals: readonly number[] +} { + const intervals: number[] = [] + return { + intervals, + ticks(input: CouncilTailTickerInput): AsyncIterable { + intervals.push(input.intervalMs) + return finiteTailTickIterable(onTicks) + }, + } +} + +async function* finiteTailTickIterable(onTicks: readonly (() => void)[]): AsyncIterable { + for (const onTick of onTicks) { + onTick() + await Promise.resolve() + yield + } +} + +function bytes(text: string): Uint8Array { + return new TextEncoder().encode(text) +} + +class MemoryTailLogReader implements TailWorkflowLogReaderPort { + readonly reads: TailWorkflowLogReadInput[] = [] + readonly stats: TailWorkflowLogStatInput[] = [] + private readonly logs = new Map() + + set(path: string, text: string): void { + this.logs.set(path, bytes(text)) + } + + stat(input: TailWorkflowLogStatInput): Promise<{ readonly sizeBytes: number } | undefined> { + this.stats.push(input) + const buffer = this.logs.get(input.path) + return Promise.resolve(buffer === undefined ? undefined : { sizeBytes: buffer.byteLength }) + } + + read(input: TailWorkflowLogReadInput): Promise { + this.reads.push(input) + const buffer = this.logs.get(input.path) ?? new Uint8Array() + return Promise.resolve(buffer.subarray(input.start, input.end)) + } +} + +function cliLiveArtifacts(input: { + readonly workerResults?: ReadonlyMap +} = {}): LiveRunArtifacts { + const workerResults = input.workerResults ?? new Map() + return { + events: [], + normalized: { + report: undefined, + runId: 'run-cli', + state: { stage: 'fanout' }, + tasks: [cliTask()], + workerResults, + }, + workerResults, + workerSupervisorSnapshots: new Map(), + } +} + +function workerResultWithLogs( + taskId: string, + paths: { readonly stderr?: string; readonly stdout?: string }, +): WorkerResult { + const result: WorkerResult & { + readonly stderr_log_path?: string + readonly stdout_log_path?: string + } = { + status: 'ok', + task_id: taskId, + ...(paths.stderr === undefined ? {} : { stderr_log_path: paths.stderr }), + ...(paths.stdout === undefined ? {} : { stdout_log_path: paths.stdout }), + } + return result +} + +function tailFrame( + taskId: string, + stream: TailWorkflowFrame['stream'], + text: string, +): TailWorkflowFrame { + return { + chunks: [{ byteCount: bytes(text).byteLength, offset: 0, stream, text }], + cursor: { offset: bytes(text).byteLength, stream }, + missing: false, + rotated: false, + stream, + taskId, + truncated: false, + } +} + +function cliTask(): Task { + return { + boundaries: 'Stay in CLI status scope.', + depends_on: [], + difficulty: 'moderate', + id: 'T1', + model: 'haiku', + objective: 'Render CLI status.', + output_format: 'Code edits', + paths: ['src/cli.ts'], + title: 'CLI task', + verify: 'npm test', + } +} + function parsed(result: CliResult): unknown { return JSON.parse(result.stdout) as unknown } @@ -388,6 +607,208 @@ describe('runCli help and command dispatch', () => { }) }) + it('passes parsed live status modes to the app and returns writer output verbatim', async () => { + const cases = [ + { + argv: ['status', '--run', '/runs/status-json', '--json'], + input: { json: true, runDir: '/runs/status-json' }, + stdout: 'live:json\n', + }, + { + argv: ['status', '--run', '/runs/status-once', '--once'], + input: { once: true, runDir: '/runs/status-once' }, + stdout: 'live:once\n', + }, + { + argv: ['status', '--run', '/runs/status-watch', '--watch', '--interval-ms', '250'], + input: { intervalMs: 250, runDir: '/runs/status-watch' }, + stdout: 'live:watch\n', + }, + { + argv: ['status', '--run', '/runs/status-watch-default', '--watch'], + input: { runDir: '/runs/status-watch-default' }, + stdout: 'live:watch\n', + }, + ] as const + + for (const testCase of cases) { + const app = new RecordingApp() + const result = await runCli(testCase.argv, injectedRuntime(app)) + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: testCase.stdout, + }) + expect(app.calls).toEqual([{ input: testCase.input, method: 'liveStatus' }]) + } + }) + + it('renders JSON live status output through the app status watch workflow', async () => { + const app = realStatusApp([ + cliLiveArtifacts({ + workerResults: new Map([['T1', { status: 'ok', task_id: 'T1' }]]), + }), + ]) + + const result = await runCli(['status', '--run', '/runs/run-cli', '--json'], { app }) + + expect(result.exitCode).toBe(0) + expect(result.stderr).toBe('') + expect(parsed(result)).toMatchObject({ + run: 'run-cli', + tasks: [ + { + state: 'succeeded', + taskId: 'T1', + terminalStatus: 'ok', + }, + ], + }) + }) + + it('renders one-shot table live status output through the app status watch workflow', async () => { + const app = realStatusApp([cliLiveArtifacts()]) + + const result = await runCli(['status', '--run', '/runs/run-cli', '--once'], { app }) + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: `run run-cli stage=fanout elapsed=0s started=- updated=- +rollup counts=ready:1 ready=T1 critical=T1 +active - +wave 0 +badge task duration details +[READY] T1 0s CLI task +`, + }) + }) + + it('renders watch status output with an injected finite ticker', async () => { + const ticker = finiteStatusTicker(['tick']) + const reader = recordingLiveReader([ + cliLiveArtifacts(), + cliLiveArtifacts({ + workerResults: new Map([['T1', { status: 'ok', task_id: 'T1' }]]), + }), + ]) + const app = new RealCouncilApp({ + clock: fixedClock('2026-07-03T12:00:00.000Z'), + liveRunDirReader: reader, + statusTicker: ticker, + }) + + const result = await runCli(['status', '--run', '/runs/run-cli', '--watch', '--interval-ms', '25'], { app }) + + expect(result.exitCode).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toContain('rollup counts=ready:1 ready=T1 critical=T1') + expect(result.stdout).toContain('rollup counts=succeeded:1 ready=- critical=-') + expect(result.stdout.match(/^run run-cli/gmu)).toHaveLength(2) + expect(reader.calls).toEqual(['/runs/run-cli', '/runs/run-cli']) + expect(ticker.intervals).toEqual([25]) + }) + + it('passes parsed tail flags to the app and returns chunk text verbatim', async () => { + const app = new RecordingApp() + const result = await runCli( + [ + 'tail', + 'T9', + '--run', + '/runs/run-tail', + '--stream', + 'stderr', + '--offset', + '12', + '--lines', + '3', + '--since', + '2026-07-03T12:00:00.000Z', + '--follow', + '--interval-ms', + '25', + ], + injectedRuntime(app), + ) + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: 'tail:T9:stderr\n', + }) + expect(app.calls).toEqual([ + { + input: { + follow: true, + intervalMs: 25, + lines: 3, + maxBytes: 65536, + offset: 12, + runDir: '/runs/run-tail', + since: '2026-07-03T12:00:00.000Z', + stream: 'stderr', + taskId: 'T9', + }, + method: 'tail', + }, + ]) + }) + + it('passes stdout tail stream selection explicitly', async () => { + const app = new RecordingApp() + + const result = await runCli(['tail', 'T1', '--run', '/runs/run-tail', '--stream', 'stdout'], injectedRuntime(app)) + + expect(result.stdout).toBe('tail:T1:stdout\n') + expect(app.calls).toEqual([ + { + input: { + maxBytes: 65536, + runDir: '/runs/run-tail', + stream: 'stdout', + taskId: 'T1', + }, + method: 'tail', + }, + ]) + }) + + it('renders finite follow ticks through injected tail app dependencies', async () => { + const path = 'workers/T1/logs/stdout.log' + const logs = new MemoryTailLogReader() + logs.set(path, 'one\n') + const ticker = finiteTailTicker([ + () => { + logs.set(path, 'one\ntwo\n') + }, + () => { + logs.set(path, 'one\ntwo\nthree\n') + }, + ]) + const reader = recordingLiveReader([ + cliLiveArtifacts({ + workerResults: new Map([['T1', workerResultWithLogs('T1', { stdout: path })]]), + }), + ]) + const app = new RealCouncilApp({ + liveRunDirReader: reader, + tailLogReader: logs, + tailTicker: ticker, + }) + + const result = await runCli(['tail', 'T1', '--run', '/runs/run-cli', '--follow', '--interval-ms', '10'], { app }) + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: 'one\ntwo\nthree\n', + }) + expect(reader.calls).toEqual(['/runs/run-cli', '/runs/run-cli', '/runs/run-cli']) + expect(ticker.intervals).toEqual([10]) + }) + it('passes parsed fanout execute flags to the app and keeps stdout machine-readable', async () => { const app = new RecordingApp() @@ -585,7 +1006,6 @@ describe('runCli help and command dispatch', () => { 'survey', 'sync-bmad', 'sync-skills', - 'tail', ] as const) { await expect(runCli([command], injectedRuntime())).resolves.toEqual({ exitCode: 0, @@ -767,6 +1187,39 @@ describe('runCli error handling', () => { stderr: '--run is required\n', stdout: '', }) + await expect( + runCli(['status', '--run', '/runs/4', '--watch', '--interval-ms'], injectedRuntime()), + ).resolves.toEqual({ + exitCode: 2, + stderr: '--interval-ms is required\n', + stdout: '', + }) + await expect( + runCli(['status', '--run', '/runs/4', '--watch', '--interval-ms', '0'], injectedRuntime()), + ).resolves.toEqual({ + exitCode: 2, + stderr: '--interval-ms must be a positive integer\n', + stdout: '', + }) + await expect( + runCli(['status', '--run', '/runs/4', '--watch', '--interval-ms', '1.5'], injectedRuntime()), + ).resolves.toEqual({ + exitCode: 2, + stderr: '--interval-ms must be a positive integer\n', + stdout: '', + }) + await expect( + runCli(['status', '--run', '/runs/4', '--once', '--interval-ms', '100'], injectedRuntime()), + ).resolves.toEqual({ + exitCode: 2, + stderr: '--interval-ms requires --watch\n', + stdout: '', + }) + await expect(runCli(['status', '--run', '/runs/4', '--json', '--watch'], injectedRuntime())).resolves.toEqual({ + exitCode: 2, + stderr: 'status mode must be only one of --json, --once, or --watch\n', + stdout: '', + }) await expect(runCli(['review-pack', '--gate', '3', '--run', '/runs/4'], injectedRuntime())).resolves.toEqual({ exitCode: 2, stderr: '--gate must be 1, design, or 2\n', @@ -782,6 +1235,44 @@ describe('runCli error handling', () => { stderr: '--input is required\n', stdout: '', }) + await expect(runCli(['tail', '--run', '/runs/run-tail'], injectedRuntime())).resolves.toEqual({ + exitCode: 2, + stderr: 'tail requires a task argument\n', + stdout: '', + }) + await expect(runCli(['tail', 'T1'], injectedRuntime())).resolves.toEqual({ + exitCode: 2, + stderr: '--run is required\n', + stdout: '', + }) + await expect( + runCli(['tail', 'T1', '--run', '/runs/run-tail', '--stream', 'combined'], injectedRuntime()), + ).resolves.toEqual({ + exitCode: 2, + stderr: '--stream must be stdout or stderr\n', + stdout: '', + }) + await expect( + runCli(['tail', 'T1', '--run', '/runs/run-tail', '--offset', '-1'], injectedRuntime()), + ).resolves.toEqual({ + exitCode: 2, + stderr: '--offset must be a non-negative integer\n', + stdout: '', + }) + await expect( + runCli(['tail', 'T1', '--run', '/runs/run-tail', '--lines', '0'], injectedRuntime()), + ).resolves.toEqual({ + exitCode: 2, + stderr: '--lines must be a positive integer\n', + stdout: '', + }) + await expect( + runCli(['tail', 'T1', '--run', '/runs/run-tail', '--interval-ms', '50'], injectedRuntime()), + ).resolves.toEqual({ + exitCode: 2, + stderr: '--interval-ms requires --follow\n', + stdout: '', + }) await expect(runCli(['supervise', '--run', '/runs/run-a', '--task', 'T1'], injectedRuntime())).resolves.toEqual({ exitCode: 2, stderr: '--worktree is required\n', diff --git a/council/ts/src/cli/index.ts b/council/ts/src/cli/index.ts index 698fbf4..31b29c8 100644 --- a/council/ts/src/cli/index.ts +++ b/council/ts/src/cli/index.ts @@ -3,6 +3,9 @@ import { PreFanoutGateError, type CouncilAppFanoutInput, type CouncilAppFleetInput, + type CouncilAppLiveStatusInput, + type CouncilAppTailFrame, + type CouncilAppTailInput, type ConfigPaths, type EvalWorkflowInput, type PlanInput, @@ -11,6 +14,7 @@ import { type TriageWorkflowInput, } from '../app/index.js' import type { CouncilConfig } from '../contexts/config/index.js' +import type { WorkerOutputStream } from '../contexts/runstore/index.js' import type { DagConcurrency, DagEvalConfig } from '../ports/index.js' export type CliCommand = @@ -113,13 +117,15 @@ export async function runCli(argv: readonly string[], runtime: CliRuntime = {}): }), ) case 'status': - return okJson(await app.status({ runDir: requireFlag(parseFlags(rest), 'run') })) + return await runStatusCommand(app, parseStatus(rest)) case 'review-pack': return okJson(await app.readReviewPack(parseReviewPack(rest))) case 'triage': return okJson(await app.triage(parseTriage(rest))) case 'supervise': return okJson(await app.supervise(parseSupervise(rest))) + case 'tail': + return await runTailCommand(app, parseTail(rest)) case 'design': case 'amend': case 'context': @@ -129,7 +135,6 @@ export async function runCli(argv: readonly string[], runtime: CliRuntime = {}): case 'survey': case 'sync-bmad': case 'sync-skills': - case 'tail': return okJson({ command, compiled: true }) } return fail(`unknown command: ${command}`) @@ -141,6 +146,14 @@ export async function runCli(argv: readonly string[], runtime: CliRuntime = {}): } } +async function runTailCommand(app: CouncilApp, input: CouncilAppTailInput): Promise { + return okRaw(renderTailFrames(await app.tail(input))) +} + +function renderTailFrames(frames: readonly CouncilAppTailFrame[]): string { + return frames.flatMap((frame) => frame.chunks.map((chunk) => chunk.text)).join('') +} + function renderPreFanoutGateError(error: PreFanoutGateError): string { return [ 'pre-fanout static gate failed', @@ -194,6 +207,96 @@ function parseEval(argv: readonly string[]): EvalWorkflowInput { return { runDir: requireFlag(parseFlags(argv), 'run') } } +type ParsedStatusCommand = + | { readonly kind: 'live'; readonly input: Omit } + | { readonly kind: 'summary'; readonly runDir: string } + +async function runStatusCommand(app: CouncilApp, parsed: ParsedStatusCommand): Promise { + if (parsed.kind === 'summary') { + return okJson(await app.status({ runDir: parsed.runDir })) + } + const chunks: string[] = [] + await app.liveStatus({ + ...parsed.input, + writer: { + write(output) { + chunks.push(output) + }, + }, + }) + return okRaw(chunks.join('')) +} + +function parseStatus(argv: readonly string[]): ParsedStatusCommand { + const flags = parseFlags(argv) + const runDir = requireFlag(flags, 'run') + const modes = statusModes(flags) + if (modes.length > 1) throw new Error('status mode must be only one of --json, --once, or --watch') + if (flags.has('interval-ms') && !flags.has('watch')) throw new Error('--interval-ms requires --watch') + const mode = modes[0] + if (mode === undefined) return { kind: 'summary', runDir } + if (mode === 'json') return { input: { json: true, runDir }, kind: 'live' } + if (mode === 'once') return { input: { once: true, runDir }, kind: 'live' } + return { + input: { + ...(flags.has('interval-ms') ? { intervalMs: parsePositiveIntegerFlag(flags, 'interval-ms') } : {}), + runDir, + }, + kind: 'live', + } +} + +const DEFAULT_TAIL_MAX_BYTES = 65536 + +function parseTail(argv: readonly string[]): CouncilAppTailInput { + const flags = parseFlags(argv) + if (flags.has('interval-ms') && !flags.has('follow')) throw new Error('--interval-ms requires --follow') + return { + maxBytes: DEFAULT_TAIL_MAX_BYTES, + runDir: requireFlag(flags, 'run'), + taskId: requireTailTask(argv), + ...(flags.has('stream') ? { stream: parseTailStream(requireFlag(flags, 'stream')) } : {}), + ...(flags.has('offset') ? { offset: parseNonNegativeIntegerFlag(flags, 'offset') } : {}), + ...(flags.has('lines') ? { lines: parsePositiveIntegerFlag(flags, 'lines') } : {}), + ...(flags.has('since') ? { since: requireFlag(flags, 'since') } : {}), + ...(flags.has('follow') ? { follow: true } : {}), + ...(flags.has('interval-ms') ? { intervalMs: parsePositiveIntegerFlag(flags, 'interval-ms') } : {}), + } +} + +function requireTailTask(argv: readonly string[]): string { + const task = positionalArgs(argv)[0] + if (task === undefined) throw new Error('tail requires a task argument') + return task +} + +function positionalArgs(argv: readonly string[]): readonly string[] { + const positional: string[] = [] + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg?.startsWith('--') === true) { + const next = argv[index + 1] + if (next !== undefined && !next.startsWith('--')) index += 1 + } else if (arg !== undefined) { + positional.push(arg) + } + } + return positional +} + +function parseTailStream(value: string): WorkerOutputStream { + if (value !== 'stdout' && value !== 'stderr') throw new Error('--stream must be stdout or stderr') + return value +} + +function statusModes(flags: ReadonlyMap): readonly ('json' | 'once' | 'watch')[] { + const modes: ('json' | 'once' | 'watch')[] = [] + if (flags.has('json')) modes.push('json') + if (flags.has('once')) modes.push('once') + if (flags.has('watch')) modes.push('watch') + return modes +} + function parseRecommend(argv: readonly string[]): RecommendInput { return { profile: JSON.parse(requireFlag(parseFlags(argv), 'input')) as NonNullable } } @@ -386,6 +489,24 @@ function optionalPositiveNumber(flags: ReadonlyMap, flag: string return value } +function parsePositiveIntegerFlag(flags: ReadonlyMap, flag: string): number { + const raw = requireFlag(flags, flag) + const value = Number.parseInt(raw, 10) + if (!Number.isInteger(value) || value < 1 || String(value) !== raw) { + throw new Error(`--${flag} must be a positive integer`) + } + return value +} + +function parseNonNegativeIntegerFlag(flags: ReadonlyMap, flag: string): number { + const raw = requireFlag(flags, flag) + const value = Number.parseInt(raw, 10) + if (!Number.isInteger(value) || value < 0 || String(value) !== raw) { + throw new Error(`--${flag} must be a non-negative integer`) + } + return value +} + function configOverrides(flags: ReadonlyMap): CouncilConfig { const config: Record = {} const intensity = flags.get('intensity') @@ -432,6 +553,10 @@ function ok(stdout: string): CliResult { return { exitCode: 0, stderr: '', stdout: `${stdout.trimEnd()}\n` } } +function okRaw(stdout: string): CliResult { + return { exitCode: 0, stderr: '', stdout } +} + function okJson(value: unknown): CliResult { return ok(JSON.stringify(value, null, 2)) } diff --git a/council/ts/src/composition/council-app.test.ts b/council/ts/src/composition/council-app.test.ts index cd91e0e..8e431ef 100644 --- a/council/ts/src/composition/council-app.test.ts +++ b/council/ts/src/composition/council-app.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,6 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CouncilApp, NodeProcessAdapter } from './council-app.js' import type { + CouncilTailTickerInput, + CouncilTailTickerPort, SuperviseInput, SuperviseRunStore, SuperviseWorkerSupervisor, @@ -28,10 +30,30 @@ import { projectWorkerTrace, validateWorkerTraceAppend, } from '../contexts/orchestration/index.js' -import { workerOutputEvent, type RunStoreEvent, type WorkerLifecycleEvent } from '../contexts/runstore/index.js' -import type { DagExecutorInput, DagExecutorResult, ProcessCommand, ProcessPort, ProcessResult } from '../ports/index.js' +import { + workerOutputEvent, + workerStartedEvent, + type RunStoreEvent, + type WorkerLifecycleEvent, +} from '../contexts/runstore/index.js' +import type { + ClockPort, + DagExecutorInput, + DagExecutorResult, + LiveRunArtifacts, + ProcessCommand, + ProcessPort, + ProcessResult, + WorkerResult, +} from '../ports/index.js' import type { Task } from '../shared-kernel/index.js' -import type { RunSummary } from '../workflows/index.js' +import { projectRunView, type RunSummary, type StatusWatchTickerPort } from '../workflows/index.js' +import type { + TailWorkflowFrame, + TailWorkflowLogReadInput, + TailWorkflowLogReaderPort, + TailWorkflowLogStatInput, +} from '../workflows/index.js' const tempRoots: string[] = [] @@ -513,6 +535,279 @@ describe('CouncilApp native execution composition', () => { }) }) +describe('CouncilApp.liveStatus', () => { + it('streams live status frames through the injected reader, clock, ticker, and writer', async () => { + const reader = recordingLiveReader([ + liveArtifacts(), + liveArtifacts({ + events: [ + workerStartedEvent({ + attempt: 1, + pid: 301, + started_at: '2026-07-03T12:00:00.000Z', + task_id: 'T1', + worker_id: 'worker-T1', + }), + ], + }), + ]) + const ticker = finiteStatusTicker(['tick']) + const writes: string[] = [] + const app = new CouncilApp({ + clock: fixedClock('2026-07-03T12:05:00.000Z'), + liveRunDirReader: reader, + statusTicker: ticker, + statusWriter: { + write(output) { + writes.push(output) + return Promise.resolve() + }, + }, + }) + + await app.liveStatus({ intervalMs: 250, runDir: '/runs/live' }) + + expect(reader.calls).toEqual(['/runs/live', '/runs/live']) + expect(ticker.intervals).toEqual([250]) + expect(writes.map((output) => output.split('\n')[1])).toEqual([ + 'rollup counts=ready:1 ready=T1 critical=T1', + 'rollup counts=running:1 ready=- critical=T1', + ]) + }) + + it('allows a call-scoped writer for JSON one-shot output', async () => { + const reader = recordingLiveReader([ + liveArtifacts({ + workerResults: new Map([['T1', { status: 'ok', task_id: 'T1' }]]), + }), + ]) + const constructorWrites: string[] = [] + const callWrites: string[] = [] + const app = new CouncilApp({ + clock: fixedClock('2026-07-03T12:05:00.000Z'), + liveRunDirReader: reader, + statusWriter: { + write(output) { + constructorWrites.push(output) + return Promise.resolve() + }, + }, + }) + + await app.liveStatus({ + json: true, + runDir: '/runs/live', + writer: { + write(output) { + callWrites.push(output) + return Promise.resolve() + }, + }, + }) + + expect(reader.calls).toEqual(['/runs/live']) + expect(constructorWrites).toEqual([]) + expect(callWrites).toHaveLength(1) + expect(callWrites[0]).toContain('"run": "run-live"') + expect(callWrites[0]).toContain('"state": "succeeded"') + }) + + it('builds watch ticks from the injected clock when no ticker is provided', async () => { + const sleeps: number[] = [] + const reader = recordingLiveReader([liveArtifacts(), liveArtifacts()]) + const writes: string[] = [] + const app = new CouncilApp({ + clock: fixedClock('2026-07-03T12:05:00.000Z', (ms) => { + sleeps.push(ms) + return Promise.resolve() + }), + liveRunDirReader: reader, + }) + + await expect( + app.liveStatus({ + intervalMs: 25, + runDir: '/runs/live', + writer: { + write(output) { + writes.push(output) + if (writes.length === 2) throw new Error('stop after tick') + return Promise.resolve() + }, + }, + }), + ).rejects.toThrow('stop after tick') + + expect(sleeps).toEqual([25]) + expect(reader.calls).toEqual(['/runs/live', '/runs/live']) + expect(writes).toHaveLength(2) + }) + + it('uses the default no-op writer for one-shot live status output', async () => { + const reader = recordingLiveReader([liveArtifacts()]) + const app = new CouncilApp({ + clock: fixedClock('2026-07-03T12:05:00.000Z'), + liveRunDirReader: reader, + }) + + await expect(app.liveStatus({ once: true, runDir: '/runs/live' })).resolves.toBeUndefined() + expect(reader.calls).toEqual(['/runs/live']) + }) +}) + +describe('CouncilApp.tail', () => { + it('streams deterministic tail frames through injected readers and ticker', async () => { + const path = 'workers/T1/logs/stdout.log' + const reader = recordingLiveReader([ + liveArtifacts({ + workerResults: new Map([['T1', workerResultWithLogs('T1', { stdout: path })]]), + }), + ]) + const logs = new MemoryTailLogReader() + logs.set(path, 'first\n') + const ticker = finiteTailTicker([ + () => { + logs.set(path, 'first\nsecond\n') + }, + ]) + const app = new CouncilApp({ + liveRunDirReader: reader, + tailLogReader: logs, + tailTicker: ticker, + }) + + const frames = await app.tail({ + follow: true, + intervalMs: 33, + maxBytes: 100, + runDir: '/runs/live', + taskId: 'T1', + }) + + expect(frames).toEqual([ + tailFrame('T1', 'stdout', 'first\n', { + cursor: 6, + logPath: path, + }), + tailFrame('T1', 'stdout', 'second\n', { + cursor: 13, + logPath: path, + offset: 6, + }), + ]) + expect(reader.calls).toEqual(['/runs/live', '/runs/live']) + expect(logs.stats).toEqual([ + { path, runDir: '/runs/live' }, + { path, runDir: '/runs/live' }, + ]) + expect(logs.reads).toEqual([ + { end: 6, path, runDir: '/runs/live', start: 0 }, + { end: 13, path, runDir: '/runs/live', start: 0 }, + ]) + expect(ticker.intervals).toEqual([33]) + }) + + it('reads bounded ranges from the default filesystem log reader', async () => { + const root = await tempRoot('council-tail-') + const runDir = join(root, 'run-tail') + const path = 'workers/T1/logs/stdout.log' + await mkdir(join(runDir, 'workers', 'T1', 'logs'), { recursive: true }) + await writeFile(join(runDir, path), 'old\nnew\n', 'utf8') + const app = new CouncilApp({ + liveRunDirReader: recordingLiveReader([ + liveArtifacts({ + workerResults: new Map([['T1', workerResultWithLogs('T1', { stdout: path })]]), + }), + ]), + }) + + await expect(app.tail({ lines: 1, maxBytes: 100, runDir, taskId: 'T1' })).resolves.toEqual([ + tailFrame('T1', 'stdout', 'new\n', { + cursor: 8, + logPath: path, + offset: 4, + truncated: true, + }), + ]) + }) + + it('reports missing default filesystem logs as deterministic frames', async () => { + const app = new CouncilApp({ + liveRunDirReader: recordingLiveReader([ + liveArtifacts({ + workerResults: new Map([['T1', workerResultWithLogs('T1', { stdout: 'missing/stdout.log' })]]), + }), + ]), + }) + + await expect(app.tail({ maxBytes: 100, runDir: '/runs/live', taskId: 'T1' })).resolves.toEqual([ + { + chunks: [], + cursor: { offset: 0, stream: 'stdout' }, + logPath: 'missing/stdout.log', + logPathSource: 'result', + missing: true, + missingReason: 'log', + rotated: false, + stream: 'stdout', + taskId: 'T1', + truncated: false, + }, + ]) + }) + + it('surfaces unexpected default filesystem stat failures', async () => { + const root = await tempRoot('council-tail-broken-') + const runDir = join(root, 'not-a-directory') + await writeFile(runDir, 'not a directory', 'utf8') + const app = new CouncilApp({ + liveRunDirReader: recordingLiveReader([ + liveArtifacts({ + workerResults: new Map([['T1', workerResultWithLogs('T1', { stdout: 'workers/T1/logs/stdout.log' })]]), + }), + ]), + }) + + await expect(app.tail({ maxBytes: 100, runDir, taskId: 'T1' })).rejects.toMatchObject({ + code: 'ENOTDIR', + }) + }) + + it('builds tail follow ticks from the injected clock when no ticker is provided', async () => { + const sleeps: number[] = [] + const path = 'workers/T1/logs/stdout.log' + const reader = recordingLiveReader([ + liveArtifacts({ + workerResults: new Map([['T1', workerResultWithLogs('T1', { stdout: path })]]), + }), + ]) + const logs = new MemoryTailLogReader({ failReadAt: 2 }) + logs.set(path, 'one\n') + const app = new CouncilApp({ + clock: fixedClock('2026-07-03T12:05:00.000Z', (ms) => { + sleeps.push(ms) + return Promise.resolve() + }), + liveRunDirReader: reader, + tailLogReader: logs, + }) + + await expect( + app.tail({ + follow: true, + intervalMs: 25, + maxBytes: 100, + runDir: '/runs/live', + taskId: 'T1', + }), + ).rejects.toThrow('stop after tick') + + expect(sleeps).toEqual([25]) + expect(reader.calls).toEqual(['/runs/live', '/runs/live']) + expect(logs.reads).toHaveLength(2) + }) +}) + describe('CouncilApp.supervise', () => { it('starts a worker and persists lifecycle events, snapshots, and result artifacts', async () => { const store = new RecordingRunStore() @@ -580,7 +875,9 @@ describe('CouncilApp.supervise', () => { event: { payload: { byte_count: 3, + log_path: 'workers/T1/logs/stdout.log', offset: 0, + observed_at: '2026-07-03T10:00:00.000Z', stream: 'stdout', tail: 'ok\n', tail_bytes: 3, @@ -595,7 +892,9 @@ describe('CouncilApp.supervise', () => { event: { payload: { byte_count: 5, + log_path: 'workers/T1/logs/stderr.log', offset: 0, + observed_at: '2026-07-03T10:00:00.000Z', stream: 'stderr', tail: 'warn\n', tail_bytes: 5, @@ -705,6 +1004,8 @@ describe('CouncilApp.supervise', () => { attempt: 1, byteCount: 3, kind: 'output', + logPath: 'workers/T6/logs/stdout.log', + occurredAt: '2026-07-03T12:00:00.000Z', offset: 0, stream: 'stdout', tail: 'ok\n', @@ -716,6 +1017,8 @@ describe('CouncilApp.supervise', () => { attempt: 1, byteCount: 5, kind: 'output', + logPath: 'workers/T6/logs/stderr.log', + occurredAt: '2026-07-03T12:00:00.000Z', offset: 0, stream: 'stderr', tail: 'warn\n', @@ -769,7 +1072,9 @@ describe('CouncilApp.supervise', () => { const appendedTrace = appendWorkerTraceEvents(trace, [ workerOutputEvent({ byte_count: 12, + log_path: 'workers/T6/logs/stderr.log', offset: 5, + observed_at: '2026-07-03T12:00:01.000Z', stream: 'stderr', tail: 'still fails\n', tail_bytes: 12, @@ -783,6 +1088,8 @@ describe('CouncilApp.supervise', () => { attempt: 2, byteCount: 12, kind: 'output', + logPath: 'workers/T6/logs/stderr.log', + occurredAt: '2026-07-03T12:00:01.000Z', offset: 5, stream: 'stderr', tail: 'still fails\n', @@ -877,6 +1184,38 @@ describe('CouncilApp.supervise', () => { }) }) + it('uses output event timestamps for RunView freshness when terminal timestamps are absent', () => { + const view = projectRunView({ + clock: { now: () => new Date('2026-07-03T12:10:00.000Z') }, + events: [ + workerStartedEvent({ + started_at: '2026-07-03T12:00:00.000Z', + task_id: 'T1', + worker_id: 'worker-T1', + }), + workerOutputEvent({ + byte_count: 3, + log_path: 'workers/T1/logs/stdout.log', + observed_at: '2026-07-03T12:04:00.000Z', + offset: 0, + stream: 'stdout', + task_id: 'T1', + worker_id: 'worker-T1', + }), + ], + summary: { + run: 'run-a', + state: { stage: 'fanout' }, + tasks: [nativeTask()], + waves: [['T1']], + workerResults: [], + }, + }) + + expect(view.tasks[0]?.updatedAt).toBe('2026-07-03T12:04:00.000Z') + expect(view.rollup.updatedAt).toBe('2026-07-03T12:04:00.000Z') + }) + it('reattaches from a saved snapshot and writes terminal result statuses', async () => { for (const status of ['failed', 'stalled', 'disk-cap', 'stopped'] as const) { const store = new RecordingRunStore({ snapshot: supervisorSnapshot('T2') }) @@ -1342,6 +1681,168 @@ function supervisorSnapshot(taskId: string): SuperviseWorkerSupervisorSnapshot { } } +function fixedClock(iso: string, sleep: ClockPort['sleep'] = () => Promise.resolve()): ClockPort { + return { + monotonicMs: () => 0, + now: () => new Date(iso), + sleep, + } +} + +function recordingLiveReader(reads: readonly LiveRunArtifacts[]): { + readonly calls: readonly string[] + readonly readRunDir: (runDir: string) => Promise +} { + const calls: string[] = [] + let index = 0 + return { + calls, + readRunDir(runDir) { + calls.push(runDir) + const current = reads[index] ?? reads.at(-1) + index += 1 + if (current === undefined) throw new Error('missing live status artifact') + return Promise.resolve(current) + }, + } +} + +function finiteStatusTicker(ticks: readonly unknown[]): StatusWatchTickerPort & { + readonly intervals: readonly number[] +} { + const intervals: number[] = [] + return { + intervals, + ticks(input) { + intervals.push(input.intervalMs) + let index = 0 + const iterable: AsyncIterable & AsyncIterator = { + [Symbol.asyncIterator](): AsyncIterator { + return iterable + }, + next(): Promise> { + const value = ticks[index] + index += 1 + return Promise.resolve(value === undefined ? { done: true, value } : { done: false, value }) + }, + } + return iterable + }, + } +} + +function finiteTailTicker(onTicks: readonly (() => void)[]): CouncilTailTickerPort & { + readonly intervals: readonly number[] +} { + const intervals: number[] = [] + return { + intervals, + ticks(input: CouncilTailTickerInput): AsyncIterable { + intervals.push(input.intervalMs) + return finiteTailTickIterable(onTicks) + }, + } +} + +async function* finiteTailTickIterable(onTicks: readonly (() => void)[]): AsyncIterable { + for (const onTick of onTicks) { + onTick() + await Promise.resolve() + yield + } +} + +function bytes(text: string): Uint8Array { + return new TextEncoder().encode(text) +} + +class MemoryTailLogReader implements TailWorkflowLogReaderPort { + readonly reads: TailWorkflowLogReadInput[] = [] + readonly stats: TailWorkflowLogStatInput[] = [] + private readonly failReadAt: number | undefined + private readonly logs = new Map() + + constructor(options: { readonly failReadAt?: number } = {}) { + this.failReadAt = options.failReadAt + } + + set(path: string, text: string): void { + this.logs.set(path, bytes(text)) + } + + stat(input: TailWorkflowLogStatInput): Promise<{ readonly sizeBytes: number } | undefined> { + this.stats.push(input) + const buffer = this.logs.get(input.path) + return Promise.resolve(buffer === undefined ? undefined : { sizeBytes: buffer.byteLength }) + } + + read(input: TailWorkflowLogReadInput): Promise { + this.reads.push(input) + if (this.reads.length === this.failReadAt) return Promise.reject(new Error('stop after tick')) + const buffer = this.logs.get(input.path) ?? new Uint8Array() + return Promise.resolve(buffer.subarray(input.start, input.end)) + } +} + +function liveArtifacts(input: { + readonly events?: LiveRunArtifacts['events'] + readonly workerResults?: ReadonlyMap +} = {}): LiveRunArtifacts { + const workerResults = input.workerResults ?? new Map() + return { + events: input.events ?? [], + normalized: { + report: undefined, + runId: 'run-live', + state: { stage: 'fanout' }, + tasks: [nativeTask()], + workerResults, + }, + workerResults, + workerSupervisorSnapshots: new Map(), + } +} + +function workerResultWithLogs( + taskId: string, + paths: { readonly stderr?: string; readonly stdout?: string }, +): WorkerResult { + const result: WorkerResult & { + readonly stderr_log_path?: string + readonly stdout_log_path?: string + } = { + status: 'ok', + task_id: taskId, + ...(paths.stderr === undefined ? {} : { stderr_log_path: paths.stderr }), + ...(paths.stdout === undefined ? {} : { stdout_log_path: paths.stdout }), + } + return result +} + +function tailFrame( + taskId: string, + stream: TailWorkflowFrame['stream'], + text: string, + input: { + readonly cursor: number + readonly logPath: string + readonly offset?: number + readonly truncated?: boolean + }, +): TailWorkflowFrame { + return { + chunks: [{ byteCount: bytes(text).byteLength, offset: input.offset ?? 0, stream, text }], + cursor: { offset: input.cursor, stream }, + logPath: input.logPath, + logPathSource: 'result', + missing: false, + rotated: false, + stream, + taskId, + truncated: input.truncated ?? false, + } +} + function evalRunSummary(): RunSummary { return { run: 'run-a', diff --git a/council/ts/src/composition/council-app.ts b/council/ts/src/composition/council-app.ts index 376c258..fc7f234 100644 --- a/council/ts/src/composition/council-app.ts +++ b/council/ts/src/composition/council-app.ts @@ -1,10 +1,11 @@ import { spawn } from 'node:child_process' -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, open as openFile, readFile, stat as statFile, writeFile } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { SystemClockAdapter } from '../adapters/clock/index.js' import { ProcessVerificationAdapter } from '../adapters/process/index.js' import { + FsLiveRunDirReader, FsRunStoreAdapter, normalizeLegacyRunDir, type WorkerResult, @@ -52,6 +53,7 @@ import type { DagSuperviseInput, DagSuperviseResult, GhPort, + LiveRunDirReaderPort, ProcessCommand, ProcessPort, ProcessResult, @@ -68,6 +70,8 @@ import { planWorkflow, reviewPackWorkflow, resolveDagWorkerCommand, + statusWatchWorkflow, + tailWorkflow, roundTripTasksMarkdownWorkflow, statusWorkflow as runStatusWorkflow, stringifyAssignments, @@ -90,6 +94,12 @@ import type { PlanResult, ReviewPackInput, RunSummary, + StatusWatchInput, + StatusWatchTickerInput, + StatusWatchTickerPort, + TailWorkflowFrame, + TailWorkflowInput, + TailWorkflowLogReaderPort, TriageWorkflowInput, } from '../workflows/index.js' @@ -100,11 +110,16 @@ export interface CouncilAppDeps { readonly executeDag?: ExecuteDagDependency readonly gh?: GhPort readonly integrationWorktreePath?: string + readonly liveRunDirReader?: LiveRunDirReaderPort readonly nowIso?: () => string readonly process?: ProcessPort readonly readText?: (path: string) => Promise readonly repoRoot?: string readonly status?: (input: { readonly runDir: string }) => Promise + readonly statusTicker?: StatusWatchTickerPort + readonly statusWriter?: CouncilStatusOutputWriter + readonly tailLogReader?: TailWorkflowLogReaderPort + readonly tailTicker?: CouncilTailTickerPort readonly worktreeDependencyProvisioner?: WorktreeDependencyProvisionerPort readonly worktreeRoot?: string readonly writeText?: (path: string, text: string) => Promise @@ -118,6 +133,28 @@ export type CouncilAppFanoutInput = FanoutBaseInput & (PlanOnlyWorkflowInput | C export type CouncilAppFleetInput = FleetBaseInput & (PlanOnlyWorkflowInput | CouncilAppExecuteDagInput) +export interface CouncilStatusOutputWriter { + write(output: string): Promise | void +} + +export interface CouncilAppLiveStatusInput extends StatusWatchInput { + readonly writer?: CouncilStatusOutputWriter +} + +export interface CouncilAppTailInput extends TailWorkflowInput { + readonly intervalMs?: number +} + +export type CouncilAppTailFrame = TailWorkflowFrame + +export interface CouncilTailTickerInput { + readonly intervalMs: number +} + +export interface CouncilTailTickerPort { + ticks(input: CouncilTailTickerInput): AsyncIterable +} + export interface RecommendInput { readonly profile?: LensProblemProfile } @@ -215,6 +252,71 @@ export class NodeProcessAdapter implements ProcessPort { } } +const NOOP_STATUS_WRITER: CouncilStatusOutputWriter = { + write: () => undefined, +} + +class ClockStatusTicker implements StatusWatchTickerPort { + private readonly clock: ClockPort + + constructor(clock: ClockPort) { + this.clock = clock + } + + async *ticks(input: StatusWatchTickerInput): AsyncIterable { + for (;;) { + await this.clock.sleep(input.intervalMs) + yield undefined + } + } +} + +class ClockTailTicker implements CouncilTailTickerPort { + private readonly clock: ClockPort + + constructor(clock: ClockPort) { + this.clock = clock + } + + async *ticks(input: CouncilTailTickerInput): AsyncIterable { + for (;;) { + await this.clock.sleep(input.intervalMs) + yield + } + } +} + +class FsTailWorkflowLogReader implements TailWorkflowLogReaderPort { + async stat(input: { readonly path: string; readonly runDir: string }): Promise<{ readonly sizeBytes: number } | undefined> { + try { + const stats = await statFile(tailLogFilePath(input)) + return { sizeBytes: stats.size } + } catch (error) { + if (isErrno(error, 'ENOENT')) return undefined + throw error + } + } + + async read(input: { + readonly end: number + readonly path: string + readonly runDir: string + readonly start: number + }): Promise { + const length = Math.max(0, input.end - input.start) + const file = await openFile(tailLogFilePath(input), 'r') + try { + const buffer = Buffer.alloc(length) + const result = await file.read(buffer, 0, length, input.start) + return buffer.subarray(0, result.bytesRead) + } finally { + await file.close() + } + } +} + +const DEFAULT_TAIL_INTERVAL_MS = 1000 + export class CouncilApp { private readonly clock: ClockPort private readonly createRunStore: (root: string) => SuperviseRunStore @@ -224,11 +326,16 @@ export class CouncilApp { private readonly executeDagOverride: ExecuteDagDependency | undefined private readonly gh: GhPort | undefined private readonly integrationWorktreePath: string | undefined + private readonly liveRunDirReader: LiveRunDirReaderPort private readonly nowIso: () => string private readonly processPort: ProcessPort private readonly readText: (path: string) => Promise private readonly repoRoot: string | undefined private readonly statusPort: (input: { readonly runDir: string }) => Promise + private readonly statusTicker: StatusWatchTickerPort + private readonly statusWriter: CouncilStatusOutputWriter + private readonly tailLogReader: TailWorkflowLogReaderPort + private readonly tailTicker: CouncilTailTickerPort private readonly worktreeDependencyProvisioner: WorktreeDependencyProvisionerPort private readonly worktreeRoot: string | undefined private readonly writeText: (path: string, text: string) => Promise @@ -248,6 +355,7 @@ export class CouncilApp { this.executeDagOverride = deps.executeDag this.gh = deps.gh this.integrationWorktreePath = deps.integrationWorktreePath + this.liveRunDirReader = deps.liveRunDirReader ?? new FsLiveRunDirReader() this.nowIso = deps.nowIso ?? (() => this.clock.now().toISOString()) this.processPort = deps.process ?? new NodeProcessAdapter() this.readText = deps.readText ?? ((path) => readFile(path, 'utf8')) @@ -258,6 +366,10 @@ export class CouncilApp { runStatusWorkflow(input, { normalizeRunDir: normalizeLegacyRunDir, })) + this.statusTicker = deps.statusTicker ?? new ClockStatusTicker(this.clock) + this.statusWriter = deps.statusWriter ?? NOOP_STATUS_WRITER + this.tailLogReader = deps.tailLogReader ?? new FsTailWorkflowLogReader() + this.tailTicker = deps.tailTicker ?? new ClockTailTicker(this.clock) this.worktreeDependencyProvisioner = deps.worktreeDependencyProvisioner ?? createWorktreeDependencyProvisioner() this.worktreeRoot = deps.worktreeRoot @@ -297,6 +409,34 @@ export class CouncilApp { return this.statusPort(input) } + async liveStatus(input: CouncilAppLiveStatusInput): Promise { + const writer = input.writer ?? this.statusWriter + for await (const frame of statusWatchWorkflow(input, { + clock: this.clock, + readRunDir: (runDir) => this.liveRunDirReader.readRunDir(runDir), + ticker: this.statusTicker, + })) { + await writer.write(frame.output) + } + } + + async tail(input: CouncilAppTailInput): Promise { + const frames: TailWorkflowFrame[] = [] + for await (const frame of tailWorkflow(input, { + artifacts: this.liveRunDirReader, + logs: this.tailLogReader, + ticker: { + ticks: () => + this.tailTicker.ticks({ + intervalMs: input.intervalMs ?? DEFAULT_TAIL_INTERVAL_MS, + }), + }, + })) { + frames.push(frame) + } + return frames + } + readReviewPack(input: ReviewPackInput): Promise { return reviewPackWorkflow(input, { status: (statusInput) => this.status(statusInput), @@ -584,6 +724,10 @@ function runStoreTarget(runDir: string): { readonly root: string; readonly runId } } +function tailLogFilePath(input: { readonly path: string; readonly runDir: string }): string { + return join(input.runDir, input.path) +} + async function readExistingSnapshot( store: SuperviseRunStore, runId: string, @@ -647,6 +791,8 @@ function workerLifecycleEvent( if (event.type === 'stdout' || event.type === 'stderr') { return workerOutputEvent({ byte_count: event.byteCount, + log_path: event.logPath, + observed_at: nowIso, offset: event.offset, stream: event.type, tail: event.tail, diff --git a/council/ts/src/contexts/orchestration/domain/worker-trace.test.ts b/council/ts/src/contexts/orchestration/domain/worker-trace.test.ts index 39d4d34..24deb7d 100644 --- a/council/ts/src/contexts/orchestration/domain/worker-trace.test.ts +++ b/council/ts/src/contexts/orchestration/domain/worker-trace.test.ts @@ -34,7 +34,9 @@ describe('worker trace projection', () => { workerOutputEvent({ byte_count: 14, content_hash: 'sha256:stdout', + log_path: 'workers/T1/logs/stdout.log', offset: 0, + observed_at: '2026-07-03T10:00:01.000Z', sha256: 'sha256:chunk-1', stream: 'stdout', tail: 'first line', @@ -110,6 +112,8 @@ describe('worker trace projection', () => { byteCount: 14, contentHash: 'sha256:stdout', kind: 'output', + logPath: 'workers/T1/logs/stdout.log', + occurredAt: '2026-07-03T10:00:01.000Z', offset: 0, sha256: 'sha256:chunk-1', stream: 'stdout', @@ -191,7 +195,9 @@ describe('worker trace projection', () => { const next = appendWorkerTraceEvents(existing, [ workerOutputEvent({ byte_count: 5, + log_path: 'workers/T2/logs/stdout.log', offset: 0, + observed_at: '2026-07-03T10:04:00.000Z', stream: 'stdout', task_id: 'T2', worker_id: 'worker-T2', @@ -210,6 +216,8 @@ describe('worker trace projection', () => { attempt: 1, byteCount: 5, kind: 'output', + logPath: 'workers/T2/logs/stdout.log', + occurredAt: '2026-07-03T10:04:00.000Z', offset: 0, stream: 'stdout', taskId: 'T2', diff --git a/council/ts/src/contexts/orchestration/domain/worker-trace.ts b/council/ts/src/contexts/orchestration/domain/worker-trace.ts index eb74a61..17ce41c 100644 --- a/council/ts/src/contexts/orchestration/domain/worker-trace.ts +++ b/council/ts/src/contexts/orchestration/domain/worker-trace.ts @@ -34,6 +34,8 @@ export interface WorkerTraceOutputEntry { readonly byteCount: number readonly tail?: string readonly tailBytes?: number + readonly logPath?: string + readonly occurredAt?: string readonly sha256?: string readonly contentHash?: string } @@ -196,6 +198,8 @@ function projectWorkerTraceEvent( workerId: event.payload.worker_id, ...(event.payload.tail === undefined ? {} : { tail: event.payload.tail }), ...(event.payload.tail_bytes === undefined ? {} : { tailBytes: event.payload.tail_bytes }), + ...(event.payload.log_path === undefined ? {} : { logPath: event.payload.log_path }), + ...(event.payload.observed_at === undefined ? {} : { occurredAt: event.payload.observed_at }), ...(event.payload.sha256 === undefined ? {} : { sha256: event.payload.sha256 }), ...(event.payload.content_hash === undefined ? {} : { contentHash: event.payload.content_hash }), } diff --git a/council/ts/src/contexts/runstore/adapters/fs/artifact-codec.ts b/council/ts/src/contexts/runstore/adapters/fs/artifact-codec.ts index fce9bf9..082354f 100644 --- a/council/ts/src/contexts/runstore/adapters/fs/artifact-codec.ts +++ b/council/ts/src/contexts/runstore/adapters/fs/artifact-codec.ts @@ -576,6 +576,7 @@ function assertWorkerOutput(value: unknown): WorkerOutputPayload { 'tail', 'tail_bytes', 'log_path', + 'observed_at', 'sha256', 'content_hash', ]) @@ -587,6 +588,7 @@ function assertWorkerOutput(value: unknown): WorkerOutputPayload { optionalBoundedString(record, 'worker output', 'tail', WORKER_OUTPUT_TAIL_MAX_CHARS) optionalNonNegativeInteger(record, 'worker output', 'tail_bytes') optionalString(record, 'worker output', 'log_path') + optionalString(record, 'worker output', 'observed_at') optionalString(record, 'worker output', 'sha256') optionalString(record, 'worker output', 'content_hash') return record as unknown as WorkerOutputPayload diff --git a/council/ts/src/contexts/runstore/adapters/fs/index.test.ts b/council/ts/src/contexts/runstore/adapters/fs/index.test.ts index 5fca909..eaa0caf 100644 --- a/council/ts/src/contexts/runstore/adapters/fs/index.test.ts +++ b/council/ts/src/contexts/runstore/adapters/fs/index.test.ts @@ -148,13 +148,14 @@ describe('FsRunStoreAdapter', () => { content_hash: 'sha256:output-event', log_path: 'workers/T1/logs/stdout.log', offset: 256, + observed_at: '2026-07-03T10:00:01.000Z', sha256: 'sha256:chunk', stream: 'stdout', tail: 'last line', tail_bytes: 9, task_id: 'T1', worker_id: 'worker-T1', - } as never), + }), workerDetectedEvent({ content_hash: 'sha256:detected', detected_at: '2026-07-03T10:01:00.000Z', @@ -203,6 +204,25 @@ describe('FsRunStoreAdapter', () => { await expect(readdir(join(root, 'run-a'))).resolves.not.toContain('events.jsonl.lock') }) + it('reads legacy worker output events without optional output metadata', async () => { + const root = await tempRoot() + const store = new FsRunStoreAdapter(root) + const legacyOutput = workerOutputEvent({ + byte_count: 12, + offset: 0, + stream: 'stderr', + tail: 'legacy tail', + tail_bytes: 11, + task_id: 'T1', + worker_id: 'worker-T1', + }) + + await mkdir(join(root, 'run-legacy'), { recursive: true }) + await appendFile(join(root, 'run-legacy', 'events.jsonl'), `${JSON.stringify(legacyOutput)}\n`) + + await expect(store.readEvents('run-legacy')).resolves.toEqual([legacyOutput]) + }) + it('waits for an existing event lock and times out stale locks', async () => { const root = await tempRoot() const store = new FsRunStoreAdapter(root, { lockRetryMs: 1, lockTimeoutMs: 250 }) @@ -399,6 +419,18 @@ describe('FsRunStoreAdapter', () => { type: 'worker_exited', } as never), ).rejects.toThrow('worker exited.signal must be a string or null') + await expect( + store.appendWorkerEvent('run-a', { + payload: { + byte_count: 1, + observed_at: 1, + offset: 0, + stream: 'stdout', + worker_id: 'worker-T1', + }, + type: 'worker_output', + } as never), + ).rejects.toThrow('worker output.observed_at must be a string') await expect( store.appendWorkerEvent('run-a', { payload: { diff --git a/council/ts/src/contexts/runstore/adapters/fs/index.ts b/council/ts/src/contexts/runstore/adapters/fs/index.ts index 349dcb2..9bd39e8 100644 --- a/council/ts/src/contexts/runstore/adapters/fs/index.ts +++ b/council/ts/src/contexts/runstore/adapters/fs/index.ts @@ -1,5 +1,6 @@ export { FsRunStoreAdapter } from './run-store.js' export type { FsRunStoreOptions } from './run-store.js' +export { FsLiveRunDirReader } from './live-run-dir-reader.js' export { normalizeLegacyRunDir } from './legacy-run-dir-normalizer.js' export type { NormalizedRunDirectory } from './legacy-run-dir-normalizer.js' export type { diff --git a/council/ts/src/contexts/runstore/adapters/fs/live-run-dir-reader.test.ts b/council/ts/src/contexts/runstore/adapters/fs/live-run-dir-reader.test.ts new file mode 100644 index 0000000..487df45 --- /dev/null +++ b/council/ts/src/contexts/runstore/adapters/fs/live-run-dir-reader.test.ts @@ -0,0 +1,273 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import type { RunState, Task } from '../../../../shared-kernel/index.js' +import { FsLiveRunDirReader, type WorkerResult, type WorkerSupervisorSnapshot } from './index.js' + +const tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { force: true, recursive: true }))) +}) + +describe('FsLiveRunDirReader', () => { + it('loads a complete run directory with deterministic worker artifacts and raw event order', async () => { + const runDir = await writeRunDir('complete') + const reader = new FsLiveRunDirReader() + const secondEvent = { + payload: { id: 'A1', summary: 'amended' }, + type: 'amendment', + } as const + const firstEvent = { + payload: { + byte_count: 3, + offset: 0, + stream: 'stdout', + task_id: 'T2', + worker_id: 'worker-T2', + }, + type: 'worker_output', + } as const + await writeJsonl(runDir, 'events.jsonl', [firstEvent, secondEvent]) + await writeWorkerResult(runDir, 'T2', fullWorkerResult('T2')) + await writeWorkerResult(runDir, 'T1', fullWorkerResult('T1')) + await writeSupervisorSnapshot(runDir, 'T2', fullSupervisorSnapshot('T2')) + await writeSupervisorSnapshot(runDir, 'T1', fullSupervisorSnapshot('T1')) + + const artifacts = await reader.readRunDir(runDir) + + expect(artifacts.normalized.runId).toBe('complete') + expect(artifacts.normalized.state).toEqual(normalizedState()) + expect(artifacts.normalized.tasks).toEqual(fullTasks()) + expect(artifacts.normalized.report).toEqual({ + run: 'complete', + tasks: [{ status: 'ok', task_id: 'T1' }], + waves: [['T1']], + }) + expect(artifacts.events).toEqual([firstEvent, secondEvent]) + expect([...artifacts.workerResults.keys()]).toEqual(['T1', 'T2']) + expect([...artifacts.workerSupervisorSnapshots.keys()]).toEqual(['T1', 'T2']) + expect(artifacts.normalized.workerResults).toBe(artifacts.workerResults) + }) + + it('treats a missing events file as an empty partial event stream', async () => { + const runDir = await writeRunDir('missing-events') + const reader = new FsLiveRunDirReader() + + await expect(reader.readRunDir(runDir)).resolves.toMatchObject({ + events: [], + }) + }) + + it('treats a missing workers directory as empty partial worker artifacts', async () => { + const runDir = await writeRunDir('missing-workers') + const reader = new FsLiveRunDirReader() + + const artifacts = await reader.readRunDir(runDir) + + expect(artifacts.workerResults.size).toBe(0) + expect(artifacts.workerSupervisorSnapshots.size).toBe(0) + expect(artifacts.normalized.workerResults.size).toBe(0) + }) + + it('skips a missing individual supervisor snapshot without dropping the worker result', async () => { + const runDir = await writeRunDir('missing-supervisor') + const reader = new FsLiveRunDirReader() + await writeWorkerResult(runDir, 'T1', fullWorkerResult('T1')) + + const artifacts = await reader.readRunDir(runDir) + + expect([...artifacts.workerResults.keys()]).toEqual(['T1']) + expect(artifacts.workerSupervisorSnapshots.size).toBe(0) + }) + + it('skips a missing individual worker result without dropping the supervisor snapshot', async () => { + const runDir = await writeRunDir('missing-result') + const reader = new FsLiveRunDirReader() + await writeSupervisorSnapshot(runDir, 'T1', fullSupervisorSnapshot('T1')) + await rm(join(runDir, 'report.json'), { force: true }) + + const artifacts = await reader.readRunDir(runDir) + + expect(artifacts.normalized.report).toBeUndefined() + expect(artifacts.workerResults.size).toBe(0) + expect([...artifacts.workerSupervisorSnapshots.keys()]).toEqual(['T1']) + expect(artifacts.normalized.workerResults.size).toBe(0) + }) + + it('fails malformed required state and tasks artifacts', async () => { + const malformedState = await writeRunDir('bad-state') + const malformedTasks = await writeRunDir('bad-tasks') + const reader = new FsLiveRunDirReader() + await writeJson(malformedState, 'state.json', []) + await writeJson(malformedTasks, 'tasks.json', []) + + await expect(reader.readRunDir(malformedState)).rejects.toThrow('state must be an object') + await expect(reader.readRunDir(malformedTasks)).rejects.toThrow('consolidator returned no tasks') + }) + + it('surfaces malformed optional artifacts when the file exists', async () => { + const badEvent = await writeRunDir('bad-event') + const badSupervisor = await writeRunDir('bad-supervisor') + const badResult = await writeRunDir('bad-result') + const reader = new FsLiveRunDirReader() + await writeJsonl(badEvent, 'events.jsonl', [{ type: 'unknown' }]) + await writeJson(join(badSupervisor, 'workers', 'T1'), 'supervisor.json', fullSupervisorSnapshot('T2')) + await writeJson(join(badResult, 'workers', 'T1'), 'result.json', { status: 'ok' }) + + await expect(reader.readRunDir(badEvent)).rejects.toThrow('unsupported run store event type: unknown') + await expect(reader.readRunDir(badSupervisor)).rejects.toThrow( + 'worker supervisor snapshot task_id must match path task id: T1', + ) + await expect(reader.readRunDir(badResult)).rejects.toThrow('worker result.task_id must be a string') + }) + + it('surfaces unexpected worker directory read errors', async () => { + const runDir = await writeRunDir('workers-file') + const reportDir = await writeRunDir('report-dir') + const reader = new FsLiveRunDirReader() + await writeFile(join(runDir, 'workers'), '', 'utf8') + await writeSupervisorSnapshot(reportDir, 'T1', fullSupervisorSnapshot('T1')) + await rm(join(reportDir, 'report.json'), { force: true }) + await mkdir(join(reportDir, 'report.json')) + + await expect(reader.readRunDir(runDir)).rejects.toMatchObject({ code: 'ENOTDIR' }) + await expect(reader.readRunDir(reportDir)).rejects.toMatchObject({ code: 'EISDIR' }) + }) +}) + +async function writeRunDir(runId: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'council-live-run-')) + tempRoots.push(root) + const runDir = join(root, runId) + await writeJson(runDir, 'state.json', fullState()) + await writeJson(runDir, 'tasks.json', fullTasks()) + await writeJson(runDir, 'report.json', { + run: runId, + tasks: [{ status: 'ok', task_id: 'T1' }], + waves: [['T1']], + }) + return runDir +} + +async function writeWorkerResult(runDir: string, taskId: string, result: WorkerResult): Promise { + await writeJson(join(runDir, 'workers', taskId), 'result.json', result) +} + +async function writeSupervisorSnapshot( + runDir: string, + taskId: string, + snapshot: WorkerSupervisorSnapshot, +): Promise { + await writeJson(join(runDir, 'workers', taskId), 'supervisor.json', snapshot) +} + +async function writeJson(runDir: string, file: string, value: unknown): Promise { + await mkdir(runDir, { recursive: true }) + await writeFile(join(runDir, file), `${JSON.stringify(value, null, 2)}\n`, 'utf8') +} + +async function writeJsonl(runDir: string, file: string, values: readonly unknown[]): Promise { + await writeFile(join(runDir, file), `${values.map((value) => JSON.stringify(value)).join('\n')}\n`, 'utf8') +} + +function fullState(): RunState { + return { + agents: ['planner-a'], + content_hash: 'sha256:state', + engine: { cli: 'codex', model: 'gpt-5' }, + integration_branch: 'council/run-a/integration', + intensity: 'quick', + model_tier: 'frontier', + rounds: 1, + spec_id: '001-run-a', + spec_relpath: 'specs/001-run-a', + spec_slug: 'run-a', + stage: 'planned', + task_count: 1, + } +} + +function normalizedState(): RunState { + return { + integration_branch: 'council/run-a/integration', + intensity: 'quick', + rounds: 1, + spec_id: '001-run-a', + spec_relpath: 'specs/001-run-a', + spec_slug: 'run-a', + stage: 'planned', + task_count: 1, + } +} + +function fullTasks(): readonly Task[] { + return [ + { + acceptance_criteria: ['Criterion'], + archetype: 'implementation', + boundaries: 'Only src/example.ts.', + content_hash: 'sha256:task', + context_profile: 'code', + context_refs: ['kb://task'], + depends_on: [], + difficulty: 'moderate', + id: 'T1', + model: 'sonnet', + model_tier: 'standard', + objective: 'Implement the example behavior.', + output_format: 'Patch', + paths: ['src/example.ts'], + supersedes: [], + title: 'Example task', + verify: 'npm test', + }, + ] +} + +function fullWorkerResult(taskId: string): WorkerResult { + return { + files_changed: ['src/example.ts'], + status: 'ok', + task_id: taskId, + verify_rc: 0, + } +} + +function fullSupervisorSnapshot(taskId: string): WorkerSupervisorSnapshot { + return { + attempt_id: 1, + logs: { + stderr: `workers/${taskId}/logs/stderr.log`, + stdout: `workers/${taskId}/logs/stdout.log`, + }, + offsets: { + stderr: 0, + stdout: 0, + }, + restart_count: 0, + status: 'running', + task_id: taskId, + watchdog: { + handling_detection: false, + loop: { + actions: [], + }, + progress: { + attemptStartedAtMs: 0, + lastActionAtMs: 0, + lastOutputAtMs: 0, + lastProgressAtMs: 0, + outputBytes: 0, + startedAtMs: 0, + }, + retry: { + attempts: 0, + failureFingerprints: [], + }, + }, + } +} diff --git a/council/ts/src/contexts/runstore/adapters/fs/live-run-dir-reader.ts b/council/ts/src/contexts/runstore/adapters/fs/live-run-dir-reader.ts new file mode 100644 index 0000000..8aab320 --- /dev/null +++ b/council/ts/src/contexts/runstore/adapters/fs/live-run-dir-reader.ts @@ -0,0 +1,189 @@ +import { readFile, readdir } from 'node:fs/promises' +import { basename, join } from 'node:path' + +import type { + LiveRunArtifacts, + LiveRunDirReaderPort, + NormalizedRunDirectory, + RunStoreEvent, +} from '../../../../ports/index.js' +import type { RunState } from '../../../../shared-kernel/index.js' +import { RUNSTORE_EVENTS_FILE } from '../../../runstore/index.js' +import { + RESULT_FILE, + SUPERVISOR_FILE, + WORKERS_DIR, + assertLegacyReport, + assertRecord, + assertRunState, + assertRunStoreEvent, + assertTasks, + assertWorkerResult, + assertWorkerSupervisorSnapshot, + copyOptionalInteger, + copyOptionalString, + isErrno, + parseJson, + type LegacyRunReport, + type WorkerResult, + type WorkerSupervisorSnapshot, +} from './artifact-codec.js' +import { normalizeLegacyRunDir } from './legacy-run-dir-normalizer.js' + +export class FsLiveRunDirReader implements LiveRunDirReaderPort { + async readRunDir(runDir: string): Promise { + const workerResults = await readWorkerResults(runDir) + const normalized = await readNormalizedSummary(runDir, workerResults) + const events = await readEvents(runDir) + const workerSupervisorSnapshots = await readWorkerSupervisorSnapshots(runDir) + + return { + events, + normalized, + workerResults, + workerSupervisorSnapshots, + } + } +} + +async function readNormalizedSummary( + runDir: string, + workerResults: ReadonlyMap, +): Promise { + try { + const normalized = await normalizeLegacyRunDir(runDir) + return { + report: normalized.report, + runId: normalized.runId, + state: normalized.state, + tasks: normalized.tasks, + workerResults, + } + } catch (error) { + if (isMissingOptionalWorkerResult(error)) return readRequiredSummary(runDir, workerResults) + throw error + } +} + +async function readRequiredSummary( + runDir: string, + workerResults: ReadonlyMap, +): Promise { + return { + report: await readOptionalLegacyReport(join(runDir, 'report.json')), + runId: basename(runDir), + state: normalizeLegacyState(await readJson(join(runDir, 'state.json'))), + tasks: assertTasks(await readJson(join(runDir, 'tasks.json'))), + workerResults, + } +} + +async function readEvents(runDir: string): Promise { + try { + const text = await readFile(join(runDir, RUNSTORE_EVENTS_FILE), 'utf8') + return text + .split('\n') + .filter((line) => line.length > 0) + .map((line) => assertRunStoreEvent(parseJson(line))) + } catch (error) { + if (isErrno(error, 'ENOENT')) return [] + throw error + } +} + +async function readWorkerResults(runDir: string): Promise> { + const results = new Map() + for (const taskId of await readWorkerTaskIds(runDir)) { + const result = await readOptionalWorkerResult(join(runDir, WORKERS_DIR, taskId, RESULT_FILE), taskId) + if (result !== undefined) results.set(taskId, result) + } + return results +} + +async function readWorkerSupervisorSnapshots( + runDir: string, +): Promise> { + const snapshots = new Map() + for (const taskId of await readWorkerTaskIds(runDir)) { + const snapshot = await readOptionalWorkerSupervisorSnapshot( + join(runDir, WORKERS_DIR, taskId, SUPERVISOR_FILE), + taskId, + ) + if (snapshot !== undefined) snapshots.set(taskId, snapshot) + } + return snapshots +} + +async function readWorkerTaskIds(runDir: string): Promise { + try { + return (await readdir(join(runDir, WORKERS_DIR))).sort() + } catch (error) { + if (isErrno(error, 'ENOENT')) return [] + throw error + } +} + +async function readOptionalWorkerResult( + path: string, + taskId: string, +): Promise { + try { + return assertWorkerResult(await readJson(path), taskId) + } catch (error) { + if (isErrno(error, 'ENOENT')) return undefined + throw error + } +} + +async function readOptionalWorkerSupervisorSnapshot( + path: string, + taskId: string, +): Promise { + try { + return assertWorkerSupervisorSnapshot(await readJson(path), taskId) + } catch (error) { + if (isErrno(error, 'ENOENT')) return undefined + throw error + } +} + +async function readOptionalLegacyReport(path: string): Promise { + try { + return assertLegacyReport(await readJson(path)) + } catch (error) { + if (isErrno(error, 'ENOENT')) return undefined + /* c8 ignore next */ + throw error + } +} + +function normalizeLegacyState(value: unknown): RunState { + const record = assertRecord(value, 'state') + const state: Record = {} + copyOptionalString(record, state, 'stage') + copyOptionalString(record, state, 'intensity') + copyOptionalInteger(record, state, 'rounds') + copyOptionalInteger(record, state, 'task_count') + copyOptionalString(record, state, 'spec_id') + copyOptionalString(record, state, 'spec_slug') + copyOptionalString(record, state, 'spec_relpath') + copyOptionalString(record, state, 'integration_branch') + return assertRunState(state) +} + +function isMissingOptionalWorkerResult(error: unknown): boolean { + if (!isErrno(error, 'ENOENT') || !hasStringPath(error)) return false + return isResultPath(error.path) +} + +function hasStringPath(error: unknown): error is { readonly path: string } { + return error instanceof Error && 'path' in error && typeof error.path === 'string' +} + +function isResultPath(path: string): boolean { + return path.includes(`${WORKERS_DIR}/`) && path.endsWith(`/${RESULT_FILE}`) +} + +async function readJson(path: string): Promise { + return parseJson(await readFile(path, 'utf8')) +} diff --git a/council/ts/src/contexts/runstore/domain/index.test.ts b/council/ts/src/contexts/runstore/domain/index.test.ts index 22af982..6686ca3 100644 --- a/council/ts/src/contexts/runstore/domain/index.test.ts +++ b/council/ts/src/contexts/runstore/domain/index.test.ts @@ -220,7 +220,9 @@ describe('runstore event append plans', () => { const output = { byte_count: 128, content_hash: 'sha256:output-event', + log_path: 'workers/T1/logs/stdout.log', offset: 256, + observed_at: '2026-07-03T10:00:01.000Z', sha256: 'sha256:chunk', stream: 'stdout', tail: 'last line', diff --git a/council/ts/src/contexts/runstore/domain/index.ts b/council/ts/src/contexts/runstore/domain/index.ts index 3084516..129ca70 100644 --- a/council/ts/src/contexts/runstore/domain/index.ts +++ b/council/ts/src/contexts/runstore/domain/index.ts @@ -41,6 +41,8 @@ export interface WorkerOutputPayload { readonly byte_count: number readonly tail?: string readonly tail_bytes?: number + readonly log_path?: string + readonly observed_at?: string readonly sha256?: string readonly content_hash?: string } diff --git a/council/ts/src/ports/index.ts b/council/ts/src/ports/index.ts index 8275660..85c9b97 100644 --- a/council/ts/src/ports/index.ts +++ b/council/ts/src/ports/index.ts @@ -42,7 +42,12 @@ export type { LegacyRunNormalizerPort, LegacyRunReport, LegacyTaskReport, + LiveRunArtifacts, + LiveRunDirReaderPort, NormalizedRunDirectory, + RunStoreEvent, RunStorePort, WorkerResult, + WorkerSupervisorSnapshot, + WorkerSupervisorSnapshotStatus, } from './run-store.js' diff --git a/council/ts/src/ports/run-store.ts b/council/ts/src/ports/run-store.ts index c8f2d81..10cc733 100644 --- a/council/ts/src/ports/run-store.ts +++ b/council/ts/src/ports/run-store.ts @@ -49,6 +49,59 @@ export interface WorkerResult { readonly model_tier?: string } +export type WorkerSupervisorSnapshotStatus = + | 'running' + | 'detected' + | 'restarting' + | 'exited' + | 'stopped' + | 'completed' + | 'failed' + | 'stalled' + | 'budget-cap' + | 'disk-cap' + +export interface WorkerSupervisorSnapshot { + readonly task_id: string + readonly attempt_id: number + readonly pid?: number + readonly restart_count: number + readonly model_tier?: string + readonly status: WorkerSupervisorSnapshotStatus + readonly offsets: { + readonly stdout: number + readonly stderr: number + } + readonly logs: { + readonly stdout: string + readonly stderr: string + } + readonly watchdog: { + readonly progress: { + readonly attemptStartedAtMs: number + readonly lastActionAtMs: number + readonly lastOutputAtMs: number + readonly lastProgressAtMs: number + readonly outputBytes: number + readonly startedAtMs: number + } + readonly loop: { + readonly actions: readonly { + readonly verbatim: string + readonly normalized: string + }[] + } + readonly retry: { + readonly attempts: number + readonly failureFingerprints: readonly string[] + } + readonly pending_detection?: Record + readonly handling_detection: boolean + } + readonly exit_code?: number | null + readonly signal?: string | null +} + export interface LegacyTaskReport { readonly task_id: string readonly status?: string @@ -81,3 +134,16 @@ export interface NormalizedRunDirectory { export interface LegacyRunNormalizerPort { normalizeRunDir(runDir: string): Promise } + +export interface LiveRunArtifacts { + readonly normalized: NormalizedRunDirectory + readonly events: readonly RunStoreEvent[] + readonly workerResults: ReadonlyMap + readonly workerSupervisorSnapshots: ReadonlyMap +} + +export interface LiveRunDirReaderPort { + readRunDir(runDir: string): Promise +} + +export type { RunStoreEvent } diff --git a/council/ts/src/workflows/index.ts b/council/ts/src/workflows/index.ts index 6d809d4..13dfc40 100644 --- a/council/ts/src/workflows/index.ts +++ b/council/ts/src/workflows/index.ts @@ -11,5 +11,9 @@ export * from './fanout.js' export * from './fleet.js' export * from './plan.js' export * from './review-pack.js' +export * from './status-render.js' +export * from './status-watch.js' export * from './status.js' +export * from './tail.js' +export * from './tail-workflow.js' export * from './triage.js' diff --git a/council/ts/src/workflows/status-render.test.ts b/council/ts/src/workflows/status-render.test.ts new file mode 100644 index 0000000..be06860 --- /dev/null +++ b/council/ts/src/workflows/status-render.test.ts @@ -0,0 +1,442 @@ +import { describe, expect, it } from 'vitest' + +import type { RunTaskView, RunView } from './status.js' +import { renderRunStatusJson, renderRunStatusTable } from './status-render.js' + +function task(input: Partial & Pick): RunTaskView { + const { state, taskId, ...overrides } = input + return { + attempt: 0, + blockedBy: [], + dependenciesSatisfied: true, + durationMs: 0, + lastDetection: null, + modelTier: null, + pid: null, + restarts: 0, + startedAt: null, + state, + taskId, + terminalStatus: null, + title: `Task ${taskId}`, + updatedAt: null, + wave: 0, + workerId: null, + ...overrides, + } +} + +function runView(tasks: readonly RunTaskView[]): RunView { + return { + rollup: { + countsByState: { + blocked: 1, + detected: 1, + failed: 1, + pending: 2, + restarting: 1, + running: 1, + succeeded: 2, + }, + criticalPath: ['ck-ready', 'ck-running', 'ck-detected'], + elapsedMs: 665_000, + readySet: ['ck-ready'], + startedAt: '2026-07-03T10:00:00.000Z', + updatedAt: '2026-07-03T10:10:30.000Z', + }, + run: 'run-a', + state: { stage: 'fanout' }, + tasks, + waves: [ + ['ck-pending', 'ck-ready', 'ck-running'], + ['ck-detected', 'ck-restarting', 'ck-succeeded'], + ['ck-failed', 'ck-skipped', 'ck-blocked'], + ], + } +} + +const COMPLETE_VIEW = runView([ + task({ + blockedBy: ['ck-root'], + dependenciesSatisfied: false, + state: 'pending', + taskId: 'ck-pending', + title: 'Waiting for dependency', + }), + task({ + state: 'pending', + taskId: 'ck-ready', + title: 'Ready task', + }), + task({ + attempt: 1, + durationMs: 600_000, + modelTier: 'sonnet', + pid: 12345, + startedAt: '2026-07-03T10:00:00.000Z', + state: 'running', + taskId: 'ck-running', + title: 'Running task', + updatedAt: '2026-07-03T10:05:00.000Z', + workerId: 'worker-running', + }), + task({ + attempt: 2, + durationMs: 125_000, + lastDetection: 'progress-stall', + pid: 456, + restarts: 1, + state: 'detected', + taskId: 'ck-detected', + title: 'Detected task', + workerId: 'worker-detected', + wave: 1, + }), + task({ + attempt: 3, + durationMs: 95_000, + lastDetection: 'budget-cap', + restarts: 2, + state: 'restarting', + taskId: 'ck-restarting', + title: 'Restarting task', + wave: 1, + }), + task({ + durationMs: 30_000, + state: 'succeeded', + taskId: 'ck-succeeded', + terminalStatus: 'ok', + title: 'Succeeded task', + wave: 1, + }), + task({ + durationMs: 45_000, + state: 'failed', + taskId: 'ck-failed', + terminalStatus: 'failed', + title: 'Failed task', + wave: 2, + }), + task({ + state: 'succeeded', + taskId: 'ck-skipped', + terminalStatus: 'no-op', + title: 'Skipped task', + wave: 2, + }), + task({ + blockedBy: ['ck-failed'], + dependenciesSatisfied: false, + state: 'blocked', + taskId: 'ck-blocked', + title: 'Blocked task', + wave: 2, + }), +]) + +describe('renderRunStatusTable', () => { + it('renders a compact deterministic table with rollups, waves, durations, ready set, and active detections', () => { + expect(renderRunStatusTable(COMPLETE_VIEW)).toMatchInlineSnapshot(` + "run run-a stage=fanout elapsed=11m05s started=2026-07-03T10:00:00.000Z updated=2026-07-03T10:10:30.000Z + rollup counts=blocked:1 detected:1 failed:1 pending:1 ready:1 restarting:1 running:1 skipped:1 succeeded:1 ready=ck-ready critical=ck-ready>ck-running>ck-detected + active detected=ck-detected(progress-stall) restarting=ck-restarting(budget-cap) running=ck-running(pid=12345) + wave 0 + badge task duration details + [PENDING] ck-pending 0s Waiting for dependency; blocked-by=ck-root + [READY] ck-ready 0s Ready task + [RUNNING] ck-running 10m00s Running task; worker=worker-running; pid=12345; attempt=1; model=sonnet + wave 1 + badge task duration details + [DETECTED] ck-detected 2m05s Detected task; worker=worker-detected; pid=456; attempt=2; restarts=1; detection=progress-stall + [RESTART] ck-restarting 1m35s Restarting task; attempt=3; restarts=2; detection=budget-cap + [OK] ck-succeeded 30s Succeeded task; terminal=ok + wave 2 + badge task duration details + [FAILED] ck-failed 45s Failed task; terminal=failed + [SKIPPED] ck-skipped 0s Skipped task; terminal=no-op + [BLOCKED] ck-blocked 0s Blocked task; blocked-by=ck-failed" + `) + }) + + it('renders deterministic ANSI colors only when explicitly requested', () => { + expect(renderRunStatusTable(COMPLETE_VIEW, { color: true })).toMatchInlineSnapshot(` + "run run-a stage=fanout elapsed=11m05s started=2026-07-03T10:00:00.000Z updated=2026-07-03T10:10:30.000Z + rollup counts=blocked:1 detected:1 failed:1 pending:1 ready:1 restarting:1 running:1 skipped:1 succeeded:1 ready=ck-ready critical=ck-ready>ck-running>ck-detected + active detected=ck-detected(progress-stall) restarting=ck-restarting(budget-cap) running=ck-running(pid=12345) + wave 0 + badge task duration details + [PENDING] ck-pending 0s Waiting for dependency; blocked-by=ck-root + [READY] ck-ready 0s Ready task + [RUNNING] ck-running 10m00s Running task; worker=worker-running; pid=12345; attempt=1; model=sonnet + wave 1 + badge task duration details + [DETECTED] ck-detected 2m05s Detected task; worker=worker-detected; pid=456; attempt=2; restarts=1; detection=progress-stall + [RESTART] ck-restarting 1m35s Restarting task; attempt=3; restarts=2; detection=budget-cap + [OK] ck-succeeded 30s Succeeded task; terminal=ok + wave 2 + badge task duration details + [FAILED] ck-failed 45s Failed task; terminal=failed + [SKIPPED] ck-skipped 0s Skipped task; terminal=no-op + [BLOCKED] ck-blocked 0s Blocked task; blocked-by=ck-failed" + `) + }) + + it('renders empty rollups and ungrouped tasks without terminal assumptions', () => { + const view = runView([ + task({ + durationMs: 3_661_000, + state: 'budget-cap', + taskId: 'ck-budget', + title: 'Budget capped task', + wave: null, + }), + ]) + + expect( + renderRunStatusTable({ + ...view, + rollup: { + countsByState: { 'budget-cap': 1 }, + criticalPath: [], + elapsedMs: 0, + readySet: [], + startedAt: null, + updatedAt: null, + }, + waves: [], + }), + ).toMatchInlineSnapshot(` + "run run-a stage=fanout elapsed=0s started=- updated=- + rollup counts=budget-cap:1 ready=- critical=- + active - + wave ? + badge task duration details + [BUDGET] ck-budget 1h01m01s Budget capped task" + `) + }) +}) + +describe('renderRunStatusJson', () => { + it('keeps empty RunView records stable in machine output', () => { + expect(renderRunStatusJson({ ...COMPLETE_VIEW, state: {} })).toContain('"state": {}') + }) + + it('formats only serializable RunView data with stable object keys', () => { + expect(renderRunStatusJson(COMPLETE_VIEW)).toMatchInlineSnapshot(` + "{ + "rollup": { + "countsByState": { + "blocked": 1, + "detected": 1, + "failed": 1, + "pending": 2, + "restarting": 1, + "running": 1, + "succeeded": 2 + }, + "criticalPath": [ + "ck-ready", + "ck-running", + "ck-detected" + ], + "elapsedMs": 665000, + "readySet": [ + "ck-ready" + ], + "startedAt": "2026-07-03T10:00:00.000Z", + "updatedAt": "2026-07-03T10:10:30.000Z" + }, + "run": "run-a", + "state": { + "stage": "fanout" + }, + "tasks": [ + { + "attempt": 0, + "blockedBy": [ + "ck-root" + ], + "dependenciesSatisfied": false, + "durationMs": 0, + "lastDetection": null, + "modelTier": null, + "pid": null, + "restarts": 0, + "startedAt": null, + "state": "pending", + "taskId": "ck-pending", + "terminalStatus": null, + "title": "Waiting for dependency", + "updatedAt": null, + "wave": 0, + "workerId": null + }, + { + "attempt": 0, + "blockedBy": [], + "dependenciesSatisfied": true, + "durationMs": 0, + "lastDetection": null, + "modelTier": null, + "pid": null, + "restarts": 0, + "startedAt": null, + "state": "pending", + "taskId": "ck-ready", + "terminalStatus": null, + "title": "Ready task", + "updatedAt": null, + "wave": 0, + "workerId": null + }, + { + "attempt": 1, + "blockedBy": [], + "dependenciesSatisfied": true, + "durationMs": 600000, + "lastDetection": null, + "modelTier": "sonnet", + "pid": 12345, + "restarts": 0, + "startedAt": "2026-07-03T10:00:00.000Z", + "state": "running", + "taskId": "ck-running", + "terminalStatus": null, + "title": "Running task", + "updatedAt": "2026-07-03T10:05:00.000Z", + "wave": 0, + "workerId": "worker-running" + }, + { + "attempt": 2, + "blockedBy": [], + "dependenciesSatisfied": true, + "durationMs": 125000, + "lastDetection": "progress-stall", + "modelTier": null, + "pid": 456, + "restarts": 1, + "startedAt": null, + "state": "detected", + "taskId": "ck-detected", + "terminalStatus": null, + "title": "Detected task", + "updatedAt": null, + "wave": 1, + "workerId": "worker-detected" + }, + { + "attempt": 3, + "blockedBy": [], + "dependenciesSatisfied": true, + "durationMs": 95000, + "lastDetection": "budget-cap", + "modelTier": null, + "pid": null, + "restarts": 2, + "startedAt": null, + "state": "restarting", + "taskId": "ck-restarting", + "terminalStatus": null, + "title": "Restarting task", + "updatedAt": null, + "wave": 1, + "workerId": null + }, + { + "attempt": 0, + "blockedBy": [], + "dependenciesSatisfied": true, + "durationMs": 30000, + "lastDetection": null, + "modelTier": null, + "pid": null, + "restarts": 0, + "startedAt": null, + "state": "succeeded", + "taskId": "ck-succeeded", + "terminalStatus": "ok", + "title": "Succeeded task", + "updatedAt": null, + "wave": 1, + "workerId": null + }, + { + "attempt": 0, + "blockedBy": [], + "dependenciesSatisfied": true, + "durationMs": 45000, + "lastDetection": null, + "modelTier": null, + "pid": null, + "restarts": 0, + "startedAt": null, + "state": "failed", + "taskId": "ck-failed", + "terminalStatus": "failed", + "title": "Failed task", + "updatedAt": null, + "wave": 2, + "workerId": null + }, + { + "attempt": 0, + "blockedBy": [], + "dependenciesSatisfied": true, + "durationMs": 0, + "lastDetection": null, + "modelTier": null, + "pid": null, + "restarts": 0, + "startedAt": null, + "state": "succeeded", + "taskId": "ck-skipped", + "terminalStatus": "no-op", + "title": "Skipped task", + "updatedAt": null, + "wave": 2, + "workerId": null + }, + { + "attempt": 0, + "blockedBy": [ + "ck-failed" + ], + "dependenciesSatisfied": false, + "durationMs": 0, + "lastDetection": null, + "modelTier": null, + "pid": null, + "restarts": 0, + "startedAt": null, + "state": "blocked", + "taskId": "ck-blocked", + "terminalStatus": null, + "title": "Blocked task", + "updatedAt": null, + "wave": 2, + "workerId": null + } + ], + "waves": [ + [ + "ck-pending", + "ck-ready", + "ck-running" + ], + [ + "ck-detected", + "ck-restarting", + "ck-succeeded" + ], + [ + "ck-failed", + "ck-skipped", + "ck-blocked" + ] + ] + }" + `) + }) +}) diff --git a/council/ts/src/workflows/status-render.ts b/council/ts/src/workflows/status-render.ts new file mode 100644 index 0000000..fe51f4f --- /dev/null +++ b/council/ts/src/workflows/status-render.ts @@ -0,0 +1,311 @@ +import type { RunTaskView, RunView, RunViewTaskState } from './status.js' + +export interface RunStatusRenderOptions { + readonly color?: boolean +} + +type DisplayState = RunViewTaskState | 'ready' | 'skipped' + +interface BadgeSpec { + readonly label: string + readonly color: AnsiColor +} + +interface WaveGroup { + readonly label: string + readonly tasks: readonly RunTaskView[] +} + +type AnsiColor = 'blue' | 'cyan' | 'gray' | 'green' | 'magenta' | 'red' | 'yellow' + +const RESET = '\u001B[0m' + +const ANSI_CODES: Readonly> = { + blue: '\u001B[34m', + cyan: '\u001B[36m', + gray: '\u001B[90m', + green: '\u001B[32m', + magenta: '\u001B[35m', + red: '\u001B[31m', + yellow: '\u001B[33m', +} + +const BADGES: Readonly> = { + blocked: { color: 'red', label: 'BLOCKED' }, + 'budget-cap': { color: 'yellow', label: 'BUDGET' }, + 'dead-snapshot': { color: 'red', label: 'DEAD' }, + detected: { color: 'yellow', label: 'DETECTED' }, + 'disk-cap': { color: 'yellow', label: 'DISK' }, + exited: { color: 'yellow', label: 'EXITED' }, + failed: { color: 'red', label: 'FAILED' }, + pending: { color: 'gray', label: 'PENDING' }, + ready: { color: 'cyan', label: 'READY' }, + restarting: { color: 'magenta', label: 'RESTART' }, + running: { color: 'blue', label: 'RUNNING' }, + skipped: { color: 'gray', label: 'SKIPPED' }, + 'stale-snapshot': { color: 'yellow', label: 'STALE' }, + stalled: { color: 'yellow', label: 'STALLED' }, + stopped: { color: 'yellow', label: 'STOPPED' }, + succeeded: { color: 'green', label: 'OK' }, +} + +export function renderRunStatusTable(view: RunView, options: RunStatusRenderOptions = {}): string { + const rows = view.tasks.map((task) => rowFor(task, options, displayStateFor(task))) + const widths = columnWidths(rows) + return [ + headerLine(view), + rollupLine(view), + activeLine(view), + ...waveGroups(view).flatMap((group) => renderWaveGroup(group, rows, widths)), + ].join('\n') +} + +export function renderRunStatusJson(view: RunView): string { + return `${stableStringify(view, 0)}\n`.trimEnd() +} + +interface Row { + readonly badge: string + readonly details: string + readonly duration: string + readonly taskId: string +} + +interface Widths { + readonly badge: number + readonly duration: number + readonly taskId: number +} + +function headerLine(view: RunView): string { + return [ + `run ${view.run}`, + `stage=${stageName(view.state)}`, + `elapsed=${formatDuration(view.rollup.elapsedMs)}`, + `started=${view.rollup.startedAt ?? '-'}`, + `updated=${view.rollup.updatedAt ?? '-'}`, + ].join(' ') +} + +function rollupLine(view: RunView): string { + return [ + `rollup counts=${renderCounts(view.tasks)}`, + `ready=${renderList(view.rollup.readySet)}`, + `critical=${renderList(view.rollup.criticalPath, '>')}`, + ].join(' ') +} + +function activeLine(view: RunView): string { + const active = ['detected', 'restarting', 'running'] as const + const parts = active.flatMap((state) => { + const tasks = view.tasks.filter((task) => task.state === state) + return tasks.length === 0 ? [] : [`${state}=${tasks.map(activeTaskLabel).join(',')}`] + }) + return `active ${parts.length === 0 ? '-' : parts.join(' ')}` +} + +function renderWaveGroup(group: WaveGroup, rows: readonly Row[], widths: Widths): readonly string[] { + const byTaskId = new Map(rows.map((row) => [row.taskId, row])) + return [ + `wave ${group.label}`, + tableHeader(widths), + ...group.tasks.map((task) => { + const row = byTaskId.get(task.taskId) + return row === undefined ? '' : tableRow(row, widths) + }), + ] +} + +function tableHeader(widths: Widths): string { + return [ + pad('badge', widths.badge), + pad('task', widths.taskId), + pad('duration', widths.duration), + 'details', + ].join(' ') +} + +function tableRow(row: Row, widths: Widths): string { + return [ + pad(row.badge, widths.badge), + pad(row.taskId, widths.taskId), + pad(row.duration, widths.duration), + row.details, + ].join(' ') +} + +function rowFor(task: RunTaskView, options: RunStatusRenderOptions, state: DisplayState): Row { + return { + badge: badgeFor(state, options), + details: detailsFor(task), + duration: formatDuration(task.durationMs), + taskId: task.taskId, + } +} + +function columnWidths(rows: readonly Row[]): Widths { + const badgeWidth = Math.max('badge'.length, ...rows.map((row) => visibleLength(row.badge))) + 1 + return { + badge: badgeWidth, + duration: Math.max('duration'.length, ...rows.map((row) => row.duration.length)), + taskId: Math.max('task'.length, ...rows.map((row) => row.taskId.length)), + } +} + +function badgeFor(state: DisplayState, options: RunStatusRenderOptions): string { + const spec = BADGES[state] + const label = `[${spec.label}]` + return options.color === true ? `${ANSI_CODES[spec.color]}${label}${RESET}` : label +} + +function detailsFor(task: RunTaskView): string { + return [ + task.title, + ...optionalDetail('worker', task.workerId), + ...optionalDetail('pid', task.pid), + ...optionalAttempt(task.attempt), + ...optionalRestarts(task.restarts), + ...optionalDetail('model', task.modelTier), + ...optionalDetail('detection', task.lastDetection), + ...optionalDetail('terminal', task.terminalStatus), + ...blockedByDetail(task.blockedBy), + ].join('; ') +} + +function optionalDetail(name: string, value: number | string | null): readonly string[] { + return value === null ? [] : [`${name}=${String(value)}`] +} + +function optionalAttempt(attempt: number): readonly string[] { + return attempt === 0 ? [] : [`attempt=${String(attempt)}`] +} + +function optionalRestarts(restarts: number): readonly string[] { + return restarts === 0 ? [] : [`restarts=${String(restarts)}`] +} + +function blockedByDetail(blockedBy: readonly string[]): readonly string[] { + return blockedBy.length === 0 ? [] : [`blocked-by=${blockedBy.join(',')}`] +} + +function waveGroups(view: RunView): readonly WaveGroup[] { + const byTaskId = new Map(view.tasks.map((task) => [task.taskId, task])) + if (view.waves.length === 0) { + return groupTasks(view.tasks) + } + const groupedIds = new Set(view.waves.flatMap((wave) => wave)) + const waveGroupsFromView = view.waves.map((wave, index) => ({ + label: String(index), + tasks: wave.flatMap((taskId) => { + const task = byTaskId.get(taskId) + return task === undefined ? [] : [task] + }), + })) + const ungrouped = view.tasks.filter((task) => !groupedIds.has(task.taskId)) + return ungrouped.length === 0 ? waveGroupsFromView : [...waveGroupsFromView, ...groupTasks(ungrouped)] +} + +function groupTasks(tasks: readonly RunTaskView[]): readonly WaveGroup[] { + const labels = [...new Set(tasks.map((task) => (task.wave === null ? '?' : String(task.wave))))] + return labels.map((label) => ({ + label, + tasks: tasks.filter((task) => (task.wave === null ? '?' : String(task.wave)) === label), + })) +} + +function renderCounts(tasks: readonly RunTaskView[]): string { + const counts = tasks.reduce>((accumulator, task) => { + const state = displayStateFor(task) + accumulator[state] = (accumulator[state] ?? 0) + 1 + return accumulator + }, {}) + return Object.keys(counts) + .sort() + .map((key) => `${key}:${String(counts[key])}`) + .join(' ') +} + +function displayStateFor(task: RunTaskView): DisplayState { + if (task.terminalStatus === 'no-op') { + return 'skipped' + } + if (task.state === 'pending' && task.dependenciesSatisfied) { + return 'ready' + } + return task.state +} + +function activeTaskLabel(task: RunTaskView): string { + const marker = task.lastDetection ?? (task.pid === null ? null : `pid=${String(task.pid)}`) + return marker === null ? task.taskId : `${task.taskId}(${marker})` +} + +function renderList(values: readonly string[], separator = ','): string { + return values.length === 0 ? '-' : values.join(separator) +} + +function stageName(value: RunView['state']): string { + return typeof value.stage === 'string' ? value.stage : JSON.stringify(value) +} + +function formatDuration(milliseconds: number): string { + const totalSeconds = Math.floor(milliseconds / 1000) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + if (hours > 0) { + return `${String(hours)}h${padTime(minutes)}m${padTime(seconds)}s` + } + if (minutes > 0) { + return `${String(minutes)}m${padTime(seconds)}s` + } + return `${String(seconds)}s` +} + +function padTime(value: number): string { + return value.toString().padStart(2, '0') +} + +function pad(value: string, width: number): string { + return `${value}${' '.repeat(Math.max(0, width - visibleLength(value)))}` +} + +function visibleLength(value: string): number { + const withoutColors = Object.values(ANSI_CODES).reduce((text, code) => text.split(code).join(''), value) + return withoutColors.split(RESET).join('').length +} + +function stableStringify(value: unknown, depth: number): string { + if (Array.isArray(value)) { + return stableArray(value, depth) + } + if (value !== null && typeof value === 'object') { + return stableObject(value as Readonly>, depth) + } + return JSON.stringify(value) +} + +function stableArray(values: readonly unknown[], depth: number): string { + if (values.length === 0) { + return '[]' + } + const indent = indentation(depth + 1) + const closeIndent = indentation(depth) + return `[\n${values.map((value) => `${indent}${stableStringify(value, depth + 1)}`).join(',\n')}\n${closeIndent}]` +} + +function stableObject(record: Readonly>, depth: number): string { + const keys = Object.keys(record).sort() + if (keys.length === 0) { + return '{}' + } + const indent = indentation(depth + 1) + const closeIndent = indentation(depth) + return `{\n${keys + .map((key) => `${indent}${JSON.stringify(key)}: ${stableStringify(record[key] ?? null, depth + 1)}`) + .join(',\n')}\n${closeIndent}}` +} + +function indentation(depth: number): string { + return ' '.repeat(depth) +} diff --git a/council/ts/src/workflows/status-watch.test.ts b/council/ts/src/workflows/status-watch.test.ts new file mode 100644 index 0000000..acea0a8 --- /dev/null +++ b/council/ts/src/workflows/status-watch.test.ts @@ -0,0 +1,404 @@ +import { describe, expect, it } from 'vitest' + +import { workerFinishedEvent, workerStartedEvent } from '../contexts/runstore/index.js' +import type { + LiveRunArtifacts, + NormalizedRunDirectory, + WorkerResult, + WorkerSupervisorSnapshot, +} from '../ports/index.js' +import type { Task } from '../shared-kernel/index.js' + +import { + DEFAULT_STATUS_WATCH_INTERVAL_MS, + statusWatchWorkflow, + type StatusWatchFrame, + type StatusWatchTickerPort, +} from './status-watch.js' + +const NOW = new Date('2026-07-03T10:10:00.000Z') + +function task(input: Partial & Pick): Task { + const { id, ...overrides } = input + return { + boundaries: 'stay in workflow', + content_hash: id, + depends_on: [], + difficulty: 'moderate', + id, + model: 'haiku', + objective: `Implement ${id}`, + output_format: 'patch', + paths: [`src/${id}.ts`], + title: `Task ${id}`, + verify: 'npm test', + ...overrides, + } +} + +function workerResult(input: WorkerResult): WorkerResult { + return input +} + +function supervisorSnapshot(input: Partial & Pick): WorkerSupervisorSnapshot { + const { task_id, ...overrides } = input + return { + attempt_id: 1, + logs: { stderr: 'err.log', stdout: 'out.log' }, + model_tier: 'sonnet', + offsets: { stderr: 0, stdout: 0 }, + restart_count: 0, + status: 'running', + task_id, + watchdog: { + handling_detection: false, + loop: { actions: [] }, + progress: { + attemptStartedAtMs: 0, + lastActionAtMs: 0, + lastOutputAtMs: 0, + lastProgressAtMs: 0, + outputBytes: 0, + startedAtMs: 0, + }, + retry: { attempts: 0, failureFingerprints: [] }, + }, + ...overrides, + } +} + +function normalized(input: { + readonly report?: NormalizedRunDirectory['report'] + readonly runId?: string + readonly tasks: readonly Task[] + readonly workerResults?: ReadonlyMap +}): NormalizedRunDirectory { + return { + report: input.report, + runId: input.runId ?? 'run-a', + state: { stage: 'fanout' }, + tasks: input.tasks, + workerResults: input.workerResults ?? new Map(), + } +} + +function artifacts(input: { + readonly events?: LiveRunArtifacts['events'] + readonly normalized: NormalizedRunDirectory + readonly snapshots?: ReadonlyMap + readonly workerResults?: ReadonlyMap +}): LiveRunArtifacts { + return { + events: input.events ?? [], + normalized: input.normalized, + workerResults: input.workerResults ?? input.normalized.workerResults, + workerSupervisorSnapshots: input.snapshots ?? new Map(), + } +} + +function createReader(reads: readonly LiveRunArtifacts[]): { + readonly calls: readonly string[] + readonly readRunDir: (runDir: string) => Promise +} { + const calls: string[] = [] + let index = 0 + return { + calls, + readRunDir(runDir) { + calls.push(runDir) + const current = reads[index] ?? reads.at(-1) + index += 1 + if (current === undefined) throw new Error('missing test artifact') + return Promise.resolve(current) + }, + } +} + +function failingReader(error: Error): (runDir: string) => Promise { + return () => Promise.reject(error) +} + +function ticker(ticks: readonly unknown[]): StatusWatchTickerPort & { readonly intervals: readonly number[] } { + const intervals: number[] = [] + return { + intervals, + ticks(input) { + intervals.push(input.intervalMs) + let index = 0 + const iterable: AsyncIterable & AsyncIterator = { + [Symbol.asyncIterator](): AsyncIterator { + return iterable + }, + next(): Promise> { + const value = ticks[index] + index += 1 + return Promise.resolve(value === undefined ? { done: true, value } : { done: false, value }) + }, + } + return iterable + }, + } +} + +async function collectFrames(input: Parameters[0], deps: Parameters[1]): Promise { + const frames: StatusWatchFrame[] = [] + for await (const frame of statusWatchWorkflow(input, deps)) { + frames.push(frame) + } + return frames +} + +describe('statusWatchWorkflow', () => { + it('renders a --json one-shot snapshot from live run artifacts', async () => { + const tasks = [task({ id: 'T1' })] + const reader = createReader([ + artifacts({ + events: [ + workerStartedEvent({ + attempt: 1, + model_tier: 'haiku', + pid: 4321, + started_at: '2026-07-03T10:00:00.000Z', + task_id: 'T1', + worker_id: 'worker-1', + }), + ], + normalized: normalized({ tasks }), + snapshots: new Map([['T1', supervisorSnapshot({ task_id: 'T1' })]]), + }), + ]) + + await expect( + collectFrames( + { json: true, runDir: '/runs/run-a' }, + { clock: { now: () => NOW }, readRunDir: reader.readRunDir }, + ), + ).resolves.toEqual([ + { + format: 'json', + output: `{ + "rollup": { + "countsByState": { + "running": 1 + }, + "criticalPath": [ + "T1" + ], + "elapsedMs": 600000, + "readySet": [], + "startedAt": "2026-07-03T10:00:00.000Z", + "updatedAt": "2026-07-03T10:00:00.000Z" + }, + "run": "run-a", + "state": { + "stage": "fanout" + }, + "tasks": [ + { + "attempt": 1, + "blockedBy": [], + "dependenciesSatisfied": true, + "durationMs": 600000, + "lastDetection": null, + "modelTier": "haiku", + "pid": 4321, + "restarts": 0, + "startedAt": "2026-07-03T10:00:00.000Z", + "state": "running", + "taskId": "T1", + "terminalStatus": null, + "title": "Task T1", + "updatedAt": "2026-07-03T10:00:00.000Z", + "wave": 0, + "workerId": "worker-1" + } + ], + "waves": [ + [ + "T1" + ] + ] +}\n`, + sequence: 0, + }, + ]) + expect(reader.calls).toEqual(['/runs/run-a']) + }) + + it('renders a --once table snapshot without requiring a ticker', async () => { + const tasks = [ + task({ id: 'T1' }), + task({ depends_on: ['T1'], id: 'T2' }), + ] + const reader = createReader([ + artifacts({ + normalized: normalized({ + report: { + run: 'run-a', + tasks: [], + waves: [['T1'], ['T2']], + }, + tasks, + workerResults: new Map([['T1', workerResult({ status: 'ok', task_id: 'T1' })]]), + }), + }), + ]) + + await expect( + collectFrames( + { once: true, runDir: '/runs/run-a' }, + { clock: { now: () => NOW }, readRunDir: reader.readRunDir }, + ), + ).resolves.toEqual([ + { + format: 'table', + output: `run run-a stage=fanout elapsed=0s started=- updated=- +rollup counts=ready:1 succeeded:1 ready=T2 critical=T2 +active - +wave 0 +badge task duration details +[OK] T1 0s Task T1; terminal=ok +wave 1 +badge task duration details +[READY] T2 0s Task T2 +`, + sequence: 0, + }, + ]) + }) + + it('polls on injected watch ticks and renders every changed frame', async () => { + const tasks = [task({ id: 'T1' })] + const watchTicker = ticker(['tick-1', 'tick-2']) + const reader = createReader([ + artifacts({ + normalized: normalized({ tasks }), + }), + artifacts({ + events: [ + workerStartedEvent({ + attempt: 1, + started_at: '2026-07-03T10:05:00.000Z', + task_id: 'T1', + worker_id: 'worker-1', + }), + ], + normalized: normalized({ tasks }), + }), + artifacts({ + events: [ + workerFinishedEvent({ + duration_ms: 30_000, + finished_at: '2026-07-03T10:05:30.000Z', + status: 'ok', + task_id: 'T1', + worker_id: 'worker-1', + }), + ], + normalized: normalized({ tasks }), + }), + ]) + + const frames = await collectFrames( + { intervalMs: 250, runDir: '/runs/run-a' }, + { + clock: { now: () => NOW }, + readRunDir: reader.readRunDir, + ticker: watchTicker, + }, + ) + + expect(frames.map((frame) => frame.sequence)).toEqual([0, 1, 2]) + expect(frames.map((frame) => frame.output.split('\n')[1])).toEqual([ + 'rollup counts=ready:1 ready=T1 critical=T1', + 'rollup counts=running:1 ready=- critical=T1', + 'rollup counts=succeeded:1 ready=- critical=-', + ]) + expect(reader.calls).toEqual(['/runs/run-a', '/runs/run-a', '/runs/run-a']) + expect(watchTicker.intervals).toEqual([250]) + }) + + it('suppresses unchanged watch frames when requested', async () => { + const tasks = [task({ id: 'T1' })] + const watchTicker = ticker(['tick-1', 'tick-2']) + const unchanged = artifacts({ normalized: normalized({ tasks }) }) + const reader = createReader([ + unchanged, + unchanged, + artifacts({ + normalized: normalized({ + tasks, + workerResults: new Map([['T1', workerResult({ status: 'ok', task_id: 'T1' })]]), + }), + }), + ]) + + const frames = await collectFrames( + { runDir: '/runs/run-a', suppressUnchanged: true }, + { + clock: { now: () => NOW }, + readRunDir: reader.readRunDir, + ticker: watchTicker, + }, + ) + + expect(frames.map((frame) => ({ output: frame.output.split('\n')[1], sequence: frame.sequence }))).toEqual([ + { output: 'rollup counts=ready:1 ready=T1 critical=T1', sequence: 0 }, + { output: 'rollup counts=succeeded:1 ready=- critical=-', sequence: 1 }, + ]) + expect(reader.calls).toEqual(['/runs/run-a', '/runs/run-a', '/runs/run-a']) + expect(watchTicker.intervals).toEqual([DEFAULT_STATUS_WATCH_INTERVAL_MS]) + }) + + it('keeps partial run directories deterministic by planning waves from tasks', async () => { + const tasks = [ + task({ id: 'T1' }), + task({ depends_on: ['T1'], id: 'T2' }), + task({ depends_on: ['T2'], id: 'T3' }), + ] + const reader = createReader([ + artifacts({ + normalized: normalized({ + tasks, + workerResults: new Map([['T1', workerResult({ status: 'ok', task_id: 'T1' })]]), + }), + workerResults: new Map([['T2', workerResult({ status: 'ok', task_id: 'T2' })]]), + }), + ]) + + const frames = await collectFrames( + { once: true, runDir: '/runs/partial' }, + { clock: { now: () => NOW }, readRunDir: reader.readRunDir }, + ) + + expect(frames[0]?.output).toContain(`rollup counts=ready:1 succeeded:2 ready=T3 critical=T3`) + expect(frames[0]?.output).toContain(`wave 0 +badge task duration details +[OK] T1 0s Task T1; terminal=ok +wave 1 +badge task duration details +[OK] T2 0s Task T2; terminal=ok +wave 2 +badge task duration details +[READY] T3 0s Task T3 +`) + }) + + it('propagates adapter errors without manufacturing output frames', async () => { + await expect( + collectFrames( + { once: true, runDir: '/runs/broken' }, + { clock: { now: () => NOW }, readRunDir: failingReader(new Error('read failed')) }, + ), + ).rejects.toThrow('read failed') + }) + + it('requires a ticker for watch mode', async () => { + await expect( + collectFrames( + { runDir: '/runs/run-a' }, + { clock: { now: () => NOW }, readRunDir: createReader([]).readRunDir }, + ), + ).rejects.toThrow('ticker dependency is required for status watch mode') + }) +}) diff --git a/council/ts/src/workflows/status-watch.ts b/council/ts/src/workflows/status-watch.ts new file mode 100644 index 0000000..10958fb --- /dev/null +++ b/council/ts/src/workflows/status-watch.ts @@ -0,0 +1,123 @@ +import { planWaves } from '../contexts/graph/index.js' +import type { LiveRunArtifacts, LiveRunDirReaderPort, WorkerResult } from '../ports/index.js' + +import { renderRunStatusJson, renderRunStatusTable } from './status-render.js' +import { projectRunView, type RunSummary, type RunView, type RunViewClock } from './status.js' + +export const DEFAULT_STATUS_WATCH_INTERVAL_MS = 1_000 + +export type StatusWatchFrameFormat = 'json' | 'table' + +export interface StatusWatchInput { + readonly color?: boolean + readonly intervalMs?: number + readonly json?: boolean + readonly once?: boolean + readonly runDir: string + readonly suppressUnchanged?: boolean +} + +export interface StatusWatchTickerInput { + readonly intervalMs: number +} + +export interface StatusWatchTickerPort { + ticks(input: StatusWatchTickerInput): AsyncIterable +} + +export interface StatusWatchWorkflowDeps { + readonly clock: RunViewClock + readonly readRunDir: LiveRunDirReaderPort['readRunDir'] + readonly ticker?: StatusWatchTickerPort +} + +export interface StatusWatchFrame { + readonly format: StatusWatchFrameFormat + readonly output: string + readonly sequence: number +} + +export async function* statusWatchWorkflow( + input: StatusWatchInput, + deps: StatusWatchWorkflowDeps, +): AsyncIterable { + const ticker = statusWatchTicker(input, deps) + let previousOutput: string | undefined + let sequence = 0 + + const firstFrame = await renderStatusFrame(input, deps.clock, deps.readRunDir, sequence) + yield firstFrame + previousOutput = firstFrame.output + sequence += 1 + + if (ticker === undefined) return + + for await (const tick of ticker.ticks({ intervalMs: input.intervalMs ?? DEFAULT_STATUS_WATCH_INTERVAL_MS })) { + void tick + const frame = await renderStatusFrame(input, deps.clock, deps.readRunDir, sequence) + if (input.suppressUnchanged === true && frame.output === previousOutput) { + continue + } + yield frame + previousOutput = frame.output + sequence += 1 + } +} + +function statusWatchTicker( + input: StatusWatchInput, + deps: StatusWatchWorkflowDeps, +): StatusWatchTickerPort | undefined { + if (input.json === true || input.once === true) return undefined + if (deps.ticker === undefined) throw new Error('ticker dependency is required for status watch mode') + return deps.ticker +} + +async function renderStatusFrame( + input: StatusWatchInput, + clock: RunViewClock, + readRunDir: LiveRunDirReaderPort['readRunDir'], + sequence: number, +): Promise { + const artifacts = await readRunDir(input.runDir) + const view = projectRunView({ + clock, + events: artifacts.events, + summary: runSummaryFromArtifacts(artifacts), + supervisorSnapshots: [...artifacts.workerSupervisorSnapshots.values()], + }) + const format = statusWatchFrameFormat(input) + return { + format, + output: `${renderView(view, format, input)}\n`, + sequence, + } +} + +function runSummaryFromArtifacts(artifacts: LiveRunArtifacts): RunSummary { + const workerResults = workerResultsFromArtifacts(artifacts) + return { + ...(artifacts.normalized.report === undefined ? {} : { report: artifacts.normalized.report }), + run: artifacts.normalized.runId, + state: artifacts.normalized.state, + tasks: artifacts.normalized.tasks, + waves: artifacts.normalized.report?.waves ?? planWaves(artifacts.normalized.tasks), + workerResults, + } +} + +function workerResultsFromArtifacts(artifacts: LiveRunArtifacts): readonly WorkerResult[] { + const merged = new Map(artifacts.normalized.workerResults) + for (const [taskId, result] of artifacts.workerResults) { + merged.set(taskId, result) + } + return [...merged.values()] +} + +function statusWatchFrameFormat(input: StatusWatchInput): StatusWatchFrameFormat { + return input.json === true ? 'json' : 'table' +} + +function renderView(view: RunView, format: StatusWatchFrameFormat, input: StatusWatchInput): string { + return format === 'json' ? renderRunStatusJson(view) : renderRunStatusTable(view, { color: input.color === true }) +} diff --git a/council/ts/src/workflows/status.projection.test.ts b/council/ts/src/workflows/status.projection.test.ts new file mode 100644 index 0000000..cf3b2c3 --- /dev/null +++ b/council/ts/src/workflows/status.projection.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it } from 'vitest' + +import { + amendmentEvent, + workerDetectedEvent, + workerExitedEvent, + workerFinishedEvent, + workerOutputEvent, + workerRestartedEvent, + workerStartedEvent, +} from '../contexts/runstore/index.js' +import type { RunStoreEvent } from '../contexts/runstore/index.js' +import type { Task } from '../shared-kernel/index.js' + +import { projectRunView } from './status.js' +import type { RunSummary, RunViewSupervisorSnapshot } from './status.js' + +const NOW = new Date('2026-07-03T10:10:00.000Z') + +function task(input: Partial & Pick): Task { + const { id, ...overrides } = input + return { + boundaries: 'stay in workflow', + content_hash: id, + depends_on: [], + difficulty: 'moderate', + id, + model: 'haiku', + objective: `Implement ${id}`, + output_format: 'patch', + paths: [`src/${id}.ts`], + title: `Task ${id}`, + verify: 'npm test', + ...overrides, + } +} + +function summary(input: { + readonly tasks: readonly Task[] + readonly waves?: readonly (readonly string[])[] + readonly workerResults?: RunSummary['workerResults'] +}): RunSummary { + return { + run: 'run-a', + state: { stage: 'fanout' }, + tasks: input.tasks, + waves: input.waves ?? input.tasks.map((item) => [item.id]), + workerResults: input.workerResults ?? [], + } +} + +function snapshot(input: Partial & Pick): RunViewSupervisorSnapshot { + const { task_id, ...overrides } = input + return { + attempt_id: 1, + model_tier: 'sonnet', + restart_count: 0, + status: 'running', + task_id, + watchdog: { + pending_detection: { kind: 'progress-stall' }, + }, + ...overrides, + } +} + +function project(input: { + readonly events?: readonly RunStoreEvent[] + readonly snapshots?: readonly RunViewSupervisorSnapshot[] + readonly summary: RunSummary +}) { + return projectRunView({ + clock: { now: () => NOW }, + events: input.events ?? [], + summary: input.summary, + supervisorSnapshots: input.snapshots ?? [], + }) +} + +describe('projectRunView', () => { + it('uses lifecycle events before supervisor snapshots for task freshness', () => { + const tasks = [task({ id: 'T1' })] + const runSummary = summary({ tasks, waves: [['T1']] }) + const events = [ + amendmentEvent({ content_hash: 'a1', id: 'amend-1', summary: 'ignored by status view' }), + workerStartedEvent({ + attempt: 2, + model_tier: 'haiku', + pid: 101, + started_at: '2026-07-03T10:00:00.000Z', + task_id: 'T1', + worker_id: 'worker-1', + }), + workerOutputEvent({ + byte_count: 11, + offset: 11, + stream: 'stdout', + tail: 'hello world', + worker_id: 'worker-1', + }), + workerDetectedEvent({ + detected_at: '2026-07-03T10:01:00.000Z', + status: 'progress-stall', + task_id: 'T1', + worker_id: 'worker-1', + }), + workerRestartedEvent({ + attempt: 3, + reason: 'progress-stall', + restarted_at: '2026-07-03T10:02:00.000Z', + task_id: 'T1', + worker_id: 'worker-1', + }), + workerExitedEvent({ + duration_ms: 180_000, + exit_code: 1, + exited_at: '2026-07-03T10:03:00.000Z', + task_id: 'T1', + worker_id: 'worker-1', + }), + ] + + const view = project({ + events, + snapshots: [ + snapshot({ + attempt_id: 9, + model_tier: 'opus', + restart_count: 8, + status: 'failed', + task_id: 'T1', + watchdog: { pending_detection: { kind: 'snapshot-only' } }, + }), + ], + summary: runSummary, + }) + + expect(view.tasks).toEqual([ + { + attempt: 3, + blockedBy: [], + dependenciesSatisfied: true, + durationMs: 180_000, + lastDetection: 'progress-stall', + modelTier: 'haiku', + pid: 101, + restarts: 2, + startedAt: '2026-07-03T10:00:00.000Z', + state: 'exited', + taskId: 'T1', + terminalStatus: null, + title: 'Task T1', + updatedAt: '2026-07-03T10:03:00.000Z', + wave: 0, + workerId: 'worker-1', + }, + ]) + expect(view.rollup.countsByState).toEqual({ exited: 1 }) + expect(runSummary.tasks).toBe(tasks) + expect(runSummary.waves).toEqual([['T1']]) + }) + + it('uses supervisor snapshots only where lifecycle events leave gaps', () => { + const view = project({ + snapshots: [ + snapshot({ + attempt_id: 4, + model_tier: 'opus', + restart_count: 3, + status: 'detected', + task_id: 'T1', + watchdog: { pending_detection: { kind: 'budget-cap' } }, + }), + ], + summary: summary({ tasks: [task({ id: 'T1' })] }), + }) + + expect(view.tasks[0]).toMatchObject({ + attempt: 4, + durationMs: 0, + lastDetection: 'budget-cap', + modelTier: 'opus', + restarts: 3, + startedAt: null, + state: 'detected', + updatedAt: null, + }) + }) + + it('projects worker-result terminal states without requiring lifecycle artifacts', () => { + const tasks = [ + task({ id: 'T1' }), + task({ id: 'T2' }), + task({ id: 'T3' }), + task({ id: 'T4' }), + task({ id: 'T5' }), + task({ id: 'T6' }), + task({ id: 'T7' }), + task({ id: 'T8' }), + task({ id: 'T9' }), + task({ id: 'T10' }), + ] + + const view = project({ + summary: summary({ + tasks, + workerResults: [ + { model_tier: 'sonnet', status: 'ok', task_id: 'T1' }, + { status: 'failed', task_id: 'T2' }, + { status: 'stalled', task_id: 'T3' }, + { status: 'budget-cap', task_id: 'T4' }, + { status: 'disk-cap', task_id: 'T5' }, + { status: 'dead-snapshot', task_id: 'T6' }, + { status: 'stale-snapshot', task_id: 'T7' }, + { status: 'stopped', task_id: 'T8' }, + { status: 'no-op', task_id: 'T9' }, + { status: 'unexpected-error', task_id: 'T10' }, + ], + }), + }) + + expect(view.tasks.map((item) => [item.taskId, item.state, item.terminalStatus])).toEqual([ + ['T1', 'succeeded', 'ok'], + ['T2', 'failed', 'failed'], + ['T3', 'stalled', 'stalled'], + ['T4', 'budget-cap', 'budget-cap'], + ['T5', 'disk-cap', 'disk-cap'], + ['T6', 'dead-snapshot', 'dead-snapshot'], + ['T7', 'stale-snapshot', 'stale-snapshot'], + ['T8', 'stopped', 'stopped'], + ['T9', 'succeeded', 'no-op'], + ['T10', 'failed', 'unexpected-error'], + ]) + expect(view.tasks[0]?.modelTier).toBe('sonnet') + expect(view.rollup.countsByState).toEqual({ + 'budget-cap': 1, + 'dead-snapshot': 1, + 'disk-cap': 1, + failed: 2, + 'stale-snapshot': 1, + stalled: 1, + stopped: 1, + succeeded: 2, + }) + }) + + it('keeps missing and partial run artifacts deterministic and dependency-blocked', () => { + const view = project({ + events: [ + workerStartedEvent({ task_id: 'T99', worker_id: 'worker-99' }), + workerOutputEvent({ + byte_count: 1, + offset: 1, + stream: 'stderr', + task_id: 'T99', + worker_id: 'worker-99', + }), + workerDetectedEvent({ task_id: 'T99', worker_id: 'worker-99' }), + workerRestartedEvent({ attempt: 2, task_id: 'T99', worker_id: 'worker-99' }), + workerExitedEvent({ exit_code: 0, task_id: 'T99', worker_id: 'worker-99' }), + workerFinishedEvent({ status: 'ok', task_id: 'T99', worker_id: 'worker-99' }), + ], + snapshots: [snapshot({ task_id: 'T99' })], + summary: summary({ + tasks: [ + task({ id: 'T1' }), + task({ depends_on: ['T1'], id: 'T2' }), + task({ depends_on: ['T404'], id: 'T3' }), + ], + waves: [], + workerResults: [{ status: 'ok', task_id: 'T99' }], + }), + }) + + expect(view.tasks.map((item) => ({ + blockedBy: item.blockedBy, + dependenciesSatisfied: item.dependenciesSatisfied, + state: item.state, + taskId: item.taskId, + wave: item.wave, + }))).toEqual([ + { blockedBy: [], dependenciesSatisfied: true, state: 'pending', taskId: 'T1', wave: null }, + { blockedBy: ['T1'], dependenciesSatisfied: false, state: 'blocked', taskId: 'T2', wave: null }, + { blockedBy: ['T404'], dependenciesSatisfied: false, state: 'blocked', taskId: 'T3', wave: null }, + ]) + expect(view.rollup.readySet).toEqual(['T1']) + expect(view.rollup.elapsedMs).toBe(0) + }) + + it('orders the ready set and critical path by longest remaining path then stable task order', () => { + const view = project({ + summary: summary({ + tasks: [ + task({ id: 'T2' }), + task({ id: 'T1' }), + task({ depends_on: ['T1'], id: 'T3' }), + task({ depends_on: ['T3'], id: 'T4' }), + task({ depends_on: ['T2'], id: 'T5' }), + task({ depends_on: ['T2'], id: 'T6' }), + task({ id: 'T7' }), + task({ depends_on: ['T5'], id: 'T8' }), + task({ depends_on: ['T6'], id: 'T9' }), + ], + }), + }) + + expect(view.rollup.readySet).toEqual(['T2', 'T1', 'T7']) + expect(view.rollup.criticalPath).toEqual(['T2', 'T5', 'T8']) + }) + + it('calculates task durations and run elapsed time with the injected clock', () => { + const view = project({ + events: [ + workerStartedEvent({ + started_at: '2026-07-03T10:00:00.000Z', + task_id: 'T1', + worker_id: 'worker-1', + }), + workerStartedEvent({ + started_at: '2026-07-03T10:01:00.000Z', + task_id: 'T2', + worker_id: 'worker-2', + }), + workerFinishedEvent({ + duration_ms: 60_000, + finished_at: '2026-07-03T10:02:00.000Z', + status: 'ok', + task_id: 'T2', + worker_id: 'worker-2', + }), + ], + summary: summary({ tasks: [task({ id: 'T1' }), task({ id: 'T2' })] }), + }) + + expect(view.tasks.map((item) => [item.taskId, item.state, item.durationMs])).toEqual([ + ['T1', 'running', 600_000], + ['T2', 'succeeded', 60_000], + ]) + expect(view.rollup).toMatchObject({ + elapsedMs: 600_000, + startedAt: '2026-07-03T10:00:00.000Z', + updatedAt: '2026-07-03T10:02:00.000Z', + }) + }) +}) diff --git a/council/ts/src/workflows/status.ts b/council/ts/src/workflows/status.ts index 511bd25..10efd74 100644 --- a/council/ts/src/workflows/status.ts +++ b/council/ts/src/workflows/status.ts @@ -1,6 +1,7 @@ import { planWaves } from '../contexts/graph/index.js' +import type { RunStoreEvent } from '../contexts/runstore/index.js' import type { LegacyRunNormalizerPort, LegacyRunReport, WorkerResult } from '../ports/index.js' -import type { RunState, Task } from '../shared-kernel/index.js' +import type { RunState, Task, TaskId } from '../shared-kernel/index.js' export interface RunSummary { readonly report?: LegacyRunReport @@ -11,6 +12,95 @@ export interface RunSummary { readonly workerResults: readonly WorkerResult[] } +export type RunViewTaskState = + | 'blocked' + | 'budget-cap' + | 'dead-snapshot' + | 'detected' + | 'disk-cap' + | 'exited' + | 'failed' + | 'pending' + | 'restarting' + | 'running' + | 'stale-snapshot' + | 'stalled' + | 'stopped' + | 'succeeded' + +export type RunViewSupervisorSnapshotStatus = + | 'budget-cap' + | 'completed' + | 'detected' + | 'disk-cap' + | 'exited' + | 'failed' + | 'restarting' + | 'running' + | 'stalled' + | 'stopped' + +export interface RunViewSupervisorSnapshot { + readonly task_id: string + readonly attempt_id: number + readonly restart_count: number + readonly model_tier?: string + readonly pid?: number + readonly status: RunViewSupervisorSnapshotStatus + readonly watchdog?: { + readonly pending_detection?: { + readonly kind?: unknown + } + } +} + +export interface RunViewClock { + now(): Date +} + +export interface ProjectRunViewInput { + readonly clock: RunViewClock + readonly events?: readonly RunStoreEvent[] + readonly summary: RunSummary + readonly supervisorSnapshots?: readonly RunViewSupervisorSnapshot[] +} + +export interface RunTaskView { + readonly attempt: number + readonly blockedBy: readonly TaskId[] + readonly dependenciesSatisfied: boolean + readonly durationMs: number + readonly lastDetection: string | null + readonly modelTier: string | null + readonly pid: number | null + readonly restarts: number + readonly startedAt: string | null + readonly state: RunViewTaskState + readonly taskId: TaskId + readonly terminalStatus: string | null + readonly title: string + readonly updatedAt: string | null + readonly wave: number | null + readonly workerId: string | null +} + +export interface RunViewRollup { + readonly countsByState: Readonly> + readonly criticalPath: readonly TaskId[] + readonly elapsedMs: number + readonly readySet: readonly TaskId[] + readonly startedAt: string | null + readonly updatedAt: string | null +} + +export interface RunView { + readonly rollup: RunViewRollup + readonly run: string + readonly state: RunState + readonly tasks: readonly RunTaskView[] + readonly waves: readonly (readonly string[])[] +} + export async function statusWorkflow( input: { readonly runDir: string }, deps: LegacyRunNormalizerPort, @@ -25,3 +115,533 @@ export async function statusWorkflow( workerResults: [...normalized.workerResults.values()], } } + +type EventField = + | 'attempt' + | 'duration' + | 'lastDetection' + | 'modelTier' + | 'pid' + | 'restarts' + | 'startedAt' + | 'state' + | 'updatedAt' + | 'workerId' + +interface MutableTaskProjection { + readonly eventFields: Set + readonly order: number + readonly task: Task + attempt: number + durationMs: number | null + lastDetection: string | null + modelTier: string | null + pid: number | null + restarts: number + startedAt: string | null + state: RunViewTaskState + terminalStatus: string | null + updatedAt: string | null + wave: number | null + workerId: string | null +} + +const SNAPSHOT_STATE: Readonly> = { + 'budget-cap': 'budget-cap', + completed: 'succeeded', + detected: 'detected', + 'disk-cap': 'disk-cap', + exited: 'exited', + failed: 'failed', + restarting: 'restarting', + running: 'running', + stalled: 'stalled', + stopped: 'stopped', +} + +const TERMINAL_WORKER_STATES: Readonly> = { + 'budget-cap': 'budget-cap', + completed: 'succeeded', + 'dead-snapshot': 'dead-snapshot', + 'disk-cap': 'disk-cap', + failed: 'failed', + 'no-op': 'succeeded', + ok: 'succeeded', + passed: 'succeeded', + stale: 'stale-snapshot', + 'stale-snapshot': 'stale-snapshot', + stalled: 'stalled', + stopped: 'stopped', + succeeded: 'succeeded', +} + +export function projectRunView(input: ProjectRunViewInput): RunView { + const nowMs = input.clock.now().getTime() + const waveByTaskId = indexWaves(input.summary.waves) + const projections = input.summary.tasks.map((task, order) => + initialProjection(task, order, waveByTaskId.get(task.id) ?? null), + ) + const byTaskId = new Map(projections.map((projection) => [projection.task.id, projection])) + const workerTaskIds = new Map() + + for (const event of input.events ?? []) { + applyRunStoreEvent(event, byTaskId, workerTaskIds) + } + applyWorkerResults(input.summary.workerResults, byTaskId) + applySupervisorSnapshots(input.supervisorSnapshots ?? [], byTaskId) + + const tasks = projections.map((projection) => taskViewFor(projection, byTaskId, nowMs)) + const rollup = rollupFor(tasks, projections, nowMs) + return { + rollup, + run: input.summary.run, + state: input.summary.state, + tasks, + waves: input.summary.waves.map((wave) => [...wave]), + } +} + +function initialProjection(task: Task, order: number, wave: number | null): MutableTaskProjection { + return { + attempt: 0, + durationMs: null, + eventFields: new Set(), + lastDetection: null, + modelTier: null, + order, + pid: null, + restarts: 0, + startedAt: null, + state: 'pending', + task, + terminalStatus: null, + updatedAt: null, + wave, + workerId: null, + } +} + +function indexWaves(waves: readonly (readonly string[])[]): ReadonlyMap { + const index = new Map() + waves.forEach((wave, waveIndex) => { + wave.forEach((taskId) => { + if (!index.has(taskId)) { + index.set(taskId, waveIndex) + } + }) + }) + return index +} + +function applyRunStoreEvent( + event: RunStoreEvent, + byTaskId: ReadonlyMap, + workerTaskIds: Map, +): void { + if (event.type === 'worker_started') { + const projection = projectionForEvent(event.payload.task_id, event.payload.worker_id, byTaskId, workerTaskIds) + if (projection === undefined) return + applyWorkerIdentity(projection, event.payload.worker_id) + projection.state = 'running' + projection.eventFields.add('state') + if (event.payload.attempt !== undefined) { + projection.attempt = event.payload.attempt + projection.restarts = Math.max(0, event.payload.attempt - 1) + projection.eventFields.add('attempt') + projection.eventFields.add('restarts') + } + if (event.payload.model_tier !== undefined) { + projection.modelTier = event.payload.model_tier + projection.eventFields.add('modelTier') + } + applyPid(projection, event.payload.pid) + applyStartedAt(projection, event.payload.started_at) + return + } + + if (event.type === 'worker_output') { + const projection = projectionForEvent(event.payload.task_id, event.payload.worker_id, byTaskId, workerTaskIds) + if (projection === undefined) return + applyWorkerIdentity(projection, event.payload.worker_id) + projection.state = 'running' + projection.eventFields.add('state') + applyUpdatedAt(projection, event.payload.observed_at) + return + } + + if (event.type === 'worker_detected') { + const projection = projectionForEvent(event.payload.task_id, event.payload.worker_id, byTaskId, workerTaskIds) + if (projection === undefined) return + applyWorkerIdentity(projection, event.payload.worker_id) + projection.state = 'detected' + projection.eventFields.add('state') + applyPid(projection, event.payload.pid) + applyDetection(projection, event.payload.status) + applyUpdatedAt(projection, event.payload.detected_at) + return + } + + if (event.type === 'worker_restarted') { + const projection = projectionForEvent(event.payload.task_id, event.payload.worker_id, byTaskId, workerTaskIds) + if (projection === undefined) return + applyWorkerIdentity(projection, event.payload.worker_id) + projection.attempt = event.payload.attempt + projection.restarts = Math.max(0, event.payload.attempt - 1) + projection.state = 'restarting' + projection.eventFields.add('attempt') + projection.eventFields.add('restarts') + projection.eventFields.add('state') + applyPid(projection, event.payload.pid) + applyDetection(projection, event.payload.reason) + applyUpdatedAt(projection, event.payload.restarted_at) + return + } + + if (event.type === 'worker_exited') { + const projection = projectionForEvent(event.payload.task_id, event.payload.worker_id, byTaskId, workerTaskIds) + if (projection === undefined) return + applyWorkerIdentity(projection, event.payload.worker_id) + projection.state = 'exited' + projection.eventFields.add('state') + applyPid(projection, event.payload.pid) + applyDuration(projection, event.payload.duration_ms) + applyUpdatedAt(projection, event.payload.exited_at) + return + } + + if (event.type === 'worker_finished') { + const projection = byTaskId.get(event.payload.task_id as TaskId) + if (projection === undefined) return + applyWorkerIdentity(projection, event.payload.worker_id) + projection.state = stateFromWorkerStatus(event.payload.status) + projection.terminalStatus = event.payload.status + projection.eventFields.add('state') + applyDuration(projection, event.payload.duration_ms) + applyUpdatedAt(projection, event.payload.finished_at) + } +} + +function projectionForEvent( + taskId: string | undefined, + workerId: string, + byTaskId: ReadonlyMap, + workerTaskIds: Map, +): MutableTaskProjection | undefined { + const knownTaskId = taskId as TaskId | undefined + if (knownTaskId !== undefined && byTaskId.has(knownTaskId)) { + workerTaskIds.set(workerId, knownTaskId) + return byTaskId.get(knownTaskId) + } + const mappedTaskId = workerTaskIds.get(workerId) + return mappedTaskId === undefined ? undefined : byTaskId.get(mappedTaskId) +} + +function applyWorkerIdentity(projection: MutableTaskProjection, workerId: string): void { + projection.workerId = workerId + projection.eventFields.add('workerId') +} + +function applyPid(projection: MutableTaskProjection, pid: number | undefined): void { + if (pid !== undefined) { + projection.pid = pid + projection.eventFields.add('pid') + } +} + +function applyStartedAt(projection: MutableTaskProjection, startedAt: string | undefined): void { + if (startedAt !== undefined) { + projection.startedAt = projection.startedAt ?? startedAt + projection.updatedAt = startedAt + projection.eventFields.add('startedAt') + projection.eventFields.add('updatedAt') + } +} + +function applyUpdatedAt(projection: MutableTaskProjection, updatedAt: string | undefined): void { + if (updatedAt !== undefined) { + projection.updatedAt = updatedAt + projection.eventFields.add('updatedAt') + } +} + +function applyDetection(projection: MutableTaskProjection, detection: string | undefined): void { + if (detection !== undefined) { + projection.lastDetection = detection + projection.eventFields.add('lastDetection') + } +} + +function applyDuration(projection: MutableTaskProjection, durationMs: number | undefined): void { + if (durationMs !== undefined) { + projection.durationMs = durationMs + projection.eventFields.add('duration') + } +} + +function applyWorkerResults( + workerResults: readonly WorkerResult[], + byTaskId: ReadonlyMap, +): void { + workerResults.forEach((result) => { + const projection = byTaskId.get(result.task_id as TaskId) + if (projection === undefined) return + projection.state = stateFromWorkerStatus(result.status) + projection.terminalStatus = result.status + if (!projection.eventFields.has('modelTier') && result.model_tier !== undefined) { + projection.modelTier = result.model_tier + } + }) +} + +function applySupervisorSnapshots( + snapshots: readonly RunViewSupervisorSnapshot[], + byTaskId: ReadonlyMap, +): void { + snapshots.forEach((snapshot) => { + const projection = byTaskId.get(snapshot.task_id as TaskId) + if (projection === undefined) return + if (!projection.eventFields.has('state') && projection.terminalStatus === null) { + projection.state = SNAPSHOT_STATE[snapshot.status] + } + if (!projection.eventFields.has('attempt')) { + projection.attempt = snapshot.attempt_id + } + if (!projection.eventFields.has('restarts')) { + projection.restarts = snapshot.restart_count + } + if (!projection.eventFields.has('modelTier') && projection.modelTier === null) { + projection.modelTier = snapshot.model_tier ?? null + } + if (!projection.eventFields.has('pid') && projection.pid === null) { + projection.pid = snapshot.pid ?? null + } + if (!projection.eventFields.has('lastDetection') && projection.lastDetection === null) { + projection.lastDetection = detectionKind(snapshot.watchdog?.pending_detection) + } + }) +} + +function detectionKind(detection: { readonly kind?: unknown } | undefined): string | null { + return typeof detection?.kind === 'string' ? detection.kind : null +} + +function stateFromWorkerStatus(status: string): RunViewTaskState { + return TERMINAL_WORKER_STATES[status] ?? 'failed' +} + +function taskViewFor( + projection: MutableTaskProjection, + byTaskId: ReadonlyMap, + nowMs: number, +): RunTaskView { + const blockedBy = projection.task.depends_on.filter( + (dependency) => byTaskId.get(dependency)?.state !== 'succeeded', + ) + const dependenciesSatisfied = blockedBy.length === 0 + const state = projection.state === 'pending' && !dependenciesSatisfied ? 'blocked' : projection.state + return { + attempt: projection.attempt, + blockedBy, + dependenciesSatisfied, + durationMs: durationMsFor(projection, state, nowMs), + lastDetection: projection.lastDetection, + modelTier: projection.modelTier, + pid: projection.pid, + restarts: projection.restarts, + startedAt: projection.startedAt, + state, + taskId: projection.task.id, + terminalStatus: projection.terminalStatus, + title: projection.task.title, + updatedAt: projection.updatedAt, + wave: projection.wave, + workerId: projection.workerId, + } +} + +function durationMsFor( + projection: MutableTaskProjection, + state: RunViewTaskState, + nowMs: number, +): number { + if (projection.durationMs !== null) { + return projection.durationMs + } + const startedAtMs = timestampMs(projection.startedAt) + if (startedAtMs === null) { + return 0 + } + const updatedAtMs = isActiveState(state) ? nowMs : timestampMs(projection.updatedAt) ?? nowMs + return Math.max(0, updatedAtMs - startedAtMs) +} + +function isActiveState(state: RunViewTaskState): boolean { + return state === 'detected' || state === 'restarting' || state === 'running' +} + +function rollupFor( + tasks: readonly RunTaskView[], + projections: readonly MutableTaskProjection[], + nowMs: number, +): RunViewRollup { + const startedAt = earliestTimestamp(tasks.map((task) => task.startedAt)) + const updatedAt = latestTimestamp(tasks.map((task) => task.updatedAt)) + return { + countsByState: countsByState(tasks), + criticalPath: criticalPath(tasks, projections), + elapsedMs: elapsedMsFor(tasks, startedAt, updatedAt, nowMs), + readySet: readySet(tasks, projections), + startedAt, + updatedAt, + } +} + +function countsByState(tasks: readonly RunTaskView[]): Readonly> { + return tasks.reduce>((counts, task) => { + counts[task.state] = (counts[task.state] ?? 0) + 1 + return counts + }, {}) +} + +function readySet( + tasks: readonly RunTaskView[], + projections: readonly MutableTaskProjection[], +): readonly TaskId[] { + const viewByTaskId = new Map(tasks.map((task) => [task.taskId, task])) + const lengths = remainingPathLengths(viewByTaskId, projections) + return tasks + .filter((task) => task.state === 'pending' && task.dependenciesSatisfied) + .sort((left, right) => comparePathPriority(left.taskId, right.taskId, lengths, projections)) + .map((task) => task.taskId) +} + +function criticalPath( + tasks: readonly RunTaskView[], + projections: readonly MutableTaskProjection[], +): readonly TaskId[] { + const viewByTaskId = new Map(tasks.map((task) => [task.taskId, task])) + const lengths = remainingPathLengths(viewByTaskId, projections) + const candidates = tasks + .filter((task) => task.state !== 'succeeded') + .filter((task) => nonSucceededDependencies(task, viewByTaskId).length === 0) + const start = [...candidates].sort((left, right) => + comparePathPriority(left.taskId, right.taskId, lengths, projections), + )[0] + return start === undefined ? [] : pathFrom(start.taskId, viewByTaskId, lengths, projections) +} + +function pathFrom( + taskId: TaskId, + viewByTaskId: ReadonlyMap, + lengths: ReadonlyMap, + projections: readonly MutableTaskProjection[], +): readonly TaskId[] { + const dependent = dependentsOf(taskId, projections) + .filter((dependentId) => viewByTaskId.get(dependentId)?.state !== 'succeeded') + .sort((left, right) => comparePathPriority(left, right, lengths, projections))[0] + return dependent === undefined ? [taskId] : [taskId, ...pathFrom(dependent, viewByTaskId, lengths, projections)] +} + +function remainingPathLengths( + viewByTaskId: ReadonlyMap, + projections: readonly MutableTaskProjection[], +): ReadonlyMap { + const lengths = new Map() + projections.forEach((projection) => { + lengths.set(projection.task.id, remainingPathLength(projection.task.id, viewByTaskId, projections, lengths)) + }) + return lengths +} + +function remainingPathLength( + taskId: TaskId, + viewByTaskId: ReadonlyMap, + projections: readonly MutableTaskProjection[], + lengths: Map, +): number { + const cached = lengths.get(taskId) + if (cached !== undefined) { + return cached + } + const openDependents = dependentsOf(taskId, projections).filter( + (dependentId) => viewByTaskId.get(dependentId)?.state !== 'succeeded', + ) + const length = + 1 + + openDependents.reduce( + (longest, dependentId) => + Math.max(longest, remainingPathLength(dependentId, viewByTaskId, projections, lengths)), + 0, + ) + lengths.set(taskId, length) + return length +} + +function dependentsOf(taskId: TaskId, projections: readonly MutableTaskProjection[]): readonly TaskId[] { + return projections + .filter((projection) => projection.task.depends_on.includes(taskId)) + .map((projection) => projection.task.id) +} + +function nonSucceededDependencies( + task: RunTaskView, + viewByTaskId: ReadonlyMap, +): readonly TaskId[] { + return task.blockedBy.filter((dependency) => viewByTaskId.has(dependency)) +} + +function comparePathPriority( + left: TaskId, + right: TaskId, + lengths: ReadonlyMap, + projections: readonly MutableTaskProjection[], +): number { + const lengthDelta = (lengths.get(right) ?? 0) - (lengths.get(left) ?? 0) + if (lengthDelta !== 0) { + return lengthDelta + } + const orderDelta = orderFor(left, projections) - orderFor(right, projections) + return orderDelta === 0 ? left.localeCompare(right) : orderDelta +} + +function orderFor(taskId: TaskId, projections: readonly MutableTaskProjection[]): number { + return projections.find((projection) => projection.task.id === taskId)?.order ?? Number.MAX_SAFE_INTEGER +} + +function elapsedMsFor( + tasks: readonly RunTaskView[], + startedAt: string | null, + updatedAt: string | null, + nowMs: number, +): number { + const startedAtMs = timestampMs(startedAt) + if (startedAtMs === null) { + return 0 + } + const endAtMs = tasks.some((task) => isActiveState(task.state)) + ? nowMs + : timestampMs(updatedAt) ?? nowMs + return Math.max(0, endAtMs - startedAtMs) +} + +function earliestTimestamp(values: readonly (string | null)[]): string | null { + return sortedTimestamps(values)[0] ?? null +} + +function latestTimestamp(values: readonly (string | null)[]): string | null { + return sortedTimestamps(values).at(-1) ?? null +} + +function sortedTimestamps(values: readonly (string | null)[]): readonly string[] { + return values + .filter((value): value is string => value !== null) + .sort((left, right) => (timestampMs(left) ?? 0) - (timestampMs(right) ?? 0)) +} + +function timestampMs(value: string | null): number | null { + if (value === null) { + return null + } + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/council/ts/src/workflows/tail-workflow.test.ts b/council/ts/src/workflows/tail-workflow.test.ts new file mode 100644 index 0000000..07b33f0 --- /dev/null +++ b/council/ts/src/workflows/tail-workflow.test.ts @@ -0,0 +1,473 @@ +import { describe, expect, it } from 'vitest' + +import { workerOutputEvent, type RunStoreEvent } from '../contexts/runstore/index.js' +import type { + LiveRunArtifacts, + LiveRunDirReaderPort, + NormalizedRunDirectory, + WorkerResult, + WorkerSupervisorSnapshot, +} from '../ports/index.js' +import type { RunState, Task, TaskId } from '../shared-kernel/index.js' + +import { + tailWorkflow, + type TailWorkflowDeps, + type TailWorkflowFrame, + type TailWorkflowLogReadInput, + type TailWorkflowLogReaderPort, + type TailWorkflowLogStatInput, + type TailWorkflowTicker, +} from './tail-workflow.js' + +function bytes(text: string): Uint8Array { + return new TextEncoder().encode(text) +} + +class SequencedArtifactsReader implements LiveRunDirReaderPort { + readonly runDirs: string[] = [] + private index = 0 + + constructor(private readonly items: readonly [LiveRunArtifacts, ...LiveRunArtifacts[]]) {} + + readRunDir(runDir: string): Promise { + this.runDirs.push(runDir) + const item = this.items[Math.min(this.index, this.items.length - 1)] ?? this.items[0] + this.index += 1 + return Promise.resolve(item) + } +} + +class MemoryLogReader implements TailWorkflowLogReaderPort { + readonly reads: TailWorkflowLogReadInput[] = [] + readonly stats: TailWorkflowLogStatInput[] = [] + private readonly logs = new Map() + + set(path: string, text: string): void { + this.logs.set(path, bytes(text)) + } + + delete(path: string): void { + this.logs.delete(path) + } + + stat(input: TailWorkflowLogStatInput): Promise<{ readonly sizeBytes: number } | undefined> { + this.stats.push(input) + const buffer = this.logs.get(input.path) + return Promise.resolve(buffer === undefined ? undefined : { sizeBytes: buffer.byteLength }) + } + + read(input: TailWorkflowLogReadInput): Promise { + this.reads.push(input) + const buffer = this.logs.get(input.path) ?? new Uint8Array() + return Promise.resolve(buffer.subarray(input.start, input.end)) + } +} + +function ticker(onTicks: readonly (() => void)[] = []): TailWorkflowTicker { + return { + async *ticks(): AsyncIterable { + for (const onTick of onTicks) { + onTick() + await Promise.resolve() + yield + } + }, + } +} + +async function collect(input: Parameters[0], deps: TailWorkflowDeps): Promise { + const frames: TailWorkflowFrame[] = [] + for await (const frame of tailWorkflow(input, deps)) frames.push(frame) + return frames +} + +function deps( + artifacts: readonly [LiveRunArtifacts, ...LiveRunArtifacts[]], + logs: MemoryLogReader, + tickSource: TailWorkflowTicker = ticker(), +): TailWorkflowDeps { + return { + artifacts: new SequencedArtifactsReader(artifacts), + logs, + ticker: tickSource, + } +} + +function task(id: TaskId = 'T1'): Task { + return { + boundaries: 'Only workflow code.', + content_hash: `sha256:${id}`, + depends_on: [], + difficulty: 'moderate', + id, + model: 'sonnet', + objective: `Tail ${id}.`, + output_format: 'Frame stream.', + paths: ['council/ts/src/workflows/tail-workflow.ts'], + title: `Task ${id}`, + verify: 'npx vitest run src/workflows/tail-workflow.test.ts', + } +} + +function runState(): RunState { + return { + stage: 'fanout', + } +} + +type ResultWithLogPaths = WorkerResult & { + readonly stderr_log_path?: string + readonly stdout_log_path?: string +} + +function result(taskId: string, paths: { readonly stderr?: string; readonly stdout?: string }): WorkerResult { + const value: ResultWithLogPaths = { + status: 'ok', + task_id: taskId, + ...(paths.stderr === undefined ? {} : { stderr_log_path: paths.stderr }), + ...(paths.stdout === undefined ? {} : { stdout_log_path: paths.stdout }), + } + return value +} + +function snapshot(taskId: string, paths: { readonly stderr: string; readonly stdout: string }): WorkerSupervisorSnapshot { + return { + attempt_id: 1, + logs: { + stderr: paths.stderr, + stdout: paths.stdout, + }, + offsets: { + stderr: 0, + stdout: 0, + }, + restart_count: 0, + status: 'running', + task_id: taskId, + watchdog: { + handling_detection: false, + loop: { + actions: [], + }, + progress: { + attemptStartedAtMs: 0, + lastActionAtMs: 0, + lastOutputAtMs: 0, + lastProgressAtMs: 0, + outputBytes: 0, + startedAtMs: 0, + }, + retry: { + attempts: 0, + failureFingerprints: [], + }, + }, + } +} + +function outputEvent(input: { + readonly byteCount: number + readonly observedAt?: string + readonly offset: number + readonly path?: string + readonly stream: 'stderr' | 'stdout' + readonly taskId?: string +}): RunStoreEvent { + return workerOutputEvent({ + byte_count: input.byteCount, + offset: input.offset, + stream: input.stream, + worker_id: `worker-${input.taskId ?? 'unknown'}`, + ...(input.observedAt === undefined ? {} : { observed_at: input.observedAt }), + ...(input.path === undefined ? {} : { log_path: input.path }), + ...(input.taskId === undefined ? {} : { task_id: input.taskId }), + }) +} + +function artifacts(input: { + readonly events?: readonly RunStoreEvent[] + readonly results?: readonly WorkerResult[] + readonly snapshots?: readonly WorkerSupervisorSnapshot[] + readonly tasks?: readonly Task[] +} = {}): LiveRunArtifacts { + const workerResults = new Map((input.results ?? []).map((item) => [item.task_id, item])) + const normalized: NormalizedRunDirectory = { + report: undefined, + runId: 'run-a', + state: runState(), + tasks: input.tasks ?? [task()], + workerResults, + } + return { + events: input.events ?? [], + normalized, + workerResults, + workerSupervisorSnapshots: new Map((input.snapshots ?? []).map((item) => [item.task_id, item])), + } +} + +describe('tailWorkflow', () => { + it('defaults to stdout and reads only the bounded window from result metadata', async () => { + const logs = new MemoryLogReader() + logs.set('workers/T1/logs/stdout.result.log', 'alpha\nbeta\ngamma\n') + + const frames = await collect( + { maxBytes: 11, runDir: '/runs/run-a', taskId: 'T1' }, + deps([artifacts({ results: [result('T1', { stdout: 'workers/T1/logs/stdout.result.log' })] })], logs), + ) + + expect(frames).toEqual([ + { + chunks: [{ byteCount: 11, offset: 6, stream: 'stdout', text: 'beta\ngamma\n' }], + cursor: { offset: 17, stream: 'stdout' }, + logPath: 'workers/T1/logs/stdout.result.log', + logPathSource: 'result', + missing: false, + rotated: false, + stream: 'stdout', + taskId: 'T1', + truncated: true, + }, + ]) + expect(logs.reads).toEqual([ + { + end: 17, + path: 'workers/T1/logs/stdout.result.log', + runDir: '/runs/run-a', + start: 6, + }, + ]) + }) + + it('selects stderr when requested', async () => { + const logs = new MemoryLogReader() + logs.set('workers/T1/logs/stderr.result.log', 'warn\n') + + const frames = await collect( + { maxBytes: 100, runDir: '/runs/run-a', stream: 'stderr', taskId: 'T1' }, + deps([artifacts({ results: [result('T1', { stderr: 'workers/T1/logs/stderr.result.log' })] })], logs), + ) + + expect(frames[0]).toMatchObject({ + chunks: [{ byteCount: 5, offset: 0, stream: 'stderr', text: 'warn\n' }], + cursor: { offset: 5, stream: 'stderr' }, + logPath: 'workers/T1/logs/stderr.result.log', + stream: 'stderr', + }) + }) + + it('resolves log paths by result, snapshot, then output event precedence', async () => { + const logs = new MemoryLogReader() + logs.set('workers/T1/logs/stdout.result.log', 'result\n') + logs.set('workers/T1/logs/stdout.snapshot.log', 'snapshot\n') + logs.set('workers/T1/logs/stdout.event.log', 'event\n') + + const resultFrame = await collect( + { maxBytes: 100, runDir: '/runs/run-a', taskId: 'T1' }, + deps( + [ + artifacts({ + events: [outputEvent({ byteCount: 6, offset: 0, path: 'workers/T1/logs/stdout.event.log', stream: 'stdout', taskId: 'T1' })], + results: [result('T1', { stdout: 'workers/T1/logs/stdout.result.log' })], + snapshots: [snapshot('T1', { stderr: 'workers/T1/logs/stderr.snapshot.log', stdout: 'workers/T1/logs/stdout.snapshot.log' })], + }), + ], + logs, + ), + ) + const snapshotFrame = await collect( + { maxBytes: 100, runDir: '/runs/run-a', taskId: 'T1' }, + deps( + [ + artifacts({ + events: [outputEvent({ byteCount: 6, offset: 0, path: 'workers/T1/logs/stdout.event.log', stream: 'stdout', taskId: 'T1' })], + snapshots: [snapshot('T1', { stderr: 'workers/T1/logs/stderr.snapshot.log', stdout: 'workers/T1/logs/stdout.snapshot.log' })], + }), + ], + logs, + ), + ) + const eventFrame = await collect( + { maxBytes: 100, runDir: '/runs/run-a', taskId: 'T1' }, + deps( + [ + artifacts({ + events: [ + outputEvent({ byteCount: 6, offset: 0, path: 'workers/T1/logs/stdout.event.log', stream: 'stdout', taskId: 'T1' }), + outputEvent({ byteCount: 7, offset: 0, stream: 'stdout', taskId: 'T1' }), + outputEvent({ byteCount: 8, offset: 0, path: 'workers/T2/logs/stdout.log', stream: 'stdout', taskId: 'T2' }), + { payload: { id: 'A1', summary: 'ignored' }, type: 'amendment' }, + ], + }), + ], + logs, + ), + ) + + expect(resultFrame[0]).toMatchObject({ logPath: 'workers/T1/logs/stdout.result.log', logPathSource: 'result' }) + expect(snapshotFrame[0]).toMatchObject({ + logPath: 'workers/T1/logs/stdout.snapshot.log', + logPathSource: 'snapshot', + }) + expect(eventFrame[0]).toMatchObject({ logPath: 'workers/T1/logs/stdout.event.log', logPathSource: 'event' }) + }) + + it('continues from explicit offsets and follow cursors', async () => { + const logs = new MemoryLogReader() + logs.set('workers/T1/logs/stdout.log', 'hello\nworld\n') + + const offsetFrames = await collect( + { maxBytes: 100, offset: 6, runDir: '/runs/run-a', taskId: 'T1' }, + deps([artifacts({ results: [result('T1', { stdout: 'workers/T1/logs/stdout.log' })] })], logs), + ) + const cursorFrames = await collect( + { cursor: { offset: 6, stream: 'stdout' }, maxBytes: 100, runDir: '/runs/run-a', taskId: 'T1' }, + deps([artifacts({ results: [result('T1', { stdout: 'workers/T1/logs/stdout.log' })] })], logs), + ) + + expect(offsetFrames[0]?.chunks).toEqual([{ byteCount: 6, offset: 6, stream: 'stdout', text: 'world\n' }]) + expect(cursorFrames[0]?.cursor).toEqual({ offset: 12, stream: 'stdout' }) + }) + + it('applies line and since filters from output event metadata', async () => { + const logs = new MemoryLogReader() + logs.set('workers/T1/logs/stdout.log', 'old\nnew\nlater\n') + + const frames = await collect( + { + lines: 1, + maxBytes: 100, + runDir: '/runs/run-a', + since: '2026-07-03T10:05:00.000Z', + taskId: 'T1', + }, + deps( + [ + artifacts({ + events: [ + outputEvent({ byteCount: 4, observedAt: '2026-07-03T10:00:00.000Z', offset: 0, stream: 'stdout', taskId: 'T1' }), + outputEvent({ byteCount: 4, observedAt: '2026-07-03T10:05:00.000Z', offset: 4, stream: 'stdout', taskId: 'T1' }), + outputEvent({ byteCount: 6, observedAt: '2026-07-03T10:06:00.000Z', offset: 8, stream: 'stdout', taskId: 'T1' }), + ], + results: [result('T1', { stdout: 'workers/T1/logs/stdout.log' })], + }), + ], + logs, + ), + ) + + expect(frames[0]?.chunks).toEqual([{ byteCount: 6, offset: 8, stream: 'stdout', text: 'later\n' }]) + expect(frames[0]?.cursor).toEqual({ offset: 14, stream: 'stdout' }) + }) + + it('reports missing tasks without touching log dependencies', async () => { + const logs = new MemoryLogReader() + + const frames = await collect( + { maxBytes: 100, runDir: '/runs/run-a', taskId: 'T-missing' }, + deps([artifacts({ tasks: [task('T1')] })], logs), + ) + + expect(frames).toEqual([ + { + chunks: [], + cursor: { offset: 0, stream: 'stdout' }, + missing: true, + missingReason: 'task', + rotated: false, + stream: 'stdout', + taskId: 'T-missing', + truncated: false, + }, + ]) + expect(logs.stats).toEqual([]) + expect(logs.reads).toEqual([]) + }) + + it('reports missing logs when metadata is absent or the selected path cannot be statted', async () => { + const logs = new MemoryLogReader() + logs.delete('workers/T1/logs/stdout.log') + + const withoutMetadata = await collect( + { maxBytes: 100, runDir: '/runs/run-a', taskId: 'T1' }, + deps([artifacts()], logs), + ) + const withoutFile = await collect( + { maxBytes: 100, runDir: '/runs/run-a', taskId: 'T1' }, + deps([artifacts({ results: [result('T1', { stdout: 'workers/T1/logs/stdout.log' })] })], logs), + ) + + expect(withoutMetadata[0]).toEqual({ + chunks: [], + cursor: { offset: 0, stream: 'stdout' }, + missing: true, + missingReason: 'log', + rotated: false, + stream: 'stdout', + taskId: 'T1', + truncated: false, + }) + expect(withoutFile[0]).toEqual({ + chunks: [], + cursor: { offset: 0, stream: 'stdout' }, + logPath: 'workers/T1/logs/stdout.log', + logPathSource: 'result', + missing: true, + missingReason: 'log', + rotated: false, + stream: 'stdout', + taskId: 'T1', + truncated: false, + }) + }) + + it('reports rotation when a cursor is beyond the current log size', async () => { + const logs = new MemoryLogReader() + logs.set('workers/T1/logs/stdout.log', 'fresh\n') + + const frames = await collect( + { cursor: { offset: 99, stream: 'stdout' }, maxBytes: 100, runDir: '/runs/run-a', taskId: 'T1' }, + deps([artifacts({ results: [result('T1', { stdout: 'workers/T1/logs/stdout.log' })] })], logs), + ) + + expect(frames[0]).toMatchObject({ + chunks: [{ byteCount: 6, offset: 0, stream: 'stdout', text: 'fresh\n' }], + cursor: { offset: 6, stream: 'stdout' }, + rotated: true, + }) + }) + + it('emits deterministic follow frames and advances cursors on injected ticks', async () => { + const logs = new MemoryLogReader() + logs.set('workers/T1/logs/stdout.log', 'one\n') + + const frames = await collect( + { follow: true, maxBytes: 100, runDir: '/runs/run-a', taskId: 'T1' }, + deps( + [artifacts({ results: [result('T1', { stdout: 'workers/T1/logs/stdout.log' })] })], + logs, + ticker([ + () => { + logs.set('workers/T1/logs/stdout.log', 'one\ntwo\n') + }, + () => { + logs.set('workers/T1/logs/stdout.log', 'one\ntwo\nthree\n') + }, + ]), + ), + ) + + expect(frames.map((frame) => frame.chunks)).toEqual([ + [{ byteCount: 4, offset: 0, stream: 'stdout', text: 'one\n' }], + [{ byteCount: 4, offset: 4, stream: 'stdout', text: 'two\n' }], + [{ byteCount: 6, offset: 8, stream: 'stdout', text: 'three\n' }], + ]) + expect(frames.map((frame) => frame.cursor)).toEqual([ + { offset: 4, stream: 'stdout' }, + { offset: 8, stream: 'stdout' }, + { offset: 14, stream: 'stdout' }, + ]) + expect(logs.reads).toHaveLength(3) + }) +}) diff --git a/council/ts/src/workflows/tail-workflow.ts b/council/ts/src/workflows/tail-workflow.ts new file mode 100644 index 0000000..134455c --- /dev/null +++ b/council/ts/src/workflows/tail-workflow.ts @@ -0,0 +1,249 @@ +import type { RunStoreEvent, WorkerOutputStream } from '../contexts/runstore/index.js' +import type { LiveRunArtifacts, LiveRunDirReaderPort, WorkerResult } from '../ports/index.js' + +import { + selectTaskLogTail, + type TailCursor, + type TailFormattedChunk, + type TailLogEventRange, + type TailLogSource, + type TailRequest, +} from './tail.js' + +export type TailWorkflowLogPathSource = 'event' | 'result' | 'snapshot' +export type TailWorkflowMissingReason = 'log' | 'task' + +export interface TailWorkflowInput { + readonly cursor?: TailCursor + readonly follow?: boolean + readonly lines?: number + readonly maxBytes: number + readonly offset?: number + readonly runDir: string + readonly since?: string + readonly stream?: WorkerOutputStream + readonly taskId: string +} + +export interface TailWorkflowLogStatInput { + readonly path: string + readonly runDir: string +} + +export interface TailWorkflowLogReadInput extends TailWorkflowLogStatInput { + readonly end: number + readonly start: number +} + +export interface TailWorkflowLogStat { + readonly sizeBytes: number +} + +export interface TailWorkflowLogReaderPort { + stat(input: TailWorkflowLogStatInput): Promise + read(input: TailWorkflowLogReadInput): Promise +} + +export interface TailWorkflowTicker { + ticks(): AsyncIterable +} + +export interface TailWorkflowDeps { + readonly artifacts: LiveRunDirReaderPort + readonly logs: TailWorkflowLogReaderPort + readonly ticker: TailWorkflowTicker +} + +export interface TailWorkflowFrame { + readonly chunks: readonly TailFormattedChunk[] + readonly cursor: TailCursor + readonly logPath?: string + readonly logPathSource?: TailWorkflowLogPathSource + readonly missing: boolean + readonly missingReason?: TailWorkflowMissingReason + readonly rotated: boolean + readonly stream: WorkerOutputStream + readonly taskId: string + readonly truncated: boolean +} + +interface WorkerResultLogMetadata extends WorkerResult { + readonly stderr_log_path?: string + readonly stdout_log_path?: string +} + +interface ResolvedLogPath { + readonly path: string + readonly source: TailWorkflowLogPathSource +} + +export async function* tailWorkflow( + input: TailWorkflowInput, + deps: TailWorkflowDeps, +): AsyncGenerator { + const stream = input.stream ?? 'stdout' + const first = await readTailFrame(input, deps, stream, input.cursor, input.offset) + yield first + + if (input.follow !== true) return + + const ticks = deps.ticker.ticks()[Symbol.asyncIterator]() + let tick = await ticks.next() + let cursor = first.cursor + while (!tick.done) { + const frame = await readTailFrame(input, deps, stream, cursor, undefined) + yield frame + cursor = frame.cursor + tick = await ticks.next() + } +} + +async function readTailFrame( + input: TailWorkflowInput, + deps: TailWorkflowDeps, + stream: WorkerOutputStream, + cursor: TailCursor | undefined, + offset: number | undefined, +): Promise { + const artifacts = await deps.artifacts.readRunDir(input.runDir) + const request = tailRequest(input, stream, cursor, offset) + + if (!hasTask(artifacts, input.taskId)) { + return frameFromSelection(input.taskId, selectTaskLogTail({ request, sources: [] }), stream, 'task') + } + + const resolved = resolveLogPath(artifacts, input.taskId, stream) + if (resolved === undefined) { + return frameFromSelection(input.taskId, selectTaskLogTail({ request, sources: [] }), stream, 'log') + } + + const stat = await deps.logs.stat({ path: resolved.path, runDir: input.runDir }) + if (stat === undefined) { + return frameFromSelection(input.taskId, selectTaskLogTail({ request, sources: [] }), stream, 'log', resolved) + } + + const maxBytes = Math.max(0, input.maxBytes) + const sizeBytes = Math.max(0, stat.sizeBytes) + const start = Math.max(0, sizeBytes - maxBytes) + const buffer = await deps.logs.read({ + end: sizeBytes, + path: resolved.path, + runDir: input.runDir, + start, + }) + const source: TailLogSource = { + buffer, + bufferStartOffset: start, + events: logEvents(artifacts.events, input.taskId, stream), + sizeBytes, + stream, + } + + return frameFromSelection( + input.taskId, + selectTaskLogTail({ request, sources: [source] }), + stream, + undefined, + resolved, + ) +} + +function tailRequest( + input: TailWorkflowInput, + stream: WorkerOutputStream, + cursor: TailCursor | undefined, + offset: number | undefined, +): TailRequest { + return { + maxBytes: Math.max(0, input.maxBytes), + stream, + ...(cursor === undefined ? {} : { cursor }), + ...(input.lines === undefined ? {} : { lines: input.lines }), + ...(offset === undefined ? {} : { offset }), + ...(input.since === undefined ? {} : { since: input.since }), + } +} + +function hasTask(artifacts: LiveRunArtifacts, taskId: string): boolean { + return artifacts.normalized.tasks.some((task) => task.id === taskId) +} + +function resolveLogPath( + artifacts: LiveRunArtifacts, + taskId: string, + stream: WorkerOutputStream, +): ResolvedLogPath | undefined { + const resultPath = resultLogPath(artifacts.workerResults.get(taskId), stream) + if (resultPath !== undefined) return { path: resultPath, source: 'result' } + + const snapshotPath = snapshotLogPath(artifacts, taskId, stream) + if (snapshotPath !== undefined) return { path: snapshotPath, source: 'snapshot' } + + return eventLogPath(artifacts.events, taskId, stream) +} + +function resultLogPath(result: WorkerResult | undefined, stream: WorkerOutputStream): string | undefined { + if (result === undefined) return undefined + const metadata: WorkerResultLogMetadata = result + return stream === 'stdout' ? metadata.stdout_log_path : metadata.stderr_log_path +} + +function snapshotLogPath( + artifacts: LiveRunArtifacts, + taskId: string, + stream: WorkerOutputStream, +): string | undefined { + const snapshot = artifacts.workerSupervisorSnapshots.get(taskId) + if (snapshot === undefined) return undefined + return stream === 'stdout' ? snapshot.logs.stdout : snapshot.logs.stderr +} + +function eventLogPath( + events: readonly RunStoreEvent[], + taskId: string, + stream: WorkerOutputStream, +): ResolvedLogPath | undefined { + for (const event of [...events].reverse()) { + if (event.type !== 'worker_output') continue + if (event.payload.task_id !== taskId || event.payload.stream !== stream) continue + if (event.payload.log_path !== undefined) return { path: event.payload.log_path, source: 'event' } + } + return undefined +} + +function logEvents( + events: readonly RunStoreEvent[], + taskId: string, + stream: WorkerOutputStream, +): readonly TailLogEventRange[] { + return events.flatMap((event) => { + if (event.type !== 'worker_output') return [] + if (event.payload.task_id !== taskId || event.payload.stream !== stream) return [] + return [{ + byteCount: event.payload.byte_count, + occurredAt: event.payload.observed_at ?? '', + offset: event.payload.offset, + stream, + }] + }) +} + +function frameFromSelection( + taskId: string, + selection: ReturnType, + stream: WorkerOutputStream, + missingReason?: TailWorkflowMissingReason, + resolved?: ResolvedLogPath, +): TailWorkflowFrame { + return { + chunks: selection.chunks, + cursor: selection.nextCursor, + missing: selection.missing, + rotated: selection.rotated, + stream, + taskId, + truncated: selection.truncated, + ...(missingReason === undefined ? {} : { missingReason }), + ...(resolved === undefined ? {} : { logPath: resolved.path, logPathSource: resolved.source }), + } +} diff --git a/council/ts/src/workflows/tail.test.ts b/council/ts/src/workflows/tail.test.ts new file mode 100644 index 0000000..050653a --- /dev/null +++ b/council/ts/src/workflows/tail.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' + +import { selectTaskLogTail, type TailLogSource } from './tail.js' + +function bytes(text: string): Uint8Array { + return new TextEncoder().encode(text) +} + +function source(overrides: Partial = {}): TailLogSource { + const buffer = overrides.buffer ?? bytes('alpha\nbeta\ngamma\n') + return { + buffer, + bufferStartOffset: overrides.bufferStartOffset ?? 0, + sizeBytes: overrides.sizeBytes ?? buffer.byteLength, + stream: overrides.stream ?? 'stdout', + ...(overrides.events === undefined ? {} : { events: overrides.events }), + } +} + +describe('selectTaskLogTail', () => { + it('slices a stream from a byte offset and advances the follow cursor', () => { + const result = selectTaskLogTail({ + request: { maxBytes: 100, offset: 6, stream: 'stdout' }, + sources: [source({ buffer: bytes('hello\nworld\n') })], + }) + + expect(result).toEqual({ + chunks: [{ byteCount: 6, offset: 6, stream: 'stdout', text: 'world\n' }], + missing: false, + nextCursor: { offset: 12, stream: 'stdout' }, + rotated: false, + truncated: false, + }) + }) + + it('keeps UTF-8 formatting boundary-safe when offsets split a character', () => { + const result = selectTaskLogTail({ + request: { maxBytes: 100, offset: 1, stream: 'stdout' }, + sources: [source({ buffer: bytes('€uro\n') })], + }) + + expect(result.chunks).toEqual([{ byteCount: 4, offset: 3, stream: 'stdout', text: 'uro\n' }]) + expect(result.nextCursor).toEqual({ offset: 7, stream: 'stdout' }) + expect(result.truncated).toBe(true) + }) + + it('keeps incomplete trailing UTF-8 bytes for the next cursor advance', () => { + const result = selectTaskLogTail({ + request: { maxBytes: 100, stream: 'stdout' }, + sources: [source({ buffer: new Uint8Array([0x6f, 0x6b, 0x0a, 0xe2, 0x82]), sizeBytes: 5 })], + }) + + expect(result.chunks).toEqual([{ byteCount: 3, offset: 0, stream: 'stdout', text: 'ok\n' }]) + expect(result.nextCursor).toEqual({ offset: 3, stream: 'stdout' }) + expect(result.truncated).toBe(true) + }) + + it('formats complete four-byte UTF-8 characters without shifting byte offsets', () => { + const result = selectTaskLogTail({ + request: { maxBytes: 100, stream: 'stdout' }, + sources: [source({ buffer: bytes('😀') })], + }) + + expect(result.chunks).toEqual([{ byteCount: 4, offset: 0, stream: 'stdout', text: '😀' }]) + expect(result.nextCursor).toEqual({ offset: 4, stream: 'stdout' }) + }) + + it('formats complete two-byte UTF-8 characters without shifting byte offsets', () => { + const result = selectTaskLogTail({ + request: { maxBytes: 100, stream: 'stdout' }, + sources: [source({ buffer: bytes('é') })], + }) + + expect(result.chunks).toEqual([{ byteCount: 2, offset: 0, stream: 'stdout', text: 'é' }]) + expect(result.nextCursor).toEqual({ offset: 2, stream: 'stdout' }) + }) + + it('drops a continuation-only fragment without fabricating replacement text', () => { + const result = selectTaskLogTail({ + request: { maxBytes: 100, stream: 'stdout' }, + sources: [source({ buffer: new Uint8Array([0x82]), sizeBytes: 1 })], + }) + + expect(result.chunks).toEqual([]) + expect(result.nextCursor).toEqual({ offset: 1, stream: 'stdout' }) + expect(result.truncated).toBe(true) + }) + + it('selects the last N lines after byte windowing', () => { + const result = selectTaskLogTail({ + request: { lines: 2, maxBytes: 100, stream: 'stdout' }, + sources: [source({ buffer: bytes('one\ntwo\nthree\nfour\n') })], + }) + + expect(result.chunks).toEqual([{ byteCount: 11, offset: 8, stream: 'stdout', text: 'three\nfour\n' }]) + expect(result.nextCursor).toEqual({ offset: 19, stream: 'stdout' }) + }) + + it('supports zero requested lines while still advancing the cursor', () => { + const result = selectTaskLogTail({ + request: { lines: 0, maxBytes: 100, stream: 'stdout' }, + sources: [source({ buffer: bytes('one\ntwo') })], + }) + + expect(result.chunks).toEqual([]) + expect(result.nextCursor).toEqual({ offset: 7, stream: 'stdout' }) + expect(result.truncated).toBe(true) + }) + + it('selects the last line without requiring a trailing newline', () => { + const result = selectTaskLogTail({ + request: { lines: 1, maxBytes: 100, stream: 'stdout' }, + sources: [source({ buffer: bytes('one\ntwo') })], + }) + + expect(result.chunks).toEqual([{ byteCount: 3, offset: 4, stream: 'stdout', text: 'two' }]) + expect(result.nextCursor).toEqual({ offset: 7, stream: 'stdout' }) + }) + + it('filters output from event timestamps before applying line selection', () => { + const log = 'old\nnew\nlater\n' + const result = selectTaskLogTail({ + request: { lines: 1, maxBytes: 100, since: '2026-07-03T10:05:00.000Z', stream: 'stdout' }, + sources: [ + source({ + buffer: bytes(log), + events: [ + { byteCount: 4, occurredAt: '2026-07-03T10:00:00.000Z', offset: 0, stream: 'stdout' }, + { byteCount: 4, occurredAt: '2026-07-03T10:05:00.000Z', offset: 4, stream: 'stdout' }, + { byteCount: 6, occurredAt: '2026-07-03T10:06:00.000Z', offset: 8, stream: 'stdout' }, + ], + sizeBytes: bytes(log).byteLength, + }), + ], + }) + + expect(result.chunks).toEqual([{ byteCount: 6, offset: 8, stream: 'stdout', text: 'later\n' }]) + expect(result.nextCursor).toEqual({ offset: 14, stream: 'stdout' }) + }) + + it('bounds formatted bytes to the requested maximum and marks omitted data truncated', () => { + const result = selectTaskLogTail({ + request: { maxBytes: 4, stream: 'stdout' }, + sources: [source({ buffer: bytes('abcdefghij') })], + }) + + expect(result.chunks).toEqual([{ byteCount: 4, offset: 6, stream: 'stdout', text: 'ghij' }]) + expect(result.nextCursor).toEqual({ offset: 10, stream: 'stdout' }) + expect(result.truncated).toBe(true) + }) + + it('reports empty logs without fabricating chunks', () => { + const result = selectTaskLogTail({ + request: { maxBytes: 100, stream: 'stderr' }, + sources: [source({ buffer: bytes(''), sizeBytes: 0, stream: 'stderr' })], + }) + + expect(result).toEqual({ + chunks: [], + missing: false, + nextCursor: { offset: 0, stream: 'stderr' }, + rotated: false, + truncated: false, + }) + }) + + it('reports missing stream data and preserves the requested follow offset', () => { + const result = selectTaskLogTail({ + request: { cursor: { offset: 42, stream: 'stderr' }, maxBytes: 100, stream: 'stderr' }, + sources: [source({ stream: 'stdout' })], + }) + + expect(result).toEqual({ + chunks: [], + missing: true, + nextCursor: { offset: 42, stream: 'stderr' }, + rotated: false, + truncated: false, + }) + }) + + it('resets a stale cursor when the file was truncated or rotated', () => { + const result = selectTaskLogTail({ + request: { cursor: { offset: 99, stream: 'stdout' }, maxBytes: 100, stream: 'stdout' }, + sources: [source({ buffer: bytes('fresh\n') })], + }) + + expect(result).toEqual({ + chunks: [{ byteCount: 6, offset: 0, stream: 'stdout', text: 'fresh\n' }], + missing: false, + nextCursor: { offset: 6, stream: 'stdout' }, + rotated: true, + truncated: false, + }) + }) + + it('uses the available bounded buffer window when the requested offset is no longer buffered', () => { + const result = selectTaskLogTail({ + request: { maxBytes: 100, offset: 0, stream: 'stdout' }, + sources: [source({ buffer: bytes('klmnop'), bufferStartOffset: 10, sizeBytes: 16 })], + }) + + expect(result.chunks).toEqual([{ byteCount: 6, offset: 10, stream: 'stdout', text: 'klmnop' }]) + expect(result.nextCursor).toEqual({ offset: 16, stream: 'stdout' }) + expect(result.truncated).toBe(true) + }) +}) diff --git a/council/ts/src/workflows/tail.ts b/council/ts/src/workflows/tail.ts new file mode 100644 index 0000000..f0d49cf --- /dev/null +++ b/council/ts/src/workflows/tail.ts @@ -0,0 +1,245 @@ +export interface TailCursor { + readonly offset: number + readonly stream: string +} + +export interface TailLogEventRange { + readonly byteCount: number + readonly occurredAt: string + readonly offset: number + readonly stream: string +} + +export interface TailLogSource { + readonly buffer: Uint8Array + readonly bufferStartOffset: number + readonly events?: readonly TailLogEventRange[] + readonly sizeBytes: number + readonly stream: string +} + +export interface TailRequest { + readonly cursor?: TailCursor + readonly lines?: number + readonly maxBytes: number + readonly offset?: number + readonly since?: string + readonly stream: string +} + +export interface TailSelectionInput { + readonly request: TailRequest + readonly sources: readonly TailLogSource[] +} + +export interface TailFormattedChunk { + readonly byteCount: number + readonly offset: number + readonly stream: string + readonly text: string +} + +export interface TailSelection { + readonly chunks: readonly TailFormattedChunk[] + readonly missing: boolean + readonly nextCursor: TailCursor + readonly rotated: boolean + readonly truncated: boolean +} + +interface ByteRange { + readonly end: number + readonly start: number +} + +interface RangePlan { + readonly ranges: readonly ByteRange[] + readonly truncated: boolean +} + +interface DecodedRange { + readonly consumedEnd: number + readonly chunk?: TailFormattedChunk + readonly truncated: boolean +} + +const textDecoder = new TextDecoder() +const textEncoder = new TextEncoder() + +export function selectTaskLogTail(input: TailSelectionInput): TailSelection { + const requestedOffset = requestedTailOffset(input.request) + const source = input.sources.find((candidate) => candidate.stream === input.request.stream) + if (source === undefined) { + return tailSelection([], input.request.stream, requestedOffset, false, false, true) + } + + const rotated = requestedOffset > source.sizeBytes + const effectiveOffset = rotated ? 0 : requestedOffset + const eventRanges = candidateRanges(source, input.request, effectiveOffset) + const bounded = takeLastBytes(eventRanges, input.request.maxBytes) + const available = availableRange(source) + const windowed = clampToAvailableBuffer(bounded.ranges, available) + const decoded = windowed.ranges.map((range) => decodeRange(source, range)) + const decodedChunks = decoded.flatMap((range) => (range.chunk === undefined ? [] : [range.chunk])) + const nextOffset = nextCursorOffset(source, decoded) + const lines = applyLineSelection(decodedChunks, input.request.lines, input.request.stream, nextOffset) + + return tailSelection( + lines.chunks, + input.request.stream, + nextOffset, + rotated, + bounded.truncated || windowed.truncated || decoded.some((range) => range.truncated) || lines.truncated, + false, + ) +} + +function requestedTailOffset(request: TailRequest): number { + return request.offset ?? (request.cursor?.stream === request.stream ? request.cursor.offset : undefined) ?? 0 +} + +function candidateRanges(source: TailLogSource, request: TailRequest, offset: number): readonly ByteRange[] { + const since = request.since + if (since === undefined) { + return offset >= source.sizeBytes ? [] : [{ start: offset, end: source.sizeBytes }] + } + + return (source.events ?? []) + .filter((event) => event.stream === request.stream && event.occurredAt >= since) + .map((event) => ({ + start: Math.max(event.offset, offset), + end: Math.min(event.offset + event.byteCount, source.sizeBytes), + })) + .filter((range) => range.end > range.start) +} + +function takeLastBytes(ranges: readonly ByteRange[], maxBytes: number): RangePlan { + const budget = Math.max(0, maxBytes) + const totalBytes = ranges.reduce((total, range) => total + range.end - range.start, 0) + if (totalBytes <= budget) return { ranges, truncated: false } + + let remaining = budget + const kept: ByteRange[] = [] + for (const range of [...ranges].reverse()) { + const take = Math.max(0, Math.min(range.end - range.start, remaining)) + if (take > 0) kept.unshift({ start: range.end - take, end: range.end }) + remaining -= take + } + return { ranges: kept, truncated: true } +} + +function availableRange(source: TailLogSource): ByteRange { + return { + start: source.bufferStartOffset, + end: Math.min(source.sizeBytes, source.bufferStartOffset + source.buffer.byteLength), + } +} + +function clampToAvailableBuffer(ranges: readonly ByteRange[], available: ByteRange): RangePlan { + const kept: ByteRange[] = [] + let truncated = false + for (const range of ranges) { + const start = Math.max(range.start, available.start) + const end = Math.min(range.end, available.end) + if (start !== range.start || end !== range.end) truncated = true + if (end > start) kept.push({ start, end }) + } + return { ranges: kept, truncated } +} + +function decodeRange(source: TailLogSource, range: ByteRange): DecodedRange { + const relativeStart = range.start - source.bufferStartOffset + const relativeEnd = range.end - source.bufferStartOffset + const safeStart = utf8SafeStart(source.buffer, relativeStart, relativeEnd) + const safeEnd = utf8SafeEnd(source.buffer, safeStart, relativeEnd) + const absoluteStart = source.bufferStartOffset + safeStart + const absoluteEnd = source.bufferStartOffset + safeEnd + const truncated = safeStart !== relativeStart || safeEnd !== relativeEnd + if (absoluteEnd <= absoluteStart) return { consumedEnd: absoluteEnd, truncated } + + return { + chunk: { + byteCount: absoluteEnd - absoluteStart, + offset: absoluteStart, + stream: source.stream, + text: textDecoder.decode(source.buffer.subarray(safeStart, safeEnd)), + }, + consumedEnd: absoluteEnd, + truncated, + } +} + +function utf8SafeStart(bytes: Uint8Array, start: number, end: number): number { + let safeStart = start + while (safeStart < end && isUtf8Continuation(bytes[safeStart] ?? 0)) safeStart += 1 + return safeStart +} + +function utf8SafeEnd(bytes: Uint8Array, start: number, end: number): number { + let sequenceStart = end - 1 + while (sequenceStart >= start && isUtf8Continuation(bytes[sequenceStart] ?? 0)) sequenceStart -= 1 + if (sequenceStart < start) return start + + const expectedLength = utf8SequenceLength(bytes[sequenceStart] ?? 0) + return sequenceStart + expectedLength <= end ? end : sequenceStart +} + +function isUtf8Continuation(byte: number): boolean { + return (byte & 0xc0) === 0x80 +} + +function utf8SequenceLength(byte: number): number { + if ((byte & 0x80) === 0) return 1 + if ((byte & 0xe0) === 0xc0) return 2 + if ((byte & 0xf0) === 0xe0) return 3 + return (byte & 0xf8) === 0xf0 ? 4 : 1 +} + +function nextCursorOffset(source: TailLogSource, decoded: readonly DecodedRange[]): number { + return decoded.length === 0 ? source.sizeBytes : Math.max(...decoded.map((range) => range.consumedEnd)) +} + +function applyLineSelection( + chunks: readonly TailFormattedChunk[], + lines: number | undefined, + stream: string, + nextOffset: number, +): { readonly chunks: readonly TailFormattedChunk[]; readonly truncated: boolean } { + if (lines === undefined) return { chunks, truncated: false } + + const text = chunks.map((chunk) => chunk.text).join('') + const selected = lastLines(text, lines) + if (selected.length === 0) return { chunks: [], truncated: text.length > 0 } + + const byteCount = textEncoder.encode(selected).byteLength + return { + chunks: [{ byteCount, offset: nextOffset - byteCount, stream, text: selected }], + truncated: selected !== text, + } +} + +function lastLines(text: string, lineCount: number): string { + if (lineCount <= 0 || text.length === 0) return '' + + const trailingNewline = text.endsWith('\n') + const records = (trailingNewline ? text.slice(0, -1) : text).split('\n') + const selected = records.slice(-lineCount).join('\n') + return trailingNewline ? `${selected}\n` : selected +} + +function tailSelection( + chunks: readonly TailFormattedChunk[], + stream: string, + offset: number, + rotated: boolean, + truncated: boolean, + missing: boolean, +): TailSelection { + return { + chunks, + missing, + nextCursor: { offset, stream }, + rotated, + truncated, + } +} diff --git a/council/ts/test/parity/cli-app-parity.test.ts b/council/ts/test/parity/cli-app-parity.test.ts index 793b1d5..d951872 100644 --- a/council/ts/test/parity/cli-app-parity.test.ts +++ b/council/ts/test/parity/cli-app-parity.test.ts @@ -15,7 +15,20 @@ import { splitDestUrl, } from '../../src/app/index.js' import { commandRegistry, runCli } from '../../src/cli/index.js' -import type { GhPort, GhPrRequest } from '../../src/ports/index.js' +import type { + ClockPort, + GhPort, + GhPrRequest, + LiveRunArtifacts, + LiveRunDirReaderPort, + WorkerResult, +} from '../../src/ports/index.js' +import type { RunState, Task } from '../../src/shared-kernel/index.js' +import type { + TailWorkflowLogReadInput, + TailWorkflowLogReaderPort, + TailWorkflowLogStatInput, +} from '../../src/workflows/index.js' const tempRoots: string[] = [] @@ -155,6 +168,83 @@ describe('CLI composition', () => { }, }) }) + + it('routes live status JSON and table rendering through CouncilApp without timers', async () => { + const artifacts = liveRunArtifacts('parity-status') + + const jsonReader = new ParityLiveRunReader([artifacts]) + const jsonResult = await runCli(['status', '--run', '/runs/parity-status', '--json'], { + app: new CouncilApp({ + clock: fixedClock('2026-07-03T12:00:00.000Z'), + liveRunDirReader: jsonReader, + }), + }) + + expect(jsonResult).toMatchObject({ exitCode: 0, stderr: '' }) + expect(JSON.parse(jsonResult.stdout)).toMatchObject({ + run: 'parity-status', + tasks: [ + { + state: 'succeeded', + taskId: 'T1', + terminalStatus: 'ok', + }, + ], + }) + expect(jsonReader.calls).toEqual(['/runs/parity-status']) + + const tableReader = new ParityLiveRunReader([artifacts]) + const tableResult = await runCli(['status', '--run', '/runs/parity-status', '--once'], { + app: new CouncilApp({ + clock: fixedClock('2026-07-03T12:00:00.000Z'), + liveRunDirReader: tableReader, + }), + }) + + expect(tableResult).toEqual({ + exitCode: 0, + stderr: '', + stdout: `run parity-status stage=fanout elapsed=0s started=- updated=- +rollup counts=succeeded:1 ready=- critical=- +active - +wave 0 +badge task duration details +[OK] T1 0s Parity status task; terminal=ok +`, + }) + expect(tableReader.calls).toEqual(['/runs/parity-status']) + }) + + it('routes tail through CouncilApp with a finite ticker instead of real timers', async () => { + const path = 'workers/T1/logs/stdout.log' + const reader = new ParityLiveRunReader([liveRunArtifacts('parity-tail', workerResultWithStdoutLog(path))]) + const logs = new ParityTailLogReader() + logs.set(path, 'first\n') + const ticker = finiteTailTicker([ + () => { + logs.set(path, 'first\nsecond\n') + }, + () => { + logs.set(path, 'first\nsecond\nthird\n') + }, + ]) + + const result = await runCli(['tail', 'T1', '--run', '/runs/parity-tail', '--follow', '--interval-ms', '5'], { + app: new CouncilApp({ + liveRunDirReader: reader, + tailLogReader: logs, + tailTicker: ticker, + }), + }) + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: 'first\nsecond\nthird\n', + }) + expect(reader.calls).toEqual(['/runs/parity-tail', '/runs/parity-tail', '/runs/parity-tail']) + expect(ticker.intervals).toEqual([5]) + }) }) describe('plan and GitHub gating', () => { @@ -572,6 +662,121 @@ class RecordingGh implements GhPort { } } +class ParityLiveRunReader implements LiveRunDirReaderPort { + readonly calls: string[] = [] + private index = 0 + + constructor(private readonly artifacts: readonly LiveRunArtifacts[]) {} + + readRunDir(runDir: string): Promise { + this.calls.push(runDir) + const artifact = this.artifacts[Math.min(this.index, this.artifacts.length - 1)] + this.index += 1 + if (artifact === undefined) throw new Error('no live run artifacts configured') + return Promise.resolve(artifact) + } +} + +class ParityTailLogReader implements TailWorkflowLogReaderPort { + private readonly logs = new Map() + + set(path: string, text: string): void { + this.logs.set(path, text) + } + + stat(input: TailWorkflowLogStatInput): Promise<{ readonly sizeBytes: number } | undefined> { + const text = this.logs.get(input.path) + return Promise.resolve(text === undefined ? undefined : { sizeBytes: Buffer.byteLength(text) }) + } + + read(input: TailWorkflowLogReadInput): Promise { + const text = this.logs.get(input.path) + if (text === undefined) throw new Error(`missing log ${input.path}`) + return Promise.resolve(Buffer.from(text).subarray(input.start, input.end)) + } +} + +function fixedClock(iso: string): ClockPort { + return { + monotonicMs: () => 0, + now: () => new Date(iso), + sleep: () => Promise.resolve(), + } +} + +function finiteTailTicker(onTicks: readonly (() => void)[]): { + readonly intervals: readonly number[] + ticks(input: { readonly intervalMs: number }): AsyncIterable +} { + const intervals: number[] = [] + return { + get intervals() { + return intervals + }, + async *ticks(input) { + intervals.push(input.intervalMs) + for (const onTick of onTicks) { + await Promise.resolve() + onTick() + yield undefined + } + }, + } +} + +function liveRunArtifacts(runId: string, result: WorkerResult = workerResult('T1')): LiveRunArtifacts { + const task = parityTask('T1') + const state: RunState = { + stage: 'fanout', + task_count: 1, + } + return { + events: [], + normalized: { + report: { + run: runId, + tasks: [{ status: result.status, task_id: result.task_id }], + waves: [['T1']], + }, + runId, + state, + tasks: [task], + workerResults: new Map([[result.task_id, result]]), + }, + workerResults: new Map([[result.task_id, result]]), + workerSupervisorSnapshots: new Map(), + } +} + +function parityTask(id: 'T1'): Task { + return { + boundaries: 'Stay in the parity fixture.', + depends_on: [], + difficulty: 'trivial', + id, + model: 'haiku', + objective: 'Exercise status and tail parity.', + output_format: 'Validated CLI output.', + paths: ['council/ts/test/parity/cli-app-parity.test.ts'], + title: 'Parity status task', + verify: 'npx vitest run test/parity/cli-app-parity.test.ts', + } +} + +function workerResult(taskId: 'T1'): WorkerResult { + return { + status: 'ok', + task_id: taskId, + } +} + +function workerResultWithStdoutLog(path: string): WorkerResult & { readonly stdout_log_path: string } { + return { + ...workerResult('T1'), + stdout_log_path: path, + } +} + async function tempRoot(prefix: string): Promise { const root = await mkdtemp(join(tmpdir(), prefix)) tempRoots.push(root) diff --git a/render-agent-kit.py b/render-agent-kit.py index 62ddd5e..7956367 100755 --- a/render-agent-kit.py +++ b/render-agent-kit.py @@ -403,10 +403,11 @@ def manifest_check() -> DoctorCheck: except (OSError, SystemExit) as exc: return DoctorCheck(name="manifest", status="fail", detail=str(exc)) + council_commands = getattr(validator, "REQUIRED_COUNCIL_CLI_COMMANDS_LABEL", "validated") return DoctorCheck( name="manifest", status="ok", - detail=f"kit manifest version {manifest_version()}; council command surface validated", + detail=f"kit manifest version {manifest_version()}; council command surface validated: {council_commands}", ) diff --git a/scripts/validate_manifest.py b/scripts/validate_manifest.py index 5d04043..da8dd16 100755 --- a/scripts/validate_manifest.py +++ b/scripts/validate_manifest.py @@ -86,7 +86,8 @@ "risk", "expectedOutputs", } -REQUIRED_COUNCIL_CLI_COMMANDS = ("eval", "triage") +REQUIRED_COUNCIL_CLI_COMMANDS = ("eval", "status", "tail", "triage") +REQUIRED_COUNCIL_CLI_COMMANDS_LABEL = ", ".join(REQUIRED_COUNCIL_CLI_COMMANDS) def load_renderer() -> Any: @@ -337,18 +338,49 @@ def _has_council_command_dispatch(source: str, command: str) -> bool: return pattern.search(source) is not None +def _has_council_command_handler_dispatch(source: str, command: str) -> bool: + command_literal = re.escape(command) + handler_literal = "run" + "".join(part.capitalize() for part in command.split("-")) + "Command" + parser_literal = "parse" + "".join(part.capitalize() for part in command.split("-")) + pattern = re.compile( + r"case\s+(['\"])" + + command_literal + + r"\1\s*:\s*return\s+await\s+" + + re.escape(handler_literal) + + r"\s*\(\s*app\s*,\s*" + + re.escape(parser_literal) + + r"\s*\(", + re.MULTILINE | re.DOTALL, + ) + return pattern.search(source) is not None + + +def _has_required_council_command_dispatch(source: str, command: str) -> bool: + return _has_council_command_dispatch(source, command) or _has_council_command_handler_dispatch(source, command) + + def validate_council_command_surface_source(source: str) -> None: missing_specs = [ command for command in REQUIRED_COUNCIL_CLI_COMMANDS if not _has_council_command_spec(source, command) ] if missing_specs: - fail("council command surface missing command registry specs: " + ", ".join(missing_specs)) + fail( + "council command surface required commands " + f"({REQUIRED_COUNCIL_CLI_COMMANDS_LABEL}) missing command registry specs: " + + ", ".join(missing_specs), + ) missing_dispatches = [ - command for command in REQUIRED_COUNCIL_CLI_COMMANDS if not _has_council_command_dispatch(source, command) + command + for command in REQUIRED_COUNCIL_CLI_COMMANDS + if not _has_required_council_command_dispatch(source, command) ] if missing_dispatches: - fail("council command surface missing command dispatch branches: " + ", ".join(missing_dispatches)) + fail( + "council command surface required commands " + f"({REQUIRED_COUNCIL_CLI_COMMANDS_LABEL}) missing command dispatch branches: " + + ", ".join(missing_dispatches), + ) def validate_council_command_surface() -> None: diff --git a/tests/test_validate_manifest.py b/tests/test_validate_manifest.py index adb9f62..139dd06 100644 --- a/tests/test_validate_manifest.py +++ b/tests/test_validate_manifest.py @@ -136,7 +136,7 @@ def test_cli_surface_requires_source_file(self) -> None: ): self.validator.validate_council_command_surface() - def test_cli_surface_requires_eval_and_triage_specs(self) -> None: + def test_cli_surface_requires_registry_specs_for_required_commands(self) -> None: source = """ const COMMANDS: readonly CommandSpec[] = [ { help: 'score a run with the eval workflow', name: 'eval' }, @@ -145,32 +145,72 @@ def test_cli_surface_requires_eval_and_triage_specs(self) -> None: switch (command) { case 'eval': return okJson(await app.eval(parseEval(rest))) + case 'status': + return await runStatusCommand(app, parseStatus(rest)) + case 'tail': + return await runTailCommand(app, parseTail(rest)) case 'triage': return okJson(await app.triage(parseTriage(rest))) } } """ - with self.assertRaisesRegex(AssertionError, "missing command registry specs: triage"): + with self.assertRaisesRegex( + AssertionError, + r"required commands \(eval, status, tail, triage\) missing command registry specs: status, tail, triage", + ): self.validator.validate_council_command_surface_source(source) - def test_cli_surface_requires_eval_and_triage_dispatch_branches(self) -> None: + def test_cli_surface_accepts_direct_and_handler_dispatch_branches(self) -> None: source = """ const COMMANDS: readonly CommandSpec[] = [ { help: 'score a run with the eval workflow', name: 'eval' }, + { help: 'summarize a run directory', name: 'status' }, + { help: 'tail one task log', name: 'tail' }, { help: 'run the triage gate and emit routing payload', name: 'triage' }, ] export async function runCli(command: string) { switch (command) { case 'eval': return okJson(await app.eval(parseEval(rest))) + case 'status': + return await runStatusCommand(app, parseStatus(rest)) + case 'tail': + return await runTailCommand(app, parseTail(rest)) + case 'triage': + return okJson(await app.triage(parseTriage(rest))) + } +} +""" + + self.validator.validate_council_command_surface_source(source) + + def test_cli_surface_requires_dispatch_branches_for_required_commands(self) -> None: + source = """ +const COMMANDS: readonly CommandSpec[] = [ + { help: 'score a run with the eval workflow', name: 'eval' }, + { help: 'summarize a run directory', name: 'status' }, + { help: 'tail one task log', name: 'tail' }, + { help: 'run the triage gate and emit routing payload', name: 'triage' }, +] +export async function runCli(command: string) { + switch (command) { + case 'eval': + return okJson(await app.eval(parseEval(rest))) + case 'status': + return okJson({ command: 'status', compiled: true }) + case 'tail': + return okJson({ command: 'tail', compiled: true }) case 'triage': return okJson({ command: 'triage', compiled: true }) } } """ - with self.assertRaisesRegex(AssertionError, "missing command dispatch branches: triage"): + with self.assertRaisesRegex( + AssertionError, + r"required commands \(eval, status, tail, triage\) missing command dispatch branches: status, tail, triage", + ): self.validator.validate_council_command_surface_source(source) @@ -225,7 +265,7 @@ def test_manifest_check_reports_successful_command_surface_validation(self) -> N self.assertEqual(result.name, "manifest") self.assertEqual(result.status, "ok") - self.assertIn("council command surface validated", result.detail) + self.assertIn("council command surface validated: eval, status, tail, triage", result.detail) def test_manifest_check_reports_missing_manifest(self) -> None: with unittest.mock.patch.object(self.renderer, "MANIFEST_PATH", ROOT / "missing-manifest.yaml"): @@ -270,7 +310,10 @@ def test_doctor_reports_council_command_surface_failure(self) -> None: return_value=self.renderer.DoctorCheck( name="council-command-surface", status="fail", - detail="missing command dispatch branches: triage", + detail=( + "required commands (eval, status, tail, triage) " + "missing command dispatch branches: status, tail" + ), ), ), unittest.mock.patch.object( @@ -284,7 +327,11 @@ def test_doctor_reports_council_command_surface_failure(self) -> None: exit_code = self.renderer.doctor(args) self.assertEqual(exit_code, 1) - self.assertIn("fail council-command-surface: missing command dispatch branches: triage", output.getvalue()) + self.assertIn( + "fail council-command-surface: required commands (eval, status, tail, triage) " + "missing command dispatch branches: status, tail", + output.getvalue(), + ) class AssertionLoader: