@signalridge/pi-workflows 1.7.5 → 1.9.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 +26 -0
- package/README.md +157 -10
- package/package.json +3 -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/arming.ts +264 -3
- package/src/engine.ts +1212 -379
- package/src/errors.ts +26 -0
- package/src/index.ts +578 -225
- package/src/inspection.ts +544 -0
- package/src/journal.ts +38 -11
- package/src/orchestrator.ts +22 -4
- package/src/runtime.ts +427 -168
- package/src/safe-text.ts +4 -35
- 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 -241
- package/src/vm-bootstrap.ts +218 -0
- package/src/vm-helper.ts +502 -0
|
@@ -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;
|
|
@@ -537,6 +537,8 @@ function parseTerminalResult(raw: unknown, runId: string): RecoveryTerminalResul
|
|
|
537
537
|
}
|
|
538
538
|
|
|
539
539
|
function parseRunRecovery(raw: Record<string, unknown>, run: ScriptRun, timestamp: number): RunRecoveryEvent {
|
|
540
|
+
if (run.status === "completed" || run.nonResumable || run.terminalIntent === "stop")
|
|
541
|
+
throw new Error("cannot recover a completed or non-resumable workflow run");
|
|
540
542
|
if (raw.status !== "interrupted") throw new Error("run recovery status is invalid");
|
|
541
543
|
if (typeof raw.branchGeneration !== "number" || !Number.isInteger(raw.branchGeneration) || raw.branchGeneration < 0) {
|
|
542
544
|
throw new Error("run recovery branchGeneration is invalid");
|
|
@@ -1140,6 +1142,8 @@ export function applyRecoveryEvent(run: ScriptRun, event: RunRecoveryEvent | Att
|
|
|
1140
1142
|
if (event.kind === "run_recovery") {
|
|
1141
1143
|
run.status = "interrupted";
|
|
1142
1144
|
run.terminalIntent = undefined;
|
|
1145
|
+
run.error = undefined;
|
|
1146
|
+
run.finalResult = undefined;
|
|
1143
1147
|
for (const rotation of event.rotations) applyRecoveryRotation(run, rotation);
|
|
1144
1148
|
applyRecoveryTerminalResults(run, event.terminalResults ?? []);
|
|
1145
1149
|
} else {
|
|
@@ -1218,6 +1222,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1218
1222
|
break;
|
|
1219
1223
|
case "workflow_transition":
|
|
1220
1224
|
run.status = event.status;
|
|
1225
|
+
if (event.status === "running") run.error = undefined;
|
|
1221
1226
|
if (event.error !== undefined) run.error = event.error;
|
|
1222
1227
|
if (event.finalResult !== undefined) run.finalResult = structuredClone(event.finalResult);
|
|
1223
1228
|
if (event.terminalIntent !== undefined) run.terminalIntent = event.terminalIntent;
|
|
@@ -1353,9 +1358,10 @@ export function snapshotRun(run: ScriptRun): ScriptRun {
|
|
|
1353
1358
|
* The runtime keys its cache by `${runId}:${callIndex}` (the same namespacing
|
|
1354
1359
|
* the store deltas use), so a nested workflow() call with its own runId can
|
|
1355
1360
|
* never replay the parent's entry. Every `call_result` or `workflow_result`
|
|
1356
|
-
* event that carries a `callHash` is a replayable
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
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.
|
|
1359
1365
|
*/
|
|
1360
1366
|
export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
1361
1367
|
string,
|
|
@@ -1381,7 +1387,7 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
|
1381
1387
|
agentCount?: number;
|
|
1382
1388
|
};
|
|
1383
1389
|
const journal = new Map<string, ResumeEntry>();
|
|
1384
|
-
const
|
|
1390
|
+
const journalSources = new Map<string, { runId: string; nodeId: string; result: CallResult }>();
|
|
1385
1391
|
|
|
1386
1392
|
// Reuse the authoritative replay validator so stale call results from an
|
|
1387
1393
|
// older attempt, terminal-after-lifecycle facts, and quarantined runs never
|
|
@@ -1404,6 +1410,10 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
|
1404
1410
|
: {}),
|
|
1405
1411
|
};
|
|
1406
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 });
|
|
1407
1417
|
const existing = journal.get(key);
|
|
1408
1418
|
if (candidate.generation !== undefined && existing?.generation !== undefined) {
|
|
1409
1419
|
if (candidate.generation < existing.generation) return;
|
|
@@ -1419,19 +1429,36 @@ export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
|
1419
1429
|
}
|
|
1420
1430
|
}
|
|
1421
1431
|
journal.set(key, candidate);
|
|
1422
|
-
journalRunIds.set(key, event.runId);
|
|
1423
1432
|
},
|
|
1424
1433
|
});
|
|
1425
1434
|
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
|
|
1430
|
-
|
|
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
|
+
}
|
|
1431
1444
|
}
|
|
1432
1445
|
return journal;
|
|
1433
1446
|
}
|
|
1434
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
|
+
|
|
1435
1462
|
/** Derive a stable recovery id from a recovery event's content. */
|
|
1436
1463
|
export function deriveRecoveryId(event: { runId: string; rotations: unknown[]; timestamp: number }): string {
|
|
1437
1464
|
const digest = createHash("sha256")
|