diff --git a/packages/cli/src/cli-impl.ts b/packages/cli/src/cli-impl.ts index 3a7570d8..8cf194ed 100644 --- a/packages/cli/src/cli-impl.ts +++ b/packages/cli/src/cli-impl.ts @@ -271,10 +271,19 @@ Commands: Discover workspace integrations, connection status, and known trigger events. JSON output includes registration health when the cloud status API provides it. - trigger [flags] + trigger [payload-json] [flags] Manually fire an active deployed persona for testing. The selector accepts agent id, compact agent id, - deployed name, persona slug, or persona id. Flags: + deployed name, persona slug, or persona id. A payload is + optional; without one the agent gets a contentless fire, + which is what a schedule looks like. Flags: + --payload JSON object sent as the event + payload + --payload-file

Read the payload from a file, or + "-" for stdin + --idempotency-key + Retrying with the same key returns + the original run --workspace Workforce workspace; defaults to the active workspace --cloud-url Override the cloud base URL diff --git a/packages/cli/src/trigger-command.test.ts b/packages/cli/src/trigger-command.test.ts index 4108e703..7eb1ef74 100644 --- a/packages/cli/src/trigger-command.test.ts +++ b/packages/cli/src/trigger-command.test.ts @@ -1,10 +1,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { buildTriggerUrl, formatTriggerAuthHint, formatTriggerResult, parseTriggerArgs, + TRIGGER_USAGE, + buildTriggerRequest, + resolvePayload, parseTriggerResponse } from './trigger-command.js'; @@ -115,3 +121,109 @@ test('buildTriggerUrl preserves cloud base paths', () => { 'https://agentrelay.com/cloud/api/v1/workspaces/rw_123/deployments/agent-1/trigger' ); }); + +// ── payload ─────────────────────────────────────────────────────────────────── + +const quietIO = { stdout: () => {}, stderr: () => {} }; + +test('parseTriggerArgs takes a payload from a flag or a trailing JSON positional', () => { + const viaFlag = parseTriggerArgs(['app-signal', '--payload', '{"accountId":"acme"}']); + const viaPositional = parseTriggerArgs(['app-signal', '{"accountId":"acme"}']); + for (const parsed of [viaFlag, viaPositional]) { + assert.ok(!('help' in parsed)); + assert.equal(parsed.selector, 'app-signal'); + assert.deepEqual(parsed.payloadSource, { kind: 'inline', value: '{"accountId":"acme"}' }); + } +}); + +test('parseTriggerArgs still rejects a non-JSON extra positional', () => { + // The trailing-JSON form must not turn every stray argument into a payload — + // only something starting with a brace, which no selector can. + assert.throws(() => parseTriggerArgs(['app-signal', 'oops']), /unexpected positional argument "oops"/); +}); + +test('parseTriggerArgs accepts "-" as a payload file without reading it', () => { + // Parsing stays side-effect free: "-" means stdin, resolved later. The usual + // "flag expects a value" guard would otherwise reject the lone dash. + const parsed = parseTriggerArgs(['app-signal', '--payload-file', '-']); + assert.ok(!('help' in parsed)); + assert.deepEqual(parsed.payloadSource, { kind: 'file', value: '-' }); + assert.deepEqual( + (parseTriggerArgs(['app-signal', '--payload-file=-']) as { payloadSource?: unknown }).payloadSource, + { kind: 'file', value: '-' } + ); + // Both spellings guard alike — the equals form previously skipped the path + // check and would have gone looking for a file called "--json". + assert.throws(() => parseTriggerArgs(['app-signal', '--payload-file', '--json']), /--payload-file expects a value/); + assert.throws(() => parseTriggerArgs(['app-signal', '--payload-file=--json']), /--payload-file expects a value/); +}); + +test('parseTriggerArgs carries an idempotency key', () => { + const parsed = parseTriggerArgs(['app-signal', '--idempotency-key', 'evt-8412']); + assert.ok(!('help' in parsed)); + assert.equal(parsed.idempotencyKey, 'evt-8412'); +}); + +test('resolvePayload reads inline JSON and a file', async () => { + assert.deepEqual( + await resolvePayload({ kind: 'inline', value: '{"accountId":"acme"}' }, quietIO), + { accountId: 'acme' } + ); + const dir = await mkdtemp(join(tmpdir(), 'trigger-payload-')); + const file = join(dir, 'signal.json'); + await writeFile(file, '{"accountId":"acme","reason":"usage_spike"}', 'utf8'); + assert.deepEqual(await resolvePayload({ kind: 'file', value: file }, quietIO), { + accountId: 'acme', + reason: 'usage_spike' + }); +}); + +test('resolvePayload rejects anything a handler could not read', async () => { + // Cloud wraps the body as `{source:"app.trigger", payload:}` and + // handlers read named fields off it, so an array or scalar would be accepted + // with a 202 and then do nothing. Fail before the request instead. + await assert.rejects(() => resolvePayload({ kind: 'inline', value: '[1,2]' }, quietIO), /must be a JSON object/); + await assert.rejects(() => resolvePayload({ kind: 'inline', value: '"hi"' }, quietIO), /must be a JSON object/); + await assert.rejects(() => resolvePayload({ kind: 'inline', value: '{oops}' }, quietIO), /not valid JSON/); + await assert.rejects(() => resolvePayload({ kind: 'inline', value: ' ' }, quietIO), /omit it entirely/i); + await assert.rejects( + () => resolvePayload({ kind: 'file', value: '/nope/missing.json' }, quietIO), + /could not read payload file/ + ); +}); + +test('resolvePayload returns undefined for a contentless fire', async () => { + assert.equal(await resolvePayload(undefined, quietIO), undefined); +}); + +test('buildTriggerRequest sends a body only when there is a payload', () => { + // The distinction that matters: no body is a contentless fire, which is what + // a schedule looks like. `{}` is an app trigger carrying an empty object. + const bare = buildTriggerRequest({}, 'tok'); + assert.equal(bare.body, undefined); + assert.equal((bare.headers as Record)['content-type'], undefined); + assert.equal((bare.headers as Record)['idempotency-key'], undefined); + + const withPayload = buildTriggerRequest( + { payload: { accountId: 'acme' }, idempotencyKey: 'evt-8412' }, + 'tok' + ); + assert.equal(withPayload.body, '{"accountId":"acme"}'); + const headers = withPayload.headers as Record; + assert.equal(headers['content-type'], 'application/json'); + assert.equal(headers['idempotency-key'], 'evt-8412'); + assert.equal(headers.authorization, 'Bearer tok'); + + // An explicitly empty object is still a payload, not a contentless fire. + assert.equal(buildTriggerRequest({ payload: {} }, 'tok').body, '{}'); +}); + +test('usage documents the payload flags', () => { + // The gap this closes: a bare trigger is a contentless fire, so an agent + // whose real work is payload-driven could not be exercised from the CLI at + // all. Anyone reading --help should see that. + for (const flag of ['--payload ', '--payload-file ', '--idempotency-key ']) { + assert.ok(TRIGGER_USAGE.includes(flag), `usage should document ${flag}`); + } + assert.match(TRIGGER_USAGE, /contentless fire/); +}); diff --git a/packages/cli/src/trigger-command.ts b/packages/cli/src/trigger-command.ts index 4e192d59..679da51c 100644 --- a/packages/cli/src/trigger-command.ts +++ b/packages/cli/src/trigger-command.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises'; import { formatHttpErrorBody } from '@agentworkforce/deploy'; import { fetchDeployments, @@ -5,15 +6,30 @@ import { resolveDeploymentRequestContext } from './list-command.js'; -export const TRIGGER_USAGE = `usage: agentworkforce trigger [flags] - agentworkforce deployments trigger [flags] +export const TRIGGER_USAGE = `usage: agentworkforce trigger [payload-json] [flags] + agentworkforce deployments trigger [payload-json] [flags] Manually fire an active deployed persona through the cloud trigger endpoint. The selector may be an agent id, compact agent id, deployed name, persona slug, or persona id. Use this to force a fresh run for testing without waiting for the persona's normal schedule or integration event. +A payload is optional. With one, this is the same call a product makes to wake +an agent with data: the JSON object reaches the handler as the event payload. +Without one the agent gets a contentless fire, which is what a schedule looks +like — so an agent whose real work is payload-driven cannot be exercised by a +bare trigger. + +Examples: + agentworkforce trigger app-signal '{"accountId":"acme","reason":"usage_spike"}' + agentworkforce trigger app-signal --payload-file ./signal.json + echo '{"accountId":"acme"}' | agentworkforce trigger app-signal --payload-file - + Flags: + --payload JSON object to send as the event payload. + --payload-file Read the payload JSON from a file, or "-" for stdin. + --idempotency-key Retrying with the same key returns the original run + instead of starting a second one. --workspace Workforce workspace; defaults to the active one. --cloud-url Override the workforce cloud base URL. --json Emit the trigger response JSON. @@ -23,13 +39,36 @@ Flags: export interface TriggerOptions { selector: string; + /** + * Parsed JSON object sent as the request body. Absent means a contentless + * fire — the endpoint treats a body-less POST and a `{}` body differently, + * so this stays undefined rather than defaulting to an empty object. + */ + payload?: Record; + /** Sent as `Idempotency-Key`; a repeat returns the original run. */ + idempotencyKey?: string; workspace?: string; cloudUrl?: string; json?: boolean; noPrompt?: boolean; } -export type ParsedTriggerArgs = TriggerOptions | { help: true }; +/** Where the payload comes from, before any I/O has happened. */ +export interface PayloadSource { + kind: 'inline' | 'file'; + value: string; +} + +/** + * Argument parsing stays synchronous and side-effect free, so it can be tested + * without a filesystem or stdin. Reading `--payload-file` is deferred to + * {@link resolvePayload}, which `runTrigger` calls before dispatching. + */ +export interface TriggerArgs extends Omit { + payloadSource?: PayloadSource; +} + +export type ParsedTriggerArgs = TriggerArgs | { help: true }; export interface TriggerResponse { agentId: string; @@ -54,11 +93,34 @@ export function parseTriggerArgs(args: readonly string[]): ParsedTriggerArgs { let cloudUrl: string | undefined; let json = false; let noPrompt = false; + let payloadSource: { kind: 'inline' | 'file'; value: string } | undefined; + let idempotencyKey: string | undefined; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (arg === '-h' || arg === '--help') { return { help: true }; + } else if (arg === '--payload') { + payloadSource = { kind: 'inline', value: expectValue('--payload', args[++i]) }; + } else if (arg.startsWith('--payload=')) { + payloadSource = { + kind: 'inline', + value: expectInlineValue('--payload', arg.slice('--payload='.length)) + }; + } else if (arg === '--payload-file') { + payloadSource = { kind: 'file', value: expectPathValue('--payload-file', args[++i]) }; + } else if (arg.startsWith('--payload-file=')) { + payloadSource = { + kind: 'file', + value: expectPathValue('--payload-file', arg.slice('--payload-file='.length)) + }; + } else if (arg === '--idempotency-key') { + idempotencyKey = expectValue('--idempotency-key', args[++i]); + } else if (arg.startsWith('--idempotency-key=')) { + idempotencyKey = expectInlineValue( + '--idempotency-key', + arg.slice('--idempotency-key='.length) + ); } else if (arg === '--workspace') { workspace = expectValue('--workspace', args[++i]); } else if (arg.startsWith('--workspace=')) { @@ -77,6 +139,11 @@ export function parseTriggerArgs(args: readonly string[]): ParsedTriggerArgs { selector = arg; } else if (arg.startsWith('-')) { throw new Error(`trigger: unknown flag "${arg}"`); + } else if (arg.trimStart().startsWith('{') && !payloadSource) { + // A trailing `{...}` is the payload. Unambiguous against a selector: an + // agent id, deployed name, persona slug and persona id can none of them + // start with a brace. + payloadSource = { kind: 'inline', value: arg }; } else { throw new Error(`trigger: unexpected positional argument "${arg}"`); } @@ -88,6 +155,8 @@ export function parseTriggerArgs(args: readonly string[]): ParsedTriggerArgs { return { selector, + ...(payloadSource ? { payloadSource } : {}), + ...(idempotencyKey ? { idempotencyKey } : {}), ...(workspace ? { workspace } : {}), ...(cloudUrl ? { cloudUrl } : {}), ...(json ? { json: true } : {}), @@ -113,7 +182,12 @@ export async function runTrigger( } try { - const result = await triggerDeployment(opts); + const payload = await resolvePayload(opts.payloadSource, io); + const { payloadSource: _ignored, ...rest } = opts; + const result = await triggerDeployment({ + ...rest, + ...(payload ? { payload } : {}) + }); if (opts.json) { io.stdout(`${JSON.stringify(result, null, 2)}\n`); } else { @@ -145,13 +219,7 @@ export async function triggerDeployment(opts: TriggerOptions): Promise }` and handlers read named fields + * off it, so an array or a bare scalar would arrive as something no handler can + * use. Rejecting it here beats a 202 followed by a run that silently does + * nothing. + */ +export async function resolvePayload( + source: PayloadSource | undefined, + io: TriggerIO +): Promise | undefined> { + if (!source) return undefined; + + const raw = + source.kind === 'inline' + ? source.value + : source.value === '-' + ? await readStdin(io) + : await readFile(source.value, 'utf8').catch((err: unknown) => { + throw new Error( + `could not read payload file "${source.value}": ${err instanceof Error ? err.message : String(err)}` + ); + }); + + const label = source.kind === 'file' ? `payload file "${source.value}"` : 'payload'; + if (!raw.trim()) { + throw new Error(`${label} is empty. Omit it entirely to send a contentless fire.`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`${label} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error( + `${label} must be a JSON object (got ${Array.isArray(parsed) ? 'an array' : typeof parsed}). ` + + 'The handler receives it as the event payload and reads named fields off it.' + ); + } + return parsed as Record; +} + +async function readStdin(io: TriggerIO): Promise { + if (process.stdin.isTTY) { + io.stderr('trigger: reading payload from stdin; end with Ctrl-D\n'); + } + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +/** + * Build the POST for the trigger endpoint. + * + * A body-less POST and a `{}` body are NOT the same thing to cloud: the first + * is a contentless fire (indistinguishable from a schedule firing), the second + * is an app trigger carrying an empty object. So Content-Type and body are + * attached only when there is a payload, never defaulted. + */ +export function buildTriggerRequest( + opts: Pick, + token: string +): RequestInit { + return { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'user-agent': 'agentworkforce-cli/trigger', + ...(opts.payload ? { 'content-type': 'application/json' } : {}), + ...(opts.idempotencyKey ? { 'idempotency-key': opts.idempotencyKey } : {}) + }, + ...(opts.payload ? { body: JSON.stringify(opts.payload) } : {}) + }; +} + export function formatTriggerAuthHint(ctx: { authSource?: 'env' | 'cloud-session'; workspace: string; @@ -227,6 +378,20 @@ function readString(record: Record, key: string): string | unde return typeof value === 'string' && value.trim() ? value.trim() : undefined; } +/** + * Like {@link expectValue} but allows a leading "-", so `--payload-file -` + * (stdin) is not mistaken for a missing value. + */ +function expectPathValue(flag: string, value: string | undefined): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`trigger: ${flag} expects a value`); + } + if (value !== '-' && value.startsWith('-')) { + throw new Error(`trigger: ${flag} expects a value`); + } + return value; +} + function expectValue(flag: string, value: string | undefined): string { if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) { throw new Error(`trigger: ${flag} expects a value`);