@osolmaz/pi-workflows 0.11.2 → 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 (177) hide show
  1. package/README.md +15 -10
  2. package/dist/builtins/autoimplement-command-batches.d.ts +52 -0
  3. package/dist/builtins/autoimplement-command-batches.js +258 -0
  4. package/dist/builtins/autoimplement-command-batches.js.map +1 -0
  5. package/dist/builtins/autoimplement.workflow.d.ts +509 -124
  6. package/dist/builtins/autoimplement.workflow.js +884 -327
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  8. package/dist/builtins/catalog.js +5 -3
  9. package/dist/builtins/catalog.js.map +1 -1
  10. package/dist/builtins/index.d.ts +4 -1
  11. package/dist/builtins/index.js +3 -1
  12. package/dist/builtins/index.js.map +1 -1
  13. package/dist/builtins/monitor.workflow.d.ts +2 -4
  14. package/dist/builtins/monitor.workflow.js +26 -128
  15. package/dist/builtins/monitor.workflow.js.map +1 -1
  16. package/dist/builtins/pi-agent-group.d.ts +72 -0
  17. package/dist/builtins/pi-agent-group.js +1087 -0
  18. package/dist/builtins/pi-agent-group.js.map +1 -0
  19. package/dist/builtins/plan-approval.workflow.d.ts +39 -5
  20. package/dist/builtins/plan-approval.workflow.js +92 -14
  21. package/dist/builtins/plan-approval.workflow.js.map +1 -1
  22. package/dist/builtins/plan-change.workflow.d.ts +301 -0
  23. package/dist/builtins/plan-change.workflow.js +256 -0
  24. package/dist/builtins/plan-change.workflow.js.map +1 -0
  25. package/dist/builtins/plan-presentation.js +2 -2
  26. package/dist/builtins/plan-presentation.js.map +1 -1
  27. package/dist/builtins/sanity-check.workflow.d.ts +85 -0
  28. package/dist/builtins/sanity-check.workflow.js +482 -0
  29. package/dist/builtins/sanity-check.workflow.js.map +1 -0
  30. package/dist/controllers/sqlite.d.ts +90 -2
  31. package/dist/controllers/sqlite.js +380 -5
  32. package/dist/controllers/sqlite.js.map +1 -1
  33. package/dist/extension/decision-channels.d.ts +2 -2
  34. package/dist/extension/decision-channels.js +29 -35
  35. package/dist/extension/decision-channels.js.map +1 -1
  36. package/dist/extension/deferred-turn-coordinator.d.ts +32 -0
  37. package/dist/extension/deferred-turn-coordinator.js +143 -0
  38. package/dist/extension/deferred-turn-coordinator.js.map +1 -0
  39. package/dist/extension/deferred-turn.d.ts +44 -0
  40. package/dist/extension/deferred-turn.js +110 -0
  41. package/dist/extension/deferred-turn.js.map +1 -0
  42. package/dist/extension/index.js +348 -80
  43. package/dist/extension/index.js.map +1 -1
  44. package/dist/extension/session-events.d.ts +2 -2
  45. package/dist/extension/step-message.d.ts +1 -0
  46. package/dist/extension/step-message.js.map +1 -1
  47. package/dist/extension/widget.js +23 -3
  48. package/dist/extension/widget.js.map +1 -1
  49. package/dist/herdr/setup.js +4 -4
  50. package/dist/render/graph-render.js +1 -2
  51. package/dist/render/graph-render.js.map +1 -1
  52. package/dist/viewer/render.js +7 -6
  53. package/dist/viewer/render.js.map +1 -1
  54. package/dist/workflows/catalog.js +7 -2
  55. package/dist/workflows/catalog.js.map +1 -1
  56. package/dist/workflows/command-batch.d.ts +38 -0
  57. package/dist/workflows/command-batch.js +176 -0
  58. package/dist/workflows/command-batch.js.map +1 -0
  59. package/dist/workflows/composition.js +8 -0
  60. package/dist/workflows/composition.js.map +1 -1
  61. package/dist/workflows/decision-presentation.d.ts +1 -1
  62. package/dist/workflows/decision-presentation.js +51 -38
  63. package/dist/workflows/decision-presentation.js.map +1 -1
  64. package/dist/workflows/engine.d.ts +2 -2
  65. package/dist/workflows/engine.js +22 -17
  66. package/dist/workflows/engine.js.map +1 -1
  67. package/dist/workflows/errors.d.ts +13 -0
  68. package/dist/workflows/errors.js +15 -0
  69. package/dist/workflows/errors.js.map +1 -1
  70. package/dist/workflows/human-decision.d.ts +16 -4
  71. package/dist/workflows/human-decision.js +175 -72
  72. package/dist/workflows/human-decision.js.map +1 -1
  73. package/dist/workflows/index.d.ts +3 -2
  74. package/dist/workflows/index.js +2 -1
  75. package/dist/workflows/index.js.map +1 -1
  76. package/dist/workflows/progress.d.ts +1 -0
  77. package/dist/workflows/progress.js +15 -3
  78. package/dist/workflows/progress.js.map +1 -1
  79. package/dist/workflows/schema.js +12 -1
  80. package/dist/workflows/schema.js.map +1 -1
  81. package/dist/workflows/shell.d.ts +4 -0
  82. package/dist/workflows/shell.js +6 -0
  83. package/dist/workflows/shell.js.map +1 -1
  84. package/dist/workflows/store.js +8 -1
  85. package/dist/workflows/store.js.map +1 -1
  86. package/dist/workflows/types.d.ts +37 -48
  87. package/docs/2026-08-18-herdr-piw-plan.md +1 -1
  88. package/docs/2026-08-20-durable-workflow-launch-plan.md +19 -23
  89. package/docs/CONTROLLERS.md +2 -2
  90. package/docs/DEFERRED_TURNS.md +298 -0
  91. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  92. package/docs/HUMAN_DECISIONS.md +30 -40
  93. package/docs/HUMAN_DECISION_PRESENTATIONS.md +17 -27
  94. package/docs/MONITOR.md +7 -13
  95. package/docs/WORKFLOW_COMPOSITION.md +10 -9
  96. package/docs/WORKFLOW_STEP_MESSAGES.md +5 -5
  97. package/docs/WORKFLOW_UPDATES.md +28 -5
  98. package/docs/plans/2026-08-04-controller-runtime-plan.md +1 -1
  99. package/docs/plans/2026-08-05-always-on-workflows-plan.md +1 -1
  100. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +9 -9
  101. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +1 -1
  102. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +1 -1
  103. package/docs/plans/2026-08-16-workflow-updates-plan.md +3 -3
  104. package/docs/plans/2026-08-17-bundled-skills-plan.md +6 -6
  105. package/docs/plans/2026-08-19-human-decision-gates-plan.md +5 -5
  106. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +1 -1
  107. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +1 -1
  108. package/docs/plans/2026-08-19-workflow-composition-plan.md +6 -6
  109. package/docs/plans/2026-08-20-autoimplement-blocker-challenge-plan.md +3 -3
  110. package/docs/plans/2026-08-20-bounded-command-batches-plan.md +206 -0
  111. package/docs/plans/2026-08-20-herdr-plugin-sync-plan.md +9 -9
  112. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +242 -0
  113. package/docs/plans/2026-08-21-deferred-turn-intents-plan.md +324 -0
  114. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +322 -0
  115. package/docs/plans/2026-08-21-sanity-check-plan.md +283 -0
  116. package/docs/run-bundles.md +8 -6
  117. package/docs/workflows.md +85 -22
  118. package/examples/workflows/approved-plan.workflow.ts +19 -46
  119. package/examples/workflows/command-batch.workflow.ts +50 -0
  120. package/examples/workflows/sanity-check.workflow.ts +1 -0
  121. package/herdr-plugin.toml +3 -3
  122. package/package.json +7 -7
  123. package/schemas/decision-presentation-v1.schema.json +1 -1
  124. package/schemas/human-decision-accepted-v1.schema.json +16 -4
  125. package/schemas/human-decision-answer-attempt-v1.schema.json +1 -1
  126. package/schemas/human-decision-cancellation-v1.schema.json +1 -1
  127. package/schemas/human-decision-continuation-v1.schema.json +11 -2
  128. package/schemas/human-decision-delivery-v1.schema.json +9 -1
  129. package/schemas/human-decision-receipt-v1.schema.json +9 -1
  130. package/schemas/human-decision-request-v1.schema.json +25 -5
  131. package/schemas/human-decision-resolution-v1.schema.json +1 -1
  132. package/schemas/human-decision-settlement-v1.schema.json +1 -1
  133. package/skills/autodoc/SKILL.md +33 -2
  134. package/skills/autoimplement/SKILL.md +93 -12
  135. package/skills/autoplan/SKILL.md +26 -2
  136. package/skills/monitor/SKILL.md +66 -24
  137. package/skills/pi-workflows/SKILL.md +21 -6
  138. package/skills/sanity-check/SKILL.md +44 -0
  139. package/src/builtins/autoimplement-command-batches.ts +364 -0
  140. package/src/builtins/autoimplement.workflow.ts +1075 -370
  141. package/src/builtins/catalog.ts +5 -3
  142. package/src/builtins/index.ts +24 -0
  143. package/src/builtins/monitor.workflow.ts +27 -150
  144. package/src/builtins/pi-agent-group.ts +1407 -0
  145. package/src/builtins/plan-approval.workflow.ts +157 -24
  146. package/src/builtins/plan-change.workflow.ts +321 -0
  147. package/src/builtins/plan-presentation.ts +2 -2
  148. package/src/builtins/sanity-check.workflow.ts +769 -0
  149. package/src/controllers/sqlite.ts +580 -7
  150. package/src/extension/decision-channels.ts +36 -66
  151. package/src/extension/deferred-turn-coordinator.ts +171 -0
  152. package/src/extension/deferred-turn.ts +166 -0
  153. package/src/extension/index.ts +438 -90
  154. package/src/extension/session-events.ts +2 -2
  155. package/src/extension/step-message.ts +1 -0
  156. package/src/extension/widget.ts +24 -5
  157. package/src/herdr/setup.ts +4 -4
  158. package/src/render/graph-render.ts +1 -2
  159. package/src/viewer/render.ts +7 -6
  160. package/src/workflows/catalog.ts +7 -2
  161. package/src/workflows/command-batch.ts +254 -0
  162. package/src/workflows/composition.ts +9 -0
  163. package/src/workflows/decision-presentation.ts +56 -43
  164. package/src/workflows/engine.ts +25 -20
  165. package/src/workflows/errors.ts +24 -0
  166. package/src/workflows/human-decision.ts +218 -101
  167. package/src/workflows/index.ts +20 -11
  168. package/src/workflows/progress.ts +18 -3
  169. package/src/workflows/schema.ts +19 -1
  170. package/src/workflows/shell.ts +10 -0
  171. package/src/workflows/store.ts +8 -1
  172. package/src/workflows/types.ts +43 -59
  173. package/schemas/human-decision-accepted-v2.schema.json +0 -50
  174. package/schemas/human-decision-delivery-v2.schema.json +0 -36
  175. package/schemas/human-decision-receipt-v2.schema.json +0 -39
  176. package/schemas/human-decision-request-v2.schema.json +0 -69
  177. package/schemas/human-decision-resolution-v2.schema.json +0 -27
