@osolmaz/pi-workflows 0.12.1 → 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 (209) hide show
  1. package/README.md +52 -28
  2. package/dist/builtins/autodoc.workflow.d.ts +4 -4
  3. package/dist/builtins/autoimplement.workflow.d.ts +369 -73
  4. package/dist/builtins/autoplan.workflow.d.ts +126 -13
  5. package/dist/builtins/autoplan.workflow.js +200 -29
  6. package/dist/builtins/autoplan.workflow.js.map +1 -1
  7. package/dist/builtins/catalog.js +6 -4
  8. package/dist/builtins/catalog.js.map +1 -1
  9. package/dist/builtins/index.d.ts +2 -1
  10. package/dist/builtins/index.js +1 -0
  11. package/dist/builtins/index.js.map +1 -1
  12. package/dist/builtins/monitor.workflow.d.ts +56 -21
  13. package/dist/builtins/monitor.workflow.js +497 -216
  14. package/dist/builtins/monitor.workflow.js.map +1 -1
  15. package/dist/builtins/plain-summary.workflow.d.ts +32 -0
  16. package/dist/builtins/plain-summary.workflow.js +134 -0
  17. package/dist/builtins/plain-summary.workflow.js.map +1 -0
  18. package/dist/builtins/plan-change.workflow.d.ts +170 -22
  19. package/dist/builtins/sanity-check.workflow.d.ts +45 -3
  20. package/dist/builtins/sanity-check.workflow.js +45 -7
  21. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  22. package/dist/controllers/effects.d.ts +3 -2
  23. package/dist/controllers/effects.js +8 -1
  24. package/dist/controllers/effects.js.map +1 -1
  25. package/dist/controllers/index.d.ts +1 -1
  26. package/dist/controllers/index.js +1 -1
  27. package/dist/controllers/index.js.map +1 -1
  28. package/dist/controllers/manager.d.ts +1 -0
  29. package/dist/controllers/manager.js +41 -17
  30. package/dist/controllers/manager.js.map +1 -1
  31. package/dist/controllers/sqlite.d.ts +107 -85
  32. package/dist/controllers/sqlite.js +1611 -1486
  33. package/dist/controllers/sqlite.js.map +1 -1
  34. package/dist/controllers/store.d.ts +9 -8
  35. package/dist/controllers/store.js +1 -31
  36. package/dist/controllers/store.js.map +1 -1
  37. package/dist/controllers/types.d.ts +3 -0
  38. package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
  39. package/dist/controllers/workflow-engine-scheduler.js +3 -5
  40. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  41. package/dist/controllers/workflows.d.ts +2 -2
  42. package/dist/controllers/workflows.js +11 -10
  43. package/dist/controllers/workflows.js.map +1 -1
  44. package/dist/extension/controller-host.js +2 -2
  45. package/dist/extension/controller-host.js.map +1 -1
  46. package/dist/extension/decision-channels.d.ts +2 -0
  47. package/dist/extension/decision-channels.js +311 -118
  48. package/dist/extension/decision-channels.js.map +1 -1
  49. package/dist/extension/executor.d.ts +9 -2
  50. package/dist/extension/executor.js +89 -2
  51. package/dist/extension/executor.js.map +1 -1
  52. package/dist/extension/herdr-viewer.d.ts +0 -1
  53. package/dist/extension/herdr-viewer.js +0 -4
  54. package/dist/extension/herdr-viewer.js.map +1 -1
  55. package/dist/extension/index.js +156 -103
  56. package/dist/extension/index.js.map +1 -1
  57. package/dist/extension/recorder.d.ts +3 -10
  58. package/dist/extension/recorder.js +13 -22
  59. package/dist/extension/recorder.js.map +1 -1
  60. package/dist/extension/step-message.d.ts +7 -2
  61. package/dist/extension/step-message.js +94 -2
  62. package/dist/extension/step-message.js.map +1 -1
  63. package/dist/extension/widget.js +6 -0
  64. package/dist/extension/widget.js.map +1 -1
  65. package/dist/host/rpc-executor.d.ts +3 -0
  66. package/dist/host/rpc-executor.js +2 -0
  67. package/dist/host/rpc-executor.js.map +1 -1
  68. package/dist/host/runner.d.ts +3 -6
  69. package/dist/host/runner.js +83 -32
  70. package/dist/host/runner.js.map +1 -1
  71. package/dist/render/graph-render.js +14 -8
  72. package/dist/render/graph-render.js.map +1 -1
  73. package/dist/state/database.d.ts +35 -0
  74. package/dist/state/database.js +287 -0
  75. package/dist/state/database.js.map +1 -0
  76. package/dist/state/index.d.ts +4 -0
  77. package/dist/state/index.js +5 -0
  78. package/dist/state/index.js.map +1 -0
  79. package/dist/state/json.d.ts +6 -0
  80. package/dist/state/json.js +38 -0
  81. package/dist/state/json.js.map +1 -0
  82. package/dist/state/mutation.d.ts +57 -0
  83. package/dist/state/mutation.js +255 -0
  84. package/dist/state/mutation.js.map +1 -0
  85. package/dist/state/schema.d.ts +6 -0
  86. package/dist/state/schema.js +473 -0
  87. package/dist/state/schema.js.map +1 -0
  88. package/dist/viewer/cli.d.ts +2 -2
  89. package/dist/viewer/cli.js +142 -122
  90. package/dist/viewer/cli.js.map +1 -1
  91. package/dist/viewer/render.d.ts +4 -4
  92. package/dist/viewer/render.js +2 -17
  93. package/dist/viewer/render.js.map +1 -1
  94. package/dist/viewer/session-reducer.d.ts +1 -1
  95. package/dist/viewer/session-reducer.js +1 -1
  96. package/dist/viewer/tui.d.ts +3 -3
  97. package/dist/viewer/tui.js +15 -13
  98. package/dist/viewer/tui.js.map +1 -1
  99. package/dist/viewer/watch.d.ts +2 -5
  100. package/dist/viewer/watch.js +13 -13
  101. package/dist/viewer/watch.js.map +1 -1
  102. package/dist/workflows/composition.js +12 -2
  103. package/dist/workflows/composition.js.map +1 -1
  104. package/dist/workflows/definition.d.ts +6 -2
  105. package/dist/workflows/definition.js +17 -0
  106. package/dist/workflows/definition.js.map +1 -1
  107. package/dist/workflows/engine.d.ts +6 -6
  108. package/dist/workflows/engine.js +183 -103
  109. package/dist/workflows/engine.js.map +1 -1
  110. package/dist/workflows/errors.d.ts +3 -3
  111. package/dist/workflows/errors.js +3 -3
  112. package/dist/workflows/human-decision.d.ts +29 -4
  113. package/dist/workflows/human-decision.js +547 -283
  114. package/dist/workflows/human-decision.js.map +1 -1
  115. package/dist/workflows/index.d.ts +4 -5
  116. package/dist/workflows/index.js +3 -4
  117. package/dist/workflows/index.js.map +1 -1
  118. package/dist/workflows/schema.js +17 -1
  119. package/dist/workflows/schema.js.map +1 -1
  120. package/dist/workflows/store.d.ts +83 -103
  121. package/dist/workflows/store.js +902 -951
  122. package/dist/workflows/store.js.map +1 -1
  123. package/dist/workflows/types.d.ts +55 -81
  124. package/docs/CONTROLLERS.md +10 -8
  125. package/docs/DEFERRED_TURNS.md +2 -2
  126. package/docs/DESIGN_PHILOSOPHY.md +4 -2
  127. package/docs/HUMAN_DECISIONS.md +13 -14
  128. package/docs/MONITOR.md +179 -231
  129. package/docs/SQLITE_STATE.md +219 -0
  130. package/docs/WORKFLOW_COMPOSITION.md +19 -19
  131. package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
  132. package/docs/WORKFLOW_UPDATES.md +10 -9
  133. package/docs/development.md +9 -9
  134. package/docs/live-replay-protocol.md +28 -37
  135. package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
  136. package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
  137. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
  138. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +1 -1
  139. package/docs/plans/2026-08-21-sanity-check-plan.md +30 -16
  140. package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
  141. package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
  142. package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
  143. package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
  144. package/docs/plans/session-event-replay-implementation-plan.md +1 -1
  145. package/docs/plans/tui-viewer-implementation-plan.md +1 -1
  146. package/docs/session-event-journal.md +63 -434
  147. package/docs/tui-viewer.md +13 -15
  148. package/docs/workflows.md +104 -50
  149. package/examples/workflows/plain-summary.workflow.ts +1 -0
  150. package/herdr-plugin.toml +1 -1
  151. package/package.json +1 -1
  152. package/plugins/herdr/viewer.mjs +1 -11
  153. package/skills/autoplan/SKILL.md +5 -4
  154. package/skills/monitor/SKILL.md +91 -163
  155. package/skills/pi-workflows/SKILL.md +7 -7
  156. package/skills/sanity-check/SKILL.md +1 -1
  157. package/src/builtins/autoplan.workflow.ts +290 -40
  158. package/src/builtins/catalog.ts +6 -4
  159. package/src/builtins/index.ts +7 -1
  160. package/src/builtins/monitor.workflow.ts +663 -249
  161. package/src/builtins/plain-summary.workflow.ts +185 -0
  162. package/src/builtins/sanity-check.workflow.ts +62 -7
  163. package/src/controllers/effects.ts +7 -0
  164. package/src/controllers/index.ts +0 -6
  165. package/src/controllers/manager.ts +72 -32
  166. package/src/controllers/sqlite.ts +2420 -1948
  167. package/src/controllers/store.ts +17 -38
  168. package/src/controllers/types.ts +3 -0
  169. package/src/controllers/workflow-engine-scheduler.ts +4 -8
  170. package/src/controllers/workflows.ts +45 -21
  171. package/src/extension/controller-host.ts +1 -2
  172. package/src/extension/decision-channels.ts +427 -144
  173. package/src/extension/executor.ts +105 -2
  174. package/src/extension/herdr-viewer.ts +0 -5
  175. package/src/extension/index.ts +180 -118
  176. package/src/extension/recorder.ts +13 -22
  177. package/src/extension/step-message.ts +106 -4
  178. package/src/extension/widget.ts +8 -0
  179. package/src/host/rpc-executor.ts +4 -0
  180. package/src/host/runner.ts +93 -41
  181. package/src/render/graph-render.ts +11 -4
  182. package/src/state/database.ts +375 -0
  183. package/src/state/index.ts +29 -0
  184. package/src/state/json.ts +42 -0
  185. package/src/state/mutation.ts +435 -0
  186. package/src/state/schema.ts +475 -0
  187. package/src/viewer/cli.ts +151 -144
  188. package/src/viewer/render.ts +12 -24
  189. package/src/viewer/session-reducer.ts +1 -1
  190. package/src/viewer/tui.ts +18 -17
  191. package/src/viewer/watch.ts +13 -17
  192. package/src/workflows/composition.ts +12 -3
  193. package/src/workflows/definition.ts +36 -3
  194. package/src/workflows/engine.ts +212 -116
  195. package/src/workflows/errors.ts +3 -3
  196. package/src/workflows/human-decision.ts +795 -340
  197. package/src/workflows/index.ts +15 -19
  198. package/src/workflows/schema.ts +19 -1
  199. package/src/workflows/store.ts +1295 -1163
  200. package/src/workflows/types.ts +61 -81
  201. package/dist/workflows/artifacts.d.ts +0 -40
  202. package/dist/workflows/artifacts.js +0 -155
  203. package/dist/workflows/artifacts.js.map +0 -1
  204. package/dist/workflows/migrate-sources.d.ts +0 -42
  205. package/dist/workflows/migrate-sources.js +0 -133
  206. package/dist/workflows/migrate-sources.js.map +0 -1
  207. package/docs/run-bundles.md +0 -481
  208. package/src/workflows/artifacts.ts +0 -188
  209. 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,
