@signalridge/pi-workflows 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +31 -1
- package/package.json +2 -2
- package/skills/workflow-authoring/SKILL.md +1 -1
- package/skills/workflow-authoring/references/capabilities.md +1 -1
- package/skills/workflow-authoring/references/capability-details.md +1 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +1 -1
- package/src/engine.ts +1088 -395
- package/src/index.ts +286 -53
- package/src/inspection.ts +544 -0
- package/src/journal.ts +33 -11
- package/src/orchestrator.ts +22 -4
- package/src/runtime.ts +387 -148
- package/src/status-actions.ts +143 -0
- package/src/ui-bus.ts +243 -0
- package/src/ui-command-center.ts +1032 -0
- package/src/ui-inspection.ts +206 -0
- package/src/ui-presentation.ts +196 -0
- package/src/ui.ts +43 -265
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/** Read-only, bounded presentation facts. No prompts, args, histories, or full results. */
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import type { ScriptRun } from "./journal.js";
|
|
4
|
+
import {
|
|
5
|
+
type AgentRunOptions,
|
|
6
|
+
MAX_RUNTIME_IDENTITY_LENGTH,
|
|
7
|
+
type WorkflowRunOptions,
|
|
8
|
+
type WorkflowRuntimeEvent,
|
|
9
|
+
} from "./runtime.js";
|
|
10
|
+
|
|
11
|
+
export const INSPECTION_LIMITS = {
|
|
12
|
+
calls: 2_048,
|
|
13
|
+
phases: 256,
|
|
14
|
+
tasks: 256,
|
|
15
|
+
events: 128,
|
|
16
|
+
text: 512,
|
|
17
|
+
preview: 300,
|
|
18
|
+
} as const;
|
|
19
|
+
export type InspectionStatus =
|
|
20
|
+
| "unknown"
|
|
21
|
+
| "pending"
|
|
22
|
+
| "queued"
|
|
23
|
+
| "running"
|
|
24
|
+
| "completed"
|
|
25
|
+
| "failed"
|
|
26
|
+
| "stopped"
|
|
27
|
+
| "interrupted";
|
|
28
|
+
export interface WorkflowInspectionPhase {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly frameId: string;
|
|
31
|
+
/** Opaque frame-scoped hash of the full, untruncated title. */
|
|
32
|
+
readonly phaseKey?: string;
|
|
33
|
+
readonly title: string;
|
|
34
|
+
readonly detail?: string;
|
|
35
|
+
readonly declared: boolean;
|
|
36
|
+
/** Only an explicit phase event proves entry; attribution and metadata do not. */
|
|
37
|
+
readonly entered: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface WorkflowInspectionCall {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly frameId: string;
|
|
42
|
+
readonly index: number;
|
|
43
|
+
readonly kind: "agent" | "workflow" | "checkpoint" | "unknown";
|
|
44
|
+
readonly label?: string;
|
|
45
|
+
readonly phase?: string;
|
|
46
|
+
/** Optional for legacy adapters; never infer this from a display label. */
|
|
47
|
+
readonly phaseKey?: string;
|
|
48
|
+
readonly agentType?: string;
|
|
49
|
+
readonly tier?: string;
|
|
50
|
+
readonly graphId?: string;
|
|
51
|
+
readonly taskKey?: string;
|
|
52
|
+
readonly taskId?: string;
|
|
53
|
+
readonly status: InspectionStatus;
|
|
54
|
+
/** Physical terminal status can differ from the script's logical outcome. */
|
|
55
|
+
readonly physicalStatus?: InspectionStatus;
|
|
56
|
+
readonly physicalDispatches?: number;
|
|
57
|
+
readonly replayed?: boolean;
|
|
58
|
+
readonly replayedAgentCount?: number;
|
|
59
|
+
readonly childFrameId?: string;
|
|
60
|
+
readonly observed: boolean;
|
|
61
|
+
readonly journalOnly: boolean;
|
|
62
|
+
readonly agentId?: string;
|
|
63
|
+
readonly attemptId?: string;
|
|
64
|
+
readonly outputFile?: string;
|
|
65
|
+
readonly tokenCount?: number;
|
|
66
|
+
readonly compactionCount?: number;
|
|
67
|
+
readonly resultPreview?: string;
|
|
68
|
+
readonly errorPreview?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface WorkflowInspectionTask {
|
|
71
|
+
readonly id: string;
|
|
72
|
+
readonly frameId: string;
|
|
73
|
+
readonly graphId: string;
|
|
74
|
+
readonly taskId: string;
|
|
75
|
+
readonly description?: string;
|
|
76
|
+
readonly phase?: string;
|
|
77
|
+
readonly attempt: number;
|
|
78
|
+
readonly errorPreview?: string;
|
|
79
|
+
readonly status: InspectionStatus | "skipped";
|
|
80
|
+
}
|
|
81
|
+
export interface WorkflowInspectionEvent {
|
|
82
|
+
readonly id: string;
|
|
83
|
+
readonly type: WorkflowRuntimeEvent["type"] | "log";
|
|
84
|
+
readonly frameId: string;
|
|
85
|
+
readonly stage?: string;
|
|
86
|
+
readonly childFrameId?: string;
|
|
87
|
+
readonly title?: string;
|
|
88
|
+
readonly graphId?: string;
|
|
89
|
+
readonly taskKey?: string;
|
|
90
|
+
readonly helperId?: string;
|
|
91
|
+
readonly helper?: string;
|
|
92
|
+
readonly outcome?: string;
|
|
93
|
+
readonly accepted?: boolean;
|
|
94
|
+
readonly attempt?: number;
|
|
95
|
+
readonly message?: string;
|
|
96
|
+
}
|
|
97
|
+
export interface WorkflowInspection {
|
|
98
|
+
readonly runId: string;
|
|
99
|
+
/** Opaque engine-state/execution identity; never use runId alone to own a UI mount. */
|
|
100
|
+
readonly executionId: string;
|
|
101
|
+
readonly executionGeneration: number;
|
|
102
|
+
readonly revision: number;
|
|
103
|
+
readonly name: string;
|
|
104
|
+
readonly status: ScriptRun["status"];
|
|
105
|
+
readonly observationSource: "execution" | "journal-only";
|
|
106
|
+
readonly restored: boolean;
|
|
107
|
+
readonly currentPhase?: string;
|
|
108
|
+
readonly currentPhaseKey?: string;
|
|
109
|
+
readonly phases: readonly WorkflowInspectionPhase[];
|
|
110
|
+
readonly calls: readonly WorkflowInspectionCall[];
|
|
111
|
+
readonly tasks: readonly WorkflowInspectionTask[];
|
|
112
|
+
readonly events: readonly WorkflowInspectionEvent[];
|
|
113
|
+
/** Counters cover observed callbacks, not anonymous historical journal records. */
|
|
114
|
+
readonly logicalCalls: number;
|
|
115
|
+
readonly replayedCalls: number;
|
|
116
|
+
readonly physicalDispatches: number;
|
|
117
|
+
readonly truncated: boolean;
|
|
118
|
+
readonly resultPreview?: string;
|
|
119
|
+
readonly errorPreview?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type WorkflowInspectionHeader = Pick<
|
|
123
|
+
WorkflowInspection,
|
|
124
|
+
"currentPhase" | "currentPhaseKey" | "restored" | "revision" | "executionId" | "executionGeneration"
|
|
125
|
+
>;
|
|
126
|
+
|
|
127
|
+
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|
|
128
|
+
type StartEvent = Parameters<NonNullable<WorkflowRunOptions["onAgentStart"]>>[0];
|
|
129
|
+
type EndEvent = Parameters<NonNullable<WorkflowRunOptions["onAgentEnd"]>>[0];
|
|
130
|
+
const text = (value: unknown, limit: number = INSPECTION_LIMITS.text): string | undefined =>
|
|
131
|
+
typeof value === "string" ? value.slice(0, limit) : undefined;
|
|
132
|
+
function identity(value: unknown): string | undefined {
|
|
133
|
+
if (value === undefined) return undefined;
|
|
134
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_RUNTIME_IDENTITY_LENGTH)
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Inspection identity must be a non-empty string of at most ${MAX_RUNTIME_IDENTITY_LENGTH} characters`,
|
|
137
|
+
);
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
function preview(value: unknown): string | undefined {
|
|
141
|
+
if (value === undefined) return undefined;
|
|
142
|
+
if (typeof value === "string") return value.slice(0, INSPECTION_LIMITS.preview);
|
|
143
|
+
try {
|
|
144
|
+
return JSON.stringify(value)?.slice(0, INSPECTION_LIMITS.preview);
|
|
145
|
+
} catch {
|
|
146
|
+
return "[unavailable]";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const phaseKey = (frameId: string, title: string): string =>
|
|
150
|
+
createHash("sha256")
|
|
151
|
+
.update(JSON.stringify([frameId, title]))
|
|
152
|
+
.digest("hex");
|
|
153
|
+
function status(value: unknown): InspectionStatus {
|
|
154
|
+
return value === "pending" ||
|
|
155
|
+
value === "queued" ||
|
|
156
|
+
value === "running" ||
|
|
157
|
+
value === "completed" ||
|
|
158
|
+
value === "failed" ||
|
|
159
|
+
value === "stopped" ||
|
|
160
|
+
value === "interrupted"
|
|
161
|
+
? value
|
|
162
|
+
: "unknown";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Engine-owned projection; public consumers receive detached snapshots only. */
|
|
166
|
+
export class WorkflowPresentation {
|
|
167
|
+
private executionId = randomUUID();
|
|
168
|
+
private revision = 0;
|
|
169
|
+
private observed = false;
|
|
170
|
+
private truncated = false;
|
|
171
|
+
private currentPhase?: string;
|
|
172
|
+
private currentPhaseKey?: string;
|
|
173
|
+
private readonly calls = new Map<string, Mutable<WorkflowInspectionCall>>();
|
|
174
|
+
private readonly phases = new Map<string, Mutable<WorkflowInspectionPhase>>();
|
|
175
|
+
private readonly tasks = new Map<string, Mutable<WorkflowInspectionTask>>();
|
|
176
|
+
private readonly events: WorkflowInspectionEvent[] = [];
|
|
177
|
+
private logicalCalls = 0;
|
|
178
|
+
private replayedCalls = 0;
|
|
179
|
+
private physicalDispatches = 0;
|
|
180
|
+
constructor(
|
|
181
|
+
private readonly rootFrameId: string,
|
|
182
|
+
private readonly restored = false,
|
|
183
|
+
) {
|
|
184
|
+
identity(rootFrameId);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
reset(observed = true): void {
|
|
188
|
+
this.executionId = randomUUID();
|
|
189
|
+
this.observed = observed;
|
|
190
|
+
this.currentPhase = undefined;
|
|
191
|
+
this.currentPhaseKey = undefined;
|
|
192
|
+
this.calls.clear();
|
|
193
|
+
this.phases.clear();
|
|
194
|
+
this.tasks.clear();
|
|
195
|
+
this.events.length = 0;
|
|
196
|
+
this.logicalCalls = this.replayedCalls = this.physicalDispatches = 0;
|
|
197
|
+
this.truncated = false;
|
|
198
|
+
this.revision++;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
changed(): void {
|
|
202
|
+
this.revision++;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private call(id: string, frameId: string, index: number): Mutable<WorkflowInspectionCall> {
|
|
206
|
+
identity(id);
|
|
207
|
+
identity(frameId);
|
|
208
|
+
const existing = this.calls.get(id);
|
|
209
|
+
if (existing) return existing;
|
|
210
|
+
this.logicalCalls++;
|
|
211
|
+
if (this.calls.size >= INSPECTION_LIMITS.calls) {
|
|
212
|
+
this.calls.delete(this.calls.keys().next().value ?? "");
|
|
213
|
+
this.truncated = true;
|
|
214
|
+
}
|
|
215
|
+
const call: Mutable<WorkflowInspectionCall> = {
|
|
216
|
+
id,
|
|
217
|
+
frameId,
|
|
218
|
+
index,
|
|
219
|
+
kind: "unknown",
|
|
220
|
+
status: "unknown",
|
|
221
|
+
observed: true,
|
|
222
|
+
journalOnly: false,
|
|
223
|
+
};
|
|
224
|
+
this.calls.set(id, call);
|
|
225
|
+
return call;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
agentStart(event: StartEvent): void {
|
|
229
|
+
for (const key of [event.id, event.frameId, event.graphId, event.taskKey, event.taskId, event.phaseKey])
|
|
230
|
+
identity(key);
|
|
231
|
+
const frameId = event.frameId ?? event.id.slice(0, event.id.lastIndexOf(":"));
|
|
232
|
+
const index = event.callIndex ?? Number(event.id.slice(event.id.lastIndexOf(":") + 1));
|
|
233
|
+
const call = this.call(event.id, frameId, index);
|
|
234
|
+
Object.assign(call, {
|
|
235
|
+
kind: "agent",
|
|
236
|
+
label: text(event.label),
|
|
237
|
+
phase: text(event.phase),
|
|
238
|
+
phaseKey: identity(event.phaseKey),
|
|
239
|
+
agentType: text(event.agentType),
|
|
240
|
+
tier: text(event.tier),
|
|
241
|
+
graphId: identity(event.graphId),
|
|
242
|
+
taskKey: identity(event.taskKey),
|
|
243
|
+
taskId: identity(event.taskId),
|
|
244
|
+
status: "running",
|
|
245
|
+
replayed: event.replayed === true,
|
|
246
|
+
physicalDispatches: call.physicalDispatches ?? 0,
|
|
247
|
+
});
|
|
248
|
+
if (event.replayed) this.replayedCalls++;
|
|
249
|
+
this.revision++;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
agentEnd(event: EndEvent): void {
|
|
253
|
+
identity(event.id);
|
|
254
|
+
const call = this.calls.get(event.id);
|
|
255
|
+
if (!call) return;
|
|
256
|
+
call.status = event.error ? "failed" : "completed";
|
|
257
|
+
call.tier = text(event.tier) ?? call.tier;
|
|
258
|
+
call.resultPreview = preview(event.result);
|
|
259
|
+
call.errorPreview = text(event.error, INSPECTION_LIMITS.preview);
|
|
260
|
+
call.tokenCount = event.tokens;
|
|
261
|
+
this.revision++;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
dispatch(options: AgentRunOptions, attemptId: string): void {
|
|
265
|
+
identity(attemptId);
|
|
266
|
+
const frameId = options.runId ?? this.rootFrameId;
|
|
267
|
+
const index = options.callIndex ?? 0;
|
|
268
|
+
const call = this.call(`${frameId}:${index}`, frameId, index);
|
|
269
|
+
call.physicalDispatches = (call.physicalDispatches ?? 0) + 1;
|
|
270
|
+
call.physicalStatus = "pending";
|
|
271
|
+
call.attemptId = identity(attemptId);
|
|
272
|
+
// An earlier retry's terminal identity/preview is not this new attempt.
|
|
273
|
+
call.agentId = call.resultPreview = call.errorPreview = call.outputFile = undefined;
|
|
274
|
+
call.tokenCount = call.compactionCount = undefined;
|
|
275
|
+
this.physicalDispatches++;
|
|
276
|
+
this.revision++;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
managed(
|
|
280
|
+
nodeId: string,
|
|
281
|
+
fact: {
|
|
282
|
+
status?: string;
|
|
283
|
+
agentId?: string;
|
|
284
|
+
tier?: string;
|
|
285
|
+
outputFile?: string;
|
|
286
|
+
tokenCount?: number;
|
|
287
|
+
compactionCount?: number;
|
|
288
|
+
},
|
|
289
|
+
): void {
|
|
290
|
+
identity(nodeId);
|
|
291
|
+
identity(fact.agentId);
|
|
292
|
+
const id = nodeId.includes(":") ? nodeId : `${this.rootFrameId}:${nodeId}`;
|
|
293
|
+
const call = this.calls.get(id);
|
|
294
|
+
if (!call) return;
|
|
295
|
+
if (fact.status !== undefined) call.physicalStatus = status(fact.status);
|
|
296
|
+
if (fact.agentId !== undefined) call.agentId = identity(fact.agentId);
|
|
297
|
+
if (fact.tier !== undefined) call.tier = text(fact.tier);
|
|
298
|
+
if (fact.outputFile !== undefined) call.outputFile = text(fact.outputFile);
|
|
299
|
+
if (fact.tokenCount !== undefined) call.tokenCount = fact.tokenCount;
|
|
300
|
+
if (fact.compactionCount !== undefined) call.compactionCount = fact.compactionCount;
|
|
301
|
+
this.revision++;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
journalResult(frameId: string, index: number, result: unknown): void {
|
|
305
|
+
identity(frameId);
|
|
306
|
+
const call = this.calls.get(`${frameId}:${index}`);
|
|
307
|
+
if (!call) return;
|
|
308
|
+
call.resultPreview = preview(result);
|
|
309
|
+
this.revision++;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
log(message: string, frameId = this.rootFrameId): void {
|
|
313
|
+
identity(frameId);
|
|
314
|
+
this.record({ type: "log", frameId, message: text(message) });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
runtime(event: WorkflowRuntimeEvent): void {
|
|
318
|
+
const rawFrameId = typeof event.frameId === "string" ? event.frameId : this.rootFrameId;
|
|
319
|
+
const frameId = identity(rawFrameId) ?? this.rootFrameId;
|
|
320
|
+
// Validate before retaining state: display limits must never alias keys.
|
|
321
|
+
for (const key of ["runId", "childFrameId", "graphId", "taskKey", "taskId", "helperId", "phaseKey"])
|
|
322
|
+
identity(event[key]);
|
|
323
|
+
if (event.type === "phase" && typeof event.title === "string") {
|
|
324
|
+
const title = text(event.title) ?? "";
|
|
325
|
+
// Hash exact titles so bounded display labels cannot alias identities or retain unbounded input.
|
|
326
|
+
const key = phaseKey(rawFrameId, event.title);
|
|
327
|
+
if (rawFrameId === this.rootFrameId) {
|
|
328
|
+
this.currentPhase = title;
|
|
329
|
+
this.currentPhaseKey = key;
|
|
330
|
+
}
|
|
331
|
+
if (!this.phases.has(key)) {
|
|
332
|
+
if (this.phases.size >= INSPECTION_LIMITS.phases) {
|
|
333
|
+
this.phases.delete(this.phases.keys().next().value ?? "");
|
|
334
|
+
this.truncated = true;
|
|
335
|
+
}
|
|
336
|
+
this.phases.set(key, {
|
|
337
|
+
id: `${frameId}:phase-${this.revision}`,
|
|
338
|
+
frameId,
|
|
339
|
+
phaseKey: key,
|
|
340
|
+
title,
|
|
341
|
+
declared: false,
|
|
342
|
+
entered: true,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (event.type === "task" && typeof event.taskKey === "string" && typeof event.taskId === "string") {
|
|
347
|
+
const id = identity(event.taskKey) ?? "";
|
|
348
|
+
if (!this.tasks.has(id) && this.tasks.size >= INSPECTION_LIMITS.tasks) {
|
|
349
|
+
this.tasks.delete(this.tasks.keys().next().value ?? "");
|
|
350
|
+
this.truncated = true;
|
|
351
|
+
}
|
|
352
|
+
this.tasks.set(id, {
|
|
353
|
+
id,
|
|
354
|
+
frameId,
|
|
355
|
+
graphId: identity(event.graphId) ?? "",
|
|
356
|
+
taskId: identity(event.taskId) ?? "",
|
|
357
|
+
description: text(event.description),
|
|
358
|
+
phase: text(event.phase),
|
|
359
|
+
attempt: typeof event.attempt === "number" ? event.attempt : 0,
|
|
360
|
+
errorPreview: text(event.error, INSPECTION_LIMITS.preview),
|
|
361
|
+
status: event.stage === "skip" ? "skipped" : event.stage === "end" ? status(event.status) : "running",
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
if (event.type === "control-attempt" && event.helper === "checkpoint" && typeof event.callIndex === "number") {
|
|
365
|
+
const call = this.call(`${frameId}:${event.callIndex}`, frameId, event.callIndex);
|
|
366
|
+
call.kind = "checkpoint";
|
|
367
|
+
call.label = "checkpoint";
|
|
368
|
+
call.phase = text(event.phase);
|
|
369
|
+
call.phaseKey = identity(event.phaseKey);
|
|
370
|
+
call.graphId = identity(event.graphId);
|
|
371
|
+
call.taskKey = identity(event.taskKey);
|
|
372
|
+
call.taskId = identity(event.taskId);
|
|
373
|
+
call.physicalDispatches = 0;
|
|
374
|
+
call.replayed = event.stage === "replay";
|
|
375
|
+
if (call.replayed) this.replayedCalls++;
|
|
376
|
+
call.status =
|
|
377
|
+
event.stage === "start"
|
|
378
|
+
? "running"
|
|
379
|
+
: event.outcome === "cancelled"
|
|
380
|
+
? "interrupted"
|
|
381
|
+
: event.outcome === "failed"
|
|
382
|
+
? "failed"
|
|
383
|
+
: "completed";
|
|
384
|
+
}
|
|
385
|
+
if (event.type === "workflow" && typeof event.callIndex === "number") {
|
|
386
|
+
const call = this.call(`${frameId}:${event.callIndex}`, frameId, event.callIndex);
|
|
387
|
+
call.kind = "workflow";
|
|
388
|
+
call.childFrameId = identity(event.childFrameId);
|
|
389
|
+
call.phase = text(event.phase);
|
|
390
|
+
call.phaseKey = identity(event.phaseKey);
|
|
391
|
+
call.graphId = identity(event.graphId);
|
|
392
|
+
call.taskKey = identity(event.taskKey);
|
|
393
|
+
call.taskId = identity(event.taskId);
|
|
394
|
+
// Only the resolver can prove a saved name. Unknown/legacy sources fail closed.
|
|
395
|
+
call.label = event.sourceKind === "saved" ? (text(event.name) ?? "nested workflow") : "nested workflow";
|
|
396
|
+
call.replayed = event.stage === "replay";
|
|
397
|
+
call.replayedAgentCount = typeof event.replayedAgentCount === "number" ? event.replayedAgentCount : undefined;
|
|
398
|
+
if (event.stage === "replay") this.replayedCalls++;
|
|
399
|
+
call.status =
|
|
400
|
+
event.stage === "start"
|
|
401
|
+
? "running"
|
|
402
|
+
: event.outcome === "cancelled"
|
|
403
|
+
? "interrupted"
|
|
404
|
+
: event.outcome === "failed"
|
|
405
|
+
? "failed"
|
|
406
|
+
: "completed";
|
|
407
|
+
}
|
|
408
|
+
this.record({
|
|
409
|
+
type: event.type,
|
|
410
|
+
frameId,
|
|
411
|
+
stage: text(event.stage),
|
|
412
|
+
childFrameId: identity(event.childFrameId),
|
|
413
|
+
title: text(event.title),
|
|
414
|
+
graphId: identity(event.graphId),
|
|
415
|
+
taskKey: identity(event.taskKey),
|
|
416
|
+
helperId: identity(event.helperId),
|
|
417
|
+
helper: text(event.helper),
|
|
418
|
+
outcome: text(event.outcome),
|
|
419
|
+
accepted: typeof event.accepted === "boolean" ? event.accepted : undefined,
|
|
420
|
+
attempt: typeof event.attempt === "number" ? event.attempt : undefined,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private record(event: Omit<WorkflowInspectionEvent, "id">): void {
|
|
425
|
+
this.events.push({ ...event, id: `${this.executionId}:event-${++this.revision}` });
|
|
426
|
+
if (this.events.length > INSPECTION_LIMITS.events) {
|
|
427
|
+
this.events.shift();
|
|
428
|
+
this.truncated = true;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
settle(runStatus: ScriptRun["status"]): void {
|
|
433
|
+
// Stop owns its terminal barrier; draining execution alone is not settlement.
|
|
434
|
+
if (runStatus === "stopping") return;
|
|
435
|
+
for (const call of this.calls.values()) {
|
|
436
|
+
if (call.status === "running" || call.status === "pending" || call.status === "queued") {
|
|
437
|
+
// A final workflow outcome does not prove unpaired calls succeeded.
|
|
438
|
+
call.status = runStatus === "stopped" ? "stopped" : "interrupted";
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
this.revision++;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
header(executionGeneration: number): WorkflowInspectionHeader {
|
|
445
|
+
return {
|
|
446
|
+
currentPhase: this.currentPhase,
|
|
447
|
+
currentPhaseKey: this.currentPhaseKey,
|
|
448
|
+
restored: this.restored && !this.observed,
|
|
449
|
+
revision: this.revision,
|
|
450
|
+
executionId: this.executionId,
|
|
451
|
+
executionGeneration,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
snapshot(run: ScriptRun, executionGeneration: number): WorkflowInspection {
|
|
456
|
+
identity(run.runId);
|
|
457
|
+
const phases: WorkflowInspectionPhase[] = [];
|
|
458
|
+
const declaredKeys = new Set<string>();
|
|
459
|
+
for (const [index, phase] of (run.meta.phases ?? []).slice(0, INSPECTION_LIMITS.phases).entries()) {
|
|
460
|
+
const key = phaseKey(this.rootFrameId, phase.title);
|
|
461
|
+
// Repeated literal declarations do not invent additional progress units.
|
|
462
|
+
if (declaredKeys.has(key)) continue;
|
|
463
|
+
declaredKeys.add(key);
|
|
464
|
+
phases.push({
|
|
465
|
+
id: `${this.rootFrameId}:declared-${index}`,
|
|
466
|
+
frameId: this.rootFrameId,
|
|
467
|
+
phaseKey: key,
|
|
468
|
+
title: text(phase.title) ?? "",
|
|
469
|
+
detail: text(phase.detail),
|
|
470
|
+
declared: true,
|
|
471
|
+
entered: this.phases.has(key),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
for (const [key, phase] of this.phases) {
|
|
475
|
+
if (!declaredKeys.has(key)) phases.push({ ...phase });
|
|
476
|
+
}
|
|
477
|
+
const calls = new Map<string, WorkflowInspectionCall>();
|
|
478
|
+
for (const [nodeId, durableStatus] of Object.entries(run.callStatus).slice(0, INSPECTION_LIMITS.calls)) {
|
|
479
|
+
const result = run.callResults[nodeId];
|
|
480
|
+
calls.set(`${this.rootFrameId}:${nodeId}`, {
|
|
481
|
+
id: `${this.rootFrameId}:${nodeId}`,
|
|
482
|
+
frameId: this.rootFrameId,
|
|
483
|
+
index: Number(nodeId),
|
|
484
|
+
kind: "unknown",
|
|
485
|
+
status: status(durableStatus),
|
|
486
|
+
observed: false,
|
|
487
|
+
journalOnly: true,
|
|
488
|
+
tier: text(result?.tier ?? run.callTiers[nodeId]?.tier),
|
|
489
|
+
// A terminal result without managed identity may be local work. Never attach a prior child's id.
|
|
490
|
+
agentId: identity(result === undefined ? run.agentIds[nodeId] : result.agentId),
|
|
491
|
+
attemptId: identity(run.attemptIds[nodeId]),
|
|
492
|
+
outputFile: text(result?.outputFile),
|
|
493
|
+
tokenCount: result?.tokenCount,
|
|
494
|
+
compactionCount: result?.compactionCount ?? run.compactions[nodeId],
|
|
495
|
+
resultPreview: preview(result?.result),
|
|
496
|
+
errorPreview: text(result?.error, INSPECTION_LIMITS.preview),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
for (const [id, observed] of this.calls) {
|
|
500
|
+
const durable = calls.get(id);
|
|
501
|
+
// Replayed calls may reference their retained historical child/results, never an invented live dispatch.
|
|
502
|
+
const replayFacts =
|
|
503
|
+
observed.replayed && durable?.status === "completed"
|
|
504
|
+
? {
|
|
505
|
+
agentId: durable.agentId,
|
|
506
|
+
attemptId: durable.attemptId,
|
|
507
|
+
outputFile: durable.outputFile,
|
|
508
|
+
compactionCount: durable.compactionCount,
|
|
509
|
+
resultPreview: durable.resultPreview,
|
|
510
|
+
}
|
|
511
|
+
: {};
|
|
512
|
+
const merged = { ...replayFacts, ...observed };
|
|
513
|
+
if (observed.replayed && observed.resultPreview === undefined) merged.resultPreview = replayFacts.resultPreview;
|
|
514
|
+
calls.set(id, merged);
|
|
515
|
+
}
|
|
516
|
+
const boundedCalls = [...calls.values()].slice(-INSPECTION_LIMITS.calls);
|
|
517
|
+
return structuredClone({
|
|
518
|
+
runId: run.runId,
|
|
519
|
+
executionId: this.executionId,
|
|
520
|
+
executionGeneration,
|
|
521
|
+
revision: this.revision,
|
|
522
|
+
name: text(run.meta.name) ?? "",
|
|
523
|
+
status: run.status,
|
|
524
|
+
observationSource: this.observed ? "execution" : "journal-only",
|
|
525
|
+
restored: this.restored && !this.observed,
|
|
526
|
+
currentPhase: this.currentPhase,
|
|
527
|
+
currentPhaseKey: this.currentPhaseKey,
|
|
528
|
+
phases: phases.slice(0, INSPECTION_LIMITS.phases),
|
|
529
|
+
calls: boundedCalls,
|
|
530
|
+
tasks: [...this.tasks.values()],
|
|
531
|
+
events: this.events,
|
|
532
|
+
logicalCalls: this.logicalCalls,
|
|
533
|
+
replayedCalls: this.replayedCalls,
|
|
534
|
+
physicalDispatches: this.physicalDispatches,
|
|
535
|
+
truncated:
|
|
536
|
+
this.truncated ||
|
|
537
|
+
calls.size > INSPECTION_LIMITS.calls ||
|
|
538
|
+
phases.length > INSPECTION_LIMITS.phases ||
|
|
539
|
+
(run.meta.phases?.length ?? 0) > INSPECTION_LIMITS.phases,
|
|
540
|
+
resultPreview: preview(run.finalResult),
|
|
541
|
+
errorPreview: text(run.error, INSPECTION_LIMITS.preview),
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
package/src/journal.ts
CHANGED
|
@@ -154,7 +154,7 @@ export interface ScriptRun {
|
|
|
154
154
|
callResults: Record<string, CallResult>;
|
|
155
155
|
/** Call index → resolved tier identity for live and stopped calls. */
|
|
156
156
|
callTiers: Record<string, CallTierIdentity>;
|
|
157
|
-
/**
|
|
157
|
+
/** Monotonic nested-result watermarks; retained even when a managed call invalidates replay. */
|
|
158
158
|
workflowResultGenerations: Record<string, number>;
|
|
159
159
|
compactions: Record<string, number>;
|
|
160
160
|
startedAt: number;
|
|
@@ -1358,9 +1358,10 @@ export function snapshotRun(run: ScriptRun): ScriptRun {
|
|
|
1358
1358
|
* The runtime keys its cache by `${runId}:${callIndex}` (the same namespacing
|
|
1359
1359
|
* the store deltas use), so a nested workflow() call with its own runId can
|
|
1360
1360
|
* never replay the parent's entry. Every `call_result` or `workflow_result`
|
|
1361
|
-
* event that carries a `callHash` is a replayable
|
|
1362
|
-
*
|
|
1363
|
-
*
|
|
1361
|
+
* event that carries a `callHash` is a replayable candidate only while it is
|
|
1362
|
+
* the aggregate's final completed result. Entries from another schema or
|
|
1363
|
+
* without a hash are not replayable, but accepted lifecycle history can still
|
|
1364
|
+
* supersede an older candidate.
|
|
1364
1365
|
*/
|
|
1365
1366
|
export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
1366
1367
|
string,
|
|
@@ -1386,7 +1387,7 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
|
1386
1387
|
agentCount?: number;
|
|
1387
1388
|
};
|
|
1388
1389
|
const journal = new Map<string, ResumeEntry>();
|
|
1389
|
-
const
|
|
1390
|
+
const journalSources = new Map<string, { runId: string; nodeId: string; result: CallResult }>();
|
|
1390
1391
|
|
|
1391
1392
|
// Reuse the authoritative replay validator so stale call results from an
|
|
1392
1393
|
// older attempt, terminal-after-lifecycle facts, and quarantined runs never
|
|
@@ -1409,6 +1410,10 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
|
1409
1410
|
: {}),
|
|
1410
1411
|
};
|
|
1411
1412
|
const key = `${event.runId}:${event.nodeId}`;
|
|
1413
|
+
// Parsing resolves the result's tier before application, so the
|
|
1414
|
+
// aggregate retains this exact accepted result object. Record even an
|
|
1415
|
+
// identical nested retry: it becomes the aggregate's latest identity.
|
|
1416
|
+
journalSources.set(key, { runId: event.runId, nodeId: event.nodeId, result: event.result });
|
|
1412
1417
|
const existing = journal.get(key);
|
|
1413
1418
|
if (candidate.generation !== undefined && existing?.generation !== undefined) {
|
|
1414
1419
|
if (candidate.generation < existing.generation) return;
|
|
@@ -1424,19 +1429,36 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
|
1424
1429
|
}
|
|
1425
1430
|
}
|
|
1426
1431
|
journal.set(key, candidate);
|
|
1427
|
-
journalRunIds.set(key, event.runId);
|
|
1428
1432
|
},
|
|
1429
1433
|
});
|
|
1430
1434
|
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
|
|
1435
|
-
|
|
1435
|
+
// Reconcile with authoritative final state, not a second event acceptance
|
|
1436
|
+
// parser. Generation rotations clear results; non-replayable replacements
|
|
1437
|
+
// (including recovery terminal results) replace their identity; removal and
|
|
1438
|
+
// quarantine discard the run. Rejected stale events change none of these.
|
|
1439
|
+
for (const [key, source] of journalSources) {
|
|
1440
|
+
const run = replayedRuns.get(source.runId);
|
|
1441
|
+
if (run?.callStatus[source.nodeId] !== "completed" || run.callResults[source.nodeId] !== source.result) {
|
|
1442
|
+
journal.delete(key);
|
|
1443
|
+
}
|
|
1436
1444
|
}
|
|
1437
1445
|
return journal;
|
|
1438
1446
|
}
|
|
1439
1447
|
|
|
1448
|
+
/**
|
|
1449
|
+
* Copy accepted aggregate watermarks for WorkflowRunOptions.resumeWorkflowGenerations.
|
|
1450
|
+
* Not a result cache: kind changes, rotations and failed results must not erase
|
|
1451
|
+
* an earlier nested generation or make its old value eligible for replay.
|
|
1452
|
+
* Supply a run returned by replayJournal (or the engine's accepted live aggregate).
|
|
1453
|
+
*/
|
|
1454
|
+
export function buildWorkflowGenerations(
|
|
1455
|
+
run: Pick<ScriptRun, "workflowResultGenerations">,
|
|
1456
|
+
): ReadonlyMap<number, number> {
|
|
1457
|
+
return new Map(
|
|
1458
|
+
Object.entries(run.workflowResultGenerations).map(([index, generation]) => [Number(index), generation]),
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1440
1462
|
/** Derive a stable recovery id from a recovery event's content. */
|
|
1441
1463
|
export function deriveRecoveryId(event: { runId: string; rotations: unknown[]; timestamp: number }): string {
|
|
1442
1464
|
const digest = createHash("sha256")
|
package/src/orchestrator.ts
CHANGED
|
@@ -252,6 +252,12 @@ function emit(hooks: OrchestrationHooks, event: OrchestrationTaskEvent): void {
|
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
function detachmentError(task: OrchestrationTask, error: unknown): TypeError {
|
|
256
|
+
return new TypeError(
|
|
257
|
+
`orchestrate() task "${task.id}" results cannot be detached: ${error instanceof Error ? error.message : String(error)}`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
255
261
|
function snapshotContext(
|
|
256
262
|
task: OrchestrationTask,
|
|
257
263
|
attempt: number,
|
|
@@ -262,9 +268,7 @@ function snapshotContext(
|
|
|
262
268
|
try {
|
|
263
269
|
detachedResults = structuredClone(values);
|
|
264
270
|
} catch (error: unknown) {
|
|
265
|
-
throw
|
|
266
|
-
`orchestrate() task "${task.id}" results cannot be detached: ${error instanceof Error ? error.message : String(error)}`,
|
|
267
|
-
);
|
|
271
|
+
throw detachmentError(task, error);
|
|
268
272
|
}
|
|
269
273
|
return Object.freeze({
|
|
270
274
|
id: task.id,
|
|
@@ -361,6 +365,19 @@ export async function executeOrchestration(
|
|
|
361
365
|
});
|
|
362
366
|
}
|
|
363
367
|
|
|
368
|
+
// Every attempt in this layer sees the same barrier state, even after a
|
|
369
|
+
// sibling settles. Detach once here, then again for each callback so nested
|
|
370
|
+
// mutations cannot alter the baseline or another attempt's input.
|
|
371
|
+
const layerStatuses = { ...statuses };
|
|
372
|
+
let layerValues: Record<string, unknown> = {};
|
|
373
|
+
let layerCloneError: { error: unknown } | undefined;
|
|
374
|
+
try {
|
|
375
|
+
layerValues = structuredClone(values);
|
|
376
|
+
} catch (error: unknown) {
|
|
377
|
+
// Keep detachment failures inside the existing per-task retry/error path.
|
|
378
|
+
layerCloneError = { error };
|
|
379
|
+
}
|
|
380
|
+
|
|
364
381
|
const outcomes = await Promise.all(
|
|
365
382
|
ready.map(async (task): Promise<{ task: OrchestrationTask; fatal?: { error: unknown } }> => {
|
|
366
383
|
const record = records.get(task.id);
|
|
@@ -369,7 +386,8 @@ export async function executeOrchestration(
|
|
|
369
386
|
record.attempts = attempt;
|
|
370
387
|
try {
|
|
371
388
|
hooks.throwIfAborted?.();
|
|
372
|
-
|
|
389
|
+
if (layerCloneError) throw detachmentError(task, layerCloneError.error);
|
|
390
|
+
const value = await task.run(snapshotContext(task, attempt, layerValues, layerStatuses));
|
|
373
391
|
hooks.throwIfAborted?.();
|
|
374
392
|
pending.delete(task.id);
|
|
375
393
|
record.status = "completed";
|