@osolmaz/pi-workflows 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -110
- package/dist/builtins/autoimplement.workflow.js +3 -1
- package/dist/builtins/autoimplement.workflow.js.map +1 -1
- package/dist/builtins/change-verification.workflow.js +7 -2
- package/dist/builtins/change-verification.workflow.js.map +1 -1
- package/dist/builtins/metadata.d.ts +22 -0
- package/dist/builtins/metadata.js +10 -0
- package/dist/builtins/metadata.js.map +1 -0
- package/dist/builtins/monitor.workflow.js +4 -1
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/sanity-check.workflow.js +4 -1
- package/dist/builtins/sanity-check.workflow.js.map +1 -1
- package/dist/builtins/workspace-preparation.workflow.js +3 -1
- package/dist/builtins/workspace-preparation.workflow.js.map +1 -1
- package/dist/controllers/index.d.ts +0 -1
- package/dist/controllers/index.js +0 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +51 -0
- package/dist/controllers/sqlite.js +433 -57
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/store.d.ts +1 -0
- package/dist/extension/controller-command.d.ts +22 -0
- package/dist/extension/controller-command.js +32 -0
- package/dist/extension/controller-command.js.map +1 -0
- package/dist/extension/index.d.ts +2 -9
- package/dist/extension/index.js +876 -3447
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/message-card.d.ts +13 -0
- package/dist/extension/message-card.js +46 -0
- package/dist/extension/message-card.js.map +1 -0
- package/dist/extension/step-message.d.ts +5 -6
- package/dist/extension/step-message.js +41 -52
- package/dist/extension/step-message.js.map +1 -1
- package/dist/host/child-worker-supervisor.d.ts +50 -0
- package/dist/host/child-worker-supervisor.js +159 -0
- package/dist/host/child-worker-supervisor.js.map +1 -0
- package/dist/host/client.d.ts +48 -0
- package/dist/host/client.js +216 -0
- package/dist/host/client.js.map +1 -0
- package/dist/host/controller-worker-entry.d.ts +2 -0
- package/dist/host/controller-worker-entry.js +244 -0
- package/dist/host/controller-worker-entry.js.map +1 -0
- package/dist/host/controller-worker-protocol.d.ts +36 -0
- package/dist/host/controller-worker-protocol.js +49 -0
- package/dist/host/controller-worker-protocol.js.map +1 -0
- package/dist/host/controller-worker-supervisor.d.ts +21 -0
- package/dist/host/controller-worker-supervisor.js +54 -0
- package/dist/host/controller-worker-supervisor.js.map +1 -0
- package/dist/host/host-entry.d.ts +2 -0
- package/dist/host/host-entry.js +23 -0
- package/dist/host/host-entry.js.map +1 -0
- package/dist/host/processes.d.ts +17 -12
- package/dist/host/processes.js +154 -50
- package/dist/host/processes.js.map +1 -1
- package/dist/host/protocol.d.ts +38 -0
- package/dist/host/protocol.js +156 -0
- package/dist/host/protocol.js.map +1 -0
- package/dist/host/resolver-entry.d.ts +49 -0
- package/dist/host/resolver-entry.js +149 -0
- package/dist/host/resolver-entry.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +14 -3
- package/dist/host/rpc-executor.js +63 -30
- package/dist/host/rpc-executor.js.map +1 -1
- package/dist/host/runner.d.ts +95 -29
- package/dist/host/runner.js +2311 -348
- package/dist/host/runner.js.map +1 -1
- package/dist/host/state.d.ts +174 -0
- package/dist/host/state.js +689 -0
- package/dist/host/state.js.map +1 -0
- package/dist/host/worker-entry.d.ts +10 -0
- package/dist/host/worker-entry.js +446 -0
- package/dist/host/worker-entry.js.map +1 -0
- package/dist/host/worker-protocol.d.ts +31 -0
- package/dist/host/worker-protocol.js +122 -0
- package/dist/host/worker-protocol.js.map +1 -0
- package/dist/host/worker-store.d.ts +80 -0
- package/dist/host/worker-store.js +91 -0
- package/dist/host/worker-store.js.map +1 -0
- package/dist/host/worker-supervisor.d.ts +22 -0
- package/dist/host/worker-supervisor.js +54 -0
- package/dist/host/worker-supervisor.js.map +1 -0
- package/dist/render/canvas.d.ts +2 -1
- package/dist/render/canvas.js +40 -15
- package/dist/render/canvas.js.map +1 -1
- package/dist/render/graph-render.d.ts +7 -3
- package/dist/render/graph-render.js +125 -74
- package/dist/render/graph-render.js.map +1 -1
- package/dist/state/database.js +3 -1
- package/dist/state/database.js.map +1 -1
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.js +1 -0
- package/dist/state/index.js.map +1 -1
- package/dist/state/schema.d.ts +1 -1
- package/dist/state/schema.js +171 -3
- package/dist/state/schema.js.map +1 -1
- package/dist/state/viewer.d.ts +46 -0
- package/dist/state/viewer.js +249 -0
- package/dist/state/viewer.js.map +1 -0
- package/dist/viewer/cli.d.ts +1 -1
- package/dist/viewer/cli.js +47 -18
- package/dist/viewer/cli.js.map +1 -1
- package/dist/workflows/composition.js +25 -2
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.d.ts +3 -1
- package/dist/workflows/definition.js +25 -0
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +1 -0
- package/dist/workflows/engine.js +157 -42
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +3 -1
- package/dist/workflows/errors.js +4 -7
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +3 -0
- package/dist/workflows/human-decision.js +31 -0
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +1 -1
- package/dist/workflows/index.js +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/schema.js +17 -6
- package/dist/workflows/schema.js.map +1 -1
- package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
- package/dist/{viewer → workflows}/session-reducer.js +4 -0
- package/dist/workflows/session-reducer.js.map +1 -0
- package/dist/workflows/store.d.ts +87 -1
- package/dist/workflows/store.js +892 -93
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +0 -26
- package/dist/workflows/tool-input.js +2 -50
- package/dist/workflows/tool-input.js.map +1 -1
- package/dist/workflows/types.d.ts +36 -4
- package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
- package/docs/CONTROLLERS.md +11 -11
- package/docs/DEFERRED_TURNS.md +23 -2
- package/docs/SQLITE_STATE.md +41 -31
- package/docs/WORKFLOW_HOST.md +452 -0
- package/docs/development.md +46 -30
- package/docs/live-replay-protocol.md +129 -100
- package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +42 -0
- package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
- package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
- package/docs/plans/tui-viewer-implementation-plan.md +5 -0
- package/docs/tui-viewer.md +18 -3
- package/docs/workflows.md +162 -210
- package/examples/workflows/command-batch.workflow.ts +2 -0
- package/examples/workflows/shell.workflow.ts +2 -1
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/builtins/autoimplement.workflow.ts +3 -0
- package/src/builtins/change-verification.workflow.ts +7 -2
- package/src/builtins/metadata.ts +9 -0
- package/src/builtins/monitor.workflow.ts +4 -0
- package/src/builtins/sanity-check.workflow.ts +4 -0
- package/src/builtins/workspace-preparation.workflow.ts +3 -1
- package/src/controllers/index.ts +0 -5
- package/src/controllers/sqlite.ts +709 -76
- package/src/controllers/store.ts +1 -0
- package/src/extension/controller-command.ts +45 -0
- package/src/extension/index.ts +944 -4163
- package/src/extension/message-card.ts +61 -0
- package/src/extension/step-message.ts +58 -63
- package/src/host/child-worker-supervisor.ts +183 -0
- package/src/host/client.ts +293 -0
- package/src/host/controller-worker-entry.ts +311 -0
- package/src/host/controller-worker-protocol.ts +104 -0
- package/src/host/controller-worker-supervisor.ts +79 -0
- package/src/host/host-entry.ts +23 -0
- package/src/host/processes.ts +171 -54
- package/src/host/protocol.ts +196 -0
- package/src/host/resolver-entry.ts +241 -0
- package/src/host/rpc-executor.ts +76 -34
- package/src/host/runner.ts +2813 -422
- package/src/host/state.ts +1160 -0
- package/src/host/worker-entry.ts +533 -0
- package/src/host/worker-protocol.ts +165 -0
- package/src/host/worker-store.ts +229 -0
- package/src/host/worker-supervisor.ts +74 -0
- package/src/render/canvas.ts +44 -10
- package/src/render/graph-render.ts +145 -90
- package/src/state/database.ts +2 -1
- package/src/state/index.ts +14 -0
- package/src/state/schema.ts +171 -3
- package/src/state/viewer.ts +356 -0
- package/src/viewer/cli.ts +49 -17
- package/src/workflows/composition.ts +36 -2
- package/src/workflows/definition.ts +32 -0
- package/src/workflows/engine.ts +157 -54
- package/src/workflows/errors.ts +11 -2
- package/src/workflows/human-decision.ts +49 -0
- package/src/workflows/index.ts +2 -0
- package/src/workflows/schema.ts +19 -6
- package/src/{viewer → workflows}/session-reducer.ts +11 -1
- package/src/workflows/store.ts +1316 -108
- package/src/workflows/tool-input.ts +3 -69
- package/src/workflows/types.ts +32 -4
- package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
- package/dist/controllers/workflow-engine-scheduler.js +0 -175
- package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
- package/dist/extension/controller-host.d.ts +0 -48
- package/dist/extension/controller-host.js +0 -110
- package/dist/extension/controller-host.js.map +0 -1
- package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
- package/dist/extension/deferred-turn-coordinator.js +0 -143
- package/dist/extension/deferred-turn-coordinator.js.map +0 -1
- package/dist/extension/deferred-turn.d.ts +0 -44
- package/dist/extension/deferred-turn.js +0 -110
- package/dist/extension/deferred-turn.js.map +0 -1
- package/dist/extension/executor.d.ts +0 -86
- package/dist/extension/executor.js +0 -311
- package/dist/extension/executor.js.map +0 -1
- package/dist/extension/follow-up-coordinator.d.ts +0 -27
- package/dist/extension/follow-up-coordinator.js +0 -131
- package/dist/extension/follow-up-coordinator.js.map +0 -1
- package/dist/extension/recorder.d.ts +0 -84
- package/dist/extension/recorder.js +0 -528
- package/dist/extension/recorder.js.map +0 -1
- package/dist/extension/restart-policy.d.ts +0 -38
- package/dist/extension/restart-policy.js +0 -116
- package/dist/extension/restart-policy.js.map +0 -1
- package/dist/extension/session-events.d.ts +0 -133
- package/dist/extension/session-events.js +0 -61
- package/dist/extension/session-events.js.map +0 -1
- package/dist/extension/terminal-decision.d.ts +0 -51
- package/dist/extension/terminal-decision.js +0 -110
- package/dist/extension/terminal-decision.js.map +0 -1
- package/dist/viewer/session-reducer.js.map +0 -1
- package/src/controllers/workflow-engine-scheduler.ts +0 -246
- package/src/extension/controller-host.ts +0 -167
- package/src/extension/deferred-turn-coordinator.ts +0 -171
- package/src/extension/deferred-turn.ts +0 -166
- package/src/extension/executor.ts +0 -411
- package/src/extension/follow-up-coordinator.ts +0 -151
- package/src/extension/recorder.ts +0 -655
- package/src/extension/restart-policy.ts +0 -163
- package/src/extension/session-events.ts +0 -121
- package/src/extension/terminal-decision.ts +0 -172
package/src/workflows/store.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { StateDatabase, workflowStatePath } from "../state/database.js";
|
|
3
|
-
import { canonicalJson, type JsonValue } from "../state/json.js";
|
|
3
|
+
import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
|
|
4
4
|
import {
|
|
5
5
|
StateMutationStore,
|
|
6
6
|
StaleResourceError,
|
|
@@ -11,8 +11,22 @@ import {
|
|
|
11
11
|
type MutationActor,
|
|
12
12
|
type OwnerType,
|
|
13
13
|
} from "../state/mutation.js";
|
|
14
|
+
import {
|
|
15
|
+
initializeViewerRun,
|
|
16
|
+
recordViewerDeltas,
|
|
17
|
+
viewerTailPatch,
|
|
18
|
+
VIEWER_PAGE_SIZE,
|
|
19
|
+
type ViewerDeltaDraft,
|
|
20
|
+
} from "../state/viewer.js";
|
|
14
21
|
import { compositionMetadata } from "./composition.js";
|
|
22
|
+
import { ClaimLostError } from "./errors.js";
|
|
23
|
+
import { HumanDecisionStore } from "./human-decision.js";
|
|
15
24
|
import { applyJsonPatch, validateJsonPatch } from "./json-patch.js";
|
|
25
|
+
import {
|
|
26
|
+
reduceSessionEvents,
|
|
27
|
+
reduceSessionEventsFromCheckpoint,
|
|
28
|
+
type TemporalSessionState,
|
|
29
|
+
} from "./session-reducer.js";
|
|
16
30
|
import {
|
|
17
31
|
applyWorkflowSettingsPatch,
|
|
18
32
|
workflowSettingsScopeId,
|
|
@@ -32,7 +46,11 @@ import {
|
|
|
32
46
|
import type {
|
|
33
47
|
WorkflowDefinition,
|
|
34
48
|
WorkflowDefinitionSnapshot,
|
|
49
|
+
WorkflowEffectRecovery,
|
|
50
|
+
WorkflowEffectReservation,
|
|
35
51
|
HumanDecisionReceipt,
|
|
52
|
+
HumanDecisionRequest,
|
|
53
|
+
ResolvedHumanDecision,
|
|
36
54
|
WorkflowNodeDefinition,
|
|
37
55
|
WorkflowNodeResult,
|
|
38
56
|
WorkflowNodeSnapshot,
|
|
@@ -44,6 +62,7 @@ import type {
|
|
|
44
62
|
WorkflowSessionCapture,
|
|
45
63
|
WorkflowSessionEntryRecord,
|
|
46
64
|
WorkflowSessionEventRecord,
|
|
65
|
+
WorkflowSessionEventType,
|
|
47
66
|
WorkflowSettingsSnapshot,
|
|
48
67
|
WorkflowTraceEvent,
|
|
49
68
|
WorkflowTraceEventDraft,
|
|
@@ -87,14 +106,85 @@ export type RunWriteAuthority = {
|
|
|
87
106
|
ownerId: string;
|
|
88
107
|
token: string;
|
|
89
108
|
generation: number;
|
|
109
|
+
/** Duration used when a protected write renews this exact live claim. */
|
|
110
|
+
leaseMs?: number;
|
|
90
111
|
};
|
|
91
112
|
|
|
92
113
|
export type WorkflowRunStoreOptions = {
|
|
93
114
|
authorityProvider?: (runId: string) => RunWriteAuthority | undefined;
|
|
115
|
+
/** Host-owned projection updates that must commit with each run snapshot. */
|
|
116
|
+
snapshotLifecycle?: (context: {
|
|
117
|
+
runId: string;
|
|
118
|
+
state: WorkflowRunState;
|
|
119
|
+
event: WorkflowTraceEvent;
|
|
120
|
+
database: StateDatabase;
|
|
121
|
+
now: number;
|
|
122
|
+
}) => void;
|
|
94
123
|
state?: StateDatabase;
|
|
95
124
|
readOnly?: boolean;
|
|
96
125
|
};
|
|
97
126
|
|
|
127
|
+
/** The state boundary used by workflow code, including out-of-process workers. */
|
|
128
|
+
export interface WorkflowExecutionStore {
|
|
129
|
+
readonly databasePath: string;
|
|
130
|
+
initializeRun(
|
|
131
|
+
workflow: WorkflowDefinition,
|
|
132
|
+
state: WorkflowRunState,
|
|
133
|
+
options?: InitializeWorkflowRunOptions,
|
|
134
|
+
): Promise<string>;
|
|
135
|
+
prepareRunResume(runId: string): Promise<LoadedWorkflowRun>;
|
|
136
|
+
readRun(
|
|
137
|
+
runId: string,
|
|
138
|
+
options?: ReadWorkflowRunOptions,
|
|
139
|
+
): LoadedWorkflowRun | null | Promise<LoadedWorkflowRun | null>;
|
|
140
|
+
writeSnapshot(
|
|
141
|
+
runId: string,
|
|
142
|
+
state: WorkflowRunState,
|
|
143
|
+
event: WorkflowTraceEventDraft,
|
|
144
|
+
): Promise<WorkflowTraceEvent>;
|
|
145
|
+
publishUpdate(
|
|
146
|
+
runId: string,
|
|
147
|
+
state: WorkflowRunState,
|
|
148
|
+
nodeId: string,
|
|
149
|
+
attemptId: string,
|
|
150
|
+
update: WorkflowUpdateInput,
|
|
151
|
+
options?: { signal?: AbortSignal },
|
|
152
|
+
): Promise<{ event: WorkflowTraceEvent; record: WorkflowUpdateRecord }>;
|
|
153
|
+
findSettingsScope(
|
|
154
|
+
runId: string,
|
|
155
|
+
mountPath: string,
|
|
156
|
+
invocation: number,
|
|
157
|
+
): WorkflowSettingsScopeRecord | undefined | Promise<WorkflowSettingsScopeRecord | undefined>;
|
|
158
|
+
ensureSettingsScope(options: {
|
|
159
|
+
runId: string;
|
|
160
|
+
mountPath: string;
|
|
161
|
+
invocation: number;
|
|
162
|
+
settings: JsonValue;
|
|
163
|
+
}): WorkflowSettingsScopeRecord | Promise<WorkflowSettingsScopeRecord>;
|
|
164
|
+
getSettingsScopeAtChange(
|
|
165
|
+
scopeId: string,
|
|
166
|
+
changeNumber: number,
|
|
167
|
+
): WorkflowSettingsScopeRecord | undefined | Promise<WorkflowSettingsScopeRecord | undefined>;
|
|
168
|
+
createHumanDecisionRequest(request: HumanDecisionRequest): Promise<"created" | "adopted">;
|
|
169
|
+
readResolvedHumanDecision(decisionId: string): Promise<ResolvedHumanDecision | null>;
|
|
170
|
+
reserveEffect(options: {
|
|
171
|
+
runId: string;
|
|
172
|
+
attemptId: string;
|
|
173
|
+
effectType: string;
|
|
174
|
+
idempotencyKey: string;
|
|
175
|
+
request: JsonValue;
|
|
176
|
+
recovery: WorkflowEffectRecovery;
|
|
177
|
+
}): Promise<WorkflowEffectReservation>;
|
|
178
|
+
settleEffect(options: {
|
|
179
|
+
runId: string;
|
|
180
|
+
effectId: string;
|
|
181
|
+
attemptNumber: number;
|
|
182
|
+
outcome: "applied" | "rejected" | "ambiguous" | "cancelled";
|
|
183
|
+
result?: JsonValue;
|
|
184
|
+
error?: string;
|
|
185
|
+
}): Promise<void>;
|
|
186
|
+
}
|
|
187
|
+
|
|
98
188
|
type RunContext = {
|
|
99
189
|
revision: number;
|
|
100
190
|
lock: Promise<void>;
|
|
@@ -168,6 +258,23 @@ type EventRow = {
|
|
|
168
258
|
recordedAt: number;
|
|
169
259
|
};
|
|
170
260
|
|
|
261
|
+
type ViewerSessionCheckpointRow = {
|
|
262
|
+
eventSeq: number;
|
|
263
|
+
stateHash: Buffer;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
type ViewerSessionEventRow = {
|
|
267
|
+
runSeq: number;
|
|
268
|
+
eventType: string;
|
|
269
|
+
nodeId: string;
|
|
270
|
+
attemptId: string;
|
|
271
|
+
turnId: string | null;
|
|
272
|
+
messageId: string | null;
|
|
273
|
+
toolCallId: string | null;
|
|
274
|
+
payloadHash: Buffer;
|
|
275
|
+
recordedAt: number;
|
|
276
|
+
};
|
|
277
|
+
|
|
171
278
|
type SettingsScopeRow = {
|
|
172
279
|
scopeId: string;
|
|
173
280
|
resourceId: string;
|
|
@@ -317,12 +424,14 @@ export class WorkflowRunStore {
|
|
|
317
424
|
private readonly authorityProvider:
|
|
318
425
|
| ((runId: string) => RunWriteAuthority | undefined)
|
|
319
426
|
| undefined;
|
|
427
|
+
private readonly snapshotLifecycle: WorkflowRunStoreOptions["snapshotLifecycle"];
|
|
320
428
|
private readonly contexts = new Map<string, RunContext>();
|
|
321
429
|
private readonly ownsState: boolean;
|
|
322
430
|
private readonly mutations: StateMutationStore;
|
|
323
431
|
|
|
324
432
|
constructor(databasePath: string = workflowStatePath(), options: WorkflowRunStoreOptions = {}) {
|
|
325
433
|
this.authorityProvider = options.authorityProvider;
|
|
434
|
+
this.snapshotLifecycle = options.snapshotLifecycle;
|
|
326
435
|
this.ownsState = options.state === undefined;
|
|
327
436
|
this.state =
|
|
328
437
|
options.state ??
|
|
@@ -339,10 +448,327 @@ export class WorkflowRunStore {
|
|
|
339
448
|
if (this.ownsState) this.state.close();
|
|
340
449
|
}
|
|
341
450
|
|
|
451
|
+
/** Host-only synchronization after a queue lifecycle mutation on the run resource. */
|
|
452
|
+
synchronizeRevision(runId: string): number {
|
|
453
|
+
const revision = this.resourceRevision(runId);
|
|
454
|
+
const context = this.contexts.get(runId);
|
|
455
|
+
if (context === undefined) {
|
|
456
|
+
this.contexts.set(runId, { revision, lock: Promise.resolve() });
|
|
457
|
+
} else {
|
|
458
|
+
context.revision = revision;
|
|
459
|
+
}
|
|
460
|
+
return revision;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async createHumanDecisionRequest(request: HumanDecisionRequest): Promise<"created" | "adopted"> {
|
|
464
|
+
return await new HumanDecisionStore(this.databasePath, { state: this.state }).createRequest(
|
|
465
|
+
request,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async readResolvedHumanDecision(decisionId: string): Promise<ResolvedHumanDecision | null> {
|
|
470
|
+
return await new HumanDecisionStore(this.databasePath, { state: this.state }).readResolved(
|
|
471
|
+
decisionId,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async reserveEffect(options: {
|
|
476
|
+
runId: string;
|
|
477
|
+
attemptId: string;
|
|
478
|
+
effectType: string;
|
|
479
|
+
idempotencyKey: string;
|
|
480
|
+
request: JsonValue;
|
|
481
|
+
recovery: WorkflowEffectRecovery;
|
|
482
|
+
}): Promise<WorkflowEffectReservation> {
|
|
483
|
+
return await this.withRunLock(options.runId, async () =>
|
|
484
|
+
this.state.transaction(() => {
|
|
485
|
+
requireBoundedEffectText(options.effectType, "type");
|
|
486
|
+
requireBoundedEffectText(options.idempotencyKey, "idempotency key");
|
|
487
|
+
const context = this.contextFor(options.runId);
|
|
488
|
+
const run = this.requireRunRow(options.runId);
|
|
489
|
+
this.assertWriteAuthority(run, context.revision);
|
|
490
|
+
const authority = this.authorityProvider?.(options.runId);
|
|
491
|
+
const payloadHash = this.state.putJson(
|
|
492
|
+
{ recovery: options.recovery, request: options.request },
|
|
493
|
+
Date.now(),
|
|
494
|
+
);
|
|
495
|
+
const effectId = workflowEffectId(
|
|
496
|
+
run.resourceId,
|
|
497
|
+
options.effectType,
|
|
498
|
+
options.idempotencyKey,
|
|
499
|
+
);
|
|
500
|
+
const existing = this.state.connection
|
|
501
|
+
.prepare(
|
|
502
|
+
`SELECT status, payload_hash AS payloadHash, result_hash AS resultHash,
|
|
503
|
+
attempt_count AS attemptCount
|
|
504
|
+
FROM effects WHERE effect_id = ?`,
|
|
505
|
+
)
|
|
506
|
+
.get(effectId);
|
|
507
|
+
if (isWorkflowEffectRow(existing)) {
|
|
508
|
+
if (!existing.payloadHash.equals(payloadHash)) {
|
|
509
|
+
throw new Error("Managed effect key was reused with another request");
|
|
510
|
+
}
|
|
511
|
+
if (existing.status === "applied") {
|
|
512
|
+
return {
|
|
513
|
+
effectId,
|
|
514
|
+
attemptNumber: existing.attemptCount,
|
|
515
|
+
disposition: "adopted",
|
|
516
|
+
...(existing.resultHash === null
|
|
517
|
+
? {}
|
|
518
|
+
: { result: this.state.readJson(existing.resultHash) }),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
if (existing.status !== "pending") {
|
|
522
|
+
return {
|
|
523
|
+
effectId,
|
|
524
|
+
attemptNumber: existing.attemptCount,
|
|
525
|
+
disposition: "ambiguous",
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
return this.beginEffectAttempt(run, effectId, existing.attemptCount + 1, authority);
|
|
529
|
+
}
|
|
530
|
+
const now = Date.now();
|
|
531
|
+
const effectResourceId = this.mutations.ensureResource("effect", effectId, now);
|
|
532
|
+
this.mutations.mutate(
|
|
533
|
+
{
|
|
534
|
+
resourceId: effectResourceId,
|
|
535
|
+
operation: "effect.reserve",
|
|
536
|
+
actor: authority?.actor ?? { type: "system" },
|
|
537
|
+
expectedRevision: 0,
|
|
538
|
+
},
|
|
539
|
+
"effect.reserved",
|
|
540
|
+
() => {
|
|
541
|
+
this.state.connection
|
|
542
|
+
.prepare(
|
|
543
|
+
`INSERT INTO effects(
|
|
544
|
+
effect_id, resource_id, source_resource_id, source_revision,
|
|
545
|
+
effect_type, idempotency_key, payload_hash, owner_scope, status,
|
|
546
|
+
attempt_count, created_at, updated_at
|
|
547
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'run', 'applying', 1, ?, ?)`,
|
|
548
|
+
)
|
|
549
|
+
.run(
|
|
550
|
+
effectId,
|
|
551
|
+
effectResourceId,
|
|
552
|
+
run.resourceId,
|
|
553
|
+
context.revision,
|
|
554
|
+
options.effectType,
|
|
555
|
+
options.idempotencyKey,
|
|
556
|
+
payloadHash,
|
|
557
|
+
now,
|
|
558
|
+
now,
|
|
559
|
+
);
|
|
560
|
+
this.insertEffectAttempt(effectId, 1, authority, now);
|
|
561
|
+
},
|
|
562
|
+
{ now, payload: { runId: options.runId, attemptId: options.attemptId } },
|
|
563
|
+
);
|
|
564
|
+
return { effectId, attemptNumber: 1, disposition: "execute" };
|
|
565
|
+
}),
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async settleEffect(options: {
|
|
570
|
+
runId: string;
|
|
571
|
+
effectId: string;
|
|
572
|
+
attemptNumber: number;
|
|
573
|
+
outcome: "applied" | "rejected" | "ambiguous" | "cancelled";
|
|
574
|
+
result?: JsonValue;
|
|
575
|
+
error?: string;
|
|
576
|
+
}): Promise<void> {
|
|
577
|
+
await this.withRunLock(options.runId, async () =>
|
|
578
|
+
this.state.transaction(() => {
|
|
579
|
+
const context = this.contextFor(options.runId);
|
|
580
|
+
const run = this.requireRunRow(options.runId);
|
|
581
|
+
this.assertWriteAuthority(run, context.revision);
|
|
582
|
+
const row = this.state.connection
|
|
583
|
+
.prepare(
|
|
584
|
+
`SELECT e.resource_id AS resourceId, e.status
|
|
585
|
+
FROM effects e WHERE e.effect_id = ? AND e.source_resource_id = ?`,
|
|
586
|
+
)
|
|
587
|
+
.get(options.effectId, run.resourceId);
|
|
588
|
+
if (!isWorkflowEffectIdentityRow(row) || row.status !== "applying") {
|
|
589
|
+
throw new Error("Managed effect is not applying");
|
|
590
|
+
}
|
|
591
|
+
const now = Date.now();
|
|
592
|
+
const revision = this.requireResourceRevision(row.resourceId);
|
|
593
|
+
const resultHash =
|
|
594
|
+
options.result === undefined ? null : this.state.putJson(options.result, now);
|
|
595
|
+
const errorHash =
|
|
596
|
+
options.error === undefined ? null : this.state.putText(options.error, now);
|
|
597
|
+
this.mutations.mutate(
|
|
598
|
+
{
|
|
599
|
+
resourceId: row.resourceId,
|
|
600
|
+
operation: "effect.settle",
|
|
601
|
+
actor: this.authorityProvider?.(options.runId)?.actor ?? { type: "system" },
|
|
602
|
+
expectedRevision: revision,
|
|
603
|
+
},
|
|
604
|
+
`effect.${options.outcome}`,
|
|
605
|
+
() => {
|
|
606
|
+
const changed = this.state.connection
|
|
607
|
+
.prepare(
|
|
608
|
+
`UPDATE effects
|
|
609
|
+
SET status = ?, result_hash = ?, error_hash = ?, updated_at = ?, settled_at = ?
|
|
610
|
+
WHERE effect_id = ? AND status = 'applying' AND attempt_count = ?`,
|
|
611
|
+
)
|
|
612
|
+
.run(
|
|
613
|
+
options.outcome,
|
|
614
|
+
resultHash,
|
|
615
|
+
errorHash,
|
|
616
|
+
now,
|
|
617
|
+
now,
|
|
618
|
+
options.effectId,
|
|
619
|
+
options.attemptNumber,
|
|
620
|
+
);
|
|
621
|
+
/* istanbul ignore if -- serialized effect mutation keeps this attempt current */
|
|
622
|
+
if (changed.changes !== 1) throw new Error("Managed effect attempt changed");
|
|
623
|
+
this.state.connection
|
|
624
|
+
.prepare(
|
|
625
|
+
`UPDATE effect_attempts
|
|
626
|
+
SET finished_at = ?, outcome = ?, result_hash = ?, error_hash = ?
|
|
627
|
+
WHERE effect_id = ? AND attempt_number = ? AND finished_at IS NULL`,
|
|
628
|
+
)
|
|
629
|
+
.run(
|
|
630
|
+
now,
|
|
631
|
+
options.outcome === "cancelled" ? "interrupted" : options.outcome,
|
|
632
|
+
resultHash,
|
|
633
|
+
errorHash,
|
|
634
|
+
options.effectId,
|
|
635
|
+
options.attemptNumber,
|
|
636
|
+
);
|
|
637
|
+
},
|
|
638
|
+
{ now, payload: { runId: options.runId, outcome: options.outcome } },
|
|
639
|
+
);
|
|
640
|
+
}),
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async recoverApplyingEffects(runId: string): Promise<"safe" | "ambiguous"> {
|
|
645
|
+
return await this.withRunLock(runId, async () =>
|
|
646
|
+
this.state.transaction(() => {
|
|
647
|
+
const context = this.contextFor(runId);
|
|
648
|
+
const run = this.requireRunRow(runId);
|
|
649
|
+
this.assertWriteAuthority(run, context.revision);
|
|
650
|
+
const authority = this.authorityProvider?.(runId);
|
|
651
|
+
const rows = this.state.connection
|
|
652
|
+
.prepare(
|
|
653
|
+
`SELECT effect_id AS effectId, resource_id AS resourceId,
|
|
654
|
+
payload_hash AS payloadHash, attempt_count AS attemptCount
|
|
655
|
+
FROM effects WHERE source_resource_id = ? AND status = 'applying'`,
|
|
656
|
+
)
|
|
657
|
+
.all(run.resourceId)
|
|
658
|
+
.filter(isApplyingEffectRow);
|
|
659
|
+
let ambiguous = false;
|
|
660
|
+
for (const row of rows) {
|
|
661
|
+
const payload = this.state.readJson(row.payloadHash);
|
|
662
|
+
const recovery = effectRecoveryFromPayload(payload);
|
|
663
|
+
const status = recovery === "idempotent" ? "pending" : "ambiguous";
|
|
664
|
+
if (status === "ambiguous") ambiguous = true;
|
|
665
|
+
const now = Date.now();
|
|
666
|
+
const revision = this.requireResourceRevision(row.resourceId);
|
|
667
|
+
this.mutations.mutate(
|
|
668
|
+
{
|
|
669
|
+
resourceId: row.resourceId,
|
|
670
|
+
operation: "effect.recover",
|
|
671
|
+
actor: authority?.actor ?? { type: "system" },
|
|
672
|
+
expectedRevision: revision,
|
|
673
|
+
},
|
|
674
|
+
status === "pending" ? "effect.retry_ready" : "effect.ambiguous",
|
|
675
|
+
() => {
|
|
676
|
+
this.state.connection
|
|
677
|
+
.prepare(
|
|
678
|
+
`UPDATE effects SET status = ?, updated_at = ?, settled_at = ?
|
|
679
|
+
WHERE effect_id = ? AND status = 'applying'`,
|
|
680
|
+
)
|
|
681
|
+
.run(status, now, status === "ambiguous" ? now : null, row.effectId);
|
|
682
|
+
this.state.connection
|
|
683
|
+
.prepare(
|
|
684
|
+
`UPDATE effect_attempts SET finished_at = ?, outcome = 'interrupted'
|
|
685
|
+
WHERE effect_id = ? AND attempt_number = ? AND finished_at IS NULL`,
|
|
686
|
+
)
|
|
687
|
+
.run(now, row.effectId, row.attemptCount);
|
|
688
|
+
},
|
|
689
|
+
{ now, payload: { runId, recovery } },
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
return ambiguous ? "ambiguous" : "safe";
|
|
693
|
+
}),
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private beginEffectAttempt(
|
|
698
|
+
run: RunRow,
|
|
699
|
+
effectId: string,
|
|
700
|
+
attemptNumber: number,
|
|
701
|
+
authority: RunWriteAuthority | undefined,
|
|
702
|
+
): WorkflowEffectReservation {
|
|
703
|
+
const now = Date.now();
|
|
704
|
+
const row = this.state.connection
|
|
705
|
+
.prepare("SELECT resource_id AS resourceId FROM effects WHERE effect_id = ?")
|
|
706
|
+
.get(effectId);
|
|
707
|
+
/* istanbul ignore if -- beginEffectAttempt is called only for a persisted effect */
|
|
708
|
+
if (!isResourceIdRow(row)) throw new Error("Managed effect resource is missing");
|
|
709
|
+
const revision = this.requireResourceRevision(row.resourceId);
|
|
710
|
+
this.mutations.mutate(
|
|
711
|
+
{
|
|
712
|
+
resourceId: row.resourceId,
|
|
713
|
+
operation: "effect.retry",
|
|
714
|
+
actor: authority?.actor ?? { type: "system" },
|
|
715
|
+
expectedRevision: revision,
|
|
716
|
+
},
|
|
717
|
+
"effect.retry_started",
|
|
718
|
+
() => {
|
|
719
|
+
this.state.connection
|
|
720
|
+
.prepare(
|
|
721
|
+
`UPDATE effects SET status = 'applying', attempt_count = ?, updated_at = ?,
|
|
722
|
+
settled_at = NULL, error_hash = NULL
|
|
723
|
+
WHERE effect_id = ? AND status = 'pending'`,
|
|
724
|
+
)
|
|
725
|
+
.run(attemptNumber, now, effectId);
|
|
726
|
+
this.insertEffectAttempt(effectId, attemptNumber, authority, now);
|
|
727
|
+
},
|
|
728
|
+
{ now, payload: { runId: run.runId, attemptNumber } },
|
|
729
|
+
);
|
|
730
|
+
return { effectId, attemptNumber, disposition: "execute" };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private insertEffectAttempt(
|
|
734
|
+
effectId: string,
|
|
735
|
+
attemptNumber: number,
|
|
736
|
+
authority: RunWriteAuthority | undefined,
|
|
737
|
+
now: number,
|
|
738
|
+
): void {
|
|
739
|
+
this.state.connection
|
|
740
|
+
.prepare(
|
|
741
|
+
`INSERT INTO effect_attempts(
|
|
742
|
+
effect_id, attempt_number, owner_id, lease_generation, started_at
|
|
743
|
+
) VALUES (?, ?, ?, ?, ?)`,
|
|
744
|
+
)
|
|
745
|
+
.run(
|
|
746
|
+
effectId,
|
|
747
|
+
attemptNumber,
|
|
748
|
+
authority?.ownerId ?? "system",
|
|
749
|
+
authority?.generation ?? 1,
|
|
750
|
+
now,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
342
754
|
async initializeRun(
|
|
343
755
|
workflow: WorkflowDefinition,
|
|
344
756
|
state: WorkflowRunState,
|
|
345
757
|
options: InitializeWorkflowRunOptions = {},
|
|
758
|
+
): Promise<string> {
|
|
759
|
+
return await this.initializeRunFromSnapshot(
|
|
760
|
+
createDefinitionSnapshot(workflow),
|
|
761
|
+
workflow.name,
|
|
762
|
+
state,
|
|
763
|
+
options,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async initializeRunFromSnapshot(
|
|
768
|
+
snapshot: WorkflowDefinitionSnapshot,
|
|
769
|
+
workflowName: string,
|
|
770
|
+
state: WorkflowRunState,
|
|
771
|
+
options: InitializeWorkflowRunOptions = {},
|
|
346
772
|
): Promise<string> {
|
|
347
773
|
assertValidRunId(state.runId);
|
|
348
774
|
if (!this.contexts.has(state.runId)) {
|
|
@@ -356,7 +782,6 @@ export class WorkflowRunStore {
|
|
|
356
782
|
let acceptedRevision = 1;
|
|
357
783
|
const now = Date.now();
|
|
358
784
|
const at = new Date(now).toISOString();
|
|
359
|
-
const snapshot = createDefinitionSnapshot(workflow);
|
|
360
785
|
const definitionJson = canonicalJson(snapshot);
|
|
361
786
|
const definitionDigest = createHash("sha256").update(definitionJson).digest();
|
|
362
787
|
const source = sourceForState(state, definitionDigest);
|
|
@@ -377,14 +802,33 @@ export class WorkflowRunStore {
|
|
|
377
802
|
acceptedRevision = revision;
|
|
378
803
|
state.traceSeq = revision;
|
|
379
804
|
state.updatedAt = at;
|
|
380
|
-
|
|
805
|
+
const traceEvent: WorkflowTraceEvent = {
|
|
806
|
+
seq: revision,
|
|
807
|
+
at,
|
|
808
|
+
runId: state.runId,
|
|
381
809
|
scope: "run",
|
|
382
810
|
type: "run_initialized",
|
|
383
|
-
payload: { workflowName
|
|
384
|
-
}
|
|
811
|
+
payload: { workflowName },
|
|
812
|
+
};
|
|
813
|
+
insertRunEvent(this.state, reserved.resourceId, revision, at, traceEvent);
|
|
385
814
|
this.persistRunState(reserved, state, revision, now);
|
|
815
|
+
initializeViewerRun(this.state, state.runId, now);
|
|
386
816
|
this.initializeRunSettingsAndFollowUps(state, options.initialSettings ?? [], now);
|
|
817
|
+
if (state.parentRunId !== undefined) {
|
|
818
|
+
recordViewerDeltas(
|
|
819
|
+
this.state,
|
|
820
|
+
state.parentRunId,
|
|
821
|
+
this.viewerInspectorTargets(state.parentRunId),
|
|
822
|
+
now,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
387
825
|
this.syncNodeAttempts(state, snapshot, now);
|
|
826
|
+
recordViewerDeltas(
|
|
827
|
+
this.state,
|
|
828
|
+
state.runId,
|
|
829
|
+
[...runViewerTargets(state, traceEvent), ...this.viewerInspectorTargets(state.runId)],
|
|
830
|
+
now,
|
|
831
|
+
);
|
|
388
832
|
this.syncContinuationIdentity(state);
|
|
389
833
|
context.revision = revision;
|
|
390
834
|
return;
|
|
@@ -397,7 +841,7 @@ export class WorkflowRunStore {
|
|
|
397
841
|
) VALUES (?, ?, ?, ?)
|
|
398
842
|
ON CONFLICT(definition_digest) DO NOTHING`,
|
|
399
843
|
)
|
|
400
|
-
.run(definitionDigest,
|
|
844
|
+
.run(definitionDigest, workflowName, definitionHash, now);
|
|
401
845
|
this.state.connection
|
|
402
846
|
.prepare(
|
|
403
847
|
`INSERT INTO resources(
|
|
@@ -442,14 +886,23 @@ export class WorkflowRunStore {
|
|
|
442
886
|
now,
|
|
443
887
|
state.finishedAt === undefined ? null : Date.parse(state.finishedAt),
|
|
444
888
|
);
|
|
889
|
+
initializeViewerRun(this.state, state.runId, now);
|
|
445
890
|
this.insertRunSources(state.runId, source, state.workflowSources ?? []);
|
|
446
891
|
this.syncContinuationIdentity(state);
|
|
447
892
|
insertRunEvent(this.state, resourceId, 1, at, {
|
|
448
893
|
scope: "run",
|
|
449
894
|
type: "run_created",
|
|
450
|
-
payload: { workflowName
|
|
895
|
+
payload: { workflowName },
|
|
451
896
|
});
|
|
452
897
|
this.initializeRunSettingsAndFollowUps(state, options.initialSettings ?? [], now);
|
|
898
|
+
if (state.parentRunId !== undefined) {
|
|
899
|
+
recordViewerDeltas(
|
|
900
|
+
this.state,
|
|
901
|
+
state.parentRunId,
|
|
902
|
+
this.viewerInspectorTargets(state.parentRunId),
|
|
903
|
+
now,
|
|
904
|
+
);
|
|
905
|
+
}
|
|
453
906
|
this.syncNodeAttempts(state, snapshot, now);
|
|
454
907
|
});
|
|
455
908
|
this.contexts.set(state.runId, { revision: acceptedRevision, lock: Promise.resolve() });
|
|
@@ -496,6 +949,12 @@ export class WorkflowRunStore {
|
|
|
496
949
|
now,
|
|
497
950
|
now,
|
|
498
951
|
);
|
|
952
|
+
recordViewerDeltas(
|
|
953
|
+
this.state,
|
|
954
|
+
options.runId,
|
|
955
|
+
this.viewerInspectorTargets(options.runId),
|
|
956
|
+
now,
|
|
957
|
+
);
|
|
499
958
|
return this.settingsScopeRecord(this.requireSettingsScopeRow(scopeId));
|
|
500
959
|
});
|
|
501
960
|
}
|
|
@@ -680,6 +1139,12 @@ export class WorkflowRunStore {
|
|
|
680
1139
|
`UPDATE workflow_settings SET current_hash = ?, updated_at = ? WHERE scope_id = ?`,
|
|
681
1140
|
)
|
|
682
1141
|
.run(savedAfterHash, now, request.scopeId);
|
|
1142
|
+
recordViewerDeltas(
|
|
1143
|
+
this.state,
|
|
1144
|
+
request.runId,
|
|
1145
|
+
this.viewerInspectorTargets(request.runId),
|
|
1146
|
+
now,
|
|
1147
|
+
);
|
|
683
1148
|
return changeId;
|
|
684
1149
|
},
|
|
685
1150
|
{
|
|
@@ -784,6 +1249,12 @@ export class WorkflowRunStore {
|
|
|
784
1249
|
now,
|
|
785
1250
|
now,
|
|
786
1251
|
);
|
|
1252
|
+
recordViewerDeltas(
|
|
1253
|
+
this.state,
|
|
1254
|
+
request.runId,
|
|
1255
|
+
this.viewerInspectorTargets(request.runId),
|
|
1256
|
+
now,
|
|
1257
|
+
);
|
|
787
1258
|
return followUpId;
|
|
788
1259
|
},
|
|
789
1260
|
{ payload: { runId: request.runId, requestId: request.requestId } },
|
|
@@ -834,6 +1305,12 @@ export class WorkflowRunStore {
|
|
|
834
1305
|
if (update.changes !== 1) {
|
|
835
1306
|
throw new StaleResourceError("Workflow follow-up changed before removal");
|
|
836
1307
|
}
|
|
1308
|
+
recordViewerDeltas(
|
|
1309
|
+
this.state,
|
|
1310
|
+
request.runId,
|
|
1311
|
+
this.viewerInspectorTargets(request.runId),
|
|
1312
|
+
now,
|
|
1313
|
+
);
|
|
837
1314
|
},
|
|
838
1315
|
{ payload: { followUpId: request.followUpId, source: request.source } },
|
|
839
1316
|
);
|
|
@@ -956,6 +1433,12 @@ export class WorkflowRunStore {
|
|
|
956
1433
|
WHERE run_id = ? AND status = 'pending_presentation'`,
|
|
957
1434
|
)
|
|
958
1435
|
.run(now, options.runId);
|
|
1436
|
+
recordViewerDeltas(
|
|
1437
|
+
this.state,
|
|
1438
|
+
options.runId,
|
|
1439
|
+
this.viewerInspectorTargets(options.runId),
|
|
1440
|
+
now,
|
|
1441
|
+
);
|
|
959
1442
|
},
|
|
960
1443
|
{ payload: { runId: options.runId, state: options.state } },
|
|
961
1444
|
);
|
|
@@ -1036,6 +1519,7 @@ export class WorkflowRunStore {
|
|
|
1036
1519
|
if (update.changes !== 1) {
|
|
1037
1520
|
throw new StaleResourceError("Workflow follow-up changed before delivery completed");
|
|
1038
1521
|
}
|
|
1522
|
+
recordViewerDeltas(this.state, row.runId, this.viewerInspectorTargets(row.runId), now);
|
|
1039
1523
|
},
|
|
1040
1524
|
{ payload: { followUpId, sessionEntryId } },
|
|
1041
1525
|
);
|
|
@@ -1071,14 +1555,19 @@ export class WorkflowRunStore {
|
|
|
1071
1555
|
WHERE run_id = ? AND status IN ('pending', 'running', 'waiting')`,
|
|
1072
1556
|
)
|
|
1073
1557
|
.run(now, runId);
|
|
1074
|
-
|
|
1558
|
+
const traceEvent: WorkflowTraceEvent = {
|
|
1559
|
+
seq: nextRevision,
|
|
1560
|
+
at,
|
|
1561
|
+
runId,
|
|
1075
1562
|
scope: "run",
|
|
1076
1563
|
type: "run_resume_prepared",
|
|
1077
1564
|
payload: {},
|
|
1078
|
-
}
|
|
1565
|
+
};
|
|
1566
|
+
insertRunEvent(this.state, row.resourceId, nextRevision, at, traceEvent);
|
|
1079
1567
|
loaded.state.traceSeq = nextRevision;
|
|
1080
1568
|
loaded.state.updatedAt = at;
|
|
1081
1569
|
this.persistRunState(row, loaded.state, nextRevision, now);
|
|
1570
|
+
recordViewerDeltas(this.state, runId, runViewerTargets(loaded.state, traceEvent), now);
|
|
1082
1571
|
context.revision = nextRevision;
|
|
1083
1572
|
});
|
|
1084
1573
|
const prepared = this.readRun(runId, { includeTrace: true });
|
|
@@ -1099,6 +1588,7 @@ export class WorkflowRunStore {
|
|
|
1099
1588
|
delete loaded.state.currentNode;
|
|
1100
1589
|
delete loaded.state.currentAttemptId;
|
|
1101
1590
|
delete loaded.state.currentNodeStartedAt;
|
|
1591
|
+
delete loaded.state.currentNodeDeadlineAt;
|
|
1102
1592
|
delete loaded.state.currentSettingsScopeId;
|
|
1103
1593
|
delete loaded.state.currentSettingsChangeNumber;
|
|
1104
1594
|
delete loaded.state.currentSettingsHash;
|
|
@@ -1120,81 +1610,95 @@ export class WorkflowRunStore {
|
|
|
1120
1610
|
update: WorkflowUpdateInput,
|
|
1121
1611
|
options: { signal?: AbortSignal } = {},
|
|
1122
1612
|
): Promise<{ event: WorkflowTraceEvent; record: WorkflowUpdateRecord }> {
|
|
1123
|
-
return await this.withRunLock(runId, async () =>
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1613
|
+
return await this.withRunLock(runId, async () =>
|
|
1614
|
+
this.publishUpdateSynchronous(runId, state, nodeId, attemptId, update, options),
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/** Host-only synchronous form for an already serialized command transaction. */
|
|
1619
|
+
publishUpdateSynchronous(
|
|
1620
|
+
runId: string,
|
|
1621
|
+
state: WorkflowRunState,
|
|
1622
|
+
nodeId: string,
|
|
1623
|
+
attemptId: string,
|
|
1624
|
+
update: WorkflowUpdateInput,
|
|
1625
|
+
options: { signal?: AbortSignal } = {},
|
|
1626
|
+
): { event: WorkflowTraceEvent; record: WorkflowUpdateRecord } {
|
|
1627
|
+
if (options.signal?.aborted === true) {
|
|
1628
|
+
throw options.signal.reason ?? new Error("workflow update attempt is no longer active");
|
|
1629
|
+
}
|
|
1630
|
+
const exists = (state.updates ?? []).some(
|
|
1631
|
+
(record) => record.type === update.type && record.key === update.key,
|
|
1632
|
+
);
|
|
1633
|
+
if (!exists && (state.updates?.length ?? 0) >= MAX_CURRENT_UPDATES) {
|
|
1634
|
+
throw new Error(`workflow run supports at most ${MAX_CURRENT_UPDATES} current updates`);
|
|
1635
|
+
}
|
|
1636
|
+
const data = structuredClone(update.data) as Record<string, unknown>;
|
|
1637
|
+
const updateId = createUpdateId();
|
|
1638
|
+
let acceptedEvent: WorkflowTraceEvent | undefined;
|
|
1639
|
+
let acceptedRecord: WorkflowUpdateRecord | undefined;
|
|
1640
|
+
this.state.transaction(() => {
|
|
1641
|
+
const context = this.contextFor(runId);
|
|
1642
|
+
const run = this.requireRunRow(runId);
|
|
1643
|
+
this.assertWriteAuthority(run, context.revision);
|
|
1644
|
+
const revision = context.revision + 1;
|
|
1645
|
+
const at = new Date().toISOString();
|
|
1646
|
+
const event: WorkflowTraceEvent = {
|
|
1647
|
+
seq: revision,
|
|
1648
|
+
at,
|
|
1649
|
+
runId,
|
|
1650
|
+
scope: "node",
|
|
1651
|
+
type: "update_published",
|
|
1652
|
+
nodeId,
|
|
1653
|
+
attemptId,
|
|
1654
|
+
payload: { updateId, type: update.type, key: update.key, data },
|
|
1655
|
+
};
|
|
1656
|
+
const record: WorkflowUpdateRecord = {
|
|
1657
|
+
updateId,
|
|
1658
|
+
seq: revision,
|
|
1659
|
+
at,
|
|
1660
|
+
runId,
|
|
1661
|
+
nodeId,
|
|
1662
|
+
attemptId,
|
|
1663
|
+
type: update.type,
|
|
1664
|
+
key: update.key,
|
|
1665
|
+
data,
|
|
1666
|
+
};
|
|
1667
|
+
state.updates = updateProjection(state.updates, record);
|
|
1668
|
+
state.traceSeq = revision;
|
|
1669
|
+
state.updatedAt = at;
|
|
1670
|
+
this.ensureAttempt(state, nodeId, attemptId, Date.now());
|
|
1671
|
+
const dataHash = this.state.putJson(data);
|
|
1672
|
+
const nextUpdateSeq = this.nextUpdateSequence(attemptId);
|
|
1673
|
+
this.state.connection
|
|
1674
|
+
.prepare(
|
|
1675
|
+
`INSERT INTO workflow_updates(
|
|
1173
1676
|
update_id, attempt_id, update_seq, run_revision,
|
|
1174
1677
|
update_type, update_key, data_hash, recorded_at
|
|
1175
1678
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
throw new Error("Workflow update transaction did not produce a result");
|
|
1195
|
-
}
|
|
1196
|
-
return { event: acceptedEvent, record: acceptedRecord };
|
|
1679
|
+
)
|
|
1680
|
+
.run(
|
|
1681
|
+
updateId,
|
|
1682
|
+
attemptId,
|
|
1683
|
+
nextUpdateSeq,
|
|
1684
|
+
revision,
|
|
1685
|
+
update.type,
|
|
1686
|
+
update.key,
|
|
1687
|
+
dataHash,
|
|
1688
|
+
Date.now(),
|
|
1689
|
+
);
|
|
1690
|
+
insertRunEvent(this.state, run.resourceId, revision, at, event);
|
|
1691
|
+
const committedAt = Date.now();
|
|
1692
|
+
this.persistRunState(run, state, revision, committedAt);
|
|
1693
|
+
recordViewerDeltas(this.state, runId, runViewerTargets(state, event), committedAt);
|
|
1694
|
+
context.revision = revision;
|
|
1695
|
+
acceptedEvent = event;
|
|
1696
|
+
acceptedRecord = record;
|
|
1197
1697
|
});
|
|
1698
|
+
if (acceptedEvent === undefined || acceptedRecord === undefined) {
|
|
1699
|
+
throw new Error("Workflow update transaction did not produce a result");
|
|
1700
|
+
}
|
|
1701
|
+
return { event: acceptedEvent, record: acceptedRecord };
|
|
1198
1702
|
}
|
|
1199
1703
|
|
|
1200
1704
|
async writeSnapshot(
|
|
@@ -1220,6 +1724,14 @@ export class WorkflowRunStore {
|
|
|
1220
1724
|
this.persistRunState(run, state, revision, now);
|
|
1221
1725
|
this.transitionFollowUpsForRunState(state, event, now);
|
|
1222
1726
|
this.syncNodeAttempts(state, snapshot, now);
|
|
1727
|
+
recordViewerDeltas(this.state, runId, runViewerTargets(state, traceEvent), now);
|
|
1728
|
+
this.snapshotLifecycle?.({
|
|
1729
|
+
runId,
|
|
1730
|
+
state,
|
|
1731
|
+
event: traceEvent,
|
|
1732
|
+
database: this.state,
|
|
1733
|
+
now,
|
|
1734
|
+
});
|
|
1223
1735
|
context.revision = revision;
|
|
1224
1736
|
accepted = traceEvent;
|
|
1225
1737
|
});
|
|
@@ -1254,6 +1766,7 @@ export class WorkflowRunStore {
|
|
|
1254
1766
|
await this.withRunLock(runId, async () => {
|
|
1255
1767
|
const segmentId = segmentIdFor(runId, attemptId);
|
|
1256
1768
|
if (this.segmentRow(segmentId) !== undefined) return;
|
|
1769
|
+
const hasExistingSession = this.segmentRows(runId).length > 0;
|
|
1257
1770
|
this.state.transaction(() => {
|
|
1258
1771
|
const run = this.requireRunRow(runId);
|
|
1259
1772
|
const context = this.contextFor(runId);
|
|
@@ -1313,7 +1826,7 @@ export class WorkflowRunStore {
|
|
|
1313
1826
|
.run(binding.piSessionId, now, runId);
|
|
1314
1827
|
const revision = context.revision + 1;
|
|
1315
1828
|
const at = new Date(now).toISOString();
|
|
1316
|
-
|
|
1829
|
+
const traceEvent: WorkflowTraceEvent = {
|
|
1317
1830
|
seq: revision,
|
|
1318
1831
|
at,
|
|
1319
1832
|
runId,
|
|
@@ -1323,11 +1836,38 @@ export class WorkflowRunStore {
|
|
|
1323
1836
|
piSessionId: binding.piSessionId,
|
|
1324
1837
|
...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
|
|
1325
1838
|
},
|
|
1326
|
-
}
|
|
1839
|
+
};
|
|
1840
|
+
insertRunEvent(this.state, run.resourceId, revision, at, traceEvent);
|
|
1327
1841
|
const current = this.readRunState(run, this.readDefinition(run.definitionHash));
|
|
1328
1842
|
current.traceSeq = revision;
|
|
1329
1843
|
current.updatedAt = at;
|
|
1330
1844
|
this.persistRunState(run, current, revision, now);
|
|
1845
|
+
const targets = runViewerTargets(current, traceEvent);
|
|
1846
|
+
targets[0]?.patch?.push(
|
|
1847
|
+
hasExistingSession
|
|
1848
|
+
? {
|
|
1849
|
+
op: "add",
|
|
1850
|
+
path: "/session/binding",
|
|
1851
|
+
value: parseJson(canonicalJson(binding)),
|
|
1852
|
+
}
|
|
1853
|
+
: {
|
|
1854
|
+
op: "add",
|
|
1855
|
+
path: "/session",
|
|
1856
|
+
value: parseJson(
|
|
1857
|
+
canonicalJson({
|
|
1858
|
+
binding,
|
|
1859
|
+
presentationRevision: 0,
|
|
1860
|
+
entryPage: { presentationRevision: 0, start: 0, total: 0, items: [] },
|
|
1861
|
+
eventPage: { presentationRevision: 0, start: 0, total: 0, items: [] },
|
|
1862
|
+
eventsMalformed: false,
|
|
1863
|
+
eventsTornTail: false,
|
|
1864
|
+
capture: null,
|
|
1865
|
+
replayCheckpoint: null,
|
|
1866
|
+
}),
|
|
1867
|
+
),
|
|
1868
|
+
},
|
|
1869
|
+
);
|
|
1870
|
+
recordViewerDeltas(this.state, runId, targets, now);
|
|
1331
1871
|
context.revision = revision;
|
|
1332
1872
|
});
|
|
1333
1873
|
});
|
|
@@ -1343,15 +1883,26 @@ export class WorkflowRunStore {
|
|
|
1343
1883
|
this.assertSessionWriteAuthority(runId);
|
|
1344
1884
|
const revision = this.requireResourceRevision(segmentResourceId(segment));
|
|
1345
1885
|
const sequence = segment.entryCount + 1;
|
|
1886
|
+
const runSequenceRow = this.state.connection
|
|
1887
|
+
.prepare(
|
|
1888
|
+
`SELECT COALESCE(MAX(run_seq), 0) + 1 AS count
|
|
1889
|
+
FROM session_entries WHERE run_id = ?`,
|
|
1890
|
+
)
|
|
1891
|
+
.get(runId);
|
|
1892
|
+
if (!isCountRow(runSequenceRow)) {
|
|
1893
|
+
throw new Error("Workflow session entry sequence is unavailable");
|
|
1894
|
+
}
|
|
1895
|
+
const runSequence = runSequenceRow.count;
|
|
1346
1896
|
const now = Date.now();
|
|
1347
1897
|
const entryHash = this.state.putJson(entry, now);
|
|
1348
1898
|
const entryId = typeof entry.id === "string" ? entry.id : `entry-${sequence}`;
|
|
1349
1899
|
this.state.connection
|
|
1350
1900
|
.prepare(
|
|
1351
|
-
`INSERT INTO session_entries(
|
|
1352
|
-
|
|
1901
|
+
`INSERT INTO session_entries(
|
|
1902
|
+
segment_id, run_id, entry_seq, run_seq, entry_id, entry_hash, recorded_at
|
|
1903
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
1353
1904
|
)
|
|
1354
|
-
.run(segment.segmentId, sequence, entryId, entryHash, now);
|
|
1905
|
+
.run(segment.segmentId, runId, sequence, runSequence, entryId, entryHash, now);
|
|
1355
1906
|
this.state.connection
|
|
1356
1907
|
.prepare("UPDATE session_segments SET entry_count = ? WHERE segment_id = ?")
|
|
1357
1908
|
.run(sequence, segment.segmentId);
|
|
@@ -1366,6 +1917,37 @@ export class WorkflowRunStore {
|
|
|
1366
1917
|
null,
|
|
1367
1918
|
now,
|
|
1368
1919
|
);
|
|
1920
|
+
recordViewerDeltas(
|
|
1921
|
+
this.state,
|
|
1922
|
+
runId,
|
|
1923
|
+
[
|
|
1924
|
+
{
|
|
1925
|
+
targetType: "conversation",
|
|
1926
|
+
targetKey: "entries:tail",
|
|
1927
|
+
patch: viewerTailPatch(runSequence - 1, [
|
|
1928
|
+
parseJson(
|
|
1929
|
+
canonicalJson({
|
|
1930
|
+
seq: runSequence,
|
|
1931
|
+
at: new Date(now).toISOString(),
|
|
1932
|
+
entry,
|
|
1933
|
+
}),
|
|
1934
|
+
),
|
|
1935
|
+
]),
|
|
1936
|
+
},
|
|
1937
|
+
{
|
|
1938
|
+
targetType: "conversation",
|
|
1939
|
+
targetKey: "capture",
|
|
1940
|
+
patch: [
|
|
1941
|
+
{
|
|
1942
|
+
op: "replace",
|
|
1943
|
+
path: "/capture/entryCount",
|
|
1944
|
+
value: runSequence,
|
|
1945
|
+
},
|
|
1946
|
+
],
|
|
1947
|
+
},
|
|
1948
|
+
],
|
|
1949
|
+
now,
|
|
1950
|
+
);
|
|
1369
1951
|
return sequence;
|
|
1370
1952
|
});
|
|
1371
1953
|
}
|
|
@@ -1382,6 +1964,18 @@ export class WorkflowRunStore {
|
|
|
1382
1964
|
const current = this.requireSegment(segment.segmentId);
|
|
1383
1965
|
if (current.status !== "recording") throw new Error("Session event capture has stopped");
|
|
1384
1966
|
let expected = current.eventCount + 1;
|
|
1967
|
+
const runSequenceRow = this.state.connection
|
|
1968
|
+
.prepare(
|
|
1969
|
+
`SELECT COALESCE(MAX(run_seq), 0) + 1 AS count
|
|
1970
|
+
FROM session_events WHERE run_id = ?`,
|
|
1971
|
+
)
|
|
1972
|
+
.get(runId);
|
|
1973
|
+
if (!isCountRow(runSequenceRow)) {
|
|
1974
|
+
throw new Error("Workflow session event sequence is unavailable");
|
|
1975
|
+
}
|
|
1976
|
+
let runSequence = runSequenceRow.count;
|
|
1977
|
+
const firstRunSequence = runSequence;
|
|
1978
|
+
const projectedRecords: Array<WorkflowSessionEventRecord & { stepIndex?: number }> = [];
|
|
1385
1979
|
for (const record of records) {
|
|
1386
1980
|
validateSessionEventRecord(record);
|
|
1387
1981
|
if (record.seq !== expected) {
|
|
@@ -1394,13 +1988,15 @@ export class WorkflowRunStore {
|
|
|
1394
1988
|
this.state.connection
|
|
1395
1989
|
.prepare(
|
|
1396
1990
|
`INSERT INTO session_events(
|
|
1397
|
-
segment_id, event_seq, event_type, node_id, attempt_id,
|
|
1991
|
+
segment_id, run_id, event_seq, run_seq, event_type, node_id, attempt_id,
|
|
1398
1992
|
turn_id, message_id, tool_call_id, payload_hash, recorded_at
|
|
1399
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1993
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1400
1994
|
)
|
|
1401
1995
|
.run(
|
|
1402
1996
|
current.segmentId,
|
|
1997
|
+
runId,
|
|
1403
1998
|
record.seq,
|
|
1999
|
+
runSequence,
|
|
1404
2000
|
record.type,
|
|
1405
2001
|
record.nodeId,
|
|
1406
2002
|
record.attemptId,
|
|
@@ -1410,14 +2006,189 @@ export class WorkflowRunStore {
|
|
|
1410
2006
|
payloadHash,
|
|
1411
2007
|
Date.parse(record.at),
|
|
1412
2008
|
);
|
|
2009
|
+
const stepIndex = this.state.connection
|
|
2010
|
+
.prepare(
|
|
2011
|
+
`SELECT step_index AS stepIndex
|
|
2012
|
+
FROM run_steps WHERE run_id = ? AND attempt_id = ?`,
|
|
2013
|
+
)
|
|
2014
|
+
.get(runId, record.attemptId);
|
|
2015
|
+
projectedRecords.push({
|
|
2016
|
+
...record,
|
|
2017
|
+
seq: runSequence,
|
|
2018
|
+
...(isStepIndexRow(stepIndex) ? { stepIndex: stepIndex.stepIndex } : {}),
|
|
2019
|
+
});
|
|
1413
2020
|
expected += 1;
|
|
2021
|
+
runSequence += 1;
|
|
1414
2022
|
}
|
|
1415
2023
|
this.state.connection
|
|
1416
2024
|
.prepare("UPDATE session_segments SET event_count = ? WHERE segment_id = ?")
|
|
1417
2025
|
.run(expected - 1, current.segmentId);
|
|
2026
|
+
const recordedAt = Date.now();
|
|
2027
|
+
this.recordSessionReplayCheckpoints(runId, firstRunSequence, projectedRecords, recordedAt);
|
|
2028
|
+
const projectedValues = projectedRecords.map((record) => parseJson(canonicalJson(record)));
|
|
2029
|
+
const nextEventTotal = firstRunSequence - 1 + projectedValues.length;
|
|
2030
|
+
const previousBlock = Math.floor(Math.max(0, firstRunSequence - 2) / VIEWER_PAGE_SIZE);
|
|
2031
|
+
const nextBlock = Math.floor(Math.max(0, nextEventTotal - 1) / VIEWER_PAGE_SIZE);
|
|
2032
|
+
const crossesPageBoundary = previousBlock !== nextBlock;
|
|
2033
|
+
recordViewerDeltas(
|
|
2034
|
+
this.state,
|
|
2035
|
+
runId,
|
|
2036
|
+
[
|
|
2037
|
+
{
|
|
2038
|
+
targetType: "timeline",
|
|
2039
|
+
targetKey: crossesPageBoundary ? "session:reload" : "session:tail",
|
|
2040
|
+
patch: crossesPageBoundary
|
|
2041
|
+
? [{ op: "replace", path: "/total", value: nextEventTotal }]
|
|
2042
|
+
: [
|
|
2043
|
+
{ op: "append", path: "/items", value: projectedValues },
|
|
2044
|
+
{ op: "replace", path: "/total", value: nextEventTotal },
|
|
2045
|
+
],
|
|
2046
|
+
},
|
|
2047
|
+
{
|
|
2048
|
+
targetType: "conversation",
|
|
2049
|
+
targetKey: "capture",
|
|
2050
|
+
patch: [
|
|
2051
|
+
{
|
|
2052
|
+
op: "replace",
|
|
2053
|
+
path: "/capture/eventCount",
|
|
2054
|
+
value: nextEventTotal,
|
|
2055
|
+
},
|
|
2056
|
+
{
|
|
2057
|
+
op: "replace",
|
|
2058
|
+
path: "/capture/lastEventSeq",
|
|
2059
|
+
value: nextEventTotal,
|
|
2060
|
+
},
|
|
2061
|
+
],
|
|
2062
|
+
},
|
|
2063
|
+
],
|
|
2064
|
+
recordedAt,
|
|
2065
|
+
);
|
|
1418
2066
|
});
|
|
1419
2067
|
}
|
|
1420
2068
|
|
|
2069
|
+
private recordSessionReplayCheckpoints(
|
|
2070
|
+
runId: string,
|
|
2071
|
+
firstRunSequence: number,
|
|
2072
|
+
newRecords: WorkflowSessionEventRecord[],
|
|
2073
|
+
now: number,
|
|
2074
|
+
): void {
|
|
2075
|
+
const row = this.state.connection
|
|
2076
|
+
.prepare(
|
|
2077
|
+
`SELECT event_seq AS eventSeq, state_hash AS stateHash
|
|
2078
|
+
FROM viewer_session_checkpoints
|
|
2079
|
+
WHERE run_id = ? ORDER BY event_seq DESC LIMIT 1`,
|
|
2080
|
+
)
|
|
2081
|
+
.get(runId);
|
|
2082
|
+
let checkpoint = reduceSessionEvents([], [], 0);
|
|
2083
|
+
if (isViewerSessionCheckpointRow(row)) {
|
|
2084
|
+
const saved = this.state.readJson(row.stateHash);
|
|
2085
|
+
if (!isTemporalSessionState(saved) || saved.throughSeq !== row.eventSeq) {
|
|
2086
|
+
throw new Error(`Viewer session checkpoint is invalid for ${runId}`);
|
|
2087
|
+
}
|
|
2088
|
+
checkpoint = saved;
|
|
2089
|
+
}
|
|
2090
|
+
let pending = [
|
|
2091
|
+
...this.sessionEventRecords(runId, checkpoint.throughSeq, firstRunSequence),
|
|
2092
|
+
...newRecords,
|
|
2093
|
+
];
|
|
2094
|
+
const lastSequence = pending.at(-1)?.seq ?? checkpoint.throughSeq;
|
|
2095
|
+
let boundary = (Math.floor(checkpoint.throughSeq / VIEWER_PAGE_SIZE) + 1) * VIEWER_PAGE_SIZE;
|
|
2096
|
+
while (boundary <= lastSequence) {
|
|
2097
|
+
const page = pending.filter((event) => event.seq <= boundary);
|
|
2098
|
+
checkpoint = reduceSessionEventsFromCheckpoint([], page, boundary, checkpoint);
|
|
2099
|
+
checkpoint = boundedTemporalCheckpoint(checkpoint);
|
|
2100
|
+
const stateHash = this.state.putJson(checkpoint, now);
|
|
2101
|
+
this.state.connection
|
|
2102
|
+
.prepare(
|
|
2103
|
+
`INSERT INTO viewer_session_checkpoints(run_id, event_seq, state_hash, recorded_at)
|
|
2104
|
+
VALUES (?, ?, ?, ?)`,
|
|
2105
|
+
)
|
|
2106
|
+
.run(runId, boundary, stateHash, now);
|
|
2107
|
+
pending = pending.filter((event) => event.seq > boundary);
|
|
2108
|
+
boundary += VIEWER_PAGE_SIZE;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
private sessionEventRecords(
|
|
2113
|
+
runId: string,
|
|
2114
|
+
afterSequence: number,
|
|
2115
|
+
beforeSequence: number,
|
|
2116
|
+
): WorkflowSessionEventRecord[] {
|
|
2117
|
+
return this.state.connection
|
|
2118
|
+
.prepare(
|
|
2119
|
+
`SELECT run_seq AS runSeq, event_type AS eventType, node_id AS nodeId,
|
|
2120
|
+
attempt_id AS attemptId, turn_id AS turnId, message_id AS messageId,
|
|
2121
|
+
tool_call_id AS toolCallId, payload_hash AS payloadHash,
|
|
2122
|
+
recorded_at AS recordedAt
|
|
2123
|
+
FROM session_events
|
|
2124
|
+
WHERE run_id = ? AND run_seq > ? AND run_seq < ?
|
|
2125
|
+
ORDER BY run_seq`,
|
|
2126
|
+
)
|
|
2127
|
+
.all(runId, afterSequence, beforeSequence)
|
|
2128
|
+
.map((value) => {
|
|
2129
|
+
if (!isViewerSessionEventRow(value)) {
|
|
2130
|
+
throw new Error(`Viewer session event row is invalid for ${runId}`);
|
|
2131
|
+
}
|
|
2132
|
+
const payload = this.state.readJson(value.payloadHash);
|
|
2133
|
+
if (!isRecord(payload)) {
|
|
2134
|
+
throw new Error(`Viewer session event payload is invalid for ${runId}`);
|
|
2135
|
+
}
|
|
2136
|
+
return {
|
|
2137
|
+
seq: value.runSeq,
|
|
2138
|
+
at: new Date(value.recordedAt).toISOString(),
|
|
2139
|
+
nodeId: value.nodeId,
|
|
2140
|
+
attemptId: value.attemptId,
|
|
2141
|
+
type: assertSessionEventType(value.eventType),
|
|
2142
|
+
payload,
|
|
2143
|
+
...(value.turnId === null ? {} : { turnId: value.turnId }),
|
|
2144
|
+
...(value.messageId === null ? {} : { messageId: value.messageId }),
|
|
2145
|
+
...(value.toolCallId === null ? {} : { toolCallId: value.toolCallId }),
|
|
2146
|
+
};
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
private viewerSessionCapture(runId: string): WorkflowSessionCapture {
|
|
2151
|
+
const segments = this.segmentRows(runId);
|
|
2152
|
+
let status: WorkflowSessionCapture["status"] = "complete";
|
|
2153
|
+
let failure: WorkflowSessionCapture["failure"];
|
|
2154
|
+
let entryCount = 0;
|
|
2155
|
+
let eventCount = 0;
|
|
2156
|
+
for (const segment of segments) {
|
|
2157
|
+
entryCount += segment.entryCount;
|
|
2158
|
+
eventCount += segment.eventCount;
|
|
2159
|
+
if (segment.status === "failed") {
|
|
2160
|
+
status = "failed";
|
|
2161
|
+
if (failure === undefined && segment.failureHash !== null) {
|
|
2162
|
+
const value = this.state.readJson(segment.failureHash);
|
|
2163
|
+
if (
|
|
2164
|
+
!isRecord(value) ||
|
|
2165
|
+
typeof value.failedAt !== "string" ||
|
|
2166
|
+
typeof value.code !== "string" ||
|
|
2167
|
+
typeof value.message !== "string"
|
|
2168
|
+
) {
|
|
2169
|
+
throw new Error(`Session capture failure is invalid for ${runId}`);
|
|
2170
|
+
}
|
|
2171
|
+
failure = {
|
|
2172
|
+
failedAt: value.failedAt,
|
|
2173
|
+
code: value.code,
|
|
2174
|
+
message: value.message,
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
} else if (segment.status === "recording" && status !== "failed") {
|
|
2178
|
+
status = "recording";
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
return {
|
|
2182
|
+
schema: SESSION_CAPTURE_SCHEMA,
|
|
2183
|
+
eventSchema: SESSION_EVENT_SCHEMA,
|
|
2184
|
+
status,
|
|
2185
|
+
eventCount,
|
|
2186
|
+
entryCount,
|
|
2187
|
+
lastEventSeq: eventCount,
|
|
2188
|
+
...(failure === undefined ? {} : { failure }),
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
|
|
1421
2192
|
async writeSessionCapture(
|
|
1422
2193
|
runId: string,
|
|
1423
2194
|
capture: WorkflowSessionCapture,
|
|
@@ -1461,6 +2232,25 @@ export class WorkflowRunStore {
|
|
|
1461
2232
|
payloadHash,
|
|
1462
2233
|
now,
|
|
1463
2234
|
);
|
|
2235
|
+
const viewerCapture = this.viewerSessionCapture(runId);
|
|
2236
|
+
recordViewerDeltas(
|
|
2237
|
+
this.state,
|
|
2238
|
+
runId,
|
|
2239
|
+
[
|
|
2240
|
+
{
|
|
2241
|
+
targetType: "conversation",
|
|
2242
|
+
targetKey: "capture",
|
|
2243
|
+
patch: [
|
|
2244
|
+
{
|
|
2245
|
+
op: "add",
|
|
2246
|
+
path: "/capture",
|
|
2247
|
+
value: parseJson(canonicalJson(viewerCapture)),
|
|
2248
|
+
},
|
|
2249
|
+
],
|
|
2250
|
+
},
|
|
2251
|
+
],
|
|
2252
|
+
now,
|
|
2253
|
+
);
|
|
1464
2254
|
});
|
|
1465
2255
|
}
|
|
1466
2256
|
|
|
@@ -1576,12 +2366,6 @@ export class WorkflowRunStore {
|
|
|
1576
2366
|
}
|
|
1577
2367
|
|
|
1578
2368
|
private assertWriteAuthority(run: RunRow, expectedRevision: number): void {
|
|
1579
|
-
const actualRevision = this.requireResourceRevision(run.resourceId);
|
|
1580
|
-
if (actualRevision !== expectedRevision) {
|
|
1581
|
-
throw new Error(
|
|
1582
|
-
`Workflow run revision conflict: expected ${expectedRevision}, got ${actualRevision}`,
|
|
1583
|
-
);
|
|
1584
|
-
}
|
|
1585
2369
|
const lease = this.state.connection
|
|
1586
2370
|
.prepare(
|
|
1587
2371
|
`SELECT generation, owner_type AS ownerType, owner_id AS ownerId,
|
|
@@ -1591,19 +2375,56 @@ export class WorkflowRunStore {
|
|
|
1591
2375
|
.get(run.resourceId);
|
|
1592
2376
|
/* istanbul ignore if -- exact schema and internal query shape */
|
|
1593
2377
|
if (!isLeaseAuthorityRow(lease)) throw new Error("Workflow run lease is missing");
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
authority === undefined
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
lease.
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
lease.
|
|
1605
|
-
|
|
1606
|
-
|
|
2378
|
+
|
|
2379
|
+
if (lease.ownerId !== null || this.authorityProvider !== undefined) {
|
|
2380
|
+
const authority = this.authorityProvider?.(run.runId);
|
|
2381
|
+
if (authority === undefined) {
|
|
2382
|
+
throw new ClaimLostError(run.runId, "missingAuthority");
|
|
2383
|
+
}
|
|
2384
|
+
const now = Date.now();
|
|
2385
|
+
if (lease.expiresAt === null || lease.expiresAt <= now) {
|
|
2386
|
+
throw new ClaimLostError(run.runId, "expired");
|
|
2387
|
+
}
|
|
2388
|
+
if (authority.ownerType !== lease.ownerType || authority.ownerId !== lease.ownerId) {
|
|
2389
|
+
throw new ClaimLostError(run.runId, "ownerChanged");
|
|
2390
|
+
}
|
|
2391
|
+
if (lease.tokenHash === null || !lease.tokenHash.equals(tokenHash(authority.token))) {
|
|
2392
|
+
throw new ClaimLostError(run.runId, "tokenChanged");
|
|
2393
|
+
}
|
|
2394
|
+
if (authority.generation !== lease.generation) {
|
|
2395
|
+
throw new ClaimLostError(run.runId, "generationChanged");
|
|
2396
|
+
}
|
|
2397
|
+
const leaseMs = authority.leaseMs ?? 30_000;
|
|
2398
|
+
if (!Number.isInteger(leaseMs) || leaseMs <= 0) {
|
|
2399
|
+
throw new Error("Workflow run authority lease duration must be a positive integer");
|
|
2400
|
+
}
|
|
2401
|
+
const renewal = this.state.connection
|
|
2402
|
+
.prepare(
|
|
2403
|
+
`UPDATE leases SET heartbeat_at = ?, expires_at = ?
|
|
2404
|
+
WHERE resource_id = ? AND owner_type = ? AND owner_id = ?
|
|
2405
|
+
AND token_hash = ? AND generation = ? AND expires_at > ?`,
|
|
2406
|
+
)
|
|
2407
|
+
.run(
|
|
2408
|
+
now,
|
|
2409
|
+
now + leaseMs,
|
|
2410
|
+
run.resourceId,
|
|
2411
|
+
authority.ownerType,
|
|
2412
|
+
authority.ownerId,
|
|
2413
|
+
tokenHash(authority.token),
|
|
2414
|
+
authority.generation,
|
|
2415
|
+
now,
|
|
2416
|
+
);
|
|
2417
|
+
/* istanbul ignore if -- BEGIN IMMEDIATE keeps the checked lease stable */
|
|
2418
|
+
if (renewal.changes !== 1) {
|
|
2419
|
+
throw new ClaimLostError(run.runId, "expired");
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
const actualRevision = this.requireResourceRevision(run.resourceId);
|
|
2424
|
+
if (actualRevision !== expectedRevision) {
|
|
2425
|
+
throw new Error(
|
|
2426
|
+
`Workflow run revision conflict: expected ${expectedRevision}, got ${actualRevision}`,
|
|
2427
|
+
);
|
|
1607
2428
|
}
|
|
1608
2429
|
}
|
|
1609
2430
|
|
|
@@ -1708,6 +2529,77 @@ export class WorkflowRunStore {
|
|
|
1708
2529
|
);
|
|
1709
2530
|
}
|
|
1710
2531
|
|
|
2532
|
+
private viewerInspectorTargets(runId: string): ViewerDeltaDraft[] {
|
|
2533
|
+
const settings = this.state.connection
|
|
2534
|
+
.prepare(
|
|
2535
|
+
`SELECT s.scope_id AS scopeId, s.resource_id AS resourceId,
|
|
2536
|
+
s.origin_run_id AS originRunId, s.active_run_id AS activeRunId,
|
|
2537
|
+
s.mount_path AS mountPath, s.invocation,
|
|
2538
|
+
s.current_hash AS currentHash, r.revision,
|
|
2539
|
+
s.created_at AS createdAt, s.updated_at AS updatedAt
|
|
2540
|
+
FROM workflow_settings s
|
|
2541
|
+
JOIN resources r ON r.resource_id = s.resource_id
|
|
2542
|
+
WHERE s.active_run_id = ?
|
|
2543
|
+
ORDER BY s.mount_path, s.invocation`,
|
|
2544
|
+
)
|
|
2545
|
+
.all(runId)
|
|
2546
|
+
.filter(isSettingsScopeRow)
|
|
2547
|
+
.map((row) => ({
|
|
2548
|
+
scopeId: row.scopeId,
|
|
2549
|
+
mountPath: row.mountPath,
|
|
2550
|
+
invocation: row.invocation,
|
|
2551
|
+
changeNumber: row.revision,
|
|
2552
|
+
settingsHash: row.currentHash.toString("hex"),
|
|
2553
|
+
}));
|
|
2554
|
+
const settingsStart = Math.max(0, settings.length - VIEWER_PAGE_SIZE);
|
|
2555
|
+
const followUps = this.state.connection
|
|
2556
|
+
.prepare(`${FOLLOW_UP_ROW_SELECT} WHERE f.run_id = ? ORDER BY f.order_number`)
|
|
2557
|
+
.all(runId)
|
|
2558
|
+
.filter(isFollowUpRow)
|
|
2559
|
+
.map((row) => ({
|
|
2560
|
+
followUpId: row.followUpId,
|
|
2561
|
+
order: row.orderNumber,
|
|
2562
|
+
state: row.status,
|
|
2563
|
+
source: row.sourceType,
|
|
2564
|
+
sessionEntryId: row.sessionEntryId,
|
|
2565
|
+
}));
|
|
2566
|
+
const followUpStart = Math.max(0, followUps.length - VIEWER_PAGE_SIZE);
|
|
2567
|
+
const queue = this.requireFollowUpQueueRow(runId);
|
|
2568
|
+
return [
|
|
2569
|
+
{
|
|
2570
|
+
targetType: "inspector",
|
|
2571
|
+
targetKey: "settings",
|
|
2572
|
+
patch: [
|
|
2573
|
+
{
|
|
2574
|
+
op: "replace",
|
|
2575
|
+
path: "/settingsScopes",
|
|
2576
|
+
value: parseJson(canonicalJson(settings.slice(settingsStart))),
|
|
2577
|
+
},
|
|
2578
|
+
{ op: "replace", path: "/settingsStart", value: settingsStart },
|
|
2579
|
+
{ op: "replace", path: "/settingsTotal", value: settings.length },
|
|
2580
|
+
],
|
|
2581
|
+
},
|
|
2582
|
+
{
|
|
2583
|
+
targetType: "inspector",
|
|
2584
|
+
targetKey: "follow-ups",
|
|
2585
|
+
patch: [
|
|
2586
|
+
{
|
|
2587
|
+
op: "replace",
|
|
2588
|
+
path: "/followUpQueue",
|
|
2589
|
+
value: parseJson(
|
|
2590
|
+
canonicalJson({
|
|
2591
|
+
presentationState: queue.presentationState,
|
|
2592
|
+
items: followUps.slice(followUpStart),
|
|
2593
|
+
}),
|
|
2594
|
+
),
|
|
2595
|
+
},
|
|
2596
|
+
{ op: "replace", path: "/followUpStart", value: followUpStart },
|
|
2597
|
+
{ op: "replace", path: "/followUpTotal", value: followUps.length },
|
|
2598
|
+
],
|
|
2599
|
+
},
|
|
2600
|
+
];
|
|
2601
|
+
}
|
|
2602
|
+
|
|
1711
2603
|
private initializeRunSettingsAndFollowUps(
|
|
1712
2604
|
state: WorkflowRunState,
|
|
1713
2605
|
initialSettings: InitialWorkflowSettingsScope[],
|
|
@@ -2207,6 +3099,20 @@ export class WorkflowRunStore {
|
|
|
2207
3099
|
}
|
|
2208
3100
|
if (state.currentAttemptId !== undefined && state.currentNode !== undefined) {
|
|
2209
3101
|
this.ensureAttempt(state, state.currentNode, state.currentAttemptId, now, snapshot);
|
|
3102
|
+
if (state.currentNodeDeadlineAt !== undefined) {
|
|
3103
|
+
const deadlineAt =
|
|
3104
|
+
state.currentNodeDeadlineAt === null ? null : Date.parse(state.currentNodeDeadlineAt);
|
|
3105
|
+
if (deadlineAt !== null && !Number.isFinite(deadlineAt)) {
|
|
3106
|
+
throw new Error("Workflow node deadline is invalid");
|
|
3107
|
+
}
|
|
3108
|
+
this.state.connection
|
|
3109
|
+
.prepare(
|
|
3110
|
+
`UPDATE node_attempts SET deadline_at = ?, updated_at = ?
|
|
3111
|
+
WHERE attempt_id = ? AND run_id = ?
|
|
3112
|
+
AND status IN ('pending', 'running', 'waiting', 'interrupted')`,
|
|
3113
|
+
)
|
|
3114
|
+
.run(deadlineAt, now, state.currentAttemptId, state.runId);
|
|
3115
|
+
}
|
|
2210
3116
|
}
|
|
2211
3117
|
this.syncRunSteps(state, now);
|
|
2212
3118
|
}
|
|
@@ -2550,6 +3456,14 @@ export class WorkflowRunStore {
|
|
|
2550
3456
|
...(activeAttempt?.startedAt === null || activeAttempt === undefined
|
|
2551
3457
|
? {}
|
|
2552
3458
|
: { currentNodeStartedAt: new Date(activeAttempt.startedAt).toISOString() }),
|
|
3459
|
+
...(activeAttempt === undefined
|
|
3460
|
+
? {}
|
|
3461
|
+
: {
|
|
3462
|
+
currentNodeDeadlineAt:
|
|
3463
|
+
activeAttempt.deadlineAt === null
|
|
3464
|
+
? null
|
|
3465
|
+
: new Date(activeAttempt.deadlineAt).toISOString(),
|
|
3466
|
+
}),
|
|
2553
3467
|
...savedCurrentSettingsBinding(
|
|
2554
3468
|
activeAttempt?.settingsScopeId ?? null,
|
|
2555
3469
|
activeAttempt?.settingsChangeNumber ?? null,
|
|
@@ -2700,6 +3614,7 @@ export class WorkflowRunStore {
|
|
|
2700
3614
|
attemptId: string;
|
|
2701
3615
|
nodeId: string;
|
|
2702
3616
|
startedAt: number | null;
|
|
3617
|
+
deadlineAt: number | null;
|
|
2703
3618
|
settingsScopeId: string | null;
|
|
2704
3619
|
settingsChangeNumber: number | null;
|
|
2705
3620
|
settingsHash: Buffer | null;
|
|
@@ -2708,7 +3623,7 @@ export class WorkflowRunStore {
|
|
|
2708
3623
|
const row = this.state.connection
|
|
2709
3624
|
.prepare(
|
|
2710
3625
|
`SELECT attempt_id AS attemptId, node_id AS nodeId, started_at AS startedAt,
|
|
2711
|
-
settings_scope_id AS settingsScopeId,
|
|
3626
|
+
deadline_at AS deadlineAt, settings_scope_id AS settingsScopeId,
|
|
2712
3627
|
settings_change_number AS settingsChangeNumber,
|
|
2713
3628
|
settings_hash AS settingsHash
|
|
2714
3629
|
FROM node_attempts
|
|
@@ -2987,11 +3902,159 @@ export class WorkflowRunStore {
|
|
|
2987
3902
|
payloadHash,
|
|
2988
3903
|
now,
|
|
2989
3904
|
);
|
|
3905
|
+
const viewerCapture = this.viewerSessionCapture(runId);
|
|
3906
|
+
recordViewerDeltas(
|
|
3907
|
+
this.state,
|
|
3908
|
+
runId,
|
|
3909
|
+
[
|
|
3910
|
+
{
|
|
3911
|
+
targetType: "conversation",
|
|
3912
|
+
targetKey: "capture",
|
|
3913
|
+
patch: [
|
|
3914
|
+
{
|
|
3915
|
+
op: "add",
|
|
3916
|
+
path: "/capture",
|
|
3917
|
+
value: parseJson(canonicalJson(viewerCapture)),
|
|
3918
|
+
},
|
|
3919
|
+
],
|
|
3920
|
+
},
|
|
3921
|
+
],
|
|
3922
|
+
now,
|
|
3923
|
+
);
|
|
2990
3924
|
});
|
|
2991
3925
|
}
|
|
2992
3926
|
}
|
|
2993
3927
|
}
|
|
2994
3928
|
|
|
3929
|
+
function runViewerTargets(
|
|
3930
|
+
state: WorkflowRunState,
|
|
3931
|
+
traceEvent: WorkflowTraceEvent,
|
|
3932
|
+
): ViewerDeltaDraft[] {
|
|
3933
|
+
const stepTotal = state.steps.length;
|
|
3934
|
+
const stepStart = Math.max(0, stepTotal - VIEWER_PAGE_SIZE);
|
|
3935
|
+
const updateTotal = state.updates?.length ?? 0;
|
|
3936
|
+
const updateStart = Math.max(0, updateTotal - VIEWER_PAGE_SIZE);
|
|
3937
|
+
const compactStep = (step: WorkflowStepRecord) => ({
|
|
3938
|
+
attemptId: step.attemptId,
|
|
3939
|
+
nodeId: step.nodeId,
|
|
3940
|
+
nodeType: step.nodeType,
|
|
3941
|
+
outcome: step.outcome,
|
|
3942
|
+
startedAt: step.startedAt,
|
|
3943
|
+
finishedAt: step.finishedAt,
|
|
3944
|
+
prompt: null,
|
|
3945
|
+
output: null,
|
|
3946
|
+
...(step.settingsScopeId === undefined ? {} : { settingsScopeId: step.settingsScopeId }),
|
|
3947
|
+
...(step.settingsChangeNumber === undefined
|
|
3948
|
+
? {}
|
|
3949
|
+
: { settingsChangeNumber: step.settingsChangeNumber }),
|
|
3950
|
+
...(step.settingsHash === undefined ? {} : { settingsHash: step.settingsHash }),
|
|
3951
|
+
});
|
|
3952
|
+
const latestStepByNode = new Map<string, WorkflowStepRecord>();
|
|
3953
|
+
for (const step of state.steps) latestStepByNode.set(step.nodeId, step);
|
|
3954
|
+
const graphSteps = state.steps
|
|
3955
|
+
.filter((step) => latestStepByNode.get(step.nodeId) === step)
|
|
3956
|
+
.map(compactStep);
|
|
3957
|
+
const transitions = new Set<string>();
|
|
3958
|
+
for (let index = 1; index < state.steps.length; index += 1) {
|
|
3959
|
+
const previous = state.steps[index - 1];
|
|
3960
|
+
const current = state.steps[index];
|
|
3961
|
+
if (previous !== undefined && current !== undefined) {
|
|
3962
|
+
transitions.add(`${previous.nodeId}->${current.nodeId}`);
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
const graphPatch: NonNullable<ViewerDeltaDraft["patch"]> = [
|
|
3966
|
+
{ op: "replace", path: "/state/traceSeq", value: state.traceSeq },
|
|
3967
|
+
{ op: "replace", path: "/state/updatedAt", value: state.updatedAt },
|
|
3968
|
+
{ op: "replace", path: "/state/status", value: state.status },
|
|
3969
|
+
{
|
|
3970
|
+
op: "add",
|
|
3971
|
+
path: "/state/updates",
|
|
3972
|
+
value: parseJson(canonicalJson(state.updates?.slice(updateStart) ?? [])),
|
|
3973
|
+
},
|
|
3974
|
+
{ op: "add", path: "/state/finishedAt", value: state.finishedAt ?? null },
|
|
3975
|
+
{ op: "add", path: "/state/currentNode", value: state.currentNode ?? null },
|
|
3976
|
+
{ op: "add", path: "/state/currentAttemptId", value: state.currentAttemptId ?? null },
|
|
3977
|
+
{
|
|
3978
|
+
op: "add",
|
|
3979
|
+
path: "/state/currentNodeStartedAt",
|
|
3980
|
+
value: state.currentNodeStartedAt ?? null,
|
|
3981
|
+
},
|
|
3982
|
+
{
|
|
3983
|
+
op: "add",
|
|
3984
|
+
path: "/state/currentNodeDeadlineAt",
|
|
3985
|
+
value: state.currentNodeDeadlineAt ?? null,
|
|
3986
|
+
},
|
|
3987
|
+
{
|
|
3988
|
+
op: "add",
|
|
3989
|
+
path: "/state/currentSettingsScopeId",
|
|
3990
|
+
value: state.currentSettingsScopeId ?? null,
|
|
3991
|
+
},
|
|
3992
|
+
{
|
|
3993
|
+
op: "add",
|
|
3994
|
+
path: "/state/currentSettingsChangeNumber",
|
|
3995
|
+
value: state.currentSettingsChangeNumber ?? null,
|
|
3996
|
+
},
|
|
3997
|
+
{
|
|
3998
|
+
op: "add",
|
|
3999
|
+
path: "/state/currentSettingsHash",
|
|
4000
|
+
value: state.currentSettingsHash ?? null,
|
|
4001
|
+
},
|
|
4002
|
+
{ op: "add", path: "/state/statusDetail", value: state.statusDetail ?? null },
|
|
4003
|
+
{ op: "add", path: "/state/paused", value: state.paused ?? false },
|
|
4004
|
+
{ op: "add", path: "/state/waitingOn", value: state.waitingOn ?? null },
|
|
4005
|
+
{
|
|
4006
|
+
op: "add",
|
|
4007
|
+
path: "/state/humanDecision",
|
|
4008
|
+
value: parseJson(canonicalJson(state.humanDecision ?? null)),
|
|
4009
|
+
},
|
|
4010
|
+
{
|
|
4011
|
+
op: "replace",
|
|
4012
|
+
path: "/graphSteps",
|
|
4013
|
+
value: parseJson(canonicalJson(graphSteps)),
|
|
4014
|
+
},
|
|
4015
|
+
{
|
|
4016
|
+
op: "replace",
|
|
4017
|
+
path: "/takenTransitions",
|
|
4018
|
+
value: [...transitions].sort(),
|
|
4019
|
+
},
|
|
4020
|
+
{ op: "replace", path: "/graphCursor", value: Math.max(0, stepTotal - 1) },
|
|
4021
|
+
{ op: "replace", path: "/stepStart", value: stepStart },
|
|
4022
|
+
{ op: "replace", path: "/stepTotal", value: stepTotal },
|
|
4023
|
+
{ op: "replace", path: "/updateStart", value: updateStart },
|
|
4024
|
+
{ op: "replace", path: "/updateTotal", value: updateTotal },
|
|
4025
|
+
{ op: "replace", path: "/manifest/status", value: state.status },
|
|
4026
|
+
{ op: "add", path: "/manifest/finishedAt", value: state.finishedAt ?? null },
|
|
4027
|
+
{
|
|
4028
|
+
op: "replace",
|
|
4029
|
+
path: "/live",
|
|
4030
|
+
value: state.status === "running" || state.status === "waiting",
|
|
4031
|
+
},
|
|
4032
|
+
];
|
|
4033
|
+
const targets: ViewerDeltaDraft[] = [
|
|
4034
|
+
{ targetType: "graph", patch: graphPatch },
|
|
4035
|
+
{
|
|
4036
|
+
targetType: "timeline",
|
|
4037
|
+
targetKey: "trace:tail",
|
|
4038
|
+
patch: viewerTailPatch(traceEvent.seq - 1, [
|
|
4039
|
+
parseJson(
|
|
4040
|
+
canonicalJson({
|
|
4041
|
+
...traceEvent,
|
|
4042
|
+
payload: compactTracePayload(traceEvent.type, traceEvent.payload),
|
|
4043
|
+
}),
|
|
4044
|
+
),
|
|
4045
|
+
]),
|
|
4046
|
+
},
|
|
4047
|
+
];
|
|
4048
|
+
if (traceEvent.type === "node_finished" || traceEvent.type === "node_failed") {
|
|
4049
|
+
targets.push({
|
|
4050
|
+
targetType: "replay",
|
|
4051
|
+
targetKey: "steps:reload",
|
|
4052
|
+
patch: [{ op: "replace", path: "/stepTotal", value: stepTotal }],
|
|
4053
|
+
});
|
|
4054
|
+
}
|
|
4055
|
+
return targets;
|
|
4056
|
+
}
|
|
4057
|
+
|
|
2995
4058
|
export function readWorkflowRun(
|
|
2996
4059
|
runId: string,
|
|
2997
4060
|
options: ReadWorkflowRunOptions & { databasePath?: string } = {},
|
|
@@ -3265,6 +4328,21 @@ function emptySegment(): SessionCaptureSegment {
|
|
|
3265
4328
|
};
|
|
3266
4329
|
}
|
|
3267
4330
|
|
|
4331
|
+
function assertSessionEventType(value: string): WorkflowSessionEventType {
|
|
4332
|
+
switch (value) {
|
|
4333
|
+
case "turn_started":
|
|
4334
|
+
case "turn_finished":
|
|
4335
|
+
case "message_started":
|
|
4336
|
+
case "assistant_event":
|
|
4337
|
+
case "message_finished":
|
|
4338
|
+
case "tool_execution_started":
|
|
4339
|
+
case "tool_execution_finished":
|
|
4340
|
+
return value;
|
|
4341
|
+
default:
|
|
4342
|
+
throw new Error(`Unknown session event type: ${value}`);
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
|
|
3268
4346
|
function validateSessionEventRecord(record: WorkflowSessionEventRecord): void {
|
|
3269
4347
|
if (!Number.isSafeInteger(record.seq) || record.seq < 1) {
|
|
3270
4348
|
throw new Error("Session event seq must be a positive safe integer");
|
|
@@ -3473,6 +4551,10 @@ function isCountRow(value: unknown): value is { count: number } {
|
|
|
3473
4551
|
return isRecord(value) && typeof value.count === "number";
|
|
3474
4552
|
}
|
|
3475
4553
|
|
|
4554
|
+
function isStepIndexRow(value: unknown): value is { stepIndex: number } {
|
|
4555
|
+
return isRecord(value) && typeof value.stepIndex === "number";
|
|
4556
|
+
}
|
|
4557
|
+
|
|
3476
4558
|
function isLeaseExpiryRow(value: unknown): value is { expiresAt: number | null } {
|
|
3477
4559
|
return isRecord(value) && (typeof value.expiresAt === "number" || value.expiresAt === null);
|
|
3478
4560
|
}
|
|
@@ -3481,6 +4563,52 @@ function isNextOrderRow(value: unknown): value is { nextOrder: number } {
|
|
|
3481
4563
|
return isRecord(value) && typeof value.nextOrder === "number";
|
|
3482
4564
|
}
|
|
3483
4565
|
|
|
4566
|
+
function isViewerSessionCheckpointRow(value: unknown): value is ViewerSessionCheckpointRow {
|
|
4567
|
+
return isRecord(value) && typeof value.eventSeq === "number" && Buffer.isBuffer(value.stateHash);
|
|
4568
|
+
}
|
|
4569
|
+
|
|
4570
|
+
function isViewerSessionEventRow(value: unknown): value is ViewerSessionEventRow {
|
|
4571
|
+
return (
|
|
4572
|
+
isRecord(value) &&
|
|
4573
|
+
typeof value.runSeq === "number" &&
|
|
4574
|
+
typeof value.eventType === "string" &&
|
|
4575
|
+
typeof value.nodeId === "string" &&
|
|
4576
|
+
typeof value.attemptId === "string" &&
|
|
4577
|
+
(typeof value.turnId === "string" || value.turnId === null) &&
|
|
4578
|
+
(typeof value.messageId === "string" || value.messageId === null) &&
|
|
4579
|
+
(typeof value.toolCallId === "string" || value.toolCallId === null) &&
|
|
4580
|
+
Buffer.isBuffer(value.payloadHash) &&
|
|
4581
|
+
typeof value.recordedAt === "number"
|
|
4582
|
+
);
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
function isTemporalSessionState(value: unknown): value is TemporalSessionState {
|
|
4586
|
+
return (
|
|
4587
|
+
isRecord(value) &&
|
|
4588
|
+
typeof value.throughSeq === "number" &&
|
|
4589
|
+
Array.isArray(value.messages) &&
|
|
4590
|
+
Array.isArray(value.tools) &&
|
|
4591
|
+
Array.isArray(value.settledEntryIds) &&
|
|
4592
|
+
value.settledEntryIds.every((item) => typeof item === "string") &&
|
|
4593
|
+
Array.isArray(value.diagnostics) &&
|
|
4594
|
+
value.diagnostics.every((item) => typeof item === "string")
|
|
4595
|
+
);
|
|
4596
|
+
}
|
|
4597
|
+
|
|
4598
|
+
function boundedTemporalCheckpoint(state: TemporalSessionState): TemporalSessionState {
|
|
4599
|
+
const messages = state.messages.filter((message) => message.status === "streaming");
|
|
4600
|
+
const activeMessages = new Set(messages.map((message) => message.messageId));
|
|
4601
|
+
return {
|
|
4602
|
+
...state,
|
|
4603
|
+
messages,
|
|
4604
|
+
tools: state.tools.filter(
|
|
4605
|
+
(tool) => tool.status === "running" && activeMessages.has(tool.messageId),
|
|
4606
|
+
),
|
|
4607
|
+
settledEntryIds: [],
|
|
4608
|
+
diagnostics: ["earlier session messages are outside this page"],
|
|
4609
|
+
};
|
|
4610
|
+
}
|
|
4611
|
+
|
|
3484
4612
|
function assertValidRunId(runId: string): void {
|
|
3485
4613
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
|
|
3486
4614
|
throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
|
|
@@ -3569,6 +4697,7 @@ function isActiveAttemptRow(value: unknown): value is {
|
|
|
3569
4697
|
attemptId: string;
|
|
3570
4698
|
nodeId: string;
|
|
3571
4699
|
startedAt: number | null;
|
|
4700
|
+
deadlineAt: number | null;
|
|
3572
4701
|
settingsScopeId: string | null;
|
|
3573
4702
|
settingsChangeNumber: number | null;
|
|
3574
4703
|
settingsHash: Buffer | null;
|
|
@@ -3635,6 +4764,79 @@ function isLeaseAuthorityRow(value: unknown): value is {
|
|
|
3635
4764
|
return isRecord(value);
|
|
3636
4765
|
}
|
|
3637
4766
|
|
|
4767
|
+
type ApplyingEffectRow = {
|
|
4768
|
+
effectId: string;
|
|
4769
|
+
resourceId: string;
|
|
4770
|
+
payloadHash: Buffer;
|
|
4771
|
+
attemptCount: number;
|
|
4772
|
+
};
|
|
4773
|
+
|
|
4774
|
+
type WorkflowEffectRow = {
|
|
4775
|
+
status: "pending" | "applying" | "applied" | "rejected" | "ambiguous" | "cancelled";
|
|
4776
|
+
payloadHash: Buffer;
|
|
4777
|
+
resultHash: Buffer | null;
|
|
4778
|
+
attemptCount: number;
|
|
4779
|
+
};
|
|
4780
|
+
|
|
4781
|
+
function isApplyingEffectRow(value: unknown): value is ApplyingEffectRow {
|
|
4782
|
+
return (
|
|
4783
|
+
isRecord(value) &&
|
|
4784
|
+
typeof value.effectId === "string" &&
|
|
4785
|
+
typeof value.resourceId === "string" &&
|
|
4786
|
+
Buffer.isBuffer(value.payloadHash) &&
|
|
4787
|
+
typeof value.attemptCount === "number"
|
|
4788
|
+
);
|
|
4789
|
+
}
|
|
4790
|
+
|
|
4791
|
+
function effectRecoveryFromPayload(value: JsonValue): WorkflowEffectRecovery {
|
|
4792
|
+
return typeof value === "object" &&
|
|
4793
|
+
value !== null &&
|
|
4794
|
+
!Array.isArray(value) &&
|
|
4795
|
+
value.recovery === "idempotent"
|
|
4796
|
+
? "idempotent"
|
|
4797
|
+
: "manual";
|
|
4798
|
+
}
|
|
4799
|
+
|
|
4800
|
+
function isWorkflowEffectRow(value: unknown): value is WorkflowEffectRow {
|
|
4801
|
+
return (
|
|
4802
|
+
isRecord(value) &&
|
|
4803
|
+
typeof value.status === "string" &&
|
|
4804
|
+
Buffer.isBuffer(value.payloadHash) &&
|
|
4805
|
+
(value.resultHash === null || Buffer.isBuffer(value.resultHash)) &&
|
|
4806
|
+
typeof value.attemptCount === "number"
|
|
4807
|
+
);
|
|
4808
|
+
}
|
|
4809
|
+
|
|
4810
|
+
function isWorkflowEffectIdentityRow(
|
|
4811
|
+
value: unknown,
|
|
4812
|
+
): value is { resourceId: string; status: string } {
|
|
4813
|
+
return (
|
|
4814
|
+
isRecord(value) && typeof value.resourceId === "string" && typeof value.status === "string"
|
|
4815
|
+
);
|
|
4816
|
+
}
|
|
4817
|
+
|
|
4818
|
+
function isResourceIdRow(value: unknown): value is { resourceId: string } {
|
|
4819
|
+
return isRecord(value) && typeof value.resourceId === "string";
|
|
4820
|
+
}
|
|
4821
|
+
|
|
4822
|
+
function workflowEffectId(
|
|
4823
|
+
sourceResourceId: string,
|
|
4824
|
+
effectType: string,
|
|
4825
|
+
idempotencyKey: string,
|
|
4826
|
+
): string {
|
|
4827
|
+
const digest = createHash("sha256")
|
|
4828
|
+
.update(`${sourceResourceId}\0${effectType}\0${idempotencyKey}`)
|
|
4829
|
+
.digest("hex")
|
|
4830
|
+
.slice(0, 40);
|
|
4831
|
+
return `effect-${digest}`;
|
|
4832
|
+
}
|
|
4833
|
+
|
|
4834
|
+
function requireBoundedEffectText(value: string, label: string): void {
|
|
4835
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 256) {
|
|
4836
|
+
throw new Error(`Managed effect ${label} must be nonempty text of at most 256 characters`);
|
|
4837
|
+
}
|
|
4838
|
+
}
|
|
4839
|
+
|
|
3638
4840
|
export function createDefinitionSnapshot(workflow: WorkflowDefinition): WorkflowDefinitionSnapshot {
|
|
3639
4841
|
const metadata = compositionMetadata(workflow);
|
|
3640
4842
|
const composition = metadata?.snapshot;
|
|
@@ -3718,7 +4920,13 @@ function snapshotNode(
|
|
|
3718
4920
|
...(typeof node.humanDecision.onTimeout === "function" ? { dynamicTimeout: true } : {}),
|
|
3719
4921
|
};
|
|
3720
4922
|
}
|
|
3721
|
-
if (node.nodeType === "action")
|
|
4923
|
+
if (node.nodeType === "action") {
|
|
4924
|
+
common.actionExecution = "exec" in node ? "shell" : "function";
|
|
4925
|
+
/* istanbul ignore else -- validated action nodes always declare a managed effect */
|
|
4926
|
+
if (node.effect !== undefined) {
|
|
4927
|
+
common.effect = { type: node.effect.type, recovery: node.effect.recovery };
|
|
4928
|
+
}
|
|
4929
|
+
}
|
|
3722
4930
|
return common;
|
|
3723
4931
|
}
|
|
3724
4932
|
|