@@ -309,10 +308,8 @@ export function validateHumanDecisionSubmission(
309
308
  };
310
309
  }
311
310
 
312
- export function humanDecisionStateRoot(runsRoot: string): string {
313
- return path.basename(runsRoot) === "runs"
314
- ? path.join(path.dirname(runsRoot), "decisions")
315
- : `${runsRoot}.decisions`;
311
+ export function humanDecisionDatabasePath(homeDir?: string): string {
312
+ return workflowStatePath(homeDir);
316
313
  }
317
314
 
318
315
  export type HumanDecisionAcceptance =
@@ -347,59 +344,121 @@ function requireCurrentDecision(value: unknown): ResolvedHumanDecision {
347
344
  return value as ResolvedHumanDecision;
348
345
  }
349
346
 
350
- function requireCurrentResolution(value: unknown): HumanDecisionResolution | null {
351
- if (value === null) return null;
352
- const resolution = requireRecord(value, "Human decision resolution");
353
- if (resolution.schema !== "pi-workflows.human-decision-resolution.v1") {
354
- throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
355
- }
356
- if (resolution.outcome === "accepted") {
357
- return {
358
- ...resolution,
359
- decision: requireCurrentDecision(resolution.decision),
360
- } as HumanDecisionResolution;
361
- }
362
- if (resolution.outcome === "cancelled") return value as HumanDecisionResolution;
363
- throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
364
- }
365
-
366
- function requireCurrentDelivery(value: unknown): HumanDecisionDeliveryRecord {
367
- const delivery = requireRecord(value, "Human decision delivery");
368
- if (
369
- delivery.schema !== "pi-workflows.human-decision-delivery.v1" ||
370
- !Object.hasOwn(delivery, "presentationDigest") ||
371
- !Object.hasOwn(delivery, "phase")
372
- ) {
373
- throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
374
- }
375
- return value as HumanDecisionDeliveryRecord;
376
- }
377
-
378
347
  export class HumanDecisionStore {
379
- 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;
380
354
 
381
- constructor(runsRoot: string) {
382
- 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;
383
373
  }
384
374
 
385
- decisionDir(decisionId: string): string {
386
- assertId(decisionId, "decision id");
387
- return path.join(this.root, decisionId);
375
+ close(): void {
376
+ if (this.ownsState) this.state.close();
388
377
  }
389
378
 
390
379
  async createRequest(request: HumanDecisionRequest): Promise<"created" | "adopted"> {
391
380
  validateHumanDecisionRequestIntegrity(request);
392
- return await writeImmutableJson(
393
- path.join(this.decisionDir(request.decisionId), "request.json"),
394
- request,
395
- );
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
+ });
396
456
  }
397
457
 
398
458
  async readRequest(decisionId: string): Promise<HumanDecisionRequest | null> {
399
- const request = (await readJson(
400
- path.join(this.decisionDir(decisionId), "request.json"),
401
- )) as HumanDecisionRequest | null;
402
- 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);
403
462
  }
