@osolmaz/pi-workflows 0.4.0 → 0.5.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 (57) hide show
  1. package/README.md +8 -5
  2. package/dist/builtins/catalog.js +12 -2
  3. package/dist/builtins/catalog.js.map +1 -1
  4. package/dist/builtins/monitor.workflow.d.ts +2 -2
  5. package/dist/builtins/monitor.workflow.js +11 -27
  6. package/dist/builtins/monitor.workflow.js.map +1 -1
  7. package/dist/controllers/index.d.ts +1 -1
  8. package/dist/controllers/index.js.map +1 -1
  9. package/dist/controllers/sqlite.d.ts +43 -5
  10. package/dist/controllers/sqlite.js +128 -32
  11. package/dist/controllers/sqlite.js.map +1 -1
  12. package/dist/extension/index.js +109 -94
  13. package/dist/extension/index.js.map +1 -1
  14. package/dist/extension/widget.d.ts +5 -2
  15. package/dist/extension/widget.js +52 -17
  16. package/dist/extension/widget.js.map +1 -1
  17. package/dist/host/runner.js +20 -1
  18. package/dist/host/runner.js.map +1 -1
  19. package/dist/render/graph-render.js +3 -0
  20. package/dist/render/graph-render.js.map +1 -1
  21. package/dist/workflows/definition.d.ts +2 -1
  22. package/dist/workflows/definition.js +9 -1
  23. package/dist/workflows/definition.js.map +1 -1
  24. package/dist/workflows/engine.d.ts +1 -0
  25. package/dist/workflows/engine.js +22 -0
  26. package/dist/workflows/engine.js.map +1 -1
  27. package/dist/workflows/index.d.ts +2 -2
  28. package/dist/workflows/index.js +1 -1
  29. package/dist/workflows/index.js.map +1 -1
  30. package/dist/workflows/migrate-sources.d.ts +1 -0
  31. package/dist/workflows/migrate-sources.js +4 -0
  32. package/dist/workflows/migrate-sources.js.map +1 -1
  33. package/dist/workflows/schema.d.ts +2 -1
  34. package/dist/workflows/schema.js +12 -0
  35. package/dist/workflows/schema.js.map +1 -1
  36. package/dist/workflows/store.js +3 -0
  37. package/dist/workflows/store.js.map +1 -1
  38. package/dist/workflows/types.d.ts +26 -1
  39. package/docs/plans/2026-08-13-responsive-workflow-widget-plan.md +64 -0
  40. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
  41. package/docs/workflows.md +23 -4
  42. package/package.json +3 -1
  43. package/src/builtins/catalog.ts +12 -2
  44. package/src/builtins/monitor.workflow.ts +11 -28
  45. package/src/controllers/index.ts +1 -0
  46. package/src/controllers/sqlite.ts +225 -33
  47. package/src/extension/index.ts +123 -125
  48. package/src/extension/widget.ts +83 -20
  49. package/src/host/runner.ts +20 -1
  50. package/src/render/graph-render.ts +3 -0
  51. package/src/workflows/definition.ts +11 -0
  52. package/src/workflows/engine.ts +26 -0
  53. package/src/workflows/index.ts +5 -0
  54. package/src/workflows/migrate-sources.ts +7 -0
  55. package/src/workflows/schema.ts +14 -0
  56. package/src/workflows/store.ts +3 -0
  57. package/src/workflows/types.ts +30 -0
@@ -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;
@@ -111,11 +112,42 @@ export type WorkflowRunQueueRecord = {
111
112
  claimToken: string | null;
112
113
  claimExpiresAt: string | null;
113
114
  affinityRunnerId: string | null;
115
+ /** Pi session that owns delivery and interactive execution, or null for detached runs. */
116
+ originSessionId: string | null;
114
117
  parentRunId: string | null;
115
118
  createdAt: string;
116
119
  updatedAt: string;
117
120
  };
118
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
+
119
151
  type RunEventRow = {
120
152
  seq: number;
121
153
  recorded_at: string;
@@ -774,6 +806,13 @@ export class SqliteControllerStore implements ControllerStore {
774
806
  .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
775
807
  .run(CONTROLLER_STORE_SCHEMA);
776
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");
777
816
  });
