@osolmaz/pi-workflows 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/README.md +56 -31
  2. package/dist/builtins/autodoc.workflow.d.ts +4 -4
  3. package/dist/builtins/autoimplement.workflow.d.ts +797 -142
  4. package/dist/builtins/autoimplement.workflow.js +17 -105
  5. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  6. package/dist/builtins/autoplan.workflow.d.ts +126 -13
  7. package/dist/builtins/autoplan.workflow.js +200 -29
  8. package/dist/builtins/autoplan.workflow.js.map +1 -1
  9. package/dist/builtins/catalog.js +7 -5
  10. package/dist/builtins/catalog.js.map +1 -1
  11. package/dist/builtins/index.d.ts +4 -2
  12. package/dist/builtins/index.js +3 -1
  13. package/dist/builtins/index.js.map +1 -1
  14. package/dist/builtins/monitor.workflow.d.ts +56 -23
  15. package/dist/builtins/monitor.workflow.js +508 -329
  16. package/dist/builtins/monitor.workflow.js.map +1 -1
  17. package/dist/builtins/pi-agent-group.d.ts +72 -0
  18. package/dist/builtins/pi-agent-group.js +1087 -0
  19. package/dist/builtins/pi-agent-group.js.map +1 -0
  20. package/dist/builtins/plain-summary.workflow.d.ts +32 -0
  21. package/dist/builtins/plain-summary.workflow.js +134 -0
  22. package/dist/builtins/plain-summary.workflow.js.map +1 -0
  23. package/dist/builtins/plan-approval.workflow.d.ts +39 -5
  24. package/dist/builtins/plan-approval.workflow.js +92 -14
  25. package/dist/builtins/plan-approval.workflow.js.map +1 -1
  26. package/dist/builtins/plan-change.workflow.d.ts +449 -0
  27. package/dist/builtins/plan-change.workflow.js +256 -0
  28. package/dist/builtins/plan-change.workflow.js.map +1 -0
  29. package/dist/builtins/plan-presentation.js +2 -2
  30. package/dist/builtins/plan-presentation.js.map +1 -1
  31. package/dist/builtins/sanity-check.workflow.d.ts +50 -6
  32. package/dist/builtins/sanity-check.workflow.js +150 -28
  33. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  34. package/dist/controllers/effects.d.ts +3 -2
  35. package/dist/controllers/effects.js +8 -1
  36. package/dist/controllers/effects.js.map +1 -1
  37. package/dist/controllers/index.d.ts +1 -1
  38. package/dist/controllers/index.js +1 -1
  39. package/dist/controllers/index.js.map +1 -1
  40. package/dist/controllers/manager.d.ts +1 -0
  41. package/dist/controllers/manager.js +41 -17
  42. package/dist/controllers/manager.js.map +1 -1
  43. package/dist/controllers/sqlite.d.ts +107 -85
  44. package/dist/controllers/sqlite.js +1611 -1486
  45. package/dist/controllers/sqlite.js.map +1 -1
  46. package/dist/controllers/store.d.ts +9 -8
  47. package/dist/controllers/store.js +1 -31
  48. package/dist/controllers/store.js.map +1 -1
  49. package/dist/controllers/types.d.ts +3 -0
  50. package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
  51. package/dist/controllers/workflow-engine-scheduler.js +3 -5
  52. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  53. package/dist/controllers/workflows.d.ts +2 -2
  54. package/dist/controllers/workflows.js +11 -10
  55. package/dist/controllers/workflows.js.map +1 -1
  56. package/dist/extension/controller-host.js +2 -2
  57. package/dist/extension/controller-host.js.map +1 -1
  58. package/dist/extension/decision-channels.d.ts +4 -2
  59. package/dist/extension/decision-channels.js +333 -146
  60. package/dist/extension/decision-channels.js.map +1 -1
  61. package/dist/extension/executor.d.ts +9 -2
  62. package/dist/extension/executor.js +89 -2
  63. package/dist/extension/executor.js.map +1 -1
  64. package/dist/extension/herdr-viewer.d.ts +0 -1
  65. package/dist/extension/herdr-viewer.js +0 -4
  66. package/dist/extension/herdr-viewer.js.map +1 -1
  67. package/dist/extension/index.js +209 -127
  68. package/dist/extension/index.js.map +1 -1
  69. package/dist/extension/recorder.d.ts +3 -10
  70. package/dist/extension/recorder.js +13 -22
  71. package/dist/extension/recorder.js.map +1 -1
  72. package/dist/extension/session-events.d.ts +2 -2
  73. package/dist/extension/step-message.d.ts +7 -2
  74. package/dist/extension/step-message.js +94 -2
  75. package/dist/extension/step-message.js.map +1 -1
  76. package/dist/extension/widget.js +29 -3
  77. package/dist/extension/widget.js.map +1 -1
  78. package/dist/host/rpc-executor.d.ts +3 -0
  79. package/dist/host/rpc-executor.js +2 -0
  80. package/dist/host/rpc-executor.js.map +1 -1
  81. package/dist/host/runner.d.ts +3 -6
  82. package/dist/host/runner.js +83 -32
  83. package/dist/host/runner.js.map +1 -1
  84. package/dist/render/graph-render.js +15 -10
  85. package/dist/render/graph-render.js.map +1 -1
  86. package/dist/state/database.d.ts +35 -0
  87. package/dist/state/database.js +287 -0
  88. package/dist/state/database.js.map +1 -0
  89. package/dist/state/index.d.ts +4 -0
  90. package/dist/state/index.js +5 -0
  91. package/dist/state/index.js.map +1 -0
  92. package/dist/state/json.d.ts +6 -0
  93. package/dist/state/json.js +38 -0
  94. package/dist/state/json.js.map +1 -0
  95. package/dist/state/mutation.d.ts +57 -0
  96. package/dist/state/mutation.js +255 -0
  97. package/dist/state/mutation.js.map +1 -0
  98. package/dist/state/schema.d.ts +6 -0
  99. package/dist/state/schema.js +473 -0
  100. package/dist/state/schema.js.map +1 -0
  101. package/dist/viewer/cli.d.ts +2 -2
  102. package/dist/viewer/cli.js +142 -122
  103. package/dist/viewer/cli.js.map +1 -1
  104. package/dist/viewer/render.d.ts +4 -4
  105. package/dist/viewer/render.js +9 -23
  106. package/dist/viewer/render.js.map +1 -1
  107. package/dist/viewer/session-reducer.d.ts +1 -1
  108. package/dist/viewer/session-reducer.js +1 -1
  109. package/dist/viewer/tui.d.ts +3 -3
  110. package/dist/viewer/tui.js +15 -13
  111. package/dist/viewer/tui.js.map +1 -1
  112. package/dist/viewer/watch.d.ts +2 -5
  113. package/dist/viewer/watch.js +13 -13
  114. package/dist/viewer/watch.js.map +1 -1
  115. package/dist/workflows/catalog.js +7 -2
  116. package/dist/workflows/catalog.js.map +1 -1
  117. package/dist/workflows/composition.js +20 -2
  118. package/dist/workflows/composition.js.map +1 -1
  119. package/dist/workflows/decision-presentation.d.ts +1 -1
  120. package/dist/workflows/decision-presentation.js +51 -38
  121. package/dist/workflows/decision-presentation.js.map +1 -1
  122. package/dist/workflows/definition.d.ts +6 -2
  123. package/dist/workflows/definition.js +17 -0
  124. package/dist/workflows/definition.js.map +1 -1
  125. package/dist/workflows/engine.d.ts +7 -7
  126. package/dist/workflows/engine.js +196 -115
  127. package/dist/workflows/engine.js.map +1 -1
  128. package/dist/workflows/errors.d.ts +16 -3
  129. package/dist/workflows/errors.js +18 -3
  130. package/dist/workflows/errors.js.map +1 -1
  131. package/dist/workflows/human-decision.d.ts +45 -8
  132. package/dist/workflows/human-decision.js +641 -274
  133. package/dist/workflows/human-decision.js.map +1 -1
  134. package/dist/workflows/index.d.ts +5 -6
  135. package/dist/workflows/index.js +4 -5
  136. package/dist/workflows/index.js.map +1 -1
  137. package/dist/workflows/progress.d.ts +1 -0
  138. package/dist/workflows/progress.js +15 -3
  139. package/dist/workflows/progress.js.map +1 -1
  140. package/dist/workflows/schema.js +27 -1
  141. package/dist/workflows/schema.js.map +1 -1
  142. package/dist/workflows/store.d.ts +83 -103
  143. package/dist/workflows/store.js +907 -951
  144. package/dist/workflows/store.js.map +1 -1
  145. package/dist/workflows/types.d.ts +88 -126
  146. package/docs/CONTROLLERS.md +10 -8
  147. package/docs/DEFERRED_TURNS.md +2 -2
  148. package/docs/DESIGN_PHILOSOPHY.md +4 -2
  149. package/docs/HUMAN_DECISIONS.md +36 -47
  150. package/docs/HUMAN_DECISION_PRESENTATIONS.md +14 -24
  151. package/docs/MONITOR.md +179 -237
  152. package/docs/SQLITE_STATE.md +219 -0
  153. package/docs/WORKFLOW_COMPOSITION.md +27 -26
  154. package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
  155. package/docs/WORKFLOW_UPDATES.md +10 -9
  156. package/docs/development.md +9 -9
  157. package/docs/live-replay-protocol.md +28 -37
  158. package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
  159. package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
  160. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
  161. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +322 -0
  162. package/docs/plans/2026-08-21-sanity-check-plan.md +217 -95
  163. package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
  164. package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
  165. package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
  166. package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
  167. package/docs/plans/session-event-replay-implementation-plan.md +1 -1
  168. package/docs/plans/tui-viewer-implementation-plan.md +1 -1
  169. package/docs/session-event-journal.md +63 -434
  170. package/docs/tui-viewer.md +13 -15
  171. package/docs/workflows.md +127 -53
  172. package/examples/workflows/approved-plan.workflow.ts +19 -46
  173. package/examples/workflows/plain-summary.workflow.ts +1 -0
  174. package/herdr-plugin.toml +1 -1
  175. package/package.json +7 -7
  176. package/plugins/herdr/viewer.mjs +1 -11
  177. package/schemas/human-decision-accepted-v1.schema.json +15 -3
  178. package/schemas/human-decision-continuation-v1.schema.json +10 -1
  179. package/schemas/human-decision-delivery-v1.schema.json +8 -0
  180. package/schemas/human-decision-receipt-v1.schema.json +8 -0
  181. package/schemas/human-decision-request-v1.schema.json +24 -4
  182. package/skills/autoimplement/SKILL.md +27 -0
  183. package/skills/autoplan/SKILL.md +5 -4
  184. package/skills/monitor/SKILL.md +91 -135
  185. package/skills/pi-workflows/SKILL.md +9 -8
  186. package/skills/sanity-check/SKILL.md +44 -0
  187. package/src/builtins/autoimplement.workflow.ts +19 -118
  188. package/src/builtins/autoplan.workflow.ts +290 -40
  189. package/src/builtins/catalog.ts +7 -5
  190. package/src/builtins/index.ts +18 -1
  191. package/src/builtins/monitor.workflow.ts +673 -382
  192. package/src/builtins/pi-agent-group.ts +1407 -0
  193. package/src/builtins/plain-summary.workflow.ts +185 -0
  194. package/src/builtins/plan-approval.workflow.ts +157 -24
  195. package/src/builtins/plan-change.workflow.ts +321 -0
  196. package/src/builtins/plan-presentation.ts +2 -2
  197. package/src/builtins/sanity-check.workflow.ts +248 -48
  198. package/src/controllers/effects.ts +7 -0
  199. package/src/controllers/index.ts +0 -6
  200. package/src/controllers/manager.ts +72 -32
  201. package/src/controllers/sqlite.ts +2420 -1948
  202. package/src/controllers/store.ts +17 -38
  203. package/src/controllers/types.ts +3 -0
  204. package/src/controllers/workflow-engine-scheduler.ts +4 -8
  205. package/src/controllers/workflows.ts +45 -21
  206. package/src/extension/controller-host.ts +1 -2
  207. package/src/extension/decision-channels.ts +456 -203
  208. package/src/extension/executor.ts +105 -2
  209. package/src/extension/herdr-viewer.ts +0 -5
  210. package/src/extension/index.ts +251 -151
  211. package/src/extension/recorder.ts +13 -22
  212. package/src/extension/session-events.ts +2 -2
  213. package/src/extension/step-message.ts +106 -4
  214. package/src/extension/widget.ts +32 -5
  215. package/src/host/rpc-executor.ts +4 -0
  216. package/src/host/runner.ts +93 -41
  217. package/src/render/graph-render.ts +12 -6
  218. package/src/state/database.ts +375 -0
  219. package/src/state/index.ts +29 -0
  220. package/src/state/json.ts +42 -0
  221. package/src/state/mutation.ts +435 -0
  222. package/src/state/schema.ts +475 -0
  223. package/src/viewer/cli.ts +151 -144
  224. package/src/viewer/render.ts +19 -30
  225. package/src/viewer/session-reducer.ts +1 -1
  226. package/src/viewer/tui.ts +18 -17
  227. package/src/viewer/watch.ts +13 -17
  228. package/src/workflows/catalog.ts +7 -2
  229. package/src/workflows/composition.ts +21 -3
  230. package/src/workflows/decision-presentation.ts +56 -43
  231. package/src/workflows/definition.ts +36 -3
  232. package/src/workflows/engine.ts +228 -130
  233. package/src/workflows/errors.ts +27 -3
  234. package/src/workflows/human-decision.ts +921 -349
  235. package/src/workflows/index.ts +20 -30
  236. package/src/workflows/progress.ts +18 -3
  237. package/src/workflows/schema.ts +36 -1
  238. package/src/workflows/store.ts +1300 -1163
  239. package/src/workflows/types.ts +100 -137
  240. package/dist/builtins/sanity-check-session.d.ts +0 -17
  241. package/dist/builtins/sanity-check-session.js +0 -168
  242. package/dist/builtins/sanity-check-session.js.map +0 -1
  243. package/dist/workflows/artifacts.d.ts +0 -40
  244. package/dist/workflows/artifacts.js +0 -155
  245. package/dist/workflows/artifacts.js.map +0 -1
  246. package/dist/workflows/migrate-sources.d.ts +0 -42
  247. package/dist/workflows/migrate-sources.js +0 -133
  248. package/dist/workflows/migrate-sources.js.map +0 -1
  249. package/docs/run-bundles.md +0 -481
  250. package/schemas/human-decision-accepted-v2.schema.json +0 -50
  251. package/schemas/human-decision-delivery-v2.schema.json +0 -36
  252. package/schemas/human-decision-receipt-v2.schema.json +0 -39
  253. package/schemas/human-decision-request-v2.schema.json +0 -69
  254. package/schemas/human-decision-resolution-v2.schema.json +0 -27
  255. package/src/builtins/sanity-check-session.ts +0 -205
  256. package/src/workflows/artifacts.ts +0 -188
  257. package/src/workflows/migrate-sources.ts +0 -178
@@ -1,15 +1,13 @@
1
- import { randomUUID } from "node:crypto";
2
- import fs from "node:fs/promises";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { ArtifactWriter, encodeValue } from "./artifacts.js";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { StateDatabase, workflowStatePath } from "../state/database.js";
3
+ import { canonicalJson } from "../state/json.js";
4
+ import { resourceIdFor, tokenHash, type MutationActor, type OwnerType } from "../state/mutation.js";
6
5
  import { compositionMetadata } from "./composition.js";