404
463
 
405
464
  async recordDelivery(
@@ -407,18 +466,7 @@ export class HumanDecisionStore {
407
466
  channel: string,
408
467
  value: HumanDecisionDeliveryRecord,
409
468
  ): Promise<"created" | "adopted"> {
410
- const safeChannel = requireSimpleId(channel, "Human decision channel");
411
- const attemptId = requireString(value.attemptId, "Human decision delivery attempt");
412
- assertId(attemptId, "delivery attempt id");
413
- return await writeImmutableJson(
414
- path.join(
415
- this.decisionDir(request.decisionId),
416
- "deliveries",
417
- safeChannel,
418
- `${attemptId}.json`,
419
- ),
420
- value,
421
- );
469
+ return this.recordChannelMessage(request.decisionId, channel, "delivery", value);
422
470
  }
423
471
 
424
472
  async accept(
@@ -426,101 +474,37 @@ export class HumanDecisionStore {
426
474
  submission: HumanDecisionSubmission,
427
475
  ): Promise<HumanDecisionAcceptance> {
428
476
  validateHumanDecisionRequestIntegrity(request);
429
- const cancellation = await this.readCancellation(request.decisionId);
430
- if (cancellation !== null) {
431
- throw new Error(`Human decision request was ${cancellation.reason}`);
432
- }
433
477
  const normalized = validateHumanDecisionSubmission(request, submission);
434
- let attemptedAt = new Date().toISOString();
478
+ const attemptedAt = new Date().toISOString();
435
479
  const attemptId = digestHex({
436
480
  decisionId: request.decisionId,
437
481
  idempotencyKey: normalized.idempotencyKey,
438
482
  }).slice(0, 40);
439
- const attemptPath = path.join(
440
- this.decisionDir(request.decisionId),
441
- "answers",
442
- `${attemptId}.json`,
443
- );
444
- const attempt = {
445
- schema: "pi-workflows.human-decision-answer-attempt.v1",
446
- attemptId,
447
- attemptedAt,
448
- ...normalized,
449
- };
450
- const attemptWrite = await writeImmutableJson(attemptPath, attempt, false);
451
- if (attemptWrite === "adopted") {
452
- const existingAttempt = requireRecord(
453
- await readJson(attemptPath),
454
- "Existing human decision answer attempt",
455
- );
456
- const existingSubmission = {
457
- decisionId: existingAttempt.decisionId,
458
- requestDigest: existingAttempt.requestDigest,
459
- choice: existingAttempt.choice,
460
- ...(existingAttempt.input !== undefined ? { input: existingAttempt.input } : {}),
461
- source: existingAttempt.source,
462
- idempotencyKey: existingAttempt.idempotencyKey,
463
- };
464
- if (canonicalJson(existingSubmission) !== canonicalJson(normalized)) {
465
- throw new Error("Human decision idempotency key was reused for a different answer");
466
- }
467
- attemptedAt = requireString(existingAttempt.attemptedAt, "answer attempt time");
468
- }
469
- const response = validateHumanDecisionResponse(request, normalized);
470
- const commonDecision = {
483
+ const decision: ResolvedHumanDecision = {
484
+ schema: "pi-workflows.human-decision-accepted.v1",
471
485
  decisionId: request.decisionId,
472
486
  requestDigest: request.requestDigest,
473
- response,
474
- provenance: "human" as const,
487
+ response: validateHumanDecisionResponse(request, normalized),
488
+ provenance: "human",
475
489
  source: normalized.source,
476
490
  idempotencyKey: normalized.idempotencyKey,
477
491
  acceptedAt: attemptedAt,
478
- answerDigest: digest({ response, source: normalized.source }),
479
- };
480
- const decision: AcceptedHumanDecision = {
481
- schema: "pi-workflows.human-decision-accepted.v1",
482
- ...commonDecision,
492
+ answerDigest: digest({
493
+ requestDigest: request.requestDigest,
494
+ response: validateHumanDecisionResponse(request, normalized),
495
+ source: normalized.source,
496
+ }),
483
497
  subjectDigest: request.subjectDigest,
484
498
  presentationDigest: request.presentationDigest,
485
499
  revision: request.revision,
486
500
  };
487
- const resolutionPath = path.join(this.decisionDir(request.decisionId), "resolution.json");
488
- const resolution: HumanDecisionResolution = {
489
- schema: "pi-workflows.human-decision-resolution.v1",
490
- outcome: "accepted",
491
- decision,
492
- };
493
- const result = await writeImmutableJson(resolutionPath, resolution, false);
494
- const winner =
495
- result === "created"
496
- ? resolution
497
- : ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
498
- if (winner === null) throw new Error("Human decision resolution became unreadable");
499
- const winningCancellation = await this.readCancellation(request.decisionId);
500
- if (winningCancellation !== null) {
501
- throw new Error(`Human decision request was ${winningCancellation.reason}`);
502
- }
503
- if (winner.outcome === "cancelled") {
504
- throw new Error(`Human decision request was ${winner.cancellation.reason}`);
505
- }
506
- const existing = winner.decision;
507
- await writeImmutableJson(
508
- path.join(this.decisionDir(request.decisionId), "accepted.json"),
509
- existing,
510
- );
511
- if (result === "created") return { status: "accepted", decision: existing };
512
- if (canonicalJson(existing) === canonicalJson(decision)) {
513
- return { status: "adopted", decision: existing };
514
- }
515
- if (
516
- existing.provenance === "human" &&
517
- existing.idempotencyKey === decision.idempotencyKey &&
518
- canonicalJson(existing.response) === canonicalJson(decision.response) &&
519
- canonicalJson(existing.source) === canonicalJson(decision.source)
520
- ) {
521
- return { status: "adopted", decision: existing };
522
- }
523
- return { status: "conflict", 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
+ });
524
508
  }
525
509
 
526
510
  async resolveTimeout(
@@ -534,63 +518,38 @@ export class HumanDecisionStore {
534
518
  if (Date.parse(request.expiresAt) > now.getTime()) {
535
519
  throw new Error("Human decision timeout default is not eligible yet");
536
520
  }
521
+ this.assertRunOwner(request.runId, now.getTime());
537
522
  const response = validateHumanDecisionResponse(request, request.defaultResponse);
538
- const commonDecision = {
523
+ const decision: ResolvedHumanDecision = {
524
+ schema: "pi-workflows.human-decision-accepted.v1",
539
525
  decisionId: request.decisionId,
540
526
  requestDigest: request.requestDigest,
541
527
  response,
542
- provenance: "timeout" as const,
528
+ provenance: "timeout",
543
529
  acceptedAt: now.toISOString(),
544
530
  answerDigest: digest({
545
531
  provenance: "timeout",
546
532
  requestDigest: request.requestDigest,
547
533
  response,
548
534
  }),
549
- };
550
- const decision: ResolvedHumanDecision = {
551
- schema: "pi-workflows.human-decision-accepted.v1",
552
- ...commonDecision,
553
535
  subjectDigest: request.subjectDigest,
554
536
  presentationDigest: request.presentationDigest,
555
537
  revision: request.revision,
556
538
  };
557
- const resolutionPath = path.join(this.decisionDir(request.decisionId), "resolution.json");
558
- const resolution: HumanDecisionResolution = {
559
- schema: "pi-workflows.human-decision-resolution.v1",
560
- outcome: "accepted",
561
- decision,
562
- };
563
- const result = await writeImmutableJson(resolutionPath, resolution, false);
564
- const winner =
565
- result === "created"
566
- ? resolution
567
- : ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
568
- if (winner === null) throw new Error("Human decision resolution became unreadable");
569
- const winningCancellation = await this.readCancellation(request.decisionId);
570
- if (winningCancellation !== null) {
571
- throw new Error(`Human decision request was ${winningCancellation.reason}`);
572
- }
573
- if (winner.outcome === "cancelled") {
574
- throw new Error(`Human decision request was ${winner.cancellation.reason}`);
575
- }
576
- const existing = winner.decision;
577
- await writeImmutableJson(
578
- path.join(this.decisionDir(request.decisionId), "accepted.json"),
579
- existing,
580
- );
581
- if (result === "created") return { status: "accepted", decision: existing };
582
- return canonicalJson(existing) === canonicalJson(decision)
583
- ? { status: "adopted", decision: existing }
584
- : { status: "conflict", decision: existing };
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
+ });
585
546
  }
586
547
 
587
548
  async listDeliveries(
588
549
  decisionId: string,
589
550
  channel: string,
590
551
  ): Promise<HumanDecisionDeliveryRecord[]> {
591
- const safeChannel = requireSimpleId(channel, "Human decision channel");
592
- const directory = path.join(this.decisionDir(decisionId), "deliveries", safeChannel);
593
- return (await readJsonDirectory(directory)).map(requireCurrentDelivery);
552
+ return this.listChannelMessages<HumanDecisionDeliveryRecord>(decisionId, channel, "delivery");
594
553
  }
595
554
 
596
555
  async recordSettlement(
@@ -598,26 +557,18 @@ export class HumanDecisionStore {
598
557
  channel: string,
599
558
  value: HumanDecisionSettlementRecord,
600
559
  ): Promise<"created" | "adopted"> {
601
- const safeChannel = requireSimpleId(channel, "Human decision channel");
602
- assertId(value.attemptId, "settlement attempt id");
603
- return await writeImmutableJson(
604
- path.join(
605
- this.decisionDir(decisionId),
606
- "settlements",
607
- safeChannel,
608
- `${value.attemptId}.json`,
609
- ),
610
- value,
611
- );
560
+ return this.recordChannelMessage(decisionId, channel, "settlement", value);
612
561
  }
613
562
 
614
563
  async listSettlements(
615
564
  decisionId: string,
616
565
  channel: string,
617
566
  ): Promise<HumanDecisionSettlementRecord[]> {
618
- const safeChannel = requireSimpleId(channel, "Human decision channel");
619
- const directory = path.join(this.decisionDir(decisionId), "settlements", safeChannel);
620
- return (await readJsonDirectory(directory)) as HumanDecisionSettlementRecord[];
567
+ return this.listChannelMessages<HumanDecisionSettlementRecord>(
568
+ decisionId,
569
+ channel,
570
+ "settlement",
571
+ );
621
572
  }
622
573
 
623
574
  async cancel(
@@ -625,107 +576,677 @@ export class HumanDecisionStore {
625
576
  reason: HumanDecisionCancellationRecord["reason"],
626
577
  ): Promise<"created" | "adopted"> {
627
578
  validateHumanDecisionRequestIntegrity(request);
628
- if ((await this.readResolved(request.decisionId)) !== null) {
629
- throw new Error("Resolved human decision cannot be cancelled");
630
- }
631
- const filePath = path.join(this.decisionDir(request.decisionId), "cancelled.json");
632
- const record: HumanDecisionCancellationRecord = {
633
- schema: "pi-workflows.human-decision-cancellation.v1",
634
- decisionId: request.decisionId,
635
- requestDigest: request.requestDigest,
636
- cancelledAt: new Date().toISOString(),
637
- reason,
638
- };
639
- const resolutionPath = path.join(this.decisionDir(request.decisionId), "resolution.json");
640
- const resolution: HumanDecisionResolution = {
641
- schema: "pi-workflows.human-decision-resolution.v1",
642
- outcome: "cancelled",
643
- cancellation: record,
644
- };
645
- const result = await writeImmutableJson(resolutionPath, resolution, false);
646
- const winner =
647
- result === "created"
648
- ? resolution
649
- : ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
650
- if (winner === null) throw new Error("Human decision resolution became unreadable");
651
- if (winner.outcome === "accepted") {
652
- throw new Error("Resolved 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");
653
581
  }
654
- const existing = winner.cancellation;
655
- if (
656
- existing.decisionId !== request.decisionId ||
657
- existing.requestDigest !== request.requestDigest ||
658
- existing.reason !== reason
659
- ) {
660
- throw new Error("Immutable human decision cancellation conflicts");
661
- }
662
- await writeImmutableJson(filePath, existing);
663
- return result;
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
+ });
664
635
  }
665
636
 
666
637
  async readCancellation(decisionId: string): Promise<HumanDecisionCancellationRecord | null> {
667
- const stored = (await readJson(
668
- path.join(this.decisionDir(decisionId), "cancelled.json"),
669
- )) as HumanDecisionCancellationRecord | null;
670
- if (stored !== null) return stored;
671
- const resolution = requireCurrentResolution(
672
- await readJson(path.join(this.decisionDir(decisionId), "resolution.json")),
673
- );
674
- if (resolution?.outcome !== "cancelled") return null;
675
- await writeImmutableJson(
676
- path.join(this.decisionDir(decisionId), "cancelled.json"),
677
- resolution.cancellation,
678
- );
679
- return resolution.cancellation;
638
+ const resolution = this.readResolution(decisionId);
639
+ return resolution?.outcome === "cancelled" ? resolution.cancellation : null;
680
640
  }
681
641
 
682
642
  async readResolved(decisionId: string): Promise<ResolvedHumanDecision | null> {
683
- const cancellation = await readJson(path.join(this.decisionDir(decisionId), "cancelled.json"));
684
- if (cancellation !== null) return null;
685
- const stored = await readJson(path.join(this.decisionDir(decisionId), "accepted.json"));
686
- if (stored !== null) return requireCurrentDecision(stored);
687
- const resolution = requireCurrentResolution(
688
- await readJson(path.join(this.decisionDir(decisionId), "resolution.json")),
689
- );
690
- if (resolution?.outcome !== "accepted") return null;
691
- await writeImmutableJson(
692
- path.join(this.decisionDir(decisionId), "accepted.json"),
693
- resolution.decision,
694
- );
695
- return resolution.decision;
643
+ const resolution = this.readResolution(decisionId);
644
+ return resolution?.outcome === "accepted" ? resolution.decision : null;
696
645
  }
697
646
 
698
647
  async recordContinuation(
699
648
  decisionId: string,
700
649
  value: HumanDecisionContinuationRecord,
701
650
  ): Promise<"created" | "adopted"> {
702
- return await writeImmutableJson(
703
- path.join(this.decisionDir(decisionId), "continuation.json"),
704
- value,
705
- );
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
+ });
706
674
  }
