@osolmaz/pi-workflows 0.12.1 → 0.13.1
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 +59 -33
- package/dist/builtins/autodoc.workflow.d.ts +194 -7
- package/dist/builtins/autodoc.workflow.js +156 -30
- package/dist/builtins/autodoc.workflow.js.map +1 -1
- package/dist/builtins/autoimplement-command-batches.d.ts +1 -0
- package/dist/builtins/autoimplement-command-batches.js +29 -31
- package/dist/builtins/autoimplement-command-batches.js.map +1 -1
- package/dist/builtins/autoimplement.workflow.d.ts +1619 -97
- package/dist/builtins/autoimplement.workflow.js +416 -153
- package/dist/builtins/autoimplement.workflow.js.map +1 -1
- package/dist/builtins/autoplan.workflow.d.ts +126 -13
- package/dist/builtins/autoplan.workflow.js +200 -29
- package/dist/builtins/autoplan.workflow.js.map +1 -1
- package/dist/builtins/catalog.js +7 -5
- package/dist/builtins/catalog.js.map +1 -1
- package/dist/builtins/change-verification.workflow.d.ts +110 -0
- package/dist/builtins/change-verification.workflow.js +860 -0
- package/dist/builtins/change-verification.workflow.js.map +1 -0
- package/dist/builtins/index.d.ts +2 -1
- package/dist/builtins/index.js +1 -0
- package/dist/builtins/index.js.map +1 -1
- package/dist/builtins/monitor.workflow.d.ts +56 -21
- package/dist/builtins/monitor.workflow.js +499 -217
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/plain-summary.workflow.d.ts +32 -0
- package/dist/builtins/plain-summary.workflow.js +134 -0
- package/dist/builtins/plain-summary.workflow.js.map +1 -0
- package/dist/builtins/plan-change.workflow.d.ts +527 -26
- package/dist/builtins/plan-change.workflow.js +26 -0
- package/dist/builtins/plan-change.workflow.js.map +1 -1
- package/dist/builtins/sanity-check.workflow.d.ts +45 -3
- package/dist/builtins/sanity-check.workflow.js +45 -7
- package/dist/builtins/sanity-check.workflow.js.map +1 -1
- package/dist/builtins/workspace-preparation.workflow.d.ts +75 -0
- package/dist/builtins/workspace-preparation.workflow.js +498 -0
- package/dist/builtins/workspace-preparation.workflow.js.map +1 -0
- package/dist/controllers/effects.d.ts +3 -2
- package/dist/controllers/effects.js +8 -1
- package/dist/controllers/effects.js.map +1 -1
- package/dist/controllers/index.d.ts +1 -1
- package/dist/controllers/index.js +1 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/manager.d.ts +1 -0
- package/dist/controllers/manager.js +41 -17
- package/dist/controllers/manager.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +107 -85
- package/dist/controllers/sqlite.js +1611 -1486
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/store.d.ts +9 -8
- package/dist/controllers/store.js +1 -31
- package/dist/controllers/store.js.map +1 -1
- package/dist/controllers/types.d.ts +3 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
- package/dist/controllers/workflow-engine-scheduler.js +3 -5
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
- package/dist/controllers/workflows.d.ts +2 -2
- package/dist/controllers/workflows.js +11 -10
- package/dist/controllers/workflows.js.map +1 -1
- package/dist/extension/controller-host.js +2 -2
- package/dist/extension/controller-host.js.map +1 -1
- package/dist/extension/decision-channels.d.ts +2 -0
- package/dist/extension/decision-channels.js +311 -118
- package/dist/extension/decision-channels.js.map +1 -1
- package/dist/extension/executor.d.ts +9 -2
- package/dist/extension/executor.js +90 -6
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/herdr-viewer.d.ts +0 -1
- package/dist/extension/herdr-viewer.js +0 -4
- package/dist/extension/herdr-viewer.js.map +1 -1
- package/dist/extension/index.js +156 -117
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +3 -10
- package/dist/extension/recorder.js +13 -22
- package/dist/extension/recorder.js.map +1 -1
- package/dist/extension/step-message.d.ts +7 -2
- package/dist/extension/step-message.js +94 -2
- package/dist/extension/step-message.js.map +1 -1
- package/dist/extension/widget.js +6 -0
- package/dist/extension/widget.js.map +1 -1
- package/dist/host/rpc-executor.d.ts +3 -0
- package/dist/host/rpc-executor.js +2 -0
- package/dist/host/rpc-executor.js.map +1 -1
- package/dist/host/runner.d.ts +3 -6
- package/dist/host/runner.js +83 -32
- package/dist/host/runner.js.map +1 -1
- package/dist/render/graph-render.js +14 -8
- package/dist/render/graph-render.js.map +1 -1
- package/dist/state/database.d.ts +35 -0
- package/dist/state/database.js +287 -0
- package/dist/state/database.js.map +1 -0
- package/dist/state/index.d.ts +4 -0
- package/dist/state/index.js +5 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/json.d.ts +6 -0
- package/dist/state/json.js +38 -0
- package/dist/state/json.js.map +1 -0
- package/dist/state/mutation.d.ts +57 -0
- package/dist/state/mutation.js +255 -0
- package/dist/state/mutation.js.map +1 -0
- package/dist/state/schema.d.ts +6 -0
- package/dist/state/schema.js +473 -0
- package/dist/state/schema.js.map +1 -0
- package/dist/viewer/cli.d.ts +2 -2
- package/dist/viewer/cli.js +142 -122
- package/dist/viewer/cli.js.map +1 -1
- package/dist/viewer/render.d.ts +4 -4
- package/dist/viewer/render.js +2 -17
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/session-reducer.d.ts +1 -1
- package/dist/viewer/session-reducer.js +1 -1
- package/dist/viewer/tui.d.ts +3 -3
- package/dist/viewer/tui.js +15 -13
- package/dist/viewer/tui.js.map +1 -1
- package/dist/viewer/watch.d.ts +2 -5
- package/dist/viewer/watch.js +13 -13
- package/dist/viewer/watch.js.map +1 -1
- package/dist/workflows/composition.js +12 -2
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.d.ts +6 -2
- package/dist/workflows/definition.js +17 -0
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +6 -6
- package/dist/workflows/engine.js +183 -103
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +3 -3
- package/dist/workflows/errors.js +3 -3
- package/dist/workflows/human-decision.d.ts +29 -4
- package/dist/workflows/human-decision.js +547 -283
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +4 -5
- package/dist/workflows/index.js +3 -4
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/schema.js +17 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +83 -103
- package/dist/workflows/store.js +902 -951
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +55 -81
- package/docs/CONTROLLERS.md +10 -8
- package/docs/DEFERRED_TURNS.md +2 -2
- package/docs/DESIGN_PHILOSOPHY.md +4 -2
- package/docs/HUMAN_DECISIONS.md +13 -14
- package/docs/MONITOR.md +179 -231
- package/docs/SQLITE_STATE.md +219 -0
- package/docs/WORKFLOW_COMPOSITION.md +27 -19
- package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
- package/docs/WORKFLOW_UPDATES.md +10 -9
- package/docs/development.md +9 -9
- package/docs/live-replay-protocol.md +28 -37
- package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
- package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
- package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
- package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +1 -1
- package/docs/plans/2026-08-21-sanity-check-plan.md +30 -16
- package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
- package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
- package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
- package/docs/plans/2026-08-24-change-scoped-verification-plan.md +419 -0
- package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
- package/docs/plans/session-event-replay-implementation-plan.md +1 -1
- package/docs/plans/tui-viewer-implementation-plan.md +1 -1
- package/docs/session-event-journal.md +63 -434
- package/docs/tui-viewer.md +13 -15
- package/docs/workflows.md +111 -62
- package/examples/workflows/plain-summary.workflow.ts +1 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/plugins/herdr/viewer.mjs +1 -11
- package/skills/autodoc/SKILL.md +7 -0
- package/skills/autoimplement/SKILL.md +4 -0
- package/skills/autoplan/SKILL.md +5 -4
- package/skills/monitor/SKILL.md +91 -163
- package/skills/pi-workflows/SKILL.md +7 -7
- package/skills/sanity-check/SKILL.md +1 -1
- package/src/builtins/autodoc.workflow.ts +184 -33
- package/src/builtins/autoimplement-command-batches.ts +39 -33
- package/src/builtins/autoimplement.workflow.ts +483 -175
- package/src/builtins/autoplan.workflow.ts +290 -40
- package/src/builtins/catalog.ts +7 -5
- package/src/builtins/change-verification.workflow.ts +1143 -0
- package/src/builtins/index.ts +7 -1
- package/src/builtins/monitor.workflow.ts +667 -250
- package/src/builtins/plain-summary.workflow.ts +185 -0
- package/src/builtins/plan-change.workflow.ts +35 -0
- package/src/builtins/sanity-check.workflow.ts +62 -7
- package/src/builtins/workspace-preparation.workflow.ts +668 -0
- package/src/controllers/effects.ts +7 -0
- package/src/controllers/index.ts +0 -6
- package/src/controllers/manager.ts +72 -32
- package/src/controllers/sqlite.ts +2420 -1948
- package/src/controllers/store.ts +17 -38
- package/src/controllers/types.ts +3 -0
- package/src/controllers/workflow-engine-scheduler.ts +4 -8
- package/src/controllers/workflows.ts +45 -21
- package/src/extension/controller-host.ts +1 -2
- package/src/extension/decision-channels.ts +427 -144
- package/src/extension/executor.ts +106 -6
- package/src/extension/herdr-viewer.ts +0 -5
- package/src/extension/index.ts +180 -132
- package/src/extension/recorder.ts +13 -22
- package/src/extension/step-message.ts +106 -4
- package/src/extension/widget.ts +8 -0
- package/src/host/rpc-executor.ts +4 -0
- package/src/host/runner.ts +93 -41
- package/src/render/graph-render.ts +11 -4
- package/src/state/database.ts +375 -0
- package/src/state/index.ts +29 -0
- package/src/state/json.ts +42 -0
- package/src/state/mutation.ts +435 -0
- package/src/state/schema.ts +475 -0
- package/src/viewer/cli.ts +151 -144
- package/src/viewer/render.ts +12 -24
- package/src/viewer/session-reducer.ts +1 -1
- package/src/viewer/tui.ts +18 -17
- package/src/viewer/watch.ts +13 -17
- package/src/workflows/composition.ts +12 -3
- package/src/workflows/definition.ts +36 -3
- package/src/workflows/engine.ts +212 -116
- package/src/workflows/errors.ts +3 -3
- package/src/workflows/human-decision.ts +795 -340
- package/src/workflows/index.ts +15 -19
- package/src/workflows/schema.ts +19 -1
- package/src/workflows/store.ts +1295 -1163
- package/src/workflows/types.ts +61 -81
- package/dist/workflows/artifacts.d.ts +0 -40
- package/dist/workflows/artifacts.js +0 -155
- package/dist/workflows/artifacts.js.map +0 -1
- package/dist/workflows/migrate-sources.d.ts +0 -42
- package/dist/workflows/migrate-sources.js +0 -133
- package/dist/workflows/migrate-sources.js.map +0 -1
- package/docs/run-bundles.md +0 -481
- package/src/workflows/artifacts.ts +0 -188
- package/src/workflows/migrate-sources.ts +0 -178
package/src/workflows/store.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import { ArtifactWriter, encodeValue } from "./artifacts.js";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { StateDatabase, workflowStatePath } from "../state/database.js";
|
|
3
|
+
import { canonicalJson } from "../state/json.js";
|
|
4
|
+
import { resourceIdFor, tokenHash, type MutationActor, type OwnerType } from "../state/mutation.js";
|
|
6
5
|
import { compositionMetadata } from "./composition.js";
|
|
7
6
|
import type {
|
|
8
7
|
WorkflowDefinition,
|
|
9
8
|
WorkflowDefinitionSnapshot,
|
|
10
9
|
WorkflowNodeDefinition,
|
|
11
10
|
WorkflowNodeSnapshot,
|
|
12
|
-
WorkflowRunManifest,
|
|
13
11
|
WorkflowRunState,
|
|
14
12
|
WorkflowSessionBinding,
|
|
15
13
|
WorkflowSessionCapture,
|
|
@@ -22,427 +20,330 @@ import type {
|
|
|
22
20
|
} from "./types.js";
|
|
23
21
|
import { MAX_CURRENT_UPDATES, createUpdateId, updateProjection } from "./updates.js";
|
|
24
22
|
|
|
25
|
-
export const RUN_BUNDLE_SCHEMA = "pi-workflows.run-bundle.v1" as const;
|
|
26
23
|
export const RUN_STATE_SCHEMA = "pi-workflows.run-state.v1" as const;
|
|
27
|
-
export const TRACE_EVENT_SCHEMA = "pi-workflows.trace-event.v1" as const;
|
|
28
24
|
export const DEFINITION_SNAPSHOT_SCHEMA = "pi-workflows.definition-snapshot.v1" as const;
|
|
29
25
|
export const SESSION_BINDING_SCHEMA = "pi-workflows.session-binding.v1" as const;
|
|
30
26
|
export const SESSION_EVENT_SCHEMA = "pi-workflows.session-event.v1" as const;
|
|
31
27
|
export const SESSION_CAPTURE_SCHEMA = "pi-workflows.session-capture.v1" as const;
|
|
32
28
|
export const SESSION_EVENT_MAX_BYTES = 1024 * 1024;
|
|
33
29
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const STATE_PATH = "state.json";
|
|
37
|
-
const TRACE_PATH = "trace.ndjson";
|
|
38
|
-
const SESSION_DIR = "session";
|
|
39
|
-
const SESSION_BINDING_PATH = `${SESSION_DIR}/binding.json`;
|
|
40
|
-
const SESSION_ENTRIES_PATH = `${SESSION_DIR}/entries.ndjson`;
|
|
41
|
-
const SESSION_EVENTS_PATH = `${SESSION_DIR}/events.ndjson`;
|
|
42
|
-
const SESSION_CAPTURE_PATH = `${SESSION_DIR}/capture.json`;
|
|
43
|
-
const SESSION_SEGMENTS_DIR = `${SESSION_DIR}/segments`;
|
|
44
|
-
|
|
45
|
-
/** Capture file layout for one recorder attempt; "" is the legacy flat stream. */
|
|
46
|
-
function sessionStreamPaths(attemptId?: string): {
|
|
47
|
-
dir: string;
|
|
48
|
-
binding: string;
|
|
49
|
-
entries: string;
|
|
50
|
-
events: string;
|
|
51
|
-
capture: string;
|
|
52
|
-
} {
|
|
53
|
-
if (attemptId === undefined) {
|
|
54
|
-
return {
|
|
55
|
-
dir: SESSION_DIR,
|
|
56
|
-
binding: SESSION_BINDING_PATH,
|
|
57
|
-
entries: SESSION_ENTRIES_PATH,
|
|
58
|
-
events: SESSION_EVENTS_PATH,
|
|
59
|
-
capture: SESSION_CAPTURE_PATH,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
assertValidRunId(attemptId);
|
|
63
|
-
const dir = `${SESSION_SEGMENTS_DIR}/${attemptId}`;
|
|
64
|
-
return {
|
|
65
|
-
dir,
|
|
66
|
-
binding: `${dir}/binding.json`,
|
|
67
|
-
entries: `${dir}/entries.ndjson`,
|
|
68
|
-
events: `${dir}/events.ndjson`,
|
|
69
|
-
capture: `${dir}/capture.json`,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Runs directory: `$PI_WORKFLOWS_RUNS_DIR` or `~/.pi/agent/workflows/runs`. */
|
|
74
|
-
export function workflowRunsBaseDir(homeDir: string = os.homedir()): string {
|
|
75
|
-
const override = process.env.PI_WORKFLOWS_RUNS_DIR;
|
|
76
|
-
if (override !== undefined && override.length > 0) {
|
|
77
|
-
return override;
|
|
78
|
-
}
|
|
79
|
-
return path.join(homeDir, ".pi", "agent", "workflows", "runs");
|
|
30
|
+
export function workflowStateDatabasePath(homeDir?: string): string {
|
|
31
|
+
return workflowStatePath(homeDir);
|
|
80
32
|
}
|
|
81
33
|
|
|
82
34
|
export function createRunId(workflowName: string, now: Date = new Date()): string {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
.replaceAll(/[-:]/g, "")
|
|
86
|
-
.replace(/\.\d+Z$/, "Z");
|
|
87
|
-
const slug = workflowName
|
|
88
|
-
.toLowerCase()
|
|
89
|
-
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
90
|
-
.replaceAll(/(^-|-$)/g, "")
|
|
91
|
-
.slice(0, 40);
|
|
92
|
-
return `${stamp}-${slug || "workflow"}-${randomUUID().slice(0, 8)}`;
|
|
35
|
+
const safeName = workflowName.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80) || "workflow";
|
|
36
|
+
return `${now.toISOString().replace(/[-:.]/g, "").replace("T", "T").replace("Z", "Z")}-${safeName}-${randomUUID().slice(0, 8)}`;
|
|
93
37
|
}
|
|
94
38
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
/** Session events have a separate append chain so token traffic cannot
|
|
102
|
-
* queue ahead of workflow transitions. */
|
|
103
|
-
lock: Promise<unknown>;
|
|
39
|
+
export type RunWriteAuthority = {
|
|
40
|
+
actor: MutationActor;
|
|
41
|
+
ownerType: OwnerType;
|
|
42
|
+
ownerId: string;
|
|
43
|
+
token: string;
|
|
44
|
+
generation: number;
|
|
104
45
|
};
|
|
105
46
|
|
|
106
|
-
type
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
* Serializes complete transitions (encode, trace append, projections) so
|
|
111
|
-
* concurrent writers cannot interleave sequence assignment with physical
|
|
112
|
-
* append order.
|
|
113
|
-
*/
|
|
114
|
-
lock: Promise<unknown>;
|
|
115
|
-
streams: Map<string, SessionStreamState>;
|
|
47
|
+
export type WorkflowRunStoreOptions = {
|
|
48
|
+
authorityProvider?: (runId: string) => RunWriteAuthority | undefined;
|
|
49
|
+
state?: StateDatabase;
|
|
50
|
+
readOnly?: boolean;
|
|
116
51
|
};
|
|
117
52
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
* `manifest.json`. Large string leaves in persisted values are externalized
|
|
123
|
-
* into content-addressed `artifacts/`. Bundles are private: directories are
|
|
124
|
-
* 0700 and files 0600.
|
|
125
|
-
*/
|
|
126
|
-
/**
|
|
127
|
-
* A fence proves the writer still owns the run. It is checked before every
|
|
128
|
-
* locked write; it throws (ClaimLostError) when the queue claim was lost, so
|
|
129
|
-
* a stalled runner can never interleave writes with the new claim holder.
|
|
130
|
-
*/
|
|
131
|
-
export type RunFence = () => void;
|
|
53
|
+
type RunContext = {
|
|
54
|
+
revision: number;
|
|
55
|
+
lock: Promise<void>;
|
|
56
|
+
};
|
|
132
57
|
|
|
133
|
-
|
|
134
|
-
|
|
58
|
+
type RunRow = {
|
|
59
|
+
runId: string;
|
|
60
|
+
resourceId: string;
|
|
61
|
+
stateHash: Buffer;
|
|
62
|
+
definitionHash: Buffer;
|
|
63
|
+
status: string;
|
|
135
64
|
};
|
|
136
65
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
66
|
+
type EventRow = {
|
|
67
|
+
resourceRevision: number;
|
|
68
|
+
eventType: string;
|
|
69
|
+
payloadHash: Buffer | null;
|
|
70
|
+
recordedAt: number;
|
|
71
|
+
};
|
|
141
72
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
73
|
+
type SegmentRow = {
|
|
74
|
+
segmentId: string;
|
|
75
|
+
runId: string;
|
|
76
|
+
attemptId: string | null;
|
|
77
|
+
captureKey: string | null;
|
|
78
|
+
sessionId: string;
|
|
79
|
+
bindingHash: Buffer | null;
|
|
80
|
+
status: WorkflowSessionCapture["status"];
|
|
81
|
+
entryCount: number;
|
|
82
|
+
eventCount: number;
|
|
83
|
+
failureHash: Buffer | null;
|
|
84
|
+
createdAt: number;
|
|
85
|
+
finishedAt: number | null;
|
|
86
|
+
};
|
|
146
87
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
88
|
+
type SessionEntryRow = {
|
|
89
|
+
entrySeq: number;
|
|
90
|
+
entryHash: Buffer;
|
|
91
|
+
recordedAt: number;
|
|
92
|
+
};
|
|
151
93
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (!runStat.isDirectory() || runStat.isSymbolicLink()) {
|
|
164
|
-
throw new Error(`Reserved workflow run path is not a directory: ${runDir}`);
|
|
165
|
-
}
|
|
166
|
-
try {
|
|
167
|
-
await fs.lstat(path.join(runDir, MANIFEST_PATH));
|
|
168
|
-
throw new Error(`Reserved workflow run has an unreadable manifest: ${runId}`);
|
|
169
|
-
} catch (error) {
|
|
170
|
-
if (!isMissingPath(error)) {
|
|
171
|
-
throw error;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
const quarantineDir = path.join(
|
|
175
|
-
this.outputRoot,
|
|
176
|
-
`.${runId}.incomplete-${randomUUID().slice(0, 8)}`,
|
|
177
|
-
);
|
|
178
|
-
await fs.rename(runDir, quarantineDir);
|
|
179
|
-
this.contexts.delete(runDir);
|
|
180
|
-
return quarantineDir;
|
|
181
|
-
}
|
|
94
|
+
type SessionEventRow = {
|
|
95
|
+
eventSeq: number;
|
|
96
|
+
eventType: WorkflowSessionEventRecord["type"];
|
|
97
|
+
nodeId: string;
|
|
98
|
+
attemptId: string;
|
|
99
|
+
turnId: string | null;
|
|
100
|
+
messageId: string | null;
|
|
101
|
+
toolCallId: string | null;
|
|
102
|
+
payloadHash: Buffer;
|
|
103
|
+
recordedAt: number;
|
|
104
|
+
};
|
|
182
105
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
traceSeq: 0,
|
|
188
|
-
artifacts: new ArtifactWriter(runDir),
|
|
189
|
-
lock: Promise.resolve(),
|
|
190
|
-
streams: new Map(),
|
|
191
|
-
};
|
|
192
|
-
this.contexts.set(runDir, context);
|
|
193
|
-
}
|
|
194
|
-
return context;
|
|
195
|
-
}
|
|
106
|
+
export type SessionCaptureIntegrity = {
|
|
107
|
+
status: "unavailable" | "recording" | "complete" | "failed" | "invalid";
|
|
108
|
+
diagnostics: string[];
|
|
109
|
+
};
|
|
196
110
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
sessionBound: false,
|
|
206
|
-
sessionEventsStopped: false,
|
|
207
|
-
lock: Promise.resolve(),
|
|
208
|
-
};
|
|
209
|
-
context.streams.set(key, stream);
|
|
210
|
-
}
|
|
211
|
-
return stream;
|
|
212
|
-
}
|
|
111
|
+
export type SessionCaptureSegment = {
|
|
112
|
+
attemptId: string;
|
|
113
|
+
binding: WorkflowSessionBinding | null;
|
|
114
|
+
entries: WorkflowSessionEntryRecord[];
|
|
115
|
+
events: WorkflowSessionEventRecord[];
|
|
116
|
+
capture: WorkflowSessionCapture | null;
|
|
117
|
+
integrity: SessionCaptureIntegrity;
|
|
118
|
+
};
|
|
213
119
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
() => undefined,
|
|
227
|
-
);
|
|
228
|
-
return result;
|
|
229
|
-
}
|
|
120
|
+
export type LoadedWorkflowRun = {
|
|
121
|
+
runId: string;
|
|
122
|
+
state: WorkflowRunState;
|
|
123
|
+
snapshot: WorkflowDefinitionSnapshot;
|
|
124
|
+
traceEvents?: WorkflowTraceEvent[];
|
|
125
|
+
sessionBinding: WorkflowSessionBinding | null;
|
|
126
|
+
sessionEntries: WorkflowSessionEntryRecord[];
|
|
127
|
+
sessionEvents: WorkflowSessionEventRecord[];
|
|
128
|
+
sessionCapture: WorkflowSessionCapture | null;
|
|
129
|
+
sessionIntegrity: SessionCaptureIntegrity;
|
|
130
|
+
sessionSegments: SessionCaptureSegment[];
|
|
131
|
+
};
|
|
230
132
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
133
|
+
export type ReadWorkflowRunOptions = {
|
|
134
|
+
includeTrace?: boolean;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export class WorkflowRunStore {
|
|
138
|
+
readonly databasePath: string;
|
|
139
|
+
readonly state: StateDatabase;
|
|
140
|
+
private readonly authorityProvider:
|
|
141
|
+
| ((runId: string) => RunWriteAuthority | undefined)
|
|
142
|
+
| undefined;
|
|
143
|
+
private readonly contexts = new Map<string, RunContext>();
|
|
144
|
+
private readonly ownsState: boolean;
|
|
145
|
+
|
|
146
|
+
constructor(databasePath: string = workflowStatePath(), options: WorkflowRunStoreOptions = {}) {
|
|
147
|
+
this.authorityProvider = options.authorityProvider;
|
|
148
|
+
this.ownsState = options.state === undefined;
|
|
149
|
+
this.state =
|
|
150
|
+
options.state ??
|
|
151
|
+
new StateDatabase({
|
|
152
|
+
filePath: databasePath,
|
|
153
|
+
mode: options.readOnly === true ? "read-only" : "read-write",
|
|
154
|
+
checkLegacyState: databasePath === workflowStatePath(),
|
|
155
|
+
});
|
|
156
|
+
this.databasePath = this.state.filePath;
|
|
246
157
|
}
|
|
247
158
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
state: WorkflowRunState,
|
|
251
|
-
): Promise<string> {
|
|
252
|
-
const runDir = this.runDirFor(state.runId);
|
|
253
|
-
return await this.withRunLock(runDir, async () => {
|
|
254
|
-
await fs.mkdir(this.outputRoot, { recursive: true, mode: 0o700 });
|
|
255
|
-
await fs.mkdir(runDir, { recursive: false, mode: 0o700 });
|
|
256
|
-
await writeJsonAtomic(
|
|
257
|
-
path.join(runDir, WORKFLOW_SNAPSHOT_PATH),
|
|
258
|
-
createDefinitionSnapshot(workflow),
|
|
259
|
-
);
|
|
260
|
-
await appendLine(path.join(runDir, TRACE_PATH), null);
|
|
261
|
-
await this.writeProjections(runDir, state);
|
|
262
|
-
return runDir;
|
|
263
|
-
});
|
|
159
|
+
close(): void {
|
|
160
|
+
if (this.ownsState) this.state.close();
|
|
264
161
|
}
|
|
265
162
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
this.fenceProvider?.(runDir)?.();
|
|
275
|
-
const bundle = await readRunBundle(runDir);
|
|
276
|
-
if (bundle === null) {
|
|
277
|
-
throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
|
|
278
|
-
}
|
|
279
|
-
if (bundle.state.status !== "running") {
|
|
280
|
-
throw new Error(`Cannot resume workflow run ${runId} with status ${bundle.state.status}`);
|
|
163
|
+
async initializeRun(workflow: WorkflowDefinition, state: WorkflowRunState): Promise<string> {
|
|
164
|
+
assertValidRunId(state.runId);
|
|
165
|
+
if (!this.contexts.has(state.runId)) {
|
|
166
|
+
const reserved = this.readRunRow(state.runId);
|
|
167
|
+
this.contexts.set(state.runId, {
|
|
168
|
+
revision: reserved === undefined ? 0 : this.requireResourceRevision(reserved.resourceId),
|
|
169
|
+
lock: Promise.resolve(),
|
|
170
|
+
});
|
|
281
171
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
172
|
+
return await this.withRunLock(state.runId, async () => {
|
|
173
|
+
let acceptedRevision = 1;
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
const at = new Date(now).toISOString();
|
|
176
|
+
const snapshot = createDefinitionSnapshot(workflow);
|
|
177
|
+
const definitionJson = canonicalJson(snapshot);
|
|
178
|
+
const definitionDigest = createHash("sha256").update(definitionJson).digest();
|
|
179
|
+
const source = sourceForState(state, definitionDigest);
|
|
180
|
+
const resourceId = resourceIdFor("run", state.runId);
|
|
181
|
+
this.state.transaction(() => {
|
|
182
|
+
const reserved = this.readRunRow(state.runId);
|
|
183
|
+
if (reserved !== undefined) {
|
|
184
|
+
if (reserved.status !== "queued") {
|
|
185
|
+
throw new Error(`Workflow run already exists: ${state.runId}`);
|
|
186
|
+
}
|
|
187
|
+
const context = this.contextFor(state.runId);
|
|
188
|
+
this.assertWriteAuthority(reserved, context.revision);
|
|
189
|
+
const storedSnapshot = this.readDefinition(reserved.definitionHash);
|
|
190
|
+
if (canonicalJson(storedSnapshot) !== definitionJson) {
|
|
191
|
+
throw new Error(`Reserved workflow definition conflicts: ${state.runId}`);
|
|
192
|
+
}
|
|
193
|
+
const revision = context.revision + 1;
|
|
194
|
+
acceptedRevision = revision;
|
|
195
|
+
state.traceSeq = revision;
|
|
196
|
+
state.updatedAt = at;
|
|
197
|
+
insertRunEvent(this.state, reserved.resourceId, revision, at, {
|
|
198
|
+
scope: "run",
|
|
199
|
+
type: "run_initialized",
|
|
200
|
+
payload: { workflowName: workflow.name },
|
|
201
|
+
});
|
|
202
|
+
this.persistRunState(reserved, state, revision, now);
|
|
203
|
+
this.syncNodeAttempts(state, snapshot, now);
|
|
204
|
+
context.revision = revision;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const definitionHash = this.state.putJson(snapshot, now);
|
|
208
|
+
this.state.connection
|
|
209
|
+
.prepare(
|
|
210
|
+
`INSERT INTO workflow_definitions(
|
|
211
|
+
definition_digest, workflow_name, definition_hash, created_at
|
|
212
|
+
) VALUES (?, ?, ?, ?)
|
|
213
|
+
ON CONFLICT(definition_digest) DO NOTHING`,
|
|
214
|
+
)
|
|
215
|
+
.run(definitionDigest, workflow.name, definitionHash, now);
|
|
216
|
+
this.state.connection
|
|
217
|
+
.prepare(
|
|
218
|
+
`INSERT INTO resources(
|
|
219
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
220
|
+
) VALUES (?, 'run', ?, 1, ?, ?)`,
|
|
221
|
+
)
|
|
222
|
+
.run(resourceId, state.runId, now, now);
|
|
223
|
+
this.state.connection
|
|
224
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
225
|
+
.run(resourceId);
|
|
226
|
+
state.traceSeq = 1;
|
|
227
|
+
state.updatedAt = at;
|
|
228
|
+
const inputHash = this.state.putJson(state.input, now);
|
|
229
|
+
const workflowSourceHash = this.state.putJson(state.workflowSource ?? source, now);
|
|
230
|
+
const launchOptionsHash = this.state.putJson({}, now);
|
|
231
|
+
const stateHash = this.state.putJson(state, now);
|
|
232
|
+
const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
|
|
233
|
+
this.state.connection
|
|
234
|
+
.prepare(
|
|
235
|
+
`INSERT INTO runs(
|
|
236
|
+
run_id, resource_id, project_id, parent_run_id, definition_digest,
|
|
237
|
+
workflow_ref, workflow_source_hash, launch_options_hash,
|
|
238
|
+
source_type, source_ref, source_revision, title, status, paused,
|
|
239
|
+
status_detail, input_hash, output_hash, error_hash,
|
|
240
|
+
created_at, updated_at, finished_at
|
|
241
|
+
) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
242
|
+
)
|
|
243
|
+
.run(
|
|
244
|
+
state.runId,
|
|
245
|
+
resourceId,
|
|
246
|
+
state.parentRunId ?? null,
|
|
247
|
+
definitionDigest,
|
|
248
|
+
state.workflowName,
|
|
249
|
+
workflowSourceHash,
|
|
250
|
+
launchOptionsHash,
|
|
251
|
+
source.type,
|
|
252
|
+
source.ref,
|
|
253
|
+
source.revision,
|
|
254
|
+
state.runTitle ?? null,
|
|
255
|
+
state.status,
|
|
256
|
+
state.paused === true ? 1 : 0,
|
|
257
|
+
state.statusDetail ?? null,
|
|
258
|
+
inputHash,
|
|
259
|
+
stateHash,
|
|
260
|
+
errorHash,
|
|
261
|
+
Date.parse(state.startedAt),
|
|
262
|
+
now,
|
|
263
|
+
state.finishedAt === undefined ? null : Date.parse(state.finishedAt),
|
|
264
|
+
);
|
|
265
|
+
insertRunEvent(this.state, resourceId, 1, at, {
|
|
266
|
+
scope: "run",
|
|
267
|
+
type: "run_created",
|
|
268
|
+
payload: { workflowName: workflow.name },
|
|
269
|
+
});
|
|
270
|
+
this.syncNodeAttempts(state, snapshot, now);
|
|
271
|
+
});
|
|
272
|
+
this.contexts.set(state.runId, { revision: acceptedRevision, lock: Promise.resolve() });
|
|
273
|
+
return state.runId;
|
|
308
274
|
});
|
|
309
|
-
const prepared = await readRunBundle(runDir);
|
|
310
|
-
if (prepared === null) {
|
|
311
|
-
throw new Error(`Workflow run ${runId} became unreadable during resume preparation`);
|
|
312
|
-
}
|
|
313
|
-
return prepared;
|
|
314
275
|
}
|
|
315
276
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
runDir: string,
|
|
322
|
-
reason: string,
|
|
323
|
-
options: { skipFlat?: boolean } = {},
|
|
324
|
-
): Promise<void> {
|
|
325
|
-
if (options.skipFlat !== true) {
|
|
326
|
-
const flatCapture = await readJsonFile<WorkflowSessionCapture>(
|
|
327
|
-
path.join(runDir, SESSION_CAPTURE_PATH),
|
|
328
|
-
);
|
|
329
|
-
if (flatCapture?.status === "recording") {
|
|
330
|
-
const counts = await this.sessionCounts(runDir);
|
|
331
|
-
await this.writeSessionCapture(runDir, {
|
|
332
|
-
schema: SESSION_CAPTURE_SCHEMA,
|
|
333
|
-
eventSchema: SESSION_EVENT_SCHEMA,
|
|
334
|
-
status: "failed",
|
|
335
|
-
...counts,
|
|
336
|
-
failure: {
|
|
337
|
-
failedAt: new Date().toISOString(),
|
|
338
|
-
code: "host_interrupted",
|
|
339
|
-
message: reason,
|
|
340
|
-
},
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
for (const segmentId of await this.listSessionSegments(runDir)) {
|
|
345
|
-
const segmentCapture = await readJsonFile<WorkflowSessionCapture>(
|
|
346
|
-
path.join(runDir, sessionStreamPaths(segmentId).capture),
|
|
347
|
-
);
|
|
348
|
-
if (segmentCapture?.status !== "recording") {
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
|
-
const segmentCounts = await this.sessionCounts(runDir, segmentId);
|
|
352
|
-
await this.writeSessionCapture(
|
|
353
|
-
runDir,
|
|
354
|
-
{
|
|
355
|
-
schema: SESSION_CAPTURE_SCHEMA,
|
|
356
|
-
eventSchema: SESSION_EVENT_SCHEMA,
|
|
357
|
-
status: "failed",
|
|
358
|
-
...segmentCounts,
|
|
359
|
-
failure: {
|
|
360
|
-
failedAt: new Date().toISOString(),
|
|
361
|
-
code: "host_interrupted",
|
|
362
|
-
message: reason,
|
|
363
|
-
},
|
|
364
|
-
},
|
|
365
|
-
segmentId,
|
|
366
|
-
);
|
|
277
|
+
async prepareRunResume(runId: string): Promise<LoadedWorkflowRun> {
|
|
278
|
+
const loaded = this.readRun(runId, { includeTrace: true });
|
|
279
|
+
if (loaded === null) throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
|
|
280
|
+
if (loaded.state.status !== "running") {
|
|
281
|
+
throw new Error(`Cannot resume workflow run ${runId} with status ${loaded.state.status}`);
|
|
367
282
|
}
|
|
283
|
+
const revision = this.resourceRevision(runId);
|
|
284
|
+
this.contexts.set(runId, { revision, lock: Promise.resolve() });
|
|
285
|
+
this.finalizeRecordingCaptures(runId, "Workflow host stopped before the run finished");
|
|
286
|
+
this.state.transaction(() => {
|
|
287
|
+
const row = this.requireRunRow(runId);
|
|
288
|
+
const context = this.contextFor(runId);
|
|
289
|
+
this.assertWriteAuthority(row, context.revision);
|
|
290
|
+
const nextRevision = context.revision + 1;
|
|
291
|
+
const now = Date.now();
|
|
292
|
+
const at = new Date(now).toISOString();
|
|
293
|
+
this.state.connection
|
|
294
|
+
.prepare(
|
|
295
|
+
`UPDATE node_attempts
|
|
296
|
+
SET status = 'cancelled', finished_at = ?, updated_at = ?
|
|
297
|
+
WHERE run_id = ? AND status IN ('pending', 'running', 'waiting')`,
|
|
298
|
+
)
|
|
299
|
+
.run(now, now, runId);
|
|
300
|
+
insertRunEvent(this.state, row.resourceId, nextRevision, at, {
|
|
301
|
+
scope: "run",
|
|
302
|
+
type: "run_resume_prepared",
|
|
303
|
+
payload: {},
|
|
304
|
+
});
|
|
305
|
+
loaded.state.traceSeq = nextRevision;
|
|
306
|
+
loaded.state.updatedAt = at;
|
|
307
|
+
this.persistRunState(row, loaded.state, nextRevision, now);
|
|
308
|
+
context.revision = nextRevision;
|
|
309
|
+
});
|
|
310
|
+
const prepared = this.readRun(runId, { includeTrace: true });
|
|
311
|
+
if (prepared === null) throw new Error(`Workflow run became unreadable: ${runId}`);
|
|
312
|
+
return prepared;
|
|
368
313
|
}
|
|
369
314
|
|
|
370
|
-
/** Mark a nonterminal bundle failed and append an interruption event. */
|
|
371
315
|
async markRunInterrupted(
|
|
372
316
|
runId: string,
|
|
373
317
|
reason = "Workflow host stopped before the run finished",
|
|
374
|
-
): Promise<
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
sessionSeq: counts.entryCount,
|
|
391
|
-
sessionEventSeq: counts.lastEventSeq,
|
|
392
|
-
sessionBound,
|
|
393
|
-
sessionEventsStopped: captureFinished,
|
|
394
|
-
}),
|
|
318
|
+
): Promise<LoadedWorkflowRun | null> {
|
|
319
|
+
const loaded = this.readRun(runId);
|
|
320
|
+
if (loaded === null || loaded.state.status !== "running") return loaded;
|
|
321
|
+
this.finalizeRecordingCaptures(runId, reason);
|
|
322
|
+
loaded.state.status = "failed";
|
|
323
|
+
loaded.state.finishedAt = new Date().toISOString();
|
|
324
|
+
loaded.state.error = reason;
|
|
325
|
+
delete loaded.state.currentNode;
|
|
326
|
+
delete loaded.state.currentAttemptId;
|
|
327
|
+
delete loaded.state.currentNodeStartedAt;
|
|
328
|
+
delete loaded.state.statusDetail;
|
|
329
|
+
delete loaded.state.paused;
|
|
330
|
+
await this.writeSnapshot(runId, loaded.state, {
|
|
331
|
+
scope: "run",
|
|
332
|
+
type: "run_interrupted",
|
|
333
|
+
payload: { error: reason },
|
|
395
334
|
});
|
|
396
|
-
|
|
397
|
-
if (lastTraceEvent !== null && recoverTerminalProjection(state, lastTraceEvent)) {
|
|
398
|
-
await this.writeLoadedProjections(runDir, state);
|
|
399
|
-
return await readRunBundle(runDir);
|
|
400
|
-
}
|
|
401
|
-
if (sessionBound && !captureFinished) {
|
|
402
|
-
await this.writeSessionCapture(runDir, {
|
|
403
|
-
schema: SESSION_CAPTURE_SCHEMA,
|
|
404
|
-
eventSchema: SESSION_EVENT_SCHEMA,
|
|
405
|
-
status: "failed",
|
|
406
|
-
...counts,
|
|
407
|
-
failure: {
|
|
408
|
-
failedAt: new Date().toISOString(),
|
|
409
|
-
code: "host_interrupted",
|
|
410
|
-
message: reason,
|
|
411
|
-
},
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
await this.finalizeRecordingCaptures(runDir, reason, { skipFlat: true });
|
|
415
|
-
state.status = "failed";
|
|
416
|
-
state.finishedAt = new Date().toISOString();
|
|
417
|
-
state.error = reason;
|
|
418
|
-
delete state.currentNode;
|
|
419
|
-
delete state.currentAttemptId;
|
|
420
|
-
delete state.currentNodeStartedAt;
|
|
421
|
-
delete state.statusDetail;
|
|
422
|
-
delete state.paused;
|
|
423
|
-
await this.withRunLock(runDir, async () => {
|
|
424
|
-
const traceEvent = await this.appendTraceEvent(runDir, state.runId, {
|
|
425
|
-
scope: "run",
|
|
426
|
-
type: "run_interrupted",
|
|
427
|
-
payload: { error: reason },
|
|
428
|
-
});
|
|
429
|
-
state.traceSeq = traceEvent.seq;
|
|
430
|
-
state.updatedAt = traceEvent.at;
|
|
431
|
-
await this.writeLoadedProjections(runDir, state);
|
|
432
|
-
});
|
|
433
|
-
return await readRunBundle(runDir);
|
|
335
|
+
return this.readRun(runId, { includeTrace: true });
|
|
434
336
|
}
|
|
435
337
|
|
|
436
|
-
/** Publish one durable update under the run's serialized claim-fenced writer. */
|
|
437
338
|
async publishUpdate(
|
|
438
|
-
|
|
339
|
+
runId: string,
|
|
439
340
|
state: WorkflowRunState,
|
|
440
341
|
nodeId: string,
|
|
441
342
|
attemptId: string,
|
|
442
343
|
update: WorkflowUpdateInput,
|
|
443
344
|
options: { signal?: AbortSignal } = {},
|
|
444
345
|
): Promise<{ event: WorkflowTraceEvent; record: WorkflowUpdateRecord }> {
|
|
445
|
-
return await this.withRunLock(
|
|
346
|
+
return await this.withRunLock(runId, async () => {
|
|
446
347
|
if (options.signal?.aborted === true) {
|
|
447
348
|
throw options.signal.reason ?? new Error("workflow update attempt is no longer active");
|
|
448
349
|
}
|
|
@@ -452,375 +353,1075 @@ export class WorkflowRunStore {
|
|
|
452
353
|
if (!exists && (state.updates?.length ?? 0) >= MAX_CURRENT_UPDATES) {
|
|
453
354
|
throw new Error(`workflow run supports at most ${MAX_CURRENT_UPDATES} current updates`);
|
|
454
355
|
}
|
|
356
|
+
const data = structuredClone(update.data) as Record<string, unknown>;
|
|
455
357
|
const updateId = createUpdateId();
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
358
|
+
let acceptedEvent: WorkflowTraceEvent | undefined;
|
|
359
|
+
let acceptedRecord: WorkflowUpdateRecord | undefined;
|
|
360
|
+
this.state.transaction(() => {
|
|
361
|
+
const context = this.contextFor(runId);
|
|
362
|
+
const run = this.requireRunRow(runId);
|
|
363
|
+
this.assertWriteAuthority(run, context.revision);
|
|
364
|
+
const revision = context.revision + 1;
|
|
365
|
+
const at = new Date().toISOString();
|
|
366
|
+
const event: WorkflowTraceEvent = {
|
|
367
|
+
seq: revision,
|
|
368
|
+
at,
|
|
369
|
+
runId,
|
|
370
|
+
scope: "node",
|
|
371
|
+
type: "update_published",
|
|
372
|
+
nodeId,
|
|
373
|
+
attemptId,
|
|
374
|
+
payload: { updateId, type: update.type, key: update.key, data },
|
|
375
|
+
};
|
|
376
|
+
const record: WorkflowUpdateRecord = {
|
|
377
|
+
updateId,
|
|
378
|
+
seq: revision,
|
|
379
|
+
at,
|
|
380
|
+
runId,
|
|
381
|
+
nodeId,
|
|
382
|
+
attemptId,
|
|
383
|
+
type: update.type,
|
|
384
|
+
key: update.key,
|
|
385
|
+
data,
|
|
386
|
+
};
|
|
387
|
+
state.updates = updateProjection(state.updates, record);
|
|
388
|
+
state.traceSeq = revision;
|
|
389
|
+
state.updatedAt = at;
|
|
390
|
+
this.ensureAttempt(state, nodeId, attemptId, Date.now());
|
|
391
|
+
const dataHash = this.state.putJson(data);
|
|
392
|
+
const nextUpdateSeq = this.nextUpdateSequence(attemptId);
|
|
393
|
+
this.state.connection
|
|
394
|
+
.prepare(
|
|
395
|
+
`INSERT INTO workflow_updates(
|
|
396
|
+
update_id, attempt_id, update_seq, update_type, update_key, data_hash, recorded_at
|
|
397
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
398
|
+
)
|
|
399
|
+
.run(updateId, attemptId, nextUpdateSeq, update.type, update.key, dataHash, Date.now());
|
|
400
|
+
insertRunEvent(this.state, run.resourceId, revision, at, event);
|
|
401
|
+
this.persistRunState(run, state, revision, Date.now());
|
|
402
|
+
context.revision = revision;
|
|
403
|
+
acceptedEvent = event;
|
|
404
|
+
acceptedRecord = record;
|
|
463
405
|
});
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
runId: state.runId,
|
|
469
|
-
nodeId,
|
|
470
|
-
attemptId,
|
|
471
|
-
type: update.type,
|
|
472
|
-
key: update.key,
|
|
473
|
-
data,
|
|
474
|
-
};
|
|
475
|
-
state.updates = updateProjection(state.updates, record);
|
|
476
|
-
state.traceSeq = event.seq;
|
|
477
|
-
state.updatedAt = event.at;
|
|
478
|
-
await this.writeProjections(runDir, state);
|
|
479
|
-
return { event, record };
|
|
406
|
+
if (acceptedEvent === undefined || acceptedRecord === undefined) {
|
|
407
|
+
throw new Error("Workflow update transaction did not produce a result");
|
|
408
|
+
}
|
|
409
|
+
return { event: acceptedEvent, record: acceptedRecord };
|
|
480
410
|
});
|
|
481
411
|
}
|
|
482
412
|
|
|
483
|
-
/**
|
|
484
|
-
* Persist one transition: append the trace event, then rewrite the
|
|
485
|
-
* projections reflecting it.
|
|
486
|
-
*/
|
|
487
413
|
async writeSnapshot(
|
|
488
|
-
|
|
414
|
+
runId: string,
|
|
489
415
|
state: WorkflowRunState,
|
|
490
416
|
event: WorkflowTraceEventDraft,
|
|
491
417
|
): Promise<WorkflowTraceEvent> {
|
|
492
|
-
return await this.withRunLock(
|
|
493
|
-
|
|
494
|
-
state.
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
418
|
+
return await this.withRunLock(runId, async () => {
|
|
419
|
+
let accepted: WorkflowTraceEvent | undefined;
|
|
420
|
+
this.state.transaction(() => {
|
|
421
|
+
const context = this.contextFor(runId);
|
|
422
|
+
const run = this.requireRunRow(runId);
|
|
423
|
+
this.assertWriteAuthority(run, context.revision);
|
|
424
|
+
const revision = context.revision + 1;
|
|
425
|
+
const now = Date.now();
|
|
426
|
+
const at = new Date(now).toISOString();
|
|
427
|
+
const traceEvent: WorkflowTraceEvent = { seq: revision, at, runId, ...event };
|
|
428
|
+
state.traceSeq = revision;
|
|
429
|
+
state.updatedAt = at;
|
|
430
|
+
insertRunEvent(this.state, run.resourceId, revision, at, traceEvent);
|
|
431
|
+
this.persistRunState(run, state, revision, now);
|
|
432
|
+
const snapshot = this.readDefinition(run.definitionHash);
|
|
433
|
+
this.syncNodeAttempts(state, snapshot, now);
|
|
434
|
+
context.revision = revision;
|
|
435
|
+
accepted = traceEvent;
|
|
436
|
+
});
|
|
437
|
+
if (accepted === undefined) throw new Error("Workflow snapshot transaction did not commit");
|
|
438
|
+
return accepted;
|
|
498
439
|
});
|
|
499
440
|
}
|
|
500
441
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
async hasSessionBinding(runDir: string): Promise<boolean> {
|
|
508
|
-
try {
|
|
509
|
-
await fs.lstat(path.join(runDir, SESSION_BINDING_PATH));
|
|
510
|
-
return true;
|
|
511
|
-
} catch {
|
|
512
|
-
return false;
|
|
513
|
-
}
|
|
442
|
+
async hasSessionBinding(runId: string): Promise<boolean> {
|
|
443
|
+
return (
|
|
444
|
+
this.state.connection
|
|
445
|
+
.prepare("SELECT 1 AS present FROM session_segments WHERE run_id = ? LIMIT 1")
|
|
446
|
+
.get(runId) !== undefined
|
|
447
|
+
);
|
|
514
448
|
}
|
|
515
449
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
withFileTypes: true,
|
|
521
|
-
});
|
|
522
|
-
return entries
|
|
523
|
-
.filter((entry) => entry.isDirectory())
|
|
524
|
-
.map((entry) => entry.name)
|
|
525
|
-
.sort();
|
|
526
|
-
} catch {
|
|
527
|
-
return [];
|
|
528
|
-
}
|
|
450
|
+
async listSessionSegments(runId: string): Promise<string[]> {
|
|
451
|
+
return this.segmentRows(runId)
|
|
452
|
+
.flatMap((row) => (row.captureKey === null ? [] : [row.captureKey]))
|
|
453
|
+
.sort();
|
|
529
454
|
}
|
|
530
455
|
|
|
531
456
|
async writeSessionBinding(
|
|
532
|
-
|
|
457
|
+
runId: string,
|
|
533
458
|
binding: WorkflowSessionBinding,
|
|
534
459
|
attemptId?: string,
|
|
535
460
|
): Promise<void> {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
461
|
+
assertValidRunId(runId);
|
|
462
|
+
if (binding.runId !== runId || binding.schema !== SESSION_BINDING_SCHEMA) {
|
|
463
|
+
throw new Error("Session binding does not match the workflow run");
|
|
464
|
+
}
|
|
465
|
+
await this.withRunLock(runId, async () => {
|
|
466
|
+
const segmentId = segmentIdFor(runId, attemptId);
|
|
467
|
+
if (this.segmentRow(segmentId) !== undefined) return;
|
|
468
|
+
this.state.transaction(() => {
|
|
469
|
+
const run = this.requireRunRow(runId);
|
|
470
|
+
const context = this.contextFor(runId);
|
|
471
|
+
this.assertWriteAuthority(run, context.revision);
|
|
472
|
+
const now = Date.parse(binding.boundAt);
|
|
473
|
+
const resourceId = resourceIdFor("session", segmentId);
|
|
474
|
+
const bindingHash = this.state.putJson(binding, now);
|
|
475
|
+
this.state.connection
|
|
476
|
+
.prepare(
|
|
477
|
+
`INSERT INTO resources(
|
|
478
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
479
|
+
) VALUES (?, 'session', ?, 1, ?, ?)`,
|
|
480
|
+
)
|
|
481
|
+
.run(resourceId, segmentId, now, now);
|
|
482
|
+
this.state.connection
|
|
483
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
484
|
+
.run(resourceId);
|
|
485
|
+
this.state.connection
|
|
486
|
+
.prepare(
|
|
487
|
+
`INSERT INTO session_segments(
|
|
488
|
+
segment_id, run_id, attempt_id, capture_key, session_id, resource_id, binding_hash,
|
|
489
|
+
status, entry_count, event_count, created_at
|
|
490
|
+
) VALUES (?, ?, NULL, ?, ?, ?, ?, 'recording', 0, 0, ?)`,
|
|
491
|
+
)
|
|
492
|
+
.run(
|
|
493
|
+
segmentId,
|
|
494
|
+
runId,
|
|
495
|
+
attemptId ?? null,
|
|
496
|
+
binding.piSessionId,
|
|
497
|
+
resourceId,
|
|
498
|
+
bindingHash,
|
|
499
|
+
now,
|
|
500
|
+
);
|
|
501
|
+
insertGenericEvent(
|
|
502
|
+
this.state,
|
|
503
|
+
resourceId,
|
|
504
|
+
1,
|
|
505
|
+
"session.created",
|
|
506
|
+
"session",
|
|
507
|
+
binding.piSessionId,
|
|
508
|
+
bindingHash,
|
|
509
|
+
now,
|
|
510
|
+
);
|
|
511
|
+
this.state.connection
|
|
512
|
+
.prepare(
|
|
513
|
+
`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
|
|
514
|
+
VALUES (?, ?, 'interactive', ?)
|
|
515
|
+
ON CONFLICT(run_id) DO NOTHING`,
|
|
516
|
+
)
|
|
517
|
+
.run(runId, binding.piSessionId, now);
|
|
518
|
+
const revision = context.revision + 1;
|
|
519
|
+
const at = new Date(now).toISOString();
|
|
520
|
+
insertRunEvent(this.state, run.resourceId, revision, at, {
|
|
521
|
+
seq: revision,
|
|
522
|
+
at,
|
|
523
|
+
runId,
|
|
524
|
+
scope: "session",
|
|
525
|
+
type: "session_bound",
|
|
526
|
+
payload: {
|
|
527
|
+
piSessionId: binding.piSessionId,
|
|
528
|
+
...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
const current = this.readState(run.stateHash);
|
|
532
|
+
current.traceSeq = revision;
|
|
533
|
+
current.updatedAt = at;
|
|
534
|
+
this.persistRunState(run, current, revision, now);
|
|
535
|
+
context.revision = revision;
|
|
552
536
|
});
|
|
553
537
|
});
|
|
554
538
|
}
|
|
555
539
|
|
|
556
|
-
/** Append one verbatim Pi session entry to `session/entries.ndjson`. */
|
|
557
540
|
async appendSessionEntry(
|
|
558
|
-
|
|
541
|
+
runId: string,
|
|
559
542
|
entry: Record<string, unknown>,
|
|
560
543
|
attemptId?: string,
|
|
561
544
|
): Promise<number> {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
|
|
545
|
+
const segment = this.requireSegment(segmentIdFor(runId, attemptId));
|
|
546
|
+
return this.state.transaction(() => {
|
|
547
|
+
this.assertSessionWriteAuthority(runId);
|
|
548
|
+
const revision = this.requireResourceRevision(segmentResourceId(segment));
|
|
549
|
+
const sequence = segment.entryCount + 1;
|
|
550
|
+
const now = Date.now();
|
|
551
|
+
const entryHash = this.state.putJson(entry, now);
|
|
552
|
+
const entryId = typeof entry.id === "string" ? entry.id : `entry-${sequence}`;
|
|
553
|
+
this.state.connection
|
|
554
|
+
.prepare(
|
|
555
|
+
`INSERT INTO session_entries(segment_id, entry_seq, entry_id, entry_hash, recorded_at)
|
|
556
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
557
|
+
)
|
|
558
|
+
.run(segment.segmentId, sequence, entryId, entryHash, now);
|
|
559
|
+
this.state.connection
|
|
560
|
+
.prepare("UPDATE session_segments SET entry_count = ? WHERE segment_id = ?")
|
|
561
|
+
.run(sequence, segment.segmentId);
|
|
562
|
+
this.bumpResource(segmentResourceId(segment), revision, now);
|
|
563
|
+
insertGenericEvent(
|
|
564
|
+
this.state,
|
|
565
|
+
segmentResourceId(segment),
|
|
566
|
+
revision + 1,
|
|
567
|
+
"session.entry_appended",
|
|
568
|
+
"session",
|
|
569
|
+
segment.sessionId,
|
|
570
|
+
entryHash,
|
|
571
|
+
now,
|
|
572
|
+
);
|
|
573
|
+
return sequence;
|
|
572
574
|
});
|
|
573
575
|
}
|
|
574
576
|
|
|
575
|
-
/** Append a fully stamped ordered batch to `session/events.ndjson`. */
|
|
576
577
|
async appendSessionEventBatch(
|
|
577
|
-
|
|
578
|
+
runId: string,
|
|
578
579
|
records: WorkflowSessionEventRecord[],
|
|
579
580
|
attemptId?: string,
|
|
580
581
|
): Promise<void> {
|
|
581
|
-
if (records.length === 0)
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
if (
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
validateSessionEventRecord(record);
|
|
593
|
-
if (record.seq !== expected) {
|
|
594
|
-
throw new Error(`Expected session event seq ${expected}, got ${record.seq}`);
|
|
595
|
-
}
|
|
596
|
-
expected += 1;
|
|
582
|
+
if (records.length === 0) return;
|
|
583
|
+
const segment = this.requireSegment(segmentIdFor(runId, attemptId));
|
|
584
|
+
this.state.transaction(() => {
|
|
585
|
+
this.assertSessionWriteAuthority(runId);
|
|
586
|
+
const current = this.requireSegment(segment.segmentId);
|
|
587
|
+
if (current.status !== "recording") throw new Error("Session event capture has stopped");
|
|
588
|
+
let expected = current.eventCount + 1;
|
|
589
|
+
for (const record of records) {
|
|
590
|
+
validateSessionEventRecord(record);
|
|
591
|
+
if (record.seq !== expected) {
|
|
592
|
+
throw new Error(`Expected session event seq ${expected}, got ${record.seq}`);
|
|
597
593
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
...record,
|
|
601
|
-
payload:
|
|
602
|
-
record.type === "tool_execution_started" ||
|
|
603
|
-
record.type === "tool_execution_finished" ||
|
|
604
|
-
(record.type === "assistant_event" && record.payload.type === "toolcall_end")
|
|
605
|
-
? ((await encodeValue(record.payload, this.contextFor(runDir).artifacts)) as Record<
|
|
606
|
-
string,
|
|
607
|
-
unknown
|
|
608
|
-
>)
|
|
609
|
-
: record.payload,
|
|
610
|
-
})),
|
|
611
|
-
);
|
|
612
|
-
for (const record of encoded) {
|
|
613
|
-
if (Buffer.byteLength(JSON.stringify(record), "utf8") + 1 > SESSION_EVENT_MAX_BYTES) {
|
|
614
|
-
throw new Error(`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`);
|
|
615
|
-
}
|
|
594
|
+
if (Buffer.byteLength(canonicalJson(record), "utf8") > SESSION_EVENT_MAX_BYTES) {
|
|
595
|
+
throw new Error(`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`);
|
|
616
596
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
597
|
+
const payloadHash = this.state.putJson(record.payload, Date.parse(record.at));
|
|
598
|
+
this.state.connection
|
|
599
|
+
.prepare(
|
|
600
|
+
`INSERT INTO session_events(
|
|
601
|
+
segment_id, event_seq, event_type, node_id, attempt_id,
|
|
602
|
+
turn_id, message_id, tool_call_id, payload_hash, recorded_at
|
|
603
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
604
|
+
)
|
|
605
|
+
.run(
|
|
606
|
+
current.segmentId,
|
|
607
|
+
record.seq,
|
|
608
|
+
record.type,
|
|
609
|
+
record.nodeId,
|
|
610
|
+
record.attemptId,
|
|
611
|
+
record.turnId ?? null,
|
|
612
|
+
record.messageId ?? null,
|
|
613
|
+
record.toolCallId ?? null,
|
|
614
|
+
payloadHash,
|
|
615
|
+
Date.parse(record.at),
|
|
616
|
+
);
|
|
617
|
+
expected += 1;
|
|
622
618
|
}
|
|
619
|
+
const now = Date.now();
|
|
620
|
+
this.state.connection
|
|
621
|
+
.prepare("UPDATE session_segments SET event_count = ? WHERE segment_id = ?")
|
|
622
|
+
.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
|
+
);
|
|
623
637
|
});
|
|
624
638
|
}
|
|
625
639
|
|
|
626
|
-
/** Atomically replace the temporal capture integrity projection. */
|
|
627
640
|
async writeSessionCapture(
|
|
628
|
-
|
|
641
|
+
runId: string,
|
|
629
642
|
capture: WorkflowSessionCapture,
|
|
630
643
|
attemptId?: string,
|
|
631
644
|
): Promise<void> {
|
|
632
645
|
validateSessionCapture(capture);
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
646
|
+
const segment = this.requireSegment(segmentIdFor(runId, attemptId));
|
|
647
|
+
this.state.transaction(() => {
|
|
648
|
+
this.assertSessionWriteAuthority(runId);
|
|
649
|
+
const current = this.requireSegment(segment.segmentId);
|
|
650
|
+
if (current.status === "failed" && capture.status !== "failed") return;
|
|
651
|
+
if (current.status === "complete" && capture.status !== "complete") return;
|
|
652
|
+
const now = Date.now();
|
|
653
|
+
const failureHash =
|
|
654
|
+
capture.failure === undefined ? null : this.state.putJson(capture.failure, now);
|
|
655
|
+
this.state.connection
|
|
656
|
+
.prepare(
|
|
657
|
+
`UPDATE session_segments
|
|
658
|
+
SET status = ?, entry_count = ?, event_count = ?, failure_hash = ?, finished_at = ?
|
|
659
|
+
WHERE segment_id = ?`,
|
|
660
|
+
)
|
|
661
|
+
.run(
|
|
662
|
+
capture.status,
|
|
663
|
+
capture.entryCount,
|
|
664
|
+
capture.eventCount,
|
|
665
|
+
failureHash,
|
|
666
|
+
capture.status === "recording" ? null : now,
|
|
667
|
+
segment.segmentId,
|
|
668
|
+
);
|
|
669
|
+
const resourceId = segmentResourceId(segment);
|
|
670
|
+
const revision = this.requireResourceRevision(resourceId);
|
|
671
|
+
this.bumpResource(resourceId, revision, now);
|
|
672
|
+
const payloadHash = this.state.putJson(capture, now);
|
|
673
|
+
insertGenericEvent(
|
|
674
|
+
this.state,
|
|
675
|
+
resourceId,
|
|
676
|
+
revision + 1,
|
|
677
|
+
"session.capture_updated",
|
|
678
|
+
"session",
|
|
679
|
+
segment.sessionId,
|
|
680
|
+
payloadHash,
|
|
681
|
+
now,
|
|
682
|
+
);
|
|
639
683
|
});
|
|
640
684
|
}
|
|
641
685
|
|
|
642
|
-
/** Count complete durable session records after both writers have drained. */
|
|
643
686
|
async sessionCounts(
|
|
644
|
-
|
|
687
|
+
runId: string,
|
|
645
688
|
attemptId?: string,
|
|
646
689
|
): Promise<{ eventCount: number; entryCount: number; lastEventSeq: number }> {
|
|
647
|
-
const
|
|
648
|
-
const events = await readCompleteNdjson(path.join(runDir, paths.events));
|
|
649
|
-
const entries = await readCompleteNdjson(path.join(runDir, paths.entries));
|
|
690
|
+
const segment = this.segmentRow(segmentIdFor(runId, attemptId));
|
|
650
691
|
return {
|
|
651
|
-
eventCount:
|
|
652
|
-
entryCount:
|
|
653
|
-
lastEventSeq:
|
|
692
|
+
eventCount: segment?.eventCount ?? 0,
|
|
693
|
+
entryCount: segment?.entryCount ?? 0,
|
|
694
|
+
lastEventSeq: segment?.eventCount ?? 0,
|
|
654
695
|
};
|
|
655
696
|
}
|
|
656
697
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
const
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
at: new Date().toISOString(),
|
|
698
|
+
readRun(runId: string, options: ReadWorkflowRunOptions = {}): LoadedWorkflowRun | null {
|
|
699
|
+
const row = this.readRunRow(runId);
|
|
700
|
+
if (row === undefined) return null;
|
|
701
|
+
const state = this.readState(row.stateHash);
|
|
702
|
+
const snapshot = this.readDefinition(row.definitionHash);
|
|
703
|
+
const segments = this.segmentRows(runId).map((segment) => this.loadSegment(segment, state));
|
|
704
|
+
const flat = segments.find((segment) => segment.attemptId === "") ?? emptySegment();
|
|
705
|
+
return {
|
|
666
706
|
runId,
|
|
667
|
-
|
|
668
|
-
|
|
707
|
+
state,
|
|
708
|
+
snapshot,
|
|
709
|
+
...(options.includeTrace === true ? { traceEvents: this.traceEvents(row) } : {}),
|
|
710
|
+
sessionBinding: flat.binding,
|
|
711
|
+
sessionEntries: flat.entries,
|
|
712
|
+
sessionEvents: flat.events,
|
|
713
|
+
sessionCapture: flat.capture,
|
|
714
|
+
sessionIntegrity: flat.integrity,
|
|
715
|
+
sessionSegments: segments.filter((segment) => segment.attemptId !== ""),
|
|
669
716
|
};
|
|
670
|
-
await appendLine(path.join(runDir, TRACE_PATH), traceEvent);
|
|
671
|
-
context.traceSeq = traceEvent.seq;
|
|
672
|
-
return traceEvent;
|
|
673
717
|
}
|
|
674
718
|
|
|
675
|
-
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
719
|
+
listRuns(options: ReadWorkflowRunOptions = {}): LoadedWorkflowRun[] {
|
|
720
|
+
const rows = this.state.connection
|
|
721
|
+
.prepare("SELECT run_id AS runId FROM runs ORDER BY created_at DESC, run_id DESC")
|
|
722
|
+
.all();
|
|
723
|
+
const loaded: LoadedWorkflowRun[] = [];
|
|
724
|
+
for (const row of rows) {
|
|
725
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
726
|
+
if (!isRunIdRow(row)) continue;
|
|
727
|
+
const run = this.readRun(row.runId, options);
|
|
728
|
+
if (run !== null) loaded.push(run);
|
|
729
|
+
}
|
|
730
|
+
return loaded;
|
|
685
731
|
}
|
|
686
732
|
|
|
687
|
-
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
}),
|
|
695
|
-
);
|
|
733
|
+
readLastTraceEvent(runId: string): WorkflowTraceEvent | null {
|
|
734
|
+
const run = this.readRunRow(runId);
|
|
735
|
+
if (run === undefined) return null;
|
|
736
|
+
const last = this.traceEvents(run).at(-1);
|
|
737
|
+
/* istanbul ignore if -- every durable run has a creation event */
|
|
738
|
+
if (last === undefined) throw new Error("Workflow run has no creation event");
|
|
739
|
+
return last;
|
|
696
740
|
}
|
|
697
|
-
}
|
|
698
741
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
742
|
+
private contextFor(runId: string): RunContext {
|
|
743
|
+
let context = this.contexts.get(runId);
|
|
744
|
+
if (context === undefined) {
|
|
745
|
+
context = { revision: this.resourceRevision(runId), lock: Promise.resolve() };
|
|
746
|
+
this.contexts.set(runId, context);
|
|
747
|
+
}
|
|
748
|
+
return context;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private async withRunLock<T>(runId: string, operation: () => Promise<T>): Promise<T> {
|
|
752
|
+
const context = this.contextFor(runId);
|
|
753
|
+
const prior = context.lock;
|
|
754
|
+
let release: (() => void) | undefined;
|
|
755
|
+
context.lock = new Promise<void>((resolve) => {
|
|
756
|
+
release = resolve;
|
|
757
|
+
});
|
|
758
|
+
await prior;
|
|
759
|
+
try {
|
|
760
|
+
return await operation();
|
|
761
|
+
} finally {
|
|
762
|
+
release?.();
|
|
763
|
+
}
|
|
717
764
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
765
|
+
|
|
766
|
+
private assertSessionWriteAuthority(runId: string): void {
|
|
767
|
+
const run = this.requireRunRow(runId);
|
|
768
|
+
this.assertWriteAuthority(run, this.contextFor(runId).revision);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private resourceRevision(runId: string): number {
|
|
772
|
+
const run = this.requireRunRow(runId);
|
|
773
|
+
return this.requireResourceRevision(run.resourceId);
|
|
721
774
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
775
|
+
|
|
776
|
+
private requireResourceRevision(resourceId: string): number {
|
|
777
|
+
const row = this.state.connection
|
|
778
|
+
.prepare("SELECT revision FROM resources WHERE resource_id = ?")
|
|
779
|
+
.get(resourceId);
|
|
780
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
781
|
+
if (!isRevisionRow(row)) throw new Error(`Resource is missing: ${resourceId}`);
|
|
782
|
+
return row.revision;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private bumpResource(resourceId: string, expectedRevision: number, now: number): void {
|
|
786
|
+
const result = this.state.connection
|
|
787
|
+
.prepare(
|
|
788
|
+
`UPDATE resources SET revision = revision + 1, updated_at = ?
|
|
789
|
+
WHERE resource_id = ? AND revision = ?`,
|
|
790
|
+
)
|
|
791
|
+
.run(now, resourceId, expectedRevision);
|
|
792
|
+
if (result.changes !== 1) throw new Error("Resource revision conflict");
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private assertWriteAuthority(run: RunRow, expectedRevision: number): void {
|
|
796
|
+
const actualRevision = this.requireResourceRevision(run.resourceId);
|
|
797
|
+
if (actualRevision !== expectedRevision) {
|
|
798
|
+
throw new Error(
|
|
799
|
+
`Workflow run revision conflict: expected ${expectedRevision}, got ${actualRevision}`,
|
|
800
|
+
);
|
|
727
801
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
802
|
+
const lease = this.state.connection
|
|
803
|
+
.prepare(
|
|
804
|
+
`SELECT generation, owner_type AS ownerType, owner_id AS ownerId,
|
|
805
|
+
token_hash AS tokenHash, expires_at AS expiresAt
|
|
806
|
+
FROM leases WHERE resource_id = ?`,
|
|
807
|
+
)
|
|
808
|
+
.get(run.resourceId);
|
|
809
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
810
|
+
if (!isLeaseAuthorityRow(lease)) throw new Error("Workflow run lease is missing");
|
|
811
|
+
if (lease.ownerId === null) return;
|
|
812
|
+
const authority = this.authorityProvider?.(run.runId);
|
|
813
|
+
if (
|
|
814
|
+
authority === undefined ||
|
|
815
|
+
authority.ownerType !== lease.ownerType ||
|
|
816
|
+
authority.ownerId !== lease.ownerId ||
|
|
817
|
+
authority.generation !== lease.generation ||
|
|
818
|
+
lease.tokenHash === null ||
|
|
819
|
+
!lease.tokenHash.equals(tokenHash(authority.token)) ||
|
|
820
|
+
lease.expiresAt === null ||
|
|
821
|
+
lease.expiresAt <= Date.now()
|
|
822
|
+
) {
|
|
823
|
+
throw new Error("Workflow run write rejected because ownership changed");
|
|
737
824
|
}
|
|
738
825
|
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
826
|
+
|
|
827
|
+
private persistRunState(
|
|
828
|
+
run: RunRow,
|
|
829
|
+
state: WorkflowRunState,
|
|
830
|
+
expectedRevision: number,
|
|
831
|
+
now: number,
|
|
832
|
+
): void {
|
|
833
|
+
this.bumpResource(run.resourceId, expectedRevision - 1, now);
|
|
834
|
+
const stateHash = this.state.putJson(state, now);
|
|
835
|
+
const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
|
|
836
|
+
const update = this.state.connection
|
|
837
|
+
.prepare(
|
|
838
|
+
`UPDATE runs
|
|
839
|
+
SET title = ?, status = ?, paused = ?, status_detail = ?, output_hash = ?,
|
|
840
|
+
error_hash = ?, updated_at = ?, finished_at = ?
|
|
841
|
+
WHERE run_id = ?`,
|
|
842
|
+
)
|
|
843
|
+
.run(
|
|
844
|
+
state.runTitle ?? null,
|
|
845
|
+
state.status,
|
|
846
|
+
state.paused === true ? 1 : 0,
|
|
847
|
+
state.statusDetail ?? null,
|
|
848
|
+
stateHash,
|
|
849
|
+
errorHash,
|
|
850
|
+
now,
|
|
851
|
+
state.finishedAt === undefined ? null : Date.parse(state.finishedAt),
|
|
852
|
+
state.runId,
|
|
853
|
+
);
|
|
854
|
+
if (update.changes !== 1) throw new Error(`Workflow run is missing: ${state.runId}`);
|
|
855
|
+
if (state.status !== "running") {
|
|
856
|
+
this.enqueueRunSettlementEffect(run.resourceId, expectedRevision, state, now);
|
|
857
|
+
}
|
|
858
|
+
run.stateHash = stateHash;
|
|
742
859
|
}
|
|
743
|
-
beforeWrite?.();
|
|
744
|
-
const tempPath = `${tracePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
745
|
-
await fs.writeFile(tempPath, kept.length === 0 ? "" : `${kept.join("\n")}\n`, {
|
|
746
|
-
encoding: "utf8",
|
|
747
|
-
mode: 0o600,
|
|
748
|
-
});
|
|
749
|
-
await fs.rename(tempPath, tracePath);
|
|
750
|
-
}
|
|
751
860
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
861
|
+
private enqueueRunSettlementEffect(
|
|
862
|
+
runResourceId: string,
|
|
863
|
+
sourceRevision: number,
|
|
864
|
+
state: WorkflowRunState,
|
|
865
|
+
now: number,
|
|
866
|
+
): void {
|
|
867
|
+
if (
|
|
868
|
+
this.state.connection.prepare("SELECT 1 FROM run_queue WHERE run_id = ?").get(state.runId) ===
|
|
869
|
+
undefined
|
|
870
|
+
) {
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
const effectType = state.status === "waiting" ? "run.park_queue" : "run.settle_queue";
|
|
874
|
+
const idempotencyKey = `${state.runId}:${state.status}`;
|
|
875
|
+
const effectId = `effect-${createHash("sha256")
|
|
876
|
+
.update(`${runResourceId}\0${effectType}\0${idempotencyKey}`)
|
|
877
|
+
.digest("hex")
|
|
878
|
+
.slice(0, 40)}`;
|
|
879
|
+
if (
|
|
880
|
+
this.state.connection.prepare("SELECT 1 FROM effects WHERE effect_id = ?").get(effectId) !==
|
|
881
|
+
undefined
|
|
882
|
+
) {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const effectResourceId = resourceIdFor("effect", effectId);
|
|
886
|
+
const payloadHash = this.state.putJson({ runId: state.runId, status: state.status }, now);
|
|
887
|
+
this.state.connection
|
|
888
|
+
.prepare(
|
|
889
|
+
`INSERT INTO resources(
|
|
890
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
891
|
+
) VALUES (?, 'effect', ?, 1, ?, ?)`,
|
|
892
|
+
)
|
|
893
|
+
.run(effectResourceId, effectId, now, now);
|
|
894
|
+
this.state.connection
|
|
895
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
896
|
+
.run(effectResourceId);
|
|
897
|
+
this.state.connection
|
|
898
|
+
.prepare(
|
|
899
|
+
`INSERT INTO effects(
|
|
900
|
+
effect_id, resource_id, source_resource_id, source_revision,
|
|
901
|
+
effect_type, idempotency_key, payload_hash, owner_scope,
|
|
902
|
+
status, attempt_count, created_at, updated_at
|
|
903
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'run', 'pending', 0, ?, ?)`,
|
|
904
|
+
)
|
|
905
|
+
.run(
|
|
906
|
+
effectId,
|
|
907
|
+
effectResourceId,
|
|
908
|
+
runResourceId,
|
|
909
|
+
sourceRevision,
|
|
910
|
+
effectType,
|
|
911
|
+
idempotencyKey,
|
|
912
|
+
payloadHash,
|
|
913
|
+
now,
|
|
914
|
+
now,
|
|
915
|
+
);
|
|
916
|
+
insertGenericEvent(
|
|
917
|
+
this.state,
|
|
918
|
+
effectResourceId,
|
|
919
|
+
1,
|
|
920
|
+
"effect.created",
|
|
921
|
+
"system",
|
|
922
|
+
null,
|
|
923
|
+
payloadHash,
|
|
924
|
+
now,
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
private syncNodeAttempts(
|
|
929
|
+
state: WorkflowRunState,
|
|
930
|
+
snapshot: WorkflowDefinitionSnapshot,
|
|
931
|
+
now: number,
|
|
932
|
+
): void {
|
|
933
|
+
for (const step of state.steps.slice(state.carriedStepCount ?? 0)) {
|
|
934
|
+
const resultHash = this.state.putJson(step, now);
|
|
935
|
+
const outputHash = step.output === undefined ? null : this.state.putJson(step.output, now);
|
|
936
|
+
const errorHash = step.error === undefined ? null : this.state.putText(step.error, now);
|
|
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);
|
|
943
|
+
this.state.connection
|
|
944
|
+
.prepare(
|
|
945
|
+
`INSERT INTO node_attempts(
|
|
946
|
+
attempt_id, run_id, node_id, attempt_number, node_type, status,
|
|
947
|
+
presentation_hash, output_hash, result_hash, error_hash,
|
|
948
|
+
started_at, finished_at, created_at, updated_at
|
|
949
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
950
|
+
)
|
|
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 {
|
|
968
|
+
this.state.connection
|
|
969
|
+
.prepare(
|
|
970
|
+
`UPDATE node_attempts
|
|
971
|
+
SET status = ?, presentation_hash = ?, output_hash = ?, result_hash = ?,
|
|
972
|
+
error_hash = ?, finished_at = ?, updated_at = ?
|
|
973
|
+
WHERE attempt_id = ?`,
|
|
974
|
+
)
|
|
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
|
+
);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
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
|
+
const existing = this.state.connection
|
|
1000
|
+
.prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
|
|
1001
|
+
.get(attemptId);
|
|
1002
|
+
const status = state.status === "waiting" ? "waiting" : "running";
|
|
1003
|
+
if (existing !== undefined) {
|
|
1004
|
+
this.state.connection
|
|
1005
|
+
.prepare("UPDATE node_attempts SET status = ?, updated_at = ? WHERE attempt_id = ?")
|
|
1006
|
+
.run(status, now, attemptId);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const definition =
|
|
1010
|
+
snapshot ?? this.readDefinition(this.requireRunRow(state.runId).definitionHash);
|
|
1011
|
+
const nodeType = definition.nodes[nodeId]?.nodeType ?? "agent";
|
|
1012
|
+
this.state.connection
|
|
1013
|
+
.prepare(
|
|
1014
|
+
`INSERT INTO node_attempts(
|
|
1015
|
+
attempt_id, run_id, node_id, attempt_number, node_type, status,
|
|
1016
|
+
started_at, created_at, updated_at
|
|
1017
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1018
|
+
)
|
|
1019
|
+
.run(
|
|
1020
|
+
attemptId,
|
|
1021
|
+
state.runId,
|
|
1022
|
+
nodeId,
|
|
1023
|
+
this.nextAttemptNumber(state.runId, nodeId),
|
|
1024
|
+
nodeType,
|
|
1025
|
+
status,
|
|
1026
|
+
state.currentNodeStartedAt === undefined ? now : Date.parse(state.currentNodeStartedAt),
|
|
1027
|
+
now,
|
|
1028
|
+
now,
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
755
1031
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1032
|
+
private nextAttemptNumber(runId: string, nodeId: string): number {
|
|
1033
|
+
const row = this.state.connection
|
|
1034
|
+
.prepare(
|
|
1035
|
+
`SELECT COALESCE(MAX(attempt_number), 0) + 1 AS attemptNumber
|
|
1036
|
+
FROM node_attempts WHERE run_id = ? AND node_id = ?`,
|
|
1037
|
+
)
|
|
1038
|
+
.get(runId, nodeId);
|
|
1039
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
1040
|
+
if (!isAttemptNumberRow(row)) throw new Error("Could not allocate node attempt number");
|
|
1041
|
+
return row.attemptNumber;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
private nextUpdateSequence(attemptId: string): number {
|
|
1045
|
+
const row = this.state.connection
|
|
1046
|
+
.prepare(
|
|
1047
|
+
`SELECT COALESCE(MAX(update_seq), 0) + 1 AS updateSeq
|
|
1048
|
+
FROM workflow_updates WHERE attempt_id = ?`,
|
|
1049
|
+
)
|
|
1050
|
+
.get(attemptId);
|
|
1051
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
1052
|
+
if (!isUpdateSequenceRow(row)) throw new Error("Could not allocate workflow update sequence");
|
|
1053
|
+
return row.updateSeq;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private readRunRow(runId: string): RunRow | undefined {
|
|
1057
|
+
const row = this.state.connection
|
|
1058
|
+
.prepare(
|
|
1059
|
+
`SELECT r.run_id AS runId, r.resource_id AS resourceId,
|
|
1060
|
+
r.output_hash AS stateHash, d.definition_hash AS definitionHash,
|
|
1061
|
+
r.status
|
|
1062
|
+
FROM runs r
|
|
1063
|
+
JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
|
|
1064
|
+
WHERE r.run_id = ?`,
|
|
1065
|
+
)
|
|
1066
|
+
.get(runId);
|
|
1067
|
+
return isRunRow(row) ? row : undefined;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
private requireRunRow(runId: string): RunRow {
|
|
1071
|
+
const row = this.readRunRow(runId);
|
|
1072
|
+
if (row === undefined) throw new Error(`Workflow run is missing: ${runId}`);
|
|
1073
|
+
return row;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
private readState(hash: Buffer): WorkflowRunState {
|
|
1077
|
+
const value = this.state.readJson(hash);
|
|
1078
|
+
if (!isRecord(value) || value.schema !== RUN_STATE_SCHEMA) {
|
|
1079
|
+
throw new Error("Workflow run state has an incompatible schema");
|
|
1080
|
+
}
|
|
1081
|
+
return value as WorkflowRunState;
|
|
760
1082
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1083
|
+
|
|
1084
|
+
private readDefinition(hash: Buffer): WorkflowDefinitionSnapshot {
|
|
1085
|
+
const value = this.state.readJson(hash);
|
|
1086
|
+
if (!isRecord(value) || value.schema !== DEFINITION_SNAPSHOT_SCHEMA) {
|
|
1087
|
+
throw new Error("Workflow definition snapshot has an incompatible schema");
|
|
1088
|
+
}
|
|
1089
|
+
return value as WorkflowDefinitionSnapshot;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
private traceEvents(run: RunRow): WorkflowTraceEvent[] {
|
|
1093
|
+
const rows = this.state.connection
|
|
1094
|
+
.prepare(
|
|
1095
|
+
`SELECT resource_revision AS resourceRevision, event_type AS eventType,
|
|
1096
|
+
payload_hash AS payloadHash, recorded_at AS recordedAt
|
|
1097
|
+
FROM events WHERE resource_id = ? ORDER BY resource_revision`,
|
|
1098
|
+
)
|
|
1099
|
+
.all(run.resourceId);
|
|
1100
|
+
const events: WorkflowTraceEvent[] = [];
|
|
1101
|
+
for (const row of rows) {
|
|
1102
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
1103
|
+
if (!isEventRow(row)) continue;
|
|
1104
|
+
const payload = row.payloadHash === null ? {} : this.state.readJson(row.payloadHash);
|
|
1105
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
1106
|
+
if (!isRecord(payload)) throw new Error("Workflow trace payload is not an object");
|
|
1107
|
+
events.push({
|
|
1108
|
+
seq: row.resourceRevision,
|
|
1109
|
+
at: new Date(row.recordedAt).toISOString(),
|
|
1110
|
+
runId: run.runId,
|
|
1111
|
+
scope: traceScope(payload.scope),
|
|
1112
|
+
type: row.eventType,
|
|
1113
|
+
...(typeof payload.nodeId === "string" ? { nodeId: payload.nodeId } : {}),
|
|
1114
|
+
...(typeof payload.attemptId === "string" ? { attemptId: payload.attemptId } : {}),
|
|
1115
|
+
payload: isRecord(payload.payload) ? payload.payload : {},
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
return events;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
private segmentRows(runId: string): SegmentRow[] {
|
|
1122
|
+
const rows = this.state.connection
|
|
1123
|
+
.prepare(
|
|
1124
|
+
`SELECT segment_id AS segmentId, run_id AS runId, attempt_id AS attemptId,
|
|
1125
|
+
capture_key AS captureKey, session_id AS sessionId, binding_hash AS bindingHash, status,
|
|
1126
|
+
entry_count AS entryCount, event_count AS eventCount,
|
|
1127
|
+
failure_hash AS failureHash, created_at AS createdAt, finished_at AS finishedAt
|
|
1128
|
+
FROM session_segments WHERE run_id = ? ORDER BY created_at, segment_id`,
|
|
1129
|
+
)
|
|
1130
|
+
.all(runId);
|
|
1131
|
+
return rows.filter(isSegmentRow);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
private segmentRow(segmentId: string): SegmentRow | undefined {
|
|
1135
|
+
const row = this.state.connection
|
|
1136
|
+
.prepare(
|
|
1137
|
+
`SELECT segment_id AS segmentId, run_id AS runId, attempt_id AS attemptId,
|
|
1138
|
+
capture_key AS captureKey, session_id AS sessionId, binding_hash AS bindingHash, status,
|
|
1139
|
+
entry_count AS entryCount, event_count AS eventCount,
|
|
1140
|
+
failure_hash AS failureHash, created_at AS createdAt, finished_at AS finishedAt
|
|
1141
|
+
FROM session_segments WHERE segment_id = ?`,
|
|
1142
|
+
)
|
|
1143
|
+
.get(segmentId);
|
|
1144
|
+
return isSegmentRow(row) ? row : undefined;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
private requireSegment(segmentId: string): SegmentRow {
|
|
1148
|
+
const row = this.segmentRow(segmentId);
|
|
1149
|
+
if (row === undefined) throw new Error(`Session capture segment is missing: ${segmentId}`);
|
|
1150
|
+
return row;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
private loadSegment(segment: SegmentRow, runState: WorkflowRunState): SessionCaptureSegment {
|
|
1154
|
+
const binding =
|
|
1155
|
+
segment.bindingHash === null
|
|
1156
|
+
? null
|
|
1157
|
+
: (this.state.readJson(segment.bindingHash) as WorkflowSessionBinding);
|
|
1158
|
+
const entries = this.state.connection
|
|
1159
|
+
.prepare(
|
|
1160
|
+
`SELECT entry_seq AS entrySeq, entry_hash AS entryHash, recorded_at AS recordedAt
|
|
1161
|
+
FROM session_entries WHERE segment_id = ? ORDER BY entry_seq`,
|
|
1162
|
+
)
|
|
1163
|
+
.all(segment.segmentId)
|
|
1164
|
+
.filter(isSessionEntryRow)
|
|
1165
|
+
.map((row) => ({
|
|
1166
|
+
seq: row.entrySeq,
|
|
1167
|
+
at: new Date(row.recordedAt).toISOString(),
|
|
1168
|
+
entry: this.state.readJson(row.entryHash) as Record<string, unknown>,
|
|
1169
|
+
}));
|
|
1170
|
+
const events = this.state.connection
|
|
1171
|
+
.prepare(
|
|
1172
|
+
`SELECT event_seq AS eventSeq, event_type AS eventType, node_id AS nodeId,
|
|
1173
|
+
attempt_id AS attemptId, turn_id AS turnId, message_id AS messageId,
|
|
1174
|
+
tool_call_id AS toolCallId, payload_hash AS payloadHash,
|
|
1175
|
+
recorded_at AS recordedAt
|
|
1176
|
+
FROM session_events WHERE segment_id = ? ORDER BY event_seq`,
|
|
1177
|
+
)
|
|
1178
|
+
.all(segment.segmentId)
|
|
1179
|
+
.filter(isSessionEventRow)
|
|
1180
|
+
.map((row) => ({
|
|
1181
|
+
seq: row.eventSeq,
|
|
1182
|
+
at: new Date(row.recordedAt).toISOString(),
|
|
1183
|
+
nodeId: row.nodeId,
|
|
1184
|
+
attemptId: row.attemptId,
|
|
1185
|
+
...(row.turnId === null ? {} : { turnId: row.turnId }),
|
|
1186
|
+
...(row.messageId === null ? {} : { messageId: row.messageId }),
|
|
1187
|
+
...(row.toolCallId === null ? {} : { toolCallId: row.toolCallId }),
|
|
1188
|
+
type: row.eventType,
|
|
1189
|
+
payload: this.state.readJson(row.payloadHash) as Record<string, unknown>,
|
|
1190
|
+
}));
|
|
1191
|
+
const failure =
|
|
1192
|
+
segment.failureHash === null
|
|
1193
|
+
? undefined
|
|
1194
|
+
: (this.state.readJson(segment.failureHash) as WorkflowSessionCapture["failure"]);
|
|
1195
|
+
const capture: WorkflowSessionCapture = {
|
|
1196
|
+
schema: SESSION_CAPTURE_SCHEMA,
|
|
1197
|
+
eventSchema: SESSION_EVENT_SCHEMA,
|
|
1198
|
+
status: segment.status,
|
|
1199
|
+
eventCount: segment.eventCount,
|
|
1200
|
+
entryCount: segment.entryCount,
|
|
1201
|
+
lastEventSeq: segment.eventCount,
|
|
1202
|
+
...(failure === undefined ? {} : { failure }),
|
|
1203
|
+
};
|
|
1204
|
+
const diagnostics: string[] = [];
|
|
1205
|
+
if (entries.length !== segment.entryCount || events.length !== segment.eventCount) {
|
|
1206
|
+
diagnostics.push("session capture counts do not match durable rows");
|
|
1207
|
+
}
|
|
1208
|
+
if (runState.status !== "running" && segment.status === "recording") {
|
|
1209
|
+
diagnostics.push("terminal run still reports recording capture");
|
|
1210
|
+
}
|
|
1211
|
+
const integrity: SessionCaptureIntegrity =
|
|
1212
|
+
diagnostics.length > 0
|
|
1213
|
+
? { status: "invalid", diagnostics }
|
|
1214
|
+
: segment.status === "failed"
|
|
1215
|
+
? { status: "failed", diagnostics: [failure?.message ?? "session capture failed"] }
|
|
1216
|
+
: { status: segment.status, diagnostics: [] };
|
|
1217
|
+
return {
|
|
1218
|
+
attemptId: segment.captureKey ?? "",
|
|
1219
|
+
binding,
|
|
1220
|
+
entries,
|
|
1221
|
+
events,
|
|
1222
|
+
capture,
|
|
1223
|
+
integrity,
|
|
1224
|
+
};
|
|
767
1225
|
}
|
|
768
|
-
|
|
769
|
-
|
|
1226
|
+
|
|
1227
|
+
private finalizeRecordingCaptures(runId: string, reason: string): void {
|
|
1228
|
+
for (const segment of this.segmentRows(runId)) {
|
|
1229
|
+
if (segment.status !== "recording") continue;
|
|
1230
|
+
const capture: WorkflowSessionCapture = {
|
|
1231
|
+
schema: SESSION_CAPTURE_SCHEMA,
|
|
1232
|
+
eventSchema: SESSION_EVENT_SCHEMA,
|
|
1233
|
+
status: "failed",
|
|
1234
|
+
eventCount: segment.eventCount,
|
|
1235
|
+
entryCount: segment.entryCount,
|
|
1236
|
+
lastEventSeq: segment.eventCount,
|
|
1237
|
+
failure: {
|
|
1238
|
+
failedAt: new Date().toISOString(),
|
|
1239
|
+
code: "host_interrupted",
|
|
1240
|
+
message: reason,
|
|
1241
|
+
},
|
|
1242
|
+
};
|
|
1243
|
+
const now = Date.now();
|
|
1244
|
+
this.state.transaction(() => {
|
|
1245
|
+
this.assertSessionWriteAuthority(runId);
|
|
1246
|
+
const failureHash = this.state.putJson(capture.failure, now);
|
|
1247
|
+
this.state.connection
|
|
1248
|
+
.prepare(
|
|
1249
|
+
`UPDATE session_segments
|
|
1250
|
+
SET status = 'failed', failure_hash = ?, finished_at = ?
|
|
1251
|
+
WHERE segment_id = ? AND status = 'recording'`,
|
|
1252
|
+
)
|
|
1253
|
+
.run(failureHash, now, segment.segmentId);
|
|
1254
|
+
const resourceId = segmentResourceId(segment);
|
|
1255
|
+
const revision = this.requireResourceRevision(resourceId);
|
|
1256
|
+
this.bumpResource(resourceId, revision, now);
|
|
1257
|
+
const payloadHash = this.state.putJson(capture, now);
|
|
1258
|
+
insertGenericEvent(
|
|
1259
|
+
this.state,
|
|
1260
|
+
resourceId,
|
|
1261
|
+
revision + 1,
|
|
1262
|
+
"session.capture_failed",
|
|
1263
|
+
"system",
|
|
1264
|
+
null,
|
|
1265
|
+
payloadHash,
|
|
1266
|
+
now,
|
|
1267
|
+
);
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
770
1270
|
}
|
|
771
|
-
|
|
772
|
-
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
export function readWorkflowRun(
|
|
1274
|
+
runId: string,
|
|
1275
|
+
options: ReadWorkflowRunOptions & { databasePath?: string } = {},
|
|
1276
|
+
): LoadedWorkflowRun | null {
|
|
1277
|
+
const store = new WorkflowRunStore(options.databasePath ?? workflowStatePath(), {
|
|
1278
|
+
readOnly: true,
|
|
1279
|
+
});
|
|
1280
|
+
try {
|
|
1281
|
+
return store.readRun(runId, options);
|
|
1282
|
+
} finally {
|
|
1283
|
+
store.close();
|
|
773
1284
|
}
|
|
774
|
-
delete state.currentNode;
|
|
775
|
-
delete state.currentAttemptId;
|
|
776
|
-
delete state.currentNodeStartedAt;
|
|
777
|
-
return true;
|
|
778
1285
|
}
|
|
779
1286
|
|
|
780
|
-
function
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
return "timed_out";
|
|
791
|
-
case "run_cancelled":
|
|
792
|
-
return "cancelled";
|
|
793
|
-
default:
|
|
794
|
-
return undefined;
|
|
1287
|
+
export function listWorkflowRuns(
|
|
1288
|
+
options: ReadWorkflowRunOptions & { databasePath?: string } = {},
|
|
1289
|
+
): LoadedWorkflowRun[] {
|
|
1290
|
+
const store = new WorkflowRunStore(options.databasePath ?? workflowStatePath(), {
|
|
1291
|
+
readOnly: true,
|
|
1292
|
+
});
|
|
1293
|
+
try {
|
|
1294
|
+
return store.listRuns(options);
|
|
1295
|
+
} finally {
|
|
1296
|
+
store.close();
|
|
795
1297
|
}
|
|
796
1298
|
}
|
|
797
1299
|
|
|
798
|
-
function
|
|
799
|
-
|
|
800
|
-
|
|
1300
|
+
export function readLastTraceEvent(
|
|
1301
|
+
runId: string,
|
|
1302
|
+
options: { databasePath?: string } = {},
|
|
1303
|
+
): WorkflowTraceEvent | null {
|
|
1304
|
+
const store = new WorkflowRunStore(options.databasePath ?? workflowStatePath(), {
|
|
1305
|
+
readOnly: true,
|
|
1306
|
+
});
|
|
1307
|
+
try {
|
|
1308
|
+
return store.readLastTraceEvent(runId);
|
|
1309
|
+
} finally {
|
|
1310
|
+
store.close();
|
|
801
1311
|
}
|
|
802
1312
|
}
|
|
803
1313
|
|
|
804
|
-
function
|
|
805
|
-
|
|
1314
|
+
function insertRunEvent(
|
|
1315
|
+
state: StateDatabase,
|
|
1316
|
+
resourceId: string,
|
|
1317
|
+
revision: number,
|
|
1318
|
+
at: string,
|
|
1319
|
+
event: WorkflowTraceEventDraft | WorkflowTraceEvent,
|
|
1320
|
+
): void {
|
|
1321
|
+
const payloadHash = state.putJson(
|
|
1322
|
+
{
|
|
1323
|
+
scope: event.scope,
|
|
1324
|
+
...(event.nodeId === undefined ? {} : { nodeId: event.nodeId }),
|
|
1325
|
+
...(event.attemptId === undefined ? {} : { attemptId: event.attemptId }),
|
|
1326
|
+
payload: event.payload,
|
|
1327
|
+
},
|
|
1328
|
+
Date.parse(at),
|
|
1329
|
+
);
|
|
1330
|
+
insertGenericEvent(
|
|
1331
|
+
state,
|
|
1332
|
+
resourceId,
|
|
1333
|
+
revision,
|
|
1334
|
+
event.type,
|
|
1335
|
+
"system",
|
|
1336
|
+
null,
|
|
1337
|
+
payloadHash,
|
|
1338
|
+
Date.parse(at),
|
|
1339
|
+
);
|
|
806
1340
|
}
|
|
807
1341
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1342
|
+
function insertGenericEvent(
|
|
1343
|
+
state: StateDatabase,
|
|
1344
|
+
resourceId: string,
|
|
1345
|
+
revision: number,
|
|
1346
|
+
eventType: string,
|
|
1347
|
+
actorType: string,
|
|
1348
|
+
actorId: string | null,
|
|
1349
|
+
payloadHash: Buffer | null,
|
|
1350
|
+
now: number,
|
|
1351
|
+
): void {
|
|
1352
|
+
state.connection
|
|
1353
|
+
.prepare(
|
|
1354
|
+
`INSERT INTO events(
|
|
1355
|
+
event_id, resource_id, resource_revision, event_type,
|
|
1356
|
+
actor_type, actor_id, payload_hash, recorded_at
|
|
1357
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1358
|
+
)
|
|
1359
|
+
.run(
|
|
1360
|
+
`event-${randomUUID()}`,
|
|
1361
|
+
resourceId,
|
|
1362
|
+
revision,
|
|
1363
|
+
eventType,
|
|
1364
|
+
actorType,
|
|
1365
|
+
actorId,
|
|
1366
|
+
payloadHash,
|
|
1367
|
+
now,
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function sourceForState(
|
|
1372
|
+
state: WorkflowRunState,
|
|
1373
|
+
definitionDigest: Buffer,
|
|
1374
|
+
): { type: "builtin" | "file"; ref: string; revision: string } {
|
|
1375
|
+
const source = state.workflowSource;
|
|
1376
|
+
if (source?.kind === "builtin")
|
|
1377
|
+
return { type: "builtin", ref: source.id, revision: source.revision };
|
|
1378
|
+
if (source?.kind === "file") return { type: "file", ref: source.path, revision: source.hash };
|
|
1379
|
+
return {
|
|
1380
|
+
type: "file",
|
|
1381
|
+
ref: `inline:${state.workflowName}`,
|
|
1382
|
+
revision: definitionDigest.toString("hex"),
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function segmentIdFor(runId: string, attemptId?: string): string {
|
|
1387
|
+
return `segment-${createHash("sha256")
|
|
1388
|
+
.update(`${runId}\0${attemptId ?? ""}`)
|
|
1389
|
+
.digest("hex")
|
|
1390
|
+
.slice(0, 40)}`;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function segmentResourceId(segment: SegmentRow): string {
|
|
1394
|
+
return resourceIdFor("session", segment.segmentId);
|
|
814
1395
|
}
|
|
815
1396
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1397
|
+
function outcomeStatus(outcome: string): string {
|
|
1398
|
+
switch (outcome) {
|
|
1399
|
+
case "ok":
|
|
1400
|
+
return "completed";
|
|
1401
|
+
case "timed_out":
|
|
1402
|
+
return "timed_out";
|
|
1403
|
+
case "cancelled":
|
|
1404
|
+
return "cancelled";
|
|
1405
|
+
default:
|
|
1406
|
+
return "failed";
|
|
1407
|
+
}
|
|
820
1408
|
}
|
|
821
1409
|
|
|
822
|
-
function
|
|
823
|
-
return
|
|
1410
|
+
function traceScope(value: unknown): WorkflowTraceEvent["scope"] {
|
|
1411
|
+
return value === "node" || value === "agent" || value === "action" || value === "session"
|
|
1412
|
+
? value
|
|
1413
|
+
: "run";
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function emptySegment(): SessionCaptureSegment {
|
|
1417
|
+
return {
|
|
1418
|
+
attemptId: "",
|
|
1419
|
+
binding: null,
|
|
1420
|
+
entries: [],
|
|
1421
|
+
events: [],
|
|
1422
|
+
capture: null,
|
|
1423
|
+
integrity: { status: "unavailable", diagnostics: [] },
|
|
1424
|
+
};
|
|
824
1425
|
}
|
|
825
1426
|
|
|
826
1427
|
function validateSessionEventRecord(record: WorkflowSessionEventRecord): void {
|
|
@@ -828,105 +1429,15 @@ function validateSessionEventRecord(record: WorkflowSessionEventRecord): void {
|
|
|
828
1429
|
throw new Error("Session event seq must be a positive safe integer");
|
|
829
1430
|
}
|
|
830
1431
|
if (
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
!isNonEmptyString(record.type) ||
|
|
1432
|
+
record.at.length === 0 ||
|
|
1433
|
+
record.nodeId.length === 0 ||
|
|
1434
|
+
record.attemptId.length === 0 ||
|
|
835
1435
|
typeof record.payload !== "object" ||
|
|
836
1436
|
record.payload === null ||
|
|
837
1437
|
Array.isArray(record.payload)
|
|
838
1438
|
) {
|
|
839
1439
|
throw new Error("Session event is missing required envelope fields");
|
|
840
1440
|
}
|
|
841
|
-
const knownType = [
|
|
842
|
-
"turn_started",
|
|
843
|
-
"turn_finished",
|
|
844
|
-
"message_started",
|
|
845
|
-
"assistant_event",
|
|
846
|
-
"message_finished",
|
|
847
|
-
"tool_execution_started",
|
|
848
|
-
"tool_execution_updated",
|
|
849
|
-
"tool_execution_finished",
|
|
850
|
-
].includes(record.type);
|
|
851
|
-
if (!knownType) {
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
if (!isNonEmptyString(record.turnId)) {
|
|
855
|
-
throw new Error(`${record.type} requires turnId`);
|
|
856
|
-
}
|
|
857
|
-
if (
|
|
858
|
-
!["turn_started", "turn_finished"].includes(record.type) &&
|
|
859
|
-
!isNonEmptyString(record.messageId)
|
|
860
|
-
) {
|
|
861
|
-
throw new Error(`${record.type} requires messageId`);
|
|
862
|
-
}
|
|
863
|
-
if (record.type.startsWith("tool_execution_") && !isNonEmptyString(record.toolCallId)) {
|
|
864
|
-
throw new Error(`${record.type} requires toolCallId`);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
function sessionRelationshipDiagnostics(
|
|
869
|
-
entries: WorkflowSessionEntryRecord[],
|
|
870
|
-
events: WorkflowSessionEventRecord[],
|
|
871
|
-
): string[] {
|
|
872
|
-
const entryIds = new Set(
|
|
873
|
-
entries.flatMap((record) => (isNonEmptyString(record.entry.id) ? [record.entry.id] : [])),
|
|
874
|
-
);
|
|
875
|
-
const turns = new Set<string>();
|
|
876
|
-
const messages = new Set<string>();
|
|
877
|
-
const tools = new Set<string>();
|
|
878
|
-
const diagnostics: string[] = [];
|
|
879
|
-
for (const event of events) {
|
|
880
|
-
switch (event.type) {
|
|
881
|
-
case "turn_started":
|
|
882
|
-
if (event.turnId) turns.add(event.turnId);
|
|
883
|
-
break;
|
|
884
|
-
case "turn_finished":
|
|
885
|
-
if (!event.turnId || !turns.has(event.turnId)) {
|
|
886
|
-
diagnostics.push(`turn_finished ${event.seq} precedes turn_started`);
|
|
887
|
-
}
|
|
888
|
-
break;
|
|
889
|
-
case "message_started":
|
|
890
|
-
if (!event.turnId || !turns.has(event.turnId)) {
|
|
891
|
-
diagnostics.push(`message_started ${event.seq} precedes turn_started`);
|
|
892
|
-
}
|
|
893
|
-
if (event.messageId) messages.add(event.messageId);
|
|
894
|
-
break;
|
|
895
|
-
case "assistant_event":
|
|
896
|
-
if (!event.messageId || !messages.has(event.messageId)) {
|
|
897
|
-
diagnostics.push(`assistant_event ${event.seq} precedes message_started`);
|
|
898
|
-
}
|
|
899
|
-
break;
|
|
900
|
-
case "message_finished": {
|
|
901
|
-
if (!event.messageId || !messages.has(event.messageId)) {
|
|
902
|
-
diagnostics.push(`message_finished ${event.seq} precedes message_started`);
|
|
903
|
-
}
|
|
904
|
-
const settled = event.payload.settled;
|
|
905
|
-
const entryId = event.payload.entryId;
|
|
906
|
-
if (settled === true && (!isNonEmptyString(entryId) || !entryIds.has(entryId))) {
|
|
907
|
-
diagnostics.push(`message_finished ${event.seq} references a missing entry`);
|
|
908
|
-
} else if (settled !== true && entryId !== undefined) {
|
|
909
|
-
diagnostics.push(`message_finished ${event.seq} has entryId while unsettled`);
|
|
910
|
-
}
|
|
911
|
-
break;
|
|
912
|
-
}
|
|
913
|
-
case "tool_execution_started":
|
|
914
|
-
if (!event.messageId || !messages.has(event.messageId)) {
|
|
915
|
-
diagnostics.push(`tool_execution_started ${event.seq} precedes message_started`);
|
|
916
|
-
}
|
|
917
|
-
if (event.toolCallId) tools.add(event.toolCallId);
|
|
918
|
-
break;
|
|
919
|
-
case "tool_execution_updated":
|
|
920
|
-
case "tool_execution_finished":
|
|
921
|
-
if (!event.toolCallId || !tools.has(event.toolCallId)) {
|
|
922
|
-
diagnostics.push(`${event.type} ${event.seq} precedes tool_execution_started`);
|
|
923
|
-
}
|
|
924
|
-
break;
|
|
925
|
-
default:
|
|
926
|
-
break;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
return diagnostics;
|
|
930
1441
|
}
|
|
931
1442
|
|
|
932
1443
|
function validateSessionCapture(capture: WorkflowSessionCapture): void {
|
|
@@ -951,423 +1462,60 @@ function validateSessionCapture(capture: WorkflowSessionCapture): void {
|
|
|
951
1462
|
}
|
|
952
1463
|
}
|
|
953
1464
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
raw = await fs.readFile(filePath, "utf8");
|
|
958
|
-
} catch {
|
|
959
|
-
return [];
|
|
960
|
-
}
|
|
961
|
-
const lines = raw.split("\n");
|
|
962
|
-
if (!raw.endsWith("\n")) {
|
|
963
|
-
lines.pop();
|
|
964
|
-
}
|
|
965
|
-
const records: Array<{ seq: number }> = [];
|
|
966
|
-
for (const line of lines) {
|
|
967
|
-
if (line.trim().length === 0) {
|
|
968
|
-
continue;
|
|
969
|
-
}
|
|
970
|
-
try {
|
|
971
|
-
const value = JSON.parse(line) as { seq?: unknown };
|
|
972
|
-
if (!Number.isSafeInteger(value.seq) || (value.seq as number) < 1) {
|
|
973
|
-
break;
|
|
974
|
-
}
|
|
975
|
-
records.push({ seq: value.seq as number });
|
|
976
|
-
} catch {
|
|
977
|
-
break;
|
|
978
|
-
}
|
|
1465
|
+
function assertValidRunId(runId: string): void {
|
|
1466
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
|
|
1467
|
+
throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
|
|
979
1468
|
}
|
|
980
|
-
return records;
|
|
981
1469
|
}
|
|
982
1470
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
* in-memory state always holds raw values; the persisted copy may carry
|
|
986
|
-
* `$artifact` references instead of large strings.
|
|
987
|
-
*/
|
|
988
|
-
async function encodeRunState(
|
|
989
|
-
state: WorkflowRunState,
|
|
990
|
-
artifacts: ArtifactWriter,
|
|
991
|
-
): Promise<WorkflowRunState> {
|
|
992
|
-
const results = Object.fromEntries(
|
|
993
|
-
await Promise.all(
|
|
994
|
-
Object.entries(state.results).map(async ([nodeId, result]) => [
|
|
995
|
-
nodeId,
|
|
996
|
-
"output" in result
|
|
997
|
-
? { ...result, output: await encodeValue(result.output, artifacts) }
|
|
998
|
-
: result,
|
|
999
|
-
]),
|
|
1000
|
-
),
|
|
1001
|
-
) as WorkflowRunState["results"];
|
|
1002
|
-
return {
|
|
1003
|
-
...state,
|
|
1004
|
-
input: await encodeValue(state.input, artifacts),
|
|
1005
|
-
outputs: Object.fromEntries(
|
|
1006
|
-
await Promise.all(
|
|
1007
|
-
Object.entries(state.outputs).map(async ([nodeId, output]) => [
|
|
1008
|
-
nodeId,
|
|
1009
|
-
await encodeValue(output, artifacts),
|
|
1010
|
-
]),
|
|
1011
|
-
),
|
|
1012
|
-
),
|
|
1013
|
-
results,
|
|
1014
|
-
steps: await Promise.all(
|
|
1015
|
-
state.steps.map(async (step) => ({
|
|
1016
|
-
...step,
|
|
1017
|
-
prompt: (await encodeValue(
|
|
1018
|
-
step.prompt,
|
|
1019
|
-
artifacts,
|
|
1020
|
-
)) as WorkflowRunState["steps"][number]["prompt"],
|
|
1021
|
-
output: await encodeValue(step.output, artifacts),
|
|
1022
|
-
})),
|
|
1023
|
-
),
|
|
1024
|
-
...(state.finalOutput !== undefined
|
|
1025
|
-
? { finalOutput: await encodeValue(state.finalOutput, artifacts) }
|
|
1026
|
-
: {}),
|
|
1027
|
-
};
|
|
1471
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1472
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1028
1473
|
}
|
|
1029
1474
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
diagnostics: string[];
|
|
1033
|
-
};
|
|
1034
|
-
|
|
1035
|
-
/** One capture attempt: the session data a single recorder wrote. */
|
|
1036
|
-
export type SessionCaptureSegment = {
|
|
1037
|
-
attemptId: string;
|
|
1038
|
-
binding: WorkflowSessionBinding | null;
|
|
1039
|
-
entries: WorkflowSessionEntryRecord[];
|
|
1040
|
-
events: WorkflowSessionEventRecord[];
|
|
1041
|
-
capture: WorkflowSessionCapture | null;
|
|
1042
|
-
integrity: SessionCaptureIntegrity;
|
|
1043
|
-
};
|
|
1044
|
-
|
|
1045
|
-
export type LoadedRunBundle = {
|
|
1046
|
-
runDir: string;
|
|
1047
|
-
manifest: WorkflowRunManifest;
|
|
1048
|
-
state: WorkflowRunState;
|
|
1049
|
-
snapshot: WorkflowDefinitionSnapshot | null;
|
|
1050
|
-
/** Full durable trace when loaded by the current reader. */
|
|
1051
|
-
traceEvents?: WorkflowTraceEvent[];
|
|
1052
|
-
sessionBinding: WorkflowSessionBinding | null;
|
|
1053
|
-
sessionEntries: WorkflowSessionEntryRecord[];
|
|
1054
|
-
sessionEvents: WorkflowSessionEventRecord[];
|
|
1055
|
-
sessionCapture: WorkflowSessionCapture | null;
|
|
1056
|
-
sessionIntegrity: SessionCaptureIntegrity;
|
|
1057
|
-
/** Per-attempt captures written after a handoff or resume. */
|
|
1058
|
-
sessionSegments: SessionCaptureSegment[];
|
|
1059
|
-
};
|
|
1060
|
-
|
|
1061
|
-
/** Read the final trace record without loading the rest of a run bundle. */
|
|
1062
|
-
export async function readLastTraceEvent(
|
|
1063
|
-
runDir: string,
|
|
1064
|
-
tracePath?: string,
|
|
1065
|
-
): Promise<WorkflowTraceEvent | null> {
|
|
1066
|
-
const events = await readNdjsonFile<WorkflowTraceEvent>(
|
|
1067
|
-
resolveBundlePath(runDir, tracePath, TRACE_PATH),
|
|
1068
|
-
);
|
|
1069
|
-
return events.records.at(-1) ?? null;
|
|
1475
|
+
function isRunRow(value: unknown): value is RunRow {
|
|
1476
|
+
return isRecord(value);
|
|
1070
1477
|
}
|
|
1071
1478
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
includeTrace?: boolean;
|
|
1075
|
-
};
|
|
1076
|
-
|
|
1077
|
-
/** Read a run bundle from disk. Returns null when the bundle is unreadable. */
|
|
1078
|
-
export async function readRunBundle(
|
|
1079
|
-
runDir: string,
|
|
1080
|
-
options: ReadRunBundleOptions = {},
|
|
1081
|
-
): Promise<LoadedRunBundle | null> {
|
|
1082
|
-
const manifest = await readJsonFile<WorkflowRunManifest>(path.join(runDir, MANIFEST_PATH));
|
|
1083
|
-
if (!manifest || manifest.schema !== RUN_BUNDLE_SCHEMA) {
|
|
1084
|
-
return null;
|
|
1085
|
-
}
|
|
1086
|
-
// A schema-tagged manifest may still be malformed (e.g. hand-edited);
|
|
1087
|
-
// treat anything unexpected as an unreadable bundle rather than throwing.
|
|
1088
|
-
const paths: Partial<WorkflowRunManifest["paths"]> =
|
|
1089
|
-
typeof manifest.paths === "object" && manifest.paths !== null ? manifest.paths : {};
|
|
1090
|
-
const state = await readJsonFile<WorkflowRunState>(
|
|
1091
|
-
resolveBundlePath(runDir, paths.state, STATE_PATH),
|
|
1092
|
-
);
|
|
1093
|
-
if (!state || state.schema !== RUN_STATE_SCHEMA) {
|
|
1094
|
-
return null;
|
|
1095
|
-
}
|
|
1096
|
-
const snapshot = await readJsonFile<WorkflowDefinitionSnapshot>(
|
|
1097
|
-
resolveBundlePath(runDir, paths.workflow, WORKFLOW_SNAPSHOT_PATH),
|
|
1098
|
-
);
|
|
1099
|
-
const trace =
|
|
1100
|
-
options.includeTrace === true
|
|
1101
|
-
? await readNdjsonFile<WorkflowTraceEvent>(resolveBundlePath(runDir, paths.trace, TRACE_PATH))
|
|
1102
|
-
: undefined;
|
|
1103
|
-
const sessionDir = resolveBundlePath(runDir, paths.session, SESSION_DIR);
|
|
1104
|
-
const sessionBinding = await readJsonFile<WorkflowSessionBinding>(
|
|
1105
|
-
path.join(sessionDir, "binding.json"),
|
|
1106
|
-
);
|
|
1107
|
-
const entries = await readNdjsonFile<WorkflowSessionEntryRecord>(
|
|
1108
|
-
path.join(sessionDir, "entries.ndjson"),
|
|
1109
|
-
);
|
|
1110
|
-
const events = await readNdjsonFile<WorkflowSessionEventRecord>(
|
|
1111
|
-
path.join(sessionDir, "events.ndjson"),
|
|
1112
|
-
);
|
|
1113
|
-
const sessionCapture = await readJsonFile<WorkflowSessionCapture>(
|
|
1114
|
-
path.join(sessionDir, "capture.json"),
|
|
1115
|
-
);
|
|
1116
|
-
const flatIntegrity = assessSessionIntegrity({
|
|
1117
|
-
binding: sessionBinding,
|
|
1118
|
-
entries,
|
|
1119
|
-
events,
|
|
1120
|
-
capture: sessionCapture,
|
|
1121
|
-
runTerminal: state.status !== "running",
|
|
1122
|
-
});
|
|
1123
|
-
const sessionSegments: SessionCaptureSegment[] = [];
|
|
1124
|
-
let segmentIds: string[] = [];
|
|
1125
|
-
try {
|
|
1126
|
-
segmentIds = (await fs.readdir(path.join(sessionDir, "segments"), { withFileTypes: true }))
|
|
1127
|
-
.filter((entry) => entry.isDirectory())
|
|
1128
|
-
.map((entry) => entry.name)
|
|
1129
|
-
.sort();
|
|
1130
|
-
} catch {
|
|
1131
|
-
// No segments directory means only the flat stream can exist.
|
|
1132
|
-
}
|
|
1133
|
-
for (const attemptId of segmentIds) {
|
|
1134
|
-
const segmentDir = path.join(sessionDir, "segments", attemptId);
|
|
1135
|
-
const binding = await readJsonFile<WorkflowSessionBinding>(
|
|
1136
|
-
path.join(segmentDir, "binding.json"),
|
|
1137
|
-
);
|
|
1138
|
-
const segmentEntries = await readNdjsonFile<WorkflowSessionEntryRecord>(
|
|
1139
|
-
path.join(segmentDir, "entries.ndjson"),
|
|
1140
|
-
);
|
|
1141
|
-
const segmentEvents = await readNdjsonFile<WorkflowSessionEventRecord>(
|
|
1142
|
-
path.join(segmentDir, "events.ndjson"),
|
|
1143
|
-
);
|
|
1144
|
-
const capture = await readJsonFile<WorkflowSessionCapture>(
|
|
1145
|
-
path.join(segmentDir, "capture.json"),
|
|
1146
|
-
);
|
|
1147
|
-
sessionSegments.push({
|
|
1148
|
-
attemptId,
|
|
1149
|
-
binding,
|
|
1150
|
-
entries: segmentEntries.records,
|
|
1151
|
-
events: segmentEvents.records,
|
|
1152
|
-
capture,
|
|
1153
|
-
integrity: assessSessionIntegrity({
|
|
1154
|
-
binding,
|
|
1155
|
-
entries: segmentEntries,
|
|
1156
|
-
events: segmentEvents,
|
|
1157
|
-
capture,
|
|
1158
|
-
runTerminal: state.status !== "running",
|
|
1159
|
-
}),
|
|
1160
|
-
});
|
|
1161
|
-
}
|
|
1162
|
-
// The headline integrity is the flat stream's when present; otherwise the
|
|
1163
|
-
// chronologically latest capture segment speaks for the run (segment ids
|
|
1164
|
-
// are random, so directory order says nothing about time).
|
|
1165
|
-
sessionSegments.sort((a, b) =>
|
|
1166
|
-
(a.binding?.boundAt ?? "").localeCompare(b.binding?.boundAt ?? ""),
|
|
1167
|
-
);
|
|
1168
|
-
const sessionIntegrity =
|
|
1169
|
-
flatIntegrity.status !== "unavailable" || sessionSegments.length === 0
|
|
1170
|
-
? flatIntegrity
|
|
1171
|
-
: (sessionSegments.at(-1)?.integrity ?? flatIntegrity);
|
|
1172
|
-
return {
|
|
1173
|
-
runDir,
|
|
1174
|
-
manifest,
|
|
1175
|
-
state,
|
|
1176
|
-
snapshot,
|
|
1177
|
-
...(trace !== undefined ? { traceEvents: trace.records } : {}),
|
|
1178
|
-
sessionBinding,
|
|
1179
|
-
sessionEntries: entries.records,
|
|
1180
|
-
sessionEvents: events.records,
|
|
1181
|
-
sessionCapture,
|
|
1182
|
-
sessionIntegrity,
|
|
1183
|
-
sessionSegments,
|
|
1184
|
-
};
|
|
1479
|
+
function isRunIdRow(value: unknown): value is { runId: string } {
|
|
1480
|
+
return isRecord(value);
|
|
1185
1481
|
}
|
|
1186
1482
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
* or escapes the bundle directory. Malformed manifests must degrade to an
|
|
1190
|
-
* unreadable bundle, never to a thrown error that aborts a listing.
|
|
1191
|
-
*/
|
|
1192
|
-
function resolveBundlePath(runDir: string, relative: unknown, fallback: string): string {
|
|
1193
|
-
const candidate = path.resolve(
|
|
1194
|
-
runDir,
|
|
1195
|
-
typeof relative === "string" && relative ? relative : fallback,
|
|
1196
|
-
);
|
|
1197
|
-
if (
|
|
1198
|
-
candidate !== path.resolve(runDir) &&
|
|
1199
|
-
!candidate.startsWith(path.resolve(runDir) + path.sep)
|
|
1200
|
-
) {
|
|
1201
|
-
return path.join(runDir, fallback);
|
|
1202
|
-
}
|
|
1203
|
-
return candidate;
|
|
1483
|
+
function isRevisionRow(value: unknown): value is { revision: number } {
|
|
1484
|
+
return isRecord(value);
|
|
1204
1485
|
}
|
|
1205
1486
|
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
let entries: string[];
|
|
1209
|
-
try {
|
|
1210
|
-
entries = await fs.readdir(outputRoot);
|
|
1211
|
-
} catch {
|
|
1212
|
-
return [];
|
|
1213
|
-
}
|
|
1214
|
-
const bundles: LoadedRunBundle[] = [];
|
|
1215
|
-
for (const entry of entries) {
|
|
1216
|
-
const bundle = await readRunBundle(path.join(outputRoot, entry), { includeTrace: false });
|
|
1217
|
-
if (bundle) {
|
|
1218
|
-
bundles.push(bundle);
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
bundles.sort((a, b) => b.state.startedAt.localeCompare(a.state.startedAt));
|
|
1222
|
-
return bundles;
|
|
1487
|
+
function isAttemptNumberRow(value: unknown): value is { attemptNumber: number } {
|
|
1488
|
+
return isRecord(value);
|
|
1223
1489
|
}
|
|
1224
1490
|
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
const raw = await fs.readFile(filePath, "utf8");
|
|
1228
|
-
return JSON.parse(raw) as T;
|
|
1229
|
-
} catch {
|
|
1230
|
-
return null;
|
|
1231
|
-
}
|
|
1491
|
+
function isUpdateSequenceRow(value: unknown): value is { updateSeq: number } {
|
|
1492
|
+
return isRecord(value);
|
|
1232
1493
|
}
|
|
1233
1494
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
tornTail: boolean;
|
|
1238
|
-
malformed: boolean;
|
|
1239
|
-
};
|
|
1495
|
+
function isEventRow(value: unknown): value is EventRow {
|
|
1496
|
+
return isRecord(value);
|
|
1497
|
+
}
|
|
1240
1498
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
try {
|
|
1244
|
-
raw = await fs.readFile(filePath, "utf8");
|
|
1245
|
-
} catch {
|
|
1246
|
-
return { records: [], exists: false, tornTail: false, malformed: false };
|
|
1247
|
-
}
|
|
1248
|
-
const tornTail = raw.length > 0 && !raw.endsWith("\n");
|
|
1249
|
-
const lines = raw.split("\n");
|
|
1250
|
-
if (tornTail) {
|
|
1251
|
-
lines.pop();
|
|
1252
|
-
}
|
|
1253
|
-
const records: T[] = [];
|
|
1254
|
-
let malformed = false;
|
|
1255
|
-
for (const line of lines) {
|
|
1256
|
-
if (line.trim().length === 0) {
|
|
1257
|
-
continue;
|
|
1258
|
-
}
|
|
1259
|
-
try {
|
|
1260
|
-
records.push(JSON.parse(line) as T);
|
|
1261
|
-
} catch {
|
|
1262
|
-
malformed = true;
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
return { records, exists: true, tornTail, malformed };
|
|
1499
|
+
function isSegmentRow(value: unknown): value is SegmentRow {
|
|
1500
|
+
return isRecord(value);
|
|
1266
1501
|
}
|
|
1267
1502
|
|
|
1268
|
-
function
|
|
1269
|
-
|
|
1270
|
-
entries: NdjsonRead<WorkflowSessionEntryRecord>;
|
|
1271
|
-
events: NdjsonRead<WorkflowSessionEventRecord>;
|
|
1272
|
-
capture: WorkflowSessionCapture | null;
|
|
1273
|
-
runTerminal: boolean;
|
|
1274
|
-
}): SessionCaptureIntegrity {
|
|
1275
|
-
const anySessionFile =
|
|
1276
|
-
input.binding !== null || input.entries.exists || input.events.exists || input.capture !== null;
|
|
1277
|
-
if (!anySessionFile) {
|
|
1278
|
-
return { status: "unavailable", diagnostics: [] };
|
|
1279
|
-
}
|
|
1280
|
-
const diagnostics: string[] = [];
|
|
1281
|
-
if (!input.binding || input.binding.schema !== SESSION_BINDING_SCHEMA) {
|
|
1282
|
-
diagnostics.push("missing or invalid session binding");
|
|
1283
|
-
}
|
|
1284
|
-
if (!input.capture) {
|
|
1285
|
-
diagnostics.push("missing session capture status");
|
|
1286
|
-
return { status: "invalid", diagnostics };
|
|
1287
|
-
}
|
|
1288
|
-
try {
|
|
1289
|
-
validateSessionCapture(input.capture);
|
|
1290
|
-
} catch (error) {
|
|
1291
|
-
diagnostics.push(failureMessageForDiagnostic(error));
|
|
1292
|
-
return { status: "invalid", diagnostics };
|
|
1293
|
-
}
|
|
1294
|
-
if (input.entries.malformed || input.events.malformed) {
|
|
1295
|
-
diagnostics.push("malformed NDJSON line before the journal tail");
|
|
1296
|
-
}
|
|
1297
|
-
if (input.events.tornTail && input.capture.status !== "recording") {
|
|
1298
|
-
diagnostics.push("terminal session event journal has a torn tail");
|
|
1299
|
-
}
|
|
1300
|
-
if (input.runTerminal && input.capture.status === "recording") {
|
|
1301
|
-
diagnostics.push("terminal run still reports recording capture");
|
|
1302
|
-
}
|
|
1303
|
-
let expected = 1;
|
|
1304
|
-
for (const event of input.events.records) {
|
|
1305
|
-
try {
|
|
1306
|
-
validateSessionEventRecord(event);
|
|
1307
|
-
} catch (error) {
|
|
1308
|
-
diagnostics.push(failureMessageForDiagnostic(error));
|
|
1309
|
-
break;
|
|
1310
|
-
}
|
|
1311
|
-
if (event.seq !== expected) {
|
|
1312
|
-
diagnostics.push(`session event sequence gap at ${expected}`);
|
|
1313
|
-
break;
|
|
1314
|
-
}
|
|
1315
|
-
expected += 1;
|
|
1316
|
-
}
|
|
1317
|
-
diagnostics.push(...sessionRelationshipDiagnostics(input.entries.records, input.events.records));
|
|
1318
|
-
if (input.capture.status !== "recording") {
|
|
1319
|
-
const lastEventSeq = input.events.records.at(-1)?.seq ?? 0;
|
|
1320
|
-
if (
|
|
1321
|
-
input.capture.eventCount !== input.events.records.length ||
|
|
1322
|
-
input.capture.entryCount !== input.entries.records.length ||
|
|
1323
|
-
input.capture.lastEventSeq !== lastEventSeq
|
|
1324
|
-
) {
|
|
1325
|
-
diagnostics.push("session capture counts do not match durable files");
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
if (diagnostics.length > 0) {
|
|
1329
|
-
return { status: "invalid", diagnostics };
|
|
1330
|
-
}
|
|
1331
|
-
if (input.capture.status === "failed") {
|
|
1332
|
-
return {
|
|
1333
|
-
status: "failed",
|
|
1334
|
-
diagnostics: [input.capture.failure?.message ?? "session capture failed"],
|
|
1335
|
-
};
|
|
1336
|
-
}
|
|
1337
|
-
return { status: input.capture.status, diagnostics: [] };
|
|
1503
|
+
function isSessionEntryRow(value: unknown): value is SessionEntryRow {
|
|
1504
|
+
return isRecord(value);
|
|
1338
1505
|
}
|
|
1339
1506
|
|
|
1340
|
-
function
|
|
1341
|
-
return
|
|
1507
|
+
function isSessionEventRow(value: unknown): value is SessionEventRow {
|
|
1508
|
+
return isRecord(value);
|
|
1342
1509
|
}
|
|
1343
1510
|
|
|
1344
|
-
function
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
...(state.runTitle !== undefined ? { runTitle: state.runTitle } : {}),
|
|
1353
|
-
...(state.workflowSource !== undefined ? { workflowSource: state.workflowSource } : {}),
|
|
1354
|
-
...(state.workflowSources !== undefined ? { workflowSources: state.workflowSources } : {}),
|
|
1355
|
-
...(state.definitionDigest !== undefined ? { definitionDigest: state.definitionDigest } : {}),
|
|
1356
|
-
startedAt: state.startedAt,
|
|
1357
|
-
...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
|
|
1358
|
-
status: state.status,
|
|
1359
|
-
traceSchema: TRACE_EVENT_SCHEMA,
|
|
1360
|
-
paths: {
|
|
1361
|
-
workflow: WORKFLOW_SNAPSHOT_PATH,
|
|
1362
|
-
state: STATE_PATH,
|
|
1363
|
-
trace: TRACE_PATH,
|
|
1364
|
-
...(present.session ? { session: SESSION_DIR } : {}),
|
|
1365
|
-
// Declare this before any payload can externalize a string. Live
|
|
1366
|
-
// session-event patches may reference a newly written artifact before
|
|
1367
|
-
// the next workflow state projection refreshes the manifest.
|
|
1368
|
-
artifacts: "artifacts",
|
|
1369
|
-
},
|
|
1370
|
-
};
|
|
1511
|
+
function isLeaseAuthorityRow(value: unknown): value is {
|
|
1512
|
+
generation: number;
|
|
1513
|
+
ownerType: OwnerType | null;
|
|
1514
|
+
ownerId: string | null;
|
|
1515
|
+
tokenHash: Buffer | null;
|
|
1516
|
+
expiresAt: number | null;
|
|
1517
|
+
} {
|
|
1518
|
+
return isRecord(value);
|
|
1371
1519
|
}
|
|
1372
1520
|
|
|
1373
1521
|
export function createDefinitionSnapshot(workflow: WorkflowDefinition): WorkflowDefinitionSnapshot {
|
|
@@ -1425,12 +1573,8 @@ function snapshotNode(
|
|
|
1425
1573
|
if (node.nodeType === "agent" && node.expectedOutput !== undefined) {
|
|
1426
1574
|
common.expectedOutput = node.expectedOutput;
|
|
1427
1575
|
}
|
|
1428
|
-
if (node.nodeType === "notify")
|
|
1429
|
-
|
|
1430
|
-
}
|
|
1431
|
-
if (node.nodeType === "checkpoint" && node.summary !== undefined) {
|
|
1432
|
-
common.summary = node.summary;
|
|
1433
|
-
}
|
|
1576
|
+
if (node.nodeType === "notify") common.summary = node.kind ?? "progress";
|
|
1577
|
+
if (node.nodeType === "checkpoint" && node.summary !== undefined) common.summary = node.summary;
|
|
1434
1578
|
if (node.nodeType === "checkpoint" && node.humanDecision !== undefined) {
|
|
1435
1579
|
common.humanDecision = {
|
|
1436
1580
|
audience:
|
|
@@ -1444,18 +1588,6 @@ function snapshotNode(
|
|
|
1444
1588
|
...(typeof node.humanDecision.onTimeout === "function" ? { dynamicTimeout: true } : {}),
|
|
1445
1589
|
};
|
|
1446
1590
|
}
|
|
1447
|
-
if (node.nodeType === "action")
|
|
1448
|
-
common.actionExecution = "exec" in node ? "shell" : "function";
|
|
1449
|
-
}
|
|
1591
|
+
if (node.nodeType === "action") common.actionExecution = "exec" in node ? "shell" : "function";
|
|
1450
1592
|
return common;
|
|
1451
1593
|
}
|
|
1452
|
-
|
|
1453
|
-
async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
|
|
1454
|
-
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1455
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
1456
|
-
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
1457
|
-
encoding: "utf8",
|
|
1458
|
-
mode: 0o600,
|
|
1459
|
-
});
|
|
1460
|
-
await fs.rename(tempPath, filePath);
|
|
1461
|
-
}
|