7
6
  import type {
8
7
  WorkflowDefinition,
9
8
  WorkflowDefinitionSnapshot,
10
9
  WorkflowNodeDefinition,
11
10
  WorkflowNodeSnapshot,
12
- WorkflowRunManifest,
13
11
  WorkflowRunState,
14
12
  WorkflowSessionBinding,
15
13
  WorkflowSessionCapture,
@@ -22,427 +20,330 @@ import type {
22
20
  } from "./types.js";
23
21
  import { MAX_CURRENT_UPDATES, createUpdateId, updateProjection } from "./updates.js";
24
22
 
25
- export const RUN_BUNDLE_SCHEMA = "pi-workflows.run-bundle.v1" as const;
26
23
  export const RUN_STATE_SCHEMA = "pi-workflows.run-state.v1" as const;
27
- export const TRACE_EVENT_SCHEMA = "pi-workflows.trace-event.v1" as const;
28
24
  export const DEFINITION_SNAPSHOT_SCHEMA = "pi-workflows.definition-snapshot.v1" as const;
29
25
  export const SESSION_BINDING_SCHEMA = "pi-workflows.session-binding.v1" as const;
30
26
  export const SESSION_EVENT_SCHEMA = "pi-workflows.session-event.v1" as const;
31
27
  export const SESSION_CAPTURE_SCHEMA = "pi-workflows.session-capture.v1" as const;
32
28
  export const SESSION_EVENT_MAX_BYTES = 1024 * 1024;
33
29
 
34
- const MANIFEST_PATH = "manifest.json";
35
- const WORKFLOW_SNAPSHOT_PATH = "workflow.json";
36
- const STATE_PATH = "state.json";
37
- const TRACE_PATH = "trace.ndjson";
38
- const SESSION_DIR = "session";
39
- const SESSION_BINDING_PATH = `${SESSION_DIR}/binding.json`;
40
- const SESSION_ENTRIES_PATH = `${SESSION_DIR}/entries.ndjson`;
41
- const SESSION_EVENTS_PATH = `${SESSION_DIR}/events.ndjson`;
42
- const SESSION_CAPTURE_PATH = `${SESSION_DIR}/capture.json`;
43
- const SESSION_SEGMENTS_DIR = `${SESSION_DIR}/segments`;
44
-
45
- /** Capture file layout for one recorder attempt; "" is the legacy flat stream. */
46
- function sessionStreamPaths(attemptId?: string): {
47
- dir: string;
48
- binding: string;
49
- entries: string;
50
- events: string;
51
- capture: string;
52
- } {
53
- if (attemptId === undefined) {
54
- return {
55
- dir: SESSION_DIR,
56
- binding: SESSION_BINDING_PATH,
57
- entries: SESSION_ENTRIES_PATH,
58
- events: SESSION_EVENTS_PATH,
59
- capture: SESSION_CAPTURE_PATH,
60
- };
61
- }
62
- assertValidRunId(attemptId);
63
- const dir = `${SESSION_SEGMENTS_DIR}/${attemptId}`;
64
- return {
65
- dir,
66
- binding: `${dir}/binding.json`,
67
- entries: `${dir}/entries.ndjson`,
68
- events: `${dir}/events.ndjson`,
69
- capture: `${dir}/capture.json`,
70
- };
71
- }
72
-
73
- /** Runs directory: `$PI_WORKFLOWS_RUNS_DIR` or `~/.pi/agent/workflows/runs`. */
74
- export function workflowRunsBaseDir(homeDir: string = os.homedir()): string {
75
- const override = process.env.PI_WORKFLOWS_RUNS_DIR;
76
- if (override !== undefined && override.length > 0) {
77
- return override;
78
- }
79
- return path.join(homeDir, ".pi", "agent", "workflows", "runs");
30
+ export function workflowStateDatabasePath(homeDir?: string): string {
31
+ return workflowStatePath(homeDir);
80
32
  }
81
33
 
82
34
  export function createRunId(workflowName: string, now: Date = new Date()): string {
83
- const stamp = now
84
- .toISOString()
85
- .replaceAll(/[-:]/g, "")
86
- .replace(/\.\d+Z$/, "Z");
87
- const slug = workflowName
88
- .toLowerCase()
89
- .replaceAll(/[^a-z0-9]+/g, "-")
90
- .replaceAll(/(^-|-$)/g, "")
91
- .slice(0, 40);
92
- return `${stamp}-${slug || "workflow"}-${randomUUID().slice(0, 8)}`;
35
+ const safeName = workflowName.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80) || "workflow";
36
+ return `${now.toISOString().replace(/[-:.]/g, "").replace("T", "T").replace("Z", "Z")}-${safeName}-${randomUUID().slice(0, 8)}`;
93
37
  }
94
38
 
95
- /** Per-attempt capture stream state; the "" key is the legacy flat stream. */
96
- type SessionStreamState = {
97
- sessionSeq: number;
98
- sessionEventSeq: number;
99
- sessionBound: boolean;
100
- sessionEventsStopped: boolean;
101
- /** Session events have a separate append chain so token traffic cannot
102
- * queue ahead of workflow transitions. */
103
- lock: Promise<unknown>;
39
+ export type RunWriteAuthority = {
40
+ actor: MutationActor;
41
+ ownerType: OwnerType;
42
+ ownerId: string;
43
+ token: string;
44
+ generation: number;
104
45
  };
105
46
 
106
- type RunBundleContext = {
107
- traceSeq: number;
108
- artifacts: ArtifactWriter;
109
- /**
110
- * Serializes complete transitions (encode, trace append, projections) so
111
- * concurrent writers cannot interleave sequence assignment with physical
112
- * append order.
113
- */
114
- lock: Promise<unknown>;
115
- streams: Map<string, SessionStreamState>;
47
+ export type WorkflowRunStoreOptions = {
48
+ authorityProvider?: (runId: string) => RunWriteAuthority | undefined;
49
+ state?: StateDatabase;
50
+ readOnly?: boolean;
116
51
  };
117
52
 
118
- /**
119
- * Persists run bundles (see docs/run-bundles.md). `trace.ndjson` is the
120
- * append-only source of truth; every transition appends the trace event
121
- * first, then atomically replaces `state.json` (carrying `traceSeq`) and
122
- * `manifest.json`. Large string leaves in persisted values are externalized
123
- * into content-addressed `artifacts/`. Bundles are private: directories are
124
- * 0700 and files 0600.
125
- */
126
- /**
127
- * A fence proves the writer still owns the run. It is checked before every
128
- * locked write; it throws (ClaimLostError) when the queue claim was lost, so
129
- * a stalled runner can never interleave writes with the new claim holder.
130
- */
131
- export type RunFence = () => void;
53
+ type RunContext = {
54
+ revision: number;
55
+ lock: Promise<void>;
56
+ };
132
57
 
133
- export type WorkflowRunStoreOptions = {
134
- fenceProvider?: (runDir: string) => RunFence | undefined;
58
+ type RunRow = {
59
+ runId: string;
60
+ resourceId: string;
61
+ stateHash: Buffer;
62
+ definitionHash: Buffer;
63
+ status: string;
135
64
  };
136
65
 
137
- export class WorkflowRunStore {
138
- readonly outputRoot: string;
139
- private readonly fenceProvider: ((runDir: string) => RunFence | undefined) | undefined;
140
- private readonly contexts = new Map<string, RunBundleContext>();
66
+ type EventRow = {
67
+ resourceRevision: number;
68
+ eventType: string;
69
+ payloadHash: Buffer | null;
70
+ recordedAt: number;
71
+ };
141
72
 
142
- constructor(outputRoot: string = workflowRunsBaseDir(), options: WorkflowRunStoreOptions = {}) {
143
- this.outputRoot = outputRoot;
144
- this.fenceProvider = options.fenceProvider;
145
- }
73
+ type SegmentRow = {
74
+ segmentId: string;
75
+ runId: string;
76
+ attemptId: string | null;
77
+ captureKey: string | null;
78
+ sessionId: string;
79
+ bindingHash: Buffer | null;
80
+ status: WorkflowSessionCapture["status"];
81
+ entryCount: number;
82
+ eventCount: number;
83
+ failureHash: Buffer | null;
84
+ createdAt: number;
85
+ finishedAt: number | null;
86
+ };
146
87
 
147
- runDirFor(runId: string): string {
148
- assertValidRunId(runId);
149
- return path.join(this.outputRoot, runId);
150
- }
88
+ type SessionEntryRow = {
89
+ entrySeq: number;
90
+ entryHash: Buffer;
91
+ recordedAt: number;
92
+ };
151
93
 
152
- async quarantineIncompleteRun(runId: string): Promise<string | undefined> {
153
- const runDir = this.runDirFor(runId);
154
- let runStat;
155
- try {
156
- runStat = await fs.lstat(runDir);
157
- } catch (error) {
158
- if (isMissingPath(error)) {
159
- return undefined;
160
- }
161
- throw error;
162
- }
163
- if (!runStat.isDirectory() || runStat.isSymbolicLink()) {
164
- throw new Error(`Reserved workflow run path is not a directory: ${runDir}`);
165
- }
166
- try {
167
- await fs.lstat(path.join(runDir, MANIFEST_PATH));
168
- throw new Error(`Reserved workflow run has an unreadable manifest: ${runId}`);
169
- } catch (error) {
170
- if (!isMissingPath(error)) {
171
- throw error;
172
- }
173
- }
174
- const quarantineDir = path.join(
175
- this.outputRoot,
176
- `.${runId}.incomplete-${randomUUID().slice(0, 8)}`,
177
- );
178
- await fs.rename(runDir, quarantineDir);
179
- this.contexts.delete(runDir);
180
- return quarantineDir;
181
- }
94
+ type SessionEventRow = {
95
+ eventSeq: number;
96
+ eventType: WorkflowSessionEventRecord["type"];
97
+ nodeId: string;
98
+ attemptId: string;
99
+ turnId: string | null;
100
+ messageId: string | null;
101
+ toolCallId: string | null;
102
+ payloadHash: Buffer;
103
+ recordedAt: number;
104
+ };
182
105
 
183
- private contextFor(runDir: string): RunBundleContext {
184
- let context = this.contexts.get(runDir);
185
- if (!context) {
186
- context = {
187
- traceSeq: 0,
188
- artifacts: new ArtifactWriter(runDir),
189
- lock: Promise.resolve(),
190
- streams: new Map(),
191
- };
192
- this.contexts.set(runDir, context);
193
- }
194
- return context;
195
- }
106
+ export type SessionCaptureIntegrity = {
107
+ status: "unavailable" | "recording" | "complete" | "failed" | "invalid";
108
+ diagnostics: string[];
109
+ };
196
110
 
197
- private streamFor(runDir: string, attemptId?: string): SessionStreamState {
198
- const context = this.contextFor(runDir);
199
- const key = attemptId ?? "";
200
- let stream = context.streams.get(key);
201
- if (!stream) {
202
- stream = {
203
- sessionSeq: 0,
204
- sessionEventSeq: 0,
205
- sessionBound: false,
206
- sessionEventsStopped: false,
207
- lock: Promise.resolve(),
208
- };
209
- context.streams.set(key, stream);
210
- }
211
- return stream;
212
- }
111
+ export type SessionCaptureSegment = {
112
+ attemptId: string;
113
+ binding: WorkflowSessionBinding | null;
114
+ entries: WorkflowSessionEntryRecord[];
115
+ events: WorkflowSessionEventRecord[];
116
+ capture: WorkflowSessionCapture | null;
117
+ integrity: SessionCaptureIntegrity;
118
+ };
213
119
 
214
- /**
215
- * Run `task` exclusively for this bundle. Sequence numbers are assigned
216
- * inside the lock, so physical file order always matches logical order.
217
- */
218
- private withRunLock<T>(runDir: string, task: () => Promise<T>): Promise<T> {
219
- const context = this.contextFor(runDir);
220
- const result = context.lock.then(async () => {
221
- this.fenceProvider?.(runDir)?.();
222
- return await task();
223
- });
224
- context.lock = result.then(
225
- () => undefined,
226
- () => undefined,
227
- );
228
- return result;
229
- }
120
+ export type LoadedWorkflowRun = {
121
+ runId: string;
122
+ state: WorkflowRunState;
123
+ snapshot: WorkflowDefinitionSnapshot;
124
+ traceEvents?: WorkflowTraceEvent[];
125
+ sessionBinding: WorkflowSessionBinding | null;
126
+ sessionEntries: WorkflowSessionEntryRecord[];
127
+ sessionEvents: WorkflowSessionEventRecord[];
128
+ sessionCapture: WorkflowSessionCapture | null;
129
+ sessionIntegrity: SessionCaptureIntegrity;
130
+ sessionSegments: SessionCaptureSegment[];
131
+ };
230
132
 
231
- private withSessionEventLock<T>(
232
- runDir: string,
233
- attemptId: string | undefined,
234
- task: () => Promise<T>,
235
- ): Promise<T> {
236
- const stream = this.streamFor(runDir, attemptId);
237
- const result = stream.lock.then(async () => {
238
- this.fenceProvider?.(runDir)?.();
239
- return await task();
240
- });
241
- stream.lock = result.then(
242
- () => undefined,
243
- () => undefined,
244
- );
245
- return result;
133
+ export type ReadWorkflowRunOptions = {
134
+ includeTrace?: boolean;
135
+ };
136
+
137
+ export class WorkflowRunStore {
138
+ readonly databasePath: string;
139
+ readonly state: StateDatabase;
140
+ private readonly authorityProvider:
141
+ | ((runId: string) => RunWriteAuthority | undefined)
142
+ | undefined;
143
+ private readonly contexts = new Map<string, RunContext>();
144
+ private readonly ownsState: boolean;
145
+
146
+ constructor(databasePath: string = workflowStatePath(), options: WorkflowRunStoreOptions = {}) {
147
+ this.authorityProvider = options.authorityProvider;
148
+ this.ownsState = options.state === undefined;
149
+ this.state =
150
+ options.state ??
151
+ new StateDatabase({
152
+ filePath: databasePath,
153
+ mode: options.readOnly === true ? "read-only" : "read-write",
154
+ checkLegacyState: databasePath === workflowStatePath(),
155
+ });
156
+ this.databasePath = this.state.filePath;
246
157
  }
247
158
 
248
- async initializeRunBundle(
249
- workflow: WorkflowDefinition,
250
- state: WorkflowRunState,
251
- ): Promise<string> {
252
- const runDir = this.runDirFor(state.runId);
253
- return await this.withRunLock(runDir, async () => {
254
- await fs.mkdir(this.outputRoot, { recursive: true, mode: 0o700 });
255
- await fs.mkdir(runDir, { recursive: false, mode: 0o700 });
256
- await writeJsonAtomic(
257
- path.join(runDir, WORKFLOW_SNAPSHOT_PATH),
258
- createDefinitionSnapshot(workflow),
259
- );
260
- await appendLine(path.join(runDir, TRACE_PATH), null);
261
- await this.writeProjections(runDir, state);
262
- return runDir;
263
- });
159
+ close(): void {
160
+ if (this.ownsState) this.state.close();
264
161
  }
265
162
 
266
- /**
267
- * Prepare an interrupted bundle for resume. Repairs a torn trace tail,
268
- * drops trace events the state projection never recorded, and seeds the
269
- * in-process context so new events continue the sequence. The caller must
270
- * hold the run's queue claim; the fence is verified before any write.
271
- */
272
- async prepareRunResume(runId: string): Promise<LoadedRunBundle> {
273
- const runDir = this.runDirFor(runId);
274
- this.fenceProvider?.(runDir)?.();
275
- const bundle = await readRunBundle(runDir);
276
- if (bundle === null) {
277
- throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
278
- }
279
- if (bundle.state.status !== "running") {
280
- throw new Error(`Cannot resume workflow run ${runId} with status ${bundle.state.status}`);
163
+ async initializeRun(workflow: WorkflowDefinition, state: WorkflowRunState): Promise<string> {
164
+ assertValidRunId(state.runId);
165
+ if (!this.contexts.has(state.runId)) {
166
+ const reserved = this.readRunRow(state.runId);
167
+ this.contexts.set(state.runId, {
168
+ revision: reserved === undefined ? 0 : this.requireResourceRevision(reserved.resourceId),
169
+ lock: Promise.resolve(),
170
+ });
281
171
  }
282
- const tracePath = resolveBundlePath(runDir, bundle.manifest.paths.trace, TRACE_PATH);
283
- await repairTraceFile(tracePath, bundle.state.traceSeq, () => {
284
- // The repair rewrites the trace; re-verify ownership immediately
285
- // before the rename so a stalled runner cannot truncate a new claim
286
- // holder's appended events.
287
- this.fenceProvider?.(runDir)?.();
288
- });
289
-
290
- // A crashed session never finalizes its capture, and resuming recorders
291
- // always write new segments — so dangling "recording" captures end here
292
- // instead of reporting the run capture-corrupt forever.
293
- await this.finalizeRecordingCaptures(runDir, "Workflow host stopped before the run finished");
294
-
295
- const counts = await this.sessionCounts(runDir);
296
- const captureFinished =
297
- bundle.sessionCapture?.status === "complete" || bundle.sessionCapture?.status === "failed";
298
- this.contexts.set(runDir, {
299
- traceSeq: bundle.state.traceSeq,
300
- artifacts: new ArtifactWriter(runDir),
301
- lock: Promise.resolve(),
302
- streams: seededStreams({
303
- sessionSeq: counts.entryCount,
304
- sessionEventSeq: counts.lastEventSeq,
305
- sessionBound: bundle.manifest.paths.session !== undefined,
306
- sessionEventsStopped: captureFinished,
307
- }),
172
+ return await this.withRunLock(state.runId, async () => {
173
+ let acceptedRevision = 1;
174
+ const now = Date.now();
175
+ const at = new Date(now).toISOString();
176
+ const snapshot = createDefinitionSnapshot(workflow);
177
+ const definitionJson = canonicalJson(snapshot);
178
+ const definitionDigest = createHash("sha256").update(definitionJson).digest();
179
+ const source = sourceForState(state, definitionDigest);
180
+ const resourceId = resourceIdFor("run", state.runId);
181
+ this.state.transaction(() => {
182
+ const reserved = this.readRunRow(state.runId);
183
+ if (reserved !== undefined) {
184
+ if (reserved.status !== "queued") {
185
+ throw new Error(`Workflow run already exists: ${state.runId}`);
186
+ }
187
+ const context = this.contextFor(state.runId);
188
+ this.assertWriteAuthority(reserved, context.revision);
189
+ const storedSnapshot = this.readDefinition(reserved.definitionHash);
190
+ if (canonicalJson(storedSnapshot) !== definitionJson) {
191
+ throw new Error(`Reserved workflow definition conflicts: ${state.runId}`);
192
+ }
193
+ const revision = context.revision + 1;
194
+ acceptedRevision = revision;
195
+ state.traceSeq = revision;
196
+ state.updatedAt = at;
197
+ insertRunEvent(this.state, reserved.resourceId, revision, at, {
198
+ scope: "run",
199
+ type: "run_initialized",
200
+ payload: { workflowName: workflow.name },
201
+ });
202
+ this.persistRunState(reserved, state, revision, now);
203
+ this.syncNodeAttempts(state, snapshot, now);
204
+ context.revision = revision;
205
+ return;
206
+ }
207
+ const definitionHash = this.state.putJson(snapshot, now);
208
+ this.state.connection
209
+ .prepare(
210
+ `INSERT INTO workflow_definitions(
211
+ definition_digest, workflow_name, definition_hash, created_at
212
+ ) VALUES (?, ?, ?, ?)
213
+ ON CONFLICT(definition_digest) DO NOTHING`,
214
+ )
215
+ .run(definitionDigest, workflow.name, definitionHash, now);
216
+ this.state.connection
217
+ .prepare(
218
+ `INSERT INTO resources(
219
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
220
+ ) VALUES (?, 'run', ?, 1, ?, ?)`,
221
+ )
222
+ .run(resourceId, state.runId, now, now);
223
+ this.state.connection
224
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
225
+ .run(resourceId);
226
+ state.traceSeq = 1;
227
+ state.updatedAt = at;
228
+ const inputHash = this.state.putJson(state.input, now);
229
+ const workflowSourceHash = this.state.putJson(state.workflowSource ?? source, now);
230
+ const launchOptionsHash = this.state.putJson({}, now);
231
+ const stateHash = this.state.putJson(state, now);
232
+ const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
233
+ this.state.connection
234
+ .prepare(
235
+ `INSERT INTO runs(
236
+ run_id, resource_id, project_id, parent_run_id, definition_digest,
237
+ workflow_ref, workflow_source_hash, launch_options_hash,
238
+ source_type, source_ref, source_revision, title, status, paused,
239
+ status_detail, input_hash, output_hash, error_hash,
240
+ created_at, updated_at, finished_at
241
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
242
+ )
243
+ .run(
244
+ state.runId,
245
+ resourceId,
246
+ state.parentRunId ?? null,
247
+ definitionDigest,
248
+ state.workflowName,
249
+ workflowSourceHash,
250
+ launchOptionsHash,
251
+ source.type,
252
+ source.ref,
253
+ source.revision,
254
+ state.runTitle ?? null,
255
+ state.status,
256
+ state.paused === true ? 1 : 0,
257
+ state.statusDetail ?? null,
258
+ inputHash,
259
+ stateHash,
260
+ errorHash,
261
+ Date.parse(state.startedAt),
262
+ now,
263
+ state.finishedAt === undefined ? null : Date.parse(state.finishedAt),
264
+ );
265
+ insertRunEvent(this.state, resourceId, 1, at, {
266
+ scope: "run",
267
+ type: "run_created",
268
+ payload: { workflowName: workflow.name },
269
+ });
270
+ this.syncNodeAttempts(state, snapshot, now);
271
+ });
272
+ this.contexts.set(state.runId, { revision: acceptedRevision, lock: Promise.resolve() });
273
+ return state.runId;
308
274
  });
309
- const prepared = await readRunBundle(runDir);
310
- if (prepared === null) {
311
- throw new Error(`Workflow run ${runId} became unreadable during resume preparation`);
312
- }
313
- return prepared;
314
275
  }
315
276
 
316
- /**
317
- * Finalize captures left "recording" by a session that is gone, so they
318
- * report failed with the reason instead of dangling forever.
319
- */
320
- private async finalizeRecordingCaptures(
321
- runDir: string,
322
- reason: string,
323
- options: { skipFlat?: boolean } = {},
324
- ): Promise<void> {
325
- if (options.skipFlat !== true) {
326
- const flatCapture = await readJsonFile<WorkflowSessionCapture>(
327
- path.join(runDir, SESSION_CAPTURE_PATH),
328
- );
329
- if (flatCapture?.status === "recording") {
330
- const counts = await this.sessionCounts(runDir);
331
- await this.writeSessionCapture(runDir, {
332
- schema: SESSION_CAPTURE_SCHEMA,
333
- eventSchema: SESSION_EVENT_SCHEMA,
334
- status: "failed",
335
- ...counts,
336
- failure: {
337
- failedAt: new Date().toISOString(),
338
- code: "host_interrupted",
339
- message: reason,
340
- },
341
- });
342
- }
343
- }
344
- for (const segmentId of await this.listSessionSegments(runDir)) {
345
- const segmentCapture = await readJsonFile<WorkflowSessionCapture>(
346
- path.join(runDir, sessionStreamPaths(segmentId).capture),
347
- );
348
- if (segmentCapture?.status !== "recording") {
349
- continue;
350
- }
351
- const segmentCounts = await this.sessionCounts(runDir, segmentId);
352
- await this.writeSessionCapture(
353
- runDir,
354
- {
355
- schema: SESSION_CAPTURE_SCHEMA,
356
- eventSchema: SESSION_EVENT_SCHEMA,
357
- status: "failed",
358
- ...segmentCounts,
359
- failure: {
360
- failedAt: new Date().toISOString(),
361
- code: "host_interrupted",
362
- message: reason,
363
- },
364
- },
365
- segmentId,
366
- );
277
+ async prepareRunResume(runId: string): Promise<LoadedWorkflowRun> {
278
+ const loaded = this.readRun(runId, { includeTrace: true });
279
+ if (loaded === null) throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
280
+ if (loaded.state.status !== "running") {
281
+ throw new Error(`Cannot resume workflow run ${runId} with status ${loaded.state.status}`);
367
282
  }
283
+ const revision = this.resourceRevision(runId);
284
+ this.contexts.set(runId, { revision, lock: Promise.resolve() });
285
+ this.finalizeRecordingCaptures(runId, "Workflow host stopped before the run finished");
286
+ this.state.transaction(() => {
287
+ const row = this.requireRunRow(runId);
288
+ const context = this.contextFor(runId);
289
+ this.assertWriteAuthority(row, context.revision);
290
+ const nextRevision = context.revision + 1;
291
+ const now = Date.now();
292
+ const at = new Date(now).toISOString();
293
+ this.state.connection
294
+ .prepare(
295
+ `UPDATE node_attempts
296
+ SET status = 'cancelled', finished_at = ?, updated_at = ?
297
+ WHERE run_id = ? AND status IN ('pending', 'running', 'waiting')`,
298
+ )
299
+ .run(now, now, runId);
300
+ insertRunEvent(this.state, row.resourceId, nextRevision, at, {
301
+ scope: "run",
302
+ type: "run_resume_prepared",
303
+ payload: {},
304
+ });
305
+ loaded.state.traceSeq = nextRevision;
306
+ loaded.state.updatedAt = at;
307
+ this.persistRunState(row, loaded.state, nextRevision, now);
308
+ context.revision = nextRevision;
309
+ });
310
+ const prepared = this.readRun(runId, { includeTrace: true });
311
+ if (prepared === null) throw new Error(`Workflow run became unreadable: ${runId}`);
312
+ return prepared;
368
313
  }
369
314
 
370
- /** Mark a nonterminal bundle failed and append an interruption event. */
371
315
  async markRunInterrupted(
372
316
  runId: string,
373
317
  reason = "Workflow host stopped before the run finished",
374
- ): Promise<LoadedRunBundle | null> {
375
- const runDir = this.runDirFor(runId);
376
- const bundle = await readRunBundle(runDir);
377
- if (bundle === null || bundle.state.status !== "running") {
378
- return bundle;
379
- }
380
- const lastTraceEvent = await readLastTraceEvent(runDir, bundle.manifest.paths.trace);
381
- const counts = await this.sessionCounts(runDir);
382
- const sessionBound = bundle.manifest.paths.session !== undefined;
383
- const captureFinished =
384
- bundle.sessionCapture?.status === "complete" || bundle.sessionCapture?.status === "failed";
385
- this.contexts.set(runDir, {
386
- traceSeq: Math.max(bundle.state.traceSeq, lastTraceEvent?.seq ?? 0),
387
- artifacts: new ArtifactWriter(runDir),
388
- lock: Promise.resolve(),
389
- streams: seededStreams({
390
- sessionSeq: counts.entryCount,
391
- sessionEventSeq: counts.lastEventSeq,
392
- sessionBound,
393
- sessionEventsStopped: captureFinished,
394
- }),
395
- });
396
- const state = bundle.state;
397
- if (lastTraceEvent !== null && recoverTerminalProjection(state, lastTraceEvent)) {
398
- await this.writeLoadedProjections(runDir, state);
399
- return await readRunBundle(runDir);
400
- }
401
- if (sessionBound && !captureFinished) {
402
- await this.writeSessionCapture(runDir, {
403
- schema: SESSION_CAPTURE_SCHEMA,
404
- eventSchema: SESSION_EVENT_SCHEMA,
405
- status: "failed",
406
- ...counts,
407
- failure: {
408
- failedAt: new Date().toISOString(),
409
- code: "host_interrupted",
410
- message: reason,
411
- },
412
- });
413
- }
414
- await this.finalizeRecordingCaptures(runDir, reason, { skipFlat: true });
415
- state.status = "failed";
416
- state.finishedAt = new Date().toISOString();
417
- state.error = reason;
418
- delete state.currentNode;
419
- delete state.currentAttemptId;
420
- delete state.currentNodeStartedAt;
421
- delete state.statusDetail;
422
- delete state.paused;
423
- await this.withRunLock(runDir, async () => {
424
- const traceEvent = await this.appendTraceEvent(runDir, state.runId, {
425
- scope: "run",
426
- type: "run_interrupted",
427
- payload: { error: reason },
428
- });
429
- state.traceSeq = traceEvent.seq;
430
- state.updatedAt = traceEvent.at;
431
- await this.writeLoadedProjections(runDir, state);
318
+ ): Promise<LoadedWorkflowRun | null> {
319
+ const loaded = this.readRun(runId);
320
+ if (loaded === null || loaded.state.status !== "running") return loaded;
321
+ this.finalizeRecordingCaptures(runId, reason);
322
+ loaded.state.status = "failed";
323
+ loaded.state.finishedAt = new Date().toISOString();
324
+ loaded.state.error = reason;
325
+ delete loaded.state.currentNode;
326
+ delete loaded.state.currentAttemptId;
327
+ delete loaded.state.currentNodeStartedAt;
328
+ delete loaded.state.statusDetail;
329
+ delete loaded.state.paused;
330
+ await this.writeSnapshot(runId, loaded.state, {
331
+ scope: "run",
332
+ type: "run_interrupted",
333
+ payload: { error: reason },
432
334
  });
433
- return await readRunBundle(runDir);
335
+ return this.readRun(runId, { includeTrace: true });
434
336
  }
435
337
 
436
- /** Publish one durable update under the run's serialized claim-fenced writer. */
437
338
  async publishUpdate(
438
- runDir: string,
339
+ runId: string,
439
340
  state: WorkflowRunState,
440
341
  nodeId: string,
441
342
  attemptId: string,
442
343
  update: WorkflowUpdateInput,
443
344
  options: { signal?: AbortSignal } = {},
444
345
  ): Promise<{ event: WorkflowTraceEvent; record: WorkflowUpdateRecord }> {
445
- return await this.withRunLock(runDir, async () => {
346
+ return await this.withRunLock(runId, async () => {
446
347
  if (options.signal?.aborted === true) {
447
348
  throw options.signal.reason ?? new Error("workflow update attempt is no longer active");
448
349
  }
@@ -452,375 +353,1075 @@ export class WorkflowRunStore {
452
353
  if (!exists && (state.updates?.length ?? 0) >= MAX_CURRENT_UPDATES) {
453
354
  throw new Error(`workflow run supports at most ${MAX_CURRENT_UPDATES} current updates`);
454
355
  }
356
+ const data = structuredClone(update.data) as Record<string, unknown>;
455
357
  const updateId = createUpdateId();
456
- const data = JSON.parse(JSON.stringify(update.data)) as Record<string, unknown>;
457
- const event = await this.appendTraceEvent(runDir, state.runId, {
458
- scope: "node",
459
- type: "update_published",
460
- nodeId,
461
- attemptId,
462
- payload: { updateId, type: update.type, key: update.key, data },
358
+ let acceptedEvent: WorkflowTraceEvent | undefined;
359
+ let acceptedRecord: WorkflowUpdateRecord | undefined;
360
+ this.state.transaction(() => {
361
+ const context = this.contextFor(runId);
362
+ const run = this.requireRunRow(runId);
363
+ this.assertWriteAuthority(run, context.revision);
364
+ const revision = context.revision + 1;
365
+ const at = new Date().toISOString();
366
+ const event: WorkflowTraceEvent = {
367
+ seq: revision,
368
+ at,
369
+ runId,
370
+ scope: "node",
371
+ type: "update_published",
372
+ nodeId,
373
+ attemptId,
374
+ payload: { updateId, type: update.type, key: update.key, data },
375
+ };
376
+ const record: WorkflowUpdateRecord = {
377
+ updateId,
378
+ seq: revision,
379
+ at,
380
+ runId,
381
+ nodeId,
382
+ attemptId,
383
+ type: update.type,
384
+ key: update.key,
385
+ data,
386
+ };
387
+ state.updates = updateProjection(state.updates, record);
388
+ state.traceSeq = revision;
389
+ state.updatedAt = at;
390
+ this.ensureAttempt(state, nodeId, attemptId, Date.now());
391
+ const dataHash = this.state.putJson(data);
392
+ const nextUpdateSeq = this.nextUpdateSequence(attemptId);
393
+ this.state.connection
394
+ .prepare(
395
+ `INSERT INTO workflow_updates(
396
+ update_id, attempt_id, update_seq, update_type, update_key, data_hash, recorded_at
397
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
398
+ )
399
+ .run(updateId, attemptId, nextUpdateSeq, update.type, update.key, dataHash, Date.now());
400
+ insertRunEvent(this.state, run.resourceId, revision, at, event);
401
+ this.persistRunState(run, state, revision, Date.now());
402
+ context.revision = revision;
403
+ acceptedEvent = event;
404
+ acceptedRecord = record;
463
405
  });
464
- const record: WorkflowUpdateRecord = {
465
- updateId,
466
- seq: event.seq,
467
- at: event.at,
468
- runId: state.runId,
469
- nodeId,
470
- attemptId,
471
- type: update.type,
472
- key: update.key,
473
- data,
474
- };
475
- state.updates = updateProjection(state.updates, record);
476
- state.traceSeq = event.seq;
477
- state.updatedAt = event.at;
478
- await this.writeProjections(runDir, state);
479
- return { event, record };
406
+ if (acceptedEvent === undefined || acceptedRecord === undefined) {
407
+ throw new Error("Workflow update transaction did not produce a result");
408
+ }
409
+ return { event: acceptedEvent, record: acceptedRecord };
480
410
  });
481
411
  }
482
412
 
483
- /**
484
- * Persist one transition: append the trace event, then rewrite the
485
- * projections reflecting it.
486
- */
487
413
  async writeSnapshot(
488
- runDir: string,
414
+ runId: string,
489
415
  state: WorkflowRunState,
490
416
  event: WorkflowTraceEventDraft,
491
417
  ): Promise<WorkflowTraceEvent> {
492
- return await this.withRunLock(runDir, async () => {
493
- const traceEvent = await this.appendTraceEvent(runDir, state.runId, event);
494
- state.traceSeq = traceEvent.seq;
495
- state.updatedAt = new Date().toISOString();
496
- await this.writeProjections(runDir, state);
497
- return traceEvent;
418
+ return await this.withRunLock(runId, async () => {
419
+ let accepted: WorkflowTraceEvent | undefined;
420
+ this.state.transaction(() => {
421
+ const context = this.contextFor(runId);
422
+ const run = this.requireRunRow(runId);
423
+ this.assertWriteAuthority(run, context.revision);
424
+ const revision = context.revision + 1;
425
+ const now = Date.now();
426
+ const at = new Date(now).toISOString();
427
+ const traceEvent: WorkflowTraceEvent = { seq: revision, at, runId, ...event };
428
+ state.traceSeq = revision;
429
+ state.updatedAt = at;
430
+ insertRunEvent(this.state, run.resourceId, revision, at, traceEvent);
431
+ this.persistRunState(run, state, revision, now);
432
+ const snapshot = this.readDefinition(run.definitionHash);
433
+ this.syncNodeAttempts(state, snapshot, now);
434
+ context.revision = revision;
435
+ accepted = traceEvent;
436
+ });
437
+ if (accepted === undefined) throw new Error("Workflow snapshot transaction did not commit");
438
+ return accepted;
498
439
  });
499
440
  }
500
441
 
501
- /**
502
- * Bind the run to a Pi conversation: write `session/binding.json` once and
503
- * append a `session_bound` trace event. Projections catch up on the next
504
- * snapshot.
505
- */
506
- /** True when a session binding already exists for this bundle. */
507
- async hasSessionBinding(runDir: string): Promise<boolean> {
508
- try {
509
- await fs.lstat(path.join(runDir, SESSION_BINDING_PATH));
510
- return true;
511
- } catch {
512
- return false;
513
- }
442
+ async hasSessionBinding(runId: string): Promise<boolean> {
443
+ return (
444
+ this.state.connection
445
+ .prepare("SELECT 1 AS present FROM session_segments WHERE run_id = ? LIMIT 1")
446
+ .get(runId) !== undefined
447
+ );
514
448
  }
515
449
 
516
- /** List capture segment attempt ids under `session/segments/`. */
517
- async listSessionSegments(runDir: string): Promise<string[]> {
518
- try {
519
- const entries = await fs.readdir(path.join(runDir, SESSION_SEGMENTS_DIR), {
520
- withFileTypes: true,
521
- });
522
- return entries
523
- .filter((entry) => entry.isDirectory())
524
- .map((entry) => entry.name)
525
- .sort();
526
- } catch {
527
- return [];
528
- }
450
+ async listSessionSegments(runId: string): Promise<string[]> {
451
+ return this.segmentRows(runId)
452
+ .flatMap((row) => (row.captureKey === null ? [] : [row.captureKey]))
453
+ .sort();
529
454
  }
530
455
 
531
456
  async writeSessionBinding(
532
- runDir: string,
457
+ runId: string,
533
458
  binding: WorkflowSessionBinding,
534
459
  attemptId?: string,
535
460
  ): Promise<void> {
536
- await this.withRunLock(runDir, async () => {
537
- const stream = this.streamFor(runDir, attemptId);
538
- if (stream.sessionBound) {
539
- return;
540
- }
541
- stream.sessionBound = true;
542
- const paths = sessionStreamPaths(attemptId);
543
- await fs.mkdir(path.join(runDir, paths.dir), { recursive: true, mode: 0o700 });
544
- await writeJsonAtomic(path.join(runDir, paths.binding), binding);
545
- await this.appendTraceEvent(runDir, binding.runId, {
546
- scope: "session",
547
- type: "session_bound",
548
- payload: {
549
- piSessionId: binding.piSessionId,
550
- ...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
551
- },
461
+ assertValidRunId(runId);
462
+ if (binding.runId !== runId || binding.schema !== SESSION_BINDING_SCHEMA) {
463
+ throw new Error("Session binding does not match the workflow run");
464
+ }
465
+ await this.withRunLock(runId, async () => {
466
+ const segmentId = segmentIdFor(runId, attemptId);
467
+ if (this.segmentRow(segmentId) !== undefined) return;
468
+ this.state.transaction(() => {
469
+ const run = this.requireRunRow(runId);
470
+ const context = this.contextFor(runId);
471
+ this.assertWriteAuthority(run, context.revision);
472
+ const now = Date.parse(binding.boundAt);
473
+ const resourceId = resourceIdFor("session", segmentId);
474
+ const bindingHash = this.state.putJson(binding, now);
475
+ this.state.connection
476
+ .prepare(
477
+ `INSERT INTO resources(
478
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
479
+ ) VALUES (?, 'session', ?, 1, ?, ?)`,
480
+ )
481
+ .run(resourceId, segmentId, now, now);
482
+ this.state.connection
483
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
484
+ .run(resourceId);
485
+ this.state.connection
486
+ .prepare(
487
+ `INSERT INTO session_segments(
488
+ segment_id, run_id, attempt_id, capture_key, session_id, resource_id, binding_hash,
489
+ status, entry_count, event_count, created_at
490
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, 'recording', 0, 0, ?)`,
491
+ )
492
+ .run(
493
+ segmentId,
494
+ runId,
495
+ attemptId ?? null,
496
+ binding.piSessionId,
497
+ resourceId,
498
+ bindingHash,
499
+ now,
500
+ );
501
+ insertGenericEvent(
502
+ this.state,
503
+ resourceId,
504
+ 1,
505
+ "session.created",
506
+ "session",
507
+ binding.piSessionId,
508
+ bindingHash,
509
+ now,
510
+ );
511
+ this.state.connection
512
+ .prepare(
513
+ `INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
514
+ VALUES (?, ?, 'interactive', ?)
515
+ ON CONFLICT(run_id) DO NOTHING`,
516
+ )
517
+ .run(runId, binding.piSessionId, now);
518
+ const revision = context.revision + 1;
519
+ const at = new Date(now).toISOString();
520
+ insertRunEvent(this.state, run.resourceId, revision, at, {
521
+ seq: revision,
522
+ at,
523
+ runId,
524
+ scope: "session",
525
+ type: "session_bound",
526
+ payload: {
527
+ piSessionId: binding.piSessionId,
528
+ ...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
529
+ },
530
+ });
531
+ const current = this.readState(run.stateHash);
532
+ current.traceSeq = revision;
533
+ current.updatedAt = at;
534
+ this.persistRunState(run, current, revision, now);
535
+ context.revision = revision;
552
536
  });
553
537
  });
554
538
  }
555
539
 
556
- /** Append one verbatim Pi session entry to `session/entries.ndjson`. */
557
540
  async appendSessionEntry(
558
- runDir: string,
541
+ runId: string,
559
542
  entry: Record<string, unknown>,
560
543
  attemptId?: string,
561
544
  ): Promise<number> {
562
- return await this.withRunLock(runDir, async () => {
563
- const stream = this.streamFor(runDir, attemptId);
564
- stream.sessionSeq += 1;
565
- const record: WorkflowSessionEntryRecord = {
566
- seq: stream.sessionSeq,
567
- at: new Date().toISOString(),
568
- entry,
569
- };
570
- await appendLine(path.join(runDir, sessionStreamPaths(attemptId).entries), record);
571
- return record.seq;
545
+ const segment = this.requireSegment(segmentIdFor(runId, attemptId));
546
+ return this.state.transaction(() => {
547
+ this.assertSessionWriteAuthority(runId);
548
+ const revision = this.requireResourceRevision(segmentResourceId(segment));
549
+ const sequence = segment.entryCount + 1;
550
+ const now = Date.now();
551
+ const entryHash = this.state.putJson(entry, now);
552
+ const entryId = typeof entry.id === "string" ? entry.id : `entry-${sequence}`;
553
+ this.state.connection
554
+ .prepare(
555
+ `INSERT INTO session_entries(segment_id, entry_seq, entry_id, entry_hash, recorded_at)
556
+ VALUES (?, ?, ?, ?, ?)`,
557
+ )
558
+ .run(segment.segmentId, sequence, entryId, entryHash, now);
559
+ this.state.connection
560
+ .prepare("UPDATE session_segments SET entry_count = ? WHERE segment_id = ?")
561
+ .run(sequence, segment.segmentId);
562
+ this.bumpResource(segmentResourceId(segment), revision, now);
563
+ insertGenericEvent(
564
+ this.state,
565
+ segmentResourceId(segment),
566
+ revision + 1,
567
+ "session.entry_appended",
568
+ "session",
569
+ segment.sessionId,
570
+ entryHash,
571
+ now,
572
+ );
573
+ return sequence;
572
574
  });
573
575
  }
574
576
 
575
- /** Append a fully stamped ordered batch to `session/events.ndjson`. */
576
577
  async appendSessionEventBatch(
577
- runDir: string,
578
+ runId: string,
578
579
  records: WorkflowSessionEventRecord[],
579
580
  attemptId?: string,
580
581
  ): Promise<void> {
581
- if (records.length === 0) {
582
- return;
583
- }
584
- await this.withSessionEventLock(runDir, attemptId, async () => {
585
- const stream = this.streamFor(runDir, attemptId);
586
- if (stream.sessionEventsStopped) {
587
- throw new Error("Session event capture has stopped");
588
- }
589
- try {
590
- let expected = stream.sessionEventSeq + 1;
591
- for (const record of records) {
592
- validateSessionEventRecord(record);
593
- if (record.seq !== expected) {
594
- throw new Error(`Expected session event seq ${expected}, got ${record.seq}`);
595
- }
596
- expected += 1;
582
+ if (records.length === 0) return;
583
+ const segment = this.requireSegment(segmentIdFor(runId, attemptId));
584
+ this.state.transaction(() => {
585
+ this.assertSessionWriteAuthority(runId);
586
+ const current = this.requireSegment(segment.segmentId);
587
+ if (current.status !== "recording") throw new Error("Session event capture has stopped");
588
+ let expected = current.eventCount + 1;
589
+ for (const record of records) {
590
+ validateSessionEventRecord(record);
591
+ if (record.seq !== expected) {
592
+ throw new Error(`Expected session event seq ${expected}, got ${record.seq}`);
597
593
  }
598
- const encoded = await Promise.all(
599
- records.map(async (record) => ({
600
- ...record,
601
- payload:
602
- record.type === "tool_execution_started" ||
603
- record.type === "tool_execution_finished" ||
604
- (record.type === "assistant_event" && record.payload.type === "toolcall_end")
605
- ? ((await encodeValue(record.payload, this.contextFor(runDir).artifacts)) as Record<
606
- string,
607
- unknown
608
- >)
609
- : record.payload,
610
- })),
611
- );
612
- for (const record of encoded) {
613
- if (Buffer.byteLength(JSON.stringify(record), "utf8") + 1 > SESSION_EVENT_MAX_BYTES) {
614
- throw new Error(`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`);
615
- }
594
+ if (Buffer.byteLength(canonicalJson(record), "utf8") > SESSION_EVENT_MAX_BYTES) {
595
+ throw new Error(`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`);
616
596
  }
617
- await appendLines(path.join(runDir, sessionStreamPaths(attemptId).events), encoded);
618
- stream.sessionEventSeq = records.at(-1)?.seq ?? stream.sessionEventSeq;
619
- } catch (error) {
620
- stream.sessionEventsStopped = true;
621
- throw error;
597
+ const payloadHash = this.state.putJson(record.payload, Date.parse(record.at));
598
+ this.state.connection
599
+ .prepare(
600
+ `INSERT INTO session_events(
601
+ segment_id, event_seq, event_type, node_id, attempt_id,
602
+ turn_id, message_id, tool_call_id, payload_hash, recorded_at
603
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
604
+ )
605
+ .run(
606
+ current.segmentId,
607
+ record.seq,
608
+ record.type,
609
+ record.nodeId,
610
+ record.attemptId,
611
+ record.turnId ?? null,
612
+ record.messageId ?? null,
613
+ record.toolCallId ?? null,
614
+ payloadHash,
615
+ Date.parse(record.at),
616
+ );
617
+ expected += 1;
622
618
  }
619
+ const now = Date.now();
620
+ this.state.connection
621
+ .prepare("UPDATE session_segments SET event_count = ? WHERE segment_id = ?")
622
+ .run(expected - 1, current.segmentId);
623
+ const resourceId = segmentResourceId(current);
624
+ const revision = this.requireResourceRevision(resourceId);
625
+ this.bumpResource(resourceId, revision, now);
626
+ const payloadHash = this.state.putJson({ count: records.length, lastSeq: expected - 1 }, now);
627
+ insertGenericEvent(
628
+ this.state,
629
+ resourceId,
630
+ revision + 1,
631
+ "session.events_appended",
632
+ "session",
633
+ current.sessionId,
634
+ payloadHash,
635
+ now,
636
+ );
623
637
  });
624
638
  }
625
639
 
626
- /** Atomically replace the temporal capture integrity projection. */
627
640
  async writeSessionCapture(
628
- runDir: string,
641
+ runId: string,
629
642
  capture: WorkflowSessionCapture,
630
643
  attemptId?: string,
631
644
  ): Promise<void> {
632
645
  validateSessionCapture(capture);
633
- await this.withSessionEventLock(runDir, attemptId, async () => {
634
- const stream = this.streamFor(runDir, attemptId);
635
- if (capture.status !== "recording") {
636
- stream.sessionEventsStopped = true;
637
- }
638
- await writeJsonAtomic(path.join(runDir, sessionStreamPaths(attemptId).capture), capture);
646
+ const segment = this.requireSegment(segmentIdFor(runId, attemptId));
647
+ this.state.transaction(() => {
648
+ this.assertSessionWriteAuthority(runId);
649
+ const current = this.requireSegment(segment.segmentId);
650
+ if (current.status === "failed" && capture.status !== "failed") return;
651
+ if (current.status === "complete" && capture.status !== "complete") return;
652
+ const now = Date.now();
653
+ const failureHash =
654
+ capture.failure === undefined ? null : this.state.putJson(capture.failure, now);
655
+ this.state.connection
656
+ .prepare(
657
+ `UPDATE session_segments
658
+ SET status = ?, entry_count = ?, event_count = ?, failure_hash = ?, finished_at = ?
659
+ WHERE segment_id = ?`,
660
+ )
661
+ .run(
662
+ capture.status,
663
+ capture.entryCount,
664
+ capture.eventCount,
665
+ failureHash,
666
+ capture.status === "recording" ? null : now,
667
+ segment.segmentId,
668
+ );
669
+ const resourceId = segmentResourceId(segment);
670
+ const revision = this.requireResourceRevision(resourceId);
671
+ this.bumpResource(resourceId, revision, now);
672
+ const payloadHash = this.state.putJson(capture, now);
673
+ insertGenericEvent(
674
+ this.state,
675
+ resourceId,
676
+ revision + 1,
677
+ "session.capture_updated",
678
+ "session",
679
+ segment.sessionId,
680
+ payloadHash,
681
+ now,
682
+ );
639
683
  });
640
684
  }
641
685
 
642
- /** Count complete durable session records after both writers have drained. */
643
686
  async sessionCounts(
644
- runDir: string,
687
+ runId: string,
645
688
  attemptId?: string,
646
689
  ): Promise<{ eventCount: number; entryCount: number; lastEventSeq: number }> {
647
- const paths = sessionStreamPaths(attemptId);
648
- const events = await readCompleteNdjson(path.join(runDir, paths.events));
649
- const entries = await readCompleteNdjson(path.join(runDir, paths.entries));
690
+ const segment = this.segmentRow(segmentIdFor(runId, attemptId));
650
691
  return {
651
- eventCount: events.length,
652
- entryCount: entries.length,
653
- lastEventSeq: events.at(-1)?.seq ?? 0,
692
+ eventCount: segment?.eventCount ?? 0,
693
+ entryCount: segment?.entryCount ?? 0,
694
+ lastEventSeq: segment?.eventCount ?? 0,
654
695
  };
655
696
  }
656
697
 
657
- private async appendTraceEvent(
658
- runDir: string,
659
- runId: string,
660
- event: WorkflowTraceEventDraft,
661
- ): Promise<WorkflowTraceEvent> {
662
- const context = this.contextFor(runDir);
663
- const traceEvent: WorkflowTraceEvent = {
664
- seq: context.traceSeq + 1,
665
- at: new Date().toISOString(),
698
+ readRun(runId: string, options: ReadWorkflowRunOptions = {}): LoadedWorkflowRun | null {
699
+ const row = this.readRunRow(runId);
700
+ if (row === undefined) return null;
701
+ const state = this.readState(row.stateHash);
702
+ const snapshot = this.readDefinition(row.definitionHash);
703
+ const segments = this.segmentRows(runId).map((segment) => this.loadSegment(segment, state));
704
+ const flat = segments.find((segment) => segment.attemptId === "") ?? emptySegment();
705
+ return {
666
706
  runId,
667
- ...event,
668
- payload: (await encodeValue(event.payload, context.artifacts)) as Record<string, unknown>,
707
+ state,
708
+ snapshot,
709
+ ...(options.includeTrace === true ? { traceEvents: this.traceEvents(row) } : {}),
710
+ sessionBinding: flat.binding,
711
+ sessionEntries: flat.entries,
712
+ sessionEvents: flat.events,
713
+ sessionCapture: flat.capture,
714
+ sessionIntegrity: flat.integrity,
715
+ sessionSegments: segments.filter((segment) => segment.attemptId !== ""),
669
716
  };
670
- await appendLine(path.join(runDir, TRACE_PATH), traceEvent);
671
- context.traceSeq = traceEvent.seq;
672
- return traceEvent;
673
717
  }
674
718
 
675
- private async writeProjections(runDir: string, state: WorkflowRunState): Promise<void> {
676
- const context = this.contextFor(runDir);
677
- const encoded = await encodeRunState(state, context.artifacts);
678
- await writeJsonAtomic(path.join(runDir, STATE_PATH), encoded);
679
- await writeJsonAtomic(
680
- path.join(runDir, MANIFEST_PATH),
681
- createManifest(state, {
682
- session: [...context.streams.values()].some((stream) => stream.sessionBound),
683
- }),
684
- );
719
+ listRuns(options: ReadWorkflowRunOptions = {}): LoadedWorkflowRun[] {
720
+ const rows = this.state.connection
721
+ .prepare("SELECT run_id AS runId FROM runs ORDER BY created_at DESC, run_id DESC")
722
+ .all();
723
+ const loaded: LoadedWorkflowRun[] = [];
724
+ for (const row of rows) {
725
+ /* istanbul ignore if -- exact schema and internal query shape */
726
+ if (!isRunIdRow(row)) continue;
727
+ const run = this.readRun(row.runId, options);
728
+ if (run !== null) loaded.push(run);
729
+ }
730
+ return loaded;
685
731
  }
686
732
 
687
- private async writeLoadedProjections(runDir: string, state: WorkflowRunState): Promise<void> {
688
- const context = this.contextFor(runDir);
689
- await writeJsonAtomic(path.join(runDir, STATE_PATH), state);
690
- await writeJsonAtomic(
691
- path.join(runDir, MANIFEST_PATH),
692
- createManifest(state, {
693
- session: [...context.streams.values()].some((stream) => stream.sessionBound),
694
- }),
695
- );
733
+ readLastTraceEvent(runId: string): WorkflowTraceEvent | null {
734
+ const run = this.readRunRow(runId);
735
+ if (run === undefined) return null;
736
+ const last = this.traceEvents(run).at(-1);
737
+ /* istanbul ignore if -- every durable run has a creation event */
738
+ if (last === undefined) throw new Error("Workflow run has no creation event");
739
+ return last;
696
740
  }
697
- }
698
741
 
699
- /**
700
- * Truncate a trace file to the longest contiguous valid prefix, then to the
701
- * event count the state projection recorded. A crash can leave a partial
702
- * final line or one event appended before its projection write; the repair
703
- * keeps state and trace consistent so resume can continue the sequence. The
704
- * rewrite is atomic: a stale writer appending to the old inode cannot
705
- * interleave with the repaired file.
706
- */
707
- async function repairTraceFile(
708
- tracePath: string,
709
- keepSeq: number,
710
- beforeWrite?: () => void,
711
- ): Promise<void> {
712
- let raw: string;
713
- try {
714
- raw = await fs.readFile(tracePath, "utf8");
715
- } catch {
716
- return;
742
+ private contextFor(runId: string): RunContext {
743
+ let context = this.contexts.get(runId);
744
+ if (context === undefined) {
745
+ context = { revision: this.resourceRevision(runId), lock: Promise.resolve() };
746
+ this.contexts.set(runId, context);
747
+ }
748
+ return context;
749
+ }
750
+
751
+ private async withRunLock<T>(runId: string, operation: () => Promise<T>): Promise<T> {
752
+ const context = this.contextFor(runId);
753
+ const prior = context.lock;
754
+ let release: (() => void) | undefined;
755
+ context.lock = new Promise<void>((resolve) => {
756
+ release = resolve;
757
+ });
758
+ await prior;
759
+ try {
760
+ return await operation();
761
+ } finally {
762
+ release?.();
763
+ }
717
764
  }
718
- const lines = raw.split("\n");
719
- if (lines.at(-1) === "") {
720
- lines.pop();
765
+
766
+ private assertSessionWriteAuthority(runId: string): void {
767
+ const run = this.requireRunRow(runId);
768
+ this.assertWriteAuthority(run, this.contextFor(runId).revision);
769
+ }
770
+
771
+ private resourceRevision(runId: string): number {
772
+ const run = this.requireRunRow(runId);
773
+ return this.requireResourceRevision(run.resourceId);
721
774
  }
722
- const good: string[] = [];
723
- let expectedSeq = 1;
724
- for (const line of lines) {
725
- if (line.trim().length === 0) {
726
- break;
775
+
776
+ private requireResourceRevision(resourceId: string): number {
777
+ const row = this.state.connection
778
+ .prepare("SELECT revision FROM resources WHERE resource_id = ?")
779
+ .get(resourceId);
780
+ /* istanbul ignore if -- exact schema and internal query shape */
781
+ if (!isRevisionRow(row)) throw new Error(`Resource is missing: ${resourceId}`);
782
+ return row.revision;
783
+ }
784
+
785
+ private bumpResource(resourceId: string, expectedRevision: number, now: number): void {
786
+ const result = this.state.connection
787
+ .prepare(
788
+ `UPDATE resources SET revision = revision + 1, updated_at = ?
789
+ WHERE resource_id = ? AND revision = ?`,
790
+ )
791
+ .run(now, resourceId, expectedRevision);
792
+ if (result.changes !== 1) throw new Error("Resource revision conflict");
793
+ }
794
+
795
+ private assertWriteAuthority(run: RunRow, expectedRevision: number): void {
796
+ const actualRevision = this.requireResourceRevision(run.resourceId);
797
+ if (actualRevision !== expectedRevision) {
798
+ throw new Error(
799
+ `Workflow run revision conflict: expected ${expectedRevision}, got ${actualRevision}`,
800
+ );
727
801
  }
728
- try {
729
- const event = JSON.parse(line) as { seq?: unknown };
730
- if (event.seq !== expectedSeq) {
731
- break;
732
- }
733
- good.push(line);
734
- expectedSeq += 1;
735
- } catch {
736
- break;
802
+ const lease = this.state.connection
803
+ .prepare(
804
+ `SELECT generation, owner_type AS ownerType, owner_id AS ownerId,
805
+ token_hash AS tokenHash, expires_at AS expiresAt
806
+ FROM leases WHERE resource_id = ?`,
807
+ )
808
+ .get(run.resourceId);
809
+ /* istanbul ignore if -- exact schema and internal query shape */
810
+ if (!isLeaseAuthorityRow(lease)) throw new Error("Workflow run lease is missing");
811
+ if (lease.ownerId === null) return;
812
+ const authority = this.authorityProvider?.(run.runId);
813
+ if (
814
+ authority === undefined ||
815
+ authority.ownerType !== lease.ownerType ||
816
+ authority.ownerId !== lease.ownerId ||
817
+ authority.generation !== lease.generation ||
818
+ lease.tokenHash === null ||
819
+ !lease.tokenHash.equals(tokenHash(authority.token)) ||
820
+ lease.expiresAt === null ||
821
+ lease.expiresAt <= Date.now()
822
+ ) {
823
+ throw new Error("Workflow run write rejected because ownership changed");
737
824
  }
738
825
  }
739
- const kept = good.slice(0, keepSeq);
740
- if (kept.length === lines.length) {
741
- return;
826
+
827
+ private persistRunState(
828
+ run: RunRow,
829
+ state: WorkflowRunState,
830
+ expectedRevision: number,
831
+ now: number,
832
+ ): void {
833
+ this.bumpResource(run.resourceId, expectedRevision - 1, now);
834
+ const stateHash = this.state.putJson(state, now);
835
+ const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
836
+ const update = this.state.connection
837
+ .prepare(
838
+ `UPDATE runs
839
+ SET title = ?, status = ?, paused = ?, status_detail = ?, output_hash = ?,
840
+ error_hash = ?, updated_at = ?, finished_at = ?
841
+ WHERE run_id = ?`,
842
+ )
843
+ .run(
844
+ state.runTitle ?? null,
845
+ state.status,
846
+ state.paused === true ? 1 : 0,
847
+ state.statusDetail ?? null,
848
+ stateHash,
849
+ errorHash,
850
+ now,
851
+ state.finishedAt === undefined ? null : Date.parse(state.finishedAt),
852
+ state.runId,
853
+ );
854
+ if (update.changes !== 1) throw new Error(`Workflow run is missing: ${state.runId}`);
855
+ if (state.status !== "running") {
856
+ this.enqueueRunSettlementEffect(run.resourceId, expectedRevision, state, now);
857
+ }
858
+ run.stateHash = stateHash;
742
859
  }
743
- beforeWrite?.();
744
- const tempPath = `${tracePath}.${process.pid}.${randomUUID()}.tmp`;
745
- await fs.writeFile(tempPath, kept.length === 0 ? "" : `${kept.join("\n")}\n`, {
746
- encoding: "utf8",
747
- mode: 0o600,
748
- });
749
- await fs.rename(tempPath, tracePath);
750
- }
751
860
 
752
- function seededStreams(flat: Omit<SessionStreamState, "lock">): Map<string, SessionStreamState> {
753
- return new Map([["", { ...flat, lock: Promise.resolve() }]]);
754
- }
861
+ private enqueueRunSettlementEffect(
862
+ runResourceId: string,
863
+ sourceRevision: number,
864
+ state: WorkflowRunState,
865
+ now: number,
866
+ ): void {
867
+ if (
868
+ this.state.connection.prepare("SELECT 1 FROM run_queue WHERE run_id = ?").get(state.runId) ===
869
+ undefined
870
+ ) {
871
+ return;
872
+ }
873
+ const effectType = state.status === "waiting" ? "run.park_queue" : "run.settle_queue";
874
+ const idempotencyKey = `${state.runId}:${state.status}`;
875
+ const effectId = `effect-${createHash("sha256")
876
+ .update(`${runResourceId}\0${effectType}\0${idempotencyKey}`)
877
+ .digest("hex")
878
+ .slice(0, 40)}`;
879
+ if (
880
+ this.state.connection.prepare("SELECT 1 FROM effects WHERE effect_id = ?").get(effectId) !==
881
+ undefined
882
+ ) {
883
+ return;
884
+ }
885
+ const effectResourceId = resourceIdFor("effect", effectId);
886
+ const payloadHash = this.state.putJson({ runId: state.runId, status: state.status }, now);
887
+ this.state.connection
888
+ .prepare(
889
+ `INSERT INTO resources(
890
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
891
+ ) VALUES (?, 'effect', ?, 1, ?, ?)`,
892
+ )
893
+ .run(effectResourceId, effectId, now, now);
894
+ this.state.connection
895
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
896
+ .run(effectResourceId);
897
+ this.state.connection
898
+ .prepare(
899
+ `INSERT INTO effects(
900
+ effect_id, resource_id, source_resource_id, source_revision,
901
+ effect_type, idempotency_key, payload_hash, owner_scope,
902
+ status, attempt_count, created_at, updated_at
903
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'run', 'pending', 0, ?, ?)`,
904
+ )
905
+ .run(
906
+ effectId,
907
+ effectResourceId,
908
+ runResourceId,
909
+ sourceRevision,
910
+ effectType,
911
+ idempotencyKey,
912
+ payloadHash,
913
+ now,
914
+ now,
915
+ );
916
+ insertGenericEvent(
917
+ this.state,
918
+ effectResourceId,
919
+ 1,
920
+ "effect.created",
921
+ "system",
922
+ null,
923
+ payloadHash,
924
+ now,
925
+ );
926
+ }
755
927
 
756
- function recoverTerminalProjection(state: WorkflowRunState, event: WorkflowTraceEvent): boolean {
757
- const status = terminalStatusForEvent(event.type);
758
- if (status === undefined) {
759
- return false;
928
+ private syncNodeAttempts(
929
+ state: WorkflowRunState,
930
+ snapshot: WorkflowDefinitionSnapshot,
931
+ now: number,
932
+ ): void {
933
+ for (const step of state.steps.slice(state.carriedStepCount ?? 0)) {
934
+ const resultHash = this.state.putJson(step, now);
935
+ const outputHash = step.output === undefined ? null : this.state.putJson(step.output, now);
936
+ const errorHash = step.error === undefined ? null : this.state.putText(step.error, now);
937
+ const promptHash = step.prompt === null ? null : this.state.putJson(step.prompt, now);
938
+ const existing = this.state.connection
939
+ .prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
940
+ .get(step.attemptId);
941
+ if (existing === undefined) {
942
+ const attemptNumber = this.nextAttemptNumber(state.runId, step.nodeId);
943
+ this.state.connection
944
+ .prepare(
945
+ `INSERT INTO node_attempts(
946
+ attempt_id, run_id, node_id, attempt_number, node_type, status,
947
+ presentation_hash, output_hash, result_hash, error_hash,
948
+ started_at, finished_at, created_at, updated_at
949
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
950
+ )
951
+ .run(
952
+ step.attemptId,
953
+ state.runId,
954
+ step.nodeId,
955
+ attemptNumber,
956
+ step.nodeType,
957
+ outcomeStatus(step.outcome),
958
+ promptHash,
959
+ outputHash,
960
+ resultHash,
961
+ errorHash,
962
+ Date.parse(step.startedAt),
963
+ Date.parse(step.finishedAt),
964
+ Date.parse(step.startedAt),
965
+ now,
966
+ );
967
+ } else {
968
+ this.state.connection
969
+ .prepare(
970
+ `UPDATE node_attempts
971
+ SET status = ?, presentation_hash = ?, output_hash = ?, result_hash = ?,
972
+ error_hash = ?, finished_at = ?, updated_at = ?
973
+ WHERE attempt_id = ?`,
974
+ )
975
+ .run(
976
+ outcomeStatus(step.outcome),
977
+ promptHash,
978
+ outputHash,
979
+ resultHash,
980
+ errorHash,
981
+ Date.parse(step.finishedAt),
982
+ now,
983
+ step.attemptId,
984
+ );
985
+ }
986
+ }
987
+ if (state.currentAttemptId !== undefined && state.currentNode !== undefined) {
988
+ this.ensureAttempt(state, state.currentNode, state.currentAttemptId, now, snapshot);
989
+ }
760
990
  }
761
- state.traceSeq = event.seq;
762
- state.status = status;
763
- state.updatedAt = event.at;
764
- state.finishedAt = event.at;
765
- if (typeof event.payload.error === "string") {
766
- state.error = event.payload.error;
991
+
992
+ private ensureAttempt(
993
+ state: WorkflowRunState,
994
+ nodeId: string,
995
+ attemptId: string,
996
+ now: number,
997
+ snapshot?: WorkflowDefinitionSnapshot,
998
+ ): void {
999
+ const existing = this.state.connection
1000
+ .prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
1001
+ .get(attemptId);
1002
+ const status = state.status === "waiting" ? "waiting" : "running";
1003
+ if (existing !== undefined) {
1004
+ this.state.connection
1005
+ .prepare("UPDATE node_attempts SET status = ?, updated_at = ? WHERE attempt_id = ?")
1006
+ .run(status, now, attemptId);
1007
+ return;
1008
+ }
1009
+ const definition =
1010
+ snapshot ?? this.readDefinition(this.requireRunRow(state.runId).definitionHash);
1011
+ const nodeType = definition.nodes[nodeId]?.nodeType ?? "agent";
1012
+ this.state.connection
1013
+ .prepare(
1014
+ `INSERT INTO node_attempts(
1015
+ attempt_id, run_id, node_id, attempt_number, node_type, status,
1016
+ started_at, created_at, updated_at
1017
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1018
+ )
1019
+ .run(
1020
+ attemptId,
1021
+ state.runId,
1022
+ nodeId,
1023
+ this.nextAttemptNumber(state.runId, nodeId),
1024
+ nodeType,
1025
+ status,
1026
+ state.currentNodeStartedAt === undefined ? now : Date.parse(state.currentNodeStartedAt),
1027
+ now,
1028
+ now,
1029
+ );
767
1030
  }
768
- if (typeof event.payload.waitingOn === "string") {
769
- state.waitingOn = event.payload.waitingOn;
1031
+
1032
+ private nextAttemptNumber(runId: string, nodeId: string): number {
1033
+ const row = this.state.connection
1034
+ .prepare(
1035
+ `SELECT COALESCE(MAX(attempt_number), 0) + 1 AS attemptNumber
1036
+ FROM node_attempts WHERE run_id = ? AND node_id = ?`,
1037
+ )
1038
+ .get(runId, nodeId);
1039
+ /* istanbul ignore if -- exact schema and internal query shape */
1040
+ if (!isAttemptNumberRow(row)) throw new Error("Could not allocate node attempt number");
1041
+ return row.attemptNumber;
1042
+ }
1043
+
1044
+ private nextUpdateSequence(attemptId: string): number {
1045
+ const row = this.state.connection
1046
+ .prepare(
1047
+ `SELECT COALESCE(MAX(update_seq), 0) + 1 AS updateSeq
1048
+ FROM workflow_updates WHERE attempt_id = ?`,
1049
+ )
1050
+ .get(attemptId);
1051
+ /* istanbul ignore if -- exact schema and internal query shape */
1052
+ if (!isUpdateSequenceRow(row)) throw new Error("Could not allocate workflow update sequence");
1053
+ return row.updateSeq;
1054
+ }
1055
+
1056
+ private readRunRow(runId: string): RunRow | undefined {
1057
+ const row = this.state.connection
1058
+ .prepare(
1059
+ `SELECT r.run_id AS runId, r.resource_id AS resourceId,
1060
+ r.output_hash AS stateHash, d.definition_hash AS definitionHash,
1061
+ r.status
1062
+ FROM runs r
1063
+ JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
1064
+ WHERE r.run_id = ?`,
1065
+ )
1066
+ .get(runId);
1067
+ return isRunRow(row) ? row : undefined;
1068
+ }
1069
+
1070
+ private requireRunRow(runId: string): RunRow {
1071
+ const row = this.readRunRow(runId);
1072
+ if (row === undefined) throw new Error(`Workflow run is missing: ${runId}`);
1073
+ return row;
1074
+ }
1075
+
1076
+ private readState(hash: Buffer): WorkflowRunState {
1077
+ const value = this.state.readJson(hash);
1078
+ if (!isRecord(value) || value.schema !== RUN_STATE_SCHEMA) {
1079
+ throw new Error("Workflow run state has an incompatible schema");
1080
+ }
1081
+ return value as WorkflowRunState;
770
1082
  }
771
- if (Object.hasOwn(event.payload, "finalOutput")) {
772
- state.finalOutput = event.payload.finalOutput;
1083
+
1084
+ private readDefinition(hash: Buffer): WorkflowDefinitionSnapshot {
1085
+ const value = this.state.readJson(hash);
1086
+ if (!isRecord(value) || value.schema !== DEFINITION_SNAPSHOT_SCHEMA) {
1087
+ throw new Error("Workflow definition snapshot has an incompatible schema");
1088
+ }
1089
+ return value as WorkflowDefinitionSnapshot;
1090
+ }
1091
+
1092
+ private traceEvents(run: RunRow): WorkflowTraceEvent[] {
1093
+ const rows = this.state.connection
1094
+ .prepare(
1095
+ `SELECT resource_revision AS resourceRevision, event_type AS eventType,
1096
+ payload_hash AS payloadHash, recorded_at AS recordedAt
1097
+ FROM events WHERE resource_id = ? ORDER BY resource_revision`,
1098
+ )
1099
+ .all(run.resourceId);
1100
+ const events: WorkflowTraceEvent[] = [];
1101
+ for (const row of rows) {
1102
+ /* istanbul ignore if -- exact schema and internal query shape */
1103
+ if (!isEventRow(row)) continue;
1104
+ const payload = row.payloadHash === null ? {} : this.state.readJson(row.payloadHash);
1105
+ /* istanbul ignore if -- exact schema and internal query shape */
1106
+ if (!isRecord(payload)) throw new Error("Workflow trace payload is not an object");
1107
+ events.push({
1108
+ seq: row.resourceRevision,
1109
+ at: new Date(row.recordedAt).toISOString(),
1110
+ runId: run.runId,
1111
+ scope: traceScope(payload.scope),
1112
+ type: row.eventType,
1113
+ ...(typeof payload.nodeId === "string" ? { nodeId: payload.nodeId } : {}),
1114
+ ...(typeof payload.attemptId === "string" ? { attemptId: payload.attemptId } : {}),
1115
+ payload: isRecord(payload.payload) ? payload.payload : {},
1116
+ });
1117
+ }
1118
+ return events;
1119
+ }
1120
+
1121
+ private segmentRows(runId: string): SegmentRow[] {
1122
+ const rows = this.state.connection
1123
+ .prepare(
1124
+ `SELECT segment_id AS segmentId, run_id AS runId, attempt_id AS attemptId,
1125
+ capture_key AS captureKey, session_id AS sessionId, binding_hash AS bindingHash, status,
1126
+ entry_count AS entryCount, event_count AS eventCount,
1127
+ failure_hash AS failureHash, created_at AS createdAt, finished_at AS finishedAt
1128
+ FROM session_segments WHERE run_id = ? ORDER BY created_at, segment_id`,
1129
+ )
1130
+ .all(runId);
1131
+ return rows.filter(isSegmentRow);
1132
+ }
1133
+
1134
+ private segmentRow(segmentId: string): SegmentRow | undefined {
1135
+ const row = this.state.connection
1136
+ .prepare(
1137
+ `SELECT segment_id AS segmentId, run_id AS runId, attempt_id AS attemptId,
1138
+ capture_key AS captureKey, session_id AS sessionId, binding_hash AS bindingHash, status,
1139
+ entry_count AS entryCount, event_count AS eventCount,
1140
+ failure_hash AS failureHash, created_at AS createdAt, finished_at AS finishedAt
1141
+ FROM session_segments WHERE segment_id = ?`,
1142
+ )
1143
+ .get(segmentId);
1144
+ return isSegmentRow(row) ? row : undefined;
1145
+ }
1146
+
1147
+ private requireSegment(segmentId: string): SegmentRow {
1148
+ const row = this.segmentRow(segmentId);
1149
+ if (row === undefined) throw new Error(`Session capture segment is missing: ${segmentId}`);
1150
+ return row;
1151
+ }
1152
+
1153
+ private loadSegment(segment: SegmentRow, runState: WorkflowRunState): SessionCaptureSegment {
1154
+ const binding =
1155
+ segment.bindingHash === null
1156
+ ? null
1157
+ : (this.state.readJson(segment.bindingHash) as WorkflowSessionBinding);
1158
+ const entries = this.state.connection
1159
+ .prepare(
1160
+ `SELECT entry_seq AS entrySeq, entry_hash AS entryHash, recorded_at AS recordedAt
1161
+ FROM session_entries WHERE segment_id = ? ORDER BY entry_seq`,
1162
+ )
1163
+ .all(segment.segmentId)
1164
+ .filter(isSessionEntryRow)
1165
+ .map((row) => ({
1166
+ seq: row.entrySeq,
1167
+ at: new Date(row.recordedAt).toISOString(),
1168
+ entry: this.state.readJson(row.entryHash) as Record<string, unknown>,
1169
+ }));
1170
+ const events = this.state.connection
1171
+ .prepare(
1172
+ `SELECT event_seq AS eventSeq, event_type AS eventType, node_id AS nodeId,
1173
+ attempt_id AS attemptId, turn_id AS turnId, message_id AS messageId,
1174
+ tool_call_id AS toolCallId, payload_hash AS payloadHash,
1175
+ recorded_at AS recordedAt
1176
+ FROM session_events WHERE segment_id = ? ORDER BY event_seq`,
1177
+ )
1178
+ .all(segment.segmentId)
1179
+ .filter(isSessionEventRow)
1180
+ .map((row) => ({
1181
+ seq: row.eventSeq,
1182
+ at: new Date(row.recordedAt).toISOString(),
1183
+ nodeId: row.nodeId,
1184
+ attemptId: row.attemptId,
1185
+ ...(row.turnId === null ? {} : { turnId: row.turnId }),
1186
+ ...(row.messageId === null ? {} : { messageId: row.messageId }),
1187
+ ...(row.toolCallId === null ? {} : { toolCallId: row.toolCallId }),
1188
+ type: row.eventType,
1189
+ payload: this.state.readJson(row.payloadHash) as Record<string, unknown>,
1190
+ }));
1191
+ const failure =
1192
+ segment.failureHash === null
1193
+ ? undefined
1194
+ : (this.state.readJson(segment.failureHash) as WorkflowSessionCapture["failure"]);
1195
+ const capture: WorkflowSessionCapture = {
1196
+ schema: SESSION_CAPTURE_SCHEMA,
1197
+ eventSchema: SESSION_EVENT_SCHEMA,
1198
+ status: segment.status,
1199
+ eventCount: segment.eventCount,
1200
+ entryCount: segment.entryCount,
1201
+ lastEventSeq: segment.eventCount,
1202
+ ...(failure === undefined ? {} : { failure }),
1203
+ };
1204
+ const diagnostics: string[] = [];
1205
+ if (entries.length !== segment.entryCount || events.length !== segment.eventCount) {
1206
+ diagnostics.push("session capture counts do not match durable rows");
1207
+ }
1208
+ if (runState.status !== "running" && segment.status === "recording") {
1209
+ diagnostics.push("terminal run still reports recording capture");
1210
+ }
1211
+ const integrity: SessionCaptureIntegrity =
1212
+ diagnostics.length > 0
1213
+ ? { status: "invalid", diagnostics }
1214
+ : segment.status === "failed"
1215
+ ? { status: "failed", diagnostics: [failure?.message ?? "session capture failed"] }
1216
+ : { status: segment.status, diagnostics: [] };
1217
+ return {
1218
+ attemptId: segment.captureKey ?? "",
1219
+ binding,
1220
+ entries,
1221
+ events,
1222
+ capture,
1223
+ integrity,
1224
+ };
773
1225
  }
774
- delete state.currentNode;
775
- delete state.currentAttemptId;
776
- delete state.currentNodeStartedAt;
777
- return true;
778
- }
779
1226
 
780
- function terminalStatusForEvent(type: string): WorkflowRunState["status"] | undefined {
781
- switch (type) {
782
- case "run_waiting":
783
- return "waiting";
784
- case "run_completed":
785
- return "completed";
786
- case "run_failed":
787
- case "run_interrupted":
788
- return "failed";
789
- case "run_timed_out":
790
- return "timed_out";
791
- case "run_cancelled":
792
- return "cancelled";
793
- default:
794
- return undefined;
1227
+ private finalizeRecordingCaptures(runId: string, reason: string): void {
1228
+ for (const segment of this.segmentRows(runId)) {
1229
+ if (segment.status !== "recording") continue;
1230
+ const capture: WorkflowSessionCapture = {
1231
+ schema: SESSION_CAPTURE_SCHEMA,
1232
+ eventSchema: SESSION_EVENT_SCHEMA,
1233
+ status: "failed",
1234
+ eventCount: segment.eventCount,
1235
+ entryCount: segment.entryCount,
1236
+ lastEventSeq: segment.eventCount,
1237
+ failure: {
1238
+ failedAt: new Date().toISOString(),
1239
+ code: "host_interrupted",
1240
+ message: reason,
1241
+ },
1242
+ };
1243
+ const now = Date.now();
1244
+ this.state.transaction(() => {
1245
+ this.assertSessionWriteAuthority(runId);
1246
+ const failureHash = this.state.putJson(capture.failure, now);
1247
+ this.state.connection
1248
+ .prepare(
1249
+ `UPDATE session_segments
1250
+ SET status = 'failed', failure_hash = ?, finished_at = ?
1251
+ WHERE segment_id = ? AND status = 'recording'`,
1252
+ )
1253
+ .run(failureHash, now, segment.segmentId);
1254
+ const resourceId = segmentResourceId(segment);
1255
+ const revision = this.requireResourceRevision(resourceId);
1256
+ this.bumpResource(resourceId, revision, now);
1257
+ const payloadHash = this.state.putJson(capture, now);
1258
+ insertGenericEvent(
1259
+ this.state,
1260
+ resourceId,
1261
+ revision + 1,
1262
+ "session.capture_failed",
1263
+ "system",
1264
+ null,
1265
+ payloadHash,
1266
+ now,
1267
+ );
1268
+ });
1269
+ }
795
1270
  }
796
1271
  }
797
1272
 
798
- function assertValidRunId(runId: string): void {
799
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
800
- throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
1273
+ export function readWorkflowRun(
1274
+ runId: string,
1275
+ options: ReadWorkflowRunOptions & { databasePath?: string } = {},
1276
+ ): LoadedWorkflowRun | null {
1277
+ const store = new WorkflowRunStore(options.databasePath ?? workflowStatePath(), {
1278
+ readOnly: true,
1279
+ });
1280
+ try {
1281
+ return store.readRun(runId, options);
1282
+ } finally {
1283
+ store.close();
801
1284
  }
802
1285
  }
803
1286
 
804
- function isMissingPath(error: unknown): boolean {
805
- return error instanceof Error && "code" in error && error.code === "ENOENT";
1287
+ export function listWorkflowRuns(
1288
+ options: ReadWorkflowRunOptions & { databasePath?: string } = {},
1289
+ ): LoadedWorkflowRun[] {
1290
+ const store = new WorkflowRunStore(options.databasePath ?? workflowStatePath(), {
1291
+ readOnly: true,
1292
+ });
1293
+ try {
1294
+ return store.listRuns(options);
1295
+ } finally {
1296
+ store.close();
1297
+ }
806
1298
  }
807
1299
 
808
- async function appendLine(filePath: string, value: unknown): Promise<void> {
809
- await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
810
- await fs.appendFile(filePath, value === null ? "" : `${JSON.stringify(value)}\n`, {
811
- encoding: "utf8",
812
- mode: 0o600,
1300
+ export function readLastTraceEvent(
1301
+ runId: string,
1302
+ options: { databasePath?: string } = {},
1303
+ ): WorkflowTraceEvent | null {
1304
+ const store = new WorkflowRunStore(options.databasePath ?? workflowStatePath(), {
1305
+ readOnly: true,
813
1306
  });
1307
+ try {
1308
+ return store.readLastTraceEvent(runId);
1309
+ } finally {
1310
+ store.close();
1311
+ }
1312
+ }
1313
+
1314
+ function insertRunEvent(
1315
+ state: StateDatabase,
1316
+ resourceId: string,
1317
+ revision: number,
1318
+ at: string,
1319
+ event: WorkflowTraceEventDraft | WorkflowTraceEvent,
1320
+ ): void {
1321
+ const payloadHash = state.putJson(
1322
+ {
1323
+ scope: event.scope,
1324
+ ...(event.nodeId === undefined ? {} : { nodeId: event.nodeId }),
1325
+ ...(event.attemptId === undefined ? {} : { attemptId: event.attemptId }),
1326
+ payload: event.payload,
1327
+ },
1328
+ Date.parse(at),
1329
+ );
1330
+ insertGenericEvent(
1331
+ state,
1332
+ resourceId,
1333
+ revision,
1334
+ event.type,
1335
+ "system",
1336
+ null,
1337
+ payloadHash,
1338
+ Date.parse(at),
1339
+ );
1340
+ }
1341
+
1342
+ function insertGenericEvent(
1343
+ state: StateDatabase,
1344
+ resourceId: string,
1345
+ revision: number,
1346
+ eventType: string,
1347
+ actorType: string,
1348
+ actorId: string | null,
1349
+ payloadHash: Buffer | null,
1350
+ now: number,
1351
+ ): void {
1352
+ state.connection
1353
+ .prepare(
1354
+ `INSERT INTO events(
1355
+ event_id, resource_id, resource_revision, event_type,
1356
+ actor_type, actor_id, payload_hash, recorded_at
1357
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1358
+ )
1359
+ .run(
1360
+ `event-${randomUUID()}`,
1361
+ resourceId,
1362
+ revision,
1363
+ eventType,
1364
+ actorType,
1365
+ actorId,
1366
+ payloadHash,
1367
+ now,
1368
+ );
1369
+ }
1370
+
1371
+ function sourceForState(
1372
+ state: WorkflowRunState,
1373
+ definitionDigest: Buffer,
1374
+ ): { type: "builtin" | "file"; ref: string; revision: string } {
1375
+ const source = state.workflowSource;
1376
+ if (source?.kind === "builtin")
1377
+ return { type: "builtin", ref: source.id, revision: source.revision };
1378
+ if (source?.kind === "file") return { type: "file", ref: source.path, revision: source.hash };
1379
+ return {
1380
+ type: "file",
1381
+ ref: `inline:${state.workflowName}`,
1382
+ revision: definitionDigest.toString("hex"),
1383
+ };
1384
+ }
1385
+
1386
+ function segmentIdFor(runId: string, attemptId?: string): string {
1387
+ return `segment-${createHash("sha256")
1388
+ .update(`${runId}\0${attemptId ?? ""}`)
1389
+ .digest("hex")
1390
+ .slice(0, 40)}`;
1391
+ }
1392
+
1393
+ function segmentResourceId(segment: SegmentRow): string {
1394
+ return resourceIdFor("session", segment.segmentId);
814
1395
  }
815
1396
 
816
- async function appendLines(filePath: string, values: unknown[]): Promise<void> {
817
- await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
818
- const text = values.map((value) => `${JSON.stringify(value)}\n`).join("");
819
- await fs.appendFile(filePath, text, { encoding: "utf8", mode: 0o600 });
1397
+ function outcomeStatus(outcome: string): string {
1398
+ switch (outcome) {
1399
+ case "ok":
1400
+ return "completed";
1401
+ case "timed_out":
1402
+ return "timed_out";
1403
+ case "cancelled":
1404
+ return "cancelled";
1405
+ default:
1406
+ return "failed";
1407
+ }
820
1408
  }
821
1409
 
822
- function isNonEmptyString(value: unknown): value is string {
823
- return typeof value === "string" && value.length > 0;
1410
+ function traceScope(value: unknown): WorkflowTraceEvent["scope"] {
1411
+ return value === "node" || value === "agent" || value === "action" || value === "session"
1412
+ ? value
1413
+ : "run";
1414
+ }
1415
+
1416
+ function emptySegment(): SessionCaptureSegment {
1417
+ return {
1418
+ attemptId: "",
1419
+ binding: null,
1420
+ entries: [],
1421
+ events: [],
1422
+ capture: null,
1423
+ integrity: { status: "unavailable", diagnostics: [] },
1424
+ };
824
1425
  }
825
1426
 
826
1427
  function validateSessionEventRecord(record: WorkflowSessionEventRecord): void {
@@ -828,105 +1429,15 @@ function validateSessionEventRecord(record: WorkflowSessionEventRecord): void {
828
1429
  throw new Error("Session event seq must be a positive safe integer");
829
1430
  }
830
1431
  if (
831
- !isNonEmptyString(record.at) ||
832
- !isNonEmptyString(record.nodeId) ||
833
- !isNonEmptyString(record.attemptId) ||
834
- !isNonEmptyString(record.type) ||
1432
+ record.at.length === 0 ||
1433
+ record.nodeId.length === 0 ||
1434
+ record.attemptId.length === 0 ||
835
1435
  typeof record.payload !== "object" ||
836
1436
  record.payload === null ||
837
1437
  Array.isArray(record.payload)
838
1438
  ) {
839
1439
  throw new Error("Session event is missing required envelope fields");
840
1440
  }
841
- const knownType = [
842
- "turn_started",
843
- "turn_finished",
844
- "message_started",
845
- "assistant_event",
846
- "message_finished",
847
- "tool_execution_started",
848
- "tool_execution_updated",
849
- "tool_execution_finished",
850
- ].includes(record.type);
851
- if (!knownType) {
852
- return;
853
- }
854
- if (!isNonEmptyString(record.turnId)) {
855
- throw new Error(`${record.type} requires turnId`);
856
- }
857
- if (
858
- !["turn_started", "turn_finished"].includes(record.type) &&
859
- !isNonEmptyString(record.messageId)
860
- ) {
861
- throw new Error(`${record.type} requires messageId`);
862
- }
863
- if (record.type.startsWith("tool_execution_") && !isNonEmptyString(record.toolCallId)) {
864
- throw new Error(`${record.type} requires toolCallId`);
865
- }
866
- }
867
-
868
- function sessionRelationshipDiagnostics(
869
- entries: WorkflowSessionEntryRecord[],
870
- events: WorkflowSessionEventRecord[],
871
- ): string[] {
872
- const entryIds = new Set(
873
- entries.flatMap((record) => (isNonEmptyString(record.entry.id) ? [record.entry.id] : [])),
874
- );
875
- const turns = new Set<string>();
876
- const messages = new Set<string>();
877
- const tools = new Set<string>();
878
- const diagnostics: string[] = [];
879
- for (const event of events) {
880
- switch (event.type) {
881
- case "turn_started":
882
- if (event.turnId) turns.add(event.turnId);
883
- break;
884
- case "turn_finished":
885
- if (!event.turnId || !turns.has(event.turnId)) {
886
- diagnostics.push(`turn_finished ${event.seq} precedes turn_started`);
887
- }
888
- break;
889
- case "message_started":
890
- if (!event.turnId || !turns.has(event.turnId)) {
891
- diagnostics.push(`message_started ${event.seq} precedes turn_started`);
892
- }
893
- if (event.messageId) messages.add(event.messageId);
894
- break;
895
- case "assistant_event":
896
- if (!event.messageId || !messages.has(event.messageId)) {
897
- diagnostics.push(`assistant_event ${event.seq} precedes message_started`);
898
- }
899
- break;
900
- case "message_finished": {
901
- if (!event.messageId || !messages.has(event.messageId)) {
902
- diagnostics.push(`message_finished ${event.seq} precedes message_started`);
903
- }
904
- const settled = event.payload.settled;
905
- const entryId = event.payload.entryId;
906
- if (settled === true && (!isNonEmptyString(entryId) || !entryIds.has(entryId))) {
907
- diagnostics.push(`message_finished ${event.seq} references a missing entry`);
908
- } else if (settled !== true && entryId !== undefined) {
909
- diagnostics.push(`message_finished ${event.seq} has entryId while unsettled`);
910
- }
911
- break;
912
- }
913
- case "tool_execution_started":
914
- if (!event.messageId || !messages.has(event.messageId)) {
915
- diagnostics.push(`tool_execution_started ${event.seq} precedes message_started`);
916
- }
917
- if (event.toolCallId) tools.add(event.toolCallId);
918
- break;
919
- case "tool_execution_updated":
920
- case "tool_execution_finished":
921
- if (!event.toolCallId || !tools.has(event.toolCallId)) {
922
- diagnostics.push(`${event.type} ${event.seq} precedes tool_execution_started`);
923
- }
924
- break;
925
- default:
926
- break;
927
- }
928
- }
929
- return diagnostics;
930
1441
  }
931
1442
 
932
1443
  function validateSessionCapture(capture: WorkflowSessionCapture): void {
@@ -951,423 +1462,60 @@ function validateSessionCapture(capture: WorkflowSessionCapture): void {
951
1462
  }
952
1463
  }
953
1464
 
954
- async function readCompleteNdjson(filePath: string): Promise<Array<{ seq: number }>> {
955
- let raw: string;
956
- try {
957
- raw = await fs.readFile(filePath, "utf8");
958
- } catch {
959
- return [];
960
- }
961
- const lines = raw.split("\n");
962
- if (!raw.endsWith("\n")) {
963
- lines.pop();
964
- }
965
- const records: Array<{ seq: number }> = [];
966
- for (const line of lines) {
967
- if (line.trim().length === 0) {
968
- continue;
969
- }
970
- try {
971
- const value = JSON.parse(line) as { seq?: unknown };
972
- if (!Number.isSafeInteger(value.seq) || (value.seq as number) < 1) {
973
- break;
974
- }
975
- records.push({ seq: value.seq as number });
976
- } catch {
977
- break;
978
- }
1465
+ function assertValidRunId(runId: string): void {
1466
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
1467
+ throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
979
1468
  }
980
- return records;
981
1469
  }
982
1470
 
983
- /**
984
- * Encode the externalizable value positions of the state document. The
985
- * in-memory state always holds raw values; the persisted copy may carry
986
- * `$artifact` references instead of large strings.
987
- */
988
- async function encodeRunState(
989
- state: WorkflowRunState,
990
- artifacts: ArtifactWriter,
991
- ): Promise<WorkflowRunState> {
992
- const results = Object.fromEntries(
993
- await Promise.all(
994
- Object.entries(state.results).map(async ([nodeId, result]) => [
995
- nodeId,
996
- "output" in result
997
- ? { ...result, output: await encodeValue(result.output, artifacts) }
998
- : result,
999
- ]),
1000
- ),
1001
- ) as WorkflowRunState["results"];
1002
- return {
1003
- ...state,
1004
- input: await encodeValue(state.input, artifacts),
1005
- outputs: Object.fromEntries(
1006
- await Promise.all(
1007
- Object.entries(state.outputs).map(async ([nodeId, output]) => [
1008
- nodeId,
1009
- await encodeValue(output, artifacts),
1010
- ]),
1011
- ),
1012
- ),
1013
- results,
1014
- steps: await Promise.all(
1015
- state.steps.map(async (step) => ({
1016
- ...step,
1017
- prompt: (await encodeValue(
1018
- step.prompt,
1019
- artifacts,
1020
- )) as WorkflowRunState["steps"][number]["prompt"],
1021
- output: await encodeValue(step.output, artifacts),
1022
- })),
1023
- ),
1024
- ...(state.finalOutput !== undefined
1025
- ? { finalOutput: await encodeValue(state.finalOutput, artifacts) }
1026
- : {}),
1027
- };
1471
+ function isRecord(value: unknown): value is Record<string, unknown> {
1472
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1028
1473
  }
1029
1474
 
1030
- export type SessionCaptureIntegrity = {
1031
- status: "unavailable" | "recording" | "complete" | "failed" | "invalid";
1032
- diagnostics: string[];
1033
- };
1034
-
1035
- /** One capture attempt: the session data a single recorder wrote. */
1036
- export type SessionCaptureSegment = {
1037
- attemptId: string;
1038
- binding: WorkflowSessionBinding | null;
1039
- entries: WorkflowSessionEntryRecord[];
1040
- events: WorkflowSessionEventRecord[];
1041
- capture: WorkflowSessionCapture | null;
1042
- integrity: SessionCaptureIntegrity;
1043
- };
1044
-
1045
- export type LoadedRunBundle = {
1046
- runDir: string;
1047
- manifest: WorkflowRunManifest;
1048
- state: WorkflowRunState;
1049
- snapshot: WorkflowDefinitionSnapshot | null;
1050
- /** Full durable trace when loaded by the current reader. */
1051
- traceEvents?: WorkflowTraceEvent[];
1052
- sessionBinding: WorkflowSessionBinding | null;
1053
- sessionEntries: WorkflowSessionEntryRecord[];
1054
- sessionEvents: WorkflowSessionEventRecord[];
1055
- sessionCapture: WorkflowSessionCapture | null;
1056
- sessionIntegrity: SessionCaptureIntegrity;
1057
- /** Per-attempt captures written after a handoff or resume. */
1058
- sessionSegments: SessionCaptureSegment[];
1059
- };
1060
-
1061
- /** Read the final trace record without loading the rest of a run bundle. */
1062
- export async function readLastTraceEvent(
1063
- runDir: string,
1064
- tracePath?: string,
1065
- ): Promise<WorkflowTraceEvent | null> {
1066
- const events = await readNdjsonFile<WorkflowTraceEvent>(
1067
- resolveBundlePath(runDir, tracePath, TRACE_PATH),
1068
- );
1069
- return events.records.at(-1) ?? null;
1475
+ function isRunRow(value: unknown): value is RunRow {
1476
+ return isRecord(value);
1070
1477
  }
1071
1478
 
1072
- export type ReadRunBundleOptions = {
1073
- /** Load the full append-only trace. Detail views need it; run lists do not. */
1074
- includeTrace?: boolean;
1075
- };
1076
-
1077
- /** Read a run bundle from disk. Returns null when the bundle is unreadable. */
1078
- export async function readRunBundle(
1079
- runDir: string,
1080
- options: ReadRunBundleOptions = {},
1081
- ): Promise<LoadedRunBundle | null> {
1082
- const manifest = await readJsonFile<WorkflowRunManifest>(path.join(runDir, MANIFEST_PATH));
1083
- if (!manifest || manifest.schema !== RUN_BUNDLE_SCHEMA) {
1084
- return null;
1085
- }
1086
- // A schema-tagged manifest may still be malformed (e.g. hand-edited);
1087
- // treat anything unexpected as an unreadable bundle rather than throwing.
1088
- const paths: Partial<WorkflowRunManifest["paths"]> =
1089
- typeof manifest.paths === "object" && manifest.paths !== null ? manifest.paths : {};
1090
- const state = await readJsonFile<WorkflowRunState>(
1091
- resolveBundlePath(runDir, paths.state, STATE_PATH),
1092
- );
1093
- if (!state || state.schema !== RUN_STATE_SCHEMA) {
1094
- return null;
1095
- }
1096
- const snapshot = await readJsonFile<WorkflowDefinitionSnapshot>(
1097
- resolveBundlePath(runDir, paths.workflow, WORKFLOW_SNAPSHOT_PATH),
1098
- );
1099
- const trace =
1100
- options.includeTrace === true
1101
- ? await readNdjsonFile<WorkflowTraceEvent>(resolveBundlePath(runDir, paths.trace, TRACE_PATH))
1102
- : undefined;
1103
- const sessionDir = resolveBundlePath(runDir, paths.session, SESSION_DIR);
1104
- const sessionBinding = await readJsonFile<WorkflowSessionBinding>(
1105
- path.join(sessionDir, "binding.json"),
1106
- );
1107
- const entries = await readNdjsonFile<WorkflowSessionEntryRecord>(
1108
- path.join(sessionDir, "entries.ndjson"),
1109
- );
1110
- const events = await readNdjsonFile<WorkflowSessionEventRecord>(
1111
- path.join(sessionDir, "events.ndjson"),
1112
- );
1113
- const sessionCapture = await readJsonFile<WorkflowSessionCapture>(
1114
- path.join(sessionDir, "capture.json"),
1115
- );
1116
- const flatIntegrity = assessSessionIntegrity({
1117
- binding: sessionBinding,
1118
- entries,
1119
- events,
1120
- capture: sessionCapture,
1121
- runTerminal: state.status !== "running",
1122
- });
1123
- const sessionSegments: SessionCaptureSegment[] = [];
1124
- let segmentIds: string[] = [];
1125
- try {
1126
- segmentIds = (await fs.readdir(path.join(sessionDir, "segments"), { withFileTypes: true }))
1127
- .filter((entry) => entry.isDirectory())
1128
- .map((entry) => entry.name)
1129
- .sort();
1130
- } catch {
1131
- // No segments directory means only the flat stream can exist.
1132
- }
1133
- for (const attemptId of segmentIds) {
1134
- const segmentDir = path.join(sessionDir, "segments", attemptId);
1135
- const binding = await readJsonFile<WorkflowSessionBinding>(
1136
- path.join(segmentDir, "binding.json"),
1137
- );
1138
- const segmentEntries = await readNdjsonFile<WorkflowSessionEntryRecord>(
1139
- path.join(segmentDir, "entries.ndjson"),
1140
- );
1141
- const segmentEvents = await readNdjsonFile<WorkflowSessionEventRecord>(
1142
- path.join(segmentDir, "events.ndjson"),
1143
- );
1144
- const capture = await readJsonFile<WorkflowSessionCapture>(
1145
- path.join(segmentDir, "capture.json"),
1146
- );
1147
- sessionSegments.push({
1148
- attemptId,
1149
- binding,
1150
- entries: segmentEntries.records,
1151
- events: segmentEvents.records,
1152
- capture,
1153
- integrity: assessSessionIntegrity({
1154
- binding,
1155
- entries: segmentEntries,
1156
- events: segmentEvents,
1157
- capture,
1158
- runTerminal: state.status !== "running",
1159
- }),
1160
- });
1161
- }
1162
- // The headline integrity is the flat stream's when present; otherwise the
1163
- // chronologically latest capture segment speaks for the run (segment ids
1164
- // are random, so directory order says nothing about time).
1165
- sessionSegments.sort((a, b) =>
1166
- (a.binding?.boundAt ?? "").localeCompare(b.binding?.boundAt ?? ""),
1167
- );
1168
- const sessionIntegrity =
1169
- flatIntegrity.status !== "unavailable" || sessionSegments.length === 0
1170
- ? flatIntegrity
1171
- : (sessionSegments.at(-1)?.integrity ?? flatIntegrity);
1172
- return {
1173
- runDir,
1174
- manifest,
1175
- state,
1176
- snapshot,
1177
- ...(trace !== undefined ? { traceEvents: trace.records } : {}),
1178
- sessionBinding,
1179
- sessionEntries: entries.records,
1180
- sessionEvents: events.records,
1181
- sessionCapture,
1182
- sessionIntegrity,
1183
- sessionSegments,
1184
- };
1479
+ function isRunIdRow(value: unknown): value is { runId: string } {
1480
+ return isRecord(value);
1185
1481
  }
1186
1482
 
1187
- /**
1188
- * Resolve a manifest-relative path, rejecting anything that is not a string
1189
- * or escapes the bundle directory. Malformed manifests must degrade to an
1190
- * unreadable bundle, never to a thrown error that aborts a listing.
1191
- */
1192
- function resolveBundlePath(runDir: string, relative: unknown, fallback: string): string {
1193
- const candidate = path.resolve(
1194
- runDir,
1195
- typeof relative === "string" && relative ? relative : fallback,
1196
- );
1197
- if (
1198
- candidate !== path.resolve(runDir) &&
1199
- !candidate.startsWith(path.resolve(runDir) + path.sep)
1200
- ) {
1201
- return path.join(runDir, fallback);
1202
- }
1203
- return candidate;
1483
+ function isRevisionRow(value: unknown): value is { revision: number } {
1484
+ return isRecord(value);
1204
1485
  }
1205
1486
 
1206
- /** List run bundles under `outputRoot`, most recently started first. */
1207
- export async function listRunBundles(outputRoot: string): Promise<LoadedRunBundle[]> {
1208
- let entries: string[];
1209
- try {
1210
- entries = await fs.readdir(outputRoot);
1211
- } catch {
1212
- return [];
1213
- }
1214
- const bundles: LoadedRunBundle[] = [];
1215
- for (const entry of entries) {
1216
- const bundle = await readRunBundle(path.join(outputRoot, entry), { includeTrace: false });
1217
- if (bundle) {
1218
- bundles.push(bundle);
1219
- }
1220
- }
1221
- bundles.sort((a, b) => b.state.startedAt.localeCompare(a.state.startedAt));
1222
- return bundles;
1487
+ function isAttemptNumberRow(value: unknown): value is { attemptNumber: number } {
1488
+ return isRecord(value);
1223
1489
  }
1224
1490
 
1225
- async function readJsonFile<T>(filePath: string): Promise<T | null> {
1226
- try {
1227
- const raw = await fs.readFile(filePath, "utf8");
1228
- return JSON.parse(raw) as T;
1229
- } catch {
1230
- return null;
1231
- }
1491
+ function isUpdateSequenceRow(value: unknown): value is { updateSeq: number } {
1492
+ return isRecord(value);
1232
1493
  }
1233
1494
 
1234
- type NdjsonRead<T> = {
1235
- records: T[];
1236
- exists: boolean;
1237
- tornTail: boolean;
1238
- malformed: boolean;
1239
- };
1495
+ function isEventRow(value: unknown): value is EventRow {
1496
+ return isRecord(value);
1497
+ }
1240
1498
 
1241
- async function readNdjsonFile<T>(filePath: string): Promise<NdjsonRead<T>> {
1242
- let raw: string;
1243
- try {
1244
- raw = await fs.readFile(filePath, "utf8");
1245
- } catch {
1246
- return { records: [], exists: false, tornTail: false, malformed: false };
1247
- }
1248
- const tornTail = raw.length > 0 && !raw.endsWith("\n");
1249
- const lines = raw.split("\n");
1250
- if (tornTail) {
1251
- lines.pop();
1252
- }
1253
- const records: T[] = [];
1254
- let malformed = false;
1255
- for (const line of lines) {
1256
- if (line.trim().length === 0) {
1257
- continue;
1258
- }
1259
- try {
1260
- records.push(JSON.parse(line) as T);
1261
- } catch {
1262
- malformed = true;
1263
- }
1264
- }
1265
- return { records, exists: true, tornTail, malformed };
1499
+ function isSegmentRow(value: unknown): value is SegmentRow {
1500
+ return isRecord(value);
1266
1501
  }
1267
1502
 
1268
- function assessSessionIntegrity(input: {
1269
- binding: WorkflowSessionBinding | null;
1270
- entries: NdjsonRead<WorkflowSessionEntryRecord>;
1271
- events: NdjsonRead<WorkflowSessionEventRecord>;
1272
- capture: WorkflowSessionCapture | null;
1273
- runTerminal: boolean;
1274
- }): SessionCaptureIntegrity {
1275
- const anySessionFile =
1276
- input.binding !== null || input.entries.exists || input.events.exists || input.capture !== null;
1277
- if (!anySessionFile) {
1278
- return { status: "unavailable", diagnostics: [] };
1279
- }
1280
- const diagnostics: string[] = [];
1281
- if (!input.binding || input.binding.schema !== SESSION_BINDING_SCHEMA) {
1282
- diagnostics.push("missing or invalid session binding");
1283
- }
1284
- if (!input.capture) {
1285
- diagnostics.push("missing session capture status");
1286
- return { status: "invalid", diagnostics };
1287
- }
1288
- try {
1289
- validateSessionCapture(input.capture);
1290
- } catch (error) {
1291
- diagnostics.push(failureMessageForDiagnostic(error));
1292
- return { status: "invalid", diagnostics };
1293
- }
1294
- if (input.entries.malformed || input.events.malformed) {
1295
- diagnostics.push("malformed NDJSON line before the journal tail");
1296
- }
1297
- if (input.events.tornTail && input.capture.status !== "recording") {
1298
- diagnostics.push("terminal session event journal has a torn tail");
1299
- }
1300
- if (input.runTerminal && input.capture.status === "recording") {
1301
- diagnostics.push("terminal run still reports recording capture");
1302
- }
1303
- let expected = 1;
1304
- for (const event of input.events.records) {
1305
- try {
1306
- validateSessionEventRecord(event);
1307
- } catch (error) {
1308
- diagnostics.push(failureMessageForDiagnostic(error));
1309
- break;
1310
- }
1311
- if (event.seq !== expected) {
1312
- diagnostics.push(`session event sequence gap at ${expected}`);
1313
- break;
1314
- }
1315
- expected += 1;
1316
- }
1317
- diagnostics.push(...sessionRelationshipDiagnostics(input.entries.records, input.events.records));
1318
- if (input.capture.status !== "recording") {
1319
- const lastEventSeq = input.events.records.at(-1)?.seq ?? 0;
1320
- if (
1321
- input.capture.eventCount !== input.events.records.length ||
1322
- input.capture.entryCount !== input.entries.records.length ||
1323
- input.capture.lastEventSeq !== lastEventSeq
1324
- ) {
1325
- diagnostics.push("session capture counts do not match durable files");
1326
- }
1327
- }
1328
- if (diagnostics.length > 0) {
1329
- return { status: "invalid", diagnostics };
1330
- }
1331
- if (input.capture.status === "failed") {
1332
- return {
1333
- status: "failed",
1334
- diagnostics: [input.capture.failure?.message ?? "session capture failed"],
1335
- };
1336
- }
1337
- return { status: input.capture.status, diagnostics: [] };
1503
+ function isSessionEntryRow(value: unknown): value is SessionEntryRow {
1504
+ return isRecord(value);
1338
1505
  }
1339
1506
 
1340
- function failureMessageForDiagnostic(error: unknown): string {
1341
- return error instanceof Error ? error.message : String(error);
1507
+ function isSessionEventRow(value: unknown): value is SessionEventRow {
1508
+ return isRecord(value);
1342
1509
  }
1343
1510
 
1344
- function createManifest(
1345
- state: WorkflowRunState,
1346
- present: { session: boolean },
1347
- ): WorkflowRunManifest {
1348
- return {
1349
- schema: RUN_BUNDLE_SCHEMA,
1350
- runId: state.runId,
1351
- workflowName: state.workflowName,
1352
- ...(state.runTitle !== undefined ? { runTitle: state.runTitle } : {}),
1353
- ...(state.workflowSource !== undefined ? { workflowSource: state.workflowSource } : {}),
1354
- ...(state.workflowSources !== undefined ? { workflowSources: state.workflowSources } : {}),
1355
- ...(state.definitionDigest !== undefined ? { definitionDigest: state.definitionDigest } : {}),
1356
- startedAt: state.startedAt,
1357
- ...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
1358
- status: state.status,
1359
- traceSchema: TRACE_EVENT_SCHEMA,
1360
- paths: {
1361
- workflow: WORKFLOW_SNAPSHOT_PATH,
1362
- state: STATE_PATH,
1363
- trace: TRACE_PATH,
1364
- ...(present.session ? { session: SESSION_DIR } : {}),
1365
- // Declare this before any payload can externalize a string. Live
1366
- // session-event patches may reference a newly written artifact before
1367
- // the next workflow state projection refreshes the manifest.
1368
- artifacts: "artifacts",
1369
- },
1370
- };
1511
+ function isLeaseAuthorityRow(value: unknown): value is {
1512
+ generation: number;
1513
+ ownerType: OwnerType | null;
1514
+ ownerId: string | null;
1515
+ tokenHash: Buffer | null;
1516
+ expiresAt: number | null;
1517
+ } {
1518
+ return isRecord(value);
1371
1519
  }
1372
1520
 
1373
1521
  export function createDefinitionSnapshot(workflow: WorkflowDefinition): WorkflowDefinitionSnapshot {
@@ -1425,32 +1573,21 @@ function snapshotNode(
1425
1573
  if (node.nodeType === "agent" && node.expectedOutput !== undefined) {
1426
1574
  common.expectedOutput = node.expectedOutput;
1427
1575
  }
1428
- if (node.nodeType === "notify") {
1429
- common.summary = node.kind ?? "progress";
1430
- }
1431
- if (node.nodeType === "checkpoint" && node.summary !== undefined) {
1432
- common.summary = node.summary;
1433
- }
1576
+ if (node.nodeType === "notify") common.summary = node.kind ?? "progress";
1577
+ if (node.nodeType === "checkpoint" && node.summary !== undefined) common.summary = node.summary;
1434
1578
  if (node.nodeType === "checkpoint" && node.humanDecision !== undefined) {
1435
1579
  common.humanDecision = {
1436
1580
  audience:
1437
1581
  typeof node.humanDecision.audience === "string" ? node.humanDecision.audience : "<dynamic>",
1438
1582
  ...(typeof node.humanDecision.audience === "function" ? { dynamicAudience: true } : {}),
1439
1583
  choices: structuredClone(node.humanDecision.choices),
1584
+ ...(node.humanDecision.onTimeout !== undefined &&
1585
+ typeof node.humanDecision.onTimeout !== "function"
1586
+ ? { onTimeout: structuredClone(node.humanDecision.onTimeout) }
1587
+ : {}),
1588
+ ...(typeof node.humanDecision.onTimeout === "function" ? { dynamicTimeout: true } : {}),
1440
1589
  };
1441
1590
  }
1442
- if (node.nodeType === "action") {
1443
- common.actionExecution = "exec" in node ? "shell" : "function";
1444
- }
1591
+ if (node.nodeType === "action") common.actionExecution = "exec" in node ? "shell" : "function";
1445
1592
  return common;
1446
1593
  }
1447
-
1448
- async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
1449
- const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
1450
- await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
1451
- await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
1452
- encoding: "utf8",
1453
- mode: 0o600,
1454
- });
1455
- await fs.rename(tempPath, filePath);
1456
- }