@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,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import fs from "node:fs/promises";
3
- import path from "node:path";
2
+ import { StateDatabase, workflowStatePath } from "../state/database.js";
3
+ import { resourceIdFor, tokenHash } from "../state/mutation.js";
4
4
  import { digestCanonical, normalizeDecisionPresentation, validateHumanDecisionRequestIntegrity, } from "./decision-presentation.js";
5
5
  import { checkpoint } from "./definition.js";
6
6
  const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
@@ -38,11 +38,23 @@ export function humanDecision(definition) {
38
38
  if (typeof definition.request !== "function") {
39
39
  throw new Error("Human decision request must be a function");
40
40
  }
41
- const { choices, request, ...common } = definition;
41
+ const { choices, request, onTimeout, ...common } = definition;
42
+ if (onTimeout !== undefined &&
43
+ typeof onTimeout !== "function" &&
44
+ (typeof onTimeout.afterMs !== "number" ||
45
+ !Number.isFinite(onTimeout.afterMs) ||
46
+ onTimeout.afterMs <= 0)) {
47
+ throw new Error("Human decision onTimeout afterMs must be a finite positive number");
48
+ }
42
49
  return checkpoint({
43
50
  ...common,
44
51
  summary: typeof audience === "string" ? `human decision for ${audience}` : "human decision",
45
- humanDecision: { audience, choices, request },
52
+ humanDecision: {
53
+ audience,
54
+ choices,
55
+ request,
56
+ ...(onTimeout !== undefined ? { onTimeout } : {}),
57
+ },
46
58
  });
47
59
  }
