@osolmaz/pi-workflows 0.3.0 → 0.5.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 (74) hide show
  1. package/README.md +11 -7
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +32 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.d.ts +2 -2
  6. package/dist/builtins/monitor.workflow.js +25 -25
  7. package/dist/builtins/monitor.workflow.js.map +1 -1
  8. package/dist/controllers/index.d.ts +1 -1
  9. package/dist/controllers/index.js.map +1 -1
  10. package/dist/controllers/sqlite.d.ts +70 -9
  11. package/dist/controllers/sqlite.js +209 -35
  12. package/dist/controllers/sqlite.js.map +1 -1
  13. package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
  14. package/dist/controllers/workflow-engine-scheduler.js +3 -1
  15. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  16. package/dist/extension/executor.d.ts +3 -0
  17. package/dist/extension/executor.js +11 -1
  18. package/dist/extension/executor.js.map +1 -1
  19. package/dist/extension/index.js +157 -108
  20. package/dist/extension/index.js.map +1 -1
  21. package/dist/host/runner.d.ts +1 -0
  22. package/dist/host/runner.js +68 -20
  23. package/dist/host/runner.js.map +1 -1
  24. package/dist/render/graph-render.js +3 -0
  25. package/dist/render/graph-render.js.map +1 -1
  26. package/dist/workflows/catalog.d.ts +43 -0
  27. package/dist/workflows/catalog.js +79 -0
  28. package/dist/workflows/catalog.js.map +1 -0
  29. package/dist/workflows/definition.d.ts +2 -1
  30. package/dist/workflows/definition.js +9 -1
  31. package/dist/workflows/definition.js.map +1 -1
  32. package/dist/workflows/engine.d.ts +6 -6
  33. package/dist/workflows/engine.js +93 -33
  34. package/dist/workflows/engine.js.map +1 -1
  35. package/dist/workflows/index.d.ts +3 -3
  36. package/dist/workflows/index.js +2 -2
  37. package/dist/workflows/index.js.map +1 -1
  38. package/dist/workflows/loader.d.ts +18 -16
  39. package/dist/workflows/loader.js +58 -23
  40. package/dist/workflows/loader.js.map +1 -1
  41. package/dist/workflows/migrate-sources.d.ts +42 -0
  42. package/dist/workflows/migrate-sources.js +133 -0
  43. package/dist/workflows/migrate-sources.js.map +1 -0
  44. package/dist/workflows/schema.d.ts +2 -1
  45. package/dist/workflows/schema.js +14 -1
  46. package/dist/workflows/schema.js.map +1 -1
  47. package/dist/workflows/store.js +5 -2
  48. package/dist/workflows/store.js.map +1 -1
  49. package/dist/workflows/types.d.ts +44 -5
  50. package/docs/development.md +5 -3
  51. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  52. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  53. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
  54. package/docs/run-bundles.md +22 -4
  55. package/docs/workflows.md +57 -14
  56. package/package.json +1 -1
  57. package/src/builtins/catalog.ts +32 -0
  58. package/src/builtins/monitor.workflow.ts +33 -26
  59. package/src/controllers/index.ts +1 -0
  60. package/src/controllers/sqlite.ts +353 -43
  61. package/src/controllers/workflow-engine-scheduler.ts +5 -2
  62. package/src/extension/executor.ts +12 -1
  63. package/src/extension/index.ts +181 -140
  64. package/src/host/runner.ts +78 -20
  65. package/src/render/graph-render.ts +3 -0
  66. package/src/workflows/catalog.ts +135 -0
  67. package/src/workflows/definition.ts +11 -0
  68. package/src/workflows/engine.ts +128 -53
  69. package/src/workflows/index.ts +7 -0
  70. package/src/workflows/loader.ts +70 -26
  71. package/src/workflows/migrate-sources.ts +174 -0
  72. package/src/workflows/schema.ts +16 -1
  73. package/src/workflows/store.ts +5 -2
  74. package/src/workflows/types.ts +43 -4
