diff --git a/README.md b/README.md index db184479..cf71a493 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,33 @@ Useful flags: `--input KEY=value` overrides declared persona inputs; filesystem with provider data the handler reads. Exit code is 0 when every envelope succeeded, 1 when any handler invocation failed. +### Where fixtures come from + +The primary path is exporting a **real fire** — the fixture is then cloud's +exact normalized output, so replay cannot drift from production: + +```bash +agentworkforce runs export --fixture event.json +agentworkforce invoke ./persona.json --fixture event.json +``` + +(`runs export` reads the captured envelope from the run's +`/runs/:runId/envelope` endpoint; pass `--agent ` to skip the +workspace-wide run lookup. Runs that predate envelope capture, or whose +envelope was too large to store, are reported with next steps — oversized +envelopes are omitted, never truncated.) + +Before any real fire exists, scaffold a skeleton: + +```bash +agentworkforce invoke --scaffold github.pull_request.opened --output event.json +``` + +The frame is filled in; for provider events the `resource` payload is an +explicit TODO hole (its shape is decided by adapter normalization + cloud's +buildEnvelope and shouldn't be guessed). `` is validated against the +trigger catalog with a warn-don't-block stance. + ## Persona vs agent A deployable agent is two files. The **persona** says *what the agent is* diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2c4ac04f..54677e3a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -72,6 +72,7 @@ import { import ora, { type Ora } from 'ora'; import { runDeploy, runLogin, runLogout } from './deploy-command.js'; import { runInvoke } from './invoke-command.js'; +import { runRuns } from './runs-command.js'; import { runDestroy } from './destroy-command.js'; import { runDeploymentList, runDeploymentLogs } from './list-command.js'; import { @@ -258,6 +259,15 @@ Commands: --input KEY=value override a declared persona input --seed PATH=file seed the simulated filesystem --workspace workspace id for the simulated ctx + --scaffold emit a fixture skeleton for an + event type instead of running + (no persona needed) + runs export [flags] + Export the gateway envelope cloud delivered to a run + as a replayable invoke fixture. Flags: + --agent agentId/deployedName owning the + run (otherwise all are checked) + --fixture write the fixture to a file deployments list List deployed cloud agents in the active workspace. deployments logs Show structured logs for a deployed cloud agent. destroy [flags] @@ -4388,6 +4398,11 @@ export async function main(): Promise { return; } + if (subcommand === 'runs') { + await runRuns(rest); + return; + } + if (subcommand === 'deployments') { const [action, ...extra] = rest; if (!action || action === '-h' || action === '--help') { diff --git a/packages/cli/src/invoke-command.test.ts b/packages/cli/src/invoke-command.test.ts index a10a3a23..d4486d56 100644 --- a/packages/cli/src/invoke-command.test.ts +++ b/packages/cli/src/invoke-command.test.ts @@ -8,6 +8,7 @@ import { parseInvokeArgs, renderHumanSummary, runInvoke, + scaffoldFixture, type RunInvokeIO } from './invoke-command.js'; import type { SimulationResult } from '@agentworkforce/runtime'; @@ -30,8 +31,8 @@ test('parseInvokeArgs: persona + fixture + repeatable flags', () => { '--output', './run.json' ]); - assert.ok(!('help' in parsed)); - if ('help' in parsed) return; + assert.ok(!('help' in parsed) && !('scaffold' in parsed)); + if ('help' in parsed || 'scaffold' in parsed) return; assert.equal(path.basename(parsed.personaPath), 'persona.json'); assert.equal(path.basename(parsed.fixturePath), 'event.json'); assert.equal(path.basename(parsed.outputPath ?? ''), 'run.json'); @@ -326,3 +327,81 @@ test('renderHumanSummary: lists runs with status, side-effect count, skips', () assert.match(summary, /error: kaboom/); assert.match(summary, /\[skip\] unsupported envelope e9 \(mystery\.event\)/); }); + +// --------------------------------------------------------------------------- +// --scaffold (workforce#189) + +test('parseInvokeArgs: --scaffold needs no persona or fixture', () => { + const parsed = parseInvokeArgs(['--scaffold', 'cron.tick', '--output', './event.json']); + assert.ok('scaffold' in parsed); + if (!('scaffold' in parsed)) return; + assert.equal(parsed.scaffold, 'cron.tick'); + assert.equal(path.basename(parsed.outputPath ?? ''), 'event.json'); +}); + +test('parseInvokeArgs: --scaffold rejects invoke-only args instead of ignoring them', () => { + assert.throws( + () => parseInvokeArgs(['./persona.json', '--scaffold', 'cron.tick']), + /only accepts --output.*/ + ); + assert.throws( + () => parseInvokeArgs(['--scaffold', 'cron.tick', '--fixture', './event.json']), + /only accepts --output.*--fixture/ + ); + assert.throws( + () => parseInvokeArgs(['--scaffold', 'cron.tick', '--input', 'FOO=bar', '--seed', '/x=./x.json']), + /only accepts --output.*--input, --seed/ + ); +}); + +test('scaffoldFixture: cron.tick emits a complete frame with name/cron and no warnings', () => { + const { fixture, warnings } = scaffoldFixture('cron.tick'); + assert.deepEqual(warnings, []); + assert.equal(fixture.type, 'cron.tick'); + assert.ok(typeof fixture.occurredAt === 'string'); + assert.ok(String(fixture.name).includes('TODO')); + assert.ok(typeof fixture.cron === 'string'); + assert.ok(!('resource' in fixture)); + + const named = scaffoldFixture('cron.daily-report'); + assert.equal(named.fixture.type, 'cron.daily-report'); +}); + +test('scaffoldFixture: known provider event emits frame + explicit resource TODO hole', () => { + const { fixture, warnings } = scaffoldFixture('github.pull_request.opened'); + assert.deepEqual(warnings, []); + assert.equal(fixture.type, 'github.pull_request.opened'); + const resource = fixture.resource as Record; + assert.ok(String(resource.TODO).includes('runs export')); +}); + +test('scaffoldFixture: unknown provider/event warns but never blocks (lintTriggers stance)', () => { + const unknownProvider = scaffoldFixture('notaprovider.something'); + assert.equal(unknownProvider.warnings.length, 1); + assert.match(unknownProvider.warnings[0], /not in KNOWN_TRIGGER_CATALOG/); + assert.equal(unknownProvider.fixture.type, 'notaprovider.something'); + + const unknownEvent = scaffoldFixture('github.not_a_real_event'); + assert.equal(unknownEvent.warnings.length, 1); + assert.match(unknownEvent.warnings[0], /not a known github trigger/); +}); + +test('runInvoke --scaffold writes the skeleton and exits clean', async () => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke(['--scaffold', 'cron.tick'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.equal(result, undefined); + assert.notEqual(exitCode, 1); + const skeleton = JSON.parse(io.out.join('')); + assert.equal(skeleton.type, 'cron.tick'); +}); + +test('scaffoldFixture: non-tick cron types are PRESERVED with a warning, never rewritten', () => { + const { fixture, warnings } = scaffoldFixture('cron.daily'); + assert.equal(fixture.type, 'cron.daily'); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /preserving requested type "cron.daily"/); +}); diff --git a/packages/cli/src/invoke-command.ts b/packages/cli/src/invoke-command.ts index ac99375e..290cd2f3 100644 --- a/packages/cli/src/invoke-command.ts +++ b/packages/cli/src/invoke-command.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { bundleStager, preflightPersona } from '@agentworkforce/deploy'; +import { KNOWN_TRIGGER_CATALOG } from '@agentworkforce/persona-kit'; import { simulateInvocation, type RawGatewayEnvelope, @@ -11,6 +12,7 @@ import { } from '@agentworkforce/runtime'; export const INVOKE_USAGE = `usage: agentworkforce invoke --fixture [flags] + agentworkforce invoke --scaffold [--output ] Simulate an invocation: execute the persona's handler against fixture event envelope(s) with every external side effect recorded, NOT executed, and emit @@ -35,6 +37,10 @@ Flags: /slack/channels/_index.json). --workspace Workspace id for the simulated ctx; defaults to the first envelope's workspace. + --scaffold Emit a fixture skeleton for an event type instead + of running a persona. Provider payloads are left + as explicit TODO holes; prefer runs export after + a real fire exists. -h, --help Print this message. Exit code: 0 when every dispatched envelope succeeded, 1 when any handler @@ -51,7 +57,10 @@ export interface InvokeOptions { workspaceId?: string; } -export type ParsedInvokeArgs = InvokeOptions | { help: true }; +export type ParsedInvokeArgs = + | InvokeOptions + | { help: true } + | { scaffold: string; outputPath?: string }; /** Parse `invoke` args. Throws on usage errors (caller maps to exit 1). */ export function parseInvokeArgs(args: readonly string[]): ParsedInvokeArgs { @@ -59,6 +68,8 @@ export function parseInvokeArgs(args: readonly string[]): ParsedInvokeArgs { let fixturePath: string | undefined; let outputPath: string | undefined; let workspaceId: string | undefined; + let scaffoldType: string | undefined; + let sawFixture = false; const inputs: Record = {}; const seeds: Record = {}; @@ -67,13 +78,19 @@ export function parseInvokeArgs(args: readonly string[]): ParsedInvokeArgs { if (a === '-h' || a === '--help') { return { help: true }; } else if (a === '--fixture') { + sawFixture = true; fixturePath = expectValue('--fixture', args[++i]); } else if (a.startsWith('--fixture=')) { + sawFixture = true; fixturePath = expectInline('--fixture', a.slice('--fixture='.length)); } else if (a === '--output') { outputPath = expectValue('--output', args[++i]); } else if (a.startsWith('--output=')) { outputPath = expectInline('--output', a.slice('--output='.length)); + } else if (a === '--scaffold') { + scaffoldType = expectValue('--scaffold', args[++i]); + } else if (a.startsWith('--scaffold=')) { + scaffoldType = expectInline('--scaffold', a.slice('--scaffold='.length)); } else if (a === '--workspace') { workspaceId = expectValue('--workspace', args[++i]); } else if (a.startsWith('--workspace=')) { @@ -95,6 +112,22 @@ export function parseInvokeArgs(args: readonly string[]): ParsedInvokeArgs { } } + if (scaffoldType) { + const invalidScaffoldFlags = [ + sawFixture ? '--fixture' : '', + workspaceId ? '--workspace' : '', + Object.keys(inputs).length > 0 ? '--input' : '', + Object.keys(seeds).length > 0 ? '--seed' : '', + personaPath ? '' : '' + ].filter(Boolean); + if (invalidScaffoldFlags.length > 0) { + throw new Error( + `invoke: --scaffold only accepts --output; remove ${invalidScaffoldFlags.join(', ')}` + ); + } + // Scaffold mode authors a fixture skeleton; no persona/fixture needed. + return { scaffold: scaffoldType, ...(outputPath ? { outputPath: path.resolve(outputPath) } : {}) }; + } if (!personaPath) { throw new Error('invoke: missing persona path. Usage: agentworkforce invoke --fixture '); } @@ -261,6 +294,18 @@ export async function runInvoke( io.stdout(INVOKE_USAGE); return undefined; } + if ('scaffold' in opts) { + const { fixture, warnings } = scaffoldFixture(opts.scaffold); + for (const warning of warnings) io.stderr(`warn: ${warning}\n`); + const text = `${JSON.stringify(fixture, null, 2)}\n`; + if (opts.outputPath) { + await writeFile(opts.outputPath, text, 'utf8'); + io.stderr(`fixture skeleton written to ${opts.outputPath}\n`); + } else { + io.stdout(text); + } + return undefined; + } try { return await runInvokeWithOptions(opts, io); @@ -369,3 +414,86 @@ export function renderHumanSummary(result: SimulationResult): string { } return `${lines.join('\n')}\n`; } + +/** + * Cold-start fixture authoring (workforce#189): emit a RawGatewayEnvelope + * skeleton for an event type before any real fire exists. The frame is + * filled in; for provider events the `resource` payload shape is decided by + * adapter normalization + cloud's buildEnvelope and CANNOT be guessed here, + * so it is left as an explicit TODO hole — prefer + * `agentworkforce runs export --fixture …` once a real fire exists. + * + * `` is validated against KNOWN_TRIGGER_CATALOG with the same + * warn-don't-block stance as lintTriggers. + */ +export function scaffoldFixture(type: string): { + fixture: Record; + warnings: string[]; +} { + const warnings: string[] = []; + const occurredAt = new Date().toISOString(); + + if (type === 'cron.tick' || type.startsWith('cron.')) { + if (type !== 'cron.tick') { + // Preserve what was asked for (a silent rewrite would scaffold a + // DIFFERENT event type than requested) but warn: the gateway + // delivers schedule fires as `cron.tick`, and the runner shim + // treats any `cron.*` as a cron event. + warnings.push( + `the gateway delivers schedule fires as "cron.tick"; preserving requested type "${type}" (the runner shim still dispatches any cron.* as a cron event)` + ); + } + return { + fixture: { + id: 'evt_local_1', + workspace: 'ws-local', + type, + occurredAt, + name: 'TODO: your schedule name (persona schedules[].name)', + cron: '0 9 * * 1' + }, + warnings + }; + } + + const firstDot = type.indexOf('.'); + const provider = firstDot > 0 ? type.slice(0, firstDot) : ''; + const eventName = firstDot > 0 ? type.slice(firstDot + 1) : ''; + if (!provider || !eventName) { + warnings.push( + `"${type}" is not a recognized envelope type shape (expected "cron.tick" or "."); emitting a provider-style skeleton anyway` + ); + } else { + const catalog = KNOWN_TRIGGER_CATALOG as Record; + const events = catalog[provider]; + if (events === undefined) { + warnings.push( + `provider "${provider}" is not in KNOWN_TRIGGER_CATALOG (known: ${Object.keys(catalog).join(', ')}); scaffolding anyway` + ); + } else { + const known = Array.isArray(events) + ? events.includes(eventName) + : typeof events === 'object' && events !== null && eventName in (events as Record); + if (!known) { + warnings.push( + `event "${eventName}" is not a known ${provider} trigger in KNOWN_TRIGGER_CATALOG; scaffolding anyway` + ); + } + } + } + + return { + fixture: { + id: 'evt_local_1', + workspace: 'ws-local', + type, + occurredAt, + resource: { + TODO: + 'the provider payload shape is decided by adapter normalization + cloud buildEnvelope; ' + + 'export a real fire with `agentworkforce runs export --fixture event.json` to get the exact shape' + } + }, + warnings + }; +} diff --git a/packages/cli/src/list-command.ts b/packages/cli/src/list-command.ts index e217a139..e43cf785 100644 --- a/packages/cli/src/list-command.ts +++ b/packages/cli/src/list-command.ts @@ -25,7 +25,7 @@ type DeploymentLogsOptions = { noPrompt?: boolean; }; -type DeploymentAgent = { +export type DeploymentAgent = { agentId: string; personaId: string; personaSlug: string; @@ -344,7 +344,7 @@ function parseAgents(body: ListResponse): DeploymentAgent[] { }).filter((agent) => agent.agentId); } -async function resolveDeploymentRequestContext(opts: { +export async function resolveDeploymentRequestContext(opts: { workspace?: string; cloudUrl?: string; noPrompt?: boolean; @@ -368,7 +368,7 @@ async function resolveDeploymentRequestContext(opts: { return { cloudUrl, workspace, token: auth.token }; } -async function fetchDeployments(args: { +export async function fetchDeployments(args: { cloudUrl: string; workspace: string; token: string; @@ -406,7 +406,7 @@ async function fetchLogEntries(args: { return entries.filter((entry): entry is LogEntry => Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry)); } -async function requestJson(url: URL, token: string, action: string): Promise { +export async function requestJson(url: URL, token: string, action: string): Promise { const res = await fetch(url, { method: 'GET', headers: { @@ -425,7 +425,7 @@ async function requestJson(url: URL, token: string, action: string): Promise< return (await res.json()) as T; } -function resolveAgentSelector(agents: readonly DeploymentAgent[], selector: string): DeploymentAgent { +export function resolveAgentSelector(agents: readonly DeploymentAgent[], selector: string): DeploymentAgent { const matches = agents.filter((agent) => { const candidates = [ agent.agentId, diff --git a/packages/cli/src/runs-command.test.ts b/packages/cli/src/runs-command.test.ts new file mode 100644 index 00000000..7b4b453c --- /dev/null +++ b/packages/cli/src/runs-command.test.ts @@ -0,0 +1,127 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { interpretEnvelopeResponse, isProbeNotFound, parseRunsArgs } from './runs-command.js'; + +// --------------------------------------------------------------------------- +// parseRunsArgs + +test('parseRunsArgs: export with full flags', () => { + const parsed = parseRunsArgs([ + 'export', + 'run-123', + '--agent', + 'hn-monitor', + '--fixture', + './event.json', + '--workspace', + 'ws-1', + '--no-prompt' + ]); + assert.ok(!('help' in parsed)); + if ('help' in parsed) return; + assert.equal(parsed.action, 'export'); + assert.equal(parsed.options.runId, 'run-123'); + assert.equal(parsed.options.agent, 'hn-monitor'); + assert.equal(path.basename(parsed.options.fixturePath ?? ''), 'event.json'); + assert.equal(parsed.options.workspace, 'ws-1'); + assert.equal(parsed.options.noPrompt, true); +}); + +test('parseRunsArgs: bare/help/unknown-action contracts', () => { + assert.deepEqual(parseRunsArgs([]), { help: true }); + assert.deepEqual(parseRunsArgs(['-h']), { help: true }); + assert.deepEqual(parseRunsArgs(['export', '-h']), { help: true }); + assert.throws(() => parseRunsArgs(['import']), /unknown action "import"/); + assert.throws(() => parseRunsArgs(['export']), /missing run id/); + assert.throws(() => parseRunsArgs(['export', 'r1', '--bogus']), /unknown flag "--bogus"/); + assert.throws(() => parseRunsArgs(['export', 'r1', 'r2']), /unexpected positional/); +}); + +// --------------------------------------------------------------------------- +// interpretEnvelopeResponse — the captured/omitted/not-captured contract + +test('interpretEnvelopeResponse: captured envelope becomes a pretty fixture', () => { + const result = interpretEnvelopeResponse( + { captured: true, omitted: false, envelope: { id: 'evt_1', type: 'cron.tick' } }, + 'run-1', + 'hn-monitor' + ); + assert.ok(result.ok); + if (!result.ok) return; + assert.equal(JSON.parse(result.fixture).id, 'evt_1'); + assert.ok(result.fixture.endsWith('\n')); +}); + +test('interpretEnvelopeResponse: omitted (oversized) explains never-truncate and points at --scaffold', () => { + const result = interpretEnvelopeResponse( + { captured: false, omitted: true, envelope: null }, + 'run-1', + 'hn-monitor' + ); + assert.ok(!result.ok); + if (result.ok) return; + assert.match(result.error, /too large to capture/); + assert.match(result.error, /never truncated/); + assert.match(result.error, /--scaffold/); +}); + +test('interpretEnvelopeResponse: pre-capture runs explain why and how to proceed', () => { + const result = interpretEnvelopeResponse( + { captured: false, omitted: false, envelope: null }, + 'run-1', + 'hn-monitor' + ); + assert.ok(!result.ok); + if (result.ok) return; + assert.match(result.error, /no envelope was captured/); + assert.match(result.error, /predate capture/); +}); + +test('interpretEnvelopeResponse: captured:true with null envelope is NOT treated as a fixture', () => { + // Defensive: a contract-violating response must not fabricate an empty fixture. + const result = interpretEnvelopeResponse( + { captured: true, omitted: false, envelope: null }, + 'run-1', + 'hn-monitor' + ); + assert.ok(!result.ok); +}); + +// --------------------------------------------------------------------------- +// isProbeNotFound — only a probe 404 continues the scan; everything else +// (esp. 401 with its actionable login hint) must fail loud. + +test('isProbeNotFound: 404 probe errors continue the scan', () => { + assert.equal(isProbeNotFound(new Error('runs export failed: 404 {"error":"Deployment run not found"}')), true); + assert.equal(isProbeNotFound(new Error('runs export failed: 404')), true); +}); + +test('isProbeNotFound: auth/transient errors are NOT swallowed', () => { + assert.equal(isProbeNotFound(new Error('unauthorized. Run `agentworkforce login` and retry.')), false); + assert.equal(isProbeNotFound(new Error('runs export failed: 403 forbidden')), false); + assert.equal(isProbeNotFound(new Error('runs export failed: 500')), false); + assert.equal(isProbeNotFound(new Error('fetch failed')), false); + assert.equal(isProbeNotFound('not-an-error'), false); +}); + +test('parseRunsArgs: =-inline forms for workspace and cloud-url', () => { + const parsed = parseRunsArgs(['export', 'r1', '--workspace=ws-9', '--cloud-url=https://example.com']); + assert.ok(!('help' in parsed)); + if ('help' in parsed) return; + assert.equal(parsed.options.workspace, 'ws-9'); + assert.equal(parsed.options.cloudUrl, 'https://example.com'); +}); + +test('interpretEnvelopeResponse: non-object envelopes are refused (string/number/array)', () => { + for (const envelope of ['"oops"', 42, ['not', 'an', 'envelope']]) { + const result = interpretEnvelopeResponse( + { captured: true, omitted: false, envelope }, + 'run-1', + 'hn-monitor' + ); + assert.ok(!result.ok, `expected refusal for ${JSON.stringify(envelope)}`); + if (result.ok) continue; + assert.match(result.error, /non-object envelope/); + } +}); diff --git a/packages/cli/src/runs-command.ts b/packages/cli/src/runs-command.ts new file mode 100644 index 00000000..af87f4c3 --- /dev/null +++ b/packages/cli/src/runs-command.ts @@ -0,0 +1,283 @@ +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + fetchDeployments, + requestJson, + resolveAgentSelector, + resolveDeploymentRequestContext, + type DeploymentAgent +} from './list-command.js'; + +export const RUNS_USAGE = `usage: agentworkforce runs export [flags] + +Export the gateway envelope cloud actually delivered to a run as a +replayable \`agentworkforce invoke --fixture\` fixture (workforce#189 / +cloud#1841). The fixture IS cloud's normalized output, so local replay +cannot drift from production normalization. + +Flags: + --agent Agent the run belongs to (agentId, deployedName, + or persona slug). Without it, every agent in the + workspace is checked for the run id. + --fixture Write the envelope fixture to . + Default: stdout. + --workspace Workforce workspace; defaults to the active one. + --cloud-url Override the workforce cloud base URL. + --no-prompt Fail instead of prompting for login. + -h, --help Print this message. + +Exit codes: 0 exported; 1 run not found / envelope not captured / errors. +`; + +export interface RunsExportOptions { + runId: string; + agent?: string; + fixturePath?: string; + workspace?: string; + cloudUrl?: string; + noPrompt?: boolean; +} + +export type ParsedRunsArgs = { action: 'export'; options: RunsExportOptions } | { help: true }; + +export function parseRunsArgs(args: readonly string[]): ParsedRunsArgs { + const [action, ...rest] = args; + if (!action || action === '-h' || action === '--help') { + return { help: true }; + } + if (action !== 'export') { + throw new Error(`runs: unknown action "${action}". Expected: export`); + } + + let runId: string | undefined; + let agent: string | undefined; + let fixturePath: string | undefined; + let workspace: string | undefined; + let cloudUrl: string | undefined; + let noPrompt = false; + + for (let i = 0; i < rest.length; i += 1) { + const a = rest[i]; + if (a === '-h' || a === '--help') { + return { help: true }; + } else if (a === '--agent') { + agent = expectValue('--agent', rest[++i]); + } else if (a.startsWith('--agent=')) { + agent = expectInline('--agent', a.slice('--agent='.length)); + } else if (a === '--fixture') { + fixturePath = expectValue('--fixture', rest[++i]); + } else if (a.startsWith('--fixture=')) { + fixturePath = expectInline('--fixture', a.slice('--fixture='.length)); + } else if (a === '--workspace') { + workspace = expectValue('--workspace', rest[++i]); + } else if (a.startsWith('--workspace=')) { + workspace = expectInline('--workspace', a.slice('--workspace='.length)); + } else if (a === '--cloud-url') { + cloudUrl = expectValue('--cloud-url', rest[++i]); + } else if (a.startsWith('--cloud-url=')) { + cloudUrl = expectInline('--cloud-url', a.slice('--cloud-url='.length)); + } else if (a === '--no-prompt') { + noPrompt = true; + } else if (a.startsWith('--')) { + throw new Error(`runs export: unknown flag "${a}"`); + } else if (!runId) { + runId = a; + } else { + throw new Error(`runs export: unexpected positional argument "${a}"`); + } + } + + if (!runId) { + throw new Error('runs export: missing run id. Usage: agentworkforce runs export '); + } + + return { + action: 'export', + options: { + runId, + ...(agent ? { agent } : {}), + ...(fixturePath ? { fixturePath: path.resolve(fixturePath) } : {}), + ...(workspace ? { workspace } : {}), + ...(cloudUrl ? { cloudUrl } : {}), + ...(noPrompt ? { noPrompt: true } : {}) + } + }; +} + +function expectValue(flag: string, value: string | undefined): string { + if (value === undefined || value.startsWith('--')) { + throw new Error(`runs export: ${flag} expects a value`); + } + return value; +} + +function expectInline(flag: string, value: string): string { + if (!value) throw new Error(`runs export: ${flag} expects a value`); + return value; +} + +type EnvelopeResponse = { + captured?: unknown; + omitted?: unknown; + envelope?: unknown; +}; + +export interface RunsIO { + stdout: (text: string) => void; + stderr: (text: string) => void; +} + +const defaultIO: RunsIO = { + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text) +}; + +/** + * Fetch the captured envelope for a run. The envelope endpoint is + * agent-scoped (`/deployments/:agentId/runs/:runId/envelope`), so with no + * --agent selector every workspace agent is probed for the run id (404 = + * not this agent's run; bounded by the workspace's agent count). + */ +export async function runRuns(args: readonly string[], io: RunsIO = defaultIO): Promise { + let parsed: ParsedRunsArgs; + try { + parsed = parseRunsArgs(args); + } catch (err) { + io.stderr(`${err instanceof Error ? err.message : String(err)}\n\n${RUNS_USAGE}`); + process.exitCode = 1; + return; + } + if ('help' in parsed) { + io.stdout(RUNS_USAGE); + return; + } + + try { + await runRunsExport(parsed.options, io); + } catch (err) { + io.stderr(`runs export: ${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; + } +} + +async function runRunsExport(opts: RunsExportOptions, io: RunsIO): Promise { + const ctx = await resolveDeploymentRequestContext({ + ...(opts.workspace ? { workspace: opts.workspace } : {}), + ...(opts.cloudUrl ? { cloudUrl: opts.cloudUrl } : {}), + ...(opts.noPrompt ? { noPrompt: true } : {}) + }); + + const agents = await fetchDeployments({ + cloudUrl: ctx.cloudUrl, + workspace: ctx.workspace, + token: ctx.token + }); + const candidates: DeploymentAgent[] = opts.agent + ? [resolveAgentSelector(agents, opts.agent)] + : agents; + if (candidates.length === 0) { + throw new Error(`no deployed agents found in workspace ${ctx.workspace}`); + } + + let payload: EnvelopeResponse | null = null; + let matchedAgent: DeploymentAgent | null = null; + for (const agent of candidates) { + const url = new URL( + `/api/v1/workspaces/${encodeURIComponent(ctx.workspace)}` + + `/deployments/${encodeURIComponent(agent.agentId)}` + + `/runs/${encodeURIComponent(opts.runId)}/envelope`, + ctx.cloudUrl + ); + try { + payload = await requestJson(url, ctx.token, 'runs export'); + matchedAgent = agent; + break; + } catch (err) { + // ONLY a 404 means "not this agent's run" — keep scanning. Anything + // else (401 unauthorized with its actionable login hint, 403, 5xx) + // must fail LOUD instead of being laundered into a misleading + // "run not found": with --agent there is exactly one candidate, and + // in scan mode an auth/transient failure poisons every probe anyway. + if (!isProbeNotFound(err)) { + throw err; + } + continue; + } + } + + if (!payload || !matchedAgent) { + throw new Error( + `run ${opts.runId} not found in workspace ${ctx.workspace}` + + `${opts.agent ? ` for agent "${opts.agent}"` : ` across ${candidates.length} agent(s)`}. ` + + 'Check the run id (dashboard run detail, or `agentworkforce deployments list`).' + ); + } + + const interpreted = interpretEnvelopeResponse(payload, opts.runId, matchedAgent.deployedName); + if (!interpreted.ok) { + throw new Error(interpreted.error); + } + + const fixture = interpreted.fixture; + if (opts.fixturePath) { + await writeFile(opts.fixturePath, fixture, 'utf8'); + io.stderr( + `exported envelope for run ${opts.runId} (agent ${matchedAgent.deployedName}) to ${opts.fixturePath}\n` + + `replay with: agentworkforce invoke --fixture ${opts.fixturePath}\n` + ); + } else { + io.stdout(fixture); + } +} + +/** + * Interpret the envelope endpoint's response into either a fixture text or + * a user-actionable error. Exported for unit tests. + */ +export function interpretEnvelopeResponse( + payload: EnvelopeResponse, + runId: string, + agentName: string +): { ok: true; fixture: string } | { ok: false; error: string } { + if (payload.captured === true && payload.envelope !== null && payload.envelope !== undefined) { + // Same never-fabricate stance as below: an envelope MUST be a JSON + // object (RawGatewayEnvelope frame). A string/number/array here is a + // contract-violating response — exporting it would only move the + // failure to `invoke` time with a worse message. + if (typeof payload.envelope !== 'object' || Array.isArray(payload.envelope)) { + return { + ok: false, + error: + `run ${runId} (agent ${agentName}): the envelope endpoint returned a non-object envelope ` + + `(${Array.isArray(payload.envelope) ? 'array' : typeof payload.envelope}) — contract-violating response, refusing to write a fixture that cannot replay.` + }; + } + return { ok: true, fixture: `${JSON.stringify(payload.envelope, null, 2)}\n` }; + } + if (payload.omitted === true) { + return { + ok: false, + error: + `run ${runId} (agent ${agentName}): the envelope was too large to capture ` + + '(omitted at write time — never truncated, a partial envelope would replay wrong). ' + + 'Use `agentworkforce invoke --scaffold ` to author a fixture by hand.' + }; + } + return { + ok: false, + error: + `run ${runId} (agent ${agentName}): no envelope was captured for this run ` + + '(runs before cloud#1841 deployed predate capture). ' + + 'Re-fire the agent or use `agentworkforce invoke --scaffold `.' + }; +} + +/** + * True only for the probe's "this agent does not own that run" outcome: + * requestJson formats non-ok statuses as " failed: …" and + * throws a distinct "unauthorized. Run `agentworkforce login` …" for 401. + * Exported for unit tests. + */ +export function isProbeNotFound(err: unknown): boolean { + return err instanceof Error && / failed: 404\b/.test(err.message); +} diff --git a/packages/runtime/src/envelope-fields.cloud.ts b/packages/runtime/src/envelope-fields.cloud.ts new file mode 100644 index 00000000..18873e5e --- /dev/null +++ b/packages/runtime/src/envelope-fields.cloud.ts @@ -0,0 +1,19 @@ +/** + * CHECKED-IN COPY of cloud's envelope contract anchor. + * + * Source: AgentWorkforce/cloud + * `packages/web/lib/proactive-runtime/deployment-trigger-delivery.ts` + * `ENVELOPE_FIELDS` (cloud#1841). Cloud pins `buildEnvelope`'s actual output + * to that constant with a unit test whose failure message points here; this + * repo pins `RawGatewayEnvelope` against this copy in + * `shim.contract.test.ts`. A field added on either side without updating + * BOTH fails CI on that side — drift cannot widen silently (workforce#189). + * + * Update procedure: change cloud's ENVELOPE_FIELDS + this file + the + * RawGatewayEnvelope type (and RAW_GATEWAY_ENVELOPE_FIELDS) in the same + * cross-repo change set. + */ +export const CLOUD_ENVELOPE_FIELDS = { + always: ["id", "workspace", "type", "occurredAt", "attempt", "name", "cron", "resource"], + optional: ["provider", "eventType", "deliveryId", "paths", "summary", "resumeContext"], +} as const; diff --git a/packages/runtime/src/shim.contract.test.ts b/packages/runtime/src/shim.contract.test.ts new file mode 100644 index 00000000..04d82bf8 --- /dev/null +++ b/packages/runtime/src/shim.contract.test.ts @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CLOUD_ENVELOPE_FIELDS } from './envelope-fields.cloud.js'; +import { RAW_GATEWAY_ENVELOPE_FIELDS, shimEnvelope, type RawGatewayEnvelope } from './shim.js'; + +/** + * Cross-repo envelope contract test (workforce#189): every field cloud's + * buildEnvelope can emit must be a documented RawGatewayEnvelope field. + * Cloud's side pins buildEnvelope's output to ENVELOPE_FIELDS; this side + * pins the type against the checked-in copy. Drift fails CI on whichever + * side moved first. + */ + +test('every cloud envelope field is documented on RawGatewayEnvelope', () => { + const documented = new Set(RAW_GATEWAY_ENVELOPE_FIELDS); + const cloudFields = [...CLOUD_ENVELOPE_FIELDS.always, ...CLOUD_ENVELOPE_FIELDS.optional]; + const missing = cloudFields.filter((field) => !documented.has(field)); + assert.deepEqual( + missing, + [], + `cloud's buildEnvelope emits field(s) not documented on RawGatewayEnvelope: ${missing.join(', ')}. ` + + 'Add them to the type + RAW_GATEWAY_ENVELOPE_FIELDS in shim.ts (and confirm ' + + 'envelope-fields.cloud.ts matches cloud ENVELOPE_FIELDS).', + ); +}); + +test('cloud field lists are disjoint and non-empty (copy sanity)', () => { + const always = new Set(CLOUD_ENVELOPE_FIELDS.always); + assert.ok(always.size > 0 && CLOUD_ENVELOPE_FIELDS.optional.length > 0); + for (const field of CLOUD_ENVELOPE_FIELDS.optional) { + assert.ok(!always.has(field), `field "${field}" is in both always and optional`); + } +}); + +test('a full cloud-shaped envelope (all contract fields) shims without loss of dispatch', () => { + // An exported fixture from `runs export` carries the cloud-only fields; + // shimEnvelope must still dispatch it (unknown-to-dispatch fields are + // simply not consumed — replay fidelity comes from `resource`). + const envelope: RawGatewayEnvelope = { + id: 'evt_1', + workspace: 'ws-1', + type: 'github.pull_request.opened', + occurredAt: '2026-06-04T09:00:00.000Z', + attempt: 1, + name: '', + cron: '', + provider: 'github', + eventType: 'pull_request.opened', + deliveryId: 'gh-123', + paths: ['/github/repos/a/b/pulls/1'], + resource: { action: 'opened' }, + summary: { title: 'x' }, + resumeContext: { phase: 2 }, + }; + const event = shimEnvelope(envelope); + assert.ok(event); + if (!event) return; + assert.equal(event.source, 'github'); + if (event.source !== 'github') return; + assert.equal(event.type, 'pull_request.opened'); + assert.deepEqual(event.payload, { action: 'opened' }); +}); diff --git a/packages/runtime/src/shim.ts b/packages/runtime/src/shim.ts index c854b7a9..a53e248c 100644 --- a/packages/runtime/src/shim.ts +++ b/packages/runtime/src/shim.ts @@ -29,8 +29,48 @@ export interface RawGatewayEnvelope { name?: string; /** Cron-only: the schedule's cron expression. */ cron?: string; + /** Provider id cloud derived for the event (e.g. "github"). */ + provider?: string; + /** Provider-qualified event name as cloud received it. */ + eventType?: string; + /** Upstream webhook delivery id, when the provider supplied one. */ + deliveryId?: string; + /** Relayfile paths the event touched (drives event-scoped mount sync). */ + paths?: string[]; + /** Opaque resume token for multi-phase deliveries (pr-reviewer resume). */ + resumeContext?: unknown; } +/** + * Every field cloud's `buildEnvelope` can emit on a delivered envelope. + * MUST stay in lockstep with the checked-in contract copy in + * `envelope-fields.cloud.ts` (source: cloud + * `packages/web/lib/proactive-runtime/deployment-trigger-delivery.ts` + * `ENVELOPE_FIELDS`, cloud#1841) — `shim.contract.test.ts` fails on drift, + * and a `satisfies` check below fails compilation if a listed field is not + * actually declared on `RawGatewayEnvelope`. + */ +export const RAW_GATEWAY_ENVELOPE_FIELDS = [ + 'id', + 'workspace', + 'type', + 'occurredAt', + 'attempt', + 'name', + 'cron', + 'resource', + 'provider', + 'eventType', + 'deliveryId', + 'paths', + 'summary', + 'resumeContext', + // Declared on the frame but never emitted by cloud's buildEnvelope — + // kept for older gateway shapes; not part of the cloud contract. + 'expand', + 'digest', +] as const satisfies readonly (keyof RawGatewayEnvelope)[]; + type ProviderSource = Exclude; const PROVIDER_SOURCES: ReadonlySet = new Set([