@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
@@ -34,6 +34,7 @@ const MAX_KEY_LENGTH = 512;
34
34
  const MAX_ERROR_LENGTH = 8_192;
35
35
  const MAX_EVENT_BYTES = 64 * 1024;
36
36
  const MAX_RESOURCE_VALUE_BYTES = 1024 * 1024;
37
+ const TURN_INTENT_FACTS_SCHEMA = "pi-workflows.deferred-turn-facts.v1";
37
38
 
38
39
  type ResourceRow = {
39
40
  controller: string;
@@ -149,7 +150,7 @@ type WorkflowNotificationRow = {
149
150
  attempt_id: string;
150
151
  notification_index: number;
151
152
  target_session_id: string;
152
- kind: "progress" | "final" | "launch_failure";
153
+ kind: string;
153
154
  content: string;
154
155
  created_at: string;
155
156
  delivery_claim_token: string | null;
@@ -164,13 +165,72 @@ export type WorkflowNotificationRecord = {
164
165
  attemptId: string;
165
166
  notificationIndex: number;
166
167
  targetSessionId: string;
167
- kind: "progress" | "final" | "launch_failure";
168
+ kind: "progress" | "final";
168
169
  content: string;
169
170
  createdAt: string;
170
171
  deliveryClaimExpiresAt: string | null;
171
172
  deliveredAt: string | null;
172
173
  };
173
174
 
175
+ export type WorkflowTurnIntentCause =
176
+ | "agentCancelled"
177
+ | "timedOut"
178
+ | "failed"
179
+ | "launchFailed"
180
+ | "controllerInterrupted"
181
+ | "claimLost";
182
+
183
+ export type WorkflowTurnIntentResolution = "workflowPrompt" | "presentation" | "fallback";
184
+
185
+ export type WorkflowTurnIntentFacts = JsonObject & {
186
+ schema: typeof TURN_INTENT_FACTS_SCHEMA;
187
+ workflowName: string;
188
+ runId: string;
189
+ observedState: string;
190
+ cause: WorkflowTurnIntentCause;
191
+ nodeId: string | null;
192
+ attemptId: string | null;
193
+ reason: string | null;
194
+ handoff: boolean;
195
+ };
196
+
197
+ type WorkflowTurnIntentRow = {
198
+ intent_id: string;
199
+ source_event_id: string;
200
+ run_id: string;
201
+ workflow_ref: string;
202
+ target_session_id: string;
203
+ cause: string;
204
+ node_id: string | null;
205
+ attempt_id: string | null;
206
+ fallback_facts_json: string;
207
+ requested_at: string;
208
+ eligible_at: string | null;
209
+ resolved_at: string | null;
210
+ resolution: string | null;
211
+ resolution_message_id: string | null;
212
+ delivery_claim_token: string | null;
213
+ delivery_claim_expires_at: number | null;
214
+ };
215
+
216
+ export type WorkflowTurnIntentRecord = {
217
+ intentId: string;
218
+ sourceEventId: string;
219
+ runId: string;
220
+ workflowRef: string;
221
+ targetSessionId: string;
222
+ cause: WorkflowTurnIntentCause;
223
+ nodeId: string | null;
224
+ attemptId: string | null;
225
+ fallbackFacts: WorkflowTurnIntentFacts;
226
+ requestedAt: string;
227
+ eligibleAt: string | null;
228
+ resolvedAt: string | null;
229
+ resolution: WorkflowTurnIntentResolution | null;
230
+ resolutionMessageId: string | null;
231
+ deliveryClaimExpiresAt: string | null;
232
+ };
233
+
174
234
  type RunEventRow = {
175
235
  seq: number;
176
236
  recorded_at: string;
@@ -826,7 +886,10 @@ export class SqliteControllerStore implements ControllerStore {
826
886
  const existingQueue = this.database
827
887
  .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_run_queue'")
828
888
  .get();
829
- if (existingQueue !== undefined) this.assertAlphaSchemaLayout();
889
+ if (existingQueue !== undefined) {
890
+ this.assertAlphaSchemaLayout(false);
891
+ this.assertNoLegacyPendingLaunchNotifications();
892
+ }
830
893
  this.transaction(() => {
831
894
  this.database
832
895
  .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
@@ -834,10 +897,11 @@ export class SqliteControllerStore implements ControllerStore {
834
897
  this.database.exec(SCHEMA_SQL);
835
898
  });
836
899
  }
837
- this.assertAlphaSchemaLayout();
900
+ this.assertAlphaSchemaLayout(true);
901
+ this.assertNoLegacyPendingLaunchNotifications();
838
902
  }
839
903
 
840
- private assertAlphaSchemaLayout(): void {
904
+ private assertAlphaSchemaLayout(requireTurnIntents: boolean): void {
841
905
  const requiredQueueColumns = new Set([
842
906
  "run_id",
843
907
  "workflow_ref",
@@ -873,6 +937,64 @@ export class SqliteControllerStore implements ControllerStore {
873
937
  "Controller store uses an incompatible alpha layout. Preserve needed run bundles, then reset the project controller store.",
874
938
  );
875
939
  }
940
+ if (!requireTurnIntents) return;
941
+ const requiredIntentColumns = new Set([
942
+ "intent_id",
943
+ "source_event_id",
944
+ "run_id",
945
+ "workflow_ref",
946
+ "target_session_id",
947
+ "cause",
948
+ "node_id",
949
+ "attempt_id",
950
+ "fallback_facts_json",
951
+ "requested_at",
952
+ "eligible_at",
953
+ "resolved_at",
954
+ "resolution",
955
+ "resolution_message_id",
956
+ "delivery_claim_token",
957
+ "delivery_claim_expires_at",
958
+ ]);
959
+ const intentColumns = new Set(
960
+ (
961
+ this.database.pragma("table_info(workflow_turn_intents)") as {
962
+ name: string;
963
+ }[]
964
+ ).map((column) => column.name),
965
+ );
966
+ const missingIntentColumns = [...requiredIntentColumns].filter(
967
+ (column) => !intentColumns.has(column),
968
+ );
969
+ if (missingIntentColumns.length > 0) {
970
+ throw new Error(
971
+ "Controller store uses an incompatible alpha turn-intent layout. Preserve needed run bundles, then reset the project controller store.",
972
+ );
973
+ }
974
+ }
975
+
976
+ private assertNoLegacyPendingLaunchNotifications(): void {
977
+ const notificationTable = this.database
978
+ .prepare(
979
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_notifications'",
980
+ )
981
+ .get();
982
+ if (notificationTable === undefined) {
983
+ throw new Error(
984
+ "Controller store uses an incompatible alpha notification layout. Preserve needed run bundles, then reset the project controller store.",
985
+ );
986
+ }
987
+ const legacy = this.database
988
+ .prepare(
989
+ `SELECT 1 FROM workflow_notifications
990
+ WHERE kind = 'launch_failure' AND delivered_at IS NULL LIMIT 1`,
991
+ )
992
+ .get();
993
+ if (legacy !== undefined) {
994
+ throw new Error(
995
+ "Controller store contains pending alpha launch-failure notifications. Preserve needed run bundles, then reset the project controller store.",
996
+ );
997
+ }
876
998
  }
877
999
 
878
1000
  private transaction<T>(task: () => T): T {
@@ -1526,13 +1648,294 @@ export class SqliteControllerStore implements ControllerStore {
1526
1648
  }));
1527
1649
  }
1528
1650
 
1651
+ ensureWorkflowTurnIntent(options: {
1652
+ intentId: string;
1653
+ sourceEventId: string;
1654
+ runId: string;
1655
+ workflowRef: string;
1656
+ targetSessionId: string;
1657
+ cause: WorkflowTurnIntentCause;
1658
+ nodeId?: string | null;
1659
+ attemptId?: string | null;
1660
+ fallbackFacts: WorkflowTurnIntentFacts;
1661
+ eligible?: boolean;
1662
+ now?: string;
1663
+ }): WorkflowTurnIntentRecord {
1664
+ validateKey(options.intentId, "workflow turn intent id");
1665
+ validateKey(options.sourceEventId, "workflow turn source event id");
1666
+ validateRunId(options.runId);
1667
+ validateKey(options.workflowRef, "workflow turn workflow ref");
1668
+ validateKey(options.targetSessionId, "workflow turn target session id");
1669
+ validateWorkflowTurnIntentCause(options.cause);
1670
+ const nodeId = options.nodeId ?? null;
1671
+ const attemptId = options.attemptId ?? null;
1672
+ if (nodeId !== null) validateKey(nodeId, "workflow turn node id");
1673
+ if (attemptId !== null) validateKey(attemptId, "workflow turn attempt id");
1674
+ const factsJson = workflowTurnIntentFactsJson(options.fallbackFacts);
1675
+ const now = validTimestamp(options.now);
1676
+ const eligibleAt = options.eligible === true ? now : null;
1677
+ this.database
1678
+ .prepare(
1679
+ `INSERT INTO workflow_turn_intents (
1680
+ intent_id, source_event_id, run_id, workflow_ref, target_session_id,
1681
+ cause, node_id, attempt_id, fallback_facts_json, requested_at, eligible_at,
1682
+ resolved_at, resolution, resolution_message_id,
1683
+ delivery_claim_token, delivery_claim_expires_at
1684
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL)
1685
+ ON CONFLICT(intent_id) DO NOTHING`,
1686
+ )
1687
+ .run(
1688
+ options.intentId,
1689
+ options.sourceEventId,
1690
+ options.runId,
1691
+ options.workflowRef,
1692
+ options.targetSessionId,
1693
+ options.cause,
1694
+ nodeId,
1695
+ attemptId,
1696
+ factsJson,
1697
+ now,
1698
+ eligibleAt,
1699
+ );
1700
+ const existing = this.getWorkflowTurnIntent(options.intentId);
1701
+ if (existing === undefined) {
1702
+ throw new Error(`Workflow turn intent was not stored: ${options.intentId}`);
1703
+ }
1704
+ if (
1705
+ existing.sourceEventId !== options.sourceEventId ||
1706
+ existing.runId !== options.runId ||
1707
+ existing.workflowRef !== options.workflowRef ||
1708
+ existing.targetSessionId !== options.targetSessionId ||
1709
+ existing.cause !== options.cause ||
1710
+ existing.nodeId !== nodeId ||
1711
+ existing.attemptId !== attemptId
1712
+ ) {
1713
+ throw new Error(`Workflow turn intent identity conflict: ${options.intentId}`);
1714
+ }
1715
+ return existing;
1716
+ }
1717
+
1718
+ getWorkflowTurnIntent(intentId: string): WorkflowTurnIntentRecord | undefined {
1719
+ validateKey(intentId, "workflow turn intent id");
1720
+ const row = this.database
1721
+ .prepare("SELECT * FROM workflow_turn_intents WHERE intent_id = ?")
1722
+ .get(intentId) as WorkflowTurnIntentRow | undefined;
1723
+ return row === undefined ? undefined : workflowTurnIntentFromRow(row);
1724
+ }
1725
+
1726
+ findPendingWorkflowTurnIntent(options: {
1727
+ runId: string;
1728
+ targetSessionId: string;
1729
+ }): WorkflowTurnIntentRecord | undefined {
1730
+ validateRunId(options.runId);
1731
+ validateKey(options.targetSessionId, "workflow turn target session id");
1732
+ const row = this.database
1733
+ .prepare(
1734
+ `SELECT * FROM workflow_turn_intents
1735
+ WHERE run_id = ? AND target_session_id = ? AND resolved_at IS NULL
1736
+ ORDER BY requested_at DESC, intent_id DESC LIMIT 1`,
1737
+ )
1738
+ .get(options.runId, options.targetSessionId) as WorkflowTurnIntentRow | undefined;
1739
+ return row === undefined ? undefined : workflowTurnIntentFromRow(row);
1740
+ }
1741
+
1742
+ claimWorkflowTurnIntent(options: {
1743
+ intentId: string;
1744
+ targetSessionId: string;
1745
+ claimToken: string;
1746
+ leaseMs: number;
1747
+ now?: string;
1748
+ }): WorkflowTurnIntentRecord | undefined {
1749
+ validateKey(options.intentId, "workflow turn intent id");
1750
+ validateKey(options.targetSessionId, "workflow turn target session id");
1751
+ validateKey(options.claimToken, "workflow turn claim token");
1752
+ validateDuration(options.leaseMs, "leaseMs");
1753
+ const nowMs = epoch(validTimestamp(options.now));
1754
+ const result = this.database
1755
+ .prepare(
1756
+ `UPDATE workflow_turn_intents
1757
+ SET delivery_claim_token = ?, delivery_claim_expires_at = ?
1758
+ WHERE intent_id = ? AND target_session_id = ? AND resolved_at IS NULL
1759
+ AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`,
1760
+ )
1761
+ .run(
1762
+ options.claimToken,
1763
+ nowMs + options.leaseMs,
1764
+ options.intentId,
1765
+ options.targetSessionId,
1766
+ nowMs,
1767
+ );
1768
+ if (result.changes !== 1) return undefined;
1769
+ const row = this.database
1770
+ .prepare("SELECT * FROM workflow_turn_intents WHERE intent_id = ?")
1771
+ .get(options.intentId) as WorkflowTurnIntentRow;
1772
+ return workflowTurnIntentFromRow(row);
1773
+ }
1774
+
1775
+ claimEligibleWorkflowTurnIntents(options: {
1776
+ targetSessionId: string;
1777
+ claimToken: string;
1778
+ leaseMs: number;
1779
+ limit?: number;
1780
+ now?: string;
1781
+ }): WorkflowTurnIntentRecord[] {
1782
+ validateKey(options.targetSessionId, "workflow turn target session id");
1783
+ validateKey(options.claimToken, "workflow turn claim token");
1784
+ validateDuration(options.leaseMs, "leaseMs");
1785
+ const limit = options.limit ?? 20;
1786
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
1787
+ throw new Error("Workflow turn intent limit must be an integer from 1 through 100");
1788
+ }
1789
+ const now = validTimestamp(options.now);
1790
+ const nowMs = epoch(now);
1791
+ return this.transaction(() => {
1792
+ const ids = this.database
1793
+ .prepare(
1794
+ `SELECT intent_id FROM workflow_turn_intents
1795
+ WHERE target_session_id = ? AND resolved_at IS NULL AND eligible_at IS NOT NULL
1796
+ AND eligible_at <= ?
1797
+ AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)
1798
+ ORDER BY eligible_at, intent_id LIMIT ?`,
1799
+ )
1800
+ .all(options.targetSessionId, now, nowMs, limit) as { intent_id: string }[];
1801
+ if (ids.length === 0) return [];
1802
+ const placeholders = ids.map(() => "?").join(", ");
1803
+ this.database
1804
+ .prepare(
1805
+ `UPDATE workflow_turn_intents
1806
+ SET delivery_claim_token = ?, delivery_claim_expires_at = ?
1807
+ WHERE intent_id IN (${placeholders}) AND resolved_at IS NULL
1808
+ AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`,
1809
+ )
1810
+ .run(
1811
+ options.claimToken,
1812
+ nowMs + options.leaseMs,
1813
+ ...ids.map((row) => row.intent_id),
1814
+ nowMs,
1815
+ );
1816
+ const rows = this.database
1817
+ .prepare(
1818
+ `SELECT * FROM workflow_turn_intents WHERE delivery_claim_token = ?
1819
+ ORDER BY eligible_at, intent_id`,
1820
+ )
1821
+ .all(options.claimToken) as WorkflowTurnIntentRow[];
1822
+ return rows.map(workflowTurnIntentFromRow);
1823
+ });
1824
+ }
1825
+
1826
+ makeWorkflowTurnIntentEligible(options: {
1827
+ intentId: string;
1828
+ fallbackFacts: WorkflowTurnIntentFacts;
1829
+ now?: string;
1830
+ }): boolean {
1831
+ validateKey(options.intentId, "workflow turn intent id");
1832
+ const result = this.database
1833
+ .prepare(
1834
+ `UPDATE workflow_turn_intents
1835
+ SET fallback_facts_json = ?, eligible_at = COALESCE(eligible_at, ?)
1836
+ WHERE intent_id = ? AND resolved_at IS NULL`,
1837
+ )
1838
+ .run(
1839
+ workflowTurnIntentFactsJson(options.fallbackFacts),
1840
+ validTimestamp(options.now),
1841
+ options.intentId,
1842
+ );
1843
+ return result.changes === 1;
1844
+ }
1845
+
1846
+ resolveWorkflowTurnIntent(options: {
1847
+ intentId: string;
1848
+ targetSessionId: string;
1849
+ claimToken: string;
1850
+ resolution: WorkflowTurnIntentResolution;
1851
+ messageId: string;
1852
+ now?: string;
1853
+ }): boolean {
1854
+ validateKey(options.intentId, "workflow turn intent id");
1855
+ validateKey(options.targetSessionId, "workflow turn target session id");
1856
+ validateKey(options.claimToken, "workflow turn claim token");
1857
+ validateWorkflowTurnIntentResolution(options.resolution);
1858
+ validateKey(options.messageId, "workflow turn resolution message id");
1859
+ const result = this.database
1860
+ .prepare(
1861
+ `UPDATE workflow_turn_intents
1862
+ SET resolved_at = ?, resolution = ?, resolution_message_id = ?,
1863
+ delivery_claim_token = NULL, delivery_claim_expires_at = NULL
1864
+ WHERE intent_id = ? AND target_session_id = ? AND resolved_at IS NULL
1865
+ AND delivery_claim_token = ?`,
1866
+ )
1867
+ .run(
1868
+ validTimestamp(options.now),
1869
+ options.resolution,
1870
+ options.messageId,
1871
+ options.intentId,
1872
+ options.targetSessionId,
1873
+ options.claimToken,
1874
+ );
1875
+ return result.changes === 1;
1876
+ }
1877
+
1878
+ releaseWorkflowTurnIntentClaim(options: {
1879
+ intentId: string;
1880
+ targetSessionId: string;
1881
+ claimToken: string;
1882
+ }): boolean {
1883
+ validateKey(options.intentId, "workflow turn intent id");
1884
+ validateKey(options.targetSessionId, "workflow turn target session id");
1885
+ validateKey(options.claimToken, "workflow turn claim token");
1886
+ const result = this.database
1887
+ .prepare(
1888
+ `UPDATE workflow_turn_intents
1889
+ SET delivery_claim_token = NULL, delivery_claim_expires_at = NULL
1890
+ WHERE intent_id = ? AND target_session_id = ? AND resolved_at IS NULL
1891
+ AND delivery_claim_token = ?`,
1892
+ )
1893
+ .run(options.intentId, options.targetSessionId, options.claimToken);
1894
+ return result.changes === 1;
1895
+ }
1896
+
1897
+ listWorkflowTurnIntents(
1898
+ options: {
1899
+ targetSessionId?: string;
1900
+ runId?: string;
1901
+ limit?: number;
1902
+ } = {},
1903
+ ): WorkflowTurnIntentRecord[] {
1904
+ if (options.targetSessionId !== undefined) {
1905
+ validateKey(options.targetSessionId, "workflow turn target session id");
1906
+ }
1907
+ if (options.runId !== undefined) validateRunId(options.runId);
1908
+ const limit = options.limit ?? 20;
1909
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
1910
+ throw new Error("Workflow turn intent limit must be an integer from 1 through 100");
1911
+ }
1912
+ const clauses: string[] = [];
1913
+ const values: unknown[] = [];
1914
+ if (options.targetSessionId !== undefined) {
1915
+ clauses.push("target_session_id = ?");
1916
+ values.push(options.targetSessionId);
1917
+ }
1918
+ if (options.runId !== undefined) {
1919
+ clauses.push("run_id = ?");
1920
+ values.push(options.runId);
1921
+ }
1922
+ const where = clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`;
1923
+ const rows = this.database
1924
+ .prepare(
1925
+ `SELECT * FROM workflow_turn_intents ${where}
1926
+ ORDER BY requested_at DESC, intent_id DESC LIMIT ?`,
1927
+ )
1928
+ .all(...values, limit) as WorkflowTurnIntentRow[];
1929
+ return rows.map(workflowTurnIntentFromRow);
1930
+ }
1931
+
1529
1932
  enqueueWorkflowNotification(options: {
1530
1933
  runId: string;
1531
1934
  nodeId: string;
1532
1935
  attemptId: string;
1533
1936
  notificationIndex: number;
1534
1937
  targetSessionId: string;
1535
- kind: "progress" | "final" | "launch_failure";
1938
+ kind: "progress" | "final";
1536
1939
  content: string;
1537
1940
  notificationId?: string;
1538
1941
  now?: string;
@@ -1766,7 +2169,7 @@ const SCHEMA_SQL = `
1766
2169
  attempt_id TEXT NOT NULL,
1767
2170
  notification_index INTEGER NOT NULL CHECK (notification_index > 0),
1768
2171
  target_session_id TEXT NOT NULL,
1769
- kind TEXT NOT NULL CHECK (kind IN ('progress', 'final', 'launch_failure')),
2172
+ kind TEXT NOT NULL CHECK (kind IN ('progress', 'final')),
1770
2173
  content TEXT NOT NULL,
1771
2174
  created_at TEXT NOT NULL,
1772
2175
  delivery_claim_token TEXT,
@@ -1778,6 +2181,40 @@ const SCHEMA_SQL = `
1778
2181
  ON workflow_notifications(target_session_id, delivery_claim_expires_at, created_at)
1779
2182
  WHERE delivered_at IS NULL;
1780
2183
 
2184
+ CREATE TABLE IF NOT EXISTS workflow_turn_intents (
2185
+ intent_id TEXT PRIMARY KEY,
2186
+ source_event_id TEXT NOT NULL,
2187
+ run_id TEXT NOT NULL,
2188
+ workflow_ref TEXT NOT NULL,
2189
+ target_session_id TEXT NOT NULL,
2190
+ cause TEXT NOT NULL CHECK (
2191
+ cause IN ('agentCancelled', 'timedOut', 'failed', 'launchFailed',
2192
+ 'controllerInterrupted', 'claimLost')
2193
+ ),
2194
+ node_id TEXT,
2195
+ attempt_id TEXT,
2196
+ fallback_facts_json TEXT NOT NULL,
2197
+ requested_at TEXT NOT NULL,
2198
+ eligible_at TEXT,
2199
+ resolved_at TEXT,
2200
+ resolution TEXT CHECK (resolution IN ('workflowPrompt', 'presentation', 'fallback')),
2201
+ resolution_message_id TEXT,
2202
+ delivery_claim_token TEXT,
2203
+ delivery_claim_expires_at INTEGER,
2204
+ CHECK (
2205
+ (resolved_at IS NULL AND resolution IS NULL AND resolution_message_id IS NULL) OR
2206
+ (resolved_at IS NOT NULL AND resolution IS NOT NULL AND resolution_message_id IS NOT NULL)
2207
+ )
2208
+ );
2209
+ CREATE UNIQUE INDEX IF NOT EXISTS workflow_turn_intents_one_pending_run
2210
+ ON workflow_turn_intents(run_id, target_session_id) WHERE resolved_at IS NULL;
2211
+ CREATE INDEX IF NOT EXISTS workflow_turn_intents_pending_run
2212
+ ON workflow_turn_intents(run_id, target_session_id, requested_at)
2213
+ WHERE resolved_at IS NULL;
2214
+ CREATE INDEX IF NOT EXISTS workflow_turn_intents_eligible_session
2215
+ ON workflow_turn_intents(target_session_id, eligible_at, intent_id)
2216
+ WHERE resolved_at IS NULL AND eligible_at IS NOT NULL;
2217
+
1781
2218
  CREATE TABLE IF NOT EXISTS effects (
1782
2219
  effect_key TEXT NOT NULL,
1783
2220
  resource_uid TEXT NOT NULL,
@@ -1868,7 +2305,56 @@ function workflowFromRow(row: WorkflowRow): ChildWorkflowRecord {
1868
2305
  };
1869
2306
  }
1870
2307
 
2308
+ function workflowTurnIntentFromRow(row: WorkflowTurnIntentRow): WorkflowTurnIntentRecord {
2309
+ const cause = validateWorkflowTurnIntentCause(row.cause);
2310
+ const resolution =
2311
+ row.resolution === null ? null : validateWorkflowTurnIntentResolution(row.resolution);
2312
+ const fallbackFacts = parseStoredJson<unknown>(
2313
+ row.fallback_facts_json,
2314
+ "workflow turn intent facts",
2315
+ );
2316
+ validateWorkflowTurnIntentFacts(fallbackFacts);
2317
+ validateKey(row.intent_id, "workflow turn intent id");
2318
+ validateKey(row.source_event_id, "workflow turn source event id");
2319
+ validateRunId(row.run_id);
2320
+ validateKey(row.workflow_ref, "workflow turn workflow ref");
2321
+ validateKey(row.target_session_id, "workflow turn target session id");
2322
+ validTimestamp(row.requested_at);
2323
+ if (row.eligible_at !== null) validTimestamp(row.eligible_at);
2324
+ if (row.resolved_at !== null) validTimestamp(row.resolved_at);
2325
+ const allResolutionFieldsNull =
2326
+ row.resolved_at === null && row.resolution === null && row.resolution_message_id === null;
2327
+ const allResolutionFieldsSet =
2328
+ row.resolved_at !== null && row.resolution !== null && row.resolution_message_id !== null;
2329
+ if (!allResolutionFieldsNull && !allResolutionFieldsSet) {
2330
+ throw new Error(`Workflow turn intent ${row.intent_id} has inconsistent resolution fields`);
2331
+ }
2332
+ return {
2333
+ intentId: row.intent_id,
2334
+ sourceEventId: row.source_event_id,
2335
+ runId: row.run_id,
2336
+ workflowRef: row.workflow_ref,
2337
+ targetSessionId: row.target_session_id,
2338
+ cause,
2339
+ nodeId: row.node_id,
2340
+ attemptId: row.attempt_id,
2341
+ fallbackFacts,
2342
+ requestedAt: row.requested_at,
2343
+ eligibleAt: row.eligible_at,
2344
+ resolvedAt: row.resolved_at,
2345
+ resolution,
2346
+ resolutionMessageId: row.resolution_message_id,
2347
+ deliveryClaimExpiresAt:
2348
+ row.delivery_claim_expires_at === null ? null : iso(row.delivery_claim_expires_at),
2349
+ };
2350
+ }
2351
+
1871
2352
  function workflowNotificationFromRow(row: WorkflowNotificationRow): WorkflowNotificationRecord {
2353
+ if (row.kind !== "progress" && row.kind !== "final") {
2354
+ throw new Error(
2355
+ "Controller store contains an incompatible pending alpha workflow notification. Preserve needed run bundles, then reset the project controller store.",
2356
+ );
2357
+ }
1872
2358
  return {
1873
2359
  notificationId: row.notification_id,
1874
2360
  runId: row.run_id,
@@ -1885,6 +2371,93 @@ function workflowNotificationFromRow(row: WorkflowNotificationRow): WorkflowNoti
1885
2371
  };
1886
2372
  }
1887
2373
 
2374
+ function workflowTurnIntentFactsJson(facts: WorkflowTurnIntentFacts): string {
2375
+ validateWorkflowTurnIntentFacts(facts);
2376
+ const json = canonicalJson(facts, "workflow turn intent facts");
2377
+ validateJsonSize(json, "Workflow turn intent facts", MAX_EVENT_BYTES);
2378
+ return json;
2379
+ }
2380
+
2381
+ function validateWorkflowTurnIntentFacts(facts: unknown): asserts facts is WorkflowTurnIntentFacts {
2382
+ if (facts === null || typeof facts !== "object" || Array.isArray(facts)) {
2383
+ throw new Error("Workflow turn intent facts must be an object");
2384
+ }
2385
+ const candidate = facts as Record<string, unknown>;
2386
+ if (candidate.schema !== TURN_INTENT_FACTS_SCHEMA) {
2387
+ throw new Error(`Workflow turn intent facts schema must be ${TURN_INTENT_FACTS_SCHEMA}`);
2388
+ }
2389
+ const allowedKeys = new Set([
2390
+ "schema",
2391
+ "workflowName",
2392
+ "runId",
2393
+ "observedState",
2394
+ "cause",
2395
+ "nodeId",
2396
+ "attemptId",
2397
+ "reason",
2398
+ "handoff",
2399
+ ]);
2400
+ for (const key of Object.keys(candidate)) {
2401
+ if (!allowedKeys.has(key)) throw new Error(`Unknown workflow turn intent facts field: ${key}`);
2402
+ }
2403
+ if (typeof candidate.workflowName !== "string") {
2404
+ throw new Error("Workflow turn intent workflowName must be a string");
2405
+ }
2406
+ if (typeof candidate.runId !== "string") {
2407
+ throw new Error("Workflow turn intent runId must be a string");
2408
+ }
2409
+ if (typeof candidate.observedState !== "string") {
2410
+ throw new Error("Workflow turn intent observedState must be a string");
2411
+ }
2412
+ if (typeof candidate.cause !== "string") {
2413
+ throw new Error("Workflow turn intent cause must be a string");
2414
+ }
2415
+ validateKey(candidate.workflowName, "workflow turn facts workflow name");
2416
+ validateRunId(candidate.runId);
2417
+ validateKey(candidate.observedState, "workflow turn facts observed state");
2418
+ validateWorkflowTurnIntentCause(candidate.cause);
2419
+ if (candidate.nodeId !== null && typeof candidate.nodeId !== "string") {
2420
+ throw new Error("Workflow turn intent nodeId must be a string or null");
2421
+ }
2422
+ if (candidate.attemptId !== null && typeof candidate.attemptId !== "string") {
2423
+ throw new Error("Workflow turn intent attemptId must be a string or null");
2424
+ }
2425
+ if (candidate.reason !== null && typeof candidate.reason !== "string") {
2426
+ throw new Error("Workflow turn intent reason must be a string or null");
2427
+ }
2428
+ if (candidate.nodeId !== null) validateKey(candidate.nodeId, "workflow turn facts node id");
2429
+ if (candidate.attemptId !== null) {
2430
+ validateKey(candidate.attemptId, "workflow turn facts attempt id");
2431
+ }
2432
+ if (candidate.reason !== null && candidate.reason.length > MAX_ERROR_LENGTH) {
2433
+ throw new Error(`Workflow turn intent reason must be at most ${MAX_ERROR_LENGTH} characters`);
2434
+ }
2435
+ if (typeof candidate.handoff !== "boolean") {
2436
+ throw new Error("Workflow turn intent handoff must be a boolean");
2437
+ }
2438
+ }
2439
+
2440
+ function validateWorkflowTurnIntentCause(value: string): WorkflowTurnIntentCause {
2441
+ if (
2442
+ value !== "agentCancelled" &&
2443
+ value !== "timedOut" &&
2444
+ value !== "failed" &&
2445
+ value !== "launchFailed" &&
2446
+ value !== "controllerInterrupted" &&
2447
+ value !== "claimLost"
2448
+ ) {
2449
+ throw new Error(`Invalid workflow turn intent cause: ${value}`);
2450
+ }
2451
+ return value;
2452
+ }
2453
+
2454
+ function validateWorkflowTurnIntentResolution(value: string): WorkflowTurnIntentResolution {
2455
+ if (value !== "workflowPrompt" && value !== "presentation" && value !== "fallback") {
2456
+ throw new Error(`Invalid workflow turn intent resolution: ${value}`);
2457
+ }
2458
+ return value;
2459
+ }
2460
+
1888
2461
  function workflowRunFromRow(row: WorkflowRunQueueRow): WorkflowRunQueueRecord {
1889
2462
  return {
1890
2463
  runId: row.run_id,