@osolmaz/pi-workflows 0.9.1 → 0.11.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 (147) hide show
  1. package/README.md +64 -19
  2. package/dist/builtins/autodoc.workflow.d.ts +58 -0
  3. package/dist/builtins/autodoc.workflow.js +266 -0
  4. package/dist/builtins/autodoc.workflow.js.map +1 -0
  5. package/dist/builtins/autoimplement.workflow.d.ts +353 -0
  6. package/dist/builtins/autoimplement.workflow.js +961 -0
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -0
  8. package/dist/builtins/autoplan.workflow.d.ts +58 -0
  9. package/dist/builtins/autoplan.workflow.js +190 -0
  10. package/dist/builtins/autoplan.workflow.js.map +1 -0
  11. package/dist/builtins/catalog.js +9 -1
  12. package/dist/builtins/catalog.js.map +1 -1
  13. package/dist/builtins/index.d.ts +6 -0
  14. package/dist/builtins/index.js +7 -0
  15. package/dist/builtins/index.js.map +1 -0
  16. package/dist/builtins/monitor.workflow.d.ts +29 -3
  17. package/dist/builtins/monitor.workflow.js +325 -13
  18. package/dist/builtins/monitor.workflow.js.map +1 -1
  19. package/dist/builtins/plan-approval.workflow.d.ts +83 -0
  20. package/dist/builtins/plan-approval.workflow.js +148 -0
  21. package/dist/builtins/plan-approval.workflow.js.map +1 -0
  22. package/dist/builtins/plan-presentation.d.ts +7 -0
  23. package/dist/builtins/plan-presentation.js +44 -0
  24. package/dist/builtins/plan-presentation.js.map +1 -0
  25. package/dist/extension/decision-channels.d.ts +132 -0
  26. package/dist/extension/decision-channels.js +1082 -0
  27. package/dist/extension/decision-channels.js.map +1 -0
  28. package/dist/extension/index.d.ts +1 -0
  29. package/dist/extension/index.js +426 -11
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/widget.js +26 -2
  32. package/dist/extension/widget.js.map +1 -1
  33. package/dist/extension/workflow-tool.d.ts +1 -37
  34. package/dist/extension/workflow-tool.js +1 -42
  35. package/dist/extension/workflow-tool.js.map +1 -1
  36. package/dist/host/rpc-bridge.js +4 -21
  37. package/dist/host/rpc-bridge.js.map +1 -1
  38. package/dist/render/graph-render.js +46 -10
  39. package/dist/render/graph-render.js.map +1 -1
  40. package/dist/viewer/render.js +36 -2
  41. package/dist/viewer/render.js.map +1 -1
  42. package/dist/workflows/catalog.d.ts +1 -0
  43. package/dist/workflows/catalog.js +6 -0
  44. package/dist/workflows/catalog.js.map +1 -1
  45. package/dist/workflows/composition.d.ts +45 -0
  46. package/dist/workflows/composition.js +485 -0
  47. package/dist/workflows/composition.js.map +1 -0
  48. package/dist/workflows/decision-presentation.d.ts +18 -0
  49. package/dist/workflows/decision-presentation.js +417 -0
  50. package/dist/workflows/decision-presentation.js.map +1 -0
  51. package/dist/workflows/decision.d.ts +11 -5
  52. package/dist/workflows/decision.js.map +1 -1
  53. package/dist/workflows/definition.d.ts +22 -3
  54. package/dist/workflows/definition.js +46 -3
  55. package/dist/workflows/definition.js.map +1 -1
  56. package/dist/workflows/engine.d.ts +2 -1
  57. package/dist/workflows/engine.js +199 -18
  58. package/dist/workflows/engine.js.map +1 -1
  59. package/dist/workflows/graph.js +8 -6
  60. package/dist/workflows/graph.js.map +1 -1
  61. package/dist/workflows/human-decision.d.ts +87 -0
  62. package/dist/workflows/human-decision.js +583 -0
  63. package/dist/workflows/human-decision.js.map +1 -0
  64. package/dist/workflows/index.d.ts +5 -2
  65. package/dist/workflows/index.js +4 -1
  66. package/dist/workflows/index.js.map +1 -1
  67. package/dist/workflows/loader.d.ts +5 -4
  68. package/dist/workflows/loader.js +118 -18
  69. package/dist/workflows/loader.js.map +1 -1
  70. package/dist/workflows/schema.d.ts +3 -1
  71. package/dist/workflows/schema.js +64 -2
  72. package/dist/workflows/schema.js.map +1 -1
  73. package/dist/workflows/store.js +39 -2
  74. package/dist/workflows/store.js.map +1 -1
  75. package/dist/workflows/tool-input.d.ts +72 -0
  76. package/dist/workflows/tool-input.js +141 -0
  77. package/dist/workflows/tool-input.js.map +1 -0
  78. package/dist/workflows/types.d.ts +300 -2
  79. package/docs/CONTROLLERS.md +1 -1
  80. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  81. package/docs/HUMAN_DECISIONS.md +371 -0
  82. package/docs/HUMAN_DECISION_PRESENTATIONS.md +323 -0
  83. package/docs/MONITOR.md +46 -18
  84. package/docs/WORKFLOW_COMPOSITION.md +333 -0
  85. package/docs/plans/2026-08-19-human-decision-gates-plan.md +296 -0
  86. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +172 -0
  87. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +45 -0
  88. package/docs/plans/2026-08-19-workflow-composition-plan.md +308 -0
  89. package/docs/run-bundles.md +45 -10
  90. package/docs/workflows.md +106 -15
  91. package/examples/workflows/approved-plan.workflow.ts +58 -0
  92. package/examples/workflows/autoimplement.workflow.ts +1 -92
  93. package/examples/workflows/autoplan.workflow.ts +1 -0
  94. package/examples/workflows/human-decision.workflow.ts +62 -0
  95. package/herdr-plugin.toml +1 -1
  96. package/package.json +6 -1
  97. package/schemas/decision-presentation-v1.schema.json +83 -0
  98. package/schemas/human-decision-accepted-v1.schema.json +44 -0
  99. package/schemas/human-decision-accepted-v2.schema.json +50 -0
  100. package/schemas/human-decision-answer-attempt-v1.schema.json +37 -0
  101. package/schemas/human-decision-cancellation-v1.schema.json +15 -0
  102. package/schemas/human-decision-continuation-v1.schema.json +16 -0
  103. package/schemas/human-decision-delivery-v1.schema.json +28 -0
  104. package/schemas/human-decision-delivery-v2.schema.json +36 -0
  105. package/schemas/human-decision-receipt-v1.schema.json +33 -0
  106. package/schemas/human-decision-receipt-v2.schema.json +39 -0
  107. package/schemas/human-decision-request-v1.schema.json +59 -0
  108. package/schemas/human-decision-request-v2.schema.json +69 -0
  109. package/schemas/human-decision-resolution-v1.schema.json +27 -0
  110. package/schemas/human-decision-resolution-v2.schema.json +27 -0
  111. package/schemas/human-decision-settlement-v1.schema.json +28 -0
  112. package/skills/autodoc/SKILL.md +43 -0
  113. package/skills/autoimplement/SKILL.md +58 -0
  114. package/skills/autoimplement/agents/openai.yaml +4 -0
  115. package/skills/autoplan/SKILL.md +25 -0
  116. package/skills/monitor/SKILL.md +8 -1
  117. package/skills/pi-workflows/SKILL.md +5 -3
  118. package/src/builtins/autodoc.workflow.ts +325 -0
  119. package/src/builtins/autoimplement.workflow.ts +1123 -0
  120. package/src/builtins/autoplan.workflow.ts +231 -0
  121. package/src/builtins/catalog.ts +9 -1
  122. package/src/builtins/index.ts +32 -0
  123. package/src/builtins/monitor.workflow.ts +396 -15
  124. package/src/builtins/plan-approval.workflow.ts +190 -0
  125. package/src/builtins/plan-presentation.ts +57 -0
  126. package/src/extension/decision-channels.ts +1533 -0
  127. package/src/extension/index.ts +499 -9
  128. package/src/extension/widget.ts +39 -2
  129. package/src/extension/workflow-tool.ts +5 -81
  130. package/src/host/rpc-bridge.ts +7 -30
  131. package/src/render/graph-render.ts +54 -5
  132. package/src/viewer/render.ts +56 -3
  133. package/src/workflows/catalog.ts +7 -0
  134. package/src/workflows/composition.ts +642 -0
  135. package/src/workflows/decision-presentation.ts +501 -0
  136. package/src/workflows/decision.ts +12 -5
  137. package/src/workflows/definition.ts +118 -8
  138. package/src/workflows/engine.ts +257 -20
  139. package/src/workflows/graph.ts +8 -6
  140. package/src/workflows/human-decision.ts +819 -0
  141. package/src/workflows/index.ts +89 -0
  142. package/src/workflows/loader.ts +186 -18
  143. package/src/workflows/schema.ts +79 -2
  144. package/src/workflows/store.ts +45 -2
  145. package/src/workflows/tool-input.ts +202 -0
  146. package/src/workflows/types.ts +374 -2
  147. package/examples/workflows/elegant-solution.workflow.ts +0 -95