707
675
 
708
676
  async readContinuation(decisionId: string): Promise<HumanDecisionContinuationRecord | null> {
709
- return (await readJson(
710
- path.join(this.decisionDir(decisionId), "continuation.json"),
711
- )) as 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
+ }
712
729
  }
713
730
 
714
731
  async listRequests(): Promise<HumanDecisionRequest[]> {
715
- let entries: Dirent[];
716
- try {
717
- entries = await fs.readdir(this.root, { withFileTypes: true });
718
- } catch (error) {
719
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
720
- throw error;
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");
721
861
  }
722
- const requests = await Promise.all(
723
- entries
724
- .filter((entry) => entry.isDirectory())
725
- .map(async (entry) => await this.readRequest(entry.name)),
726
- );
727
- return requests.filter((request): request is HumanDecisionRequest => request !== null);
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
+ };
870
+ }
871
+ const cancellation: HumanDecisionCancellationRecord = {
872
+ schema: "pi-workflows.human-decision-cancellation.v1",
873
+ decisionId,
874
+ requestDigest: storedRequest.requestDigest,
875
+ cancelledAt: new Date(row.resolvedAt).toISOString(),
876
+ reason: row.reason === "expired" ? "expired" : "cancelled",
877
+ };
878
+ return {
879
+ schema: "pi-workflows.human-decision-resolution.v1",
880
+ outcome: "cancelled",
881
+ cancellation,
882
+ };
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
+ );
1003
+ }
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);
1019
+ if (
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
1028
+ ) {
1029
+ throw new Error("Timeout policy run ownership is stale");
1030
+ }
1031
+ }
1032
+
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
+ });
1088
+ }
1089
+
1090
+ private listChannelMessages<T>(
1091
+ decisionId: string,
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);
728
1105
  }
