@smithers-orchestrator/engine 0.20.3 → 0.21.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/package.json +14 -15
- package/src/approvals.js +6 -0
- package/src/cache-policy.js +54 -0
- package/src/child-workflow.js +7 -0
- package/src/effect/builder.js +48 -13
- package/src/effect/compute-task-bridge.js +42 -47
- package/src/effect/deferred-state-bridge.js +37 -0
- package/src/effect/diff-bundle.js +24 -12
- package/src/effect/durable-deferred-bridge.js +16 -4
- package/src/effect/http-runner.js +2 -86
- package/src/effect/single-runner.js +26 -1
- package/src/effect/sql-message-storage.js +2 -817
- package/src/effect/static-task-bridge.js +6 -2
- package/src/effect/workflow-bridge.js +15 -4
- package/src/effect/workflow-make-bridge.js +10 -3
- package/src/engine.js +198 -172
- package/src/hot/HotWorkflowController.js +37 -23
- package/src/hot/overlay.js +42 -24
- package/src/hot/watch.js +162 -4
- package/src/human-requests.js +3 -0
- package/src/workflow-hash.js +157 -0
package/src/hot/overlay.js
CHANGED
|
@@ -13,6 +13,17 @@ const DEFAULT_EXCLUDE = [
|
|
|
13
13
|
".smithers",
|
|
14
14
|
".DS_Store",
|
|
15
15
|
];
|
|
16
|
+
|
|
17
|
+
function hotOverlayError(cause, operation, details) {
|
|
18
|
+
return toSmithersError(cause, operation, {
|
|
19
|
+
code: "HOT_OVERLAY_FAILED",
|
|
20
|
+
details,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hotOverlayErrorMapper(operation, details) {
|
|
25
|
+
return (cause) => hotOverlayError(cause, operation, details);
|
|
26
|
+
}
|
|
16
27
|
/**
|
|
17
28
|
* Build a generation overlay by hardlinking (or copying) the hot root
|
|
18
29
|
* tree into a new generation directory.
|
|
@@ -31,9 +42,10 @@ export function buildOverlayEffect(hotRoot, outDir, generation, opts) {
|
|
|
31
42
|
return Effect.gen(function* () {
|
|
32
43
|
yield* Effect.tryPromise({
|
|
33
44
|
try: () => mkdir(genDir, { recursive: true }),
|
|
34
|
-
catch: (
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
catch: hotOverlayErrorMapper("create hot overlay generation dir", {
|
|
46
|
+
hotRoot,
|
|
47
|
+
outDir,
|
|
48
|
+
generation,
|
|
37
49
|
}),
|
|
38
50
|
});
|
|
39
51
|
yield* mirrorTreeEffect(hotRoot, genDir, exclude);
|
|
@@ -63,9 +75,9 @@ function mirrorTreeEffect(src, dest, exclude) {
|
|
|
63
75
|
return Effect.gen(function* () {
|
|
64
76
|
const entries = yield* Effect.tryPromise({
|
|
65
77
|
try: () => readdir(src, { withFileTypes: true }),
|
|
66
|
-
catch: (
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
catch: hotOverlayErrorMapper("read hot overlay source dir", {
|
|
79
|
+
src,
|
|
80
|
+
dest,
|
|
69
81
|
}),
|
|
70
82
|
});
|
|
71
83
|
for (const entry of entries) {
|
|
@@ -78,9 +90,9 @@ function mirrorTreeEffect(src, dest, exclude) {
|
|
|
78
90
|
if (entry.isDirectory()) {
|
|
79
91
|
yield* Effect.tryPromise({
|
|
80
92
|
try: () => mkdir(destPath, { recursive: true }),
|
|
81
|
-
catch: (
|
|
82
|
-
|
|
83
|
-
|
|
93
|
+
catch: hotOverlayErrorMapper("create mirrored hot overlay dir", {
|
|
94
|
+
srcPath,
|
|
95
|
+
destPath,
|
|
84
96
|
}),
|
|
85
97
|
});
|
|
86
98
|
yield* mirrorTreeEffect(srcPath, destPath, exclude);
|
|
@@ -88,24 +100,24 @@ function mirrorTreeEffect(src, dest, exclude) {
|
|
|
88
100
|
else if (entry.isFile()) {
|
|
89
101
|
const linked = yield* Effect.either(Effect.tryPromise({
|
|
90
102
|
try: () => link(srcPath, destPath),
|
|
91
|
-
catch: (
|
|
92
|
-
|
|
93
|
-
|
|
103
|
+
catch: hotOverlayErrorMapper("hardlink overlay file", {
|
|
104
|
+
srcPath,
|
|
105
|
+
destPath,
|
|
94
106
|
}),
|
|
95
107
|
}));
|
|
96
108
|
if (linked._tag === "Left") {
|
|
97
109
|
yield* Effect.tryPromise({
|
|
98
110
|
try: () => mkdir(dirname(destPath), { recursive: true }),
|
|
99
|
-
catch: (
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
catch: hotOverlayErrorMapper("create overlay file parent dir", {
|
|
112
|
+
srcPath,
|
|
113
|
+
destPath,
|
|
102
114
|
}),
|
|
103
115
|
});
|
|
104
116
|
yield* Effect.tryPromise({
|
|
105
117
|
try: () => copyFile(srcPath, destPath),
|
|
106
|
-
catch: (
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
catch: hotOverlayErrorMapper("copy overlay file", {
|
|
119
|
+
srcPath,
|
|
120
|
+
destPath,
|
|
109
121
|
}),
|
|
110
122
|
});
|
|
111
123
|
}
|
|
@@ -126,9 +138,9 @@ export function cleanupGenerationsEffect(outDir, keepLast) {
|
|
|
126
138
|
return;
|
|
127
139
|
const entries = yield* Effect.tryPromise({
|
|
128
140
|
try: () => readdir(outDir, { withFileTypes: true }),
|
|
129
|
-
catch: (
|
|
130
|
-
|
|
131
|
-
|
|
141
|
+
catch: hotOverlayErrorMapper("read hot overlay generations", {
|
|
142
|
+
outDir,
|
|
143
|
+
keepLast,
|
|
132
144
|
}),
|
|
133
145
|
});
|
|
134
146
|
const genDirs = entries
|
|
@@ -143,9 +155,9 @@ export function cleanupGenerationsEffect(outDir, keepLast) {
|
|
|
143
155
|
for (const dir of toRemove) {
|
|
144
156
|
yield* Effect.either(Effect.tryPromise({
|
|
145
157
|
try: () => rm(join(outDir, dir.name), { recursive: true, force: true }),
|
|
146
|
-
catch: (
|
|
147
|
-
|
|
148
|
-
|
|
158
|
+
catch: hotOverlayErrorMapper("remove stale hot overlay generation", {
|
|
159
|
+
outDir,
|
|
160
|
+
generationDir: dir.name,
|
|
149
161
|
}),
|
|
150
162
|
}));
|
|
151
163
|
}
|
|
@@ -175,3 +187,9 @@ export function resolveOverlayEntry(entryPath, hotRoot, genDir) {
|
|
|
175
187
|
const rel = relative(hotRoot, entryPath);
|
|
176
188
|
return resolve(genDir, rel);
|
|
177
189
|
}
|
|
190
|
+
|
|
191
|
+
export const __overlayInternals = {
|
|
192
|
+
hotOverlayError,
|
|
193
|
+
hotOverlayErrorMapper,
|
|
194
|
+
mirrorTreeEffect,
|
|
195
|
+
};
|
package/src/hot/watch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { watch } from "node:fs";
|
|
2
|
-
import { readdir } from "node:fs/promises";
|
|
3
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
4
|
import { Effect } from "effect";
|
|
5
5
|
import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
|
|
6
6
|
import { logDebug, logInfo } from "@smithers-orchestrator/observability/logging";
|
|
@@ -12,13 +12,27 @@ const DEFAULT_IGNORE = [
|
|
|
12
12
|
".jj",
|
|
13
13
|
".smithers",
|
|
14
14
|
];
|
|
15
|
+
const MIN_POLL_MS = 1000;
|
|
16
|
+
const MAX_POLL_MS = 10_000;
|
|
17
|
+
const MAX_POLL_FILES = 5000;
|
|
18
|
+
class HotWatchScanLimitError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super(`Hot watch polling skipped after ${MAX_POLL_FILES} files.`);
|
|
21
|
+
this.name = "HotWatchScanLimitError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
15
24
|
export class WatchTree {
|
|
16
25
|
watchers = [];
|
|
17
26
|
rootDir;
|
|
18
27
|
ignore;
|
|
19
28
|
debounceMs;
|
|
20
29
|
changedFiles = new Set();
|
|
30
|
+
fileSignatures = new Map();
|
|
21
31
|
debounceTimer = null;
|
|
32
|
+
pollTimer = null;
|
|
33
|
+
polling = false;
|
|
34
|
+
pollingDisabled = false;
|
|
35
|
+
currentPollIntervalMs = MIN_POLL_MS;
|
|
22
36
|
waitResolve = null;
|
|
23
37
|
closed = false;
|
|
24
38
|
/**
|
|
@@ -53,6 +67,8 @@ export class WatchTree {
|
|
|
53
67
|
this.closed = true;
|
|
54
68
|
if (this.debounceTimer)
|
|
55
69
|
clearTimeout(this.debounceTimer);
|
|
70
|
+
if (this.pollTimer)
|
|
71
|
+
clearTimeout(this.pollTimer);
|
|
56
72
|
for (const w of this.watchers) {
|
|
57
73
|
try {
|
|
58
74
|
w.close();
|
|
@@ -71,7 +87,31 @@ export class WatchTree {
|
|
|
71
87
|
}
|
|
72
88
|
startEffect() {
|
|
73
89
|
return Effect.tryPromise({
|
|
74
|
-
try: () =>
|
|
90
|
+
try: async () => {
|
|
91
|
+
try {
|
|
92
|
+
this.fileSignatures = await this.scanFileSignatures(this.rootDir);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
this.pollingDisabled = true;
|
|
96
|
+
this.fileSignatures = new Map();
|
|
97
|
+
if (error instanceof HotWatchScanLimitError) {
|
|
98
|
+
logInfo("hot watch polling disabled by file count guard", {
|
|
99
|
+
rootDir: this.rootDir,
|
|
100
|
+
maxFiles: MAX_POLL_FILES,
|
|
101
|
+
}, "hot:watch");
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
logInfo("hot watch polling disabled after initial scan failed", {
|
|
105
|
+
rootDir: this.rootDir,
|
|
106
|
+
errorName: error instanceof Error ? error.name : typeof error,
|
|
107
|
+
}, "hot:watch");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
await this.watchDir(this.rootDir);
|
|
111
|
+
if (!this.pollingDisabled) {
|
|
112
|
+
this.startPolling();
|
|
113
|
+
}
|
|
114
|
+
},
|
|
75
115
|
catch: (cause) => toSmithersError(cause, "start hot watch tree"),
|
|
76
116
|
}).pipe(Effect.annotateLogs({
|
|
77
117
|
rootDir: this.rootDir,
|
|
@@ -105,6 +145,122 @@ export class WatchTree {
|
|
|
105
145
|
shouldIgnore(name) {
|
|
106
146
|
return this.ignore.includes(name) || name.startsWith(".");
|
|
107
147
|
}
|
|
148
|
+
pollIntervalMs() {
|
|
149
|
+
return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, this.debounceMs * 4));
|
|
150
|
+
}
|
|
151
|
+
resetPollBackoff() {
|
|
152
|
+
this.currentPollIntervalMs = this.pollIntervalMs();
|
|
153
|
+
}
|
|
154
|
+
advancePollBackoff(changed) {
|
|
155
|
+
if (changed) {
|
|
156
|
+
this.resetPollBackoff();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this.currentPollIntervalMs = Math.min(MAX_POLL_MS, Math.max(this.pollIntervalMs(), this.currentPollIntervalMs * 2));
|
|
160
|
+
}
|
|
161
|
+
scheduleNextPoll() {
|
|
162
|
+
if (this.pollTimer || this.closed || this.pollingDisabled)
|
|
163
|
+
return;
|
|
164
|
+
this.pollTimer = setTimeout(() => {
|
|
165
|
+
this.pollTimer = null;
|
|
166
|
+
void this.pollOnce().finally(() => {
|
|
167
|
+
this.scheduleNextPoll();
|
|
168
|
+
});
|
|
169
|
+
}, this.currentPollIntervalMs);
|
|
170
|
+
}
|
|
171
|
+
startPolling() {
|
|
172
|
+
if (this.pollTimer || this.closed || this.pollingDisabled)
|
|
173
|
+
return;
|
|
174
|
+
this.resetPollBackoff();
|
|
175
|
+
this.scheduleNextPoll();
|
|
176
|
+
}
|
|
177
|
+
async pollOnce() {
|
|
178
|
+
if (this.closed || this.polling || this.pollingDisabled)
|
|
179
|
+
return false;
|
|
180
|
+
this.polling = true;
|
|
181
|
+
try {
|
|
182
|
+
const next = await this.scanFileSignatures(this.rootDir);
|
|
183
|
+
const changed = this.recordScanChanges(next);
|
|
184
|
+
this.advancePollBackoff(changed);
|
|
185
|
+
return changed;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error instanceof HotWatchScanLimitError) {
|
|
189
|
+
this.pollingDisabled = true;
|
|
190
|
+
logInfo("hot watch polling disabled by file count guard", {
|
|
191
|
+
rootDir: this.rootDir,
|
|
192
|
+
maxFiles: MAX_POLL_FILES,
|
|
193
|
+
}, "hot:watch");
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
this.advancePollBackoff(false);
|
|
197
|
+
}
|
|
198
|
+
// Ignore transient filesystem races; the next interval will retry.
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
this.polling = false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* @param {string} dir
|
|
207
|
+
* @returns {Promise<Map<string, string>>}
|
|
208
|
+
*/
|
|
209
|
+
async scanFileSignatures(dir) {
|
|
210
|
+
const files = new Map();
|
|
211
|
+
await this.scanDir(dir, files);
|
|
212
|
+
return files;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* @param {string} dir
|
|
216
|
+
* @param {Map<string, string>} files
|
|
217
|
+
* @returns {Promise<void>}
|
|
218
|
+
*/
|
|
219
|
+
async scanDir(dir, files) {
|
|
220
|
+
if (this.closed)
|
|
221
|
+
return;
|
|
222
|
+
const baseName = basename(dir);
|
|
223
|
+
if (baseName && this.shouldIgnore(baseName) && dir !== this.rootDir)
|
|
224
|
+
return;
|
|
225
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
if (this.shouldIgnore(entry.name))
|
|
228
|
+
continue;
|
|
229
|
+
if (files.size >= MAX_POLL_FILES) {
|
|
230
|
+
throw new HotWatchScanLimitError();
|
|
231
|
+
}
|
|
232
|
+
const fullPath = resolve(dir, entry.name);
|
|
233
|
+
if (entry.isDirectory()) {
|
|
234
|
+
await this.scanDir(fullPath, files);
|
|
235
|
+
}
|
|
236
|
+
else if (entry.isFile()) {
|
|
237
|
+
const info = await stat(fullPath).catch(() => null);
|
|
238
|
+
if (info?.isFile()) {
|
|
239
|
+
files.set(fullPath, `${info.mtimeMs}:${info.size}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* @param {Map<string, string>} next
|
|
246
|
+
*/
|
|
247
|
+
recordScanChanges(next) {
|
|
248
|
+
let changed = false;
|
|
249
|
+
for (const [filePath, signature] of next) {
|
|
250
|
+
if (this.fileSignatures.get(filePath) !== signature) {
|
|
251
|
+
this.onFileChange(filePath);
|
|
252
|
+
changed = true;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const filePath of this.fileSignatures.keys()) {
|
|
256
|
+
if (!next.has(filePath)) {
|
|
257
|
+
this.onFileChange(filePath);
|
|
258
|
+
changed = true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
this.fileSignatures = next;
|
|
262
|
+
return changed;
|
|
263
|
+
}
|
|
108
264
|
/**
|
|
109
265
|
* @param {string} dir
|
|
110
266
|
* @returns {Promise<void>}
|
|
@@ -112,7 +268,7 @@ export class WatchTree {
|
|
|
112
268
|
async watchDir(dir) {
|
|
113
269
|
if (this.closed)
|
|
114
270
|
return;
|
|
115
|
-
const baseName = dir
|
|
271
|
+
const baseName = basename(dir);
|
|
116
272
|
if (baseName && this.shouldIgnore(baseName) && dir !== this.rootDir)
|
|
117
273
|
return;
|
|
118
274
|
try {
|
|
@@ -130,6 +286,8 @@ export class WatchTree {
|
|
|
130
286
|
fullPath,
|
|
131
287
|
}, "hot:watch");
|
|
132
288
|
this.onFileChange(fullPath);
|
|
289
|
+
this.resetPollBackoff();
|
|
290
|
+
void this.pollOnce();
|
|
133
291
|
});
|
|
134
292
|
this.watchers.push(watcher);
|
|
135
293
|
// Recursively watch subdirectories
|
package/src/human-requests.js
CHANGED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
6
|
+
|
|
7
|
+
const STATIC_IMPORT_RE = /\b(?:import|export)\s+(?:[^"'`]*?\s+from\s*)?["']([^"']+)["']/g;
|
|
8
|
+
const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
9
|
+
const WORKFLOW_IMPORT_EXTENSIONS = [
|
|
10
|
+
"",
|
|
11
|
+
".ts",
|
|
12
|
+
".tsx",
|
|
13
|
+
".mts",
|
|
14
|
+
".cts",
|
|
15
|
+
".js",
|
|
16
|
+
".jsx",
|
|
17
|
+
".mjs",
|
|
18
|
+
".cjs",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} input
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export function sha256Hex(input) {
|
|
26
|
+
return createHash("sha256").update(input).digest("hex");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string | null | undefined} sourcePath
|
|
31
|
+
*/
|
|
32
|
+
export function getWorkflowImportScanLoader(sourcePath) {
|
|
33
|
+
const lower = sourcePath?.toLowerCase() ?? "";
|
|
34
|
+
if (lower.endsWith(".tsx"))
|
|
35
|
+
return "tsx";
|
|
36
|
+
if (lower.endsWith(".jsx"))
|
|
37
|
+
return "jsx";
|
|
38
|
+
if (lower.endsWith(".ts") ||
|
|
39
|
+
lower.endsWith(".mts") ||
|
|
40
|
+
lower.endsWith(".cts")) {
|
|
41
|
+
return "ts";
|
|
42
|
+
}
|
|
43
|
+
return "js";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string | null} workflowPath
|
|
48
|
+
* @returns {Promise<string | null>}
|
|
49
|
+
*/
|
|
50
|
+
export async function readWorkflowEntryHash(workflowPath) {
|
|
51
|
+
if (!workflowPath)
|
|
52
|
+
return null;
|
|
53
|
+
try {
|
|
54
|
+
const raw = await readFile(workflowPath, "utf8");
|
|
55
|
+
return sha256Hex(raw);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} source
|
|
64
|
+
* @param {string | null} [sourcePath]
|
|
65
|
+
* @returns {string[]}
|
|
66
|
+
*/
|
|
67
|
+
export function extractWorkflowImportSpecifiers(source, sourcePath) {
|
|
68
|
+
if (typeof Bun !== "undefined" && typeof Bun.Transpiler === "function") {
|
|
69
|
+
try {
|
|
70
|
+
const scanned = new Bun.Transpiler({
|
|
71
|
+
loader: getWorkflowImportScanLoader(sourcePath),
|
|
72
|
+
}).scanImports(source);
|
|
73
|
+
const specifiers = new Set();
|
|
74
|
+
for (const entry of scanned) {
|
|
75
|
+
const specifier = entry?.path?.trim();
|
|
76
|
+
if (specifier?.startsWith(".")) {
|
|
77
|
+
specifiers.add(specifier);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [...specifiers];
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Fall back to regex scanning if Bun's parser cannot handle the source.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const specifiers = new Set();
|
|
87
|
+
for (const pattern of [STATIC_IMPORT_RE, DYNAMIC_IMPORT_RE]) {
|
|
88
|
+
pattern.lastIndex = 0;
|
|
89
|
+
let match;
|
|
90
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
91
|
+
const specifier = match[1]?.trim();
|
|
92
|
+
if (!specifier?.startsWith("."))
|
|
93
|
+
continue;
|
|
94
|
+
specifiers.add(specifier);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [...specifiers];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {string} baseFile
|
|
102
|
+
* @param {string} specifier
|
|
103
|
+
* @returns {string | null}
|
|
104
|
+
*/
|
|
105
|
+
export function resolveWorkflowImport(baseFile, specifier) {
|
|
106
|
+
const basePath = resolve(dirname(baseFile), specifier);
|
|
107
|
+
const candidates = [
|
|
108
|
+
...WORKFLOW_IMPORT_EXTENSIONS.map((ext) => `${basePath}${ext}`),
|
|
109
|
+
...WORKFLOW_IMPORT_EXTENSIONS
|
|
110
|
+
.filter((ext) => ext.length > 0)
|
|
111
|
+
.map((ext) => resolve(basePath, `index${ext}`)),
|
|
112
|
+
];
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
|
115
|
+
return resolve(candidate);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {string} workflowPath
|
|
123
|
+
* @returns {Promise<string[]>}
|
|
124
|
+
*/
|
|
125
|
+
async function collectWorkflowModuleHashEntries(workflowPath, visited = new Set()) {
|
|
126
|
+
const resolvedPath = resolve(workflowPath);
|
|
127
|
+
if (visited.has(resolvedPath)) {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
visited.add(resolvedPath);
|
|
131
|
+
const source = await readFile(resolvedPath, "utf8");
|
|
132
|
+
const entries = [`${resolvedPath}:${sha256Hex(source)}`];
|
|
133
|
+
for (const specifier of extractWorkflowImportSpecifiers(source, resolvedPath)) {
|
|
134
|
+
const importedPath = resolveWorkflowImport(resolvedPath, specifier);
|
|
135
|
+
if (!importedPath) {
|
|
136
|
+
throw new SmithersError("WORKFLOW_HASH_RESOLUTION_FAILED", `Unable to resolve workflow import "${specifier}" from ${resolvedPath}.`, { workflowPath: resolvedPath, specifier });
|
|
137
|
+
}
|
|
138
|
+
entries.push(...(await collectWorkflowModuleHashEntries(importedPath, visited)));
|
|
139
|
+
}
|
|
140
|
+
return entries;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {string | null} workflowPath
|
|
145
|
+
* @returns {Promise<string | null>}
|
|
146
|
+
*/
|
|
147
|
+
export async function readWorkflowGraphHash(workflowPath) {
|
|
148
|
+
if (!workflowPath)
|
|
149
|
+
return null;
|
|
150
|
+
try {
|
|
151
|
+
const entries = await collectWorkflowModuleHashEntries(workflowPath);
|
|
152
|
+
return sha256Hex(entries.sort().join("|"));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|