diff --git a/README.md b/README.md index 44fbed18..db184479 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,44 @@ The example searches Brave on a weekly cron schedule, clusters findings, and upserts a GitHub issue. See [`examples/weekly-digest`](./examples/weekly-digest/). +## Simulate an invocation (dry-run the handler) + +`deploy --dry-run` validates the persona and exits **without invoking the +handler**. To answer "if this agent received this event, what would happen?", +use `invoke`: it executes the handler against fixture event envelope(s) with +**every external side effect recorded, not executed** — no harness spawn, no +shell, no cloud writes, no scheduling. + +```bash +agentworkforce invoke ./persona.json --fixture ./event.json +``` + +The fixture is a raw gateway envelope (the runner's stdin line shape): a +single JSON object, a JSON array, or NDJSON — one envelope per line: + +```json +{ "id": "e1", "workspace": "ws-dev", "type": "cron.tick", + "occurredAt": "2026-06-03T09:00:00Z", "name": "weekly", "cron": "0 9 * * 6" } +``` + +Provider events use `"type": "."` (e.g. +`github.pull_request.opened`) with the payload under `"resource"`. + +`invoke` prints a human summary to stderr and a machine-readable run record +to stdout (or `--output run.json`). One record is emitted per envelope, in +the same compact shape Cloud's hosted run API serves (`runId`, `status`, +`exitCode`, `summary`, `error`, timings, `trigger`, `failureClass`, `logs`) +with `origin: "local_dry_run"`, so simulated runs can later be ingested and +displayed alongside hosted ones. Captured `ctx.log(...)` output lands in +`logs.stdout`/`logs.stderr`; every intercepted call (`harness.run`, +`sandbox.exec`, `memory.save`, `workflow.run`, `schedule.at`, …) is listed +under `simulation.sideEffects` with the inert result the handler received. + +Useful flags: `--input KEY=value` overrides declared persona inputs; +`--seed /slack/channels/_index.json=./channels.json` seeds the simulated +filesystem with provider data the handler reads. Exit code is 0 when every +envelope succeeded, 1 when any handler invocation failed. + ## Persona vs agent A deployable agent is two files. The **persona** says *what the agent is* diff --git a/packages/cli/package.json b/packages/cli/package.json index f4c55010..c2af5998 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,6 +13,7 @@ "@agent-relay/cloud": "^6.0.17", "@agentworkforce/deploy": "workspace:*", "@agentworkforce/persona-kit": "workspace:*", + "@agentworkforce/runtime": "workspace:*", "@agentworkforce/workload-router": "workspace:*", "@relayburn/sdk": "^2.5.2", "@relayfile/local-mount": "^0.7.24", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1feb3fa1..2c4ac04f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -71,6 +71,7 @@ import { } from '@relayfile/local-mount'; import ora, { type Ora } from 'ora'; import { runDeploy, runLogin, runLogout } from './deploy-command.js'; +import { runInvoke } from './invoke-command.js'; import { runDestroy } from './destroy-command.js'; import { runDeploymentList, runDeploymentLogs } from './list-command.js'; import { @@ -243,6 +244,20 @@ Commands: --cloud-url override the workforce cloud URL --input KEY=value override a declared persona input (repeat for multiple) + invoke --fixture [flags] + Simulate an invocation: run the persona's handler + against fixture event envelope(s) with every external + side effect recorded, NOT executed. Emits a + Cloud-compatible run record (origin "local_dry_run") + to stdout. Distinct from \`deploy --dry-run\`, which + validates without invoking the handler. + Flags: + --fixture JSON envelope, JSON array, or + NDJSON of raw gateway envelopes + --output write the run record to a file + --input KEY=value override a declared persona input + --seed PATH=file seed the simulated filesystem + --workspace workspace id for the simulated ctx deployments list List deployed cloud agents in the active workspace. deployments logs Show structured logs for a deployed cloud agent. destroy [flags] @@ -4368,6 +4383,11 @@ export async function main(): Promise { return; } + if (subcommand === 'invoke') { + await runInvoke(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 new file mode 100644 index 00000000..a10a3a23 --- /dev/null +++ b/packages/cli/src/invoke-command.test.ts @@ -0,0 +1,328 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + extractBundleHandler, + parseFixtureEnvelopes, + parseInvokeArgs, + renderHumanSummary, + runInvoke, + type RunInvokeIO +} from './invoke-command.js'; +import type { SimulationResult } from '@agentworkforce/runtime'; + +// --------------------------------------------------------------------------- +// parseInvokeArgs + +test('parseInvokeArgs: persona + fixture + repeatable flags', () => { + const parsed = parseInvokeArgs([ + './persona.json', + '--fixture', + './event.json', + '--input', + 'SLACK_CHANNEL=#general', + '--input=REGION=eu', + '--seed', + '/slack/channels/_index.json=./channels.json', + '--workspace', + 'ws-123', + '--output', + './run.json' + ]); + assert.ok(!('help' in parsed)); + if ('help' 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'); + assert.deepEqual(parsed.inputs, { SLACK_CHANNEL: '#general', REGION: 'eu' }); + assert.deepEqual(parsed.seeds, { '/slack/channels/_index.json': './channels.json' }); + assert.equal(parsed.workspaceId, 'ws-123'); +}); + +test('parseInvokeArgs: -h returns help sentinel', () => { + assert.deepEqual(parseInvokeArgs(['-h']), { help: true }); +}); + +test('parseInvokeArgs: missing persona path throws', () => { + assert.throws(() => parseInvokeArgs(['--fixture', 'x.json']), /missing persona path/); +}); + +test('parseInvokeArgs: missing --fixture throws', () => { + assert.throws(() => parseInvokeArgs(['./persona.json']), /missing --fixture/); +}); + +test('parseInvokeArgs: unknown flag throws', () => { + assert.throws( + () => parseInvokeArgs(['./persona.json', '--fixture', 'x.json', '--bogus']), + /unknown flag "--bogus"/ + ); +}); + +// --------------------------------------------------------------------------- +// parseFixtureEnvelopes + +const ENVELOPE = { + id: 'e1', + workspace: 'ws-test', + type: 'cron.tick', + occurredAt: '2026-05-12T09:00:00Z', + name: 'weekly', + cron: '0 9 * * 6' +}; + +test('parseFixtureEnvelopes: single JSON object (incl. pretty-printed)', () => { + const parsed = parseFixtureEnvelopes(JSON.stringify(ENVELOPE, null, 2), 'event.json'); + assert.equal(parsed.length, 1); + assert.equal(parsed[0].id, 'e1'); +}); + +test('parseFixtureEnvelopes: JSON array', () => { + const parsed = parseFixtureEnvelopes(JSON.stringify([ENVELOPE, { ...ENVELOPE, id: 'e2' }]), 'events.json'); + assert.deepEqual(parsed.map((e) => e.id), ['e1', 'e2']); +}); + +test('parseFixtureEnvelopes: NDJSON lines', () => { + const ndjson = `${JSON.stringify(ENVELOPE)}\n${JSON.stringify({ ...ENVELOPE, id: 'e2' })}\n`; + const parsed = parseFixtureEnvelopes(ndjson, 'events.ndjson'); + assert.deepEqual(parsed.map((e) => e.id), ['e1', 'e2']); +}); + +test('parseFixtureEnvelopes: empty / malformed inputs throw with the label', () => { + assert.throws(() => parseFixtureEnvelopes(' ', 'empty.json'), /empty\.json is empty/); + assert.throws(() => parseFixtureEnvelopes('not json', 'bad.json'), /must be a JSON envelope object/); + assert.throws(() => parseFixtureEnvelopes('[{"id":"x"}, 42]', 'mixed.json'), /mixed\.json\[1\] must be a JSON object/); + assert.throws( + () => parseFixtureEnvelopes(`${JSON.stringify(ENVELOPE)}\nnot-json\n`, 'lines.ndjson'), + /lines\.ndjson:2 is not valid JSON/ + ); +}); + +// --------------------------------------------------------------------------- +// extractBundleHandler — mirrors the generated runner.mjs extraction + +test('extractBundleHandler: defineAgent default export', () => { + const handler = async () => {}; + const extracted = extractBundleHandler( + { default: { __workforceAgent: true, handler, schedules: [] } }, + 'p1' + ); + assert.equal(extracted, handler); +}); + +test('extractBundleHandler: plain { handler } object', () => { + const handler = async () => {}; + assert.equal(extractBundleHandler({ default: { handler } }, 'p2'), handler); +}); + +test('extractBundleHandler: bare function fallback', () => { + const handler = async () => {}; + assert.equal(extractBundleHandler({ default: handler }, 'p3'), handler); +}); + +test('extractBundleHandler: non-function throws the defineAgent hint', () => { + assert.throws(() => extractBundleHandler({ default: { nope: true } }, 'p4'), /defineAgent/); +}); + +// --------------------------------------------------------------------------- +// End-to-end: real preflight → real esbuild bundle → simulateInvocation. +// +// The temp persona lives INSIDE packages/cli (not os.tmpdir) on purpose: the +// staged bundle leaves `@agentworkforce/runtime` external, so Node must be +// able to walk up from the bundle to a node_modules that provides it — same +// constraint deploy's `.workforce/build/` placement satisfies. + +const E2E_PERSONA = { + id: 'invoke-e2e', + intent: 'documentation', + tags: ['documentation'], + description: 'invoke e2e persona', + harness: 'claude', + model: 'anthropic/claude-3-5-sonnet', + systemPrompt: 'be helpful', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + cloud: true, + onEvent: './agent.ts' +}; + +const E2E_AGENT_SRC = `import { defineAgent } from '@agentworkforce/runtime'; +export default defineAgent({ + schedules: [{ name: 'weekly', cron: '0 9 * * 6' }], + handler: async (ctx, event) => { + ctx.log('info', 'e2e handler ran', { id: event.id }); + await ctx.memory.save('e2e note'); + if (event.source === 'cron' && event.name === 'explode') { + throw new Error('fixture asked for failure'); + } + return 'weekly digest sent'; + } +}); +`; + +function collectingIO(): RunInvokeIO & { out: string[]; err: string[] } { + const out: string[] = []; + const err: string[] = []; + return { + out, + err, + stdout: (text: string) => { + out.push(text); + }, + stderr: (text: string) => { + err.push(text); + } + }; +} + +async function withE2EPersona( + fn: (dir: string, personaPath: string) => Promise +): Promise { + const dir = await mkdtemp(path.join(process.cwd(), '.invoke-e2e-')); + const personaPath = path.join(dir, 'persona.json'); + try { + await writeFile(personaPath, JSON.stringify(E2E_PERSONA, null, 2), 'utf8'); + await writeFile(path.join(dir, 'agent.ts'), E2E_AGENT_SRC, 'utf8'); + return await fn(dir, personaPath); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test('runInvoke e2e: bundles, simulates, emits Cloud-compatible record', async () => { + await withE2EPersona(async (dir, personaPath) => { + const fixturePath = path.join(dir, 'event.json'); + await writeFile(fixturePath, JSON.stringify(ENVELOPE), 'utf8'); + + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([personaPath, '--fixture', fixturePath], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result, `expected a result; stderr: ${io.err.join('')}`); + assert.equal(exitCode, 0); + assert.equal(result.summary.total, 1); + assert.equal(result.runs[0].status, 'succeeded'); + assert.equal(result.runs[0].origin, 'local_dry_run'); + assert.equal(result.runs[0].summary, 'weekly digest sent'); + assert.equal(result.runs[0].failureClass, 'success'); + // The memory.save the handler made was recorded, not executed. + assert.ok(result.runs[0].simulation.sideEffects.some((e) => e.kind === 'memory.save')); + // ctx.log landed in the record's stdout stream. + assert.match(result.runs[0].logs.stdout, /e2e handler ran/); + + // stdout got the machine record; stderr got the human summary. + const machine = JSON.parse(io.out.join('')) as SimulationResult; + assert.equal(machine.runs[0].runId, result.runs[0].runId); + assert.match(io.err.join(''), /1 run\(s\) — 1 ok, 0 failed/); + + // Build dir was cleaned up. + const { readdir } = await import('node:fs/promises'); + const buildRoot = path.join(dir, '.workforce', 'invoke-build'); + const leftover = await readdir(buildRoot).catch(() => []); + assert.deepEqual(leftover, []); + }); +}); + +test('runInvoke e2e: handler failure → exit 1, error in record', async () => { + await withE2EPersona(async (dir, personaPath) => { + const fixturePath = path.join(dir, 'events.ndjson'); + const failing = { ...ENVELOPE, id: 'e-fail', name: 'explode' }; + await writeFile( + fixturePath, + `${JSON.stringify(failing)}\n${JSON.stringify(ENVELOPE)}\n`, + 'utf8' + ); + + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([personaPath, '--fixture', fixturePath], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result, `expected a result; stderr: ${io.err.join('')}`); + assert.equal(exitCode, 1); + assert.equal(result.summary.failed, 1); + assert.equal(result.summary.succeeded, 1); + assert.equal(result.runs[0].status, 'failed'); + assert.equal(result.runs[0].error, 'fixture asked for failure'); + assert.equal(result.runs[0].failureClass, 'runner_error'); + assert.equal(result.runs[1].status, 'succeeded'); + }); +}); + +test('runInvoke e2e: --output writes the record to a file', async () => { + await withE2EPersona(async (dir, personaPath) => { + const fixturePath = path.join(dir, 'event.json'); + const outputPath = path.join(dir, 'run.json'); + await writeFile(fixturePath, JSON.stringify(ENVELOPE), 'utf8'); + + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke( + [personaPath, '--fixture', fixturePath, '--output', outputPath], + io + ); + process.exitCode = previousExitCode; + + assert.ok(result); + assert.equal(io.out.join(''), ''); + const { readFile } = await import('node:fs/promises'); + const written = JSON.parse(await readFile(outputPath, 'utf8')) as SimulationResult; + assert.equal(written.origin, 'local_dry_run'); + assert.match(io.err.join(''), /run record written to/); + }); +}); + +test('runInvoke: usage error prints usage and sets exit 1, no throw', async () => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke(['--fixture', 'x.json'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.equal(result, undefined); + assert.equal(exitCode, 1); + assert.match(io.err.join(''), /missing persona path/); + assert.match(io.err.join(''), /usage: agentworkforce invoke/); +}); + +// --------------------------------------------------------------------------- +// renderHumanSummary + +test('renderHumanSummary: lists runs with status, side-effect count, skips', () => { + const summary = renderHumanSummary({ + origin: 'local_dry_run', + mode: 'simulate', + startedAt: 't0', + endedAt: 't1', + durationMs: 5, + runs: [ + { + runId: 'sim_run_1', + deploymentId: 'd', + agentId: 'a', + status: 'failed', + exitCode: 1, + summary: null, + error: 'kaboom', + startedAt: 't0', + endedAt: 't1', + durationMs: 5, + trigger: { kind: 'clock', eventSource: 'cron' }, + sandbox: { id: null, name: 'local-simulation' }, + failureClass: 'runner_error', + origin: 'local_dry_run', + logs: { stdout: '', stderr: '', mountLogTail: '', stdoutTruncated: false, stderrTruncated: false }, + simulation: { mode: 'simulate', sideEffects: [], capturedLogs: [] } + } + ], + unsupported: [{ id: 'e9', type: 'mystery.event' }], + summary: { total: 1, succeeded: 0, failed: 1, unsupported: 1 }, + exitCode: 1 + }); + assert.match(summary, /1 run\(s\) — 0 ok, 1 failed, 1 unsupported/); + assert.match(summary, /\[FAIL\] cron\/clock sim_run_1/); + assert.match(summary, /error: kaboom/); + assert.match(summary, /\[skip\] unsupported envelope e9 \(mystery\.event\)/); +}); diff --git a/packages/cli/src/invoke-command.ts b/packages/cli/src/invoke-command.ts new file mode 100644 index 00000000..ac99375e --- /dev/null +++ b/packages/cli/src/invoke-command.ts @@ -0,0 +1,371 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { bundleStager, preflightPersona } from '@agentworkforce/deploy'; +import { + simulateInvocation, + type RawGatewayEnvelope, + type SimulationResult, + type WorkforceHandler +} from '@agentworkforce/runtime'; + +export const INVOKE_USAGE = `usage: agentworkforce invoke --fixture [flags] + +Simulate an invocation: execute the persona's handler against fixture event +envelope(s) with every external side effect recorded, NOT executed, and emit +a machine-readable run record per envelope (Cloud-compatible shape, +origin "local_dry_run"). + +This is distinct from \`agentworkforce deploy --dry-run\`, which validates the +persona/config and exits without ever invoking the handler. + +Flags: + --fixture Event fixture (required): a JSON envelope object, + a JSON array of envelopes, or NDJSON (one envelope + per line). Envelope shape: the runner's + RawGatewayEnvelope (id, workspace, type, + occurredAt, resource?, name?/cron? for cron). + --output Write the run-record JSON to ; a human + summary still prints to stderr. Default: stdout. + --input = Override a declared persona input (repeatable). + --seed = Seed the simulated filesystem: VFS gets + 's contents (repeatable). Use for provider + VFS data the handler reads (e.g. + /slack/channels/_index.json). + --workspace Workspace id for the simulated ctx; defaults to + the first envelope's workspace. + -h, --help Print this message. + +Exit code: 0 when every dispatched envelope succeeded, 1 when any handler +invocation failed (or on usage/setup errors). +`; + +export interface InvokeOptions { + personaPath: string; + fixturePath: string; + outputPath?: string; + inputs?: Record; + /** VFS path → local file whose contents seed it. */ + seeds?: Record; + workspaceId?: string; +} + +export type ParsedInvokeArgs = InvokeOptions | { help: true }; + +/** Parse `invoke` args. Throws on usage errors (caller maps to exit 1). */ +export function parseInvokeArgs(args: readonly string[]): ParsedInvokeArgs { + let personaPath: string | undefined; + let fixturePath: string | undefined; + let outputPath: string | undefined; + let workspaceId: string | undefined; + const inputs: Record = {}; + const seeds: Record = {}; + + for (let i = 0; i < args.length; i += 1) { + const a = args[i]; + if (a === '-h' || a === '--help') { + return { help: true }; + } else if (a === '--fixture') { + fixturePath = expectValue('--fixture', args[++i]); + } else if (a.startsWith('--fixture=')) { + 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 === '--workspace') { + workspaceId = expectValue('--workspace', args[++i]); + } else if (a.startsWith('--workspace=')) { + workspaceId = expectInline('--workspace', a.slice('--workspace='.length)); + } else if (a === '--input') { + addKeyValue('--input', expectValue('--input', args[++i]), inputs); + } else if (a.startsWith('--input=')) { + addKeyValue('--input', expectInline('--input', a.slice('--input='.length)), inputs); + } else if (a === '--seed') { + addKeyValue('--seed', expectValue('--seed', args[++i]), seeds); + } else if (a.startsWith('--seed=')) { + addKeyValue('--seed', expectInline('--seed', a.slice('--seed='.length)), seeds); + } else if (a.startsWith('--')) { + throw new Error(`invoke: unknown flag "${a}"`); + } else if (!personaPath) { + personaPath = path.resolve(a); + } else { + throw new Error(`invoke: unexpected positional argument "${a}"`); + } + } + + if (!personaPath) { + throw new Error('invoke: missing persona path. Usage: agentworkforce invoke --fixture '); + } + if (!fixturePath) { + throw new Error('invoke: missing --fixture (a JSON envelope, JSON array, or NDJSON file)'); + } + + return { + personaPath, + fixturePath: path.resolve(fixturePath), + ...(outputPath ? { outputPath: path.resolve(outputPath) } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(Object.keys(inputs).length > 0 ? { inputs } : {}), + ...(Object.keys(seeds).length > 0 ? { seeds } : {}) + }; +} + +function expectValue(flag: string, value: string | undefined): string { + if (value === undefined || value.startsWith('--')) { + throw new Error(`invoke: ${flag} expects a value`); + } + return value; +} + +function expectInline(flag: string, value: string): string { + if (!value) throw new Error(`invoke: ${flag} expects a value`); + return value; +} + +function addKeyValue(flag: string, raw: string, into: Record): void { + const eq = raw.indexOf('='); + if (eq <= 0) { + throw new Error(`invoke: ${flag} expects =; got "${raw}"`); + } + into[raw.slice(0, eq)] = raw.slice(eq + 1); +} + +/** + * Parse fixture file contents into raw envelopes. Accepts a single JSON + * object, a JSON array of objects, or NDJSON (one JSON object per line). + * Validation of envelope semantics happens downstream via the runner's own + * `shimEnvelope`; this only enforces "every entry is an object". + */ +export function parseFixtureEnvelopes(raw: string, label: string): RawGatewayEnvelope[] { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error(`invoke: fixture ${label} is empty`); + } + + if (trimmed.startsWith('[')) { + const parsed = parseJson(trimmed, label) as unknown; + if (!Array.isArray(parsed)) { + throw new Error(`invoke: fixture ${label} starts with "[" but is not a JSON array`); + } + return parsed.map((entry, index) => asEnvelopeObject(entry, `${label}[${index}]`)); + } + + if (trimmed.startsWith('{')) { + // Either a single JSON object (possibly pretty-printed across lines) or + // NDJSON. Try whole-file JSON first; fall back to per-line parsing. + try { + const single = JSON.parse(trimmed) as unknown; + return [asEnvelopeObject(single, label)]; + } catch { + /* fall through to NDJSON */ + } + return trimmed + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line, index) => + asEnvelopeObject(parseJson(line, `${label}:${index + 1}`), `${label}:${index + 1}`) + ); + } + + throw new Error( + `invoke: fixture ${label} must be a JSON envelope object, a JSON array, or NDJSON lines` + ); +} + +function parseJson(text: string, label: string): unknown { + try { + return JSON.parse(text); + } catch (err) { + throw new Error( + `invoke: fixture ${label} is not valid JSON: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +function asEnvelopeObject(value: unknown, label: string): RawGatewayEnvelope { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`invoke: fixture ${label} must be a JSON object envelope`); + } + return value as RawGatewayEnvelope; +} + +/** + * Extract the handler from a dynamically imported agent bundle. Mirrors the + * extraction the generated runner.mjs performs (deploy bundle.ts + * renderRunner): defineAgent default export → `.handler`; `{ handler }` + * object → `.handler`; bare function fallback. + */ +export function extractBundleHandler( + userModule: Record, + personaId: string +): WorkforceHandler { + const exported = (userModule.default ?? userModule.handler) as + | { __workforceAgent?: boolean; handler?: unknown } + | ((...args: unknown[]) => unknown) + | undefined; + + let candidate: unknown; + if (exported && typeof exported === 'object' && exported.__workforceAgent) { + candidate = exported.handler; + } else if (exported && typeof exported === 'object' && typeof exported.handler === 'function') { + candidate = exported.handler; + } else { + candidate = exported; + } + + if (typeof candidate !== 'function') { + throw new Error( + `invoke: ${personaId} did not default-export defineAgent({ ..., handler }). Did you forget \`export default defineAgent(...)\`?` + ); + } + return candidate as WorkforceHandler; +} + +export interface RunInvokeIO { + stdout: (text: string) => void; + stderr: (text: string) => void; +} + +const defaultIO: RunInvokeIO = { + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text) +}; + +/** + * `agentworkforce invoke` entry. Stages the agent bundle next to the + * persona (under `.workforce/invoke-build/`, mirroring deploy's build dir + * so the externalized `@agentworkforce/runtime` import resolves from the + * persona's project tree), imports the handler, replays the fixture through + * `simulateInvocation`, prints a human summary to stderr and the + * machine-readable record to stdout (or `--output`). + * + * Sets `process.exitCode` (never calls `process.exit`) so streams flush + * and tests can call this directly. + */ +export async function runInvoke( + args: readonly string[], + io: RunInvokeIO = defaultIO +): Promise { + let opts: ParsedInvokeArgs; + try { + opts = parseInvokeArgs(args); + } catch (err) { + io.stderr(`${err instanceof Error ? err.message : String(err)}\n\n${INVOKE_USAGE}`); + process.exitCode = 1; + return undefined; + } + if ('help' in opts) { + io.stdout(INVOKE_USAGE); + return undefined; + } + + try { + return await runInvokeWithOptions(opts, io); + } catch (err) { + io.stderr(`invoke: ${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; + return undefined; + } +} + +async function runInvokeWithOptions( + opts: InvokeOptions, + io: RunInvokeIO +): Promise { + const preflight = await preflightPersona(opts.personaPath); + for (const warning of preflight.warnings) { + io.stderr(`warn: ${warning}\n`); + } + + // Stage inside the persona's tree (not os.tmpdir) — the bundle leaves + // `@agentworkforce/runtime` external, so Node must be able to walk up + // from the bundle to a node_modules that provides it, exactly like + // deploy's `.workforce/build/` convention. + const buildRoot = path.join(preflight.personaDir, '.workforce', 'invoke-build'); + await mkdir(buildRoot, { recursive: true }); + const buildDir = await mkdtemp(path.join(buildRoot, `${preflight.persona.id}-`)); + + try { + const bundle = await bundleStager.stage({ + personaPath: preflight.personaPath, + persona: preflight.persona, + outDir: buildDir + }); + + // Cache-bust the import so repeated invokes in one process (tests, + // watch flows) load the freshly staged bundle, not the ESM cache. + const bundleUrl = `${pathToFileURL(bundle.bundlePath).href}?invoke=${randomUUID()}`; + const userModule = (await import(bundleUrl)) as Record; + const handler = extractBundleHandler(userModule, preflight.persona.id); + + const fixtureRaw = await readFile(opts.fixturePath, 'utf8').catch((err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + throw new Error(`fixture not found at ${opts.fixturePath}`); + } + throw err; + }); + const envelopes = parseFixtureEnvelopes(fixtureRaw, path.basename(opts.fixturePath)); + + const seeds = await loadSeeds(opts.seeds); + + const result = await simulateInvocation({ + persona: preflight.persona, + handler, + envelopes, + ...(opts.workspaceId ? { workspaceId: opts.workspaceId } : {}), + ...(opts.inputs ? { agent: { inputValues: opts.inputs } } : {}), + ...(seeds ? { files: seeds } : {}) + }); + + io.stderr(renderHumanSummary(result)); + + const json = `${JSON.stringify(result, null, 2)}\n`; + if (opts.outputPath) { + await writeFile(opts.outputPath, json, 'utf8'); + io.stderr(`run record written to ${opts.outputPath}\n`); + } else { + io.stdout(json); + } + + process.exitCode = result.exitCode; + return result; + } finally { + await rm(buildDir, { recursive: true, force: true }); + } +} + +async function loadSeeds( + seeds: Record | undefined +): Promise | undefined> { + if (!seeds) return undefined; + const loaded: Record = {}; + for (const [vfsPath, localFile] of Object.entries(seeds)) { + loaded[vfsPath] = await readFile(path.resolve(localFile), 'utf8').catch( + (err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + throw new Error(`--seed ${vfsPath}: local file not found at ${localFile}`); + } + throw err; + } + ); + } + return loaded; +} + +export function renderHumanSummary(result: SimulationResult): string { + const lines: string[] = []; + lines.push(`simulation: ${result.summary.total} run(s) — ${result.summary.succeeded} ok, ${result.summary.failed} failed${result.summary.unsupported > 0 ? `, ${result.summary.unsupported} unsupported envelope(s) skipped` : ''}`); + for (const run of result.runs) { + const head = ` [${run.status === 'succeeded' ? 'ok' : 'FAIL'}] ${run.trigger.eventSource}/${run.trigger.kind} ${run.runId} (${run.durationMs}ms, ${run.simulation.sideEffects.length} side effect(s) recorded)`; + lines.push(head); + if (run.summary) lines.push(` summary: ${run.summary}`); + if (run.error) lines.push(` error: ${run.error}`); + } + for (const skipped of result.unsupported) { + lines.push(` [skip] unsupported envelope ${skipped.id} (${skipped.type})`); + } + return `${lines.join('\n')}\n`; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index a50552d6..57892a47 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -49,6 +49,10 @@ export { unwrapResourceRecord } from './types.js'; +// Raw gateway envelope contract (the runner's stdin NDJSON line shape, and +// the fixture format for invocation simulation). +export { shimEnvelope, type RawGatewayEnvelope } from './shim.js'; + export type { LinearAgentActivity, LinearAgentActivityType, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e4cd46f..02c3c37e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@agentworkforce/persona-kit': specifier: workspace:* version: link:../persona-kit + '@agentworkforce/runtime': + specifier: workspace:* + version: link:../runtime '@agentworkforce/workload-router': specifier: workspace:* version: link:../workload-router