@@ -0,0 +1,819 @@
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";
5
+ import {
6
+ digestCanonical,
7
+ normalizeDecisionPresentation,
8
+ validateHumanDecisionRequestIntegrity,
9
+ } from "./decision-presentation.js";
10
+ import { checkpoint } from "./definition.js";
11
+ import type {
12
+ AcceptedHumanDecision,
13
+ CheckpointNodeDefinition,
14
+ HumanDecisionChoice,
15
+ HumanDecisionAudience,
16
+ HumanDecisionCancellationRecord,
17
+ HumanDecisionChoiceMap,
18
+ HumanDecisionChannelRequest,
19
+ HumanDecisionContinuationRecord,
20
+ HumanDecisionDeliveryRecord,
21
+ HumanDecisionPrompt,
22
+ HumanDecisionRequest,
23
+ HumanDecisionResponse,
24
+ HumanDecisionSettlementRecord,
25
+ HumanDecisionSubmission,
26
+ HumanDecisionTextInput,
27
+ WorkflowNodeCommon,
28
+ WorkflowNodeContext,
29
+ } from "./types.js";
30
+
31
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
32
+ const CHOICE_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]{0,127}$/;
33
+ const CHANNEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
34
+ const DEFAULT_MIN_LENGTH = 1;
35
+ const DEFAULT_MAX_LENGTH = 4_000;
36
+
37
+ export type HumanDecisionChoiceDefinition = Omit<HumanDecisionChoice, "input"> & {
38
+ input?: HumanDecisionTextInput;
39
+ };
40
+
41
+ export type HumanDecisionDefinition<TChoices extends HumanDecisionChoiceMap> =
42
+ WorkflowNodeCommon & {
43
+ audience: HumanDecisionAudience;
44
+ choices: TChoices;
45
+ request: (context: WorkflowNodeContext) => HumanDecisionPrompt | Promise<HumanDecisionPrompt>;
46
+ };
47
+
48
+ export type HumanDecisionResponseFor<TChoices extends HumanDecisionChoiceMap> = {
49
+ [K in keyof TChoices & string]: TChoices[K] extends { input: HumanDecisionTextInput }
50
+ ? { choice: K; input: Record<TChoices[K]["input"]["name"], string> }
51
+ : { choice: K };
52
+ }[keyof TChoices & string];
53
+
54
+ export function textInput<const TName extends string>(options: {
55
+ name: TName;
56
+ prompt: string;
57
+ minLength?: number;
58
+ maxLength?: number;
59
+ }): HumanDecisionTextInput & { name: TName } {
60
+ const name = requireSimpleId(options.name, "Human decision text input name");
61
+ const prompt = requireString(options.prompt, "Human decision text input prompt");
62
+ const minLength = options.minLength ?? DEFAULT_MIN_LENGTH;
63
+ const maxLength = options.maxLength ?? DEFAULT_MAX_LENGTH;
64
+ if (!Number.isInteger(minLength) || minLength < 0) {
65
+ throw new Error("Human decision text input minLength must be a non-negative integer");
66
+ }
67
+ if (!Number.isInteger(maxLength) || maxLength < 1 || maxLength < minLength) {
68
+ throw new Error(
69
+ "Human decision text input maxLength must be a positive integer at least minLength",
70
+ );
71
+ }
72
+ return { kind: "text", name: name as TName, prompt, minLength, maxLength };
73
+ }
74
+
75
+ export function choice<const TChoice extends HumanDecisionChoiceDefinition>(
76
+ definition: TChoice,
77
+ ): TChoice {
78
+ validateChoice(definition, "choice");
79
+ return definition;
80
+ }
81
+
82
+ export function defineHumanChoices<const TChoices extends HumanDecisionChoiceMap>(
83
+ choices: TChoices,
84
+ ): TChoices {
85
+ validateChoices(choices);
86
+ return Object.freeze({ ...choices });
87
+ }
88
+
89
+ /** Build a typed verified-human gate that still executes as a checkpoint. */
90
+ export function humanDecision<const TChoices extends HumanDecisionChoiceMap>(
91
+ definition: HumanDecisionDefinition<TChoices>,
92
+ ): CheckpointNodeDefinition & { readonly __humanChoices?: TChoices } {
93
+ const audience =
94
+ typeof definition.audience === "function"
95
+ ? definition.audience
96
+ : requireSimpleId(definition.audience, "Human decision audience");
97
+ validateChoices(definition.choices);
98
+ if (typeof definition.request !== "function") {
99
+ throw new Error("Human decision request must be a function");
100
+ }
101
+ const { choices, request, ...common } = definition;
102
+ return checkpoint({
103
+ ...common,
104
+ summary: typeof audience === "string" ? `human decision for ${audience}` : "human decision",
105
+ humanDecision: { audience, choices, request },
106
+ }) as CheckpointNodeDefinition & { readonly __humanChoices?: TChoices };
107
+ }
108
+
109
+ export function humanDecisionEdge<
110
+ const TChoices extends HumanDecisionChoiceMap,
111
+ const TFrom extends string,
112
+ const TCases extends Record<keyof TChoices & string, string>,
113
+ >(args: {
114
+ from: TFrom;
115
+ choices: TChoices;
116
+ cases: TCases & Record<Exclude<keyof TCases, keyof TChoices>, never>;
117
+ }): { from: TFrom; switch: { on: "$.choice"; cases: TCases } } {
118
+ validateChoices(args.choices);
119
+ for (const key of Object.keys(args.choices)) {
120
+ if (!Object.hasOwn(args.cases, key)) {
121
+ throw new Error(`Human decision edge is missing case for choice ${JSON.stringify(key)}`);
122
+ }
123
+ }
124
+ for (const key of Object.keys(args.cases)) {
125
+ if (!Object.hasOwn(args.choices, key)) {
126
+ throw new Error(`Human decision edge has unknown case ${JSON.stringify(key)}`);
127
+ }
128
+ }
129
+ return { from: args.from, switch: { on: "$.choice", cases: args.cases } };
130
+ }
131
+
132
+ export function createHumanDecisionRequest(input: {
133
+ runId: string;
134
+ workflowName: string;
135
+ nodeId: string;
136
+ attemptId: string;
137
+ contract: { audience: string; choices: HumanDecisionChoiceMap };
138
+ prompt: HumanDecisionPrompt;
139
+ createdAt?: string;
140
+ }): HumanDecisionRequest {
141
+ validateChoices(input.contract.choices);
142
+ const title = requireString(input.prompt.title, "Human decision title");
143
+ const expiresAt = validateExpiry(input.prompt.expiresAt, input.createdAt);
144
+ const common = {
145
+ runId: input.runId,
146
+ workflowName: input.workflowName,
147
+ nodeId: input.nodeId,
148
+ attemptId: input.attemptId,
149
+ audience: requireSimpleId(input.contract.audience, "Human decision audience"),
150
+ title,
151
+ choices: input.contract.choices,
152
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
153
+ } as const;
154
+ const presented =
155
+ Object.hasOwn(input.prompt, "subject") || Object.hasOwn(input.prompt, "presentation");
156
+ if (presented) {
157
+ validatePresentedText(title, "Human decision title");
158
+ validatePresentedChoices(input.contract.choices);
159
+ if (Object.hasOwn(input.prompt, "body")) {
160
+ throw new Error("Presented human decision requests must not contain a legacy body");
161
+ }
162
+ if (!Object.hasOwn(input.prompt, "subject") || !Object.hasOwn(input.prompt, "presentation")) {
163
+ throw new Error("Presented human decision requests require subject and presentation");
164
+ }
165
+ const prompt = input.prompt as Extract<HumanDecisionPrompt, { subject: unknown }>;
166
+ assertJsonValue(prompt.subject, "Human decision subject");
167
+ const presentation = normalizeDecisionPresentation(prompt.presentation);
168
+ const revision = prompt.revision ?? 1;
169
+ if (!Number.isInteger(revision) || revision < 1) {
170
+ throw new Error("Human decision revision must be a positive integer");
171
+ }
172
+ const basis = {
173
+ schema: "pi-workflows.human-decision-request.v2" as const,
174
+ ...common,
175
+ subject: prompt.subject,
176
+ presentation,
177
+ revision,
178
+ };
179
+ const subjectDigest = digestCanonical(prompt.subject);
180
+ const presentationDigest = digestCanonical(presentation);
181
+ const requestDigest = digestCanonical(basis);
182
+ const decisionId = decisionIdFor(input, requestDigest);
183
+ return {
184
+ ...basis,
185
+ decisionId,
186
+ requestDigest,
187
+ subjectDigest,
188
+ presentationDigest,
189
+ createdAt: input.createdAt ?? new Date().toISOString(),
190
+ };
191
+ }
192
+ const prompt = input.prompt as Extract<HumanDecisionPrompt, { body: unknown }>;
193
+ assertJsonValue(prompt.body, "Human decision body");
194
+ const basis = {
195
+ schema: "pi-workflows.human-decision-request.v1" as const,
196
+ ...common,
197
+ body: prompt.body,
198
+ };
199
+ const requestDigest = digest(basis);
200
+ return {
201
+ ...basis,
202
+ decisionId: decisionIdFor(input, requestDigest),
203
+ requestDigest,
204
+ createdAt: input.createdAt ?? new Date().toISOString(),
205
+ };
206
+ }
207
+
208
+ function decisionIdFor(
209
+ input: Pick<Parameters<typeof createHumanDecisionRequest>[0], "runId" | "nodeId" | "attemptId">,
210
+ requestDigest: string,
211
+ ): string {
212
+ return `decision-${digestHex({
213
+ runId: input.runId,
214
+ nodeId: input.nodeId,
215
+ attemptId: input.attemptId,
216
+ requestDigest,
217
+ }).slice(0, 40)}`;
218
+ }
219
+
220
+ export function validateHumanDecisionResponse(
221
+ request: HumanDecisionRequest,
222
+ value: unknown,
223
+ ): HumanDecisionResponse {
224
+ const response = requireRecord(value, "Human decision response");
225
+ const selected = response.choice;
226
+ if (typeof selected !== "string" || !Object.hasOwn(request.choices, selected)) {
227
+ throw new Error(`Human decision choice ${JSON.stringify(selected)} is not available`);
228
+ }
229
+ const selectedChoice = request.choices[selected];
230
+ if (!selectedChoice) throw new Error("Human decision choice contract is missing");
231
+ if (selectedChoice.input === undefined) {
232
+ if (response.input !== undefined) {
233
+ throw new Error(`Human decision choice ${selected} does not accept input`);
234
+ }
235
+ return { choice: selected };
236
+ }
237
+ const rawInput = requireRecord(response.input, `Human decision choice ${selected} input`);
238
+ const keys = Object.keys(rawInput);
239
+ if (keys.length !== 1 || keys[0] !== selectedChoice.input.name) {
240
+ throw new Error(
241
+ `Human decision choice ${selected} input must contain only ${selectedChoice.input.name}`,
242
+ );
243
+ }
244
+ const text = rawInput[selectedChoice.input.name];
245
+ if (typeof text !== "string") {
246
+ throw new Error(`Human decision choice ${selected} input must be text`);
247
+ }
248
+ if (
249
+ text.length < selectedChoice.input.minLength ||
250
+ text.length > selectedChoice.input.maxLength
251
+ ) {
252
+ throw new Error(
253
+ `Human decision choice ${selected} input length must be ${selectedChoice.input.minLength} through ${selectedChoice.input.maxLength}`,
254
+ );
255
+ }
256
+ return { choice: selected, input: { [selectedChoice.input.name]: text } };
257
+ }
258
+
259
+ export function validateHumanDecisionSubmission(
260
+ request: HumanDecisionRequest,
261
+ value: HumanDecisionSubmission,
262
+ ): HumanDecisionSubmission {
263
+ if (value.decisionId !== request.decisionId || value.requestDigest !== request.requestDigest) {
264
+ throw new Error("Human decision answer is stale or belongs to another request");
265
+ }
266
+ if (request.expiresAt !== undefined && Date.parse(request.expiresAt) <= Date.now()) {
267
+ throw new Error("Human decision request has expired");
268
+ }
269
+ const response = validateHumanDecisionResponse(request, value);
270
+ const source = requireRecord(value.source, "Human decision source");
271
+ const channel = requireChannelId(source.channel, "Human decision channel");
272
+ const actorId = requireString(source.actorId, "Human decision actor");
273
+ const eventId = requireString(source.eventId, "Human decision event");
274
+ const idempotencyKey = requireString(value.idempotencyKey, "Human decision idempotency key");
275
+ return {
276
+ decisionId: request.decisionId,
277
+ requestDigest: request.requestDigest,
278
+ ...response,
279
+ source: { channel, actorId, eventId },
280
+ idempotencyKey,
281
+ };
282
+ }
283
+
284
+ export function humanDecisionStateRoot(runsRoot: string): string {
285
+ return path.basename(runsRoot) === "runs"
286
+ ? path.join(path.dirname(runsRoot), "decisions")
287
+ : `${runsRoot}.decisions`;
288
+ }
289
+
290
+ export type HumanDecisionAcceptance =
291
+ | { status: "accepted" | "adopted"; decision: AcceptedHumanDecision }
292
+ | { status: "conflict"; decision: AcceptedHumanDecision };
293
+
294
+ type HumanDecisionResolution =
295
+ | {
296
+ schema: "pi-workflows.human-decision-resolution.v1";
297
+ outcome: "accepted";
298
+ decision: Extract<
299
+ AcceptedHumanDecision,
300
+ { schema: "pi-workflows.human-decision-accepted.v1" }
301
+ >;
302
+ }
303
+ | {
304
+ schema: "pi-workflows.human-decision-resolution.v2";
305
+ outcome: "accepted";
306
+ decision: Extract<
307
+ AcceptedHumanDecision,
308
+ { schema: "pi-workflows.human-decision-accepted.v2" }
309
+ >;
310
+ }
311
+ | {
312
+ schema:
313
+ | "pi-workflows.human-decision-resolution.v1"
314
+ | "pi-workflows.human-decision-resolution.v2";
315
+ outcome: "cancelled";
316
+ cancellation: HumanDecisionCancellationRecord;
317
+ };
318
+
319
+ export class HumanDecisionStore {
320
+ readonly root: string;
321
+
322
+ constructor(runsRoot: string) {
323
+ this.root = humanDecisionStateRoot(runsRoot);
324
+ }
325
+
326
+ decisionDir(decisionId: string): string {
327
+ assertId(decisionId, "decision id");
328
+ return path.join(this.root, decisionId);
329
+ }
330
+
331
+ async createRequest(request: HumanDecisionRequest): Promise<"created" | "adopted"> {
332
+ validateHumanDecisionRequestIntegrity(request);
333
+ return await writeImmutableJson(
334
+ path.join(this.decisionDir(request.decisionId), "request.json"),
335
+ request,
336
+ );
337
+ }
338
+
339
+ async readRequest(decisionId: string): Promise<HumanDecisionRequest | null> {
340
+ const request = (await readJson(
341
+ path.join(this.decisionDir(decisionId), "request.json"),
342
+ )) as HumanDecisionRequest | null;
343
+ return request === null ? null : validateHumanDecisionRequestIntegrity(request);
344
+ }
345
+
346
+ async recordDelivery(
347
+ request: HumanDecisionRequest | HumanDecisionChannelRequest,
348
+ channel: string,
349
+ value: HumanDecisionDeliveryRecord,
350
+ ): Promise<"created" | "adopted"> {
351
+ const safeChannel = requireSimpleId(channel, "Human decision channel");
352
+ const attemptId = requireString(value.attemptId, "Human decision delivery attempt");
353
+ assertId(attemptId, "delivery attempt id");
354
+ return await writeImmutableJson(
355
+ path.join(
356
+ this.decisionDir(request.decisionId),
357
+ "deliveries",
358
+ safeChannel,
359
+ `${attemptId}.json`,
360
+ ),
361
+ value,
362
+ );
363
+ }
364
+
365
+ async accept(
366
+ request: HumanDecisionRequest,
367
+ submission: HumanDecisionSubmission,
368
+ ): Promise<HumanDecisionAcceptance> {
369
+ validateHumanDecisionRequestIntegrity(request);
370
+ const cancellation = await this.readCancellation(request.decisionId);
371
+ if (cancellation !== null) {
372
+ throw new Error(`Human decision request was ${cancellation.reason}`);
373
+ }
374
+ const normalized = validateHumanDecisionSubmission(request, submission);
375
+ let attemptedAt = new Date().toISOString();
376
+ const attemptId = digestHex({
377
+ decisionId: request.decisionId,
378
+ idempotencyKey: normalized.idempotencyKey,
379
+ }).slice(0, 40);
380
+ const attemptPath = path.join(
381
+ this.decisionDir(request.decisionId),
382
+ "answers",
383
+ `${attemptId}.json`,
384
+ );
385
+ const attempt = {
386
+ schema: "pi-workflows.human-decision-answer-attempt.v1",
387
+ attemptId,
388
+ attemptedAt,
389
+ ...normalized,
390
+ };
391
+ const attemptWrite = await writeImmutableJson(attemptPath, attempt, false);
392
+ if (attemptWrite === "adopted") {
393
+ const existingAttempt = requireRecord(
394
+ await readJson(attemptPath),
395
+ "Existing human decision answer attempt",
396
+ );
397
+ const existingSubmission = {
398
+ decisionId: existingAttempt.decisionId,
399
+ requestDigest: existingAttempt.requestDigest,
400
+ choice: existingAttempt.choice,
401
+ ...(existingAttempt.input !== undefined ? { input: existingAttempt.input } : {}),
402
+ source: existingAttempt.source,
403
+ idempotencyKey: existingAttempt.idempotencyKey,
404
+ };
405
+ if (canonicalJson(existingSubmission) !== canonicalJson(normalized)) {
406
+ throw new Error("Human decision idempotency key was reused for a different answer");
407
+ }
408
+ attemptedAt = requireString(existingAttempt.attemptedAt, "answer attempt time");
409
+ }
410
+ const response = validateHumanDecisionResponse(request, normalized);
411
+ const commonDecision = {
412
+ decisionId: request.decisionId,
413
+ requestDigest: request.requestDigest,
414
+ response,
415
+ source: normalized.source,
416
+ idempotencyKey: normalized.idempotencyKey,
417
+ acceptedAt: attemptedAt,
418
+ answerDigest: digest({ response, source: normalized.source }),
419
+ };
420
+ const decision: AcceptedHumanDecision =
421
+ request.schema === "pi-workflows.human-decision-request.v2"
422
+ ? {
423
+ schema: "pi-workflows.human-decision-accepted.v2",
424
+ ...commonDecision,
425
+ subjectDigest: request.subjectDigest,
426
+ presentationDigest: request.presentationDigest,
427
+ revision: request.revision,
428
+ }
429
+ : { schema: "pi-workflows.human-decision-accepted.v1", ...commonDecision };
430
+ const resolutionPath = path.join(this.decisionDir(request.decisionId), "resolution.json");
431
+ const resolution = {
432
+ schema:
433
+ decision.schema === "pi-workflows.human-decision-accepted.v2"
434
+ ? ("pi-workflows.human-decision-resolution.v2" as const)
435
+ : ("pi-workflows.human-decision-resolution.v1" as const),
436
+ outcome: "accepted" as const,
437
+ decision,
438
+ } as HumanDecisionResolution;
439
+ const result = await writeImmutableJson(resolutionPath, resolution, false);
440
+ const winner =
441
+ result === "created"
442
+ ? resolution
443
+ : ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
444
+ if (winner === null) throw new Error("Human decision resolution became unreadable");
445
+ if (winner.outcome === "cancelled") {
446
+ throw new Error(`Human decision request was ${winner.cancellation.reason}`);
447
+ }
448
+ const existing = winner.decision;
449
+ await writeImmutableJson(
450
+ path.join(this.decisionDir(request.decisionId), "accepted.json"),
451
+ existing,
452
+ );
453
+ if (result === "created") return { status: "accepted", decision: existing };
454
+ if (canonicalJson(existing) === canonicalJson(decision)) {
455
+ return { status: "adopted", decision: existing };
456
+ }
457
+ if (
458
+ existing.idempotencyKey === decision.idempotencyKey &&
459
+ canonicalJson(existing.response) === canonicalJson(decision.response) &&
460
+ canonicalJson(existing.source) === canonicalJson(decision.source)
461
+ ) {
462
+ return { status: "adopted", decision: existing };
463
+ }
464
+ return { status: "conflict", decision: existing };
465
+ }
466
+
467
+ async listDeliveries(
468
+ decisionId: string,
469
+ channel: string,
470
+ ): Promise<HumanDecisionDeliveryRecord[]> {
471
+ const safeChannel = requireSimpleId(channel, "Human decision channel");
472
+ const directory = path.join(this.decisionDir(decisionId), "deliveries", safeChannel);
473
+ return (await readJsonDirectory(directory)) as HumanDecisionDeliveryRecord[];
474
+ }
475
+
476
+ async recordSettlement(
477
+ decisionId: string,
478
+ channel: string,
479
+ value: HumanDecisionSettlementRecord,
480
+ ): Promise<"created" | "adopted"> {
481
+ const safeChannel = requireSimpleId(channel, "Human decision channel");
482
+ assertId(value.attemptId, "settlement attempt id");
483
+ return await writeImmutableJson(
484
+ path.join(
485
+ this.decisionDir(decisionId),
486
+ "settlements",
487
+ safeChannel,
488
+ `${value.attemptId}.json`,
489
+ ),
490
+ value,
491
+ );
492
+ }
493
+
494
+ async listSettlements(
495
+ decisionId: string,
496
+ channel: string,
497
+ ): Promise<HumanDecisionSettlementRecord[]> {
498
+ const safeChannel = requireSimpleId(channel, "Human decision channel");
499
+ const directory = path.join(this.decisionDir(decisionId), "settlements", safeChannel);
500
+ return (await readJsonDirectory(directory)) as HumanDecisionSettlementRecord[];
501
+ }
502
+
503
+ async cancel(
504
+ request: HumanDecisionRequest,
505
+ reason: HumanDecisionCancellationRecord["reason"],
506
+ ): Promise<"created" | "adopted"> {
507
+ validateHumanDecisionRequestIntegrity(request);
508
+ if ((await this.readAccepted(request.decisionId)) !== null) {
509
+ throw new Error("Accepted human decision cannot be cancelled");
510
+ }
511
+ const filePath = path.join(this.decisionDir(request.decisionId), "cancelled.json");
512
+ const record: HumanDecisionCancellationRecord = {
513
+ schema: "pi-workflows.human-decision-cancellation.v1",
514
+ decisionId: request.decisionId,
515
+ requestDigest: request.requestDigest,
516
+ cancelledAt: new Date().toISOString(),
517
+ reason,
518
+ };
519
+ const resolutionPath = path.join(this.decisionDir(request.decisionId), "resolution.json");
520
+ const resolution: HumanDecisionResolution = {
521
+ schema:
522
+ request.schema === "pi-workflows.human-decision-request.v2"
523
+ ? "pi-workflows.human-decision-resolution.v2"
524
+ : "pi-workflows.human-decision-resolution.v1",
525
+ outcome: "cancelled",
526
+ cancellation: record,
527
+ };
528
+ const result = await writeImmutableJson(resolutionPath, resolution, false);
529
+ const winner =
530
+ result === "created"
531
+ ? resolution
532
+ : ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
533
+ if (winner === null) throw new Error("Human decision resolution became unreadable");
534
+ if (winner.outcome === "accepted") {
535
+ throw new Error("Accepted human decision cannot be cancelled");
536
+ }
537
+ const existing = winner.cancellation;
538
+ if (
539
+ existing.decisionId !== request.decisionId ||
540
+ existing.requestDigest !== request.requestDigest ||
541
+ existing.reason !== reason
542
+ ) {
543
+ throw new Error("Immutable human decision cancellation conflicts");
544
+ }
545
+ await writeImmutableJson(filePath, existing);
546
+ return result;
547
+ }
548
+
549
+ async readCancellation(decisionId: string): Promise<HumanDecisionCancellationRecord | null> {
550
+ const stored = (await readJson(
551
+ path.join(this.decisionDir(decisionId), "cancelled.json"),
552
+ )) as HumanDecisionCancellationRecord | null;
553
+ if (stored !== null) return stored;
554
+ const resolution = (await readJson(
555
+ path.join(this.decisionDir(decisionId), "resolution.json"),
556
+ )) as HumanDecisionResolution | null;
557
+ if (resolution?.outcome !== "cancelled") return null;
558
+ await writeImmutableJson(
559
+ path.join(this.decisionDir(decisionId), "cancelled.json"),
560
+ resolution.cancellation,
561
+ );
562
+ return resolution.cancellation;
563
+ }
564
+
565
+ async readAccepted(decisionId: string): Promise<AcceptedHumanDecision | null> {
566
+ const stored = (await readJson(
567
+ path.join(this.decisionDir(decisionId), "accepted.json"),
568
+ )) as AcceptedHumanDecision | null;
569
+ if (stored !== null) return stored;
570
+ const resolution = (await readJson(
571
+ path.join(this.decisionDir(decisionId), "resolution.json"),
572
+ )) as HumanDecisionResolution | null;
573
+ if (resolution?.outcome !== "accepted") return null;
574
+ await writeImmutableJson(
575
+ path.join(this.decisionDir(decisionId), "accepted.json"),
576
+ resolution.decision,
577
+ );
578
+ return resolution.decision;
579
+ }
580
+
581
+ async recordContinuation(
582
+ decisionId: string,
583
+ value: HumanDecisionContinuationRecord,
584
+ ): Promise<"created" | "adopted"> {
585
+ return await writeImmutableJson(
586
+ path.join(this.decisionDir(decisionId), "continuation.json"),
587
+ value,
588
+ );
589
+ }
590
+
591
+ async readContinuation(decisionId: string): Promise<HumanDecisionContinuationRecord | null> {
592
+ return (await readJson(
593
+ path.join(this.decisionDir(decisionId), "continuation.json"),
594
+ )) as HumanDecisionContinuationRecord | null;
595
+ }
596
+
597
+ async listRequests(): Promise<HumanDecisionRequest[]> {
598
+ let entries: Dirent[];
599
+ try {
600
+ entries = await fs.readdir(this.root, { withFileTypes: true });
601
+ } catch (error) {
602
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
603
+ throw error;
604
+ }
605
+ const requests = await Promise.all(
606
+ entries
607
+ .filter((entry) => entry.isDirectory())
608
+ .map(async (entry) => await this.readRequest(entry.name)),
609
+ );
610
+ return requests.filter((request): request is HumanDecisionRequest => request !== null);
611
+ }
612
+ }
613
+
614
+ export function createHumanDecisionAttemptId(): string {
615
+ return randomUUID();
616
+ }
617
+
618
+ export function canonicalJson(value: unknown): string {
619
+ return JSON.stringify(sortJson(value));
620
+ }
621
+
622
+ export function digest(value: unknown): string {
623
+ return `sha256:${digestHex(value)}`;
624
+ }
625
+
626
+ function digestHex(value: unknown): string {
627
+ return createHash("sha256").update(canonicalJson(value)).digest("hex");
628
+ }
629
+
630
+ function sortJson(value: unknown): unknown {
631
+ if (Array.isArray(value)) return value.map(sortJson);
632
+ if (value !== null && typeof value === "object") {
633
+ return Object.fromEntries(
634
+ Object.entries(value as Record<string, unknown>)
635
+ .sort(([left], [right]) => left.localeCompare(right))
636
+ .map(([key, child]) => [key, sortJson(child)]),
637
+ );
638
+ }
639
+ return value;
640
+ }
641
+
642
+ function validatePresentedChoices(choices: HumanDecisionChoiceMap): void {
643
+ for (const [key, definition] of Object.entries(choices)) {
644
+ validatePresentedText(definition.label, `Human decision choice ${key} label`);
645
+ if (definition.input !== undefined) {
646
+ validatePresentedText(definition.input.prompt, `Human decision choice ${key} input prompt`);
647
+ }
648
+ }
649
+ }
650
+
651
+ function validatePresentedText(value: string, label: string): void {
652
+ if (
653
+ [...value].some((character) => {
654
+ const code = character.codePointAt(0) ?? 0;
655
+ return code <= 31 || code === 127;
656
+ })
657
+ ) {
658
+ throw new Error(`${label} contains a control character`);
659
+ }
660
+ }
661
+
662
+ function validateChoices(choices: HumanDecisionChoiceMap): void {
663
+ const entries = Object.entries(choices);
664
+ if (entries.length === 0) throw new Error("Human decision choices must not be empty");
665
+ const labels = new Set<string>();
666
+ for (const [key, definition] of entries) {
667
+ if (!CHOICE_PATTERN.test(key)) {
668
+ throw new Error(`Human decision choice ${JSON.stringify(key)} is invalid`);
669
+ }
670
+ validateChoice(definition, `Human decision choice ${key}`);
671
+ if (labels.has(definition.label)) {
672
+ throw new Error(
673
+ `Human decision choice label ${JSON.stringify(definition.label)} is duplicated`,
674
+ );
675
+ }
676
+ labels.add(definition.label);
677
+ }
678
+ }
679
+
680
+ function validateChoice(value: unknown, label: string): asserts value is HumanDecisionChoice {
681
+ const definition = requireRecord(value, label);
682
+ requireString(definition.label, `${label} label`);
683
+ if (definition.input !== undefined) {
684
+ const input = requireRecord(definition.input, `${label} input`);
685
+ if (input.kind !== "text") throw new Error(`${label} input kind must be text`);
686
+ requireSimpleId(input.name, `${label} input name`);
687
+ requireString(input.prompt, `${label} input prompt`);
688
+ if (!Number.isInteger(input.minLength) || (input.minLength as number) < 0) {
689
+ throw new Error(`${label} input minLength must be a non-negative integer`);
690
+ }
691
+ if (
692
+ !Number.isInteger(input.maxLength) ||
693
+ (input.maxLength as number) < 1 ||
694
+ (input.maxLength as number) < (input.minLength as number)
695
+ ) {
696
+ throw new Error(`${label} input maxLength is invalid`);
697
+ }
698
+ }
699
+ }
700
+
701
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
702
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
703
+ throw new Error(`${label} must be an object`);
704
+ }
705
+ return value as Record<string, unknown>;
706
+ }
707
+
708
+ function requireString(value: unknown, label: string): string {
709
+ if (typeof value !== "string" || value.trim().length === 0) {
710
+ throw new Error(`${label} must be a non-empty string`);
711
+ }
712
+ return value;
713
+ }
714
+
715
+ function requireChannelId(value: unknown, label: string): string {
716
+ const text = requireString(value, label);
717
+ if (!CHANNEL_PATTERN.test(text)) throw new Error(`${label} is invalid`);
718
+ return text;
719
+ }
720
+
721
+ function requireSimpleId(value: unknown, label: string): string {
722
+ const text = requireString(value, label);
723
+ if (!CHOICE_PATTERN.test(text)) throw new Error(`${label} is invalid`);
724
+ return text;
725
+ }
726
+
727
+ function assertId(value: string, label: string): void {
728
+ if (!ID_PATTERN.test(value)) throw new Error(`${label} is invalid`);
729
+ }
730
+
731
+ function assertJsonValue(value: unknown, label: string): void {
732
+ try {
733
+ const encoded = JSON.stringify(value);
734
+ if (encoded === undefined) throw new Error("undefined JSON value");
735
+ JSON.parse(encoded);
736
+ } catch {
737
+ throw new Error(`${label} must be JSON-serializable`);
738
+ }
739
+ }
740
+
741
+ async function writeImmutableJson(
742
+ filePath: string,
743
+ value: unknown,
744
+ requireIdentical = true,
745
+ ): Promise<"created" | "adopted"> {
746
+ const bytes = `${canonicalJson(value)}\n`;
747
+ const directory = path.dirname(filePath);
748
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
749
+ const temporary = path.join(
750
+ directory,
751
+ `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
752
+ );
753
+ const handle = await fs.open(temporary, "wx", 0o600);
754
+ try {
755
+ await handle.writeFile(bytes, "utf8");
756
+ await handle.sync();
757
+ } finally {
758
+ await handle.close();
759
+ }
760
+ try {
761
+ await fs.link(temporary, filePath);
762
+ const directoryHandle = await fs.open(directory, "r");
763
+ try {
764
+ await directoryHandle.sync();
765
+ } finally {
766
+ await directoryHandle.close();
767
+ }
768
+ return "created";
769
+ } catch (error) {
770
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
771
+ const existing = await fs.readFile(filePath, "utf8");
772
+ if (requireIdentical && existing !== bytes) {
773
+ throw new Error(`Immutable human decision record conflicts: ${filePath}`);
774
+ }
775
+ return "adopted";
776
+ } finally {
777
+ await fs.unlink(temporary).catch(() => undefined);
778
+ }
779
+ }
780
+
781
+ function validateExpiry(
782
+ value: string | undefined,
783
+ createdAt: string | undefined,
784
+ ): string | undefined {
785
+ if (value === undefined) return undefined;
786
+ const parsed = Date.parse(value);
787
+ if (!Number.isFinite(parsed)) throw new Error("Human decision expiry must be an ISO timestamp");
788
+ const created = createdAt === undefined ? Date.now() : Date.parse(createdAt);
789
+ if (!Number.isFinite(created) || parsed <= created) {
790
+ throw new Error("Human decision expiry must be after creation");
791
+ }
792
+ return new Date(parsed).toISOString();
793
+ }
794
+
795
+ async function readJsonDirectory(directory: string): Promise<unknown[]> {
796
+ let names: string[];
797
+ try {
798
+ names = await fs.readdir(directory);
799
+ } catch (error) {
800
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
801
+ throw error;
802
+ }
803
+ const values = await Promise.all(
804
+ names
805
+ .filter((name) => name.endsWith(".json"))
806
+ .sort()
807
+ .map(async (name) => await readJson(path.join(directory, name))),
808
+ );
809
+ return values.filter((value) => value !== null);
810
+ }
811
+
812
+ async function readJson(filePath: string): Promise<unknown | null> {
813
+ try {
814
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
815
+ } catch (error) {
816
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
817
+ throw error;
818
+ }
819
+ }