778
817
  }
779
818
 
@@ -875,6 +914,7 @@ export class SqliteControllerStore implements ControllerStore {
875
914
  claimToken: string;
876
915
  leaseMs: number;
877
916
  affinityRunnerId?: string;
917
+ originSessionId?: string;
878
918
  parentRunId?: string;
879
919
  now?: string;
880
920
  }): WorkflowRunQueueRecord {
@@ -884,6 +924,9 @@ export class SqliteControllerStore implements ControllerStore {
884
924
  validateKey(options.runnerId, "runner id");
885
925
  validateKey(options.claimToken, "claim token");
886
926
  validateDuration(options.leaseMs, "leaseMs");
927
+ if (options.originSessionId !== undefined) {
928
+ validateKey(options.originSessionId, "origin session id");
929
+ }
887
930
  const inputJson = canonicalJson(options.input ?? null, "workflow run input");
888
931
  validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
889
932
  const now = validTimestamp(options.now);
@@ -893,8 +936,8 @@ export class SqliteControllerStore implements ControllerStore {
893
936
  `INSERT INTO workflow_run_queue (
894
937
  run_id, workflow_ref, workflow_path, input_json, status,
895
938
  runner_id, claim_token, claim_expires_at, affinity_runner_id,
896
- parent_run_id, created_at, updated_at
897
- ) VALUES (?, ?, ?, ?, 'claimed', ?, ?, ?, ?, ?, ?, ?)`,
939
+ origin_session_id, parent_run_id, created_at, updated_at
940
+ ) VALUES (?, ?, ?, ?, 'claimed', ?, ?, ?, ?, ?, ?, ?, ?)`,
898
941
  )
899
942
  .run(
900
943
  options.runId,
@@ -905,6 +948,7 @@ export class SqliteControllerStore implements ControllerStore {
905
948
  options.claimToken,
906
949
  expiresAt,
907
950
  options.affinityRunnerId ?? options.runnerId,
951
+ options.originSessionId ?? null,
908
952
  options.parentRunId ?? null,
909
953
  now,
910
954
  now,
@@ -945,11 +989,14 @@ export class SqliteControllerStore implements ControllerStore {
945
989
  claimToken: string;
946
990
  leaseMs: number;
947
991
  excludeRunIds?: string[];
992
+ /** Interactive sessions provide their id; detached hosts omit it. */
993
+ sessionId?: string;
948
994
  now?: string;
949
995
  }): WorkflowRunQueueRecord | undefined {
950
996
  validateKey(options.runnerId, "runner id");
951
997
  validateKey(options.claimToken, "claim token");
952
998
  validateDuration(options.leaseMs, "leaseMs");
999
+ if (options.sessionId !== undefined) validateKey(options.sessionId, "session id");
953
1000
  const now = validTimestamp(options.now);
954
1001
  const nowMs = epoch(now);
955
1002
  const expiresAt = nowMs + options.leaseMs;
@@ -959,6 +1006,10 @@ export class SqliteControllerStore implements ControllerStore {
959
1006
  }
960
1007
  const exclusion =
961
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 = ?)";
962
1013
  const claimable = `(
963
1014
  status = 'parked'
964
1015
  OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
@@ -967,13 +1018,18 @@ export class SqliteControllerStore implements ControllerStore {
967
1018
  const candidate = this.database
968
1019
  .prepare(
969
1020
  `SELECT run_id FROM workflow_run_queue
970
- WHERE ${claimable} ${exclusion}
1021
+ WHERE ${claimable} ${exclusion} ${sessionFilter}
971
1022
  ORDER BY
972
1023
  CASE WHEN affinity_runner_id = ? THEN 0 ELSE 1 END,
973
1024
  created_at ASC
974
1025
  LIMIT 1`,
975
1026
  )
976
- .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;
977
1033
  if (candidate === undefined) {
978
1034
  return undefined;
979
1035
  }
@@ -1235,37 +1291,141 @@ export class SqliteControllerStore implements ControllerStore {
1235
1291
  }));
1236
1292
  }
1237
1293
 
1238
- /** The highest run event seq in the feed; 0 when empty. */
1239
- latestRunEventSeq(): number {
1240
- const row = this.database.prepare("SELECT MAX(seq) AS latest FROM run_events").get() as {
1241
- latest: number | null;
1242
- };
1243
- return row.latest ?? 0;
1244
- }
1245
-
1246
- /** The last run event this session was told about; 0 before any sync. */
1247
- getSessionWatermark(sessionId: string): number {
1248
- 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
+ );
1249
1336
  const row = this.database
1250
- .prepare("SELECT last_event_seq FROM session_watermarks WHERE session_id = ?")
1251
- .get(sessionId) as { last_event_seq: number } | undefined;
1252
- 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);
1253
1342
  }
