Skip to content
Source
Sign inSign up

claudeWorkflowProgress.ts

186 lines7.2 KB

1import type {
2 OrchestrationV2SubagentWorkflow,
3 OrchestrationV2WorkflowAgent,
4 OrchestrationV2WorkflowPhase,
5 OrchestrationV2WorkflowRunHandles,
6} from "@t3tools/contracts";
7​
8import { field, text } from "../unknownField.ts";
9​
10export const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow";
11​
12export function hasClaudeWorkflowSnapshot(
13 message: unknown,
14): message is { readonly workflow_progress: ReadonlyArray<unknown> } {
15 const entries = field(message, "workflow_progress");
16 return Array.isArray(entries) && entries.length > 0;
18​
19const AGENT_STATES: Record<string, OrchestrationV2WorkflowAgent["state"] | undefined> = {
20 queued: "queued",
21 start: "running",
22 done: "completed",
23 error: "failed",
24};
25​
26function count(value: unknown): number | undefined {
27 return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
29​
30function timestamp(value: unknown): number | undefined {
31 const milliseconds = count(value);
32 return milliseconds !== undefined && milliseconds <= 8_640_000_000_000_000
33 ? milliseconds
34 : undefined;
36​
37function preview(value: unknown): string | undefined {
38 return typeof value === "string" && value.length > 0 ? value : undefined;
40​
41function optional<T>(key: string, value: T | undefined): Record<string, T> {
42 return value === undefined ? {} : ({ [key]: value } as Record<string, T>);
44​
45function parsePhase(entry: unknown): OrchestrationV2WorkflowPhase | null {
46 const index = count(field(entry, "index"));
47 const title = text(field(entry, "title"));
48 return index === undefined || title === undefined ? null : { index, title };
50​
51function parseAgent(entry: unknown): OrchestrationV2WorkflowAgent | null {
52 const index = count(field(entry, "index"));
53 const label = text(field(entry, "label"));
54 if (index === undefined || label === undefined) return null;
55 const reportedState = text(field(entry, "state")) ?? "";
56 const startedAt = timestamp(field(entry, "startedAt"));
57 // Claude marks members `start` when scheduled; they only run once a concurrency slot sets startedAt.
58 const state =
59 reportedState === "start" && startedAt === undefined
60 ? "queued"
61 : Object.hasOwn(AGENT_STATES, reportedState)
62 ? (AGENT_STATES[reportedState] ?? "running")
63 : "running";
64 return {
65 index,
66 label,
67 state,
68 ...optional("agentId", text(field(entry, "agentId"))),
69 ...optional("phaseIndex", count(field(entry, "phaseIndex"))),
70 ...optional("phaseTitle", text(field(entry, "phaseTitle"))),
71 ...optional("model", text(field(entry, "model"))),
72 ...optional("attempt", count(field(entry, "attempt"))),
73 ...optional("totalTokens", count(field(entry, "tokens"))),
74 ...optional("toolCalls", count(field(entry, "toolCalls"))),
75 ...optional("durationMs", count(field(entry, "durationMs"))),
76 ...optional("queuedAt", timestamp(field(entry, "queuedAt"))),
77 ...optional("startedAt", startedAt),
78 // Execution duration excludes startup waits; terminal progress carries the end instant.
79 ...optional(
80 "completedAt",
81 state === "completed" || state === "failed"
82 ? timestamp(field(entry, "lastProgressAt"))
83 : undefined,
84 ),
85 ...optional("prompt", preview(field(entry, "promptPreview"))),
86 ...optional("result", preview(field(entry, "resultPreview"))),
87 };
89​
90/** The background-launch ACK is the only frame carrying the run's filesystem handles. */
91export function parseClaudeWorkflowRunHandles(
92 toolUseResult: unknown,
93): OrchestrationV2WorkflowRunHandles | undefined {
94 if (text(field(toolUseResult, "taskType")) !== CLAUDE_WORKFLOW_TASK_TYPE) return undefined;
95 const handles = {
96 ...optional("runId", text(field(toolUseResult, "runId"))),
97 ...optional("transcriptDir", text(field(toolUseResult, "transcriptDir"))),
98 ...optional("scriptPath", text(field(toolUseResult, "scriptPath"))),
99 };
100 return Object.keys(handles).length === 0 ? undefined : handles;
102​
103function parseClaudeWorkflowProgress(message: unknown): {
104 readonly phases: ReadonlyArray<OrchestrationV2WorkflowPhase>;
105 readonly agents: ReadonlyArray<OrchestrationV2WorkflowAgent>;
106} | null {
107 if (!hasClaudeWorkflowSnapshot(message)) return null;
108 const phases: OrchestrationV2WorkflowPhase[] = [];
109 const agents: OrchestrationV2WorkflowAgent[] = [];
110 for (const entry of message.workflow_progress) {
111 const type = text(field(entry, "type"));
112 if (type === "workflow_phase") {
113 const phase = parsePhase(entry);
114 if (phase !== null) phases.push(phase);
115 } else if (type === "workflow_agent") {
116 const agent = parseAgent(entry);
117 if (agent !== null) agents.push(agent);
118 }
119 }
120 return phases.length === 0 && agents.length === 0 ? null : { phases, agents };
122​
123/** Returns undefined without workflow evidence; progress frames omit task_type and ordinary subagents also emit usage. */
124export function mergeClaudeWorkflowProgress(input: {
125 readonly previous: OrchestrationV2SubagentWorkflow | undefined;
126 readonly name?: string | undefined;
127 readonly runHandles?: OrchestrationV2WorkflowRunHandles | undefined;
128 readonly message: unknown;
129}): OrchestrationV2SubagentWorkflow | undefined {
130 const { previous } = input;
131 const snapshot = parseClaudeWorkflowProgress(input.message);
132 if (
133 previous === undefined &&
134 input.name === undefined &&
135 input.runHandles === undefined &&
136 snapshot === null
137 ) {
138 return undefined;
139 }
140 const reported = field(input.message, "usage");
141 const phasesByIndex = new Map(
142 (previous?.phases ?? []).map((phase) => [phase.index, phase] as const),
143 );
144 for (const phase of snapshot?.phases ?? []) phasesByIndex.set(phase.index, phase);
145 const agentsByIndex = new Map(
146 (previous?.agents ?? []).map((agent) => [agent.index, agent] as const),
147 );
148 for (const agent of snapshot?.agents ?? []) {
149 const prior = agentsByIndex.get(agent.index);
150 const attempt = agent.attempt ?? prior?.attempt ?? 1;
151 if (prior !== undefined && attempt < (prior.attempt ?? 1)) continue;
152 const restarted = prior !== undefined && attempt > (prior.attempt ?? 1);
153 const regressed =
154 !restarted &&
155 (((prior?.state === "completed" || prior?.state === "failed") &&
156 agent.state !== prior.state) ||
157 (prior?.state === "running" && agent.state === "queued"));
158 agentsByIndex.set(
159 agent.index,
160 regressed
161 ? { ...agent, ...prior }
162 : {
163 ...(restarted
164 ? {
165 ...optional("phaseIndex", prior.phaseIndex),
166 ...optional("phaseTitle", prior.phaseTitle),
167 }
168 : prior),
169 ...agent,
170 },
171 );
172 }
173 const runHandles =
174 input.runHandles === undefined
175 ? previous?.runHandles
176 : { ...previous?.runHandles, ...input.runHandles };
177 return {
178 ...optional("name", input.name ?? previous?.name),
179 ...optional("runHandles", runHandles),
180 phases: Array.from(phasesByIndex.values()).sort((left, right) => left.index - right.index),
181 agents: Array.from(agentsByIndex.values()).sort((left, right) => left.index - right.index),
182 ...optional("totalTokens", count(field(reported, "total_tokens")) ?? previous?.totalTokens),
183 ...optional("toolCalls", count(field(reported, "tool_uses")) ?? previous?.toolCalls),
184 ...optional("durationMs", count(field(reported, "duration_ms")) ?? previous?.durationMs),
185 };