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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ agentworkforce persona compile <path/to/persona.ts|persona.js>
agentworkforce install [flags] <pkg|path>
agentworkforce deploy <path/to/persona.json|persona.ts|persona.js> [flags]
agentworkforce integrations [provider] [--all] [--json]
agentworkforce trigger <agent-name-or-id> [--workspace <id>] [--cloud-url <url>] [--json] [--no-prompt]
agentworkforce sources <list|add|remove>
agentworkforce harness check
agentworkforce destroy <persona-or-agent-id> [--workspace <id>] [--cloud-url <url>] [--no-prompt]
Expand All @@ -36,6 +37,9 @@ agentworkforce --version
an authored source module such as `persona.ts` or `persona.js`.
- `integrations` — discover available integrations, known trigger events, and
connection status for the active workspace.
- `trigger` — manually fire an active deployed persona for testing. The
selector accepts agent id, compact agent id, deployed name, persona slug, or
persona id, and posts to the same cloud trigger endpoint used by the dashboard.
- `sources` — list, add, or remove persona source directories.
- `harness check` — probe which harnesses (`claude`, `codex`, `opencode`, `grok`)
are installed. See [`## Harness check`](#harness-check) below.
Expand Down
23 changes: 21 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import { installPersonas, type PersonaInstallResult } from './persona-install.js
import { runPersonaCompileCommand } from './persona-compile.js';
import { pickPersona, type PickCandidate, type PickResult } from './persona-picker.js';
import { recordRecent, loadRecents, runPersonaPickerTui, type TuiCandidate } from './persona-tui.js';
import { runTrigger } from './trigger-command.js';

const launchMetadataLog = createLogger('launch-metadata');

Expand Down Expand Up @@ -230,6 +231,15 @@ Commands:
integrations [provider] [--all] [--json]
Discover workspace integrations, connection status, and
known trigger events.
trigger <agent-name-or-id> [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:
--workspace <name> Workforce workspace; defaults to
the active workspace
--cloud-url <url> Override the cloud base URL
--json Emit JSON
--no-prompt Fail instead of prompting for login
harness check Probe which harnesses (${HARNESS_VALUES.join(', ')}) are
installed and runnable on this machine.
pick "<task>" Pick the best-fit persona for a free-text task description
Expand Down Expand Up @@ -4510,10 +4520,15 @@ export async function main(): Promise<void> {
return;
}

if (subcommand === 'trigger') {
await runTrigger(rest);
return;
}

if (subcommand === 'deployments') {
const [action, ...extra] = rest;
if (!action || action === '-h' || action === '--help') {
process.stdout.write('Usage: agentworkforce deployments <list|logs> [flags]\n');
process.stdout.write('Usage: agentworkforce deployments <list|logs|trigger> [flags]\n');
process.exit(action ? 0 : 1);
}
if (action === 'list') {
Expand All @@ -4524,7 +4539,11 @@ export async function main(): Promise<void> {
await runDeploymentLogs(extra);
return;
}
die(`deployments: unknown action "${action}". Expected: list, logs`);
if (action === 'trigger') {
await runTrigger(extra);
return;
}
die(`deployments: unknown action "${action}". Expected: list, logs, trigger`);
}

if (subcommand === 'destroy') {
Expand Down
94 changes: 94 additions & 0 deletions packages/cli/src/trigger-command.test.ts
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'
);
});
223 changes: 223 additions & 0 deletions packages/cli/src/trigger-command.ts
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,
Comment on lines +140 to +144

Copy link
Copy Markdown
Contributor

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 endpoint

The trigger command constructs its URL using new URL('/api/v1/...', ctx.cloudUrl). Because the path starts with /, the URL constructor treats it as an absolute path and replaces the entire pathname of the base URL. The default cloud URL resolved by resolveCloudUrl is https://agentrelay.com/cloud (see packages/deploy/src/cloud-url.ts:44,54), so the resulting URL becomes https://agentrelay.com/api/v1/workspaces/.../trigger instead of the correct https://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.

Suggested change
});
const agent = resolveAgentSelector(agents, opts.selector);
const url = buildTriggerUrl({
cloudUrl: ctx.cloudUrl,
workspace: ctx.workspace,
const url = new URL(
`${ctx.cloudUrl}/api/v1/workspaces/${encodeURIComponent(ctx.workspace)}` +
`/deployments/${encodeURIComponent(agent.agentId)}/trigger`
);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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(/\/+$/, '');
}
Loading