1106
+
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;
1132
+ }
1133
+
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;
1153
+ }
1154
+ }
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);
729
1250
  }
730
1251
 
731
1252
  export function createHumanDecisionAttemptId(): string {
@@ -855,46 +1376,6 @@ function assertJsonValue(value: unknown, label: string): void {
855
1376
  }
856
1377
  }
857
1378
 
858
- async function writeImmutableJson(
859
- filePath: string,
860
- value: unknown,
861
- requireIdentical = true,
862
- ): Promise<"created" | "adopted"> {
863
- const bytes = `${canonicalJson(value)}\n`;
864
- const directory = path.dirname(filePath);
865
- await fs.mkdir(directory, { recursive: true, mode: 0o700 });
866
- const temporary = path.join(
867
- directory,
868
- `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
869
- );
870
- const handle = await fs.open(temporary, "wx", 0o600);
871
- try {
872
- await handle.writeFile(bytes, "utf8");
873
- await handle.sync();
874
- } finally {
875
- await handle.close();
876
- }
877
- try {
878
- await fs.link(temporary, filePath);
879
- const directoryHandle = await fs.open(directory, "r");
880
- try {
881
- await directoryHandle.sync();
882
- } finally {
883
- await directoryHandle.close();
884
- }
885
- return "created";
886
- } catch (error) {
887
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
888
- const existing = await fs.readFile(filePath, "utf8");
889
- if (requireIdentical && existing !== bytes) {
890
- throw new Error(`Immutable human decision record conflicts: ${filePath}`);
891
- }
892
- return "adopted";
893
- } finally {
894
- await fs.unlink(temporary).catch(() => undefined);
895
- }
896
- }
897
-
898
1379
  function validateExpiry(
899
1380
  value: string | undefined,
900
1381
  createdAt: string | undefined,
@@ -908,29 +1389,3 @@ function validateExpiry(
908
1389
  }
909
1390
  return new Date(parsed).toISOString();
910
1391
  }
911
-
912
- async function readJsonDirectory(directory: string): Promise<unknown[]> {
913
- let names: string[];
914
- try {
915
- names = await fs.readdir(directory);
916
- } catch (error) {
917
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
918
- throw error;
919
- }
920
- const values = await Promise.all(
921
- names
922
- .filter((name) => name.endsWith(".json"))
923
- .sort()
924
- .map(async (name) => await readJson(path.join(directory, name))),
925
- );
926
- return values.filter((value) => value !== null);
927
- }
928
-
929
- async function readJson(filePath: string): Promise<unknown | null> {
930
- try {
931
- return JSON.parse(await fs.readFile(filePath, "utf8"));
932
- } catch (error) {
933
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
934
- throw error;
935
- }
936
- }