@osolmaz/pi-workflows 0.12.0 → 0.12.1

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 (119) hide show
  1. package/README.md +4 -3
  2. package/dist/builtins/autoimplement.workflow.d.ts +485 -126
  3. package/dist/builtins/autoimplement.workflow.js +17 -105
  4. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  5. package/dist/builtins/catalog.js +4 -4
  6. package/dist/builtins/index.d.ts +2 -1
  7. package/dist/builtins/index.js +2 -1
  8. package/dist/builtins/index.js.map +1 -1
  9. package/dist/builtins/monitor.workflow.d.ts +2 -4
  10. package/dist/builtins/monitor.workflow.js +26 -128
  11. package/dist/builtins/monitor.workflow.js.map +1 -1
  12. package/dist/builtins/pi-agent-group.d.ts +72 -0
  13. package/dist/builtins/pi-agent-group.js +1087 -0
  14. package/dist/builtins/pi-agent-group.js.map +1 -0
  15. package/dist/builtins/plan-approval.workflow.d.ts +39 -5
  16. package/dist/builtins/plan-approval.workflow.js +92 -14
  17. package/dist/builtins/plan-approval.workflow.js.map +1 -1
  18. package/dist/builtins/plan-change.workflow.d.ts +301 -0
  19. package/dist/builtins/plan-change.workflow.js +256 -0
  20. package/dist/builtins/plan-change.workflow.js.map +1 -0
  21. package/dist/builtins/plan-presentation.js +2 -2
  22. package/dist/builtins/plan-presentation.js.map +1 -1
  23. package/dist/builtins/sanity-check.workflow.d.ts +5 -3
  24. package/dist/builtins/sanity-check.workflow.js +105 -21
  25. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  26. package/dist/extension/decision-channels.d.ts +2 -2
  27. package/dist/extension/decision-channels.js +22 -28
  28. package/dist/extension/decision-channels.js.map +1 -1
  29. package/dist/extension/index.js +62 -33
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/session-events.d.ts +2 -2
  32. package/dist/extension/widget.js +23 -3
  33. package/dist/extension/widget.js.map +1 -1
  34. package/dist/render/graph-render.js +1 -2
  35. package/dist/render/graph-render.js.map +1 -1
  36. package/dist/viewer/render.js +7 -6
  37. package/dist/viewer/render.js.map +1 -1
  38. package/dist/workflows/catalog.js +7 -2
  39. package/dist/workflows/catalog.js.map +1 -1
  40. package/dist/workflows/composition.js +8 -0
  41. package/dist/workflows/composition.js.map +1 -1
  42. package/dist/workflows/decision-presentation.d.ts +1 -1
  43. package/dist/workflows/decision-presentation.js +51 -38
  44. package/dist/workflows/decision-presentation.js.map +1 -1
  45. package/dist/workflows/engine.d.ts +2 -2
  46. package/dist/workflows/engine.js +14 -13
  47. package/dist/workflows/engine.js.map +1 -1
  48. package/dist/workflows/errors.d.ts +13 -0
  49. package/dist/workflows/errors.js +15 -0
  50. package/dist/workflows/errors.js.map +1 -1
  51. package/dist/workflows/human-decision.d.ts +16 -4
  52. package/dist/workflows/human-decision.js +175 -72
  53. package/dist/workflows/human-decision.js.map +1 -1
  54. package/dist/workflows/index.d.ts +2 -2
  55. package/dist/workflows/index.js +1 -1
  56. package/dist/workflows/index.js.map +1 -1
  57. package/dist/workflows/progress.d.ts +1 -0
  58. package/dist/workflows/progress.js +15 -3
  59. package/dist/workflows/progress.js.map +1 -1
  60. package/dist/workflows/schema.js +10 -0
  61. package/dist/workflows/schema.js.map +1 -1
  62. package/dist/workflows/store.js +5 -0
  63. package/dist/workflows/store.js.map +1 -1
  64. package/dist/workflows/types.d.ts +33 -45
  65. package/docs/HUMAN_DECISIONS.md +25 -35
  66. package/docs/HUMAN_DECISION_PRESENTATIONS.md +14 -24
  67. package/docs/MONITOR.md +5 -11
  68. package/docs/WORKFLOW_COMPOSITION.md +8 -7
  69. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +322 -0
  70. package/docs/plans/2026-08-21-sanity-check-plan.md +202 -94
  71. package/docs/run-bundles.md +6 -6
  72. package/docs/workflows.md +26 -6
  73. package/examples/workflows/approved-plan.workflow.ts +19 -46
  74. package/herdr-plugin.toml +1 -1
  75. package/package.json +7 -7
  76. package/schemas/human-decision-accepted-v1.schema.json +15 -3
  77. package/schemas/human-decision-continuation-v1.schema.json +10 -1
  78. package/schemas/human-decision-delivery-v1.schema.json +8 -0
  79. package/schemas/human-decision-receipt-v1.schema.json +8 -0
  80. package/schemas/human-decision-request-v1.schema.json +24 -4
  81. package/skills/autoimplement/SKILL.md +27 -0
  82. package/skills/monitor/SKILL.md +31 -3
  83. package/skills/pi-workflows/SKILL.md +2 -1
  84. package/skills/sanity-check/SKILL.md +44 -0
  85. package/src/builtins/autoimplement.workflow.ts +19 -118
  86. package/src/builtins/catalog.ts +4 -4
  87. package/src/builtins/index.ts +11 -0
  88. package/src/builtins/monitor.workflow.ts +27 -150
  89. package/src/builtins/pi-agent-group.ts +1407 -0
  90. package/src/builtins/plan-approval.workflow.ts +157 -24
  91. package/src/builtins/plan-change.workflow.ts +321 -0
  92. package/src/builtins/plan-presentation.ts +2 -2
  93. package/src/builtins/sanity-check.workflow.ts +186 -41
  94. package/src/extension/decision-channels.ts +29 -59
  95. package/src/extension/index.ts +79 -41
  96. package/src/extension/session-events.ts +2 -2
  97. package/src/extension/widget.ts +24 -5
  98. package/src/render/graph-render.ts +1 -2
  99. package/src/viewer/render.ts +7 -6
  100. package/src/workflows/catalog.ts +7 -2
  101. package/src/workflows/composition.ts +9 -0
  102. package/src/workflows/decision-presentation.ts +56 -43
  103. package/src/workflows/engine.ts +17 -15
  104. package/src/workflows/errors.ts +24 -0
  105. package/src/workflows/human-decision.ts +218 -101
  106. package/src/workflows/index.ts +5 -11
  107. package/src/workflows/progress.ts +18 -3
  108. package/src/workflows/schema.ts +17 -0
  109. package/src/workflows/store.ts +5 -0
  110. package/src/workflows/types.ts +39 -56
  111. package/dist/builtins/sanity-check-session.d.ts +0 -17
  112. package/dist/builtins/sanity-check-session.js +0 -168
  113. package/dist/builtins/sanity-check-session.js.map +0 -1
  114. package/schemas/human-decision-accepted-v2.schema.json +0 -50
  115. package/schemas/human-decision-delivery-v2.schema.json +0 -36
  116. package/schemas/human-decision-receipt-v2.schema.json +0 -39
  117. package/schemas/human-decision-request-v2.schema.json +0 -69
  118. package/schemas/human-decision-resolution-v2.schema.json +0 -27
  119. package/src/builtins/sanity-check-session.ts +0 -205