48
60
  export function humanDecisionEdge(args) {
@@ -61,8 +73,23 @@ export function humanDecisionEdge(args) {
61
73
  }
62
74
  export function createHumanDecisionRequest(input) {
63
75
  validateChoices(input.contract.choices);
76
+ const createdAt = input.createdAt ?? new Date().toISOString();
64
77
  const title = requireString(input.prompt.title, "Human decision title");
65
- const expiresAt = validateExpiry(input.prompt.expiresAt, input.createdAt);
78
+ if (input.timeout !== undefined && input.prompt.expiresAt !== undefined) {
79
+ throw new Error("Human decision request cannot combine expiresAt with onTimeout");
80
+ }
81
+ if (input.timeout !== undefined &&
82
+ (typeof input.timeout.afterMs !== "number" ||
83
+ !Number.isFinite(input.timeout.afterMs) ||
84
+ input.timeout.afterMs <= 0)) {
85
+ throw new Error("Human decision onTimeout afterMs must be a finite positive number");
86
+ }
87
+ const defaultResponse = input.timeout === undefined
88
+ ? undefined
89
+ : validateResponseForChoices(input.contract.choices, input.timeout.response);
90
+ const expiresAt = input.timeout === undefined
91
+ ? validateExpiry(input.prompt.expiresAt, createdAt)
92
+ : new Date(Date.parse(createdAt) + input.timeout.afterMs).toISOString();
66
93
  const common = {
67
94
  runId: input.runId,
68
95
  workflowName: input.workflowName,
@@ -72,57 +99,35 @@ export function createHumanDecisionRequest(input) {
72
99
  title,
73
100
  choices: input.contract.choices,
74
101
  ...(expiresAt !== undefined ? { expiresAt } : {}),
102
+ ...(defaultResponse !== undefined ? { defaultResponse } : {}),
75
103
  };
76
- const presented = Object.hasOwn(input.prompt, "subject") || Object.hasOwn(input.prompt, "presentation");
77
- if (presented) {
78
- validatePresentedText(title, "Human decision title");
79
- validatePresentedChoices(input.contract.choices);
80
- if (Object.hasOwn(input.prompt, "body")) {
81
- throw new Error("Presented human decision requests must not contain a legacy body");
82
- }
83
- if (!Object.hasOwn(input.prompt, "subject") || !Object.hasOwn(input.prompt, "presentation")) {
84
- throw new Error("Presented human decision requests require subject and presentation");
85
- }
86
- const prompt = input.prompt;
87
- assertJsonValue(prompt.subject, "Human decision subject");
88
- const presentation = normalizeDecisionPresentation(prompt.presentation);
89
- const revision = prompt.revision ?? 1;
90
- if (!Number.isInteger(revision) || revision < 1) {
91
- throw new Error("Human decision revision must be a positive integer");
92
- }
93
- const basis = {
94
- schema: "pi-workflows.human-decision-request.v2",
95
- ...common,
96
- subject: prompt.subject,
97
- presentation,
98
- revision,
99
- };
100
- const subjectDigest = digestCanonical(prompt.subject);
101
- const presentationDigest = digestCanonical(presentation);
102
- const requestDigest = digestCanonical(basis);
103
- const decisionId = decisionIdFor(input, requestDigest);
104
- return {
105
- ...basis,
106
- decisionId,
107
- requestDigest,
108
- subjectDigest,
109
- presentationDigest,
110
- createdAt: input.createdAt ?? new Date().toISOString(),
111
- };
112
- }
104
+ validatePresentedText(title, "Human decision title");
105
+ validatePresentedChoices(input.contract.choices);
113
106
  const prompt = input.prompt;
114
- assertJsonValue(prompt.body, "Human decision body");
107
+ assertJsonValue(prompt.subject, "Human decision subject");
108
+ const presentation = normalizeDecisionPresentation(prompt.presentation);
109
+ const revision = prompt.revision ?? 1;
110
+ if (!Number.isInteger(revision) || revision < 1) {
111
+ throw new Error("Human decision revision must be a positive integer");
112
+ }
115
113
  const basis = {
116
114
  schema: "pi-workflows.human-decision-request.v1",
117
115
  ...common,
118
- body: prompt.body,
116
+ subject: prompt.subject,
117
+ presentation,
118
+ revision,
119
119
  };
120
- const requestDigest = digest(basis);
120
+ const subjectDigest = digestCanonical(prompt.subject);
121
+ const presentationDigest = digestCanonical(presentation);
122
+ const requestDigest = digestCanonical(basis);
123
+ const decisionId = decisionIdFor(input, requestDigest);
121
124
  return {
122
125
  ...basis,
123
- decisionId: decisionIdFor(input, requestDigest),
126
+ decisionId,
124
127
  requestDigest,
125
- createdAt: input.createdAt ?? new Date().toISOString(),
128
+ subjectDigest,
129
+ presentationDigest,
130
+ createdAt,
126
131
  };
127
132
  }
128
133
  function decisionIdFor(input, requestDigest) {
@@ -134,12 +139,15 @@ function decisionIdFor(input, requestDigest) {
134
139
  }).slice(0, 40)}`;
135
140
  }
136
141
  export function validateHumanDecisionResponse(request, value) {
142
+ return validateResponseForChoices(request.choices, value);
143
+ }
144
+ function validateResponseForChoices(choices, value) {
137
145
  const response = requireRecord(value, "Human decision response");
138
146
  const selected = response.choice;
139
- if (typeof selected !== "string" || !Object.hasOwn(request.choices, selected)) {
147
+ if (typeof selected !== "string" || !Object.hasOwn(choices, selected)) {
140
148
  throw new Error(`Human decision choice ${JSON.stringify(selected)} is not available`);
141
149
  }
142
- const selectedChoice = request.choices[selected];
150
+ const selectedChoice = choices[selected];
143
151
  if (!selectedChoice)
144
152
  throw new Error("Human decision choice contract is missing");
145
153
  if (selectedChoice.input === undefined) {
@@ -184,214 +192,636 @@ export function validateHumanDecisionSubmission(request, value) {
184
192
  idempotencyKey,
185
193
  };
186
194
  }
187
- export function humanDecisionStateRoot(runsRoot) {
188
- return path.basename(runsRoot) === "runs"
189
- ? path.join(path.dirname(runsRoot), "decisions")
190
- : `${runsRoot}.decisions`;
195
+ export function humanDecisionDatabasePath(homeDir) {
196
+ return workflowStatePath(homeDir);
191
197
  }
192
- export class HumanDecisionStore {
193
- root;
194
- constructor(runsRoot) {
195
- this.root = humanDecisionStateRoot(runsRoot);
198
+ const INCOMPATIBLE_HUMAN_DECISION_STATE = "Human decision state uses an incompatible alpha contract; reset the affected workflow run and decision state.";
199
+ function requireCurrentDecision(value) {
200
+ const decision = requireRecord(value, "Resolved human decision");
201
+ if (decision.schema !== "pi-workflows.human-decision-accepted.v1" ||
202
+ !Object.hasOwn(decision, "subjectDigest") ||
203
+ !Object.hasOwn(decision, "presentationDigest") ||
204
+ !Object.hasOwn(decision, "revision")) {
205
+ throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
196
206
  }
197
- decisionDir(decisionId) {
198
- assertId(decisionId, "decision id");
199
- return path.join(this.root, decisionId);
207
+ return value;
208
+ }
209
+ export class HumanDecisionStore {
210
+ databasePath;
211
+ state;
212
+ ownsState;
213
+ authorityProvider;
214
+ constructor(databasePath = workflowStatePath(), options = {}) {
215
+ this.ownsState = options.state === undefined;
216
+ this.state =
217
+ options.state ??
218
+ new StateDatabase({
219
+ filePath: databasePath,
220
+ mode: options.readOnly === true ? "read-only" : "read-write",
221
+ checkLegacyState: databasePath === workflowStatePath(),
222
+ });
223
+ this.databasePath = this.state.filePath;
224
+ this.authorityProvider = options.authorityProvider;
225
+ }
226
+ close() {
227
+ if (this.ownsState)
228
+ this.state.close();
200
229
  }
201
230
  async createRequest(request) {
202
231
  validateHumanDecisionRequestIntegrity(request);
203
- return await writeImmutableJson(path.join(this.decisionDir(request.decisionId), "request.json"), request);
232
+ return this.state.transaction(() => {
233
+ const existing = this.readRequestRow(request.decisionId);
234
+ if (existing !== undefined) {
235
+ const stored = this.readRequestFromHash(existing.requestHash);
236
+ if (canonicalJson(stored) !== canonicalJson(request)) {
237
+ throw new Error("Immutable human decision request conflicts");
238
+ }
239
+ return "adopted";
240
+ }
241
+ const run = this.state.connection
242
+ .prepare("SELECT resource_id AS resourceId FROM runs WHERE run_id = ?")
243
+ .get(request.runId);
244
+ if (!isResourceIdRow(run))
245
+ throw new Error(`Human decision run is missing: ${request.runId}`);
246
+ const attempt = this.state.connection
247
+ .prepare("SELECT 1 AS present FROM node_attempts WHERE attempt_id = ? AND run_id = ?")
248
+ .get(request.attemptId, request.runId);
249
+ if (!isPresentRow(attempt)) {
250
+ throw new Error(`Human decision attempt is missing: ${request.attemptId}`);
251
+ }
252
+ const now = Date.parse(request.createdAt);
253
+ const resourceId = resourceIdFor("decision", request.decisionId);
254
+ const subjectHash = this.state.putJson(request.subject, now);
255
+ const presentationHash = this.state.putJson(request.presentation, now);
256
+ const choicesHash = this.state.putJson(request.choices, now);
257
+ const defaultHash = request.defaultResponse === undefined
258
+ ? null
259
+ : this.state.putJson(request.defaultResponse, now);
260
+ const requestHash = this.state.putJson(request, now);
261
+ this.state.connection
262
+ .prepare(`INSERT INTO resources(
263
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
264
+ ) VALUES (?, 'decision', ?, 1, ?, ?)`)
265
+ .run(resourceId, request.decisionId, now, now);
266
+ this.state.connection
267
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
268
+ .run(resourceId);
269
+ this.state.connection
270
+ .prepare(`INSERT INTO human_decisions(
271
+ decision_id, resource_id, run_id, attempt_id, audience, title,
272
+ subject_hash, presentation_hash, choices_hash, request_digest,
273
+ presentation_revision, deadline_at, default_response_hash, request_hash, created_at
274
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
275
+ .run(request.decisionId, resourceId, request.runId, request.attemptId, request.audience, request.title, subjectHash, presentationHash, choicesHash, digestBuffer(request.requestDigest), request.revision, request.expiresAt === undefined ? null : Date.parse(request.expiresAt), defaultHash, requestHash, now);
276
+ this.insertDecisionEvent(resourceId, 1, "decision.requested", "system", null, requestHash, now);
277
+ return "created";
278
+ });
204
279
  }
