From 461dcd1a724e63a973f66199da1295c872752805 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Tue, 19 May 2026 15:38:45 +0200 Subject: [PATCH 1/2] Improve deployment list and logs CLI --- packages/cli/src/cli.ts | 17 +- packages/cli/src/list-command.test.ts | 53 ++++- packages/cli/src/list-command.ts | 306 +++++++++++++++++++++++++- 3 files changed, 358 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b3f0328..dc510c4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -60,7 +60,7 @@ import { import ora, { type Ora } from 'ora'; import { runDeploy, runLogin, runLogout } from './deploy-command.js'; import { runDestroy } from './destroy-command.js'; -import { runDeploymentList } from './list-command.js'; +import { runDeploymentList, runDeploymentLogs } from './list-command.js'; import { startLaunchMetadataRecording, type LaunchMetadataRun @@ -206,6 +206,7 @@ Commands: --input KEY=value override a declared persona input (repeat for multiple) deployments list List deployed cloud agents in the active workspace. + deployments logs Show structured logs for a deployed cloud agent. destroy [flags] Tear down a deployed agent: cancel all schedules and mark the agent as destroyed in the workspace. Accepts @@ -3805,14 +3806,18 @@ export async function main(): Promise { if (subcommand === 'deployments') { const [action, ...extra] = rest; if (!action || action === '-h' || action === '--help') { - process.stdout.write('Usage: agentworkforce deployments list [flags]\n'); + process.stdout.write('Usage: agentworkforce deployments [flags]\n'); process.exit(action ? 0 : 1); } - if (action !== 'list') { - die(`deployments: unknown action "${action}". Expected: list`); + if (action === 'list') { + await runDeploymentList(extra); + return; } - await runDeploymentList(extra); - return; + if (action === 'logs') { + await runDeploymentLogs(extra); + return; + } + die(`deployments: unknown action "${action}". Expected: list, logs`); } if (subcommand === 'destroy') { diff --git a/packages/cli/src/list-command.test.ts b/packages/cli/src/list-command.test.ts index a66878a..b9b8cbf 100644 --- a/packages/cli/src/list-command.test.ts +++ b/packages/cli/src/list-command.test.ts @@ -1,6 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { formatDeploymentsTable, parseDeploymentListArgs } from './list-command.js'; +import { + formatDeploymentLogEntries, + formatDeploymentsTable, + parseDeploymentListArgs, + parseDeploymentLogsArgs +} from './list-command.js'; test('parseDeploymentListArgs accepts deployment list filters', () => { assert.deepEqual( @@ -30,7 +35,8 @@ test('formatDeploymentsTable renders agent rows', () => { const out = formatDeploymentsTable([ { agentId: 'b2f111111111111111111111e8c2', - personaId: 'weekly-digest', + personaId: '7133e815-8c84-5d05-a08b-e434006b11ac', + personaSlug: 'weekly-digest', deployedName: 'Weekly Digest', status: 'active', createdAt: '2026-05-13T09:11:00.000Z', @@ -39,8 +45,47 @@ test('formatDeploymentsTable renders agent rows', () => { deployedByUserId: 'user-1' } ]); - assert.match(out, /agentId\s+persona\s+status\s+deployed\s+lastUsed/); + assert.match(out, /name\s+status\s+deployed\s+lastUsed\s+agentId/); assert.match(out, /b2f1\.\.\.e8c2/); - assert.match(out, /weekly-digest/); + assert.match(out, /Weekly Digest/); + assert.doesNotMatch(out, /7133e815/); assert.match(out, /2026-05-13 09:11 UTC/); }); + +test('parseDeploymentLogsArgs accepts selector and log flags', () => { + assert.deepEqual( + parseDeploymentLogsArgs([ + 'Weekly Digest', + '--workspace=ws-1', + '--path', + '/_logs/ws-1/2026-05-19.jsonl', + '--tail', + '25', + '--cloud-url', + 'https://cloud.example.test', + '--json', + '--no-prompt' + ]), + { + selector: 'Weekly Digest', + workspace: 'ws-1', + path: '/_logs/ws-1/2026-05-19.jsonl', + tail: 25, + cloudUrl: 'https://cloud.example.test', + json: true, + noPrompt: true + } + ); +}); + +test('formatDeploymentLogEntries renders structured log rows', () => { + const out = formatDeploymentLogEntries([ + { + ts: '2026-05-19T13:00:00.000Z', + level: 'info', + agentId: 'agent-1', + msg: 'handled event' + } + ]); + assert.match(out, /2026-05-19T13:00:00.000Z\s+INFO\s+agent-1\s+handled event/); +}); diff --git a/packages/cli/src/list-command.ts b/packages/cli/src/list-command.ts index 1a64da0..df6e60f 100644 --- a/packages/cli/src/list-command.ts +++ b/packages/cli/src/list-command.ts @@ -15,9 +15,20 @@ type DeploymentListOptions = { noPrompt?: boolean; }; +type DeploymentLogsOptions = { + selector?: string; + workspace?: string; + path?: string; + tail: number; + json?: boolean; + cloudUrl?: string; + noPrompt?: boolean; +}; + type DeploymentAgent = { agentId: string; personaId: string; + personaSlug: string; deployedName: string; status: string; createdAt: string; @@ -30,6 +41,24 @@ type ListResponse = { agents?: unknown; }; +type LogsListResponse = { + data?: { + workspace?: unknown; + items?: unknown; + nextCursor?: unknown; + }; +}; + +type LogsReadResponse = { + data?: { + workspace?: unknown; + path?: unknown; + entries?: unknown; + }; +}; + +type LogEntry = Record; + export async function runDeploymentList(args: readonly string[]): Promise { if (args.length > 0 && (args[0] === '-h' || args[0] === '--help')) { process.stdout.write(LIST_USAGE); @@ -90,6 +119,62 @@ export async function runDeploymentList(args: readonly string[]): Promise } } +export async function runDeploymentLogs(args: readonly string[]): Promise { + if (args.length > 0 && (args[0] === '-h' || args[0] === '--help')) { + process.stdout.write(LOGS_USAGE); + process.exit(0); + } + + try { + const opts = parseDeploymentLogsArgs(args); + const { cloudUrl, workspace, token } = await resolveDeploymentRequestContext(opts); + const agent = opts.selector + ? resolveAgentSelector(await fetchDeployments({ cloudUrl, workspace, token }), opts.selector) + : null; + + if (opts.path) { + const entries = await fetchLogEntries({ + cloudUrl, + workspace, + token, + path: opts.path, + ...(agent ? { agentId: agent.agentId } : {}) + }); + writeLogOutput(entries.slice(-opts.tail), opts); + process.exit(0); + } + + const paths = await fetchLogPaths({ cloudUrl, workspace, token }); + if (!agent) { + if (opts.json) { + process.stdout.write(`${JSON.stringify({ paths }, null, 2)}\n`); + } else { + process.stdout.write(paths.length ? `${paths.join('\n')}\n` : 'No workspace log files found.\n'); + } + process.exit(0); + } + + const entries: LogEntry[] = []; + for (const path of paths.slice(0, 14)) { + entries.push(...await fetchLogEntries({ + cloudUrl, + workspace, + token, + path, + agentId: agent.agentId + })); + if (entries.length >= opts.tail) break; + } + writeLogOutput(entries.slice(-opts.tail), opts); + process.exit(0); + } catch (err) { + process.stderr.write( + `\nagentworkforce logs failed: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(1); + } +} + export function parseDeploymentListArgs(args: readonly string[]): DeploymentListOptions { let workspace: string | undefined; let status: string | undefined; @@ -135,39 +220,93 @@ export function parseDeploymentListArgs(args: readonly string[]): DeploymentList }; } +export function parseDeploymentLogsArgs(args: readonly string[]): DeploymentLogsOptions { + let selector: string | undefined; + let workspace: string | undefined; + let path: string | undefined; + let tail = 50; + let json = false; + let cloudUrl: string | undefined; + let noPrompt = false; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === '--json') { + json = 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 === '--path') { + path = expectValue('--path', args[++i]); + } else if (arg.startsWith('--path=')) { + path = expectInlineValue('--path', arg.slice('--path='.length)); + } else if (arg === '--tail') { + tail = parseTail(expectValue('--tail', args[++i])); + } else if (arg.startsWith('--tail=')) { + tail = parseTail(expectInlineValue('--tail', arg.slice('--tail='.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 === '--no-prompt') { + noPrompt = true; + } else if (!arg.startsWith('-') && !selector) { + selector = arg; + } else { + throw new Error(`logs: unexpected argument "${arg}"`); + } + } + + return { + tail, + ...(selector ? { selector } : {}), + ...(workspace ? { workspace } : {}), + ...(path ? { path } : {}), + ...(json ? { json: true } : {}), + ...(cloudUrl ? { cloudUrl } : {}), + ...(noPrompt ? { noPrompt: true } : {}) + }; +} + export function formatDeploymentsTable(agents: readonly DeploymentAgent[]): string { if (agents.length === 0) return 'No deployed agents found.\n'; const rows = agents.map((agent) => ({ + name: deploymentDisplayName(agent), agentId: compactId(agent.agentId), - persona: agent.personaId || agent.deployedName, status: agent.status, deployed: formatDate(agent.createdAt), lastUsed: agent.lastUsedAt ? formatRelative(agent.lastUsedAt) : '-' })); const widths = { + name: Math.max('name'.length, ...rows.map((r) => r.name.length)), agentId: Math.max('agentId'.length, ...rows.map((r) => r.agentId.length)), - persona: Math.max('persona'.length, ...rows.map((r) => r.persona.length)), status: Math.max('status'.length, ...rows.map((r) => r.status.length)), deployed: Math.max('deployed'.length, ...rows.map((r) => r.deployed.length)), lastUsed: Math.max('lastUsed'.length, ...rows.map((r) => r.lastUsed.length)) }; const header = [ - pad('agentId', widths.agentId), - pad('persona', widths.persona), + pad('name', widths.name), pad('status', widths.status), pad('deployed', widths.deployed), - pad('lastUsed', widths.lastUsed) + pad('lastUsed', widths.lastUsed), + pad('agentId', widths.agentId) ].join(' '); const body = rows.map((row) => [ - pad(row.agentId, widths.agentId), - pad(row.persona, widths.persona), + pad(row.name, widths.name), pad(row.status, widths.status), pad(row.deployed, widths.deployed), - pad(row.lastUsed, widths.lastUsed) + pad(row.lastUsed, widths.lastUsed), + pad(row.agentId, widths.agentId) ].join(' ')); return `${[header, ...body].join('\n')}\n`; } +export function formatDeploymentLogEntries(entries: readonly LogEntry[]): string { + if (entries.length === 0) return 'No log entries found.\n'; + return entries.map(formatLogEntry).join('\n') + '\n'; +} + function parseAgents(body: ListResponse): DeploymentAgent[] { const raw = Array.isArray(body) ? body : Array.isArray(body.agents) ? body.agents : []; return raw.map((value) => { @@ -178,6 +317,7 @@ function parseAgents(body: ListResponse): DeploymentAgent[] { return { agentId: readString(record, 'agentId') ?? readString(record, 'id') ?? '', personaId: readString(record, 'personaId') ?? readString(record, 'persona') ?? '', + personaSlug: readString(record, 'personaSlug') ?? readString(record, 'slug') ?? '', deployedName: readString(record, 'deployedName') ?? readString(record, 'name') ?? '', status: readString(record, 'status') ?? 'unknown', createdAt: readString(record, 'createdAt') ?? '', @@ -190,6 +330,133 @@ function parseAgents(body: ListResponse): DeploymentAgent[] { }).filter((agent) => agent.agentId); } +async function resolveDeploymentRequestContext(opts: { + workspace?: string; + cloudUrl?: string; + noPrompt?: boolean; +}): Promise<{ cloudUrl: string; workspace: string; token: string }> { + const io = createTerminalIO(); + const active = await readActiveWorkspace().catch(() => null); + const cloudUrl = resolveCloudUrl({ + ...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}), + active + }); + const auth = await resolveWorkspaceToken({ + ...(opts.workspace ? { workspace: opts.workspace } : {}), + cloudUrl, + io, + ...(opts.noPrompt ? { noPrompt: true } : {}) + }); + const workspace = auth.workspace?.trim() || opts.workspace?.trim(); + if (!workspace) { + throw new Error('workspace is required: pass --workspace, set WORKFORCE_WORKSPACE_ID, or run `agentworkforce login`'); + } + return { cloudUrl, workspace, token: auth.token }; +} + +async function fetchDeployments(args: { + cloudUrl: string; + workspace: string; + token: string; +}): Promise { + const url = new URL(`${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent(args.workspace)}/deployments`); + return parseAgents(await requestJson(url, args.token, 'deployment list')); +} + +async function fetchLogPaths(args: { + cloudUrl: string; + workspace: string; + token: string; +}): Promise { + const url = new URL(`${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent(args.workspace)}/logs`); + const body = await requestJson(url, args.token, 'workspace logs'); + const items = Array.isArray(body.data?.items) ? body.data.items : []; + return items + .map(readLogPath) + .filter((path): path is string => Boolean(path)) + .sort((a, b) => b.localeCompare(a)); +} + +async function fetchLogEntries(args: { + cloudUrl: string; + workspace: string; + token: string; + path: string; + agentId?: string; +}): Promise { + const url = new URL(`${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent(args.workspace)}/logs`); + url.searchParams.set('path', args.path); + if (args.agentId) url.searchParams.set('agentId', args.agentId); + const body = await requestJson(url, args.token, 'workspace log file'); + const entries = Array.isArray(body.data?.entries) ? body.data.entries : []; + 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 { + const res = await fetch(url, { + method: 'GET', + headers: { + authorization: `Bearer ${token}`, + 'user-agent': 'agentworkforce-cli' + } + }); + 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(`${action} failed: ${res.status}${hint ? ` ${hint}` : ''}`); + } + return (await res.json()) as T; +} + +function resolveAgentSelector(agents: readonly DeploymentAgent[], selector: string): DeploymentAgent { + const matches = agents.filter((agent) => { + const candidates = [ + agent.agentId, + compactId(agent.agentId), + agent.deployedName, + agent.personaSlug, + agent.personaId + ].filter(Boolean); + return candidates.includes(selector); + }); + if (matches.length === 0) { + throw new Error(`no deployed agent matched "${selector}". Run \`agentworkforce deployments list\` to see names.`); + } + if (matches.length > 1) { + throw new Error(`multiple deployed agents matched "${selector}". Use the agentId from \`agentworkforce deployments list\`.`); + } + return matches[0]; +} + +function deploymentDisplayName(agent: DeploymentAgent): string { + return agent.deployedName || agent.personaSlug || agent.personaId || agent.agentId; +} + +function writeLogOutput(entries: readonly LogEntry[], opts: DeploymentLogsOptions): void { + if (opts.json) { + process.stdout.write(`${JSON.stringify({ entries }, null, 2)}\n`); + } else { + process.stdout.write(formatDeploymentLogEntries(entries)); + } +} + +function readLogPath(value: unknown): string | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + return readString(record, 'path') ?? readString(record, 'filePath') ?? readString(record, 'id'); +} + +function formatLogEntry(entry: LogEntry): string { + const ts = readString(entry, 'ts') ?? readString(entry, 'timestamp') ?? '-'; + const level = (readString(entry, 'level') ?? 'info').toUpperCase(); + const agent = readString(entry, 'agentName') ?? readString(entry, 'agentId') ?? '-'; + const msg = readString(entry, 'msg') ?? readString(entry, 'message') ?? readString(entry, 'event') ?? JSON.stringify(entry); + return `${ts} ${level.padEnd(5)} ${agent} ${msg}`; +} + function compactId(id: string): string { return id.length <= 12 ? id : `${id.slice(0, 4)}...${id.slice(-4)}`; } @@ -226,6 +493,14 @@ function readNullableString(record: Record, key: string): strin return typeof value === 'string' && value.trim() ? value.trim() : null; } +function parseTail(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error('--tail: expected a positive integer'); + } + return parsed; +} + function expectValue(flag: string, value: string | undefined): string { if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) { throw new Error(`${flag}: missing value`); @@ -253,3 +528,18 @@ Flags: --no-prompt Fail instead of prompting for cloud setup -h, --help Print this message `; + +const LOGS_USAGE = `usage: agentworkforce deployments logs [agent-name-or-id] [flags] + +Read structured cloud logs for a deployed agent. Without an agent or --path, +prints available workspace log files. + +Flags: + --workspace Workforce workspace; defaults to the logged-in workspace + --path Read a specific /_logs/... JSONL file + --tail Number of entries to print (default: 50) + --json Emit JSON instead of text lines + --cloud-url Override the workforce cloud base URL + --no-prompt Fail instead of prompting for cloud setup + -h, --help Print this message +`; From 08fe5201013fca57f185354268b81f16dcc8ef8b Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Tue, 19 May 2026 18:10:00 +0200 Subject: [PATCH 2/2] Fix deployment log tail ordering --- packages/cli/src/list-command.test.ts | 22 +++++++++++++++++++++- packages/cli/src/list-command.ts | 24 +++++++++++++++++++----- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/list-command.test.ts b/packages/cli/src/list-command.test.ts index b9b8cbf..0ae4be4 100644 --- a/packages/cli/src/list-command.test.ts +++ b/packages/cli/src/list-command.test.ts @@ -4,7 +4,8 @@ import { formatDeploymentLogEntries, formatDeploymentsTable, parseDeploymentListArgs, - parseDeploymentLogsArgs + parseDeploymentLogsArgs, + tailLogEntriesFromNewestFiles } from './list-command.js'; test('parseDeploymentListArgs accepts deployment list filters', () => { @@ -89,3 +90,22 @@ test('formatDeploymentLogEntries renders structured log rows', () => { ]); assert.match(out, /2026-05-19T13:00:00.000Z\s+INFO\s+agent-1\s+handled event/); }); + +test('tailLogEntriesFromNewestFiles keeps the newest entries across files', () => { + const entries = tailLogEntriesFromNewestFiles( + [ + [ + { ts: '2026-05-19T13:00:00.000Z', msg: 'newer-a' }, + { ts: '2026-05-19T14:00:00.000Z', msg: 'newer-b' }, + { ts: '2026-05-19T15:00:00.000Z', msg: 'newer-c' } + ], + [ + { ts: '2026-05-18T10:00:00.000Z', msg: 'older-a' }, + { ts: '2026-05-18T11:00:00.000Z', msg: 'older-b' } + ] + ], + 3 + ); + + assert.deepEqual(entries.map((entry) => entry.msg), ['newer-a', 'newer-b', 'newer-c']); +}); diff --git a/packages/cli/src/list-command.ts b/packages/cli/src/list-command.ts index df6e60f..e217a13 100644 --- a/packages/cli/src/list-command.ts +++ b/packages/cli/src/list-command.ts @@ -154,18 +154,21 @@ export async function runDeploymentLogs(args: readonly string[]): Promise process.exit(0); } - const entries: LogEntry[] = []; + const entryFiles: LogEntry[][] = []; + let collected = 0; for (const path of paths.slice(0, 14)) { - entries.push(...await fetchLogEntries({ + const fileEntries = await fetchLogEntries({ cloudUrl, workspace, token, path, agentId: agent.agentId - })); - if (entries.length >= opts.tail) break; + }); + entryFiles.push(fileEntries); + collected += fileEntries.length; + if (collected >= opts.tail) break; } - writeLogOutput(entries.slice(-opts.tail), opts); + writeLogOutput(tailLogEntriesFromNewestFiles(entryFiles, opts.tail), opts); process.exit(0); } catch (err) { process.stderr.write( @@ -307,6 +310,17 @@ export function formatDeploymentLogEntries(entries: readonly LogEntry[]): string return entries.map(formatLogEntry).join('\n') + '\n'; } +export function tailLogEntriesFromNewestFiles( + filesNewestFirst: readonly (readonly LogEntry[])[], + tail: number +): LogEntry[] { + const chronological: LogEntry[] = []; + for (let i = filesNewestFirst.length - 1; i >= 0; i -= 1) { + chronological.push(...filesNewestFirst[i]); + } + return chronological.slice(-tail); +} + function parseAgents(body: ListResponse): DeploymentAgent[] { const raw = Array.isArray(body) ? body : Array.isArray(body.agents) ? body.agents : []; return raw.map((value) => {