@smithers-orchestrator/engine 0.20.4 → 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 -14
- package/src/cache-policy.js +54 -0
- package/src/engine.js +113 -164
- package/src/hot/watch.js +162 -4
- package/src/workflow-hash.js +157 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smithers-orchestrator/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Concrete Smithers workflow execution engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -33,19 +33,19 @@
|
|
|
33
33
|
"react": "^19.2.5",
|
|
34
34
|
"react-dom": "^19.2.5",
|
|
35
35
|
"zod": "^4.3.6",
|
|
36
|
-
"@smithers-orchestrator/
|
|
37
|
-
"@smithers-orchestrator/
|
|
38
|
-
"@smithers-orchestrator/
|
|
39
|
-
"@smithers-orchestrator/
|
|
40
|
-
"@smithers-orchestrator/
|
|
41
|
-
"@smithers-orchestrator/
|
|
42
|
-
"@smithers-orchestrator/
|
|
43
|
-
"@smithers-orchestrator/react-reconciler": "0.
|
|
44
|
-
"@smithers-orchestrator/
|
|
45
|
-
"@smithers-orchestrator/
|
|
46
|
-
"@smithers-orchestrator/scorers": "0.
|
|
47
|
-
"@smithers-orchestrator/time-travel": "0.
|
|
48
|
-
"@smithers-orchestrator/vcs": "0.
|
|
36
|
+
"@smithers-orchestrator/agents": "0.21.0",
|
|
37
|
+
"@smithers-orchestrator/components": "0.21.0",
|
|
38
|
+
"@smithers-orchestrator/db": "0.21.0",
|
|
39
|
+
"@smithers-orchestrator/driver": "0.21.0",
|
|
40
|
+
"@smithers-orchestrator/errors": "0.21.0",
|
|
41
|
+
"@smithers-orchestrator/graph": "0.21.0",
|
|
42
|
+
"@smithers-orchestrator/observability": "0.21.0",
|
|
43
|
+
"@smithers-orchestrator/react-reconciler": "0.21.0",
|
|
44
|
+
"@smithers-orchestrator/sandbox": "0.21.0",
|
|
45
|
+
"@smithers-orchestrator/scheduler": "0.21.0",
|
|
46
|
+
"@smithers-orchestrator/scorers": "0.21.0",
|
|
47
|
+
"@smithers-orchestrator/time-travel": "0.21.0",
|
|
48
|
+
"@smithers-orchestrator/vcs": "0.21.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/bun": "latest",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { nowMs } from "@smithers-orchestrator/scheduler/nowMs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import("@smithers-orchestrator/graph/TaskDescriptor").TaskDescriptor} TaskDescriptor
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {TaskDescriptor["cachePolicy"]} policy
|
|
9
|
+
* @returns {"run" | "workflow" | "global"}
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeCacheScope(policy) {
|
|
12
|
+
return policy?.scope === "run" || policy?.scope === "global"
|
|
13
|
+
? policy.scope
|
|
14
|
+
: "workflow";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {"run" | "workflow" | "global"} scope
|
|
19
|
+
* @param {string} runId
|
|
20
|
+
* @param {string} workflowName
|
|
21
|
+
* @param {TaskDescriptor} desc
|
|
22
|
+
* @returns {Record<string, unknown>}
|
|
23
|
+
*/
|
|
24
|
+
export function buildCacheScopeIdentity(scope, runId, workflowName, desc) {
|
|
25
|
+
const taskKey = desc.cachePolicy?.key ?? desc.nodeId;
|
|
26
|
+
const identity = {
|
|
27
|
+
taskKey,
|
|
28
|
+
outputTableName: desc.outputTableName,
|
|
29
|
+
};
|
|
30
|
+
if (scope === "global") {
|
|
31
|
+
return identity;
|
|
32
|
+
}
|
|
33
|
+
if (scope === "run") {
|
|
34
|
+
return { runId, workflowName, ...identity };
|
|
35
|
+
}
|
|
36
|
+
return { workflowName, ...identity };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {unknown} row
|
|
41
|
+
* @param {TaskDescriptor["cachePolicy"]} policy
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
export function isFreshCacheRow(row, policy) {
|
|
45
|
+
const ttlMs = policy?.ttlMs;
|
|
46
|
+
if (ttlMs === undefined || ttlMs === null) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
if (typeof ttlMs !== "number" || !Number.isFinite(ttlMs) || ttlMs < 0) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const createdAtMs = /** @type {{ createdAtMs?: unknown }} */ (row)?.createdAtMs;
|
|
53
|
+
return typeof createdAtMs === "number" && nowMs() - createdAtMs <= ttlMs;
|
|
54
|
+
}
|
package/src/engine.js
CHANGED
|
@@ -30,14 +30,13 @@ import { Cause, Chunk, Duration, Effect, Exit, Fiber, Metric, Queue, Schedule }
|
|
|
30
30
|
import { attemptDuration, cacheHits, cacheMisses, nodeDuration, promptSizeBytes, responseSizeBytes, runDuration, runsResumedTotal, schedulerConcurrencyUtilization, schedulerQueueDepth, schedulerWaitDuration, trackEvent, } from "@smithers-orchestrator/observability/metrics";
|
|
31
31
|
import { runScorersAsync } from "@smithers-orchestrator/scorers/run-scorers";
|
|
32
32
|
import { dirname, resolve } from "node:path";
|
|
33
|
-
import { existsSync
|
|
34
|
-
import { readFile } from "node:fs/promises";
|
|
33
|
+
import { existsSync } from "node:fs";
|
|
35
34
|
import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
|
|
36
35
|
import { logDebug, logError, logInfo, logWarning } from "@smithers-orchestrator/observability/logging";
|
|
37
36
|
import { isPidAlive, parseRuntimeOwnerPid } from "./runtime-owner.js";
|
|
38
37
|
import { HotWorkflowController } from "./hot/index.js";
|
|
39
38
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
40
|
-
import {
|
|
39
|
+
import { randomUUID } from "node:crypto";
|
|
41
40
|
import { platform } from "node:os";
|
|
42
41
|
import { annotateSmithersTrace, smithersSpanNames, withSmithersSpan, } from "@smithers-orchestrator/observability";
|
|
43
42
|
import { withTaskRuntime } from "@smithers-orchestrator/driver/task-runtime";
|
|
@@ -45,9 +44,13 @@ import { hashCapabilityRegistry } from "@smithers-orchestrator/agents/capability
|
|
|
45
44
|
import { cancelPendingTimersBridge, executeTaskBridgeEffect, isBridgeManagedTimerTask as isTimerTask, resolveDeferredTaskStateBridge, } from "./effect/workflow-bridge.js";
|
|
46
45
|
import { AlertRuntime } from "./alert-runtime.js";
|
|
47
46
|
import { executeChildWorkflow } from "./child-workflow.js";
|
|
47
|
+
import { executeSandbox } from "@smithers-orchestrator/sandbox/execute";
|
|
48
|
+
import { applyDiffBundle } from "./effect/diff-bundle.js";
|
|
49
|
+
import { buildCacheScopeIdentity, isFreshCacheRow, normalizeCacheScope } from "./cache-policy.js";
|
|
48
50
|
import { runWorkflowWithMakeBridge } from "./effect/workflow-make-bridge.js";
|
|
49
51
|
import { createWorkflowVersioningRuntime, getWorkflowPatchDecisions, withWorkflowVersioningRuntime, } from "./effect/versioning.js";
|
|
50
52
|
import { runWithCorrelationContext, updateCurrentCorrelationContext, withCorrelationContext, } from "@smithers-orchestrator/observability/correlation";
|
|
53
|
+
import { extractWorkflowImportSpecifiers, getWorkflowImportScanLoader, readWorkflowEntryHash, readWorkflowGraphHash, resolveWorkflowImport, sha256Hex, } from "./workflow-hash.js";
|
|
51
54
|
/** @typedef {import("@smithers-orchestrator/graph/GraphSnapshot").GraphSnapshot} GraphSnapshot */
|
|
52
55
|
/** @typedef {import("./HijackState.ts").HijackState} HijackState */
|
|
53
56
|
/** @typedef {import("@smithers-orchestrator/driver/RunOptions").RunOptions} RunOptions */
|
|
@@ -61,13 +64,6 @@ import { runWithCorrelationContext, updateCurrentCorrelationContext, withCorrela
|
|
|
61
64
|
/** @typedef {import("drizzle-orm/bun-sqlite").BunSQLiteDatabase<Record<string, unknown>>} BunSQLiteDatabase */
|
|
62
65
|
/** @typedef {import("drizzle-orm/sqlite-core").SQLiteTable} SQLiteTable */
|
|
63
66
|
|
|
64
|
-
/**
|
|
65
|
-
* @param {string} input
|
|
66
|
-
* @returns {string}
|
|
67
|
-
*/
|
|
68
|
-
function sha256Hex(input) {
|
|
69
|
-
return createHash("sha256").update(input).digest("hex");
|
|
70
|
-
}
|
|
71
67
|
/**
|
|
72
68
|
* Track which worktree paths have already been created this run so we don't
|
|
73
69
|
* re-create them for every task sharing the same worktree.
|
|
@@ -1345,143 +1341,6 @@ function resolveLogDir(rootDir, runId, logDir) {
|
|
|
1345
1341
|
}
|
|
1346
1342
|
return resolve(rootDir, ".smithers", "executions", runId, "logs");
|
|
1347
1343
|
}
|
|
1348
|
-
const STATIC_IMPORT_RE = /\b(?:import|export)\s+(?:[^"'`]*?\s+from\s*)?["']([^"']+)["']/g;
|
|
1349
|
-
const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
1350
|
-
const WORKFLOW_IMPORT_EXTENSIONS = [
|
|
1351
|
-
"",
|
|
1352
|
-
".ts",
|
|
1353
|
-
".tsx",
|
|
1354
|
-
".mts",
|
|
1355
|
-
".cts",
|
|
1356
|
-
".js",
|
|
1357
|
-
".jsx",
|
|
1358
|
-
".mjs",
|
|
1359
|
-
".cjs",
|
|
1360
|
-
];
|
|
1361
|
-
/**
|
|
1362
|
-
* @param {string | null | undefined} sourcePath
|
|
1363
|
-
*/
|
|
1364
|
-
function getWorkflowImportScanLoader(sourcePath) {
|
|
1365
|
-
const lower = sourcePath?.toLowerCase() ?? "";
|
|
1366
|
-
if (lower.endsWith(".tsx"))
|
|
1367
|
-
return "tsx";
|
|
1368
|
-
if (lower.endsWith(".jsx"))
|
|
1369
|
-
return "jsx";
|
|
1370
|
-
if (lower.endsWith(".ts") ||
|
|
1371
|
-
lower.endsWith(".mts") ||
|
|
1372
|
-
lower.endsWith(".cts")) {
|
|
1373
|
-
return "ts";
|
|
1374
|
-
}
|
|
1375
|
-
return "js";
|
|
1376
|
-
}
|
|
1377
|
-
/**
|
|
1378
|
-
* @param {string | null} workflowPath
|
|
1379
|
-
* @returns {Promise<string | null>}
|
|
1380
|
-
*/
|
|
1381
|
-
async function readWorkflowEntryHash(workflowPath) {
|
|
1382
|
-
if (!workflowPath)
|
|
1383
|
-
return null;
|
|
1384
|
-
try {
|
|
1385
|
-
const raw = await readFile(workflowPath, "utf8");
|
|
1386
|
-
return sha256Hex(raw);
|
|
1387
|
-
}
|
|
1388
|
-
catch {
|
|
1389
|
-
return null;
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
/**
|
|
1393
|
-
* @param {string} source
|
|
1394
|
-
* @param {string | null} [sourcePath]
|
|
1395
|
-
* @returns {string[]}
|
|
1396
|
-
*/
|
|
1397
|
-
function extractWorkflowImportSpecifiers(source, sourcePath) {
|
|
1398
|
-
if (typeof Bun !== "undefined" && typeof Bun.Transpiler === "function") {
|
|
1399
|
-
try {
|
|
1400
|
-
const scanned = new Bun.Transpiler({
|
|
1401
|
-
loader: getWorkflowImportScanLoader(sourcePath),
|
|
1402
|
-
}).scanImports(source);
|
|
1403
|
-
const specifiers = new Set();
|
|
1404
|
-
for (const entry of scanned) {
|
|
1405
|
-
const specifier = entry?.path?.trim();
|
|
1406
|
-
if (specifier?.startsWith(".")) {
|
|
1407
|
-
specifiers.add(specifier);
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
return [...specifiers];
|
|
1411
|
-
}
|
|
1412
|
-
catch {
|
|
1413
|
-
// Fall back to regex scanning if Bun's parser cannot handle the source.
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
const specifiers = new Set();
|
|
1417
|
-
for (const pattern of [STATIC_IMPORT_RE, DYNAMIC_IMPORT_RE]) {
|
|
1418
|
-
pattern.lastIndex = 0;
|
|
1419
|
-
let match;
|
|
1420
|
-
while ((match = pattern.exec(source)) !== null) {
|
|
1421
|
-
const specifier = match[1]?.trim();
|
|
1422
|
-
if (!specifier?.startsWith("."))
|
|
1423
|
-
continue;
|
|
1424
|
-
specifiers.add(specifier);
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
return [...specifiers];
|
|
1428
|
-
}
|
|
1429
|
-
/**
|
|
1430
|
-
* @param {string} baseFile
|
|
1431
|
-
* @param {string} specifier
|
|
1432
|
-
* @returns {string | null}
|
|
1433
|
-
*/
|
|
1434
|
-
function resolveWorkflowImport(baseFile, specifier) {
|
|
1435
|
-
const basePath = resolve(dirname(baseFile), specifier);
|
|
1436
|
-
const candidates = [
|
|
1437
|
-
...WORKFLOW_IMPORT_EXTENSIONS.map((ext) => `${basePath}${ext}`),
|
|
1438
|
-
...WORKFLOW_IMPORT_EXTENSIONS
|
|
1439
|
-
.filter((ext) => ext.length > 0)
|
|
1440
|
-
.map((ext) => resolve(basePath, `index${ext}`)),
|
|
1441
|
-
];
|
|
1442
|
-
for (const candidate of candidates) {
|
|
1443
|
-
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
|
1444
|
-
return resolve(candidate);
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
return null;
|
|
1448
|
-
}
|
|
1449
|
-
/**
|
|
1450
|
-
* @param {string} workflowPath
|
|
1451
|
-
* @returns {Promise<string[]>}
|
|
1452
|
-
*/
|
|
1453
|
-
async function collectWorkflowModuleHashEntries(workflowPath, visited = new Set()) {
|
|
1454
|
-
const resolvedPath = resolve(workflowPath);
|
|
1455
|
-
if (visited.has(resolvedPath)) {
|
|
1456
|
-
return [];
|
|
1457
|
-
}
|
|
1458
|
-
visited.add(resolvedPath);
|
|
1459
|
-
const source = await readFile(resolvedPath, "utf8");
|
|
1460
|
-
const entries = [`${resolvedPath}:${sha256Hex(source)}`];
|
|
1461
|
-
for (const specifier of extractWorkflowImportSpecifiers(source, resolvedPath)) {
|
|
1462
|
-
const importedPath = resolveWorkflowImport(resolvedPath, specifier);
|
|
1463
|
-
if (!importedPath) {
|
|
1464
|
-
throw new SmithersError("WORKFLOW_HASH_RESOLUTION_FAILED", `Unable to resolve workflow import "${specifier}" from ${resolvedPath}.`, { workflowPath: resolvedPath, specifier });
|
|
1465
|
-
}
|
|
1466
|
-
entries.push(...(await collectWorkflowModuleHashEntries(importedPath, visited)));
|
|
1467
|
-
}
|
|
1468
|
-
return entries;
|
|
1469
|
-
}
|
|
1470
|
-
/**
|
|
1471
|
-
* @param {string | null} workflowPath
|
|
1472
|
-
* @returns {Promise<string | null>}
|
|
1473
|
-
*/
|
|
1474
|
-
async function readWorkflowGraphHash(workflowPath) {
|
|
1475
|
-
if (!workflowPath)
|
|
1476
|
-
return null;
|
|
1477
|
-
try {
|
|
1478
|
-
const entries = await collectWorkflowModuleHashEntries(workflowPath);
|
|
1479
|
-
return sha256Hex(entries.sort().join("|"));
|
|
1480
|
-
}
|
|
1481
|
-
catch {
|
|
1482
|
-
return null;
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
1344
|
/**
|
|
1486
1345
|
* @param {string} cwd
|
|
1487
1346
|
* @returns {Promise<string | null>}
|
|
@@ -1980,6 +1839,54 @@ function attachSubflowComputeFns(tasks, workflow, opts = {}) {
|
|
|
1980
1839
|
task.meta = persistableMeta;
|
|
1981
1840
|
}
|
|
1982
1841
|
}
|
|
1842
|
+
/**
|
|
1843
|
+
* @param {TaskDescriptor[]} tasks
|
|
1844
|
+
* @param {SmithersWorkflow<any>} workflow
|
|
1845
|
+
* @param {{ rootDir?: string; workflowPath?: string | null }} [opts]
|
|
1846
|
+
*/
|
|
1847
|
+
function attachSandboxComputeFns(tasks, workflow, opts = {}) {
|
|
1848
|
+
for (const task of tasks) {
|
|
1849
|
+
if (!task.meta?.__sandbox || task.computeFn)
|
|
1850
|
+
continue;
|
|
1851
|
+
const sandboxWorkflow = task.meta.__sandboxWorkflow;
|
|
1852
|
+
if (!sandboxWorkflow)
|
|
1853
|
+
continue;
|
|
1854
|
+
const sandboxInput = task.meta.__sandboxInput;
|
|
1855
|
+
const sandboxProvider = task.meta.__sandboxProvider;
|
|
1856
|
+
const sandboxRuntime = task.meta.__sandboxRuntime;
|
|
1857
|
+
const sandboxAllowNetwork = Boolean(task.meta.__sandboxAllowNetwork);
|
|
1858
|
+
const sandboxReviewDiffs = task.meta.__sandboxReviewDiffs;
|
|
1859
|
+
const sandboxAutoAcceptDiffs = task.meta.__sandboxAutoAcceptDiffs;
|
|
1860
|
+
const sandboxAllowNested = Boolean(task.meta.__sandboxAllowNested);
|
|
1861
|
+
const sandboxConfig = task.meta.__sandboxConfig && typeof task.meta.__sandboxConfig === "object"
|
|
1862
|
+
? task.meta.__sandboxConfig
|
|
1863
|
+
: {};
|
|
1864
|
+
task.computeFn = async () => executeSandbox({
|
|
1865
|
+
parentWorkflow: workflow,
|
|
1866
|
+
sandboxId: task.nodeId,
|
|
1867
|
+
provider: sandboxProvider,
|
|
1868
|
+
runtime: sandboxRuntime,
|
|
1869
|
+
workflow: sandboxWorkflow,
|
|
1870
|
+
executeChildWorkflow,
|
|
1871
|
+
applyDiffBundle,
|
|
1872
|
+
input: sandboxInput,
|
|
1873
|
+
rootDir: task.worktreePath ?? opts.rootDir ?? process.cwd(),
|
|
1874
|
+
allowNetwork: sandboxAllowNetwork,
|
|
1875
|
+
maxOutputBytes: 200_000,
|
|
1876
|
+
toolTimeoutMs: 60_000,
|
|
1877
|
+
reviewDiffs: sandboxReviewDiffs,
|
|
1878
|
+
autoAcceptDiffs: sandboxAutoAcceptDiffs,
|
|
1879
|
+
allowNested: sandboxAllowNested,
|
|
1880
|
+
config: sandboxConfig,
|
|
1881
|
+
});
|
|
1882
|
+
const {
|
|
1883
|
+
__sandboxWorkflow: _workflow,
|
|
1884
|
+
__sandboxProvider: _provider,
|
|
1885
|
+
...persistableMeta
|
|
1886
|
+
} = task.meta;
|
|
1887
|
+
task.meta = persistableMeta;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1983
1890
|
/**
|
|
1984
1891
|
* @param {XmlNode} xml
|
|
1985
1892
|
* @returns {string}
|
|
@@ -2660,6 +2567,9 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
|
|
|
2660
2567
|
const taskRoot = desc.worktreePath ?? toolConfig.rootDir;
|
|
2661
2568
|
const stepCacheEnabled = cacheEnabled || Boolean(desc.cachePolicy);
|
|
2662
2569
|
const cacheAgent = Array.isArray(desc.agent) ? desc.agent[0] : desc.agent;
|
|
2570
|
+
const cachePolicyTtlMs = typeof desc.cachePolicy?.ttlMs === "number" && Number.isFinite(desc.cachePolicy.ttlMs)
|
|
2571
|
+
? Math.max(0, desc.cachePolicy.ttlMs)
|
|
2572
|
+
: null;
|
|
2663
2573
|
let heartbeatWatchdogFiber = null;
|
|
2664
2574
|
try {
|
|
2665
2575
|
if (taskSignal.aborted) {
|
|
@@ -2732,6 +2642,7 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
|
|
|
2732
2642
|
if (desc.cachePolicy) {
|
|
2733
2643
|
let cachePayload = null;
|
|
2734
2644
|
let cacheByOk = true;
|
|
2645
|
+
const cacheScope = normalizeCacheScope(desc.cachePolicy);
|
|
2735
2646
|
try {
|
|
2736
2647
|
const ctx = await buildCacheContext(db, inputTable, runId, desc, descriptorMap, attemptNo);
|
|
2737
2648
|
if (desc.cachePolicy.by) {
|
|
@@ -2752,16 +2663,15 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
|
|
|
2752
2663
|
cacheKeyDisabled = true;
|
|
2753
2664
|
}
|
|
2754
2665
|
cacheBase = {
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
iteration: desc.iteration,
|
|
2758
|
-
outputTableName: desc.outputTableName,
|
|
2666
|
+
cacheScope,
|
|
2667
|
+
...buildCacheScopeIdentity(cacheScope, runId, workflowName, desc),
|
|
2759
2668
|
schemaSig,
|
|
2760
2669
|
outputSchemaSig,
|
|
2761
2670
|
agentSig,
|
|
2762
2671
|
toolsSig,
|
|
2763
2672
|
jjPointer: cacheJjBase,
|
|
2764
2673
|
cacheVersion: desc.cachePolicy.version ?? null,
|
|
2674
|
+
cacheKey: desc.cachePolicy.key ?? null,
|
|
2765
2675
|
cacheBy: cachePayload ?? null,
|
|
2766
2676
|
};
|
|
2767
2677
|
}
|
|
@@ -2797,26 +2707,53 @@ async function legacyExecuteTask(adapter, db, runId, desc, descriptorMap, inputT
|
|
|
2797
2707
|
}
|
|
2798
2708
|
if (cacheKey) {
|
|
2799
2709
|
const cachedRow = await Effect.runPromise(adapter.getCache(cacheKey));
|
|
2800
|
-
if (cachedRow) {
|
|
2801
|
-
const
|
|
2802
|
-
const
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
void Effect.runPromise(Metric.increment(
|
|
2807
|
-
logInfo("cache
|
|
2710
|
+
if (cachedRow && isFreshCacheRow(cachedRow, desc.cachePolicy)) {
|
|
2711
|
+
const createdAtMs = Number(cachedRow.createdAtMs);
|
|
2712
|
+
const expired = cachePolicyTtlMs !== null &&
|
|
2713
|
+
Number.isFinite(createdAtMs) &&
|
|
2714
|
+
nowMs() - createdAtMs >= cachePolicyTtlMs;
|
|
2715
|
+
if (expired) {
|
|
2716
|
+
void Effect.runPromise(Metric.increment(cacheMisses));
|
|
2717
|
+
logInfo("cache entry expired for task output", {
|
|
2808
2718
|
runId,
|
|
2809
2719
|
nodeId: desc.nodeId,
|
|
2810
2720
|
iteration: desc.iteration,
|
|
2811
2721
|
attempt: attemptNo,
|
|
2812
2722
|
cacheKey,
|
|
2723
|
+
ttlMs: cachePolicyTtlMs,
|
|
2813
2724
|
}, "engine:task-cache");
|
|
2814
2725
|
}
|
|
2815
2726
|
else {
|
|
2816
|
-
|
|
2727
|
+
const parsed = JSON.parse(cachedRow.payloadJson);
|
|
2728
|
+
const valid = validateOutput(desc.outputTable, parsed);
|
|
2729
|
+
if (valid.ok) {
|
|
2730
|
+
payload = valid.data;
|
|
2731
|
+
cached = true;
|
|
2732
|
+
void Effect.runPromise(Metric.increment(cacheHits));
|
|
2733
|
+
logInfo("cache hit for task output", {
|
|
2734
|
+
runId,
|
|
2735
|
+
nodeId: desc.nodeId,
|
|
2736
|
+
iteration: desc.iteration,
|
|
2737
|
+
attempt: attemptNo,
|
|
2738
|
+
cacheKey,
|
|
2739
|
+
}, "engine:task-cache");
|
|
2740
|
+
}
|
|
2741
|
+
else {
|
|
2742
|
+
void Effect.runPromise(Metric.increment(cacheMisses));
|
|
2743
|
+
}
|
|
2817
2744
|
}
|
|
2818
2745
|
}
|
|
2819
2746
|
else {
|
|
2747
|
+
if (cachedRow) {
|
|
2748
|
+
logInfo("cache entry expired for task output", {
|
|
2749
|
+
runId,
|
|
2750
|
+
nodeId: desc.nodeId,
|
|
2751
|
+
iteration: desc.iteration,
|
|
2752
|
+
attempt: attemptNo,
|
|
2753
|
+
cacheKey,
|
|
2754
|
+
ttlMs: desc.cachePolicy?.ttlMs,
|
|
2755
|
+
}, "engine:task-cache");
|
|
2756
|
+
}
|
|
2820
2757
|
void Effect.runPromise(Metric.increment(cacheMisses));
|
|
2821
2758
|
}
|
|
2822
2759
|
}
|
|
@@ -4132,6 +4069,10 @@ async function renderFrameAsync(workflow, ctx, opts) {
|
|
|
4132
4069
|
rootDir: opts?.baseRootDir,
|
|
4133
4070
|
workflowPath: opts?.workflowPath,
|
|
4134
4071
|
});
|
|
4072
|
+
attachSandboxComputeFns(tasks, workflow, {
|
|
4073
|
+
rootDir: opts?.baseRootDir,
|
|
4074
|
+
workflowPath: opts?.workflowPath,
|
|
4075
|
+
});
|
|
4135
4076
|
return { runId: ctx.runId, frameNo: 0, xml: result.xml, tasks };
|
|
4136
4077
|
}
|
|
4137
4078
|
/**
|
|
@@ -5269,10 +5210,14 @@ async function runWorkflowBodyDriver(workflow, opts) {
|
|
|
5269
5210
|
const graph = await withWorkflowVersioningRuntime(workflowVersioning, () => renderer.render(element, renderOpts));
|
|
5270
5211
|
await workflowVersioning.flush();
|
|
5271
5212
|
resolveTaskOutputs(graph.tasks, workflowRef);
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5213
|
+
attachSubflowComputeFns(graph.tasks, workflowRef, {
|
|
5214
|
+
rootDir,
|
|
5215
|
+
workflowPath: resolvedWorkflowPath ?? opts.workflowPath,
|
|
5216
|
+
});
|
|
5217
|
+
attachSandboxComputeFns(graph.tasks, workflowRef, {
|
|
5218
|
+
rootDir,
|
|
5219
|
+
workflowPath: resolvedWorkflowPath ?? opts.workflowPath,
|
|
5220
|
+
});
|
|
5276
5221
|
lastGraph = graph;
|
|
5277
5222
|
descriptorMap = buildDescriptorMap(graph.tasks);
|
|
5278
5223
|
workflowName = getWorkflowNameFromXml(graph.xml);
|
|
@@ -6348,6 +6293,10 @@ async function runWorkflowBodyLegacy(workflow, opts) {
|
|
|
6348
6293
|
rootDir,
|
|
6349
6294
|
workflowPath: resolvedWorkflowPath ?? opts.workflowPath,
|
|
6350
6295
|
});
|
|
6296
|
+
attachSandboxComputeFns(tasks, workflow, {
|
|
6297
|
+
rootDir,
|
|
6298
|
+
workflowPath: resolvedWorkflowPath ?? opts.workflowPath,
|
|
6299
|
+
});
|
|
6351
6300
|
const workflowName = getWorkflowNameFromXml(xml);
|
|
6352
6301
|
updateCurrentCorrelationContext({ workflowName });
|
|
6353
6302
|
const cacheEnabled = workflow.opts.cache ??
|
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
|
|
@@ -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
|
+
}
|