diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 44379cb..190249f 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Preserve deployment run failures and integration-watch health in JSON output, + and surface them clearly in the human-readable deployments list. + ## [4.1.29] - 2026-07-17 ### Fixed diff --git a/packages/cli/src/list-command.test.ts b/packages/cli/src/list-command.test.ts index f1080ef..9936910 100644 --- a/packages/cli/src/list-command.test.ts +++ b/packages/cli/src/list-command.test.ts @@ -3,8 +3,10 @@ import assert from 'node:assert/strict'; import { formatDeploymentLogEntries, formatDeploymentsTable, + parseDeploymentAgents, parseDeploymentListArgs, parseDeploymentLogsArgs, + runDeploymentList, tailLogEntriesFromNewestFiles } from './list-command.js'; @@ -43,16 +45,31 @@ test('formatDeploymentsTable renders agent rows', () => { createdAt: '2026-05-13T09:11:00.000Z', updatedAt: '2026-07-17T08:45:00.000Z', lastUsedAt: null, + lastRunStatus: 'succeeded', + lastError: null, + integrationWatchHealth: { + status: 'healthy', + reason: null, + lastSuccessfulDeliveryAt: '2026-07-17T08:44:00.000Z', + lastDeliveryAt: '2026-07-17T08:44:00.000Z', + lastFailedDeliveryAt: null, + pendingDeliveryCount: 0, + recentFailedDeliveryCount: 0, + recentWorkspaceDispatchFailureCount: 0, + latestWorkspaceDispatchFailureAt: null + }, scheduleIds: ['sched-1'], deployedByUserId: 'user-1' } ]); - assert.match(out, /name\s+status\s+deployed\s+lastUsed\s+agentId/); + assert.match(out, /name\s+deployment\s+lastRun\s+watch\s+deployed\s+lastUsed\s+agentId/); assert.match(out, /b2f1\.\.\.e8c2/); assert.match(out, /Weekly Digest/); assert.doesNotMatch(out, /7133e815/); assert.match(out, /2026-07-17 08:45 UTC/); assert.doesNotMatch(out, /2026-05-13 09:11 UTC/); + assert.match(out, /Weekly Digest\s+active\s+succeeded\s+healthy/); + assert.doesNotMatch(out, /Errors:/); }); test('formatDeploymentsTable falls back to createdAt when updatedAt is absent', () => { @@ -72,6 +89,275 @@ test('formatDeploymentsTable falls back to createdAt when updatedAt is absent', assert.match(out, /2026-05-13 09:11 UTC/); }); +test('parseDeploymentAgents preserves run failures and integration watch health', () => { + const e2bigError = + '/home/daytona/.daytona/sessions/tick-1/cmd.sh:\r\n\u001b[31mline 3: /usr/bin/timeout: Argument list too long\u001b[0m'; + const agents = parseDeploymentAgents({ + agents: [ + { + agentId: 'agent-e2big', + personaId: 'hoopsheet-maintainability', + deployedName: 'hoopsheet-maintainability', + status: 'active', + createdAt: '2026-07-17T12:55:00.000Z', + lastUsedAt: null, + lastRunStatus: 'failed', + lastError: e2bigError, + scheduleIds: [], + deployedByUserId: 'user-1', + integrationWatchHealth: { + status: 'unhealthy', + reason: 'delivery_failures', + lastSuccessfulDeliveryAt: null, + lastDeliveryAt: '2026-07-17T12:56:18.851Z', + lastFailedDeliveryAt: '2026-07-17T13:15:18.163Z', + pendingDeliveryCount: 0, + recentFailedDeliveryCount: 1, + recentWorkspaceDispatchFailureCount: 0, + latestWorkspaceDispatchFailureAt: null + } + }, + { + agentId: 'agent-xai', + personaId: 'x-reply-radar', + deployedName: 'x-reply-radar', + status: 'active', + createdAt: '2026-07-17T07:54:00.000Z', + lastUsedAt: null, + lastRunStatus: 'failed', + lastError: 'Your xAI (Grok) credentials have expired and could not be refreshed automatically.', + scheduleIds: [], + deployedByUserId: 'user-2', + integrationWatchHealth: { + status: 'unknown', + reason: 'awaiting_first_successful_delivery', + lastSuccessfulDeliveryAt: null, + lastDeliveryAt: null, + lastFailedDeliveryAt: null, + pendingDeliveryCount: 0, + recentFailedDeliveryCount: 0, + recentWorkspaceDispatchFailureCount: 0, + latestWorkspaceDispatchFailureAt: null + } + } + ] + }); + + assert.equal(agents[0]?.lastRunStatus, 'failed'); + assert.equal(agents[0]?.lastError, e2bigError); + assert.deepEqual(agents[0]?.integrationWatchHealth, { + status: 'unhealthy', + reason: 'delivery_failures', + lastSuccessfulDeliveryAt: null, + lastDeliveryAt: '2026-07-17T12:56:18.851Z', + lastFailedDeliveryAt: '2026-07-17T13:15:18.163Z', + pendingDeliveryCount: 0, + recentFailedDeliveryCount: 1, + recentWorkspaceDispatchFailureCount: 0, + latestWorkspaceDispatchFailureAt: null + }); + assert.equal(agents[1]?.lastRunStatus, 'failed'); + assert.equal( + agents[1]?.lastError, + 'Your xAI (Grok) credentials have expired and could not be refreshed automatically.' + ); + assert.deepEqual(agents[1]?.integrationWatchHealth, { + status: 'unknown', + reason: 'awaiting_first_successful_delivery', + lastSuccessfulDeliveryAt: null, + lastDeliveryAt: null, + lastFailedDeliveryAt: null, + pendingDeliveryCount: 0, + recentFailedDeliveryCount: 0, + recentWorkspaceDispatchFailureCount: 0, + latestWorkspaceDispatchFailureAt: null + }); +}); + +test('runDeploymentList --json preserves raw multiline and control-bearing failure fields', async () => { + const rawError = + '/home/daytona/.daytona/sessions/tick-1/cmd.sh:\r\n\u001b[31mline 3: /usr/bin/timeout: Argument list too long\u001b[0m'; + const originalFetch = globalThis.fetch; + const originalExit = process.exit; + const originalStdout = process.stdout.write; + const originalStderr = process.stderr.write; + const originalToken = process.env.WORKFORCE_WORKSPACE_TOKEN; + let stdout = ''; + let stderr = ''; + const exits: number[] = []; + + process.env.WORKFORCE_WORKSPACE_TOKEN = 'test-token'; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + agents: [ + { + agentId: 'agent-e2big', + personaId: 'hoopsheet-maintainability', + deployedName: 'hoopsheet-maintainability', + status: 'active', + createdAt: '2026-07-17T12:55:00.000Z', + lastUsedAt: null, + lastRunStatus: 'failed', + lastError: rawError, + scheduleIds: [], + deployedByUserId: 'user-1', + integrationWatchHealth: { + status: 'unhealthy', + reason: 'delivery_failures', + lastSuccessfulDeliveryAt: null, + lastDeliveryAt: '2026-07-17T12:56:18.851Z', + lastFailedDeliveryAt: '2026-07-17T13:15:18.163Z', + pendingDeliveryCount: 0, + recentFailedDeliveryCount: 1, + recentWorkspaceDispatchFailureCount: 0, + latestWorkspaceDispatchFailureAt: null + } + } + ] + }), + { status: 200, headers: { 'content-type': 'application/json' } } + )) as typeof globalThis.fetch; + process.exit = ((code?: number) => { + exits.push(code ?? 0); + return undefined as never; + }) as typeof process.exit; + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += typeof chunk === 'string' ? chunk : chunk.toString(); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += typeof chunk === 'string' ? chunk : chunk.toString(); + return true; + }) as typeof process.stderr.write; + + try { + await runDeploymentList([ + '--workspace', + 'ws-test', + '--cloud-url', + 'https://cloud.example.test', + '--json', + '--no-prompt' + ]); + + const output = JSON.parse(stdout) as { agents: Array> }; + assert.deepEqual(exits, [0]); + assert.equal(stderr, ''); + assert.equal(output.agents[0]?.lastRunStatus, 'failed'); + assert.equal(output.agents[0]?.lastError, rawError); + assert.deepEqual(output.agents[0]?.integrationWatchHealth, { + status: 'unhealthy', + reason: 'delivery_failures', + lastSuccessfulDeliveryAt: null, + lastDeliveryAt: '2026-07-17T12:56:18.851Z', + lastFailedDeliveryAt: '2026-07-17T13:15:18.163Z', + pendingDeliveryCount: 0, + recentFailedDeliveryCount: 1, + recentWorkspaceDispatchFailureCount: 0, + latestWorkspaceDispatchFailureAt: null + }); + } finally { + globalThis.fetch = originalFetch; + process.exit = originalExit; + process.stdout.write = originalStdout; + process.stderr.write = originalStderr; + if (originalToken === undefined) delete process.env.WORKFORCE_WORKSPACE_TOKEN; + else process.env.WORKFORCE_WORKSPACE_TOKEN = originalToken; + } +}); + +test('formatDeploymentsTable makes distinct active deployment failures unmistakable', () => { + const e2bigMultilineError = + '/home/daytona/.daytona/sessions/tick-1/cmd.sh:\r\nline 3: /usr/bin/timeout: Argument list too long'; + const agents = parseDeploymentAgents({ + agents: [ + { + agentId: 'agent-e2big', + personaId: 'hoopsheet-maintainability', + deployedName: 'hoopsheet-maintainability', + status: 'active', + createdAt: '2026-07-17T12:55:00.000Z', + lastUsedAt: null, + lastRunStatus: 'failed', + lastError: e2bigMultilineError, + scheduleIds: [], + deployedByUserId: 'user-1', + integrationWatchHealth: { + status: 'unhealthy', + reason: 'delivery_failures', + lastSuccessfulDeliveryAt: null, + lastDeliveryAt: '2026-07-17T12:56:18.851Z', + lastFailedDeliveryAt: '2026-07-17T13:15:18.163Z', + pendingDeliveryCount: 0, + recentFailedDeliveryCount: 1, + recentWorkspaceDispatchFailureCount: 0, + latestWorkspaceDispatchFailureAt: null + } + }, + { + agentId: 'agent-xai', + personaId: 'x-reply-radar', + deployedName: 'x-reply-radar', + status: 'active', + createdAt: '2026-07-17T07:54:00.000Z', + lastUsedAt: null, + lastRunStatus: 'failed', + lastError: 'Your xAI (Grok) credentials have expired and could not be refreshed automatically.', + scheduleIds: [], + deployedByUserId: 'user-2', + integrationWatchHealth: { + status: 'unknown', + reason: 'awaiting_first_successful_delivery', + lastSuccessfulDeliveryAt: null, + lastDeliveryAt: null, + lastFailedDeliveryAt: null, + pendingDeliveryCount: 0, + recentFailedDeliveryCount: 0, + recentWorkspaceDispatchFailureCount: 0, + latestWorkspaceDispatchFailureAt: null + } + } + ] + }); + + const out = formatDeploymentsTable(agents); + assert.match(out, /name\s+deployment\s+lastRun\s+watch\s+deployed\s+lastUsed\s+agentId/); + assert.match(out, /hoopsheet-maintainability\s+active\s+failed\s+unhealthy/); + assert.match(out, /x-reply-radar\s+active\s+failed\s+unknown/); + assert.match(out, /Errors:/); + assert.match( + out, + /hoopsheet-maintainability: \/home\/daytona\/.* line 3: \/usr\/bin\/timeout: Argument list too long/ + ); + assert.match(out, /x-reply-radar: Your xAI \(Grok\) credentials have expired/); + assert.doesNotMatch(out, /\r/); +}); + +test('formatDeploymentsTable collapses and bounds multiline error details', () => { + const longMultilineError = `first line\r\nsecond line ${'x'.repeat(300)}`; + const out = formatDeploymentsTable([ + { + agentId: 'agent-long-error', + personaId: 'long-error', + personaSlug: 'long-error', + deployedName: 'long-error', + status: 'active', + createdAt: '2026-07-17T12:55:00.000Z', + lastUsedAt: null, + lastRunStatus: 'failed', + lastError: longMultilineError, + scheduleIds: [], + deployedByUserId: 'user-1' + } + ]); + + assert.match(out, /long-error: first line second line x+/); + assert.doesNotMatch(out, /\r/); + assert.doesNotMatch(out, new RegExp(`x{300}`)); + assert.match(out, /\.\.\./); +}); + test('parseDeploymentLogsArgs accepts selector and log flags', () => { assert.deepEqual( parseDeploymentLogsArgs([ diff --git a/packages/cli/src/list-command.ts b/packages/cli/src/list-command.ts index 149a670..a96cb8d 100644 --- a/packages/cli/src/list-command.ts +++ b/packages/cli/src/list-command.ts @@ -33,10 +33,25 @@ export type DeploymentAgent = { createdAt: string; updatedAt?: string; lastUsedAt: string | null; + lastRunStatus?: string | null; + lastError?: string | null; + integrationWatchHealth?: DeploymentIntegrationWatchHealth | null; scheduleIds: string[]; deployedByUserId: string; }; +export type DeploymentIntegrationWatchHealth = { + status: string; + reason: string | null; + lastSuccessfulDeliveryAt: string | null; + lastDeliveryAt: string | null; + lastFailedDeliveryAt: string | null; + pendingDeliveryCount: number; + recentFailedDeliveryCount: number; + recentWorkspaceDispatchFailureCount: number; + latestWorkspaceDispatchFailureAt: string | null; +}; + type ListResponse = { agents?: unknown; }; @@ -101,7 +116,7 @@ export async function runDeploymentList(args: readonly string[]): Promise const hint = formatHttpErrorBody(body, { url: url.toString() }); throw new Error(`list failed: ${res.status}${hint ? ` ${hint}` : ''}`); } - const agents = parseAgents((await res.json()) as ListResponse); + const agents = parseDeploymentAgents((await res.json()) as ListResponse); if (opts.json) { process.stdout.write(`${JSON.stringify({ agents }, null, 2)}\n`); } else { @@ -275,32 +290,46 @@ export function formatDeploymentsTable(agents: readonly DeploymentAgent[]): stri const rows = agents.map((agent) => ({ name: deploymentDisplayName(agent), agentId: compactId(agent.agentId), - status: agent.status, + deployment: agent.status, + lastRun: agent.lastRunStatus ?? '-', + watch: agent.integrationWatchHealth?.status ?? '-', deployed: formatDate(agent.updatedAt ?? 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)), - status: Math.max('status'.length, ...rows.map((r) => r.status.length)), + deployment: Math.max('deployment'.length, ...rows.map((r) => r.deployment.length)), + lastRun: Math.max('lastRun'.length, ...rows.map((r) => r.lastRun.length)), + watch: Math.max('watch'.length, ...rows.map((r) => r.watch.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('name', widths.name), - pad('status', widths.status), + pad('deployment', widths.deployment), + pad('lastRun', widths.lastRun), + pad('watch', widths.watch), pad('deployed', widths.deployed), pad('lastUsed', widths.lastUsed), pad('agentId', widths.agentId) ].join(' '); const body = rows.map((row) => [ pad(row.name, widths.name), - pad(row.status, widths.status), + pad(row.deployment, widths.deployment), + pad(row.lastRun, widths.lastRun), + pad(row.watch, widths.watch), pad(row.deployed, widths.deployed), pad(row.lastUsed, widths.lastUsed), pad(row.agentId, widths.agentId) ].join(' ')); - return `${[header, ...body].join('\n')}\n`; + const table = [header, ...body].join('\n'); + const errors = agents + .filter((agent) => agent.lastError) + .map((agent) => ` ${deploymentDisplayName(agent)}: ${formatDeploymentErrorSnippet(agent.lastError!)}`); + return errors.length > 0 + ? `${table}\n\nErrors:\n${errors.join('\n')}\n` + : `${table}\n`; } export function formatDeploymentLogEntries(entries: readonly LogEntry[]): string { @@ -319,7 +348,7 @@ export function tailLogEntriesFromNewestFiles( return chronological.slice(-tail); } -function parseAgents(body: ListResponse): DeploymentAgent[] { +export function parseDeploymentAgents(body: ListResponse): DeploymentAgent[] { const raw = Array.isArray(body) ? body : Array.isArray(body.agents) ? body.agents : []; return raw.map((value) => { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -336,6 +365,9 @@ function parseAgents(body: ListResponse): DeploymentAgent[] { createdAt: readString(record, 'createdAt') ?? '', ...(updatedAt ? { updatedAt } : {}), lastUsedAt: readNullableString(record, 'lastUsedAt'), + lastRunStatus: readNullableString(record, 'lastRunStatus'), + lastError: readNullableText(record, 'lastError'), + integrationWatchHealth: readIntegrationWatchHealth(record), scheduleIds: Array.isArray(record.scheduleIds) ? record.scheduleIds.filter((id): id is string => typeof id === 'string') : [], @@ -389,7 +421,7 @@ export async function fetchDeployments(args: { 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')); + return parseDeploymentAgents(await requestJson(url, args.token, 'deployment list')); } async function fetchLogPaths(args: { @@ -522,6 +554,52 @@ function readNullableString(record: Record, key: string): strin return typeof value === 'string' && value.trim() ? value.trim() : null; } +function readNullableText(record: Record, key: string): string | null { + const value = record[key]; + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function readIntegrationWatchHealth( + record: Record +): DeploymentIntegrationWatchHealth | null { + const value = record.integrationWatchHealth; + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const health = value as Record; + return { + status: readString(health, 'status') ?? 'unknown', + reason: readNullableString(health, 'reason'), + lastSuccessfulDeliveryAt: readNullableString(health, 'lastSuccessfulDeliveryAt'), + lastDeliveryAt: readNullableString(health, 'lastDeliveryAt'), + lastFailedDeliveryAt: readNullableString(health, 'lastFailedDeliveryAt'), + pendingDeliveryCount: readFiniteNumber(health, 'pendingDeliveryCount'), + recentFailedDeliveryCount: readFiniteNumber(health, 'recentFailedDeliveryCount'), + recentWorkspaceDispatchFailureCount: readFiniteNumber( + health, + 'recentWorkspaceDispatchFailureCount' + ), + latestWorkspaceDispatchFailureAt: readNullableString( + health, + 'latestWorkspaceDispatchFailureAt' + ) + }; +} + +function readFiniteNumber(record: Record, key: string): number { + const value = record[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +const DEPLOYMENT_ERROR_SNIPPET_LENGTH = 240; + +function formatDeploymentErrorSnippet(error: string): string { + const normalized = error + .replace(/[\u0000-\u001f\u007f]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (normalized.length <= DEPLOYMENT_ERROR_SNIPPET_LENGTH) return normalized; + return `${normalized.slice(0, DEPLOYMENT_ERROR_SNIPPET_LENGTH - 3)}...`; +} + function parseTail(value: string): number { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) {