@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.
- package/README.md +11 -7
- package/dist/builtins/catalog.d.ts +2 -0
- package/dist/builtins/catalog.js +32 -0
- package/dist/builtins/catalog.js.map +1 -0
- package/dist/builtins/monitor.workflow.d.ts +2 -2
- package/dist/builtins/monitor.workflow.js +25 -25
- 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 +70 -9
- package/dist/controllers/sqlite.js +209 -35
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
- package/dist/controllers/workflow-engine-scheduler.js +3 -1
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
- package/dist/extension/executor.d.ts +3 -0
- package/dist/extension/executor.js +11 -1
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.js +157 -108
- package/dist/extension/index.js.map +1 -1
- package/dist/host/runner.d.ts +1 -0
- package/dist/host/runner.js +68 -20
- 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/catalog.d.ts +43 -0
- package/dist/workflows/catalog.js +79 -0
- package/dist/workflows/catalog.js.map +1 -0
- 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 +6 -6
- package/dist/workflows/engine.js +93 -33
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +3 -3
- package/dist/workflows/index.js +2 -2
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/loader.d.ts +18 -16
- package/dist/workflows/loader.js +58 -23
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/migrate-sources.d.ts +42 -0
- package/dist/workflows/migrate-sources.js +133 -0
- package/dist/workflows/migrate-sources.js.map +1 -0
- package/dist/workflows/schema.d.ts +2 -1
- package/dist/workflows/schema.js +14 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.js +5 -2
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +44 -5
- package/docs/development.md +5 -3
- package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
- package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
- package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
- package/docs/run-bundles.md +22 -4
- package/docs/workflows.md +57 -14
- package/package.json +1 -1
- package/src/builtins/catalog.ts +32 -0
- package/src/builtins/monitor.workflow.ts +33 -26
- package/src/controllers/index.ts +1 -0
- package/src/controllers/sqlite.ts +353 -43
- package/src/controllers/workflow-engine-scheduler.ts +5 -2
- package/src/extension/executor.ts +12 -1
- package/src/extension/index.ts +181 -140
- package/src/host/runner.ts +78 -20
- package/src/render/graph-render.ts +3 -0
- package/src/workflows/catalog.ts +135 -0
- package/src/workflows/definition.ts +11 -0
- package/src/workflows/engine.ts +128 -53
- package/src/workflows/index.ts +7 -0
- package/src/workflows/loader.ts +70 -26
- package/src/workflows/migrate-sources.ts +174 -0
- package/src/workflows/schema.ts +16 -1
- package/src/workflows/store.ts +5 -2
- 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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
870
|
-
|
|
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.
|
|
881
|
-
validateKey(options.
|
|
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.
|
|
900
|
-
options.
|
|
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(
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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(
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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(
|
|
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
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
-
|
|
1336
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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;
|