@osolmaz/pi-workflows 0.2.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 +71 -5
- 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.js +1 -1
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +1053 -96
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +6 -0
- package/dist/extension/recorder.js +20 -8
- package/dist/extension/recorder.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/viewer/cli.d.ts +7 -3
- package/dist/viewer/cli.js +150 -19
- package/dist/viewer/cli.js.map +1 -1
- package/dist/workflows/engine.d.ts +36 -0
- package/dist/workflows/engine.js +244 -14
- 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/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 +50 -6
- package/dist/workflows/store.js +446 -51
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +10 -0
- package/docs/CONTROLLERS.md +215 -0
- package/docs/development.md +12 -9
- 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/run-bundles.md +66 -27
- package/docs/workflows.md +131 -11
- package/examples/controllers/pull-request.controller.ts +215 -0
- package/package.json +10 -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 +1 -1
- package/src/extension/index.ts +1243 -117
- package/src/extension/recorder.ts +65 -36
- 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/viewer/cli.ts +167 -21
- package/src/workflows/engine.ts +327 -13
- package/src/workflows/errors.ts +45 -0
- package/src/workflows/graph.ts +0 -5
- package/src/workflows/loader.ts +13 -3
- package/src/workflows/schema.ts +1 -1
- package/src/workflows/store.ts +555 -46
- package/src/workflows/types.ts +10 -0
package/src/workflows/store.ts
CHANGED
|
@@ -36,6 +36,35 @@ const SESSION_BINDING_PATH = `${SESSION_DIR}/binding.json`;
|
|
|
36
36
|
const SESSION_ENTRIES_PATH = `${SESSION_DIR}/entries.ndjson`;
|
|
37
37
|
const SESSION_EVENTS_PATH = `${SESSION_DIR}/events.ndjson`;
|
|
38
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
|
+
}
|
|
39
68
|
|
|
40
69
|
/** Runs directory: `$PI_WORKFLOWS_RUNS_DIR` or `~/.pi/agent/workflows/runs`. */
|
|
41
70
|
export function workflowRunsBaseDir(homeDir: string = os.homedir()): string {
|
|
@@ -59,12 +88,19 @@ export function createRunId(workflowName: string, now: Date = new Date()): strin
|
|
|
59
88
|
return `${stamp}-${slug || "workflow"}-${randomUUID().slice(0, 8)}`;
|
|
60
89
|
}
|
|
61
90
|
|
|
62
|
-
|
|
63
|
-
|
|
91
|
+
/** Per-attempt capture stream state; the "" key is the legacy flat stream. */
|
|
92
|
+
type SessionStreamState = {
|
|
64
93
|
sessionSeq: number;
|
|
65
94
|
sessionEventSeq: number;
|
|
66
95
|
sessionBound: boolean;
|
|
67
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;
|
|
68
104
|
artifacts: ArtifactWriter;
|
|
69
105
|
/**
|
|
70
106
|
* Serializes complete transitions (encode, trace append, projections) so
|
|
@@ -72,9 +108,7 @@ type RunBundleContext = {
|
|
|
72
108
|
* append order.
|
|
73
109
|
*/
|
|
74
110
|
lock: Promise<unknown>;
|
|
75
|
-
|
|
76
|
-
* queue ahead of workflow transitions. */
|
|
77
|
-
sessionEventLock: Promise<unknown>;
|
|
111
|
+
streams: Map<string, SessionStreamState>;
|
|
78
112
|
};
|
|
79
113
|
|
|
80
114
|
/**
|
|
@@ -85,34 +119,92 @@ type RunBundleContext = {
|
|
|
85
119
|
* into content-addressed `artifacts/`. Bundles are private: directories are
|
|
86
120
|
* 0700 and files 0600.
|
|
87
121
|
*/
|
|
122
|
+
/**
|
|
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.
|
|
126
|
+
*/
|
|
127
|
+
export type RunFence = () => void;
|
|
128
|
+
|
|
129
|
+
export type WorkflowRunStoreOptions = {
|
|
130
|
+
fenceProvider?: (runDir: string) => RunFence | undefined;
|
|
131
|
+
};
|
|
132
|
+
|
|
88
133
|
export class WorkflowRunStore {
|
|
89
134
|
readonly outputRoot: string;
|
|
135
|
+
private readonly fenceProvider: ((runDir: string) => RunFence | undefined) | undefined;
|
|
90
136
|
private readonly contexts = new Map<string, RunBundleContext>();
|
|
91
137
|
|
|
92
|
-
constructor(outputRoot: string = workflowRunsBaseDir()) {
|
|
138
|
+
constructor(outputRoot: string = workflowRunsBaseDir(), options: WorkflowRunStoreOptions = {}) {
|
|
93
139
|
this.outputRoot = outputRoot;
|
|
140
|
+
this.fenceProvider = options.fenceProvider;
|
|
94
141
|
}
|
|
95
142
|
|
|
96
143
|
runDirFor(runId: string): string {
|
|
144
|
+
assertValidRunId(runId);
|
|
97
145
|
return path.join(this.outputRoot, runId);
|
|
98
146
|
}
|
|
99
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
|
+
|
|
100
179
|
private contextFor(runDir: string): RunBundleContext {
|
|
101
180
|
let context = this.contexts.get(runDir);
|
|
102
181
|
if (!context) {
|
|
103
182
|
context = {
|
|
104
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 = {
|
|
105
199
|
sessionSeq: 0,
|
|
106
200
|
sessionEventSeq: 0,
|
|
107
201
|
sessionBound: false,
|
|
108
202
|
sessionEventsStopped: false,
|
|
109
|
-
artifacts: new ArtifactWriter(runDir),
|
|
110
203
|
lock: Promise.resolve(),
|
|
111
|
-
sessionEventLock: Promise.resolve(),
|
|
112
204
|
};
|
|
113
|
-
|
|
205
|
+
context.streams.set(key, stream);
|
|
114
206
|
}
|
|
115
|
-
return
|
|
207
|
+
return stream;
|
|
116
208
|
}
|
|
117
209
|
|
|
118
210
|
/**
|
|
@@ -121,7 +213,10 @@ export class WorkflowRunStore {
|
|
|
121
213
|
*/
|
|
122
214
|
private withRunLock<T>(runDir: string, task: () => Promise<T>): Promise<T> {
|
|
123
215
|
const context = this.contextFor(runDir);
|
|
124
|
-
const result = context.lock.then(
|
|
216
|
+
const result = context.lock.then(async () => {
|
|
217
|
+
this.fenceProvider?.(runDir)?.();
|
|
218
|
+
return await task();
|
|
219
|
+
});
|
|
125
220
|
context.lock = result.then(
|
|
126
221
|
() => undefined,
|
|
127
222
|
() => undefined,
|
|
@@ -129,10 +224,17 @@ export class WorkflowRunStore {
|
|
|
129
224
|
return result;
|
|
130
225
|
}
|
|
131
226
|
|
|
132
|
-
private withSessionEventLock<T>(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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(
|
|
136
238
|
() => undefined,
|
|
137
239
|
() => undefined,
|
|
138
240
|
);
|
|
@@ -144,9 +246,9 @@ export class WorkflowRunStore {
|
|
|
144
246
|
state: WorkflowRunState,
|
|
145
247
|
): Promise<string> {
|
|
146
248
|
const runDir = this.runDirFor(state.runId);
|
|
147
|
-
this.contexts.delete(runDir);
|
|
148
249
|
return await this.withRunLock(runDir, async () => {
|
|
149
|
-
await fs.mkdir(
|
|
250
|
+
await fs.mkdir(this.outputRoot, { recursive: true, mode: 0o700 });
|
|
251
|
+
await fs.mkdir(runDir, { recursive: false, mode: 0o700 });
|
|
150
252
|
await writeJsonAtomic(
|
|
151
253
|
path.join(runDir, WORKFLOW_SNAPSHOT_PATH),
|
|
152
254
|
createDefinitionSnapshot(workflow),
|
|
@@ -157,6 +259,176 @@ export class WorkflowRunStore {
|
|
|
157
259
|
});
|
|
158
260
|
}
|
|
159
261
|
|
|
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
|
+
}
|
|
311
|
+
|
|
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);
|
|
430
|
+
}
|
|
431
|
+
|
|
160
432
|
/**
|
|
161
433
|
* Persist one transition: append the trace event, then rewrite the
|
|
162
434
|
* projections reflecting it.
|
|
@@ -180,34 +452,71 @@ export class WorkflowRunStore {
|
|
|
180
452
|
* append a `session_bound` trace event. Projections catch up on the next
|
|
181
453
|
* snapshot.
|
|
182
454
|
*/
|
|
183
|
-
|
|
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
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
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(
|
|
481
|
+
runDir: string,
|
|
482
|
+
binding: WorkflowSessionBinding,
|
|
483
|
+
attemptId?: string,
|
|
484
|
+
): Promise<void> {
|
|
184
485
|
await this.withRunLock(runDir, async () => {
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
486
|
+
const stream = this.streamFor(runDir, attemptId);
|
|
487
|
+
if (stream.sessionBound) {
|
|
187
488
|
return;
|
|
188
489
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
await
|
|
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);
|
|
192
494
|
await this.appendTraceEvent(runDir, binding.runId, {
|
|
193
495
|
scope: "session",
|
|
194
496
|
type: "session_bound",
|
|
195
|
-
payload: {
|
|
497
|
+
payload: {
|
|
498
|
+
piSessionId: binding.piSessionId,
|
|
499
|
+
...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
|
|
500
|
+
},
|
|
196
501
|
});
|
|
197
502
|
});
|
|
198
503
|
}
|
|
199
504
|
|
|
200
505
|
/** Append one verbatim Pi session entry to `session/entries.ndjson`. */
|
|
201
|
-
async appendSessionEntry(
|
|
506
|
+
async appendSessionEntry(
|
|
507
|
+
runDir: string,
|
|
508
|
+
entry: Record<string, unknown>,
|
|
509
|
+
attemptId?: string,
|
|
510
|
+
): Promise<number> {
|
|
202
511
|
return await this.withRunLock(runDir, async () => {
|
|
203
|
-
const
|
|
204
|
-
|
|
512
|
+
const stream = this.streamFor(runDir, attemptId);
|
|
513
|
+
stream.sessionSeq += 1;
|
|
205
514
|
const record: WorkflowSessionEntryRecord = {
|
|
206
|
-
seq:
|
|
515
|
+
seq: stream.sessionSeq,
|
|
207
516
|
at: new Date().toISOString(),
|
|
208
517
|
entry,
|
|
209
518
|
};
|
|
210
|
-
await appendLine(path.join(runDir,
|
|
519
|
+
await appendLine(path.join(runDir, sessionStreamPaths(attemptId).entries), record);
|
|
211
520
|
return record.seq;
|
|
212
521
|
});
|
|
213
522
|
}
|
|
@@ -216,17 +525,18 @@ export class WorkflowRunStore {
|
|
|
216
525
|
async appendSessionEventBatch(
|
|
217
526
|
runDir: string,
|
|
218
527
|
records: WorkflowSessionEventRecord[],
|
|
528
|
+
attemptId?: string,
|
|
219
529
|
): Promise<void> {
|
|
220
530
|
if (records.length === 0) {
|
|
221
531
|
return;
|
|
222
532
|
}
|
|
223
|
-
await this.withSessionEventLock(runDir, async () => {
|
|
224
|
-
const
|
|
225
|
-
if (
|
|
533
|
+
await this.withSessionEventLock(runDir, attemptId, async () => {
|
|
534
|
+
const stream = this.streamFor(runDir, attemptId);
|
|
535
|
+
if (stream.sessionEventsStopped) {
|
|
226
536
|
throw new Error("Session event capture has stopped");
|
|
227
537
|
}
|
|
228
538
|
try {
|
|
229
|
-
let expected =
|
|
539
|
+
let expected = stream.sessionEventSeq + 1;
|
|
230
540
|
for (const record of records) {
|
|
231
541
|
validateSessionEventRecord(record);
|
|
232
542
|
if (record.seq !== expected) {
|
|
@@ -241,7 +551,7 @@ export class WorkflowRunStore {
|
|
|
241
551
|
record.type === "tool_execution_started" ||
|
|
242
552
|
record.type === "tool_execution_finished" ||
|
|
243
553
|
(record.type === "assistant_event" && record.payload.type === "toolcall_end")
|
|
244
|
-
? ((await encodeValue(record.payload,
|
|
554
|
+
? ((await encodeValue(record.payload, this.contextFor(runDir).artifacts)) as Record<
|
|
245
555
|
string,
|
|
246
556
|
unknown
|
|
247
557
|
>)
|
|
@@ -253,33 +563,39 @@ export class WorkflowRunStore {
|
|
|
253
563
|
throw new Error(`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`);
|
|
254
564
|
}
|
|
255
565
|
}
|
|
256
|
-
await appendLines(path.join(runDir,
|
|
257
|
-
|
|
566
|
+
await appendLines(path.join(runDir, sessionStreamPaths(attemptId).events), encoded);
|
|
567
|
+
stream.sessionEventSeq = records.at(-1)?.seq ?? stream.sessionEventSeq;
|
|
258
568
|
} catch (error) {
|
|
259
|
-
|
|
569
|
+
stream.sessionEventsStopped = true;
|
|
260
570
|
throw error;
|
|
261
571
|
}
|
|
262
572
|
});
|
|
263
573
|
}
|
|
264
574
|
|
|
265
575
|
/** Atomically replace the temporal capture integrity projection. */
|
|
266
|
-
async writeSessionCapture(
|
|
576
|
+
async writeSessionCapture(
|
|
577
|
+
runDir: string,
|
|
578
|
+
capture: WorkflowSessionCapture,
|
|
579
|
+
attemptId?: string,
|
|
580
|
+
): Promise<void> {
|
|
267
581
|
validateSessionCapture(capture);
|
|
268
|
-
await this.withSessionEventLock(runDir, async () => {
|
|
269
|
-
const
|
|
582
|
+
await this.withSessionEventLock(runDir, attemptId, async () => {
|
|
583
|
+
const stream = this.streamFor(runDir, attemptId);
|
|
270
584
|
if (capture.status !== "recording") {
|
|
271
|
-
|
|
585
|
+
stream.sessionEventsStopped = true;
|
|
272
586
|
}
|
|
273
|
-
await writeJsonAtomic(path.join(runDir,
|
|
587
|
+
await writeJsonAtomic(path.join(runDir, sessionStreamPaths(attemptId).capture), capture);
|
|
274
588
|
});
|
|
275
589
|
}
|
|
276
590
|
|
|
277
591
|
/** Count complete durable session records after both writers have drained. */
|
|
278
592
|
async sessionCounts(
|
|
279
593
|
runDir: string,
|
|
594
|
+
attemptId?: string,
|
|
280
595
|
): Promise<{ eventCount: number; entryCount: number; lastEventSeq: number }> {
|
|
281
|
-
const
|
|
282
|
-
const
|
|
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));
|
|
283
599
|
return {
|
|
284
600
|
eventCount: events.length,
|
|
285
601
|
entryCount: entries.length,
|
|
@@ -312,12 +628,132 @@ export class WorkflowRunStore {
|
|
|
312
628
|
await writeJsonAtomic(
|
|
313
629
|
path.join(runDir, MANIFEST_PATH),
|
|
314
630
|
createManifest(state, {
|
|
315
|
-
session: context.sessionBound,
|
|
631
|
+
session: [...context.streams.values()].some((stream) => stream.sessionBound),
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
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),
|
|
316
643
|
}),
|
|
317
644
|
);
|
|
318
645
|
}
|
|
319
646
|
}
|
|
320
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;
|
|
681
|
+
}
|
|
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
|
+
|
|
321
757
|
async function appendLine(filePath: string, value: unknown): Promise<void> {
|
|
322
758
|
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
323
759
|
await fs.appendFile(filePath, value === null ? "" : `${JSON.stringify(value)}\n`, {
|
|
@@ -545,6 +981,16 @@ export type SessionCaptureIntegrity = {
|
|
|
545
981
|
diagnostics: string[];
|
|
546
982
|
};
|
|
547
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
|
+
|
|
548
994
|
export type LoadedRunBundle = {
|
|
549
995
|
runDir: string;
|
|
550
996
|
manifest: WorkflowRunManifest;
|
|
@@ -555,8 +1001,21 @@ export type LoadedRunBundle = {
|
|
|
555
1001
|
sessionEvents: WorkflowSessionEventRecord[];
|
|
556
1002
|
sessionCapture: WorkflowSessionCapture | null;
|
|
557
1003
|
sessionIntegrity: SessionCaptureIntegrity;
|
|
1004
|
+
/** Per-attempt captures written after a handoff or resume. */
|
|
1005
|
+
sessionSegments: SessionCaptureSegment[];
|
|
558
1006
|
};
|
|
559
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
|
+
|
|
560
1019
|
/** Read a run bundle from disk. Returns null when the bundle is unreadable. */
|
|
561
1020
|
export async function readRunBundle(runDir: string): Promise<LoadedRunBundle | null> {
|
|
562
1021
|
const manifest = await readJsonFile<WorkflowRunManifest>(path.join(runDir, MANIFEST_PATH));
|
|
@@ -589,13 +1048,62 @@ export async function readRunBundle(runDir: string): Promise<LoadedRunBundle | n
|
|
|
589
1048
|
const sessionCapture = await readJsonFile<WorkflowSessionCapture>(
|
|
590
1049
|
path.join(sessionDir, "capture.json"),
|
|
591
1050
|
);
|
|
592
|
-
const
|
|
1051
|
+
const flatIntegrity = assessSessionIntegrity({
|
|
593
1052
|
binding: sessionBinding,
|
|
594
1053
|
entries,
|
|
595
1054
|
events,
|
|
596
1055
|
capture: sessionCapture,
|
|
597
1056
|
runTerminal: state.status !== "running",
|
|
598
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 ?? ""),
|
|
1102
|
+
);
|
|
1103
|
+
const sessionIntegrity =
|
|
1104
|
+
flatIntegrity.status !== "unavailable" || sessionSegments.length === 0
|
|
1105
|
+
? flatIntegrity
|
|
1106
|
+
: (sessionSegments.at(-1)?.integrity ?? flatIntegrity);
|
|
599
1107
|
return {
|
|
600
1108
|
runDir,
|
|
601
1109
|
manifest,
|
|
@@ -606,6 +1114,7 @@ export async function readRunBundle(runDir: string): Promise<LoadedRunBundle | n
|
|
|
606
1114
|
sessionEvents: events.records,
|
|
607
1115
|
sessionCapture,
|
|
608
1116
|
sessionIntegrity,
|
|
1117
|
+
sessionSegments,
|
|
609
1118
|
};
|
|
610
1119
|
}
|
|
611
1120
|
|