@@ -32,7 +32,7 @@ import {
32
32
  readRunBundle,
33
33
  } from "./store.js";
34
34
  import type {
35
- AcceptedHumanDecision,
35
+ ResolvedHumanDecision,
36
36
  AgentNodeDefinition,
37
37
  AgentStepExecutor,
38
38
  ActionNodeDefinition,
@@ -377,7 +377,7 @@ export class WorkflowEngine {
377
377
  workflowSource?: WorkflowSource;
378
378
  runId?: string;
379
379
  force?: boolean;
380
- humanDecision?: AcceptedHumanDecision;
380
+ humanDecision?: ResolvedHumanDecision;
381
381
  } = {},
382
382
  ): Promise<WorkflowRunResult> {
383
383
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
@@ -412,14 +412,13 @@ export class WorkflowEngine {
412
412
  }
413
413
  const request = parent.state.finalOutput as HumanDecisionRequest;
414
414
  if (
415
- (request?.schema !== "pi-workflows.human-decision-request.v1" &&
416
- request?.schema !== "pi-workflows.human-decision-request.v2") ||
415
+ request?.schema !== "pi-workflows.human-decision-request.v1" ||
417
416
  request.decisionId !== options.humanDecision.decisionId ||
418
417
  request.requestDigest !== options.humanDecision.requestDigest
419
418
  ) {
420
419
  throw new Error("Accepted human decision does not match the waiting request");
421
420
  }
422
- const durableDecision = await new HumanDecisionStore(this.store.outputRoot).readAccepted(
421
+ const durableDecision = await new HumanDecisionStore(this.store.outputRoot).readResolved(
423
422
  request.decisionId,
424
423
  );
425
424
  if (durableDecision === null || !isDeepStrictEqual(durableDecision, options.humanDecision)) {
@@ -464,19 +463,17 @@ export class WorkflowEngine {
464
463
  requestDigest: options.humanDecision.requestDigest,
465
464
  nodeId: acceptedNodeId ?? waitingNodeId,
466
465
  response: options.humanDecision.response,
466
+ provenance: options.humanDecision.provenance,
467
467
  acceptedAt: options.humanDecision.acceptedAt,
468
468
  answerDigest: options.humanDecision.answerDigest,
469
469
  };
470
- state.humanDecision =
471
- options.humanDecision.schema === "pi-workflows.human-decision-accepted.v2"
472
- ? {
473
- schema: "pi-workflows.human-decision-receipt.v2",
474
- ...receipt,
475
- subjectDigest: options.humanDecision.subjectDigest,
476
- presentationDigest: options.humanDecision.presentationDigest,
477
- revision: options.humanDecision.revision,
478
- }
479
- : { schema: "pi-workflows.human-decision-receipt.v1", ...receipt };
470
+ state.humanDecision = {
471
+ schema: "pi-workflows.human-decision-receipt.v1",
472
+ ...receipt,
473
+ subjectDigest: options.humanDecision.subjectDigest,
474
+ presentationDigest: options.humanDecision.presentationDigest,
475
+ revision: options.humanDecision.revision,
476
+ };
480
477
  state.outputs[waitingNodeId] = acceptedResponse;
481
478
  const priorResult = state.results[waitingNodeId];
482
479
  if (priorResult === undefined) {
@@ -1317,6 +1314,10 @@ async function runCheckpointNode(
1317
1314
  typeof node.humanDecision.audience === "function"
1318
1315
  ? await node.humanDecision.audience(context)
1319
1316
  : node.humanDecision.audience;
1317
+ const timeout =
1318
+ typeof node.humanDecision.onTimeout === "function"
1319
+ ? await node.humanDecision.onTimeout(context)
1320
+ : node.humanDecision.onTimeout;
1320
1321
  const request = createHumanDecisionRequest({
1321
1322
  runId: context.state.runId,
1322
1323
  workflowName: execution.workflowName,
@@ -1324,6 +1325,7 @@ async function runCheckpointNode(
1324
1325
  attemptId: execution.attemptId,
1325
1326
  contract: { audience, choices: node.humanDecision.choices },
1326
1327
  prompt,
1328
+ ...(timeout !== undefined ? { timeout } : {}),
1327
1329
  });
1328
1330
  await new HumanDecisionStore(execution.store.outputRoot).createRequest(request);
1329
1331
  return { output: request, promptText: null };
@@ -56,6 +56,30 @@ export class WorkflowSourceChangedError extends Error {
56
56
  }
57
57
  }
58
58
 
59
+ /** A built-in revision is unavailable, so its incompatible run must restart. */
60
+ export class BuiltinWorkflowRevisionChangedError extends Error {
61
+ readonly runId: string;
62
+ readonly workflowId: string;
63
+ readonly previousRevision: string;
64
+ readonly currentRevision: string;
65
+
66
+ constructor(options: {
67
+ runId: string;
68
+ workflowId: string;
69
+ previousRevision: string;
70
+ currentRevision: string;
71
+ }) {
72
+ super(
73
+ `Built-in workflow ${options.workflowId} revision ${options.previousRevision} cannot resume run ${options.runId} with installed revision ${options.currentRevision}; cancel run ${options.runId}, then start ${options.workflowId} again`,
74
+ );
75
+ this.name = "BuiltinWorkflowRevisionChangedError";
76
+ this.runId = options.runId;
77
+ this.workflowId = options.workflowId;
78
+ this.previousRevision = options.previousRevision;
79
+ this.currentRevision = options.currentRevision;
80
+ }
81
+ }
82
+
59
83
  export function isClaimLostError(error: unknown): error is ClaimLostError {
60
84
  return error instanceof ClaimLostError;
61
85
  }
@@ -24,6 +24,8 @@ import type {
24
24
  HumanDecisionSettlementRecord,
25
25
  HumanDecisionSubmission,
26
26
  HumanDecisionTextInput,
27
+ HumanDecisionTimeout,
28
+ ResolvedHumanDecision,
27
29
  WorkflowNodeCommon,
28
30
  WorkflowNodeContext,
29
31
  } from "./types.js";
@@ -43,6 +45,14 @@ export type HumanDecisionDefinition<TChoices extends HumanDecisionChoiceMap> =
43
45
  audience: HumanDecisionAudience;
44
46
  choices: TChoices;
45
47
  request: (context: WorkflowNodeContext) => HumanDecisionPrompt | Promise<HumanDecisionPrompt>;
48
+ onTimeout?:
49
+ | { afterMs: number; response: HumanDecisionResponseFor<TChoices> }
50
+ | ((
51
+ context: WorkflowNodeContext,
52
+ ) =>
53
+ | { afterMs: number; response: HumanDecisionResponseFor<TChoices> }
54
+ | undefined
55
+ | Promise<{ afterMs: number; response: HumanDecisionResponseFor<TChoices> } | undefined>);
46
56
  };
47
57
 
48
58
  export type HumanDecisionResponseFor<TChoices extends HumanDecisionChoiceMap> = {
@@ -98,11 +108,25 @@ export function humanDecision<const TChoices extends HumanDecisionChoiceMap>(
98
108
  if (typeof definition.request !== "function") {
99
109
  throw new Error("Human decision request must be a function");
100
110
  }
101
- const { choices, request, ...common } = definition;
111
+ const { choices, request, onTimeout, ...common } = definition;
112
+ if (
113
+ onTimeout !== undefined &&
114
+ typeof onTimeout !== "function" &&
115
+ (typeof onTimeout.afterMs !== "number" ||
116
+ !Number.isFinite(onTimeout.afterMs) ||
117
+ onTimeout.afterMs <= 0)
118
+ ) {
119
+ throw new Error("Human decision onTimeout afterMs must be a finite positive number");
120
+ }
102
121
  return checkpoint({
103
122
  ...common,
104
123
  summary: typeof audience === "string" ? `human decision for ${audience}` : "human decision",
105
- humanDecision: { audience, choices, request },
124
+ humanDecision: {
125
+ audience,
126
+ choices,
127
+ request,
128
+ ...(onTimeout !== undefined ? { onTimeout } : {}),
129
+ },
106
130
  }) as CheckpointNodeDefinition & { readonly __humanChoices?: TChoices };
107
131
  }
108
132
 
@@ -136,11 +160,31 @@ export function createHumanDecisionRequest(input: {
136
160
  attemptId: string;
137
161
  contract: { audience: string; choices: HumanDecisionChoiceMap };
138
162
  prompt: HumanDecisionPrompt;
163
+ timeout?: HumanDecisionTimeout;
139
164
  createdAt?: string;
140
165
  }): HumanDecisionRequest {
141
166
  validateChoices(input.contract.choices);
167
+ const createdAt = input.createdAt ?? new Date().toISOString();
142
168
  const title = requireString(input.prompt.title, "Human decision title");
143
- const expiresAt = validateExpiry(input.prompt.expiresAt, input.createdAt);
169
+ if (input.timeout !== undefined && input.prompt.expiresAt !== undefined) {
170
+ throw new Error("Human decision request cannot combine expiresAt with onTimeout");
171
+ }
172
+ if (
173
+ input.timeout !== undefined &&
174
+ (typeof input.timeout.afterMs !== "number" ||
175
+ !Number.isFinite(input.timeout.afterMs) ||
176
+ input.timeout.afterMs <= 0)
177
+ ) {
178
+ throw new Error("Human decision onTimeout afterMs must be a finite positive number");
179
+ }
180
+ const defaultResponse =
181
+ input.timeout === undefined
182
+ ? undefined
183
+ : validateResponseForChoices(input.contract.choices, input.timeout.response);
184
+ const expiresAt =
185
+ input.timeout === undefined
186
+ ? validateExpiry(input.prompt.expiresAt, createdAt)
187
+ : new Date(Date.parse(createdAt) + input.timeout.afterMs).toISOString();
144
188
  const common = {
145
189
  runId: input.runId,
146
190
  workflowName: input.workflowName,
@@ -150,58 +194,35 @@ export function createHumanDecisionRequest(input: {
150
194
  title,
151
195
  choices: input.contract.choices,
152
196
  ...(expiresAt !== undefined ? { expiresAt } : {}),
197
+ ...(defaultResponse !== undefined ? { defaultResponse } : {}),
153
198
  } 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
- };
199
+ validatePresentedText(title, "Human decision title");
200
+ validatePresentedChoices(input.contract.choices);
201
+ const prompt = input.prompt;
202
+ assertJsonValue(prompt.subject, "Human decision subject");
203
+ const presentation = normalizeDecisionPresentation(prompt.presentation);
204
+ const revision = prompt.revision ?? 1;
205
+ if (!Number.isInteger(revision) || revision < 1) {
206
+ throw new Error("Human decision revision must be a positive integer");
191
207
  }
192
- const prompt = input.prompt as Extract<HumanDecisionPrompt, { body: unknown }>;
193
- assertJsonValue(prompt.body, "Human decision body");
194
208
  const basis = {
195
209
  schema: "pi-workflows.human-decision-request.v1" as const,
196
210
  ...common,
197
- body: prompt.body,
211
+ subject: prompt.subject,
212
+ presentation,
213
+ revision,
198
214
  };
199
- const requestDigest = digest(basis);
215
+ const subjectDigest = digestCanonical(prompt.subject);
216
+ const presentationDigest = digestCanonical(presentation);
217
+ const requestDigest = digestCanonical(basis);
218
+ const decisionId = decisionIdFor(input, requestDigest);
200
219
  return {
201
220
  ...basis,
202
- decisionId: decisionIdFor(input, requestDigest),
221
+ decisionId,
203
222
  requestDigest,
204
- createdAt: input.createdAt ?? new Date().toISOString(),
223
+ subjectDigest,
224
+ presentationDigest,
225
+ createdAt,
205
226
  };
206
227
  }
207
228
 
@@ -220,13 +241,20 @@ function decisionIdFor(
220
241
  export function validateHumanDecisionResponse(
221
242
  request: HumanDecisionRequest,
222
243
  value: unknown,
244
+ ): HumanDecisionResponse {
245
+ return validateResponseForChoices(request.choices, value);
246
+ }
247
+
248
+ function validateResponseForChoices(
249
+ choices: HumanDecisionChoiceMap,
250
+ value: unknown,
223
251
  ): HumanDecisionResponse {
224
252
  const response = requireRecord(value, "Human decision response");
225
253
  const selected = response.choice;
226
- if (typeof selected !== "string" || !Object.hasOwn(request.choices, selected)) {
254
+ if (typeof selected !== "string" || !Object.hasOwn(choices, selected)) {
227
255
  throw new Error(`Human decision choice ${JSON.stringify(selected)} is not available`);
228
256
  }
229
- const selectedChoice = request.choices[selected];
257
+ const selectedChoice = choices[selected];
230
258
  if (!selectedChoice) throw new Error("Human decision choice contract is missing");
231
259
  if (selectedChoice.input === undefined) {
232
260
  if (response.input !== undefined) {
@@ -288,34 +316,65 @@ export function humanDecisionStateRoot(runsRoot: string): string {
288
316
  }
289
317
 
290
318
  export type HumanDecisionAcceptance =
291
- | { status: "accepted" | "adopted"; decision: AcceptedHumanDecision }
292
- | { status: "conflict"; decision: AcceptedHumanDecision };
319
+ | { status: "accepted" | "adopted"; decision: ResolvedHumanDecision }
320
+ | { status: "conflict"; decision: ResolvedHumanDecision };
293
321
 
294
322
  type HumanDecisionResolution =
295
323
  | {
296
324
  schema: "pi-workflows.human-decision-resolution.v1";
297
325
  outcome: "accepted";
298
- decision: Extract<
299
- AcceptedHumanDecision,
300
- { schema: "pi-workflows.human-decision-accepted.v1" }
301
- >;
326
+ decision: ResolvedHumanDecision;
302
327
  }
303
328
  | {
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";
329
+ schema: "pi-workflows.human-decision-resolution.v1";
315
330
  outcome: "cancelled";
316
331
  cancellation: HumanDecisionCancellationRecord;
317
332
  };
318
333
 
334
+ const INCOMPATIBLE_HUMAN_DECISION_STATE =
335
+ "Human decision state uses an incompatible alpha contract; reset the affected workflow run and decision state.";
336
+
337
+ function requireCurrentDecision(value: unknown): ResolvedHumanDecision {
338
+ const decision = requireRecord(value, "Resolved human decision");
339
+ if (
340
+ decision.schema !== "pi-workflows.human-decision-accepted.v1" ||
341
+ !Object.hasOwn(decision, "subjectDigest") ||
342
+ !Object.hasOwn(decision, "presentationDigest") ||
343
+ !Object.hasOwn(decision, "revision")
344
+ ) {
345
+ throw new Error(INCOMPATIBLE_HUMAN_DECISION_STATE);
346
+ }
347
+ return value as ResolvedHumanDecision;
348
+ }
349
+
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
+
319
378
  export class HumanDecisionStore {
320
379
  readonly root: string;
321
380
 
@@ -412,36 +471,35 @@ export class HumanDecisionStore {
412
471
  decisionId: request.decisionId,
413
472
  requestDigest: request.requestDigest,
414
473
  response,
474
+ provenance: "human" as const,
415
475
  source: normalized.source,
416
476
  idempotencyKey: normalized.idempotencyKey,
417
477
  acceptedAt: attemptedAt,
418
478
  answerDigest: digest({ response, source: normalized.source }),
419
479
  };
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 };
480
+ const decision: AcceptedHumanDecision = {
481
+ schema: "pi-workflows.human-decision-accepted.v1",
482
+ ...commonDecision,
483
+ subjectDigest: request.subjectDigest,
484
+ presentationDigest: request.presentationDigest,
485
+ revision: request.revision,
486
+ };
430
487
  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,
488
+ const resolution: HumanDecisionResolution = {
489
+ schema: "pi-workflows.human-decision-resolution.v1",
490
+ outcome: "accepted",
437
491
  decision,
438
- } as HumanDecisionResolution;
492
+ };
439
493
  const result = await writeImmutableJson(resolutionPath, resolution, false);
440
494
  const winner =
441
495
  result === "created"
442
496
  ? resolution
443
497
  : ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
444
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
+ }
445
503
  if (winner.outcome === "cancelled") {
446
504
  throw new Error(`Human decision request was ${winner.cancellation.reason}`);
447
505
  }
@@ -455,6 +513,7 @@ export class HumanDecisionStore {
455
513
  return { status: "adopted", decision: existing };
456
514
  }
457
515
  if (
516
+ existing.provenance === "human" &&
458
517
  existing.idempotencyKey === decision.idempotencyKey &&
459
518
  canonicalJson(existing.response) === canonicalJson(decision.response) &&
460
519
  canonicalJson(existing.source) === canonicalJson(decision.source)
@@ -464,13 +523,74 @@ export class HumanDecisionStore {
464
523
  return { status: "conflict", decision: existing };
465
524
  }
466
525
 
526
+ async resolveTimeout(
527
+ request: HumanDecisionRequest,
528
+ now = new Date(),
529
+ ): Promise<HumanDecisionAcceptance> {
530
+ validateHumanDecisionRequestIntegrity(request);
531
+ if (request.expiresAt === undefined || request.defaultResponse === undefined) {
532
+ throw new Error("Human decision request has no timeout default");
533
+ }
534
+ if (Date.parse(request.expiresAt) > now.getTime()) {
535
+ throw new Error("Human decision timeout default is not eligible yet");
536
+ }
537
+ const response = validateHumanDecisionResponse(request, request.defaultResponse);
538
+ const commonDecision = {
539
+ decisionId: request.decisionId,
540
+ requestDigest: request.requestDigest,
541
+ response,
542
+ provenance: "timeout" as const,
543
+ acceptedAt: now.toISOString(),
544
+ answerDigest: digest({
545
+ provenance: "timeout",
546
+ requestDigest: request.requestDigest,
547
+ response,
548
+ }),
549
+ };
550
+ const decision: ResolvedHumanDecision = {
551
+ schema: "pi-workflows.human-decision-accepted.v1",
552
+ ...commonDecision,
553
+ subjectDigest: request.subjectDigest,
554
+ presentationDigest: request.presentationDigest,
555
+ revision: request.revision,
556
+ };
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 };
585
+ }
586
+
467
587
  async listDeliveries(
468
588
  decisionId: string,
469
589
  channel: string,
470
590
  ): Promise<HumanDecisionDeliveryRecord[]> {
471
591
  const safeChannel = requireSimpleId(channel, "Human decision channel");
472
592
  const directory = path.join(this.decisionDir(decisionId), "deliveries", safeChannel);
473
- return (await readJsonDirectory(directory)) as HumanDecisionDeliveryRecord[];
593
+ return (await readJsonDirectory(directory)).map(requireCurrentDelivery);
474
594
  }
475
595
 
476
596
  async recordSettlement(
@@ -505,8 +625,8 @@ export class HumanDecisionStore {
505
625
  reason: HumanDecisionCancellationRecord["reason"],
506
626
  ): Promise<"created" | "adopted"> {
507
627
  validateHumanDecisionRequestIntegrity(request);
508
- if ((await this.readAccepted(request.decisionId)) !== null) {
509
- throw new Error("Accepted human decision cannot be cancelled");
628
+ if ((await this.readResolved(request.decisionId)) !== null) {
629
+ throw new Error("Resolved human decision cannot be cancelled");
510
630
  }
511
631
  const filePath = path.join(this.decisionDir(request.decisionId), "cancelled.json");
512
632
  const record: HumanDecisionCancellationRecord = {
@@ -518,10 +638,7 @@ export class HumanDecisionStore {
518
638
  };
519
639
  const resolutionPath = path.join(this.decisionDir(request.decisionId), "resolution.json");
520
640
  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",
641
+ schema: "pi-workflows.human-decision-resolution.v1",
525
642
  outcome: "cancelled",
526
643
  cancellation: record,
527
644
  };
@@ -532,7 +649,7 @@ export class HumanDecisionStore {
532
649
  : ((await readJson(resolutionPath)) as HumanDecisionResolution | null);
533
650
  if (winner === null) throw new Error("Human decision resolution became unreadable");
534
651
  if (winner.outcome === "accepted") {
535
- throw new Error("Accepted human decision cannot be cancelled");
652
+ throw new Error("Resolved human decision cannot be cancelled");
536
653
  }
537
654
  const existing = winner.cancellation;
538
655
  if (
@@ -551,9 +668,9 @@ export class HumanDecisionStore {
551
668
  path.join(this.decisionDir(decisionId), "cancelled.json"),
552
669
  )) as HumanDecisionCancellationRecord | null;
553
670
  if (stored !== null) return stored;
554
- const resolution = (await readJson(
555
- path.join(this.decisionDir(decisionId), "resolution.json"),
556
- )) as HumanDecisionResolution | null;
671
+ const resolution = requireCurrentResolution(
672
+ await readJson(path.join(this.decisionDir(decisionId), "resolution.json")),
673
+ );
557
674
  if (resolution?.outcome !== "cancelled") return null;
558
675
  await writeImmutableJson(
559
676
  path.join(this.decisionDir(decisionId), "cancelled.json"),
@@ -562,14 +679,14 @@ export class HumanDecisionStore {
562
679
  return resolution.cancellation;
563
680
  }
564
681
 
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;
682
+ 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
+ );
573
690
  if (resolution?.outcome !== "accepted") return null;
574
691
  await writeImmutableJson(
575
692
  path.join(this.decisionDir(decisionId), "accepted.json"),
@@ -23,8 +23,8 @@ export {
23
23
  decisionPresentationFingerprint,
24
24
  digestCanonical,
25
25
  humanDecisionChannelRequest,
26
- legacyDecisionPresentation,
27
26
  normalizeDecisionPresentation,
27
+ valueDecisionPresentation,
28
28
  validateHumanDecisionRequestIntegrity,
29
29
  type DecisionDocumentSegment,
30
30
  } from "./decision-presentation.js";
@@ -137,8 +137,8 @@ export {
137
137
  } from "./store.js";
138
138
  export type {
139
139
  AcceptedHumanDecision,
140
- AcceptedHumanDecisionV1,
141
- AcceptedHumanDecisionV2,
140
+ DefaultedHumanDecision,
141
+ ResolvedHumanDecision,
142
142
  AgentNodeDefinition,
143
143
  AgentStepContract,
144
144
  AgentStepExecutor,
@@ -167,24 +167,18 @@ export type {
167
167
  HumanDecisionChoiceMap,
168
168
  HumanDecisionContinuationRecord,
169
169
  HumanDecisionDeliveryRecord,
170
- HumanDecisionDeliveryRecordV1,
171
- HumanDecisionDeliveryRecordV2,
172
170
  HumanDecisionNodeContract,
173
171
  HumanDecisionPrompt,
174
172
  HumanDecisionReceipt,
175
- HumanDecisionReceiptV1,
176
- HumanDecisionReceiptV2,
177
173
  HumanDecisionRequest,
178
- HumanDecisionRequestV1,
179
- HumanDecisionRequestV2,
180
174
  HumanDecisionResponse,
181
175
  HumanDecisionSettlementRecord,
182
176
  HumanDecisionSubmission,
183
177
  HumanDecisionTextInput,
184
- LegacyHumanDecisionPrompt,
178
+ HumanDecisionTimeout,
179
+ HumanDecisionTimeoutPolicy,
185
180
  MaybePromise,
186
181
  NotifyNodeDefinition,
187
- PresentedHumanDecisionPrompt,
188
182
  ShellActionExecution,
189
183
  ShellActionNodeDefinition,
190
184
  ShellActionResult,
@@ -18,6 +18,7 @@ export type ProgressEstimate = {
18
18
  confidence?: ProgressConfidence;
19
19
  sourceEstimatedFinishAt?: string;
20
20
  unavailableReason?: string;
21
+ elapsedMs?: number;
21
22
  };
22
23
 
23
24
  export type ProgressSample = {
@@ -42,7 +43,16 @@ export function estimateProgress(
42
43
  for (const sample of samples) validateProgressData(sample.data as Record<string, unknown>);
43
44
  const latest = samples.at(-1) as ProgressSample;
44
45
  const data = latest.data;
45
- const base: ProgressEstimate = { key, data, sampleCount: 1 };
46
+ const firstAt = Date.parse(samples[0]!.at);
47
+ const endAt = TERMINAL.has(data.status) ? Date.parse(latest.at) : now.getTime();
48
+ const elapsedMs =
49
+ Number.isFinite(firstAt) && Number.isFinite(endAt) ? Math.max(0, endAt - firstAt) : undefined;
50
+ const base: ProgressEstimate = {
51
+ key,
52
+ data,
53
+ sampleCount: 1,
54
+ ...(elapsedMs !== undefined ? { elapsedMs } : {}),
55
+ };
46
56
  if (TERMINAL.has(data.status)) return base;
47
57
  const sourceFinish = validSourceFinish(latest, now);
48
58
  if (sourceFinish !== undefined) base.sourceEstimatedFinishAt = sourceFinish;
@@ -212,10 +222,15 @@ export function formatProgressLine(estimate: ProgressEstimate, now = new Date())
212
222
  ? `${formatRemaining(estimate.remainingLowMs)}–${formatRemaining(estimate.remainingHighMs)}`
213
223
  : formatRemaining(estimate.remainingMedianMs);
214
224
  eta = `ETA ${range}`;
215
- } else if (!TERMINAL.has(data.status)) {
225
+ } else if (!TERMINAL.has(data.status) && data.phase === undefined) {
216
226
  eta = `ETA unavailable${estimate.unavailableReason ? ` (${estimate.unavailableReason})` : ""}`;
217
227
  }
218
- return [label, count, eta].filter(Boolean).join(" ");
228
+ const phase = data.phase === undefined ? "" : sanitizeText(data.phase);
229
+ const elapsed =
230
+ data.phase === undefined || estimate.elapsedMs === undefined
231
+ ? ""
232
+ : `elapsed ${formatRemaining(estimate.elapsedMs)}`;
233
+ return [label, count, phase, elapsed, eta].filter(Boolean).join(" ");
219
234
  }
220
235
 
221
236
  export function formatProgressReport(