@osolmaz/pi-workflows 0.4.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 (52) 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 +74 -80
  13. package/dist/extension/index.js.map +1 -1
  14. package/dist/host/runner.js +20 -1
  15. package/dist/host/runner.js.map +1 -1
  16. package/dist/render/graph-render.js +3 -0
  17. package/dist/render/graph-render.js.map +1 -1
  18. package/dist/workflows/definition.d.ts +2 -1
  19. package/dist/workflows/definition.js +9 -1
  20. package/dist/workflows/definition.js.map +1 -1
  21. package/dist/workflows/engine.d.ts +1 -0
  22. package/dist/workflows/engine.js +22 -0
  23. package/dist/workflows/engine.js.map +1 -1
  24. package/dist/workflows/index.d.ts +2 -2
  25. package/dist/workflows/index.js +1 -1
  26. package/dist/workflows/index.js.map +1 -1
  27. package/dist/workflows/migrate-sources.d.ts +1 -0
  28. package/dist/workflows/migrate-sources.js +4 -0
  29. package/dist/workflows/migrate-sources.js.map +1 -1
  30. package/dist/workflows/schema.d.ts +2 -1
  31. package/dist/workflows/schema.js +12 -0
  32. package/dist/workflows/schema.js.map +1 -1
  33. package/dist/workflows/store.js +3 -0
  34. package/dist/workflows/store.js.map +1 -1
  35. package/dist/workflows/types.d.ts +26 -1
  36. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
  37. package/docs/workflows.md +23 -4
  38. package/package.json +1 -1
  39. package/src/builtins/catalog.ts +12 -2
  40. package/src/builtins/monitor.workflow.ts +11 -28
  41. package/src/controllers/index.ts +1 -0
  42. package/src/controllers/sqlite.ts +225 -33
  43. package/src/extension/index.ts +76 -104
  44. package/src/host/runner.ts +20 -1
  45. package/src/render/graph-render.ts +3 -0
  46. package/src/workflows/definition.ts +11 -0
  47. package/src/workflows/engine.ts +26 -0
  48. package/src/workflows/index.ts +5 -0
  49. package/src/workflows/migrate-sources.ts +7 -0
  50. package/src/workflows/schema.ts +14 -0
  51. package/src/workflows/store.ts +3 -0
  52. package/src/workflows/types.ts +30 -0
