@osolmaz/pi-workflows 0.13.1 → 0.13.3
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 +9 -7
- package/dist/builtins/autodoc.workflow.d.ts +5 -5
- package/dist/builtins/autoimplement.workflow.d.ts +69 -61
- package/dist/builtins/autoimplement.workflow.js +47 -8
- package/dist/builtins/autoimplement.workflow.js.map +1 -1
- package/dist/builtins/autoplan.workflow.d.ts +11 -5
- package/dist/builtins/autoplan.workflow.js +68 -32
- package/dist/builtins/autoplan.workflow.js.map +1 -1
- package/dist/builtins/catalog.js +4 -4
- package/dist/builtins/change-verification.workflow.d.ts +1 -1
- package/dist/builtins/plain-summary.workflow.d.ts +1 -1
- package/dist/builtins/plain-summary.workflow.js +35 -24
- package/dist/builtins/plain-summary.workflow.js.map +1 -1
- package/dist/builtins/plan-approval.workflow.d.ts +1 -1
- package/dist/builtins/plan-change.workflow.d.ts +25 -23
- package/dist/builtins/sanity-check.workflow.d.ts +3 -3
- package/dist/builtins/sanity-check.workflow.js +19 -22
- package/dist/builtins/sanity-check.workflow.js.map +1 -1
- package/dist/builtins/workspace-preparation.workflow.d.ts +1 -1
- package/dist/controllers/index.d.ts +2 -2
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.js +88 -61
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/types.d.ts +34 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +5 -1
- package/dist/controllers/workflow-engine-scheduler.js +83 -1
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
- package/dist/controllers/workflows.d.ts +8 -1
- package/dist/controllers/workflows.js +36 -0
- package/dist/controllers/workflows.js.map +1 -1
- package/dist/extension/controller-host.d.ts +2 -1
- package/dist/extension/controller-host.js +1 -1
- package/dist/extension/controller-host.js.map +1 -1
- package/dist/extension/decision-channels.js +45 -7
- package/dist/extension/decision-channels.js.map +1 -1
- package/dist/extension/follow-up-coordinator.d.ts +27 -0
- package/dist/extension/follow-up-coordinator.js +131 -0
- package/dist/extension/follow-up-coordinator.js.map +1 -0
- package/dist/extension/index.d.ts +9 -0
- package/dist/extension/index.js +462 -76
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +1 -1
- package/dist/extension/recorder.js +8 -8
- package/dist/extension/recorder.js.map +1 -1
- package/dist/extension/session-events.d.ts +2 -3
- package/dist/extension/session-events.js +11 -10
- package/dist/extension/session-events.js.map +1 -1
- package/dist/extension/widget.js +9 -0
- package/dist/extension/widget.js.map +1 -1
- package/dist/state/database.d.ts +2 -1
- package/dist/state/database.js +11 -13
- package/dist/state/database.js.map +1 -1
- package/dist/state/json.js +6 -1
- package/dist/state/json.js.map +1 -1
- package/dist/state/mutation.d.ts +1 -1
- package/dist/state/mutation.js.map +1 -1
- package/dist/state/prune.d.ts +18 -0
- package/dist/state/prune.js +373 -0
- package/dist/state/prune.js.map +1 -0
- package/dist/state/schema.d.ts +1 -1
- package/dist/state/schema.js +130 -13
- package/dist/state/schema.js.map +1 -1
- package/dist/viewer/cli.d.ts +3 -1
- package/dist/viewer/cli.js +55 -4
- package/dist/viewer/cli.js.map +1 -1
- package/dist/viewer/render.js +19 -1
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/session-reducer.d.ts +0 -1
- package/dist/viewer/session-reducer.js +2 -24
- package/dist/viewer/session-reducer.js.map +1 -1
- package/dist/workflows/composition.d.ts +2 -0
- package/dist/workflows/composition.js +15 -0
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.d.ts +6 -3
- package/dist/workflows/definition.js +3 -0
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +6 -1
- package/dist/workflows/engine.js +303 -36
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +1 -0
- package/dist/workflows/human-decision.js +25 -9
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +3 -1
- package/dist/workflows/index.js +2 -0
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/json-patch.d.ts +47 -0
- package/dist/workflows/json-patch.js +298 -0
- package/dist/workflows/json-patch.js.map +1 -0
- package/dist/workflows/schema.js +29 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/settings.d.ts +128 -0
- package/dist/workflows/settings.js +199 -0
- package/dist/workflows/settings.js.map +1 -0
- package/dist/workflows/store.d.ts +81 -3
- package/dist/workflows/store.js +1563 -50
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +22 -0
- package/dist/workflows/tool-input.js +43 -0
- package/dist/workflows/tool-input.js.map +1 -1
- package/dist/workflows/types.d.ts +41 -6
- package/docs/2026-08-25-workflow-follow-ups.md +132 -0
- package/docs/2026-08-25-workflow-settings.md +169 -0
- package/docs/DEFERRED_TURNS.md +6 -0
- package/docs/DESIGN_PHILOSOPHY.md +2 -0
- package/docs/SQLITE_STATE.md +36 -17
- package/docs/WORKFLOW_COMPOSITION.md +15 -0
- package/docs/WORKFLOW_STEP_MESSAGES.md +4 -2
- package/docs/plans/2026-08-25-autoplan-user-intent-capture-plan.md +107 -0
- package/docs/plans/2026-08-25-live-workflow-settings-plan.md +532 -0
- package/docs/plans/2026-08-25-workflow-run-storage-plan.md +67 -0
- package/docs/session-event-journal.md +2 -3
- package/docs/tui-viewer.md +7 -6
- package/docs/workflows.md +58 -10
- package/examples/workflows/live-settings.workflow.ts +93 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/builtins/autoimplement.workflow.ts +56 -8
- package/src/builtins/autoplan.workflow.ts +80 -32
- package/src/builtins/catalog.ts +4 -4
- package/src/builtins/plain-summary.workflow.ts +38 -40
- package/src/builtins/sanity-check.workflow.ts +19 -22
- package/src/controllers/index.ts +6 -0
- package/src/controllers/sqlite.ts +132 -72
- package/src/controllers/types.ts +40 -0
- package/src/controllers/workflow-engine-scheduler.ts +103 -1
- package/src/controllers/workflows.ts +68 -0
- package/src/extension/controller-host.ts +6 -1
- package/src/extension/decision-channels.ts +47 -7
- package/src/extension/follow-up-coordinator.ts +151 -0
- package/src/extension/index.ts +540 -89
- package/src/extension/recorder.ts +10 -8
- package/src/extension/session-events.ts +15 -13
- package/src/extension/widget.ts +8 -0
- package/src/state/database.ts +12 -12
- package/src/state/json.ts +6 -1
- package/src/state/mutation.ts +4 -1
- package/src/state/prune.ts +502 -0
- package/src/state/schema.ts +130 -13
- package/src/viewer/cli.ts +52 -5
- package/src/viewer/render.ts +43 -1
- package/src/viewer/session-reducer.ts +2 -29
- package/src/workflows/composition.ts +19 -0
- package/src/workflows/definition.ts +19 -5
- package/src/workflows/engine.ts +365 -36
- package/src/workflows/human-decision.ts +41 -11
- package/src/workflows/index.ts +43 -0
- package/src/workflows/json-patch.ts +375 -0
- package/src/workflows/schema.ts +31 -1
- package/src/workflows/settings.ts +430 -0
- package/src/workflows/store.ts +2268 -121
- package/src/workflows/tool-input.ts +58 -0
- package/src/workflows/types.ts +43 -5
package/src/workflows/store.ts
CHANGED
|
@@ -1,18 +1,50 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { StateDatabase, workflowStatePath } from "../state/database.js";
|
|
3
|
-
import { canonicalJson } from "../state/json.js";
|
|
4
|
-
import {
|
|
3
|
+
import { canonicalJson, type JsonValue } from "../state/json.js";
|
|
4
|
+
import {
|
|
5
|
+
StateMutationStore,
|
|
6
|
+
StaleResourceError,
|
|
7
|
+
resourceIdFor,
|
|
8
|
+
tokenHash,
|
|
9
|
+
type ActorType,
|
|
10
|
+
type LeaseClaim,
|
|
11
|
+
type MutationActor,
|
|
12
|
+
type OwnerType,
|
|
13
|
+
} from "../state/mutation.js";
|
|
5
14
|
import { compositionMetadata } from "./composition.js";
|
|
15
|
+
import { applyJsonPatch, validateJsonPatch } from "./json-patch.js";
|
|
16
|
+
import {
|
|
17
|
+
applyWorkflowSettingsPatch,
|
|
18
|
+
workflowSettingsScopeId,
|
|
19
|
+
type InitialWorkflowSettingsScope,
|
|
20
|
+
type WorkflowFollowUpQueueRecord,
|
|
21
|
+
type WorkflowFollowUpRecord,
|
|
22
|
+
type WorkflowFollowUpState,
|
|
23
|
+
type WorkflowPresentationState,
|
|
24
|
+
type WorkflowQueueFollowUpRequest,
|
|
25
|
+
type WorkflowRemoveFollowUpRequest,
|
|
26
|
+
type WorkflowSettingsChangeRecord,
|
|
27
|
+
type WorkflowSettingsChangeRequest,
|
|
28
|
+
type WorkflowSettingsChangeResult,
|
|
29
|
+
type WorkflowSettingsDefinition,
|
|
30
|
+
type WorkflowSettingsScopeRecord,
|
|
31
|
+
} from "./settings.js";
|
|
6
32
|
import type {
|
|
7
33
|
WorkflowDefinition,
|
|
8
34
|
WorkflowDefinitionSnapshot,
|
|
35
|
+
HumanDecisionReceipt,
|
|
9
36
|
WorkflowNodeDefinition,
|
|
37
|
+
WorkflowNodeResult,
|
|
10
38
|
WorkflowNodeSnapshot,
|
|
11
39
|
WorkflowRunState,
|
|
40
|
+
WorkflowSource,
|
|
41
|
+
WorkflowMountedSource,
|
|
42
|
+
WorkflowStepRecord,
|
|
12
43
|
WorkflowSessionBinding,
|
|
13
44
|
WorkflowSessionCapture,
|
|
14
45
|
WorkflowSessionEntryRecord,
|
|
15
46
|
WorkflowSessionEventRecord,
|
|
47
|
+
WorkflowSettingsSnapshot,
|
|
16
48
|
WorkflowTraceEvent,
|
|
17
49
|
WorkflowTraceEventDraft,
|
|
18
50
|
WorkflowUpdateInput,
|
|
@@ -27,6 +59,19 @@ export const SESSION_EVENT_SCHEMA = "pi-workflows.session-event.v1" as const;
|
|
|
27
59
|
export const SESSION_CAPTURE_SCHEMA = "pi-workflows.session-capture.v1" as const;
|
|
28
60
|
export const SESSION_EVENT_MAX_BYTES = 1024 * 1024;
|
|
29
61
|
|
|
62
|
+
const FOLLOW_UP_ROW_SELECT = `
|
|
63
|
+
SELECT f.follow_up_id AS followUpId, f.resource_id AS resourceId,
|
|
64
|
+
f.queue_resource_id AS queueResourceId, f.run_id AS runId,
|
|
65
|
+
f.request_id AS requestId, f.order_number AS orderNumber,
|
|
66
|
+
f.target_session_id AS targetSessionId, f.actor_type AS actorType,
|
|
67
|
+
f.actor_id AS actorId, f.source_type AS sourceType,
|
|
68
|
+
f.prompt_hash AS promptHash, f.status,
|
|
69
|
+
f.session_entry_id AS sessionEntryId, f.reason_hash AS reasonHash,
|
|
70
|
+
f.created_at AS createdAt, f.updated_at AS updatedAt, f.sent_at AS sentAt,
|
|
71
|
+
r.revision
|
|
72
|
+
FROM workflow_follow_ups f
|
|
73
|
+
JOIN resources r ON r.resource_id = f.resource_id`;
|
|
74
|
+
|
|
30
75
|
export function workflowStateDatabasePath(homeDir?: string): string {
|
|
31
76
|
return workflowStatePath(homeDir);
|
|
32
77
|
}
|
|
@@ -58,9 +103,62 @@ type RunContext = {
|
|
|
58
103
|
type RunRow = {
|
|
59
104
|
runId: string;
|
|
60
105
|
resourceId: string;
|
|
61
|
-
stateHash: Buffer;
|
|
62
106
|
definitionHash: Buffer;
|
|
107
|
+
definitionDigest: Buffer;
|
|
108
|
+
workflowRef: string;
|
|
109
|
+
parentRunId: string | null;
|
|
110
|
+
title: string | null;
|
|
111
|
+
status: string;
|
|
112
|
+
paused: number;
|
|
113
|
+
statusDetail: string | null;
|
|
114
|
+
inputHash: Buffer;
|
|
115
|
+
finalOutputHash: Buffer | null;
|
|
116
|
+
errorHash: Buffer | null;
|
|
117
|
+
createdAt: number;
|
|
118
|
+
updatedAt: number;
|
|
119
|
+
finishedAt: number | null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
type RunSourceRow = {
|
|
123
|
+
mountPath: string;
|
|
124
|
+
sourceType: "builtin" | "file";
|
|
125
|
+
sourceRef: string;
|
|
126
|
+
sourceRevision: string;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
type StoredAttemptReceipt = Pick<WorkflowStepRecord, "action" | "assistantMessage">;
|
|
130
|
+
|
|
131
|
+
type StepRow = {
|
|
132
|
+
stepIndex: number;
|
|
133
|
+
attemptId: string;
|
|
134
|
+
nodeId: string;
|
|
135
|
+
nodeType: WorkflowStepRecord["nodeType"];
|
|
63
136
|
status: string;
|
|
137
|
+
promptHash: Buffer | null;
|
|
138
|
+
outputHash: Buffer | null;
|
|
139
|
+
outputOverrideHash: Buffer | null;
|
|
140
|
+
receiptHash: Buffer | null;
|
|
141
|
+
promptEntryHash: Buffer | null;
|
|
142
|
+
responseEntryHash: Buffer | null;
|
|
143
|
+
firstEntryId: string | null;
|
|
144
|
+
lastEntryId: string | null;
|
|
145
|
+
errorHash: Buffer | null;
|
|
146
|
+
settingsScopeId: string | null;
|
|
147
|
+
settingsChangeNumber: number | null;
|
|
148
|
+
settingsHash: Buffer | null;
|
|
149
|
+
startedAt: number;
|
|
150
|
+
finishedAt: number;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
type UpdateRow = {
|
|
154
|
+
updateId: string;
|
|
155
|
+
runRevision: number;
|
|
156
|
+
nodeId: string;
|
|
157
|
+
attemptId: string;
|
|
158
|
+
updateType: string;
|
|
159
|
+
updateKey: string;
|
|
160
|
+
dataHash: Buffer;
|
|
161
|
+
recordedAt: number;
|
|
64
162
|
};
|
|
65
163
|
|
|
66
164
|
type EventRow = {
|
|
@@ -70,6 +168,79 @@ type EventRow = {
|
|
|
70
168
|
recordedAt: number;
|
|
71
169
|
};
|
|
72
170
|
|
|
171
|
+
type SettingsScopeRow = {
|
|
172
|
+
scopeId: string;
|
|
173
|
+
resourceId: string;
|
|
174
|
+
originRunId: string;
|
|
175
|
+
activeRunId: string;
|
|
176
|
+
mountPath: string;
|
|
177
|
+
invocation: number;
|
|
178
|
+
currentHash: Buffer;
|
|
179
|
+
revision: number;
|
|
180
|
+
createdAt: number;
|
|
181
|
+
updatedAt: number;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
type SettingsProjectionRow = {
|
|
185
|
+
scopeId: string;
|
|
186
|
+
initialHash: Buffer;
|
|
187
|
+
currentHash: Buffer;
|
|
188
|
+
revision: number;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
type SettingsProjectionChangeRow = {
|
|
192
|
+
changeNumber: number;
|
|
193
|
+
patchHash: Buffer;
|
|
194
|
+
beforeHash: Buffer;
|
|
195
|
+
afterHash: Buffer;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
type SettingsChangeRow = {
|
|
199
|
+
changeId: string;
|
|
200
|
+
scopeId: string;
|
|
201
|
+
requestId: string;
|
|
202
|
+
changeNumber: number;
|
|
203
|
+
actorType: string;
|
|
204
|
+
actorId: string | null;
|
|
205
|
+
sourceType: string;
|
|
206
|
+
patchHash: Buffer;
|
|
207
|
+
beforeHash: Buffer;
|
|
208
|
+
afterHash: Buffer;
|
|
209
|
+
acceptedAt: number;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
type FollowUpQueueRow = {
|
|
213
|
+
runId: string;
|
|
214
|
+
resourceId: string;
|
|
215
|
+
originSessionId: string | null;
|
|
216
|
+
presentationState: string;
|
|
217
|
+
presentationEntryId: string | null;
|
|
218
|
+
presentationAssistantEntryId: string | null;
|
|
219
|
+
presentationReasonHash: Buffer | null;
|
|
220
|
+
revision: number;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
type FollowUpRow = {
|
|
224
|
+
followUpId: string;
|
|
225
|
+
resourceId: string;
|
|
226
|
+
queueResourceId: string;
|
|
227
|
+
runId: string;
|
|
228
|
+
requestId: string;
|
|
229
|
+
orderNumber: number;
|
|
230
|
+
targetSessionId: string;
|
|
231
|
+
actorType: string;
|
|
232
|
+
actorId: string | null;
|
|
233
|
+
sourceType: string;
|
|
234
|
+
promptHash: Buffer;
|
|
235
|
+
status: string;
|
|
236
|
+
sessionEntryId: string | null;
|
|
237
|
+
reasonHash: Buffer | null;
|
|
238
|
+
createdAt: number;
|
|
239
|
+
updatedAt: number;
|
|
240
|
+
sentAt: number | null;
|
|
241
|
+
revision: number;
|
|
242
|
+
};
|
|
243
|
+
|
|
73
244
|
type SegmentRow = {
|
|
74
245
|
segmentId: string;
|
|
75
246
|
runId: string;
|
|
@@ -128,12 +299,18 @@ export type LoadedWorkflowRun = {
|
|
|
128
299
|
sessionCapture: WorkflowSessionCapture | null;
|
|
129
300
|
sessionIntegrity: SessionCaptureIntegrity;
|
|
130
301
|
sessionSegments: SessionCaptureSegment[];
|
|
302
|
+
settingsScopes?: WorkflowSettingsScopeRecord[];
|
|
303
|
+
followUpQueue?: WorkflowFollowUpQueueRecord | null;
|
|
131
304
|
};
|
|
132
305
|
|
|
133
306
|
export type ReadWorkflowRunOptions = {
|
|
134
307
|
includeTrace?: boolean;
|
|
135
308
|
};
|
|
136
309
|
|
|
310
|
+
export type InitializeWorkflowRunOptions = {
|
|
311
|
+
initialSettings?: InitialWorkflowSettingsScope[];
|
|
312
|
+
};
|
|
313
|
+
|
|
137
314
|
export class WorkflowRunStore {
|
|
138
315
|
readonly databasePath: string;
|
|
139
316
|
readonly state: StateDatabase;
|
|
@@ -142,6 +319,7 @@ export class WorkflowRunStore {
|
|
|
142
319
|
| undefined;
|
|
143
320
|
private readonly contexts = new Map<string, RunContext>();
|
|
144
321
|
private readonly ownsState: boolean;
|
|
322
|
+
private readonly mutations: StateMutationStore;
|
|
145
323
|
|
|
146
324
|
constructor(databasePath: string = workflowStatePath(), options: WorkflowRunStoreOptions = {}) {
|
|
147
325
|
this.authorityProvider = options.authorityProvider;
|
|
@@ -154,13 +332,18 @@ export class WorkflowRunStore {
|
|
|
154
332
|
checkLegacyState: databasePath === workflowStatePath(),
|
|
155
333
|
});
|
|
156
334
|
this.databasePath = this.state.filePath;
|
|
335
|
+
this.mutations = new StateMutationStore(this.state);
|
|
157
336
|
}
|
|
158
337
|
|
|
159
338
|
close(): void {
|
|
160
339
|
if (this.ownsState) this.state.close();
|
|
161
340
|
}
|
|
162
341
|
|
|
163
|
-
async initializeRun(
|
|
342
|
+
async initializeRun(
|
|
343
|
+
workflow: WorkflowDefinition,
|
|
344
|
+
state: WorkflowRunState,
|
|
345
|
+
options: InitializeWorkflowRunOptions = {},
|
|
346
|
+
): Promise<string> {
|
|
164
347
|
assertValidRunId(state.runId);
|
|
165
348
|
if (!this.contexts.has(state.runId)) {
|
|
166
349
|
const reserved = this.readRunRow(state.runId);
|
|
@@ -200,7 +383,9 @@ export class WorkflowRunStore {
|
|
|
200
383
|
payload: { workflowName: workflow.name },
|
|
201
384
|
});
|
|
202
385
|
this.persistRunState(reserved, state, revision, now);
|
|
386
|
+
this.initializeRunSettingsAndFollowUps(state, options.initialSettings ?? [], now);
|
|
203
387
|
this.syncNodeAttempts(state, snapshot, now);
|
|
388
|
+
this.syncContinuationIdentity(state);
|
|
204
389
|
context.revision = revision;
|
|
205
390
|
return;
|
|
206
391
|
}
|
|
@@ -226,19 +411,18 @@ export class WorkflowRunStore {
|
|
|
226
411
|
state.traceSeq = 1;
|
|
227
412
|
state.updatedAt = at;
|
|
228
413
|
const inputHash = this.state.putJson(state.input, now);
|
|
229
|
-
const workflowSourceHash = this.state.putJson(state.workflowSource ?? source, now);
|
|
230
414
|
const launchOptionsHash = this.state.putJson({}, now);
|
|
231
|
-
const
|
|
415
|
+
const finalOutputHash =
|
|
416
|
+
state.finalOutput === undefined ? null : this.state.putJson(state.finalOutput, now);
|
|
232
417
|
const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
|
|
233
418
|
this.state.connection
|
|
234
419
|
.prepare(
|
|
235
420
|
`INSERT INTO runs(
|
|
236
421
|
run_id, resource_id, project_id, parent_run_id, definition_digest,
|
|
237
|
-
workflow_ref,
|
|
238
|
-
|
|
239
|
-
status_detail, input_hash, output_hash, error_hash,
|
|
422
|
+
workflow_ref, launch_options_hash, title, status, paused,
|
|
423
|
+
status_detail, input_hash, final_output_hash, error_hash,
|
|
240
424
|
created_at, updated_at, finished_at
|
|
241
|
-
) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
425
|
+
) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
242
426
|
)
|
|
243
427
|
.run(
|
|
244
428
|
state.runId,
|
|
@@ -246,27 +430,26 @@ export class WorkflowRunStore {
|
|
|
246
430
|
state.parentRunId ?? null,
|
|
247
431
|
definitionDigest,
|
|
248
432
|
state.workflowName,
|
|
249
|
-
workflowSourceHash,
|
|
250
433
|
launchOptionsHash,
|
|
251
|
-
source.type,
|
|
252
|
-
source.ref,
|
|
253
|
-
source.revision,
|
|
254
434
|
state.runTitle ?? null,
|
|
255
435
|
state.status,
|
|
256
436
|
state.paused === true ? 1 : 0,
|
|
257
437
|
state.statusDetail ?? null,
|
|
258
438
|
inputHash,
|
|
259
|
-
|
|
439
|
+
finalOutputHash,
|
|
260
440
|
errorHash,
|
|
261
441
|
Date.parse(state.startedAt),
|
|
262
442
|
now,
|
|
263
443
|
state.finishedAt === undefined ? null : Date.parse(state.finishedAt),
|
|
264
444
|
);
|
|
445
|
+
this.insertRunSources(state.runId, source, state.workflowSources ?? []);
|
|
446
|
+
this.syncContinuationIdentity(state);
|
|
265
447
|
insertRunEvent(this.state, resourceId, 1, at, {
|
|
266
448
|
scope: "run",
|
|
267
449
|
type: "run_created",
|
|
268
450
|
payload: { workflowName: workflow.name },
|
|
269
451
|
});
|
|
452
|
+
this.initializeRunSettingsAndFollowUps(state, options.initialSettings ?? [], now);
|
|
270
453
|
this.syncNodeAttempts(state, snapshot, now);
|
|
271
454
|
});
|
|
272
455
|
this.contexts.set(state.runId, { revision: acceptedRevision, lock: Promise.resolve() });
|
|
@@ -274,12 +457,603 @@ export class WorkflowRunStore {
|
|
|
274
457
|
});
|
|
275
458
|
}
|
|
276
459
|
|
|
460
|
+
ensureSettingsScope(options: {
|
|
461
|
+
runId: string;
|
|
462
|
+
mountPath: string;
|
|
463
|
+
invocation: number;
|
|
464
|
+
settings: JsonValue;
|
|
465
|
+
}): WorkflowSettingsScopeRecord {
|
|
466
|
+
const now = Date.now();
|
|
467
|
+
return this.state.transaction(() => {
|
|
468
|
+
this.requireRunAcceptsSettings(options.runId, false);
|
|
469
|
+
const originRunId = this.logicalOriginRunId(options.runId);
|
|
470
|
+
const scopeId = workflowSettingsScopeId(originRunId, options.mountPath, options.invocation);
|
|
471
|
+
const existing = this.settingsScopeRow(scopeId);
|
|
472
|
+
if (existing !== undefined) {
|
|
473
|
+
if (existing.activeRunId !== options.runId) {
|
|
474
|
+
throw new Error(`Workflow settings scope belongs to another active run: ${scopeId}`);
|
|
475
|
+
}
|
|
476
|
+
return this.settingsScopeRecord(existing);
|
|
477
|
+
}
|
|
478
|
+
const resourceId = this.mutations.ensureResource("settings", scopeId, now);
|
|
479
|
+
const settingsHash = this.state.putJson(options.settings, now);
|
|
480
|
+
this.state.connection
|
|
481
|
+
.prepare(
|
|
482
|
+
`INSERT INTO workflow_settings(
|
|
483
|
+
scope_id, resource_id, origin_run_id, active_run_id, mount_path, invocation,
|
|
484
|
+
initial_hash, current_hash, created_at, updated_at
|
|
485
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
486
|
+
)
|
|
487
|
+
.run(
|
|
488
|
+
scopeId,
|
|
489
|
+
resourceId,
|
|
490
|
+
originRunId,
|
|
491
|
+
options.runId,
|
|
492
|
+
options.mountPath,
|
|
493
|
+
options.invocation,
|
|
494
|
+
settingsHash,
|
|
495
|
+
settingsHash,
|
|
496
|
+
now,
|
|
497
|
+
now,
|
|
498
|
+
);
|
|
499
|
+
return this.settingsScopeRecord(this.requireSettingsScopeRow(scopeId));
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
getSettingsScope(scopeId: string): WorkflowSettingsScopeRecord | undefined {
|
|
504
|
+
const row = this.settingsScopeRow(scopeId);
|
|
505
|
+
return row === undefined ? undefined : this.settingsScopeRecord(row);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
getSettingsScopeAtChange(
|
|
509
|
+
scopeId: string,
|
|
510
|
+
changeNumber: number,
|
|
511
|
+
): WorkflowSettingsScopeRecord | undefined {
|
|
512
|
+
if (!Number.isInteger(changeNumber) || changeNumber < 0) {
|
|
513
|
+
throw new Error("Workflow settings change number must be a non-negative integer");
|
|
514
|
+
}
|
|
515
|
+
const row = this.settingsScopeRow(scopeId);
|
|
516
|
+
if (row === undefined) return undefined;
|
|
517
|
+
if (changeNumber > row.revision) {
|
|
518
|
+
throw new Error(`Workflow settings change ${changeNumber} does not exist for ${scopeId}`);
|
|
519
|
+
}
|
|
520
|
+
if (changeNumber === row.revision) return this.settingsScopeRecord(row);
|
|
521
|
+
const saved =
|
|
522
|
+
changeNumber === 0
|
|
523
|
+
? this.state.connection
|
|
524
|
+
.prepare(
|
|
525
|
+
"SELECT initial_hash AS settingsHash FROM workflow_settings WHERE scope_id = ?",
|
|
526
|
+
)
|
|
527
|
+
.get(scopeId)
|
|
528
|
+
: this.state.connection
|
|
529
|
+
.prepare(
|
|
530
|
+
`SELECT after_hash AS settingsHash FROM workflow_setting_changes
|
|
531
|
+
WHERE scope_id = ? AND change_number = ?`,
|
|
532
|
+
)
|
|
533
|
+
.get(scopeId, changeNumber);
|
|
534
|
+
if (!isRecord(saved) || !Buffer.isBuffer(saved.settingsHash)) {
|
|
535
|
+
throw new Error(`Workflow settings change ${changeNumber} is missing for ${scopeId}`);
|
|
536
|
+
}
|
|
537
|
+
return this.settingsScopeRecord(row, changeNumber, saved.settingsHash);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
listSettingsScopes(runId: string): WorkflowSettingsScopeRecord[] {
|
|
541
|
+
return this.state.connection
|
|
542
|
+
.prepare(
|
|
543
|
+
`SELECT s.scope_id AS scopeId, s.resource_id AS resourceId,
|
|
544
|
+
s.origin_run_id AS originRunId, s.active_run_id AS activeRunId,
|
|
545
|
+
s.mount_path AS mountPath, s.invocation,
|
|
546
|
+
s.current_hash AS currentHash, r.revision,
|
|
547
|
+
s.created_at AS createdAt, s.updated_at AS updatedAt
|
|
548
|
+
FROM workflow_settings s
|
|
549
|
+
JOIN resources r ON r.resource_id = s.resource_id
|
|
550
|
+
WHERE s.active_run_id = ?
|
|
551
|
+
ORDER BY s.mount_path, s.invocation`,
|
|
552
|
+
)
|
|
553
|
+
.all(runId)
|
|
554
|
+
.filter(isSettingsScopeRow)
|
|
555
|
+
.map((row) => this.settingsScopeRecord(row));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private settingsScopesForRunView(runId: string): WorkflowSettingsScopeRecord[] {
|
|
559
|
+
const originRunId = this.logicalOriginRunId(runId);
|
|
560
|
+
return this.state.connection
|
|
561
|
+
.prepare(
|
|
562
|
+
`SELECT s.scope_id AS scopeId, s.resource_id AS resourceId,
|
|
563
|
+
s.origin_run_id AS originRunId, s.active_run_id AS activeRunId,
|
|
564
|
+
s.mount_path AS mountPath, s.invocation,
|
|
565
|
+
s.current_hash AS currentHash, r.revision,
|
|
566
|
+
s.created_at AS createdAt, s.updated_at AS updatedAt
|
|
567
|
+
FROM workflow_settings s
|
|
568
|
+
JOIN resources r ON r.resource_id = s.resource_id
|
|
569
|
+
WHERE s.origin_run_id = ? OR s.active_run_id = ?
|
|
570
|
+
ORDER BY s.mount_path, s.invocation`,
|
|
571
|
+
)
|
|
572
|
+
.all(originRunId, runId)
|
|
573
|
+
.filter(isSettingsScopeRow)
|
|
574
|
+
.map((row) => this.settingsScopeRecord(row));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
findSettingsScope(
|
|
578
|
+
runId: string,
|
|
579
|
+
mountPath: string,
|
|
580
|
+
invocation: number,
|
|
581
|
+
): WorkflowSettingsScopeRecord | undefined {
|
|
582
|
+
const row = this.state.connection
|
|
583
|
+
.prepare(
|
|
584
|
+
`SELECT s.scope_id AS scopeId, s.resource_id AS resourceId,
|
|
585
|
+
s.origin_run_id AS originRunId, s.active_run_id AS activeRunId,
|
|
586
|
+
s.mount_path AS mountPath, s.invocation,
|
|
587
|
+
s.current_hash AS currentHash, r.revision,
|
|
588
|
+
s.created_at AS createdAt, s.updated_at AS updatedAt
|
|
589
|
+
FROM workflow_settings s
|
|
590
|
+
JOIN resources r ON r.resource_id = s.resource_id
|
|
591
|
+
WHERE s.active_run_id = ? AND s.mount_path = ? AND s.invocation = ?`,
|
|
592
|
+
)
|
|
593
|
+
.get(runId, mountPath, invocation);
|
|
594
|
+
return isSettingsScopeRow(row) ? this.settingsScopeRecord(row) : undefined;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async changeSettings<TSettings, TInput = unknown>(
|
|
598
|
+
definition: WorkflowSettingsDefinition<TSettings, TInput>,
|
|
599
|
+
request: WorkflowSettingsChangeRequest,
|
|
600
|
+
): Promise<WorkflowSettingsChangeResult> {
|
|
601
|
+
assertRequestId(request.requestId);
|
|
602
|
+
assertSourceType(request.source);
|
|
603
|
+
const patch = validateJsonPatch(request.patch);
|
|
604
|
+
const patchJson = canonicalJson(patch);
|
|
605
|
+
const patchDigest = createHash("sha256").update(patchJson).digest();
|
|
606
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
607
|
+
const prior = this.settingsChangeRow(request.scopeId, request.requestId);
|
|
608
|
+
if (prior !== undefined) {
|
|
609
|
+
this.assertSameSettingsChange(prior, request, patchDigest);
|
|
610
|
+
return {
|
|
611
|
+
scope: this.settingsScopeRecord(this.requireSettingsScopeRow(request.scopeId)),
|
|
612
|
+
change: this.settingsChangeRecord(prior),
|
|
613
|
+
adopted: true,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
const scope = this.requireSettingsScopeRow(request.scopeId);
|
|
617
|
+
if (scope.activeRunId !== request.runId) {
|
|
618
|
+
throw new Error(`Workflow settings scope is not active for run ${request.runId}`);
|
|
619
|
+
}
|
|
620
|
+
this.requireRunAcceptsSettings(request.runId, true);
|
|
621
|
+
if (
|
|
622
|
+
request.expectedChangeNumber !== undefined &&
|
|
623
|
+
request.expectedChangeNumber !== scope.revision
|
|
624
|
+
) {
|
|
625
|
+
throw new StaleResourceError(
|
|
626
|
+
`Workflow settings changed: expected ${request.expectedChangeNumber}, current ${scope.revision}`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
const before = this.state.readJson(scope.currentHash);
|
|
630
|
+
const applied = await applyWorkflowSettingsPatch(
|
|
631
|
+
definition,
|
|
632
|
+
before,
|
|
633
|
+
patch,
|
|
634
|
+
request.actor,
|
|
635
|
+
request.source,
|
|
636
|
+
);
|
|
637
|
+
const beforeHash = scope.currentHash;
|
|
638
|
+
const afterHash = createHash("sha256").update(canonicalJson(applied.json)).digest();
|
|
639
|
+
try {
|
|
640
|
+
const mutation = this.mutations.mutate(
|
|
641
|
+
{
|
|
642
|
+
resourceId: scope.resourceId,
|
|
643
|
+
operation: "settings.change",
|
|
644
|
+
actor: request.actor,
|
|
645
|
+
expectedRevision: scope.revision,
|
|
646
|
+
},
|
|
647
|
+
"settings.changed",
|
|
648
|
+
({ database, nextRevision, now }) => {
|
|
649
|
+
this.requireRunAcceptsSettings(request.runId, true);
|
|
650
|
+
const current = this.requireSettingsScopeRow(request.scopeId);
|
|
651
|
+
if (current.activeRunId !== request.runId || !current.currentHash.equals(beforeHash)) {
|
|
652
|
+
throw new StaleResourceError("Workflow settings changed before the patch committed");
|
|
653
|
+
}
|
|
654
|
+
const patchHash = database.putJson(applied.patch, now);
|
|
655
|
+
const savedAfterHash = database.putJson(applied.json, now);
|
|
656
|
+
const changeId = `setting-change-${randomUUID()}`;
|
|
657
|
+
database.connection
|
|
658
|
+
.prepare(
|
|
659
|
+
`INSERT INTO workflow_setting_changes(
|
|
660
|
+
change_id, scope_id, request_id, change_number,
|
|
661
|
+
actor_type, actor_id, source_type, patch_hash,
|
|
662
|
+
before_hash, after_hash, accepted_at
|
|
663
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
664
|
+
)
|
|
665
|
+
.run(
|
|
666
|
+
changeId,
|
|
667
|
+
request.scopeId,
|
|
668
|
+
request.requestId,
|
|
669
|
+
nextRevision,
|
|
670
|
+
request.actor.type,
|
|
671
|
+
request.actor.id ?? null,
|
|
672
|
+
request.source,
|
|
673
|
+
patchHash,
|
|
674
|
+
beforeHash,
|
|
675
|
+
savedAfterHash,
|
|
676
|
+
now,
|
|
677
|
+
);
|
|
678
|
+
database.connection
|
|
679
|
+
.prepare(
|
|
680
|
+
`UPDATE workflow_settings SET current_hash = ?, updated_at = ? WHERE scope_id = ?`,
|
|
681
|
+
)
|
|
682
|
+
.run(savedAfterHash, now, request.scopeId);
|
|
683
|
+
return changeId;
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
payload: {
|
|
687
|
+
scopeId: request.scopeId,
|
|
688
|
+
requestId: request.requestId,
|
|
689
|
+
source: request.source,
|
|
690
|
+
beforeHash: beforeHash.toString("hex"),
|
|
691
|
+
afterHash: afterHash.toString("hex"),
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
);
|
|
695
|
+
const row = this.settingsChangeRow(request.scopeId, request.requestId);
|
|
696
|
+
if (row === undefined || row.changeId !== mutation.value) {
|
|
697
|
+
throw new Error("Accepted workflow settings change is missing");
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
scope: this.settingsScopeRecord(this.requireSettingsScopeRow(request.scopeId)),
|
|
701
|
+
change: this.settingsChangeRecord(row),
|
|
702
|
+
adopted: false,
|
|
703
|
+
};
|
|
704
|
+
} catch (error) {
|
|
705
|
+
if (!(error instanceof StaleResourceError) || request.expectedChangeNumber !== undefined) {
|
|
706
|
+
throw error;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
throw new StaleResourceError("Workflow settings kept changing; retry with an expected number");
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
queueFollowUp(request: WorkflowQueueFollowUpRequest): {
|
|
714
|
+
followUp: WorkflowFollowUpRecord;
|
|
715
|
+
adopted: boolean;
|
|
716
|
+
} {
|
|
717
|
+
assertRequestId(request.requestId);
|
|
718
|
+
assertSourceType(request.source);
|
|
719
|
+
if (typeof request.prompt !== "string" || request.prompt.trim().length === 0) {
|
|
720
|
+
throw new Error("Workflow follow-up prompt must be a non-empty string");
|
|
721
|
+
}
|
|
722
|
+
if (Buffer.byteLength(request.prompt, "utf8") > 64 * 1024) {
|
|
723
|
+
throw new Error("Workflow follow-up prompt cannot exceed 65536 bytes");
|
|
724
|
+
}
|
|
725
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
726
|
+
const prior = this.followUpRowByRequest(request.runId, request.requestId);
|
|
727
|
+
if (prior !== undefined) {
|
|
728
|
+
this.assertSameFollowUp(prior, request);
|
|
729
|
+
return { followUp: this.followUpRecord(prior), adopted: true };
|
|
730
|
+
}
|
|
731
|
+
this.requireRunAcceptsSettings(request.runId, true);
|
|
732
|
+
const queue = this.requireFollowUpQueueRow(request.runId);
|
|
733
|
+
try {
|
|
734
|
+
const result = this.mutations.mutate(
|
|
735
|
+
{
|
|
736
|
+
resourceId: queue.resourceId,
|
|
737
|
+
operation: "follow-up.queue",
|
|
738
|
+
actor: request.actor,
|
|
739
|
+
expectedRevision: queue.revision,
|
|
740
|
+
},
|
|
741
|
+
"follow-up.queued",
|
|
742
|
+
({ database, now }) => {
|
|
743
|
+
this.requireRunAcceptsSettings(request.runId, true);
|
|
744
|
+
const currentQueue = this.requireFollowUpQueueRow(request.runId);
|
|
745
|
+
if (
|
|
746
|
+
currentQueue.originSessionId !== null &&
|
|
747
|
+
currentQueue.originSessionId !== request.targetSessionId
|
|
748
|
+
) {
|
|
749
|
+
throw new Error("Workflow follow-ups must target the run's origin Pi session");
|
|
750
|
+
}
|
|
751
|
+
if (currentQueue.originSessionId === null) {
|
|
752
|
+
database.connection
|
|
753
|
+
.prepare(
|
|
754
|
+
`UPDATE workflow_follow_up_queues
|
|
755
|
+
SET origin_session_id = ?, updated_at = ? WHERE run_id = ?`,
|
|
756
|
+
)
|
|
757
|
+
.run(request.targetSessionId, now, request.runId);
|
|
758
|
+
}
|
|
759
|
+
const order = this.nextFollowUpOrder(request.runId);
|
|
760
|
+
const followUpId = followUpIdFor(request.runId, request.requestId);
|
|
761
|
+
const resourceId = this.mutations.ensureResource("follow_up", followUpId, now);
|
|
762
|
+
const promptHash = database.putText(request.prompt, now);
|
|
763
|
+
database.connection
|
|
764
|
+
.prepare(
|
|
765
|
+
`INSERT INTO workflow_follow_ups(
|
|
766
|
+
follow_up_id, resource_id, queue_resource_id, run_id,
|
|
767
|
+
request_id, order_number, target_session_id,
|
|
768
|
+
actor_type, actor_id, source_type, prompt_hash, status,
|
|
769
|
+
created_at, updated_at
|
|
770
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)`,
|
|
771
|
+
)
|
|
772
|
+
.run(
|
|
773
|
+
followUpId,
|
|
774
|
+
resourceId,
|
|
775
|
+
queue.resourceId,
|
|
776
|
+
request.runId,
|
|
777
|
+
request.requestId,
|
|
778
|
+
order,
|
|
779
|
+
request.targetSessionId,
|
|
780
|
+
request.actor.type,
|
|
781
|
+
request.actor.id ?? null,
|
|
782
|
+
request.source,
|
|
783
|
+
promptHash,
|
|
784
|
+
now,
|
|
785
|
+
now,
|
|
786
|
+
);
|
|
787
|
+
return followUpId;
|
|
788
|
+
},
|
|
789
|
+
{ payload: { runId: request.runId, requestId: request.requestId } },
|
|
790
|
+
);
|
|
791
|
+
return {
|
|
792
|
+
followUp: this.followUpRecord(this.requireFollowUpRow(result.value)),
|
|
793
|
+
adopted: false,
|
|
794
|
+
};
|
|
795
|
+
} catch (error) {
|
|
796
|
+
if (!(error instanceof StaleResourceError)) throw error;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
throw new StaleResourceError("Workflow follow-up queue kept changing; retry the request");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
removeFollowUp(request: WorkflowRemoveFollowUpRequest): WorkflowFollowUpRecord {
|
|
803
|
+
assertSourceType(request.source);
|
|
804
|
+
const row = this.requireFollowUpRow(request.followUpId);
|
|
805
|
+
if (row.runId !== request.runId) {
|
|
806
|
+
throw new Error(`Workflow follow-up is not part of run ${request.runId}`);
|
|
807
|
+
}
|
|
808
|
+
if (row.status === "removed") return this.followUpRecord(row);
|
|
809
|
+
if (row.status === "sent" || row.status === "cancelled") {
|
|
810
|
+
throw new Error(`Workflow follow-up cannot be removed after it is ${row.status}`);
|
|
811
|
+
}
|
|
812
|
+
this.assertFollowUpRemovalAuthority(row, request.actor, request.source);
|
|
813
|
+
if (this.hasLiveLease(row.resourceId)) {
|
|
814
|
+
throw new Error("Workflow follow-up is being delivered and cannot be removed");
|
|
815
|
+
}
|
|
816
|
+
const reason = "Removed before delivery";
|
|
817
|
+
this.mutations.mutate(
|
|
818
|
+
{
|
|
819
|
+
resourceId: row.resourceId,
|
|
820
|
+
operation: "follow-up.remove",
|
|
821
|
+
actor: request.actor,
|
|
822
|
+
expectedRevision: row.revision,
|
|
823
|
+
},
|
|
824
|
+
"follow-up.removed",
|
|
825
|
+
({ database, now }) => {
|
|
826
|
+
const reasonHash = database.putText(reason, now);
|
|
827
|
+
const update = database.connection
|
|
828
|
+
.prepare(
|
|
829
|
+
`UPDATE workflow_follow_ups
|
|
830
|
+
SET status = 'removed', reason_hash = ?, updated_at = ?
|
|
831
|
+
WHERE follow_up_id = ? AND status IN ('queued', 'pending_presentation', 'ready')`,
|
|
832
|
+
)
|
|
833
|
+
.run(reasonHash, now, request.followUpId);
|
|
834
|
+
if (update.changes !== 1) {
|
|
835
|
+
throw new StaleResourceError("Workflow follow-up changed before removal");
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
{ payload: { followUpId: request.followUpId, source: request.source } },
|
|
839
|
+
);
|
|
840
|
+
return this.followUpRecord(this.requireFollowUpRow(request.followUpId));
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
originSessionId(runId: string): string | undefined {
|
|
844
|
+
const row = this.state.connection
|
|
845
|
+
.prepare(
|
|
846
|
+
`SELECT COALESCE(q.origin_session_id, b.origin_session_id) AS originSessionId
|
|
847
|
+
FROM runs r
|
|
848
|
+
LEFT JOIN workflow_follow_up_queues q ON q.run_id = r.run_id
|
|
849
|
+
LEFT JOIN run_bindings b ON b.run_id = r.run_id
|
|
850
|
+
WHERE r.run_id = ?`,
|
|
851
|
+
)
|
|
852
|
+
.get(runId);
|
|
853
|
+
return isRecord(row) && typeof row.originSessionId === "string"
|
|
854
|
+
? row.originSessionId
|
|
855
|
+
: undefined;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
readFollowUpQueue(runId: string): WorkflowFollowUpQueueRecord | undefined {
|
|
859
|
+
const queue = this.followUpQueueRow(runId);
|
|
860
|
+
if (queue === undefined) return undefined;
|
|
861
|
+
const followUps = this.state.connection
|
|
862
|
+
.prepare(`${FOLLOW_UP_ROW_SELECT} WHERE f.run_id = ? ORDER BY f.order_number`)
|
|
863
|
+
.all(runId)
|
|
864
|
+
.filter(isFollowUpRow)
|
|
865
|
+
.map((row) => this.followUpRecord(row));
|
|
866
|
+
return {
|
|
867
|
+
runId,
|
|
868
|
+
...(queue.originSessionId !== null ? { originSessionId: queue.originSessionId } : {}),
|
|
869
|
+
presentationState: assertPresentationState(queue.presentationState),
|
|
870
|
+
...(queue.presentationEntryId !== null
|
|
871
|
+
? { presentationEntryId: queue.presentationEntryId }
|
|
872
|
+
: {}),
|
|
873
|
+
...(queue.presentationAssistantEntryId !== null
|
|
874
|
+
? { presentationAssistantEntryId: queue.presentationAssistantEntryId }
|
|
875
|
+
: {}),
|
|
876
|
+
...(queue.presentationReasonHash !== null
|
|
877
|
+
? { presentationReason: this.readText(queue.presentationReasonHash) }
|
|
878
|
+
: {}),
|
|
879
|
+
followUps,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
listPendingPresentations(targetSessionId: string): string[] {
|
|
884
|
+
return this.state.connection
|
|
885
|
+
.prepare(
|
|
886
|
+
`SELECT run_id AS runId FROM workflow_follow_up_queues
|
|
887
|
+
WHERE origin_session_id = ? AND presentation_state = 'pending'
|
|
888
|
+
ORDER BY created_at`,
|
|
889
|
+
)
|
|
890
|
+
.all(targetSessionId)
|
|
891
|
+
.flatMap((row) => (isRecord(row) && typeof row.runId === "string" ? [row.runId] : []));
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
settleFollowUpPresentation(options: {
|
|
895
|
+
runId: string;
|
|
896
|
+
state: "settled" | "unavailable";
|
|
897
|
+
presentationEntryId?: string;
|
|
898
|
+
assistantEntryId?: string;
|
|
899
|
+
reason?: string;
|
|
900
|
+
}): WorkflowFollowUpQueueRecord {
|
|
901
|
+
const queue = this.requireFollowUpQueueRow(options.runId);
|
|
902
|
+
if (queue.presentationState === options.state) {
|
|
903
|
+
return this.readFollowUpQueue(options.runId) as WorkflowFollowUpQueueRecord;
|
|
904
|
+
}
|
|
905
|
+
if (queue.presentationState !== "pending") {
|
|
906
|
+
throw new Error(
|
|
907
|
+
`Workflow presentation is ${queue.presentationState}, not pending for ${options.runId}`,
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
if (
|
|
911
|
+
options.state === "settled" &&
|
|
912
|
+
(options.presentationEntryId === undefined || options.assistantEntryId === undefined)
|
|
913
|
+
) {
|
|
914
|
+
throw new Error("Settled workflow presentation requires both session entry IDs");
|
|
915
|
+
}
|
|
916
|
+
if (options.state === "unavailable" && !options.reason?.trim()) {
|
|
917
|
+
throw new Error("Unavailable workflow presentation requires a reason");
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
this.mutations.mutate(
|
|
921
|
+
{
|
|
922
|
+
resourceId: queue.resourceId,
|
|
923
|
+
operation: "follow-up.presentation",
|
|
924
|
+
actor: {
|
|
925
|
+
type: "session",
|
|
926
|
+
...(queue.originSessionId !== null ? { id: queue.originSessionId } : {}),
|
|
927
|
+
},
|
|
928
|
+
expectedRevision: queue.revision,
|
|
929
|
+
},
|
|
930
|
+
`follow-up.presentation-${options.state}`,
|
|
931
|
+
({ database, now }) => {
|
|
932
|
+
const reasonHash =
|
|
933
|
+
options.state === "unavailable"
|
|
934
|
+
? database.putText(options.reason as string, now)
|
|
935
|
+
: null;
|
|
936
|
+
database.connection
|
|
937
|
+
.prepare(
|
|
938
|
+
`UPDATE workflow_follow_up_queues
|
|
939
|
+
SET presentation_state = ?, presentation_entry_id = ?,
|
|
940
|
+
presentation_assistant_entry_id = ?, presentation_reason_hash = ?,
|
|
941
|
+
presentation_updated_at = ?, updated_at = ?
|
|
942
|
+
WHERE run_id = ? AND presentation_state = 'pending'`,
|
|
943
|
+
)
|
|
944
|
+
.run(
|
|
945
|
+
options.state,
|
|
946
|
+
options.presentationEntryId ?? null,
|
|
947
|
+
options.assistantEntryId ?? null,
|
|
948
|
+
reasonHash,
|
|
949
|
+
now,
|
|
950
|
+
now,
|
|
951
|
+
options.runId,
|
|
952
|
+
);
|
|
953
|
+
database.connection
|
|
954
|
+
.prepare(
|
|
955
|
+
`UPDATE workflow_follow_ups SET status = 'ready', updated_at = ?
|
|
956
|
+
WHERE run_id = ? AND status = 'pending_presentation'`,
|
|
957
|
+
)
|
|
958
|
+
.run(now, options.runId);
|
|
959
|
+
},
|
|
960
|
+
{ payload: { runId: options.runId, state: options.state } },
|
|
961
|
+
);
|
|
962
|
+
} catch (error) {
|
|
963
|
+
if (error instanceof StaleResourceError) {
|
|
964
|
+
const current = this.requireFollowUpQueueRow(options.runId);
|
|
965
|
+
if (current.presentationState === options.state) {
|
|
966
|
+
return this.readFollowUpQueue(options.runId) as WorkflowFollowUpQueueRecord;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
throw error;
|
|
970
|
+
}
|
|
971
|
+
return this.readFollowUpQueue(options.runId) as WorkflowFollowUpQueueRecord;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
claimNextFollowUp(
|
|
975
|
+
targetSessionId: string,
|
|
976
|
+
ownerId: string,
|
|
977
|
+
leaseMs = 30_000,
|
|
978
|
+
): { followUp: WorkflowFollowUpRecord; claim: LeaseClaim } | undefined {
|
|
979
|
+
const rows = this.state.connection
|
|
980
|
+
.prepare(
|
|
981
|
+
`${FOLLOW_UP_ROW_SELECT}
|
|
982
|
+
WHERE f.target_session_id = ? AND f.status = 'ready'
|
|
983
|
+
ORDER BY f.created_at, f.order_number LIMIT 16`,
|
|
984
|
+
)
|
|
985
|
+
.all(targetSessionId)
|
|
986
|
+
.filter(isFollowUpRow);
|
|
987
|
+
for (const row of rows) {
|
|
988
|
+
let claim: LeaseClaim | undefined;
|
|
989
|
+
try {
|
|
990
|
+
claim = this.mutations.claim({
|
|
991
|
+
resourceId: row.resourceId,
|
|
992
|
+
ownerType: "session",
|
|
993
|
+
ownerId,
|
|
994
|
+
expectedRevision: row.revision,
|
|
995
|
+
leaseMs,
|
|
996
|
+
});
|
|
997
|
+
} catch (error) {
|
|
998
|
+
if (error instanceof StaleResourceError) continue;
|
|
999
|
+
throw error;
|
|
1000
|
+
}
|
|
1001
|
+
if (claim === undefined) continue;
|
|
1002
|
+
const current = this.requireFollowUpRow(row.followUpId);
|
|
1003
|
+
if (current.status !== "ready") {
|
|
1004
|
+
this.mutations.release(claim, claim.resourceRevision);
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
return { followUp: this.followUpRecord(current), claim };
|
|
1008
|
+
}
|
|
1009
|
+
return undefined;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
markFollowUpSent(
|
|
1013
|
+
followUpId: string,
|
|
1014
|
+
claim: LeaseClaim,
|
|
1015
|
+
sessionEntryId: string,
|
|
1016
|
+
): WorkflowFollowUpRecord {
|
|
1017
|
+
const row = this.requireFollowUpRow(followUpId);
|
|
1018
|
+
if (row.status === "sent") return this.followUpRecord(row);
|
|
1019
|
+
this.mutations.mutate(
|
|
1020
|
+
{
|
|
1021
|
+
resourceId: row.resourceId,
|
|
1022
|
+
operation: "follow-up.sent",
|
|
1023
|
+
actor: { type: "session", id: claim.ownerId },
|
|
1024
|
+
expectedRevision: claim.resourceRevision,
|
|
1025
|
+
lease: claim,
|
|
1026
|
+
},
|
|
1027
|
+
"follow-up.sent",
|
|
1028
|
+
({ database, now }) => {
|
|
1029
|
+
const update = database.connection
|
|
1030
|
+
.prepare(
|
|
1031
|
+
`UPDATE workflow_follow_ups
|
|
1032
|
+
SET status = 'sent', session_entry_id = ?, sent_at = ?, updated_at = ?
|
|
1033
|
+
WHERE follow_up_id = ? AND status = 'ready'`,
|
|
1034
|
+
)
|
|
1035
|
+
.run(sessionEntryId, now, now, followUpId);
|
|
1036
|
+
if (update.changes !== 1) {
|
|
1037
|
+
throw new StaleResourceError("Workflow follow-up changed before delivery completed");
|
|
1038
|
+
}
|
|
1039
|
+
},
|
|
1040
|
+
{ payload: { followUpId, sessionEntryId } },
|
|
1041
|
+
);
|
|
1042
|
+
return this.followUpRecord(this.requireFollowUpRow(followUpId));
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
releaseFollowUpClaim(claim: LeaseClaim): void {
|
|
1046
|
+
const revision = this.requireResourceRevision(claim.resourceId);
|
|
1047
|
+
this.mutations.release(claim, revision);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
277
1050
|
async prepareRunResume(runId: string): Promise<LoadedWorkflowRun> {
|
|
278
1051
|
const loaded = this.readRun(runId, { includeTrace: true });
|
|
279
1052
|
if (loaded === null) throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
|
|
280
1053
|
if (loaded.state.status !== "running") {
|
|
281
1054
|
throw new Error(`Cannot resume workflow run ${runId} with status ${loaded.state.status}`);
|
|
282
1055
|
}
|
|
1056
|
+
this.verifySettingsProjections(runId);
|
|
283
1057
|
const revision = this.resourceRevision(runId);
|
|
284
1058
|
this.contexts.set(runId, { revision, lock: Promise.resolve() });
|
|
285
1059
|
this.finalizeRecordingCaptures(runId, "Workflow host stopped before the run finished");
|
|
@@ -293,10 +1067,10 @@ export class WorkflowRunStore {
|
|
|
293
1067
|
this.state.connection
|
|
294
1068
|
.prepare(
|
|
295
1069
|
`UPDATE node_attempts
|
|
296
|
-
SET status = '
|
|
1070
|
+
SET status = 'interrupted', updated_at = ?
|
|
297
1071
|
WHERE run_id = ? AND status IN ('pending', 'running', 'waiting')`,
|
|
298
1072
|
)
|
|
299
|
-
.run(now,
|
|
1073
|
+
.run(now, runId);
|
|
300
1074
|
insertRunEvent(this.state, row.resourceId, nextRevision, at, {
|
|
301
1075
|
scope: "run",
|
|
302
1076
|
type: "run_resume_prepared",
|
|
@@ -325,6 +1099,9 @@ export class WorkflowRunStore {
|
|
|
325
1099
|
delete loaded.state.currentNode;
|
|
326
1100
|
delete loaded.state.currentAttemptId;
|
|
327
1101
|
delete loaded.state.currentNodeStartedAt;
|
|
1102
|
+
delete loaded.state.currentSettingsScopeId;
|
|
1103
|
+
delete loaded.state.currentSettingsChangeNumber;
|
|
1104
|
+
delete loaded.state.currentSettingsHash;
|
|
328
1105
|
delete loaded.state.statusDetail;
|
|
329
1106
|
delete loaded.state.paused;
|
|
330
1107
|
await this.writeSnapshot(runId, loaded.state, {
|
|
@@ -393,10 +1170,20 @@ export class WorkflowRunStore {
|
|
|
393
1170
|
this.state.connection
|
|
394
1171
|
.prepare(
|
|
395
1172
|
`INSERT INTO workflow_updates(
|
|
396
|
-
update_id, attempt_id, update_seq,
|
|
397
|
-
|
|
1173
|
+
update_id, attempt_id, update_seq, run_revision,
|
|
1174
|
+
update_type, update_key, data_hash, recorded_at
|
|
1175
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
398
1176
|
)
|
|
399
|
-
.run(
|
|
1177
|
+
.run(
|
|
1178
|
+
updateId,
|
|
1179
|
+
attemptId,
|
|
1180
|
+
nextUpdateSeq,
|
|
1181
|
+
revision,
|
|
1182
|
+
update.type,
|
|
1183
|
+
update.key,
|
|
1184
|
+
dataHash,
|
|
1185
|
+
Date.now(),
|
|
1186
|
+
);
|
|
400
1187
|
insertRunEvent(this.state, run.resourceId, revision, at, event);
|
|
401
1188
|
this.persistRunState(run, state, revision, Date.now());
|
|
402
1189
|
context.revision = revision;
|
|
@@ -424,12 +1211,14 @@ export class WorkflowRunStore {
|
|
|
424
1211
|
const revision = context.revision + 1;
|
|
425
1212
|
const now = Date.now();
|
|
426
1213
|
const at = new Date(now).toISOString();
|
|
1214
|
+
const snapshot = this.readDefinition(run.definitionHash);
|
|
1215
|
+
this.assertSettingsRouteCurrent(event, snapshot);
|
|
427
1216
|
const traceEvent: WorkflowTraceEvent = { seq: revision, at, runId, ...event };
|
|
428
1217
|
state.traceSeq = revision;
|
|
429
1218
|
state.updatedAt = at;
|
|
430
1219
|
insertRunEvent(this.state, run.resourceId, revision, at, traceEvent);
|
|
431
1220
|
this.persistRunState(run, state, revision, now);
|
|
432
|
-
|
|
1221
|
+
this.transitionFollowUpsForRunState(state, event, now);
|
|
433
1222
|
this.syncNodeAttempts(state, snapshot, now);
|
|
434
1223
|
context.revision = revision;
|
|
435
1224
|
accepted = traceEvent;
|
|
@@ -515,6 +1304,13 @@ export class WorkflowRunStore {
|
|
|
515
1304
|
ON CONFLICT(run_id) DO NOTHING`,
|
|
516
1305
|
)
|
|
517
1306
|
.run(runId, binding.piSessionId, now);
|
|
1307
|
+
this.state.connection
|
|
1308
|
+
.prepare(
|
|
1309
|
+
`UPDATE workflow_follow_up_queues
|
|
1310
|
+
SET origin_session_id = COALESCE(origin_session_id, ?), updated_at = ?
|
|
1311
|
+
WHERE run_id = ?`,
|
|
1312
|
+
)
|
|
1313
|
+
.run(binding.piSessionId, now, runId);
|
|
518
1314
|
const revision = context.revision + 1;
|
|
519
1315
|
const at = new Date(now).toISOString();
|
|
520
1316
|
insertRunEvent(this.state, run.resourceId, revision, at, {
|
|
@@ -528,7 +1324,7 @@ export class WorkflowRunStore {
|
|
|
528
1324
|
...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
|
|
529
1325
|
},
|
|
530
1326
|
});
|
|
531
|
-
const current = this.
|
|
1327
|
+
const current = this.readRunState(run, this.readDefinition(run.definitionHash));
|
|
532
1328
|
current.traceSeq = revision;
|
|
533
1329
|
current.updatedAt = at;
|
|
534
1330
|
this.persistRunState(run, current, revision, now);
|
|
@@ -567,7 +1363,7 @@ export class WorkflowRunStore {
|
|
|
567
1363
|
"session.entry_appended",
|
|
568
1364
|
"session",
|
|
569
1365
|
segment.sessionId,
|
|
570
|
-
|
|
1366
|
+
null,
|
|
571
1367
|
now,
|
|
572
1368
|
);
|
|
573
1369
|
return sequence;
|
|
@@ -616,24 +1412,9 @@ export class WorkflowRunStore {
|
|
|
616
1412
|
);
|
|
617
1413
|
expected += 1;
|
|
618
1414
|
}
|
|
619
|
-
const now = Date.now();
|
|
620
1415
|
this.state.connection
|
|
621
1416
|
.prepare("UPDATE session_segments SET event_count = ? WHERE segment_id = ?")
|
|
622
1417
|
.run(expected - 1, current.segmentId);
|
|
623
|
-
const resourceId = segmentResourceId(current);
|
|
624
|
-
const revision = this.requireResourceRevision(resourceId);
|
|
625
|
-
this.bumpResource(resourceId, revision, now);
|
|
626
|
-
const payloadHash = this.state.putJson({ count: records.length, lastSeq: expected - 1 }, now);
|
|
627
|
-
insertGenericEvent(
|
|
628
|
-
this.state,
|
|
629
|
-
resourceId,
|
|
630
|
-
revision + 1,
|
|
631
|
-
"session.events_appended",
|
|
632
|
-
"session",
|
|
633
|
-
current.sessionId,
|
|
634
|
-
payloadHash,
|
|
635
|
-
now,
|
|
636
|
-
);
|
|
637
1418
|
});
|
|
638
1419
|
}
|
|
639
1420
|
|
|
@@ -698,8 +1479,8 @@ export class WorkflowRunStore {
|
|
|
698
1479
|
readRun(runId: string, options: ReadWorkflowRunOptions = {}): LoadedWorkflowRun | null {
|
|
699
1480
|
const row = this.readRunRow(runId);
|
|
700
1481
|
if (row === undefined) return null;
|
|
701
|
-
const state = this.readState(row.stateHash);
|
|
702
1482
|
const snapshot = this.readDefinition(row.definitionHash);
|
|
1483
|
+
const state = this.readRunState(row, snapshot);
|
|
703
1484
|
const segments = this.segmentRows(runId).map((segment) => this.loadSegment(segment, state));
|
|
704
1485
|
const flat = segments.find((segment) => segment.attemptId === "") ?? emptySegment();
|
|
705
1486
|
return {
|
|
@@ -713,6 +1494,8 @@ export class WorkflowRunStore {
|
|
|
713
1494
|
sessionCapture: flat.capture,
|
|
714
1495
|
sessionIntegrity: flat.integrity,
|
|
715
1496
|
sessionSegments: segments.filter((segment) => segment.attemptId !== ""),
|
|
1497
|
+
settingsScopes: this.settingsScopesForRunView(runId),
|
|
1498
|
+
followUpQueue: this.readFollowUpQueue(runId) ?? null,
|
|
716
1499
|
};
|
|
717
1500
|
}
|
|
718
1501
|
|
|
@@ -831,13 +1614,14 @@ export class WorkflowRunStore {
|
|
|
831
1614
|
now: number,
|
|
832
1615
|
): void {
|
|
833
1616
|
this.bumpResource(run.resourceId, expectedRevision - 1, now);
|
|
834
|
-
const
|
|
1617
|
+
const finalOutputHash =
|
|
1618
|
+
state.finalOutput === undefined ? null : this.state.putJson(state.finalOutput, now);
|
|
835
1619
|
const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
|
|
836
1620
|
const update = this.state.connection
|
|
837
1621
|
.prepare(
|
|
838
1622
|
`UPDATE runs
|
|
839
|
-
SET title = ?, status = ?, paused = ?, status_detail = ?,
|
|
840
|
-
error_hash = ?, updated_at = ?, finished_at = ?
|
|
1623
|
+
SET title = ?, status = ?, paused = ?, status_detail = ?,
|
|
1624
|
+
final_output_hash = ?, error_hash = ?, updated_at = ?, finished_at = ?
|
|
841
1625
|
WHERE run_id = ?`,
|
|
842
1626
|
)
|
|
843
1627
|
.run(
|
|
@@ -845,7 +1629,7 @@ export class WorkflowRunStore {
|
|
|
845
1629
|
state.status,
|
|
846
1630
|
state.paused === true ? 1 : 0,
|
|
847
1631
|
state.statusDetail ?? null,
|
|
848
|
-
|
|
1632
|
+
finalOutputHash,
|
|
849
1633
|
errorHash,
|
|
850
1634
|
now,
|
|
851
1635
|
state.finishedAt === undefined ? null : Date.parse(state.finishedAt),
|
|
@@ -855,7 +1639,6 @@ export class WorkflowRunStore {
|
|
|
855
1639
|
if (state.status !== "running") {
|
|
856
1640
|
this.enqueueRunSettlementEffect(run.resourceId, expectedRevision, state, now);
|
|
857
1641
|
}
|
|
858
|
-
run.stateHash = stateHash;
|
|
859
1642
|
}
|
|
860
1643
|
|
|
861
1644
|
private enqueueRunSettlementEffect(
|
|
@@ -925,96 +1708,686 @@ export class WorkflowRunStore {
|
|
|
925
1708
|
);
|
|
926
1709
|
}
|
|
927
1710
|
|
|
928
|
-
private
|
|
1711
|
+
private initializeRunSettingsAndFollowUps(
|
|
929
1712
|
state: WorkflowRunState,
|
|
930
|
-
|
|
1713
|
+
initialSettings: InitialWorkflowSettingsScope[],
|
|
931
1714
|
now: number,
|
|
932
1715
|
): void {
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
const promptHash = step.prompt === null ? null : this.state.putJson(step.prompt, now);
|
|
938
|
-
const existing = this.state.connection
|
|
939
|
-
.prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
|
|
940
|
-
.get(step.attemptId);
|
|
941
|
-
if (existing === undefined) {
|
|
942
|
-
const attemptNumber = this.nextAttemptNumber(state.runId, step.nodeId);
|
|
1716
|
+
const childQueueResourceId = this.ensureFollowUpQueue(state.runId, now);
|
|
1717
|
+
if (state.parentRunId !== undefined) {
|
|
1718
|
+
const parentQueue = this.followUpQueueRow(state.parentRunId);
|
|
1719
|
+
if (parentQueue !== undefined) {
|
|
943
1720
|
this.state.connection
|
|
944
1721
|
.prepare(
|
|
945
|
-
`
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
started_at, finished_at, created_at, updated_at
|
|
949
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1722
|
+
`UPDATE workflow_follow_up_queues
|
|
1723
|
+
SET origin_session_id = COALESCE(origin_session_id, ?), updated_at = ?
|
|
1724
|
+
WHERE run_id = ?`,
|
|
950
1725
|
)
|
|
951
|
-
.run(
|
|
952
|
-
step.attemptId,
|
|
953
|
-
state.runId,
|
|
954
|
-
step.nodeId,
|
|
955
|
-
attemptNumber,
|
|
956
|
-
step.nodeType,
|
|
957
|
-
outcomeStatus(step.outcome),
|
|
958
|
-
promptHash,
|
|
959
|
-
outputHash,
|
|
960
|
-
resultHash,
|
|
961
|
-
errorHash,
|
|
962
|
-
Date.parse(step.startedAt),
|
|
963
|
-
Date.parse(step.finishedAt),
|
|
964
|
-
Date.parse(step.startedAt),
|
|
965
|
-
now,
|
|
966
|
-
);
|
|
967
|
-
} else {
|
|
1726
|
+
.run(parentQueue.originSessionId, now, state.runId);
|
|
968
1727
|
this.state.connection
|
|
969
1728
|
.prepare(
|
|
970
|
-
`UPDATE
|
|
971
|
-
SET
|
|
972
|
-
|
|
973
|
-
WHERE attempt_id = ?`,
|
|
1729
|
+
`UPDATE workflow_follow_ups
|
|
1730
|
+
SET run_id = ?, queue_resource_id = ?, updated_at = ?
|
|
1731
|
+
WHERE run_id = ? AND status = 'queued'`,
|
|
974
1732
|
)
|
|
975
|
-
.run(
|
|
976
|
-
outcomeStatus(step.outcome),
|
|
977
|
-
promptHash,
|
|
978
|
-
outputHash,
|
|
979
|
-
resultHash,
|
|
980
|
-
errorHash,
|
|
981
|
-
Date.parse(step.finishedAt),
|
|
982
|
-
now,
|
|
983
|
-
step.attemptId,
|
|
984
|
-
);
|
|
1733
|
+
.run(state.runId, childQueueResourceId, now, state.parentRunId);
|
|
985
1734
|
}
|
|
1735
|
+
this.state.connection
|
|
1736
|
+
.prepare(
|
|
1737
|
+
`UPDATE workflow_settings SET active_run_id = ?, updated_at = ? WHERE active_run_id = ?`,
|
|
1738
|
+
)
|
|
1739
|
+
.run(state.runId, now, state.parentRunId);
|
|
986
1740
|
}
|
|
987
|
-
if (state.currentAttemptId !== undefined && state.currentNode !== undefined) {
|
|
988
|
-
this.ensureAttempt(state, state.currentNode, state.currentAttemptId, now, snapshot);
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
private ensureAttempt(
|
|
993
|
-
state: WorkflowRunState,
|
|
994
|
-
nodeId: string,
|
|
995
|
-
attemptId: string,
|
|
996
|
-
now: number,
|
|
997
|
-
snapshot?: WorkflowDefinitionSnapshot,
|
|
998
|
-
): void {
|
|
999
1741
|
const existing = this.state.connection
|
|
1000
|
-
.prepare("SELECT
|
|
1001
|
-
.get(
|
|
1002
|
-
const
|
|
1003
|
-
if (
|
|
1742
|
+
.prepare("SELECT COUNT(*) AS count FROM workflow_settings WHERE active_run_id = ?")
|
|
1743
|
+
.get(state.runId);
|
|
1744
|
+
const hasSettings = isCountRow(existing) && existing.count > 0;
|
|
1745
|
+
if (hasSettings) return;
|
|
1746
|
+
for (const scope of initialSettings) {
|
|
1747
|
+
const originRunId = this.logicalOriginRunId(state.runId);
|
|
1748
|
+
const scopeId = workflowSettingsScopeId(originRunId, scope.mountPath, scope.invocation);
|
|
1749
|
+
const resourceId = this.mutations.ensureResource("settings", scopeId, now);
|
|
1750
|
+
const settingsHash = this.state.putJson(scope.settings, now);
|
|
1004
1751
|
this.state.connection
|
|
1005
|
-
.prepare(
|
|
1006
|
-
|
|
1007
|
-
|
|
1752
|
+
.prepare(
|
|
1753
|
+
`INSERT INTO workflow_settings(
|
|
1754
|
+
scope_id, resource_id, origin_run_id, active_run_id, mount_path, invocation,
|
|
1755
|
+
initial_hash, current_hash, created_at, updated_at
|
|
1756
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1757
|
+
ON CONFLICT(scope_id) DO NOTHING`,
|
|
1758
|
+
)
|
|
1759
|
+
.run(
|
|
1760
|
+
scopeId,
|
|
1761
|
+
resourceId,
|
|
1762
|
+
originRunId,
|
|
1763
|
+
state.runId,
|
|
1764
|
+
scope.mountPath,
|
|
1765
|
+
scope.invocation,
|
|
1766
|
+
settingsHash,
|
|
1767
|
+
settingsHash,
|
|
1768
|
+
now,
|
|
1769
|
+
now,
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
private ensureFollowUpQueue(runId: string, now: number): string {
|
|
1775
|
+
const resourceId = this.mutations.ensureResource("follow_up_queue", runId, now);
|
|
1776
|
+
this.state.connection
|
|
1777
|
+
.prepare(
|
|
1778
|
+
`INSERT INTO workflow_follow_up_queues(
|
|
1779
|
+
run_id, resource_id, created_at, updated_at
|
|
1780
|
+
) VALUES (?, ?, ?, ?)
|
|
1781
|
+
ON CONFLICT(run_id) DO NOTHING`,
|
|
1782
|
+
)
|
|
1783
|
+
.run(runId, resourceId, now, now);
|
|
1784
|
+
return resourceId;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
private logicalOriginRunId(runId: string): string {
|
|
1788
|
+
let current = runId;
|
|
1789
|
+
for (let depth = 0; depth < 100; depth += 1) {
|
|
1790
|
+
const row = this.state.connection
|
|
1791
|
+
.prepare("SELECT parent_run_id AS parentRunId FROM runs WHERE run_id = ?")
|
|
1792
|
+
.get(current);
|
|
1793
|
+
if (!isParentRunRow(row)) return current;
|
|
1794
|
+
if (row.parentRunId === null) return current;
|
|
1795
|
+
current = row.parentRunId;
|
|
1796
|
+
}
|
|
1797
|
+
throw new Error(`Workflow continuation chain is too deep for run ${runId}`);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
private requireRunAcceptsSettings(runId: string, allowWaiting: boolean): void {
|
|
1801
|
+
const row = this.state.connection
|
|
1802
|
+
.prepare("SELECT status FROM runs WHERE run_id = ?")
|
|
1803
|
+
.get(runId);
|
|
1804
|
+
if (!isStatusRow(row)) throw new Error(`Workflow run is missing: ${runId}`);
|
|
1805
|
+
if (row.status !== "running" && !(allowWaiting && row.status === "waiting")) {
|
|
1806
|
+
throw new Error(`Workflow run ${runId} does not accept changes with status ${row.status}`);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
private verifySettingsProjections(runId: string): void {
|
|
1811
|
+
const scopes = this.state.connection
|
|
1812
|
+
.prepare(
|
|
1813
|
+
`SELECT s.scope_id AS scopeId, s.initial_hash AS initialHash,
|
|
1814
|
+
s.current_hash AS currentHash, r.revision
|
|
1815
|
+
FROM workflow_settings s
|
|
1816
|
+
JOIN resources r ON r.resource_id = s.resource_id
|
|
1817
|
+
WHERE s.active_run_id = ?`,
|
|
1818
|
+
)
|
|
1819
|
+
.all(runId);
|
|
1820
|
+
for (const value of scopes) {
|
|
1821
|
+
if (!isSettingsProjectionRow(value)) {
|
|
1822
|
+
throw new Error("Workflow settings projection row is invalid");
|
|
1823
|
+
}
|
|
1824
|
+
let current = this.state.readJson(value.initialHash);
|
|
1825
|
+
let currentHash = value.initialHash;
|
|
1826
|
+
const changes = this.state.connection
|
|
1827
|
+
.prepare(
|
|
1828
|
+
`SELECT change_number AS changeNumber, patch_hash AS patchHash,
|
|
1829
|
+
before_hash AS beforeHash, after_hash AS afterHash
|
|
1830
|
+
FROM workflow_setting_changes WHERE scope_id = ? ORDER BY change_number`,
|
|
1831
|
+
)
|
|
1832
|
+
.all(value.scopeId);
|
|
1833
|
+
let expected = 1;
|
|
1834
|
+
for (const change of changes) {
|
|
1835
|
+
if (!isSettingsProjectionChangeRow(change) || change.changeNumber !== expected) {
|
|
1836
|
+
throw new Error(`Workflow settings changes are not contiguous for ${value.scopeId}`);
|
|
1837
|
+
}
|
|
1838
|
+
if (!change.beforeHash.equals(currentHash)) {
|
|
1839
|
+
throw new Error(`Workflow settings change has a wrong old value for ${value.scopeId}`);
|
|
1840
|
+
}
|
|
1841
|
+
current = applyJsonPatch(current, this.state.readJson(change.patchHash));
|
|
1842
|
+
currentHash = createHash("sha256").update(canonicalJson(current)).digest();
|
|
1843
|
+
if (!currentHash.equals(change.afterHash)) {
|
|
1844
|
+
throw new Error(`Workflow settings change has a wrong new value for ${value.scopeId}`);
|
|
1845
|
+
}
|
|
1846
|
+
expected += 1;
|
|
1847
|
+
}
|
|
1848
|
+
if (value.revision !== expected - 1 || !value.currentHash.equals(currentHash)) {
|
|
1849
|
+
throw new Error(
|
|
1850
|
+
`Workflow settings projection does not match saved changes for ${value.scopeId}`,
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
private settingsScopeRow(scopeId: string): SettingsScopeRow | undefined {
|
|
1857
|
+
const row = this.state.connection
|
|
1858
|
+
.prepare(
|
|
1859
|
+
`SELECT s.scope_id AS scopeId, s.resource_id AS resourceId,
|
|
1860
|
+
s.origin_run_id AS originRunId, s.active_run_id AS activeRunId,
|
|
1861
|
+
s.mount_path AS mountPath, s.invocation,
|
|
1862
|
+
s.current_hash AS currentHash, r.revision,
|
|
1863
|
+
s.created_at AS createdAt, s.updated_at AS updatedAt
|
|
1864
|
+
FROM workflow_settings s
|
|
1865
|
+
JOIN resources r ON r.resource_id = s.resource_id
|
|
1866
|
+
WHERE s.scope_id = ?`,
|
|
1867
|
+
)
|
|
1868
|
+
.get(scopeId);
|
|
1869
|
+
return isSettingsScopeRow(row) ? row : undefined;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
private requireSettingsScopeRow(scopeId: string): SettingsScopeRow {
|
|
1873
|
+
const row = this.settingsScopeRow(scopeId);
|
|
1874
|
+
if (row === undefined) throw new Error(`Workflow settings scope not found: ${scopeId}`);
|
|
1875
|
+
return row;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
private settingsScopeRecord(
|
|
1879
|
+
row: SettingsScopeRow,
|
|
1880
|
+
changeNumber = row.revision,
|
|
1881
|
+
settingsHash = row.currentHash,
|
|
1882
|
+
): WorkflowSettingsScopeRecord {
|
|
1883
|
+
return {
|
|
1884
|
+
scopeId: row.scopeId,
|
|
1885
|
+
originRunId: row.originRunId,
|
|
1886
|
+
activeRunId: row.activeRunId,
|
|
1887
|
+
mountPath: row.mountPath,
|
|
1888
|
+
invocation: row.invocation,
|
|
1889
|
+
changeNumber,
|
|
1890
|
+
settings: this.state.readJson(settingsHash),
|
|
1891
|
+
settingsHash: settingsHash.toString("hex"),
|
|
1892
|
+
createdAt: new Date(row.createdAt).toISOString(),
|
|
1893
|
+
updatedAt: new Date(row.updatedAt).toISOString(),
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
private settingsChangeRow(scopeId: string, requestId: string): SettingsChangeRow | undefined {
|
|
1898
|
+
const row = this.state.connection
|
|
1899
|
+
.prepare(
|
|
1900
|
+
`SELECT change_id AS changeId, scope_id AS scopeId, request_id AS requestId,
|
|
1901
|
+
change_number AS changeNumber, actor_type AS actorType, actor_id AS actorId,
|
|
1902
|
+
source_type AS sourceType, patch_hash AS patchHash,
|
|
1903
|
+
before_hash AS beforeHash, after_hash AS afterHash,
|
|
1904
|
+
accepted_at AS acceptedAt
|
|
1905
|
+
FROM workflow_setting_changes WHERE scope_id = ? AND request_id = ?`,
|
|
1906
|
+
)
|
|
1907
|
+
.get(scopeId, requestId);
|
|
1908
|
+
return isSettingsChangeRow(row) ? row : undefined;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
private settingsChangeRecord(row: SettingsChangeRow): WorkflowSettingsChangeRecord {
|
|
1912
|
+
return {
|
|
1913
|
+
changeId: row.changeId,
|
|
1914
|
+
scopeId: row.scopeId,
|
|
1915
|
+
requestId: row.requestId,
|
|
1916
|
+
changeNumber: row.changeNumber,
|
|
1917
|
+
actor: {
|
|
1918
|
+
type: assertActorType(row.actorType),
|
|
1919
|
+
...(row.actorId !== null ? { id: row.actorId } : {}),
|
|
1920
|
+
},
|
|
1921
|
+
source: row.sourceType,
|
|
1922
|
+
patch: validateJsonPatch(this.state.readJson(row.patchHash)),
|
|
1923
|
+
beforeHash: row.beforeHash.toString("hex"),
|
|
1924
|
+
afterHash: row.afterHash.toString("hex"),
|
|
1925
|
+
acceptedAt: new Date(row.acceptedAt).toISOString(),
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
private assertSameSettingsChange(
|
|
1930
|
+
row: SettingsChangeRow,
|
|
1931
|
+
request: WorkflowSettingsChangeRequest,
|
|
1932
|
+
patchDigest: Buffer,
|
|
1933
|
+
): void {
|
|
1934
|
+
if (
|
|
1935
|
+
!row.patchHash.equals(patchDigest) ||
|
|
1936
|
+
row.actorType !== request.actor.type ||
|
|
1937
|
+
row.actorId !== (request.actor.id ?? null) ||
|
|
1938
|
+
row.sourceType !== request.source
|
|
1939
|
+
) {
|
|
1940
|
+
throw new Error(`Workflow settings request ID was reused with different content`);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
private followUpQueueRow(runId: string): FollowUpQueueRow | undefined {
|
|
1945
|
+
const row = this.state.connection
|
|
1946
|
+
.prepare(
|
|
1947
|
+
`SELECT q.run_id AS runId, q.resource_id AS resourceId,
|
|
1948
|
+
q.origin_session_id AS originSessionId,
|
|
1949
|
+
q.presentation_state AS presentationState,
|
|
1950
|
+
q.presentation_entry_id AS presentationEntryId,
|
|
1951
|
+
q.presentation_assistant_entry_id AS presentationAssistantEntryId,
|
|
1952
|
+
q.presentation_reason_hash AS presentationReasonHash,
|
|
1953
|
+
r.revision
|
|
1954
|
+
FROM workflow_follow_up_queues q
|
|
1955
|
+
JOIN resources r ON r.resource_id = q.resource_id
|
|
1956
|
+
WHERE q.run_id = ?`,
|
|
1957
|
+
)
|
|
1958
|
+
.get(runId);
|
|
1959
|
+
return isFollowUpQueueRow(row) ? row : undefined;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
private requireFollowUpQueueRow(runId: string): FollowUpQueueRow {
|
|
1963
|
+
const row = this.followUpQueueRow(runId);
|
|
1964
|
+
if (row === undefined) throw new Error(`Workflow follow-up queue not found: ${runId}`);
|
|
1965
|
+
return row;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
private followUpRowByRequest(runId: string, requestId: string): FollowUpRow | undefined {
|
|
1969
|
+
const row = this.state.connection
|
|
1970
|
+
.prepare(`${FOLLOW_UP_ROW_SELECT} WHERE f.run_id = ? AND f.request_id = ?`)
|
|
1971
|
+
.get(runId, requestId);
|
|
1972
|
+
return isFollowUpRow(row) ? row : undefined;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
private followUpRow(followUpId: string): FollowUpRow | undefined {
|
|
1976
|
+
const row = this.state.connection
|
|
1977
|
+
.prepare(`${FOLLOW_UP_ROW_SELECT} WHERE f.follow_up_id = ?`)
|
|
1978
|
+
.get(followUpId);
|
|
1979
|
+
return isFollowUpRow(row) ? row : undefined;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
private requireFollowUpRow(followUpId: string): FollowUpRow {
|
|
1983
|
+
const row = this.followUpRow(followUpId);
|
|
1984
|
+
if (row === undefined) throw new Error(`Workflow follow-up not found: ${followUpId}`);
|
|
1985
|
+
return row;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
private followUpRecord(row: FollowUpRow): WorkflowFollowUpRecord {
|
|
1989
|
+
return {
|
|
1990
|
+
followUpId: row.followUpId,
|
|
1991
|
+
runId: row.runId,
|
|
1992
|
+
requestId: row.requestId,
|
|
1993
|
+
order: row.orderNumber,
|
|
1994
|
+
targetSessionId: row.targetSessionId,
|
|
1995
|
+
actor: {
|
|
1996
|
+
type: assertActorType(row.actorType),
|
|
1997
|
+
...(row.actorId !== null ? { id: row.actorId } : {}),
|
|
1998
|
+
},
|
|
1999
|
+
source: row.sourceType,
|
|
2000
|
+
prompt: this.readText(row.promptHash),
|
|
2001
|
+
state: assertFollowUpState(row.status),
|
|
2002
|
+
...(row.sessionEntryId !== null ? { sessionEntryId: row.sessionEntryId } : {}),
|
|
2003
|
+
...(row.reasonHash !== null ? { reason: this.readText(row.reasonHash) } : {}),
|
|
2004
|
+
createdAt: new Date(row.createdAt).toISOString(),
|
|
2005
|
+
updatedAt: new Date(row.updatedAt).toISOString(),
|
|
2006
|
+
...(row.sentAt !== null ? { sentAt: new Date(row.sentAt).toISOString() } : {}),
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
private assertSameFollowUp(row: FollowUpRow, request: WorkflowQueueFollowUpRequest): void {
|
|
2011
|
+
const promptHash = createHash("sha256").update(request.prompt).digest();
|
|
2012
|
+
if (
|
|
2013
|
+
!row.promptHash.equals(promptHash) ||
|
|
2014
|
+
row.targetSessionId !== request.targetSessionId ||
|
|
2015
|
+
row.actorType !== request.actor.type ||
|
|
2016
|
+
row.actorId !== (request.actor.id ?? null) ||
|
|
2017
|
+
row.sourceType !== request.source
|
|
2018
|
+
) {
|
|
2019
|
+
throw new Error("Workflow follow-up request ID was reused with different content");
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
private assertFollowUpRemovalAuthority(
|
|
2024
|
+
row: FollowUpRow,
|
|
2025
|
+
actor: MutationActor,
|
|
2026
|
+
source: string,
|
|
2027
|
+
): void {
|
|
2028
|
+
if (actor.type === "human") return;
|
|
2029
|
+
if (
|
|
2030
|
+
(actor.type === "session" || actor.type === "controller") &&
|
|
2031
|
+
row.actorType === actor.type &&
|
|
2032
|
+
row.actorId === (actor.id ?? null) &&
|
|
2033
|
+
row.sourceType === source
|
|
2034
|
+
) {
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
throw new Error("Workflow follow-up can be removed only by its source or a verified human");
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
private hasLiveLease(resourceId: string): boolean {
|
|
2041
|
+
const row = this.state.connection
|
|
2042
|
+
.prepare("SELECT expires_at AS expiresAt FROM leases WHERE resource_id = ?")
|
|
2043
|
+
.get(resourceId);
|
|
2044
|
+
return isLeaseExpiryRow(row) && row.expiresAt !== null && row.expiresAt > Date.now();
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
private nextFollowUpOrder(runId: string): number {
|
|
2048
|
+
const row = this.state.connection
|
|
2049
|
+
.prepare(
|
|
2050
|
+
`SELECT COALESCE(MAX(order_number), 0) + 1 AS nextOrder
|
|
2051
|
+
FROM workflow_follow_ups WHERE run_id = ?`,
|
|
2052
|
+
)
|
|
2053
|
+
.get(runId);
|
|
2054
|
+
if (!isNextOrderRow(row)) throw new Error("Could not allocate workflow follow-up order");
|
|
2055
|
+
return row.nextOrder;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
private assertSettingsRouteCurrent(
|
|
2059
|
+
event: WorkflowTraceEventDraft,
|
|
2060
|
+
snapshot: WorkflowDefinitionSnapshot,
|
|
2061
|
+
): void {
|
|
2062
|
+
if (event.type !== "node_finished" || event.nodeId === undefined) return;
|
|
2063
|
+
if (snapshot.nodes[event.nodeId]?.settingsRoute !== true) return;
|
|
2064
|
+
const scopeId = event.payload.settingsScopeId;
|
|
2065
|
+
const changeNumber = event.payload.settingsChangeNumber;
|
|
2066
|
+
if (typeof scopeId !== "string" || typeof changeNumber !== "number") {
|
|
2067
|
+
throw new Error(`Settings route ${event.nodeId} has no saved settings binding`);
|
|
2068
|
+
}
|
|
2069
|
+
const scope = this.requireSettingsScopeRow(scopeId);
|
|
2070
|
+
if (scope.revision !== changeNumber) {
|
|
2071
|
+
throw new StaleResourceError(
|
|
2072
|
+
`Settings route ${event.nodeId} used change ${changeNumber}, current change is ${scope.revision}`,
|
|
2073
|
+
);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
private transitionFollowUpsForRunState(
|
|
2078
|
+
state: WorkflowRunState,
|
|
2079
|
+
event: WorkflowTraceEventDraft,
|
|
2080
|
+
now: number,
|
|
2081
|
+
): void {
|
|
2082
|
+
if (state.status === "running" || state.status === "waiting") return;
|
|
2083
|
+
const queue = this.followUpQueueRow(state.runId);
|
|
2084
|
+
if (queue === undefined || queue.presentationState !== "none") return;
|
|
2085
|
+
let presentationState: WorkflowPresentationState = "not-needed";
|
|
2086
|
+
let followUpState: WorkflowFollowUpState = "cancelled";
|
|
2087
|
+
let reasonHash: Buffer | null = null;
|
|
2088
|
+
if (state.status === "completed") {
|
|
2089
|
+
const required = event.payload.presentationRequired === true;
|
|
2090
|
+
presentationState = required ? "pending" : "not-needed";
|
|
2091
|
+
followUpState = required ? "pending_presentation" : "ready";
|
|
2092
|
+
} else {
|
|
2093
|
+
reasonHash = this.state.putText(
|
|
2094
|
+
state.error ?? `Workflow ended with status ${state.status}`,
|
|
2095
|
+
now,
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
this.state.connection
|
|
2099
|
+
.prepare(
|
|
2100
|
+
`UPDATE workflow_follow_up_queues
|
|
2101
|
+
SET presentation_state = ?, presentation_updated_at = ?, updated_at = ?
|
|
2102
|
+
WHERE run_id = ? AND presentation_state = 'none'`,
|
|
2103
|
+
)
|
|
2104
|
+
.run(presentationState, now, now, state.runId);
|
|
2105
|
+
this.state.connection
|
|
2106
|
+
.prepare(
|
|
2107
|
+
`UPDATE workflow_follow_ups
|
|
2108
|
+
SET status = ?, reason_hash = ?, updated_at = ?
|
|
2109
|
+
WHERE run_id = ? AND status IN ('queued', 'pending_presentation', 'ready')`,
|
|
2110
|
+
)
|
|
2111
|
+
.run(followUpState, reasonHash, now, state.runId);
|
|
2112
|
+
const revision = this.requireResourceRevision(queue.resourceId);
|
|
2113
|
+
this.bumpResource(queue.resourceId, revision, now);
|
|
2114
|
+
const payloadHash = this.state.putJson(
|
|
2115
|
+
{ runId: state.runId, state: presentationState, followUps: followUpState },
|
|
2116
|
+
now,
|
|
2117
|
+
);
|
|
2118
|
+
insertGenericEvent(
|
|
2119
|
+
this.state,
|
|
2120
|
+
queue.resourceId,
|
|
2121
|
+
revision + 1,
|
|
2122
|
+
"follow-up.run-finished",
|
|
2123
|
+
"system",
|
|
2124
|
+
null,
|
|
2125
|
+
payloadHash,
|
|
2126
|
+
now,
|
|
2127
|
+
);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
private syncNodeAttempts(
|
|
2131
|
+
state: WorkflowRunState,
|
|
2132
|
+
snapshot: WorkflowDefinitionSnapshot,
|
|
2133
|
+
now: number,
|
|
2134
|
+
): void {
|
|
2135
|
+
for (const step of state.steps.slice(state.carriedStepCount ?? 0)) {
|
|
2136
|
+
const promptEntryId = this.findAttemptPromptEntry(state.runId, step.attemptId);
|
|
2137
|
+
const promptHash =
|
|
2138
|
+
promptEntryId === undefined && step.prompt !== null
|
|
2139
|
+
? this.state.putText(step.prompt, now)
|
|
2140
|
+
: null;
|
|
2141
|
+
const receipt = attemptReceipt(step);
|
|
2142
|
+
const receiptHash =
|
|
2143
|
+
Object.keys(receipt).length === 0 ? null : this.state.putJson(receipt, now);
|
|
2144
|
+
const outputHash = step.output === undefined ? null : this.state.putJson(step.output, now);
|
|
2145
|
+
const errorHash = step.error === undefined ? null : this.state.putText(step.error, now);
|
|
2146
|
+
const existing = this.state.connection
|
|
2147
|
+
.prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
|
|
2148
|
+
.get(step.attemptId);
|
|
2149
|
+
if (existing === undefined) {
|
|
2150
|
+
const attemptNumber = this.nextAttemptNumber(state.runId, step.nodeId);
|
|
2151
|
+
this.state.connection
|
|
2152
|
+
.prepare(
|
|
2153
|
+
`INSERT INTO node_attempts(
|
|
2154
|
+
attempt_id, run_id, node_id, attempt_number, node_type, status,
|
|
2155
|
+
prompt_hash, output_hash, receipt_hash, error_hash,
|
|
2156
|
+
settings_scope_id, settings_change_number, settings_hash,
|
|
2157
|
+
started_at, finished_at, created_at, updated_at
|
|
2158
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2159
|
+
)
|
|
2160
|
+
.run(
|
|
2161
|
+
step.attemptId,
|
|
2162
|
+
state.runId,
|
|
2163
|
+
step.nodeId,
|
|
2164
|
+
attemptNumber,
|
|
2165
|
+
step.nodeType,
|
|
2166
|
+
outcomeStatus(step.outcome),
|
|
2167
|
+
promptHash,
|
|
2168
|
+
outputHash,
|
|
2169
|
+
receiptHash,
|
|
2170
|
+
errorHash,
|
|
2171
|
+
step.settingsScopeId ?? null,
|
|
2172
|
+
step.settingsChangeNumber ?? null,
|
|
2173
|
+
step.settingsHash === undefined ? null : Buffer.from(step.settingsHash, "hex"),
|
|
2174
|
+
Date.parse(step.startedAt),
|
|
2175
|
+
Date.parse(step.finishedAt),
|
|
2176
|
+
Date.parse(step.startedAt),
|
|
2177
|
+
now,
|
|
2178
|
+
);
|
|
2179
|
+
} else {
|
|
2180
|
+
this.state.connection
|
|
2181
|
+
.prepare(
|
|
2182
|
+
`UPDATE node_attempts
|
|
2183
|
+
SET status = ?, prompt_hash = ?, output_hash = ?, receipt_hash = ?, error_hash = ?,
|
|
2184
|
+
settings_scope_id = ?, settings_change_number = ?,
|
|
2185
|
+
settings_hash = ?, finished_at = ?, updated_at = ?
|
|
2186
|
+
WHERE attempt_id = ?`,
|
|
2187
|
+
)
|
|
2188
|
+
.run(
|
|
2189
|
+
outcomeStatus(step.outcome),
|
|
2190
|
+
promptHash,
|
|
2191
|
+
outputHash,
|
|
2192
|
+
receiptHash,
|
|
2193
|
+
errorHash,
|
|
2194
|
+
step.settingsScopeId ?? null,
|
|
2195
|
+
step.settingsChangeNumber ?? null,
|
|
2196
|
+
step.settingsHash === undefined ? null : Buffer.from(step.settingsHash, "hex"),
|
|
2197
|
+
Date.parse(step.finishedAt),
|
|
2198
|
+
now,
|
|
2199
|
+
step.attemptId,
|
|
2200
|
+
);
|
|
2201
|
+
}
|
|
2202
|
+
if (this.syncAttemptEntries(state.runId, step, promptEntryId)) {
|
|
2203
|
+
this.state.connection
|
|
2204
|
+
.prepare("UPDATE node_attempts SET output_hash = NULL WHERE attempt_id = ?")
|
|
2205
|
+
.run(step.attemptId);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
if (state.currentAttemptId !== undefined && state.currentNode !== undefined) {
|
|
2209
|
+
this.ensureAttempt(state, state.currentNode, state.currentAttemptId, now, snapshot);
|
|
2210
|
+
}
|
|
2211
|
+
this.syncRunSteps(state, now);
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
private syncRunSteps(state: WorkflowRunState, now: number): void {
|
|
2215
|
+
const existingRows = this.state.connection
|
|
2216
|
+
.prepare(
|
|
2217
|
+
`SELECT step_index AS stepIndex, attempt_id AS attemptId
|
|
2218
|
+
FROM run_steps WHERE run_id = ? ORDER BY step_index`,
|
|
2219
|
+
)
|
|
2220
|
+
.all(state.runId)
|
|
2221
|
+
.filter(isRunStepIdentityRow);
|
|
2222
|
+
if (existingRows.length > state.steps.length) {
|
|
2223
|
+
throw new Error(`Workflow run steps cannot shrink: ${state.runId}`);
|
|
2224
|
+
}
|
|
2225
|
+
for (const existing of existingRows) {
|
|
2226
|
+
if (state.steps[existing.stepIndex]?.attemptId !== existing.attemptId) {
|
|
2227
|
+
throw new Error(`Workflow run step history changed at index ${existing.stepIndex}`);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
for (let stepIndex = existingRows.length; stepIndex < state.steps.length; stepIndex += 1) {
|
|
2231
|
+
const step = state.steps[stepIndex];
|
|
2232
|
+
/* istanbul ignore if -- array index follows a checked bound */
|
|
2233
|
+
if (step === undefined) throw new Error("Workflow run step became unavailable");
|
|
2234
|
+
const attemptOutput = this.readAttemptOutput(step.attemptId);
|
|
2235
|
+
const carried = stepIndex < (state.carriedStepCount ?? 0);
|
|
2236
|
+
const outputOverrideHash =
|
|
2237
|
+
carried && canonicalJson(attemptOutput) !== canonicalJson(step.output)
|
|
2238
|
+
? this.state.putJson(step.output, now)
|
|
2239
|
+
: null;
|
|
2240
|
+
this.state.connection
|
|
2241
|
+
.prepare(
|
|
2242
|
+
`INSERT INTO run_steps(run_id, step_index, attempt_id, output_override_hash)
|
|
2243
|
+
VALUES (?, ?, ?, ?)`,
|
|
2244
|
+
)
|
|
2245
|
+
.run(state.runId, stepIndex, step.attemptId, outputOverrideHash);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
private syncAttemptEntries(
|
|
2250
|
+
runId: string,
|
|
2251
|
+
step: WorkflowStepRecord,
|
|
2252
|
+
promptEntryId: string | undefined,
|
|
2253
|
+
): boolean {
|
|
2254
|
+
const links: Array<["prompt" | "response" | "first" | "last", string]> = [];
|
|
2255
|
+
if (promptEntryId !== undefined) links.push(["prompt", promptEntryId]);
|
|
2256
|
+
if (step.conversation !== undefined) {
|
|
2257
|
+
links.push(
|
|
2258
|
+
["first", step.conversation.firstEntryId],
|
|
2259
|
+
["last", step.conversation.lastEntryId],
|
|
2260
|
+
);
|
|
2261
|
+
}
|
|
2262
|
+
if (step.assistantMessage?.entryId !== undefined) {
|
|
2263
|
+
links.push(["response", step.assistantMessage.entryId]);
|
|
2264
|
+
}
|
|
2265
|
+
let responseLinked = false;
|
|
2266
|
+
for (const [role, entryId] of links) {
|
|
2267
|
+
const row = this.state.connection
|
|
2268
|
+
.prepare(
|
|
2269
|
+
`SELECT e.segment_id AS segmentId, e.entry_hash AS entryHash
|
|
2270
|
+
FROM session_entries e
|
|
2271
|
+
JOIN session_segments s ON s.segment_id = e.segment_id
|
|
2272
|
+
WHERE s.run_id = ? AND e.entry_id = ?
|
|
2273
|
+
ORDER BY e.recorded_at DESC LIMIT 1`,
|
|
2274
|
+
)
|
|
2275
|
+
.get(runId, entryId);
|
|
2276
|
+
if (!isSegmentIdentityRow(row)) continue;
|
|
2277
|
+
if (role === "response") {
|
|
2278
|
+
const output = assistantOutputFromEntry(
|
|
2279
|
+
this.state.readJson(row.entryHash),
|
|
2280
|
+
step.assistantMessage?.maxChars,
|
|
2281
|
+
);
|
|
2282
|
+
if (
|
|
2283
|
+
canonicalJson(output) !== canonicalJson(step.output) ||
|
|
2284
|
+
createHash("sha256").update(output).digest("hex") !== step.assistantMessage?.sha256
|
|
2285
|
+
) {
|
|
2286
|
+
throw new Error(`Assistant response entry conflicts with attempt ${step.attemptId}`);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
this.state.connection
|
|
2290
|
+
.prepare(
|
|
2291
|
+
`INSERT INTO attempt_entries(attempt_id, role, segment_id, entry_id)
|
|
2292
|
+
VALUES (?, ?, ?, ?)
|
|
2293
|
+
ON CONFLICT(attempt_id, role) DO UPDATE SET
|
|
2294
|
+
segment_id = excluded.segment_id, entry_id = excluded.entry_id`,
|
|
2295
|
+
)
|
|
2296
|
+
.run(step.attemptId, role, row.segmentId, entryId);
|
|
2297
|
+
if (role === "response") responseLinked = true;
|
|
2298
|
+
}
|
|
2299
|
+
return responseLinked;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
private findAttemptPromptEntry(runId: string, attemptId: string): string | undefined {
|
|
2303
|
+
const row = this.state.connection
|
|
2304
|
+
.prepare(
|
|
2305
|
+
`SELECT e.entry_id AS entryId
|
|
2306
|
+
FROM session_entries e
|
|
2307
|
+
JOIN session_segments s ON s.segment_id = e.segment_id
|
|
2308
|
+
JOIN blobs b ON b.blob_hash = e.entry_hash
|
|
2309
|
+
WHERE s.run_id = ?
|
|
2310
|
+
AND json_extract(CAST(b.content AS TEXT), '$.customType') = 'pi-workflows-agent-step'
|
|
2311
|
+
AND json_extract(CAST(b.content AS TEXT), '$.details.contract.attemptId') = ?
|
|
2312
|
+
ORDER BY e.recorded_at DESC LIMIT 1`,
|
|
2313
|
+
)
|
|
2314
|
+
.get(runId, attemptId);
|
|
2315
|
+
return isEntryIdentityRow(row) ? row.entryId : undefined;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
private readAttemptOutput(attemptId: string): unknown {
|
|
2319
|
+
const row = this.state.connection
|
|
2320
|
+
.prepare(
|
|
2321
|
+
`SELECT a.output_hash AS outputHash, a.receipt_hash AS receiptHash,
|
|
2322
|
+
e.entry_hash AS responseEntryHash
|
|
2323
|
+
FROM node_attempts a
|
|
2324
|
+
LEFT JOIN attempt_entries l ON l.attempt_id = a.attempt_id AND l.role = 'response'
|
|
2325
|
+
LEFT JOIN session_entries e
|
|
2326
|
+
ON e.segment_id = l.segment_id AND e.entry_id = l.entry_id
|
|
2327
|
+
WHERE a.attempt_id = ?`,
|
|
2328
|
+
)
|
|
2329
|
+
.get(attemptId);
|
|
2330
|
+
if (!isAttemptValueRow(row)) {
|
|
2331
|
+
throw new Error(`Workflow node attempt is missing: ${attemptId}`);
|
|
2332
|
+
}
|
|
2333
|
+
if (row.outputHash !== null) return this.state.readJson(row.outputHash);
|
|
2334
|
+
if (row.responseEntryHash === null) return null;
|
|
2335
|
+
const receipt =
|
|
2336
|
+
row.receiptHash === null ? {} : this.readJsonAs<StoredAttemptReceipt>(row.receiptHash);
|
|
2337
|
+
return assistantOutputFromEntry(
|
|
2338
|
+
this.state.readJson(row.responseEntryHash),
|
|
2339
|
+
receipt.assistantMessage?.maxChars,
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
private ensureAttempt(
|
|
2344
|
+
state: WorkflowRunState,
|
|
2345
|
+
nodeId: string,
|
|
2346
|
+
attemptId: string,
|
|
2347
|
+
now: number,
|
|
2348
|
+
snapshot?: WorkflowDefinitionSnapshot,
|
|
2349
|
+
): void {
|
|
2350
|
+
const existing = this.state.connection
|
|
2351
|
+
.prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
|
|
2352
|
+
.get(attemptId);
|
|
2353
|
+
const status = state.status === "waiting" ? "waiting" : "running";
|
|
2354
|
+
if (existing !== undefined) {
|
|
2355
|
+
this.state.connection
|
|
2356
|
+
.prepare(
|
|
2357
|
+
`UPDATE node_attempts
|
|
2358
|
+
SET status = ?, settings_scope_id = ?, settings_change_number = ?,
|
|
2359
|
+
settings_hash = ?, updated_at = ?
|
|
2360
|
+
WHERE attempt_id = ?`,
|
|
2361
|
+
)
|
|
2362
|
+
.run(
|
|
2363
|
+
status,
|
|
2364
|
+
state.currentSettingsScopeId ?? null,
|
|
2365
|
+
state.currentSettingsChangeNumber ?? null,
|
|
2366
|
+
state.currentSettingsHash === undefined
|
|
2367
|
+
? null
|
|
2368
|
+
: Buffer.from(state.currentSettingsHash, "hex"),
|
|
2369
|
+
now,
|
|
2370
|
+
attemptId,
|
|
2371
|
+
);
|
|
2372
|
+
return;
|
|
1008
2373
|
}
|
|
1009
2374
|
const definition =
|
|
1010
2375
|
snapshot ?? this.readDefinition(this.requireRunRow(state.runId).definitionHash);
|
|
1011
2376
|
const nodeType = definition.nodes[nodeId]?.nodeType ?? "agent";
|
|
2377
|
+
this.state.connection
|
|
2378
|
+
.prepare(
|
|
2379
|
+
`UPDATE node_attempts
|
|
2380
|
+
SET status = 'cancelled', finished_at = COALESCE(finished_at, ?), updated_at = ?
|
|
2381
|
+
WHERE run_id = ? AND status = 'interrupted'`,
|
|
2382
|
+
)
|
|
2383
|
+
.run(now, now, state.runId);
|
|
1012
2384
|
this.state.connection
|
|
1013
2385
|
.prepare(
|
|
1014
2386
|
`INSERT INTO node_attempts(
|
|
1015
2387
|
attempt_id, run_id, node_id, attempt_number, node_type, status,
|
|
2388
|
+
settings_scope_id, settings_change_number, settings_hash,
|
|
1016
2389
|
started_at, created_at, updated_at
|
|
1017
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2390
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1018
2391
|
)
|
|
1019
2392
|
.run(
|
|
1020
2393
|
attemptId,
|
|
@@ -1023,6 +2396,11 @@ export class WorkflowRunStore {
|
|
|
1023
2396
|
this.nextAttemptNumber(state.runId, nodeId),
|
|
1024
2397
|
nodeType,
|
|
1025
2398
|
status,
|
|
2399
|
+
state.currentSettingsScopeId ?? null,
|
|
2400
|
+
state.currentSettingsChangeNumber ?? null,
|
|
2401
|
+
state.currentSettingsHash === undefined
|
|
2402
|
+
? null
|
|
2403
|
+
: Buffer.from(state.currentSettingsHash, "hex"),
|
|
1026
2404
|
state.currentNodeStartedAt === undefined ? now : Date.parse(state.currentNodeStartedAt),
|
|
1027
2405
|
now,
|
|
1028
2406
|
now,
|
|
@@ -1053,12 +2431,60 @@ export class WorkflowRunStore {
|
|
|
1053
2431
|
return row.updateSeq;
|
|
1054
2432
|
}
|
|
1055
2433
|
|
|
2434
|
+
private insertRunSources(
|
|
2435
|
+
runId: string,
|
|
2436
|
+
root: { type: "builtin" | "file"; ref: string; revision: string },
|
|
2437
|
+
mounted: WorkflowMountedSource[],
|
|
2438
|
+
): void {
|
|
2439
|
+
const insert = this.state.connection.prepare(
|
|
2440
|
+
`INSERT INTO run_sources(run_id, mount_path, source_type, source_ref, source_revision)
|
|
2441
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
2442
|
+
);
|
|
2443
|
+
insert.run(runId, "", root.type, root.ref, root.revision);
|
|
2444
|
+
for (const item of mounted) {
|
|
2445
|
+
const source = sourceParts(item.source);
|
|
2446
|
+
insert.run(runId, item.mountPath.join("/"), source.type, source.ref, source.revision);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
private syncContinuationIdentity(state: WorkflowRunState): void {
|
|
2451
|
+
if (state.parentRunId === undefined || state.humanDecision === undefined) return;
|
|
2452
|
+
const createdAt = Date.parse(state.humanDecision.acceptedAt);
|
|
2453
|
+
this.state.connection
|
|
2454
|
+
.prepare(
|
|
2455
|
+
`INSERT INTO continuations(
|
|
2456
|
+
decision_id, parent_run_id, continuation_run_id, created_at
|
|
2457
|
+
) VALUES (?, ?, ?, ?)
|
|
2458
|
+
ON CONFLICT(decision_id) DO NOTHING`,
|
|
2459
|
+
)
|
|
2460
|
+
.run(state.humanDecision.decisionId, state.parentRunId, state.runId, createdAt);
|
|
2461
|
+
const row = this.state.connection
|
|
2462
|
+
.prepare(
|
|
2463
|
+
`SELECT parent_run_id AS parentRunId, continuation_run_id AS continuationRunId,
|
|
2464
|
+
created_at AS createdAt
|
|
2465
|
+
FROM continuations WHERE decision_id = ?`,
|
|
2466
|
+
)
|
|
2467
|
+
.get(state.humanDecision.decisionId);
|
|
2468
|
+
if (
|
|
2469
|
+
!isContinuationIdentityRow(row) ||
|
|
2470
|
+
row.parentRunId !== state.parentRunId ||
|
|
2471
|
+
row.continuationRunId !== state.runId ||
|
|
2472
|
+
row.createdAt !== createdAt
|
|
2473
|
+
) {
|
|
2474
|
+
throw new Error("Immutable human decision continuation conflicts");
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
1056
2478
|
private readRunRow(runId: string): RunRow | undefined {
|
|
1057
2479
|
const row = this.state.connection
|
|
1058
2480
|
.prepare(
|
|
1059
2481
|
`SELECT r.run_id AS runId, r.resource_id AS resourceId,
|
|
1060
|
-
|
|
1061
|
-
r.
|
|
2482
|
+
d.definition_hash AS definitionHash, r.definition_digest AS definitionDigest,
|
|
2483
|
+
d.workflow_name AS workflowRef, r.parent_run_id AS parentRunId,
|
|
2484
|
+
r.title, r.status, r.paused, r.status_detail AS statusDetail,
|
|
2485
|
+
r.input_hash AS inputHash, r.final_output_hash AS finalOutputHash,
|
|
2486
|
+
r.error_hash AS errorHash,
|
|
2487
|
+
r.created_at AS createdAt, r.updated_at AS updatedAt, r.finished_at AS finishedAt
|
|
1062
2488
|
FROM runs r
|
|
1063
2489
|
JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
|
|
1064
2490
|
WHERE r.run_id = ?`,
|
|
@@ -1073,12 +2499,308 @@ export class WorkflowRunStore {
|
|
|
1073
2499
|
return row;
|
|
1074
2500
|
}
|
|
1075
2501
|
|
|
1076
|
-
private
|
|
1077
|
-
const
|
|
1078
|
-
|
|
1079
|
-
|
|
2502
|
+
private readRunState(row: RunRow, snapshot: WorkflowDefinitionSnapshot): WorkflowRunState {
|
|
2503
|
+
const steps = this.readSteps(row.runId);
|
|
2504
|
+
const sources = this.readRunSources(row.runId, snapshot);
|
|
2505
|
+
const carriedStepCount = this.carriedStepCount(row.runId);
|
|
2506
|
+
const activeAttempt = row.status === "running" ? this.readActiveAttempt(row.runId) : undefined;
|
|
2507
|
+
const humanDecision = this.readHumanDecisionReceipt(row.runId);
|
|
2508
|
+
const outputs: Record<string, unknown> = {};
|
|
2509
|
+
const results: Record<string, WorkflowNodeResult> = {};
|
|
2510
|
+
for (const step of steps) {
|
|
2511
|
+
const result = resultForStep(step);
|
|
2512
|
+
results[step.nodeId] = result;
|
|
2513
|
+
if (step.outcome === "ok") outputs[step.nodeId] = step.output;
|
|
2514
|
+
else delete outputs[step.nodeId];
|
|
2515
|
+
const node = snapshot.nodes[step.nodeId];
|
|
2516
|
+
const mountPath =
|
|
2517
|
+
node?.includeTransition === "exit" && node.mountPath !== undefined
|
|
2518
|
+
? node.mountPath.join("/")
|
|
2519
|
+
: undefined;
|
|
2520
|
+
if (mountPath !== undefined && step.outcome === "ok") {
|
|
2521
|
+
outputs[mountPath] = step.output;
|
|
2522
|
+
results[mountPath] = { ...result, nodeId: mountPath };
|
|
2523
|
+
}
|
|
1080
2524
|
}
|
|
1081
|
-
|
|
2525
|
+
const revision = this.requireResourceRevision(row.resourceId);
|
|
2526
|
+
return {
|
|
2527
|
+
schema: RUN_STATE_SCHEMA,
|
|
2528
|
+
traceSeq: revision,
|
|
2529
|
+
runId: row.runId,
|
|
2530
|
+
workflowName: row.workflowRef,
|
|
2531
|
+
...(row.parentRunId === null ? {} : { parentRunId: row.parentRunId }),
|
|
2532
|
+
...(carriedStepCount === 0 ? {} : { carriedStepCount }),
|
|
2533
|
+
...(row.title === null ? {} : { runTitle: row.title }),
|
|
2534
|
+
...(sources.root === undefined ? {} : { workflowSource: sources.root }),
|
|
2535
|
+
...(sources.mounted.length === 0 ? {} : { workflowSources: sources.mounted }),
|
|
2536
|
+
...(sources.mounted.length !== 0 || snapshot.composition?.mounts.length
|
|
2537
|
+
? { definitionDigest: `sha256:${row.definitionDigest.toString("hex")}` }
|
|
2538
|
+
: {}),
|
|
2539
|
+
startedAt: new Date(row.createdAt).toISOString(),
|
|
2540
|
+
...(row.finishedAt === null ? {} : { finishedAt: new Date(row.finishedAt).toISOString() }),
|
|
2541
|
+
updatedAt: new Date(row.updatedAt).toISOString(),
|
|
2542
|
+
status: row.status as WorkflowRunState["status"],
|
|
2543
|
+
input: this.state.readJson(row.inputHash),
|
|
2544
|
+
outputs,
|
|
2545
|
+
results,
|
|
2546
|
+
steps,
|
|
2547
|
+
...this.readUpdates(row.runId),
|
|
2548
|
+
...(activeAttempt === undefined ? {} : { currentNode: activeAttempt.nodeId }),
|
|
2549
|
+
...(activeAttempt === undefined ? {} : { currentAttemptId: activeAttempt.attemptId }),
|
|
2550
|
+
...(activeAttempt?.startedAt === null || activeAttempt === undefined
|
|
2551
|
+
? {}
|
|
2552
|
+
: { currentNodeStartedAt: new Date(activeAttempt.startedAt).toISOString() }),
|
|
2553
|
+
...savedCurrentSettingsBinding(
|
|
2554
|
+
activeAttempt?.settingsScopeId ?? null,
|
|
2555
|
+
activeAttempt?.settingsChangeNumber ?? null,
|
|
2556
|
+
activeAttempt?.settingsHash ?? null,
|
|
2557
|
+
),
|
|
2558
|
+
...(row.statusDetail === null ? {} : { statusDetail: row.statusDetail }),
|
|
2559
|
+
...(humanDecision === undefined ? {} : { humanDecision }),
|
|
2560
|
+
...(row.paused === 0 ? {} : { paused: true }),
|
|
2561
|
+
...(row.status === "waiting" && steps.at(-1) !== undefined
|
|
2562
|
+
? { waitingOn: steps.at(-1)?.nodeId as string }
|
|
2563
|
+
: {}),
|
|
2564
|
+
...(row.finalOutputHash === null
|
|
2565
|
+
? {}
|
|
2566
|
+
: { finalOutput: this.state.readJson(row.finalOutputHash) }),
|
|
2567
|
+
...(row.errorHash === null ? {} : { error: this.readText(row.errorHash) }),
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
private readSteps(runId: string): WorkflowStepRecord[] {
|
|
2572
|
+
const rows = this.state.connection
|
|
2573
|
+
.prepare(
|
|
2574
|
+
`SELECT s.step_index AS stepIndex, a.attempt_id AS attemptId, a.node_id AS nodeId,
|
|
2575
|
+
a.node_type AS nodeType, a.status,
|
|
2576
|
+
a.prompt_hash AS promptHash, a.output_hash AS outputHash,
|
|
2577
|
+
s.output_override_hash AS outputOverrideHash,
|
|
2578
|
+
a.receipt_hash AS receiptHash, a.error_hash AS errorHash,
|
|
2579
|
+
prompt_entry.entry_hash AS promptEntryHash,
|
|
2580
|
+
response_entry.entry_hash AS responseEntryHash,
|
|
2581
|
+
first_link.entry_id AS firstEntryId, last_link.entry_id AS lastEntryId,
|
|
2582
|
+
a.settings_scope_id AS settingsScopeId,
|
|
2583
|
+
a.settings_change_number AS settingsChangeNumber,
|
|
2584
|
+
a.settings_hash AS settingsHash,
|
|
2585
|
+
a.started_at AS startedAt, a.finished_at AS finishedAt
|
|
2586
|
+
FROM run_steps s
|
|
2587
|
+
JOIN node_attempts a ON a.attempt_id = s.attempt_id
|
|
2588
|
+
LEFT JOIN attempt_entries prompt_link
|
|
2589
|
+
ON prompt_link.attempt_id = a.attempt_id AND prompt_link.role = 'prompt'
|
|
2590
|
+
LEFT JOIN session_entries prompt_entry
|
|
2591
|
+
ON prompt_entry.segment_id = prompt_link.segment_id
|
|
2592
|
+
AND prompt_entry.entry_id = prompt_link.entry_id
|
|
2593
|
+
LEFT JOIN attempt_entries response_link
|
|
2594
|
+
ON response_link.attempt_id = a.attempt_id AND response_link.role = 'response'
|
|
2595
|
+
LEFT JOIN session_entries response_entry
|
|
2596
|
+
ON response_entry.segment_id = response_link.segment_id
|
|
2597
|
+
AND response_entry.entry_id = response_link.entry_id
|
|
2598
|
+
LEFT JOIN attempt_entries first_link
|
|
2599
|
+
ON first_link.attempt_id = a.attempt_id AND first_link.role = 'first'
|
|
2600
|
+
LEFT JOIN attempt_entries last_link
|
|
2601
|
+
ON last_link.attempt_id = a.attempt_id AND last_link.role = 'last'
|
|
2602
|
+
WHERE s.run_id = ? ORDER BY s.step_index`,
|
|
2603
|
+
)
|
|
2604
|
+
.all(runId)
|
|
2605
|
+
.filter(isStepRow);
|
|
2606
|
+
return rows.map((row, index) => {
|
|
2607
|
+
if (row.stepIndex !== index)
|
|
2608
|
+
throw new Error(`Workflow run step sequence has a gap: ${runId}`);
|
|
2609
|
+
const receipt =
|
|
2610
|
+
row.receiptHash === null ? {} : this.readJsonAs<StoredAttemptReceipt>(row.receiptHash);
|
|
2611
|
+
const prompt =
|
|
2612
|
+
row.promptEntryHash !== null
|
|
2613
|
+
? promptFromEntry(this.state.readJson(row.promptEntryHash))
|
|
2614
|
+
: row.promptHash === null
|
|
2615
|
+
? null
|
|
2616
|
+
: this.readText(row.promptHash);
|
|
2617
|
+
const outputHash = row.outputOverrideHash ?? row.outputHash;
|
|
2618
|
+
const output =
|
|
2619
|
+
outputHash !== null
|
|
2620
|
+
? this.state.readJson(outputHash)
|
|
2621
|
+
: row.responseEntryHash === null
|
|
2622
|
+
? null
|
|
2623
|
+
: assistantOutputFromEntry(
|
|
2624
|
+
this.state.readJson(row.responseEntryHash),
|
|
2625
|
+
receipt.assistantMessage?.maxChars,
|
|
2626
|
+
);
|
|
2627
|
+
const error = row.errorHash === null ? undefined : this.readText(row.errorHash);
|
|
2628
|
+
const conversation =
|
|
2629
|
+
row.firstEntryId === null || row.lastEntryId === null
|
|
2630
|
+
? undefined
|
|
2631
|
+
: { firstEntryId: row.firstEntryId, lastEntryId: row.lastEntryId };
|
|
2632
|
+
return {
|
|
2633
|
+
attemptId: row.attemptId,
|
|
2634
|
+
nodeId: row.nodeId,
|
|
2635
|
+
nodeType: row.nodeType,
|
|
2636
|
+
outcome: outcomeForStatus(row.status),
|
|
2637
|
+
startedAt: new Date(row.startedAt).toISOString(),
|
|
2638
|
+
finishedAt: new Date(row.finishedAt).toISOString(),
|
|
2639
|
+
prompt,
|
|
2640
|
+
output,
|
|
2641
|
+
...(error === undefined ? {} : { error }),
|
|
2642
|
+
...savedStepSettingsBinding(
|
|
2643
|
+
row.settingsScopeId,
|
|
2644
|
+
row.settingsChangeNumber,
|
|
2645
|
+
row.settingsHash,
|
|
2646
|
+
),
|
|
2647
|
+
...receipt,
|
|
2648
|
+
...(conversation === undefined ? {} : { conversation }),
|
|
2649
|
+
};
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
private readRunSources(
|
|
2654
|
+
runId: string,
|
|
2655
|
+
snapshot: WorkflowDefinitionSnapshot,
|
|
2656
|
+
): { root?: WorkflowSource; mounted: WorkflowMountedSource[] } {
|
|
2657
|
+
const rows = this.state.connection
|
|
2658
|
+
.prepare(
|
|
2659
|
+
`SELECT mount_path AS mountPath, source_type AS sourceType,
|
|
2660
|
+
source_ref AS sourceRef, source_revision AS sourceRevision
|
|
2661
|
+
FROM run_sources WHERE run_id = ? ORDER BY mount_path`,
|
|
2662
|
+
)
|
|
2663
|
+
.all(runId)
|
|
2664
|
+
.filter(isRunSourceRow);
|
|
2665
|
+
const rootRow = rows.find((row) => row.mountPath === "");
|
|
2666
|
+
const root =
|
|
2667
|
+
rootRow === undefined ||
|
|
2668
|
+
(rootRow.sourceType === "file" && rootRow.sourceRef.startsWith("inline:"))
|
|
2669
|
+
? undefined
|
|
2670
|
+
: workflowSourceFromRow(rootRow);
|
|
2671
|
+
const mountNames = new Map(
|
|
2672
|
+
(snapshot.composition?.mounts ?? []).map((mount) => [
|
|
2673
|
+
mount.mountPath.join("/"),
|
|
2674
|
+
mount.workflowName,
|
|
2675
|
+
]),
|
|
2676
|
+
);
|
|
2677
|
+
const mounted = rows
|
|
2678
|
+
.filter((row) => row.mountPath !== "")
|
|
2679
|
+
.map((row) => ({
|
|
2680
|
+
mountPath: row.mountPath.split("/"),
|
|
2681
|
+
workflowName: mountNames.get(row.mountPath) ?? row.mountPath,
|
|
2682
|
+
source: workflowSourceFromRow(row),
|
|
2683
|
+
}));
|
|
2684
|
+
return { ...(root === undefined ? {} : { root }), mounted };
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
private carriedStepCount(runId: string): number {
|
|
2688
|
+
const row = this.state.connection
|
|
2689
|
+
.prepare(
|
|
2690
|
+
`SELECT count(*) AS count
|
|
2691
|
+
FROM run_steps s JOIN node_attempts a ON a.attempt_id = s.attempt_id
|
|
2692
|
+
WHERE s.run_id = ? AND a.run_id <> s.run_id`,
|
|
2693
|
+
)
|
|
2694
|
+
.get(runId);
|
|
2695
|
+
return isCountRow(row) ? row.count : 0;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
private readActiveAttempt(runId: string):
|
|
2699
|
+
| {
|
|
2700
|
+
attemptId: string;
|
|
2701
|
+
nodeId: string;
|
|
2702
|
+
startedAt: number | null;
|
|
2703
|
+
settingsScopeId: string | null;
|
|
2704
|
+
settingsChangeNumber: number | null;
|
|
2705
|
+
settingsHash: Buffer | null;
|
|
2706
|
+
}
|
|
2707
|
+
| undefined {
|
|
2708
|
+
const row = this.state.connection
|
|
2709
|
+
.prepare(
|
|
2710
|
+
`SELECT attempt_id AS attemptId, node_id AS nodeId, started_at AS startedAt,
|
|
2711
|
+
settings_scope_id AS settingsScopeId,
|
|
2712
|
+
settings_change_number AS settingsChangeNumber,
|
|
2713
|
+
settings_hash AS settingsHash
|
|
2714
|
+
FROM node_attempts
|
|
2715
|
+
WHERE run_id = ? AND status IN ('pending', 'running', 'waiting', 'interrupted')`,
|
|
2716
|
+
)
|
|
2717
|
+
.get(runId);
|
|
2718
|
+
return isActiveAttemptRow(row) ? row : undefined;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
private readHumanDecisionReceipt(runId: string): HumanDecisionReceipt | undefined {
|
|
2722
|
+
const row = this.state.connection
|
|
2723
|
+
.prepare(
|
|
2724
|
+
`SELECT d.request_hash AS requestHash, r.response_hash AS responseHash
|
|
2725
|
+
FROM continuations c
|
|
2726
|
+
JOIN human_decisions d ON d.decision_id = c.decision_id
|
|
2727
|
+
JOIN human_decision_resolutions r ON r.decision_id = c.decision_id
|
|
2728
|
+
WHERE c.continuation_run_id = ? AND r.outcome = 'accepted'`,
|
|
2729
|
+
)
|
|
2730
|
+
.get(runId);
|
|
2731
|
+
if (!isDecisionReceiptRow(row)) return undefined;
|
|
2732
|
+
const request = this.readJsonAs<Record<string, unknown>>(row.requestHash);
|
|
2733
|
+
const decision = this.readJsonAs<Record<string, unknown>>(row.responseHash);
|
|
2734
|
+
if (
|
|
2735
|
+
typeof request.decisionId !== "string" ||
|
|
2736
|
+
typeof request.requestDigest !== "string" ||
|
|
2737
|
+
typeof request.nodeId !== "string" ||
|
|
2738
|
+
typeof decision.provenance !== "string" ||
|
|
2739
|
+
typeof decision.acceptedAt !== "string" ||
|
|
2740
|
+
typeof decision.answerDigest !== "string" ||
|
|
2741
|
+
typeof decision.subjectDigest !== "string" ||
|
|
2742
|
+
typeof decision.presentationDigest !== "string" ||
|
|
2743
|
+
typeof decision.revision !== "number" ||
|
|
2744
|
+
decision.response === undefined
|
|
2745
|
+
) {
|
|
2746
|
+
throw new Error(`Human decision receipt is invalid for run ${runId}`);
|
|
2747
|
+
}
|
|
2748
|
+
return {
|
|
2749
|
+
schema: "pi-workflows.human-decision-receipt.v1",
|
|
2750
|
+
decisionId: request.decisionId,
|
|
2751
|
+
requestDigest: request.requestDigest,
|
|
2752
|
+
nodeId: request.nodeId,
|
|
2753
|
+
response: decision.response as HumanDecisionReceipt["response"],
|
|
2754
|
+
provenance: decision.provenance === "timeout" ? "timeout" : "human",
|
|
2755
|
+
acceptedAt: decision.acceptedAt,
|
|
2756
|
+
answerDigest: decision.answerDigest,
|
|
2757
|
+
subjectDigest: decision.subjectDigest,
|
|
2758
|
+
presentationDigest: decision.presentationDigest,
|
|
2759
|
+
revision: decision.revision,
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
private readUpdates(runId: string): Pick<WorkflowRunState, "updates"> {
|
|
2764
|
+
const rows = this.state.connection
|
|
2765
|
+
.prepare(
|
|
2766
|
+
`SELECT u.update_id AS updateId, u.run_revision AS runRevision,
|
|
2767
|
+
a.node_id AS nodeId, u.attempt_id AS attemptId,
|
|
2768
|
+
u.update_type AS updateType, u.update_key AS updateKey,
|
|
2769
|
+
u.data_hash AS dataHash, u.recorded_at AS recordedAt
|
|
2770
|
+
FROM workflow_updates u
|
|
2771
|
+
JOIN node_attempts a ON a.attempt_id = u.attempt_id
|
|
2772
|
+
WHERE a.run_id = ? ORDER BY u.run_revision`,
|
|
2773
|
+
)
|
|
2774
|
+
.all(runId)
|
|
2775
|
+
.filter(isUpdateRow);
|
|
2776
|
+
if (rows.length === 0) return {};
|
|
2777
|
+
let updates: WorkflowUpdateRecord[] | undefined;
|
|
2778
|
+
for (const row of rows) {
|
|
2779
|
+
updates = updateProjection(updates, {
|
|
2780
|
+
updateId: row.updateId,
|
|
2781
|
+
seq: row.runRevision,
|
|
2782
|
+
at: new Date(row.recordedAt).toISOString(),
|
|
2783
|
+
runId,
|
|
2784
|
+
nodeId: row.nodeId,
|
|
2785
|
+
attemptId: row.attemptId,
|
|
2786
|
+
type: row.updateType,
|
|
2787
|
+
key: row.updateKey,
|
|
2788
|
+
data: this.readJsonAs<Record<string, unknown>>(row.dataHash),
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
return updates === undefined ? {} : { updates };
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
private readJsonAs<T>(hash: Buffer): T {
|
|
2795
|
+
return this.state.readJson(hash) as T;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
private readText(hash: Buffer): string {
|
|
2799
|
+
const blob = this.state.readBlob(hash);
|
|
2800
|
+
if (blob === undefined || blob.mediaType !== "text/plain") {
|
|
2801
|
+
throw new Error("Text blob is missing or has the wrong media type");
|
|
2802
|
+
}
|
|
2803
|
+
return blob.content.toString("utf8");
|
|
1082
2804
|
}
|
|
1083
2805
|
|
|
1084
2806
|
private readDefinition(hash: Buffer): WorkflowDefinitionSnapshot {
|
|
@@ -1318,12 +3040,13 @@ function insertRunEvent(
|
|
|
1318
3040
|
at: string,
|
|
1319
3041
|
event: WorkflowTraceEventDraft | WorkflowTraceEvent,
|
|
1320
3042
|
): void {
|
|
3043
|
+
const payload = compactTracePayload(event.type, event.payload);
|
|
1321
3044
|
const payloadHash = state.putJson(
|
|
1322
3045
|
{
|
|
1323
3046
|
scope: event.scope,
|
|
1324
3047
|
...(event.nodeId === undefined ? {} : { nodeId: event.nodeId }),
|
|
1325
3048
|
...(event.attemptId === undefined ? {} : { attemptId: event.attemptId }),
|
|
1326
|
-
payload
|
|
3049
|
+
payload,
|
|
1327
3050
|
},
|
|
1328
3051
|
Date.parse(at),
|
|
1329
3052
|
);
|
|
@@ -1339,6 +3062,40 @@ function insertRunEvent(
|
|
|
1339
3062
|
);
|
|
1340
3063
|
}
|
|
1341
3064
|
|
|
3065
|
+
function compactTracePayload(
|
|
3066
|
+
eventType: string,
|
|
3067
|
+
payload: Record<string, unknown>,
|
|
3068
|
+
): Record<string, unknown> {
|
|
3069
|
+
if (eventType === "agent_prompt_sent" && Object.hasOwn(payload, "prompt")) {
|
|
3070
|
+
const { prompt: _prompt, ...rest } = payload;
|
|
3071
|
+
return { ...rest, promptStored: true };
|
|
3072
|
+
}
|
|
3073
|
+
if (eventType === "node_finished" || eventType === "node_failed") {
|
|
3074
|
+
const {
|
|
3075
|
+
output: _output,
|
|
3076
|
+
action: _action,
|
|
3077
|
+
assistantMessage: _assistantMessage,
|
|
3078
|
+
conversation: _conversation,
|
|
3079
|
+
error: _error,
|
|
3080
|
+
...rest
|
|
3081
|
+
} = payload;
|
|
3082
|
+
return { ...rest, ...(Object.hasOwn(payload, "output") ? { outputStored: true } : {}) };
|
|
3083
|
+
}
|
|
3084
|
+
if (eventType === "include_exited" && Object.hasOwn(payload, "output")) {
|
|
3085
|
+
const { output: _output, ...rest } = payload;
|
|
3086
|
+
return { ...rest, outputStored: true };
|
|
3087
|
+
}
|
|
3088
|
+
if (eventType === "run_started" && Object.hasOwn(payload, "input")) {
|
|
3089
|
+
const { input: _input, ...rest } = payload;
|
|
3090
|
+
return { ...rest, inputStored: true };
|
|
3091
|
+
}
|
|
3092
|
+
if (Object.hasOwn(payload, "finalOutput")) {
|
|
3093
|
+
const { finalOutput: _finalOutput, ...rest } = payload;
|
|
3094
|
+
return { ...rest, finalOutputStored: true };
|
|
3095
|
+
}
|
|
3096
|
+
return payload;
|
|
3097
|
+
}
|
|
3098
|
+
|
|
1342
3099
|
function insertGenericEvent(
|
|
1343
3100
|
state: StateDatabase,
|
|
1344
3101
|
resourceId: string,
|
|
@@ -1383,6 +3140,22 @@ function sourceForState(
|
|
|
1383
3140
|
};
|
|
1384
3141
|
}
|
|
1385
3142
|
|
|
3143
|
+
function sourceParts(source: WorkflowSource): {
|
|
3144
|
+
type: "builtin" | "file";
|
|
3145
|
+
ref: string;
|
|
3146
|
+
revision: string;
|
|
3147
|
+
} {
|
|
3148
|
+
return source.kind === "builtin"
|
|
3149
|
+
? { type: "builtin", ref: source.id, revision: source.revision }
|
|
3150
|
+
: { type: "file", ref: source.path, revision: source.hash };
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
function workflowSourceFromRow(row: RunSourceRow): WorkflowSource {
|
|
3154
|
+
return row.sourceType === "builtin"
|
|
3155
|
+
? { kind: "builtin", id: row.sourceRef, revision: row.sourceRevision }
|
|
3156
|
+
: { kind: "file", path: row.sourceRef, hash: row.sourceRevision };
|
|
3157
|
+
}
|
|
3158
|
+
|
|
1386
3159
|
function segmentIdFor(runId: string, attemptId?: string): string {
|
|
1387
3160
|
return `segment-${createHash("sha256")
|
|
1388
3161
|
.update(`${runId}\0${attemptId ?? ""}`)
|
|
@@ -1407,6 +3180,74 @@ function outcomeStatus(outcome: string): string {
|
|
|
1407
3180
|
}
|
|
1408
3181
|
}
|
|
1409
3182
|
|
|
3183
|
+
function outcomeForStatus(status: string): WorkflowStepRecord["outcome"] {
|
|
3184
|
+
switch (status) {
|
|
3185
|
+
case "completed":
|
|
3186
|
+
return "ok";
|
|
3187
|
+
case "timed_out":
|
|
3188
|
+
return "timed_out";
|
|
3189
|
+
case "cancelled":
|
|
3190
|
+
return "cancelled";
|
|
3191
|
+
case "failed":
|
|
3192
|
+
return "failed";
|
|
3193
|
+
default:
|
|
3194
|
+
throw new Error(`Workflow step has nonterminal status: ${status}`);
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
function attemptReceipt(step: WorkflowStepRecord): StoredAttemptReceipt {
|
|
3199
|
+
return {
|
|
3200
|
+
...(step.action === undefined ? {} : { action: step.action }),
|
|
3201
|
+
...(step.assistantMessage === undefined ? {} : { assistantMessage: step.assistantMessage }),
|
|
3202
|
+
};
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
function promptFromEntry(value: unknown): string | null {
|
|
3206
|
+
if (!isRecord(value)) return null;
|
|
3207
|
+
const content = value.content;
|
|
3208
|
+
if (typeof content === "string") return content;
|
|
3209
|
+
if (!Array.isArray(content)) return null;
|
|
3210
|
+
return content
|
|
3211
|
+
.filter(isRecord)
|
|
3212
|
+
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
3213
|
+
.filter((text) => text.length !== 0)
|
|
3214
|
+
.join("\n");
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
function assistantOutputFromEntry(value: unknown, maxChars?: number): string {
|
|
3218
|
+
if (!isRecord(value) || !isRecord(value.message)) {
|
|
3219
|
+
throw new Error("Assistant response entry is invalid");
|
|
3220
|
+
}
|
|
3221
|
+
const message = value.message;
|
|
3222
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
3223
|
+
throw new Error("Assistant response entry does not contain an assistant message");
|
|
3224
|
+
}
|
|
3225
|
+
const text = message.content
|
|
3226
|
+
.filter(isRecord)
|
|
3227
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
3228
|
+
.map((part) => part.text as string)
|
|
3229
|
+
.join("\n");
|
|
3230
|
+
if (text.trim().length === 0) throw new Error("Assistant response entry has no visible text");
|
|
3231
|
+
if (maxChars !== undefined && text.length > maxChars) {
|
|
3232
|
+
throw new Error(`Assistant response exceeds its stored limit of ${maxChars} characters`);
|
|
3233
|
+
}
|
|
3234
|
+
return text;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
function resultForStep(step: WorkflowStepRecord): WorkflowNodeResult {
|
|
3238
|
+
return {
|
|
3239
|
+
attemptId: step.attemptId,
|
|
3240
|
+
nodeId: step.nodeId,
|
|
3241
|
+
nodeType: step.nodeType,
|
|
3242
|
+
outcome: step.outcome,
|
|
3243
|
+
startedAt: step.startedAt,
|
|
3244
|
+
finishedAt: step.finishedAt,
|
|
3245
|
+
durationMs: Date.parse(step.finishedAt) - Date.parse(step.startedAt),
|
|
3246
|
+
...(step.outcome === "ok" ? { output: step.output } : {}),
|
|
3247
|
+
...(step.error === undefined ? {} : { error: step.error }),
|
|
3248
|
+
};
|
|
3249
|
+
}
|
|
3250
|
+
|
|
1410
3251
|
function traceScope(value: unknown): WorkflowTraceEvent["scope"] {
|
|
1411
3252
|
return value === "node" || value === "agent" || value === "action" || value === "session"
|
|
1412
3253
|
? value
|
|
@@ -1438,6 +3279,12 @@ function validateSessionEventRecord(record: WorkflowSessionEventRecord): void {
|
|
|
1438
3279
|
) {
|
|
1439
3280
|
throw new Error("Session event is missing required envelope fields");
|
|
1440
3281
|
}
|
|
3282
|
+
if (
|
|
3283
|
+
record.type === "assistant_event" &&
|
|
3284
|
+
["text_delta", "thinking_delta", "toolcall_delta"].includes(String(record.payload.type))
|
|
3285
|
+
) {
|
|
3286
|
+
throw new Error("Incremental assistant events are not durable session facts");
|
|
3287
|
+
}
|
|
1441
3288
|
}
|
|
1442
3289
|
|
|
1443
3290
|
function validateSessionCapture(capture: WorkflowSessionCapture): void {
|
|
@@ -1462,6 +3309,178 @@ function validateSessionCapture(capture: WorkflowSessionCapture): void {
|
|
|
1462
3309
|
}
|
|
1463
3310
|
}
|
|
1464
3311
|
|
|
3312
|
+
function followUpIdFor(runId: string, requestId: string): string {
|
|
3313
|
+
const digest = createHash("sha256").update(`${runId}\0${requestId}`).digest("hex").slice(0, 40);
|
|
3314
|
+
return `follow-up-${digest}`;
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
function assertRequestId(requestId: string): void {
|
|
3318
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u.test(requestId)) {
|
|
3319
|
+
throw new Error("Workflow request ID must be a stable identifier of at most 512 characters");
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
function assertSourceType(source: string): void {
|
|
3324
|
+
if (typeof source !== "string" || source.trim().length === 0 || source.length > 128) {
|
|
3325
|
+
throw new Error("Workflow change source must be a non-empty string of at most 128 characters");
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
function assertActorType(value: string): ActorType {
|
|
3330
|
+
if (
|
|
3331
|
+
value !== "session" &&
|
|
3332
|
+
value !== "host" &&
|
|
3333
|
+
value !== "controller" &&
|
|
3334
|
+
value !== "channel" &&
|
|
3335
|
+
value !== "human" &&
|
|
3336
|
+
value !== "policy" &&
|
|
3337
|
+
value !== "control" &&
|
|
3338
|
+
value !== "system"
|
|
3339
|
+
) {
|
|
3340
|
+
throw new Error(`Unknown saved actor type: ${value}`);
|
|
3341
|
+
}
|
|
3342
|
+
return value;
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
function assertFollowUpState(value: string): WorkflowFollowUpState {
|
|
3346
|
+
if (
|
|
3347
|
+
value !== "queued" &&
|
|
3348
|
+
value !== "pending_presentation" &&
|
|
3349
|
+
value !== "ready" &&
|
|
3350
|
+
value !== "sent" &&
|
|
3351
|
+
value !== "removed" &&
|
|
3352
|
+
value !== "cancelled"
|
|
3353
|
+
) {
|
|
3354
|
+
throw new Error(`Unknown workflow follow-up state: ${value}`);
|
|
3355
|
+
}
|
|
3356
|
+
return value;
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
function assertPresentationState(value: string): WorkflowPresentationState {
|
|
3360
|
+
if (
|
|
3361
|
+
value !== "none" &&
|
|
3362
|
+
value !== "not-needed" &&
|
|
3363
|
+
value !== "pending" &&
|
|
3364
|
+
value !== "settled" &&
|
|
3365
|
+
value !== "unavailable"
|
|
3366
|
+
) {
|
|
3367
|
+
throw new Error(`Unknown workflow presentation state: ${value}`);
|
|
3368
|
+
}
|
|
3369
|
+
return value;
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
function isSettingsScopeRow(value: unknown): value is SettingsScopeRow {
|
|
3373
|
+
return (
|
|
3374
|
+
isRecord(value) &&
|
|
3375
|
+
typeof value.scopeId === "string" &&
|
|
3376
|
+
typeof value.resourceId === "string" &&
|
|
3377
|
+
typeof value.originRunId === "string" &&
|
|
3378
|
+
typeof value.activeRunId === "string" &&
|
|
3379
|
+
typeof value.mountPath === "string" &&
|
|
3380
|
+
typeof value.invocation === "number" &&
|
|
3381
|
+
Buffer.isBuffer(value.currentHash) &&
|
|
3382
|
+
typeof value.revision === "number" &&
|
|
3383
|
+
typeof value.createdAt === "number" &&
|
|
3384
|
+
typeof value.updatedAt === "number"
|
|
3385
|
+
);
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
function isSettingsProjectionRow(value: unknown): value is SettingsProjectionRow {
|
|
3389
|
+
return (
|
|
3390
|
+
isRecord(value) &&
|
|
3391
|
+
typeof value.scopeId === "string" &&
|
|
3392
|
+
Buffer.isBuffer(value.initialHash) &&
|
|
3393
|
+
Buffer.isBuffer(value.currentHash) &&
|
|
3394
|
+
typeof value.revision === "number"
|
|
3395
|
+
);
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
function isSettingsProjectionChangeRow(value: unknown): value is SettingsProjectionChangeRow {
|
|
3399
|
+
return (
|
|
3400
|
+
isRecord(value) &&
|
|
3401
|
+
typeof value.changeNumber === "number" &&
|
|
3402
|
+
Buffer.isBuffer(value.patchHash) &&
|
|
3403
|
+
Buffer.isBuffer(value.beforeHash) &&
|
|
3404
|
+
Buffer.isBuffer(value.afterHash)
|
|
3405
|
+
);
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
function isSettingsChangeRow(value: unknown): value is SettingsChangeRow {
|
|
3409
|
+
return (
|
|
3410
|
+
isRecord(value) &&
|
|
3411
|
+
typeof value.changeId === "string" &&
|
|
3412
|
+
typeof value.scopeId === "string" &&
|
|
3413
|
+
typeof value.requestId === "string" &&
|
|
3414
|
+
typeof value.changeNumber === "number" &&
|
|
3415
|
+
typeof value.actorType === "string" &&
|
|
3416
|
+
(typeof value.actorId === "string" || value.actorId === null) &&
|
|
3417
|
+
typeof value.sourceType === "string" &&
|
|
3418
|
+
Buffer.isBuffer(value.patchHash) &&
|
|
3419
|
+
Buffer.isBuffer(value.beforeHash) &&
|
|
3420
|
+
Buffer.isBuffer(value.afterHash) &&
|
|
3421
|
+
typeof value.acceptedAt === "number"
|
|
3422
|
+
);
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
function isFollowUpQueueRow(value: unknown): value is FollowUpQueueRow {
|
|
3426
|
+
return (
|
|
3427
|
+
isRecord(value) &&
|
|
3428
|
+
typeof value.runId === "string" &&
|
|
3429
|
+
typeof value.resourceId === "string" &&
|
|
3430
|
+
(typeof value.originSessionId === "string" || value.originSessionId === null) &&
|
|
3431
|
+
typeof value.presentationState === "string" &&
|
|
3432
|
+
(typeof value.presentationEntryId === "string" || value.presentationEntryId === null) &&
|
|
3433
|
+
(typeof value.presentationAssistantEntryId === "string" ||
|
|
3434
|
+
value.presentationAssistantEntryId === null) &&
|
|
3435
|
+
(Buffer.isBuffer(value.presentationReasonHash) || value.presentationReasonHash === null) &&
|
|
3436
|
+
typeof value.revision === "number"
|
|
3437
|
+
);
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
function isFollowUpRow(value: unknown): value is FollowUpRow {
|
|
3441
|
+
return (
|
|
3442
|
+
isRecord(value) &&
|
|
3443
|
+
typeof value.followUpId === "string" &&
|
|
3444
|
+
typeof value.resourceId === "string" &&
|
|
3445
|
+
typeof value.queueResourceId === "string" &&
|
|
3446
|
+
typeof value.runId === "string" &&
|
|
3447
|
+
typeof value.requestId === "string" &&
|
|
3448
|
+
typeof value.orderNumber === "number" &&
|
|
3449
|
+
typeof value.targetSessionId === "string" &&
|
|
3450
|
+
typeof value.actorType === "string" &&
|
|
3451
|
+
(typeof value.actorId === "string" || value.actorId === null) &&
|
|
3452
|
+
typeof value.sourceType === "string" &&
|
|
3453
|
+
Buffer.isBuffer(value.promptHash) &&
|
|
3454
|
+
typeof value.status === "string" &&
|
|
3455
|
+
(typeof value.sessionEntryId === "string" || value.sessionEntryId === null) &&
|
|
3456
|
+
(Buffer.isBuffer(value.reasonHash) || value.reasonHash === null) &&
|
|
3457
|
+
typeof value.createdAt === "number" &&
|
|
3458
|
+
typeof value.updatedAt === "number" &&
|
|
3459
|
+
(typeof value.sentAt === "number" || value.sentAt === null) &&
|
|
3460
|
+
typeof value.revision === "number"
|
|
3461
|
+
);
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
function isParentRunRow(value: unknown): value is { parentRunId: string | null } {
|
|
3465
|
+
return isRecord(value) && (typeof value.parentRunId === "string" || value.parentRunId === null);
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
function isStatusRow(value: unknown): value is { status: string } {
|
|
3469
|
+
return isRecord(value) && typeof value.status === "string";
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
function isCountRow(value: unknown): value is { count: number } {
|
|
3473
|
+
return isRecord(value) && typeof value.count === "number";
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
function isLeaseExpiryRow(value: unknown): value is { expiresAt: number | null } {
|
|
3477
|
+
return isRecord(value) && (typeof value.expiresAt === "number" || value.expiresAt === null);
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
function isNextOrderRow(value: unknown): value is { nextOrder: number } {
|
|
3481
|
+
return isRecord(value) && typeof value.nextOrder === "number";
|
|
3482
|
+
}
|
|
3483
|
+
|
|
1465
3484
|
function assertValidRunId(runId: string): void {
|
|
1466
3485
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
|
|
1467
3486
|
throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
|
|
@@ -1476,10 +3495,108 @@ function isRunRow(value: unknown): value is RunRow {
|
|
|
1476
3495
|
return isRecord(value);
|
|
1477
3496
|
}
|
|
1478
3497
|
|
|
3498
|
+
function savedCurrentSettingsBinding(
|
|
3499
|
+
scopeId: string | null,
|
|
3500
|
+
changeNumber: number | null,
|
|
3501
|
+
settingsHash: Buffer | null,
|
|
3502
|
+
): Pick<
|
|
3503
|
+
WorkflowRunState,
|
|
3504
|
+
"currentSettingsScopeId" | "currentSettingsChangeNumber" | "currentSettingsHash"
|
|
3505
|
+
> {
|
|
3506
|
+
if (scopeId === null && changeNumber === null && settingsHash === null) return {};
|
|
3507
|
+
if (scopeId === null || changeNumber === null || settingsHash === null) {
|
|
3508
|
+
throw new Error("Saved current workflow settings binding is incomplete");
|
|
3509
|
+
}
|
|
3510
|
+
return {
|
|
3511
|
+
currentSettingsScopeId: scopeId,
|
|
3512
|
+
currentSettingsChangeNumber: changeNumber,
|
|
3513
|
+
currentSettingsHash: settingsHash.toString("hex"),
|
|
3514
|
+
};
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
function savedStepSettingsBinding(
|
|
3518
|
+
scopeId: string | null,
|
|
3519
|
+
changeNumber: number | null,
|
|
3520
|
+
settingsHash: Buffer | null,
|
|
3521
|
+
): Pick<WorkflowStepRecord, "settingsScopeId" | "settingsChangeNumber" | "settingsHash"> {
|
|
3522
|
+
if (scopeId === null && changeNumber === null && settingsHash === null) return {};
|
|
3523
|
+
if (scopeId === null || changeNumber === null || settingsHash === null) {
|
|
3524
|
+
throw new Error("Saved workflow step settings binding is incomplete");
|
|
3525
|
+
}
|
|
3526
|
+
return {
|
|
3527
|
+
settingsScopeId: scopeId,
|
|
3528
|
+
settingsChangeNumber: changeNumber,
|
|
3529
|
+
settingsHash: settingsHash.toString("hex"),
|
|
3530
|
+
};
|
|
3531
|
+
}
|
|
3532
|
+
|
|
1479
3533
|
function isRunIdRow(value: unknown): value is { runId: string } {
|
|
1480
3534
|
return isRecord(value);
|
|
1481
3535
|
}
|
|
1482
3536
|
|
|
3537
|
+
function isRunStepIdentityRow(value: unknown): value is {
|
|
3538
|
+
stepIndex: number;
|
|
3539
|
+
attemptId: string;
|
|
3540
|
+
} {
|
|
3541
|
+
return isRecord(value);
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
function isStepRow(value: unknown): value is StepRow {
|
|
3545
|
+
return isRecord(value);
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
function isRunSourceRow(value: unknown): value is RunSourceRow {
|
|
3549
|
+
return isRecord(value);
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
function isSegmentIdentityRow(value: unknown): value is { segmentId: string; entryHash: Buffer } {
|
|
3553
|
+
return isRecord(value) && typeof value.segmentId === "string" && Buffer.isBuffer(value.entryHash);
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
function isEntryIdentityRow(value: unknown): value is { entryId: string } {
|
|
3557
|
+
return isRecord(value) && typeof value.entryId === "string";
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
function isAttemptValueRow(value: unknown): value is {
|
|
3561
|
+
outputHash: Buffer | null;
|
|
3562
|
+
receiptHash: Buffer | null;
|
|
3563
|
+
responseEntryHash: Buffer | null;
|
|
3564
|
+
} {
|
|
3565
|
+
return isRecord(value);
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
function isActiveAttemptRow(value: unknown): value is {
|
|
3569
|
+
attemptId: string;
|
|
3570
|
+
nodeId: string;
|
|
3571
|
+
startedAt: number | null;
|
|
3572
|
+
settingsScopeId: string | null;
|
|
3573
|
+
settingsChangeNumber: number | null;
|
|
3574
|
+
settingsHash: Buffer | null;
|
|
3575
|
+
} {
|
|
3576
|
+
return isRecord(value);
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
function isDecisionReceiptRow(value: unknown): value is {
|
|
3580
|
+
requestHash: Buffer;
|
|
3581
|
+
responseHash: Buffer;
|
|
3582
|
+
} {
|
|
3583
|
+
return (
|
|
3584
|
+
isRecord(value) && Buffer.isBuffer(value.requestHash) && Buffer.isBuffer(value.responseHash)
|
|
3585
|
+
);
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
function isContinuationIdentityRow(value: unknown): value is {
|
|
3589
|
+
parentRunId: string;
|
|
3590
|
+
continuationRunId: string;
|
|
3591
|
+
createdAt: number;
|
|
3592
|
+
} {
|
|
3593
|
+
return isRecord(value);
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
function isUpdateRow(value: unknown): value is UpdateRow {
|
|
3597
|
+
return isRecord(value);
|
|
3598
|
+
}
|
|
3599
|
+
|
|
1483
3600
|
function isRevisionRow(value: unknown): value is { revision: number } {
|
|
1484
3601
|
return isRecord(value);
|
|
1485
3602
|
}
|
|
@@ -1519,7 +3636,15 @@ function isLeaseAuthorityRow(value: unknown): value is {
|
|
|
1519
3636
|
}
|
|
1520
3637
|
|
|
1521
3638
|
export function createDefinitionSnapshot(workflow: WorkflowDefinition): WorkflowDefinitionSnapshot {
|
|
1522
|
-
const
|
|
3639
|
+
const metadata = compositionMetadata(workflow);
|
|
3640
|
+
const composition = metadata?.snapshot;
|
|
3641
|
+
const settingsScopes = Object.values(metadata?.scopes ?? {})
|
|
3642
|
+
.filter((scope) => scope.path !== "" && scope.settings !== undefined)
|
|
3643
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
3644
|
+
.map((scope) => ({
|
|
3645
|
+
mountPath: scope.path.split("/"),
|
|
3646
|
+
settings: snapshotSettings(scope.settings!),
|
|
3647
|
+
}));
|
|
1523
3648
|
return {
|
|
1524
3649
|
schema: DEFINITION_SNAPSHOT_SCHEMA,
|
|
1525
3650
|
name: workflow.name,
|
|
@@ -1533,6 +3658,8 @@ export function createDefinitionSnapshot(workflow: WorkflowDefinition): Workflow
|
|
|
1533
3658
|
),
|
|
1534
3659
|
edges: structuredClone(workflow.edges),
|
|
1535
3660
|
...(composition !== undefined ? { composition: structuredClone(composition) } : {}),
|
|
3661
|
+
...(workflow.settings !== undefined ? { settings: snapshotSettings(workflow.settings) } : {}),
|
|
3662
|
+
...(settingsScopes.length > 0 ? { settingsScopes } : {}),
|
|
1536
3663
|
};
|
|
1537
3664
|
}
|
|
1538
3665
|
|
|
@@ -1573,6 +3700,9 @@ function snapshotNode(
|
|
|
1573
3700
|
if (node.nodeType === "agent" && node.expectedOutput !== undefined) {
|
|
1574
3701
|
common.expectedOutput = node.expectedOutput;
|
|
1575
3702
|
}
|
|
3703
|
+
if (node.nodeType === "compute" && node.settingsRoute === true) {
|
|
3704
|
+
common.settingsRoute = true;
|
|
3705
|
+
}
|
|
1576
3706
|
if (node.nodeType === "notify") common.summary = node.kind ?? "progress";
|
|
1577
3707
|
if (node.nodeType === "checkpoint" && node.summary !== undefined) common.summary = node.summary;
|
|
1578
3708
|
if (node.nodeType === "checkpoint" && node.humanDecision !== undefined) {
|
|
@@ -1591,3 +3721,20 @@ function snapshotNode(
|
|
|
1591
3721
|
if (node.nodeType === "action") common.actionExecution = "exec" in node ? "shell" : "function";
|
|
1592
3722
|
return common;
|
|
1593
3723
|
}
|
|
3724
|
+
|
|
3725
|
+
function snapshotSettings(
|
|
3726
|
+
settings: NonNullable<WorkflowDefinition["settings"]>,
|
|
3727
|
+
): WorkflowSettingsSnapshot {
|
|
3728
|
+
return {
|
|
3729
|
+
...(settings.description !== undefined ? { description: settings.description } : {}),
|
|
3730
|
+
paths: settings.paths.map((rule) => ({
|
|
3731
|
+
path: rule.path,
|
|
3732
|
+
permissions: Object.fromEntries(
|
|
3733
|
+
Object.entries(rule.permissions).map(([permission, actors]) => [
|
|
3734
|
+
permission,
|
|
3735
|
+
actors === undefined ? [] : [...actors],
|
|
3736
|
+
]),
|
|
3737
|
+
),
|
|
3738
|
+
})),
|
|
3739
|
+
};
|
|
3740
|
+
}
|