@bastani/atomic 0.9.3-alpha.1 → 0.9.3-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/builtin/cursor/CHANGELOG.md +21 -0
- package/dist/builtin/cursor/README.md +2 -1
- package/dist/builtin/cursor/package.json +2 -2
- package/dist/builtin/cursor/src/cursor-models-raw.json +2 -9
- package/dist/builtin/cursor/src/model-mapper.ts +14 -3
- package/dist/builtin/cursor/src/proto/protobuf-codec-base64.ts +22 -0
- package/dist/builtin/cursor/src/proto/protobuf-codec-request.ts +53 -13
- package/dist/builtin/cursor/src/proto/protobuf-codec-wire.ts +24 -7
- package/dist/builtin/cursor/src/proto/protobuf-codec.ts +3 -2
- package/dist/builtin/cursor/src/stream.ts +5 -11
- package/dist/builtin/cursor/src/transport-types.ts +3 -0
- package/dist/builtin/cursor/src/transport.ts +1 -0
- package/dist/builtin/intercom/CHANGELOG.md +6 -0
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/mcp/CHANGELOG.md +6 -0
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/CHANGELOG.md +15 -0
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/subagents/src/extension/fanout-child.ts +1 -0
- package/dist/builtin/subagents/src/extension/index.ts +6 -3
- package/dist/builtin/subagents/src/extension/schemas.ts +0 -5
- package/dist/builtin/subagents/src/runs/background/async-job-tracker.ts +1 -4
- package/dist/builtin/subagents/src/runs/foreground/subagent-executor-single.ts +15 -1
- package/dist/builtin/subagents/src/runs/foreground/subagent-executor.ts +35 -1
- package/dist/builtin/subagents/src/runs/shared/subagent-prompt-runtime.ts +4 -2
- package/dist/builtin/subagents/src/shared/types-async.ts +1 -0
- package/dist/builtin/subagents/src/slash/prompt-template-bridge.ts +27 -5
- package/dist/builtin/subagents/src/tui/render-layout.ts +27 -4
- package/dist/builtin/subagents/src/tui/render-result-animation.ts +22 -31
- package/dist/builtin/subagents/src/tui/render-result-compact.ts +6 -6
- package/dist/builtin/subagents/src/tui/render-result.ts +20 -19
- package/dist/builtin/subagents/src/tui/render-status-progress.ts +3 -3
- package/dist/builtin/subagents/src/tui/render-widget.ts +46 -7
- package/dist/builtin/subagents/src/tui/render.ts +2 -2
- package/dist/builtin/web-access/CHANGELOG.md +6 -0
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/workflows/CHANGELOG.md +49 -0
- package/dist/builtin/workflows/README.md +1 -1
- package/dist/builtin/workflows/package.json +1 -1
- package/dist/builtin/workflows/src/authoring.d.ts +1 -1
- package/dist/builtin/workflows/src/durable/backend.ts +343 -0
- package/dist/builtin/workflows/src/durable/child-primitive.ts +79 -0
- package/dist/builtin/workflows/src/durable/dbos-backend.ts +421 -0
- package/dist/builtin/workflows/src/durable/dbos-envelope.ts +171 -0
- package/dist/builtin/workflows/src/durable/factory.ts +96 -0
- package/dist/builtin/workflows/src/durable/file-backend.ts +433 -0
- package/dist/builtin/workflows/src/durable/index.ts +73 -0
- package/dist/builtin/workflows/src/durable/resume-catalog.ts +217 -0
- package/dist/builtin/workflows/src/durable/resume-runtime.ts +299 -0
- package/dist/builtin/workflows/src/durable/scoped-backend.ts +171 -0
- package/dist/builtin/workflows/src/durable/stage-primitive.ts +284 -0
- package/dist/builtin/workflows/src/durable/tool-primitive.ts +180 -0
- package/dist/builtin/workflows/src/durable/types.ts +168 -0
- package/dist/builtin/workflows/src/durable/ui-primitive.ts +96 -0
- package/dist/builtin/workflows/src/engine/options.ts +3 -0
- package/dist/builtin/workflows/src/engine/primitives/parallel.ts +2 -2
- package/dist/builtin/workflows/src/engine/primitives/task.ts +4 -4
- package/dist/builtin/workflows/src/engine/primitives/ui.ts +22 -8
- package/dist/builtin/workflows/src/engine/primitives/workflow.ts +8 -0
- package/dist/builtin/workflows/src/engine/run-durable-finalize.ts +69 -0
- package/dist/builtin/workflows/src/engine/run-durable-stage-session.ts +31 -0
- package/dist/builtin/workflows/src/engine/run.ts +148 -6
- package/dist/builtin/workflows/src/engine/runtime.ts +8 -2
- package/dist/builtin/workflows/src/extension/extension-factory.ts +6 -12
- package/dist/builtin/workflows/src/extension/extension-lifecycle.ts +5 -1
- package/dist/builtin/workflows/src/extension/extension-runtime-state.ts +3 -0
- package/dist/builtin/workflows/src/extension/runtime.ts +48 -9
- package/dist/builtin/workflows/src/extension/workflow-run-control-command.ts +143 -4
- package/dist/builtin/workflows/src/runs/background/quit.ts +61 -0
- package/dist/builtin/workflows/src/runs/background/status.ts +1 -0
- package/dist/builtin/workflows/src/runs/foreground/executor-direct-helpers.ts +5 -5
- package/dist/builtin/workflows/src/runs/foreground/executor-stage-call.ts +74 -33
- package/dist/builtin/workflows/src/runs/foreground/executor-stage-context.ts +20 -1
- package/dist/builtin/workflows/src/runs/foreground/executor-stage-factory.ts +8 -7
- package/dist/builtin/workflows/src/runs/foreground/executor-stage-replay.ts +1 -0
- package/dist/builtin/workflows/src/runs/foreground/executor-stage-types.ts +1 -1
- package/dist/builtin/workflows/src/runs/foreground/executor-types.ts +19 -2
- package/dist/builtin/workflows/src/runs/foreground/stage-runner-context.ts +4 -0
- package/dist/builtin/workflows/src/runs/foreground/stage-runner-controller.ts +10 -10
- package/dist/builtin/workflows/src/runs/foreground/stage-runner-options.ts +5 -1
- package/dist/builtin/workflows/src/runs/foreground/stage-runner-send-user-message.ts +25 -0
- package/dist/builtin/workflows/src/runs/foreground/stage-runner-types.ts +3 -0
- package/dist/builtin/workflows/src/shared/authoring-contract-stage.d.ts +16 -0
- package/dist/builtin/workflows/src/shared/authoring-contract-stage.ts +20 -0
- package/dist/builtin/workflows/src/shared/authoring-contract-ui.d.ts +23 -1
- package/dist/builtin/workflows/src/shared/authoring-contract-ui.ts +30 -1
- package/dist/builtin/workflows/src/shared/store-public-types.ts +6 -2
- package/dist/builtin/workflows/src/shared/store-run-methods.ts +12 -6
- package/dist/builtin/workflows/src/shared/types.ts +55 -0
- package/dist/builtin/workflows/src/tui/graph-view-constants.ts +1 -1
- package/dist/builtin/workflows/src/tui/graph-view-graph-render.ts +41 -0
- package/dist/builtin/workflows/src/tui/graph-view-input.ts +82 -24
- package/dist/builtin/workflows/src/tui/graph-view-render.ts +7 -0
- package/dist/builtin/workflows/src/tui/graph-view-state.ts +22 -2
- package/dist/builtin/workflows/src/tui/graph-view-types.ts +4 -5
- package/dist/builtin/workflows/src/tui/overlay-adapter.ts +9 -11
- package/dist/builtin/workflows/src/tui/stage-chat-view-footer-status.ts +9 -3
- package/dist/builtin/workflows/src/tui/stage-chat-view-input.ts +11 -2
- package/dist/builtin/workflows/src/tui/stage-chat-view-live-events.ts +35 -0
- package/dist/builtin/workflows/src/tui/stage-chat-view-state.ts +51 -17
- package/dist/builtin/workflows/src/tui/stage-chat-view-status.ts +36 -0
- package/dist/builtin/workflows/src/tui/stage-chat-view-types.ts +5 -1
- package/dist/builtin/workflows/src/tui/stage-chat-view.ts +3 -1
- package/dist/builtin/workflows/src/tui/status-list.ts +14 -2
- package/dist/builtin/workflows/src/tui/widget.ts +23 -8
- package/dist/builtin/workflows/src/tui/workflow-attach-pane-types.ts +5 -4
- package/dist/builtin/workflows/src/tui/workflow-attach-pane.ts +8 -8
- package/dist/builtin/workflows/src/tui/workflow-resume-selector.ts +151 -0
- package/dist/core/extensions/loader-virtual-modules.d.ts.map +1 -1
- package/dist/core/extensions/loader-virtual-modules.js +47 -30
- package/dist/core/extensions/loader-virtual-modules.js.map +1 -1
- package/dist/core/messages.d.ts +1 -0
- package/dist/core/messages.d.ts.map +1 -1
- package/dist/core/messages.js +46 -1
- package/dist/core/messages.js.map +1 -1
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +12 -0
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-manager-core.d.ts +15 -7
- package/dist/core/session-manager-core.d.ts.map +1 -1
- package/dist/core/session-manager-core.js +20 -9
- package/dist/core/session-manager-core.js.map +1 -1
- package/dist/core/session-manager-entries.d.ts +2 -2
- package/dist/core/session-manager-entries.d.ts.map +1 -1
- package/dist/core/session-manager-entries.js +9 -3
- package/dist/core/session-manager-entries.js.map +1 -1
- package/dist/core/session-manager-history.d.ts.map +1 -1
- package/dist/core/session-manager-history.js +2 -1
- package/dist/core/session-manager-history.js.map +1 -1
- package/dist/core/session-manager-list.d.ts +3 -3
- package/dist/core/session-manager-list.d.ts.map +1 -1
- package/dist/core/session-manager-list.js +27 -8
- package/dist/core/session-manager-list.js.map +1 -1
- package/dist/core/session-manager-storage.d.ts +3 -1
- package/dist/core/session-manager-storage.d.ts.map +1 -1
- package/dist/core/session-manager-storage.js +55 -12
- package/dist/core/session-manager-storage.js.map +1 -1
- package/dist/core/session-manager-tool-dependencies.d.ts +10 -0
- package/dist/core/session-manager-tool-dependencies.d.ts.map +1 -0
- package/dist/core/session-manager-tool-dependencies.js +133 -0
- package/dist/core/session-manager-tool-dependencies.js.map +1 -0
- package/dist/core/session-manager-types.d.ts +22 -0
- package/dist/core/session-manager-types.d.ts.map +1 -1
- package/dist/core/session-manager-types.js.map +1 -1
- package/dist/core/session-manager.d.ts +2 -2
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +1 -1
- package/dist/core/session-manager.js.map +1 -1
- package/dist/modes/interactive/components/chat-session-host-runtime.d.ts +1 -0
- package/dist/modes/interactive/components/chat-session-host-runtime.d.ts.map +1 -1
- package/dist/modes/interactive/components/chat-session-host-runtime.js +12 -0
- package/dist/modes/interactive/components/chat-session-host-runtime.js.map +1 -1
- package/dist/modes/interactive/components/chat-session-host-terminal-cleanup.d.ts +4 -0
- package/dist/modes/interactive/components/chat-session-host-terminal-cleanup.d.ts.map +1 -0
- package/dist/modes/interactive/components/chat-session-host-terminal-cleanup.js +131 -0
- package/dist/modes/interactive/components/chat-session-host-terminal-cleanup.js.map +1 -0
- package/dist/modes/interactive/components/chat-session-host.d.ts +2 -0
- package/dist/modes/interactive/components/chat-session-host.d.ts.map +1 -1
- package/dist/modes/interactive/components/chat-session-host.js +7 -1
- package/dist/modes/interactive/components/chat-session-host.js.map +1 -1
- package/dist/modes/interactive/components/chat-transcript.d.ts.map +1 -1
- package/dist/modes/interactive/components/chat-transcript.js +15 -4
- package/dist/modes/interactive/components/chat-transcript.js.map +1 -1
- package/dist/modes/interactive/components/tool-execution.d.ts +3 -0
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +26 -0
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/docs/compaction.md +2 -0
- package/docs/models.md +1 -1
- package/docs/providers.md +2 -1
- package/docs/session-format.md +6 -0
- package/docs/sessions.md +6 -0
- package/docs/workflows.md +105 -3
- package/package.json +4 -3
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-session resume catalog.
|
|
3
|
+
*
|
|
4
|
+
* Builds the list of resumable workflows for the `/workflow resume` selector by
|
|
5
|
+
* scanning session JSONL files for `workflow.durable.checkpoint` entries. This
|
|
6
|
+
* is the session-file cache described by the issue — it lets a new session
|
|
7
|
+
* discover workflows started in prior sessions without requiring a live DBOS
|
|
8
|
+
* system database connection.
|
|
9
|
+
*
|
|
10
|
+
* The catalog reads session files lazily and caches results per scan. DBOS
|
|
11
|
+
* remains the checkpoint source of truth; this catalog provides the discovery
|
|
12
|
+
* index for the selector UI.
|
|
13
|
+
*
|
|
14
|
+
* cross-ref: issue #1498 — "/workflow resume should show a selector for
|
|
15
|
+
* resumable workflow sessions, analogous to the /resume session selector."
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import type { DurableCheckpointEntry, DurableWorkflowStatus, ResumableWorkflowEntry } from "./types.js";
|
|
21
|
+
import type { DurableWorkflowBackend } from "./backend.js";
|
|
22
|
+
import type { WorkflowSerializableValue } from "../shared/types.js";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Session file scanning
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Scan a session directory for `workflow.durable.checkpoint` entries and build
|
|
30
|
+
* a list of resumable workflows.
|
|
31
|
+
*
|
|
32
|
+
* @param sessionDir Directory containing session JSONL files.
|
|
33
|
+
* @returns Resumable workflow entries, most recently updated first.
|
|
34
|
+
*/
|
|
35
|
+
export function scanResumableWorkflows(sessionDir: string): readonly ResumableWorkflowEntry[] {
|
|
36
|
+
if (!existsSync(sessionDir)) return [];
|
|
37
|
+
const entries = new Map<string, ResumableWorkflowEntry>();
|
|
38
|
+
let files: string[];
|
|
39
|
+
try {
|
|
40
|
+
files = readdirSync(sessionDir).filter((f) => f.endsWith(".jsonl"));
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
const filePath = join(sessionDir, file);
|
|
46
|
+
const fileEntries = readDurableEntriesFromFile(filePath);
|
|
47
|
+
const sessionFile = filePath;
|
|
48
|
+
for (const entry of fileEntries) {
|
|
49
|
+
const existing = entries.get(entry.workflowId);
|
|
50
|
+
if (!existing || entry.ts > existing.updatedAt) {
|
|
51
|
+
entries.set(entry.workflowId, entryToResumable(entry, sessionFile));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return [...entries.values()].filter(isResumableEntry).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readDurableEntriesFromFile(filePath: string): readonly DurableCheckpointEntry[] {
|
|
59
|
+
let content: string;
|
|
60
|
+
try {
|
|
61
|
+
content = readFileSync(filePath, "utf-8");
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const results: DurableCheckpointEntry[] = [];
|
|
66
|
+
for (const line of content.split("\n")) {
|
|
67
|
+
const trimmed = line.trim();
|
|
68
|
+
if (!trimmed) continue;
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
71
|
+
const rawEntry = durablePayloadFromJsonlEntry(parsed);
|
|
72
|
+
if (rawEntry !== undefined) {
|
|
73
|
+
const entry = parseDurableEntry(rawEntry);
|
|
74
|
+
if (entry) results.push(entry);
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Skip malformed lines.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return results;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function durablePayloadFromJsonlEntry(parsed: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
84
|
+
if (parsed["type"] === "workflow.durable.checkpoint") return parsed;
|
|
85
|
+
if (parsed["type"] !== "custom" || parsed["customType"] !== "workflow.durable.checkpoint") return undefined;
|
|
86
|
+
const data = parsed["data"];
|
|
87
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined;
|
|
88
|
+
return data as Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseDurableEntry(raw: Record<string, unknown>): DurableCheckpointEntry | undefined {
|
|
92
|
+
const workflowId = raw["workflowId"];
|
|
93
|
+
const name = raw["name"];
|
|
94
|
+
const inputs = raw["inputs"];
|
|
95
|
+
const status = raw["status"];
|
|
96
|
+
const ts = raw["ts"];
|
|
97
|
+
if (typeof workflowId !== "string" || typeof name !== "string" || typeof status !== "string" || typeof ts !== "number") return undefined;
|
|
98
|
+
if (!isWorkflowSerializableObject(inputs)) return undefined;
|
|
99
|
+
return {
|
|
100
|
+
type: "workflow.durable.checkpoint",
|
|
101
|
+
workflowId,
|
|
102
|
+
name,
|
|
103
|
+
inputs,
|
|
104
|
+
status: status as DurableWorkflowStatus,
|
|
105
|
+
completedCheckpoints: typeof raw["completedCheckpoints"] === "number" ? raw["completedCheckpoints"] : 0,
|
|
106
|
+
pendingPrompts: typeof raw["pendingPrompts"] === "number" ? raw["pendingPrompts"] : 0,
|
|
107
|
+
...(typeof raw["label"] === "string" ? { label: raw["label"] } : {}),
|
|
108
|
+
...(typeof raw["rootWorkflowId"] === "string" ? { rootWorkflowId: raw["rootWorkflowId"] } : {}),
|
|
109
|
+
...(typeof raw["resumable"] === "boolean" ? { resumable: raw["resumable"] } : {}),
|
|
110
|
+
ts,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isWorkflowSerializableObject(value: unknown): value is Readonly<Record<string, WorkflowSerializableValue>> {
|
|
115
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
116
|
+
const obj = value as Record<string, unknown>;
|
|
117
|
+
for (const key of Object.keys(obj)) {
|
|
118
|
+
if (!isSerializableValue(obj[key])) return false;
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isSerializableValue(value: unknown): value is WorkflowSerializableValue {
|
|
124
|
+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return true;
|
|
125
|
+
if (Array.isArray(value)) return value.every(isSerializableValue);
|
|
126
|
+
if (typeof value === "object") {
|
|
127
|
+
const obj = value as Record<string, unknown>;
|
|
128
|
+
return Object.keys(obj).every((k) => isSerializableValue(obj[k]));
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function entryToResumable(entry: DurableCheckpointEntry, sessionFile: string): ResumableWorkflowEntry {
|
|
134
|
+
return {
|
|
135
|
+
workflowId: entry.workflowId,
|
|
136
|
+
name: entry.name,
|
|
137
|
+
inputs: entry.inputs,
|
|
138
|
+
status: entry.status,
|
|
139
|
+
completedCheckpoints: entry.completedCheckpoints,
|
|
140
|
+
pendingPrompts: entry.pendingPrompts,
|
|
141
|
+
sessionFile,
|
|
142
|
+
...(entry.label !== undefined ? { label: entry.label } : {}),
|
|
143
|
+
...(entry.rootWorkflowId !== undefined ? { rootWorkflowId: entry.rootWorkflowId } : {}),
|
|
144
|
+
...(entry.resumable !== undefined ? { resumable: entry.resumable } : {}),
|
|
145
|
+
createdAt: entry.ts,
|
|
146
|
+
updatedAt: entry.ts,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isResumableStatus(status: DurableWorkflowStatus): boolean {
|
|
151
|
+
// `running`/`paused` are both resumable at the catalog level. A `running`
|
|
152
|
+
// durable handle may be a crashed process (cross-session crash recovery);
|
|
153
|
+
// same-session double-resume is filtered out by the command layer.
|
|
154
|
+
return status === "running" || status === "paused";
|
|
155
|
+
}
|
|
156
|
+
function hasResumeProgress(entry: ResumableWorkflowEntry): boolean {
|
|
157
|
+
return entry.completedCheckpoints > 0 || entry.pendingPrompts > 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
function isResumableEntry(entry: ResumableWorkflowEntry): boolean {
|
|
162
|
+
const isRoot = entry.rootWorkflowId === undefined || entry.rootWorkflowId === entry.workflowId;
|
|
163
|
+
if (!isRoot) return false;
|
|
164
|
+
if (entry.status === "failed" || entry.status === "blocked") return entry.resumable !== false;
|
|
165
|
+
return isResumableStatus(entry.status) && hasResumeProgress(entry);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Backend-backed catalog
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* List resumable workflows from a durable backend (in-memory or file-backed).
|
|
174
|
+
* Used when the session file scan is not available (e.g. same-process resume).
|
|
175
|
+
*/
|
|
176
|
+
export function listResumableFromBackend(backend: DurableWorkflowBackend): readonly ResumableWorkflowEntry[] {
|
|
177
|
+
return backend.listResumableWorkflows();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Append a durable checkpoint entry to a session JSONL persistence port.
|
|
182
|
+
* This caches the top-level workflow metadata so a future session can discover
|
|
183
|
+
* it via {@link scanResumableWorkflows}.
|
|
184
|
+
*/
|
|
185
|
+
export function persistDurableCacheEntry(
|
|
186
|
+
persistence: { appendEntry?: (type: string, payload: Record<string, unknown>) => string | undefined },
|
|
187
|
+
entry: DurableCheckpointEntry,
|
|
188
|
+
): void {
|
|
189
|
+
if (typeof persistence.appendEntry !== "function") return;
|
|
190
|
+
persistence.appendEntry("workflow.durable.checkpoint", {
|
|
191
|
+
workflowId: entry.workflowId,
|
|
192
|
+
name: entry.name,
|
|
193
|
+
inputs: entry.inputs as Record<string, unknown>,
|
|
194
|
+
status: entry.status,
|
|
195
|
+
completedCheckpoints: entry.completedCheckpoints,
|
|
196
|
+
pendingPrompts: entry.pendingPrompts,
|
|
197
|
+
...(entry.label !== undefined ? { label: entry.label } : {}),
|
|
198
|
+
...(entry.rootWorkflowId !== undefined ? { rootWorkflowId: entry.rootWorkflowId } : {}),
|
|
199
|
+
...(entry.resumable !== undefined ? { resumable: entry.resumable } : {}),
|
|
200
|
+
ts: entry.ts,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Format the resumable workflow list for display in the selector.
|
|
206
|
+
*/
|
|
207
|
+
export function formatResumableWorkflowList(entries: readonly ResumableWorkflowEntry[]): string {
|
|
208
|
+
if (entries.length === 0) return "No resumable workflows found.";
|
|
209
|
+
const lines = entries.map((e, i) => {
|
|
210
|
+
const id = e.workflowId.slice(0, 8);
|
|
211
|
+
const status = e.status.padEnd(8);
|
|
212
|
+
const checkpoints = `${e.completedCheckpoints} checkpoint${e.completedCheckpoints === 1 ? "" : "s"}`;
|
|
213
|
+
const label = e.label ? ` "${e.label}"` : "";
|
|
214
|
+
return ` ${i + 1}. ${id} ${status} ${e.name}${label} (${checkpoints})`;
|
|
215
|
+
});
|
|
216
|
+
return `Resumable workflows:\n${lines.join("\n")}`;
|
|
217
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-session durable workflow resume adapter.
|
|
3
|
+
*
|
|
4
|
+
* Resumes a workflow whose durable checkpoints live in the durable backend
|
|
5
|
+
* (and are mirrored to the session JSONL cache) but whose in-process run is no
|
|
6
|
+
* longer live. This is the production path behind `/workflow resume <id>` when
|
|
7
|
+
* the id names a durable workflow that is not present in the live run store.
|
|
8
|
+
*
|
|
9
|
+
* Resume semantics (DBOS-aligned):
|
|
10
|
+
* 1. Look up the durable catalog entry (workflow name + cached inputs).
|
|
11
|
+
* 2. Resolve the workflow definition from the registry.
|
|
12
|
+
* 3. Re-dispatch the workflow as a new background run, reusing the ORIGINAL
|
|
13
|
+
* top-level workflow id as the run id. Because durable checkpoints are
|
|
14
|
+
* keyed by workflow id, every `ctx.tool` / `ctx.ui` / `ctx.stage` call
|
|
15
|
+
* inside the resumed run returns its cached result instead of re-executing
|
|
16
|
+
* — completed side effects are not repeated, exactly like DBOS replay.
|
|
17
|
+
*
|
|
18
|
+
* The adapter deliberately re-dispatches through `runDetached` rather than
|
|
19
|
+
* reconstructing an in-memory snapshot, so it works across processes and
|
|
20
|
+
* sessions without a live store entry.
|
|
21
|
+
*
|
|
22
|
+
* cross-ref: issue #1498 — "/workflow resume connects/attempts resume by
|
|
23
|
+
* top-level workflow id."
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { WorkflowInputValues } from "../shared/types.js";
|
|
27
|
+
import type { WorkflowRegistry } from "../workflows/registry.js";
|
|
28
|
+
import type { RunOpts } from "../runs/foreground/executor-types.js";
|
|
29
|
+
import { runDetached, type DetachedAccepted } from "../runs/background/runner.js";
|
|
30
|
+
import { resolveAndValidateInputs } from "../runs/foreground/executor-inputs.js";
|
|
31
|
+
import { getDurableBackend } from "./factory.js";
|
|
32
|
+
import type { DurableWorkflowBackend } from "./backend.js";
|
|
33
|
+
import type { ResumableWorkflowEntry } from "./types.js";
|
|
34
|
+
import { workflowDefinitionRequirementMessage } from "../runs/foreground/executor-child-helpers.js";
|
|
35
|
+
import { isWorkflowDefinition } from "../runs/foreground/executor-child-helpers.js";
|
|
36
|
+
import type { RunSnapshot } from "../shared/store-types.js";
|
|
37
|
+
|
|
38
|
+
export type ResumeDurableResult =
|
|
39
|
+
| { ok: true; runId: string; workflowId: string; name: string; message: string }
|
|
40
|
+
| { ok: false; reason: "workflow_not_found" | "not_resumable" | "invalid_inputs" | "not_registered" | "stale"; message: string };
|
|
41
|
+
|
|
42
|
+
export interface ResumeDurableDeps {
|
|
43
|
+
readonly registry: WorkflowRegistry;
|
|
44
|
+
/** Base run options forwarded to the detached runner (store, persistence, …). */
|
|
45
|
+
readonly baseRunOpts: RunOpts;
|
|
46
|
+
/** Durable backend override (defaults to the global singleton). */
|
|
47
|
+
readonly durableBackend?: DurableWorkflowBackend;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Prepare a durable resume: hydrate the backend's in-memory mirror from the
|
|
52
|
+
* persistent store (DBOS) so synchronous reads in {@link resumeDurableWorkflow}
|
|
53
|
+
* find the workflow and its checkpoints. No-op for backends without hydration.
|
|
54
|
+
*
|
|
55
|
+
* Must be awaited before calling {@link resumeDurableWorkflow} when the backend
|
|
56
|
+
* might be a fresh DBOS process.
|
|
57
|
+
*/
|
|
58
|
+
export async function prepareDurableResume(
|
|
59
|
+
workflowIdOrPrefix: string | undefined,
|
|
60
|
+
deps: ResumeDurableDeps,
|
|
61
|
+
): Promise<readonly ResumableWorkflowEntry[]> {
|
|
62
|
+
const backend = deps.durableBackend ?? getDurableBackend();
|
|
63
|
+
// Hydrate all resumable workflows first so the catalog is complete.
|
|
64
|
+
if (backend.hydrateResumableWorkflows !== undefined) {
|
|
65
|
+
await backend.hydrateResumableWorkflows();
|
|
66
|
+
}
|
|
67
|
+
const catalog = backend.listResumableWorkflows();
|
|
68
|
+
// If a specific target was requested, hydrate that workflow too (it might
|
|
69
|
+
// be resumable but not yet in the resumable filter — e.g. recently failed).
|
|
70
|
+
if (workflowIdOrPrefix !== undefined) {
|
|
71
|
+
const resolved = resolveDurableEntry(workflowIdOrPrefix, catalog);
|
|
72
|
+
if (resolved !== undefined && !("kind" in resolved)) {
|
|
73
|
+
if (backend.hydrateWorkflow !== undefined) {
|
|
74
|
+
await backend.hydrateWorkflow(resolved.workflowId);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return backend.listResumableWorkflows();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a durable catalog entry for a workflow id (full or prefix match).
|
|
83
|
+
* Prefers the durable backend's resumable list; falls back to an explicit
|
|
84
|
+
* session-scan catalog when provided by the caller.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveDurableEntry(
|
|
87
|
+
workflowIdOrPrefix: string,
|
|
88
|
+
catalog: readonly ResumableWorkflowEntry[],
|
|
89
|
+
): ResumableWorkflowEntry | { kind: "ambiguous"; matches: readonly ResumableWorkflowEntry[] } | undefined {
|
|
90
|
+
const exact = catalog.find((entry) => entry.workflowId === workflowIdOrPrefix);
|
|
91
|
+
if (exact !== undefined) return exact;
|
|
92
|
+
const prefixMatches = catalog.filter((entry) => entry.workflowId.startsWith(workflowIdOrPrefix));
|
|
93
|
+
if (prefixMatches.length === 0) return undefined;
|
|
94
|
+
if (prefixMatches.length === 1) return prefixMatches[0];
|
|
95
|
+
return { kind: "ambiguous", matches: prefixMatches };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resume a durable workflow by top-level workflow id. Re-dispatches the workflow
|
|
100
|
+
* with the cached inputs and the original workflow id so durable checkpoints
|
|
101
|
+
* replay (skipping completed side effects).
|
|
102
|
+
*/
|
|
103
|
+
export function resumeDurableWorkflow(
|
|
104
|
+
workflowIdOrPrefix: string,
|
|
105
|
+
deps: ResumeDurableDeps,
|
|
106
|
+
catalog?: readonly ResumableWorkflowEntry[],
|
|
107
|
+
): ResumeDurableResult {
|
|
108
|
+
const backend = deps.durableBackend ?? getDurableBackend();
|
|
109
|
+
const resolvedCatalog = catalog ?? backend.listResumableWorkflows();
|
|
110
|
+
const resolved = resolveDurableEntry(workflowIdOrPrefix, resolvedCatalog);
|
|
111
|
+
if (resolved === undefined) {
|
|
112
|
+
// Not in the (filtered) resumable catalog. It may still be a workflow the
|
|
113
|
+
// backend knows about. Only surface "already running" when there is a live,
|
|
114
|
+
// actively-executing run for it in this session; otherwise a `running`
|
|
115
|
+
// durable handle is a crashed process and falls through to not-found.
|
|
116
|
+
const direct = backend.getWorkflow(workflowIdOrPrefix);
|
|
117
|
+
if (direct !== undefined && direct.status === "running" && hasActiveLiveRun(deps.baseRunOpts.store, direct.workflowId)) {
|
|
118
|
+
return alreadyRunningResult(direct.name, direct.workflowId, deps.baseRunOpts.store);
|
|
119
|
+
}
|
|
120
|
+
return { ok: false, reason: "not_registered", message: `No durable workflow found for id/prefix: ${workflowIdOrPrefix}` };
|
|
121
|
+
}
|
|
122
|
+
if ("kind" in resolved) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
reason: "not_registered",
|
|
126
|
+
message: `Ambiguous workflow prefix "${workflowIdOrPrefix}" matches: ${resolved.matches.map((m) => `${m.name} (${m.workflowId.slice(0, 8)})`).join(", ")}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// Authoritative backend-handle check: a cache-only entry (no handle) is
|
|
130
|
+
// "stale" regardless of its cached status. A `running` handle is only
|
|
131
|
+
// refused when there is a live, actively-executing run for it in THIS
|
|
132
|
+
// session — otherwise it is a crashed process and cross-session crash
|
|
133
|
+
// recovery should proceed.
|
|
134
|
+
const handle = backend.getWorkflow(resolved.workflowId);
|
|
135
|
+
if (handle === undefined) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
reason: "stale",
|
|
139
|
+
message: `Workflow ${resolved.workflowId.slice(0, 8)} has only session-cache metadata and no durable checkpoint state; resume would re-run from scratch. Re-run the workflow to start fresh.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (handle.status === "running" && hasActiveLiveRun(deps.baseRunOpts.store, resolved.workflowId)) {
|
|
143
|
+
return alreadyRunningResult(resolved.name, resolved.workflowId, deps.baseRunOpts.store);
|
|
144
|
+
}
|
|
145
|
+
if (!isResumableEntry(resolved)) {
|
|
146
|
+
return { ok: false, reason: "not_resumable", message: `Workflow ${resolved.workflowId.slice(0, 8)} is ${resolved.status}, not resumable.` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const def = deps.registry.get(resolved.name);
|
|
150
|
+
if (def === undefined) {
|
|
151
|
+
return { ok: false, reason: "workflow_not_found", message: `Workflow definition not found: ${resolved.name}` };
|
|
152
|
+
}
|
|
153
|
+
if (!isWorkflowDefinition(def)) {
|
|
154
|
+
return { ok: false, reason: "workflow_not_found", message: workflowDefinitionRequirementMessage("resumeDurableWorkflow", def) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const inputs: Record<string, unknown> = { ...handle.inputs };
|
|
158
|
+
try {
|
|
159
|
+
resolveAndValidateInputs(def.inputs, inputs as WorkflowInputValues, `workflow "${def.name}"`);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return { ok: false, reason: "invalid_inputs", message: `invalid_inputs: ${err instanceof Error ? err.message : String(err)}` };
|
|
162
|
+
}
|
|
163
|
+
removeDurableResumeShadowRuns(deps.baseRunOpts.store, resolved.workflowId);
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
// Mark the workflow as resuming in the backend, then re-dispatch with the
|
|
167
|
+
// ORIGINAL workflow id as the run id so durable checkpoints replay.
|
|
168
|
+
backend.setWorkflowStatus(resolved.workflowId, "running");
|
|
169
|
+
|
|
170
|
+
const accepted: DetachedAccepted = runDetached(def, inputs, {
|
|
171
|
+
...deps.baseRunOpts,
|
|
172
|
+
runId: resolved.workflowId,
|
|
173
|
+
durableBackend: backend,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
runId: accepted.runId,
|
|
179
|
+
workflowId: resolved.workflowId,
|
|
180
|
+
name: resolved.name,
|
|
181
|
+
message: `Resuming durable workflow "${resolved.name}" (${resolved.workflowId.slice(0, 8)}) — completed checkpoints will be replayed.`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isDurableResumeShadow(run: RunSnapshot): boolean {
|
|
186
|
+
return run.endedAt !== undefined || run.exitReason === "quit" || run.status === "paused";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function removeDurableResumeShadowRuns(store: RunOpts["store"], workflowId: string): void {
|
|
190
|
+
if (store === undefined) return;
|
|
191
|
+
for (;;) {
|
|
192
|
+
const existing = store.runs().find((run) => run.id === workflowId);
|
|
193
|
+
if (existing === undefined || !isDurableResumeShadow(existing)) return;
|
|
194
|
+
if (!store.removeRun(workflowId)) return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function alreadyRunningResult(name: string, workflowId: string, store: RunOpts["store"]): ResumeDurableResult {
|
|
199
|
+
const here = store?.runs().some((r) => r.id === workflowId && r.endedAt === undefined) === true;
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
reason: "not_resumable",
|
|
203
|
+
message: `Workflow "${name}" (${workflowId.slice(0, 8)}) is already running${
|
|
204
|
+
here ? " in this session" : " in another session"
|
|
205
|
+
}. Attach with \`/workflow connect ${workflowId.slice(0, 8)}\`, or if that session has ended, clear it with \`/workflow kill ${workflowId.slice(0, 8)}\` and re-run.`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function hasResumeProgress(entry: ResumableWorkflowEntry): boolean {
|
|
210
|
+
return entry.completedCheckpoints > 0 || entry.pendingPrompts > 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isResumableEntry(entry: ResumableWorkflowEntry): boolean {
|
|
214
|
+
const isRoot = entry.rootWorkflowId === undefined || entry.rootWorkflowId === entry.workflowId;
|
|
215
|
+
if (!isRoot) return false;
|
|
216
|
+
if (entry.status === "failed" || entry.status === "blocked") return entry.resumable !== false;
|
|
217
|
+
// `running` is resumable at this layer: a `running` durable handle may be a
|
|
218
|
+
// crashed process. Same-session double-resume is blocked separately via
|
|
219
|
+
// `hasActiveLiveRun` before dispatch.
|
|
220
|
+
return (entry.status === "running" || entry.status === "paused") && hasResumeProgress(entry);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* True when the live run store has an actively-executing (not ended, not quit)
|
|
225
|
+
* run for `workflowId`. This is the only reliable signal that a durable
|
|
226
|
+
* `running` handle is genuinely live in THIS process — distinguishing a real
|
|
227
|
+
* double-resume from cross-session crash recovery.
|
|
228
|
+
*/
|
|
229
|
+
function hasActiveLiveRun(store: RunOpts["store"] | undefined, workflowId: string): boolean {
|
|
230
|
+
if (store === undefined) return false;
|
|
231
|
+
return store.runs().some((r) => r.id === workflowId && r.endedAt === undefined && r.exitReason !== "quit");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check whether the durable backend records a TERMINAL (non-resumable) status
|
|
236
|
+
* for the given workflow id. Terminal status suppresses stale session-cache
|
|
237
|
+
* entries so a completed/cancelled workflow is not resurrected as resumable.
|
|
238
|
+
*
|
|
239
|
+
* Returns true only when the backend has a registered handle whose status is
|
|
240
|
+
* definitively terminal (completed, cancelled, or failed-and-non-resumable).
|
|
241
|
+
*/
|
|
242
|
+
export function isBackendTerminal(backend: DurableWorkflowBackend, workflowId: string): boolean {
|
|
243
|
+
const handle = backend.getWorkflow(workflowId);
|
|
244
|
+
if (handle === undefined) return false;
|
|
245
|
+
const status = handle.status;
|
|
246
|
+
if (status === "completed" || status === "cancelled") return true;
|
|
247
|
+
if (status === "failed" || status === "blocked") return handle.resumable === false;
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function hasBackendResumeState(backend: DurableWorkflowBackend, workflowId: string): boolean {
|
|
252
|
+
return backend.getWorkflow(workflowId) !== undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Runtime-facing async preparation: hydrate the durable backend from DBOS
|
|
257
|
+
* (when supported) then list resumable workflows with optional session-dir
|
|
258
|
+
* scan merge. Used by the ExtensionRuntime's `prepareDurableResumable`.
|
|
259
|
+
*
|
|
260
|
+
* Stale-cache suppression: when a session JSONL cache entry has no matching
|
|
261
|
+
* durable backend handle, it came from an older/non-checkpointed workflow
|
|
262
|
+
* engine and is hidden from selectors. When the backend knows a workflow is
|
|
263
|
+
* terminal (completed/cancelled/non-resumable), stale cache rows for that id
|
|
264
|
+
* are also suppressed. cross-ref: issue #1498.
|
|
265
|
+
*/
|
|
266
|
+
export async function prepareRuntimeDurableResumable(
|
|
267
|
+
getBackend: () => DurableWorkflowBackend,
|
|
268
|
+
resolveSessionDir: () => string | undefined,
|
|
269
|
+
workflowIdOrPrefix?: string,
|
|
270
|
+
sessionDir?: string,
|
|
271
|
+
): Promise<readonly ResumableWorkflowEntry[]> {
|
|
272
|
+
const backend = getBackend();
|
|
273
|
+
if (backend.hydrateResumableWorkflows !== undefined) {
|
|
274
|
+
await backend.hydrateResumableWorkflows();
|
|
275
|
+
}
|
|
276
|
+
if (workflowIdOrPrefix !== undefined && backend.hydrateWorkflow !== undefined) {
|
|
277
|
+
const catalog = backend.listResumableWorkflows();
|
|
278
|
+
const resolved = resolveDurableEntry(workflowIdOrPrefix, catalog);
|
|
279
|
+
if (resolved !== undefined && !("kind" in resolved)) {
|
|
280
|
+
await backend.hydrateWorkflow(resolved.workflowId);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const live = backend.listResumableWorkflows();
|
|
284
|
+
const effectiveSessionDir = sessionDir ?? resolveSessionDir();
|
|
285
|
+
if (effectiveSessionDir === undefined) return live;
|
|
286
|
+
const { scanResumableWorkflows } = await import("./resume-catalog.js");
|
|
287
|
+
const scanned = scanResumableWorkflows(effectiveSessionDir);
|
|
288
|
+
const liveIds = new Set(live.map((e) => e.workflowId));
|
|
289
|
+
// Suppress cache-only entries from older workflow engines. Without a
|
|
290
|
+
// durable backend handle/checkpoint state, selecting the row can only fail
|
|
291
|
+
// as stale (or risk re-running from scratch), so it should not clutter the
|
|
292
|
+
// resume selector.
|
|
293
|
+
const suppressed = scanned.filter((e) =>
|
|
294
|
+
!liveIds.has(e.workflowId) &&
|
|
295
|
+
hasBackendResumeState(backend, e.workflowId) &&
|
|
296
|
+
!isBackendTerminal(backend, e.workflowId)
|
|
297
|
+
);
|
|
298
|
+
return [...live, ...suppressed];
|
|
299
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped durable backend for child workflow runs.
|
|
3
|
+
*
|
|
4
|
+
* A child workflow launched via `ctx.workflow(child)` runs with its own run id,
|
|
5
|
+
* but its internal `ctx.tool` / `ctx.ui` / `ctx.stage` side effects must be
|
|
6
|
+
* checkpointed under the PARENT (root) durable workflow so that an interrupted
|
|
7
|
+
* child does not re-execute completed side effects when the parent is resumed.
|
|
8
|
+
*
|
|
9
|
+
* Without scoping, child checkpoints are written under a fresh per-run UUID
|
|
10
|
+
* that is never recovered on resume, so a re-dispatched child loses all of its
|
|
11
|
+
* prior checkpoints and re-runs side effects (split-brain). {@link ScopedDurableBackend}
|
|
12
|
+
* remaps every checkpoint identity to the root workflow id, prefixed by a stable
|
|
13
|
+
* child boundary key, so the same side effects are recovered on resume.
|
|
14
|
+
*
|
|
15
|
+
* Only checkpoint read/write methods are scoped. Lifecycle methods
|
|
16
|
+
* (`registerWorkflow`, `setWorkflowStatus`, `listResumableWorkflows`,
|
|
17
|
+
* `toCacheEntry`, `getWorkflow`) are no-ops for scoped children because child
|
|
18
|
+
* runs are never independently resumable — only the root workflow is resumed.
|
|
19
|
+
*
|
|
20
|
+
* cross-ref: issue #1498 — child side effects under the root durable workflow.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { DurableCheckpoint, DurableWorkflowStatus, ResumableWorkflowEntry } from "./types.js";
|
|
24
|
+
import type { WorkflowSerializableValue } from "../shared/types.js";
|
|
25
|
+
import type { DurableWorkflowBackend, WorkflowRegistrationInput } from "./backend.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Durable scope for a child workflow run.
|
|
29
|
+
*
|
|
30
|
+
* - `rootWorkflowId`: the top-level workflow id under which checkpoints persist.
|
|
31
|
+
* - `scopePrefix`: a stable boundary key (e.g. `workflow:<name>:<ordinal>`)
|
|
32
|
+
* unique within the root so multiple children (and the root itself) do not
|
|
33
|
+
* collide.
|
|
34
|
+
*/
|
|
35
|
+
export interface DurableScope {
|
|
36
|
+
readonly rootWorkflowId: string;
|
|
37
|
+
readonly scopePrefix: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wrap a durable backend so all checkpoint identities for a child run are
|
|
42
|
+
* namespaced under the root workflow. The wrapped backend is the source of
|
|
43
|
+
* truth; this wrapper only translates keys.
|
|
44
|
+
*/
|
|
45
|
+
export class ScopedDurableBackend implements DurableWorkflowBackend {
|
|
46
|
+
public readonly persistent: boolean;
|
|
47
|
+
private readonly inner: DurableWorkflowBackend;
|
|
48
|
+
private readonly scope: DurableScope;
|
|
49
|
+
|
|
50
|
+
constructor(inner: DurableWorkflowBackend, scope: DurableScope) {
|
|
51
|
+
this.inner = inner;
|
|
52
|
+
this.scope = scope;
|
|
53
|
+
this.persistent = inner.persistent;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
registerWorkflow(_handle: WorkflowRegistrationInput): void {
|
|
57
|
+
// Child runs are not independently resumable; only the root is registered.
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
recordCheckpoint(checkpoint: DurableCheckpoint): void {
|
|
61
|
+
this.inner.recordCheckpoint(this.remap(checkpoint));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async recordCheckpointAsync(checkpoint: DurableCheckpoint): Promise<void> {
|
|
65
|
+
if (this.inner.recordCheckpointAsync !== undefined) {
|
|
66
|
+
await this.inner.recordCheckpointAsync(this.remap(checkpoint));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.inner.recordCheckpoint(this.remap(checkpoint));
|
|
70
|
+
await this.inner.flush?.();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
flush(): Promise<void> {
|
|
74
|
+
return this.inner.flush?.() ?? Promise.resolve();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getToolOutput(_workflowId: string, argsHash: string): WorkflowSerializableValue | undefined {
|
|
78
|
+
return this.inner.getToolOutput(this.scope.rootWorkflowId, this.scopeKey(argsHash));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getUiResponse(_workflowId: string, promptHash: string): WorkflowSerializableValue | undefined {
|
|
82
|
+
return this.inner.getUiResponse(this.scope.rootWorkflowId, this.scopeKey(promptHash));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getStageOutput(_workflowId: string, replayKey: string): WorkflowSerializableValue | undefined {
|
|
86
|
+
return this.inner.getStageOutput(this.scope.rootWorkflowId, this.scopeKey(replayKey));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getStageSession(_workflowId: string, replayKey: string): { sessionId?: string; sessionFile?: string } | undefined {
|
|
90
|
+
return this.inner.getStageSession(this.scope.rootWorkflowId, this.scopeKey(replayKey));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
listCheckpoints(_workflowId: string): readonly DurableCheckpoint[] {
|
|
94
|
+
const all = this.inner.listCheckpoints(this.scope.rootWorkflowId);
|
|
95
|
+
const prefix = `${this.scope.scopePrefix}:`;
|
|
96
|
+
// Checkpoints are stored with their scope prefix already embedded in their
|
|
97
|
+
// ids (see remap()). Filter by the stored id directly — NOT by re-prefixing
|
|
98
|
+
// — so sibling scopes (e.g. "workflow:child:1") are excluded when the
|
|
99
|
+
// current scope is "workflow:child:2". Re-prefixing would prepend the
|
|
100
|
+
// current scope prefix to every id, causing sibling ids to falsely match.
|
|
101
|
+
return all.filter((cp) => storedScopeId(cp).startsWith(prefix));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getWorkflow(_workflowId: string): undefined {
|
|
105
|
+
// Child runs have no independent resumable handle.
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setWorkflowStatus(_workflowId: string, _status: DurableWorkflowStatus, _pendingPrompts?: number, _resumable?: boolean): void {
|
|
110
|
+
// No-op: child status is reflected via the root workflow boundary.
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
listResumableWorkflows(): readonly ResumableWorkflowEntry[] {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
toCacheEntry(_workflowId: string): undefined {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
reset(): void {
|
|
122
|
+
// No-op: scoped backends never own root state.
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
hydrateWorkflow(_workflowId: string): Promise<void> {
|
|
126
|
+
return Promise.resolve();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
hydrateResumableWorkflows(): Promise<void> {
|
|
130
|
+
return Promise.resolve();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private scopeKey(key: string): string {
|
|
134
|
+
return `${this.scope.scopePrefix}:${key}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private remap(checkpoint: DurableCheckpoint): DurableCheckpoint {
|
|
138
|
+
const workflowId = this.scope.rootWorkflowId;
|
|
139
|
+
if (checkpoint.kind === "tool") {
|
|
140
|
+
return {
|
|
141
|
+
...checkpoint,
|
|
142
|
+
workflowId,
|
|
143
|
+
checkpointId: this.scopeKey(checkpoint.checkpointId),
|
|
144
|
+
argsHash: this.scopeKey(checkpoint.argsHash),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (checkpoint.kind === "ui") {
|
|
148
|
+
return {
|
|
149
|
+
...checkpoint,
|
|
150
|
+
workflowId,
|
|
151
|
+
checkpointId: this.scopeKey(checkpoint.checkpointId),
|
|
152
|
+
promptHash: this.scopeKey(checkpoint.promptHash),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
...checkpoint,
|
|
157
|
+
workflowId,
|
|
158
|
+
checkpointId: this.scopeKey(checkpoint.checkpointId),
|
|
159
|
+
replayKey: this.scopeKey(checkpoint.replayKey),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Return the checkpoint's stored scope-qualified id, which includes any scope
|
|
166
|
+
* prefix embedded by {@link ScopedDurableBackend.remap}. This is the raw stored
|
|
167
|
+
* value used for prefix filtering in {@link ScopedDurableBackend.listCheckpoints}.
|
|
168
|
+
*/
|
|
169
|
+
function storedScopeId(cp: DurableCheckpoint): string {
|
|
170
|
+
return cp.kind === "stage" ? cp.replayKey : cp.checkpointId;
|
|
171
|
+
}
|