@@ -93,6 +93,7 @@ type WorkflowRunQueueRow = {
93
93
  claim_token: string | null;
94
94
  claim_expires_at: number | null;
95
95
  affinity_runner_id: string | null;
96
+ origin_session_id: string | null;
96
97
  parent_run_id: string | null;
97
98
  created_at: string;
98
99
  updated_at: string;
@@ -101,19 +102,52 @@ type WorkflowRunQueueRow = {
101
102
  /** A user-started workflow run tracked by the durable run queue. */
102
103
  export type WorkflowRunQueueRecord = {
103
104
  runId: string;
104
- workflowRef: string;
105
- workflowPath: string;
105
+ /** Human-readable workflow name used in status and event output. */
106
+ workflowName: string;
107
+ /** Canonical source reference used to reopen the run. */
108
+ workflowSourceRef: string;
106
109
  input: unknown;
107
110
  status: "claimed" | "parked" | "done";
108
111
  runnerId: string | null;
109
112
  claimToken: string | null;
110
113
  claimExpiresAt: string | null;
111
114
  affinityRunnerId: string | null;
115
+ /** Pi session that owns delivery and interactive execution, or null for detached runs. */
116
+ originSessionId: string | null;
112
117
  parentRunId: string | null;
113
118
  createdAt: string;
114
119
  updatedAt: string;
115
120
  };
116
121
 
122
+ type WorkflowNotificationRow = {
123
+ notification_id: string;
124
+ run_id: string;
125
+ node_id: string;
126
+ attempt_id: string;
127
+ notification_index: number;
128
+ target_session_id: string;
129
+ kind: "progress" | "final";
130
+ content: string;
131
+ created_at: string;
132
+ delivery_claim_token: string | null;
133
+ delivery_claim_expires_at: number | null;
134
+ delivered_at: string | null;
135
+ };
136
+
137
+ export type WorkflowNotificationRecord = {
138
+ notificationId: string;
139
+ runId: string;
140
+ nodeId: string;
141
+ attemptId: string;
142
+ notificationIndex: number;
143
+ targetSessionId: string;
144
+ kind: "progress" | "final";
145
+ content: string;
146
+ createdAt: string;
147
+ deliveryClaimExpiresAt: string | null;
148
+ deliveredAt: string | null;
149
+ };
150
+
117
151
  type RunEventRow = {
118
152
  seq: number;
119
153
  recorded_at: string;
@@ -772,6 +806,13 @@ export class SqliteControllerStore implements ControllerStore {
772
806
  .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
773
807
  .run(CONTROLLER_STORE_SCHEMA);
774
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");
814
+ }
815
+ this.database.exec("DROP TABLE IF EXISTS session_watermarks");
775
816
  });
776
817
  }
777
818
 
