@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,323 +1,189 @@
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 } from "../state/mutation.js";
6
5
  import { compositionMetadata } from "./composition.js";
7
6
  import { MAX_CURRENT_UPDATES, createUpdateId, updateProjection } from "./updates.js";
8
- export const RUN_BUNDLE_SCHEMA = "pi-workflows.run-bundle.v1";
9
7
  export const RUN_STATE_SCHEMA = "pi-workflows.run-state.v1";
10
- export const TRACE_EVENT_SCHEMA = "pi-workflows.trace-event.v1";
11
8
  export const DEFINITION_SNAPSHOT_SCHEMA = "pi-workflows.definition-snapshot.v1";
12
9
  export const SESSION_BINDING_SCHEMA = "pi-workflows.session-binding.v1";
13
10
  export const SESSION_EVENT_SCHEMA = "pi-workflows.session-event.v1";
14
11
  export const SESSION_CAPTURE_SCHEMA = "pi-workflows.session-capture.v1";
15
12
  export const SESSION_EVENT_MAX_BYTES = 1024 * 1024;
16
- const MANIFEST_PATH = "manifest.json";
17
- const WORKFLOW_SNAPSHOT_PATH = "workflow.json";
18
- const STATE_PATH = "state.json";
19
- const TRACE_PATH = "trace.ndjson";
20
- const SESSION_DIR = "session";
21
- const SESSION_BINDING_PATH = `${SESSION_DIR}/binding.json`;
22
- const SESSION_ENTRIES_PATH = `${SESSION_DIR}/entries.ndjson`;
23
- const SESSION_EVENTS_PATH = `${SESSION_DIR}/events.ndjson`;
24
- const SESSION_CAPTURE_PATH = `${SESSION_DIR}/capture.json`;
25
- const SESSION_SEGMENTS_DIR = `${SESSION_DIR}/segments`;
26
- /** Capture file layout for one recorder attempt; "" is the legacy flat stream. */
27
- function sessionStreamPaths(attemptId) {
28
- if (attemptId === undefined) {
29
- return {
30
- dir: SESSION_DIR,
31
- binding: SESSION_BINDING_PATH,
32
- entries: SESSION_ENTRIES_PATH,
33
- events: SESSION_EVENTS_PATH,
34
- capture: SESSION_CAPTURE_PATH,
35
- };
36
- }
37
- assertValidRunId(attemptId);
38
- const dir = `${SESSION_SEGMENTS_DIR}/${attemptId}`;
39
- return {
40
- dir,
41
- binding: `${dir}/binding.json`,
42
- entries: `${dir}/entries.ndjson`,
43
- events: `${dir}/events.ndjson`,
44
- capture: `${dir}/capture.json`,
45
- };
46
- }
47
- /** Runs directory: `$PI_WORKFLOWS_RUNS_DIR` or `~/.pi/agent/workflows/runs`. */
48
- export function workflowRunsBaseDir(homeDir = os.homedir()) {
49
- const override = process.env.PI_WORKFLOWS_RUNS_DIR;
50
- if (override !== undefined && override.length > 0) {
51
- return override;
52
- }
53
- return path.join(homeDir, ".pi", "agent", "workflows", "runs");
13
+ export function workflowStateDatabasePath(homeDir) {
14
+ return workflowStatePath(homeDir);
54
15
  }
55
16
  export function createRunId(workflowName, now = new Date()) {
56
- const stamp = now
57
- .toISOString()
58
- .replaceAll(/[-:]/g, "")
59
- .replace(/\.\d+Z$/, "Z");
60
- const slug = workflowName
61
- .toLowerCase()
62
- .replaceAll(/[^a-z0-9]+/g, "-")
63
- .replaceAll(/(^-|-$)/g, "")
64
- .slice(0, 40);
65
- return `${stamp}-${slug || "workflow"}-${randomUUID().slice(0, 8)}`;
17
+ const safeName = workflowName.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80) || "workflow";
18
+ return `${now.toISOString().replace(/[-:.]/g, "").replace("T", "T").replace("Z", "Z")}-${safeName}-${randomUUID().slice(0, 8)}`;
66
19
  }
