@osolmaz/pi-workflows 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/README.md +56 -31
  2. package/dist/builtins/autodoc.workflow.d.ts +4 -4
  3. package/dist/builtins/autoimplement.workflow.d.ts +797 -142
  4. package/dist/builtins/autoimplement.workflow.js +17 -105
  5. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  6. package/dist/builtins/autoplan.workflow.d.ts +126 -13
  7. package/dist/builtins/autoplan.workflow.js +200 -29
  8. package/dist/builtins/autoplan.workflow.js.map +1 -1
  9. package/dist/builtins/catalog.js +7 -5
  10. package/dist/builtins/catalog.js.map +1 -1
  11. package/dist/builtins/index.d.ts +4 -2
  12. package/dist/builtins/index.js +3 -1
  13. package/dist/builtins/index.js.map +1 -1
  14. package/dist/builtins/monitor.workflow.d.ts +56 -23
  15. package/dist/builtins/monitor.workflow.js +508 -329
  16. package/dist/builtins/monitor.workflow.js.map +1 -1
  17. package/dist/builtins/pi-agent-group.d.ts +72 -0
  18. package/dist/builtins/pi-agent-group.js +1087 -0
  19. package/dist/builtins/pi-agent-group.js.map +1 -0
  20. package/dist/builtins/plain-summary.workflow.d.ts +32 -0
  21. package/dist/builtins/plain-summary.workflow.js +134 -0
  22. package/dist/builtins/plain-summary.workflow.js.map +1 -0
  23. package/dist/builtins/plan-approval.workflow.d.ts +39 -5
  24. package/dist/builtins/plan-approval.workflow.js +92 -14
  25. package/dist/builtins/plan-approval.workflow.js.map +1 -1
  26. package/dist/builtins/plan-change.workflow.d.ts +449 -0
  27. package/dist/builtins/plan-change.workflow.js +256 -0
  28. package/dist/builtins/plan-change.workflow.js.map +1 -0
  29. package/dist/builtins/plan-presentation.js +2 -2
  30. package/dist/builtins/plan-presentation.js.map +1 -1
  31. package/dist/builtins/sanity-check.workflow.d.ts +50 -6
  32. package/dist/builtins/sanity-check.workflow.js +150 -28
  33. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  34. package/dist/controllers/effects.d.ts +3 -2
  35. package/dist/controllers/effects.js +8 -1
  36. package/dist/controllers/effects.js.map +1 -1
  37. package/dist/controllers/index.d.ts +1 -1
  38. package/dist/controllers/index.js +1 -1
  39. package/dist/controllers/index.js.map +1 -1
  40. package/dist/controllers/manager.d.ts +1 -0
  41. package/dist/controllers/manager.js +41 -17
  42. package/dist/controllers/manager.js.map +1 -1
  43. package/dist/controllers/sqlite.d.ts +107 -85
  44. package/dist/controllers/sqlite.js +1611 -1486
  45. package/dist/controllers/sqlite.js.map +1 -1
  46. package/dist/controllers/store.d.ts +9 -8
  47. package/dist/controllers/store.js +1 -31
  48. package/dist/controllers/store.js.map +1 -1
  49. package/dist/controllers/types.d.ts +3 -0
  50. package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
  51. package/dist/controllers/workflow-engine-scheduler.js +3 -5
  52. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  53. package/dist/controllers/workflows.d.ts +2 -2
  54. package/dist/controllers/workflows.js +11 -10
  55. package/dist/controllers/workflows.js.map +1 -1
  56. package/dist/extension/controller-host.js +2 -2
  57. package/dist/extension/controller-host.js.map +1 -1
  58. package/dist/extension/decision-channels.d.ts +4 -2
  59. package/dist/extension/decision-channels.js +333 -146
  60. package/dist/extension/decision-channels.js.map +1 -1
  61. package/dist/extension/executor.d.ts +9 -2
  62. package/dist/extension/executor.js +89 -2
  63. package/dist/extension/executor.js.map +1 -1
  64. package/dist/extension/herdr-viewer.d.ts +0 -1
  65. package/dist/extension/herdr-viewer.js +0 -4
  66. package/dist/extension/herdr-viewer.js.map +1 -1
  67. package/dist/extension/index.js +209 -127
  68. package/dist/extension/index.js.map +1 -1
  69. package/dist/extension/recorder.d.ts +3 -10
  70. package/dist/extension/recorder.js +13 -22
  71. package/dist/extension/recorder.js.map +1 -1
  72. package/dist/extension/session-events.d.ts +2 -2
  73. package/dist/extension/step-message.d.ts +7 -2
  74. package/dist/extension/step-message.js +94 -2
  75. package/dist/extension/step-message.js.map +1 -1
  76. package/dist/extension/widget.js +29 -3
  77. package/dist/extension/widget.js.map +1 -1
  78. package/dist/host/rpc-executor.d.ts +3 -0
  79. package/dist/host/rpc-executor.js +2 -0
  80. package/dist/host/rpc-executor.js.map +1 -1
  81. package/dist/host/runner.d.ts +3 -6
  82. package/dist/host/runner.js +83 -32
  83. package/dist/host/runner.js.map +1 -1
  84. package/dist/render/graph-render.js +15 -10
  85. package/dist/render/graph-render.js.map +1 -1
  86. package/dist/state/database.d.ts +35 -0
  87. package/dist/state/database.js +287 -0
  88. package/dist/state/database.js.map +1 -0
  89. package/dist/state/index.d.ts +4 -0
  90. package/dist/state/index.js +5 -0
  91. package/dist/state/index.js.map +1 -0
  92. package/dist/state/json.d.ts +6 -0
  93. package/dist/state/json.js +38 -0
  94. package/dist/state/json.js.map +1 -0
  95. package/dist/state/mutation.d.ts +57 -0
  96. package/dist/state/mutation.js +255 -0
  97. package/dist/state/mutation.js.map +1 -0
  98. package/dist/state/schema.d.ts +6 -0
  99. package/dist/state/schema.js +473 -0
  100. package/dist/state/schema.js.map +1 -0
  101. package/dist/viewer/cli.d.ts +2 -2
  102. package/dist/viewer/cli.js +142 -122
  103. package/dist/viewer/cli.js.map +1 -1
  104. package/dist/viewer/render.d.ts +4 -4
  105. package/dist/viewer/render.js +9 -23
  106. package/dist/viewer/render.js.map +1 -1
  107. package/dist/viewer/session-reducer.d.ts +1 -1
  108. package/dist/viewer/session-reducer.js +1 -1
  109. package/dist/viewer/tui.d.ts +3 -3
  110. package/dist/viewer/tui.js +15 -13
  111. package/dist/viewer/tui.js.map +1 -1
  112. package/dist/viewer/watch.d.ts +2 -5
  113. package/dist/viewer/watch.js +13 -13
  114. package/dist/viewer/watch.js.map +1 -1
  115. package/dist/workflows/catalog.js +7 -2
  116. package/dist/workflows/catalog.js.map +1 -1
  117. package/dist/workflows/composition.js +20 -2
  118. package/dist/workflows/composition.js.map +1 -1
  119. package/dist/workflows/decision-presentation.d.ts +1 -1
  120. package/dist/workflows/decision-presentation.js +51 -38
  121. package/dist/workflows/decision-presentation.js.map +1 -1
  122. package/dist/workflows/definition.d.ts +6 -2
  123. package/dist/workflows/definition.js +17 -0
  124. package/dist/workflows/definition.js.map +1 -1
  125. package/dist/workflows/engine.d.ts +7 -7
  126. package/dist/workflows/engine.js +196 -115
  127. package/dist/workflows/engine.js.map +1 -1
  128. package/dist/workflows/errors.d.ts +16 -3
  129. package/dist/workflows/errors.js +18 -3
  130. package/dist/workflows/errors.js.map +1 -1
  131. package/dist/workflows/human-decision.d.ts +45 -8
  132. package/dist/workflows/human-decision.js +641 -274
  133. package/dist/workflows/human-decision.js.map +1 -1
  134. package/dist/workflows/index.d.ts +5 -6
  135. package/dist/workflows/index.js +4 -5
  136. package/dist/workflows/index.js.map +1 -1
  137. package/dist/workflows/progress.d.ts +1 -0
  138. package/dist/workflows/progress.js +15 -3
  139. package/dist/workflows/progress.js.map +1 -1
  140. package/dist/workflows/schema.js +27 -1
  141. package/dist/workflows/schema.js.map +1 -1
  142. package/dist/workflows/store.d.ts +83 -103
  143. package/dist/workflows/store.js +907 -951
  144. package/dist/workflows/store.js.map +1 -1
  145. package/dist/workflows/types.d.ts +88 -126
  146. package/docs/CONTROLLERS.md +10 -8
  147. package/docs/DEFERRED_TURNS.md +2 -2
  148. package/docs/DESIGN_PHILOSOPHY.md +4 -2
  149. package/docs/HUMAN_DECISIONS.md +36 -47
  150. package/docs/HUMAN_DECISION_PRESENTATIONS.md +14 -24
  151. package/docs/MONITOR.md +179 -237
  152. package/docs/SQLITE_STATE.md +219 -0
  153. package/docs/WORKFLOW_COMPOSITION.md +27 -26
  154. package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
  155. package/docs/WORKFLOW_UPDATES.md +10 -9
  156. package/docs/development.md +9 -9
  157. package/docs/live-replay-protocol.md +28 -37
  158. package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
  159. package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
  160. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
  161. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +322 -0
  162. package/docs/plans/2026-08-21-sanity-check-plan.md +217 -95
  163. package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
  164. package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
  165. package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
  166. package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
  167. package/docs/plans/session-event-replay-implementation-plan.md +1 -1
  168. package/docs/plans/tui-viewer-implementation-plan.md +1 -1
  169. package/docs/session-event-journal.md +63 -434
  170. package/docs/tui-viewer.md +13 -15
  171. package/docs/workflows.md +127 -53
  172. package/examples/workflows/approved-plan.workflow.ts +19 -46
  173. package/examples/workflows/plain-summary.workflow.ts +1 -0
  174. package/herdr-plugin.toml +1 -1
  175. package/package.json +7 -7
  176. package/plugins/herdr/viewer.mjs +1 -11
  177. package/schemas/human-decision-accepted-v1.schema.json +15 -3
  178. package/schemas/human-decision-continuation-v1.schema.json +10 -1
  179. package/schemas/human-decision-delivery-v1.schema.json +8 -0
  180. package/schemas/human-decision-receipt-v1.schema.json +8 -0
  181. package/schemas/human-decision-request-v1.schema.json +24 -4
  182. package/skills/autoimplement/SKILL.md +27 -0
  183. package/skills/autoplan/SKILL.md +5 -4
  184. package/skills/monitor/SKILL.md +91 -135
  185. package/skills/pi-workflows/SKILL.md +9 -8
  186. package/skills/sanity-check/SKILL.md +44 -0
  187. package/src/builtins/autoimplement.workflow.ts +19 -118
  188. package/src/builtins/autoplan.workflow.ts +290 -40
  189. package/src/builtins/catalog.ts +7 -5
  190. package/src/builtins/index.ts +18 -1
  191. package/src/builtins/monitor.workflow.ts +673 -382
  192. package/src/builtins/pi-agent-group.ts +1407 -0
  193. package/src/builtins/plain-summary.workflow.ts +185 -0
  194. package/src/builtins/plan-approval.workflow.ts +157 -24
  195. package/src/builtins/plan-change.workflow.ts +321 -0
  196. package/src/builtins/plan-presentation.ts +2 -2
  197. package/src/builtins/sanity-check.workflow.ts +248 -48
  198. package/src/controllers/effects.ts +7 -0
  199. package/src/controllers/index.ts +0 -6
  200. package/src/controllers/manager.ts +72 -32
  201. package/src/controllers/sqlite.ts +2420 -1948
  202. package/src/controllers/store.ts +17 -38
  203. package/src/controllers/types.ts +3 -0
  204. package/src/controllers/workflow-engine-scheduler.ts +4 -8
  205. package/src/controllers/workflows.ts +45 -21
  206. package/src/extension/controller-host.ts +1 -2
  207. package/src/extension/decision-channels.ts +456 -203
  208. package/src/extension/executor.ts +105 -2
  209. package/src/extension/herdr-viewer.ts +0 -5
  210. package/src/extension/index.ts +251 -151
  211. package/src/extension/recorder.ts +13 -22
  212. package/src/extension/session-events.ts +2 -2
  213. package/src/extension/step-message.ts +106 -4
  214. package/src/extension/widget.ts +32 -5
  215. package/src/host/rpc-executor.ts +4 -0
  216. package/src/host/runner.ts +93 -41
  217. package/src/render/graph-render.ts +12 -6
  218. package/src/state/database.ts +375 -0
  219. package/src/state/index.ts +29 -0
  220. package/src/state/json.ts +42 -0
  221. package/src/state/mutation.ts +435 -0
  222. package/src/state/schema.ts +475 -0
  223. package/src/viewer/cli.ts +151 -144
  224. package/src/viewer/render.ts +19 -30
  225. package/src/viewer/session-reducer.ts +1 -1
  226. package/src/viewer/tui.ts +18 -17
  227. package/src/viewer/watch.ts +13 -17
  228. package/src/workflows/catalog.ts +7 -2
  229. package/src/workflows/composition.ts +21 -3
  230. package/src/workflows/decision-presentation.ts +56 -43
  231. package/src/workflows/definition.ts +36 -3
  232. package/src/workflows/engine.ts +228 -130
  233. package/src/workflows/errors.ts +27 -3
  234. package/src/workflows/human-decision.ts +921 -349
  235. package/src/workflows/index.ts +20 -30
  236. package/src/workflows/progress.ts +18 -3
  237. package/src/workflows/schema.ts +36 -1
  238. package/src/workflows/store.ts +1300 -1163
  239. package/src/workflows/types.ts +100 -137
  240. package/dist/builtins/sanity-check-session.d.ts +0 -17
  241. package/dist/builtins/sanity-check-session.js +0 -168
  242. package/dist/builtins/sanity-check-session.js.map +0 -1
  243. package/dist/workflows/artifacts.d.ts +0 -40
  244. package/dist/workflows/artifacts.js +0 -155
  245. package/dist/workflows/artifacts.js.map +0 -1
  246. package/dist/workflows/migrate-sources.d.ts +0 -42
  247. package/dist/workflows/migrate-sources.js +0 -133
  248. package/dist/workflows/migrate-sources.js.map +0 -1
  249. package/docs/run-bundles.md +0 -481
  250. package/schemas/human-decision-accepted-v2.schema.json +0 -50
  251. package/schemas/human-decision-delivery-v2.schema.json +0 -36
  252. package/schemas/human-decision-receipt-v2.schema.json +0 -39
  253. package/schemas/human-decision-request-v2.schema.json +0 -69
  254. package/schemas/human-decision-resolution-v2.schema.json +0 -27
  255. package/src/builtins/sanity-check-session.ts +0 -205
  256. package/src/workflows/artifacts.ts +0 -188
  257. package/src/workflows/migrate-sources.ts +0 -178
