@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.
- package/README.md +36 -21
- package/dist/extension/executor.d.ts +14 -1
- package/dist/extension/executor.js +11 -1
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.js +79 -7
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +85 -0
- package/dist/extension/recorder.js +525 -0
- package/dist/extension/recorder.js.map +1 -0
- package/dist/extension/session-events.d.ts +134 -0
- package/dist/extension/session-events.js +60 -0
- package/dist/extension/session-events.js.map +1 -0
- package/dist/extension/widget.js +25 -24
- package/dist/extension/widget.js.map +1 -1
- package/dist/render/canvas.d.ts +1 -1
- package/dist/render/canvas.js +5 -0
- package/dist/render/canvas.js.map +1 -1
- package/dist/render/graph-render.d.ts +5 -0
- package/dist/render/graph-render.js +211 -48
- package/dist/render/graph-render.js.map +1 -1
- package/dist/viewer/render.js +19 -3
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/session-reducer.d.ts +45 -0
- package/dist/viewer/session-reducer.js +266 -0
- package/dist/viewer/session-reducer.js.map +1 -0
- package/dist/workflows/artifacts.d.ts +40 -0
- package/dist/workflows/artifacts.js +155 -0
- package/dist/workflows/artifacts.js.map +1 -0
- package/dist/workflows/engine.d.ts +2 -0
- package/dist/workflows/engine.js +38 -7
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +3 -2
- package/dist/workflows/index.js +2 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/store.d.ts +53 -9
- package/dist/workflows/store.js +523 -43
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +126 -3
- package/docs/development.md +43 -19
- package/docs/live-replay-protocol.md +155 -0
- package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
- package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
- package/docs/plans/session-event-replay-implementation-plan.md +494 -0
- package/docs/plans/tui-viewer-implementation-plan.md +64 -0
- package/docs/run-bundles.md +320 -55
- package/docs/session-event-journal.md +470 -0
- package/docs/tui-viewer.md +218 -0
- package/package.json +2 -1
- package/src/extension/executor.ts +28 -1
- package/src/extension/index.ts +87 -7
- package/src/extension/recorder.ts +633 -0
- package/src/extension/session-events.ts +119 -0
- package/src/extension/widget.ts +26 -24
- package/src/render/canvas.ts +19 -1
- package/src/render/graph-render.ts +277 -44
- package/src/viewer/render.ts +21 -3
- package/src/viewer/session-reducer.ts +347 -0
- package/src/workflows/artifacts.ts +188 -0
- package/src/workflows/engine.ts +39 -7
- package/src/workflows/index.ts +15 -0
- package/src/workflows/store.ts +649 -49
- package/src/workflows/types.ts +141 -3
package/src/workflows/store.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { ArtifactWriter, encodeValue } from "./artifacts.js";
|
|
5
6
|
import type {
|
|
6
7
|
WorkflowDefinition,
|
|
7
8
|
WorkflowDefinitionSnapshot,
|
|
@@ -9,18 +10,32 @@ import type {
|
|
|
9
10
|
WorkflowNodeSnapshot,
|
|
10
11
|
WorkflowRunManifest,
|
|
11
12
|
WorkflowRunState,
|
|
13
|
+
WorkflowSessionBinding,
|
|
14
|
+
WorkflowSessionCapture,
|
|
15
|
+
WorkflowSessionEntryRecord,
|
|
16
|
+
WorkflowSessionEventRecord,
|
|
12
17
|
WorkflowTraceEvent,
|
|
13
18
|
WorkflowTraceEventDraft,
|
|
14
19
|
} from "./types.js";
|
|
15
20
|
|
|
16
21
|
export const RUN_BUNDLE_SCHEMA = "pi-workflows.run-bundle.v1" as const;
|
|
22
|
+
export const RUN_STATE_SCHEMA = "pi-workflows.run-state.v1" as const;
|
|
17
23
|
export const TRACE_EVENT_SCHEMA = "pi-workflows.trace-event.v1" as const;
|
|
18
24
|
export const DEFINITION_SNAPSHOT_SCHEMA = "pi-workflows.definition-snapshot.v1" as const;
|
|
25
|
+
export const SESSION_BINDING_SCHEMA = "pi-workflows.session-binding.v1" as const;
|
|
26
|
+
export const SESSION_EVENT_SCHEMA = "pi-workflows.session-event.v1" as const;
|
|
27
|
+
export const SESSION_CAPTURE_SCHEMA = "pi-workflows.session-capture.v1" as const;
|
|
28
|
+
export const SESSION_EVENT_MAX_BYTES = 1024 * 1024;
|
|
19
29
|
|
|
20
30
|
const MANIFEST_PATH = "manifest.json";
|
|
21
31
|
const WORKFLOW_SNAPSHOT_PATH = "workflow.json";
|
|
22
32
|
const STATE_PATH = "state.json";
|
|
23
33
|
const TRACE_PATH = "trace.ndjson";
|
|
34
|
+
const SESSION_DIR = "session";
|
|
35
|
+
const SESSION_BINDING_PATH = `${SESSION_DIR}/binding.json`;
|
|
36
|
+
const SESSION_ENTRIES_PATH = `${SESSION_DIR}/entries.ndjson`;
|
|
37
|
+
const SESSION_EVENTS_PATH = `${SESSION_DIR}/events.ndjson`;
|
|
38
|
+
const SESSION_CAPTURE_PATH = `${SESSION_DIR}/capture.json`;
|
|
24
39
|
|
|
25
40
|
/** Runs directory: `$PI_WORKFLOWS_RUNS_DIR` or `~/.pi/agent/workflows/runs`. */
|
|
26
41
|
export function workflowRunsBaseDir(homeDir: string = os.homedir()): string {
|
|
@@ -44,15 +59,35 @@ export function createRunId(workflowName: string, now: Date = new Date()): strin
|
|
|
44
59
|
return `${stamp}-${slug || "workflow"}-${randomUUID().slice(0, 8)}`;
|
|
45
60
|
}
|
|
46
61
|
|
|
62
|
+
type RunBundleContext = {
|
|
63
|
+
traceSeq: number;
|
|
64
|
+
sessionSeq: number;
|
|
65
|
+
sessionEventSeq: number;
|
|
66
|
+
sessionBound: boolean;
|
|
67
|
+
sessionEventsStopped: boolean;
|
|
68
|
+
artifacts: ArtifactWriter;
|
|
69
|
+
/**
|
|
70
|
+
* Serializes complete transitions (encode, trace append, projections) so
|
|
71
|
+
* concurrent writers cannot interleave sequence assignment with physical
|
|
72
|
+
* append order.
|
|
73
|
+
*/
|
|
74
|
+
lock: Promise<unknown>;
|
|
75
|
+
/** Session events have a separate append chain so token traffic cannot
|
|
76
|
+
* queue ahead of workflow transitions. */
|
|
77
|
+
sessionEventLock: Promise<unknown>;
|
|
78
|
+
};
|
|
79
|
+
|
|
47
80
|
/**
|
|
48
|
-
* Persists run bundles
|
|
49
|
-
*
|
|
50
|
-
* atomically
|
|
81
|
+
* Persists run bundles (see docs/run-bundles.md). `trace.ndjson` is the
|
|
82
|
+
* append-only source of truth; every transition appends the trace event
|
|
83
|
+
* first, then atomically replaces `state.json` (carrying `traceSeq`) and
|
|
84
|
+
* `manifest.json`. Large string leaves in persisted values are externalized
|
|
85
|
+
* into content-addressed `artifacts/`. Bundles are private: directories are
|
|
86
|
+
* 0700 and files 0600.
|
|
51
87
|
*/
|
|
52
88
|
export class WorkflowRunStore {
|
|
53
89
|
readonly outputRoot: string;
|
|
54
|
-
private readonly
|
|
55
|
-
private readonly appendChainByPath = new Map<string, Promise<void>>();
|
|
90
|
+
private readonly contexts = new Map<string, RunBundleContext>();
|
|
56
91
|
|
|
57
92
|
constructor(outputRoot: string = workflowRunsBaseDir()) {
|
|
58
93
|
this.outputRoot = outputRoot;
|
|
@@ -62,91 +97,535 @@ export class WorkflowRunStore {
|
|
|
62
97
|
return path.join(this.outputRoot, runId);
|
|
63
98
|
}
|
|
64
99
|
|
|
100
|
+
private contextFor(runDir: string): RunBundleContext {
|
|
101
|
+
let context = this.contexts.get(runDir);
|
|
102
|
+
if (!context) {
|
|
103
|
+
context = {
|
|
104
|
+
traceSeq: 0,
|
|
105
|
+
sessionSeq: 0,
|
|
106
|
+
sessionEventSeq: 0,
|
|
107
|
+
sessionBound: false,
|
|
108
|
+
sessionEventsStopped: false,
|
|
109
|
+
artifacts: new ArtifactWriter(runDir),
|
|
110
|
+
lock: Promise.resolve(),
|
|
111
|
+
sessionEventLock: Promise.resolve(),
|
|
112
|
+
};
|
|
113
|
+
this.contexts.set(runDir, context);
|
|
114
|
+
}
|
|
115
|
+
return context;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Run `task` exclusively for this bundle. Sequence numbers are assigned
|
|
120
|
+
* inside the lock, so physical file order always matches logical order.
|
|
121
|
+
*/
|
|
122
|
+
private withRunLock<T>(runDir: string, task: () => Promise<T>): Promise<T> {
|
|
123
|
+
const context = this.contextFor(runDir);
|
|
124
|
+
const result = context.lock.then(task);
|
|
125
|
+
context.lock = result.then(
|
|
126
|
+
() => undefined,
|
|
127
|
+
() => undefined,
|
|
128
|
+
);
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private withSessionEventLock<T>(runDir: string, task: () => Promise<T>): Promise<T> {
|
|
133
|
+
const context = this.contextFor(runDir);
|
|
134
|
+
const result = context.sessionEventLock.then(task);
|
|
135
|
+
context.sessionEventLock = result.then(
|
|
136
|
+
() => undefined,
|
|
137
|
+
() => undefined,
|
|
138
|
+
);
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
65
142
|
async initializeRunBundle(
|
|
66
143
|
workflow: WorkflowDefinition,
|
|
67
144
|
state: WorkflowRunState,
|
|
68
145
|
): Promise<string> {
|
|
69
146
|
const runDir = this.runDirFor(state.runId);
|
|
70
|
-
|
|
71
|
-
this.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return runDir;
|
|
147
|
+
this.contexts.delete(runDir);
|
|
148
|
+
return await this.withRunLock(runDir, async () => {
|
|
149
|
+
await fs.mkdir(runDir, { recursive: true, mode: 0o700 });
|
|
150
|
+
await writeJsonAtomic(
|
|
151
|
+
path.join(runDir, WORKFLOW_SNAPSHOT_PATH),
|
|
152
|
+
createDefinitionSnapshot(workflow),
|
|
153
|
+
);
|
|
154
|
+
await appendLine(path.join(runDir, TRACE_PATH), null);
|
|
155
|
+
await this.writeProjections(runDir, state);
|
|
156
|
+
return runDir;
|
|
157
|
+
});
|
|
82
158
|
}
|
|
83
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Persist one transition: append the trace event, then rewrite the
|
|
162
|
+
* projections reflecting it.
|
|
163
|
+
*/
|
|
84
164
|
async writeSnapshot(
|
|
85
165
|
runDir: string,
|
|
86
166
|
state: WorkflowRunState,
|
|
87
167
|
event: WorkflowTraceEventDraft,
|
|
88
168
|
): Promise<WorkflowTraceEvent> {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
169
|
+
return await this.withRunLock(runDir, async () => {
|
|
170
|
+
const traceEvent = await this.appendTraceEvent(runDir, state.runId, event);
|
|
171
|
+
state.traceSeq = traceEvent.seq;
|
|
172
|
+
state.updatedAt = new Date().toISOString();
|
|
173
|
+
await this.writeProjections(runDir, state);
|
|
174
|
+
return traceEvent;
|
|
175
|
+
});
|
|
93
176
|
}
|
|
94
177
|
|
|
95
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Bind the run to a Pi conversation: write `session/binding.json` once and
|
|
180
|
+
* append a `session_bound` trace event. Projections catch up on the next
|
|
181
|
+
* snapshot.
|
|
182
|
+
*/
|
|
183
|
+
async writeSessionBinding(runDir: string, binding: WorkflowSessionBinding): Promise<void> {
|
|
184
|
+
await this.withRunLock(runDir, async () => {
|
|
185
|
+
const context = this.contextFor(runDir);
|
|
186
|
+
if (context.sessionBound) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
context.sessionBound = true;
|
|
190
|
+
await fs.mkdir(path.join(runDir, SESSION_DIR), { recursive: true, mode: 0o700 });
|
|
191
|
+
await writeJsonAtomic(path.join(runDir, SESSION_BINDING_PATH), binding);
|
|
192
|
+
await this.appendTraceEvent(runDir, binding.runId, {
|
|
193
|
+
scope: "session",
|
|
194
|
+
type: "session_bound",
|
|
195
|
+
payload: { piSessionId: binding.piSessionId },
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Append one verbatim Pi session entry to `session/entries.ndjson`. */
|
|
201
|
+
async appendSessionEntry(runDir: string, entry: Record<string, unknown>): Promise<number> {
|
|
202
|
+
return await this.withRunLock(runDir, async () => {
|
|
203
|
+
const context = this.contextFor(runDir);
|
|
204
|
+
context.sessionSeq += 1;
|
|
205
|
+
const record: WorkflowSessionEntryRecord = {
|
|
206
|
+
seq: context.sessionSeq,
|
|
207
|
+
at: new Date().toISOString(),
|
|
208
|
+
entry,
|
|
209
|
+
};
|
|
210
|
+
await appendLine(path.join(runDir, SESSION_ENTRIES_PATH), record);
|
|
211
|
+
return record.seq;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Append a fully stamped ordered batch to `session/events.ndjson`. */
|
|
216
|
+
async appendSessionEventBatch(
|
|
96
217
|
runDir: string,
|
|
97
|
-
|
|
218
|
+
records: WorkflowSessionEventRecord[],
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
if (records.length === 0) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await this.withSessionEventLock(runDir, async () => {
|
|
224
|
+
const context = this.contextFor(runDir);
|
|
225
|
+
if (context.sessionEventsStopped) {
|
|
226
|
+
throw new Error("Session event capture has stopped");
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
let expected = context.sessionEventSeq + 1;
|
|
230
|
+
for (const record of records) {
|
|
231
|
+
validateSessionEventRecord(record);
|
|
232
|
+
if (record.seq !== expected) {
|
|
233
|
+
throw new Error(`Expected session event seq ${expected}, got ${record.seq}`);
|
|
234
|
+
}
|
|
235
|
+
expected += 1;
|
|
236
|
+
}
|
|
237
|
+
const encoded = await Promise.all(
|
|
238
|
+
records.map(async (record) => ({
|
|
239
|
+
...record,
|
|
240
|
+
payload:
|
|
241
|
+
record.type === "tool_execution_started" ||
|
|
242
|
+
record.type === "tool_execution_finished" ||
|
|
243
|
+
(record.type === "assistant_event" && record.payload.type === "toolcall_end")
|
|
244
|
+
? ((await encodeValue(record.payload, context.artifacts)) as Record<
|
|
245
|
+
string,
|
|
246
|
+
unknown
|
|
247
|
+
>)
|
|
248
|
+
: record.payload,
|
|
249
|
+
})),
|
|
250
|
+
);
|
|
251
|
+
for (const record of encoded) {
|
|
252
|
+
if (Buffer.byteLength(JSON.stringify(record), "utf8") + 1 > SESSION_EVENT_MAX_BYTES) {
|
|
253
|
+
throw new Error(`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
await appendLines(path.join(runDir, SESSION_EVENTS_PATH), encoded);
|
|
257
|
+
context.sessionEventSeq = records.at(-1)?.seq ?? context.sessionEventSeq;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
context.sessionEventsStopped = true;
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Atomically replace the temporal capture integrity projection. */
|
|
266
|
+
async writeSessionCapture(runDir: string, capture: WorkflowSessionCapture): Promise<void> {
|
|
267
|
+
validateSessionCapture(capture);
|
|
268
|
+
await this.withSessionEventLock(runDir, async () => {
|
|
269
|
+
const context = this.contextFor(runDir);
|
|
270
|
+
if (capture.status !== "recording") {
|
|
271
|
+
context.sessionEventsStopped = true;
|
|
272
|
+
}
|
|
273
|
+
await writeJsonAtomic(path.join(runDir, SESSION_CAPTURE_PATH), capture);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Count complete durable session records after both writers have drained. */
|
|
278
|
+
async sessionCounts(
|
|
279
|
+
runDir: string,
|
|
280
|
+
): Promise<{ eventCount: number; entryCount: number; lastEventSeq: number }> {
|
|
281
|
+
const events = await readCompleteNdjson(path.join(runDir, SESSION_EVENTS_PATH));
|
|
282
|
+
const entries = await readCompleteNdjson(path.join(runDir, SESSION_ENTRIES_PATH));
|
|
283
|
+
return {
|
|
284
|
+
eventCount: events.length,
|
|
285
|
+
entryCount: entries.length,
|
|
286
|
+
lastEventSeq: events.at(-1)?.seq ?? 0,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async appendTraceEvent(
|
|
291
|
+
runDir: string,
|
|
292
|
+
runId: string,
|
|
98
293
|
event: WorkflowTraceEventDraft,
|
|
99
294
|
): Promise<WorkflowTraceEvent> {
|
|
295
|
+
const context = this.contextFor(runDir);
|
|
100
296
|
const traceEvent: WorkflowTraceEvent = {
|
|
101
|
-
seq:
|
|
297
|
+
seq: context.traceSeq + 1,
|
|
102
298
|
at: new Date().toISOString(),
|
|
103
|
-
runId
|
|
299
|
+
runId,
|
|
104
300
|
...event,
|
|
301
|
+
payload: (await encodeValue(event.payload, context.artifacts)) as Record<string, unknown>,
|
|
105
302
|
};
|
|
106
|
-
await
|
|
303
|
+
await appendLine(path.join(runDir, TRACE_PATH), traceEvent);
|
|
304
|
+
context.traceSeq = traceEvent.seq;
|
|
107
305
|
return traceEvent;
|
|
108
306
|
}
|
|
109
307
|
|
|
110
|
-
private
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
308
|
+
private async writeProjections(runDir: string, state: WorkflowRunState): Promise<void> {
|
|
309
|
+
const context = this.contextFor(runDir);
|
|
310
|
+
const encoded = await encodeRunState(state, context.artifacts);
|
|
311
|
+
await writeJsonAtomic(path.join(runDir, STATE_PATH), encoded);
|
|
312
|
+
await writeJsonAtomic(
|
|
313
|
+
path.join(runDir, MANIFEST_PATH),
|
|
314
|
+
createManifest(state, {
|
|
315
|
+
session: context.sessionBound,
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
114
318
|
}
|
|
319
|
+
}
|
|
115
320
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
321
|
+
async function appendLine(filePath: string, value: unknown): Promise<void> {
|
|
322
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
323
|
+
await fs.appendFile(filePath, value === null ? "" : `${JSON.stringify(value)}\n`, {
|
|
324
|
+
encoding: "utf8",
|
|
325
|
+
mode: 0o600,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function appendLines(filePath: string, values: unknown[]): Promise<void> {
|
|
330
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
331
|
+
const text = values.map((value) => `${JSON.stringify(value)}\n`).join("");
|
|
332
|
+
await fs.appendFile(filePath, text, { encoding: "utf8", mode: 0o600 });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
336
|
+
return typeof value === "string" && value.length > 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function validateSessionEventRecord(record: WorkflowSessionEventRecord): void {
|
|
340
|
+
if (!Number.isSafeInteger(record.seq) || record.seq < 1) {
|
|
341
|
+
throw new Error("Session event seq must be a positive safe integer");
|
|
342
|
+
}
|
|
343
|
+
if (
|
|
344
|
+
!isNonEmptyString(record.at) ||
|
|
345
|
+
!isNonEmptyString(record.nodeId) ||
|
|
346
|
+
!isNonEmptyString(record.attemptId) ||
|
|
347
|
+
!isNonEmptyString(record.type) ||
|
|
348
|
+
typeof record.payload !== "object" ||
|
|
349
|
+
record.payload === null ||
|
|
350
|
+
Array.isArray(record.payload)
|
|
351
|
+
) {
|
|
352
|
+
throw new Error("Session event is missing required envelope fields");
|
|
353
|
+
}
|
|
354
|
+
const knownType = [
|
|
355
|
+
"turn_started",
|
|
356
|
+
"turn_finished",
|
|
357
|
+
"message_started",
|
|
358
|
+
"assistant_event",
|
|
359
|
+
"message_finished",
|
|
360
|
+
"tool_execution_started",
|
|
361
|
+
"tool_execution_updated",
|
|
362
|
+
"tool_execution_finished",
|
|
363
|
+
].includes(record.type);
|
|
364
|
+
if (!knownType) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (!isNonEmptyString(record.turnId)) {
|
|
368
|
+
throw new Error(`${record.type} requires turnId`);
|
|
369
|
+
}
|
|
370
|
+
if (
|
|
371
|
+
!["turn_started", "turn_finished"].includes(record.type) &&
|
|
372
|
+
!isNonEmptyString(record.messageId)
|
|
373
|
+
) {
|
|
374
|
+
throw new Error(`${record.type} requires messageId`);
|
|
375
|
+
}
|
|
376
|
+
if (record.type.startsWith("tool_execution_") && !isNonEmptyString(record.toolCallId)) {
|
|
377
|
+
throw new Error(`${record.type} requires toolCallId`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function sessionRelationshipDiagnostics(
|
|
382
|
+
entries: WorkflowSessionEntryRecord[],
|
|
383
|
+
events: WorkflowSessionEventRecord[],
|
|
384
|
+
): string[] {
|
|
385
|
+
const entryIds = new Set(
|
|
386
|
+
entries.flatMap((record) => (isNonEmptyString(record.entry.id) ? [record.entry.id] : [])),
|
|
387
|
+
);
|
|
388
|
+
const turns = new Set<string>();
|
|
389
|
+
const messages = new Set<string>();
|
|
390
|
+
const tools = new Set<string>();
|
|
391
|
+
const diagnostics: string[] = [];
|
|
392
|
+
for (const event of events) {
|
|
393
|
+
switch (event.type) {
|
|
394
|
+
case "turn_started":
|
|
395
|
+
if (event.turnId) turns.add(event.turnId);
|
|
396
|
+
break;
|
|
397
|
+
case "turn_finished":
|
|
398
|
+
if (!event.turnId || !turns.has(event.turnId)) {
|
|
399
|
+
diagnostics.push(`turn_finished ${event.seq} precedes turn_started`);
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
case "message_started":
|
|
403
|
+
if (!event.turnId || !turns.has(event.turnId)) {
|
|
404
|
+
diagnostics.push(`message_started ${event.seq} precedes turn_started`);
|
|
405
|
+
}
|
|
406
|
+
if (event.messageId) messages.add(event.messageId);
|
|
407
|
+
break;
|
|
408
|
+
case "assistant_event":
|
|
409
|
+
if (!event.messageId || !messages.has(event.messageId)) {
|
|
410
|
+
diagnostics.push(`assistant_event ${event.seq} precedes message_started`);
|
|
411
|
+
}
|
|
412
|
+
break;
|
|
413
|
+
case "message_finished": {
|
|
414
|
+
if (!event.messageId || !messages.has(event.messageId)) {
|
|
415
|
+
diagnostics.push(`message_finished ${event.seq} precedes message_started`);
|
|
416
|
+
}
|
|
417
|
+
const settled = event.payload.settled;
|
|
418
|
+
const entryId = event.payload.entryId;
|
|
419
|
+
if (settled === true && (!isNonEmptyString(entryId) || !entryIds.has(entryId))) {
|
|
420
|
+
diagnostics.push(`message_finished ${event.seq} references a missing entry`);
|
|
421
|
+
} else if (settled !== true && entryId !== undefined) {
|
|
422
|
+
diagnostics.push(`message_finished ${event.seq} has entryId while unsettled`);
|
|
423
|
+
}
|
|
424
|
+
break;
|
|
125
425
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
426
|
+
case "tool_execution_started":
|
|
427
|
+
if (!event.messageId || !messages.has(event.messageId)) {
|
|
428
|
+
diagnostics.push(`tool_execution_started ${event.seq} precedes message_started`);
|
|
429
|
+
}
|
|
430
|
+
if (event.toolCallId) tools.add(event.toolCallId);
|
|
431
|
+
break;
|
|
432
|
+
case "tool_execution_updated":
|
|
433
|
+
case "tool_execution_finished":
|
|
434
|
+
if (!event.toolCallId || !tools.has(event.toolCallId)) {
|
|
435
|
+
diagnostics.push(`${event.type} ${event.seq} precedes tool_execution_started`);
|
|
436
|
+
}
|
|
437
|
+
break;
|
|
438
|
+
default:
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return diagnostics;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function validateSessionCapture(capture: WorkflowSessionCapture): void {
|
|
446
|
+
if (
|
|
447
|
+
capture.schema !== SESSION_CAPTURE_SCHEMA ||
|
|
448
|
+
capture.eventSchema !== SESSION_EVENT_SCHEMA ||
|
|
449
|
+
!["recording", "complete", "failed"].includes(capture.status) ||
|
|
450
|
+
!Number.isSafeInteger(capture.eventCount) ||
|
|
451
|
+
capture.eventCount < 0 ||
|
|
452
|
+
!Number.isSafeInteger(capture.entryCount) ||
|
|
453
|
+
capture.entryCount < 0 ||
|
|
454
|
+
!Number.isSafeInteger(capture.lastEventSeq) ||
|
|
455
|
+
capture.lastEventSeq < 0
|
|
456
|
+
) {
|
|
457
|
+
throw new Error("Invalid session capture projection");
|
|
458
|
+
}
|
|
459
|
+
if (capture.status === "failed" && capture.failure === undefined) {
|
|
460
|
+
throw new Error("Failed session capture requires failure details");
|
|
461
|
+
}
|
|
462
|
+
if (capture.status !== "failed" && capture.failure !== undefined) {
|
|
463
|
+
throw new Error("Only failed session capture may contain failure details");
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function readCompleteNdjson(filePath: string): Promise<Array<{ seq: number }>> {
|
|
468
|
+
let raw: string;
|
|
469
|
+
try {
|
|
470
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
471
|
+
} catch {
|
|
472
|
+
return [];
|
|
129
473
|
}
|
|
474
|
+
const lines = raw.split("\n");
|
|
475
|
+
if (!raw.endsWith("\n")) {
|
|
476
|
+
lines.pop();
|
|
477
|
+
}
|
|
478
|
+
const records: Array<{ seq: number }> = [];
|
|
479
|
+
for (const line of lines) {
|
|
480
|
+
if (line.trim().length === 0) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
const value = JSON.parse(line) as { seq?: unknown };
|
|
485
|
+
if (!Number.isSafeInteger(value.seq) || (value.seq as number) < 1) {
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
records.push({ seq: value.seq as number });
|
|
489
|
+
} catch {
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return records;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Encode the externalizable value positions of the state document. The
|
|
498
|
+
* in-memory state always holds raw values; the persisted copy may carry
|
|
499
|
+
* `$artifact` references instead of large strings.
|
|
500
|
+
*/
|
|
501
|
+
async function encodeRunState(
|
|
502
|
+
state: WorkflowRunState,
|
|
503
|
+
artifacts: ArtifactWriter,
|
|
504
|
+
): Promise<WorkflowRunState> {
|
|
505
|
+
const results = Object.fromEntries(
|
|
506
|
+
await Promise.all(
|
|
507
|
+
Object.entries(state.results).map(async ([nodeId, result]) => [
|
|
508
|
+
nodeId,
|
|
509
|
+
"output" in result
|
|
510
|
+
? { ...result, output: await encodeValue(result.output, artifacts) }
|
|
511
|
+
: result,
|
|
512
|
+
]),
|
|
513
|
+
),
|
|
514
|
+
) as WorkflowRunState["results"];
|
|
515
|
+
return {
|
|
516
|
+
...state,
|
|
517
|
+
input: await encodeValue(state.input, artifacts),
|
|
518
|
+
outputs: Object.fromEntries(
|
|
519
|
+
await Promise.all(
|
|
520
|
+
Object.entries(state.outputs).map(async ([nodeId, output]) => [
|
|
521
|
+
nodeId,
|
|
522
|
+
await encodeValue(output, artifacts),
|
|
523
|
+
]),
|
|
524
|
+
),
|
|
525
|
+
),
|
|
526
|
+
results,
|
|
527
|
+
steps: await Promise.all(
|
|
528
|
+
state.steps.map(async (step) => ({
|
|
529
|
+
...step,
|
|
530
|
+
prompt: (await encodeValue(
|
|
531
|
+
step.prompt,
|
|
532
|
+
artifacts,
|
|
533
|
+
)) as WorkflowRunState["steps"][number]["prompt"],
|
|
534
|
+
output: await encodeValue(step.output, artifacts),
|
|
535
|
+
})),
|
|
536
|
+
),
|
|
537
|
+
...(state.finalOutput !== undefined
|
|
538
|
+
? { finalOutput: await encodeValue(state.finalOutput, artifacts) }
|
|
539
|
+
: {}),
|
|
540
|
+
};
|
|
130
541
|
}
|
|
131
542
|
|
|
543
|
+
export type SessionCaptureIntegrity = {
|
|
544
|
+
status: "unavailable" | "recording" | "complete" | "failed" | "invalid";
|
|
545
|
+
diagnostics: string[];
|
|
546
|
+
};
|
|
547
|
+
|
|
132
548
|
export type LoadedRunBundle = {
|
|
133
549
|
runDir: string;
|
|
134
550
|
manifest: WorkflowRunManifest;
|
|
135
551
|
state: WorkflowRunState;
|
|
136
552
|
snapshot: WorkflowDefinitionSnapshot | null;
|
|
553
|
+
sessionBinding: WorkflowSessionBinding | null;
|
|
554
|
+
sessionEntries: WorkflowSessionEntryRecord[];
|
|
555
|
+
sessionEvents: WorkflowSessionEventRecord[];
|
|
556
|
+
sessionCapture: WorkflowSessionCapture | null;
|
|
557
|
+
sessionIntegrity: SessionCaptureIntegrity;
|
|
137
558
|
};
|
|
138
559
|
|
|
139
560
|
/** Read a run bundle from disk. Returns null when the bundle is unreadable. */
|
|
140
561
|
export async function readRunBundle(runDir: string): Promise<LoadedRunBundle | null> {
|
|
141
562
|
const manifest = await readJsonFile<WorkflowRunManifest>(path.join(runDir, MANIFEST_PATH));
|
|
142
|
-
|
|
143
|
-
|
|
563
|
+
if (!manifest || manifest.schema !== RUN_BUNDLE_SCHEMA) {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
// A schema-tagged manifest may still be malformed (e.g. hand-edited);
|
|
567
|
+
// treat anything unexpected as an unreadable bundle rather than throwing.
|
|
568
|
+
const paths: Partial<WorkflowRunManifest["paths"]> =
|
|
569
|
+
typeof manifest.paths === "object" && manifest.paths !== null ? manifest.paths : {};
|
|
570
|
+
const state = await readJsonFile<WorkflowRunState>(
|
|
571
|
+
resolveBundlePath(runDir, paths.state, STATE_PATH),
|
|
572
|
+
);
|
|
573
|
+
if (!state || state.schema !== RUN_STATE_SCHEMA) {
|
|
144
574
|
return null;
|
|
145
575
|
}
|
|
146
576
|
const snapshot = await readJsonFile<WorkflowDefinitionSnapshot>(
|
|
147
|
-
|
|
577
|
+
resolveBundlePath(runDir, paths.workflow, WORKFLOW_SNAPSHOT_PATH),
|
|
578
|
+
);
|
|
579
|
+
const sessionDir = resolveBundlePath(runDir, paths.session, SESSION_DIR);
|
|
580
|
+
const sessionBinding = await readJsonFile<WorkflowSessionBinding>(
|
|
581
|
+
path.join(sessionDir, "binding.json"),
|
|
582
|
+
);
|
|
583
|
+
const entries = await readNdjsonFile<WorkflowSessionEntryRecord>(
|
|
584
|
+
path.join(sessionDir, "entries.ndjson"),
|
|
148
585
|
);
|
|
149
|
-
|
|
586
|
+
const events = await readNdjsonFile<WorkflowSessionEventRecord>(
|
|
587
|
+
path.join(sessionDir, "events.ndjson"),
|
|
588
|
+
);
|
|
589
|
+
const sessionCapture = await readJsonFile<WorkflowSessionCapture>(
|
|
590
|
+
path.join(sessionDir, "capture.json"),
|
|
591
|
+
);
|
|
592
|
+
const sessionIntegrity = assessSessionIntegrity({
|
|
593
|
+
binding: sessionBinding,
|
|
594
|
+
entries,
|
|
595
|
+
events,
|
|
596
|
+
capture: sessionCapture,
|
|
597
|
+
runTerminal: state.status !== "running",
|
|
598
|
+
});
|
|
599
|
+
return {
|
|
600
|
+
runDir,
|
|
601
|
+
manifest,
|
|
602
|
+
state,
|
|
603
|
+
snapshot,
|
|
604
|
+
sessionBinding,
|
|
605
|
+
sessionEntries: entries.records,
|
|
606
|
+
sessionEvents: events.records,
|
|
607
|
+
sessionCapture,
|
|
608
|
+
sessionIntegrity,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Resolve a manifest-relative path, rejecting anything that is not a string
|
|
614
|
+
* or escapes the bundle directory. Malformed manifests must degrade to an
|
|
615
|
+
* unreadable bundle, never to a thrown error that aborts a listing.
|
|
616
|
+
*/
|
|
617
|
+
function resolveBundlePath(runDir: string, relative: unknown, fallback: string): string {
|
|
618
|
+
const candidate = path.resolve(
|
|
619
|
+
runDir,
|
|
620
|
+
typeof relative === "string" && relative ? relative : fallback,
|
|
621
|
+
);
|
|
622
|
+
if (
|
|
623
|
+
candidate !== path.resolve(runDir) &&
|
|
624
|
+
!candidate.startsWith(path.resolve(runDir) + path.sep)
|
|
625
|
+
) {
|
|
626
|
+
return path.join(runDir, fallback);
|
|
627
|
+
}
|
|
628
|
+
return candidate;
|
|
150
629
|
}
|
|
151
630
|
|
|
152
631
|
/** List run bundles under `outputRoot`, most recently started first. */
|
|
@@ -177,7 +656,120 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
|
|
177
656
|
}
|
|
178
657
|
}
|
|
179
658
|
|
|
180
|
-
|
|
659
|
+
type NdjsonRead<T> = {
|
|
660
|
+
records: T[];
|
|
661
|
+
exists: boolean;
|
|
662
|
+
tornTail: boolean;
|
|
663
|
+
malformed: boolean;
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
async function readNdjsonFile<T>(filePath: string): Promise<NdjsonRead<T>> {
|
|
667
|
+
let raw: string;
|
|
668
|
+
try {
|
|
669
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
670
|
+
} catch {
|
|
671
|
+
return { records: [], exists: false, tornTail: false, malformed: false };
|
|
672
|
+
}
|
|
673
|
+
const tornTail = raw.length > 0 && !raw.endsWith("\n");
|
|
674
|
+
const lines = raw.split("\n");
|
|
675
|
+
if (tornTail) {
|
|
676
|
+
lines.pop();
|
|
677
|
+
}
|
|
678
|
+
const records: T[] = [];
|
|
679
|
+
let malformed = false;
|
|
680
|
+
for (const line of lines) {
|
|
681
|
+
if (line.trim().length === 0) {
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
try {
|
|
685
|
+
records.push(JSON.parse(line) as T);
|
|
686
|
+
} catch {
|
|
687
|
+
malformed = true;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return { records, exists: true, tornTail, malformed };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function assessSessionIntegrity(input: {
|
|
694
|
+
binding: WorkflowSessionBinding | null;
|
|
695
|
+
entries: NdjsonRead<WorkflowSessionEntryRecord>;
|
|
696
|
+
events: NdjsonRead<WorkflowSessionEventRecord>;
|
|
697
|
+
capture: WorkflowSessionCapture | null;
|
|
698
|
+
runTerminal: boolean;
|
|
699
|
+
}): SessionCaptureIntegrity {
|
|
700
|
+
const anySessionFile =
|
|
701
|
+
input.binding !== null || input.entries.exists || input.events.exists || input.capture !== null;
|
|
702
|
+
if (!anySessionFile) {
|
|
703
|
+
return { status: "unavailable", diagnostics: [] };
|
|
704
|
+
}
|
|
705
|
+
const diagnostics: string[] = [];
|
|
706
|
+
if (!input.binding || input.binding.schema !== SESSION_BINDING_SCHEMA) {
|
|
707
|
+
diagnostics.push("missing or invalid session binding");
|
|
708
|
+
}
|
|
709
|
+
if (!input.capture) {
|
|
710
|
+
diagnostics.push("missing session capture status");
|
|
711
|
+
return { status: "invalid", diagnostics };
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
validateSessionCapture(input.capture);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
diagnostics.push(failureMessageForDiagnostic(error));
|
|
717
|
+
return { status: "invalid", diagnostics };
|
|
718
|
+
}
|
|
719
|
+
if (input.entries.malformed || input.events.malformed) {
|
|
720
|
+
diagnostics.push("malformed NDJSON line before the journal tail");
|
|
721
|
+
}
|
|
722
|
+
if (input.events.tornTail && input.capture.status !== "recording") {
|
|
723
|
+
diagnostics.push("terminal session event journal has a torn tail");
|
|
724
|
+
}
|
|
725
|
+
if (input.runTerminal && input.capture.status === "recording") {
|
|
726
|
+
diagnostics.push("terminal run still reports recording capture");
|
|
727
|
+
}
|
|
728
|
+
let expected = 1;
|
|
729
|
+
for (const event of input.events.records) {
|
|
730
|
+
try {
|
|
731
|
+
validateSessionEventRecord(event);
|
|
732
|
+
} catch (error) {
|
|
733
|
+
diagnostics.push(failureMessageForDiagnostic(error));
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
if (event.seq !== expected) {
|
|
737
|
+
diagnostics.push(`session event sequence gap at ${expected}`);
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
expected += 1;
|
|
741
|
+
}
|
|
742
|
+
diagnostics.push(...sessionRelationshipDiagnostics(input.entries.records, input.events.records));
|
|
743
|
+
if (input.capture.status !== "recording") {
|
|
744
|
+
const lastEventSeq = input.events.records.at(-1)?.seq ?? 0;
|
|
745
|
+
if (
|
|
746
|
+
input.capture.eventCount !== input.events.records.length ||
|
|
747
|
+
input.capture.entryCount !== input.entries.records.length ||
|
|
748
|
+
input.capture.lastEventSeq !== lastEventSeq
|
|
749
|
+
) {
|
|
750
|
+
diagnostics.push("session capture counts do not match durable files");
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (diagnostics.length > 0) {
|
|
754
|
+
return { status: "invalid", diagnostics };
|
|
755
|
+
}
|
|
756
|
+
if (input.capture.status === "failed") {
|
|
757
|
+
return {
|
|
758
|
+
status: "failed",
|
|
759
|
+
diagnostics: [input.capture.failure?.message ?? "session capture failed"],
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
return { status: input.capture.status, diagnostics: [] };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function failureMessageForDiagnostic(error: unknown): string {
|
|
766
|
+
return error instanceof Error ? error.message : String(error);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function createManifest(
|
|
770
|
+
state: WorkflowRunState,
|
|
771
|
+
present: { session: boolean },
|
|
772
|
+
): WorkflowRunManifest {
|
|
181
773
|
return {
|
|
182
774
|
schema: RUN_BUNDLE_SCHEMA,
|
|
183
775
|
runId: state.runId,
|
|
@@ -192,6 +784,11 @@ function createManifest(state: WorkflowRunState): WorkflowRunManifest {
|
|
|
192
784
|
workflow: WORKFLOW_SNAPSHOT_PATH,
|
|
193
785
|
state: STATE_PATH,
|
|
194
786
|
trace: TRACE_PATH,
|
|
787
|
+
...(present.session ? { session: SESSION_DIR } : {}),
|
|
788
|
+
// Declare this before any payload can externalize a string. Live
|
|
789
|
+
// session-event patches may reference a newly written artifact before
|
|
790
|
+
// the next workflow state projection refreshes the manifest.
|
|
791
|
+
artifacts: "artifacts",
|
|
195
792
|
},
|
|
196
793
|
};
|
|
197
794
|
}
|
|
@@ -228,7 +825,10 @@ function snapshotNode(node: WorkflowNodeDefinition): WorkflowNodeSnapshot {
|
|
|
228
825
|
|
|
229
826
|
async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
|
|
230
827
|
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
231
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
232
|
-
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`,
|
|
828
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
829
|
+
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
830
|
+
encoding: "utf8",
|
|
831
|
+
mode: 0o600,
|
|
832
|
+
});
|
|
233
833
|
await fs.rename(tempPath, filePath);
|
|
234
834
|
}
|