67
20
  export class WorkflowRunStore {
68
- outputRoot;
69
- fenceProvider;
21
+ databasePath;
22
+ state;
23
+ authorityProvider;
70
24
  contexts = new Map();
71
- constructor(outputRoot = workflowRunsBaseDir(), options = {}) {
72
- this.outputRoot = outputRoot;
73
- this.fenceProvider = options.fenceProvider;
74
- }
75
- runDirFor(runId) {
76
- assertValidRunId(runId);
77
- return path.join(this.outputRoot, runId);
78
- }
79
- async quarantineIncompleteRun(runId) {
80
- const runDir = this.runDirFor(runId);
81
- let runStat;
82
- try {
83
- runStat = await fs.lstat(runDir);
84
- }
85
- catch (error) {
86
- if (isMissingPath(error)) {
87
- return undefined;
88
- }
89
- throw error;
90
- }
91
- if (!runStat.isDirectory() || runStat.isSymbolicLink()) {
92
- throw new Error(`Reserved workflow run path is not a directory: ${runDir}`);
93
- }
94
- try {
95
- await fs.lstat(path.join(runDir, MANIFEST_PATH));
96
- throw new Error(`Reserved workflow run has an unreadable manifest: ${runId}`);
97
- }
98
- catch (error) {
99
- if (!isMissingPath(error)) {
100
- throw error;
101
- }
102
- }
103
- const quarantineDir = path.join(this.outputRoot, `.${runId}.incomplete-${randomUUID().slice(0, 8)}`);
104
- await fs.rename(runDir, quarantineDir);
105
- this.contexts.delete(runDir);
106
- return quarantineDir;
107
- }
108
- contextFor(runDir) {
109
- let context = this.contexts.get(runDir);
110
- if (!context) {
111
- context = {
112
- traceSeq: 0,
113
- artifacts: new ArtifactWriter(runDir),
114
- lock: Promise.resolve(),
115
- streams: new Map(),
116
- };
117
- this.contexts.set(runDir, context);
118
- }
119
- return context;
120
- }
121
- streamFor(runDir, attemptId) {
122
- const context = this.contextFor(runDir);
123
- const key = attemptId ?? "";
124
- let stream = context.streams.get(key);
125
- if (!stream) {
126
- stream = {
127
- sessionSeq: 0,
128
- sessionEventSeq: 0,
129
- sessionBound: false,
130
- sessionEventsStopped: false,
25
+ ownsState;
26
+ constructor(databasePath = workflowStatePath(), options = {}) {
27
+ this.authorityProvider = options.authorityProvider;
28
+ this.ownsState = options.state === undefined;
29
+ this.state =
30
+ options.state ??
31
+ new StateDatabase({
32
+ filePath: databasePath,
33
+ mode: options.readOnly === true ? "read-only" : "read-write",
34
+ checkLegacyState: databasePath === workflowStatePath(),
35
+ });
36
+ this.databasePath = this.state.filePath;
37
+ }
38
+ close() {
39
+ if (this.ownsState)
40
+ this.state.close();
41
+ }
42
+ async initializeRun(workflow, state) {
43
+ assertValidRunId(state.runId);
44
+ if (!this.contexts.has(state.runId)) {
45
+ const reserved = this.readRunRow(state.runId);
46
+ this.contexts.set(state.runId, {
47
+ revision: reserved === undefined ? 0 : this.requireResourceRevision(reserved.resourceId),
131
48
  lock: Promise.resolve(),
132
- };
133
- context.streams.set(key, stream);
49
+ });
134
50
  }
135
- return stream;
136
- }
137
- /**
138
- * Run `task` exclusively for this bundle. Sequence numbers are assigned
139
- * inside the lock, so physical file order always matches logical order.
140
- */
141
- withRunLock(runDir, task) {
142
- const context = this.contextFor(runDir);
143
- const result = context.lock.then(async () => {
144
- this.fenceProvider?.(runDir)?.();
145
- return await task();
146
- });
147
- context.lock = result.then(() => undefined, () => undefined);
148
- return result;
149
- }
150
- withSessionEventLock(runDir, attemptId, task) {
151
- const stream = this.streamFor(runDir, attemptId);
152
- const result = stream.lock.then(async () => {
153
- this.fenceProvider?.(runDir)?.();
154
- return await task();
155
- });
156
- stream.lock = result.then(() => undefined, () => undefined);
157
- return result;
158
- }
159
- async initializeRunBundle(workflow, state) {
160
- const runDir = this.runDirFor(state.runId);
161
- return await this.withRunLock(runDir, async () => {
162
- await fs.mkdir(this.outputRoot, { recursive: true, mode: 0o700 });
163
- await fs.mkdir(runDir, { recursive: false, mode: 0o700 });
164
- await writeJsonAtomic(path.join(runDir, WORKFLOW_SNAPSHOT_PATH), createDefinitionSnapshot(workflow));
165
- await appendLine(path.join(runDir, TRACE_PATH), null);
166
- await this.writeProjections(runDir, state);
167
- return runDir;
51
+ return await this.withRunLock(state.runId, async () => {
52
+ let acceptedRevision = 1;
53
+ const now = Date.now();
54
+ const at = new Date(now).toISOString();
55
+ const snapshot = createDefinitionSnapshot(workflow);
56
+ const definitionJson = canonicalJson(snapshot);
57
+ const definitionDigest = createHash("sha256").update(definitionJson).digest();
58
+ const source = sourceForState(state, definitionDigest);
59
+ const resourceId = resourceIdFor("run", state.runId);
60
+ this.state.transaction(() => {
61
+ const reserved = this.readRunRow(state.runId);
62
+ if (reserved !== undefined) {
63
+ if (reserved.status !== "queued") {
64
+ throw new Error(`Workflow run already exists: ${state.runId}`);
65
+ }
66
+ const context = this.contextFor(state.runId);
67
+ this.assertWriteAuthority(reserved, context.revision);
68
+ const storedSnapshot = this.readDefinition(reserved.definitionHash);
69
+ if (canonicalJson(storedSnapshot) !== definitionJson) {
70
+ throw new Error(`Reserved workflow definition conflicts: ${state.runId}`);
71
+ }
72
+ const revision = context.revision + 1;
73
+ acceptedRevision = revision;
74
+ state.traceSeq = revision;
75
+ state.updatedAt = at;
76
+ insertRunEvent(this.state, reserved.resourceId, revision, at, {
77
+ scope: "run",
78
+ type: "run_initialized",
79
+ payload: { workflowName: workflow.name },
80
+ });
81
+ this.persistRunState(reserved, state, revision, now);
82
+ this.syncNodeAttempts(state, snapshot, now);
83
+ context.revision = revision;
84
+ return;
85
+ }
86
+ const definitionHash = this.state.putJson(snapshot, now);
87
+ this.state.connection
88
+ .prepare(`INSERT INTO workflow_definitions(
89
+ definition_digest, workflow_name, definition_hash, created_at
90
+ ) VALUES (?, ?, ?, ?)
91
+ ON CONFLICT(definition_digest) DO NOTHING`)
92
+ .run(definitionDigest, workflow.name, definitionHash, now);
93
+ this.state.connection
94
+ .prepare(`INSERT INTO resources(
95
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
96
+ ) VALUES (?, 'run', ?, 1, ?, ?)`)
97
+ .run(resourceId, state.runId, now, now);
98
+ this.state.connection
99
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
100
+ .run(resourceId);
101
+ state.traceSeq = 1;
102
+ state.updatedAt = at;
103
+ const inputHash = this.state.putJson(state.input, now);
104
+ const workflowSourceHash = this.state.putJson(state.workflowSource ?? source, now);
105
+ const launchOptionsHash = this.state.putJson({}, now);
106
+ const stateHash = this.state.putJson(state, now);
107
+ const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
108
+ this.state.connection
109
+ .prepare(`INSERT INTO runs(
110
+ run_id, resource_id, project_id, parent_run_id, definition_digest,
111
+ workflow_ref, workflow_source_hash, launch_options_hash,
112
+ source_type, source_ref, source_revision, title, status, paused,
113
+ status_detail, input_hash, output_hash, error_hash,
114
+ created_at, updated_at, finished_at
115
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
116
+ .run(state.runId, resourceId, state.parentRunId ?? null, definitionDigest, state.workflowName, workflowSourceHash, launchOptionsHash, source.type, source.ref, source.revision, state.runTitle ?? null, state.status, state.paused === true ? 1 : 0, state.statusDetail ?? null, inputHash, stateHash, errorHash, Date.parse(state.startedAt), now, state.finishedAt === undefined ? null : Date.parse(state.finishedAt));
117
+ insertRunEvent(this.state, resourceId, 1, at, {
118
+ scope: "run",
119
+ type: "run_created",
120
+ payload: { workflowName: workflow.name },
121
+ });
122
+ this.syncNodeAttempts(state, snapshot, now);
123
+ });
124
+ this.contexts.set(state.runId, { revision: acceptedRevision, lock: Promise.resolve() });
125
+ return state.runId;
168
126
  });
169
127
  }
170
- /**
171
- * Prepare an interrupted bundle for resume. Repairs a torn trace tail,
172
- * drops trace events the state projection never recorded, and seeds the
173
- * in-process context so new events continue the sequence. The caller must
174
- * hold the run's queue claim; the fence is verified before any write.
175
- */
176
128
  async prepareRunResume(runId) {
177
- const runDir = this.runDirFor(runId);
178
- this.fenceProvider?.(runDir)?.();
179
- const bundle = await readRunBundle(runDir);
180
- if (bundle === null) {
129
+ const loaded = this.readRun(runId, { includeTrace: true });
130
+ if (loaded === null)
181
131
  throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
132
+ if (loaded.state.status !== "running") {
133
+ throw new Error(`Cannot resume workflow run ${runId} with status ${loaded.state.status}`);
182
134
  }
183
- if (bundle.state.status !== "running") {
184
- throw new Error(`Cannot resume workflow run ${runId} with status ${bundle.state.status}`);
185
- }
186
- const tracePath = resolveBundlePath(runDir, bundle.manifest.paths.trace, TRACE_PATH);
187
- await repairTraceFile(tracePath, bundle.state.traceSeq, () => {
188
- // The repair rewrites the trace; re-verify ownership immediately
189
- // before the rename so a stalled runner cannot truncate a new claim
190
- // holder's appended events.
191
- this.fenceProvider?.(runDir)?.();
192
- });
193
- // A crashed session never finalizes its capture, and resuming recorders
194
- // always write new segments — so dangling "recording" captures end here
195
- // instead of reporting the run capture-corrupt forever.
196
- await this.finalizeRecordingCaptures(runDir, "Workflow host stopped before the run finished");
197
- const counts = await this.sessionCounts(runDir);
198
- const captureFinished = bundle.sessionCapture?.status === "complete" || bundle.sessionCapture?.status === "failed";
199
- this.contexts.set(runDir, {
200
- traceSeq: bundle.state.traceSeq,
201
- artifacts: new ArtifactWriter(runDir),
202
- lock: Promise.resolve(),
203
- streams: seededStreams({
204
- sessionSeq: counts.entryCount,
205
- sessionEventSeq: counts.lastEventSeq,
206
- sessionBound: bundle.manifest.paths.session !== undefined,
207
- sessionEventsStopped: captureFinished,
208
- }),
135
+ const revision = this.resourceRevision(runId);
136
+ this.contexts.set(runId, { revision, lock: Promise.resolve() });
137
+ this.finalizeRecordingCaptures(runId, "Workflow host stopped before the run finished");
138
+ this.state.transaction(() => {
139
+ const row = this.requireRunRow(runId);
140
+ const context = this.contextFor(runId);
141
+ this.assertWriteAuthority(row, context.revision);
142
+ const nextRevision = context.revision + 1;
143
+ const now = Date.now();
144
+ const at = new Date(now).toISOString();
145
+ this.state.connection
146
+ .prepare(`UPDATE node_attempts
147
+ SET status = 'cancelled', finished_at = ?, updated_at = ?
148
+ WHERE run_id = ? AND status IN ('pending', 'running', 'waiting')`)
149
+ .run(now, now, runId);
150
+ insertRunEvent(this.state, row.resourceId, nextRevision, at, {
151
+ scope: "run",
152
+ type: "run_resume_prepared",
153
+ payload: {},
154
+ });
155
+ loaded.state.traceSeq = nextRevision;
156
+ loaded.state.updatedAt = at;
157
+ this.persistRunState(row, loaded.state, nextRevision, now);
158
+ context.revision = nextRevision;
209
159
  });
210
- const prepared = await readRunBundle(runDir);
211
- if (prepared === null) {
212
- throw new Error(`Workflow run ${runId} became unreadable during resume preparation`);
213
- }
160
+ const prepared = this.readRun(runId, { includeTrace: true });
161
+ if (prepared === null)
162
+ throw new Error(`Workflow run became unreadable: ${runId}`);
214
163
  return prepared;
215
164
  }
216
- /**
217
- * Finalize captures left "recording" by a session that is gone, so they
218
- * report failed with the reason instead of dangling forever.
219
- */
220
- async finalizeRecordingCaptures(runDir, reason, options = {}) {
221
- if (options.skipFlat !== true) {
222
- const flatCapture = await readJsonFile(path.join(runDir, SESSION_CAPTURE_PATH));
223
- if (flatCapture?.status === "recording") {
224
- const counts = await this.sessionCounts(runDir);
225
- await this.writeSessionCapture(runDir, {
226
- schema: SESSION_CAPTURE_SCHEMA,
227
- eventSchema: SESSION_EVENT_SCHEMA,
228
- status: "failed",
229
- ...counts,
230
- failure: {
231
- failedAt: new Date().toISOString(),
232
- code: "host_interrupted",
233
- message: reason,
234
- },
235
- });
236
- }
237
- }
238
- for (const segmentId of await this.listSessionSegments(runDir)) {
239
- const segmentCapture = await readJsonFile(path.join(runDir, sessionStreamPaths(segmentId).capture));
240
- if (segmentCapture?.status !== "recording") {
241
- continue;
242
- }
243
- const segmentCounts = await this.sessionCounts(runDir, segmentId);
244
- await this.writeSessionCapture(runDir, {
245
- schema: SESSION_CAPTURE_SCHEMA,
246
- eventSchema: SESSION_EVENT_SCHEMA,
247
- status: "failed",
248
- ...segmentCounts,
249
- failure: {
250
- failedAt: new Date().toISOString(),
251
- code: "host_interrupted",
252
- message: reason,
253
- },
254
- }, segmentId);
255
- }
256
- }
257
- /** Mark a nonterminal bundle failed and append an interruption event. */
258
165
  async markRunInterrupted(runId, reason = "Workflow host stopped before the run finished") {
259
- const runDir = this.runDirFor(runId);
260
- const bundle = await readRunBundle(runDir);
261
- if (bundle === null || bundle.state.status !== "running") {
262
- return bundle;
263
- }
264
- const lastTraceEvent = await readLastTraceEvent(runDir, bundle.manifest.paths.trace);
265
- const counts = await this.sessionCounts(runDir);
266
- const sessionBound = bundle.manifest.paths.session !== undefined;
267
- const captureFinished = bundle.sessionCapture?.status === "complete" || bundle.sessionCapture?.status === "failed";
268
- this.contexts.set(runDir, {
269
- traceSeq: Math.max(bundle.state.traceSeq, lastTraceEvent?.seq ?? 0),
270
- artifacts: new ArtifactWriter(runDir),
271
- lock: Promise.resolve(),
272
- streams: seededStreams({
273
- sessionSeq: counts.entryCount,
274
- sessionEventSeq: counts.lastEventSeq,
275
- sessionBound,
276
- sessionEventsStopped: captureFinished,
277
- }),
166
+ const loaded = this.readRun(runId);
167
+ if (loaded === null || loaded.state.status !== "running")
168
+ return loaded;
169
+ this.finalizeRecordingCaptures(runId, reason);
170
+ loaded.state.status = "failed";
171
+ loaded.state.finishedAt = new Date().toISOString();
172
+ loaded.state.error = reason;
173
+ delete loaded.state.currentNode;
174
+ delete loaded.state.currentAttemptId;
175
+ delete loaded.state.currentNodeStartedAt;
176
+ delete loaded.state.statusDetail;
177
+ delete loaded.state.paused;
178
+ await this.writeSnapshot(runId, loaded.state, {
179
+ scope: "run",
180
+ type: "run_interrupted",
181
+ payload: { error: reason },
278
182
  });
279
- const state = bundle.state;
280
- if (lastTraceEvent !== null && recoverTerminalProjection(state, lastTraceEvent)) {
281
- await this.writeLoadedProjections(runDir, state);
282
- return await readRunBundle(runDir);
283
- }
284
- if (sessionBound && !captureFinished) {
285
- await this.writeSessionCapture(runDir, {
286
- schema: SESSION_CAPTURE_SCHEMA,
287
- eventSchema: SESSION_EVENT_SCHEMA,
288
- status: "failed",
289
- ...counts,
290
- failure: {
291
- failedAt: new Date().toISOString(),
292
- code: "host_interrupted",
293
- message: reason,
294
- },
295
- });
296
- }
297
- await this.finalizeRecordingCaptures(runDir, reason, { skipFlat: true });
298
- state.status = "failed";
299
- state.finishedAt = new Date().toISOString();
300
- state.error = reason;
301
- delete state.currentNode;
302
- delete state.currentAttemptId;
303
- delete state.currentNodeStartedAt;
304
- delete state.statusDetail;
305
- delete state.paused;
306
- await this.withRunLock(runDir, async () => {
307
- const traceEvent = await this.appendTraceEvent(runDir, state.runId, {
308
- scope: "run",
309
- type: "run_interrupted",
310
- payload: { error: reason },
311
- });
312
- state.traceSeq = traceEvent.seq;
313
- state.updatedAt = traceEvent.at;
314
- await this.writeLoadedProjections(runDir, state);
315
- });
316
- return await readRunBundle(runDir);
183
+ return this.readRun(runId, { includeTrace: true });
317
184
  }
318
- /** Publish one durable update under the run's serialized claim-fenced writer. */
319
- async publishUpdate(runDir, state, nodeId, attemptId, update, options = {}) {
320
- return await this.withRunLock(runDir, async () => {
185
+ async publishUpdate(runId, state, nodeId, attemptId, update, options = {}) {
186
+ return await this.withRunLock(runId, async () => {
321
187
  if (options.signal?.aborted === true) {
322
188
  throw options.signal.reason ?? new Error("workflow update attempt is no longer active");
323
189
  }
@@ -325,416 +191,783 @@ export class WorkflowRunStore {
325
191
  if (!exists && (state.updates?.length ?? 0) >= MAX_CURRENT_UPDATES) {
326
192
  throw new Error(`workflow run supports at most ${MAX_CURRENT_UPDATES} current updates`);
327
193
  }
194
+ const data = structuredClone(update.data);
328
195
  const updateId = createUpdateId();
329
- const data = JSON.parse(JSON.stringify(update.data));
330
- const event = await this.appendTraceEvent(runDir, state.runId, {
331
- scope: "node",
332
- type: "update_published",
333
- nodeId,
334
- attemptId,
335
- payload: { updateId, type: update.type, key: update.key, data },
196
+ let acceptedEvent;
197
+ let acceptedRecord;
198
+ this.state.transaction(() => {
199
+ const context = this.contextFor(runId);
200
+ const run = this.requireRunRow(runId);
201
+ this.assertWriteAuthority(run, context.revision);
202
+ const revision = context.revision + 1;
203
+ const at = new Date().toISOString();
204
+ const event = {
205
+ seq: revision,
206
+ at,
207
+ runId,
208
+ scope: "node",
209
+ type: "update_published",
210
+ nodeId,
211
+ attemptId,
212
+ payload: { updateId, type: update.type, key: update.key, data },
213
+ };
214
+ const record = {
215
+ updateId,
216
+ seq: revision,
217
+ at,
218
+ runId,
219
+ nodeId,
220
+ attemptId,
221
+ type: update.type,
222
+ key: update.key,
223
+ data,
224
+ };
225
+ state.updates = updateProjection(state.updates, record);
226
+ state.traceSeq = revision;
227
+ state.updatedAt = at;
228
+ this.ensureAttempt(state, nodeId, attemptId, Date.now());
229
+ const dataHash = this.state.putJson(data);
230
+ const nextUpdateSeq = this.nextUpdateSequence(attemptId);
231
+ this.state.connection
232
+ .prepare(`INSERT INTO workflow_updates(
233
+ update_id, attempt_id, update_seq, update_type, update_key, data_hash, recorded_at
234
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`)
235
+ .run(updateId, attemptId, nextUpdateSeq, update.type, update.key, dataHash, Date.now());
236
+ insertRunEvent(this.state, run.resourceId, revision, at, event);
237
+ this.persistRunState(run, state, revision, Date.now());
238
+ context.revision = revision;
239
+ acceptedEvent = event;
240
+ acceptedRecord = record;
336
241
  });
337
- const record = {
338
- updateId,
339
- seq: event.seq,
340
- at: event.at,
341
- runId: state.runId,
342
- nodeId,
343
- attemptId,
344
- type: update.type,
345
- key: update.key,
346
- data,
347
- };
348
- state.updates = updateProjection(state.updates, record);
349
- state.traceSeq = event.seq;
350
- state.updatedAt = event.at;
351
- await this.writeProjections(runDir, state);
352
- return { event, record };
242
+ if (acceptedEvent === undefined || acceptedRecord === undefined) {
243
+ throw new Error("Workflow update transaction did not produce a result");
244
+ }
245
+ return { event: acceptedEvent, record: acceptedRecord };
353
246
  });
354
247
  }
355
- /**
356
- * Persist one transition: append the trace event, then rewrite the
357
- * projections reflecting it.
358
- */
359
- async writeSnapshot(runDir, state, event) {
360
- return await this.withRunLock(runDir, async () => {
361
- const traceEvent = await this.appendTraceEvent(runDir, state.runId, event);
362
- state.traceSeq = traceEvent.seq;
363
- state.updatedAt = new Date().toISOString();
364
- await this.writeProjections(runDir, state);
365
- return traceEvent;
248
+ async writeSnapshot(runId, state, event) {
249
+ return await this.withRunLock(runId, async () => {
250
+ let accepted;
251
+ this.state.transaction(() => {
252
+ const context = this.contextFor(runId);
253
+ const run = this.requireRunRow(runId);
254
+ this.assertWriteAuthority(run, context.revision);
255
+ const revision = context.revision + 1;
256
+ const now = Date.now();
257
+ const at = new Date(now).toISOString();
258
+ const traceEvent = { seq: revision, at, runId, ...event };
259
+ state.traceSeq = revision;
260
+ state.updatedAt = at;
261
+ insertRunEvent(this.state, run.resourceId, revision, at, traceEvent);
262
+ this.persistRunState(run, state, revision, now);
263
+ const snapshot = this.readDefinition(run.definitionHash);
264
+ this.syncNodeAttempts(state, snapshot, now);
265
+ context.revision = revision;
266
+ accepted = traceEvent;
267
+ });
268
+ if (accepted === undefined)
269
+ throw new Error("Workflow snapshot transaction did not commit");
270
+ return accepted;
366
271
  });
367
272
  }
368
- /**
369
- * Bind the run to a Pi conversation: write `session/binding.json` once and
370
- * append a `session_bound` trace event. Projections catch up on the next
371
- * snapshot.
372
- */
373
- /** True when a session binding already exists for this bundle. */
374
- async hasSessionBinding(runDir) {
375
- try {
376
- await fs.lstat(path.join(runDir, SESSION_BINDING_PATH));
377
- return true;
378
- }
379
- catch {
380
- return false;
381
- }
273
+ async hasSessionBinding(runId) {
274
+ return (this.state.connection
275
+ .prepare("SELECT 1 AS present FROM session_segments WHERE run_id = ? LIMIT 1")
276
+ .get(runId) !== undefined);
382
277
  }
383
- /** List capture segment attempt ids under `session/segments/`. */
384
- async listSessionSegments(runDir) {
385
- try {
386
- const entries = await fs.readdir(path.join(runDir, SESSION_SEGMENTS_DIR), {
387
- withFileTypes: true,
388
- });
389
- return entries
390
- .filter((entry) => entry.isDirectory())
391
- .map((entry) => entry.name)
392
- .sort();
393
- }
394
- catch {
395
- return [];
396
- }
278
+ async listSessionSegments(runId) {
279
+ return this.segmentRows(runId)
280
+ .flatMap((row) => (row.captureKey === null ? [] : [row.captureKey]))
281
+ .sort();
397
282
  }
398
- async writeSessionBinding(runDir, binding, attemptId) {
399
- await this.withRunLock(runDir, async () => {
400
- const stream = this.streamFor(runDir, attemptId);
401
- if (stream.sessionBound) {
283
+ async writeSessionBinding(runId, binding, attemptId) {
284
+ assertValidRunId(runId);
285
+ if (binding.runId !== runId || binding.schema !== SESSION_BINDING_SCHEMA) {
286
+ throw new Error("Session binding does not match the workflow run");
287
+ }
288
+ await this.withRunLock(runId, async () => {
289
+ const segmentId = segmentIdFor(runId, attemptId);
290
+ if (this.segmentRow(segmentId) !== undefined)
402
291
  return;
403
- }
404
- stream.sessionBound = true;
405
- const paths = sessionStreamPaths(attemptId);
406
- await fs.mkdir(path.join(runDir, paths.dir), { recursive: true, mode: 0o700 });
407
- await writeJsonAtomic(path.join(runDir, paths.binding), binding);
408
- await this.appendTraceEvent(runDir, binding.runId, {
409
- scope: "session",
410
- type: "session_bound",
411
- payload: {
412
- piSessionId: binding.piSessionId,
413
- ...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
414
- },
292
+ this.state.transaction(() => {
293
+ const run = this.requireRunRow(runId);
294
+ const context = this.contextFor(runId);
295
+ this.assertWriteAuthority(run, context.revision);
296
+ const now = Date.parse(binding.boundAt);
297
+ const resourceId = resourceIdFor("session", segmentId);
298
+ const bindingHash = this.state.putJson(binding, now);
299
+ this.state.connection
300
+ .prepare(`INSERT INTO resources(
301
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
302
+ ) VALUES (?, 'session', ?, 1, ?, ?)`)
303
+ .run(resourceId, segmentId, now, now);
304
+ this.state.connection
305
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
306
+ .run(resourceId);
307
+ this.state.connection
308
+ .prepare(`INSERT INTO session_segments(
309
+ segment_id, run_id, attempt_id, capture_key, session_id, resource_id, binding_hash,
310
+ status, entry_count, event_count, created_at
311
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, 'recording', 0, 0, ?)`)
312
+ .run(segmentId, runId, attemptId ?? null, binding.piSessionId, resourceId, bindingHash, now);
313
+ insertGenericEvent(this.state, resourceId, 1, "session.created", "session", binding.piSessionId, bindingHash, now);
314
+ this.state.connection
315
+ .prepare(`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
316
+ VALUES (?, ?, 'interactive', ?)
317
+ ON CONFLICT(run_id) DO NOTHING`)
318
+ .run(runId, binding.piSessionId, now);
319
+ const revision = context.revision + 1;
320
+ const at = new Date(now).toISOString();
321
+ insertRunEvent(this.state, run.resourceId, revision, at, {
322
+ seq: revision,
323
+ at,
324
+ runId,
325
+ scope: "session",
326
+ type: "session_bound",
327
+ payload: {
328
+ piSessionId: binding.piSessionId,
329
+ ...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
330
+ },
331
+ });
332
+ const current = this.readState(run.stateHash);
333
+ current.traceSeq = revision;
334
+ current.updatedAt = at;
335
+ this.persistRunState(run, current, revision, now);
336
+ context.revision = revision;
415
337
  });
416
338
  });
417
339
  }
418
- /** Append one verbatim Pi session entry to `session/entries.ndjson`. */
419
- async appendSessionEntry(runDir, entry, attemptId) {
420
- return await this.withRunLock(runDir, async () => {
421
- const stream = this.streamFor(runDir, attemptId);
422
- stream.sessionSeq += 1;
423
- const record = {
424
- seq: stream.sessionSeq,
425
- at: new Date().toISOString(),
426
- entry,
427
- };
428
- await appendLine(path.join(runDir, sessionStreamPaths(attemptId).entries), record);
429
- return record.seq;
340
+ async appendSessionEntry(runId, entry, attemptId) {
341
+ const segment = this.requireSegment(segmentIdFor(runId, attemptId));
342
+ return this.state.transaction(() => {
343
+ this.assertSessionWriteAuthority(runId);
344
+ const revision = this.requireResourceRevision(segmentResourceId(segment));
345
+ const sequence = segment.entryCount + 1;
346
+ const now = Date.now();
347
+ const entryHash = this.state.putJson(entry, now);
348
+ const entryId = typeof entry.id === "string" ? entry.id : `entry-${sequence}`;
349
+ this.state.connection
350
+ .prepare(`INSERT INTO session_entries(segment_id, entry_seq, entry_id, entry_hash, recorded_at)
351
+ VALUES (?, ?, ?, ?, ?)`)
352
+ .run(segment.segmentId, sequence, entryId, entryHash, now);
353
+ this.state.connection
354
+ .prepare("UPDATE session_segments SET entry_count = ? WHERE segment_id = ?")
355
+ .run(sequence, segment.segmentId);
356
+ this.bumpResource(segmentResourceId(segment), revision, now);
357
+ insertGenericEvent(this.state, segmentResourceId(segment), revision + 1, "session.entry_appended", "session", segment.sessionId, entryHash, now);
358
+ return sequence;
430
359
  });
431
360
  }
432
- /** Append a fully stamped ordered batch to `session/events.ndjson`. */
433
- async appendSessionEventBatch(runDir, records, attemptId) {
434
- if (records.length === 0) {
361
+ async appendSessionEventBatch(runId, records, attemptId) {
362
+ if (records.length === 0)
435
363
  return;
436
- }
437
- await this.withSessionEventLock(runDir, attemptId, async () => {
438
- const stream = this.streamFor(runDir, attemptId);
439
- if (stream.sessionEventsStopped) {
364
+ const segment = this.requireSegment(segmentIdFor(runId, attemptId));
365
+ this.state.transaction(() => {
366
+ this.assertSessionWriteAuthority(runId);
367
+ const current = this.requireSegment(segment.segmentId);
368
+ if (current.status !== "recording")
440
369
  throw new Error("Session event capture has stopped");
441
- }
442
- try {
443
- let expected = stream.sessionEventSeq + 1;
444
- for (const record of records) {
445
- validateSessionEventRecord(record);
446
- if (record.seq !== expected) {
447
- throw new Error(`Expected session event seq ${expected}, got ${record.seq}`);
448
- }
449
- expected += 1;
370
+ let expected = current.eventCount + 1;
371
+ for (const record of records) {
372
+ validateSessionEventRecord(record);
373
+ if (record.seq !== expected) {
374
+ throw new Error(`Expected session event seq ${expected}, got ${record.seq}`);
450
375
  }
451
- const encoded = await Promise.all(records.map(async (record) => ({
452
- ...record,
453
- payload: record.type === "tool_execution_started" ||
454
- record.type === "tool_execution_finished" ||
455
- (record.type === "assistant_event" && record.payload.type === "toolcall_end")
456
- ? (await encodeValue(record.payload, this.contextFor(runDir).artifacts))
457
- : record.payload,
458
- })));
459
- for (const record of encoded) {
460
- if (Buffer.byteLength(JSON.stringify(record), "utf8") + 1 > SESSION_EVENT_MAX_BYTES) {
461
- throw new Error(`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`);
462
- }
376
+ if (Buffer.byteLength(canonicalJson(record), "utf8") > SESSION_EVENT_MAX_BYTES) {
377
+ throw new Error(`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`);
463
378
  }
464
- await appendLines(path.join(runDir, sessionStreamPaths(attemptId).events), encoded);
465
- stream.sessionEventSeq = records.at(-1)?.seq ?? stream.sessionEventSeq;
466
- }
467
- catch (error) {
468
- stream.sessionEventsStopped = true;
469
- throw error;
379
+ const payloadHash = this.state.putJson(record.payload, Date.parse(record.at));
380
+ this.state.connection
381
+ .prepare(`INSERT INTO session_events(
382
+ segment_id, event_seq, event_type, node_id, attempt_id,
383
+ turn_id, message_id, tool_call_id, payload_hash, recorded_at
384
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
385
+ .run(current.segmentId, record.seq, record.type, record.nodeId, record.attemptId, record.turnId ?? null, record.messageId ?? null, record.toolCallId ?? null, payloadHash, Date.parse(record.at));
386
+ expected += 1;
470
387
  }
388
+ const now = Date.now();
389
+ this.state.connection
390
+ .prepare("UPDATE session_segments SET event_count = ? WHERE segment_id = ?")
391
+ .run(expected - 1, current.segmentId);
392
+ const resourceId = segmentResourceId(current);
393
+ const revision = this.requireResourceRevision(resourceId);
394
+ this.bumpResource(resourceId, revision, now);
395
+ const payloadHash = this.state.putJson({ count: records.length, lastSeq: expected - 1 }, now);
396
+ insertGenericEvent(this.state, resourceId, revision + 1, "session.events_appended", "session", current.sessionId, payloadHash, now);
471
397
  });
472
398
  }
473
- /** Atomically replace the temporal capture integrity projection. */
474
- async writeSessionCapture(runDir, capture, attemptId) {
399
+ async writeSessionCapture(runId, capture, attemptId) {
475
400
  validateSessionCapture(capture);
476
- await this.withSessionEventLock(runDir, attemptId, async () => {
477
- const stream = this.streamFor(runDir, attemptId);
478
- if (capture.status !== "recording") {
479
- stream.sessionEventsStopped = true;
480
- }
481
- await writeJsonAtomic(path.join(runDir, sessionStreamPaths(attemptId).capture), capture);
401
+ const segment = this.requireSegment(segmentIdFor(runId, attemptId));
402
+ this.state.transaction(() => {
403
+ this.assertSessionWriteAuthority(runId);
404
+ const current = this.requireSegment(segment.segmentId);
405
+ if (current.status === "failed" && capture.status !== "failed")
406
+ return;
407
+ if (current.status === "complete" && capture.status !== "complete")
408
+ return;
409
+ const now = Date.now();
410
+ const failureHash = capture.failure === undefined ? null : this.state.putJson(capture.failure, now);
411
+ this.state.connection
412
+ .prepare(`UPDATE session_segments
413
+ SET status = ?, entry_count = ?, event_count = ?, failure_hash = ?, finished_at = ?
414
+ WHERE segment_id = ?`)
415
+ .run(capture.status, capture.entryCount, capture.eventCount, failureHash, capture.status === "recording" ? null : now, segment.segmentId);
416
+ const resourceId = segmentResourceId(segment);
417
+ const revision = this.requireResourceRevision(resourceId);
418
+ this.bumpResource(resourceId, revision, now);
419
+ const payloadHash = this.state.putJson(capture, now);
420
+ insertGenericEvent(this.state, resourceId, revision + 1, "session.capture_updated", "session", segment.sessionId, payloadHash, now);
482
421
  });
483
422
  }
484
- /** Count complete durable session records after both writers have drained. */
485
- async sessionCounts(runDir, attemptId) {
486
- const paths = sessionStreamPaths(attemptId);
487
- const events = await readCompleteNdjson(path.join(runDir, paths.events));
488
- const entries = await readCompleteNdjson(path.join(runDir, paths.entries));
423
+ async sessionCounts(runId, attemptId) {
424
+ const segment = this.segmentRow(segmentIdFor(runId, attemptId));
489
425
  return {
490
- eventCount: events.length,
491
- entryCount: entries.length,
492
- lastEventSeq: events.at(-1)?.seq ?? 0,
426
+ eventCount: segment?.eventCount ?? 0,
427
+ entryCount: segment?.entryCount ?? 0,
428
+ lastEventSeq: segment?.eventCount ?? 0,
493
429
  };
494
430
  }
495
- async appendTraceEvent(runDir, runId, event) {
496
- const context = this.contextFor(runDir);
497
- const traceEvent = {
498
- seq: context.traceSeq + 1,
499
- at: new Date().toISOString(),
431
+ readRun(runId, options = {}) {
432
+ const row = this.readRunRow(runId);
433
+ if (row === undefined)
434
+ return null;
435
+ const state = this.readState(row.stateHash);
436
+ const snapshot = this.readDefinition(row.definitionHash);
437
+ const segments = this.segmentRows(runId).map((segment) => this.loadSegment(segment, state));
438
+ const flat = segments.find((segment) => segment.attemptId === "") ?? emptySegment();
439
+ return {
500
440
  runId,
501
- ...event,
502
- payload: (await encodeValue(event.payload, context.artifacts)),
441
+ state,
442
+ snapshot,
443
+ ...(options.includeTrace === true ? { traceEvents: this.traceEvents(row) } : {}),
444
+ sessionBinding: flat.binding,
445
+ sessionEntries: flat.entries,
446
+ sessionEvents: flat.events,
447
+ sessionCapture: flat.capture,
448
+ sessionIntegrity: flat.integrity,
449
+ sessionSegments: segments.filter((segment) => segment.attemptId !== ""),
503
450
  };
504
- await appendLine(path.join(runDir, TRACE_PATH), traceEvent);
505
- context.traceSeq = traceEvent.seq;
506
- return traceEvent;
507
- }
508
- async writeProjections(runDir, state) {
509
- const context = this.contextFor(runDir);
510
- const encoded = await encodeRunState(state, context.artifacts);
511
- await writeJsonAtomic(path.join(runDir, STATE_PATH), encoded);
512
- await writeJsonAtomic(path.join(runDir, MANIFEST_PATH), createManifest(state, {
513
- session: [...context.streams.values()].some((stream) => stream.sessionBound),
514
- }));
515
451
  }
516
- async writeLoadedProjections(runDir, state) {
517
- const context = this.contextFor(runDir);
518
- await writeJsonAtomic(path.join(runDir, STATE_PATH), state);
519
- await writeJsonAtomic(path.join(runDir, MANIFEST_PATH), createManifest(state, {
520
- session: [...context.streams.values()].some((stream) => stream.sessionBound),
521
- }));
452
+ listRuns(options = {}) {
453
+ const rows = this.state.connection
454
+ .prepare("SELECT run_id AS runId FROM runs ORDER BY created_at DESC, run_id DESC")
455
+ .all();
456
+ const loaded = [];
457
+ for (const row of rows) {
458
+ /* istanbul ignore if -- exact schema and internal query shape */
459
+ if (!isRunIdRow(row))
460
+ continue;
461
+ const run = this.readRun(row.runId, options);
462
+ if (run !== null)
463
+ loaded.push(run);
464
+ }
465
+ return loaded;
466
+ }
467
+ readLastTraceEvent(runId) {
468
+ const run = this.readRunRow(runId);
469
+ if (run === undefined)
470
+ return null;
471
+ const last = this.traceEvents(run).at(-1);
472
+ /* istanbul ignore if -- every durable run has a creation event */
473
+ if (last === undefined)
474
+ throw new Error("Workflow run has no creation event");
475
+ return last;
476
+ }
477
+ contextFor(runId) {
478
+ let context = this.contexts.get(runId);
479
+ if (context === undefined) {
480
+ context = { revision: this.resourceRevision(runId), lock: Promise.resolve() };
481
+ this.contexts.set(runId, context);
482
+ }
483
+ return context;
522
484
  }
523
- }
524
- /**
525
- * Truncate a trace file to the longest contiguous valid prefix, then to the
526
- * event count the state projection recorded. A crash can leave a partial
527
- * final line or one event appended before its projection write; the repair
528
- * keeps state and trace consistent so resume can continue the sequence. The
529
- * rewrite is atomic: a stale writer appending to the old inode cannot
530
- * interleave with the repaired file.
531
- */
532
- async function repairTraceFile(tracePath, keepSeq, beforeWrite) {
533
- let raw;
534
- try {
535
- raw = await fs.readFile(tracePath, "utf8");
485
+ async withRunLock(runId, operation) {
486
+ const context = this.contextFor(runId);
487
+ const prior = context.lock;
488
+ let release;
489
+ context.lock = new Promise((resolve) => {
490
+ release = resolve;
491
+ });
492
+ await prior;
493
+ try {
494
+ return await operation();
495
+ }
496
+ finally {
497
+ release?.();
498
+ }
536
499
  }
537
- catch {
538
- return;
500
+ assertSessionWriteAuthority(runId) {
501
+ const run = this.requireRunRow(runId);
502
+ this.assertWriteAuthority(run, this.contextFor(runId).revision);
503
+ }
504
+ resourceRevision(runId) {
505
+ const run = this.requireRunRow(runId);
506
+ return this.requireResourceRevision(run.resourceId);
507
+ }
508
+ requireResourceRevision(resourceId) {
509
+ const row = this.state.connection
510
+ .prepare("SELECT revision FROM resources WHERE resource_id = ?")
511
+ .get(resourceId);
512
+ /* istanbul ignore if -- exact schema and internal query shape */
513
+ if (!isRevisionRow(row))
514
+ throw new Error(`Resource is missing: ${resourceId}`);
515
+ return row.revision;
516
+ }
517
+ bumpResource(resourceId, expectedRevision, now) {
518
+ const result = this.state.connection
519
+ .prepare(`UPDATE resources SET revision = revision + 1, updated_at = ?
520
+ WHERE resource_id = ? AND revision = ?`)
521
+ .run(now, resourceId, expectedRevision);
522
+ if (result.changes !== 1)
523
+ throw new Error("Resource revision conflict");
524
+ }
525
+ assertWriteAuthority(run, expectedRevision) {
526
+ const actualRevision = this.requireResourceRevision(run.resourceId);
527
+ if (actualRevision !== expectedRevision) {
528
+ throw new Error(`Workflow run revision conflict: expected ${expectedRevision}, got ${actualRevision}`);
529
+ }
530
+ const lease = this.state.connection
531
+ .prepare(`SELECT generation, owner_type AS ownerType, owner_id AS ownerId,
532
+ token_hash AS tokenHash, expires_at AS expiresAt
533
+ FROM leases WHERE resource_id = ?`)
534
+ .get(run.resourceId);
535
+ /* istanbul ignore if -- exact schema and internal query shape */
536
+ if (!isLeaseAuthorityRow(lease))
537
+ throw new Error("Workflow run lease is missing");
538
+ if (lease.ownerId === null)
539
+ return;
540
+ const authority = this.authorityProvider?.(run.runId);
541
+ if (authority === undefined ||
542
+ authority.ownerType !== lease.ownerType ||
543
+ authority.ownerId !== lease.ownerId ||
544
+ authority.generation !== lease.generation ||
545
+ lease.tokenHash === null ||
546
+ !lease.tokenHash.equals(tokenHash(authority.token)) ||
547
+ lease.expiresAt === null ||
548
+ lease.expiresAt <= Date.now()) {
549
+ throw new Error("Workflow run write rejected because ownership changed");
550
+ }
539
551
  }
540
- const lines = raw.split("\n");
541
- if (lines.at(-1) === "") {
542
- lines.pop();
552
+ persistRunState(run, state, expectedRevision, now) {
553
+ this.bumpResource(run.resourceId, expectedRevision - 1, now);
554
+ const stateHash = this.state.putJson(state, now);
555
+ const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
556
+ const update = this.state.connection
557
+ .prepare(`UPDATE runs
558
+ SET title = ?, status = ?, paused = ?, status_detail = ?, output_hash = ?,
559
+ error_hash = ?, updated_at = ?, finished_at = ?
560
+ WHERE run_id = ?`)
561
+ .run(state.runTitle ?? null, state.status, state.paused === true ? 1 : 0, state.statusDetail ?? null, stateHash, errorHash, now, state.finishedAt === undefined ? null : Date.parse(state.finishedAt), state.runId);
562
+ if (update.changes !== 1)
563
+ throw new Error(`Workflow run is missing: ${state.runId}`);
564
+ if (state.status !== "running") {
565
+ this.enqueueRunSettlementEffect(run.resourceId, expectedRevision, state, now);
566
+ }
567
+ run.stateHash = stateHash;
543
568
  }
544
- const good = [];
545
- let expectedSeq = 1;
546
- for (const line of lines) {
547
- if (line.trim().length === 0) {
548
- break;
569
+ enqueueRunSettlementEffect(runResourceId, sourceRevision, state, now) {
570
+ if (this.state.connection.prepare("SELECT 1 FROM run_queue WHERE run_id = ?").get(state.runId) ===
571
+ undefined) {
572
+ return;
549
573
  }
550
- try {
551
- const event = JSON.parse(line);
552
- if (event.seq !== expectedSeq) {
553
- break;
574
+ const effectType = state.status === "waiting" ? "run.park_queue" : "run.settle_queue";
575
+ const idempotencyKey = `${state.runId}:${state.status}`;
576
+ const effectId = `effect-${createHash("sha256")
577
+ .update(`${runResourceId}\0${effectType}\0${idempotencyKey}`)
578
+ .digest("hex")
579
+ .slice(0, 40)}`;
580
+ if (this.state.connection.prepare("SELECT 1 FROM effects WHERE effect_id = ?").get(effectId) !==
581
+ undefined) {
582
+ return;
583
+ }
584
+ const effectResourceId = resourceIdFor("effect", effectId);
585
+ const payloadHash = this.state.putJson({ runId: state.runId, status: state.status }, now);
586
+ this.state.connection
587
+ .prepare(`INSERT INTO resources(
588
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
589
+ ) VALUES (?, 'effect', ?, 1, ?, ?)`)
590
+ .run(effectResourceId, effectId, now, now);
591
+ this.state.connection
592
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
593
+ .run(effectResourceId);
594
+ this.state.connection
595
+ .prepare(`INSERT INTO effects(
596
+ effect_id, resource_id, source_resource_id, source_revision,
597
+ effect_type, idempotency_key, payload_hash, owner_scope,
598
+ status, attempt_count, created_at, updated_at
599
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'run', 'pending', 0, ?, ?)`)
600
+ .run(effectId, effectResourceId, runResourceId, sourceRevision, effectType, idempotencyKey, payloadHash, now, now);
601
+ insertGenericEvent(this.state, effectResourceId, 1, "effect.created", "system", null, payloadHash, now);
602
+ }
603
+ syncNodeAttempts(state, snapshot, now) {
604
+ for (const step of state.steps.slice(state.carriedStepCount ?? 0)) {
605
+ const resultHash = this.state.putJson(step, now);
606
+ const outputHash = step.output === undefined ? null : this.state.putJson(step.output, now);
607
+ const errorHash = step.error === undefined ? null : this.state.putText(step.error, now);
608
+ const promptHash = step.prompt === null ? null : this.state.putJson(step.prompt, now);
609
+ const existing = this.state.connection
610
+ .prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
611
+ .get(step.attemptId);
612
+ if (existing === undefined) {
613
+ const attemptNumber = this.nextAttemptNumber(state.runId, step.nodeId);
614
+ this.state.connection
615
+ .prepare(`INSERT INTO node_attempts(
616
+ attempt_id, run_id, node_id, attempt_number, node_type, status,
617
+ presentation_hash, output_hash, result_hash, error_hash,
618
+ started_at, finished_at, created_at, updated_at
619
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
620
+ .run(step.attemptId, state.runId, step.nodeId, attemptNumber, step.nodeType, outcomeStatus(step.outcome), promptHash, outputHash, resultHash, errorHash, Date.parse(step.startedAt), Date.parse(step.finishedAt), Date.parse(step.startedAt), now);
621
+ }
622
+ else {
623
+ this.state.connection
624
+ .prepare(`UPDATE node_attempts
625
+ SET status = ?, presentation_hash = ?, output_hash = ?, result_hash = ?,
626
+ error_hash = ?, finished_at = ?, updated_at = ?
627
+ WHERE attempt_id = ?`)
628
+ .run(outcomeStatus(step.outcome), promptHash, outputHash, resultHash, errorHash, Date.parse(step.finishedAt), now, step.attemptId);
554
629
  }
555
- good.push(line);
556
- expectedSeq += 1;
557
630
  }
558
- catch {
559
- break;
631
+ if (state.currentAttemptId !== undefined && state.currentNode !== undefined) {
632
+ this.ensureAttempt(state, state.currentNode, state.currentAttemptId, now, snapshot);
560
633
  }
561
634
  }
562
- const kept = good.slice(0, keepSeq);
563
- if (kept.length === lines.length) {
564
- return;
635
+ ensureAttempt(state, nodeId, attemptId, now, snapshot) {
636
+ const existing = this.state.connection
637
+ .prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
638
+ .get(attemptId);
639
+ const status = state.status === "waiting" ? "waiting" : "running";
640
+ if (existing !== undefined) {
641
+ this.state.connection
642
+ .prepare("UPDATE node_attempts SET status = ?, updated_at = ? WHERE attempt_id = ?")
643
+ .run(status, now, attemptId);
644
+ return;
645
+ }
646
+ const definition = snapshot ?? this.readDefinition(this.requireRunRow(state.runId).definitionHash);
647
+ const nodeType = definition.nodes[nodeId]?.nodeType ?? "agent";
648
+ this.state.connection
649
+ .prepare(`INSERT INTO node_attempts(
650
+ attempt_id, run_id, node_id, attempt_number, node_type, status,
651
+ started_at, created_at, updated_at
652
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
653
+ .run(attemptId, state.runId, nodeId, this.nextAttemptNumber(state.runId, nodeId), nodeType, status, state.currentNodeStartedAt === undefined ? now : Date.parse(state.currentNodeStartedAt), now, now);
654
+ }
655
+ nextAttemptNumber(runId, nodeId) {
656
+ const row = this.state.connection
657
+ .prepare(`SELECT COALESCE(MAX(attempt_number), 0) + 1 AS attemptNumber
658
+ FROM node_attempts WHERE run_id = ? AND node_id = ?`)
659
+ .get(runId, nodeId);
660
+ /* istanbul ignore if -- exact schema and internal query shape */
661
+ if (!isAttemptNumberRow(row))
662
+ throw new Error("Could not allocate node attempt number");
663
+ return row.attemptNumber;
664
+ }
665
+ nextUpdateSequence(attemptId) {
666
+ const row = this.state.connection
667
+ .prepare(`SELECT COALESCE(MAX(update_seq), 0) + 1 AS updateSeq
668
+ FROM workflow_updates WHERE attempt_id = ?`)
669
+ .get(attemptId);
670
+ /* istanbul ignore if -- exact schema and internal query shape */
671
+ if (!isUpdateSequenceRow(row))
672
+ throw new Error("Could not allocate workflow update sequence");
673
+ return row.updateSeq;
674
+ }
675
+ readRunRow(runId) {
676
+ const row = this.state.connection
677
+ .prepare(`SELECT r.run_id AS runId, r.resource_id AS resourceId,
678
+ r.output_hash AS stateHash, d.definition_hash AS definitionHash,
679
+ r.status
680
+ FROM runs r
681
+ JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
682
+ WHERE r.run_id = ?`)
683
+ .get(runId);
684
+ return isRunRow(row) ? row : undefined;
685
+ }
686
+ requireRunRow(runId) {
687
+ const row = this.readRunRow(runId);
688
+ if (row === undefined)
689
+ throw new Error(`Workflow run is missing: ${runId}`);
690
+ return row;
691
+ }
692
+ readState(hash) {
693
+ const value = this.state.readJson(hash);
694
+ if (!isRecord(value) || value.schema !== RUN_STATE_SCHEMA) {
695
+ throw new Error("Workflow run state has an incompatible schema");
696
+ }
697
+ return value;
698
+ }
699
+ readDefinition(hash) {
700
+ const value = this.state.readJson(hash);
701
+ if (!isRecord(value) || value.schema !== DEFINITION_SNAPSHOT_SCHEMA) {
702
+ throw new Error("Workflow definition snapshot has an incompatible schema");
703
+ }
704
+ return value;
705
+ }
706
+ traceEvents(run) {
707
+ const rows = this.state.connection
708
+ .prepare(`SELECT resource_revision AS resourceRevision, event_type AS eventType,
709
+ payload_hash AS payloadHash, recorded_at AS recordedAt
710
+ FROM events WHERE resource_id = ? ORDER BY resource_revision`)
711
+ .all(run.resourceId);
712
+ const events = [];
713
+ for (const row of rows) {
714
+ /* istanbul ignore if -- exact schema and internal query shape */
715
+ if (!isEventRow(row))
716
+ continue;
717
+ const payload = row.payloadHash === null ? {} : this.state.readJson(row.payloadHash);
718
+ /* istanbul ignore if -- exact schema and internal query shape */
719
+ if (!isRecord(payload))
720
+ throw new Error("Workflow trace payload is not an object");
721
+ events.push({
722
+ seq: row.resourceRevision,
723
+ at: new Date(row.recordedAt).toISOString(),
724
+ runId: run.runId,
725
+ scope: traceScope(payload.scope),
726
+ type: row.eventType,
727
+ ...(typeof payload.nodeId === "string" ? { nodeId: payload.nodeId } : {}),
728
+ ...(typeof payload.attemptId === "string" ? { attemptId: payload.attemptId } : {}),
729
+ payload: isRecord(payload.payload) ? payload.payload : {},
730
+ });
731
+ }
732
+ return events;
733
+ }
734
+ segmentRows(runId) {
735
+ const rows = this.state.connection
736
+ .prepare(`SELECT segment_id AS segmentId, run_id AS runId, attempt_id AS attemptId,
737
+ capture_key AS captureKey, session_id AS sessionId, binding_hash AS bindingHash, status,
738
+ entry_count AS entryCount, event_count AS eventCount,
739
+ failure_hash AS failureHash, created_at AS createdAt, finished_at AS finishedAt
740
+ FROM session_segments WHERE run_id = ? ORDER BY created_at, segment_id`)
741
+ .all(runId);
742
+ return rows.filter(isSegmentRow);
743
+ }
744
+ segmentRow(segmentId) {
745
+ const row = this.state.connection
746
+ .prepare(`SELECT segment_id AS segmentId, run_id AS runId, attempt_id AS attemptId,
747
+ capture_key AS captureKey, session_id AS sessionId, binding_hash AS bindingHash, status,
748
+ entry_count AS entryCount, event_count AS eventCount,
749
+ failure_hash AS failureHash, created_at AS createdAt, finished_at AS finishedAt
750
+ FROM session_segments WHERE segment_id = ?`)
751
+ .get(segmentId);
752
+ return isSegmentRow(row) ? row : undefined;
753
+ }
754
+ requireSegment(segmentId) {
755
+ const row = this.segmentRow(segmentId);
756
+ if (row === undefined)
757
+ throw new Error(`Session capture segment is missing: ${segmentId}`);
758
+ return row;
759
+ }
760
+ loadSegment(segment, runState) {
761
+ const binding = segment.bindingHash === null
762
+ ? null
763
+ : this.state.readJson(segment.bindingHash);
764
+ const entries = this.state.connection
765
+ .prepare(`SELECT entry_seq AS entrySeq, entry_hash AS entryHash, recorded_at AS recordedAt
766
+ FROM session_entries WHERE segment_id = ? ORDER BY entry_seq`)
767
+ .all(segment.segmentId)
768
+ .filter(isSessionEntryRow)
769
+ .map((row) => ({
770
+ seq: row.entrySeq,
771
+ at: new Date(row.recordedAt).toISOString(),
772
+ entry: this.state.readJson(row.entryHash),
773
+ }));
774
+ const events = this.state.connection
775
+ .prepare(`SELECT event_seq AS eventSeq, event_type AS eventType, node_id AS nodeId,
776
+ attempt_id AS attemptId, turn_id AS turnId, message_id AS messageId,
777
+ tool_call_id AS toolCallId, payload_hash AS payloadHash,
778
+ recorded_at AS recordedAt
779
+ FROM session_events WHERE segment_id = ? ORDER BY event_seq`)
780
+ .all(segment.segmentId)
781
+ .filter(isSessionEventRow)
782
+ .map((row) => ({
783
+ seq: row.eventSeq,
784
+ at: new Date(row.recordedAt).toISOString(),
785
+ nodeId: row.nodeId,
786
+ attemptId: row.attemptId,
787
+ ...(row.turnId === null ? {} : { turnId: row.turnId }),
788
+ ...(row.messageId === null ? {} : { messageId: row.messageId }),
789
+ ...(row.toolCallId === null ? {} : { toolCallId: row.toolCallId }),
790
+ type: row.eventType,
791
+ payload: this.state.readJson(row.payloadHash),
792
+ }));
793
+ const failure = segment.failureHash === null
794
+ ? undefined
795
+ : this.state.readJson(segment.failureHash);
796
+ const capture = {
797
+ schema: SESSION_CAPTURE_SCHEMA,
798
+ eventSchema: SESSION_EVENT_SCHEMA,
799
+ status: segment.status,
800
+ eventCount: segment.eventCount,
801
+ entryCount: segment.entryCount,
802
+ lastEventSeq: segment.eventCount,
803
+ ...(failure === undefined ? {} : { failure }),
804
+ };
805
+ const diagnostics = [];
806
+ if (entries.length !== segment.entryCount || events.length !== segment.eventCount) {
807
+ diagnostics.push("session capture counts do not match durable rows");
808
+ }
809
+ if (runState.status !== "running" && segment.status === "recording") {
810
+ diagnostics.push("terminal run still reports recording capture");
811
+ }
812
+ const integrity = diagnostics.length > 0
813
+ ? { status: "invalid", diagnostics }
814
+ : segment.status === "failed"
815
+ ? { status: "failed", diagnostics: [failure?.message ?? "session capture failed"] }
816
+ : { status: segment.status, diagnostics: [] };
817
+ return {
818
+ attemptId: segment.captureKey ?? "",
819
+ binding,
820
+ entries,
821
+ events,
822
+ capture,
823
+ integrity,
824
+ };
825
+ }
826
+ finalizeRecordingCaptures(runId, reason) {
827
+ for (const segment of this.segmentRows(runId)) {
828
+ if (segment.status !== "recording")
829
+ continue;
830
+ const capture = {
831
+ schema: SESSION_CAPTURE_SCHEMA,
832
+ eventSchema: SESSION_EVENT_SCHEMA,
833
+ status: "failed",
834
+ eventCount: segment.eventCount,
835
+ entryCount: segment.entryCount,
836
+ lastEventSeq: segment.eventCount,
837
+ failure: {
838
+ failedAt: new Date().toISOString(),
839
+ code: "host_interrupted",
840
+ message: reason,
841
+ },
842
+ };
843
+ const now = Date.now();
844
+ this.state.transaction(() => {
845
+ this.assertSessionWriteAuthority(runId);
846
+ const failureHash = this.state.putJson(capture.failure, now);
847
+ this.state.connection
848
+ .prepare(`UPDATE session_segments
849
+ SET status = 'failed', failure_hash = ?, finished_at = ?
850
+ WHERE segment_id = ? AND status = 'recording'`)
851
+ .run(failureHash, now, segment.segmentId);
852
+ const resourceId = segmentResourceId(segment);
853
+ const revision = this.requireResourceRevision(resourceId);
854
+ this.bumpResource(resourceId, revision, now);
855
+ const payloadHash = this.state.putJson(capture, now);
856
+ insertGenericEvent(this.state, resourceId, revision + 1, "session.capture_failed", "system", null, payloadHash, now);
857
+ });
858
+ }
565
859
  }
566
- beforeWrite?.();
567
- const tempPath = `${tracePath}.${process.pid}.${randomUUID()}.tmp`;
568
- await fs.writeFile(tempPath, kept.length === 0 ? "" : `${kept.join("\n")}\n`, {
569
- encoding: "utf8",
570
- mode: 0o600,
571
- });
572
- await fs.rename(tempPath, tracePath);
573
860
  }
574
- function seededStreams(flat) {
575
- return new Map([["", { ...flat, lock: Promise.resolve() }]]);
861
+ export function readWorkflowRun(runId, options = {}) {
862
+ const store = new WorkflowRunStore(options.databasePath ?? workflowStatePath(), {
863
+ readOnly: true,
864
+ });
865
+ try {
866
+ return store.readRun(runId, options);
867
+ }
868
+ finally {
869
+ store.close();
870
+ }
576
871
  }
577
- function recoverTerminalProjection(state, event) {
578
- const status = terminalStatusForEvent(event.type);
579
- if (status === undefined) {
580
- return false;
872
+ export function listWorkflowRuns(options = {}) {
873
+ const store = new WorkflowRunStore(options.databasePath ?? workflowStatePath(), {
874
+ readOnly: true,
875
+ });
876
+ try {
877
+ return store.listRuns(options);
581
878
  }
582
- state.traceSeq = event.seq;
583
- state.status = status;
584
- state.updatedAt = event.at;
585
- state.finishedAt = event.at;
586
- if (typeof event.payload.error === "string") {
587
- state.error = event.payload.error;
879
+ finally {
880
+ store.close();
588
881
  }
589
- if (typeof event.payload.waitingOn === "string") {
590
- state.waitingOn = event.payload.waitingOn;
882
+ }
883
+ export function readLastTraceEvent(runId, options = {}) {
884
+ const store = new WorkflowRunStore(options.databasePath ?? workflowStatePath(), {
885
+ readOnly: true,
886
+ });
887
+ try {
888
+ return store.readLastTraceEvent(runId);
591
889
  }
592
- if (Object.hasOwn(event.payload, "finalOutput")) {
593
- state.finalOutput = event.payload.finalOutput;
890
+ finally {
891
+ store.close();
594
892
  }
595
- delete state.currentNode;
596
- delete state.currentAttemptId;
597
- delete state.currentNodeStartedAt;
598
- return true;
599
893
  }
600
- function terminalStatusForEvent(type) {
601
- switch (type) {
602
- case "run_waiting":
603
- return "waiting";
604
- case "run_completed":
894
+ function insertRunEvent(state, resourceId, revision, at, event) {
895
+ const payloadHash = state.putJson({
896
+ scope: event.scope,
897
+ ...(event.nodeId === undefined ? {} : { nodeId: event.nodeId }),
898
+ ...(event.attemptId === undefined ? {} : { attemptId: event.attemptId }),
899
+ payload: event.payload,
900
+ }, Date.parse(at));
901
+ insertGenericEvent(state, resourceId, revision, event.type, "system", null, payloadHash, Date.parse(at));
902
+ }
903
+ function insertGenericEvent(state, resourceId, revision, eventType, actorType, actorId, payloadHash, now) {
904
+ state.connection
905
+ .prepare(`INSERT INTO events(
906
+ event_id, resource_id, resource_revision, event_type,
907
+ actor_type, actor_id, payload_hash, recorded_at
908
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
909
+ .run(`event-${randomUUID()}`, resourceId, revision, eventType, actorType, actorId, payloadHash, now);
910
+ }
911
+ function sourceForState(state, definitionDigest) {
912
+ const source = state.workflowSource;
913
+ if (source?.kind === "builtin")
914
+ return { type: "builtin", ref: source.id, revision: source.revision };
915
+ if (source?.kind === "file")
916
+ return { type: "file", ref: source.path, revision: source.hash };
917
+ return {
918
+ type: "file",
919
+ ref: `inline:${state.workflowName}`,
920
+ revision: definitionDigest.toString("hex"),
921
+ };
922
+ }
923
+ function segmentIdFor(runId, attemptId) {
924
+ return `segment-${createHash("sha256")
925
+ .update(`${runId}\0${attemptId ?? ""}`)
926
+ .digest("hex")
927
+ .slice(0, 40)}`;
928
+ }
929
+ function segmentResourceId(segment) {
930
+ return resourceIdFor("session", segment.segmentId);
931
+ }
932
+ function outcomeStatus(outcome) {
933
+ switch (outcome) {
934
+ case "ok":
605
935
  return "completed";
606
- case "run_failed":
607
- case "run_interrupted":
608
- return "failed";
609
- case "run_timed_out":
936
+ case "timed_out":
610
937
  return "timed_out";
611
- case "run_cancelled":
938
+ case "cancelled":
612
939
  return "cancelled";
613
940
  default:
614
- return undefined;
615
- }
616
- }
617
- function assertValidRunId(runId) {
618
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
619
- throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
941
+ return "failed";
620
942
  }
621
943
  }
622
- function isMissingPath(error) {
623
- return error instanceof Error && "code" in error && error.code === "ENOENT";
944
+ function traceScope(value) {
945
+ return value === "node" || value === "agent" || value === "action" || value === "session"
946
+ ? value
947
+ : "run";
624
948
  }
625
- async function appendLine(filePath, value) {
626
- await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
627
- await fs.appendFile(filePath, value === null ? "" : `${JSON.stringify(value)}\n`, {
628
- encoding: "utf8",
629
- mode: 0o600,
630
- });
631
- }
632
- async function appendLines(filePath, values) {
633
- await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
634
- const text = values.map((value) => `${JSON.stringify(value)}\n`).join("");
635
- await fs.appendFile(filePath, text, { encoding: "utf8", mode: 0o600 });
636
- }
637
- function isNonEmptyString(value) {
638
- return typeof value === "string" && value.length > 0;
949
+ function emptySegment() {
950
+ return {
951
+ attemptId: "",
952
+ binding: null,
953
+ entries: [],
954
+ events: [],
955
+ capture: null,
956
+ integrity: { status: "unavailable", diagnostics: [] },
957
+ };
639
958
  }
640
959
  function validateSessionEventRecord(record) {
641
960
  if (!Number.isSafeInteger(record.seq) || record.seq < 1) {
642
961
  throw new Error("Session event seq must be a positive safe integer");
643
962
  }
644
- if (!isNonEmptyString(record.at) ||
645
- !isNonEmptyString(record.nodeId) ||
646
- !isNonEmptyString(record.attemptId) ||
647
- !isNonEmptyString(record.type) ||
963
+ if (record.at.length === 0 ||
964
+ record.nodeId.length === 0 ||
965
+ record.attemptId.length === 0 ||
648
966
  typeof record.payload !== "object" ||
649
967
  record.payload === null ||
650
968
  Array.isArray(record.payload)) {
651
969
  throw new Error("Session event is missing required envelope fields");
652
970
  }
653
- const knownType = [
654
- "turn_started",
655
- "turn_finished",
656
- "message_started",
657
- "assistant_event",
658
- "message_finished",
659
- "tool_execution_started",
660
- "tool_execution_updated",
661
- "tool_execution_finished",
662
- ].includes(record.type);
663
- if (!knownType) {
664
- return;
665
- }
666
- if (!isNonEmptyString(record.turnId)) {
667
- throw new Error(`${record.type} requires turnId`);
668
- }
669
- if (!["turn_started", "turn_finished"].includes(record.type) &&
670
- !isNonEmptyString(record.messageId)) {
671
- throw new Error(`${record.type} requires messageId`);
672
- }
673
- if (record.type.startsWith("tool_execution_") && !isNonEmptyString(record.toolCallId)) {
674
- throw new Error(`${record.type} requires toolCallId`);
675
- }
676
- }
677
- function sessionRelationshipDiagnostics(entries, events) {
678
- const entryIds = new Set(entries.flatMap((record) => (isNonEmptyString(record.entry.id) ? [record.entry.id] : [])));
679
- const turns = new Set();
680
- const messages = new Set();
681
- const tools = new Set();
682
- const diagnostics = [];
683
- for (const event of events) {
684
- switch (event.type) {
685
- case "turn_started":
686
- if (event.turnId)
687
- turns.add(event.turnId);
688
- break;
689
- case "turn_finished":
690
- if (!event.turnId || !turns.has(event.turnId)) {
691
- diagnostics.push(`turn_finished ${event.seq} precedes turn_started`);
692
- }
693
- break;
694
- case "message_started":
695
- if (!event.turnId || !turns.has(event.turnId)) {
696
- diagnostics.push(`message_started ${event.seq} precedes turn_started`);
697
- }
698
- if (event.messageId)
699
- messages.add(event.messageId);
700
- break;
701
- case "assistant_event":
702
- if (!event.messageId || !messages.has(event.messageId)) {
703
- diagnostics.push(`assistant_event ${event.seq} precedes message_started`);
704
- }
705
- break;
706
- case "message_finished": {
707
- if (!event.messageId || !messages.has(event.messageId)) {
708
- diagnostics.push(`message_finished ${event.seq} precedes message_started`);
709
- }
710
- const settled = event.payload.settled;
711
- const entryId = event.payload.entryId;
712
- if (settled === true && (!isNonEmptyString(entryId) || !entryIds.has(entryId))) {
713
- diagnostics.push(`message_finished ${event.seq} references a missing entry`);
714
- }
715
- else if (settled !== true && entryId !== undefined) {
716
- diagnostics.push(`message_finished ${event.seq} has entryId while unsettled`);
717
- }
718
- break;
719
- }
720
- case "tool_execution_started":
721
- if (!event.messageId || !messages.has(event.messageId)) {
722
- diagnostics.push(`tool_execution_started ${event.seq} precedes message_started`);
723
- }
724
- if (event.toolCallId)
725
- tools.add(event.toolCallId);
726
- break;
727
- case "tool_execution_updated":
728
- case "tool_execution_finished":
729
- if (!event.toolCallId || !tools.has(event.toolCallId)) {
730
- diagnostics.push(`${event.type} ${event.seq} precedes tool_execution_started`);
731
- }
732
- break;
733
- default:
734
- break;
735
- }
736
- }
737
- return diagnostics;
738
971
  }
739
972
  function validateSessionCapture(capture) {
740
973
  if (capture.schema !== SESSION_CAPTURE_SCHEMA ||
@@ -755,313 +988,43 @@ function validateSessionCapture(capture) {
755
988
  throw new Error("Only failed session capture may contain failure details");
756
989
  }
757
990
  }
758
- async function readCompleteNdjson(filePath) {
759
- let raw;
760
- try {
761
- raw = await fs.readFile(filePath, "utf8");
762
- }
763
- catch {
764
- return [];
765
- }
766
- const lines = raw.split("\n");
767
- if (!raw.endsWith("\n")) {
768
- lines.pop();
769
- }
770
- const records = [];
771
- for (const line of lines) {
772
- if (line.trim().length === 0) {
773
- continue;
774
- }
775
- try {
776
- const value = JSON.parse(line);
777
- if (!Number.isSafeInteger(value.seq) || value.seq < 1) {
778
- break;
779
- }
780
- records.push({ seq: value.seq });
781
- }
782
- catch {
783
- break;
784
- }
991
+ function assertValidRunId(runId) {
992
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
993
+ throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
785
994
  }
786
- return records;
787
995
  }
788
- /**
789
- * Encode the externalizable value positions of the state document. The
790
- * in-memory state always holds raw values; the persisted copy may carry
791
- * `$artifact` references instead of large strings.
792
- */
793
- async function encodeRunState(state, artifacts) {
794
- const results = Object.fromEntries(await Promise.all(Object.entries(state.results).map(async ([nodeId, result]) => [
795
- nodeId,
796
- "output" in result
797
- ? { ...result, output: await encodeValue(result.output, artifacts) }
798
- : result,
799
- ])));
800
- return {
801
- ...state,
802
- input: await encodeValue(state.input, artifacts),
803
- outputs: Object.fromEntries(await Promise.all(Object.entries(state.outputs).map(async ([nodeId, output]) => [
804
- nodeId,
805
- await encodeValue(output, artifacts),
806
- ]))),
807
- results,
808
- steps: await Promise.all(state.steps.map(async (step) => ({
809
- ...step,
810
- prompt: (await encodeValue(step.prompt, artifacts)),
811
- output: await encodeValue(step.output, artifacts),
812
- }))),
813
- ...(state.finalOutput !== undefined
814
- ? { finalOutput: await encodeValue(state.finalOutput, artifacts) }
815
- : {}),
816
- };
996
+ function isRecord(value) {
997
+ return typeof value === "object" && value !== null && !Array.isArray(value);
817
998
  }
818
- /** Read the final trace record without loading the rest of a run bundle. */
819
- export async function readLastTraceEvent(runDir, tracePath) {
820
- const events = await readNdjsonFile(resolveBundlePath(runDir, tracePath, TRACE_PATH));
821
- return events.records.at(-1) ?? null;
999
+ function isRunRow(value) {
1000
+ return isRecord(value);
822
1001
  }
823
- /** Read a run bundle from disk. Returns null when the bundle is unreadable. */
824
- export async function readRunBundle(runDir, options = {}) {
825
- const manifest = await readJsonFile(path.join(runDir, MANIFEST_PATH));
826
- if (!manifest || manifest.schema !== RUN_BUNDLE_SCHEMA) {
827
- return null;
828
- }
829
- // A schema-tagged manifest may still be malformed (e.g. hand-edited);
830
- // treat anything unexpected as an unreadable bundle rather than throwing.
831
- const paths = typeof manifest.paths === "object" && manifest.paths !== null ? manifest.paths : {};
832
- const state = await readJsonFile(resolveBundlePath(runDir, paths.state, STATE_PATH));
833
- if (!state || state.schema !== RUN_STATE_SCHEMA) {
834
- return null;
835
- }
836
- const snapshot = await readJsonFile(resolveBundlePath(runDir, paths.workflow, WORKFLOW_SNAPSHOT_PATH));
837
- const trace = options.includeTrace === true
838
- ? await readNdjsonFile(resolveBundlePath(runDir, paths.trace, TRACE_PATH))
839
- : undefined;
840
- const sessionDir = resolveBundlePath(runDir, paths.session, SESSION_DIR);
841
- const sessionBinding = await readJsonFile(path.join(sessionDir, "binding.json"));
842
- const entries = await readNdjsonFile(path.join(sessionDir, "entries.ndjson"));
843
- const events = await readNdjsonFile(path.join(sessionDir, "events.ndjson"));
844
- const sessionCapture = await readJsonFile(path.join(sessionDir, "capture.json"));
845
- const flatIntegrity = assessSessionIntegrity({
846
- binding: sessionBinding,
847
- entries,
848
- events,
849
- capture: sessionCapture,
850
- runTerminal: state.status !== "running",
851
- });
852
- const sessionSegments = [];
853
- let segmentIds = [];
854
- try {
855
- segmentIds = (await fs.readdir(path.join(sessionDir, "segments"), { withFileTypes: true }))
856
- .filter((entry) => entry.isDirectory())
857
- .map((entry) => entry.name)
858
- .sort();
859
- }
860
- catch {
861
- // No segments directory means only the flat stream can exist.
862
- }
863
- for (const attemptId of segmentIds) {
864
- const segmentDir = path.join(sessionDir, "segments", attemptId);
865
- const binding = await readJsonFile(path.join(segmentDir, "binding.json"));
866
- const segmentEntries = await readNdjsonFile(path.join(segmentDir, "entries.ndjson"));
867
- const segmentEvents = await readNdjsonFile(path.join(segmentDir, "events.ndjson"));
868
- const capture = await readJsonFile(path.join(segmentDir, "capture.json"));
869
- sessionSegments.push({
870
- attemptId,
871
- binding,
872
- entries: segmentEntries.records,
873
- events: segmentEvents.records,
874
- capture,
875
- integrity: assessSessionIntegrity({
876
- binding,
877
- entries: segmentEntries,
878
- events: segmentEvents,
879
- capture,
880
- runTerminal: state.status !== "running",
881
- }),
882
- });
883
- }
884
- // The headline integrity is the flat stream's when present; otherwise the
885
- // chronologically latest capture segment speaks for the run (segment ids
886
- // are random, so directory order says nothing about time).
887
- sessionSegments.sort((a, b) => (a.binding?.boundAt ?? "").localeCompare(b.binding?.boundAt ?? ""));
888
- const sessionIntegrity = flatIntegrity.status !== "unavailable" || sessionSegments.length === 0
889
- ? flatIntegrity
890
- : (sessionSegments.at(-1)?.integrity ?? flatIntegrity);
891
- return {
892
- runDir,
893
- manifest,
894
- state,
895
- snapshot,
896
- ...(trace !== undefined ? { traceEvents: trace.records } : {}),
897
- sessionBinding,
898
- sessionEntries: entries.records,
899
- sessionEvents: events.records,
900
- sessionCapture,
901
- sessionIntegrity,
902
- sessionSegments,
903
- };
1002
+ function isRunIdRow(value) {
1003
+ return isRecord(value);
904
1004
  }
905
- /**
906
- * Resolve a manifest-relative path, rejecting anything that is not a string
907
- * or escapes the bundle directory. Malformed manifests must degrade to an
908
- * unreadable bundle, never to a thrown error that aborts a listing.
909
- */
910
- function resolveBundlePath(runDir, relative, fallback) {
911
- const candidate = path.resolve(runDir, typeof relative === "string" && relative ? relative : fallback);
912
- if (candidate !== path.resolve(runDir) &&
913
- !candidate.startsWith(path.resolve(runDir) + path.sep)) {
914
- return path.join(runDir, fallback);
915
- }
916
- return candidate;
1005
+ function isRevisionRow(value) {
1006
+ return isRecord(value);
917
1007
  }
918
- /** List run bundles under `outputRoot`, most recently started first. */
919
- export async function listRunBundles(outputRoot) {
920
- let entries;
921
- try {
922
- entries = await fs.readdir(outputRoot);
923
- }
924
- catch {
925
- return [];
926
- }
927
- const bundles = [];
928
- for (const entry of entries) {
929
- const bundle = await readRunBundle(path.join(outputRoot, entry), { includeTrace: false });
930
- if (bundle) {
931
- bundles.push(bundle);
932
- }
933
- }
934
- bundles.sort((a, b) => b.state.startedAt.localeCompare(a.state.startedAt));
935
- return bundles;
1008
+ function isAttemptNumberRow(value) {
1009
+ return isRecord(value);
936
1010
  }
937
- async function readJsonFile(filePath) {
938
- try {
939
- const raw = await fs.readFile(filePath, "utf8");
940
- return JSON.parse(raw);
941
- }
942
- catch {
943
- return null;
944
- }
1011
+ function isUpdateSequenceRow(value) {
1012
+ return isRecord(value);
945
1013
  }
946
- async function readNdjsonFile(filePath) {
947
- let raw;
948
- try {
949
- raw = await fs.readFile(filePath, "utf8");
950
- }
951
- catch {
952
- return { records: [], exists: false, tornTail: false, malformed: false };
953
- }
954
- const tornTail = raw.length > 0 && !raw.endsWith("\n");
955
- const lines = raw.split("\n");
956
- if (tornTail) {
957
- lines.pop();
958
- }
959
- const records = [];
960
- let malformed = false;
961
- for (const line of lines) {
962
- if (line.trim().length === 0) {
963
- continue;
964
- }
965
- try {
966
- records.push(JSON.parse(line));
967
- }
968
- catch {
969
- malformed = true;
970
- }
971
- }
972
- return { records, exists: true, tornTail, malformed };
1014
+ function isEventRow(value) {
1015
+ return isRecord(value);
973
1016
  }
974
- function assessSessionIntegrity(input) {
975
- const anySessionFile = input.binding !== null || input.entries.exists || input.events.exists || input.capture !== null;
976
- if (!anySessionFile) {
977
- return { status: "unavailable", diagnostics: [] };
978
- }
979
- const diagnostics = [];
980
- if (!input.binding || input.binding.schema !== SESSION_BINDING_SCHEMA) {
981
- diagnostics.push("missing or invalid session binding");
982
- }
983
- if (!input.capture) {
984
- diagnostics.push("missing session capture status");
985
- return { status: "invalid", diagnostics };
986
- }
987
- try {
988
- validateSessionCapture(input.capture);
989
- }
990
- catch (error) {
991
- diagnostics.push(failureMessageForDiagnostic(error));
992
- return { status: "invalid", diagnostics };
993
- }
994
- if (input.entries.malformed || input.events.malformed) {
995
- diagnostics.push("malformed NDJSON line before the journal tail");
996
- }
997
- if (input.events.tornTail && input.capture.status !== "recording") {
998
- diagnostics.push("terminal session event journal has a torn tail");
999
- }
1000
- if (input.runTerminal && input.capture.status === "recording") {
1001
- diagnostics.push("terminal run still reports recording capture");
1002
- }
1003
- let expected = 1;
1004
- for (const event of input.events.records) {
1005
- try {
1006
- validateSessionEventRecord(event);
1007
- }
1008
- catch (error) {
1009
- diagnostics.push(failureMessageForDiagnostic(error));
1010
- break;
1011
- }
1012
- if (event.seq !== expected) {
1013
- diagnostics.push(`session event sequence gap at ${expected}`);
1014
- break;
1015
- }
1016
- expected += 1;
1017
- }
1018
- diagnostics.push(...sessionRelationshipDiagnostics(input.entries.records, input.events.records));
1019
- if (input.capture.status !== "recording") {
1020
- const lastEventSeq = input.events.records.at(-1)?.seq ?? 0;
1021
- if (input.capture.eventCount !== input.events.records.length ||
1022
- input.capture.entryCount !== input.entries.records.length ||
1023
- input.capture.lastEventSeq !== lastEventSeq) {
1024
- diagnostics.push("session capture counts do not match durable files");
1025
- }
1026
- }
1027
- if (diagnostics.length > 0) {
1028
- return { status: "invalid", diagnostics };
1029
- }
1030
- if (input.capture.status === "failed") {
1031
- return {
1032
- status: "failed",
1033
- diagnostics: [input.capture.failure?.message ?? "session capture failed"],
1034
- };
1035
- }
1036
- return { status: input.capture.status, diagnostics: [] };
1017
+ function isSegmentRow(value) {
1018
+ return isRecord(value);
1037
1019
  }
1038
- function failureMessageForDiagnostic(error) {
1039
- return error instanceof Error ? error.message : String(error);
1020
+ function isSessionEntryRow(value) {
1021
+ return isRecord(value);
1040
1022
  }
1041
- function createManifest(state, present) {
1042
- return {
1043
- schema: RUN_BUNDLE_SCHEMA,
1044
- runId: state.runId,
1045
- workflowName: state.workflowName,
1046
- ...(state.runTitle !== undefined ? { runTitle: state.runTitle } : {}),
1047
- ...(state.workflowSource !== undefined ? { workflowSource: state.workflowSource } : {}),
1048
- ...(state.workflowSources !== undefined ? { workflowSources: state.workflowSources } : {}),
1049
- ...(state.definitionDigest !== undefined ? { definitionDigest: state.definitionDigest } : {}),
1050
- startedAt: state.startedAt,
1051
- ...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
1052
- status: state.status,
1053
- traceSchema: TRACE_EVENT_SCHEMA,
1054
- paths: {
1055
- workflow: WORKFLOW_SNAPSHOT_PATH,
1056
- state: STATE_PATH,
1057
- trace: TRACE_PATH,
1058
- ...(present.session ? { session: SESSION_DIR } : {}),
1059
- // Declare this before any payload can externalize a string. Live
1060
- // session-event patches may reference a newly written artifact before
1061
- // the next workflow state projection refreshes the manifest.
1062
- artifacts: "artifacts",
1063
- },
1064
- };
1023
+ function isSessionEventRow(value) {
1024
+ return isRecord(value);
1025
+ }
1026
+ function isLeaseAuthorityRow(value) {
1027
+ return isRecord(value);
1065
1028
  }
1066
1029
  export function createDefinitionSnapshot(workflow) {
1067
1030
  const composition = compositionMetadata(workflow)?.snapshot;
@@ -1110,31 +1073,24 @@ function snapshotNode(workflow, nodeId, node) {
1110
1073
  if (node.nodeType === "agent" && node.expectedOutput !== undefined) {
1111
1074
  common.expectedOutput = node.expectedOutput;
1112
1075
  }
1113
- if (node.nodeType === "notify") {
1076
+ if (node.nodeType === "notify")
1114
1077
  common.summary = node.kind ?? "progress";
1115
- }
1116
- if (node.nodeType === "checkpoint" && node.summary !== undefined) {
1078
+ if (node.nodeType === "checkpoint" && node.summary !== undefined)
1117
1079
  common.summary = node.summary;
1118
- }
1119
1080
  if (node.nodeType === "checkpoint" && node.humanDecision !== undefined) {
1120
1081
  common.humanDecision = {
1121
1082
  audience: typeof node.humanDecision.audience === "string" ? node.humanDecision.audience : "<dynamic>",
1122
1083
  ...(typeof node.humanDecision.audience === "function" ? { dynamicAudience: true } : {}),
1123
1084
  choices: structuredClone(node.humanDecision.choices),
1085
+ ...(node.humanDecision.onTimeout !== undefined &&
1086
+ typeof node.humanDecision.onTimeout !== "function"
1087
+ ? { onTimeout: structuredClone(node.humanDecision.onTimeout) }
1088
+ : {}),
1089
+ ...(typeof node.humanDecision.onTimeout === "function" ? { dynamicTimeout: true } : {}),
1124
1090
  };
1125
1091
  }
1126
- if (node.nodeType === "action") {
1092
+ if (node.nodeType === "action")
1127
1093
  common.actionExecution = "exec" in node ? "shell" : "function";
1128
- }
1129
1094
  return common;
1130
1095
  }
1131
- async function writeJsonAtomic(filePath, value) {
1132
- const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
1133
- await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
1134
- await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
1135
- encoding: "utf8",
1136
- mode: 0o600,
1137
- });
1138
- await fs.rename(tempPath, filePath);
1139
- }
1140
1096
  //# sourceMappingURL=store.js.map