@@ -0,0 +1,95 @@
1
+ ---
2
+ title: Route workflow reports to their starting session
3
+ author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
4
+ date: 2026-08-13
5
+ ---
6
+
7
+ # Route workflow reports to their starting session
8
+
9
+ Pi Workflows must not send one workflow's report into an unrelated conversation. A workflow started in one Pi session must report only to that session, even when another session or the standalone host executes part of the run.
10
+
11
+ ## Requirements
12
+
13
+ - Record the starting Pi session as the origin of each interactive workflow run.
14
+ - Keep execution events separate from user-facing notifications.
15
+ - Store each notification durably and address it to one Pi session.
16
+ - Deliver a notification only when its target session is open.
17
+ - Keep undelivered notifications until the target session opens again.
18
+ - Prevent unrelated sessions from claiming session-bound runs.
19
+ - Permit a detached host to execute a run without changing its report target.
20
+ - Give workflow authors a runtime-owned notification node instead of using an agent step to relay text.
21
+ - Migrate active path-based runs and existing queue rows without guessing their origin.
22
+ - Remove project-wide hidden-message broadcasts and the shared project watermark.
23
+
24
+ ## Data model
25
+
26
+ Queue records gain an optional `origin_session_id`. Interactive runs set it from `ctx.sessionManager.getSessionId()`. Controller-created detached runs leave it empty. A session runner can claim a record only when the origin is empty or matches its current session. The standalone host can claim either form.
27
+
28
+ A `workflow_notifications` table is the durable outbox:
29
+
30
+ | Field | Meaning |
31
+ | --------------------------- | --------------------------------------------------- |
32
+ | `notification_id` | Stable unique delivery ID |
33
+ | `run_id` | Workflow run that created the notification |
34
+ | `node_id` | Node that created it |
35
+ | `attempt_id` | Latest node attempt that requested it |
36
+ | `notification_index` | Stable one-based occurrence of this node in the run |
37
+ | `target_session_id` | Exact Pi session that may receive it |
38
+ | `kind` | `progress` or `final` |
39
+ | `content` | Bounded plain-text report |
40
+ | `created_at` | Creation time |
41
+ | `delivery_claim_token` | Current delivery owner, or null |
42
+ | `delivery_claim_expires_at` | Delivery lease deadline, or null |
43
+ | `delivered_at` | Delivery time, or null while pending |
44
+
45
+ `(run_id, node_id, notification_index)` is unique. A crash retry reuses the same logical index, even though it gets a new attempt ID. A later loop iteration gets the next index. Delivery polling claims pending rows with a short lease before it writes to a session. This prevents two Pi processes that open the same session from sending the same notification concurrently. If a process stops, another process can reclaim the row after the lease expires. A stable notification ID is included in the custom session entry so the same session can detect a delivery completed before a crash.
46
+
47
+ The existing `run_events` table remains an execution audit feed. It does not inject conversation messages. The obsolete `session_watermarks` table is no longer used.
48
+
49
+ ## Workflow API
50
+
51
+ Add a `notify(...)` node. Its message callback returns plain text. The engine calls a host-provided notification sink and persists the resulting receipt as the node output. The node does not ask the model to relay a message and does not depend on the runner's active conversation.
52
+
53
+ The built-in monitor uses `notify` for progress and final reports. Its check agent still decides whether a report is needed, but delivery always targets the origin session.
54
+
55
+ ## Migration
56
+
57
+ For each active run with a queue row and no origin:
58
+
59
+ 1. Read `session/binding.json` from its run bundle.
60
+ 2. If the binding exists, set `origin_session_id` to its `piSessionId`.
61
+ 3. If no binding exists, keep the run detached.
62
+ 4. Never infer an origin from the working directory.
63
+
64
+ Existing lifecycle events are not converted into notifications. This prevents old completion events from appearing in unrelated sessions after the update.
65
+
66
+ ## Non-goals
67
+
68
+ - Do not modify Pi core or Pi session files directly.
69
+ - Do not broadcast workflow reports to all sessions in a project.
70
+ - Do not keep compatibility aliases for the shared project watermark behavior.
71
+ - Do not make closed Pi sessions execute workflow steps.
72
+
73
+ ## Acceptance criteria
74
+
75
+ - Two open Pi sessions in the same directory cannot receive each other's workflow reports.
76
+ - A report executed by the standalone host is delivered after the origin session opens.
77
+ - A report remains pending while its origin session is closed.
78
+ - A session-bound run is not claimed by a different interactive session.
79
+ - Duplicate polling and process restart do not duplicate a delivered notification.
80
+ - Monitor progress and final reports use the notification outbox.
81
+ - Existing active runs gain their recorded binding as origin when available.
82
+ - Pi starts with the released package in OnurPi.
83
+
84
+ ## Verification
85
+
86
+ - `npm run check`
87
+ - `npm run test:e2e`
88
+ - `npm run slophammer`
89
+ - `git diff --check`
90
+ - `npx -y @simpledoc/simpledoc check`
91
+ - `cargo fmt --check --manifest-path tui/Cargo.toml`
92
+ - `cargo clippy --manifest-path tui/Cargo.toml --all-targets --all-features -- -D warnings`
93
+ - `cargo test --manifest-path tui/Cargo.toml`
94
+ - `pi-reviewer --base main`
95
+ - Start two isolated RPC Pi sessions in one directory and prove that only the matching session receives a targeted notification.
package/docs/workflows.md CHANGED
@@ -117,10 +117,9 @@ Conversation nodes execute in headless `pi --mode rpc` children that load a
117
117
  small bridge extension; the model sees the same `workflow` tool contract as an
118
118
  in-session run. The host is a foreground process: start it in a terminal and
119
119
  stop it with Ctrl-C. A second host for the same project refuses to start, and
120
- a host that dies has its orphaned children reaped by the next one. While the
121
- host works, any open Pi session stays current: a per-session watermark over
122
- the shared run event feed produces catch-up summaries and quiet context
123
- updates.
120
+ a host that dies has its orphaned children reaped by the next one. While the host works, reports enter a durable outbox addressed to the Pi
121
+ session that started the run. They remain pending while that session is closed
122
+ and never enter another conversation in the same project.
124
123
 
125
124
  ## Node types
126
125
 
@@ -165,6 +164,26 @@ Runs a TypeScript function inline. Use it for pure data shaping.
165
164
  compute({ run: ({ outputs }) => ({ merged: { ...outputs } }) });
