@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.
- package/README.md +8 -5
- package/dist/builtins/catalog.js +12 -2
- package/dist/builtins/catalog.js.map +1 -1
- package/dist/builtins/monitor.workflow.d.ts +2 -2
- package/dist/builtins/monitor.workflow.js +11 -27
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/controllers/index.d.ts +1 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +43 -5
- package/dist/controllers/sqlite.js +128 -32
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.js +74 -80
- package/dist/extension/index.js.map +1 -1
- package/dist/host/runner.js +20 -1
- package/dist/host/runner.js.map +1 -1
- package/dist/render/graph-render.js +3 -0
- package/dist/render/graph-render.js.map +1 -1
- package/dist/workflows/definition.d.ts +2 -1
- package/dist/workflows/definition.js +9 -1
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +1 -0
- package/dist/workflows/engine.js +22 -0
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +2 -2
- package/dist/workflows/index.js +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/migrate-sources.d.ts +1 -0
- package/dist/workflows/migrate-sources.js +4 -0
- package/dist/workflows/migrate-sources.js.map +1 -1
- package/dist/workflows/schema.d.ts +2 -1
- package/dist/workflows/schema.js +12 -0
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.js +3 -0
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +26 -1
- package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
- package/docs/workflows.md +23 -4
- package/package.json +1 -1
- package/src/builtins/catalog.ts +12 -2
- package/src/builtins/monitor.workflow.ts +11 -28
- package/src/controllers/index.ts +1 -0
- package/src/controllers/sqlite.ts +225 -33
- package/src/extension/index.ts +76 -104
- package/src/host/runner.ts +20 -1
- package/src/render/graph-render.ts +3 -0
- package/src/workflows/definition.ts +11 -0
- package/src/workflows/engine.ts +26 -0
- package/src/workflows/index.ts +5 -0
- package/src/workflows/migrate-sources.ts +7 -0
- package/src/workflows/schema.ts +14 -0
- package/src/workflows/store.ts +3 -0
- 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
|
-
|
|
122
|
-
|
|
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
package/src/builtins/catalog.ts
CHANGED
|
@@ -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: "
|
|
7
|
+
revision: "2",
|
|
8
8
|
definition: monitorWorkflow,
|
|
9
9
|
legacySources: [
|
|
10
10
|
{
|
|
11
11
|
workflowHash: "7a22158da94d18ec1c9fe42e70d72017a4e0620d5e5142ae839d0cd6eea55c06",
|
|
12
|
-
revision: "
|
|
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
|
|
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:
|
|
236
|
-
statusDetail: "
|
|
237
|
-
|
|
238
|
-
|
|
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:
|
|
243
|
-
statusDetail: "
|
|
244
|
-
|
|
245
|
-
|
|
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",
|
package/src/controllers/index.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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(
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
1263
|
-
|
|
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(
|
|
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
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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,
|