@osolmaz/pi-workflows 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -31
- package/dist/builtins/autodoc.workflow.d.ts +4 -4
- package/dist/builtins/autoimplement.workflow.d.ts +797 -142
- package/dist/builtins/autoimplement.workflow.js +17 -105
- 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/index.d.ts +4 -2
- package/dist/builtins/index.js +3 -1
- package/dist/builtins/index.js.map +1 -1
- package/dist/builtins/monitor.workflow.d.ts +56 -23
- package/dist/builtins/monitor.workflow.js +508 -329
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/pi-agent-group.d.ts +72 -0
- package/dist/builtins/pi-agent-group.js +1087 -0
- package/dist/builtins/pi-agent-group.js.map +1 -0
- 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-approval.workflow.d.ts +39 -5
- package/dist/builtins/plan-approval.workflow.js +92 -14
- package/dist/builtins/plan-approval.workflow.js.map +1 -1
- package/dist/builtins/plan-change.workflow.d.ts +449 -0
- package/dist/builtins/plan-change.workflow.js +256 -0
- package/dist/builtins/plan-change.workflow.js.map +1 -0
- package/dist/builtins/plan-presentation.js +2 -2
- package/dist/builtins/plan-presentation.js.map +1 -1
- package/dist/builtins/sanity-check.workflow.d.ts +50 -6
- package/dist/builtins/sanity-check.workflow.js +150 -28
- package/dist/builtins/sanity-check.workflow.js.map +1 -1
- 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 +4 -2
- package/dist/extension/decision-channels.js +333 -146
- package/dist/extension/decision-channels.js.map +1 -1
- package/dist/extension/executor.d.ts +9 -2
- package/dist/extension/executor.js +89 -2
- 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 +209 -127
- 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/session-events.d.ts +2 -2
- 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 +29 -3
- 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 +15 -10
- 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 +9 -23
- 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/catalog.js +7 -2
- package/dist/workflows/catalog.js.map +1 -1
- package/dist/workflows/composition.js +20 -2
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/decision-presentation.d.ts +1 -1
- package/dist/workflows/decision-presentation.js +51 -38
- package/dist/workflows/decision-presentation.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 +7 -7
- package/dist/workflows/engine.js +196 -115
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +16 -3
- package/dist/workflows/errors.js +18 -3
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +45 -8
- package/dist/workflows/human-decision.js +641 -274
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +5 -6
- package/dist/workflows/index.js +4 -5
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/progress.d.ts +1 -0
- package/dist/workflows/progress.js +15 -3
- package/dist/workflows/progress.js.map +1 -1
- package/dist/workflows/schema.js +27 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +83 -103
- package/dist/workflows/store.js +907 -951
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +88 -126
- 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 +36 -47
- package/docs/HUMAN_DECISION_PRESENTATIONS.md +14 -24
- package/docs/MONITOR.md +179 -237
- package/docs/SQLITE_STATE.md +219 -0
- package/docs/WORKFLOW_COMPOSITION.md +27 -26
- 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 +322 -0
- package/docs/plans/2026-08-21-sanity-check-plan.md +217 -95
- 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/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 +127 -53
- package/examples/workflows/approved-plan.workflow.ts +19 -46
- package/examples/workflows/plain-summary.workflow.ts +1 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +7 -7
- package/plugins/herdr/viewer.mjs +1 -11
- package/schemas/human-decision-accepted-v1.schema.json +15 -3
- package/schemas/human-decision-continuation-v1.schema.json +10 -1
- package/schemas/human-decision-delivery-v1.schema.json +8 -0
- package/schemas/human-decision-receipt-v1.schema.json +8 -0
- package/schemas/human-decision-request-v1.schema.json +24 -4
- package/skills/autoimplement/SKILL.md +27 -0
- package/skills/autoplan/SKILL.md +5 -4
- package/skills/monitor/SKILL.md +91 -135
- package/skills/pi-workflows/SKILL.md +9 -8
- package/skills/sanity-check/SKILL.md +44 -0
- package/src/builtins/autoimplement.workflow.ts +19 -118
- package/src/builtins/autoplan.workflow.ts +290 -40
- package/src/builtins/catalog.ts +7 -5
- package/src/builtins/index.ts +18 -1
- package/src/builtins/monitor.workflow.ts +673 -382
- package/src/builtins/pi-agent-group.ts +1407 -0
- package/src/builtins/plain-summary.workflow.ts +185 -0
- package/src/builtins/plan-approval.workflow.ts +157 -24
- package/src/builtins/plan-change.workflow.ts +321 -0
- package/src/builtins/plan-presentation.ts +2 -2
- package/src/builtins/sanity-check.workflow.ts +248 -48
- 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 +456 -203
- package/src/extension/executor.ts +105 -2
- package/src/extension/herdr-viewer.ts +0 -5
- package/src/extension/index.ts +251 -151
- package/src/extension/recorder.ts +13 -22
- package/src/extension/session-events.ts +2 -2
- package/src/extension/step-message.ts +106 -4
- package/src/extension/widget.ts +32 -5
- package/src/host/rpc-executor.ts +4 -0
- package/src/host/runner.ts +93 -41
- package/src/render/graph-render.ts +12 -6
- 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 +19 -30
- 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/catalog.ts +7 -2
- package/src/workflows/composition.ts +21 -3
- package/src/workflows/decision-presentation.ts +56 -43
- package/src/workflows/definition.ts +36 -3
- package/src/workflows/engine.ts +228 -130
- package/src/workflows/errors.ts +27 -3
- package/src/workflows/human-decision.ts +921 -349
- package/src/workflows/index.ts +20 -30
- package/src/workflows/progress.ts +18 -3
- package/src/workflows/schema.ts +36 -1
- package/src/workflows/store.ts +1300 -1163
- package/src/workflows/types.ts +100 -137
- package/dist/builtins/sanity-check-session.d.ts +0 -17
- package/dist/builtins/sanity-check-session.js +0 -168
- package/dist/builtins/sanity-check-session.js.map +0 -1
- 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/schemas/human-decision-accepted-v2.schema.json +0 -50
- package/schemas/human-decision-delivery-v2.schema.json +0 -36
- package/schemas/human-decision-receipt-v2.schema.json +0 -39
- package/schemas/human-decision-request-v2.schema.json +0 -69
- package/schemas/human-decision-resolution-v2.schema.json +0 -27
- package/src/builtins/sanity-check-session.ts +0 -205
- package/src/workflows/artifacts.ts +0 -188
- package/src/workflows/migrate-sources.ts +0 -178
package/dist/workflows/store.js
CHANGED
|
@@ -1,323 +1,189 @@
|
|
|
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 } from "../state/mutation.js";
|
|
6
5
|
import { compositionMetadata } from "./composition.js";
|
|
7
6
|
import { MAX_CURRENT_UPDATES, createUpdateId, updateProjection } from "./updates.js";
|
|
8
|
-
export const RUN_BUNDLE_SCHEMA = "pi-workflows.run-bundle.v1";
|
|
9
7
|
export const RUN_STATE_SCHEMA = "pi-workflows.run-state.v1";
|
|
10
|
-
export const TRACE_EVENT_SCHEMA = "pi-workflows.trace-event.v1";
|
|
11
8
|
export const DEFINITION_SNAPSHOT_SCHEMA = "pi-workflows.definition-snapshot.v1";
|
|
12
9
|
export const SESSION_BINDING_SCHEMA = "pi-workflows.session-binding.v1";
|
|
13
10
|
export const SESSION_EVENT_SCHEMA = "pi-workflows.session-event.v1";
|
|
14
11
|
export const SESSION_CAPTURE_SCHEMA = "pi-workflows.session-capture.v1";
|
|
15
12
|
export const SESSION_EVENT_MAX_BYTES = 1024 * 1024;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const STATE_PATH = "state.json";
|
|
19
|
-
const TRACE_PATH = "trace.ndjson";
|
|
20
|
-
const SESSION_DIR = "session";
|
|
21
|
-
const SESSION_BINDING_PATH = `${SESSION_DIR}/binding.json`;
|
|
22
|
-
const SESSION_ENTRIES_PATH = `${SESSION_DIR}/entries.ndjson`;
|
|
23
|
-
const SESSION_EVENTS_PATH = `${SESSION_DIR}/events.ndjson`;
|
|
24
|
-
const SESSION_CAPTURE_PATH = `${SESSION_DIR}/capture.json`;
|
|
25
|
-
const SESSION_SEGMENTS_DIR = `${SESSION_DIR}/segments`;
|
|
26
|
-
/** Capture file layout for one recorder attempt; "" is the legacy flat stream. */
|
|
27
|
-
function sessionStreamPaths(attemptId) {
|
|
28
|
-
if (attemptId === undefined) {
|
|
29
|
-
return {
|
|
30
|
-
dir: SESSION_DIR,
|
|
31
|
-
binding: SESSION_BINDING_PATH,
|
|
32
|
-
entries: SESSION_ENTRIES_PATH,
|
|
33
|
-
events: SESSION_EVENTS_PATH,
|
|
34
|
-
capture: SESSION_CAPTURE_PATH,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
assertValidRunId(attemptId);
|
|
38
|
-
const dir = `${SESSION_SEGMENTS_DIR}/${attemptId}`;
|
|
39
|
-
return {
|
|
40
|
-
dir,
|
|
41
|
-
binding: `${dir}/binding.json`,
|
|
42
|
-
entries: `${dir}/entries.ndjson`,
|
|
43
|
-
events: `${dir}/events.ndjson`,
|
|
44
|
-
capture: `${dir}/capture.json`,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
/** Runs directory: `$PI_WORKFLOWS_RUNS_DIR` or `~/.pi/agent/workflows/runs`. */
|
|
48
|
-
export function workflowRunsBaseDir(homeDir = os.homedir()) {
|
|
49
|
-
const override = process.env.PI_WORKFLOWS_RUNS_DIR;
|
|
50
|
-
if (override !== undefined && override.length > 0) {
|
|
51
|
-
return override;
|
|
52
|
-
}
|
|
53
|
-
return path.join(homeDir, ".pi", "agent", "workflows", "runs");
|
|
13
|
+
export function workflowStateDatabasePath(homeDir) {
|
|
14
|
+
return workflowStatePath(homeDir);
|
|
54
15
|
}
|
|
55
16
|
export function createRunId(workflowName, now = new Date()) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
.replaceAll(/[-:]/g, "")
|
|
59
|
-
.replace(/\.\d+Z$/, "Z");
|
|
60
|
-
const slug = workflowName
|
|
61
|
-
.toLowerCase()
|
|
62
|
-
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
63
|
-
.replaceAll(/(^-|-$)/g, "")
|
|
64
|
-
.slice(0, 40);
|
|
65
|
-
return `${stamp}-${slug || "workflow"}-${randomUUID().slice(0, 8)}`;
|
|
17
|
+
const safeName = workflowName.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80) || "workflow";
|
|
18
|
+
return `${now.toISOString().replace(/[-:.]/g, "").replace("T", "T").replace("Z", "Z")}-${safeName}-${randomUUID().slice(0, 8)}`;
|
|
66
19
|
}
|
|
67
20
|
export class WorkflowRunStore {
|
|
68
|
-
|
|
69
|
-
|
|
21
|
+
databasePath;
|
|
22
|
+
state;
|
|
23
|
+
authorityProvider;
|
|
70
24
|
contexts = new Map();
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
try {
|
|
95
|
-
await fs.lstat(path.join(runDir, MANIFEST_PATH));
|
|
96
|
-
throw new Error(`Reserved workflow run has an unreadable manifest: ${runId}`);
|
|
97
|
-
}
|
|
98
|
-
catch (error) {
|
|
99
|
-
if (!isMissingPath(error)) {
|
|
100
|
-
throw error;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const quarantineDir = path.join(this.outputRoot, `.${runId}.incomplete-${randomUUID().slice(0, 8)}`);
|
|
104
|
-
await fs.rename(runDir, quarantineDir);
|
|
105
|
-
this.contexts.delete(runDir);
|
|
106
|
-
return quarantineDir;
|
|
107
|
-
}
|
|
108
|
-
contextFor(runDir) {
|
|
109
|
-
let context = this.contexts.get(runDir);
|
|
110
|
-
if (!context) {
|
|
111
|
-
context = {
|
|
112
|
-
traceSeq: 0,
|
|
113
|
-
artifacts: new ArtifactWriter(runDir),
|
|
114
|
-
lock: Promise.resolve(),
|
|
115
|
-
streams: new Map(),
|
|
116
|
-
};
|
|
117
|
-
this.contexts.set(runDir, context);
|
|
118
|
-
}
|
|
119
|
-
return context;
|
|
120
|
-
}
|
|
121
|
-
streamFor(runDir, attemptId) {
|
|
122
|
-
const context = this.contextFor(runDir);
|
|
123
|
-
const key = attemptId ?? "";
|
|
124
|
-
let stream = context.streams.get(key);
|
|
125
|
-
if (!stream) {
|
|
126
|
-
stream = {
|
|
127
|
-
sessionSeq: 0,
|
|
128
|
-
sessionEventSeq: 0,
|
|
129
|
-
sessionBound: false,
|
|
130
|
-
sessionEventsStopped: false,
|
|
25
|
+
ownsState;
|
|
26
|
+
constructor(databasePath = workflowStatePath(), options = {}) {
|
|
27
|
+
this.authorityProvider = options.authorityProvider;
|
|
28
|
+
this.ownsState = options.state === undefined;
|
|
29
|
+
this.state =
|
|
30
|
+
options.state ??
|
|
31
|
+
new StateDatabase({
|
|
32
|
+
filePath: databasePath,
|
|
33
|
+
mode: options.readOnly === true ? "read-only" : "read-write",
|
|
34
|
+
checkLegacyState: databasePath === workflowStatePath(),
|
|
35
|
+
});
|
|
36
|
+
this.databasePath = this.state.filePath;
|
|
37
|
+
}
|
|
38
|
+
close() {
|
|
39
|
+
if (this.ownsState)
|
|
40
|
+
this.state.close();
|
|
41
|
+
}
|
|
42
|
+
async initializeRun(workflow, state) {
|
|
43
|
+
assertValidRunId(state.runId);
|
|
44
|
+
if (!this.contexts.has(state.runId)) {
|
|
45
|
+
const reserved = this.readRunRow(state.runId);
|
|
46
|
+
this.contexts.set(state.runId, {
|
|
47
|
+
revision: reserved === undefined ? 0 : this.requireResourceRevision(reserved.resourceId),
|
|
131
48
|
lock: Promise.resolve(),
|
|
132
|
-
};
|
|
133
|
-
context.streams.set(key, stream);
|
|
49
|
+
});
|
|
134
50
|
}
|
|
135
|
-
return
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
this.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
51
|
+
return await this.withRunLock(state.runId, async () => {
|
|
52
|
+
let acceptedRevision = 1;
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const at = new Date(now).toISOString();
|
|
55
|
+
const snapshot = createDefinitionSnapshot(workflow);
|
|
56
|
+
const definitionJson = canonicalJson(snapshot);
|
|
57
|
+
const definitionDigest = createHash("sha256").update(definitionJson).digest();
|
|
58
|
+
const source = sourceForState(state, definitionDigest);
|
|
59
|
+
const resourceId = resourceIdFor("run", state.runId);
|
|
60
|
+
this.state.transaction(() => {
|
|
61
|
+
const reserved = this.readRunRow(state.runId);
|
|
62
|
+
if (reserved !== undefined) {
|
|
63
|
+
if (reserved.status !== "queued") {
|
|
64
|
+
throw new Error(`Workflow run already exists: ${state.runId}`);
|
|
65
|
+
}
|
|
66
|
+
const context = this.contextFor(state.runId);
|
|
67
|
+
this.assertWriteAuthority(reserved, context.revision);
|
|
68
|
+
const storedSnapshot = this.readDefinition(reserved.definitionHash);
|
|
69
|
+
if (canonicalJson(storedSnapshot) !== definitionJson) {
|
|
70
|
+
throw new Error(`Reserved workflow definition conflicts: ${state.runId}`);
|
|
71
|
+
}
|
|
72
|
+
const revision = context.revision + 1;
|
|
73
|
+
acceptedRevision = revision;
|
|
74
|
+
state.traceSeq = revision;
|
|
75
|
+
state.updatedAt = at;
|
|
76
|
+
insertRunEvent(this.state, reserved.resourceId, revision, at, {
|
|
77
|
+
scope: "run",
|
|
78
|
+
type: "run_initialized",
|
|
79
|
+
payload: { workflowName: workflow.name },
|
|
80
|
+
});
|
|
81
|
+
this.persistRunState(reserved, state, revision, now);
|
|
82
|
+
this.syncNodeAttempts(state, snapshot, now);
|
|
83
|
+
context.revision = revision;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const definitionHash = this.state.putJson(snapshot, now);
|
|
87
|
+
this.state.connection
|
|
88
|
+
.prepare(`INSERT INTO workflow_definitions(
|
|
89
|
+
definition_digest, workflow_name, definition_hash, created_at
|
|
90
|
+
) VALUES (?, ?, ?, ?)
|
|
91
|
+
ON CONFLICT(definition_digest) DO NOTHING`)
|
|
92
|
+
.run(definitionDigest, workflow.name, definitionHash, now);
|
|
93
|
+
this.state.connection
|
|
94
|
+
.prepare(`INSERT INTO resources(
|
|
95
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
96
|
+
) VALUES (?, 'run', ?, 1, ?, ?)`)
|
|
97
|
+
.run(resourceId, state.runId, now, now);
|
|
98
|
+
this.state.connection
|
|
99
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
100
|
+
.run(resourceId);
|
|
101
|
+
state.traceSeq = 1;
|
|
102
|
+
state.updatedAt = at;
|
|
103
|
+
const inputHash = this.state.putJson(state.input, now);
|
|
104
|
+
const workflowSourceHash = this.state.putJson(state.workflowSource ?? source, now);
|
|
105
|
+
const launchOptionsHash = this.state.putJson({}, now);
|
|
106
|
+
const stateHash = this.state.putJson(state, now);
|
|
107
|
+
const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
|
|
108
|
+
this.state.connection
|
|
109
|
+
.prepare(`INSERT INTO runs(
|
|
110
|
+
run_id, resource_id, project_id, parent_run_id, definition_digest,
|
|
111
|
+
workflow_ref, workflow_source_hash, launch_options_hash,
|
|
112
|
+
source_type, source_ref, source_revision, title, status, paused,
|
|
113
|
+
status_detail, input_hash, output_hash, error_hash,
|
|
114
|
+
created_at, updated_at, finished_at
|
|
115
|
+
) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
116
|
+
.run(state.runId, resourceId, state.parentRunId ?? null, definitionDigest, state.workflowName, workflowSourceHash, launchOptionsHash, source.type, source.ref, source.revision, state.runTitle ?? null, state.status, state.paused === true ? 1 : 0, state.statusDetail ?? null, inputHash, stateHash, errorHash, Date.parse(state.startedAt), now, state.finishedAt === undefined ? null : Date.parse(state.finishedAt));
|
|
117
|
+
insertRunEvent(this.state, resourceId, 1, at, {
|
|
118
|
+
scope: "run",
|
|
119
|
+
type: "run_created",
|
|
120
|
+
payload: { workflowName: workflow.name },
|
|
121
|
+
});
|
|
122
|
+
this.syncNodeAttempts(state, snapshot, now);
|
|
123
|
+
});
|
|
124
|
+
this.contexts.set(state.runId, { revision: acceptedRevision, lock: Promise.resolve() });
|
|
125
|
+
return state.runId;
|
|
168
126
|
});
|
|
169
127
|
}
|
|
170
|
-
/**
|
|
171
|
-
* Prepare an interrupted bundle for resume. Repairs a torn trace tail,
|
|
172
|
-
* drops trace events the state projection never recorded, and seeds the
|
|
173
|
-
* in-process context so new events continue the sequence. The caller must
|
|
174
|
-
* hold the run's queue claim; the fence is verified before any write.
|
|
175
|
-
*/
|
|
176
128
|
async prepareRunResume(runId) {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
const bundle = await readRunBundle(runDir);
|
|
180
|
-
if (bundle === null) {
|
|
129
|
+
const loaded = this.readRun(runId, { includeTrace: true });
|
|
130
|
+
if (loaded === null)
|
|
181
131
|
throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
|
|
132
|
+
if (loaded.state.status !== "running") {
|
|
133
|
+
throw new Error(`Cannot resume workflow run ${runId} with status ${loaded.state.status}`);
|
|
182
134
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
sessionEventsStopped: captureFinished,
|
|
208
|
-
}),
|
|
135
|
+
const revision = this.resourceRevision(runId);
|
|
136
|
+
this.contexts.set(runId, { revision, lock: Promise.resolve() });
|
|
137
|
+
this.finalizeRecordingCaptures(runId, "Workflow host stopped before the run finished");
|
|
138
|
+
this.state.transaction(() => {
|
|
139
|
+
const row = this.requireRunRow(runId);
|
|
140
|
+
const context = this.contextFor(runId);
|
|
141
|
+
this.assertWriteAuthority(row, context.revision);
|
|
142
|
+
const nextRevision = context.revision + 1;
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
const at = new Date(now).toISOString();
|
|
145
|
+
this.state.connection
|
|
146
|
+
.prepare(`UPDATE node_attempts
|
|
147
|
+
SET status = 'cancelled', finished_at = ?, updated_at = ?
|
|
148
|
+
WHERE run_id = ? AND status IN ('pending', 'running', 'waiting')`)
|
|
149
|
+
.run(now, now, runId);
|
|
150
|
+
insertRunEvent(this.state, row.resourceId, nextRevision, at, {
|
|
151
|
+
scope: "run",
|
|
152
|
+
type: "run_resume_prepared",
|
|
153
|
+
payload: {},
|
|
154
|
+
});
|
|
155
|
+
loaded.state.traceSeq = nextRevision;
|
|
156
|
+
loaded.state.updatedAt = at;
|
|
157
|
+
this.persistRunState(row, loaded.state, nextRevision, now);
|
|
158
|
+
context.revision = nextRevision;
|
|
209
159
|
});
|
|
210
|
-
const prepared =
|
|
211
|
-
if (prepared === null)
|
|
212
|
-
throw new Error(`Workflow run ${runId}
|
|
213
|
-
}
|
|
160
|
+
const prepared = this.readRun(runId, { includeTrace: true });
|
|
161
|
+
if (prepared === null)
|
|
162
|
+
throw new Error(`Workflow run became unreadable: ${runId}`);
|
|
214
163
|
return prepared;
|
|
215
164
|
}
|
|
216
|
-
/**
|
|
217
|
-
* Finalize captures left "recording" by a session that is gone, so they
|
|
218
|
-
* report failed with the reason instead of dangling forever.
|
|
219
|
-
*/
|
|
220
|
-
async finalizeRecordingCaptures(runDir, reason, options = {}) {
|
|
221
|
-
if (options.skipFlat !== true) {
|
|
222
|
-
const flatCapture = await readJsonFile(path.join(runDir, SESSION_CAPTURE_PATH));
|
|
223
|
-
if (flatCapture?.status === "recording") {
|
|
224
|
-
const counts = await this.sessionCounts(runDir);
|
|
225
|
-
await this.writeSessionCapture(runDir, {
|
|
226
|
-
schema: SESSION_CAPTURE_SCHEMA,
|
|
227
|
-
eventSchema: SESSION_EVENT_SCHEMA,
|
|
228
|
-
status: "failed",
|
|
229
|
-
...counts,
|
|
230
|
-
failure: {
|
|
231
|
-
failedAt: new Date().toISOString(),
|
|
232
|
-
code: "host_interrupted",
|
|
233
|
-
message: reason,
|
|
234
|
-
},
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
for (const segmentId of await this.listSessionSegments(runDir)) {
|
|
239
|
-
const segmentCapture = await readJsonFile(path.join(runDir, sessionStreamPaths(segmentId).capture));
|
|
240
|
-
if (segmentCapture?.status !== "recording") {
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
const segmentCounts = await this.sessionCounts(runDir, segmentId);
|
|
244
|
-
await this.writeSessionCapture(runDir, {
|
|
245
|
-
schema: SESSION_CAPTURE_SCHEMA,
|
|
246
|
-
eventSchema: SESSION_EVENT_SCHEMA,
|
|
247
|
-
status: "failed",
|
|
248
|
-
...segmentCounts,
|
|
249
|
-
failure: {
|
|
250
|
-
failedAt: new Date().toISOString(),
|
|
251
|
-
code: "host_interrupted",
|
|
252
|
-
message: reason,
|
|
253
|
-
},
|
|
254
|
-
}, segmentId);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
/** Mark a nonterminal bundle failed and append an interruption event. */
|
|
258
165
|
async markRunInterrupted(runId, reason = "Workflow host stopped before the run finished") {
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
sessionBound,
|
|
276
|
-
sessionEventsStopped: captureFinished,
|
|
277
|
-
}),
|
|
166
|
+
const loaded = this.readRun(runId);
|
|
167
|
+
if (loaded === null || loaded.state.status !== "running")
|
|
168
|
+
return loaded;
|
|
169
|
+
this.finalizeRecordingCaptures(runId, reason);
|
|
170
|
+
loaded.state.status = "failed";
|
|
171
|
+
loaded.state.finishedAt = new Date().toISOString();
|
|
172
|
+
loaded.state.error = reason;
|
|
173
|
+
delete loaded.state.currentNode;
|
|
174
|
+
delete loaded.state.currentAttemptId;
|
|
175
|
+
delete loaded.state.currentNodeStartedAt;
|
|
176
|
+
delete loaded.state.statusDetail;
|
|
177
|
+
delete loaded.state.paused;
|
|
178
|
+
await this.writeSnapshot(runId, loaded.state, {
|
|
179
|
+
scope: "run",
|
|
180
|
+
type: "run_interrupted",
|
|
181
|
+
payload: { error: reason },
|
|
278
182
|
});
|
|
279
|
-
|
|
280
|
-
if (lastTraceEvent !== null && recoverTerminalProjection(state, lastTraceEvent)) {
|
|
281
|
-
await this.writeLoadedProjections(runDir, state);
|
|
282
|
-
return await readRunBundle(runDir);
|
|
283
|
-
}
|
|
284
|
-
if (sessionBound && !captureFinished) {
|
|
285
|
-
await this.writeSessionCapture(runDir, {
|
|
286
|
-
schema: SESSION_CAPTURE_SCHEMA,
|
|
287
|
-
eventSchema: SESSION_EVENT_SCHEMA,
|
|
288
|
-
status: "failed",
|
|
289
|
-
...counts,
|
|
290
|
-
failure: {
|
|
291
|
-
failedAt: new Date().toISOString(),
|
|
292
|
-
code: "host_interrupted",
|
|
293
|
-
message: reason,
|
|
294
|
-
},
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
await this.finalizeRecordingCaptures(runDir, reason, { skipFlat: true });
|
|
298
|
-
state.status = "failed";
|
|
299
|
-
state.finishedAt = new Date().toISOString();
|
|
300
|
-
state.error = reason;
|
|
301
|
-
delete state.currentNode;
|
|
302
|
-
delete state.currentAttemptId;
|
|
303
|
-
delete state.currentNodeStartedAt;
|
|
304
|
-
delete state.statusDetail;
|
|
305
|
-
delete state.paused;
|
|
306
|
-
await this.withRunLock(runDir, async () => {
|
|
307
|
-
const traceEvent = await this.appendTraceEvent(runDir, state.runId, {
|
|
308
|
-
scope: "run",
|
|
309
|
-
type: "run_interrupted",
|
|
310
|
-
payload: { error: reason },
|
|
311
|
-
});
|
|
312
|
-
state.traceSeq = traceEvent.seq;
|
|
313
|
-
state.updatedAt = traceEvent.at;
|
|
314
|
-
await this.writeLoadedProjections(runDir, state);
|
|
315
|
-
});
|
|
316
|
-
return await readRunBundle(runDir);
|
|
183
|
+
return this.readRun(runId, { includeTrace: true });
|
|
317
184
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
return await this.withRunLock(runDir, async () => {
|
|
185
|
+
async publishUpdate(runId, state, nodeId, attemptId, update, options = {}) {
|
|
186
|
+
return await this.withRunLock(runId, async () => {
|
|
321
187
|
if (options.signal?.aborted === true) {
|
|
322
188
|
throw options.signal.reason ?? new Error("workflow update attempt is no longer active");
|
|
323
189
|
}
|
|
@@ -325,416 +191,783 @@ export class WorkflowRunStore {
|
|
|
325
191
|
if (!exists && (state.updates?.length ?? 0) >= MAX_CURRENT_UPDATES) {
|
|
326
192
|
throw new Error(`workflow run supports at most ${MAX_CURRENT_UPDATES} current updates`);
|
|
327
193
|
}
|
|
194
|
+
const data = structuredClone(update.data);
|
|
328
195
|
const updateId = createUpdateId();
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
196
|
+
let acceptedEvent;
|
|
197
|
+
let acceptedRecord;
|
|
198
|
+
this.state.transaction(() => {
|
|
199
|
+
const context = this.contextFor(runId);
|
|
200
|
+
const run = this.requireRunRow(runId);
|
|
201
|
+
this.assertWriteAuthority(run, context.revision);
|
|
202
|
+
const revision = context.revision + 1;
|
|
203
|
+
const at = new Date().toISOString();
|
|
204
|
+
const event = {
|
|
205
|
+
seq: revision,
|
|
206
|
+
at,
|
|
207
|
+
runId,
|
|
208
|
+
scope: "node",
|
|
209
|
+
type: "update_published",
|
|
210
|
+
nodeId,
|
|
211
|
+
attemptId,
|
|
212
|
+
payload: { updateId, type: update.type, key: update.key, data },
|
|
213
|
+
};
|
|
214
|
+
const record = {
|
|
215
|
+
updateId,
|
|
216
|
+
seq: revision,
|
|
217
|
+
at,
|
|
218
|
+
runId,
|
|
219
|
+
nodeId,
|
|
220
|
+
attemptId,
|
|
221
|
+
type: update.type,
|
|
222
|
+
key: update.key,
|
|
223
|
+
data,
|
|
224
|
+
};
|
|
225
|
+
state.updates = updateProjection(state.updates, record);
|
|
226
|
+
state.traceSeq = revision;
|
|
227
|
+
state.updatedAt = at;
|
|
228
|
+
this.ensureAttempt(state, nodeId, attemptId, Date.now());
|
|
229
|
+
const dataHash = this.state.putJson(data);
|
|
230
|
+
const nextUpdateSeq = this.nextUpdateSequence(attemptId);
|
|
231
|
+
this.state.connection
|
|
232
|
+
.prepare(`INSERT INTO workflow_updates(
|
|
233
|
+
update_id, attempt_id, update_seq, update_type, update_key, data_hash, recorded_at
|
|
234
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
235
|
+
.run(updateId, attemptId, nextUpdateSeq, update.type, update.key, dataHash, Date.now());
|
|
236
|
+
insertRunEvent(this.state, run.resourceId, revision, at, event);
|
|
237
|
+
this.persistRunState(run, state, revision, Date.now());
|
|
238
|
+
context.revision = revision;
|
|
239
|
+
acceptedEvent = event;
|
|
240
|
+
acceptedRecord = record;
|
|
336
241
|
});
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
runId: state.runId,
|
|
342
|
-
nodeId,
|
|
343
|
-
attemptId,
|
|
344
|
-
type: update.type,
|
|
345
|
-
key: update.key,
|
|
346
|
-
data,
|
|
347
|
-
};
|
|
348
|
-
state.updates = updateProjection(state.updates, record);
|
|
349
|
-
state.traceSeq = event.seq;
|
|
350
|
-
state.updatedAt = event.at;
|
|
351
|
-
await this.writeProjections(runDir, state);
|
|
352
|
-
return { event, record };
|
|
242
|
+
if (acceptedEvent === undefined || acceptedRecord === undefined) {
|
|
243
|
+
throw new Error("Workflow update transaction did not produce a result");
|
|
244
|
+
}
|
|
245
|
+
return { event: acceptedEvent, record: acceptedRecord };
|
|
353
246
|
});
|
|
354
247
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
248
|
+
async writeSnapshot(runId, state, event) {
|
|
249
|
+
return await this.withRunLock(runId, async () => {
|
|
250
|
+
let accepted;
|
|
251
|
+
this.state.transaction(() => {
|
|
252
|
+
const context = this.contextFor(runId);
|
|
253
|
+
const run = this.requireRunRow(runId);
|
|
254
|
+
this.assertWriteAuthority(run, context.revision);
|
|
255
|
+
const revision = context.revision + 1;
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
const at = new Date(now).toISOString();
|
|
258
|
+
const traceEvent = { seq: revision, at, runId, ...event };
|
|
259
|
+
state.traceSeq = revision;
|
|
260
|
+
state.updatedAt = at;
|
|
261
|
+
insertRunEvent(this.state, run.resourceId, revision, at, traceEvent);
|
|
262
|
+
this.persistRunState(run, state, revision, now);
|
|
263
|
+
const snapshot = this.readDefinition(run.definitionHash);
|
|
264
|
+
this.syncNodeAttempts(state, snapshot, now);
|
|
265
|
+
context.revision = revision;
|
|
266
|
+
accepted = traceEvent;
|
|
267
|
+
});
|
|
268
|
+
if (accepted === undefined)
|
|
269
|
+
throw new Error("Workflow snapshot transaction did not commit");
|
|
270
|
+
return accepted;
|
|
366
271
|
});
|
|
367
272
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
*/
|
|
373
|
-
/** True when a session binding already exists for this bundle. */
|
|
374
|
-
async hasSessionBinding(runDir) {
|
|
375
|
-
try {
|
|
376
|
-
await fs.lstat(path.join(runDir, SESSION_BINDING_PATH));
|
|
377
|
-
return true;
|
|
378
|
-
}
|
|
379
|
-
catch {
|
|
380
|
-
return false;
|
|
381
|
-
}
|
|
273
|
+
async hasSessionBinding(runId) {
|
|
274
|
+
return (this.state.connection
|
|
275
|
+
.prepare("SELECT 1 AS present FROM session_segments WHERE run_id = ? LIMIT 1")
|
|
276
|
+
.get(runId) !== undefined);
|
|
382
277
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
withFileTypes: true,
|
|
388
|
-
});
|
|
389
|
-
return entries
|
|
390
|
-
.filter((entry) => entry.isDirectory())
|
|
391
|
-
.map((entry) => entry.name)
|
|
392
|
-
.sort();
|
|
393
|
-
}
|
|
394
|
-
catch {
|
|
395
|
-
return [];
|
|
396
|
-
}
|
|
278
|
+
async listSessionSegments(runId) {
|
|
279
|
+
return this.segmentRows(runId)
|
|
280
|
+
.flatMap((row) => (row.captureKey === null ? [] : [row.captureKey]))
|
|
281
|
+
.sort();
|
|
397
282
|
}
|
|
398
|
-
async writeSessionBinding(
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
283
|
+
async writeSessionBinding(runId, binding, attemptId) {
|
|
284
|
+
assertValidRunId(runId);
|
|
285
|
+
if (binding.runId !== runId || binding.schema !== SESSION_BINDING_SCHEMA) {
|
|
286
|
+
throw new Error("Session binding does not match the workflow run");
|
|
287
|
+
}
|
|
288
|
+
await this.withRunLock(runId, async () => {
|
|
289
|
+
const segmentId = segmentIdFor(runId, attemptId);
|
|
290
|
+
if (this.segmentRow(segmentId) !== undefined)
|
|
402
291
|
return;
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
292
|
+
this.state.transaction(() => {
|
|
293
|
+
const run = this.requireRunRow(runId);
|
|
294
|
+
const context = this.contextFor(runId);
|
|
295
|
+
this.assertWriteAuthority(run, context.revision);
|
|
296
|
+
const now = Date.parse(binding.boundAt);
|
|
297
|
+
const resourceId = resourceIdFor("session", segmentId);
|
|
298
|
+
const bindingHash = this.state.putJson(binding, now);
|
|
299
|
+
this.state.connection
|
|
300
|
+
.prepare(`INSERT INTO resources(
|
|
301
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
302
|
+
) VALUES (?, 'session', ?, 1, ?, ?)`)
|
|
303
|
+
.run(resourceId, segmentId, now, now);
|
|
304
|
+
this.state.connection
|
|
305
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
306
|
+
.run(resourceId);
|
|
307
|
+
this.state.connection
|
|
308
|
+
.prepare(`INSERT INTO session_segments(
|
|
309
|
+
segment_id, run_id, attempt_id, capture_key, session_id, resource_id, binding_hash,
|
|
310
|
+
status, entry_count, event_count, created_at
|
|
311
|
+
) VALUES (?, ?, NULL, ?, ?, ?, ?, 'recording', 0, 0, ?)`)
|
|
312
|
+
.run(segmentId, runId, attemptId ?? null, binding.piSessionId, resourceId, bindingHash, now);
|
|
313
|
+
insertGenericEvent(this.state, resourceId, 1, "session.created", "session", binding.piSessionId, bindingHash, now);
|
|
314
|
+
this.state.connection
|
|
315
|
+
.prepare(`INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
|
|
316
|
+
VALUES (?, ?, 'interactive', ?)
|
|
317
|
+
ON CONFLICT(run_id) DO NOTHING`)
|
|
318
|
+
.run(runId, binding.piSessionId, now);
|
|
319
|
+
const revision = context.revision + 1;
|
|
320
|
+
const at = new Date(now).toISOString();
|
|
321
|
+
insertRunEvent(this.state, run.resourceId, revision, at, {
|
|
322
|
+
seq: revision,
|
|
323
|
+
at,
|
|
324
|
+
runId,
|
|
325
|
+
scope: "session",
|
|
326
|
+
type: "session_bound",
|
|
327
|
+
payload: {
|
|
328
|
+
piSessionId: binding.piSessionId,
|
|
329
|
+
...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
const current = this.readState(run.stateHash);
|
|
333
|
+
current.traceSeq = revision;
|
|
334
|
+
current.updatedAt = at;
|
|
335
|
+
this.persistRunState(run, current, revision, now);
|
|
336
|
+
context.revision = revision;
|
|
415
337
|
});
|
|
416
338
|
});
|
|
417
339
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
return
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
340
|
+
async appendSessionEntry(runId, entry, attemptId) {
|
|
341
|
+
const segment = this.requireSegment(segmentIdFor(runId, attemptId));
|
|
342
|
+
return this.state.transaction(() => {
|
|
343
|
+
this.assertSessionWriteAuthority(runId);
|
|
344
|
+
const revision = this.requireResourceRevision(segmentResourceId(segment));
|
|
345
|
+
const sequence = segment.entryCount + 1;
|
|
346
|
+
const now = Date.now();
|
|
347
|
+
const entryHash = this.state.putJson(entry, now);
|
|
348
|
+
const entryId = typeof entry.id === "string" ? entry.id : `entry-${sequence}`;
|
|
349
|
+
this.state.connection
|
|
350
|
+
.prepare(`INSERT INTO session_entries(segment_id, entry_seq, entry_id, entry_hash, recorded_at)
|
|
351
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
352
|
+
.run(segment.segmentId, sequence, entryId, entryHash, now);
|
|
353
|
+
this.state.connection
|
|
354
|
+
.prepare("UPDATE session_segments SET entry_count = ? WHERE segment_id = ?")
|
|
355
|
+
.run(sequence, segment.segmentId);
|
|
356
|
+
this.bumpResource(segmentResourceId(segment), revision, now);
|
|
357
|
+
insertGenericEvent(this.state, segmentResourceId(segment), revision + 1, "session.entry_appended", "session", segment.sessionId, entryHash, now);
|
|
358
|
+
return sequence;
|
|
430
359
|
});
|
|
431
360
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (records.length === 0) {
|
|
361
|
+
async appendSessionEventBatch(runId, records, attemptId) {
|
|
362
|
+
if (records.length === 0)
|
|
435
363
|
return;
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
364
|
+
const segment = this.requireSegment(segmentIdFor(runId, attemptId));
|
|
365
|
+
this.state.transaction(() => {
|
|
366
|
+
this.assertSessionWriteAuthority(runId);
|
|
367
|
+
const current = this.requireSegment(segment.segmentId);
|
|
368
|
+
if (current.status !== "recording")
|
|
440
369
|
throw new Error("Session event capture has stopped");
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (record.seq !== expected) {
|
|
447
|
-
throw new Error(`Expected session event seq ${expected}, got ${record.seq}`);
|
|
448
|
-
}
|
|
449
|
-
expected += 1;
|
|
370
|
+
let expected = current.eventCount + 1;
|
|
371
|
+
for (const record of records) {
|
|
372
|
+
validateSessionEventRecord(record);
|
|
373
|
+
if (record.seq !== expected) {
|
|
374
|
+
throw new Error(`Expected session event seq ${expected}, got ${record.seq}`);
|
|
450
375
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
payload: record.type === "tool_execution_started" ||
|
|
454
|
-
record.type === "tool_execution_finished" ||
|
|
455
|
-
(record.type === "assistant_event" && record.payload.type === "toolcall_end")
|
|
456
|
-
? (await encodeValue(record.payload, this.contextFor(runDir).artifacts))
|
|
457
|
-
: record.payload,
|
|
458
|
-
})));
|
|
459
|
-
for (const record of encoded) {
|
|
460
|
-
if (Buffer.byteLength(JSON.stringify(record), "utf8") + 1 > SESSION_EVENT_MAX_BYTES) {
|
|
461
|
-
throw new Error(`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`);
|
|
462
|
-
}
|
|
376
|
+
if (Buffer.byteLength(canonicalJson(record), "utf8") > SESSION_EVENT_MAX_BYTES) {
|
|
377
|
+
throw new Error(`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`);
|
|
463
378
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
379
|
+
const payloadHash = this.state.putJson(record.payload, Date.parse(record.at));
|
|
380
|
+
this.state.connection
|
|
381
|
+
.prepare(`INSERT INTO session_events(
|
|
382
|
+
segment_id, event_seq, event_type, node_id, attempt_id,
|
|
383
|
+
turn_id, message_id, tool_call_id, payload_hash, recorded_at
|
|
384
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
385
|
+
.run(current.segmentId, record.seq, record.type, record.nodeId, record.attemptId, record.turnId ?? null, record.messageId ?? null, record.toolCallId ?? null, payloadHash, Date.parse(record.at));
|
|
386
|
+
expected += 1;
|
|
470
387
|
}
|
|
388
|
+
const now = Date.now();
|
|
389
|
+
this.state.connection
|
|
390
|
+
.prepare("UPDATE session_segments SET event_count = ? WHERE segment_id = ?")
|
|
391
|
+
.run(expected - 1, current.segmentId);
|
|
392
|
+
const resourceId = segmentResourceId(current);
|
|
393
|
+
const revision = this.requireResourceRevision(resourceId);
|
|
394
|
+
this.bumpResource(resourceId, revision, now);
|
|
395
|
+
const payloadHash = this.state.putJson({ count: records.length, lastSeq: expected - 1 }, now);
|
|
396
|
+
insertGenericEvent(this.state, resourceId, revision + 1, "session.events_appended", "session", current.sessionId, payloadHash, now);
|
|
471
397
|
});
|
|
472
398
|
}
|
|
473
|
-
|
|
474
|
-
async writeSessionCapture(runDir, capture, attemptId) {
|
|
399
|
+
async writeSessionCapture(runId, capture, attemptId) {
|
|
475
400
|
validateSessionCapture(capture);
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
401
|
+
const segment = this.requireSegment(segmentIdFor(runId, attemptId));
|
|
402
|
+
this.state.transaction(() => {
|
|
403
|
+
this.assertSessionWriteAuthority(runId);
|
|
404
|
+
const current = this.requireSegment(segment.segmentId);
|
|
405
|
+
if (current.status === "failed" && capture.status !== "failed")
|
|
406
|
+
return;
|
|
407
|
+
if (current.status === "complete" && capture.status !== "complete")
|
|
408
|
+
return;
|
|
409
|
+
const now = Date.now();
|
|
410
|
+
const failureHash = capture.failure === undefined ? null : this.state.putJson(capture.failure, now);
|
|
411
|
+
this.state.connection
|
|
412
|
+
.prepare(`UPDATE session_segments
|
|
413
|
+
SET status = ?, entry_count = ?, event_count = ?, failure_hash = ?, finished_at = ?
|
|
414
|
+
WHERE segment_id = ?`)
|
|
415
|
+
.run(capture.status, capture.entryCount, capture.eventCount, failureHash, capture.status === "recording" ? null : now, segment.segmentId);
|
|
416
|
+
const resourceId = segmentResourceId(segment);
|
|
417
|
+
const revision = this.requireResourceRevision(resourceId);
|
|
418
|
+
this.bumpResource(resourceId, revision, now);
|
|
419
|
+
const payloadHash = this.state.putJson(capture, now);
|
|
420
|
+
insertGenericEvent(this.state, resourceId, revision + 1, "session.capture_updated", "session", segment.sessionId, payloadHash, now);
|
|
482
421
|
});
|
|
483
422
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const paths = sessionStreamPaths(attemptId);
|
|
487
|
-
const events = await readCompleteNdjson(path.join(runDir, paths.events));
|
|
488
|
-
const entries = await readCompleteNdjson(path.join(runDir, paths.entries));
|
|
423
|
+
async sessionCounts(runId, attemptId) {
|
|
424
|
+
const segment = this.segmentRow(segmentIdFor(runId, attemptId));
|
|
489
425
|
return {
|
|
490
|
-
eventCount:
|
|
491
|
-
entryCount:
|
|
492
|
-
lastEventSeq:
|
|
426
|
+
eventCount: segment?.eventCount ?? 0,
|
|
427
|
+
entryCount: segment?.entryCount ?? 0,
|
|
428
|
+
lastEventSeq: segment?.eventCount ?? 0,
|
|
493
429
|
};
|
|
494
430
|
}
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
431
|
+
readRun(runId, options = {}) {
|
|
432
|
+
const row = this.readRunRow(runId);
|
|
433
|
+
if (row === undefined)
|
|
434
|
+
return null;
|
|
435
|
+
const state = this.readState(row.stateHash);
|
|
436
|
+
const snapshot = this.readDefinition(row.definitionHash);
|
|
437
|
+
const segments = this.segmentRows(runId).map((segment) => this.loadSegment(segment, state));
|
|
438
|
+
const flat = segments.find((segment) => segment.attemptId === "") ?? emptySegment();
|
|
439
|
+
return {
|
|
500
440
|
runId,
|
|
501
|
-
|
|
502
|
-
|
|
441
|
+
state,
|
|
442
|
+
snapshot,
|
|
443
|
+
...(options.includeTrace === true ? { traceEvents: this.traceEvents(row) } : {}),
|
|
444
|
+
sessionBinding: flat.binding,
|
|
445
|
+
sessionEntries: flat.entries,
|
|
446
|
+
sessionEvents: flat.events,
|
|
447
|
+
sessionCapture: flat.capture,
|
|
448
|
+
sessionIntegrity: flat.integrity,
|
|
449
|
+
sessionSegments: segments.filter((segment) => segment.attemptId !== ""),
|
|
503
450
|
};
|
|
504
|
-
await appendLine(path.join(runDir, TRACE_PATH), traceEvent);
|
|
505
|
-
context.traceSeq = traceEvent.seq;
|
|
506
|
-
return traceEvent;
|
|
507
|
-
}
|
|
508
|
-
async writeProjections(runDir, state) {
|
|
509
|
-
const context = this.contextFor(runDir);
|
|
510
|
-
const encoded = await encodeRunState(state, context.artifacts);
|
|
511
|
-
await writeJsonAtomic(path.join(runDir, STATE_PATH), encoded);
|
|
512
|
-
await writeJsonAtomic(path.join(runDir, MANIFEST_PATH), createManifest(state, {
|
|
513
|
-
session: [...context.streams.values()].some((stream) => stream.sessionBound),
|
|
514
|
-
}));
|
|
515
451
|
}
|
|
516
|
-
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
452
|
+
listRuns(options = {}) {
|
|
453
|
+
const rows = this.state.connection
|
|
454
|
+
.prepare("SELECT run_id AS runId FROM runs ORDER BY created_at DESC, run_id DESC")
|
|
455
|
+
.all();
|
|
456
|
+
const loaded = [];
|
|
457
|
+
for (const row of rows) {
|
|
458
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
459
|
+
if (!isRunIdRow(row))
|
|
460
|
+
continue;
|
|
461
|
+
const run = this.readRun(row.runId, options);
|
|
462
|
+
if (run !== null)
|
|
463
|
+
loaded.push(run);
|
|
464
|
+
}
|
|
465
|
+
return loaded;
|
|
466
|
+
}
|
|
467
|
+
readLastTraceEvent(runId) {
|
|
468
|
+
const run = this.readRunRow(runId);
|
|
469
|
+
if (run === undefined)
|
|
470
|
+
return null;
|
|
471
|
+
const last = this.traceEvents(run).at(-1);
|
|
472
|
+
/* istanbul ignore if -- every durable run has a creation event */
|
|
473
|
+
if (last === undefined)
|
|
474
|
+
throw new Error("Workflow run has no creation event");
|
|
475
|
+
return last;
|
|
476
|
+
}
|
|
477
|
+
contextFor(runId) {
|
|
478
|
+
let context = this.contexts.get(runId);
|
|
479
|
+
if (context === undefined) {
|
|
480
|
+
context = { revision: this.resourceRevision(runId), lock: Promise.resolve() };
|
|
481
|
+
this.contexts.set(runId, context);
|
|
482
|
+
}
|
|
483
|
+
return context;
|
|
522
484
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
485
|
+
async withRunLock(runId, operation) {
|
|
486
|
+
const context = this.contextFor(runId);
|
|
487
|
+
const prior = context.lock;
|
|
488
|
+
let release;
|
|
489
|
+
context.lock = new Promise((resolve) => {
|
|
490
|
+
release = resolve;
|
|
491
|
+
});
|
|
492
|
+
await prior;
|
|
493
|
+
try {
|
|
494
|
+
return await operation();
|
|
495
|
+
}
|
|
496
|
+
finally {
|
|
497
|
+
release?.();
|
|
498
|
+
}
|
|
536
499
|
}
|
|
537
|
-
|
|
538
|
-
|
|
500
|
+
assertSessionWriteAuthority(runId) {
|
|
501
|
+
const run = this.requireRunRow(runId);
|
|
502
|
+
this.assertWriteAuthority(run, this.contextFor(runId).revision);
|
|
503
|
+
}
|
|
504
|
+
resourceRevision(runId) {
|
|
505
|
+
const run = this.requireRunRow(runId);
|
|
506
|
+
return this.requireResourceRevision(run.resourceId);
|
|
507
|
+
}
|
|
508
|
+
requireResourceRevision(resourceId) {
|
|
509
|
+
const row = this.state.connection
|
|
510
|
+
.prepare("SELECT revision FROM resources WHERE resource_id = ?")
|
|
511
|
+
.get(resourceId);
|
|
512
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
513
|
+
if (!isRevisionRow(row))
|
|
514
|
+
throw new Error(`Resource is missing: ${resourceId}`);
|
|
515
|
+
return row.revision;
|
|
516
|
+
}
|
|
517
|
+
bumpResource(resourceId, expectedRevision, now) {
|
|
518
|
+
const result = this.state.connection
|
|
519
|
+
.prepare(`UPDATE resources SET revision = revision + 1, updated_at = ?
|
|
520
|
+
WHERE resource_id = ? AND revision = ?`)
|
|
521
|
+
.run(now, resourceId, expectedRevision);
|
|
522
|
+
if (result.changes !== 1)
|
|
523
|
+
throw new Error("Resource revision conflict");
|
|
524
|
+
}
|
|
525
|
+
assertWriteAuthority(run, expectedRevision) {
|
|
526
|
+
const actualRevision = this.requireResourceRevision(run.resourceId);
|
|
527
|
+
if (actualRevision !== expectedRevision) {
|
|
528
|
+
throw new Error(`Workflow run revision conflict: expected ${expectedRevision}, got ${actualRevision}`);
|
|
529
|
+
}
|
|
530
|
+
const lease = this.state.connection
|
|
531
|
+
.prepare(`SELECT generation, owner_type AS ownerType, owner_id AS ownerId,
|
|
532
|
+
token_hash AS tokenHash, expires_at AS expiresAt
|
|
533
|
+
FROM leases WHERE resource_id = ?`)
|
|
534
|
+
.get(run.resourceId);
|
|
535
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
536
|
+
if (!isLeaseAuthorityRow(lease))
|
|
537
|
+
throw new Error("Workflow run lease is missing");
|
|
538
|
+
if (lease.ownerId === null)
|
|
539
|
+
return;
|
|
540
|
+
const authority = this.authorityProvider?.(run.runId);
|
|
541
|
+
if (authority === undefined ||
|
|
542
|
+
authority.ownerType !== lease.ownerType ||
|
|
543
|
+
authority.ownerId !== lease.ownerId ||
|
|
544
|
+
authority.generation !== lease.generation ||
|
|
545
|
+
lease.tokenHash === null ||
|
|
546
|
+
!lease.tokenHash.equals(tokenHash(authority.token)) ||
|
|
547
|
+
lease.expiresAt === null ||
|
|
548
|
+
lease.expiresAt <= Date.now()) {
|
|
549
|
+
throw new Error("Workflow run write rejected because ownership changed");
|
|
550
|
+
}
|
|
539
551
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
552
|
+
persistRunState(run, state, expectedRevision, now) {
|
|
553
|
+
this.bumpResource(run.resourceId, expectedRevision - 1, now);
|
|
554
|
+
const stateHash = this.state.putJson(state, now);
|
|
555
|
+
const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
|
|
556
|
+
const update = this.state.connection
|
|
557
|
+
.prepare(`UPDATE runs
|
|
558
|
+
SET title = ?, status = ?, paused = ?, status_detail = ?, output_hash = ?,
|
|
559
|
+
error_hash = ?, updated_at = ?, finished_at = ?
|
|
560
|
+
WHERE run_id = ?`)
|
|
561
|
+
.run(state.runTitle ?? null, state.status, state.paused === true ? 1 : 0, state.statusDetail ?? null, stateHash, errorHash, now, state.finishedAt === undefined ? null : Date.parse(state.finishedAt), state.runId);
|
|
562
|
+
if (update.changes !== 1)
|
|
563
|
+
throw new Error(`Workflow run is missing: ${state.runId}`);
|
|
564
|
+
if (state.status !== "running") {
|
|
565
|
+
this.enqueueRunSettlementEffect(run.resourceId, expectedRevision, state, now);
|
|
566
|
+
}
|
|
567
|
+
run.stateHash = stateHash;
|
|
543
568
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
break;
|
|
569
|
+
enqueueRunSettlementEffect(runResourceId, sourceRevision, state, now) {
|
|
570
|
+
if (this.state.connection.prepare("SELECT 1 FROM run_queue WHERE run_id = ?").get(state.runId) ===
|
|
571
|
+
undefined) {
|
|
572
|
+
return;
|
|
549
573
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
574
|
+
const effectType = state.status === "waiting" ? "run.park_queue" : "run.settle_queue";
|
|
575
|
+
const idempotencyKey = `${state.runId}:${state.status}`;
|
|
576
|
+
const effectId = `effect-${createHash("sha256")
|
|
577
|
+
.update(`${runResourceId}\0${effectType}\0${idempotencyKey}`)
|
|
578
|
+
.digest("hex")
|
|
579
|
+
.slice(0, 40)}`;
|
|
580
|
+
if (this.state.connection.prepare("SELECT 1 FROM effects WHERE effect_id = ?").get(effectId) !==
|
|
581
|
+
undefined) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const effectResourceId = resourceIdFor("effect", effectId);
|
|
585
|
+
const payloadHash = this.state.putJson({ runId: state.runId, status: state.status }, now);
|
|
586
|
+
this.state.connection
|
|
587
|
+
.prepare(`INSERT INTO resources(
|
|
588
|
+
resource_id, resource_type, aggregate_key, revision, created_at, updated_at
|
|
589
|
+
) VALUES (?, 'effect', ?, 1, ?, ?)`)
|
|
590
|
+
.run(effectResourceId, effectId, now, now);
|
|
591
|
+
this.state.connection
|
|
592
|
+
.prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
|
|
593
|
+
.run(effectResourceId);
|
|
594
|
+
this.state.connection
|
|
595
|
+
.prepare(`INSERT INTO effects(
|
|
596
|
+
effect_id, resource_id, source_resource_id, source_revision,
|
|
597
|
+
effect_type, idempotency_key, payload_hash, owner_scope,
|
|
598
|
+
status, attempt_count, created_at, updated_at
|
|
599
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'run', 'pending', 0, ?, ?)`)
|
|
600
|
+
.run(effectId, effectResourceId, runResourceId, sourceRevision, effectType, idempotencyKey, payloadHash, now, now);
|
|
601
|
+
insertGenericEvent(this.state, effectResourceId, 1, "effect.created", "system", null, payloadHash, now);
|
|
602
|
+
}
|
|
603
|
+
syncNodeAttempts(state, snapshot, now) {
|
|
604
|
+
for (const step of state.steps.slice(state.carriedStepCount ?? 0)) {
|
|
605
|
+
const resultHash = this.state.putJson(step, now);
|
|
606
|
+
const outputHash = step.output === undefined ? null : this.state.putJson(step.output, now);
|
|
607
|
+
const errorHash = step.error === undefined ? null : this.state.putText(step.error, now);
|
|
608
|
+
const promptHash = step.prompt === null ? null : this.state.putJson(step.prompt, now);
|
|
609
|
+
const existing = this.state.connection
|
|
610
|
+
.prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
|
|
611
|
+
.get(step.attemptId);
|
|
612
|
+
if (existing === undefined) {
|
|
613
|
+
const attemptNumber = this.nextAttemptNumber(state.runId, step.nodeId);
|
|
614
|
+
this.state.connection
|
|
615
|
+
.prepare(`INSERT INTO node_attempts(
|
|
616
|
+
attempt_id, run_id, node_id, attempt_number, node_type, status,
|
|
617
|
+
presentation_hash, output_hash, result_hash, error_hash,
|
|
618
|
+
started_at, finished_at, created_at, updated_at
|
|
619
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
620
|
+
.run(step.attemptId, state.runId, step.nodeId, attemptNumber, step.nodeType, outcomeStatus(step.outcome), promptHash, outputHash, resultHash, errorHash, Date.parse(step.startedAt), Date.parse(step.finishedAt), Date.parse(step.startedAt), now);
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
this.state.connection
|
|
624
|
+
.prepare(`UPDATE node_attempts
|
|
625
|
+
SET status = ?, presentation_hash = ?, output_hash = ?, result_hash = ?,
|
|
626
|
+
error_hash = ?, finished_at = ?, updated_at = ?
|
|
627
|
+
WHERE attempt_id = ?`)
|
|
628
|
+
.run(outcomeStatus(step.outcome), promptHash, outputHash, resultHash, errorHash, Date.parse(step.finishedAt), now, step.attemptId);
|
|
554
629
|
}
|
|
555
|
-
good.push(line);
|
|
556
|
-
expectedSeq += 1;
|
|
557
630
|
}
|
|
558
|
-
|
|
559
|
-
|
|
631
|
+
if (state.currentAttemptId !== undefined && state.currentNode !== undefined) {
|
|
632
|
+
this.ensureAttempt(state, state.currentNode, state.currentAttemptId, now, snapshot);
|
|
560
633
|
}
|
|
561
634
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
635
|
+
ensureAttempt(state, nodeId, attemptId, now, snapshot) {
|
|
636
|
+
const existing = this.state.connection
|
|
637
|
+
.prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
|
|
638
|
+
.get(attemptId);
|
|
639
|
+
const status = state.status === "waiting" ? "waiting" : "running";
|
|
640
|
+
if (existing !== undefined) {
|
|
641
|
+
this.state.connection
|
|
642
|
+
.prepare("UPDATE node_attempts SET status = ?, updated_at = ? WHERE attempt_id = ?")
|
|
643
|
+
.run(status, now, attemptId);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const definition = snapshot ?? this.readDefinition(this.requireRunRow(state.runId).definitionHash);
|
|
647
|
+
const nodeType = definition.nodes[nodeId]?.nodeType ?? "agent";
|
|
648
|
+
this.state.connection
|
|
649
|
+
.prepare(`INSERT INTO node_attempts(
|
|
650
|
+
attempt_id, run_id, node_id, attempt_number, node_type, status,
|
|
651
|
+
started_at, created_at, updated_at
|
|
652
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
653
|
+
.run(attemptId, state.runId, nodeId, this.nextAttemptNumber(state.runId, nodeId), nodeType, status, state.currentNodeStartedAt === undefined ? now : Date.parse(state.currentNodeStartedAt), now, now);
|
|
654
|
+
}
|
|
655
|
+
nextAttemptNumber(runId, nodeId) {
|
|
656
|
+
const row = this.state.connection
|
|
657
|
+
.prepare(`SELECT COALESCE(MAX(attempt_number), 0) + 1 AS attemptNumber
|
|
658
|
+
FROM node_attempts WHERE run_id = ? AND node_id = ?`)
|
|
659
|
+
.get(runId, nodeId);
|
|
660
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
661
|
+
if (!isAttemptNumberRow(row))
|
|
662
|
+
throw new Error("Could not allocate node attempt number");
|
|
663
|
+
return row.attemptNumber;
|
|
664
|
+
}
|
|
665
|
+
nextUpdateSequence(attemptId) {
|
|
666
|
+
const row = this.state.connection
|
|
667
|
+
.prepare(`SELECT COALESCE(MAX(update_seq), 0) + 1 AS updateSeq
|
|
668
|
+
FROM workflow_updates WHERE attempt_id = ?`)
|
|
669
|
+
.get(attemptId);
|
|
670
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
671
|
+
if (!isUpdateSequenceRow(row))
|
|
672
|
+
throw new Error("Could not allocate workflow update sequence");
|
|
673
|
+
return row.updateSeq;
|
|
674
|
+
}
|
|
675
|
+
readRunRow(runId) {
|
|
676
|
+
const row = this.state.connection
|
|
677
|
+
.prepare(`SELECT r.run_id AS runId, r.resource_id AS resourceId,
|
|
678
|
+
r.output_hash AS stateHash, d.definition_hash AS definitionHash,
|
|
679
|
+
r.status
|
|
680
|
+
FROM runs r
|
|
681
|
+
JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
|
|
682
|
+
WHERE r.run_id = ?`)
|
|
683
|
+
.get(runId);
|
|
684
|
+
return isRunRow(row) ? row : undefined;
|
|
685
|
+
}
|
|
686
|
+
requireRunRow(runId) {
|
|
687
|
+
const row = this.readRunRow(runId);
|
|
688
|
+
if (row === undefined)
|
|
689
|
+
throw new Error(`Workflow run is missing: ${runId}`);
|
|
690
|
+
return row;
|
|
691
|
+
}
|
|
692
|
+
readState(hash) {
|
|
693
|
+
const value = this.state.readJson(hash);
|
|
694
|
+
if (!isRecord(value) || value.schema !== RUN_STATE_SCHEMA) {
|
|
695
|
+
throw new Error("Workflow run state has an incompatible schema");
|
|
696
|
+
}
|
|
697
|
+
return value;
|
|
698
|
+
}
|
|
699
|
+
readDefinition(hash) {
|
|
700
|
+
const value = this.state.readJson(hash);
|
|
701
|
+
if (!isRecord(value) || value.schema !== DEFINITION_SNAPSHOT_SCHEMA) {
|
|
702
|
+
throw new Error("Workflow definition snapshot has an incompatible schema");
|
|
703
|
+
}
|
|
704
|
+
return value;
|
|
705
|
+
}
|
|
706
|
+
traceEvents(run) {
|
|
707
|
+
const rows = this.state.connection
|
|
708
|
+
.prepare(`SELECT resource_revision AS resourceRevision, event_type AS eventType,
|
|
709
|
+
payload_hash AS payloadHash, recorded_at AS recordedAt
|
|
710
|
+
FROM events WHERE resource_id = ? ORDER BY resource_revision`)
|
|
711
|
+
.all(run.resourceId);
|
|
712
|
+
const events = [];
|
|
713
|
+
for (const row of rows) {
|
|
714
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
715
|
+
if (!isEventRow(row))
|
|
716
|
+
continue;
|
|
717
|
+
const payload = row.payloadHash === null ? {} : this.state.readJson(row.payloadHash);
|
|
718
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
719
|
+
if (!isRecord(payload))
|
|
720
|
+
throw new Error("Workflow trace payload is not an object");
|
|
721
|
+
events.push({
|
|
722
|
+
seq: row.resourceRevision,
|
|
723
|
+
at: new Date(row.recordedAt).toISOString(),
|
|
724
|
+
runId: run.runId,
|
|
725
|
+
scope: traceScope(payload.scope),
|
|
726
|
+
type: row.eventType,
|
|
727
|
+
...(typeof payload.nodeId === "string" ? { nodeId: payload.nodeId } : {}),
|
|
728
|
+
...(typeof payload.attemptId === "string" ? { attemptId: payload.attemptId } : {}),
|
|
729
|
+
payload: isRecord(payload.payload) ? payload.payload : {},
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
return events;
|
|
733
|
+
}
|
|
734
|
+
segmentRows(runId) {
|
|
735
|
+
const rows = this.state.connection
|
|
736
|
+
.prepare(`SELECT segment_id AS segmentId, run_id AS runId, attempt_id AS attemptId,
|
|
737
|
+
capture_key AS captureKey, session_id AS sessionId, binding_hash AS bindingHash, status,
|
|
738
|
+
entry_count AS entryCount, event_count AS eventCount,
|
|
739
|
+
failure_hash AS failureHash, created_at AS createdAt, finished_at AS finishedAt
|
|
740
|
+
FROM session_segments WHERE run_id = ? ORDER BY created_at, segment_id`)
|
|
741
|
+
.all(runId);
|
|
742
|
+
return rows.filter(isSegmentRow);
|
|
743
|
+
}
|
|
744
|
+
segmentRow(segmentId) {
|
|
745
|
+
const row = this.state.connection
|
|
746
|
+
.prepare(`SELECT segment_id AS segmentId, run_id AS runId, attempt_id AS attemptId,
|
|
747
|
+
capture_key AS captureKey, session_id AS sessionId, binding_hash AS bindingHash, status,
|
|
748
|
+
entry_count AS entryCount, event_count AS eventCount,
|
|
749
|
+
failure_hash AS failureHash, created_at AS createdAt, finished_at AS finishedAt
|
|
750
|
+
FROM session_segments WHERE segment_id = ?`)
|
|
751
|
+
.get(segmentId);
|
|
752
|
+
return isSegmentRow(row) ? row : undefined;
|
|
753
|
+
}
|
|
754
|
+
requireSegment(segmentId) {
|
|
755
|
+
const row = this.segmentRow(segmentId);
|
|
756
|
+
if (row === undefined)
|
|
757
|
+
throw new Error(`Session capture segment is missing: ${segmentId}`);
|
|
758
|
+
return row;
|
|
759
|
+
}
|
|
760
|
+
loadSegment(segment, runState) {
|
|
761
|
+
const binding = segment.bindingHash === null
|
|
762
|
+
? null
|
|
763
|
+
: this.state.readJson(segment.bindingHash);
|
|
764
|
+
const entries = this.state.connection
|
|
765
|
+
.prepare(`SELECT entry_seq AS entrySeq, entry_hash AS entryHash, recorded_at AS recordedAt
|
|
766
|
+
FROM session_entries WHERE segment_id = ? ORDER BY entry_seq`)
|
|
767
|
+
.all(segment.segmentId)
|
|
768
|
+
.filter(isSessionEntryRow)
|
|
769
|
+
.map((row) => ({
|
|
770
|
+
seq: row.entrySeq,
|
|
771
|
+
at: new Date(row.recordedAt).toISOString(),
|
|
772
|
+
entry: this.state.readJson(row.entryHash),
|
|
773
|
+
}));
|
|
774
|
+
const events = this.state.connection
|
|
775
|
+
.prepare(`SELECT event_seq AS eventSeq, event_type AS eventType, node_id AS nodeId,
|
|
776
|
+
attempt_id AS attemptId, turn_id AS turnId, message_id AS messageId,
|
|
777
|
+
tool_call_id AS toolCallId, payload_hash AS payloadHash,
|
|
778
|
+
recorded_at AS recordedAt
|
|
779
|
+
FROM session_events WHERE segment_id = ? ORDER BY event_seq`)
|
|
780
|
+
.all(segment.segmentId)
|
|
781
|
+
.filter(isSessionEventRow)
|
|
782
|
+
.map((row) => ({
|
|
783
|
+
seq: row.eventSeq,
|
|
784
|
+
at: new Date(row.recordedAt).toISOString(),
|
|
785
|
+
nodeId: row.nodeId,
|
|
786
|
+
attemptId: row.attemptId,
|
|
787
|
+
...(row.turnId === null ? {} : { turnId: row.turnId }),
|
|
788
|
+
...(row.messageId === null ? {} : { messageId: row.messageId }),
|
|
789
|
+
...(row.toolCallId === null ? {} : { toolCallId: row.toolCallId }),
|
|
790
|
+
type: row.eventType,
|
|
791
|
+
payload: this.state.readJson(row.payloadHash),
|
|
792
|
+
}));
|
|
793
|
+
const failure = segment.failureHash === null
|
|
794
|
+
? undefined
|
|
795
|
+
: this.state.readJson(segment.failureHash);
|
|
796
|
+
const capture = {
|
|
797
|
+
schema: SESSION_CAPTURE_SCHEMA,
|
|
798
|
+
eventSchema: SESSION_EVENT_SCHEMA,
|
|
799
|
+
status: segment.status,
|
|
800
|
+
eventCount: segment.eventCount,
|
|
801
|
+
entryCount: segment.entryCount,
|
|
802
|
+
lastEventSeq: segment.eventCount,
|
|
803
|
+
...(failure === undefined ? {} : { failure }),
|
|
804
|
+
};
|
|
805
|
+
const diagnostics = [];
|
|
806
|
+
if (entries.length !== segment.entryCount || events.length !== segment.eventCount) {
|
|
807
|
+
diagnostics.push("session capture counts do not match durable rows");
|
|
808
|
+
}
|
|
809
|
+
if (runState.status !== "running" && segment.status === "recording") {
|
|
810
|
+
diagnostics.push("terminal run still reports recording capture");
|
|
811
|
+
}
|
|
812
|
+
const integrity = diagnostics.length > 0
|
|
813
|
+
? { status: "invalid", diagnostics }
|
|
814
|
+
: segment.status === "failed"
|
|
815
|
+
? { status: "failed", diagnostics: [failure?.message ?? "session capture failed"] }
|
|
816
|
+
: { status: segment.status, diagnostics: [] };
|
|
817
|
+
return {
|
|
818
|
+
attemptId: segment.captureKey ?? "",
|
|
819
|
+
binding,
|
|
820
|
+
entries,
|
|
821
|
+
events,
|
|
822
|
+
capture,
|
|
823
|
+
integrity,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
finalizeRecordingCaptures(runId, reason) {
|
|
827
|
+
for (const segment of this.segmentRows(runId)) {
|
|
828
|
+
if (segment.status !== "recording")
|
|
829
|
+
continue;
|
|
830
|
+
const capture = {
|
|
831
|
+
schema: SESSION_CAPTURE_SCHEMA,
|
|
832
|
+
eventSchema: SESSION_EVENT_SCHEMA,
|
|
833
|
+
status: "failed",
|
|
834
|
+
eventCount: segment.eventCount,
|
|
835
|
+
entryCount: segment.entryCount,
|
|
836
|
+
lastEventSeq: segment.eventCount,
|
|
837
|
+
failure: {
|
|
838
|
+
failedAt: new Date().toISOString(),
|
|
839
|
+
code: "host_interrupted",
|
|
840
|
+
message: reason,
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
const now = Date.now();
|
|
844
|
+
this.state.transaction(() => {
|
|
845
|
+
this.assertSessionWriteAuthority(runId);
|
|
846
|
+
const failureHash = this.state.putJson(capture.failure, now);
|
|
847
|
+
this.state.connection
|
|
848
|
+
.prepare(`UPDATE session_segments
|
|
849
|
+
SET status = 'failed', failure_hash = ?, finished_at = ?
|
|
850
|
+
WHERE segment_id = ? AND status = 'recording'`)
|
|
851
|
+
.run(failureHash, now, segment.segmentId);
|
|
852
|
+
const resourceId = segmentResourceId(segment);
|
|
853
|
+
const revision = this.requireResourceRevision(resourceId);
|
|
854
|
+
this.bumpResource(resourceId, revision, now);
|
|
855
|
+
const payloadHash = this.state.putJson(capture, now);
|
|
856
|
+
insertGenericEvent(this.state, resourceId, revision + 1, "session.capture_failed", "system", null, payloadHash, now);
|
|
857
|
+
});
|
|
858
|
+
}
|
|
565
859
|
}
|
|
566
|
-
beforeWrite?.();
|
|
567
|
-
const tempPath = `${tracePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
568
|
-
await fs.writeFile(tempPath, kept.length === 0 ? "" : `${kept.join("\n")}\n`, {
|
|
569
|
-
encoding: "utf8",
|
|
570
|
-
mode: 0o600,
|
|
571
|
-
});
|
|
572
|
-
await fs.rename(tempPath, tracePath);
|
|
573
860
|
}
|
|
574
|
-
function
|
|
575
|
-
|
|
861
|
+
export function readWorkflowRun(runId, options = {}) {
|
|
862
|
+
const store = new WorkflowRunStore(options.databasePath ?? workflowStatePath(), {
|
|
863
|
+
readOnly: true,
|
|
864
|
+
});
|
|
865
|
+
try {
|
|
866
|
+
return store.readRun(runId, options);
|
|
867
|
+
}
|
|
868
|
+
finally {
|
|
869
|
+
store.close();
|
|
870
|
+
}
|
|
576
871
|
}
|
|
577
|
-
function
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
872
|
+
export function listWorkflowRuns(options = {}) {
|
|
873
|
+
const store = new WorkflowRunStore(options.databasePath ?? workflowStatePath(), {
|
|
874
|
+
readOnly: true,
|
|
875
|
+
});
|
|
876
|
+
try {
|
|
877
|
+
return store.listRuns(options);
|
|
581
878
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
state.updatedAt = event.at;
|
|
585
|
-
state.finishedAt = event.at;
|
|
586
|
-
if (typeof event.payload.error === "string") {
|
|
587
|
-
state.error = event.payload.error;
|
|
879
|
+
finally {
|
|
880
|
+
store.close();
|
|
588
881
|
}
|
|
589
|
-
|
|
590
|
-
|
|
882
|
+
}
|
|
883
|
+
export function readLastTraceEvent(runId, options = {}) {
|
|
884
|
+
const store = new WorkflowRunStore(options.databasePath ?? workflowStatePath(), {
|
|
885
|
+
readOnly: true,
|
|
886
|
+
});
|
|
887
|
+
try {
|
|
888
|
+
return store.readLastTraceEvent(runId);
|
|
591
889
|
}
|
|
592
|
-
|
|
593
|
-
|
|
890
|
+
finally {
|
|
891
|
+
store.close();
|
|
594
892
|
}
|
|
595
|
-
delete state.currentNode;
|
|
596
|
-
delete state.currentAttemptId;
|
|
597
|
-
delete state.currentNodeStartedAt;
|
|
598
|
-
return true;
|
|
599
893
|
}
|
|
600
|
-
function
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
894
|
+
function insertRunEvent(state, resourceId, revision, at, event) {
|
|
895
|
+
const payloadHash = state.putJson({
|
|
896
|
+
scope: event.scope,
|
|
897
|
+
...(event.nodeId === undefined ? {} : { nodeId: event.nodeId }),
|
|
898
|
+
...(event.attemptId === undefined ? {} : { attemptId: event.attemptId }),
|
|
899
|
+
payload: event.payload,
|
|
900
|
+
}, Date.parse(at));
|
|
901
|
+
insertGenericEvent(state, resourceId, revision, event.type, "system", null, payloadHash, Date.parse(at));
|
|
902
|
+
}
|
|
903
|
+
function insertGenericEvent(state, resourceId, revision, eventType, actorType, actorId, payloadHash, now) {
|
|
904
|
+
state.connection
|
|
905
|
+
.prepare(`INSERT INTO events(
|
|
906
|
+
event_id, resource_id, resource_revision, event_type,
|
|
907
|
+
actor_type, actor_id, payload_hash, recorded_at
|
|
908
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
909
|
+
.run(`event-${randomUUID()}`, resourceId, revision, eventType, actorType, actorId, payloadHash, now);
|
|
910
|
+
}
|
|
911
|
+
function sourceForState(state, definitionDigest) {
|
|
912
|
+
const source = state.workflowSource;
|
|
913
|
+
if (source?.kind === "builtin")
|
|
914
|
+
return { type: "builtin", ref: source.id, revision: source.revision };
|
|
915
|
+
if (source?.kind === "file")
|
|
916
|
+
return { type: "file", ref: source.path, revision: source.hash };
|
|
917
|
+
return {
|
|
918
|
+
type: "file",
|
|
919
|
+
ref: `inline:${state.workflowName}`,
|
|
920
|
+
revision: definitionDigest.toString("hex"),
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
function segmentIdFor(runId, attemptId) {
|
|
924
|
+
return `segment-${createHash("sha256")
|
|
925
|
+
.update(`${runId}\0${attemptId ?? ""}`)
|
|
926
|
+
.digest("hex")
|
|
927
|
+
.slice(0, 40)}`;
|
|
928
|
+
}
|
|
929
|
+
function segmentResourceId(segment) {
|
|
930
|
+
return resourceIdFor("session", segment.segmentId);
|
|
931
|
+
}
|
|
932
|
+
function outcomeStatus(outcome) {
|
|
933
|
+
switch (outcome) {
|
|
934
|
+
case "ok":
|
|
605
935
|
return "completed";
|
|
606
|
-
case "
|
|
607
|
-
case "run_interrupted":
|
|
608
|
-
return "failed";
|
|
609
|
-
case "run_timed_out":
|
|
936
|
+
case "timed_out":
|
|
610
937
|
return "timed_out";
|
|
611
|
-
case "
|
|
938
|
+
case "cancelled":
|
|
612
939
|
return "cancelled";
|
|
613
940
|
default:
|
|
614
|
-
return
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
function assertValidRunId(runId) {
|
|
618
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
|
|
619
|
-
throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
|
|
941
|
+
return "failed";
|
|
620
942
|
}
|
|
621
943
|
}
|
|
622
|
-
function
|
|
623
|
-
return
|
|
944
|
+
function traceScope(value) {
|
|
945
|
+
return value === "node" || value === "agent" || value === "action" || value === "session"
|
|
946
|
+
? value
|
|
947
|
+
: "run";
|
|
624
948
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const text = values.map((value) => `${JSON.stringify(value)}\n`).join("");
|
|
635
|
-
await fs.appendFile(filePath, text, { encoding: "utf8", mode: 0o600 });
|
|
636
|
-
}
|
|
637
|
-
function isNonEmptyString(value) {
|
|
638
|
-
return typeof value === "string" && value.length > 0;
|
|
949
|
+
function emptySegment() {
|
|
950
|
+
return {
|
|
951
|
+
attemptId: "",
|
|
952
|
+
binding: null,
|
|
953
|
+
entries: [],
|
|
954
|
+
events: [],
|
|
955
|
+
capture: null,
|
|
956
|
+
integrity: { status: "unavailable", diagnostics: [] },
|
|
957
|
+
};
|
|
639
958
|
}
|
|
640
959
|
function validateSessionEventRecord(record) {
|
|
641
960
|
if (!Number.isSafeInteger(record.seq) || record.seq < 1) {
|
|
642
961
|
throw new Error("Session event seq must be a positive safe integer");
|
|
643
962
|
}
|
|
644
|
-
if (
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
!isNonEmptyString(record.type) ||
|
|
963
|
+
if (record.at.length === 0 ||
|
|
964
|
+
record.nodeId.length === 0 ||
|
|
965
|
+
record.attemptId.length === 0 ||
|
|
648
966
|
typeof record.payload !== "object" ||
|
|
649
967
|
record.payload === null ||
|
|
650
968
|
Array.isArray(record.payload)) {
|
|
651
969
|
throw new Error("Session event is missing required envelope fields");
|
|
652
970
|
}
|
|
653
|
-
const knownType = [
|
|
654
|
-
"turn_started",
|
|
655
|
-
"turn_finished",
|
|
656
|
-
"message_started",
|
|
657
|
-
"assistant_event",
|
|
658
|
-
"message_finished",
|
|
659
|
-
"tool_execution_started",
|
|
660
|
-
"tool_execution_updated",
|
|
661
|
-
"tool_execution_finished",
|
|
662
|
-
].includes(record.type);
|
|
663
|
-
if (!knownType) {
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
if (!isNonEmptyString(record.turnId)) {
|
|
667
|
-
throw new Error(`${record.type} requires turnId`);
|
|
668
|
-
}
|
|
669
|
-
if (!["turn_started", "turn_finished"].includes(record.type) &&
|
|
670
|
-
!isNonEmptyString(record.messageId)) {
|
|
671
|
-
throw new Error(`${record.type} requires messageId`);
|
|
672
|
-
}
|
|
673
|
-
if (record.type.startsWith("tool_execution_") && !isNonEmptyString(record.toolCallId)) {
|
|
674
|
-
throw new Error(`${record.type} requires toolCallId`);
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
function sessionRelationshipDiagnostics(entries, events) {
|
|
678
|
-
const entryIds = new Set(entries.flatMap((record) => (isNonEmptyString(record.entry.id) ? [record.entry.id] : [])));
|
|
679
|
-
const turns = new Set();
|
|
680
|
-
const messages = new Set();
|
|
681
|
-
const tools = new Set();
|
|
682
|
-
const diagnostics = [];
|
|
683
|
-
for (const event of events) {
|
|
684
|
-
switch (event.type) {
|
|
685
|
-
case "turn_started":
|
|
686
|
-
if (event.turnId)
|
|
687
|
-
turns.add(event.turnId);
|
|
688
|
-
break;
|
|
689
|
-
case "turn_finished":
|
|
690
|
-
if (!event.turnId || !turns.has(event.turnId)) {
|
|
691
|
-
diagnostics.push(`turn_finished ${event.seq} precedes turn_started`);
|
|
692
|
-
}
|
|
693
|
-
break;
|
|
694
|
-
case "message_started":
|
|
695
|
-
if (!event.turnId || !turns.has(event.turnId)) {
|
|
696
|
-
diagnostics.push(`message_started ${event.seq} precedes turn_started`);
|
|
697
|
-
}
|
|
698
|
-
if (event.messageId)
|
|
699
|
-
messages.add(event.messageId);
|
|
700
|
-
break;
|
|
701
|
-
case "assistant_event":
|
|
702
|
-
if (!event.messageId || !messages.has(event.messageId)) {
|
|
703
|
-
diagnostics.push(`assistant_event ${event.seq} precedes message_started`);
|
|
704
|
-
}
|
|
705
|
-
break;
|
|
706
|
-
case "message_finished": {
|
|
707
|
-
if (!event.messageId || !messages.has(event.messageId)) {
|
|
708
|
-
diagnostics.push(`message_finished ${event.seq} precedes message_started`);
|
|
709
|
-
}
|
|
710
|
-
const settled = event.payload.settled;
|
|
711
|
-
const entryId = event.payload.entryId;
|
|
712
|
-
if (settled === true && (!isNonEmptyString(entryId) || !entryIds.has(entryId))) {
|
|
713
|
-
diagnostics.push(`message_finished ${event.seq} references a missing entry`);
|
|
714
|
-
}
|
|
715
|
-
else if (settled !== true && entryId !== undefined) {
|
|
716
|
-
diagnostics.push(`message_finished ${event.seq} has entryId while unsettled`);
|
|
717
|
-
}
|
|
718
|
-
break;
|
|
719
|
-
}
|
|
720
|
-
case "tool_execution_started":
|
|
721
|
-
if (!event.messageId || !messages.has(event.messageId)) {
|
|
722
|
-
diagnostics.push(`tool_execution_started ${event.seq} precedes message_started`);
|
|
723
|
-
}
|
|
724
|
-
if (event.toolCallId)
|
|
725
|
-
tools.add(event.toolCallId);
|
|
726
|
-
break;
|
|
727
|
-
case "tool_execution_updated":
|
|
728
|
-
case "tool_execution_finished":
|
|
729
|
-
if (!event.toolCallId || !tools.has(event.toolCallId)) {
|
|
730
|
-
diagnostics.push(`${event.type} ${event.seq} precedes tool_execution_started`);
|
|
731
|
-
}
|
|
732
|
-
break;
|
|
733
|
-
default:
|
|
734
|
-
break;
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
return diagnostics;
|
|
738
971
|
}
|
|
739
972
|
function validateSessionCapture(capture) {
|
|
740
973
|
if (capture.schema !== SESSION_CAPTURE_SCHEMA ||
|
|
@@ -755,313 +988,43 @@ function validateSessionCapture(capture) {
|
|
|
755
988
|
throw new Error("Only failed session capture may contain failure details");
|
|
756
989
|
}
|
|
757
990
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
raw = await fs.readFile(filePath, "utf8");
|
|
762
|
-
}
|
|
763
|
-
catch {
|
|
764
|
-
return [];
|
|
765
|
-
}
|
|
766
|
-
const lines = raw.split("\n");
|
|
767
|
-
if (!raw.endsWith("\n")) {
|
|
768
|
-
lines.pop();
|
|
769
|
-
}
|
|
770
|
-
const records = [];
|
|
771
|
-
for (const line of lines) {
|
|
772
|
-
if (line.trim().length === 0) {
|
|
773
|
-
continue;
|
|
774
|
-
}
|
|
775
|
-
try {
|
|
776
|
-
const value = JSON.parse(line);
|
|
777
|
-
if (!Number.isSafeInteger(value.seq) || value.seq < 1) {
|
|
778
|
-
break;
|
|
779
|
-
}
|
|
780
|
-
records.push({ seq: value.seq });
|
|
781
|
-
}
|
|
782
|
-
catch {
|
|
783
|
-
break;
|
|
784
|
-
}
|
|
991
|
+
function assertValidRunId(runId) {
|
|
992
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
|
|
993
|
+
throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
|
|
785
994
|
}
|
|
786
|
-
return records;
|
|
787
995
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
* in-memory state always holds raw values; the persisted copy may carry
|
|
791
|
-
* `$artifact` references instead of large strings.
|
|
792
|
-
*/
|
|
793
|
-
async function encodeRunState(state, artifacts) {
|
|
794
|
-
const results = Object.fromEntries(await Promise.all(Object.entries(state.results).map(async ([nodeId, result]) => [
|
|
795
|
-
nodeId,
|
|
796
|
-
"output" in result
|
|
797
|
-
? { ...result, output: await encodeValue(result.output, artifacts) }
|
|
798
|
-
: result,
|
|
799
|
-
])));
|
|
800
|
-
return {
|
|
801
|
-
...state,
|
|
802
|
-
input: await encodeValue(state.input, artifacts),
|
|
803
|
-
outputs: Object.fromEntries(await Promise.all(Object.entries(state.outputs).map(async ([nodeId, output]) => [
|
|
804
|
-
nodeId,
|
|
805
|
-
await encodeValue(output, artifacts),
|
|
806
|
-
]))),
|
|
807
|
-
results,
|
|
808
|
-
steps: await Promise.all(state.steps.map(async (step) => ({
|
|
809
|
-
...step,
|
|
810
|
-
prompt: (await encodeValue(step.prompt, artifacts)),
|
|
811
|
-
output: await encodeValue(step.output, artifacts),
|
|
812
|
-
}))),
|
|
813
|
-
...(state.finalOutput !== undefined
|
|
814
|
-
? { finalOutput: await encodeValue(state.finalOutput, artifacts) }
|
|
815
|
-
: {}),
|
|
816
|
-
};
|
|
996
|
+
function isRecord(value) {
|
|
997
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
817
998
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
const events = await readNdjsonFile(resolveBundlePath(runDir, tracePath, TRACE_PATH));
|
|
821
|
-
return events.records.at(-1) ?? null;
|
|
999
|
+
function isRunRow(value) {
|
|
1000
|
+
return isRecord(value);
|
|
822
1001
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
const manifest = await readJsonFile(path.join(runDir, MANIFEST_PATH));
|
|
826
|
-
if (!manifest || manifest.schema !== RUN_BUNDLE_SCHEMA) {
|
|
827
|
-
return null;
|
|
828
|
-
}
|
|
829
|
-
// A schema-tagged manifest may still be malformed (e.g. hand-edited);
|
|
830
|
-
// treat anything unexpected as an unreadable bundle rather than throwing.
|
|
831
|
-
const paths = typeof manifest.paths === "object" && manifest.paths !== null ? manifest.paths : {};
|
|
832
|
-
const state = await readJsonFile(resolveBundlePath(runDir, paths.state, STATE_PATH));
|
|
833
|
-
if (!state || state.schema !== RUN_STATE_SCHEMA) {
|
|
834
|
-
return null;
|
|
835
|
-
}
|
|
836
|
-
const snapshot = await readJsonFile(resolveBundlePath(runDir, paths.workflow, WORKFLOW_SNAPSHOT_PATH));
|
|
837
|
-
const trace = options.includeTrace === true
|
|
838
|
-
? await readNdjsonFile(resolveBundlePath(runDir, paths.trace, TRACE_PATH))
|
|
839
|
-
: undefined;
|
|
840
|
-
const sessionDir = resolveBundlePath(runDir, paths.session, SESSION_DIR);
|
|
841
|
-
const sessionBinding = await readJsonFile(path.join(sessionDir, "binding.json"));
|
|
842
|
-
const entries = await readNdjsonFile(path.join(sessionDir, "entries.ndjson"));
|
|
843
|
-
const events = await readNdjsonFile(path.join(sessionDir, "events.ndjson"));
|
|
844
|
-
const sessionCapture = await readJsonFile(path.join(sessionDir, "capture.json"));
|
|
845
|
-
const flatIntegrity = assessSessionIntegrity({
|
|
846
|
-
binding: sessionBinding,
|
|
847
|
-
entries,
|
|
848
|
-
events,
|
|
849
|
-
capture: sessionCapture,
|
|
850
|
-
runTerminal: state.status !== "running",
|
|
851
|
-
});
|
|
852
|
-
const sessionSegments = [];
|
|
853
|
-
let segmentIds = [];
|
|
854
|
-
try {
|
|
855
|
-
segmentIds = (await fs.readdir(path.join(sessionDir, "segments"), { withFileTypes: true }))
|
|
856
|
-
.filter((entry) => entry.isDirectory())
|
|
857
|
-
.map((entry) => entry.name)
|
|
858
|
-
.sort();
|
|
859
|
-
}
|
|
860
|
-
catch {
|
|
861
|
-
// No segments directory means only the flat stream can exist.
|
|
862
|
-
}
|
|
863
|
-
for (const attemptId of segmentIds) {
|
|
864
|
-
const segmentDir = path.join(sessionDir, "segments", attemptId);
|
|
865
|
-
const binding = await readJsonFile(path.join(segmentDir, "binding.json"));
|
|
866
|
-
const segmentEntries = await readNdjsonFile(path.join(segmentDir, "entries.ndjson"));
|
|
867
|
-
const segmentEvents = await readNdjsonFile(path.join(segmentDir, "events.ndjson"));
|
|
868
|
-
const capture = await readJsonFile(path.join(segmentDir, "capture.json"));
|
|
869
|
-
sessionSegments.push({
|
|
870
|
-
attemptId,
|
|
871
|
-
binding,
|
|
872
|
-
entries: segmentEntries.records,
|
|
873
|
-
events: segmentEvents.records,
|
|
874
|
-
capture,
|
|
875
|
-
integrity: assessSessionIntegrity({
|
|
876
|
-
binding,
|
|
877
|
-
entries: segmentEntries,
|
|
878
|
-
events: segmentEvents,
|
|
879
|
-
capture,
|
|
880
|
-
runTerminal: state.status !== "running",
|
|
881
|
-
}),
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
// The headline integrity is the flat stream's when present; otherwise the
|
|
885
|
-
// chronologically latest capture segment speaks for the run (segment ids
|
|
886
|
-
// are random, so directory order says nothing about time).
|
|
887
|
-
sessionSegments.sort((a, b) => (a.binding?.boundAt ?? "").localeCompare(b.binding?.boundAt ?? ""));
|
|
888
|
-
const sessionIntegrity = flatIntegrity.status !== "unavailable" || sessionSegments.length === 0
|
|
889
|
-
? flatIntegrity
|
|
890
|
-
: (sessionSegments.at(-1)?.integrity ?? flatIntegrity);
|
|
891
|
-
return {
|
|
892
|
-
runDir,
|
|
893
|
-
manifest,
|
|
894
|
-
state,
|
|
895
|
-
snapshot,
|
|
896
|
-
...(trace !== undefined ? { traceEvents: trace.records } : {}),
|
|
897
|
-
sessionBinding,
|
|
898
|
-
sessionEntries: entries.records,
|
|
899
|
-
sessionEvents: events.records,
|
|
900
|
-
sessionCapture,
|
|
901
|
-
sessionIntegrity,
|
|
902
|
-
sessionSegments,
|
|
903
|
-
};
|
|
1002
|
+
function isRunIdRow(value) {
|
|
1003
|
+
return isRecord(value);
|
|
904
1004
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
* or escapes the bundle directory. Malformed manifests must degrade to an
|
|
908
|
-
* unreadable bundle, never to a thrown error that aborts a listing.
|
|
909
|
-
*/
|
|
910
|
-
function resolveBundlePath(runDir, relative, fallback) {
|
|
911
|
-
const candidate = path.resolve(runDir, typeof relative === "string" && relative ? relative : fallback);
|
|
912
|
-
if (candidate !== path.resolve(runDir) &&
|
|
913
|
-
!candidate.startsWith(path.resolve(runDir) + path.sep)) {
|
|
914
|
-
return path.join(runDir, fallback);
|
|
915
|
-
}
|
|
916
|
-
return candidate;
|
|
1005
|
+
function isRevisionRow(value) {
|
|
1006
|
+
return isRecord(value);
|
|
917
1007
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
let entries;
|
|
921
|
-
try {
|
|
922
|
-
entries = await fs.readdir(outputRoot);
|
|
923
|
-
}
|
|
924
|
-
catch {
|
|
925
|
-
return [];
|
|
926
|
-
}
|
|
927
|
-
const bundles = [];
|
|
928
|
-
for (const entry of entries) {
|
|
929
|
-
const bundle = await readRunBundle(path.join(outputRoot, entry), { includeTrace: false });
|
|
930
|
-
if (bundle) {
|
|
931
|
-
bundles.push(bundle);
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
bundles.sort((a, b) => b.state.startedAt.localeCompare(a.state.startedAt));
|
|
935
|
-
return bundles;
|
|
1008
|
+
function isAttemptNumberRow(value) {
|
|
1009
|
+
return isRecord(value);
|
|
936
1010
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const raw = await fs.readFile(filePath, "utf8");
|
|
940
|
-
return JSON.parse(raw);
|
|
941
|
-
}
|
|
942
|
-
catch {
|
|
943
|
-
return null;
|
|
944
|
-
}
|
|
1011
|
+
function isUpdateSequenceRow(value) {
|
|
1012
|
+
return isRecord(value);
|
|
945
1013
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
try {
|
|
949
|
-
raw = await fs.readFile(filePath, "utf8");
|
|
950
|
-
}
|
|
951
|
-
catch {
|
|
952
|
-
return { records: [], exists: false, tornTail: false, malformed: false };
|
|
953
|
-
}
|
|
954
|
-
const tornTail = raw.length > 0 && !raw.endsWith("\n");
|
|
955
|
-
const lines = raw.split("\n");
|
|
956
|
-
if (tornTail) {
|
|
957
|
-
lines.pop();
|
|
958
|
-
}
|
|
959
|
-
const records = [];
|
|
960
|
-
let malformed = false;
|
|
961
|
-
for (const line of lines) {
|
|
962
|
-
if (line.trim().length === 0) {
|
|
963
|
-
continue;
|
|
964
|
-
}
|
|
965
|
-
try {
|
|
966
|
-
records.push(JSON.parse(line));
|
|
967
|
-
}
|
|
968
|
-
catch {
|
|
969
|
-
malformed = true;
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
return { records, exists: true, tornTail, malformed };
|
|
1014
|
+
function isEventRow(value) {
|
|
1015
|
+
return isRecord(value);
|
|
973
1016
|
}
|
|
974
|
-
function
|
|
975
|
-
|
|
976
|
-
if (!anySessionFile) {
|
|
977
|
-
return { status: "unavailable", diagnostics: [] };
|
|
978
|
-
}
|
|
979
|
-
const diagnostics = [];
|
|
980
|
-
if (!input.binding || input.binding.schema !== SESSION_BINDING_SCHEMA) {
|
|
981
|
-
diagnostics.push("missing or invalid session binding");
|
|
982
|
-
}
|
|
983
|
-
if (!input.capture) {
|
|
984
|
-
diagnostics.push("missing session capture status");
|
|
985
|
-
return { status: "invalid", diagnostics };
|
|
986
|
-
}
|
|
987
|
-
try {
|
|
988
|
-
validateSessionCapture(input.capture);
|
|
989
|
-
}
|
|
990
|
-
catch (error) {
|
|
991
|
-
diagnostics.push(failureMessageForDiagnostic(error));
|
|
992
|
-
return { status: "invalid", diagnostics };
|
|
993
|
-
}
|
|
994
|
-
if (input.entries.malformed || input.events.malformed) {
|
|
995
|
-
diagnostics.push("malformed NDJSON line before the journal tail");
|
|
996
|
-
}
|
|
997
|
-
if (input.events.tornTail && input.capture.status !== "recording") {
|
|
998
|
-
diagnostics.push("terminal session event journal has a torn tail");
|
|
999
|
-
}
|
|
1000
|
-
if (input.runTerminal && input.capture.status === "recording") {
|
|
1001
|
-
diagnostics.push("terminal run still reports recording capture");
|
|
1002
|
-
}
|
|
1003
|
-
let expected = 1;
|
|
1004
|
-
for (const event of input.events.records) {
|
|
1005
|
-
try {
|
|
1006
|
-
validateSessionEventRecord(event);
|
|
1007
|
-
}
|
|
1008
|
-
catch (error) {
|
|
1009
|
-
diagnostics.push(failureMessageForDiagnostic(error));
|
|
1010
|
-
break;
|
|
1011
|
-
}
|
|
1012
|
-
if (event.seq !== expected) {
|
|
1013
|
-
diagnostics.push(`session event sequence gap at ${expected}`);
|
|
1014
|
-
break;
|
|
1015
|
-
}
|
|
1016
|
-
expected += 1;
|
|
1017
|
-
}
|
|
1018
|
-
diagnostics.push(...sessionRelationshipDiagnostics(input.entries.records, input.events.records));
|
|
1019
|
-
if (input.capture.status !== "recording") {
|
|
1020
|
-
const lastEventSeq = input.events.records.at(-1)?.seq ?? 0;
|
|
1021
|
-
if (input.capture.eventCount !== input.events.records.length ||
|
|
1022
|
-
input.capture.entryCount !== input.entries.records.length ||
|
|
1023
|
-
input.capture.lastEventSeq !== lastEventSeq) {
|
|
1024
|
-
diagnostics.push("session capture counts do not match durable files");
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
if (diagnostics.length > 0) {
|
|
1028
|
-
return { status: "invalid", diagnostics };
|
|
1029
|
-
}
|
|
1030
|
-
if (input.capture.status === "failed") {
|
|
1031
|
-
return {
|
|
1032
|
-
status: "failed",
|
|
1033
|
-
diagnostics: [input.capture.failure?.message ?? "session capture failed"],
|
|
1034
|
-
};
|
|
1035
|
-
}
|
|
1036
|
-
return { status: input.capture.status, diagnostics: [] };
|
|
1017
|
+
function isSegmentRow(value) {
|
|
1018
|
+
return isRecord(value);
|
|
1037
1019
|
}
|
|
1038
|
-
function
|
|
1039
|
-
return
|
|
1020
|
+
function isSessionEntryRow(value) {
|
|
1021
|
+
return isRecord(value);
|
|
1040
1022
|
}
|
|
1041
|
-
function
|
|
1042
|
-
return
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
...(state.runTitle !== undefined ? { runTitle: state.runTitle } : {}),
|
|
1047
|
-
...(state.workflowSource !== undefined ? { workflowSource: state.workflowSource } : {}),
|
|
1048
|
-
...(state.workflowSources !== undefined ? { workflowSources: state.workflowSources } : {}),
|
|
1049
|
-
...(state.definitionDigest !== undefined ? { definitionDigest: state.definitionDigest } : {}),
|
|
1050
|
-
startedAt: state.startedAt,
|
|
1051
|
-
...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
|
|
1052
|
-
status: state.status,
|
|
1053
|
-
traceSchema: TRACE_EVENT_SCHEMA,
|
|
1054
|
-
paths: {
|
|
1055
|
-
workflow: WORKFLOW_SNAPSHOT_PATH,
|
|
1056
|
-
state: STATE_PATH,
|
|
1057
|
-
trace: TRACE_PATH,
|
|
1058
|
-
...(present.session ? { session: SESSION_DIR } : {}),
|
|
1059
|
-
// Declare this before any payload can externalize a string. Live
|
|
1060
|
-
// session-event patches may reference a newly written artifact before
|
|
1061
|
-
// the next workflow state projection refreshes the manifest.
|
|
1062
|
-
artifacts: "artifacts",
|
|
1063
|
-
},
|
|
1064
|
-
};
|
|
1023
|
+
function isSessionEventRow(value) {
|
|
1024
|
+
return isRecord(value);
|
|
1025
|
+
}
|
|
1026
|
+
function isLeaseAuthorityRow(value) {
|
|
1027
|
+
return isRecord(value);
|
|
1065
1028
|
}
|
|
1066
1029
|
export function createDefinitionSnapshot(workflow) {
|
|
1067
1030
|
const composition = compositionMetadata(workflow)?.snapshot;
|
|
@@ -1110,31 +1073,24 @@ function snapshotNode(workflow, nodeId, node) {
|
|
|
1110
1073
|
if (node.nodeType === "agent" && node.expectedOutput !== undefined) {
|
|
1111
1074
|
common.expectedOutput = node.expectedOutput;
|
|
1112
1075
|
}
|
|
1113
|
-
if (node.nodeType === "notify")
|
|
1076
|
+
if (node.nodeType === "notify")
|
|
1114
1077
|
common.summary = node.kind ?? "progress";
|
|
1115
|
-
|
|
1116
|
-
if (node.nodeType === "checkpoint" && node.summary !== undefined) {
|
|
1078
|
+
if (node.nodeType === "checkpoint" && node.summary !== undefined)
|
|
1117
1079
|
common.summary = node.summary;
|
|
1118
|
-
}
|
|
1119
1080
|
if (node.nodeType === "checkpoint" && node.humanDecision !== undefined) {
|
|
1120
1081
|
common.humanDecision = {
|
|
1121
1082
|
audience: typeof node.humanDecision.audience === "string" ? node.humanDecision.audience : "<dynamic>",
|
|
1122
1083
|
...(typeof node.humanDecision.audience === "function" ? { dynamicAudience: true } : {}),
|
|
1123
1084
|
choices: structuredClone(node.humanDecision.choices),
|
|
1085
|
+
...(node.humanDecision.onTimeout !== undefined &&
|
|
1086
|
+
typeof node.humanDecision.onTimeout !== "function"
|
|
1087
|
+
? { onTimeout: structuredClone(node.humanDecision.onTimeout) }
|
|
1088
|
+
: {}),
|
|
1089
|
+
...(typeof node.humanDecision.onTimeout === "function" ? { dynamicTimeout: true } : {}),
|
|
1124
1090
|
};
|
|
1125
1091
|
}
|
|
1126
|
-
if (node.nodeType === "action")
|
|
1092
|
+
if (node.nodeType === "action")
|
|
1127
1093
|
common.actionExecution = "exec" in node ? "shell" : "function";
|
|
1128
|
-
}
|
|
1129
1094
|
return common;
|
|
1130
1095
|
}
|
|
1131
|
-
async function writeJsonAtomic(filePath, value) {
|
|
1132
|
-
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1133
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
1134
|
-
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
1135
|
-
encoding: "utf8",
|
|
1136
|
-
mode: 0o600,
|
|
1137
|
-
});
|
|
1138
|
-
await fs.rename(tempPath, filePath);
|
|
1139
|
-
}
|
|
1140
1096
|
//# sourceMappingURL=store.js.map
|