166
165
  ```
167
166
 
167
+ ### notify
168
+
169
+ Queues a durable plain-text message for the Pi session that started the run.
170
+ The runner does not write the message into its own conversation. A standalone
171
+ host can execute this node, and the message still waits for the origin session.
172
+
173
+ ```typescript
174
+ notify({
175
+ kind: "progress", // or "final"
176
+ message: ({ outputs }) => String(outputs.check),
177
+ });
178
+ ```
179
+
180
+ The runtime gives each logical execution of a notification node a stable index.
181
+ A retry after a crash reuses that index, so it cannot queue the same message
182
+ twice. Re-entering the node later in a loop gets the next index. A workflow
183
+ with a `notify` node must be an interactive queued run with an origin session.
184
+ Controller child workflows are detached and must report through their
185
+ controller resource instead.
186
+
168
187
  ### action
169
188
 
170
189
  Performs a side effect. Two forms exist. The function form runs arbitrary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osolmaz/pi-workflows",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Workflow and controller runtime with a live terminal viewer for the pi coding agent",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -4,12 +4,12 @@ import monitorWorkflow from "./monitor.workflow.js";
4
4
  export const builtinWorkflowCatalog = new BuiltinWorkflowCatalog([
5
5
  {
6
6
  id: "monitor",
7
- revision: "1",
7
+ revision: "2",
8
8
  definition: monitorWorkflow,
9
9
  legacySources: [
10
10
  {
11
11
  workflowHash: "7a22158da94d18ec1c9fe42e70d72017a4e0620d5e5142ae839d0cd6eea55c06",
12
- revision: "1",
12
+ revision: "2",
13
13
  pathSuffixes: [
14
14
  "/src/builtins/monitor.workflow.ts",
15
15
  "/dist/builtins/monitor.workflow.js",
@@ -17,6 +17,16 @@ export const builtinWorkflowCatalog = new BuiltinWorkflowCatalog([
17
17
  "/dist/workflows/monitor.workflow.js",
18
18
  ],
19
19
  },
20
+ {
21
+ workflowHash: "352fc09c88922c7375281b52f049c1039d05441ce37f2082f5ff07fea66d5318",
22
+ revision: "2",
23
+ pathSuffixes: ["/dist/builtins/monitor.workflow.js"],
24
+ },
25
+ {
26
+ workflowHash: "dc601e2323a8213f5d52fa555e804ae8ed0846f809b3a1e9e5073a3c9c3a114e",
27
+ revision: "2",
28
+ pathSuffixes: ["/dist/builtins/monitor.workflow.js"],
29
+ },
20
30
  ],
21
31
  },
22
32
  ]);
@@ -1,4 +1,4 @@
1
- import { agent, compute, defineWorkflow, shell } from "../workflows/definition.js";
1
+ import { agent, compute, defineWorkflow, notify, shell } from "../workflows/definition.js";
2
2
  import type { WorkflowNodeContext } from "../workflows/types.js";
3
3
 
4
4
  const MIN_INTERVAL_MINUTES = 1;
@@ -150,22 +150,9 @@ function validateCheck(output: unknown): MonitorCheck {
150
150
  };
151
151
  }
152
152
 
153
- function validateReportAck(output: unknown): { reported: true } {
154
- const value = requireRecord(output, "report acknowledgement");
155
- if (value.reported !== true) {
156
- throw new Error("report acknowledgement must set reported to true");
157
- }
158
- return { reported: true };
159
- }
160
-
161
- function reportPrompt(outputs: Record<string, unknown>): string {
153
+ function reportMessage(outputs: Record<string, unknown>): string {
162
154
  const check = outputs.check as MonitorCheck;
163
- return [
164
- "Write one concise normal assistant message to the user with this monitoring update:",
165
- check.report ?? check.observation,
166
- "Do not add unrelated detail.",
167
- "After writing the update, submit the acknowledgement required by the workflow step contract.",
168
- ].join("\n\n");
155
+ return check.report ?? check.observation;
169
156
  }
170
157
 
171
158
  export default defineWorkflow({
@@ -232,19 +219,15 @@ export default defineWorkflow({
232
219
  '{ "route": "continue_quiet" | "continue_report" | "stop_quiet" | "stop_report", "observation": "current factual state", "report": "required for report routes", "reason": "short reason" }',
233
220
  validate: (output) => validateCheck(output),
234
221
  }),
235
- report_continue: agent({
236
- statusDetail: "reporting monitor update",
237
- timeoutMs: agentTimeoutMs,
238
- prompt: ({ outputs }) => reportPrompt(outputs),
239
- expectedOutput: '{ "reported": true }',
240
- validate: (output) => validateReportAck(output),
222
+ report_continue: notify({
223
+ statusDetail: "queueing monitor update",
224
+ message: ({ outputs }) => reportMessage(outputs),
225
+ kind: "progress",
241
226
  }),
242
- report_stop: agent({
243
- statusDetail: "reporting final monitor update",
244
- timeoutMs: agentTimeoutMs,
245
- prompt: ({ outputs }) => reportPrompt(outputs),
246
- expectedOutput: '{ "reported": true }',
247
- validate: (output) => validateReportAck(output),
227
+ report_stop: notify({
228
+ statusDetail: "queueing final monitor update",
229
+ message: ({ outputs }) => reportMessage(outputs),
230
+ kind: "final",
248
231
  }),
249
232
  sleep: shell({
250
233
  statusDetail: "waiting for next monitor check",
@@ -23,6 +23,7 @@ export { createResultHelpers, requeue, requeueAfter, settled } from "./results.j
23
23
  export {
24
24
  SqliteControllerStore,
25
25
  type RunEventRecord,
26
+ type WorkflowNotificationRecord,
26
27
  type WorkflowRunQueueRecord,
27
28
  } from "./sqlite.js";
28
29
  export {
@@ -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,