@@ -14,7 +14,7 @@ import {
14
14
  } from "../workflows/decision-presentation.js";
15
15
  import { HumanDecisionStore, createHumanDecisionAttemptId } from "../workflows/human-decision.js";
16
16
  import type {
17
- AcceptedHumanDecision,
17
+ ResolvedHumanDecision,
18
18
  HumanDecisionAnswerSource,
19
19
  HumanDecisionCancellationRecord,
20
20
  HumanDecisionChannelRequest,
@@ -47,7 +47,7 @@ export type HumanDecisionDeliveryResult = {
47
47
  errorCode?: string;
48
48
  };
49
49
 
50
- export type SettledHumanDecision = AcceptedHumanDecision | HumanDecisionCancellationRecord;
50
+ export type SettledHumanDecision = ResolvedHumanDecision | HumanDecisionCancellationRecord;
51
51
 
52
52
  export interface HumanDecisionChannel {
53
53
  readonly id: string;
@@ -105,13 +105,13 @@ export async function loadDecisionChannelConfig(
105
105
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
106
106
  throw error;
107
107
  }
108
- await requirePrivateFile(channelPath, "Pi Workflows channel profile");
108
+ await requirePrivateFile(channelPath, "pi-workflows channel profile");
109
109
  const channels = parseChannelConfig(channelsRaw);
110
110
  const profileNames = Object.keys(channels.telegramProfiles ?? {});
111
111
  if (profileNames.length === 0) return { channels, credentials: {}, configDir };
112
112
 
113
113
  const credentialPath = path.join(configDir, "credentials.json");
114
- await requirePrivateFile(credentialPath, "Pi Workflows credential profile");
114
+ await requirePrivateFile(credentialPath, "pi-workflows credential profile");
115
115
  const credentialConfig = parseCredentialConfig(
116
116
  JSON.parse(await fsp.readFile(credentialPath, "utf8")),
117
117
  );
@@ -162,7 +162,7 @@ export class PiDecisionChannel implements HumanDecisionChannel {
162
162
  await this.options.store.recordDelivery(
163
163
  request,
164
164
  this.id,
165
- v2DeliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt),
165
+ deliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt),
166
166
  );
167
167
  const entries = Object.entries(request.choices);
168
168
  const selectedChoice = await this.options.ui.custom<string | undefined>(
@@ -241,7 +241,7 @@ export class PiDecisionChannel implements HumanDecisionChannel {
241
241
  await this.options.store.recordDelivery(
242
242
  request,
243
243
  this.id,
244
- v2DeliveryRecord(
244
+ deliveryRecord(
245
245
  request,
246
246
  this.id,
247
247
  `${attemptId}-cancelled`,
@@ -267,7 +267,7 @@ export class PiDecisionChannel implements HumanDecisionChannel {
267
267
  await this.options.store.recordDelivery(
268
268
  request,
269
269
  this.id,
270
- v2DeliveryRecord(
270
+ deliveryRecord(
271
271
  request,
272
272
  this.id,
273
273
  `${attemptId}-cancelled`,
@@ -291,7 +291,7 @@ export class PiDecisionChannel implements HumanDecisionChannel {
291
291
  await this.options.store.recordDelivery(
292
292
  request,
293
293
  this.id,
294
- v2DeliveryRecord(
294
+ deliveryRecord(
295
295
  request,
296
296
  this.id,
297
297
  `${attemptId}-confirmed`,
@@ -684,7 +684,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
684
684
  await this.store.recordDelivery(
685
685
  request,
686
686
  channelPath,
687
- v2DeliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt, {
687
+ deliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt, {
688
688
  partCount: parts.length,
689
689
  }),
690
690
  );
@@ -704,7 +704,6 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
704
704
  );
705
705
  const confirmed = prior.some(
706
706
  (record) =>
707
- record.schema === "pi-workflows.human-decision-delivery.v2" &&
708
707
  record.phase === "part" &&
709
708
  record.state === "confirmed" &&
710
709
  record.recipientIndex === recipientIndex &&
@@ -724,7 +723,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
724
723
  await this.store.recordDelivery(
725
724
  request,
726
725
  channelPath,
727
- v2DeliveryRecord(
726
+ deliveryRecord(
728
727
  request,
729
728
  this.id,
730
729
  `${attemptId}-r${recipientIndex}-p${partIndex}-unknown`,
@@ -740,15 +739,12 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
740
739
  await this.store.recordDelivery(
741
740
  request,
742
741
  channelPath,
743
- v2DeliveryRecord(
744
- request,
745
- this.id,
746
- `${partAttemptId}-intent`,
747
- "part",
748
- "intent",
749
- createdAt,
750
- { recipientIndex, partIndex, partCount: parts.length, contentDigest },
751
- ),
742
+ deliveryRecord(request, this.id, `${partAttemptId}-intent`, "part", "intent", createdAt, {
743
+ recipientIndex,
744
+ partIndex,
745
+ partCount: parts.length,
746
+ contentDigest,
747
+ }),
752
748
  );
753
749
  try {
754
750
  const result = await this.call("sendMessage", {
@@ -769,7 +765,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
769
765
  await this.store.recordDelivery(
770
766
  request,
771
767
  channelPath,
772
- v2DeliveryRecord(
768
+ deliveryRecord(
773
769
  request,
774
770
  this.id,
775
771
  `${partAttemptId}-confirmed`,
@@ -785,7 +781,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
785
781
  await this.store.recordDelivery(
786
782
  request,
787
783
  channelPath,
788
- v2DeliveryRecord(
784
+ deliveryRecord(
789
785
  request,
790
786
  this.id,
791
787
  `${partAttemptId}-${state}`,
@@ -804,7 +800,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
804
800
  await this.store.recordDelivery(
805
801
  request,
806
802
  channelPath,
807
- v2DeliveryRecord(
803
+ deliveryRecord(
808
804
  request,
809
805
  this.id,
810
806
  `${attemptId}-${state}`,
@@ -821,7 +817,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
821
817
  await this.store.recordDelivery(
822
818
  request,
823
819
  channelPath,
824
- v2DeliveryRecord(
820
+ deliveryRecord(
825
821
  request,
826
822
  this.id,
827
823
  `${attemptId}-confirmed`,
@@ -1163,9 +1159,9 @@ export async function writeDecisionChannelProfile(options: {
1163
1159
  }
1164
1160
 
1165
1161
  function parseChannelConfig(value: unknown): DecisionChannelConfig {
1166
- const config = record(value, "Pi Workflows channel config");
1162
+ const config = record(value, "pi-workflows channel config");
1167
1163
  if (config.schema !== "pi-workflows.channels.v1") {
1168
- throw new Error("Pi Workflows channel config schema is invalid");
1164
+ throw new Error("pi-workflows channel config schema is invalid");
1169
1165
  }
1170
1166
  const telegramProfiles: NonNullable<DecisionChannelConfig["telegramProfiles"]> = {};
1171
1167
  if (config.telegramProfiles !== undefined) {
@@ -1191,7 +1187,7 @@ function parseChannelConfig(value: unknown): DecisionChannelConfig {
1191
1187
  };
1192
1188
  }
1193
1189
  }
1194
- const audiencesRaw = record(config.audiences, "Pi Workflows audiences");
1190
+ const audiencesRaw = record(config.audiences, "pi-workflows audiences");
1195
1191
  const audiences: DecisionChannelConfig["audiences"] = {};
1196
1192
  for (const [name, raw] of Object.entries(audiencesRaw)) {
1197
1193
  requireSimpleId(name, "Decision audience");
@@ -1219,9 +1215,9 @@ function parseChannelConfig(value: unknown): DecisionChannelConfig {
1219
1215
  }
1220
1216
 
1221
1217
  function parseCredentialConfig(value: unknown): DecisionCredentialConfig {
1222
- const config = record(value, "Pi Workflows credential config");
1218
+ const config = record(value, "pi-workflows credential config");
1223
1219
  if (config.schema !== "pi-workflows.credentials.v1") {
1224
- throw new Error("Pi Workflows credential config schema is invalid");
1220
+ throw new Error("pi-workflows credential config schema is invalid");
1225
1221
  }
1226
1222
  const telegram: DecisionCredentialConfig["telegram"] = {};
1227
1223
  for (const [name, raw] of Object.entries(record(config.telegram, "Telegram credentials"))) {
@@ -1234,29 +1230,26 @@ function parseCredentialConfig(value: unknown): DecisionCredentialConfig {
1234
1230
  return { schema: "pi-workflows.credentials.v1", telegram };
1235
1231
  }
1236
1232
 
1237
- type DeliveryV2 = Extract<
1238
- HumanDecisionDeliveryRecord,
1239
- { schema: "pi-workflows.human-decision-delivery.v2" }
1240
- >;
1233
+ type DeliveryRecord = HumanDecisionDeliveryRecord;
1241
1234
 
1242
- type DeliveryV2Extra = Partial<
1235
+ type DeliveryRecordExtra = Partial<
1243
1236
  Pick<
1244
- DeliveryV2,
1237
+ DeliveryRecord,
1245
1238
  "recipientIndex" | "partIndex" | "partCount" | "contentDigest" | "messageCount" | "errorCode"
1246
1239
  >
1247
1240
  >;
1248
1241
 
1249
- function v2DeliveryRecord(
1242
+ function deliveryRecord(
1250
1243
  request: HumanDecisionChannelRequest,
1251
1244
  channel: string,
1252
1245
  attemptId: string,
1253
- phase: DeliveryV2["phase"],
1254
- state: DeliveryV2["state"],
1246
+ phase: DeliveryRecord["phase"],
1247
+ state: DeliveryRecord["state"],
1255
1248
  createdAt: string,
1256
- extra: DeliveryV2Extra = {},
1257
- ): DeliveryV2 {
1249
+ extra: DeliveryRecordExtra = {},
1250
+ ): DeliveryRecord {
1258
1251
  return {
1259
- schema: "pi-workflows.human-decision-delivery.v2",
1252
+ schema: "pi-workflows.human-decision-delivery.v1",
1260
1253
  attemptId,
1261
1254
  decisionId: request.decisionId,
1262
1255
  requestDigest: request.requestDigest,
@@ -1271,37 +1264,14 @@ function v2DeliveryRecord(
1271
1264
  }
1272
1265
 
1273
1266
  function isCompleteDelivery(record: HumanDecisionDeliveryRecord): boolean {
1274
- return (
1275
- record.state === "confirmed" &&
1276
- (record.schema === "pi-workflows.human-decision-delivery.v1" || record.phase === "complete")
1277
- );
1267
+ return record.state === "confirmed" && record.phase === "complete";
1278
1268
  }
1279
1269
 
1280
1270
  function hasUnsettledDeliveryIntent(records: HumanDecisionDeliveryRecord[]): boolean {
1281
- const hasLegacyIntent = records.some(
1282
- (record) =>
1283
- record.schema === "pi-workflows.human-decision-delivery.v1" && record.state === "intent",
1284
- );
1285
- if (
1286
- hasLegacyIntent &&
1287
- !records.some(
1288
- (record) =>
1289
- record.schema === "pi-workflows.human-decision-delivery.v1" && record.state !== "intent",
1290
- )
1291
- ) {
1292
- return true;
1293
- }
1294
1271
  return records.some((record) => {
1295
- if (
1296
- record.schema !== "pi-workflows.human-decision-delivery.v2" ||
1297
- record.phase !== "part" ||
1298
- record.state !== "intent"
1299
- ) {
1300
- return false;
1301
- }
1272
+ if (record.phase !== "part" || record.state !== "intent") return false;
1302
1273
  return !records.some(
1303
1274
  (other) =>
1304
- other.schema === "pi-workflows.human-decision-delivery.v2" &&
1305
1275
  other.phase === "part" &&
1306
1276
  other.state !== "intent" &&
1307
1277
  other.recipientIndex === record.recipientIndex &&
@@ -0,0 +1,171 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ SqliteControllerStore,
4
+ WorkflowTurnIntentRecord,
5
+ WorkflowTurnIntentResolution,
6
+ } from "../controllers/sqlite.js";
7
+ import { deferredTurnMessageId } from "./deferred-turn.js";
8
+
9
+ export type BranchIntentResolution = {
10
+ resolution: WorkflowTurnIntentResolution;
11
+ messageId: string;
12
+ };
13
+
14
+ type NaturalDelivery = {
15
+ runId: string;
16
+ targetSessionId: string;
17
+ resolution: Exclude<WorkflowTurnIntentResolution, "fallback">;
18
+ send: (turnIntentId?: string) => void;
19
+ };
20
+
21
+ type FallbackDelivery = {
22
+ targetSessionId: string;
23
+ send: (intent: WorkflowTurnIntentRecord) => void;
24
+ };
25
+
26
+ export class DeferredTurnCoordinator {
27
+ private readonly store: () => SqliteControllerStore | null;
28
+ private readonly branchResolution: (intentId: string) => BranchIntentResolution | null;
29
+ private readonly leaseMs: number;
30
+ private readonly deferredNatural = new Map<string, NaturalDelivery>();
31
+
32
+ constructor(options: {
33
+ store: () => SqliteControllerStore | null;
34
+ branchResolution: (intentId: string) => BranchIntentResolution | null;
35
+ leaseMs: number;
36
+ }) {
37
+ this.store = options.store;
38
+ this.branchResolution = options.branchResolution;
39
+ this.leaseMs = options.leaseMs;
40
+ }
41
+
42
+ sendNatural(delivery: NaturalDelivery, idle: boolean): "sent" | "deferred" | "suppressed" {
43
+ const store = this.store();
44
+ const intent = store?.findPendingWorkflowTurnIntent({
45
+ runId: delivery.runId,
46
+ targetSessionId: delivery.targetSessionId,
47
+ });
48
+ if (store === null || store === undefined || intent === undefined) {
49
+ delivery.send();
50
+ return "sent";
51
+ }
52
+ if (!idle) {
53
+ this.deferredNatural.set(intent.intentId, delivery);
54
+ return "deferred";
55
+ }
56
+ const result = this.deliverNatural(intent.intentId, delivery);
57
+ if (result === "deferred") this.deferredNatural.set(intent.intentId, delivery);
58
+ return result;
59
+ }
60
+
61
+ flushNatural(idle: boolean): number {
62
+ if (!idle) return 0;
63
+ let sent = 0;
64
+ for (const [intentId, delivery] of this.deferredNatural) {
65
+ const result = this.deliverNatural(intentId, delivery);
66
+ if (result !== "deferred") {
67
+ this.deferredNatural.delete(intentId);
68
+ }
69
+ if (result === "sent") sent += 1;
70
+ }
71
+ return sent;
72
+ }
73
+
74
+ deliverFallbacks(delivery: FallbackDelivery, idle: boolean): number {
75
+ const store = this.store();
76
+ if (!idle || store === null) return 0;
77
+ const claimToken = randomUUID();
78
+ const intents = store.claimEligibleWorkflowTurnIntents({
79
+ targetSessionId: delivery.targetSessionId,
80
+ claimToken,
81
+ leaseMs: this.leaseMs,
82
+ });
83
+ let sent = 0;
84
+ for (const [index, intent] of intents.entries()) {
85
+ const branch = this.branchResolution(intent.intentId);
86
+ if (branch !== null) {
87
+ store.resolveWorkflowTurnIntent({
88
+ intentId: intent.intentId,
89
+ targetSessionId: delivery.targetSessionId,
90
+ claimToken,
91
+ resolution: branch.resolution,
92
+ messageId: branch.messageId,
93
+ });
94
+ continue;
95
+ }
96
+ try {
97
+ delivery.send(intent);
98
+ store.resolveWorkflowTurnIntent({
99
+ intentId: intent.intentId,
100
+ targetSessionId: delivery.targetSessionId,
101
+ claimToken,
102
+ resolution: "fallback",
103
+ messageId: deferredTurnMessageId(intent.intentId, "fallback"),
104
+ });
105
+ sent += 1;
106
+ } catch (error) {
107
+ for (const pending of intents.slice(index)) {
108
+ store.releaseWorkflowTurnIntentClaim({
109
+ intentId: pending.intentId,
110
+ targetSessionId: delivery.targetSessionId,
111
+ claimToken,
112
+ });
113
+ }
114
+ throw error;
115
+ }
116
+ }
117
+ return sent;
118
+ }
119
+
120
+ clearDeferred(): void {
121
+ this.deferredNatural.clear();
122
+ }
123
+
124
+ private deliverNatural(
125
+ intentId: string,
126
+ delivery: NaturalDelivery,
127
+ ): "sent" | "deferred" | "suppressed" {
128
+ const store = this.store();
129
+ if (store === null) return "deferred";
130
+ const claimToken = randomUUID();
131
+ const intent = store.claimWorkflowTurnIntent({
132
+ intentId,
133
+ targetSessionId: delivery.targetSessionId,
134
+ claimToken,
135
+ leaseMs: this.leaseMs,
136
+ });
137
+ if (intent === undefined) {
138
+ const current = store.getWorkflowTurnIntent(intentId);
139
+ return current?.resolvedAt === null ? "deferred" : "suppressed";
140
+ }
141
+ const branch = this.branchResolution(intent.intentId);
142
+ if (branch !== null) {
143
+ store.resolveWorkflowTurnIntent({
144
+ intentId: intent.intentId,
145
+ targetSessionId: delivery.targetSessionId,
146
+ claimToken,
147
+ resolution: branch.resolution,
148
+ messageId: branch.messageId,
149
+ });
150
+ return "suppressed";
151
+ }
152
+ try {
153
+ delivery.send(intent.intentId);
154
+ store.resolveWorkflowTurnIntent({
155
+ intentId: intent.intentId,
156
+ targetSessionId: delivery.targetSessionId,
157
+ claimToken,
158
+ resolution: delivery.resolution,
159
+ messageId: deferredTurnMessageId(intent.intentId, delivery.resolution),
160
+ });
161
+ return "sent";
162
+ } catch (error) {
163
+ store.releaseWorkflowTurnIntentClaim({
164
+ intentId: intent.intentId,
165
+ targetSessionId: delivery.targetSessionId,
166
+ claimToken,
167
+ });
168
+ throw error;
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,166 @@
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ WorkflowTurnIntentCause,
4
+ WorkflowTurnIntentFacts,
5
+ WorkflowTurnIntentRecord,
6
+ WorkflowTurnIntentResolution,
7
+ } from "../controllers/sqlite.js";
8
+
9
+ export const DEFERRED_TURN_MESSAGE_TYPE = "pi-workflows-deferred-turn";
10
+ export const DEFERRED_TURN_MESSAGE_SCHEMA = "pi-workflows.deferred-turn-message.v1";
11
+ const FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
12
+ const MAX_REASON_CHARS = 8_192;
13
+
14
+ export type DeferredTurnSource = {
15
+ runId: string;
16
+ workflowName: string;
17
+ targetSessionId: string;
18
+ cause: WorkflowTurnIntentCause;
19
+ sourceEventId: string;
20
+ observedState: string;
21
+ nodeId?: string | null;
22
+ attemptId?: string | null;
23
+ reason?: string | null;
24
+ handoff?: boolean;
25
+ };
26
+
27
+ export type DeferredTurnDescriptor = {
28
+ intentId: string;
29
+ sourceEventId: string;
30
+ runId: string;
31
+ workflowRef: string;
32
+ targetSessionId: string;
33
+ cause: WorkflowTurnIntentCause;
34
+ nodeId: string | null;
35
+ attemptId: string | null;
36
+ fallbackFacts: WorkflowTurnIntentFacts;
37
+ };
38
+
39
+ export type DeferredTurnMessageDetails = {
40
+ schema: typeof DEFERRED_TURN_MESSAGE_SCHEMA;
41
+ turnIntentId: string;
42
+ runId: string;
43
+ cause: WorkflowTurnIntentCause;
44
+ };
45
+
46
+ export function deferredTurnSourceEventId(options: {
47
+ runId: string;
48
+ cause: WorkflowTurnIntentCause;
49
+ nodeId?: string | null;
50
+ attemptId?: string | null;
51
+ source?: string;
52
+ }): string {
53
+ const parts = [
54
+ options.runId,
55
+ options.source ?? "run",
56
+ options.nodeId ?? "$run",
57
+ options.attemptId ?? "$none",
58
+ options.cause,
59
+ ];
60
+ return `turn-source:${digest(parts)}`;
61
+ }
62
+
63
+ export function createDeferredTurnDescriptor(source: DeferredTurnSource): DeferredTurnDescriptor {
64
+ const nodeId = source.nodeId ?? null;
65
+ const attemptId = source.attemptId ?? null;
66
+ const fallbackFacts = createDeferredTurnFacts(source);
67
+ return {
68
+ intentId: `deferred-turn:${digest([
69
+ source.targetSessionId,
70
+ source.runId,
71
+ source.sourceEventId,
72
+ nodeId ?? "$run",
73
+ attemptId ?? "$none",
74
+ source.cause,
75
+ ])}`,
76
+ sourceEventId: source.sourceEventId,
77
+ runId: source.runId,
78
+ workflowRef: source.workflowName,
79
+ targetSessionId: source.targetSessionId,
80
+ cause: source.cause,
81
+ nodeId,
82
+ attemptId,
83
+ fallbackFacts,
84
+ };
85
+ }
86
+
87
+ export function createDeferredTurnFacts(source: DeferredTurnSource): WorkflowTurnIntentFacts {
88
+ return {
89
+ schema: FACTS_SCHEMA,
90
+ workflowName: source.workflowName,
91
+ runId: source.runId,
92
+ observedState: source.observedState,
93
+ cause: source.cause,
94
+ nodeId: source.nodeId ?? null,
95
+ attemptId: source.attemptId ?? null,
96
+ reason: boundedReason(source.reason),
97
+ handoff: source.handoff ?? false,
98
+ };
99
+ }
100
+
101
+ export function deferredTurnMessageId(
102
+ intentId: string,
103
+ resolution: WorkflowTurnIntentResolution,
104
+ ): string {
105
+ return `turn-message:${digest([intentId, resolution])}`;
106
+ }
107
+
108
+ export function deferredTurnMessageDetails(
109
+ intent: WorkflowTurnIntentRecord,
110
+ ): DeferredTurnMessageDetails {
111
+ return {
112
+ schema: DEFERRED_TURN_MESSAGE_SCHEMA,
113
+ turnIntentId: intent.intentId,
114
+ runId: intent.runId,
115
+ cause: intent.cause,
116
+ };
117
+ }
118
+
119
+ export function buildDeferredTurnContent(intent: WorkflowTurnIntentRecord): string {
120
+ const facts = intent.fallbackFacts;
121
+ const source = [
122
+ facts.nodeId === null ? null : `node ${facts.nodeId}`,
123
+ facts.attemptId === null ? null : `attempt ${facts.attemptId}`,
124
+ ]
125
+ .filter((part): part is string => part !== null)
126
+ .join(", ");
127
+ const reason =
128
+ facts.reason === null
129
+ ? ""
130
+ : ` Reason: ${facts.reason}${/[.!?]$/.test(facts.reason) ? "" : "."}`;
131
+ const sourceText = source.length === 0 ? "" : ` Source: ${source}.`;
132
+ if (facts.cause === "launchFailed") {
133
+ return [
134
+ `Workflow ${facts.workflowName} failed to start (run ${facts.runId}).`,
135
+ `${sourceText}${reason}`.trim(),
136
+ "Inspect the durable workflow state before you explain the outcome or start a corrected workflow.",
137
+ ]
138
+ .filter((part) => part.length > 0)
139
+ .join(" ");
140
+ }
141
+ if (facts.handoff) {
142
+ return [
143
+ `Workflow ${facts.workflowName} was handed to another runner (run ${facts.runId}).`,
144
+ `Observed state: ${facts.observedState}.${sourceText}${reason}`,
145
+ "Inspect the durable workflow state before you explain the outcome or take any authorized corrective action.",
146
+ ].join(" ");
147
+ }
148
+ return [
149
+ `Workflow ${facts.workflowName} ended with state ${facts.observedState} (run ${facts.runId}).`,
150
+ `${sourceText}${reason}`.trim(),
151
+ "Inspect the durable workflow state before you explain the outcome or take any authorized corrective action.",
152
+ ]
153
+ .filter((part) => part.length > 0)
154
+ .join(" ");
155
+ }
156
+
157
+ function boundedReason(value: string | null | undefined): string | null {
158
+ if (value === null || value === undefined) return null;
159
+ const trimmed = value.trim();
160
+ if (trimmed.length === 0) return null;
161
+ return trimmed.length <= MAX_REASON_CHARS ? trimmed : trimmed.slice(0, MAX_REASON_CHARS);
162
+ }
163
+
164
+ function digest(parts: readonly string[]): string {
165
+ return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
166
+ }