Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,777 changes: 1,448 additions & 1,329 deletions council/ts/package-lock.json

Large diffs are not rendered by default.

126 changes: 126 additions & 0 deletions council/ts/src/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ import type {
import type { ClockPort, LiveRunArtifacts, WorkerResult } from '../ports/index.js'
import type { Task } from '../shared-kernel/index.js'
import type {
MonitorListInput,
MonitorListResult,
MonitorStartInput,
MonitorStartResult,
MonitorStatusInput,
MonitorStatusResult,
StatusWatchTickerPort,
TailWorkflowFrame,
TailWorkflowLogReadInput,
Expand Down Expand Up @@ -58,6 +64,9 @@ type AppCall =
| { readonly input: SuperviseInput; readonly method: 'supervise' }
| { readonly input: CouncilAppTailInput; readonly method: 'tail' }
| { readonly input: TriageWorkflowInput; readonly method: 'triage' }
| { readonly input: MonitorStartInput; readonly method: 'monitor' }
| { readonly input: MonitorStatusInput; readonly method: 'monitorStatus' }
| { readonly input: MonitorListInput; readonly method: 'monitorList' }

interface RecordingAppOptions {
readonly configError?: Error
Expand Down Expand Up @@ -148,6 +157,34 @@ class RecordingApp {
this.calls.push({ input, method: 'triage' })
return Promise.resolve({ input, method: 'triage', route: 'program' })
}

monitor(input: MonitorStartInput): Promise<MonitorStartResult> {
this.calls.push({ input, method: 'monitor' })
return Promise.resolve({ status: 'passed', lastOutput: 'done' })
}

monitorStatus(input: MonitorStatusInput): Promise<MonitorStatusResult> {
this.calls.push({ input, method: 'monitorStatus' })
return Promise.resolve({
state: {
name: input.name,
status: 'polling',
startedAt: '2026-01-01T00:00:00.000Z',
deadline: '2026-01-01T01:00:00.000Z',
lastTickAt: '2026-01-01T00:00:00.000Z',
lastOutput: '',
intervalMs: 5000,
cmd: 'echo',
until: 'done',
then: '',
},
})
}

monitorList(input: MonitorListInput): Promise<MonitorListResult> {
this.calls.push({ input, method: 'monitorList' })
return Promise.resolve({ monitors: [] })
}
}

function asCouncilApp(app: RecordingApp): CouncilApp {
Expand Down Expand Up @@ -1316,3 +1353,92 @@ describe('runCli error handling', () => {
})
})
})

describe('monitor command', () => {
it('routes monitor start through the app', async () => {
const app = new RecordingApp()
const result = await runCli(
[
'monitor',
'start',
'--name', 'test-monitor',
'--interval', '5s',
'--deadline', '60s',
'--cmd', 'echo hello',
'--until', 'hello',
'--then', 'echo done',
'--exec-dir', '/tmp/exec',
],
injectedRuntime(app),
)
expect(result.exitCode).toBe(0)
expect(app.calls).toEqual([
{
method: 'monitor',
input: {
name: 'test-monitor',
interval: '5s',
deadline: '60s',
cmd: 'echo hello',
until: 'hello',
then: 'echo done',
execDir: '/tmp/exec',
},
},
])
})

it('routes monitor status through the app', async () => {
const app = new RecordingApp()
const result = await runCli(
[
'monitor',
'status',
'--name', 'test-monitor',
'--exec-dir', '/tmp/exec',
],
injectedRuntime(app),
)
expect(result.exitCode).toBe(0)
expect(app.calls).toEqual([
{
method: 'monitorStatus',
input: { name: 'test-monitor', execDir: '/tmp/exec' },
},
])
})

it('routes monitor list through the app', async () => {
const app = new RecordingApp()
const result = await runCli(
[
'monitor',
'list',
'--exec-dir', '/tmp/exec',
],
injectedRuntime(app),
)
expect(result.exitCode).toBe(0)
expect(app.calls).toEqual([
{
method: 'monitorList',
input: { execDir: '/tmp/exec' },
},
])
})

it('fails when monitor has no subcommand', async () => {
const result = await runCli(['monitor'], injectedRuntime())
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('monitor requires subcommand')
})

it('fails when monitor start is missing required flags', async () => {
const result = await runCli(
['monitor', 'start', '--name', 'test'],
injectedRuntime(),
)
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('--interval is required')
})
})
61 changes: 61 additions & 0 deletions council/ts/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import type { CouncilConfig } from '../contexts/config/index.js'
import type { WorkerOutputStream } from '../contexts/runstore/index.js'
import type { DagConcurrency, DagEvalConfig } from '../ports/index.js'
import type { MonitorStartInput, MonitorStatusInput, MonitorListInput } from '../workflows/index.js'

export type CliCommand =
| 'amend'
Expand All @@ -39,6 +40,7 @@ export type CliCommand =
| 'sync-skills'
| 'tail'
| 'triage'
| 'monitor'

