@osolmaz/pi-workflows 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +15 -6
  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 +29 -3
  6. package/dist/builtins/autoimplement.workflow.js +876 -231
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  8. package/dist/builtins/catalog.js +3 -1
  9. package/dist/builtins/catalog.js.map +1 -1
  10. package/dist/builtins/index.d.ts +2 -0
  11. package/dist/builtins/index.js +1 -0
  12. package/dist/builtins/index.js.map +1 -1
  13. package/dist/builtins/sanity-check-session.d.ts +17 -0
  14. package/dist/builtins/sanity-check-session.js +168 -0
  15. package/dist/builtins/sanity-check-session.js.map +1 -0
  16. package/dist/builtins/sanity-check.workflow.d.ts +83 -0
  17. package/dist/builtins/sanity-check.workflow.js +398 -0
  18. package/dist/builtins/sanity-check.workflow.js.map +1 -0
  19. package/dist/controllers/sqlite.d.ts +141 -5
  20. package/dist/controllers/sqlite.js +568 -46
  21. package/dist/controllers/sqlite.js.map +1 -1
  22. package/dist/extension/decision-channels.js +7 -7
  23. package/dist/extension/deferred-turn-coordinator.d.ts +32 -0
  24. package/dist/extension/deferred-turn-coordinator.js +143 -0
  25. package/dist/extension/deferred-turn-coordinator.js.map +1 -0
  26. package/dist/extension/deferred-turn.d.ts +44 -0
  27. package/dist/extension/deferred-turn.js +110 -0
  28. package/dist/extension/deferred-turn.js.map +1 -0
  29. package/dist/extension/index.js +515 -84
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/step-message.d.ts +1 -0
  32. package/dist/extension/step-message.js.map +1 -1
  33. package/dist/herdr/setup.js +4 -4
  34. package/dist/host/runner.js +3 -0
  35. package/dist/host/runner.js.map +1 -1
  36. package/dist/workflows/command-batch.d.ts +38 -0
  37. package/dist/workflows/command-batch.js +176 -0
  38. package/dist/workflows/command-batch.js.map +1 -0
  39. package/dist/workflows/engine.js +8 -4
  40. package/dist/workflows/engine.js.map +1 -1
  41. package/dist/workflows/index.d.ts +1 -0
  42. package/dist/workflows/index.js +1 -0
  43. package/dist/workflows/index.js.map +1 -1
  44. package/dist/workflows/migrate-sources.d.ts +1 -1
  45. package/dist/workflows/migrate-sources.js.map +1 -1
  46. package/dist/workflows/schema.js +2 -1
  47. package/dist/workflows/schema.js.map +1 -1
  48. package/dist/workflows/shell.d.ts +4 -0
  49. package/dist/workflows/shell.js +6 -0
  50. package/dist/workflows/shell.js.map +1 -1
  51. package/dist/workflows/store.js +3 -1
  52. package/dist/workflows/store.js.map +1 -1
  53. package/dist/workflows/tool-input.d.ts +1 -0
  54. package/dist/workflows/tool-input.js +2 -2
  55. package/dist/workflows/tool-input.js.map +1 -1
  56. package/dist/workflows/types.d.ts +4 -3
  57. package/docs/2026-08-18-herdr-piw-plan.md +1 -1
  58. package/docs/2026-08-20-durable-workflow-launch-plan.md +445 -0
  59. package/docs/CONTROLLERS.md +2 -2
  60. package/docs/DEFERRED_TURNS.md +298 -0
  61. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  62. package/docs/HUMAN_DECISIONS.md +7 -7
  63. package/docs/HUMAN_DECISION_PRESENTATIONS.md +3 -3
  64. package/docs/MONITOR.md +2 -2
  65. package/docs/WORKFLOW_COMPOSITION.md +2 -2
  66. package/docs/WORKFLOW_STEP_MESSAGES.md +5 -5
  67. package/docs/WORKFLOW_UPDATES.md +28 -5
  68. package/docs/plans/2026-08-04-controller-runtime-plan.md +1 -1
  69. package/docs/plans/2026-08-05-always-on-workflows-plan.md +1 -1
  70. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +9 -9
  71. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +1 -1
  72. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +1 -1
  73. package/docs/plans/2026-08-16-workflow-updates-plan.md +3 -3
  74. package/docs/plans/2026-08-17-bundled-skills-plan.md +6 -6
  75. package/docs/plans/2026-08-19-human-decision-gates-plan.md +5 -5
  76. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +1 -1
  77. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +1 -1
  78. package/docs/plans/2026-08-19-workflow-composition-plan.md +6 -6
  79. package/docs/plans/2026-08-20-autoimplement-blocker-challenge-plan.md +3 -3
  80. package/docs/plans/2026-08-20-bounded-command-batches-plan.md +206 -0
  81. package/docs/plans/2026-08-20-herdr-plugin-sync-plan.md +9 -9
  82. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +242 -0
  83. package/docs/plans/2026-08-21-deferred-turn-intents-plan.md +324 -0
  84. package/docs/plans/2026-08-21-sanity-check-plan.md +175 -0
  85. package/docs/run-bundles.md +3 -1
  86. package/docs/workflows.md +67 -17
  87. package/examples/workflows/command-batch.workflow.ts +50 -0
  88. package/examples/workflows/sanity-check.workflow.ts +1 -0
  89. package/herdr-plugin.toml +3 -3
  90. package/package.json +1 -1
  91. package/schemas/decision-presentation-v1.schema.json +1 -1
  92. package/schemas/human-decision-accepted-v1.schema.json +1 -1
  93. package/schemas/human-decision-accepted-v2.schema.json +1 -1
  94. package/schemas/human-decision-answer-attempt-v1.schema.json +1 -1
  95. package/schemas/human-decision-cancellation-v1.schema.json +1 -1
  96. package/schemas/human-decision-continuation-v1.schema.json +1 -1
  97. package/schemas/human-decision-delivery-v1.schema.json +1 -1
  98. package/schemas/human-decision-delivery-v2.schema.json +1 -1
  99. package/schemas/human-decision-receipt-v1.schema.json +1 -1
  100. package/schemas/human-decision-receipt-v2.schema.json +1 -1
  101. package/schemas/human-decision-request-v1.schema.json +1 -1
  102. package/schemas/human-decision-request-v2.schema.json +1 -1
  103. package/schemas/human-decision-resolution-v1.schema.json +1 -1
  104. package/schemas/human-decision-resolution-v2.schema.json +1 -1
  105. package/schemas/human-decision-settlement-v1.schema.json +1 -1
  106. package/skills/autodoc/SKILL.md +33 -2
  107. package/skills/autoimplement/SKILL.md +66 -12
  108. package/skills/autoplan/SKILL.md +26 -2
  109. package/skills/monitor/SKILL.md +35 -21
  110. package/skills/pi-workflows/SKILL.md +19 -5
  111. package/src/builtins/autoimplement-command-batches.ts +364 -0
  112. package/src/builtins/autoimplement.workflow.ts +1065 -261
  113. package/src/builtins/catalog.ts +3 -1
  114. package/src/builtins/index.ts +13 -0
  115. package/src/builtins/sanity-check-session.ts +205 -0
  116. package/src/builtins/sanity-check.workflow.ts +624 -0
  117. package/src/controllers/sqlite.ts +878 -51
  118. package/src/extension/decision-channels.ts +7 -7
  119. package/src/extension/deferred-turn-coordinator.ts +171 -0
  120. package/src/extension/deferred-turn.ts +166 -0
  121. package/src/extension/index.ts +646 -91
  122. package/src/extension/step-message.ts +1 -0
  123. package/src/herdr/setup.ts +4 -4
  124. package/src/host/runner.ts +3 -0
  125. package/src/workflows/command-batch.ts +254 -0
  126. package/src/workflows/engine.ts +8 -5
  127. package/src/workflows/index.ts +15 -0
  128. package/src/workflows/migrate-sources.ts +5 -1
  129. package/src/workflows/schema.ts +2 -1
  130. package/src/workflows/shell.ts +10 -0
  131. package/src/workflows/store.ts +3 -1
  132. package/src/workflows/tool-input.ts +5 -2
  133. package/src/workflows/types.ts +4 -3
