@smithers-orchestrator/engine 0.16.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/LICENSE +21 -0
- package/package.json +50 -0
- package/src/AlertHumanRequestOptions.ts +8 -0
- package/src/AlertRuntimeServices.ts +10 -0
- package/src/ChildWorkflowDefinition.ts +5 -0
- package/src/ChildWorkflowExecuteOptions.ts +14 -0
- package/src/ContinuationRequest.ts +3 -0
- package/src/HijackState.ts +19 -0
- package/src/HumanRequestKind.ts +1 -0
- package/src/HumanRequestStatus.ts +1 -0
- package/src/PlanNode.ts +29 -0
- package/src/RalphMeta.ts +7 -0
- package/src/RalphState.ts +4 -0
- package/src/RalphStateMap.ts +3 -0
- package/src/ScheduleResult.ts +15 -0
- package/src/SignalRunOptions.ts +5 -0
- package/src/alert-runtime.js +22 -0
- package/src/approvals.js +220 -0
- package/src/child-workflow.js +163 -0
- package/src/effect/ApprovalDeferredResolution.ts +13 -0
- package/src/effect/ApprovalDurableDeferredResolution.ts +11 -0
- package/src/effect/ApprovalPayload.ts +7 -0
- package/src/effect/ApprovalResult.ts +6 -0
- package/src/effect/BuilderNode.ts +52 -0
- package/src/effect/BuilderStepHandle.ts +47 -0
- package/src/effect/CancelPayload.ts +3 -0
- package/src/effect/CancelResult.ts +4 -0
- package/src/effect/DeferredResolution.ts +7 -0
- package/src/effect/DiffBundle.ts +7 -0
- package/src/effect/ExecuteTaskActivityOptions.ts +7 -0
- package/src/effect/FilePatch.ts +6 -0
- package/src/effect/GetRunPayload.ts +3 -0
- package/src/effect/GetRunResult.ts +3 -0
- package/src/effect/LegacyExecuteTaskFn.ts +24 -0
- package/src/effect/ListRunsPayload.ts +6 -0
- package/src/effect/RunStatusSchema.ts +9 -0
- package/src/effect/RunSummary.ts +23 -0
- package/src/effect/SignalPayload.ts +7 -0
- package/src/effect/SignalResult.ts +6 -0
- package/src/effect/SmithersSqliteOptions.ts +3 -0
- package/src/effect/SqlMessageStorageEventHistoryQuery.ts +7 -0
- package/src/effect/TaggedWorkerError.ts +46 -0
- package/src/effect/TaskActivityContext.ts +4 -0
- package/src/effect/TaskActivityRetryOptions.ts +4 -0
- package/src/effect/TaskBridgeToolConfig.ts +6 -0
- package/src/effect/TaskFailure.ts +3 -0
- package/src/effect/TaskResult.ts +5 -0
- package/src/effect/UnknownWorkerError.ts +5 -0
- package/src/effect/WaitForEventDurableDeferredResolution.ts +11 -0
- package/src/effect/WorkerDispatchKind.ts +1 -0
- package/src/effect/WorkerTask.ts +14 -0
- package/src/effect/WorkerTaskError.ts +4 -0
- package/src/effect/WorkerTaskKind.ts +1 -0
- package/src/effect/WorkflowPatchDecisionRecord.ts +4 -0
- package/src/effect/WorkflowPatchDecisions.ts +1 -0
- package/src/effect/WorkflowVersioningRuntime.ts +7 -0
- package/src/effect/activity-bridge.js +131 -0
- package/src/effect/bridge-utils.js +45 -0
- package/src/effect/builder.js +837 -0
- package/src/effect/compute-task-bridge.js +734 -0
- package/src/effect/deferred-bridge.js +63 -0
- package/src/effect/deferred-state-bridge.js +1343 -0
- package/src/effect/diff-bundle.js +352 -0
- package/src/effect/durable-deferred-bridge.js +282 -0
- package/src/effect/entity-worker.js +154 -0
- package/src/effect/http-runner.js +86 -0
- package/src/effect/rpc-schema.js +101 -0
- package/src/effect/single-runner.js +189 -0
- package/src/effect/sql-message-storage.js +817 -0
- package/src/effect/static-task-bridge.js +308 -0
- package/src/effect/versioning.js +123 -0
- package/src/effect/workflow-bridge.js +260 -0
- package/src/effect/workflow-make-bridge.js +233 -0
- package/src/engine.js +6933 -0
- package/src/events.js +237 -0
- package/src/external/json-schema-to-zod.js +214 -0
- package/src/getDefinedToolMetadata.js +10 -0
- package/src/hot/HotReloadEvent.ts +21 -0
- package/src/hot/HotWorkflowController.js +220 -0
- package/src/hot/OverlayOptions.ts +4 -0
- package/src/hot/WatchTreeOptions.ts +6 -0
- package/src/hot/index.js +9 -0
- package/src/hot/overlay.js +177 -0
- package/src/hot/watch.js +174 -0
- package/src/human-requests.js +120 -0
- package/src/index.d.ts +1597 -0
- package/src/index.js +41 -0
- package/src/runtime-owner.js +36 -0
- package/src/scheduler.js +31 -0
- package/src/signals.js +82 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { applyPatch as applyUnifiedPatch } from "diff";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
6
|
+
/** @typedef {import("./DiffBundle.ts").DiffBundle} DiffBundle */
|
|
7
|
+
/** @typedef {import("./FilePatch.ts").FilePatch} FilePatch */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} cwd
|
|
11
|
+
* @param {string[]} args
|
|
12
|
+
* @param {{ input?: string; allowExitCodes?: ReadonlySet<number>; }} [options]
|
|
13
|
+
* @returns {Promise<GitCommandResult>}
|
|
14
|
+
*/
|
|
15
|
+
async function runGit(cwd, args, options) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const child = spawn("git", args, {
|
|
18
|
+
cwd,
|
|
19
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
20
|
+
});
|
|
21
|
+
let stdout = "";
|
|
22
|
+
let stderr = "";
|
|
23
|
+
child.stdout.setEncoding("utf8");
|
|
24
|
+
child.stderr.setEncoding("utf8");
|
|
25
|
+
child.stdout.on("data", (chunk) => {
|
|
26
|
+
stdout += chunk;
|
|
27
|
+
});
|
|
28
|
+
child.stderr.on("data", (chunk) => {
|
|
29
|
+
stderr += chunk;
|
|
30
|
+
});
|
|
31
|
+
child.once("error", reject);
|
|
32
|
+
child.once("close", (code) => {
|
|
33
|
+
const allowExitCodes = options?.allowExitCodes;
|
|
34
|
+
if (code === 0 || (typeof code === "number" && allowExitCodes?.has(code))) {
|
|
35
|
+
resolve({ stdout, stderr });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
reject(new SmithersError("INVALID_INPUT", `git ${args.join(" ")} failed`, { cwd, args, code, stderr: stderr.trim(), stdout: stdout.trim() }));
|
|
39
|
+
});
|
|
40
|
+
if (options?.input) {
|
|
41
|
+
child.stdin.write(options.input);
|
|
42
|
+
}
|
|
43
|
+
child.stdin.end();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} diff
|
|
48
|
+
* @returns {string[]}
|
|
49
|
+
*/
|
|
50
|
+
function splitGitDiff(diff) {
|
|
51
|
+
const normalized = diff.trim();
|
|
52
|
+
if (normalized.length === 0) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
return normalized
|
|
56
|
+
.split(/^diff --git /m)
|
|
57
|
+
.filter((chunk) => chunk.length > 0)
|
|
58
|
+
.map((chunk) => `diff --git ${chunk}`.trimEnd() + "\n");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} chunk
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function extractPatchPath(chunk) {
|
|
65
|
+
const renameTo = chunk.match(/^rename to (.+)$/m)?.[1];
|
|
66
|
+
if (renameTo) {
|
|
67
|
+
return renameTo.trim();
|
|
68
|
+
}
|
|
69
|
+
const plusPath = chunk.match(/^\+\+\+ b\/(.+)$/m)?.[1];
|
|
70
|
+
if (plusPath) {
|
|
71
|
+
return plusPath.trim();
|
|
72
|
+
}
|
|
73
|
+
const minusPath = chunk.match(/^--- a\/(.+)$/m)?.[1];
|
|
74
|
+
if (minusPath) {
|
|
75
|
+
return minusPath.trim();
|
|
76
|
+
}
|
|
77
|
+
const diffHeader = chunk.match(/^diff --git a\/(.+?) b\/(.+)$/m);
|
|
78
|
+
if (diffHeader) {
|
|
79
|
+
return diffHeader[2].trim();
|
|
80
|
+
}
|
|
81
|
+
throw new SmithersError("INVALID_INPUT", "Unable to determine file path from diff chunk", { chunk: chunk.slice(0, 200) });
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} chunk
|
|
85
|
+
* @returns {FilePatch["operation"]}
|
|
86
|
+
*/
|
|
87
|
+
function extractOperation(chunk) {
|
|
88
|
+
if (/^new file mode /m.test(chunk)) {
|
|
89
|
+
return "add";
|
|
90
|
+
}
|
|
91
|
+
if (/^deleted file mode /m.test(chunk)) {
|
|
92
|
+
return "delete";
|
|
93
|
+
}
|
|
94
|
+
return "modify";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} chunk
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
function isBinaryPatch(chunk) {
|
|
101
|
+
return /(^GIT binary patch$)|(^Binary files )/m.test(chunk);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* @param {string} path
|
|
105
|
+
* @returns {Promise<boolean>}
|
|
106
|
+
*/
|
|
107
|
+
async function fileExists(path) {
|
|
108
|
+
try {
|
|
109
|
+
await access(path);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} baseRef
|
|
118
|
+
* @param {string} currentDir
|
|
119
|
+
* @returns {Promise<Set<string>>}
|
|
120
|
+
*/
|
|
121
|
+
async function listBinaryPaths(baseRef, currentDir) {
|
|
122
|
+
const { stdout } = await runGit(currentDir, [
|
|
123
|
+
"diff",
|
|
124
|
+
"--numstat",
|
|
125
|
+
"--find-renames=100%",
|
|
126
|
+
baseRef,
|
|
127
|
+
"--",
|
|
128
|
+
".",
|
|
129
|
+
]);
|
|
130
|
+
const binaryPaths = new Set();
|
|
131
|
+
for (const line of stdout.split("\n")) {
|
|
132
|
+
const trimmed = line.trim();
|
|
133
|
+
if (!trimmed)
|
|
134
|
+
continue;
|
|
135
|
+
const [added, removed, ...rest] = trimmed.split("\t");
|
|
136
|
+
if (added === "-" && removed === "-" && rest.length > 0) {
|
|
137
|
+
binaryPaths.add(rest.join("\t"));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return binaryPaths;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* @param {string} currentDir
|
|
144
|
+
* @returns {Promise<string[]>}
|
|
145
|
+
*/
|
|
146
|
+
async function listUntrackedFiles(currentDir) {
|
|
147
|
+
const { stdout } = await runGit(currentDir, [
|
|
148
|
+
"ls-files",
|
|
149
|
+
"--others",
|
|
150
|
+
"--exclude-standard",
|
|
151
|
+
"--",
|
|
152
|
+
".",
|
|
153
|
+
]);
|
|
154
|
+
return stdout
|
|
155
|
+
.split("\n")
|
|
156
|
+
.map((line) => line.trim())
|
|
157
|
+
.filter((line) => line.length > 0);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} currentDir
|
|
161
|
+
* @returns {Promise<string[]>}
|
|
162
|
+
*/
|
|
163
|
+
async function computeUntrackedDiffs(currentDir) {
|
|
164
|
+
const untracked = await listUntrackedFiles(currentDir);
|
|
165
|
+
const diffs = [];
|
|
166
|
+
for (const relativePath of untracked) {
|
|
167
|
+
const { stdout } = await runGit(currentDir, ["diff", "--no-index", "--binary", "--", "/dev/null", relativePath], { allowExitCodes: new Set([1]) });
|
|
168
|
+
if (stdout.trim().length > 0) {
|
|
169
|
+
diffs.push(stdout.trimEnd() + "\n");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return diffs;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Compute a diff bundle strictly between two immutable refs.
|
|
176
|
+
*
|
|
177
|
+
* Unlike {@link computeDiffBundle}, this variant does NOT read the working
|
|
178
|
+
* tree or untracked files. It is the preferred entry point for historical
|
|
179
|
+
* diffs (e.g. the `getNodeDiff` RPC) because it is read-only and cannot be
|
|
180
|
+
* contaminated by concurrent runs mutating the checkout.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} baseRef
|
|
183
|
+
* @param {string} targetRef
|
|
184
|
+
* @param {string} currentDir
|
|
185
|
+
* @param {number} [seq]
|
|
186
|
+
* @returns {Promise<DiffBundle>}
|
|
187
|
+
*/
|
|
188
|
+
export async function computeDiffBundleBetweenRefs(baseRef, targetRef, currentDir, seq = 1) {
|
|
189
|
+
const [{ stdout: trackedDiff }, { stdout: numstat }] = await Promise.all([
|
|
190
|
+
runGit(currentDir, [
|
|
191
|
+
"diff",
|
|
192
|
+
"--binary",
|
|
193
|
+
"--find-renames=100%",
|
|
194
|
+
"--no-ext-diff",
|
|
195
|
+
baseRef,
|
|
196
|
+
targetRef,
|
|
197
|
+
"--",
|
|
198
|
+
".",
|
|
199
|
+
]),
|
|
200
|
+
runGit(currentDir, [
|
|
201
|
+
"diff",
|
|
202
|
+
"--numstat",
|
|
203
|
+
"--find-renames=100%",
|
|
204
|
+
baseRef,
|
|
205
|
+
targetRef,
|
|
206
|
+
"--",
|
|
207
|
+
".",
|
|
208
|
+
]),
|
|
209
|
+
]);
|
|
210
|
+
const binaryPaths = new Set();
|
|
211
|
+
for (const line of numstat.split("\n")) {
|
|
212
|
+
const trimmed = line.trim();
|
|
213
|
+
if (!trimmed)
|
|
214
|
+
continue;
|
|
215
|
+
const [added, removed, ...rest] = trimmed.split("\t");
|
|
216
|
+
if (added === "-" && removed === "-" && rest.length > 0) {
|
|
217
|
+
binaryPaths.add(rest.join("\t"));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const patches = [];
|
|
221
|
+
const chunks = splitGitDiff(trackedDiff);
|
|
222
|
+
for (const chunk of chunks) {
|
|
223
|
+
const path = extractPatchPath(chunk);
|
|
224
|
+
const operation = extractOperation(chunk);
|
|
225
|
+
const binary = isBinaryPatch(chunk) || binaryPaths.has(path);
|
|
226
|
+
let binaryContent;
|
|
227
|
+
if (binary && operation !== "delete") {
|
|
228
|
+
try {
|
|
229
|
+
const { stdout } = await runGit(currentDir, [
|
|
230
|
+
"show",
|
|
231
|
+
`${targetRef}:${path}`,
|
|
232
|
+
]);
|
|
233
|
+
binaryContent = Buffer.from(stdout, "binary").toString("base64");
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// File may not be readable as a blob at targetRef; fall through
|
|
237
|
+
// without binaryContent. Caller receives operation + diff only.
|
|
238
|
+
binaryContent = undefined;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
patches.push({
|
|
242
|
+
path,
|
|
243
|
+
operation,
|
|
244
|
+
diff: chunk,
|
|
245
|
+
binaryContent,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
seq,
|
|
250
|
+
baseRef,
|
|
251
|
+
patches,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* @param {string} baseRef
|
|
256
|
+
* @param {string} currentDir
|
|
257
|
+
* @returns {Promise<DiffBundle>}
|
|
258
|
+
*/
|
|
259
|
+
export async function computeDiffBundle(baseRef, currentDir, seq = 1) {
|
|
260
|
+
const [{ stdout: trackedDiff }, binaryPaths, untrackedDiffs] = await Promise.all([
|
|
261
|
+
runGit(currentDir, [
|
|
262
|
+
"diff",
|
|
263
|
+
"--binary",
|
|
264
|
+
"--find-renames=100%",
|
|
265
|
+
"--no-ext-diff",
|
|
266
|
+
baseRef,
|
|
267
|
+
"--",
|
|
268
|
+
".",
|
|
269
|
+
]),
|
|
270
|
+
listBinaryPaths(baseRef, currentDir),
|
|
271
|
+
computeUntrackedDiffs(currentDir),
|
|
272
|
+
]);
|
|
273
|
+
const patches = [];
|
|
274
|
+
const chunks = [
|
|
275
|
+
...splitGitDiff(trackedDiff),
|
|
276
|
+
...untrackedDiffs.flatMap(splitGitDiff),
|
|
277
|
+
];
|
|
278
|
+
for (const chunk of chunks) {
|
|
279
|
+
const path = extractPatchPath(chunk);
|
|
280
|
+
const operation = extractOperation(chunk);
|
|
281
|
+
const binary = isBinaryPatch(chunk) || binaryPaths.has(path);
|
|
282
|
+
const fullPath = join(currentDir, path);
|
|
283
|
+
patches.push({
|
|
284
|
+
path,
|
|
285
|
+
operation,
|
|
286
|
+
diff: chunk,
|
|
287
|
+
binaryContent: binary && operation !== "delete" && await fileExists(fullPath)
|
|
288
|
+
? (await readFile(fullPath)).toString("base64")
|
|
289
|
+
: undefined,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
seq,
|
|
294
|
+
baseRef,
|
|
295
|
+
patches,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* @param {FilePatch} patch
|
|
300
|
+
* @param {string} targetDir
|
|
301
|
+
* @returns {Promise<void>}
|
|
302
|
+
*/
|
|
303
|
+
async function applyPatchFallback(patch, targetDir) {
|
|
304
|
+
const targetPath = join(targetDir, patch.path);
|
|
305
|
+
const targetExists = await fileExists(targetPath);
|
|
306
|
+
if (patch.binaryContent) {
|
|
307
|
+
if (patch.operation === "delete") {
|
|
308
|
+
await rm(targetPath, { force: true });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
312
|
+
await writeFile(targetPath, Buffer.from(patch.binaryContent, "base64"));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (patch.operation === "delete" && !targetExists) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const current = patch.operation === "add" || !targetExists
|
|
319
|
+
? ""
|
|
320
|
+
: await readFile(targetPath, "utf8");
|
|
321
|
+
const updated = applyUnifiedPatch(current, patch.diff);
|
|
322
|
+
if (updated === false) {
|
|
323
|
+
throw new SmithersError("TOOL_PATCH_FAILED", `Failed to apply patch for ${patch.path}`, { path: patch.path, operation: patch.operation });
|
|
324
|
+
}
|
|
325
|
+
if (patch.operation === "delete") {
|
|
326
|
+
await rm(targetPath, { force: true });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
330
|
+
await writeFile(targetPath, updated, "utf8");
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* @param {DiffBundle} bundle
|
|
334
|
+
* @param {string} targetDir
|
|
335
|
+
* @returns {Promise<void>}
|
|
336
|
+
*/
|
|
337
|
+
export async function applyDiffBundle(bundle, targetDir) {
|
|
338
|
+
if (bundle.patches.length === 0) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
await mkdir(targetDir, { recursive: true });
|
|
342
|
+
const fullPatch = bundle.patches.map((patch) => patch.diff).join("");
|
|
343
|
+
try {
|
|
344
|
+
await runGit(targetDir, ["apply", "--binary", "--whitespace=nowarn", "--unsafe-paths", "-"], { input: fullPatch });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
for (const patch of bundle.patches) {
|
|
349
|
+
await applyPatchFallback(patch, targetDir);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// @smithers-type-exports-begin
|
|
2
|
+
/** @typedef {import("./ApprovalDurableDeferredResolution.ts").ApprovalDurableDeferredResolution} ApprovalDurableDeferredResolution */
|
|
3
|
+
/** @typedef {import("./WaitForEventDurableDeferredResolution.ts").WaitForEventDurableDeferredResolution} WaitForEventDurableDeferredResolution */
|
|
4
|
+
// @smithers-type-exports-end
|
|
5
|
+
|
|
6
|
+
import * as DurableDeferred from "@effect/workflow/DurableDeferred";
|
|
7
|
+
import * as Workflow from "@effect/workflow/Workflow";
|
|
8
|
+
import { resolve as resolvePath } from "node:path";
|
|
9
|
+
import { Effect, Exit, Schema } from "effect";
|
|
10
|
+
import { updateAsyncExternalWaitPending } from "@smithers-orchestrator/observability/metrics";
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {{ _tag: "Complete"; exit: Exit.Exit<any, any>; } | { _tag: "Pending"; }} BridgeDeferredResult
|
|
13
|
+
*/
|
|
14
|
+
/** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} _SmithersDb */
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {{ signalName: string; correlationId: string | null; payloadJson: string; seq: number; receivedAtMs: number; }} WaitForEventSignalInput
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const DurableDeferredBridgeWorkflow = Workflow.make({
|
|
20
|
+
name: "SmithersDurableDeferredBridge",
|
|
21
|
+
payload: { executionId: Schema.String },
|
|
22
|
+
success: Schema.Unknown,
|
|
23
|
+
idempotencyKey: ({ executionId }) => executionId,
|
|
24
|
+
});
|
|
25
|
+
const adapterNamespaces = new WeakMap();
|
|
26
|
+
let nextAdapterNamespace = 0;
|
|
27
|
+
/**
|
|
28
|
+
* @param {_SmithersDb} adapter
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
const getAdapterNamespace = (adapter) => {
|
|
32
|
+
const filename = adapter?.db?.$client?.filename;
|
|
33
|
+
if (typeof filename === "string" && filename.length > 0 && filename !== ":memory:") {
|
|
34
|
+
return `sqlite:${resolvePath(filename)}`;
|
|
35
|
+
}
|
|
36
|
+
const existing = adapterNamespaces.get(adapter);
|
|
37
|
+
if (existing) {
|
|
38
|
+
return existing;
|
|
39
|
+
}
|
|
40
|
+
const created = `adapter-${++nextAdapterNamespace}`;
|
|
41
|
+
adapterNamespaces.set(adapter, created);
|
|
42
|
+
return created;
|
|
43
|
+
};
|
|
44
|
+
export const approvalDurableDeferredSuccessSchema = Schema.Struct({
|
|
45
|
+
approved: Schema.Boolean,
|
|
46
|
+
note: Schema.NullOr(Schema.String),
|
|
47
|
+
decidedBy: Schema.NullOr(Schema.String),
|
|
48
|
+
decisionJson: Schema.NullOr(Schema.String),
|
|
49
|
+
autoApproved: Schema.Boolean,
|
|
50
|
+
});
|
|
51
|
+
export const waitForEventDurableDeferredSuccessSchema = Schema.Struct({
|
|
52
|
+
signalName: Schema.String,
|
|
53
|
+
correlationId: Schema.NullOr(Schema.String),
|
|
54
|
+
payloadJson: Schema.String,
|
|
55
|
+
seq: Schema.Number,
|
|
56
|
+
receivedAtMs: Schema.Number,
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* @param {string | null | undefined} value
|
|
60
|
+
* @returns {string | null}
|
|
61
|
+
*/
|
|
62
|
+
function normalizeCorrelationId(value) {
|
|
63
|
+
const normalized = typeof value === "string" ? value.trim() : "";
|
|
64
|
+
return normalized.length > 0 ? normalized : null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* @param {unknown} value
|
|
68
|
+
* @returns {number | undefined}
|
|
69
|
+
*/
|
|
70
|
+
function parseOptionalFiniteNumber(value) {
|
|
71
|
+
if (value == null || value === "") {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const parsed = Number(value);
|
|
75
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* @param {string | null} [metaJson]
|
|
79
|
+
* @returns {WaitForEventAttemptSnapshot | null}
|
|
80
|
+
*/
|
|
81
|
+
function parseWaitForEventAttemptSnapshot(metaJson) {
|
|
82
|
+
if (!metaJson)
|
|
83
|
+
return null;
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(metaJson);
|
|
86
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const waitForEvent = parsed?.waitForEvent;
|
|
90
|
+
if (!waitForEvent || typeof waitForEvent !== "object" || Array.isArray(waitForEvent)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const signalName = typeof waitForEvent.signalName === "string"
|
|
94
|
+
? waitForEvent.signalName.trim()
|
|
95
|
+
: "";
|
|
96
|
+
if (!signalName) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
meta: parsed,
|
|
101
|
+
signalName,
|
|
102
|
+
correlationId: normalizeCorrelationId(waitForEvent.correlationId),
|
|
103
|
+
waitAsync: waitForEvent.waitAsync === true,
|
|
104
|
+
resolvedSignalSeq: parseOptionalFiniteNumber(waitForEvent.resolvedSignalSeq),
|
|
105
|
+
receivedAtMs: parseOptionalFiniteNumber(waitForEvent.receivedAtMs),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* @param {WaitForEventAttemptSnapshot} snapshot
|
|
114
|
+
* @param {WaitForEventSignalInput} signal
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
function buildResolvedWaitForEventMetaJson(snapshot, signal) {
|
|
118
|
+
const waitForEvent = snapshot.meta.waitForEvent &&
|
|
119
|
+
typeof snapshot.meta.waitForEvent === "object" &&
|
|
120
|
+
!Array.isArray(snapshot.meta.waitForEvent)
|
|
121
|
+
? snapshot.meta.waitForEvent
|
|
122
|
+
: {};
|
|
123
|
+
return JSON.stringify({
|
|
124
|
+
...snapshot.meta,
|
|
125
|
+
kind: typeof snapshot.meta.kind === "string"
|
|
126
|
+
? snapshot.meta.kind
|
|
127
|
+
: "wait-for-event",
|
|
128
|
+
waitForEvent: {
|
|
129
|
+
...waitForEvent,
|
|
130
|
+
signalName: snapshot.signalName,
|
|
131
|
+
correlationId: snapshot.correlationId,
|
|
132
|
+
waitAsync: snapshot.waitAsync,
|
|
133
|
+
resolvedSignalSeq: signal.seq,
|
|
134
|
+
receivedAtMs: signal.receivedAtMs,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* @param {_SmithersDb} adapter
|
|
140
|
+
* @param {string} runId
|
|
141
|
+
* @param {string} nodeId
|
|
142
|
+
* @param {number} iteration
|
|
143
|
+
* @param {WaitForEventSignalInput} signal
|
|
144
|
+
*/
|
|
145
|
+
async function markWaitForEventResolved(adapter, runId, nodeId, iteration, signal) {
|
|
146
|
+
const attempts = await Effect.runPromise(adapter.listAttempts(runId, nodeId, iteration));
|
|
147
|
+
const waitingAttempt = attempts.find((attempt) => attempt.state === "waiting-event") ??
|
|
148
|
+
attempts[0];
|
|
149
|
+
const snapshot = parseWaitForEventAttemptSnapshot(waitingAttempt?.metaJson);
|
|
150
|
+
if (!waitingAttempt || !snapshot || snapshot.resolvedSignalSeq !== undefined) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
await Effect.runPromise(adapter.updateAttempt(runId, nodeId, iteration, waitingAttempt.attempt, {
|
|
154
|
+
metaJson: buildResolvedWaitForEventMetaJson(snapshot, signal),
|
|
155
|
+
}));
|
|
156
|
+
if (snapshot.waitAsync) {
|
|
157
|
+
try {
|
|
158
|
+
await Effect.runPromise(updateAsyncExternalWaitPending("event", -1));
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const deferredResolutions = new Map();
|
|
164
|
+
/**
|
|
165
|
+
* @template Success, Error
|
|
166
|
+
* @param {string} executionId
|
|
167
|
+
* @param {DurableDeferred.DurableDeferred<Success, Error>} _deferred
|
|
168
|
+
* @returns {Promise<BridgeDeferredResult>}
|
|
169
|
+
*/
|
|
170
|
+
const awaitBridgeDeferred = async (executionId, _deferred) => {
|
|
171
|
+
const exit = deferredResolutions.get(executionId);
|
|
172
|
+
return exit ? { _tag: "Complete", exit } : { _tag: "Pending" };
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* @template Success, Error
|
|
176
|
+
* @param {string} executionId
|
|
177
|
+
* @param {DurableDeferred.DurableDeferred<Success, Error>} _deferred
|
|
178
|
+
* @param {Exit.Exit<Success["Type"], Error["Type"]>} exit
|
|
179
|
+
*/
|
|
180
|
+
const resolveBridgeDeferred = async (executionId, _deferred, exit) => {
|
|
181
|
+
deferredResolutions.set(executionId, exit);
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* @param {_SmithersDb} adapter
|
|
185
|
+
* @param {string} runId
|
|
186
|
+
* @param {string} nodeId
|
|
187
|
+
* @param {number} iteration
|
|
188
|
+
* @returns {string}
|
|
189
|
+
*/
|
|
190
|
+
export const makeDurableDeferredBridgeExecutionId = (adapter, runId, nodeId, iteration) => [
|
|
191
|
+
"smithers-durable-deferred-bridge",
|
|
192
|
+
getAdapterNamespace(adapter),
|
|
193
|
+
runId,
|
|
194
|
+
nodeId,
|
|
195
|
+
String(iteration),
|
|
196
|
+
].join(":");
|
|
197
|
+
/**
|
|
198
|
+
* @param {string} nodeId
|
|
199
|
+
*/
|
|
200
|
+
export const makeApprovalDurableDeferred = (nodeId) => DurableDeferred.make(`approval:${nodeId}`, {
|
|
201
|
+
success: approvalDurableDeferredSuccessSchema,
|
|
202
|
+
});
|
|
203
|
+
/**
|
|
204
|
+
* @param {string} nodeId
|
|
205
|
+
*/
|
|
206
|
+
export const makeWaitForEventDurableDeferred = (nodeId) => DurableDeferred.make(`wait-for-event:${nodeId}`, {
|
|
207
|
+
success: waitForEventDurableDeferredSuccessSchema,
|
|
208
|
+
});
|
|
209
|
+
/**
|
|
210
|
+
* @param {_SmithersDb} adapter
|
|
211
|
+
* @param {string} runId
|
|
212
|
+
* @param {string} nodeId
|
|
213
|
+
* @param {number} iteration
|
|
214
|
+
*/
|
|
215
|
+
export const awaitApprovalDurableDeferred = (adapter, runId, nodeId, iteration) => awaitBridgeDeferred(makeDurableDeferredBridgeExecutionId(adapter, runId, nodeId, iteration), makeApprovalDurableDeferred(nodeId));
|
|
216
|
+
/**
|
|
217
|
+
* @param {_SmithersDb} adapter
|
|
218
|
+
* @param {string} runId
|
|
219
|
+
* @param {string} nodeId
|
|
220
|
+
* @param {number} iteration
|
|
221
|
+
*/
|
|
222
|
+
export const awaitWaitForEventDurableDeferred = (adapter, runId, nodeId, iteration) => awaitBridgeDeferred(makeDurableDeferredBridgeExecutionId(adapter, runId, nodeId, iteration), makeWaitForEventDurableDeferred(nodeId));
|
|
223
|
+
/**
|
|
224
|
+
* @param {_SmithersDb} adapter
|
|
225
|
+
* @param {string} runId
|
|
226
|
+
* @param {string} nodeId
|
|
227
|
+
* @param {number} iteration
|
|
228
|
+
* @param {{ approved: boolean; note?: string | null; decidedBy?: string | null; decisionJson?: string | null; autoApproved?: boolean; }} resolution
|
|
229
|
+
*/
|
|
230
|
+
export const bridgeApprovalResolve = async (adapter, runId, nodeId, iteration, resolution) => {
|
|
231
|
+
await resolveBridgeDeferred(makeDurableDeferredBridgeExecutionId(adapter, runId, nodeId, iteration), makeApprovalDurableDeferred(nodeId), Exit.succeed({
|
|
232
|
+
approved: resolution.approved,
|
|
233
|
+
note: resolution.note ?? null,
|
|
234
|
+
decidedBy: resolution.decidedBy ?? null,
|
|
235
|
+
decisionJson: resolution.decisionJson ?? null,
|
|
236
|
+
autoApproved: resolution.autoApproved ?? false,
|
|
237
|
+
}));
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
240
|
+
* @param {_SmithersDb} adapter
|
|
241
|
+
* @param {string} runId
|
|
242
|
+
* @param {string} nodeId
|
|
243
|
+
* @param {number} iteration
|
|
244
|
+
* @param {WaitForEventSignalInput} signal
|
|
245
|
+
*/
|
|
246
|
+
export const bridgeWaitForEventResolve = async (adapter, runId, nodeId, iteration, signal) => {
|
|
247
|
+
await markWaitForEventResolved(adapter, runId, nodeId, iteration, signal);
|
|
248
|
+
await resolveBridgeDeferred(makeDurableDeferredBridgeExecutionId(adapter, runId, nodeId, iteration), makeWaitForEventDurableDeferred(nodeId), Exit.succeed({
|
|
249
|
+
signalName: signal.signalName,
|
|
250
|
+
correlationId: normalizeCorrelationId(signal.correlationId),
|
|
251
|
+
payloadJson: signal.payloadJson,
|
|
252
|
+
seq: signal.seq,
|
|
253
|
+
receivedAtMs: signal.receivedAtMs,
|
|
254
|
+
}));
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* @param {_SmithersDb} adapter
|
|
258
|
+
* @param {string} runId
|
|
259
|
+
* @param {WaitForEventSignalInput} signal
|
|
260
|
+
*/
|
|
261
|
+
export const bridgeSignalResolve = async (adapter, runId, signal) => {
|
|
262
|
+
const nodes = await Effect.runPromise(adapter.listNodes(runId));
|
|
263
|
+
const normalizedCorrelationId = normalizeCorrelationId(signal.correlationId);
|
|
264
|
+
for (const node of nodes) {
|
|
265
|
+
if (node.state !== "waiting-event")
|
|
266
|
+
continue;
|
|
267
|
+
const iteration = node.iteration ?? 0;
|
|
268
|
+
const attempts = await Effect.runPromise(adapter.listAttempts(runId, node.nodeId, iteration));
|
|
269
|
+
const waitingAttempt = attempts.find((attempt) => attempt.state === "waiting-event") ??
|
|
270
|
+
attempts[0];
|
|
271
|
+
if (!waitingAttempt)
|
|
272
|
+
continue;
|
|
273
|
+
const snapshot = parseWaitForEventAttemptSnapshot(waitingAttempt.metaJson);
|
|
274
|
+
if (!snapshot)
|
|
275
|
+
continue;
|
|
276
|
+
if (snapshot.signalName !== signal.signalName)
|
|
277
|
+
continue;
|
|
278
|
+
if (snapshot.correlationId !== normalizedCorrelationId)
|
|
279
|
+
continue;
|
|
280
|
+
await bridgeWaitForEventResolve(adapter, runId, node.nodeId, iteration, signal);
|
|
281
|
+
}
|
|
282
|
+
};
|