205
280
  async readRequest(decisionId) {
206
- const request = (await readJson(path.join(this.decisionDir(decisionId), "request.json")));
207
- return request === null ? null : validateHumanDecisionRequestIntegrity(request);
281
+ assertId(decisionId, "decision id");
282
+ const row = this.readRequestRow(decisionId);
283
+ return row === undefined ? null : this.readRequestFromHash(row.requestHash);
208
284
  }
209
285
  async recordDelivery(request, channel, value) {
210
- const safeChannel = requireSimpleId(channel, "Human decision channel");
211
- const attemptId = requireString(value.attemptId, "Human decision delivery attempt");
212
- assertId(attemptId, "delivery attempt id");
213
- return await writeImmutableJson(path.join(this.decisionDir(request.decisionId), "deliveries", safeChannel, `${attemptId}.json`), value);
286
+ return this.recordChannelMessage(request.decisionId, channel, "delivery", value);
214
287
  }
215
288
  async accept(request, submission) {
216
289
  validateHumanDecisionRequestIntegrity(request);
217
- const cancellation = await this.readCancellation(request.decisionId);
218
- if (cancellation !== null) {
219
- throw new Error(`Human decision request was ${cancellation.reason}`);
220
- }
221
290
  const normalized = validateHumanDecisionSubmission(request, submission);
222
- let attemptedAt = new Date().toISOString();
291
+ const attemptedAt = new Date().toISOString();
223
292
  const attemptId = digestHex({
224
293
  decisionId: request.decisionId,
225
294
  idempotencyKey: normalized.idempotencyKey,
226
295
  }).slice(0, 40);
227
- const attemptPath = path.join(this.decisionDir(request.decisionId), "answers", `${attemptId}.json`);
228
- const attempt = {
229
- schema: "pi-workflows.human-decision-answer-attempt.v1",
230
- attemptId,
231
- attemptedAt,
232
- ...normalized,
233
- };
234
- const attemptWrite = await writeImmutableJson(attemptPath, attempt, false);
235
- if (attemptWrite === "adopted") {
236
- const existingAttempt = requireRecord(await readJson(attemptPath), "Existing human decision answer attempt");
237
- const existingSubmission = {
238
- decisionId: existingAttempt.decisionId,
239
- requestDigest: existingAttempt.requestDigest,
240
- choice: existingAttempt.choice,
241
- ...(existingAttempt.input !== undefined ? { input: existingAttempt.input } : {}),
242
- source: existingAttempt.source,
243
- idempotencyKey: existingAttempt.idempotencyKey,
244
- };
245
- if (canonicalJson(existingSubmission) !== canonicalJson(normalized)) {
246
- throw new Error("Human decision idempotency key was reused for a different answer");
247
- }
248
- attemptedAt = requireString(existingAttempt.attemptedAt, "answer attempt time");
249
- }
250
- const response = validateHumanDecisionResponse(request, normalized);
251
- const commonDecision = {
296
+ const decision = {
297
+ schema: "pi-workflows.human-decision-accepted.v1",
252
298
  decisionId: request.decisionId,
253
299
  requestDigest: request.requestDigest,
254
- response,
300
+ response: validateHumanDecisionResponse(request, normalized),
301
+ provenance: "human",
255
302
  source: normalized.source,
256
303
  idempotencyKey: normalized.idempotencyKey,
257
304
  acceptedAt: attemptedAt,
258
- answerDigest: digest({ response, source: normalized.source }),
259
- };
260
- const decision = request.schema === "pi-workflows.human-decision-request.v2"
261
- ? {
262
- schema: "pi-workflows.human-decision-accepted.v2",
263
- ...commonDecision,
264
- subjectDigest: request.subjectDigest,
265
- presentationDigest: request.presentationDigest,
266
- revision: request.revision,
267
- }
268
- : { schema: "pi-workflows.human-decision-accepted.v1", ...commonDecision };
269
- const resolutionPath = path.join(this.decisionDir(request.decisionId), "resolution.json");
270
- const resolution = {
271
- schema: decision.schema === "pi-workflows.human-decision-accepted.v2"
272
- ? "pi-workflows.human-decision-resolution.v2"
273
- : "pi-workflows.human-decision-resolution.v1",
274
- outcome: "accepted",
275
- decision,
305
+ answerDigest: digest({
306
+ requestDigest: request.requestDigest,
307
+ response: validateHumanDecisionResponse(request, normalized),
308
+ source: normalized.source,
309
+ }),
310
+ subjectDigest: request.subjectDigest,
311
+ presentationDigest: request.presentationDigest,
312
+ revision: request.revision,
276
313
  };
277
- const result = await writeImmutableJson(resolutionPath, resolution, false);
278
- const winner = result === "created"
279
- ? resolution
280
- : (await readJson(resolutionPath));
281
- if (winner === null)
282
- throw new Error("Human decision resolution became unreadable");
283
- if (winner.outcome === "cancelled") {
284
- throw new Error(`Human decision request was ${winner.cancellation.reason}`);
285
- }
286
- const existing = winner.decision;
287
- await writeImmutableJson(path.join(this.decisionDir(request.decisionId), "accepted.json"), existing);
288
- if (result === "created")
289
- return { status: "accepted", decision: existing };
290
- if (canonicalJson(existing) === canonicalJson(decision)) {
291
- return { status: "adopted", decision: existing };
314
+ return this.attemptAcceptance(request, decision, {
315
+ attemptId,
316
+ source: "human",
317
+ actorId: normalized.source.actorId,
318
+ channel: normalized.source.channel,
319
+ candidate: normalized,
320
+ });
321
+ }
322
+ async resolveTimeout(request, now = new Date()) {
323
+ validateHumanDecisionRequestIntegrity(request);
324
+ if (request.expiresAt === undefined || request.defaultResponse === undefined) {
325
+ throw new Error("Human decision request has no timeout default");
292
326
  }
293
- if (existing.idempotencyKey === decision.idempotencyKey &&
294
- canonicalJson(existing.response) === canonicalJson(decision.response) &&
295
- canonicalJson(existing.source) === canonicalJson(decision.source)) {
296
- return { status: "adopted", decision: existing };
327
+ if (Date.parse(request.expiresAt) > now.getTime()) {
328
+ throw new Error("Human decision timeout default is not eligible yet");
297
329
  }
298
- return { status: "conflict", decision: existing };
330
+ this.assertRunOwner(request.runId, now.getTime());
331
+ const response = validateHumanDecisionResponse(request, request.defaultResponse);
332
+ const decision = {
333
+ schema: "pi-workflows.human-decision-accepted.v1",
334
+ decisionId: request.decisionId,
335
+ requestDigest: request.requestDigest,
336
+ response,
337
+ provenance: "timeout",
338
+ acceptedAt: now.toISOString(),
339
+ answerDigest: digest({
340
+ provenance: "timeout",
341
+ requestDigest: request.requestDigest,
342
+ response,
343
+ }),
344
+ subjectDigest: request.subjectDigest,
345
+ presentationDigest: request.presentationDigest,
346
+ revision: request.revision,
347
+ };
348
+ const actorId = this.authorityProvider?.(request.runId)?.ownerId;
349
+ return this.attemptAcceptance(request, decision, {
350
+ attemptId: digestHex({ decisionId: request.decisionId, provenance: "timeout" }).slice(0, 40),
351
+ source: "policy",
352
+ ...(actorId === undefined ? {} : { actorId }),
353
+ candidate: response,
354
+ });
299
355
  }
300
356
  async listDeliveries(decisionId, channel) {
301
- const safeChannel = requireSimpleId(channel, "Human decision channel");
302
- const directory = path.join(this.decisionDir(decisionId), "deliveries", safeChannel);
303
- return (await readJsonDirectory(directory));
357
+ return this.listChannelMessages(decisionId, channel, "delivery");
304
358
  }
305
359
  async recordSettlement(decisionId, channel, value) {
306
- const safeChannel = requireSimpleId(channel, "Human decision channel");
307
- assertId(value.attemptId, "settlement attempt id");
308
- return await writeImmutableJson(path.join(this.decisionDir(decisionId), "settlements", safeChannel, `${value.attemptId}.json`), value);
360
+ return this.recordChannelMessage(decisionId, channel, "settlement", value);
309
361
  }
310
362
  async listSettlements(decisionId, channel) {
311
- const safeChannel = requireSimpleId(channel, "Human decision channel");
312
- const directory = path.join(this.decisionDir(decisionId), "settlements", safeChannel);
313
- return (await readJsonDirectory(directory));
363
+ return this.listChannelMessages(decisionId, channel, "settlement");
314
364
  }
315
365
  async cancel(request, reason) {
316
366
  validateHumanDecisionRequestIntegrity(request);
317
- if ((await this.readAccepted(request.decisionId)) !== null) {
318
- throw new Error("Accepted human decision cannot be cancelled");
367
+ if (reason === "expired" && request.defaultResponse !== undefined) {
368
+ throw new Error("A defaulted human decision must resolve through its timeout policy");
319
369
  }
320
- const filePath = path.join(this.decisionDir(request.decisionId), "cancelled.json");
321
- const record = {
322
- schema: "pi-workflows.human-decision-cancellation.v1",
323
- decisionId: request.decisionId,
324
- requestDigest: request.requestDigest,
325
- cancelledAt: new Date().toISOString(),
326
- reason,
327
- };
328
- const resolutionPath = path.join(this.decisionDir(request.decisionId), "resolution.json");
329
- const resolution = {
330
- schema: request.schema === "pi-workflows.human-decision-request.v2"
331
- ? "pi-workflows.human-decision-resolution.v2"
332
- : "pi-workflows.human-decision-resolution.v1",
333
- outcome: "cancelled",
334
- cancellation: record,
335
- };
336
- const result = await writeImmutableJson(resolutionPath, resolution, false);
337
- const winner = result === "created"
338
- ? resolution
339
- : (await readJson(resolutionPath));
340
- if (winner === null)
341
- throw new Error("Human decision resolution became unreadable");
342
- if (winner.outcome === "accepted") {
343
- throw new Error("Accepted human decision cannot be cancelled");
344
- }
345
- const existing = winner.cancellation;
346
- if (existing.decisionId !== request.decisionId ||
347
- existing.requestDigest !== request.requestDigest ||
348
- existing.reason !== reason) {
349
- throw new Error("Immutable human decision cancellation conflicts");
350
- }
351
- await writeImmutableJson(filePath, existing);
352
- return result;
370
+ return this.state.transaction(() => {
371
+ this.requireStoredRequest(request);
372
+ const winner = this.readResolution(request.decisionId);
373
+ if (winner !== null) {
374
+ if (winner.outcome === "cancelled") {
375
+ if (winner.cancellation.reason === reason)
376
+ return "adopted";
377
+ throw new Error(`Human decision was already cancelled with reason ${winner.cancellation.reason}`);
378
+ }
379
+ throw new Error(`Human decision was already accepted by ${winner.decision.provenance}`);
380
+ }
381
+ const now = Date.now();
382
+ if (reason === "expired") {
383
+ if (request.expiresAt === undefined) {
384
+ throw new Error("Human decision request has no expiry");
385
+ }
386
+ if (Date.parse(request.expiresAt) > now) {
387
+ throw new Error("Human decision expiry cancellation is not eligible yet");
388
+ }
389
+ this.assertRunOwner(request.runId, now);
390
+ }
391
+ const record = {
392
+ schema: "pi-workflows.human-decision-cancellation.v1",
393
+ decisionId: request.decisionId,
394
+ requestDigest: request.requestDigest,
395
+ cancelledAt: new Date(now).toISOString(),
396
+ reason,
397
+ };
398
+ const provenance = reason === "expired" ? "expired_no_default" : "explicit_cancel";
399
+ this.state.connection
400
+ .prepare(`INSERT INTO human_decision_resolutions(
401
+ decision_id, outcome, provenance, response_hash, reason, channel,
402
+ actor_id, request_digest, resolved_at
403
+ ) VALUES (?, 'cancelled', ?, NULL, ?, NULL, NULL, ?, ?)`)
404
+ .run(request.decisionId, provenance, reason, digestBuffer(request.requestDigest), now);
405
+ const row = this.requireDecisionResource(request.decisionId);
406
+ this.bumpDecision(row.resourceId, row.revision, now);
407
+ const recordHash = this.state.putJson(record, now);
408
+ this.insertDecisionEvent(row.resourceId, row.revision + 1, "decision.cancelled", reason === "expired" ? "policy" : "control", null, recordHash, now);
409
+ this.enqueueResolutionEffects(row.resourceId, row.revision + 1, request, "cancelled", now);
410
+ return "created";
411
+ });
353
412
  }
354
413
  async readCancellation(decisionId) {
355
- const stored = (await readJson(path.join(this.decisionDir(decisionId), "cancelled.json")));
356
- if (stored !== null)
357
- return stored;
358
- const resolution = (await readJson(path.join(this.decisionDir(decisionId), "resolution.json")));
359
- if (resolution?.outcome !== "cancelled")
360
- return null;
361
- await writeImmutableJson(path.join(this.decisionDir(decisionId), "cancelled.json"), resolution.cancellation);
362
- return resolution.cancellation;
363
- }
364
- async readAccepted(decisionId) {
365
- const stored = (await readJson(path.join(this.decisionDir(decisionId), "accepted.json")));
366
- if (stored !== null)
367
- return stored;
368
- const resolution = (await readJson(path.join(this.decisionDir(decisionId), "resolution.json")));
369
- if (resolution?.outcome !== "accepted")
370
- return null;
371
- await writeImmutableJson(path.join(this.decisionDir(decisionId), "accepted.json"), resolution.decision);
372
- return resolution.decision;
414
+ const resolution = this.readResolution(decisionId);
415
+ return resolution?.outcome === "cancelled" ? resolution.cancellation : null;
416
+ }
417
+ async readResolved(decisionId) {
418
+ const resolution = this.readResolution(decisionId);
419
+ return resolution?.outcome === "accepted" ? resolution.decision : null;
373
420
  }
374
421
  async recordContinuation(decisionId, value) {
375
- return await writeImmutableJson(path.join(this.decisionDir(decisionId), "continuation.json"), value);
422
+ return this.state.transaction(() => {
423
+ const existing = this.readContinuationRow(decisionId);
424
+ if (existing !== undefined) {
425
+ const stored = this.state.readJson(existing.recordHash);
426
+ if (canonicalJson(stored) !== canonicalJson(value)) {
427
+ throw new Error("Immutable human decision continuation conflicts");
428
+ }
429
+ return "adopted";
430
+ }
431
+ const resolution = this.readResolution(decisionId);
432
+ if (resolution?.outcome !== "accepted") {
433
+ throw new Error("A continuation requires an accepted human decision");
434
+ }
435
+ const recordHash = this.state.putJson(value, Date.parse(value.createdAt));
436
+ this.state.connection
437
+ .prepare(`INSERT INTO continuations(
438
+ decision_id, parent_run_id, continuation_run_id, response_hash, created_at
439
+ ) VALUES (?, ?, ?, ?, ?)`)
440
+ .run(decisionId, value.parentRunId, value.runId, recordHash, Date.parse(value.createdAt));
441
+ return "created";
442
+ });
376
443
  }
377
444
  async readContinuation(decisionId) {
378
- return (await readJson(path.join(this.decisionDir(decisionId), "continuation.json")));
445
+ const row = this.readContinuationRow(decisionId);
446
+ return row === undefined
447
+ ? null
448
+ : this.state.readJson(row.recordHash);
449
+ }
450
+ markEffectApplied(decisionId, effectType) {
451
+ const rows = this.state.connection
452
+ .prepare(`SELECT e.effect_id AS effectId, e.resource_id AS resourceId
453
+ FROM effects e
454
+ JOIN human_decisions d ON d.resource_id = e.source_resource_id
455
+ WHERE d.decision_id = ? AND e.effect_type = ? AND e.status = 'pending'`)
456
+ .all(decisionId, effectType);
457
+ for (const row of rows) {
458
+ /* istanbul ignore if -- exact schema and internal query shape */
459
+ if (!isEffectIdentityRecord(row))
460
+ continue;
461
+ this.state.transaction(() => {
462
+ const now = Date.now();
463
+ const revisionRow = this.state.connection
464
+ .prepare("SELECT revision FROM resources WHERE resource_id = ?")
465
+ .get(row.resourceId);
466
+ /* istanbul ignore if -- exact schema and internal query shape */
467
+ if (!isRevisionRecord(revisionRow))
468
+ throw new Error("Decision effect resource is missing");
469
+ this.state.connection
470
+ .prepare(`UPDATE effects SET status = 'applied', updated_at = ?, settled_at = ?
471
+ WHERE effect_id = ? AND status = 'pending'`)
472
+ .run(now, now, row.effectId);
473
+ this.state.connection
474
+ .prepare(`UPDATE resources SET revision = revision + 1, updated_at = ?
475
+ WHERE resource_id = ? AND revision = ?`)
476
+ .run(now, row.resourceId, revisionRow.revision);
477
+ const payloadHash = this.state.putJson({ decisionId, effectType }, now);
478
+ this.insertDecisionEvent(row.resourceId, revisionRow.revision + 1, "effect.applied", "system", null, payloadHash, now);
479
+ });
480
+ }
379
481
  }
380
482
  async listRequests() {
381
- let entries;
382
- try {
383
- entries = await fs.readdir(this.root, { withFileTypes: true });
483
+ const rows = this.state.connection
484
+ .prepare("SELECT request_hash AS requestHash FROM human_decisions ORDER BY created_at")
485
+ .all();
486
+ return rows.filter(isRequestHashRow).map((row) => this.readRequestFromHash(row.requestHash));
487
+ }
488
+ attemptAcceptance(request, decision, attempt) {
489
+ return this.state.transaction(() => {
490
+ this.requireStoredRequest(request);
491
+ const timing = this.state.connection
492
+ .prepare("SELECT deadline_at AS deadlineAt FROM human_decisions WHERE decision_id = ?")
493
+ .get(request.decisionId);
494
+ if (!isDeadlineRow(timing))
495
+ throw new Error("Human decision request is missing");
496
+ const arbitrationTime = attempt.source === "policy" ? Date.parse(decision.acceptedAt) : Date.now();
497
+ if (attempt.source === "human" &&
498
+ timing.deadlineAt !== null &&
499
+ arbitrationTime > timing.deadlineAt) {
500
+ throw new Error("Human decision request expired before the answer was accepted");
501
+ }
502
+ if (attempt.source === "policy" &&
503
+ (timing.deadlineAt === null || arbitrationTime < timing.deadlineAt)) {
504
+ throw new Error("Human decision timeout default is not eligible yet");
505
+ }
506
+ if (attempt.source === "policy")
507
+ this.assertRunOwner(request.runId, arbitrationTime);
508
+ const candidateHash = this.state.putJson(attempt.candidate);
509
+ const existingSubmission = this.state.connection
510
+ .prepare(`SELECT candidate_hash AS candidateHash
511
+ FROM human_decision_submissions WHERE decision_id = ? AND attempt_id = ?`)
512
+ .get(request.decisionId, attempt.attemptId);
513
+ if (isCandidateHashRow(existingSubmission) &&
514
+ !existingSubmission.candidateHash.equals(candidateHash)) {
515
+ throw new Error("Human decision submission idempotency key conflicts");
516
+ }
517
+ this.state.connection
518
+ .prepare(`INSERT INTO human_decision_submissions(
519
+ decision_id, attempt_id, source, actor_id, channel,
520
+ candidate_hash, outcome, submitted_at
521
+ ) VALUES (?, ?, ?, ?, ?, ?, 'rejected', ?)
522
+ ON CONFLICT(decision_id, attempt_id) DO NOTHING`)
523
+ .run(request.decisionId, attempt.attemptId, attempt.source, attempt.actorId ?? null, attempt.channel ?? null, candidateHash, Date.parse(decision.acceptedAt));
524
+ const winner = this.readResolution(request.decisionId);
525
+ if (winner !== null) {
526
+ this.markSubmission(request.decisionId, attempt.attemptId, "already_resolved", null);
527
+ if (winner.outcome === "cancelled") {
528
+ throw new Error(`Human decision request was ${winner.cancellation.reason}`);
529
+ }
530
+ return canonicalJson(winner.decision) === canonicalJson(decision) ||
531
+ sameHumanAnswer(winner.decision, decision)
532
+ ? { status: "adopted", decision: winner.decision }
533
+ : { status: "conflict", decision: winner.decision };
534
+ }
535
+ const now = Date.parse(decision.acceptedAt);
536
+ const responseHash = this.state.putJson(decision, now);
537
+ this.state.connection
538
+ .prepare(`INSERT INTO human_decision_resolutions(
539
+ decision_id, outcome, provenance, response_hash, reason, channel,
540
+ actor_id, request_digest, resolved_at
541
+ ) VALUES (?, 'accepted', ?, ?, NULL, ?, ?, ?, ?)`)
542
+ .run(request.decisionId, decision.provenance === "timeout" ? "timeout_policy" : "human", responseHash, attempt.channel ?? null, attempt.actorId ?? null, digestBuffer(request.requestDigest), now);
543
+ const row = this.requireDecisionResource(request.decisionId);
544
+ this.bumpDecision(row.resourceId, row.revision, now);
545
+ this.insertDecisionEvent(row.resourceId, row.revision + 1, "decision.accepted", decision.provenance === "timeout" ? "policy" : "human", attempt.actorId ?? null, responseHash, now);
546
+ this.enqueueResolutionEffects(row.resourceId, row.revision + 1, request, "accepted", now);
547
+ this.markSubmission(request.decisionId, attempt.attemptId, "won", responseHash);
548
+ return { status: "accepted", decision };
549
+ });
550
+ }
551
+ readResolution(decisionId) {
552
+ const row = this.state.connection
553
+ .prepare(`SELECT outcome, provenance, response_hash AS responseHash, reason,
554
+ request_digest AS requestDigest, resolved_at AS resolvedAt
555
+ FROM human_decision_resolutions WHERE decision_id = ?`)
556
+ .get(decisionId);
557
+ if (!isResolutionRow(row))
558
+ return null;
559
+ const request = this.readRequestRow(decisionId);
560
+ if (request === undefined)
561
+ throw new Error("Human decision request is missing");
562
+ const storedRequest = this.readRequestFromHash(request.requestHash);
563
+ if (!row.requestDigest.equals(digestBuffer(storedRequest.requestDigest))) {
564
+ throw new Error("Human decision resolution request digest conflicts");
384
565
  }
385
- catch (error) {
386
- if (error.code === "ENOENT")
387
- return [];
388
- throw error;
566
+ if (row.outcome === "accepted") {
567
+ if (row.responseHash === null)
568
+ throw new Error("Accepted decision response is missing");
569
+ const decision = this.state.readJson(row.responseHash);
570
+ return {
571
+ schema: "pi-workflows.human-decision-resolution.v1",
572
+ outcome: "accepted",
573
+ decision: requireCurrentDecision(decision),
574
+ };
575
+ }
576
+ const cancellation = {
577
+ schema: "pi-workflows.human-decision-cancellation.v1",
578
+ decisionId,
579
+ requestDigest: storedRequest.requestDigest,
580
+ cancelledAt: new Date(row.resolvedAt).toISOString(),
581
+ reason: row.reason === "expired" ? "expired" : "cancelled",
582
+ };
583
+ return {
584
+ schema: "pi-workflows.human-decision-resolution.v1",
585
+ outcome: "cancelled",
586
+ cancellation,
587
+ };
588
+ }
589
+ requireStoredRequest(request) {
590
+ const row = this.readRequestRow(request.decisionId);
591
+ if (row === undefined)
592
+ throw new Error("Human decision request is not durable");
593
+ const stored = this.readRequestFromHash(row.requestHash);
594
+ if (canonicalJson(stored) !== canonicalJson(request)) {
595
+ throw new Error("Human decision request does not match durable state");
596
+ }
597
+ }
598
+ readRequestRow(decisionId) {
599
+ const row = this.state.connection
600
+ .prepare("SELECT request_hash AS requestHash FROM human_decisions WHERE decision_id = ?")
601
+ .get(decisionId);
602
+ return isRequestHashRow(row) ? row : undefined;
603
+ }
604
+ readRequestFromHash(hash) {
605
+ return validateHumanDecisionRequestIntegrity(this.state.readJson(hash));
606
+ }
607
+ requireDecisionResource(decisionId) {
608
+ const row = this.state.connection
609
+ .prepare(`SELECT d.resource_id AS resourceId, r.revision
610
+ FROM human_decisions d JOIN resources r ON r.resource_id = d.resource_id
611
+ WHERE d.decision_id = ?`)
612
+ .get(decisionId);
613
+ if (!isDecisionResourceRow(row))
614
+ throw new Error(`Human decision is missing: ${decisionId}`);
615
+ return row;
616
+ }
617
+ bumpDecision(resourceId, expectedRevision, now) {
618
+ const result = this.state.connection
619
+ .prepare(`UPDATE resources SET revision = revision + 1, updated_at = ?
620
+ WHERE resource_id = ? AND revision = ?`)
621
+ .run(now, resourceId, expectedRevision);
622
+ if (result.changes !== 1)
623
+ throw new Error("Human decision revision conflict");
624
+ }
625
+ insertDecisionEvent(resourceId, revision, eventType, actorType, actorId, payloadHash, now) {
626
+ this.state.connection
627
+ .prepare(`INSERT INTO events(
628
+ event_id, resource_id, resource_revision, event_type,
629
+ actor_type, actor_id, payload_hash, recorded_at
630
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
631
+ .run(`event-${randomUUID()}`, resourceId, revision, eventType, actorType, actorId, payloadHash, now);
632
+ }
633
+ enqueueResolutionEffects(decisionResourceId, revision, request, outcome, now) {
634
+ const types = outcome === "accepted"
635
+ ? ["decision.continue", "decision.settle_presentations"]
636
+ : ["decision.cancel_parent", "decision.settle_presentations"];
637
+ for (const effectType of types) {
638
+ const idempotencyKey = request.decisionId;
639
+ const effectId = `effect-${digestHex({ decisionId: request.decisionId, effectType }).slice(0, 40)}`;
640
+ const effectResourceId = resourceIdFor("effect", effectId);
641
+ const payloadHash = this.state.putJson({ decisionId: request.decisionId, runId: request.runId, outcome }, now);
642
+ this.state.connection
643
+ .prepare(`INSERT INTO resources(
644
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
645
+ ) VALUES (?, 'effect', ?, 0, ?, ?)`)
646
+ .run(effectResourceId, effectId, now, now);
647
+ this.state.connection
648
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
649
+ .run(effectResourceId);
650
+ this.state.connection
651
+ .prepare(`INSERT INTO effects(
652
+ effect_id, resource_id, source_resource_id, source_revision,
653
+ effect_type, idempotency_key, payload_hash, owner_scope,
654
+ status, attempt_count, created_at, updated_at
655
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'run', 'pending', 0, ?, ?)`)
656
+ .run(effectId, effectResourceId, decisionResourceId, revision, effectType, idempotencyKey, payloadHash, now, now);
389
657
  }
390
- const requests = await Promise.all(entries
391
- .filter((entry) => entry.isDirectory())
392
- .map(async (entry) => await this.readRequest(entry.name)));
393
- return requests.filter((request) => request !== null);
394
658
  }
659
+ assertRunOwner(runId, now) {
660
+ const authority = this.authorityProvider?.(runId);
661
+ if (authority === undefined) {
662
+ throw new Error("Timeout policy requires the current run owner");
663
+ }
664
+ const row = this.state.connection
665
+ .prepare(`SELECT l.generation, l.owner_type AS ownerType, l.owner_id AS ownerId,
666
+ l.token_hash AS tokenHash, l.expires_at AS expiresAt
667
+ FROM runs r JOIN leases l ON l.resource_id = r.resource_id
668
+ WHERE r.run_id = ?`)
669
+ .get(runId);
670
+ if (!isOwnerRow(row) ||
671
+ row.ownerType !== authority.ownerType ||
672
+ row.ownerId !== authority.ownerId ||
673
+ row.generation !== authority.generation ||
674
+ row.tokenHash === null ||
675
+ !row.tokenHash.equals(tokenHash(authority.token)) ||
676
+ row.expiresAt === null ||
677
+ row.expiresAt <= now) {
678
+ throw new Error("Timeout policy run ownership is stale");
679
+ }
680
+ }
681
+ recordChannelMessage(decisionId, channel, purpose, value) {
682
+ const safeChannel = requireSimpleId(channel, "Human decision channel");
683
+ assertId(value.attemptId, `${purpose} attempt id`);
684
+ return this.state.transaction(() => {
685
+ if (this.readRequestRow(decisionId) === undefined) {
686
+ throw new Error(`Human decision is missing: ${decisionId}`);
687
+ }
688
+ const channelId = this.ensureChannel(safeChannel, Date.parse(value.createdAt));
689
+ const messageId = `message-${digestHex({
690
+ decisionId,
691
+ channel: safeChannel,
692
+ purpose,
693
+ attemptId: value.attemptId,
694
+ phase: "phase" in value ? value.phase : undefined,
695
+ partIndex: "partIndex" in value ? value.partIndex : undefined,
696
+ }).slice(0, 40)}`;
697
+ const contentHash = this.state.putJson(value, Date.parse(value.createdAt));
698
+ const existing = this.state.connection
699
+ .prepare("SELECT content_hash AS contentHash FROM channel_messages WHERE message_id = ?")
700
+ .get(messageId);
701
+ if (isContentHashRow(existing)) {
702
+ if (!existing.contentHash.equals(contentHash)) {
703
+ throw new Error(`Immutable human decision ${purpose} conflicts`);
704
+ }
705
+ return "adopted";
706
+ }
707
+ const status = channelMessageStatus(value);
708
+ this.state.connection
709
+ .prepare(`INSERT INTO channel_messages(
710
+ message_id, channel_id, decision_id, purpose, content_hash,
711
+ status, created_at, updated_at
712
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
713
+ .run(messageId, channelId, decisionId, purpose, contentHash, status, Date.parse(value.createdAt), "finishedAt" in value && value.finishedAt !== undefined
714
+ ? Date.parse(value.finishedAt)
715
+ : Date.parse(value.createdAt));
716
+ return "created";
717
+ });
718
+ }
719
+ listChannelMessages(decisionId, channel, purpose) {
720
+ const channelId = channelIdFor(requireSimpleId(channel, "Human decision channel"));
721
+ const rows = this.state.connection
722
+ .prepare(`SELECT content_hash AS contentHash
723
+ FROM channel_messages
724
+ WHERE decision_id = ? AND channel_id = ? AND purpose = ?
725
+ ORDER BY created_at, message_id`)
726
+ .all(decisionId, channelId, purpose);
727
+ return rows.filter(isContentHashRow).map((row) => this.state.readJson(row.contentHash));
728
+ }
729
+ ensureChannel(channel, now) {
730
+ const channelId = channelIdFor(channel);
731
+ const resourceId = resourceIdFor("channel", channelId);
732
+ this.state.connection
733
+ .prepare(`INSERT INTO resources(
734
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
735
+ ) VALUES (?, 'channel', ?, 1, ?, ?)
736
+ ON CONFLICT(resource_type, aggregate_key) DO NOTHING`)
737
+ .run(resourceId, channelId, now, now);
738
+ this.state.connection
739
+ .prepare(`INSERT INTO leases(resource_id, generation)
740
+ VALUES (?, 0) ON CONFLICT(resource_id) DO NOTHING`)
741
+ .run(resourceId);
742
+ this.state.connection
743
+ .prepare(`INSERT INTO channels(channel_id, resource_id, adapter_type, profile_key, created_at)
744
+ VALUES (?, ?, ?, ?, ?)
745
+ ON CONFLICT(channel_id) DO NOTHING`)
746
+ .run(channelId, resourceId, channel.split("-")[0] ?? channel, channel, now);
747
+ return channelId;
748
+ }
749
+ markSubmission(decisionId, attemptId, outcome, resultHash) {
750
+ this.state.connection
751
+ .prepare(`UPDATE human_decision_submissions SET outcome = ?, result_hash = ?
752
+ WHERE decision_id = ? AND attempt_id = ?`)
753
+ .run(outcome, resultHash, decisionId, attemptId);
754
+ }
755
+ readContinuationRow(decisionId) {
756
+ const row = this.state.connection
757
+ .prepare("SELECT response_hash AS recordHash FROM continuations WHERE decision_id = ?")
758
+ .get(decisionId);
759
+ return isContinuationRow(row) ? row : undefined;
760
+ }
761
+ }
762
+ function digestBuffer(value) {
763
+ const hex = value.startsWith("sha256:") ? value.slice(7) : value;
764
+ if (!/^[a-f0-9]{64}$/i.test(hex))
765
+ throw new Error("Expected a SHA-256 digest");
766
+ return Buffer.from(hex, "hex");
767
+ }
768
+ function channelIdFor(channel) {
769
+ return `channel-${digestHex(channel).slice(0, 40)}`;
770
+ }
771
+ function sameHumanAnswer(left, right) {
772
+ return (left.provenance === "human" &&
773
+ right.provenance === "human" &&
774
+ left.idempotencyKey === right.idempotencyKey &&
775
+ canonicalJson(left.response) === canonicalJson(right.response) &&
776
+ canonicalJson(left.source) === canonicalJson(right.source));
777
+ }
778
+ function channelMessageStatus(value) {
779
+ if (value.state === "confirmed")
780
+ return "confirmed";
781
+ if (value.state === "failed")
782
+ return "failed";
783
+ if (value.state === "unknown")
784
+ return "ambiguous";
785
+ return "pending";
786
+ }
787
+ function isResourceIdRow(value) {
788
+ return isRecordValue(value);
789
+ }
790
+ function isPresentRow(value) {
791
+ return isRecordValue(value);
792
+ }
793
+ function isRequestHashRow(value) {
794
+ return isRecordValue(value);
795
+ }
796
+ function isDeadlineRow(value) {
797
+ return isRecordValue(value);
798
+ }
799
+ function isCandidateHashRow(value) {
800
+ return isRecordValue(value);
801
+ }
802
+ function isContentHashRow(value) {
803
+ return isRecordValue(value);
804
+ }
805
+ function isContinuationRow(value) {
806
+ return isRecordValue(value);
807
+ }
808
+ function isDecisionResourceRow(value) {
809
+ return isRecordValue(value);
810
+ }
811
+ function isResolutionRow(value) {
812
+ return isRecordValue(value);
813
+ }
814
+ function isEffectIdentityRecord(value) {
815
+ return isRecordValue(value);
816
+ }
817
+ function isRevisionRecord(value) {
818
+ return isRecordValue(value);
819
+ }
820
+ function isOwnerRow(value) {
821
+ return isRecordValue(value);
822
+ }
823
+ function isRecordValue(value) {
824
+ return typeof value === "object" && value !== null && !Array.isArray(value);
395
825
  }
396
826
  export function createHumanDecisionAttemptId() {
397
827
  return randomUUID();
@@ -505,43 +935,6 @@ function assertJsonValue(value, label) {
505
935
  throw new Error(`${label} must be JSON-serializable`);
506
936
  }
507
937
  }
508
- async function writeImmutableJson(filePath, value, requireIdentical = true) {
509
- const bytes = `${canonicalJson(value)}\n`;
510
- const directory = path.dirname(filePath);
511
- await fs.mkdir(directory, { recursive: true, mode: 0o700 });
512
- const temporary = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
513
- const handle = await fs.open(temporary, "wx", 0o600);
514
- try {
515
- await handle.writeFile(bytes, "utf8");
516
- await handle.sync();
517
- }
518
- finally {
519
- await handle.close();
520
- }
521
- try {
522
- await fs.link(temporary, filePath);
523
- const directoryHandle = await fs.open(directory, "r");
524
- try {
525
- await directoryHandle.sync();
526
- }
527
- finally {
528
- await directoryHandle.close();
529
- }
530
- return "created";
531
- }
532
- catch (error) {
533
- if (error.code !== "EEXIST")
534
- throw error;
535
- const existing = await fs.readFile(filePath, "utf8");
536
- if (requireIdentical && existing !== bytes) {
537
- throw new Error(`Immutable human decision record conflicts: ${filePath}`);
538
- }
539
- return "adopted";
540
- }
541
- finally {
542
- await fs.unlink(temporary).catch(() => undefined);
543
- }
544
- }
545
938
  function validateExpiry(value, createdAt) {
546
939
  if (value === undefined)
547
940
  return undefined;
@@ -554,30 +947,4 @@ function validateExpiry(value, createdAt) {
554
947
  }
555
948
  return new Date(parsed).toISOString();
556
949
  }
557
- async function readJsonDirectory(directory) {
558
- let names;
559
- try {
560
- names = await fs.readdir(directory);
561
- }
562
- catch (error) {
563
- if (error.code === "ENOENT")
564
- return [];
565
- throw error;
566
- }
567
- const values = await Promise.all(names
568
- .filter((name) => name.endsWith(".json"))
569
- .sort()
570
- .map(async (name) => await readJson(path.join(directory, name))));
571
- return values.filter((value) => value !== null);
572
- }
573
- async function readJson(filePath) {
574
- try {
575
- return JSON.parse(await fs.readFile(filePath, "utf8"));
576
- }
577
- catch (error) {
578
- if (error.code === "ENOENT")
579
- return null;
580
- throw error;
581
- }
582
- }
583
950
  //# sourceMappingURL=human-decision.js.map