@osolmaz/pi-workflows 0.1.0 → 0.2.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.
Files changed (62) hide show
  1. package/README.md +36 -21
  2. package/dist/extension/executor.d.ts +14 -1
  3. package/dist/extension/executor.js +11 -1
  4. package/dist/extension/executor.js.map +1 -1
  5. package/dist/extension/index.js +79 -7
  6. package/dist/extension/index.js.map +1 -1
  7. package/dist/extension/recorder.d.ts +85 -0
  8. package/dist/extension/recorder.js +525 -0
  9. package/dist/extension/recorder.js.map +1 -0
  10. package/dist/extension/session-events.d.ts +134 -0
  11. package/dist/extension/session-events.js +60 -0
  12. package/dist/extension/session-events.js.map +1 -0
  13. package/dist/extension/widget.js +25 -24
  14. package/dist/extension/widget.js.map +1 -1
  15. package/dist/render/canvas.d.ts +1 -1
  16. package/dist/render/canvas.js +5 -0
  17. package/dist/render/canvas.js.map +1 -1
  18. package/dist/render/graph-render.d.ts +5 -0
  19. package/dist/render/graph-render.js +211 -48
  20. package/dist/render/graph-render.js.map +1 -1
  21. package/dist/viewer/render.js +19 -3
  22. package/dist/viewer/render.js.map +1 -1
  23. package/dist/viewer/session-reducer.d.ts +45 -0
  24. package/dist/viewer/session-reducer.js +266 -0
  25. package/dist/viewer/session-reducer.js.map +1 -0
  26. package/dist/workflows/artifacts.d.ts +40 -0
  27. package/dist/workflows/artifacts.js +155 -0
  28. package/dist/workflows/artifacts.js.map +1 -0
  29. package/dist/workflows/engine.d.ts +2 -0
  30. package/dist/workflows/engine.js +38 -7
  31. package/dist/workflows/engine.js.map +1 -1
  32. package/dist/workflows/index.d.ts +3 -2
  33. package/dist/workflows/index.js +2 -1
  34. package/dist/workflows/index.js.map +1 -1
  35. package/dist/workflows/store.d.ts +53 -9
  36. package/dist/workflows/store.js +523 -43
  37. package/dist/workflows/store.js.map +1 -1
  38. package/dist/workflows/types.d.ts +126 -3
  39. package/docs/development.md +43 -19
  40. package/docs/live-replay-protocol.md +155 -0
  41. package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
  42. package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
  43. package/docs/plans/session-event-replay-implementation-plan.md +494 -0
  44. package/docs/plans/tui-viewer-implementation-plan.md +64 -0
  45. package/docs/run-bundles.md +320 -55
  46. package/docs/session-event-journal.md +470 -0
  47. package/docs/tui-viewer.md +218 -0
  48. package/package.json +2 -1
  49. package/src/extension/executor.ts +28 -1
  50. package/src/extension/index.ts +87 -7
  51. package/src/extension/recorder.ts +633 -0
  52. package/src/extension/session-events.ts +119 -0
  53. package/src/extension/widget.ts +26 -24
  54. package/src/render/canvas.ts +19 -1
  55. package/src/render/graph-render.ts +277 -44
  56. package/src/viewer/render.ts +21 -3
  57. package/src/viewer/session-reducer.ts +347 -0
  58. package/src/workflows/artifacts.ts +188 -0
  59. package/src/workflows/engine.ts +39 -7
  60. package/src/workflows/index.ts +15 -0
  61. package/src/workflows/store.ts +649 -49
  62. package/src/workflows/types.ts +141 -3
