/** * Display helpers for workflow parser/runtime results. */ import type { WorkflowAgentCall, WorkflowAgentChild, WorkflowMeta, WorkflowProgressState, WorkflowRunResult, } from "./workflow-types.js"; export function formatWorkflowTitle(meta: WorkflowMeta): string { const name = typeof meta.name === "string" ? meta.name : "workflow"; const description = typeof meta.description === "string" ? ` — ${meta.description}` : ""; return `${name}${description}`; } export function formatWorkflowMeta(meta: WorkflowMeta): string { const entries = Object.entries(meta); if (entries.length === 0) { return "workflow"; } return entries .map(([key, value]) => `${key}: ${formatMetaValue(value)}`) .join("\n"); } export function formatWorkflowRunSummary(result: WorkflowRunResult): string { const title = formatWorkflowTitle(result.meta); const parts = [ formatCount(result.phases.length, "phase"), formatCount(result.agentCalls.length, "agent call"), formatCount(result.logs.length, "log"), ]; return `${title}\n${parts.join(" · ")}`; } export function formatWorkflowProgress(state: WorkflowProgressState): string { const agentCalls = getWorkflowAgentCalls(state); const lines = [formatWorkflowProgressHeader(state, agentCalls)]; const callsByPhase = groupAgentCallsByPhase(agentCalls); const visiblePhaseNames = getVisiblePhaseNames(state, callsByPhase); for (const phaseName of visiblePhaseNames) { const calls = callsByPhase.get(phaseName) ?? []; lines.push( ` ${getPhaseMarker(state, phaseName, calls)} ${phaseName} ${formatPhaseSummary(calls)}` ); const visibleCalls = calls.slice(-WORKFLOW_PHASE_ROW_LIMIT); const omitted = calls.length - visibleCalls.length; if (omitted > 0) { lines.push(` … ${omitted} earlier agents`); } for (const call of visibleCalls) { lines.push( ` #${call.index + 1} ${getAgentCallStatusIcon(call.status)} ${formatCompactLine(call.label ?? String(call.index + 1))}` ); const snippet = formatAgentCallSnippet(call); if (snippet) { lines.push(` ${snippet}`); } } } const activeAgents = getActiveWorkflowAgents(state); if (activeAgents.length > 0) { lines.push( "", ` active: ${activeAgents.map(formatWorkflowAgentChild).join(", ")}` ); } const latestLog = state.logs.at(-1); if (latestLog) { lines.push("", ` log: ${formatCompactLine(latestLog.message)}`); } return lines.join("\n"); } export function formatWorkflowProgressStatus( state: WorkflowProgressState ): string { const agentCalls = getWorkflowAgentCalls(state); const counts = countAgentCallStatuses(agentCalls); const name = typeof state.meta.name === "string" ? state.meta.name : "workflow"; const phase = state.currentPhase; const countParts = compactWorkflowTextParts([ `${counts.done}/${agentCalls.length} done`, counts.running > 0 ? `${counts.running} running` : undefined, counts.errors > 0 ? `${counts.errors} errors` : undefined, counts.queued > 0 ? `${counts.queued} queued` : undefined, counts.skipped > 0 ? `${counts.skipped} skipped` : undefined, ]); const phasePart = phase ? ` — ${phase}` : ""; return `Workflow ${name} running${phasePart} · ${countParts.join(", ")} · ${formatDuration(state.duration)}`; } const WORKFLOW_PHASE_ROW_LIMIT = 4; const WORKFLOW_UNPHASED_GROUP = "workflow"; type NormalizedAgentCallStatus = | "queued" | "running" | "done" | "error" | "skipped"; function formatWorkflowProgressHeader( state: WorkflowProgressState, agentCalls: WorkflowAgentCall[] ): string { const counts = countAgentCallStatuses(agentCalls); let suffix = ""; if (counts.errors > 0) { suffix = `, ${counts.errors} errors`; } else if (counts.running > 0) { suffix = `, ${counts.running} running`; } const name = typeof state.meta.name === "string" ? state.meta.name : "workflow"; return `◆ Workflow: ${name} (${counts.done}/${agentCalls.length} done${suffix}, ${formatDuration(state.duration)})`; } function getWorkflowAgentCalls( state: WorkflowProgressState ): WorkflowAgentCall[] { return Array.isArray(state.agentCalls) ? state.agentCalls : []; } function getActiveWorkflowAgents( state: WorkflowProgressState ): WorkflowAgentChild[] { return state.agents.filter((agent) => { const status = normalizeAgentCallStatus(agent.status); return status === "queued" || status === "running"; }); } function formatWorkflowAgentChild(agent: WorkflowAgentChild): string { return `${agent.type} ${agent.status}`; } function groupAgentCallsByPhase( calls: WorkflowAgentCall[] ): Map { const grouped = new Map(); for (const call of calls) { const phaseName = call.phase ?? WORKFLOW_UNPHASED_GROUP; const group = grouped.get(phaseName) ?? []; group.push(call); grouped.set(phaseName, group); } for (const group of grouped.values()) { group.sort((left, right) => left.index - right.index); } return grouped; } function getVisiblePhaseNames( state: WorkflowProgressState, callsByPhase: ReadonlyMap ): string[] { const names: string[] = []; const seen = new Set(); const phases = [...state.phases].sort( (left, right) => left.index - right.index ); for (const phase of phases) { if (callsByPhase.has(phase.name) || phase.name === state.currentPhase) { names.push(phase.name); seen.add(phase.name); } } for (const [phaseName] of callsByPhase) { if (!seen.has(phaseName)) { names.push(phaseName); } } if ( state.currentPhase && !seen.has(state.currentPhase) && !callsByPhase.has(state.currentPhase) ) { names.push(state.currentPhase); } return names; } interface AgentCallStatusCounts { done: number; errors: number; queued: number; running: number; skipped: number; } function countAgentCallStatuses( calls: WorkflowAgentCall[] ): AgentCallStatusCounts { const counts: AgentCallStatusCounts = { done: 0, errors: 0, queued: 0, running: 0, skipped: 0, }; for (const call of calls) { switch (normalizeAgentCallStatus(call.status)) { case "queued": counts.queued += 1; break; case "running": counts.running += 1; break; case "done": counts.done += 1; break; case "error": counts.errors += 1; break; case "skipped": counts.skipped += 1; break; } } return counts; } function formatPhaseSummary(calls: WorkflowAgentCall[]): string { const counts = countAgentCallStatuses(calls); const suffixes = compactWorkflowTextParts([ counts.running > 0 ? `${counts.running} running` : undefined, counts.errors > 0 ? `${counts.errors} errors` : undefined, counts.skipped > 0 ? `${counts.skipped} skipped` : undefined, ]); const suffix = suffixes.length > 0 ? ` · ${suffixes.join(" · ")}` : ""; return `${counts.done}/${calls.length}${suffix}`; } function getPhaseMarker( state: WorkflowProgressState, phaseName: string, calls: WorkflowAgentCall[] ): "▶" | "✓" | " " { const counts = countAgentCallStatuses(calls); const terminalCount = counts.done + counts.errors + counts.skipped; if ( counts.running > 0 || (phaseName === state.currentPhase && !isTerminalWorkflowProgress(state) && (calls.length === 0 || terminalCount < calls.length)) ) { return "▶"; } if (calls.length > 0 && terminalCount === calls.length) { return "✓"; } return " "; } function isTerminalWorkflowProgress(state: WorkflowProgressState): boolean { const status = (state as { status?: string }).status; return status === "completed" || status === "stopped" || status === "error"; } function getAgentCallStatusIcon(status: WorkflowAgentCall["status"]): string { switch (normalizeAgentCallStatus(status)) { case "queued": return "○"; case "running": return "●"; case "done": return "✓"; case "error": return "✗"; case "skipped": return "-"; } } function normalizeAgentCallStatus( status: WorkflowAgentCall["status"] | string | undefined ): NormalizedAgentCallStatus { switch (status) { case "queued": return "queued"; case "completed": case "done": case "success": return "done"; case "error": case "failed": return "error"; case "skipped": return "skipped"; default: return "running"; } } function compactWorkflowTextParts(parts: Array): string[] { return parts.filter((part): part is string => part !== undefined); } function formatCompactLine(value: string): string { return value.trim().replace(/\s+/g, " "); } function formatAgentCallSnippet(call: WorkflowAgentCall): string | undefined { if (call.error) { return `error: ${formatSnippetValue(call.error)}`; } if (call.result === undefined) { return undefined; } return formatSnippetValue(call.result); } function formatSnippetValue(value: unknown): string { const raw = typeof value === "string" ? value : JSON.stringify(value); return formatCompactLine(raw ?? "").slice(0, 120); } function formatMetaValue(value: unknown): string { if (typeof value === "string") { return value; } return JSON.stringify(value); } function formatDuration(ms: number | undefined): string { if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) { return "0.0s"; } return `${(ms / 1000).toFixed(1)}s`; } function formatCount(count: number, label: string): string { return `${count} ${label}${count === 1 ? "" : "s"}`; }