@@ -1,15 +1,14 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import type { Dirent } from "node:fs";
3
- import fs from "node:fs/promises";
4
- import path from "node:path";
2
+ import { StateDatabase, workflowStatePath } from "../state/database.js";
3
+ import { resourceIdFor, tokenHash } from "../state/mutation.js";
5
4
  import {
6
5
  digestCanonical,
7
6
  normalizeDecisionPresentation,
8
7
  validateHumanDecisionRequestIntegrity,
9
8
  } from "./decision-presentation.js";
10
9
  import { checkpoint } from "./definition.js";
10
+ import type { RunWriteAuthority } from "./store.js";
11
11
  import type {
12
- AcceptedHumanDecision,
13
12
  CheckpointNodeDefinition,
14
13
  HumanDecisionChoice,
15
14
  HumanDecisionAudience,
@@ -24,6 +23,8 @@ import type {
24
23
  HumanDecisionSettlementRecord,
25
24
  HumanDecisionSubmission,
26
25
  HumanDecisionTextInput,
26
+ HumanDecisionTimeout,
27
+ ResolvedHumanDecision,
27
28
  WorkflowNodeCommon,
28
29
  WorkflowNodeContext,
29
30
  } from "./types.js";
@@ -43,6 +44,14 @@ export type HumanDecisionDefinition<TChoices extends HumanDecisionChoiceMap> =
43
44
  audience: HumanDecisionAudience;
44
45
  choices: TChoices;
45
46
  request: (context: WorkflowNodeContext) => HumanDecisionPrompt | Promise<HumanDecisionPrompt>;
47
+ onTimeout?:
48
+ | { afterMs: number; response: HumanDecisionResponseFor<TChoices> }
49
+ | ((
50
+ context: WorkflowNodeContext,
51
+ ) =>
52
+ | { afterMs: number; response: HumanDecisionResponseFor<TChoices> }
53
+ | undefined
54
+ | Promise<{ afterMs: number; response: HumanDecisionResponseFor<TChoices> } | undefined>);
46
55
  };
47
56
 
