@osolmaz/pi-workflows 0.1.0 → 0.3.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 +107 -26
- package/dist/builtins/monitor.workflow.d.ts +71 -0
- package/dist/builtins/monitor.workflow.js +234 -0
- package/dist/builtins/monitor.workflow.js.map +1 -0
- package/dist/controllers/conditions.d.ts +6 -0
- package/dist/controllers/conditions.js +68 -0
- package/dist/controllers/conditions.js.map +1 -0
- package/dist/controllers/definition.d.ts +6 -0
- package/dist/controllers/definition.js +45 -0
- package/dist/controllers/definition.js.map +1 -0
- package/dist/controllers/effects.d.ts +14 -0
- package/dist/controllers/effects.js +104 -0
- package/dist/controllers/effects.js.map +1 -0
- package/dist/controllers/errors.d.ts +12 -0
- package/dist/controllers/errors.js +25 -0
- package/dist/controllers/errors.js.map +1 -0
- package/dist/controllers/index.d.ts +13 -0
- package/dist/controllers/index.js +13 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/controllers/json.d.ts +5 -0
- package/dist/controllers/json.js +57 -0
- package/dist/controllers/json.js.map +1 -0
- package/dist/controllers/loader.d.ts +23 -0
- package/dist/controllers/loader.js +74 -0
- package/dist/controllers/loader.js.map +1 -0
- package/dist/controllers/manager.d.ts +58 -0
- package/dist/controllers/manager.js +399 -0
- package/dist/controllers/manager.js.map +1 -0
- package/dist/controllers/results.d.ts +5 -0
- package/dist/controllers/results.js +32 -0
- package/dist/controllers/results.js.map +1 -0
- package/dist/controllers/sqlite.d.ts +212 -0
- package/dist/controllers/sqlite.js +1009 -0
- package/dist/controllers/sqlite.js.map +1 -0
- package/dist/controllers/store.d.ts +112 -0
- package/dist/controllers/store.js +32 -0
- package/dist/controllers/store.js.map +1 -0
- package/dist/controllers/types.d.ts +159 -0
- package/dist/controllers/types.js +2 -0
- package/dist/controllers/types.js.map +1 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
- package/dist/controllers/workflow-engine-scheduler.js +93 -0
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
- package/dist/controllers/workflows.d.ts +27 -0
- package/dist/controllers/workflows.js +109 -0
- package/dist/controllers/workflows.js.map +1 -0
- package/dist/extension/controller-host.d.ts +47 -0
- package/dist/extension/controller-host.js +110 -0
- package/dist/extension/controller-host.js.map +1 -0
- package/dist/extension/executor.d.ts +14 -1
- package/dist/extension/executor.js +12 -2
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +1118 -89
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +91 -0
- package/dist/extension/recorder.js +537 -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/extension/workflow-tool.d.ts +28 -0
- package/dist/extension/workflow-tool.js +33 -0
- package/dist/extension/workflow-tool.js.map +1 -0
- package/dist/host/processes.d.ts +24 -0
- package/dist/host/processes.js +114 -0
- package/dist/host/processes.js.map +1 -0
- package/dist/host/rpc-bridge.d.ts +9 -0
- package/dist/host/rpc-bridge.js +39 -0
- package/dist/host/rpc-bridge.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +38 -0
- package/dist/host/rpc-executor.js +254 -0
- package/dist/host/rpc-executor.js.map +1 -0
- package/dist/host/runner.d.ts +49 -0
- package/dist/host/runner.js +350 -0
- package/dist/host/runner.js.map +1 -0
- 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/cli.d.ts +7 -3
- package/dist/viewer/cli.js +150 -19
- package/dist/viewer/cli.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 +38 -0
- package/dist/workflows/engine.js +281 -20
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +23 -0
- package/dist/workflows/errors.js +38 -0
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/graph.js +0 -5
- package/dist/workflows/graph.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/loader.d.ts +5 -3
- package/dist/workflows/loader.js +10 -1
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/schema.js +1 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +98 -10
- package/dist/workflows/store.js +921 -46
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +136 -3
- package/docs/CONTROLLERS.md +215 -0
- package/docs/development.md +51 -24
- package/docs/live-replay-protocol.md +155 -0
- package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
- package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
- package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -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 +359 -55
- package/docs/session-event-journal.md +470 -0
- package/docs/tui-viewer.md +218 -0
- package/docs/workflows.md +131 -11
- package/examples/controllers/pull-request.controller.ts +215 -0
- package/package.json +11 -2
- package/src/builtins/monitor.workflow.ts +278 -0
- package/src/controllers/conditions.ts +110 -0
- package/src/controllers/definition.ts +65 -0
- package/src/controllers/effects.ts +123 -0
- package/src/controllers/errors.ts +27 -0
- package/src/controllers/index.ts +90 -0
- package/src/controllers/json.ts +62 -0
- package/src/controllers/loader.ts +104 -0
- package/src/controllers/manager.ts +533 -0
- package/src/controllers/results.ts +46 -0
- package/src/controllers/sqlite.ts +1427 -0
- package/src/controllers/store.ts +160 -0
- package/src/controllers/types.ts +183 -0
- package/src/controllers/workflow-engine-scheduler.ts +145 -0
- package/src/controllers/workflows.ts +152 -0
- package/src/extension/controller-host.ts +163 -0
- package/src/extension/executor.ts +29 -2
- package/src/extension/index.ts +1315 -109
- package/src/extension/recorder.ts +662 -0
- package/src/extension/session-events.ts +119 -0
- package/src/extension/widget.ts +26 -24
- package/src/extension/workflow-tool.ts +59 -0
- package/src/host/processes.ts +119 -0
- package/src/host/rpc-bridge.ts +44 -0
- package/src/host/rpc-executor.ts +299 -0
- package/src/host/runner.ts +406 -0
- package/src/render/canvas.ts +19 -1
- package/src/render/graph-render.ts +277 -44
- package/src/viewer/cli.ts +167 -21
- 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 +365 -19
- package/src/workflows/errors.ts +45 -0
- package/src/workflows/graph.ts +0 -5
- package/src/workflows/index.ts +15 -0
- package/src/workflows/loader.ts +13 -3
- package/src/workflows/schema.ts +1 -1
- package/src/workflows/store.ts +1157 -48
- package/src/workflows/types.ts +151 -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,61 @@ 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`;
|
|
39
|
+
const SESSION_SEGMENTS_DIR = `${SESSION_DIR}/segments`;
|
|
40
|
+
|
|
41
|
+
/** Capture file layout for one recorder attempt; "" is the legacy flat stream. */
|
|
42
|
+
function sessionStreamPaths(attemptId?: string): {
|
|
43
|
+
dir: string;
|
|
44
|
+
binding: string;
|
|
45
|
+
entries: string;
|
|
46
|
+
events: string;
|
|
47
|
+
capture: string;
|
|
48
|
+
} {
|
|
49
|
+
if (attemptId === undefined) {
|
|
50
|
+
return {
|
|
51
|
+
dir: SESSION_DIR,
|
|
52
|
+
binding: SESSION_BINDING_PATH,
|
|
53
|
+
entries: SESSION_ENTRIES_PATH,
|
|
54
|
+
events: SESSION_EVENTS_PATH,
|
|
55
|
+
capture: SESSION_CAPTURE_PATH,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
assertValidRunId(attemptId);
|
|
59
|
+
const dir = `${SESSION_SEGMENTS_DIR}/${attemptId}`;
|
|
60
|
+
return {
|
|
61
|
+
dir,
|
|
62
|
+
binding: `${dir}/binding.json`,
|
|
63
|
+
entries: `${dir}/entries.ndjson`,
|
|
64
|
+
events: `${dir}/events.ndjson`,
|
|
65
|
+
capture: `${dir}/capture.json`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
24
68
|
|
|
25
69
|
/** Runs directory: `$PI_WORKFLOWS_RUNS_DIR` or `~/.pi/agent/workflows/runs`. */
|
|
26
70
|
export function workflowRunsBaseDir(homeDir: string = os.homedir()): string {
|
|
@@ -44,109 +88,1053 @@ export function createRunId(workflowName: string, now: Date = new Date()): strin
|
|
|
44
88
|
return `${stamp}-${slug || "workflow"}-${randomUUID().slice(0, 8)}`;
|
|
45
89
|
}
|
|
46
90
|
|
|
91
|
+
/** Per-attempt capture stream state; the "" key is the legacy flat stream. */
|
|
92
|
+
type SessionStreamState = {
|
|
93
|
+
sessionSeq: number;
|
|
94
|
+
sessionEventSeq: number;
|
|
95
|
+
sessionBound: boolean;
|
|
96
|
+
sessionEventsStopped: boolean;
|
|
97
|
+
/** Session events have a separate append chain so token traffic cannot
|
|
98
|
+
* queue ahead of workflow transitions. */
|
|
99
|
+
lock: Promise<unknown>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
type RunBundleContext = {
|
|
103
|
+
traceSeq: number;
|
|
104
|
+
artifacts: ArtifactWriter;
|
|
105
|
+
/**
|
|
106
|
+
* Serializes complete transitions (encode, trace append, projections) so
|
|
107
|
+
* concurrent writers cannot interleave sequence assignment with physical
|
|
108
|
+
* append order.
|
|
109
|
+
*/
|
|
110
|
+
lock: Promise<unknown>;
|
|
111
|
+
streams: Map<string, SessionStreamState>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Persists run bundles (see docs/run-bundles.md). `trace.ndjson` is the
|
|
116
|
+
* append-only source of truth; every transition appends the trace event
|
|
117
|
+
* first, then atomically replaces `state.json` (carrying `traceSeq`) and
|
|
118
|
+
* `manifest.json`. Large string leaves in persisted values are externalized
|
|
119
|
+
* into content-addressed `artifacts/`. Bundles are private: directories are
|
|
120
|
+
* 0700 and files 0600.
|
|
121
|
+
*/
|
|
47
122
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
123
|
+
* A fence proves the writer still owns the run. It is checked before every
|
|
124
|
+
* locked write; it throws (ClaimLostError) when the queue claim was lost, so
|
|
125
|
+
* a stalled runner can never interleave writes with the new claim holder.
|
|
51
126
|
*/
|
|
127
|
+
export type RunFence = () => void;
|
|
128
|
+
|
|
129
|
+
export type WorkflowRunStoreOptions = {
|
|
130
|
+
fenceProvider?: (runDir: string) => RunFence | undefined;
|
|
131
|
+
};
|
|
132
|
+
|
|
52
133
|
export class WorkflowRunStore {
|
|
53
134
|
readonly outputRoot: string;
|
|
54
|
-
private readonly
|
|
55
|
-
private readonly
|
|
135
|
+
private readonly fenceProvider: ((runDir: string) => RunFence | undefined) | undefined;
|
|
136
|
+
private readonly contexts = new Map<string, RunBundleContext>();
|
|
56
137
|
|
|
57
|
-
constructor(outputRoot: string = workflowRunsBaseDir()) {
|
|
138
|
+
constructor(outputRoot: string = workflowRunsBaseDir(), options: WorkflowRunStoreOptions = {}) {
|
|
58
139
|
this.outputRoot = outputRoot;
|
|
140
|
+
this.fenceProvider = options.fenceProvider;
|
|
59
141
|
}
|
|
60
142
|
|
|
61
143
|
runDirFor(runId: string): string {
|
|
144
|
+
assertValidRunId(runId);
|
|
62
145
|
return path.join(this.outputRoot, runId);
|
|
63
146
|
}
|
|
64
147
|
|
|
148
|
+
async quarantineIncompleteRun(runId: string): Promise<string | undefined> {
|
|
149
|
+
const runDir = this.runDirFor(runId);
|
|
150
|
+
let runStat;
|
|
151
|
+
try {
|
|
152
|
+
runStat = await fs.lstat(runDir);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (isMissingPath(error)) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
if (!runStat.isDirectory() || runStat.isSymbolicLink()) {
|
|
160
|
+
throw new Error(`Reserved workflow run path is not a directory: ${runDir}`);
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await fs.lstat(path.join(runDir, MANIFEST_PATH));
|
|
164
|
+
throw new Error(`Reserved workflow run has an unreadable manifest: ${runId}`);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (!isMissingPath(error)) {
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const quarantineDir = path.join(
|
|
171
|
+
this.outputRoot,
|
|
172
|
+
`.${runId}.incomplete-${randomUUID().slice(0, 8)}`,
|
|
173
|
+
);
|
|
174
|
+
await fs.rename(runDir, quarantineDir);
|
|
175
|
+
this.contexts.delete(runDir);
|
|
176
|
+
return quarantineDir;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private contextFor(runDir: string): RunBundleContext {
|
|
180
|
+
let context = this.contexts.get(runDir);
|
|
181
|
+
if (!context) {
|
|
182
|
+
context = {
|
|
183
|
+
traceSeq: 0,
|
|
184
|
+
artifacts: new ArtifactWriter(runDir),
|
|
185
|
+
lock: Promise.resolve(),
|
|
186
|
+
streams: new Map(),
|
|
187
|
+
};
|
|
188
|
+
this.contexts.set(runDir, context);
|
|
189
|
+
}
|
|
190
|
+
return context;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private streamFor(runDir: string, attemptId?: string): SessionStreamState {
|
|
194
|
+
const context = this.contextFor(runDir);
|
|
195
|
+
const key = attemptId ?? "";
|
|
196
|
+
let stream = context.streams.get(key);
|
|
197
|
+
if (!stream) {
|
|
198
|
+
stream = {
|
|
199
|
+
sessionSeq: 0,
|
|
200
|
+
sessionEventSeq: 0,
|
|
201
|
+
sessionBound: false,
|
|
202
|
+
sessionEventsStopped: false,
|
|
203
|
+
lock: Promise.resolve(),
|
|
204
|
+
};
|
|
205
|
+
context.streams.set(key, stream);
|
|
206
|
+
}
|
|
207
|
+
return stream;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Run `task` exclusively for this bundle. Sequence numbers are assigned
|
|
212
|
+
* inside the lock, so physical file order always matches logical order.
|
|
213
|
+
*/
|
|
214
|
+
private withRunLock<T>(runDir: string, task: () => Promise<T>): Promise<T> {
|
|
215
|
+
const context = this.contextFor(runDir);
|
|
216
|
+
const result = context.lock.then(async () => {
|
|
217
|
+
this.fenceProvider?.(runDir)?.();
|
|
218
|
+
return await task();
|
|
219
|
+
});
|
|
220
|
+
context.lock = result.then(
|
|
221
|
+
() => undefined,
|
|
222
|
+
() => undefined,
|
|
223
|
+
);
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private withSessionEventLock<T>(
|
|
228
|
+
runDir: string,
|
|
229
|
+
attemptId: string | undefined,
|
|
230
|
+
task: () => Promise<T>,
|
|
231
|
+
): Promise<T> {
|
|
232
|
+
const stream = this.streamFor(runDir, attemptId);
|
|
233
|
+
const result = stream.lock.then(async () => {
|
|
234
|
+
this.fenceProvider?.(runDir)?.();
|
|
235
|
+
return await task();
|
|
236
|
+
});
|
|
237
|
+
stream.lock = result.then(
|
|
238
|
+
() => undefined,
|
|
239
|
+
() => undefined,
|
|
240
|
+
);
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
65
244
|
async initializeRunBundle(
|
|
66
245
|
workflow: WorkflowDefinition,
|
|
67
246
|
state: WorkflowRunState,
|
|
68
247
|
): Promise<string> {
|
|
69
248
|
const runDir = this.runDirFor(state.runId);
|
|
70
|
-
await
|
|
71
|
-
|
|
249
|
+
return await this.withRunLock(runDir, async () => {
|
|
250
|
+
await fs.mkdir(this.outputRoot, { recursive: true, mode: 0o700 });
|
|
251
|
+
await fs.mkdir(runDir, { recursive: false, mode: 0o700 });
|
|
252
|
+
await writeJsonAtomic(
|
|
253
|
+
path.join(runDir, WORKFLOW_SNAPSHOT_PATH),
|
|
254
|
+
createDefinitionSnapshot(workflow),
|
|
255
|
+
);
|
|
256
|
+
await appendLine(path.join(runDir, TRACE_PATH), null);
|
|
257
|
+
await this.writeProjections(runDir, state);
|
|
258
|
+
return runDir;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
72
261
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Prepare an interrupted bundle for resume. Repairs a torn trace tail,
|
|
264
|
+
* drops trace events the state projection never recorded, and seeds the
|
|
265
|
+
* in-process context so new events continue the sequence. The caller must
|
|
266
|
+
* hold the run's queue claim; the fence is verified before any write.
|
|
267
|
+
*/
|
|
268
|
+
async prepareRunResume(runId: string): Promise<LoadedRunBundle> {
|
|
269
|
+
const runDir = this.runDirFor(runId);
|
|
270
|
+
this.fenceProvider?.(runDir)?.();
|
|
271
|
+
const bundle = await readRunBundle(runDir);
|
|
272
|
+
if (bundle === null) {
|
|
273
|
+
throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
|
|
274
|
+
}
|
|
275
|
+
if (bundle.state.status !== "running") {
|
|
276
|
+
throw new Error(`Cannot resume workflow run ${runId} with status ${bundle.state.status}`);
|
|
277
|
+
}
|
|
278
|
+
const tracePath = resolveBundlePath(runDir, bundle.manifest.paths.trace, TRACE_PATH);
|
|
279
|
+
await repairTraceFile(tracePath, bundle.state.traceSeq, () => {
|
|
280
|
+
// The repair rewrites the trace; re-verify ownership immediately
|
|
281
|
+
// before the rename so a stalled runner cannot truncate a new claim
|
|
282
|
+
// holder's appended events.
|
|
283
|
+
this.fenceProvider?.(runDir)?.();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// A crashed session never finalizes its capture, and resuming recorders
|
|
287
|
+
// always write new segments — so dangling "recording" captures end here
|
|
288
|
+
// instead of reporting the run capture-corrupt forever.
|
|
289
|
+
await this.finalizeRecordingCaptures(runDir, "Workflow host stopped before the run finished");
|
|
290
|
+
|
|
291
|
+
const counts = await this.sessionCounts(runDir);
|
|
292
|
+
const captureFinished =
|
|
293
|
+
bundle.sessionCapture?.status === "complete" || bundle.sessionCapture?.status === "failed";
|
|
294
|
+
this.contexts.set(runDir, {
|
|
295
|
+
traceSeq: bundle.state.traceSeq,
|
|
296
|
+
artifacts: new ArtifactWriter(runDir),
|
|
297
|
+
lock: Promise.resolve(),
|
|
298
|
+
streams: seededStreams({
|
|
299
|
+
sessionSeq: counts.entryCount,
|
|
300
|
+
sessionEventSeq: counts.lastEventSeq,
|
|
301
|
+
sessionBound: bundle.manifest.paths.session !== undefined,
|
|
302
|
+
sessionEventsStopped: captureFinished,
|
|
303
|
+
}),
|
|
304
|
+
});
|
|
305
|
+
const prepared = await readRunBundle(runDir);
|
|
306
|
+
if (prepared === null) {
|
|
307
|
+
throw new Error(`Workflow run ${runId} became unreadable during resume preparation`);
|
|
308
|
+
}
|
|
309
|
+
return prepared;
|
|
310
|
+
}
|
|
80
311
|
|
|
81
|
-
|
|
312
|
+
/**
|
|
313
|
+
* Finalize captures left "recording" by a session that is gone, so they
|
|
314
|
+
* report failed with the reason instead of dangling forever.
|
|
315
|
+
*/
|
|
316
|
+
private async finalizeRecordingCaptures(
|
|
317
|
+
runDir: string,
|
|
318
|
+
reason: string,
|
|
319
|
+
options: { skipFlat?: boolean } = {},
|
|
320
|
+
): Promise<void> {
|
|
321
|
+
if (options.skipFlat !== true) {
|
|
322
|
+
const flatCapture = await readJsonFile<WorkflowSessionCapture>(
|
|
323
|
+
path.join(runDir, SESSION_CAPTURE_PATH),
|
|
324
|
+
);
|
|
325
|
+
if (flatCapture?.status === "recording") {
|
|
326
|
+
const counts = await this.sessionCounts(runDir);
|
|
327
|
+
await this.writeSessionCapture(runDir, {
|
|
328
|
+
schema: SESSION_CAPTURE_SCHEMA,
|
|
329
|
+
eventSchema: SESSION_EVENT_SCHEMA,
|
|
330
|
+
status: "failed",
|
|
331
|
+
...counts,
|
|
332
|
+
failure: {
|
|
333
|
+
failedAt: new Date().toISOString(),
|
|
334
|
+
code: "host_interrupted",
|
|
335
|
+
message: reason,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (const segmentId of await this.listSessionSegments(runDir)) {
|
|
341
|
+
const segmentCapture = await readJsonFile<WorkflowSessionCapture>(
|
|
342
|
+
path.join(runDir, sessionStreamPaths(segmentId).capture),
|
|
343
|
+
);
|
|
344
|
+
if (segmentCapture?.status !== "recording") {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const segmentCounts = await this.sessionCounts(runDir, segmentId);
|
|
348
|
+
await this.writeSessionCapture(
|
|
349
|
+
runDir,
|
|
350
|
+
{
|
|
351
|
+
schema: SESSION_CAPTURE_SCHEMA,
|
|
352
|
+
eventSchema: SESSION_EVENT_SCHEMA,
|
|
353
|
+
status: "failed",
|
|
354
|
+
...segmentCounts,
|
|
355
|
+
failure: {
|
|
356
|
+
failedAt: new Date().toISOString(),
|
|
357
|
+
code: "host_interrupted",
|
|
358
|
+
message: reason,
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
segmentId,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Mark a nonterminal bundle failed and append an interruption event. */
|
|
367
|
+
async markRunInterrupted(
|
|
368
|
+
runId: string,
|
|
369
|
+
reason = "Workflow host stopped before the run finished",
|
|
370
|
+
): Promise<LoadedRunBundle | null> {
|
|
371
|
+
const runDir = this.runDirFor(runId);
|
|
372
|
+
const bundle = await readRunBundle(runDir);
|
|
373
|
+
if (bundle === null || bundle.state.status !== "running") {
|
|
374
|
+
return bundle;
|
|
375
|
+
}
|
|
376
|
+
const lastTraceEvent = await readLastTraceEvent(runDir, bundle.manifest.paths.trace);
|
|
377
|
+
const counts = await this.sessionCounts(runDir);
|
|
378
|
+
const sessionBound = bundle.manifest.paths.session !== undefined;
|
|
379
|
+
const captureFinished =
|
|
380
|
+
bundle.sessionCapture?.status === "complete" || bundle.sessionCapture?.status === "failed";
|
|
381
|
+
this.contexts.set(runDir, {
|
|
382
|
+
traceSeq: Math.max(bundle.state.traceSeq, lastTraceEvent?.seq ?? 0),
|
|
383
|
+
artifacts: new ArtifactWriter(runDir),
|
|
384
|
+
lock: Promise.resolve(),
|
|
385
|
+
streams: seededStreams({
|
|
386
|
+
sessionSeq: counts.entryCount,
|
|
387
|
+
sessionEventSeq: counts.lastEventSeq,
|
|
388
|
+
sessionBound,
|
|
389
|
+
sessionEventsStopped: captureFinished,
|
|
390
|
+
}),
|
|
391
|
+
});
|
|
392
|
+
const state = bundle.state;
|
|
393
|
+
if (lastTraceEvent !== null && recoverTerminalProjection(state, lastTraceEvent)) {
|
|
394
|
+
await this.writeLoadedProjections(runDir, state);
|
|
395
|
+
return await readRunBundle(runDir);
|
|
396
|
+
}
|
|
397
|
+
if (sessionBound && !captureFinished) {
|
|
398
|
+
await this.writeSessionCapture(runDir, {
|
|
399
|
+
schema: SESSION_CAPTURE_SCHEMA,
|
|
400
|
+
eventSchema: SESSION_EVENT_SCHEMA,
|
|
401
|
+
status: "failed",
|
|
402
|
+
...counts,
|
|
403
|
+
failure: {
|
|
404
|
+
failedAt: new Date().toISOString(),
|
|
405
|
+
code: "host_interrupted",
|
|
406
|
+
message: reason,
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
await this.finalizeRecordingCaptures(runDir, reason, { skipFlat: true });
|
|
411
|
+
state.status = "failed";
|
|
412
|
+
state.finishedAt = new Date().toISOString();
|
|
413
|
+
state.error = reason;
|
|
414
|
+
delete state.currentNode;
|
|
415
|
+
delete state.currentAttemptId;
|
|
416
|
+
delete state.currentNodeStartedAt;
|
|
417
|
+
delete state.statusDetail;
|
|
418
|
+
delete state.paused;
|
|
419
|
+
await this.withRunLock(runDir, async () => {
|
|
420
|
+
const traceEvent = await this.appendTraceEvent(runDir, state.runId, {
|
|
421
|
+
scope: "run",
|
|
422
|
+
type: "run_interrupted",
|
|
423
|
+
payload: { error: reason },
|
|
424
|
+
});
|
|
425
|
+
state.traceSeq = traceEvent.seq;
|
|
426
|
+
state.updatedAt = traceEvent.at;
|
|
427
|
+
await this.writeLoadedProjections(runDir, state);
|
|
428
|
+
});
|
|
429
|
+
return await readRunBundle(runDir);
|
|
82
430
|
}
|
|
83
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Persist one transition: append the trace event, then rewrite the
|
|
434
|
+
* projections reflecting it.
|
|
435
|
+
*/
|
|
84
436
|
async writeSnapshot(
|
|
85
437
|
runDir: string,
|
|
86
438
|
state: WorkflowRunState,
|
|
87
439
|
event: WorkflowTraceEventDraft,
|
|
88
440
|
): Promise<WorkflowTraceEvent> {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
441
|
+
return await this.withRunLock(runDir, async () => {
|
|
442
|
+
const traceEvent = await this.appendTraceEvent(runDir, state.runId, event);
|
|
443
|
+
state.traceSeq = traceEvent.seq;
|
|
444
|
+
state.updatedAt = new Date().toISOString();
|
|
445
|
+
await this.writeProjections(runDir, state);
|
|
446
|
+
return traceEvent;
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Bind the run to a Pi conversation: write `session/binding.json` once and
|
|
452
|
+
* append a `session_bound` trace event. Projections catch up on the next
|
|
453
|
+
* snapshot.
|
|
454
|
+
*/
|
|
455
|
+
/** True when a session binding already exists for this bundle. */
|
|
456
|
+
async hasSessionBinding(runDir: string): Promise<boolean> {
|
|
457
|
+
try {
|
|
458
|
+
await fs.lstat(path.join(runDir, SESSION_BINDING_PATH));
|
|
459
|
+
return true;
|
|
460
|
+
} catch {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
93
463
|
}
|
|
94
464
|
|
|
95
|
-
|
|
465
|
+
/** List capture segment attempt ids under `session/segments/`. */
|
|
466
|
+
async listSessionSegments(runDir: string): Promise<string[]> {
|
|
467
|
+
try {
|
|
468
|
+
const entries = await fs.readdir(path.join(runDir, SESSION_SEGMENTS_DIR), {
|
|
469
|
+
withFileTypes: true,
|
|
470
|
+
});
|
|
471
|
+
return entries
|
|
472
|
+
.filter((entry) => entry.isDirectory())
|
|
473
|
+
.map((entry) => entry.name)
|
|
474
|
+
.sort();
|
|
475
|
+
} catch {
|
|
476
|
+
return [];
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async writeSessionBinding(
|
|
96
481
|
runDir: string,
|
|
97
|
-
|
|
482
|
+
binding: WorkflowSessionBinding,
|
|
483
|
+
attemptId?: string,
|
|
484
|
+
): Promise<void> {
|
|
485
|
+
await this.withRunLock(runDir, async () => {
|
|
486
|
+
const stream = this.streamFor(runDir, attemptId);
|
|
487
|
+
if (stream.sessionBound) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
stream.sessionBound = true;
|
|
491
|
+
const paths = sessionStreamPaths(attemptId);
|
|
492
|
+
await fs.mkdir(path.join(runDir, paths.dir), { recursive: true, mode: 0o700 });
|
|
493
|
+
await writeJsonAtomic(path.join(runDir, paths.binding), binding);
|
|
494
|
+
await this.appendTraceEvent(runDir, binding.runId, {
|
|
495
|
+
scope: "session",
|
|
496
|
+
type: "session_bound",
|
|
497
|
+
payload: {
|
|
498
|
+
piSessionId: binding.piSessionId,
|
|
499
|
+
...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Append one verbatim Pi session entry to `session/entries.ndjson`. */
|
|
506
|
+
async appendSessionEntry(
|
|
507
|
+
runDir: string,
|
|
508
|
+
entry: Record<string, unknown>,
|
|
509
|
+
attemptId?: string,
|
|
510
|
+
): Promise<number> {
|
|
511
|
+
return await this.withRunLock(runDir, async () => {
|
|
512
|
+
const stream = this.streamFor(runDir, attemptId);
|
|
513
|
+
stream.sessionSeq += 1;
|
|
514
|
+
const record: WorkflowSessionEntryRecord = {
|
|
515
|
+
seq: stream.sessionSeq,
|
|
516
|
+
at: new Date().toISOString(),
|
|
517
|
+
entry,
|
|
518
|
+
};
|
|
519
|
+
await appendLine(path.join(runDir, sessionStreamPaths(attemptId).entries), record);
|
|
520
|
+
return record.seq;
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Append a fully stamped ordered batch to `session/events.ndjson`. */
|
|
525
|
+
async appendSessionEventBatch(
|
|
526
|
+
runDir: string,
|
|
527
|
+
records: WorkflowSessionEventRecord[],
|
|
528
|
+
attemptId?: string,
|
|
529
|
+
): Promise<void> {
|
|
530
|
+
if (records.length === 0) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
await this.withSessionEventLock(runDir, attemptId, async () => {
|
|
534
|
+
const stream = this.streamFor(runDir, attemptId);
|
|
535
|
+
if (stream.sessionEventsStopped) {
|
|
536
|
+
throw new Error("Session event capture has stopped");
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
let expected = stream.sessionEventSeq + 1;
|
|
540
|
+
for (const record of records) {
|
|
541
|
+
validateSessionEventRecord(record);
|
|
542
|
+
if (record.seq !== expected) {
|
|
543
|
+
throw new Error(`Expected session event seq ${expected}, got ${record.seq}`);
|
|
544
|
+
}
|
|
545
|
+
expected += 1;
|
|
546
|
+
}
|
|
547
|
+
const encoded = await Promise.all(
|
|
548
|
+
records.map(async (record) => ({
|
|
549
|
+
...record,
|
|
550
|
+
payload:
|
|
551
|
+
record.type === "tool_execution_started" ||
|
|
552
|
+
record.type === "tool_execution_finished" ||
|
|
553
|
+
(record.type === "assistant_event" && record.payload.type === "toolcall_end")
|
|
554
|
+
? ((await encodeValue(record.payload, this.contextFor(runDir).artifacts)) as Record<
|
|
555
|
+
string,
|
|
556
|
+
unknown
|
|
557
|
+
>)
|
|
558
|
+
: record.payload,
|
|
559
|
+
})),
|
|
560
|
+
);
|
|
561
|
+
for (const record of encoded) {
|
|
562
|
+
if (Buffer.byteLength(JSON.stringify(record), "utf8") + 1 > SESSION_EVENT_MAX_BYTES) {
|
|
563
|
+
throw new Error(`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
await appendLines(path.join(runDir, sessionStreamPaths(attemptId).events), encoded);
|
|
567
|
+
stream.sessionEventSeq = records.at(-1)?.seq ?? stream.sessionEventSeq;
|
|
568
|
+
} catch (error) {
|
|
569
|
+
stream.sessionEventsStopped = true;
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Atomically replace the temporal capture integrity projection. */
|
|
576
|
+
async writeSessionCapture(
|
|
577
|
+
runDir: string,
|
|
578
|
+
capture: WorkflowSessionCapture,
|
|
579
|
+
attemptId?: string,
|
|
580
|
+
): Promise<void> {
|
|
581
|
+
validateSessionCapture(capture);
|
|
582
|
+
await this.withSessionEventLock(runDir, attemptId, async () => {
|
|
583
|
+
const stream = this.streamFor(runDir, attemptId);
|
|
584
|
+
if (capture.status !== "recording") {
|
|
585
|
+
stream.sessionEventsStopped = true;
|
|
586
|
+
}
|
|
587
|
+
await writeJsonAtomic(path.join(runDir, sessionStreamPaths(attemptId).capture), capture);
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Count complete durable session records after both writers have drained. */
|
|
592
|
+
async sessionCounts(
|
|
593
|
+
runDir: string,
|
|
594
|
+
attemptId?: string,
|
|
595
|
+
): Promise<{ eventCount: number; entryCount: number; lastEventSeq: number }> {
|
|
596
|
+
const paths = sessionStreamPaths(attemptId);
|
|
597
|
+
const events = await readCompleteNdjson(path.join(runDir, paths.events));
|
|
598
|
+
const entries = await readCompleteNdjson(path.join(runDir, paths.entries));
|
|
599
|
+
return {
|
|
600
|
+
eventCount: events.length,
|
|
601
|
+
entryCount: entries.length,
|
|
602
|
+
lastEventSeq: events.at(-1)?.seq ?? 0,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private async appendTraceEvent(
|
|
607
|
+
runDir: string,
|
|
608
|
+
runId: string,
|
|
98
609
|
event: WorkflowTraceEventDraft,
|
|
99
610
|
): Promise<WorkflowTraceEvent> {
|
|
611
|
+
const context = this.contextFor(runDir);
|
|
100
612
|
const traceEvent: WorkflowTraceEvent = {
|
|
101
|
-
seq:
|
|
613
|
+
seq: context.traceSeq + 1,
|
|
102
614
|
at: new Date().toISOString(),
|
|
103
|
-
runId
|
|
615
|
+
runId,
|
|
104
616
|
...event,
|
|
617
|
+
payload: (await encodeValue(event.payload, context.artifacts)) as Record<string, unknown>,
|
|
105
618
|
};
|
|
106
|
-
await
|
|
619
|
+
await appendLine(path.join(runDir, TRACE_PATH), traceEvent);
|
|
620
|
+
context.traceSeq = traceEvent.seq;
|
|
107
621
|
return traceEvent;
|
|
108
622
|
}
|
|
109
623
|
|
|
110
|
-
private
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
624
|
+
private async writeProjections(runDir: string, state: WorkflowRunState): Promise<void> {
|
|
625
|
+
const context = this.contextFor(runDir);
|
|
626
|
+
const encoded = await encodeRunState(state, context.artifacts);
|
|
627
|
+
await writeJsonAtomic(path.join(runDir, STATE_PATH), encoded);
|
|
628
|
+
await writeJsonAtomic(
|
|
629
|
+
path.join(runDir, MANIFEST_PATH),
|
|
630
|
+
createManifest(state, {
|
|
631
|
+
session: [...context.streams.values()].some((stream) => stream.sessionBound),
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
114
634
|
}
|
|
115
635
|
|
|
116
|
-
private async
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
636
|
+
private async writeLoadedProjections(runDir: string, state: WorkflowRunState): Promise<void> {
|
|
637
|
+
const context = this.contextFor(runDir);
|
|
638
|
+
await writeJsonAtomic(path.join(runDir, STATE_PATH), state);
|
|
639
|
+
await writeJsonAtomic(
|
|
640
|
+
path.join(runDir, MANIFEST_PATH),
|
|
641
|
+
createManifest(state, {
|
|
642
|
+
session: [...context.streams.values()].some((stream) => stream.sessionBound),
|
|
643
|
+
}),
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Truncate a trace file to the longest contiguous valid prefix, then to the
|
|
650
|
+
* event count the state projection recorded. A crash can leave a partial
|
|
651
|
+
* final line or one event appended before its projection write; the repair
|
|
652
|
+
* keeps state and trace consistent so resume can continue the sequence. The
|
|
653
|
+
* rewrite is atomic: a stale writer appending to the old inode cannot
|
|
654
|
+
* interleave with the repaired file.
|
|
655
|
+
*/
|
|
656
|
+
async function repairTraceFile(
|
|
657
|
+
tracePath: string,
|
|
658
|
+
keepSeq: number,
|
|
659
|
+
beforeWrite?: () => void,
|
|
660
|
+
): Promise<void> {
|
|
661
|
+
let raw: string;
|
|
662
|
+
try {
|
|
663
|
+
raw = await fs.readFile(tracePath, "utf8");
|
|
664
|
+
} catch {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const lines = raw.split("\n");
|
|
668
|
+
if (lines.at(-1) === "") {
|
|
669
|
+
lines.pop();
|
|
670
|
+
}
|
|
671
|
+
const good: string[] = [];
|
|
672
|
+
let expectedSeq = 1;
|
|
673
|
+
for (const line of lines) {
|
|
674
|
+
if (line.trim().length === 0) {
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
const event = JSON.parse(line) as { seq?: unknown };
|
|
679
|
+
if (event.seq !== expectedSeq) {
|
|
680
|
+
break;
|
|
125
681
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
682
|
+
good.push(line);
|
|
683
|
+
expectedSeq += 1;
|
|
684
|
+
} catch {
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
const kept = good.slice(0, keepSeq);
|
|
689
|
+
if (kept.length === lines.length) {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
beforeWrite?.();
|
|
693
|
+
const tempPath = `${tracePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
694
|
+
await fs.writeFile(tempPath, kept.length === 0 ? "" : `${kept.join("\n")}\n`, {
|
|
695
|
+
encoding: "utf8",
|
|
696
|
+
mode: 0o600,
|
|
697
|
+
});
|
|
698
|
+
await fs.rename(tempPath, tracePath);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function seededStreams(flat: Omit<SessionStreamState, "lock">): Map<string, SessionStreamState> {
|
|
702
|
+
return new Map([["", { ...flat, lock: Promise.resolve() }]]);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function recoverTerminalProjection(state: WorkflowRunState, event: WorkflowTraceEvent): boolean {
|
|
706
|
+
const status = terminalStatusForEvent(event.type);
|
|
707
|
+
if (status === undefined) {
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
state.traceSeq = event.seq;
|
|
711
|
+
state.status = status;
|
|
712
|
+
state.updatedAt = event.at;
|
|
713
|
+
state.finishedAt = event.at;
|
|
714
|
+
if (typeof event.payload.error === "string") {
|
|
715
|
+
state.error = event.payload.error;
|
|
716
|
+
}
|
|
717
|
+
if (typeof event.payload.waitingOn === "string") {
|
|
718
|
+
state.waitingOn = event.payload.waitingOn;
|
|
719
|
+
}
|
|
720
|
+
if (Object.hasOwn(event.payload, "finalOutput")) {
|
|
721
|
+
state.finalOutput = event.payload.finalOutput;
|
|
722
|
+
}
|
|
723
|
+
delete state.currentNode;
|
|
724
|
+
delete state.currentAttemptId;
|
|
725
|
+
delete state.currentNodeStartedAt;
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function terminalStatusForEvent(type: string): WorkflowRunState["status"] | undefined {
|
|
730
|
+
switch (type) {
|
|
731
|
+
case "run_waiting":
|
|
732
|
+
return "waiting";
|
|
733
|
+
case "run_completed":
|
|
734
|
+
return "completed";
|
|
735
|
+
case "run_failed":
|
|
736
|
+
case "run_interrupted":
|
|
737
|
+
return "failed";
|
|
738
|
+
case "run_timed_out":
|
|
739
|
+
return "timed_out";
|
|
740
|
+
case "run_cancelled":
|
|
741
|
+
return "cancelled";
|
|
742
|
+
default:
|
|
743
|
+
return undefined;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function assertValidRunId(runId: string): void {
|
|
748
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
|
|
749
|
+
throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function isMissingPath(error: unknown): boolean {
|
|
754
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function appendLine(filePath: string, value: unknown): Promise<void> {
|
|
758
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
759
|
+
await fs.appendFile(filePath, value === null ? "" : `${JSON.stringify(value)}\n`, {
|
|
760
|
+
encoding: "utf8",
|
|
761
|
+
mode: 0o600,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function appendLines(filePath: string, values: unknown[]): Promise<void> {
|
|
766
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
767
|
+
const text = values.map((value) => `${JSON.stringify(value)}\n`).join("");
|
|
768
|
+
await fs.appendFile(filePath, text, { encoding: "utf8", mode: 0o600 });
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
772
|
+
return typeof value === "string" && value.length > 0;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function validateSessionEventRecord(record: WorkflowSessionEventRecord): void {
|
|
776
|
+
if (!Number.isSafeInteger(record.seq) || record.seq < 1) {
|
|
777
|
+
throw new Error("Session event seq must be a positive safe integer");
|
|
778
|
+
}
|
|
779
|
+
if (
|
|
780
|
+
!isNonEmptyString(record.at) ||
|
|
781
|
+
!isNonEmptyString(record.nodeId) ||
|
|
782
|
+
!isNonEmptyString(record.attemptId) ||
|
|
783
|
+
!isNonEmptyString(record.type) ||
|
|
784
|
+
typeof record.payload !== "object" ||
|
|
785
|
+
record.payload === null ||
|
|
786
|
+
Array.isArray(record.payload)
|
|
787
|
+
) {
|
|
788
|
+
throw new Error("Session event is missing required envelope fields");
|
|
789
|
+
}
|
|
790
|
+
const knownType = [
|
|
791
|
+
"turn_started",
|
|
792
|
+
"turn_finished",
|
|
793
|
+
"message_started",
|
|
794
|
+
"assistant_event",
|
|
795
|
+
"message_finished",
|
|
796
|
+
"tool_execution_started",
|
|
797
|
+
"tool_execution_updated",
|
|
798
|
+
"tool_execution_finished",
|
|
799
|
+
].includes(record.type);
|
|
800
|
+
if (!knownType) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (!isNonEmptyString(record.turnId)) {
|
|
804
|
+
throw new Error(`${record.type} requires turnId`);
|
|
805
|
+
}
|
|
806
|
+
if (
|
|
807
|
+
!["turn_started", "turn_finished"].includes(record.type) &&
|
|
808
|
+
!isNonEmptyString(record.messageId)
|
|
809
|
+
) {
|
|
810
|
+
throw new Error(`${record.type} requires messageId`);
|
|
811
|
+
}
|
|
812
|
+
if (record.type.startsWith("tool_execution_") && !isNonEmptyString(record.toolCallId)) {
|
|
813
|
+
throw new Error(`${record.type} requires toolCallId`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function sessionRelationshipDiagnostics(
|
|
818
|
+
entries: WorkflowSessionEntryRecord[],
|
|
819
|
+
events: WorkflowSessionEventRecord[],
|
|
820
|
+
): string[] {
|
|
821
|
+
const entryIds = new Set(
|
|
822
|
+
entries.flatMap((record) => (isNonEmptyString(record.entry.id) ? [record.entry.id] : [])),
|
|
823
|
+
);
|
|
824
|
+
const turns = new Set<string>();
|
|
825
|
+
const messages = new Set<string>();
|
|
826
|
+
const tools = new Set<string>();
|
|
827
|
+
const diagnostics: string[] = [];
|
|
828
|
+
for (const event of events) {
|
|
829
|
+
switch (event.type) {
|
|
830
|
+
case "turn_started":
|
|
831
|
+
if (event.turnId) turns.add(event.turnId);
|
|
832
|
+
break;
|
|
833
|
+
case "turn_finished":
|
|
834
|
+
if (!event.turnId || !turns.has(event.turnId)) {
|
|
835
|
+
diagnostics.push(`turn_finished ${event.seq} precedes turn_started`);
|
|
836
|
+
}
|
|
837
|
+
break;
|
|
838
|
+
case "message_started":
|
|
839
|
+
if (!event.turnId || !turns.has(event.turnId)) {
|
|
840
|
+
diagnostics.push(`message_started ${event.seq} precedes turn_started`);
|
|
841
|
+
}
|
|
842
|
+
if (event.messageId) messages.add(event.messageId);
|
|
843
|
+
break;
|
|
844
|
+
case "assistant_event":
|
|
845
|
+
if (!event.messageId || !messages.has(event.messageId)) {
|
|
846
|
+
diagnostics.push(`assistant_event ${event.seq} precedes message_started`);
|
|
847
|
+
}
|
|
848
|
+
break;
|
|
849
|
+
case "message_finished": {
|
|
850
|
+
if (!event.messageId || !messages.has(event.messageId)) {
|
|
851
|
+
diagnostics.push(`message_finished ${event.seq} precedes message_started`);
|
|
852
|
+
}
|
|
853
|
+
const settled = event.payload.settled;
|
|
854
|
+
const entryId = event.payload.entryId;
|
|
855
|
+
if (settled === true && (!isNonEmptyString(entryId) || !entryIds.has(entryId))) {
|
|
856
|
+
diagnostics.push(`message_finished ${event.seq} references a missing entry`);
|
|
857
|
+
} else if (settled !== true && entryId !== undefined) {
|
|
858
|
+
diagnostics.push(`message_finished ${event.seq} has entryId while unsettled`);
|
|
859
|
+
}
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
case "tool_execution_started":
|
|
863
|
+
if (!event.messageId || !messages.has(event.messageId)) {
|
|
864
|
+
diagnostics.push(`tool_execution_started ${event.seq} precedes message_started`);
|
|
865
|
+
}
|
|
866
|
+
if (event.toolCallId) tools.add(event.toolCallId);
|
|
867
|
+
break;
|
|
868
|
+
case "tool_execution_updated":
|
|
869
|
+
case "tool_execution_finished":
|
|
870
|
+
if (!event.toolCallId || !tools.has(event.toolCallId)) {
|
|
871
|
+
diagnostics.push(`${event.type} ${event.seq} precedes tool_execution_started`);
|
|
872
|
+
}
|
|
873
|
+
break;
|
|
874
|
+
default:
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return diagnostics;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function validateSessionCapture(capture: WorkflowSessionCapture): void {
|
|
882
|
+
if (
|
|
883
|
+
capture.schema !== SESSION_CAPTURE_SCHEMA ||
|
|
884
|
+
capture.eventSchema !== SESSION_EVENT_SCHEMA ||
|
|
885
|
+
!["recording", "complete", "failed"].includes(capture.status) ||
|
|
886
|
+
!Number.isSafeInteger(capture.eventCount) ||
|
|
887
|
+
capture.eventCount < 0 ||
|
|
888
|
+
!Number.isSafeInteger(capture.entryCount) ||
|
|
889
|
+
capture.entryCount < 0 ||
|
|
890
|
+
!Number.isSafeInteger(capture.lastEventSeq) ||
|
|
891
|
+
capture.lastEventSeq < 0
|
|
892
|
+
) {
|
|
893
|
+
throw new Error("Invalid session capture projection");
|
|
894
|
+
}
|
|
895
|
+
if (capture.status === "failed" && capture.failure === undefined) {
|
|
896
|
+
throw new Error("Failed session capture requires failure details");
|
|
897
|
+
}
|
|
898
|
+
if (capture.status !== "failed" && capture.failure !== undefined) {
|
|
899
|
+
throw new Error("Only failed session capture may contain failure details");
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function readCompleteNdjson(filePath: string): Promise<Array<{ seq: number }>> {
|
|
904
|
+
let raw: string;
|
|
905
|
+
try {
|
|
906
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
907
|
+
} catch {
|
|
908
|
+
return [];
|
|
909
|
+
}
|
|
910
|
+
const lines = raw.split("\n");
|
|
911
|
+
if (!raw.endsWith("\n")) {
|
|
912
|
+
lines.pop();
|
|
913
|
+
}
|
|
914
|
+
const records: Array<{ seq: number }> = [];
|
|
915
|
+
for (const line of lines) {
|
|
916
|
+
if (line.trim().length === 0) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
const value = JSON.parse(line) as { seq?: unknown };
|
|
921
|
+
if (!Number.isSafeInteger(value.seq) || (value.seq as number) < 1) {
|
|
922
|
+
break;
|
|
923
|
+
}
|
|
924
|
+
records.push({ seq: value.seq as number });
|
|
925
|
+
} catch {
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
129
928
|
}
|
|
929
|
+
return records;
|
|
130
930
|
}
|
|
131
931
|
|
|
932
|
+
/**
|
|
933
|
+
* Encode the externalizable value positions of the state document. The
|
|
934
|
+
* in-memory state always holds raw values; the persisted copy may carry
|
|
935
|
+
* `$artifact` references instead of large strings.
|
|
936
|
+
*/
|
|
937
|
+
async function encodeRunState(
|
|
938
|
+
state: WorkflowRunState,
|
|
939
|
+
artifacts: ArtifactWriter,
|
|
940
|
+
): Promise<WorkflowRunState> {
|
|
941
|
+
const results = Object.fromEntries(
|
|
942
|
+
await Promise.all(
|
|
943
|
+
Object.entries(state.results).map(async ([nodeId, result]) => [
|
|
944
|
+
nodeId,
|
|
945
|
+
"output" in result
|
|
946
|
+
? { ...result, output: await encodeValue(result.output, artifacts) }
|
|
947
|
+
: result,
|
|
948
|
+
]),
|
|
949
|
+
),
|
|
950
|
+
) as WorkflowRunState["results"];
|
|
951
|
+
return {
|
|
952
|
+
...state,
|
|
953
|
+
input: await encodeValue(state.input, artifacts),
|
|
954
|
+
outputs: Object.fromEntries(
|
|
955
|
+
await Promise.all(
|
|
956
|
+
Object.entries(state.outputs).map(async ([nodeId, output]) => [
|
|
957
|
+
nodeId,
|
|
958
|
+
await encodeValue(output, artifacts),
|
|
959
|
+
]),
|
|
960
|
+
),
|
|
961
|
+
),
|
|
962
|
+
results,
|
|
963
|
+
steps: await Promise.all(
|
|
964
|
+
state.steps.map(async (step) => ({
|
|
965
|
+
...step,
|
|
966
|
+
prompt: (await encodeValue(
|
|
967
|
+
step.prompt,
|
|
968
|
+
artifacts,
|
|
969
|
+
)) as WorkflowRunState["steps"][number]["prompt"],
|
|
970
|
+
output: await encodeValue(step.output, artifacts),
|
|
971
|
+
})),
|
|
972
|
+
),
|
|
973
|
+
...(state.finalOutput !== undefined
|
|
974
|
+
? { finalOutput: await encodeValue(state.finalOutput, artifacts) }
|
|
975
|
+
: {}),
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
export type SessionCaptureIntegrity = {
|
|
980
|
+
status: "unavailable" | "recording" | "complete" | "failed" | "invalid";
|
|
981
|
+
diagnostics: string[];
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
/** One capture attempt: the session data a single recorder wrote. */
|
|
985
|
+
export type SessionCaptureSegment = {
|
|
986
|
+
attemptId: string;
|
|
987
|
+
binding: WorkflowSessionBinding | null;
|
|
988
|
+
entries: WorkflowSessionEntryRecord[];
|
|
989
|
+
events: WorkflowSessionEventRecord[];
|
|
990
|
+
capture: WorkflowSessionCapture | null;
|
|
991
|
+
integrity: SessionCaptureIntegrity;
|
|
992
|
+
};
|
|
993
|
+
|
|
132
994
|
export type LoadedRunBundle = {
|
|
133
995
|
runDir: string;
|
|
134
996
|
manifest: WorkflowRunManifest;
|
|
135
997
|
state: WorkflowRunState;
|
|
136
998
|
snapshot: WorkflowDefinitionSnapshot | null;
|
|
999
|
+
sessionBinding: WorkflowSessionBinding | null;
|
|
1000
|
+
sessionEntries: WorkflowSessionEntryRecord[];
|
|
1001
|
+
sessionEvents: WorkflowSessionEventRecord[];
|
|
1002
|
+
sessionCapture: WorkflowSessionCapture | null;
|
|
1003
|
+
sessionIntegrity: SessionCaptureIntegrity;
|
|
1004
|
+
/** Per-attempt captures written after a handoff or resume. */
|
|
1005
|
+
sessionSegments: SessionCaptureSegment[];
|
|
137
1006
|
};
|
|
138
1007
|
|
|
1008
|
+
/** Read the final trace record without loading the rest of a run bundle. */
|
|
1009
|
+
export async function readLastTraceEvent(
|
|
1010
|
+
runDir: string,
|
|
1011
|
+
tracePath?: string,
|
|
1012
|
+
): Promise<WorkflowTraceEvent | null> {
|
|
1013
|
+
const events = await readNdjsonFile<WorkflowTraceEvent>(
|
|
1014
|
+
resolveBundlePath(runDir, tracePath, TRACE_PATH),
|
|
1015
|
+
);
|
|
1016
|
+
return events.records.at(-1) ?? null;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
139
1019
|
/** Read a run bundle from disk. Returns null when the bundle is unreadable. */
|
|
140
1020
|
export async function readRunBundle(runDir: string): Promise<LoadedRunBundle | null> {
|
|
141
1021
|
const manifest = await readJsonFile<WorkflowRunManifest>(path.join(runDir, MANIFEST_PATH));
|
|
142
|
-
|
|
143
|
-
|
|
1022
|
+
if (!manifest || manifest.schema !== RUN_BUNDLE_SCHEMA) {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
// A schema-tagged manifest may still be malformed (e.g. hand-edited);
|
|
1026
|
+
// treat anything unexpected as an unreadable bundle rather than throwing.
|
|
1027
|
+
const paths: Partial<WorkflowRunManifest["paths"]> =
|
|
1028
|
+
typeof manifest.paths === "object" && manifest.paths !== null ? manifest.paths : {};
|
|
1029
|
+
const state = await readJsonFile<WorkflowRunState>(
|
|
1030
|
+
resolveBundlePath(runDir, paths.state, STATE_PATH),
|
|
1031
|
+
);
|
|
1032
|
+
if (!state || state.schema !== RUN_STATE_SCHEMA) {
|
|
144
1033
|
return null;
|
|
145
1034
|
}
|
|
146
1035
|
const snapshot = await readJsonFile<WorkflowDefinitionSnapshot>(
|
|
147
|
-
|
|
1036
|
+
resolveBundlePath(runDir, paths.workflow, WORKFLOW_SNAPSHOT_PATH),
|
|
1037
|
+
);
|
|
1038
|
+
const sessionDir = resolveBundlePath(runDir, paths.session, SESSION_DIR);
|
|
1039
|
+
const sessionBinding = await readJsonFile<WorkflowSessionBinding>(
|
|
1040
|
+
path.join(sessionDir, "binding.json"),
|
|
1041
|
+
);
|
|
1042
|
+
const entries = await readNdjsonFile<WorkflowSessionEntryRecord>(
|
|
1043
|
+
path.join(sessionDir, "entries.ndjson"),
|
|
1044
|
+
);
|
|
1045
|
+
const events = await readNdjsonFile<WorkflowSessionEventRecord>(
|
|
1046
|
+
path.join(sessionDir, "events.ndjson"),
|
|
1047
|
+
);
|
|
1048
|
+
const sessionCapture = await readJsonFile<WorkflowSessionCapture>(
|
|
1049
|
+
path.join(sessionDir, "capture.json"),
|
|
1050
|
+
);
|
|
1051
|
+
const flatIntegrity = assessSessionIntegrity({
|
|
1052
|
+
binding: sessionBinding,
|
|
1053
|
+
entries,
|
|
1054
|
+
events,
|
|
1055
|
+
capture: sessionCapture,
|
|
1056
|
+
runTerminal: state.status !== "running",
|
|
1057
|
+
});
|
|
1058
|
+
const sessionSegments: SessionCaptureSegment[] = [];
|
|
1059
|
+
let segmentIds: string[] = [];
|
|
1060
|
+
try {
|
|
1061
|
+
segmentIds = (await fs.readdir(path.join(sessionDir, "segments"), { withFileTypes: true }))
|
|
1062
|
+
.filter((entry) => entry.isDirectory())
|
|
1063
|
+
.map((entry) => entry.name)
|
|
1064
|
+
.sort();
|
|
1065
|
+
} catch {
|
|
1066
|
+
// No segments directory means only the flat stream can exist.
|
|
1067
|
+
}
|
|
1068
|
+
for (const attemptId of segmentIds) {
|
|
1069
|
+
const segmentDir = path.join(sessionDir, "segments", attemptId);
|
|
1070
|
+
const binding = await readJsonFile<WorkflowSessionBinding>(
|
|
1071
|
+
path.join(segmentDir, "binding.json"),
|
|
1072
|
+
);
|
|
1073
|
+
const segmentEntries = await readNdjsonFile<WorkflowSessionEntryRecord>(
|
|
1074
|
+
path.join(segmentDir, "entries.ndjson"),
|
|
1075
|
+
);
|
|
1076
|
+
const segmentEvents = await readNdjsonFile<WorkflowSessionEventRecord>(
|
|
1077
|
+
path.join(segmentDir, "events.ndjson"),
|
|
1078
|
+
);
|
|
1079
|
+
const capture = await readJsonFile<WorkflowSessionCapture>(
|
|
1080
|
+
path.join(segmentDir, "capture.json"),
|
|
1081
|
+
);
|
|
1082
|
+
sessionSegments.push({
|
|
1083
|
+
attemptId,
|
|
1084
|
+
binding,
|
|
1085
|
+
entries: segmentEntries.records,
|
|
1086
|
+
events: segmentEvents.records,
|
|
1087
|
+
capture,
|
|
1088
|
+
integrity: assessSessionIntegrity({
|
|
1089
|
+
binding,
|
|
1090
|
+
entries: segmentEntries,
|
|
1091
|
+
events: segmentEvents,
|
|
1092
|
+
capture,
|
|
1093
|
+
runTerminal: state.status !== "running",
|
|
1094
|
+
}),
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
// The headline integrity is the flat stream's when present; otherwise the
|
|
1098
|
+
// chronologically latest capture segment speaks for the run (segment ids
|
|
1099
|
+
// are random, so directory order says nothing about time).
|
|
1100
|
+
sessionSegments.sort((a, b) =>
|
|
1101
|
+
(a.binding?.boundAt ?? "").localeCompare(b.binding?.boundAt ?? ""),
|
|
148
1102
|
);
|
|
149
|
-
|
|
1103
|
+
const sessionIntegrity =
|
|
1104
|
+
flatIntegrity.status !== "unavailable" || sessionSegments.length === 0
|
|
1105
|
+
? flatIntegrity
|
|
1106
|
+
: (sessionSegments.at(-1)?.integrity ?? flatIntegrity);
|
|
1107
|
+
return {
|
|
1108
|
+
runDir,
|
|
1109
|
+
manifest,
|
|
1110
|
+
state,
|
|
1111
|
+
snapshot,
|
|
1112
|
+
sessionBinding,
|
|
1113
|
+
sessionEntries: entries.records,
|
|
1114
|
+
sessionEvents: events.records,
|
|
1115
|
+
sessionCapture,
|
|
1116
|
+
sessionIntegrity,
|
|
1117
|
+
sessionSegments,
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Resolve a manifest-relative path, rejecting anything that is not a string
|
|
1123
|
+
* or escapes the bundle directory. Malformed manifests must degrade to an
|
|
1124
|
+
* unreadable bundle, never to a thrown error that aborts a listing.
|
|
1125
|
+
*/
|
|
1126
|
+
function resolveBundlePath(runDir: string, relative: unknown, fallback: string): string {
|
|
1127
|
+
const candidate = path.resolve(
|
|
1128
|
+
runDir,
|
|
1129
|
+
typeof relative === "string" && relative ? relative : fallback,
|
|
1130
|
+
);
|
|
1131
|
+
if (
|
|
1132
|
+
candidate !== path.resolve(runDir) &&
|
|
1133
|
+
!candidate.startsWith(path.resolve(runDir) + path.sep)
|
|
1134
|
+
) {
|
|
1135
|
+
return path.join(runDir, fallback);
|
|
1136
|
+
}
|
|
1137
|
+
return candidate;
|
|
150
1138
|
}
|
|
151
1139
|
|
|
152
1140
|
/** List run bundles under `outputRoot`, most recently started first. */
|
|
@@ -177,7 +1165,120 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
|
|
177
1165
|
}
|
|
178
1166
|
}
|
|
179
1167
|
|
|
180
|
-
|
|
1168
|
+
type NdjsonRead<T> = {
|
|
1169
|
+
records: T[];
|
|
1170
|
+
exists: boolean;
|
|
1171
|
+
tornTail: boolean;
|
|
1172
|
+
malformed: boolean;
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
async function readNdjsonFile<T>(filePath: string): Promise<NdjsonRead<T>> {
|
|
1176
|
+
let raw: string;
|
|
1177
|
+
try {
|
|
1178
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
1179
|
+
} catch {
|
|
1180
|
+
return { records: [], exists: false, tornTail: false, malformed: false };
|
|
1181
|
+
}
|
|
1182
|
+
const tornTail = raw.length > 0 && !raw.endsWith("\n");
|
|
1183
|
+
const lines = raw.split("\n");
|
|
1184
|
+
if (tornTail) {
|
|
1185
|
+
lines.pop();
|
|
1186
|
+
}
|
|
1187
|
+
const records: T[] = [];
|
|
1188
|
+
let malformed = false;
|
|
1189
|
+
for (const line of lines) {
|
|
1190
|
+
if (line.trim().length === 0) {
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
try {
|
|
1194
|
+
records.push(JSON.parse(line) as T);
|
|
1195
|
+
} catch {
|
|
1196
|
+
malformed = true;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return { records, exists: true, tornTail, malformed };
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function assessSessionIntegrity(input: {
|
|
1203
|
+
binding: WorkflowSessionBinding | null;
|
|
1204
|
+
entries: NdjsonRead<WorkflowSessionEntryRecord>;
|
|
1205
|
+
events: NdjsonRead<WorkflowSessionEventRecord>;
|
|
1206
|
+
capture: WorkflowSessionCapture | null;
|
|
1207
|
+
runTerminal: boolean;
|
|
1208
|
+
}): SessionCaptureIntegrity {
|
|
1209
|
+
const anySessionFile =
|
|
1210
|
+
input.binding !== null || input.entries.exists || input.events.exists || input.capture !== null;
|
|
1211
|
+
if (!anySessionFile) {
|
|
1212
|
+
return { status: "unavailable", diagnostics: [] };
|
|
1213
|
+
}
|
|
1214
|
+
const diagnostics: string[] = [];
|
|
1215
|
+
if (!input.binding || input.binding.schema !== SESSION_BINDING_SCHEMA) {
|
|
1216
|
+
diagnostics.push("missing or invalid session binding");
|
|
1217
|
+
}
|
|
1218
|
+
if (!input.capture) {
|
|
1219
|
+
diagnostics.push("missing session capture status");
|
|
1220
|
+
return { status: "invalid", diagnostics };
|
|
1221
|
+
}
|
|
1222
|
+
try {
|
|
1223
|
+
validateSessionCapture(input.capture);
|
|
1224
|
+
} catch (error) {
|
|
1225
|
+
diagnostics.push(failureMessageForDiagnostic(error));
|
|
1226
|
+
return { status: "invalid", diagnostics };
|
|
1227
|
+
}
|
|
1228
|
+
if (input.entries.malformed || input.events.malformed) {
|
|
1229
|
+
diagnostics.push("malformed NDJSON line before the journal tail");
|
|
1230
|
+
}
|
|
1231
|
+
if (input.events.tornTail && input.capture.status !== "recording") {
|
|
1232
|
+
diagnostics.push("terminal session event journal has a torn tail");
|
|
1233
|
+
}
|
|
1234
|
+
if (input.runTerminal && input.capture.status === "recording") {
|
|
1235
|
+
diagnostics.push("terminal run still reports recording capture");
|
|
1236
|
+
}
|
|
1237
|
+
let expected = 1;
|
|
1238
|
+
for (const event of input.events.records) {
|
|
1239
|
+
try {
|
|
1240
|
+
validateSessionEventRecord(event);
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
diagnostics.push(failureMessageForDiagnostic(error));
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
if (event.seq !== expected) {
|
|
1246
|
+
diagnostics.push(`session event sequence gap at ${expected}`);
|
|
1247
|
+
break;
|
|
1248
|
+
}
|
|
1249
|
+
expected += 1;
|
|
1250
|
+
}
|
|
1251
|
+
diagnostics.push(...sessionRelationshipDiagnostics(input.entries.records, input.events.records));
|
|
1252
|
+
if (input.capture.status !== "recording") {
|
|
1253
|
+
const lastEventSeq = input.events.records.at(-1)?.seq ?? 0;
|
|
1254
|
+
if (
|
|
1255
|
+
input.capture.eventCount !== input.events.records.length ||
|
|
1256
|
+
input.capture.entryCount !== input.entries.records.length ||
|
|
1257
|
+
input.capture.lastEventSeq !== lastEventSeq
|
|
1258
|
+
) {
|
|
1259
|
+
diagnostics.push("session capture counts do not match durable files");
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
if (diagnostics.length > 0) {
|
|
1263
|
+
return { status: "invalid", diagnostics };
|
|
1264
|
+
}
|
|
1265
|
+
if (input.capture.status === "failed") {
|
|
1266
|
+
return {
|
|
1267
|
+
status: "failed",
|
|
1268
|
+
diagnostics: [input.capture.failure?.message ?? "session capture failed"],
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
return { status: input.capture.status, diagnostics: [] };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function failureMessageForDiagnostic(error: unknown): string {
|
|
1275
|
+
return error instanceof Error ? error.message : String(error);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function createManifest(
|
|
1279
|
+
state: WorkflowRunState,
|
|
1280
|
+
present: { session: boolean },
|
|
1281
|
+
): WorkflowRunManifest {
|
|
181
1282
|
return {
|
|
182
1283
|
schema: RUN_BUNDLE_SCHEMA,
|
|
183
1284
|
runId: state.runId,
|
|
@@ -192,6 +1293,11 @@ function createManifest(state: WorkflowRunState): WorkflowRunManifest {
|
|
|
192
1293
|
workflow: WORKFLOW_SNAPSHOT_PATH,
|
|
193
1294
|
state: STATE_PATH,
|
|
194
1295
|
trace: TRACE_PATH,
|
|
1296
|
+
...(present.session ? { session: SESSION_DIR } : {}),
|
|
1297
|
+
// Declare this before any payload can externalize a string. Live
|
|
1298
|
+
// session-event patches may reference a newly written artifact before
|
|
1299
|
+
// the next workflow state projection refreshes the manifest.
|
|
1300
|
+
artifacts: "artifacts",
|
|
195
1301
|
},
|
|
196
1302
|
};
|
|
197
1303
|
}
|
|
@@ -228,7 +1334,10 @@ function snapshotNode(node: WorkflowNodeDefinition): WorkflowNodeSnapshot {
|
|
|
228
1334
|
|
|
229
1335
|
async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
|
|
230
1336
|
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`,
|
|
1337
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
1338
|
+
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
1339
|
+
encoding: "utf8",
|
|
1340
|
+
mode: 0o600,
|
|
1341
|
+
});
|
|
233
1342
|
await fs.rename(tempPath, filePath);
|
|
234
1343
|
}
|