@@ -866,22 +907,26 @@ export class SqliteControllerStore implements ControllerStore {
866
907
  */
867
908
  enqueueWorkflowRun(options: {
868
909
  runId: string;
869
- workflowRef: string;
870
- workflowPath: string;
910
+ workflowName: string;
911
+ workflowSourceRef: string;
871
912
  input: unknown;
872
913
  runnerId: string;
873
914
  claimToken: string;
874
915
  leaseMs: number;
875
916
  affinityRunnerId?: string;
917
+ originSessionId?: string;
876
918
  parentRunId?: string;
877
919
  now?: string;
878
920
  }): WorkflowRunQueueRecord {
879
921
  validateRunId(options.runId);
880
- validateKey(options.workflowRef, "workflow ref");
881
- validateKey(options.workflowPath, "workflow path");
922
+ validateKey(options.workflowName, "workflow name");
923
+ validateKey(options.workflowSourceRef, "workflow source ref");
882
924
  validateKey(options.runnerId, "runner id");
883
925
  validateKey(options.claimToken, "claim token");
884
926
  validateDuration(options.leaseMs, "leaseMs");
927
+ if (options.originSessionId !== undefined) {
928
+ validateKey(options.originSessionId, "origin session id");
929
+ }
885
930
  const inputJson = canonicalJson(options.input ?? null, "workflow run input");
886
931
  validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
887
932
  const now = validTimestamp(options.now);
@@ -891,18 +936,19 @@ export class SqliteControllerStore implements ControllerStore {
891
936
  `INSERT INTO workflow_run_queue (
892
937
  run_id, workflow_ref, workflow_path, input_json, status,
893
938
  runner_id, claim_token, claim_expires_at, affinity_runner_id,
894
- parent_run_id, created_at, updated_at
895
- ) VALUES (?, ?, ?, ?, 'claimed', ?, ?, ?, ?, ?, ?, ?)`,
939
+ origin_session_id, parent_run_id, created_at, updated_at
940
+ ) VALUES (?, ?, ?, ?, 'claimed', ?, ?, ?, ?, ?, ?, ?, ?)`,
896
941
  )
897
942
  .run(
898
943
  options.runId,
899
- options.workflowRef,
900
- options.workflowPath,
944
+ options.workflowName,
945
+ options.workflowSourceRef,
901
946
  inputJson,
902
947
  options.runnerId,
903
948
  options.claimToken,
904
949
  expiresAt,
905
950
  options.affinityRunnerId ?? options.runnerId,
951
+ options.originSessionId ?? null,
906
952
  options.parentRunId ?? null,
907
953
  now,
908
954
  now,
@@ -943,11 +989,14 @@ export class SqliteControllerStore implements ControllerStore {
943
989
  claimToken: string;
944
990
  leaseMs: number;
945
991
  excludeRunIds?: string[];
992
+ /** Interactive sessions provide their id; detached hosts omit it. */
993
+ sessionId?: string;
946
994
  now?: string;
947
995
  }): WorkflowRunQueueRecord | undefined {
948
996
  validateKey(options.runnerId, "runner id");
949
997
  validateKey(options.claimToken, "claim token");
950
998
  validateDuration(options.leaseMs, "leaseMs");
999
+ if (options.sessionId !== undefined) validateKey(options.sessionId, "session id");
951
1000
  const now = validTimestamp(options.now);
952
1001
  const nowMs = epoch(now);
953
1002
  const expiresAt = nowMs + options.leaseMs;
@@ -957,6 +1006,10 @@ export class SqliteControllerStore implements ControllerStore {
957
1006
  }
958
1007
  const exclusion =
959
1008
  excluded.length === 0 ? "" : `AND run_id NOT IN (${excluded.map(() => "?").join(", ")})`;
1009
+ const sessionFilter =
1010
+ options.sessionId === undefined
1011
+ ? ""
1012
+ : "AND (origin_session_id IS NULL OR origin_session_id = ?)";
960
1013
  const claimable = `(
961
1014
  status = 'parked'
962
1015
  OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
@@ -965,13 +1018,18 @@ export class SqliteControllerStore implements ControllerStore {
965
1018
  const candidate = this.database
966
1019
  .prepare(
967
1020
  `SELECT run_id FROM workflow_run_queue
968
- WHERE ${claimable} ${exclusion}
1021
+ WHERE ${claimable} ${exclusion} ${sessionFilter}
969
1022
  ORDER BY
970
1023
  CASE WHEN affinity_runner_id = ? THEN 0 ELSE 1 END,
971
1024
  created_at ASC
972
1025
  LIMIT 1`,
973
1026
  )
974
- .get(nowMs, ...excluded, options.runnerId) as { run_id: string } | undefined;
1027
+ .get(
1028
+ nowMs,
1029
+ ...excluded,
1030
+ ...(options.sessionId === undefined ? [] : [options.sessionId]),
1031
+ options.runnerId,
1032
+ ) as { run_id: string } | undefined;
975
1033
  if (candidate === undefined) {
976
1034
  return undefined;
977
1035
  }
@@ -1068,6 +1126,122 @@ export class SqliteControllerStore implements ControllerStore {
1068
1126
  return result.changes === 1;
1069
1127
  }
1070
1128
 
1129
+ /** Repair a canonical bundle's queue source and claim it only when needed. */
1130
+ repairCanonicalWorkflowSourceRun(options: {
1131
+ runId: string;
1132
+ workflowName: string;
1133
+ workflowSourceRef: string;
1134
+ runnerId: string;
1135
+ claimToken: string;
1136
+ leaseMs: number;
1137
+ now?: string;
1138
+ }): "unchanged" | "claimed" | false {
1139
+ validateRunId(options.runId);
1140
+ validateKey(options.workflowName, "workflow name");
1141
+ validateKey(options.workflowSourceRef, "workflow source ref");
1142
+ validateKey(options.runnerId, "runner id");
1143
+ validateKey(options.claimToken, "claim token");
1144
+ validateDuration(options.leaseMs, "leaseMs");
1145
+ const now = validTimestamp(options.now);
1146
+ const nowMs = epoch(now);
1147
+ const expiresAt = nowMs + options.leaseMs;
1148
+ return this.transaction(() => {
1149
+ const row = this.database
1150
+ .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1151
+ .get(options.runId) as WorkflowRunQueueRow | undefined;
1152
+ if (row === undefined || row.status === "done") return false;
1153
+ if (row.workflow_path === options.workflowSourceRef && row.status === "parked") {
1154
+ return "unchanged";
1155
+ }
1156
+ const claimable =
1157
+ row.status === "parked" ||
1158
+ (row.status === "claimed" &&
1159
+ row.claim_token === options.claimToken &&
1160
+ row.claim_expires_at !== null &&
1161
+ row.claim_expires_at > nowMs) ||
1162
+ (row.status === "claimed" &&
1163
+ row.claim_expires_at !== null &&
1164
+ row.claim_expires_at <= nowMs);
1165
+ if (!claimable) return false;
1166
+ const result = this.database
1167
+ .prepare(
1168
+ `UPDATE workflow_run_queue
1169
+ SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
1170
+ claim_token = ?, claim_expires_at = ?, updated_at = ?
1171
+ WHERE run_id = ? AND status != 'done'`,
1172
+ )
1173
+ .run(
1174
+ options.workflowName,
1175
+ options.workflowSourceRef,
1176
+ options.runnerId,
1177
+ options.claimToken,
1178
+ expiresAt,
1179
+ now,
1180
+ options.runId,
1181
+ );
1182
+ return result.changes === 1 ? "claimed" : false;
1183
+ });
1184
+ }
1185
+
1186
+ /** Claim and rewrite one proved legacy workflow source queue row atomically. */
1187
+ claimLegacyWorkflowSourceRun(options: {
1188
+ runId: string;
1189
+ workflowName: string;
1190
+ oldWorkflowPath: string;
1191
+ workflowSourceRef: string;
1192
+ runnerId: string;
1193
+ claimToken: string;
1194
+ leaseMs: number;
1195
+ now?: string;
1196
+ }): boolean {
1197
+ validateRunId(options.runId);
1198
+ validateKey(options.workflowName, "workflow name");
1199
+ validateKey(options.oldWorkflowPath, "legacy workflow path");
1200
+ validateKey(options.workflowSourceRef, "workflow source ref");
1201
+ validateKey(options.runnerId, "runner id");
1202
+ validateKey(options.claimToken, "claim token");
1203
+ validateDuration(options.leaseMs, "leaseMs");
1204
+ const now = validTimestamp(options.now);
1205
+ const nowMs = epoch(now);
1206
+ const expiresAt = nowMs + options.leaseMs;
1207
+ return this.transaction(() => {
1208
+ const row = this.database
1209
+ .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1210
+ .get(options.runId) as WorkflowRunQueueRow | undefined;
1211
+ if (row === undefined || row.status === "done") return false;
1212
+ const sourceMatches =
1213
+ row.workflow_path === options.oldWorkflowPath ||
1214
+ row.workflow_path === options.workflowSourceRef;
1215
+ const claimable =
1216
+ row.status === "parked" ||
1217
+ (row.status === "claimed" &&
1218
+ row.claim_token === options.claimToken &&
1219
+ row.claim_expires_at !== null &&
1220
+ row.claim_expires_at > nowMs) ||
1221
+ (row.status === "claimed" &&
1222
+ row.claim_expires_at !== null &&
1223
+ row.claim_expires_at <= nowMs);
1224
+ if (!sourceMatches || !claimable) return false;
1225
+ const result = this.database
1226
+ .prepare(
1227
+ `UPDATE workflow_run_queue
1228
+ SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
1229
+ claim_token = ?, claim_expires_at = ?, updated_at = ?
1230
+ WHERE run_id = ? AND status != 'done'`,
1231
+ )
1232
+ .run(
1233
+ options.workflowName,
1234
+ options.workflowSourceRef,
1235
+ options.runnerId,
1236
+ options.claimToken,
1237
+ expiresAt,
1238
+ now,
1239
+ options.runId,
1240
+ );
1241
+ return result.changes === 1;
1242
+ });
1243
+ }
1244
+
1071
1245
  /** Append a run lifecycle transition to the event feed. */
1072
1246
  recordRunEvent(options: {
1073
1247
  runId: string;
@@ -1117,37 +1291,141 @@ export class SqliteControllerStore implements ControllerStore {
1117
1291
  }));
1118
1292
  }
1119
1293
 
1120
- /** The highest run event seq in the feed; 0 when empty. */
1121
- latestRunEventSeq(): number {
1122
- const row = this.database.prepare("SELECT MAX(seq) AS latest FROM run_events").get() as {
1123
- latest: number | null;
1124
- };
1125
- return row.latest ?? 0;
1126
- }
1127
-
1128
- /** The last run event this session was told about; 0 before any sync. */
1129
- getSessionWatermark(sessionId: string): number {
1130
- validateKey(sessionId, "session id");
1294
+ enqueueWorkflowNotification(options: {
1295
+ runId: string;
1296
+ nodeId: string;
1297
+ attemptId: string;
1298
+ notificationIndex: number;
1299
+ targetSessionId: string;
1300
+ kind: "progress" | "final";
1301
+ content: string;
1302
+ notificationId?: string;
1303
+ now?: string;
1304
+ }): WorkflowNotificationRecord {
1305
+ validateRunId(options.runId);
1306
+ validateKey(options.nodeId, "workflow node id");
1307
+ validateKey(options.attemptId, "workflow attempt id");
1308
+ if (!Number.isSafeInteger(options.notificationIndex) || options.notificationIndex <= 0) {
1309
+ throw new Error("Workflow notification index must be a positive safe integer");
1310
+ }
1311
+ validateKey(options.targetSessionId, "target session id");
1312
+ if (options.content.trim().length === 0 || options.content.length > MAX_EVENT_BYTES) {
1313
+ throw new Error(`Workflow notification content must be 1-${MAX_EVENT_BYTES} characters`);
1314
+ }
1315
+ const notificationId = options.notificationId ?? randomUUID();
1316
+ validateKey(notificationId, "notification id");
1317
+ this.database
1318
+ .prepare(
1319
+ `INSERT INTO workflow_notifications (
1320
+ notification_id, run_id, node_id, attempt_id, notification_index,
1321
+ target_session_id, kind, content, created_at, delivered_at
1322
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
1323
+ ON CONFLICT(run_id, node_id, notification_index) DO NOTHING`,
1324
+ )
1325
+ .run(
1326
+ notificationId,
1327
+ options.runId,
1328
+ options.nodeId,
1329
+ options.attemptId,
1330
+ options.notificationIndex,
1331
+ options.targetSessionId,
1332
+ options.kind,
1333
+ options.content.trim(),
1334
+ validTimestamp(options.now),
1335
+ );
1131
1336
  const row = this.database
1132
- .prepare("SELECT last_event_seq FROM session_watermarks WHERE session_id = ?")
1133
- .get(sessionId) as { last_event_seq: number } | undefined;
1134
- return row?.last_event_seq ?? 0;
1337
+ .prepare(
1338
+ "SELECT * FROM workflow_notifications WHERE run_id = ? AND node_id = ? AND notification_index = ?",
1339
+ )
1340
+ .get(options.runId, options.nodeId, options.notificationIndex) as WorkflowNotificationRow;
1341
+ return workflowNotificationFromRow(row);
1135
1342
  }
1136
1343
 
1137
- setSessionWatermark(sessionId: string, seq: number, now?: string): void {
1138
- validateKey(sessionId, "session id");
1139
- if (!Number.isSafeInteger(seq) || seq < 0) {
1140
- throw new Error(`Invalid run event watermark: ${seq}`);
1344
+ claimPendingWorkflowNotifications(options: {
1345
+ targetSessionId: string;
1346
+ claimToken: string;
1347
+ leaseMs: number;
1348
+ limit?: number;
1349
+ now?: string;
1350
+ }): WorkflowNotificationRecord[] {
1351
+ validateKey(options.targetSessionId, "target session id");
1352
+ validateKey(options.claimToken, "notification claim token");
1353
+ validateDuration(options.leaseMs, "notification leaseMs");
1354
+ const limit = options.limit ?? 20;
1355
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
1356
+ throw new Error("Workflow notification limit must be an integer from 1 through 100");
1141
1357
  }
1142
- this.database
1358
+ const now = validTimestamp(options.now);
1359
+ const nowMs = epoch(now);
1360
+ return this.transaction(() => {
1361
+ const ids = this.database
1362
+ .prepare(
1363
+ `SELECT notification_id FROM workflow_notifications
1364
+ WHERE target_session_id = ? AND delivered_at IS NULL
1365
+ AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)
1366
+ ORDER BY created_at, notification_id LIMIT ?`,
1367
+ )
1368
+ .all(options.targetSessionId, nowMs, limit) as { notification_id: string }[];
1369
+ if (ids.length === 0) return [];
1370
+ const placeholders = ids.map(() => "?").join(", ");
1371
+ this.database
1372
+ .prepare(
1373
+ `UPDATE workflow_notifications
1374
+ SET delivery_claim_token = ?, delivery_claim_expires_at = ?
1375
+ WHERE notification_id IN (${placeholders}) AND delivered_at IS NULL
1376
+ AND (delivery_claim_expires_at IS NULL OR delivery_claim_expires_at <= ?)`,
1377
+ )
1378
+ .run(
1379
+ options.claimToken,
1380
+ nowMs + options.leaseMs,
1381
+ ...ids.map((row) => row.notification_id),
1382
+ nowMs,
1383
+ );
1384
+ const rows = this.database
1385
+ .prepare(
1386
+ `SELECT * FROM workflow_notifications WHERE delivery_claim_token = ?
1387
+ ORDER BY created_at, notification_id`,
1388
+ )
1389
+ .all(options.claimToken) as WorkflowNotificationRow[];
1390
+ return rows.map(workflowNotificationFromRow);
1391
+ });
1392
+ }
1393
+
1394
+ markWorkflowNotificationDelivered(options: {
1395
+ notificationId: string;
1396
+ targetSessionId: string;
1397
+ claimToken: string;
1398
+ now?: string;
1399
+ }): boolean {
1400
+ validateKey(options.notificationId, "notification id");
1401
+ validateKey(options.targetSessionId, "target session id");
1402
+ validateKey(options.claimToken, "notification claim token");
1403
+ const result = this.database
1143
1404
  .prepare(
1144
- `INSERT INTO session_watermarks (session_id, last_event_seq, updated_at)
1145
- VALUES (?, ?, ?)
1146
- ON CONFLICT(session_id) DO UPDATE SET
1147
- last_event_seq = MAX(session_watermarks.last_event_seq, excluded.last_event_seq),
1148
- updated_at = excluded.updated_at`,
1405
+ `UPDATE workflow_notifications
1406
+ SET delivered_at = ?, delivery_claim_token = NULL, delivery_claim_expires_at = NULL
1407
+ WHERE notification_id = ? AND target_session_id = ?
1408
+ AND delivery_claim_token = ? AND delivered_at IS NULL`,
1149
1409
  )
1150
- .run(sessionId, seq, validTimestamp(now));
1410
+ .run(
1411
+ validTimestamp(options.now),
1412
+ options.notificationId,
1413
+ options.targetSessionId,
1414
+ options.claimToken,
1415
+ );
1416
+ return result.changes === 1;
1417
+ }
1418
+
1419
+ setWorkflowRunOriginSession(runId: string, originSessionId: string): boolean {
1420
+ validateRunId(runId);
1421
+ validateKey(originSessionId, "origin session id");
1422
+ const result = this.database
1423
+ .prepare(
1424
+ `UPDATE workflow_run_queue SET origin_session_id = ?
1425
+ WHERE run_id = ? AND origin_session_id IS NULL`,
1426
+ )
1427
+ .run(originSessionId, runId);
1428
+ return result.changes === 1;
1151
1429
  }
1152
1430
 
1153
1431
  private requireWorkflowRun(runId: string): WorkflowRunQueueRecord {
@@ -1211,6 +1489,7 @@ const SCHEMA_SQL = `
1211
1489
  claim_token TEXT,
1212
1490
  claim_expires_at INTEGER,
1213
1491
  affinity_runner_id TEXT,
1492
+ origin_session_id TEXT,
1214
1493
  parent_run_id TEXT,
1215
1494
  created_at TEXT NOT NULL,
1216
1495
  updated_at TEXT NOT NULL
@@ -1233,11 +1512,24 @@ const SCHEMA_SQL = `
1233
1512
  );
1234
1513
  CREATE INDEX IF NOT EXISTS run_events_run ON run_events(run_id);
1235
1514
 
1236
- CREATE TABLE IF NOT EXISTS session_watermarks (
1237
- session_id TEXT PRIMARY KEY,
1238
- last_event_seq INTEGER NOT NULL CHECK (last_event_seq >= 0),
1239
- updated_at TEXT NOT NULL
1515
+ CREATE TABLE IF NOT EXISTS workflow_notifications (
1516
+ notification_id TEXT PRIMARY KEY,
1517
+ run_id TEXT NOT NULL,
1518
+ node_id TEXT NOT NULL,
1519
+ attempt_id TEXT NOT NULL,
1520
+ notification_index INTEGER NOT NULL CHECK (notification_index > 0),
1521
+ target_session_id TEXT NOT NULL,
1522
+ kind TEXT NOT NULL CHECK (kind IN ('progress', 'final')),
1523
+ content TEXT NOT NULL,
1524
+ created_at TEXT NOT NULL,
1525
+ delivery_claim_token TEXT,
1526
+ delivery_claim_expires_at INTEGER,
1527
+ delivered_at TEXT,
1528
+ UNIQUE (run_id, node_id, notification_index)
1240
1529
  );
1530
+ CREATE INDEX IF NOT EXISTS workflow_notifications_pending
1531
+ ON workflow_notifications(target_session_id, delivery_claim_expires_at, created_at)
1532
+ WHERE delivered_at IS NULL;
1241
1533
 
1242
1534
  CREATE TABLE IF NOT EXISTS effects (
1243
1535
  effect_key TEXT NOT NULL,
@@ -1329,17 +1621,35 @@ function workflowFromRow(row: WorkflowRow): ChildWorkflowRecord {
1329
1621
  };
1330
1622
  }
1331
1623
 
1624
+ function workflowNotificationFromRow(row: WorkflowNotificationRow): WorkflowNotificationRecord {
1625
+ return {
1626
+ notificationId: row.notification_id,
1627
+ runId: row.run_id,
1628
+ nodeId: row.node_id,
1629
+ attemptId: row.attempt_id,
1630
+ notificationIndex: row.notification_index,
1631
+ targetSessionId: row.target_session_id,
1632
+ kind: row.kind,
1633
+ content: row.content,
1634
+ createdAt: row.created_at,
1635
+ deliveryClaimExpiresAt:
1636
+ row.delivery_claim_expires_at === null ? null : iso(row.delivery_claim_expires_at),
1637
+ deliveredAt: row.delivered_at,
1638
+ };
1639
+ }
1640
+
1332
1641
  function workflowRunFromRow(row: WorkflowRunQueueRow): WorkflowRunQueueRecord {
1333
1642
  return {
1334
1643
  runId: row.run_id,
1335
- workflowRef: row.workflow_ref,
1336
- workflowPath: row.workflow_path,
1644
+ workflowName: row.workflow_ref,
1645
+ workflowSourceRef: row.workflow_path,
1337
1646
  input: parseStoredJson(row.input_json, "workflow run input"),
1338
1647
  status: row.status,
1339
1648
  runnerId: row.runner_id,
1340
1649
  claimToken: row.claim_token,
1341
1650
  claimExpiresAt: row.claim_expires_at === null ? null : iso(row.claim_expires_at),
1342
1651
  affinityRunnerId: row.affinity_runner_id,
1652
+ originSessionId: row.origin_session_id,
1343
1653
  parentRunId: row.parent_run_id,
1344
1654
  createdAt: row.created_at,
1345
1655
  updatedAt: row.updated_at,
@@ -4,6 +4,7 @@ import type {
4
4
  WorkflowDefinition,
5
5
  WorkflowRunResult,
6
6
  WorkflowRunStatus,
7
+ WorkflowSource,
7
8
  } from "../workflows/types.js";
8
9
  import type {
9
10
  ControllerWorkflowScheduler,
@@ -13,7 +14,7 @@ import type {
13
14
 
14
15
  export type ResolvedChildWorkflow = {
15
16
  workflow: WorkflowDefinition;
16
- workflowPath?: string;
17
+ workflowSource?: WorkflowSource;
17
18
  };
18
19
 
19
20
  export type WorkflowEngineSchedulerOptions = {
@@ -77,7 +78,9 @@ export class WorkflowEngineScheduler implements ControllerWorkflowScheduler {
77
78
  const promise = engine
78
79
  .run(resolved.workflow, request.input, {
79
80
  runId,
80
- ...(resolved.workflowPath !== undefined ? { workflowPath: resolved.workflowPath } : {}),
81
+ ...(resolved.workflowSource !== undefined
82
+ ? { workflowSource: resolved.workflowSource }
83
+ : {}),
81
84
  })
82
85
  .then((result) => {
83
86
  callCompletion(onComplete, resultFromRun(result));
@@ -33,6 +33,8 @@ export type ConversationStepExecutorOptions = {
33
33
  maxNudges?: number;
34
34
  /** Conversation linkage hooks, wired to the session recorder. */
35
35
  conversation?: ConversationHooks;
36
+ /** Called when the engine aborts a pending agent step. */
37
+ onAbort?: (contract: AgentStepRequest["contract"], reason: unknown) => void;
36
38
  };
37
39
 
38
40
  type PendingStep = {
@@ -61,6 +63,7 @@ export class ConversationStepExecutor implements AgentStepExecutor {
61
63
  private readonly sendPrompt: (delivery: PromptDelivery) => void;
62
64
  private readonly maxNudges: number;
63
65
  private readonly conversation: ConversationHooks | undefined;
66
+ private readonly onAbort: ConversationStepExecutorOptions["onAbort"];
64
67
  private pending: PendingStep | null = null;
65
68
  private streaming = false;
66
69
  private heldByUser = false;
@@ -69,6 +72,7 @@ export class ConversationStepExecutor implements AgentStepExecutor {
69
72
  this.sendPrompt = options.sendPrompt;
70
73
  this.maxNudges = options.maxNudges ?? DEFAULT_MAX_NUDGES;
71
74
  this.conversation = options.conversation;
75
+ this.onAbort = options.onAbort;
72
76
  }
73
77
 
74
78
  /** Track agent streaming state (wire to agent_start / agent_settled). */
@@ -121,8 +125,15 @@ export class ConversationStepExecutor implements AgentStepExecutor {
121
125
  return await new Promise<AgentStepSubmission>((resolve, reject) => {
122
126
  const onAbort = () => {
123
127
  const reason: unknown = signal.reason ?? new Error("Workflow step aborted");
128
+ if (this.pending?.request !== request) {
129
+ return;
130
+ }
124
131
  this.clearPending();
125
- reject(reason);
132
+ try {
133
+ this.onAbort?.(request.contract, reason);
134
+ } finally {
135
+ reject(reason);
136
+ }
126
137
  };
127
138
  signal.addEventListener("abort", onAbort, { once: true });
128
139
  let markCleared!: () => void;