-
Notifications
You must be signed in to change notification settings - Fork 1
feat(cli): add deployed persona trigger command #247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+342
−2
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import test from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import { | ||
| buildTriggerUrl, | ||
| formatTriggerResult, | ||
| parseTriggerArgs, | ||
| parseTriggerResponse | ||
| } from './trigger-command.js'; | ||
|
|
||
| test('parseTriggerArgs accepts selector and cloud flags', () => { | ||
| assert.deepEqual( | ||
| parseTriggerArgs([ | ||
| 'hn-monitor', | ||
| '--workspace', | ||
| 'rw_123', | ||
| '--cloud-url=https://cloud.example.test', | ||
| '--json', | ||
| '--no-prompt' | ||
| ]), | ||
| { | ||
| selector: 'hn-monitor', | ||
| workspace: 'rw_123', | ||
| cloudUrl: 'https://cloud.example.test', | ||
| json: true, | ||
| noPrompt: true | ||
| } | ||
| ); | ||
| }); | ||
|
|
||
| test('parseTriggerArgs supports help sentinel', () => { | ||
| assert.deepEqual(parseTriggerArgs(['--help']), { help: true }); | ||
| }); | ||
|
|
||
| test('parseTriggerArgs rejects missing selector and extra positionals', () => { | ||
| assert.throws(() => parseTriggerArgs([]), /missing agent selector/); | ||
| assert.throws(() => parseTriggerArgs(['a', 'b']), /unexpected positional argument "b"/); | ||
| }); | ||
|
|
||
| test('parseTriggerArgs rejects unknown single-dash flags as flags, not selectors', () => { | ||
| assert.throws(() => parseTriggerArgs(['-x']), /unknown flag "-x"/); | ||
| }); | ||
|
|
||
| test('parseTriggerArgs does not swallow single-dash flags as option values', () => { | ||
| assert.throws(() => parseTriggerArgs(['hn-monitor', '--workspace', '-x']), /--workspace expects a value/); | ||
| assert.throws(() => parseTriggerArgs(['hn-monitor', '--cloud-url', '-x']), /--cloud-url expects a value/); | ||
| }); | ||
|
|
||
| test('parseTriggerResponse validates cloud trigger response shape', () => { | ||
| assert.deepEqual( | ||
| parseTriggerResponse( | ||
| { | ||
| agentId: 'agent-1', | ||
| workspaceId: 'rw_123', | ||
| deploymentId: 'deployment-1', | ||
| status: 'starting' | ||
| }, | ||
| 'hn-monitor' | ||
| ), | ||
| { | ||
| agentId: 'agent-1', | ||
| workspaceId: 'rw_123', | ||
| deploymentId: 'deployment-1', | ||
| status: 'starting' | ||
| } | ||
| ); | ||
|
|
||
| assert.throws( | ||
| () => parseTriggerResponse({ agentId: 'agent-1' }, 'hn-monitor'), | ||
| /incomplete response/ | ||
| ); | ||
| }); | ||
|
|
||
| test('formatTriggerResult prints a concise human summary', () => { | ||
| assert.equal( | ||
| formatTriggerResult({ | ||
| agentId: 'agent-1', | ||
| workspaceId: 'rw_123', | ||
| deploymentId: 'deployment-1', | ||
| status: 'starting' | ||
| }), | ||
| 'triggered: agent-1\ndeployment: deployment-1\nworkspace: rw_123\nstatus: starting\n' | ||
| ); | ||
| }); | ||
|
|
||
| test('buildTriggerUrl preserves cloud base paths', () => { | ||
| assert.equal( | ||
| buildTriggerUrl({ | ||
| cloudUrl: 'https://agentrelay.com/cloud', | ||
| workspace: 'rw_123', | ||
| agentId: 'agent-1' | ||
| }).toString(), | ||
| 'https://agentrelay.com/cloud/api/v1/workspaces/rw_123/deployments/agent-1/trigger' | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,223 @@ | ||
| import { formatHttpErrorBody } from '@agentworkforce/deploy'; | ||
| import { | ||
| fetchDeployments, | ||
| resolveAgentSelector, | ||
| resolveDeploymentRequestContext | ||
| } from './list-command.js'; | ||
|
|
||
| export const TRIGGER_USAGE = `usage: agentworkforce trigger <agent-name-or-id> [flags] | ||
| agentworkforce deployments trigger <agent-name-or-id> [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. | ||
|
|
||
| Flags: | ||
| --workspace <name> Workforce workspace; defaults to the active one. | ||
| --cloud-url <url> Override the workforce cloud base URL. | ||
| --json Emit the trigger response JSON. | ||
| --no-prompt Fail instead of prompting for login. | ||
| -h, --help Print this message. | ||
| `; | ||
|
|
||
| export interface TriggerOptions { | ||
| selector: string; | ||
| workspace?: string; | ||
| cloudUrl?: string; | ||
| json?: boolean; | ||
| noPrompt?: boolean; | ||
| } | ||
|
|
||
| export type ParsedTriggerArgs = TriggerOptions | { help: true }; | ||
|
|
||
| export interface TriggerResponse { | ||
| agentId: string; | ||
| workspaceId: string; | ||
| deploymentId: string; | ||
| status: string; | ||
| } | ||
|
|
||
| export interface TriggerIO { | ||
| stdout: (text: string) => void; | ||
| stderr: (text: string) => void; | ||
| } | ||
|
|
||
| const defaultIO: TriggerIO = { | ||
| stdout: (text) => process.stdout.write(text), | ||
| stderr: (text) => process.stderr.write(text) | ||
| }; | ||
|
|
||
| export function parseTriggerArgs(args: readonly string[]): ParsedTriggerArgs { | ||
| let selector: string | undefined; | ||
| let workspace: string | undefined; | ||
| let cloudUrl: string | undefined; | ||
| let json = false; | ||
| let noPrompt = false; | ||
|
|
||
| for (let i = 0; i < args.length; i += 1) { | ||
| const arg = args[i]; | ||
| if (arg === '-h' || arg === '--help') { | ||
| return { help: true }; | ||
| } else if (arg === '--workspace') { | ||
| workspace = expectValue('--workspace', args[++i]); | ||
| } else if (arg.startsWith('--workspace=')) { | ||
| workspace = expectInlineValue('--workspace', arg.slice('--workspace='.length)); | ||
| } else if (arg === '--cloud-url') { | ||
| cloudUrl = expectValue('--cloud-url', args[++i]); | ||
| } else if (arg.startsWith('--cloud-url=')) { | ||
| cloudUrl = expectInlineValue('--cloud-url', arg.slice('--cloud-url='.length)); | ||
| } else if (arg === '--json') { | ||
| json = true; | ||
| } else if (arg === '--no-prompt') { | ||
| noPrompt = true; | ||
| } else if (arg.startsWith('--')) { | ||
| throw new Error(`trigger: unknown flag "${arg}"`); | ||
| } else if (!arg.startsWith('-') && !selector) { | ||
| selector = arg; | ||
| } else if (arg.startsWith('-')) { | ||
| throw new Error(`trigger: unknown flag "${arg}"`); | ||
| } else { | ||
| throw new Error(`trigger: unexpected positional argument "${arg}"`); | ||
| } | ||
| } | ||
|
|
||
| if (!selector) { | ||
| throw new Error('trigger: missing agent selector. Usage: agentworkforce trigger <agent-name-or-id>'); | ||
| } | ||
|
|
||
| return { | ||
| selector, | ||
| ...(workspace ? { workspace } : {}), | ||
| ...(cloudUrl ? { cloudUrl } : {}), | ||
| ...(json ? { json: true } : {}), | ||
| ...(noPrompt ? { noPrompt: true } : {}) | ||
| }; | ||
| } | ||
|
|
||
| export async function runTrigger( | ||
| args: readonly string[], | ||
| io: TriggerIO = defaultIO | ||
| ): Promise<void> { | ||
| let opts: ParsedTriggerArgs; | ||
| try { | ||
| opts = parseTriggerArgs(args); | ||
| } catch (err) { | ||
| io.stderr(`${err instanceof Error ? err.message : String(err)}\n\n${TRIGGER_USAGE}`); | ||
| process.exitCode = 1; | ||
| return; | ||
| } | ||
| if ('help' in opts) { | ||
| io.stdout(TRIGGER_USAGE); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const result = await triggerDeployment(opts); | ||
| if (opts.json) { | ||
| io.stdout(`${JSON.stringify(result, null, 2)}\n`); | ||
| } else { | ||
| io.stdout(formatTriggerResult(result)); | ||
| } | ||
| process.exitCode = 0; | ||
| } catch (err) { | ||
| io.stderr(`trigger: ${err instanceof Error ? err.message : String(err)}\n`); | ||
| process.exitCode = 1; | ||
| } | ||
| } | ||
|
|
||
| export async function triggerDeployment(opts: TriggerOptions): Promise<TriggerResponse> { | ||
| 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 agent = resolveAgentSelector(agents, opts.selector); | ||
| const url = buildTriggerUrl({ | ||
| cloudUrl: ctx.cloudUrl, | ||
| workspace: ctx.workspace, | ||
| agentId: agent.agentId | ||
| }); | ||
|
|
||
| const res = await fetch(url, { | ||
| method: 'POST', | ||
| headers: { | ||
| authorization: `Bearer ${ctx.token}`, | ||
| 'user-agent': 'agentworkforce-cli/trigger' | ||
| } | ||
| }); | ||
| if (res.status === 401) { | ||
| throw new Error('unauthorized. Run `agentworkforce login` and retry.'); | ||
| } | ||
| if (!res.ok) { | ||
| const body = await res.text().catch(() => ''); | ||
| const hint = formatHttpErrorBody(body, { url: url.toString() }); | ||
| throw new Error(`manual trigger failed: ${res.status}${hint ? ` ${hint}` : ''}`); | ||
| } | ||
|
|
||
| return parseTriggerResponse(await res.json(), opts.selector); | ||
| } | ||
|
|
||
| export function formatTriggerResult(result: TriggerResponse): string { | ||
| return ( | ||
| `triggered: ${result.agentId}\n` + | ||
| `deployment: ${result.deploymentId}\n` + | ||
| `workspace: ${result.workspaceId}\n` + | ||
| `status: ${result.status}\n` | ||
| ); | ||
| } | ||
|
|
||
| export function parseTriggerResponse(value: unknown, selector: string): TriggerResponse { | ||
| if (!value || typeof value !== 'object' || Array.isArray(value)) { | ||
| throw new Error(`manual trigger for "${selector}" returned an invalid response`); | ||
| } | ||
| const record = value as Record<string, unknown>; | ||
| const agentId = readString(record, 'agentId'); | ||
| const workspaceId = readString(record, 'workspaceId'); | ||
| const deploymentId = readString(record, 'deploymentId'); | ||
| const status = readString(record, 'status'); | ||
| if (!agentId || !workspaceId || !deploymentId || !status) { | ||
| throw new Error(`manual trigger for "${selector}" returned an incomplete response`); | ||
| } | ||
| return { agentId, workspaceId, deploymentId, status }; | ||
| } | ||
|
|
||
| export function buildTriggerUrl(input: { | ||
| cloudUrl: string; | ||
| workspace: string; | ||
| agentId: string; | ||
| }): URL { | ||
| return new URL( | ||
| `${trimTrailingSlash(input.cloudUrl)}/api/v1/workspaces/${encodeURIComponent(input.workspace)}` + | ||
| `/deployments/${encodeURIComponent(input.agentId)}/trigger` | ||
| ); | ||
| } | ||
|
|
||
| function readString(record: Record<string, unknown>, key: string): string | undefined { | ||
| const value = record[key]; | ||
| return typeof value === 'string' && value.trim() ? value.trim() : undefined; | ||
| } | ||
|
|
||
| function expectValue(flag: string, value: string | undefined): string { | ||
| if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) { | ||
| throw new Error(`trigger: ${flag} expects a value`); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| function expectInlineValue(flag: string, value: string): string { | ||
| if (!value.trim()) { | ||
| throw new Error(`trigger: ${flag} expects a value`); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| function trimTrailingSlash(value: string): string { | ||
| return value.replace(/\/+$/, ''); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Two-arg
new URL()with absolute path drops the cloud base path, causing requests to wrong endpointThe trigger command constructs its URL using
new URL('/api/v1/...', ctx.cloudUrl). Because the path starts with/, theURLconstructor treats it as an absolute path and replaces the entire pathname of the base URL. The default cloud URL resolved byresolveCloudUrlishttps://agentrelay.com/cloud(seepackages/deploy/src/cloud-url.ts:44,54), so the resulting URL becomeshttps://agentrelay.com/api/v1/workspaces/.../triggerinstead of the correcthttps://agentrelay.com/cloud/api/v1/workspaces/.../trigger. Every other command in the codebase uses single-arg string concatenation to preserve the base path, e.g.new URL(\${cloudUrl}/api/v1/...`)atpackages/cli/src/list-command.ts:84andpackages/cli/src/list-command.ts:371`. This will cause trigger requests to 404 or hit the wrong endpoint in production.Was this helpful? React with 👍 or 👎 to provide feedback.