@@ -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;
@@ -83,20 +84,36 @@ type WorkflowRow = {
83
84
  error: string | null;
84
85
  };
85
86
 
87
+ export type WorkflowRunLaunchStatus =
88
+ | "queued"
89
+ | "starting"
90
+ | "running"
91
+ | "parked"
92
+ | "done"
93
+ | "failed"
94
+ | "cancelled";
95
+
86
96
  type WorkflowRunQueueRow = {
87
97
  run_id: string;
88
98
  workflow_ref: string;
89
99
  workflow_path: string;
100
+ workflow_source_json: string;
101
+ definition_digest: string;
90
102
  input_json: string;
91
- status: "claimed" | "parked" | "done";
103
+ launch_options_json: string;
104
+ status: WorkflowRunLaunchStatus;
92
105
  runner_id: string | null;
93
106
  claim_token: string | null;
94
107
  claim_expires_at: number | null;
95
108
  affinity_runner_id: string | null;
96
109
  origin_session_id: string | null;
97
110
  parent_run_id: string | null;
111
+ error_code: string | null;
112
+ error_message: string | null;
98
113
  created_at: string;
99
114
  updated_at: string;
115
+ started_at: string | null;
116
+ finished_at: string | null;
100
117
  };
101
118
 
102
119
  /** A user-started workflow run tracked by the durable run queue. */