48
57
  export type HumanDecisionResponseFor<TChoices extends HumanDecisionChoiceMap> = {
@@ -98,11 +107,25 @@ export function humanDecision<const TChoices extends HumanDecisionChoiceMap>(
98
107
  if (typeof definition.request !== "function") {
99
108
  throw new Error("Human decision request must be a function");
100
109
  }
101
- const { choices, request, ...common } = definition;
110
+ const { choices, request, onTimeout, ...common } = definition;
111
+ if (
112
+ onTimeout !== undefined &&
113
+ typeof onTimeout !== "function" &&
114
+ (typeof onTimeout.afterMs !== "number" ||
115
+ !Number.isFinite(onTimeout.afterMs) ||
116
+ onTimeout.afterMs <= 0)
117
+ ) {
118
+ throw new Error("Human decision onTimeout afterMs must be a finite positive number");
119
+ }
102
120
  return checkpoint({
103
121
  ...common,
104
122
  summary: typeof audience === "string" ? `human decision for ${audience}` : "human decision",
105
- humanDecision: { audience, choices, request },
123
+ humanDecision: {
124
+ audience,
125
+ choices,
126
+ request,
127
+ ...(onTimeout !== undefined ? { onTimeout } : {}),
128
+ },
106
129
  }) as CheckpointNodeDefinition & { readonly __humanChoices?: TChoices };
107
130
  }
108
131
 
@@ -136,11 +159,31 @@ export function createHumanDecisionRequest(input: {
136
159
  attemptId: string;
137
160
  contract: { audience: string; choices: HumanDecisionChoiceMap };
138
161
  prompt: HumanDecisionPrompt;
162
+ timeout?: HumanDecisionTimeout;
139
163
  createdAt?: string;
140
164
  }): HumanDecisionRequest {
141
165
  validateChoices(input.contract.choices);
166
+ const createdAt = input.createdAt ?? new Date().toISOString();
142
167
  const title = requireString(input.prompt.title, "Human decision title");
143
- const expiresAt = validateExpiry(input.prompt.expiresAt, input.createdAt);
168
+ if (input.timeout !== undefined && input.prompt.expiresAt !== undefined) {
169
+ throw new Error("Human decision request cannot combine expiresAt with onTimeout");
170
+ }
171
+ if (
172
+ input.timeout !== undefined &&
173
+ (typeof input.timeout.afterMs !== "number" ||
174
+ !Number.isFinite(input.timeout.afterMs) ||
175
+ input.timeout.afterMs <= 0)
176
+ ) {
177
+ throw new Error("Human decision onTimeout afterMs must be a finite positive number");
178
+ }
179
+ const defaultResponse =
180
+ input.timeout === undefined
181
+ ? undefined
182
+ : validateResponseForChoices(input.contract.choices, input.timeout.response);
183
+ const expiresAt =
184
+ input.timeout === undefined
185
+ ? validateExpiry(input.prompt.expiresAt, createdAt)
186
+ : new Date(Date.parse(createdAt) + input.timeout.afterMs).toISOString();
144
187
  const common = {
145
188
  runId: input.runId,
146
189
  workflowName: input.workflowName,
@@ -150,58 +193,35 @@ export function createHumanDecisionRequest(input: {
150
193
  title,
151
194
  choices: input.contract.choices,
152
195
  ...(expiresAt !== undefined ? { expiresAt } : {}),
196
+ ...(defaultResponse !== undefined ? { defaultResponse } : {}),
153
197
  } as const;
154
- const presented =
155
- Object.hasOwn(input.prompt, "subject") || Object.hasOwn(input.prompt, "presentation");
156
- if (presented) {
157
- validatePresentedText(title, "Human decision title");
158
- validatePresentedChoices(input.contract.choices);
159
- if (Object.hasOwn(input.prompt, "body")) {
160
- throw new Error("Presented human decision requests must not contain a legacy body");
161
- }
162
- if (!Object.hasOwn(input.prompt, "subject") || !Object.hasOwn(input.prompt, "presentation")) {
163
- throw new Error("Presented human decision requests require subject and presentation");
164
- }
165
- const prompt = input.prompt as Extract<HumanDecisionPrompt, { subject: unknown }>;
166
- assertJsonValue(prompt.subject, "Human decision subject");
167
- const presentation = normalizeDecisionPresentation(prompt.presentation);
168
- const revision = prompt.revision ?? 1;
169
- if (!Number.isInteger(revision) || revision < 1) {
170
- throw new Error("Human decision revision must be a positive integer");
171
- }
172
- const basis = {
173
- schema: "pi-workflows.human-decision-request.v2" as const,
174
- ...common,
175
- subject: prompt.subject,
176
- presentation,
177
- revision,
178
- };
179
- const subjectDigest = digestCanonical(prompt.subject);
180
- const presentationDigest = digestCanonical(presentation);
181
- const requestDigest = digestCanonical(basis);
182
- const decisionId = decisionIdFor(input, requestDigest);
183
- return {
184
- ...basis,
185
- decisionId,
186
- requestDigest,
187
- subjectDigest,
188
- presentationDigest,
189
- createdAt: input.createdAt ?? new Date().toISOString(),
190
- };
198
+ validatePresentedText(title, "Human decision title");
199
+ validatePresentedChoices(input.contract.choices);
200
+ const prompt = input.prompt;
201
+ assertJsonValue(prompt.subject, "Human decision subject");
202
+ const presentation = normalizeDecisionPresentation(prompt.presentation);
203
+ const revision = prompt.revision ?? 1;
204
+ if (!Number.isInteger(revision) || revision < 1) {
205
+ throw new Error("Human decision revision must be a positive integer");
191
206
  }
192
- const prompt = input.prompt as Extract<HumanDecisionPrompt, { body: unknown }>;
193
- assertJsonValue(prompt.body, "Human decision body");
194
207
  const basis = {
195
208
  schema: "pi-workflows.human-decision-request.v1" as const,
196
209
  ...common,
197
- body: prompt.body,
210
+ subject: prompt.subject,
211
+ presentation,
212
+ revision,
198
213
  };
199
- const requestDigest = digest(basis);
214
+ const subjectDigest = digestCanonical(prompt.subject);
215
+ const presentationDigest = digestCanonical(presentation);
216
+ const requestDigest = digestCanonical(basis);
217
+ const decisionId = decisionIdFor(input, requestDigest);
200
218
  return {
201
219
  ...basis,
202
- decisionId: decisionIdFor(input, requestDigest),
220
+ decisionId,
203
221
  requestDigest,
204
- createdAt: input.createdAt ?? new Date().toISOString(),
222
+ subjectDigest,
223
+ presentationDigest,
224
+ createdAt,
205
225
  };
206
226
  }
207
227
 
@@ -220,13 +240,20 @@ function decisionIdFor(
220
240
  export function validateHumanDecisionResponse(
221
241
  request: HumanDecisionRequest,
222
242
  value: unknown,
243
+ ): HumanDecisionResponse {
244
+ return validateResponseForChoices(request.choices, value);
245
+ }
246
+
247
+ function validateResponseForChoices(
248
+ choices: HumanDecisionChoiceMap,
249
+ value: unknown,
223
250
  ): HumanDecisionResponse {
224
251
  const response = requireRecord(value, "Human decision response");
225
252
  const selected = response.choice;
226
- if (typeof selected !== "string" || !Object.hasOwn(request.choices, selected)) {
253
+ if (typeof selected !== "string" || !Object.hasOwn(choices, selected)) {
227
254
  throw new Error(`Human decision choice ${JSON.stringify(selected)} is not available`);
228
255
  }
229
- const selectedChoice = request.choices[selected];
256
+ const selectedChoice = choices[selected];
230
257
  if (!selectedChoice) throw new Error("Human decision choice contract is missing");
231
258
  if (selectedChoice.input === undefined) {
232
259
  if (response.input !== undefined) {
@@ -281,66 +308,157 @@ export function validateHumanDecisionSubmission(
281
308
  };
282
309
  }
283
310
 
284
- export function humanDecisionStateRoot(runsRoot: string): string {
285
- return path.basename(runsRoot) === "runs"
286
- ? path.join(path.dirname(runsRoot), "decisions")
287
- : `${runsRoot}.decisions`;
311
+ export function humanDecisionDatabasePath(homeDir?: string): string {
312
+ return workflowStatePath(homeDir);
288
313
  }
289
314
 
290
315
  export type HumanDecisionAcceptance =
291
- | { status: "accepted" | "adopted"; decision: AcceptedHumanDecision }
292
- | { status: "conflict"; decision: AcceptedHumanDecision };
316
+ | { status: "accepted" | "adopted"; decision: ResolvedHumanDecision }
317
+ | { status: "conflict"; decision: ResolvedHumanDecision };
293
318
 
294
319
  type HumanDecisionResolution =
295
320
  | {
296
321
  schema: "pi-workflows.human-decision-resolution.v1";
297
322
  outcome: "accepted";
298
- decision: Extract<
299
- AcceptedHumanDecision,
300
- { schema: "pi-workflows.human-decision-accepted.v1" }
301
- >;
323
+ decision: ResolvedHumanDecision;
302
324
  }
303
325
  | {
304
- schema: "pi-workflows.human-decision-resolution.v2";
305
- outcome: "accepted";
306
- decision: Extract<
307
- AcceptedHumanDecision,
308
- { schema: "pi-workflows.human-decision-accepted.v2" }
309
- >;
310
- }
311
- | {
312
- schema:
313
- | "pi-workflows.human-decision-resolution.v1"
314
- | "pi-workflows.human-decision-resolution.v2";
326
+ schema: "pi-workflows.human-decision-resolution.v1";
315
327
  outcome: "cancelled";
316
328
  cancellation: HumanDecisionCancellationRecord;
317
329
  };
318
330
 
331
+ const INCOMPATIBLE_HUMAN_DECISION_STATE =
332
+ "Human decision state uses an incompatible alpha contract; reset the affected workflow run and decision state.";
333
+
334
+ function requireCurrentDecision(value: unknown): ResolvedHumanDecision {
335
+ const decision = requireRecord(value, "Resolved human decision");
336
+ if (
337
+ decision.schema !== "pi-workflows.human-decision-accepted.v1" ||
338
+ !Object.hasOwn(decision, "subjectDigest") ||
339
+ !Object.hasOwn(decision, "presentationDigest") ||
340
+ !Object.hasOwn(decision, "revision")
341
+ ) {
342
+ throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
343
+ }
344
+ return value as ResolvedHumanDecision;
345
+ }
346
+
319
347
  export class HumanDecisionStore {
320
- readonly root: string;
348
+ readonly databasePath: string;
349
+ readonly state: StateDatabase;
350
+ private readonly ownsState: boolean;
351
+ private readonly authorityProvider:
352
+ | ((runId: string) => RunWriteAuthority | undefined)
353
+ | undefined;
321
354
 
322
- constructor(runsRoot: string) {
323
- this.root = humanDecisionStateRoot(runsRoot);
355
+ constructor(
356
+ databasePath: string = workflowStatePath(),
357
+ options: {
358
+ state?: StateDatabase;
359
+ authorityProvider?: (runId: string) => RunWriteAuthority | undefined;
360
+ readOnly?: boolean;
361
+ } = {},
362
+ ) {
363
+ this.ownsState = options.state === undefined;
364
+ this.state =
365
+ options.state ??
366
+ new StateDatabase({
367
+ filePath: databasePath,
368
+ mode: options.readOnly === true ? "read-only" : "read-write",
369
+ checkLegacyState: databasePath === workflowStatePath(),
370
+ });
371
+ this.databasePath = this.state.filePath;
372
+ this.authorityProvider = options.authorityProvider;
324
373
  }
325
374
 
326
- decisionDir(decisionId: string): string {
327
- assertId(decisionId, "decision id");
328
- return path.join(this.root, decisionId);
375
+ close(): void {
376
+ if (this.ownsState) this.state.close();
329
377
  }
330
378
 
331
379
  async createRequest(request: HumanDecisionRequest): Promise<"created" | "adopted"> {
332
380
  validateHumanDecisionRequestIntegrity(request);
333
- return await writeImmutableJson(
334
- path.join(this.decisionDir(request.decisionId), "request.json"),
335
- request,
336
- );
381
+ return this.state.transaction(() => {
382
+ const existing = this.readRequestRow(request.decisionId);
383
+ if (existing !== undefined) {
384
+ const stored = this.readRequestFromHash(existing.requestHash);
385
+ if (canonicalJson(stored) !== canonicalJson(request)) {
386
+ throw new Error("Immutable human decision request conflicts");
387
+ }
388
+ return "adopted";
389
+ }
390
+ const run = this.state.connection
391
+ .prepare("SELECT resource_id AS resourceId FROM runs WHERE run_id = ?")
392
+ .get(request.runId);
393
+ if (!isResourceIdRow(run)) throw new Error(`Human decision run is missing: ${request.runId}`);
394
+ const attempt = this.state.connection
395
+ .prepare("SELECT 1 AS present FROM node_attempts WHERE attempt_id = ? AND run_id = ?")
396
+ .get(request.attemptId, request.runId);
397
+ if (!isPresentRow(attempt)) {
398
+ throw new Error(`Human decision attempt is missing: ${request.attemptId}`);
399
+ }
400
+ const now = Date.parse(request.createdAt);
401
+ const resourceId = resourceIdFor("decision", request.decisionId);
402
+ const subjectHash = this.state.putJson(request.subject, now);
403
+ const presentationHash = this.state.putJson(request.presentation, now);
404
+ const choicesHash = this.state.putJson(request.choices, now);
405
+ const defaultHash =
406
+ request.defaultResponse === undefined
407
+ ? null
408
+ : this.state.putJson(request.defaultResponse, now);
409
+ const requestHash = this.state.putJson(request, now);
410
+ this.state.connection
411
+ .prepare(
412
+ `INSERT INTO resources(
413
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
414
+ ) VALUES (?, 'decision', ?, 1, ?, ?)`,
415
+ )
416
+ .run(resourceId, request.decisionId, now, now);
417
+ this.state.connection
418
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
419
+ .run(resourceId);
420
+ this.state.connection
421
+ .prepare(
422
+ `INSERT INTO human_decisions(
423
+ decision_id, resource_id, run_id, attempt_id, audience, title,
424
+ subject_hash, presentation_hash, choices_hash, request_digest,
425
+ presentation_revision, deadline_at, default_response_hash, request_hash, created_at
426
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
427
+ )
428
+ .run(
429
+ request.decisionId,
430
+ resourceId,
431
+ request.runId,
432
+ request.attemptId,
433
+ request.audience,
434
+ request.title,
435
+ subjectHash,
436
+ presentationHash,
437
+ choicesHash,
438
+ digestBuffer(request.requestDigest),
439
+ request.revision,
440
+ request.expiresAt === undefined ? null : Date.parse(request.expiresAt),
441
+ defaultHash,
442
+ requestHash,
443
+ now,
444
+ );
445
+ this.insertDecisionEvent(
446
+ resourceId,
447
+ 1,
448
+ "decision.requested",
449
+ "system",
450
+ null,
451
+ requestHash,
452
+ now,
453
+ );
454
+ return "created";
455
+ });
337
456
  }
338
457
 
339
458
  async readRequest(decisionId: string): Promise<HumanDecisionRequest | null> {
340
- const request = (await readJson(
341
- path.join(this.decisionDir(decisionId), "request.json"),
342
- )) as HumanDecisionRequest | null;
343
- return request === null ? null : validateHumanDecisionRequestIntegrity(request);
459
+ assertId(decisionId, "decision id");
460
+ const row = this.readRequestRow(decisionId);
461
+ return row === undefined ? null : this.readRequestFromHash(row.requestHash);
344
462
  }
345
463
 
346
464
  async recordDelivery(
@@ -348,18 +466,7 @@ export class HumanDecisionStore {
348
466
  channel: string,
349
467
  value: HumanDecisionDeliveryRecord,
350
468
  ): Promise<"created" | "adopted"> {
351
- const safeChannel = requireSimpleId(channel, "Human decision channel");
352
- const attemptId = requireString(value.attemptId, "Human decision delivery attempt");
353
- assertId(attemptId, "delivery attempt id");
354
- return await writeImmutableJson(
355
- path.join(
356
- this.decisionDir(request.decisionId),
357
- "deliveries",
358
- safeChannel,
359
- `${attemptId}.json`,
360
- ),
361
- value,
362
- );
469
+ return this.recordChannelMessage(request.decisionId, channel, "delivery", value);
363
470
  }
364
471
 
365
472
  async accept(
@@ -367,110 +474,82 @@ export class HumanDecisionStore {
367
474
  submission: HumanDecisionSubmission,
368
475
  ): Promise<HumanDecisionAcceptance> {
369
476
  validateHumanDecisionRequestIntegrity(request);
370
- const cancellation = await this.readCancellation(request.decisionId);
371
- if (cancellation !== null) {
372
- throw new Error(`Human decision request was ${cancellation.reason}`);
373
- }
374
477
  const normalized = validateHumanDecisionSubmission(request, submission);
375
- let attemptedAt = new Date().toISOString();
478
+ const attemptedAt = new Date().toISOString();
376
479
  const attemptId = digestHex({
377
480
  decisionId: request.decisionId,
378
481
  idempotencyKey: normalized.idempotencyKey,
379
482
  }).slice(0, 40);
380
- const attemptPath = path.join(
381
- this.decisionDir(request.decisionId),
382
- "answers",
383
- `${attemptId}.json`,
384
- );
385
- const attempt = {
386
- schema: "pi-workflows.human-decision-answer-attempt.v1",
387
- attemptId,
388
- attemptedAt,
389
- ...normalized,
390
- };
391
- const attemptWrite = await writeImmutableJson(attemptPath, attempt, false);
392
- if (attemptWrite === "adopted") {
393
- const existingAttempt = requireRecord(
394
- await readJson(attemptPath),
395
- "Existing human decision answer attempt",
396
- );
397
- const existingSubmission = {
398
- decisionId: existingAttempt.decisionId,
399
- requestDigest: existingAttempt.requestDigest,
400
- choice: existingAttempt.choice,
401
- ...(existingAttempt.input !== undefined ? { input: existingAttempt.input } : {}),
402
- source: existingAttempt.source,
403
- idempotencyKey: existingAttempt.idempotencyKey,
404
- };
405
- if (canonicalJson(existingSubmission) !== canonicalJson(normalized)) {
406
- throw new Error("Human decision idempotency key was reused for a different answer");
407
- }
408
- attemptedAt = requireString(existingAttempt.attemptedAt, "answer attempt time");
409
- }
410
- const response = validateHumanDecisionResponse(request, normalized);
411
- const commonDecision = {
483
+ const decision: ResolvedHumanDecision = {
484
+ schema: "pi-workflows.human-decision-accepted.v1",
412
485
  decisionId: request.decisionId,
413
486
  requestDigest: request.requestDigest,
414
- response,
487
+ response: validateHumanDecisionResponse(request, normalized),
488
+ provenance: "human",
415
489
  source: normalized.source,
416
490
  idempotencyKey: normalized.idempotencyKey,
417
491
  acceptedAt: attemptedAt,
418
- answerDigest: digest({ response, source: normalized.source }),
492
+ answerDigest: digest({
493
+ requestDigest: request.requestDigest,
494
+ response: validateHumanDecisionResponse(request, normalized),
495
+ source: normalized.source,
496
+ }),
497
+ subjectDigest: request.subjectDigest,
498
+ presentationDigest: request.presentationDigest,
499
+ revision: request.revision,
419
500
  };
420
- const decision: AcceptedHumanDecision =
421
- request.schema === "pi-workflows.human-decision-request.v2"
422
- ? {
423
- schema: "pi-workflows.human-decision-accepted.v2",
424
- ...commonDecision,
425
- subjectDigest: request.subjectDigest,
426
- presentationDigest: request.presentationDigest,
427
- revision: request.revision,
428
- }
429
- : { schema: "pi-workflows.human-decision-accepted.v1", ...commonDecision };
430
- const resolutionPath = path.join(this.decisionDir(request.decisionId), "resolution.json");
431
- const resolution = {
432
- schema:
433
- decision.schema === "pi-workflows.human-decision-accepted.v2"
434
- ? ("pi-workflows.human-decision-resolution.v2" as const)
435
- : ("pi-workflows.human-decision-resolution.v1" as const),
436
- outcome: "accepted" as const,
437
- decision,
438
- } as HumanDecisionResolution;
439
- const result = await writeImmutableJson(resolutionPath, resolution, false);
440
- const winner =
441
- result === "created"
442
- ? resolution
443
- : ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
444
- if (winner === null) throw new Error("Human decision resolution became unreadable");
445
- if (winner.outcome === "cancelled") {
446
- throw new Error(`Human decision request was ${winner.cancellation.reason}`);
447
- }
448
- const existing = winner.decision;
449
- await writeImmutableJson(
450
- path.join(this.decisionDir(request.decisionId), "accepted.json"),
451
- existing,
452
- );
453
- if (result === "created") return { status: "accepted", decision: existing };
454
- if (canonicalJson(existing) === canonicalJson(decision)) {
455
- return { status: "adopted", decision: existing };
501
+ return this.attemptAcceptance(request, decision, {
502
+ attemptId,
503
+ source: "human",
504
+ actorId: normalized.source.actorId,
505
+ channel: normalized.source.channel,
506
+ candidate: normalized,
507
+ });
508
+ }
509
+
510
+ async resolveTimeout(
511
+ request: HumanDecisionRequest,
512
+ now = new Date(),
513
+ ): Promise<HumanDecisionAcceptance> {
514
+ validateHumanDecisionRequestIntegrity(request);
515
+ if (request.expiresAt === undefined || request.defaultResponse === undefined) {
516
+ throw new Error("Human decision request has no timeout default");
456
517
  }
457
- if (
458
- existing.idempotencyKey === decision.idempotencyKey &&
459
- canonicalJson(existing.response) === canonicalJson(decision.response) &&
460
- canonicalJson(existing.source) === canonicalJson(decision.source)
461
- ) {
462
- return { status: "adopted", decision: existing };
518
+ if (Date.parse(request.expiresAt) > now.getTime()) {
519
+ throw new Error("Human decision timeout default is not eligible yet");
463
520
  }
464
- return { status: "conflict", decision: existing };
521
+ this.assertRunOwner(request.runId, now.getTime());
522
+ const response = validateHumanDecisionResponse(request, request.defaultResponse);
523
+ const decision: ResolvedHumanDecision = {
524
+ schema: "pi-workflows.human-decision-accepted.v1",
525
+ decisionId: request.decisionId,
526
+ requestDigest: request.requestDigest,
527
+ response,
528
+ provenance: "timeout",
529
+ acceptedAt: now.toISOString(),
530
+ answerDigest: digest({
531
+ provenance: "timeout",
532
+ requestDigest: request.requestDigest,
533
+ response,
534
+ }),
535
+ subjectDigest: request.subjectDigest,
536
+ presentationDigest: request.presentationDigest,
537
+ revision: request.revision,
538
+ };
539
+ const actorId = this.authorityProvider?.(request.runId)?.ownerId;
540
+ return this.attemptAcceptance(request, decision, {
541
+ attemptId: digestHex({ decisionId: request.decisionId, provenance: "timeout" }).slice(0, 40),
542
+ source: "policy",
543
+ ...(actorId === undefined ? {} : { actorId }),
544
+ candidate: response,
545
+ });
465
546
  }
466
547
 
467
548
  async listDeliveries(
468
549
  decisionId: string,
469
550
  channel: string,
470
551
  ): Promise<HumanDecisionDeliveryRecord[]> {
471
- const safeChannel = requireSimpleId(channel, "Human decision channel");
472
- const directory = path.join(this.decisionDir(decisionId), "deliveries", safeChannel);
473
- return (await readJsonDirectory(directory)) as HumanDecisionDeliveryRecord[];
552
+ return this.listChannelMessages<HumanDecisionDeliveryRecord>(decisionId, channel, "delivery");
474
553
  }
475
554
 
476
555
  async recordSettlement(
@@ -478,26 +557,18 @@ export class HumanDecisionStore {
478
557
  channel: string,
479
558
  value: HumanDecisionSettlementRecord,
480
559
  ): Promise<"created" | "adopted"> {
481
- const safeChannel = requireSimpleId(channel, "Human decision channel");
482
- assertId(value.attemptId, "settlement attempt id");
483
- return await writeImmutableJson(
484
- path.join(
485
- this.decisionDir(decisionId),
486
- "settlements",
487
- safeChannel,
488
- `${value.attemptId}.json`,
489
- ),
490
- value,
491
- );
560
+ return this.recordChannelMessage(decisionId, channel, "settlement", value);
492
561
  }
493
562
 
494
563
  async listSettlements(
495
564
  decisionId: string,
496
565
  channel: string,
497
566
  ): Promise<HumanDecisionSettlementRecord[]> {
498
- const safeChannel = requireSimpleId(channel, "Human decision channel");
499
- const directory = path.join(this.decisionDir(decisionId), "settlements", safeChannel);
500
- return (await readJsonDirectory(directory)) as HumanDecisionSettlementRecord[];
567
+ return this.listChannelMessages<HumanDecisionSettlementRecord>(
568
+ decisionId,
569
+ channel,
570
+ "settlement",
571
+ );
501
572
  }
502
573
 
503
574
  async cancel(
@@ -505,112 +576,679 @@ export class HumanDecisionStore {
505
576
  reason: HumanDecisionCancellationRecord["reason"],
506
577
  ): Promise<"created" | "adopted"> {
507
578
  validateHumanDecisionRequestIntegrity(request);
508
- if ((await this.readAccepted(request.decisionId)) !== null) {
509
- throw new Error("Accepted human decision cannot be cancelled");
579
+ if (reason === "expired" && request.defaultResponse !== undefined) {
580
+ throw new Error("A defaulted human decision must resolve through its timeout policy");
581
+ }
582
+ return this.state.transaction(() => {
583
+ this.requireStoredRequest(request);
584
+ const winner = this.readResolution(request.decisionId);
585
+ if (winner !== null) {
586
+ if (winner.outcome === "cancelled") {
587
+ if (winner.cancellation.reason === reason) return "adopted";
588
+ throw new Error(
589
+ `Human decision was already cancelled with reason ${winner.cancellation.reason}`,
590
+ );
591
+ }
592
+ throw new Error(`Human decision was already accepted by ${winner.decision.provenance}`);
593
+ }
594
+ const now = Date.now();
595
+ if (reason === "expired") {
596
+ if (request.expiresAt === undefined) {
597
+ throw new Error("Human decision request has no expiry");
598
+ }
599
+ if (Date.parse(request.expiresAt) > now) {
600
+ throw new Error("Human decision expiry cancellation is not eligible yet");
601
+ }
602
+ this.assertRunOwner(request.runId, now);
603
+ }
604
+ const record: HumanDecisionCancellationRecord = {
605
+ schema: "pi-workflows.human-decision-cancellation.v1",
606
+ decisionId: request.decisionId,
607
+ requestDigest: request.requestDigest,
608
+ cancelledAt: new Date(now).toISOString(),
609
+ reason,
610
+ };
611
+ const provenance = reason === "expired" ? "expired_no_default" : "explicit_cancel";
612
+ this.state.connection
613
+ .prepare(
614
+ `INSERT INTO human_decision_resolutions(
615
+ decision_id, outcome, provenance, response_hash, reason, channel,
616
+ actor_id, request_digest, resolved_at
617
+ ) VALUES (?, 'cancelled', ?, NULL, ?, NULL, NULL, ?, ?)`,
618
+ )
619
+ .run(request.decisionId, provenance, reason, digestBuffer(request.requestDigest), now);
620
+ const row = this.requireDecisionResource(request.decisionId);
621
+ this.bumpDecision(row.resourceId, row.revision, now);
622
+ const recordHash = this.state.putJson(record, now);
623
+ this.insertDecisionEvent(
624
+ row.resourceId,
625
+ row.revision + 1,
626
+ "decision.cancelled",
627
+ reason === "expired" ? "policy" : "control",
628
+ null,
629
+ recordHash,
630
+ now,
631
+ );
632
+ this.enqueueResolutionEffects(row.resourceId, row.revision + 1, request, "cancelled", now);
633
+ return "created";
634
+ });
635
+ }
636
+
637
+ async readCancellation(decisionId: string): Promise<HumanDecisionCancellationRecord | null> {
638
+ const resolution = this.readResolution(decisionId);
639
+ return resolution?.outcome === "cancelled" ? resolution.cancellation : null;
640
+ }
641
+
642
+ async readResolved(decisionId: string): Promise<ResolvedHumanDecision | null> {
643
+ const resolution = this.readResolution(decisionId);
644
+ return resolution?.outcome === "accepted" ? resolution.decision : null;
645
+ }
646
+
647
+ async recordContinuation(
648
+ decisionId: string,
649
+ value: HumanDecisionContinuationRecord,
650
+ ): Promise<"created" | "adopted"> {
651
+ return this.state.transaction(() => {
652
+ const existing = this.readContinuationRow(decisionId);
653
+ if (existing !== undefined) {
654
+ const stored = this.state.readJson(existing.recordHash) as HumanDecisionContinuationRecord;
655
+ if (canonicalJson(stored) !== canonicalJson(value)) {
656
+ throw new Error("Immutable human decision continuation conflicts");
657
+ }
658
+ return "adopted";
659
+ }
660
+ const resolution = this.readResolution(decisionId);
661
+ if (resolution?.outcome !== "accepted") {
662
+ throw new Error("A continuation requires an accepted human decision");
663
+ }
664
+ const recordHash = this.state.putJson(value, Date.parse(value.createdAt));
665
+ this.state.connection
666
+ .prepare(
667
+ `INSERT INTO continuations(
668
+ decision_id, parent_run_id, continuation_run_id, response_hash, created_at
669
+ ) VALUES (?, ?, ?, ?, ?)`,
670
+ )
671
+ .run(decisionId, value.parentRunId, value.runId, recordHash, Date.parse(value.createdAt));
672
+ return "created";
673
+ });
674
+ }
675
+
676
+ async readContinuation(decisionId: string): Promise<HumanDecisionContinuationRecord | null> {
677
+ const row = this.readContinuationRow(decisionId);
678
+ return row === undefined
679
+ ? null
680
+ : (this.state.readJson(row.recordHash) as HumanDecisionContinuationRecord);
681
+ }
682
+
683
+ markEffectApplied(
684
+ decisionId: string,
685
+ effectType: "decision.continue" | "decision.cancel_parent" | "decision.settle_presentations",
686
+ ): void {
687
+ const rows = this.state.connection
688
+ .prepare(
689
+ `SELECT e.effect_id AS effectId, e.resource_id AS resourceId
690
+ FROM effects e
691
+ JOIN human_decisions d ON d.resource_id = e.source_resource_id
692
+ WHERE d.decision_id = ? AND e.effect_type = ? AND e.status = 'pending'`,
693
+ )
694
+ .all(decisionId, effectType);
695
+ for (const row of rows) {
696
+ /* istanbul ignore if -- exact schema and internal query shape */
697
+ if (!isEffectIdentityRecord(row)) continue;
698
+ this.state.transaction(() => {
699
+ const now = Date.now();
700
+ const revisionRow = this.state.connection
701
+ .prepare("SELECT revision FROM resources WHERE resource_id = ?")
702
+ .get(row.resourceId);
703
+ /* istanbul ignore if -- exact schema and internal query shape */
704
+ if (!isRevisionRecord(revisionRow)) throw new Error("Decision effect resource is missing");
705
+ this.state.connection
706
+ .prepare(
707
+ `UPDATE effects SET status = 'applied', updated_at = ?, settled_at = ?
708
+ WHERE effect_id = ? AND status = 'pending'`,
709
+ )
710
+ .run(now, now, row.effectId);
711
+ this.state.connection
712
+ .prepare(
713
+ `UPDATE resources SET revision = revision + 1, updated_at = ?
714
+ WHERE resource_id = ? AND revision = ?`,
715
+ )
716
+ .run(now, row.resourceId, revisionRow.revision);
717
+ const payloadHash = this.state.putJson({ decisionId, effectType }, now);
718
+ this.insertDecisionEvent(
719
+ row.resourceId,
720
+ revisionRow.revision + 1,
721
+ "effect.applied",
722
+ "system",
723
+ null,
724
+ payloadHash,
725
+ now,
726
+ );
727
+ });
728
+ }
729
+ }
730
+
731
+ async listRequests(): Promise<HumanDecisionRequest[]> {
732
+ const rows = this.state.connection
733
+ .prepare("SELECT request_hash AS requestHash FROM human_decisions ORDER BY created_at")
734
+ .all();
735
+ return rows.filter(isRequestHashRow).map((row) => this.readRequestFromHash(row.requestHash));
736
+ }
737
+
738
+ private attemptAcceptance(
739
+ request: HumanDecisionRequest,
740
+ decision: ResolvedHumanDecision,
741
+ attempt: {
742
+ attemptId: string;
743
+ source: "human" | "policy";
744
+ actorId?: string;
745
+ channel?: string;
746
+ candidate: unknown;
747
+ },
748
+ ): HumanDecisionAcceptance {
749
+ return this.state.transaction(() => {
750
+ this.requireStoredRequest(request);
751
+ const timing = this.state.connection
752
+ .prepare("SELECT deadline_at AS deadlineAt FROM human_decisions WHERE decision_id = ?")
753
+ .get(request.decisionId);
754
+ if (!isDeadlineRow(timing)) throw new Error("Human decision request is missing");
755
+ const arbitrationTime =
756
+ attempt.source === "policy" ? Date.parse(decision.acceptedAt) : Date.now();
757
+ if (
758
+ attempt.source === "human" &&
759
+ timing.deadlineAt !== null &&
760
+ arbitrationTime > timing.deadlineAt
761
+ ) {
762
+ throw new Error("Human decision request expired before the answer was accepted");
763
+ }
764
+ if (
765
+ attempt.source === "policy" &&
766
+ (timing.deadlineAt === null || arbitrationTime < timing.deadlineAt)
767
+ ) {
768
+ throw new Error("Human decision timeout default is not eligible yet");
769
+ }
770
+ if (attempt.source === "policy") this.assertRunOwner(request.runId, arbitrationTime);
771
+ const candidateHash = this.state.putJson(attempt.candidate);
772
+ const existingSubmission = this.state.connection
773
+ .prepare(
774
+ `SELECT candidate_hash AS candidateHash
775
+ FROM human_decision_submissions WHERE decision_id = ? AND attempt_id = ?`,
776
+ )
777
+ .get(request.decisionId, attempt.attemptId);
778
+ if (
779
+ isCandidateHashRow(existingSubmission) &&
780
+ !existingSubmission.candidateHash.equals(candidateHash)
781
+ ) {
782
+ throw new Error("Human decision submission idempotency key conflicts");
783
+ }
784
+ this.state.connection
785
+ .prepare(
786
+ `INSERT INTO human_decision_submissions(
787
+ decision_id, attempt_id, source, actor_id, channel,
788
+ candidate_hash, outcome, submitted_at
789
+ ) VALUES (?, ?, ?, ?, ?, ?, 'rejected', ?)
790
+ ON CONFLICT(decision_id, attempt_id) DO NOTHING`,
791
+ )
792
+ .run(
793
+ request.decisionId,
794
+ attempt.attemptId,
795
+ attempt.source,
796
+ attempt.actorId ?? null,
797
+ attempt.channel ?? null,
798
+ candidateHash,
799
+ Date.parse(decision.acceptedAt),
800
+ );
801
+ const winner = this.readResolution(request.decisionId);
802
+ if (winner !== null) {
803
+ this.markSubmission(request.decisionId, attempt.attemptId, "already_resolved", null);
804
+ if (winner.outcome === "cancelled") {
805
+ throw new Error(`Human decision request was ${winner.cancellation.reason}`);
806
+ }
807
+ return canonicalJson(winner.decision) === canonicalJson(decision) ||
808
+ sameHumanAnswer(winner.decision, decision)
809
+ ? { status: "adopted", decision: winner.decision }
810
+ : { status: "conflict", decision: winner.decision };
811
+ }
812
+ const now = Date.parse(decision.acceptedAt);
813
+ const responseHash = this.state.putJson(decision, now);
814
+ this.state.connection
815
+ .prepare(
816
+ `INSERT INTO human_decision_resolutions(
817
+ decision_id, outcome, provenance, response_hash, reason, channel,
818
+ actor_id, request_digest, resolved_at
819
+ ) VALUES (?, 'accepted', ?, ?, NULL, ?, ?, ?, ?)`,
820
+ )
821
+ .run(
822
+ request.decisionId,
823
+ decision.provenance === "timeout" ? "timeout_policy" : "human",
824
+ responseHash,
825
+ attempt.channel ?? null,
826
+ attempt.actorId ?? null,
827
+ digestBuffer(request.requestDigest),
828
+ now,
829
+ );
830
+ const row = this.requireDecisionResource(request.decisionId);
831
+ this.bumpDecision(row.resourceId, row.revision, now);
832
+ this.insertDecisionEvent(
833
+ row.resourceId,
834
+ row.revision + 1,
835
+ "decision.accepted",
836
+ decision.provenance === "timeout" ? "policy" : "human",
837
+ attempt.actorId ?? null,
838
+ responseHash,
839
+ now,
840
+ );
841
+ this.enqueueResolutionEffects(row.resourceId, row.revision + 1, request, "accepted", now);
842
+ this.markSubmission(request.decisionId, attempt.attemptId, "won", responseHash);
843
+ return { status: "accepted", decision };
844
+ });
845
+ }
846
+
847
+ private readResolution(decisionId: string): HumanDecisionResolution | null {
848
+ const row = this.state.connection
849
+ .prepare(
850
+ `SELECT outcome, provenance, response_hash AS responseHash, reason,
851
+ request_digest AS requestDigest, resolved_at AS resolvedAt
852
+ FROM human_decision_resolutions WHERE decision_id = ?`,
853
+ )
854
+ .get(decisionId);
855
+ if (!isResolutionRow(row)) return null;
856
+ const request = this.readRequestRow(decisionId);
857
+ if (request === undefined) throw new Error("Human decision request is missing");
858
+ const storedRequest = this.readRequestFromHash(request.requestHash);
859
+ if (!row.requestDigest.equals(digestBuffer(storedRequest.requestDigest))) {
860
+ throw new Error("Human decision resolution request digest conflicts");
861
+ }
862
+ if (row.outcome === "accepted") {
863
+ if (row.responseHash === null) throw new Error("Accepted decision response is missing");
864
+ const decision = this.state.readJson(row.responseHash) as ResolvedHumanDecision;
865
+ return {
866
+ schema: "pi-workflows.human-decision-resolution.v1",
867
+ outcome: "accepted",
868
+ decision: requireCurrentDecision(decision),
869
+ };
510
870
  }
511
- const filePath = path.join(this.decisionDir(request.decisionId), "cancelled.json");
512
- const record: HumanDecisionCancellationRecord = {
871
+ const cancellation: HumanDecisionCancellationRecord = {
513
872
  schema: "pi-workflows.human-decision-cancellation.v1",
514
- decisionId: request.decisionId,
515
- requestDigest: request.requestDigest,
516
- cancelledAt: new Date().toISOString(),
517
- reason,
873
+ decisionId,
874
+ requestDigest: storedRequest.requestDigest,
875
+ cancelledAt: new Date(row.resolvedAt).toISOString(),
876
+ reason: row.reason === "expired" ? "expired" : "cancelled",
518
877
  };
519
- const resolutionPath = path.join(this.decisionDir(request.decisionId), "resolution.json");
520
- const resolution: HumanDecisionResolution = {
521
- schema:
522
- request.schema === "pi-workflows.human-decision-request.v2"
523
- ? "pi-workflows.human-decision-resolution.v2"
524
- : "pi-workflows.human-decision-resolution.v1",
878
+ return {
879
+ schema: "pi-workflows.human-decision-resolution.v1",
525
880
  outcome: "cancelled",
526
- cancellation: record,
881
+ cancellation,
527
882
  };
528
- const result = await writeImmutableJson(resolutionPath, resolution, false);
529
- const winner =
530
- result === "created"
531
- ? resolution
532
- : ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
533
- if (winner === null) throw new Error("Human decision resolution became unreadable");
534
- if (winner.outcome === "accepted") {
535
- throw new Error("Accepted human decision cannot be cancelled");
883
+ }
884
+
885
+ private requireStoredRequest(request: HumanDecisionRequest): void {
886
+ const row = this.readRequestRow(request.decisionId);
887
+ if (row === undefined) throw new Error("Human decision request is not durable");
888
+ const stored = this.readRequestFromHash(row.requestHash);
889
+ if (canonicalJson(stored) !== canonicalJson(request)) {
890
+ throw new Error("Human decision request does not match durable state");
891
+ }
892
+ }
893
+
894
+ private readRequestRow(decisionId: string): { requestHash: Buffer } | undefined {
895
+ const row = this.state.connection
896
+ .prepare("SELECT request_hash AS requestHash FROM human_decisions WHERE decision_id = ?")
897
+ .get(decisionId);
898
+ return isRequestHashRow(row) ? row : undefined;
899
+ }
900
+
901
+ private readRequestFromHash(hash: Buffer): HumanDecisionRequest {
902
+ return validateHumanDecisionRequestIntegrity(this.state.readJson(hash) as HumanDecisionRequest);
903
+ }
904
+
905
+ private requireDecisionResource(decisionId: string): { resourceId: string; revision: number } {
906
+ const row = this.state.connection
907
+ .prepare(
908
+ `SELECT d.resource_id AS resourceId, r.revision
909
+ FROM human_decisions d JOIN resources r ON r.resource_id = d.resource_id
910
+ WHERE d.decision_id = ?`,
911
+ )
912
+ .get(decisionId);
913
+ if (!isDecisionResourceRow(row)) throw new Error(`Human decision is missing: ${decisionId}`);
914
+ return row;
915
+ }
916
+
917
+ private bumpDecision(resourceId: string, expectedRevision: number, now: number): void {
918
+ const result = this.state.connection
919
+ .prepare(
920
+ `UPDATE resources SET revision = revision + 1, updated_at = ?
921
+ WHERE resource_id = ? AND revision = ?`,
922
+ )
923
+ .run(now, resourceId, expectedRevision);
924
+ if (result.changes !== 1) throw new Error("Human decision revision conflict");
925
+ }
926
+
927
+ private insertDecisionEvent(
928
+ resourceId: string,
929
+ revision: number,
930
+ eventType: string,
931
+ actorType: string,
932
+ actorId: string | null,
933
+ payloadHash: Buffer,
934
+ now: number,
935
+ ): void {
936
+ this.state.connection
937
+ .prepare(
938
+ `INSERT INTO events(
939
+ event_id, resource_id, resource_revision, event_type,
940
+ actor_type, actor_id, payload_hash, recorded_at
941
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
942
+ )
943
+ .run(
944
+ `event-${randomUUID()}`,
945
+ resourceId,
946
+ revision,
947
+ eventType,
948
+ actorType,
949
+ actorId,
950
+ payloadHash,
951
+ now,
952
+ );
953
+ }
954
+
955
+ private enqueueResolutionEffects(
956
+ decisionResourceId: string,
957
+ revision: number,
958
+ request: HumanDecisionRequest,
959
+ outcome: "accepted" | "cancelled",
960
+ now: number,
961
+ ): void {
962
+ const types =
963
+ outcome === "accepted"
964
+ ? ["decision.continue", "decision.settle_presentations"]
965
+ : ["decision.cancel_parent", "decision.settle_presentations"];
966
+ for (const effectType of types) {
967
+ const idempotencyKey = request.decisionId;
968
+ const effectId = `effect-${digestHex({ decisionId: request.decisionId, effectType }).slice(0, 40)}`;
969
+ const effectResourceId = resourceIdFor("effect", effectId);
970
+ const payloadHash = this.state.putJson(
971
+ { decisionId: request.decisionId, runId: request.runId, outcome },
972
+ now,
973
+ );
974
+ this.state.connection
975
+ .prepare(
976
+ `INSERT INTO resources(
977
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
978
+ ) VALUES (?, 'effect', ?, 0, ?, ?)`,
979
+ )
980
+ .run(effectResourceId, effectId, now, now);
981
+ this.state.connection
982
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
983
+ .run(effectResourceId);
984
+ this.state.connection
985
+ .prepare(
986
+ `INSERT INTO effects(
987
+ effect_id, resource_id, source_resource_id, source_revision,
988
+ effect_type, idempotency_key, payload_hash, owner_scope,
989
+ status, attempt_count, created_at, updated_at
990
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'run', 'pending', 0, ?, ?)`,
991
+ )
992
+ .run(
993
+ effectId,
994
+ effectResourceId,
995
+ decisionResourceId,
996
+ revision,
997
+ effectType,
998
+ idempotencyKey,
999
+ payloadHash,
1000
+ now,
1001
+ now,
1002
+ );
536
1003
  }
537
- const existing = winner.cancellation;
1004
+ }
1005
+
1006
+ private assertRunOwner(runId: string, now: number): void {
1007
+ const authority = this.authorityProvider?.(runId);
1008
+ if (authority === undefined) {
1009
+ throw new Error("Timeout policy requires the current run owner");
1010
+ }
1011
+ const row = this.state.connection
1012
+ .prepare(
1013
+ `SELECT l.generation, l.owner_type AS ownerType, l.owner_id AS ownerId,
1014
+ l.token_hash AS tokenHash, l.expires_at AS expiresAt
1015
+ FROM runs r JOIN leases l ON l.resource_id = r.resource_id
1016
+ WHERE r.run_id = ?`,
1017
+ )
1018
+ .get(runId);
538
1019
  if (
539
- existing.decisionId !== request.decisionId ||
540
- existing.requestDigest !== request.requestDigest ||
541
- existing.reason !== reason
1020
+ !isOwnerRow(row) ||
1021
+ row.ownerType !== authority.ownerType ||
1022
+ row.ownerId !== authority.ownerId ||
1023
+ row.generation !== authority.generation ||
1024
+ row.tokenHash === null ||
1025
+ !row.tokenHash.equals(tokenHash(authority.token)) ||
1026
+ row.expiresAt === null ||
1027
+ row.expiresAt <= now
542
1028
  ) {
543
- throw new Error("Immutable human decision cancellation conflicts");
1029
+ throw new Error("Timeout policy run ownership is stale");
544
1030
  }
545
- await writeImmutableJson(filePath, existing);
546
- return result;
547
1031
  }
548
1032
 
549
- async readCancellation(decisionId: string): Promise<HumanDecisionCancellationRecord | null> {
550
- const stored = (await readJson(
551
- path.join(this.decisionDir(decisionId), "cancelled.json"),
552
- )) as HumanDecisionCancellationRecord | null;
553
- if (stored !== null) return stored;
554
- const resolution = (await readJson(
555
- path.join(this.decisionDir(decisionId), "resolution.json"),
556
- )) as HumanDecisionResolution | null;
557
- if (resolution?.outcome !== "cancelled") return null;
558
- await writeImmutableJson(
559
- path.join(this.decisionDir(decisionId), "cancelled.json"),
560
- resolution.cancellation,
561
- );
562
- return resolution.cancellation;
563
- }
564
-
565
- async readAccepted(decisionId: string): Promise<AcceptedHumanDecision | null> {
566
- const stored = (await readJson(
567
- path.join(this.decisionDir(decisionId), "accepted.json"),
568
- )) as AcceptedHumanDecision | null;
569
- if (stored !== null) return stored;
570
- const resolution = (await readJson(
571
- path.join(this.decisionDir(decisionId), "resolution.json"),
572
- )) as HumanDecisionResolution | null;
573
- if (resolution?.outcome !== "accepted") return null;
574
- await writeImmutableJson(
575
- path.join(this.decisionDir(decisionId), "accepted.json"),
576
- resolution.decision,
577
- );
578
- return resolution.decision;
1033
+ private recordChannelMessage<
1034
+ T extends HumanDecisionDeliveryRecord | HumanDecisionSettlementRecord,
1035
+ >(
1036
+ decisionId: string,
1037
+ channel: string,
1038
+ purpose: "delivery" | "settlement",
1039
+ value: T,
1040
+ ): "created" | "adopted" {
1041
+ const safeChannel = requireSimpleId(channel, "Human decision channel");
1042
+ assertId(value.attemptId, `${purpose} attempt id`);
1043
+ return this.state.transaction(() => {
1044
+ if (this.readRequestRow(decisionId) === undefined) {
1045
+ throw new Error(`Human decision is missing: ${decisionId}`);
1046
+ }
1047
+ const channelId = this.ensureChannel(safeChannel, Date.parse(value.createdAt));
1048
+ const messageId = `message-${digestHex({
1049
+ decisionId,
1050
+ channel: safeChannel,
1051
+ purpose,
1052
+ attemptId: value.attemptId,
1053
+ phase: "phase" in value ? value.phase : undefined,
1054
+ partIndex: "partIndex" in value ? value.partIndex : undefined,
1055
+ }).slice(0, 40)}`;
1056
+ const contentHash = this.state.putJson(value, Date.parse(value.createdAt));
1057
+ const existing = this.state.connection
1058
+ .prepare("SELECT content_hash AS contentHash FROM channel_messages WHERE message_id = ?")
1059
+ .get(messageId);
1060
+ if (isContentHashRow(existing)) {
1061
+ if (!existing.contentHash.equals(contentHash)) {
1062
+ throw new Error(`Immutable human decision ${purpose} conflicts`);
1063
+ }
1064
+ return "adopted";
1065
+ }
1066
+ const status = channelMessageStatus(value);
1067
+ this.state.connection
1068
+ .prepare(
1069
+ `INSERT INTO channel_messages(
1070
+ message_id, channel_id, decision_id, purpose, content_hash,
1071
+ status, created_at, updated_at
1072
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1073
+ )
1074
+ .run(
1075
+ messageId,
1076
+ channelId,
1077
+ decisionId,
1078
+ purpose,
1079
+ contentHash,
1080
+ status,
1081
+ Date.parse(value.createdAt),
1082
+ "finishedAt" in value && value.finishedAt !== undefined
1083
+ ? Date.parse(value.finishedAt)
1084
+ : Date.parse(value.createdAt),
1085
+ );
1086
+ return "created";
1087
+ });
579
1088
  }
580
1089
 
581
- async recordContinuation(
1090
+ private listChannelMessages<T>(
582
1091
  decisionId: string,
583
- value: HumanDecisionContinuationRecord,
584
- ): Promise<"created" | "adopted"> {
585
- return await writeImmutableJson(
586
- path.join(this.decisionDir(decisionId), "continuation.json"),
587
- value,
588
- );
1092
+ channel: string,
1093
+ purpose: "delivery" | "settlement",
1094
+ ): T[] {
1095
+ const channelId = channelIdFor(requireSimpleId(channel, "Human decision channel"));
1096
+ const rows = this.state.connection
1097
+ .prepare(
1098
+ `SELECT content_hash AS contentHash
1099
+ FROM channel_messages
1100
+ WHERE decision_id = ? AND channel_id = ? AND purpose = ?
1101
+ ORDER BY created_at, message_id`,
1102
+ )
1103
+ .all(decisionId, channelId, purpose);
1104
+ return rows.filter(isContentHashRow).map((row) => this.state.readJson(row.contentHash) as T);
589
1105
  }
590
1106
 
591
- async readContinuation(decisionId: string): Promise<HumanDecisionContinuationRecord | null> {
592
- return (await readJson(
593
- path.join(this.decisionDir(decisionId), "continuation.json"),
594
- )) as HumanDecisionContinuationRecord | null;
1107
+ private ensureChannel(channel: string, now: number): string {
1108
+ const channelId = channelIdFor(channel);
1109
+ const resourceId = resourceIdFor("channel", channelId);
1110
+ this.state.connection
1111
+ .prepare(
1112
+ `INSERT INTO resources(
1113
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
1114
+ ) VALUES (?, 'channel', ?, 1, ?, ?)
1115
+ ON CONFLICT(resource_type, aggregate_key) DO NOTHING`,
1116
+ )
1117
+ .run(resourceId, channelId, now, now);
1118
+ this.state.connection
1119
+ .prepare(
1120
+ `INSERT INTO leases(resource_id, generation)
1121
+ VALUES (?, 0) ON CONFLICT(resource_id) DO NOTHING`,
1122
+ )
1123
+ .run(resourceId);
1124
+ this.state.connection
1125
+ .prepare(
1126
+ `INSERT INTO channels(channel_id, resource_id, adapter_type, profile_key, created_at)
1127
+ VALUES (?, ?, ?, ?, ?)
1128
+ ON CONFLICT(channel_id) DO NOTHING`,
1129
+ )
1130
+ .run(channelId, resourceId, channel.split("-")[0] ?? channel, channel, now);
1131
+ return channelId;
595
1132
  }
596
1133
 
597
- async listRequests(): Promise<HumanDecisionRequest[]> {
598
- let entries: Dirent[];
599
- try {
600
- entries = await fs.readdir(this.root, { withFileTypes: true });
601
- } catch (error) {
602
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
603
- throw error;
604
- }
605
- const requests = await Promise.all(
606
- entries
607
- .filter((entry) => entry.isDirectory())
608
- .map(async (entry) => await this.readRequest(entry.name)),
609
- );
610
- return requests.filter((request): request is HumanDecisionRequest => request !== null);
1134
+ private markSubmission(
1135
+ decisionId: string,
1136
+ attemptId: string,
1137
+ outcome: "won" | "adopted" | "already_resolved" | "rejected",
1138
+ resultHash: Buffer | null,
1139
+ ): void {
1140
+ this.state.connection
1141
+ .prepare(
1142
+ `UPDATE human_decision_submissions SET outcome = ?, result_hash = ?
1143
+ WHERE decision_id = ? AND attempt_id = ?`,
1144
+ )
1145
+ .run(outcome, resultHash, decisionId, attemptId);
1146
+ }
1147
+
1148
+ private readContinuationRow(decisionId: string): { recordHash: Buffer } | undefined {
1149
+ const row = this.state.connection
1150
+ .prepare("SELECT response_hash AS recordHash FROM continuations WHERE decision_id = ?")
1151
+ .get(decisionId);
1152
+ return isContinuationRow(row) ? row : undefined;
611
1153
  }
612
1154
  }
613
1155
 
1156
+ function digestBuffer(value: string): Buffer {
1157
+ const hex = value.startsWith("sha256:") ? value.slice(7) : value;
1158
+ if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Expected a SHA-256 digest");
1159
+ return Buffer.from(hex, "hex");
1160
+ }
1161
+
1162
+ function channelIdFor(channel: string): string {
1163
+ return `channel-${digestHex(channel).slice(0, 40)}`;
1164
+ }
1165
+
1166
+ function sameHumanAnswer(left: ResolvedHumanDecision, right: ResolvedHumanDecision): boolean {
1167
+ return (
1168
+ left.provenance === "human" &&
1169
+ right.provenance === "human" &&
1170
+ left.idempotencyKey === right.idempotencyKey &&
1171
+ canonicalJson(left.response) === canonicalJson(right.response) &&
1172
+ canonicalJson(left.source) === canonicalJson(right.source)
1173
+ );
1174
+ }
1175
+
1176
+ function channelMessageStatus(
1177
+ value: HumanDecisionDeliveryRecord | HumanDecisionSettlementRecord,
1178
+ ): "pending" | "confirmed" | "failed" | "ambiguous" {
1179
+ if (value.state === "confirmed") return "confirmed";
1180
+ if (value.state === "failed") return "failed";
1181
+ if (value.state === "unknown") return "ambiguous";
1182
+ return "pending";
1183
+ }
1184
+
1185
+ type ResolutionRow = {
1186
+ outcome: "accepted" | "cancelled";
1187
+ provenance: string;
1188
+ responseHash: Buffer | null;
1189
+ reason: string | null;
1190
+ requestDigest: Buffer;
1191
+ resolvedAt: number;
1192
+ };
1193
+
1194
+ function isResourceIdRow(value: unknown): value is { resourceId: string } {
1195
+ return isRecordValue(value);
1196
+ }
1197
+
1198
+ function isPresentRow(value: unknown): value is { present: number } {
1199
+ return isRecordValue(value);
1200
+ }
1201
+
1202
+ function isRequestHashRow(value: unknown): value is { requestHash: Buffer } {
1203
+ return isRecordValue(value);
1204
+ }
1205
+
1206
+ function isDeadlineRow(value: unknown): value is { deadlineAt: number | null } {
1207
+ return isRecordValue(value);
1208
+ }
1209
+
1210
+ function isCandidateHashRow(value: unknown): value is { candidateHash: Buffer } {
1211
+ return isRecordValue(value);
1212
+ }
1213
+
1214
+ function isContentHashRow(value: unknown): value is { contentHash: Buffer } {
1215
+ return isRecordValue(value);
1216
+ }
1217
+
1218
+ function isContinuationRow(value: unknown): value is { recordHash: Buffer } {
1219
+ return isRecordValue(value);
1220
+ }
1221
+
1222
+ function isDecisionResourceRow(value: unknown): value is { resourceId: string; revision: number } {
1223
+ return isRecordValue(value);
1224
+ }
1225
+
1226
+ function isResolutionRow(value: unknown): value is ResolutionRow {
1227
+ return isRecordValue(value);
1228
+ }
1229
+
1230
+ function isEffectIdentityRecord(value: unknown): value is { effectId: string; resourceId: string } {
1231
+ return isRecordValue(value);
1232
+ }
1233
+
1234
+ function isRevisionRecord(value: unknown): value is { revision: number } {
1235
+ return isRecordValue(value);
1236
+ }
1237
+
1238
+ function isOwnerRow(value: unknown): value is {
1239
+ generation: number;
1240
+ ownerType: string | null;
1241
+ ownerId: string | null;
1242
+ tokenHash: Buffer | null;
1243
+ expiresAt: number | null;
1244
+ } {
1245
+ return isRecordValue(value);
1246
+ }
1247
+
1248
+ function isRecordValue(value: unknown): value is Record<string, unknown> {
1249
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1250
+ }
1251
+
614
1252
  export function createHumanDecisionAttemptId(): string {
615
1253
  return randomUUID();
616
1254
  }
@@ -738,46 +1376,6 @@ function assertJsonValue(value: unknown, label: string): void {
738
1376
  }
739
1377
  }
740
1378
 
741
- async function writeImmutableJson(
742
- filePath: string,
743
- value: unknown,
744
- requireIdentical = true,
745
- ): Promise<"created" | "adopted"> {
746
- const bytes = `${canonicalJson(value)}\n`;
747
- const directory = path.dirname(filePath);
748
- await fs.mkdir(directory, { recursive: true, mode: 0o700 });
749
- const temporary = path.join(
750
- directory,
751
- `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
752
- );
753
- const handle = await fs.open(temporary, "wx", 0o600);
754
- try {
755
- await handle.writeFile(bytes, "utf8");
756
- await handle.sync();
757
- } finally {
758
- await handle.close();
759
- }
760
- try {
761
- await fs.link(temporary, filePath);
762
- const directoryHandle = await fs.open(directory, "r");
763
- try {
764
- await directoryHandle.sync();
765
- } finally {
766
- await directoryHandle.close();
767
- }
768
- return "created";
769
- } catch (error) {
770
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
771
- const existing = await fs.readFile(filePath, "utf8");
772
- if (requireIdentical && existing !== bytes) {
773
- throw new Error(`Immutable human decision record conflicts: ${filePath}`);
774
- }
775
- return "adopted";
776
- } finally {
777
- await fs.unlink(temporary).catch(() => undefined);
778
- }
779
- }
780
-
781
1379
  function validateExpiry(
782
1380
  value: string | undefined,
783
1381
  createdAt: string | undefined,
@@ -791,29 +1389,3 @@ function validateExpiry(
791
1389
  }
792
1390
  return new Date(parsed).toISOString();
793
1391
  }
794
-
795
- async function readJsonDirectory(directory: string): Promise<unknown[]> {
796
- let names: string[];
797
- try {
798
- names = await fs.readdir(directory);
799
- } catch (error) {
800
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
801
- throw error;
802
- }
803
- const values = await Promise.all(
804
- names
805
- .filter((name) => name.endsWith(".json"))
806
- .sort()
807
- .map(async (name) => await readJson(path.join(directory, name))),
808
- );
809
- return values.filter((value) => value !== null);
810
- }
811
-
812
- async function readJson(filePath: string): Promise<unknown | null> {
813
- try {
814
- return JSON.parse(await fs.readFile(filePath, "utf8"));
815
- } catch (error) {
816
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
817
- throw error;
818
- }
819
- }