@@ -0,0 +1,347 @@
1
+ import type { WorkflowSessionEntryRecord, WorkflowSessionEventRecord } from "../workflows/types.js";
2
+
3
+ export type TemporalContentBlock = {
4
+ contentIndex: number;
5
+ kind: "text" | "thinking" | "toolCall";
6
+ text: string;
7
+ value?: unknown;
8
+ };
9
+
10
+ export type TemporalMessage = {
11
+ messageId: string;
12
+ role: string;
13
+ status: "streaming" | "finished" | "error" | "settled" | "unsettled";
14
+ entryId?: string;
15
+ blocks: TemporalContentBlock[];
16
+ };
17
+
18
+ export type TemporalTool = {
19
+ toolCallId: string;
20
+ messageId: string;
21
+ toolName: string;
22
+ status: "running" | "finished" | "failed";
23
+ updates: number;
24
+ args?: unknown;
25
+ result?: unknown;
26
+ };
27
+
28
+ export type TemporalSessionState = {
29
+ throughSeq: number;
30
+ messages: TemporalMessage[];
31
+ tools: TemporalTool[];
32
+ settledEntryIds: string[];
33
+ diagnostics: string[];
34
+ };
35
+
36
+ type MutableMessage = Omit<TemporalMessage, "blocks"> & {
37
+ blocks: Map<number, TemporalContentBlock>;
38
+ };
39
+
40
+ function payloadString(payload: Record<string, unknown>, key: string): string | undefined {
41
+ const value = payload[key];
42
+ return typeof value === "string" ? value : undefined;
43
+ }
44
+
45
+ function payloadIndex(payload: Record<string, unknown>): number | undefined {
46
+ const value = payload.contentIndex;
47
+ return Number.isSafeInteger(value) && (value as number) >= 0 ? (value as number) : undefined;
48
+ }
49
+
50
+ function ensureBlock(
51
+ message: MutableMessage,
52
+ contentIndex: number,
53
+ kind: TemporalContentBlock["kind"],
54
+ ): TemporalContentBlock {
55
+ const existing = message.blocks.get(contentIndex);
56
+ if (existing) {
57
+ return existing;
58
+ }
59
+ const block: TemporalContentBlock = { contentIndex, kind, text: "" };
60
+ message.blocks.set(contentIndex, block);
61
+ return block;
62
+ }
63
+
64
+ function entryIds(entries: WorkflowSessionEntryRecord[]): Set<string> {
65
+ return new Set(
66
+ entries.flatMap((record) => {
67
+ const id = record.entry.id;
68
+ return typeof id === "string" ? [id] : [];
69
+ }),
70
+ );
71
+ }
72
+
73
+ function foldSessionEvents(
74
+ entries: WorkflowSessionEntryRecord[],
75
+ events: WorkflowSessionEventRecord[],
76
+ throughSeq: number,
77
+ initial?: TemporalSessionState,
78
+ ): TemporalSessionState {
79
+ const messages = new Map<string, MutableMessage>();
80
+ const messageOrder: string[] = [];
81
+ for (const message of initial?.messages ?? []) {
82
+ messages.set(message.messageId, {
83
+ ...message,
84
+ blocks: new Map(message.blocks.map((block) => [block.contentIndex, { ...block }] as const)),
85
+ });
86
+ messageOrder.push(message.messageId);
87
+ }
88
+ const tools = new Map<string, TemporalTool>();
89
+ const toolOrder: string[] = [];
90
+ for (const tool of initial?.tools ?? []) {
91
+ tools.set(tool.toolCallId, { ...tool });
92
+ toolOrder.push(tool.toolCallId);
93
+ }
94
+ const settledEntryIds: string[] = [...(initial?.settledEntryIds ?? [])];
95
+ const knownEntries = entryIds(entries);
96
+ const diagnostics: string[] = [...(initial?.diagnostics ?? [])];
97
+ let expectedSeq = (initial?.throughSeq ?? 0) + 1;
98
+ let lastSeq = initial?.throughSeq ?? 0;
99
+
100
+ for (const event of events) {
101
+ if (event.seq <= lastSeq) {
102
+ continue;
103
+ }
104
+ if (event.seq > throughSeq) {
105
+ break;
106
+ }
107
+ if (event.seq !== expectedSeq) {
108
+ diagnostics.push(`session event sequence gap at ${expectedSeq}`);
109
+ expectedSeq = event.seq;
110
+ }
111
+ expectedSeq += 1;
112
+ lastSeq = event.seq;
113
+
114
+ switch (event.type) {
115
+ case "message_started": {
116
+ if (!event.messageId) {
117
+ diagnostics.push(`message_started ${event.seq} has no messageId`);
118
+ break;
119
+ }
120
+ if (!messages.has(event.messageId)) {
121
+ messages.set(event.messageId, {
122
+ messageId: event.messageId,
123
+ role: payloadString(event.payload, "role") ?? "unknown",
124
+ status: "streaming",
125
+ blocks: new Map(),
126
+ });
127
+ messageOrder.push(event.messageId);
128
+ }
129
+ break;
130
+ }
131
+ case "assistant_event": {
132
+ if (!event.messageId) {
133
+ diagnostics.push(`assistant_event ${event.seq} has no messageId`);
134
+ break;
135
+ }
136
+ const message = messages.get(event.messageId);
137
+ if (!message) {
138
+ diagnostics.push(`assistant_event ${event.seq} precedes message_started`);
139
+ break;
140
+ }
141
+ const assistantType = payloadString(event.payload, "type");
142
+ const contentIndex = payloadIndex(event.payload);
143
+ if (
144
+ contentIndex !== undefined &&
145
+ (assistantType === "text_start" ||
146
+ assistantType === "thinking_start" ||
147
+ assistantType === "toolcall_start")
148
+ ) {
149
+ ensureBlock(
150
+ message,
151
+ contentIndex,
152
+ assistantType === "text_start"
153
+ ? "text"
154
+ : assistantType === "thinking_start"
155
+ ? "thinking"
156
+ : "toolCall",
157
+ );
158
+ } else if (
159
+ contentIndex !== undefined &&
160
+ (assistantType === "text_delta" ||
161
+ assistantType === "thinking_delta" ||
162
+ assistantType === "toolcall_delta")
163
+ ) {
164
+ const block = ensureBlock(
165
+ message,
166
+ contentIndex,
167
+ assistantType === "text_delta"
168
+ ? "text"
169
+ : assistantType === "thinking_delta"
170
+ ? "thinking"
171
+ : "toolCall",
172
+ );
173
+ block.text += payloadString(event.payload, "delta") ?? "";
174
+ } else if (
175
+ contentIndex !== undefined &&
176
+ (assistantType === "text_end" || assistantType === "thinking_end")
177
+ ) {
178
+ const block = ensureBlock(
179
+ message,
180
+ contentIndex,
181
+ assistantType === "text_end" ? "text" : "thinking",
182
+ );
183
+ const content = payloadString(event.payload, "content") ?? "";
184
+ if (block.text !== content) {
185
+ diagnostics.push(`${assistantType} mismatch for ${event.messageId}:${contentIndex}`);
186
+ block.text = content;
187
+ }
188
+ } else if (contentIndex !== undefined && assistantType === "toolcall_end") {
189
+ const block = ensureBlock(message, contentIndex, "toolCall");
190
+ block.value = event.payload.toolCall;
191
+ } else if (assistantType === "done") {
192
+ message.status = "finished";
193
+ } else if (assistantType === "error") {
194
+ message.status = "error";
195
+ }
196
+ break;
197
+ }
198
+ case "message_finished": {
199
+ if (!event.messageId) {
200
+ diagnostics.push(`message_finished ${event.seq} has no messageId`);
201
+ break;
202
+ }
203
+ const message = messages.get(event.messageId);
204
+ if (!message) {
205
+ diagnostics.push(`message_finished ${event.seq} precedes message_started`);
206
+ break;
207
+ }
208
+ const settled = event.payload.settled === true;
209
+ const entryId = payloadString(event.payload, "entryId");
210
+ if (settled && entryId) {
211
+ message.status = "settled";
212
+ message.entryId = entryId;
213
+ settledEntryIds.push(entryId);
214
+ if (!knownEntries.has(entryId)) {
215
+ diagnostics.push(`settled entry ${entryId} is missing`);
216
+ }
217
+ } else {
218
+ message.status = "unsettled";
219
+ }
220
+ break;
221
+ }
222
+ case "tool_execution_started": {
223
+ if (!event.toolCallId || !event.messageId) {
224
+ diagnostics.push(`tool_execution_started ${event.seq} is uncorrelated`);
225
+ break;
226
+ }
227
+ const tool: TemporalTool = {
228
+ toolCallId: event.toolCallId,
229
+ messageId: event.messageId,
230
+ toolName: payloadString(event.payload, "toolName") ?? "tool",
231
+ status: "running",
232
+ updates: 0,
233
+ ...(event.payload.args === undefined ? {} : { args: event.payload.args }),
234
+ };
235
+ tools.set(event.toolCallId, tool);
236
+ toolOrder.push(event.toolCallId);
237
+ break;
238
+ }
239
+ case "tool_execution_updated": {
240
+ const tool = event.toolCallId ? tools.get(event.toolCallId) : undefined;
241
+ if (tool) {
242
+ tool.updates += 1;
243
+ } else {
244
+ diagnostics.push(`tool_execution_updated ${event.seq} precedes start`);
245
+ }
246
+ break;
247
+ }
248
+ case "tool_execution_finished": {
249
+ const tool = event.toolCallId ? tools.get(event.toolCallId) : undefined;
250
+ if (!tool) {
251
+ diagnostics.push(`tool_execution_finished ${event.seq} precedes start`);
252
+ break;
253
+ }
254
+ tool.status = event.payload.isError === true ? "failed" : "finished";
255
+ if (event.payload.result !== undefined) {
256
+ tool.result = event.payload.result;
257
+ }
258
+ break;
259
+ }
260
+ case "turn_started":
261
+ case "turn_finished":
262
+ break;
263
+ default:
264
+ break;
265
+ }
266
+ }
267
+
268
+ return {
269
+ throughSeq: lastSeq,
270
+ messages: messageOrder.map((messageId) => {
271
+ const message = messages.get(messageId)!;
272
+ return {
273
+ messageId: message.messageId,
274
+ role: message.role,
275
+ status: message.status,
276
+ ...(message.entryId === undefined ? {} : { entryId: message.entryId }),
277
+ blocks: [...message.blocks.values()].toSorted(
278
+ (left, right) => left.contentIndex - right.contentIndex,
279
+ ),
280
+ };
281
+ }),
282
+ tools: toolOrder.map((toolCallId) => tools.get(toolCallId)!),
283
+ settledEntryIds,
284
+ diagnostics,
285
+ };
286
+ }
287
+
288
+ /** Fold the durable semantic journal through one sequence position. */
289
+ export function reduceSessionEvents(
290
+ entries: WorkflowSessionEntryRecord[],
291
+ events: WorkflowSessionEventRecord[],
292
+ throughSeq: number = Number.MAX_SAFE_INTEGER,
293
+ ): TemporalSessionState {
294
+ return foldSessionEvents(entries, events, throughSeq);
295
+ }
296
+
297
+ /**
298
+ * In-memory seek index. Checkpoints are viewer-only cache state and are never
299
+ * persisted into a run bundle.
300
+ */
301
+ export class SessionReplayIndex {
302
+ private readonly entries: WorkflowSessionEntryRecord[];
303
+ private readonly events: WorkflowSessionEventRecord[];
304
+ private readonly checkpoints: Array<{ seq: number; state: TemporalSessionState }> = [];
305
+ private readonly timestamps: Array<{ at: number; seq: number }>;
306
+
307
+ constructor(
308
+ entries: WorkflowSessionEntryRecord[],
309
+ events: WorkflowSessionEventRecord[],
310
+ checkpointInterval = 256,
311
+ ) {
312
+ this.entries = entries;
313
+ this.events = events;
314
+ let lastTimestamp = Number.NEGATIVE_INFINITY;
315
+ this.timestamps = events.map((event) => {
316
+ const parsed = Date.parse(event.at);
317
+ lastTimestamp = Math.max(lastTimestamp, Number.isFinite(parsed) ? parsed : lastTimestamp);
318
+ return { at: lastTimestamp, seq: event.seq };
319
+ });
320
+ let state = foldSessionEvents(entries, events, 0);
321
+ this.checkpoints.push({ seq: 0, state });
322
+ for (let end = checkpointInterval - 1; end < events.length; end += checkpointInterval) {
323
+ const seq = events[end]?.seq ?? state.throughSeq;
324
+ state = foldSessionEvents(entries, events, seq, state);
325
+ this.checkpoints.push({ seq, state });
326
+ }
327
+ }
328
+
329
+ stateAtSeq(throughSeq: number): TemporalSessionState {
330
+ const checkpoint = this.checkpoints.findLast(({ seq }) => seq <= throughSeq);
331
+ return foldSessionEvents(this.entries, this.events, throughSeq, checkpoint?.state);
332
+ }
333
+
334
+ seqAtOrBefore(timestampMs: number): number {
335
+ let low = 0;
336
+ let high = this.timestamps.length;
337
+ while (low < high) {
338
+ const middle = (low + high) >>> 1;
339
+ if ((this.timestamps[middle]?.at ?? Number.POSITIVE_INFINITY) <= timestampMs) {
340
+ low = middle + 1;
341
+ } else {
342
+ high = middle;
343
+ }
344
+ }
345
+ return low === 0 ? 0 : (this.timestamps[low - 1]?.seq ?? 0);
346
+ }
347
+ }
@@ -0,0 +1,188 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { ArtifactRef, ArtifactValue } from "./types.js";
5
+
6
+ /**
7
+ * Value externalization for run bundles (see docs/run-bundles.md): string
8
+ * leaves larger than the threshold are written once, content-addressed, under
9
+ * `artifacts/` and replaced by `{ "$artifact": ref }`. Because artifacts are
10
+ * immutable and deduplicated by hash, the same output appearing in `outputs`,
11
+ * `results`, `steps`, and the trace costs one file plus small references.
12
+ */
13
+
14
+ export const ARTIFACT_THRESHOLD_BYTES = 4096;
15
+ export const ARTIFACTS_DIR = "artifacts";
16
+
17
+ const ARTIFACT_KEY = "$artifact";
18
+ const ESCAPED_KEY = "$escaped";
19
+
20
+ export function isArtifactValue(value: unknown): value is ArtifactValue {
21
+ return (
22
+ typeof value === "object" &&
23
+ value !== null &&
24
+ !Array.isArray(value) &&
25
+ Object.keys(value).length === 1 &&
26
+ ARTIFACT_KEY in value
27
+ );
28
+ }
29
+
30
+ function isEscapedValue(value: unknown): value is { $escaped: Record<string, unknown> } {
31
+ return (
32
+ typeof value === "object" &&
33
+ value !== null &&
34
+ !Array.isArray(value) &&
35
+ Object.keys(value).length === 1 &&
36
+ ESCAPED_KEY in value
37
+ );
38
+ }
39
+
40
+ /** True for a single-key object that would be misread as a sentinel. */
41
+ function needsEscape(value: Record<string, unknown>): boolean {
42
+ const keys = Object.keys(value);
43
+ return keys.length === 1 && (keys[0] === ARTIFACT_KEY || keys[0] === ESCAPED_KEY);
44
+ }
45
+
46
+ /**
47
+ * Writes externalized string leaves for one run bundle. Instances serialize
48
+ * their writes and deduplicate by content hash, so encoding the same large
49
+ * string in several value positions touches the filesystem once.
50
+ */
51
+ export class ArtifactWriter {
52
+ private readonly runDir: string;
53
+ private readonly written = new Set<string>();
54
+ private chain: Promise<unknown> = Promise.resolve();
55
+ private dirCreated = false;
56
+
57
+ constructor(runDir: string) {
58
+ this.runDir = runDir;
59
+ }
60
+
61
+ /** True once any artifact exists for this run. */
62
+ get hasArtifacts(): boolean {
63
+ return this.written.size > 0;
64
+ }
65
+
66
+ async externalize(text: string): Promise<ArtifactValue> {
67
+ const bytes = Buffer.from(text, "utf8");
68
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
69
+ const relativePath = `${ARTIFACTS_DIR}/sha256-${sha256}.txt`;
70
+ const task = this.chain.then(async () => {
71
+ if (this.written.has(sha256)) {
72
+ return;
73
+ }
74
+ if (!this.dirCreated) {
75
+ await fs.mkdir(path.join(this.runDir, ARTIFACTS_DIR), { recursive: true, mode: 0o700 });
76
+ this.dirCreated = true;
77
+ }
78
+ const filePath = path.join(this.runDir, relativePath);
79
+ // Content-addressed files are immutable; an existing file is complete
80
+ // unless a previous crash left a partial write, which the temp-rename
81
+ // pattern prevents.
82
+ const tempPath = `${filePath}.${process.pid}.tmp`;
83
+ await fs.writeFile(tempPath, bytes, { mode: 0o600 });
84
+ await fs.rename(tempPath, filePath);
85
+ this.written.add(sha256);
86
+ });
87
+ this.chain = task.catch(() => undefined);
88
+ await task;
89
+ return {
90
+ $artifact: {
91
+ path: relativePath,
92
+ mediaType: "text/plain",
93
+ bytes: bytes.byteLength,
94
+ sha256,
95
+ },
96
+ };
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Encode a persisted value: externalize large string leaves and escape
102
+ * single-key `$artifact`/`$escaped` objects so the sentinel stays
103
+ * unambiguous. Returns the original value when nothing changed.
104
+ */
105
+ export async function encodeValue(
106
+ value: unknown,
107
+ writer: ArtifactWriter,
108
+ thresholdBytes: number = ARTIFACT_THRESHOLD_BYTES,
109
+ ): Promise<unknown> {
110
+ if (typeof value === "string") {
111
+ if (Buffer.byteLength(value, "utf8") <= thresholdBytes) {
112
+ return value;
113
+ }
114
+ return await writer.externalize(value);
115
+ }
116
+ if (Array.isArray(value)) {
117
+ const encoded = await Promise.all(
118
+ value.map((item) => encodeValue(item, writer, thresholdBytes)),
119
+ );
120
+ return encoded.some((item, index) => item !== value[index]) ? encoded : value;
121
+ }
122
+ if (typeof value === "object" && value !== null) {
123
+ const record = value as Record<string, unknown>;
124
+ const entries = await Promise.all(
125
+ Object.entries(record).map(
126
+ async ([key, item]) => [key, await encodeValue(item, writer, thresholdBytes)] as const,
127
+ ),
128
+ );
129
+ const changed = entries.some(([key, item]) => item !== record[key]);
130
+ const encoded = changed ? Object.fromEntries(entries) : record;
131
+ return needsEscape(record) ? { [ESCAPED_KEY]: encoded } : encoded;
132
+ }
133
+ return value;
134
+ }
135
+
136
+ /**
137
+ * Walk a persisted value, replacing every `$artifact` sentinel via `resolve`
138
+ * and unwrapping `$escaped` objects. `resolve` may return the artifact
139
+ * contents or a placeholder; it receives the reference untouched.
140
+ */
141
+ export function decodeValueWith(value: unknown, resolve: (ref: ArtifactRef) => unknown): unknown {
142
+ if (isArtifactValue(value)) {
143
+ return resolve(value.$artifact);
144
+ }
145
+ if (isEscapedValue(value)) {
146
+ // The unwrapped object is user data: process its children but do not
147
+ // re-test the object itself as a sentinel.
148
+ return decodeChildren(value.$escaped, resolve);
149
+ }
150
+ if (Array.isArray(value)) {
151
+ return value.map((item) => decodeValueWith(item, resolve));
152
+ }
153
+ if (typeof value === "object" && value !== null) {
154
+ return decodeChildren(value as Record<string, unknown>, resolve);
155
+ }
156
+ return value;
157
+ }
158
+
159
+ function decodeChildren(
160
+ record: Record<string, unknown>,
161
+ resolve: (ref: ArtifactRef) => unknown,
162
+ ): Record<string, unknown> {
163
+ return Object.fromEntries(
164
+ Object.entries(record).map(([key, item]) => [key, decodeValueWith(item, resolve)]),
165
+ );
166
+ }
167
+
168
+ /** Resolve every artifact reference in `value` by reading the bundle files. */
169
+ export async function resolveArtifacts(value: unknown, runDir: string): Promise<unknown> {
170
+ const refs: ArtifactRef[] = [];
171
+ decodeValueWith(value, (ref) => {
172
+ refs.push(ref);
173
+ return null;
174
+ });
175
+ const contents = new Map<string, string>();
176
+ for (const ref of refs) {
177
+ if (contents.has(ref.path)) {
178
+ continue;
179
+ }
180
+ const resolved = path.resolve(runDir, ref.path);
181
+ // A reference must never escape the bundle directory.
182
+ if (!resolved.startsWith(path.resolve(runDir) + path.sep)) {
183
+ throw new Error(`Artifact path escapes the bundle: ${ref.path}`);
184
+ }
185
+ contents.set(ref.path, await fs.readFile(resolved, "utf8"));
186
+ }
187
+ return decodeValueWith(value, (ref) => contents.get(ref.path) ?? null);
188
+ }
@@ -4,12 +4,13 @@ import { CancelledError, errorMessage, isAbortLikeError, TimeoutError } from "./
4
4
  import { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
5
5
  import { extractJsonValue } from "./json.js";
6
6
  import { runShellAction, shellResultFromError } from "./shell.js";
7
- import { WorkflowRunStore, createRunId } from "./store.js";
7
+ import { RUN_STATE_SCHEMA, WorkflowRunStore, createRunId } from "./store.js";
8
8
  import type {
9
9
  AgentNodeDefinition,
10
10
  AgentStepExecutor,
11
11
  ActionNodeDefinition,
12
12
  CheckpointNodeDefinition,
13
+ ConversationRange,
13
14
  ShellActionNodeDefinition,
14
15
  ShellActionResult,
15
16
  WorkflowActionReceipt,
@@ -35,6 +36,7 @@ type NodeExecution = {
35
36
  output: unknown;
36
37
  promptText: string | null;
37
38
  action?: WorkflowActionReceipt;
39
+ conversation?: ConversationRange;
38
40
  };
39
41
 
40
42
  /**
@@ -64,6 +66,8 @@ export class WorkflowEngine {
64
66
  private readonly defaultNodeTimeoutMs: number;
65
67
  private readonly maxSteps: number;
66
68
  private readonly onEvent?: WorkflowEngineOptions["onEvent"];
69
+ private readonly onRunStarted?: WorkflowEngineOptions["onRunStarted"];
70
+ private readonly onRunFinishing?: WorkflowEngineOptions["onRunFinishing"];
67
71
  private activeAbort: AbortController | null = null;
68
72
  private cancelled = false;
69
73
  private paused = false;
@@ -71,10 +75,12 @@ export class WorkflowEngine {
71
75
 
72
76
  constructor(options: WorkflowEngineOptions) {
73
77
  this.executor = options.executor;
74
- this.store = new WorkflowRunStore(options.outputRoot);
78
+ this.store = options.store ?? new WorkflowRunStore(options.outputRoot);
75
79
  this.defaultNodeTimeoutMs = options.defaultNodeTimeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
76
80
  this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
77
81
  this.onEvent = options.onEvent;
82
+ this.onRunStarted = options.onRunStarted;
83
+ this.onRunFinishing = options.onRunFinishing;
78
84
  }
79
85
 
80
86
  get outputRoot(): string {
@@ -130,8 +136,13 @@ export class WorkflowEngine {
130
136
  payload: {
131
137
  workflowName: workflow.name,
132
138
  ...(state.runTitle ? { runTitle: state.runTitle } : {}),
139
+ input: state.input,
133
140
  },
134
141
  });
142
+ // Awaited so anything the hook writes (e.g. a session binding and its
143
+ // `session_bound` event) lands before node events and can never trail
144
+ // the terminal event of a fast run.
145
+ await this.onRunStarted?.(runDir, state);
135
146
 
136
147
  try {
137
148
  await this.executeGraph(workflow, state, runDir);
@@ -178,6 +189,8 @@ export class WorkflowEngine {
178
189
  ): Promise<WorkflowRunState> {
179
190
  const now = new Date().toISOString();
180
191
  return {
192
+ schema: RUN_STATE_SCHEMA,
193
+ traceSeq: 0,
181
194
  runId: createRunId(workflow.name),
182
195
  workflowName: workflow.name,
183
196
  ...(await this.resolveTitleBounded(workflow, input)),
@@ -218,6 +231,8 @@ export class WorkflowEngine {
218
231
 
219
232
  const attempt = await this.executeNode(workflow, state, runDir, currentNodeId, node);
220
233
  this.recordAttempt(state, attempt);
234
+ // The terminal node event carries the output, receipt, and conversation
235
+ // linkage so the trace alone is sufficient to reconstruct the run.
221
236
  await this.persist(runDir, state, {
222
237
  scope: "node",
223
238
  type: attempt.result.outcome === "ok" ? "node_finished" : "node_failed",
@@ -226,7 +241,12 @@ export class WorkflowEngine {
226
241
  payload: {
227
242
  outcome: attempt.result.outcome,
228
243
  durationMs: attempt.result.durationMs,
244
+ ...(attempt.result.outcome === "ok" ? { output: attempt.result.output ?? null } : {}),
229
245
  ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
246
+ ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
247
+ ...(attempt.execution?.conversation !== undefined
248
+ ? { conversation: attempt.execution.conversation }
249
+ : {}),
230
250
  },
231
251
  });
232
252
 
@@ -316,16 +336,18 @@ export class WorkflowEngine {
316
336
  outcome: attempt.result.outcome,
317
337
  startedAt: attempt.result.startedAt,
318
338
  finishedAt: attempt.result.finishedAt,
319
- promptText: attempt.execution?.promptText ?? null,
339
+ prompt: attempt.execution?.promptText ?? null,
320
340
  // `undefined` would drop the required field during JSON serialization.
321
341
  output: attempt.result.output ?? null,
322
342
  ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
323
343
  ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
344
+ ...(attempt.execution?.conversation !== undefined
345
+ ? { conversation: attempt.execution.conversation }
346
+ : {}),
324
347
  };
325
348
  state.steps.push(step);
326
349
  delete state.currentNode;
327
350
  delete state.currentAttemptId;
328
- delete state.currentNodeType;
329
351
  delete state.currentNodeStartedAt;
330
352
  delete state.statusDetail;
331
353
  }
@@ -341,7 +363,6 @@ export class WorkflowEngine {
341
363
  const startedAt = new Date().toISOString();
342
364
  state.currentNode = nodeId;
343
365
  state.currentAttemptId = attemptId;
344
- state.currentNodeType = node.nodeType;
345
366
  state.currentNodeStartedAt = startedAt;
346
367
  if (node.statusDetail !== undefined) {
347
368
  state.statusDetail = node.statusDetail;
@@ -571,7 +592,11 @@ export class WorkflowEngine {
571
592
  },
572
593
  signal,
573
594
  );
574
- return { output: submission.output, promptText: prompt };
595
+ return {
596
+ output: submission.output,
597
+ promptText: prompt,
598
+ ...(submission.conversation !== undefined ? { conversation: submission.conversation } : {}),
599
+ };
575
600
  }
576
601
 
577
602
  private async acceptSubmission(
@@ -629,6 +654,13 @@ export class WorkflowEngine {
629
654
  if (status === "failed" && state.status === "timed_out") {
630
655
  status = "timed_out";
631
656
  }
657
+ // Let observers (e.g. the session recorder) stop and drain before the
658
+ // terminal event exists, so the bundle is immutable from that point on.
659
+ try {
660
+ await this.onRunFinishing?.(runDir, state);
661
+ } catch {
662
+ // Finishing the run wins over observer failures.
663
+ }
632
664
  state.status = status;
633
665
  state.finishedAt = new Date().toISOString();
634
666
  if (fields.error !== undefined) {
@@ -642,7 +674,6 @@ export class WorkflowEngine {
642
674
  }
643
675
  delete state.currentNode;
644
676
  delete state.currentAttemptId;
645
- delete state.currentNodeType;
646
677
  delete state.currentNodeStartedAt;
647
678
  await this.persist(runDir, state, {
648
679
  scope: "run",
@@ -651,6 +682,7 @@ export class WorkflowEngine {
651
682
  status,
652
683
  ...(fields.error !== undefined ? { error: fields.error } : {}),
653
684
  ...(fields.waitingOn !== undefined ? { waitingOn: fields.waitingOn } : {}),
685
+ ...(fields.finalOutput !== undefined ? { finalOutput: fields.finalOutput } : {}),
654
686
  },
655
687
  });
656
688
  }