1254
1343
 
1255
- setSessionWatermark(sessionId: string, seq: number, now?: string): void {
1256
- validateKey(sessionId, "session id");
1257
- if (!Number.isSafeInteger(seq) || seq < 0) {
1258
- 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");
1259
1357
  }
1260
- 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
1404
+ .prepare(
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`,
1409
+ )
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
1261
1423
  .prepare(
1262
- `INSERT INTO session_watermarks (session_id, last_event_seq, updated_at)
1263
- VALUES (?, ?, ?)
1264
- ON CONFLICT(session_id) DO UPDATE SET
1265
- last_event_seq = MAX(session_watermarks.last_event_seq, excluded.last_event_seq),
1266
- updated_at = excluded.updated_at`,
1424
+ `UPDATE workflow_run_queue SET origin_session_id = ?
1425
+ WHERE run_id = ? AND origin_session_id IS NULL`,
1267
1426
  )
1268
- .run(sessionId, seq, validTimestamp(now));
1427
+ .run(originSessionId, runId);
1428
+ return result.changes === 1;
1269
1429
  }
1270
1430
 
1271
1431
  private requireWorkflowRun(runId: string): WorkflowRunQueueRecord {
@@ -1329,6 +1489,7 @@ const SCHEMA_SQL = `
1329
1489
  claim_token TEXT,
1330
1490
  claim_expires_at INTEGER,
1331
1491
  affinity_runner_id TEXT,
1492
+ origin_session_id TEXT,
1332
1493
  parent_run_id TEXT,
1333
1494
  created_at TEXT NOT NULL,
1334
1495
  updated_at TEXT NOT NULL
@@ -1351,11 +1512,24 @@ const SCHEMA_SQL = `
1351
1512
  );
1352
1513
  CREATE INDEX IF NOT EXISTS run_events_run ON run_events(run_id);
1353
1514
 
1354
- CREATE TABLE IF NOT EXISTS session_watermarks (
1355
- session_id TEXT PRIMARY KEY,
1356
- last_event_seq INTEGER NOT NULL CHECK (last_event_seq >= 0),
1357
- 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)
1358
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;
1359
1533
 
1360
1534
  CREATE TABLE IF NOT EXISTS effects (
1361
1535
  effect_key TEXT NOT NULL,
@@ -1447,6 +1621,23 @@ function workflowFromRow(row: WorkflowRow): ChildWorkflowRecord {
1447
1621
  };
1448
1622
  }
1449
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
+
1450
1641
  function workflowRunFromRow(row: WorkflowRunQueueRow): WorkflowRunQueueRecord {
1451
1642
  return {
1452
1643
  runId: row.run_id,
@@ -1458,6 +1649,7 @@ function workflowRunFromRow(row: WorkflowRunQueueRow): WorkflowRunQueueRecord {
1458
1649
  claimToken: row.claim_token,
1459
1650
  claimExpiresAt: row.claim_expires_at === null ? null : iso(row.claim_expires_at),
1460
1651
  affinityRunnerId: row.affinity_runner_id,
1652
+ originSessionId: row.origin_session_id,
1461
1653
  parentRunId: row.parent_run_id,
1462
1654
  createdAt: row.created_at,
1463
1655
  updatedAt: row.updated_at,