@@ -106,8 +123,11 @@ export type WorkflowRunQueueRecord = {
106
123
  workflowName: string;
107
124
  /** Canonical source reference used to reopen the run. */
108
125
  workflowSourceRef: string;
126
+ workflowSource: unknown;
127
+ definitionDigest: string;
109
128
  input: unknown;
110
- status: "claimed" | "parked" | "done";
129
+ launchOptions: unknown;
130
+ status: WorkflowRunLaunchStatus;
111
131
  runnerId: string | null;
112
132
  claimToken: string | null;
113
133
  claimExpiresAt: string | null;
@@ -115,8 +135,12 @@ export type WorkflowRunQueueRecord = {
115
135
  /** Pi session that owns delivery and interactive execution, or null for detached runs. */
116
136
  originSessionId: string | null;
117
137
  parentRunId: string | null;
138
+ errorCode: string | null;
139
+ errorMessage: string | null;
118
140
  createdAt: string;
119
141
  updatedAt: string;
142
+ startedAt: string | null;
143
+ finishedAt: string | null;
120
144
  };
121
145
 
122
146
  type WorkflowNotificationRow = {
@@ -126,7 +150,7 @@ type WorkflowNotificationRow = {
126
150
  attempt_id: string;
127
151
  notification_index: number;
128
152
  target_session_id: string;
129
- kind: "progress" | "final";
153
+ kind: string;
130
154
  content: string;
131
155
  created_at: string;
132
156
  delivery_claim_token: string | null;
@@ -148,6 +172,65 @@ export type WorkflowNotificationRecord = {
148
172
  deliveredAt: string | null;
149
173
  };
150
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
+
151
234
  type RunEventRow = {
152
235
  seq: number;
153
236
  recorded_at: string;
@@ -799,21 +882,119 @@ export class SqliteControllerStore implements ControllerStore {
799
882
  if (row === undefined) {
800
883
  throw new Error("Controller store has no schema identifier");
801
884
  }
802
- return;
803
- }
804
- this.transaction(() => {
805
- this.database
806
- .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
807
- .run(CONTROLLER_STORE_SCHEMA);
808
- this.database.exec(SCHEMA_SQL);
809
- const queueColumns = this.database.pragma("table_info(workflow_run_queue)") as {
810
- name: string;
811
- }[];
812
- if (!queueColumns.some((column) => column.name === "origin_session_id")) {
813
- this.database.exec("ALTER TABLE workflow_run_queue ADD COLUMN origin_session_id TEXT");
885
+ } else {
886
+ const existingQueue = this.database
887
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_run_queue'")
888
+ .get();
889
+ if (existingQueue !== undefined) {
890
+ this.assertAlphaSchemaLayout(false);
891
+ this.assertNoLegacyPendingLaunchNotifications();
814
892
  }
815
- this.database.exec("DROP TABLE IF EXISTS session_watermarks");
816
- });
893
+ this.transaction(() => {
894
+ this.database
895
+ .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
896
+ .run(CONTROLLER_STORE_SCHEMA);
897
+ this.database.exec(SCHEMA_SQL);
898
+ });
899
+ }
900
+ this.assertAlphaSchemaLayout(true);
901
+ this.assertNoLegacyPendingLaunchNotifications();
902
+ }
903
+
904
+ private assertAlphaSchemaLayout(requireTurnIntents: boolean): void {
905
+ const requiredQueueColumns = new Set([
906
+ "run_id",
907
+ "workflow_ref",
908
+ "workflow_path",
909
+ "workflow_source_json",
910
+ "definition_digest",
911
+ "input_json",
912
+ "launch_options_json",
913
+ "status",
914
+ "runner_id",
915
+ "claim_token",
916
+ "claim_expires_at",
917
+ "affinity_runner_id",
918
+ "origin_session_id",
919
+ "parent_run_id",
920
+ "error_code",
921
+ "error_message",
922
+ "created_at",
923
+ "updated_at",
924
+ "started_at",
925
+ "finished_at",
926
+ ]);
927
+ const actual = new Set(
928
+ (
929
+ this.database.pragma("table_info(workflow_run_queue)") as {
930
+ name: string;
931
+ }[]
932
+ ).map((column) => column.name),
933
+ );
934
+ const missing = [...requiredQueueColumns].filter((column) => !actual.has(column));
935
+ if (missing.length > 0) {
936
+ throw new Error(
937
+ "Controller store uses an incompatible alpha layout. Preserve needed run bundles, then reset the project controller store.",
938
+ );
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
+ }
817
998
  }
818
999
 
819
1000
  private transaction<T>(task: () => T): T {
@@ -901,15 +1082,68 @@ export class SqliteControllerStore implements ControllerStore {
901
1082
  return workflow;
902
1083
  }
903
1084
 
904
- /**
905
- * Insert a user-started run and claim it in one statement, so the
906
- * originating runner owns the run from birth (origin affinity).
907
- */
1085
+ /** Reserve a user-started run before the initiating agent turn settles. */
1086
+ reserveWorkflowRun(options: {
1087
+ runId: string;
1088
+ workflowName: string;
1089
+ workflowSourceRef: string;
1090
+ workflowSource: unknown;
1091
+ definitionDigest: string;
1092
+ input: unknown;
1093
+ launchOptions?: unknown;
1094
+ runnerId: string;
1095
+ originSessionId: string;
1096
+ parentRunId?: string;
1097
+ now?: string;
1098
+ }): WorkflowRunQueueRecord {
1099
+ validateRunId(options.runId);
1100
+ validateKey(options.workflowName, "workflow name");
1101
+ validateKey(options.workflowSourceRef, "workflow source ref");
1102
+ validateKey(options.definitionDigest, "workflow definition digest");
1103
+ validateKey(options.runnerId, "runner id");
1104
+ validateKey(options.originSessionId, "origin session id");
1105
+ const inputJson = canonicalJson(options.input ?? null, "workflow run input");
1106
+ const sourceJson = canonicalJson(options.workflowSource, "workflow run source");
1107
+ const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
1108
+ validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
1109
+ validateJsonSize(sourceJson, "Workflow run source", MAX_EVENT_BYTES);
1110
+ validateJsonSize(launchJson, "Workflow launch options", MAX_EVENT_BYTES);
1111
+ const now = validTimestamp(options.now);
1112
+ this.database
1113
+ .prepare(
1114
+ `INSERT INTO workflow_run_queue (
1115
+ run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
1116
+ input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
1117
+ affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
1118
+ created_at, updated_at, started_at, finished_at
1119
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', NULL, NULL, NULL, ?, ?, ?, NULL, NULL, ?, ?, NULL, NULL)`,
1120
+ )
1121
+ .run(
1122
+ options.runId,
1123
+ options.workflowName,
1124
+ options.workflowSourceRef,
1125
+ sourceJson,
1126
+ options.definitionDigest,
1127
+ inputJson,
1128
+ launchJson,
1129
+ options.runnerId,
1130
+ options.originSessionId,
1131
+ options.parentRunId ?? null,
1132
+ now,
1133
+ now,
1134
+ );
1135
+ return this.requireWorkflowRun(options.runId);
1136
+ }
1137
+
1138
+ /** Insert a run that will start immediately outside a live model turn. */
908
1139
  enqueueWorkflowRun(options: {
909
1140
  runId: string;
910
1141
  workflowName: string;
911
1142
  workflowSourceRef: string;
1143
+ workflowSource?: unknown;
1144
+ definitionDigest?: string;
912
1145
  input: unknown;
1146
+ launchOptions?: unknown;
913
1147
  runnerId: string;
914
1148
  claimToken: string;
915
1149
  leaseMs: number;
@@ -928,22 +1162,31 @@ export class SqliteControllerStore implements ControllerStore {
928
1162
  validateKey(options.originSessionId, "origin session id");
929
1163
  }
930
1164
  const inputJson = canonicalJson(options.input ?? null, "workflow run input");
1165
+ const sourceJson = canonicalJson(
1166
+ options.workflowSource ?? { ref: options.workflowSourceRef },
1167
+ "workflow run source",
1168
+ );
1169
+ const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
931
1170
  validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
932
1171
  const now = validTimestamp(options.now);
933
1172
  const expiresAt = epoch(now) + options.leaseMs;
934
1173
  this.database
935
1174
  .prepare(
936
1175
  `INSERT INTO workflow_run_queue (
937
- run_id, workflow_ref, workflow_path, input_json, status,
938
- runner_id, claim_token, claim_expires_at, affinity_runner_id,
939
- origin_session_id, parent_run_id, created_at, updated_at
940
- ) VALUES (?, ?, ?, ?, 'claimed', ?, ?, ?, ?, ?, ?, ?, ?)`,
1176
+ run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
1177
+ input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
1178
+ affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
1179
+ created_at, updated_at, started_at, finished_at
1180
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL)`,
941
1181
  )
942
1182
  .run(
943
1183
  options.runId,
944
1184
  options.workflowName,
945
1185
  options.workflowSourceRef,
1186
+ sourceJson,
1187
+ options.definitionDigest ?? "unavailable",
946
1188
  inputJson,
1189
+ launchJson,
947
1190
  options.runnerId,
948
1191
  options.claimToken,
949
1192
  expiresAt,
@@ -952,6 +1195,7 @@ export class SqliteControllerStore implements ControllerStore {
952
1195
  options.parentRunId ?? null,
953
1196
  now,
954
1197
  now,
1198
+ now,
955
1199
  );
956
1200
  return this.requireWorkflowRun(options.runId);
957
1201
  }
@@ -978,6 +1222,58 @@ export class SqliteControllerStore implements ControllerStore {
978
1222
  return rows.map(workflowRunFromRow);
979
1223
  }
980
1224
 
1225
+ findSessionReservation(sessionId: string): WorkflowRunQueueRecord | undefined {
1226
+ validateKey(sessionId, "session id");
1227
+ const row = this.database
1228
+ .prepare(
1229
+ `SELECT * FROM workflow_run_queue
1230
+ WHERE origin_session_id = ? AND status IN ('queued', 'starting', 'running')
1231
+ ORDER BY created_at LIMIT 1`,
1232
+ )
1233
+ .get(sessionId) as WorkflowRunQueueRow | undefined;
1234
+ return row === undefined ? undefined : workflowRunFromRow(row);
1235
+ }
1236
+
1237
+ claimWorkflowRun(options: {
1238
+ runId: string;
1239
+ runnerId: string;
1240
+ claimToken: string;
1241
+ leaseMs: number;
1242
+ now?: string;
1243
+ }): WorkflowRunQueueRecord | undefined {
1244
+ validateRunId(options.runId);
1245
+ validateKey(options.runnerId, "runner id");
1246
+ validateKey(options.claimToken, "claim token");
1247
+ validateDuration(options.leaseMs, "leaseMs");
1248
+ const now = validTimestamp(options.now);
1249
+ const nowMs = epoch(now);
1250
+ const expiresAt = nowMs + options.leaseMs;
1251
+ const result = this.database
1252
+ .prepare(
1253
+ `UPDATE workflow_run_queue
1254
+ SET status = 'starting', runner_id = ?, claim_token = ?, claim_expires_at = ?,
1255
+ started_at = COALESCE(started_at, ?), updated_at = ?
1256
+ WHERE run_id = ? AND (
1257
+ status IN ('queued', 'parked')
1258
+ OR (status = 'starting' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1259
+ )`,
1260
+ )
1261
+ .run(options.runnerId, options.claimToken, expiresAt, now, now, options.runId, nowMs);
1262
+ return result.changes === 1 ? this.requireWorkflowRun(options.runId) : undefined;
1263
+ }
1264
+
1265
+ markWorkflowRunRunning(options: { runId: string; claimToken: string; now?: string }): boolean {
1266
+ validateRunId(options.runId);
1267
+ const now = validTimestamp(options.now);
1268
+ const result = this.database
1269
+ .prepare(
1270
+ `UPDATE workflow_run_queue SET status = 'running', updated_at = ?
1271
+ WHERE run_id = ? AND claim_token = ? AND status = 'starting'`,
1272
+ )
1273
+ .run(now, options.runId, options.claimToken);
1274
+ return result.changes === 1;
1275
+ }
1276
+
981
1277
  /**
982
1278
  * Claim the oldest claimable run, preferring runs with affinity to this
983
1279
  * runner. Parked rows are claimable immediately; claimed rows become
@@ -1008,11 +1304,11 @@ export class SqliteControllerStore implements ControllerStore {
1008
1304
  excluded.length === 0 ? "" : `AND run_id NOT IN (${excluded.map(() => "?").join(", ")})`;
1009
1305
  const sessionFilter =
1010
1306
  options.sessionId === undefined
1011
- ? ""
1307
+ ? "AND status != 'queued' AND (status != 'starting' OR origin_session_id IS NULL)"
1012
1308
  : "AND (origin_session_id IS NULL OR origin_session_id = ?)";
1013
1309
  const claimable = `(
1014
- status = 'parked'
1015
- OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1310
+ status IN ('queued', 'parked')
1311
+ OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1016
1312
  )`;
1017
1313
  return this.transaction(() => {
1018
1314
  const candidate = this.database
@@ -1036,14 +1332,14 @@ export class SqliteControllerStore implements ControllerStore {
1036
1332
  const result = this.database
1037
1333
  .prepare(
1038
1334
  `UPDATE workflow_run_queue
1039
- SET status = 'claimed', runner_id = ?, claim_token = ?,
1040
- claim_expires_at = ?, updated_at = ?
1335
+ SET status = 'starting', runner_id = ?, claim_token = ?,
1336
+ claim_expires_at = ?, started_at = COALESCE(started_at, ?), updated_at = ?
1041
1337
  WHERE run_id = ? AND (
1042
- status = 'parked'
1043
- OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1338
+ status IN ('queued', 'parked')
1339
+ OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1044
1340
  )`,
1045
1341
  )
1046
- .run(options.runnerId, options.claimToken, expiresAt, now, candidate.run_id, nowMs);
1342
+ .run(options.runnerId, options.claimToken, expiresAt, now, now, candidate.run_id, nowMs);
1047
1343
  return result.changes === 1 ? this.requireWorkflowRun(candidate.run_id) : undefined;
1048
1344
  });
1049
1345
  }
@@ -1062,7 +1358,7 @@ export class SqliteControllerStore implements ControllerStore {
1062
1358
  const result = this.database
1063
1359
  .prepare(
1064
1360
  `UPDATE workflow_run_queue SET claim_expires_at = ?
1065
- WHERE run_id = ? AND claim_token = ? AND status = 'claimed'
1361
+ WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')
1066
1362
  AND claim_expires_at > ?`,
1067
1363
  )
1068
1364
  .run(expiresAt, options.runId, options.claimToken, nowMs);
@@ -1076,7 +1372,7 @@ export class SqliteControllerStore implements ControllerStore {
1076
1372
  const row = this.database
1077
1373
  .prepare(
1078
1374
  `SELECT 1 AS live FROM workflow_run_queue
1079
- WHERE run_id = ? AND claim_token = ? AND status = 'claimed'
1375
+ WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')
1080
1376
  AND claim_expires_at > ?`,
1081
1377
  )
1082
1378
  .get(options.runId, options.claimToken, nowMs);
@@ -1092,12 +1388,68 @@ export class SqliteControllerStore implements ControllerStore {
1092
1388
  `UPDATE workflow_run_queue
1093
1389
  SET status = 'parked', runner_id = NULL, claim_token = NULL,
1094
1390
  claim_expires_at = NULL, updated_at = ?
1095
- WHERE run_id = ? AND claim_token = ? AND status = 'claimed'`,
1391
+ WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`,
1096
1392
  )
1097
1393
  .run(now, options.runId, options.claimToken);
1098
1394
  return result.changes === 1;
1099
1395
  }
1100
1396
 
1397
+ failWorkflowRun(options: {
1398
+ runId: string;
1399
+ claimToken?: string;
1400
+ errorCode: string;
1401
+ errorMessage: string;
1402
+ now?: string;
1403
+ }): boolean {
1404
+ validateRunId(options.runId);
1405
+ validateKey(options.errorCode, "workflow launch error code");
1406
+ const message = options.errorMessage.trim();
1407
+ if (message.length === 0 || Buffer.byteLength(message) > 2_048) {
1408
+ throw new Error("Workflow launch error message must be between 1 and 2048 bytes");
1409
+ }
1410
+ const now = validTimestamp(options.now);
1411
+ const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
1412
+ const result = this.database
1413
+ .prepare(
1414
+ `UPDATE workflow_run_queue
1415
+ SET status = 'failed', runner_id = NULL, claim_token = NULL,
1416
+ claim_expires_at = NULL, parent_run_id = NULL, input_json = 'null',
1417
+ launch_options_json = '{}', error_code = ?, error_message = ?,
1418
+ finished_at = ?, updated_at = ?
1419
+ WHERE run_id = ? AND status IN ('queued', 'starting', 'running') ${claimFilter}`,
1420
+ )
1421
+ .run(
1422
+ options.errorCode,
1423
+ message,
1424
+ now,
1425
+ now,
1426
+ options.runId,
1427
+ ...(options.claimToken === undefined ? [] : [options.claimToken]),
1428
+ );
1429
+ return result.changes === 1;
1430
+ }
1431
+
1432
+ cancelWorkflowRun(options: { runId: string; claimToken?: string; now?: string }): boolean {
1433
+ validateRunId(options.runId);
1434
+ const now = validTimestamp(options.now);
1435
+ const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
1436
+ const result = this.database
1437
+ .prepare(
1438
+ `UPDATE workflow_run_queue
1439
+ SET status = 'cancelled', runner_id = NULL, claim_token = NULL,
1440
+ claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
1441
+ finished_at = ?, updated_at = ?
1442
+ WHERE run_id = ? AND status IN ('queued', 'starting') ${claimFilter}`,
1443
+ )
1444
+ .run(
1445
+ now,
1446
+ now,
1447
+ options.runId,
1448
+ ...(options.claimToken === undefined ? [] : [options.claimToken]),
1449
+ );
1450
+ return result.changes === 1;
1451
+ }
1452
+
1101
1453
  /**
1102
1454
  * Delete a claimed row. Used when a continuation fails before its bundle
1103
1455
  * exists, so the parent's one-continuation slot is not consumed by a run
@@ -1119,10 +1471,11 @@ export class SqliteControllerStore implements ControllerStore {
1119
1471
  .prepare(
1120
1472
  `UPDATE workflow_run_queue
1121
1473
  SET status = 'done', runner_id = NULL, claim_token = NULL,
1122
- claim_expires_at = NULL, updated_at = ?
1123
- WHERE run_id = ? AND claim_token = ? AND status = 'claimed'`,
1474
+ claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
1475
+ finished_at = ?, updated_at = ?
1476
+ WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`,
1124
1477
  )
1125
- .run(now, options.runId, options.claimToken);
1478
+ .run(now, now, options.runId, options.claimToken);
1126
1479
  return result.changes === 1;
1127
1480
  }
1128
1481
 
@@ -1149,26 +1502,28 @@ export class SqliteControllerStore implements ControllerStore {
1149
1502
  const row = this.database
1150
1503
  .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1151
1504
  .get(options.runId) as WorkflowRunQueueRow | undefined;
1152
- if (row === undefined || row.status === "done") return false;
1505
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
1506
+ return false;
1507
+ }
1153
1508
  if (row.workflow_path === options.workflowSourceRef && row.status === "parked") {
1154
1509
  return "unchanged";
1155
1510
  }
1156
1511
  const claimable =
1157
1512
  row.status === "parked" ||
1158
- (row.status === "claimed" &&
1513
+ (row.status === "starting" &&
1159
1514
  row.claim_token === options.claimToken &&
1160
1515
  row.claim_expires_at !== null &&
1161
1516
  row.claim_expires_at > nowMs) ||
1162
- (row.status === "claimed" &&
1517
+ (row.status === "starting" &&
1163
1518
  row.claim_expires_at !== null &&
1164
1519
  row.claim_expires_at <= nowMs);
1165
1520
  if (!claimable) return false;
1166
1521
  const result = this.database
1167
1522
  .prepare(
1168
1523
  `UPDATE workflow_run_queue
1169
- SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
1524
+ SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
1170
1525
  claim_token = ?, claim_expires_at = ?, updated_at = ?
1171
- WHERE run_id = ? AND status != 'done'`,
1526
+ WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`,
1172
1527
  )
1173
1528
  .run(
1174
1529
  options.workflowName,
@@ -1208,26 +1563,28 @@ export class SqliteControllerStore implements ControllerStore {
1208
1563
  const row = this.database
1209
1564
  .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1210
1565
  .get(options.runId) as WorkflowRunQueueRow | undefined;
1211
- if (row === undefined || row.status === "done") return false;
1566
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
1567
+ return false;
1568
+ }
1212
1569
  const sourceMatches =
1213
1570
  row.workflow_path === options.oldWorkflowPath ||
1214
1571
  row.workflow_path === options.workflowSourceRef;
1215
1572
  const claimable =
1216
1573
  row.status === "parked" ||
1217
- (row.status === "claimed" &&
1574
+ (row.status === "starting" &&
1218
1575
  row.claim_token === options.claimToken &&
1219
1576
  row.claim_expires_at !== null &&
1220
1577
  row.claim_expires_at > nowMs) ||
1221
- (row.status === "claimed" &&
1578
+ (row.status === "starting" &&
1222
1579
  row.claim_expires_at !== null &&
1223
1580
  row.claim_expires_at <= nowMs);
1224
1581
  if (!sourceMatches || !claimable) return false;
1225
1582
  const result = this.database
1226
1583
  .prepare(
1227
1584
  `UPDATE workflow_run_queue
1228
- SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
1585
+ SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
1229
1586
  claim_token = ?, claim_expires_at = ?, updated_at = ?
1230
- WHERE run_id = ? AND status != 'done'`,
1587
+ WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`,
1231
1588
  )
1232
1589
  .run(
1233
1590
  options.workflowName,
@@ -1291,6 +1648,287 @@ export class SqliteControllerStore implements ControllerStore {
1291
1648
  }));
1292
1649
  }
1293
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
+
1294
1932
  enqueueWorkflowNotification(options: {
1295
1933
  runId: string;
1296
1934
  nodeId: string;
@@ -1483,19 +2121,31 @@ const SCHEMA_SQL = `
1483
2121
  run_id TEXT PRIMARY KEY,
1484
2122
  workflow_ref TEXT NOT NULL,
1485
2123
  workflow_path TEXT NOT NULL,
2124
+ workflow_source_json TEXT NOT NULL,
2125
+ definition_digest TEXT NOT NULL,
1486
2126
  input_json TEXT NOT NULL,
1487
- status TEXT NOT NULL CHECK (status IN ('claimed', 'parked', 'done')),
2127
+ launch_options_json TEXT NOT NULL,
2128
+ status TEXT NOT NULL CHECK (
2129
+ status IN ('queued', 'starting', 'running', 'parked', 'done', 'failed', 'cancelled')
2130
+ ),
1488
2131
  runner_id TEXT,
1489
2132
  claim_token TEXT,
1490
2133
  claim_expires_at INTEGER,
1491
2134
  affinity_runner_id TEXT,
1492
2135
  origin_session_id TEXT,
1493
2136
  parent_run_id TEXT,
2137
+ error_code TEXT,
2138
+ error_message TEXT,
1494
2139
  created_at TEXT NOT NULL,
1495
- updated_at TEXT NOT NULL
2140
+ updated_at TEXT NOT NULL,
2141
+ started_at TEXT,
2142
+ finished_at TEXT
1496
2143
  );
1497
2144
  CREATE INDEX IF NOT EXISTS workflow_run_queue_claimable
1498
2145
  ON workflow_run_queue(status, claim_expires_at);
2146
+ CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_session_reservation
2147
+ ON workflow_run_queue(origin_session_id)
2148
+ WHERE origin_session_id IS NOT NULL AND status IN ('queued', 'starting', 'running');
1499
2149
  -- A checkpointed parent admits exactly one continuation run, across
1500
2150
  -- sessions and processes.
1501
2151
  CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_parent
@@ -1531,6 +2181,40 @@ const SCHEMA_SQL = `
1531
2181
  ON workflow_notifications(target_session_id, delivery_claim_expires_at, created_at)
1532
2182
  WHERE delivered_at IS NULL;
1533
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
+
1534
2218
  CREATE TABLE IF NOT EXISTS effects (
1535
2219
  effect_key TEXT NOT NULL,
1536
2220
  resource_uid TEXT NOT NULL,
@@ -1621,7 +2305,56 @@ function workflowFromRow(row: WorkflowRow): ChildWorkflowRecord {
1621
2305
  };
1622
2306
  }
1623
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
+
1624
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
+ }
1625
2358
  return {
1626
2359
  notificationId: row.notification_id,
1627
2360
  runId: row.run_id,
@@ -1638,12 +2371,102 @@ function workflowNotificationFromRow(row: WorkflowNotificationRow): WorkflowNoti
1638
2371
  };
1639
2372
  }
1640
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
+
1641
2461
  function workflowRunFromRow(row: WorkflowRunQueueRow): WorkflowRunQueueRecord {
1642
2462
  return {
1643
2463
  runId: row.run_id,
1644
2464
  workflowName: row.workflow_ref,
1645
2465
  workflowSourceRef: row.workflow_path,
2466
+ workflowSource: parseStoredJson(row.workflow_source_json, "workflow run source"),
2467
+ definitionDigest: row.definition_digest,
1646
2468
  input: parseStoredJson(row.input_json, "workflow run input"),
2469
+ launchOptions: parseStoredJson(row.launch_options_json, "workflow launch options"),
1647
2470
  status: row.status,
1648
2471
  runnerId: row.runner_id,
1649
2472
  claimToken: row.claim_token,
@@ -1651,8 +2474,12 @@ function workflowRunFromRow(row: WorkflowRunQueueRow): WorkflowRunQueueRecord {
1651
2474
  affinityRunnerId: row.affinity_runner_id,
1652
2475
  originSessionId: row.origin_session_id,
1653
2476
  parentRunId: row.parent_run_id,
2477
+ errorCode: row.error_code,
2478
+ errorMessage: row.error_message,
1654
2479
  createdAt: row.created_at,
1655
2480
  updatedAt: row.updated_at,
2481
+ startedAt: row.started_at,
2482
+ finishedAt: row.finished_at,
1656
2483
  };
1657
2484
  }
1658
2485