export interface CliResult {
readonly exitCode: number
Expand Down Expand Up @@ -78,6 +80,7 @@ const COMMANDS: readonly CommandSpec[] = [
{ help: 'synchronize council skills', name: 'sync-skills' },
{ help: 'tail one task log', name: 'tail' },
{ help: 'run the triage gate and emit routing payload', name: 'triage' },
{ help: 'poll a probe until a condition is met, then run a finalizer', name: 'monitor' },
]

export function commandRegistry(): readonly CommandSpec[] {
Expand Down Expand Up @@ -126,6 +129,8 @@ export async function runCli(argv: readonly string[], runtime: CliRuntime = {}):
return okJson(await app.supervise(parseSupervise(rest)))
case 'tail':
return await runTailCommand(app, parseTail(rest))
case 'monitor':
return await runMonitorCommand(app, parseMonitor(rest))
case 'design':
case 'amend':
case 'context':
Expand Down Expand Up @@ -565,6 +570,62 @@ function fail(stderr: string): CliResult {
return { exitCode: 2, stderr: `${stderr.trimEnd()}\n`, stdout: '' }
}


type ParsedMonitorCommand =
| { readonly kind: 'start'; readonly input: MonitorStartInput }
| { readonly kind: 'status'; readonly input: MonitorStatusInput }
| { readonly kind: 'list'; readonly input: MonitorListInput }

function parseMonitor(argv: readonly string[]): ParsedMonitorCommand {
const [subcommand, ...rest] = argv
const flags = parseFlags(rest)
if (subcommand === 'start') {
return {
kind: 'start',
input: {
name: requireFlag(flags, 'name'),
interval: requireFlag(flags, 'interval'),
deadline: requireFlag(flags, 'deadline'),
cmd: requireFlag(flags, 'cmd'),
until: requireFlag(flags, 'until'),
then: requireFlag(flags, 'then'),
execDir: requireFlag(flags, 'exec-dir'),
},
}
}
if (subcommand === 'status') {
return {
kind: 'status',
input: {
name: requireFlag(flags, 'name'),
execDir: requireFlag(flags, 'exec-dir'),
},
}
}
if (subcommand === 'list') {
return {
kind: 'list',
input: {
execDir: requireFlag(flags, 'exec-dir'),
},
}
}
throw new Error(`monitor requires subcommand: start | status | list`)
}

async function runMonitorCommand(app: CouncilApp, parsed: ParsedMonitorCommand): Promise<CliResult> {
if (parsed.kind === 'start') {
const result = await app.monitor(parsed.input)
return ok(JSON.stringify(result, null, 2))
}
if (parsed.kind === 'status') {
const result = await app.monitorStatus(parsed.input)
return ok(JSON.stringify(result, null, 2))
}
const result = await app.monitorList(parsed.input)
return ok(JSON.stringify(result, null, 2))
}

/* c8 ignore start -- process entry bootstrap; not unit-testable */
if (import.meta.url === `file://${process.argv[1] ?? ''}`) {
void runCli(process.argv.slice(2)).then((result) => {
Expand Down
125 changes: 125 additions & 0 deletions council/ts/src/composition/council-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2007,3 +2007,128 @@ function nativeTask(): Task {
verify_proves: ['npm test exercises the native task behavior'],
}
}

describe('CouncilApp.monitor', () => {
let tempDirs: string[] = []

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })))
})

it('runs monitor start and returns passed status when predicate matches on first tick', async () => {
const execDir = await mkdtemp(join(tmpdir(), 'monitor-app-test-'))
tempDirs.push(execDir)

const app = new CouncilApp({
process: {
exec: vi.fn().mockResolvedValue({ stdout: 'hello world', stderr: '', exitCode: 0 }),
},
})

const result = await app.monitor({
name: 'test-monitor',
interval: '1s',
deadline: '60s',
cmd: 'echo hello',
until: 'hello',
then: '',
execDir,
})

expect(result.status).toBe('passed')
})

it('runs monitor start with multiple ticks exercising sleep', async () => {
const execDir = await mkdtemp(join(tmpdir(), 'monitor-app-test-'))
tempDirs.push(execDir)

let callCount = 0
const app = new CouncilApp({
process: {
exec: vi.fn().mockImplementation(async () => {
callCount += 1
return { stdout: callCount >= 2 ? 'matched' : 'not yet', stderr: '', exitCode: 0 }
}),
},
})

const result = await app.monitor({
name: 'multi-tick-monitor',
interval: '1s',
deadline: '60s',
cmd: 'probe cmd',
until: 'matched',
then: '',
execDir,
})

expect(result.status).toBe('passed')
expect(callCount).toBeGreaterThanOrEqual(2)
})

it('runs monitorStatus and returns the monitor state', async () => {
const execDir = await mkdtemp(join(tmpdir(), 'monitor-app-test-'))
tempDirs.push(execDir)

// First create a monitor state by running start
const startApp = new CouncilApp({
process: {
exec: vi.fn().mockResolvedValue({ stdout: 'ok', stderr: '', exitCode: 0 }),
},
})
await startApp.monitor({
name: 'status-test',
interval: '1s',
deadline: '60s',
cmd: 'echo ok',
until: 'ok',
then: '',
execDir,
})

// Now read status
const statusApp = new CouncilApp({
process: {
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }),
},
})
const result = await statusApp.monitorStatus({
name: 'status-test',
execDir,
})

expect(result.state.name).toBe('status-test')
expect(result.state.status).toBe('passed')
})

it('runs monitorList and returns all monitors in the exec dir', async () => {
const execDir = await mkdtemp(join(tmpdir(), 'monitor-app-test-'))
tempDirs.push(execDir)

// Create a monitor
const startApp = new CouncilApp({
process: {
exec: vi.fn().mockResolvedValue({ stdout: 'ready', stderr: '', exitCode: 0 }),
},
})
await startApp.monitor({
name: 'list-test',
interval: '1s',
deadline: '60s',
cmd: 'echo ready',
until: 'ready',
then: '',
execDir,
})

const listApp = new CouncilApp({
process: {
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }),
},
})
const result = await listApp.monitorList({ execDir })

expect(result.monitors).toHaveLength(1)
expect(result.monitors[0]?.name).toBe('list-test')
})
})
Loading