@deksden-com/dd-flow-cli 0.9.0-beta.123 → 0.9.0-beta.127
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 +26 -0
- package/README.md +33 -1
- package/dist/build-info.json +7 -7
- package/dist/cli/argument-values.js +38 -0
- package/dist/cli/command-inputs.js +4 -3
- package/dist/cli/help.js +1 -0
- package/dist/cli/hook-ingress.js +1 -1
- package/dist/cli/run-cli.js +126 -193
- package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
- package/dist/cli/runtime-maintenance-ingress.js +51 -0
- package/dist/cli/runtime-process.js +128 -0
- package/dist/cli.js +8 -5
- package/dist/domain/stage-catalog.js +1 -1
- package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
- package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
- package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
- package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
- package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
- package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
- package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
- package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +159 -15
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +215 -86
- package/dist/harness-runtime/lib/dd-agy.mjs +11 -13
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +47 -23
- package/dist/harness-runtime/lib/dd-codex.mjs +257 -99
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +32 -30
- package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +96 -38
- package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +54 -20
- package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +81 -76
- package/dist/harness-runtime/lib/dd-zcode.mjs +204 -116
- package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
- package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
- package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
- package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
- package/dist/harness-runtime/lib/managed-daemon.d.mts +11 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +321 -65
- package/dist/harness-runtime/lib/native-children.d.mts +13 -0
- package/dist/harness-runtime/lib/native-children.mjs +78 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
- package/dist/harness-runtime/lib/native-streams.mjs +45 -0
- package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
- package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
- package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
- package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
- package/dist/harness-runtime/lib/process-json.mjs +136 -38
- package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
- package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
- package/dist/runtime/context.js +8 -4
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/plan-review-decision.schema.json +3 -3
- package/dist/schemas/run-control-receipt.schema.json +19 -0
- package/dist/services/code-checks.js +93 -27
- package/dist/services/codex-capacity.js +251 -74
- package/dist/services/codex-hook-delivery.js +2 -2
- package/dist/services/compatibility-preflight.js +4 -1
- package/dist/services/controller-fanout.js +9 -75
- package/dist/services/engines.js +70 -20
- package/dist/services/eval-snapshots.js +179 -40
- package/dist/services/external-work-launch.js +21 -9
- package/dist/services/harness-adapter.js +195 -25
- package/dist/services/hooks.js +65 -21
- package/dist/services/lifecycle-invocations.js +404 -32
- package/dist/services/managed-lease-monitor.js +128 -0
- package/dist/services/managed-processes.js +117 -53
- package/dist/services/merge-server.js +66 -23
- package/dist/services/recovery-observation-budget.js +67 -7
- package/dist/services/recovery-snapshot-database.js +7 -4
- package/dist/services/review-deferral.js +23 -0
- package/dist/services/run-control-receipt.js +1 -1
- package/dist/services/run-control-worker.js +177 -118
- package/dist/services/run-control.js +99 -8
- package/dist/services/run-controller-adapter.js +177 -51
- package/dist/services/run-controller-capture.js +14 -2
- package/dist/services/run-controller-process.js +11 -4
- package/dist/services/run-controller-recovery.js +17 -0
- package/dist/services/run-controller-state.js +68 -7
- package/dist/services/run-controller.js +207 -107
- package/dist/services/run-fork.js +8 -3
- package/dist/services/run-recovery-guard.js +75 -0
- package/dist/services/run-recovery-runtime.js +21 -30
- package/dist/services/run-recovery.js +17 -13
- package/dist/services/runs.js +40 -7
- package/dist/services/runtime-budget.js +32 -15
- package/dist/services/runtime-scope-control.js +8 -0
- package/dist/services/runtime-scope-resume.js +30 -8
- package/dist/services/runtime-scope-worker.js +118 -73
- package/dist/services/runtime-service.js +6 -7
- package/dist/services/scope-writer-cleanup.js +3 -2
- package/dist/services/stage-context.js +135 -0
- package/dist/services/stage-lifecycle.js +3 -15
- package/dist/services/stage-pause.js +3 -2
- package/dist/services/usage.js +14 -3
- package/dist/services/verification-matrix.js +30 -1
- package/dist/services/vnext-code-review.js +26 -13
- package/dist/services/vnext-code.js +10 -8
- package/dist/services/vnext-contracts.js +10 -1
- package/dist/services/vnext-execution-profile.js +75 -2
- package/dist/services/vnext-fanout.js +5 -0
- package/dist/services/vnext-merge.js +30 -11
- package/dist/services/vnext-plan-review.js +102 -35
- package/dist/services/vnext-plan.js +9 -7
- package/dist/services/vnext-protocolize.js +15 -9
- package/dist/services/vnext-specify.js +6 -6
- package/dist/services/work-registry.js +43 -6
- package/dist/services/workspace-bootstrap.js +14 -3
- package/dist/shared/errors.js +11 -6
- package/dist/storage/agent-output.js +26 -0
- package/dist/storage/database.js +80 -21
- package/package.json +7 -4
package/dist/services/engines.js
CHANGED
|
@@ -82,7 +82,7 @@ export function installCurrentEngine(context, input = {}) {
|
|
|
82
82
|
packageVersion: build.version,
|
|
83
83
|
integrityChecksum: stagedChecksum
|
|
84
84
|
}) : null;
|
|
85
|
-
if (duplicate) {
|
|
85
|
+
if (duplicate && manifestDiagnostics(duplicate, { checkEntrypoint: false }).length === 0) {
|
|
86
86
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
87
87
|
return {
|
|
88
88
|
ok: true,
|
|
@@ -141,7 +141,7 @@ export function doctorEngines(context, input = {}) {
|
|
|
141
141
|
const installed = readInstalledManifests(root);
|
|
142
142
|
const checks = installed.map(manifest => {
|
|
143
143
|
const diagnostics = manifestDiagnostics(manifest);
|
|
144
|
-
return { engine: manifestSummary(manifest, diagnostics), status: diagnostics.length ? "corrupt" : "ok", diagnostics };
|
|
144
|
+
return { engine: manifestSummary(manifest, diagnostics), status: diagnostics.includes("entrypoint_probe_unavailable") ? "unverified" : diagnostics.length ? "corrupt" : "ok", diagnostics };
|
|
145
145
|
});
|
|
146
146
|
const classification = input.classification ?? classifyCliOperation(["engine", "doctor"], input.env ?? context.env);
|
|
147
147
|
const selection = selectEngine(context, input, {}, classification);
|
|
@@ -223,6 +223,8 @@ export function bindRunEngine(context, input, prepared = prepareRunEngineBind(co
|
|
|
223
223
|
encoding: "utf8",
|
|
224
224
|
timeout: 30_000
|
|
225
225
|
});
|
|
226
|
+
if (probe.error || probe.signal)
|
|
227
|
+
throw new AppError("run_engine_probe_unavailable", "Selected engine observation could not complete; compatibility is unknown", 1, { run_id: located.run_id, cause: probe.error?.message, signal: probe.signal });
|
|
226
228
|
if (probe.status !== 0) {
|
|
227
229
|
throw new AppError("run_engine_probe_failed", "Selected engine cannot read the RUN authority", 1, {
|
|
228
230
|
run_id: located.run_id,
|
|
@@ -291,6 +293,8 @@ export function upgradeForkedRunEngine(context, input) {
|
|
|
291
293
|
env: { ...process.env, ...context.env, DD_FLOW_HOME: context.ddFlowHome, DD_FLOW_ENGINE_MODE: "1", DD_FLOW_ENGINE_HOME: manifest.snapshot_root },
|
|
292
294
|
encoding: "utf8", timeout: 30_000
|
|
293
295
|
});
|
|
296
|
+
if (probe.error || probe.signal)
|
|
297
|
+
throw new AppError("fork_engine_probe_unavailable", "Selected engine observation could not complete; compatibility is unknown", 1, { run_id: located.run_id, cause: probe.error?.message, signal: probe.signal });
|
|
294
298
|
if (probe.status !== 0)
|
|
295
299
|
throw new AppError("fork_engine_probe_failed", "Selected engine cannot read the forked RUN", 1, { run_id: located.run_id, engine: manifestSummary(manifest), stderr: probe.stderr.slice(0, 2000) });
|
|
296
300
|
const binding = { schema_id: runEngineBindingSchemaId, run_id: located.run_id, project_root: projectRoot, bound_at: context.now(), source: "fork_upgrade", reason: requiredText(input.reason, "reason"), engine: requested, probe: { status: "passed", command: `dd-flow ${probeArgs.join(" ")}` } };
|
|
@@ -429,7 +433,11 @@ export function selectEngine(context, input, options = {}, classification = inpu
|
|
|
429
433
|
const requiredRange = stringValue(compatibility?.engine?.version_range) ?? (classification.mode === "mb_upgrade" ? null : `>=${build.version} <9999.0.0`);
|
|
430
434
|
const recommended = stringValue(compatibility?.engine?.recommended_version) ?? build.version;
|
|
431
435
|
const installHintTemplate = stringValue(compatibility?.engine?.old_engine_install_hint) ?? `npx ${packageName}@<version> engine install`;
|
|
432
|
-
|
|
436
|
+
// An engine-mode caller already executes this module's build. Another
|
|
437
|
+
// installed engine cannot establish compatibility for its current writes.
|
|
438
|
+
// Keep full snapshot validation in routing/binding, not lease maintenance.
|
|
439
|
+
const executingCurrent = options.allowCurrentInProcess && isEngineMode(input.env ?? process.env);
|
|
440
|
+
const manifests = executingCurrent ? [] : readInstalledManifests(engineStoreRoot(context.ddFlowHome))
|
|
433
441
|
.filter((manifest) => manifest.package_name === packageName)
|
|
434
442
|
.filter((manifest) => requiredRange !== null && satisfiesRange(manifest.package_version, requiredRange))
|
|
435
443
|
.filter((manifest) => manifestHealthy(manifest))
|
|
@@ -447,7 +455,7 @@ export function selectEngine(context, input, options = {}, classification = inpu
|
|
|
447
455
|
requiredRange !== null &&
|
|
448
456
|
packageName === build.package_name &&
|
|
449
457
|
satisfiesRange(build.version, requiredRange)
|
|
450
|
-
? currentBuildManifest(context, build)
|
|
458
|
+
? currentBuildManifest(context, build, options.verifyCurrentContent !== false)
|
|
451
459
|
: null);
|
|
452
460
|
const diagnostics = [
|
|
453
461
|
...targetBlockers,
|
|
@@ -465,9 +473,38 @@ export function selectEngine(context, input, options = {}, classification = inpu
|
|
|
465
473
|
diagnostics
|
|
466
474
|
};
|
|
467
475
|
}
|
|
468
|
-
function currentBuildManifest(context, build) {
|
|
476
|
+
function currentBuildManifest(context, build, verifyContent) {
|
|
469
477
|
const packageRoot = findPackageRoot(path.dirname(sourceFile)) ?? process.cwd();
|
|
470
|
-
|
|
478
|
+
const manifestFile = path.join(packageRoot, "engine.json");
|
|
479
|
+
const installedLayout = path.basename(path.dirname(path.dirname(packageRoot))) === "engines"
|
|
480
|
+
&& path.basename(path.dirname(packageRoot)) === path.basename(path.dirname(engineVersionRoot(context.ddFlowHome, build.package_name, build.version)));
|
|
481
|
+
if (fs.existsSync(manifestFile) || installedLayout) {
|
|
482
|
+
const installed = readManifest(manifestFile);
|
|
483
|
+
// Use the executing module's physical root, never an environment alias.
|
|
484
|
+
// A damaged installed manifest must not become a development fallback.
|
|
485
|
+
if (!installed || installed.package_name !== build.package_name || installed.package_version !== build.version
|
|
486
|
+
|| installed.engine_version !== build.version || installed.engine_id !== "dd-flow-engine"
|
|
487
|
+
|| typeof installed.snapshot_root !== "string" || !fs.existsSync(installed.snapshot_root)
|
|
488
|
+
|| fs.realpathSync(installed.snapshot_root) !== fs.realpathSync(packageRoot)
|
|
489
|
+
|| typeof installed.entrypoint !== "string" || installed.integrity?.source !== "package_snapshot"
|
|
490
|
+
|| typeof installed.integrity?.checksum !== "string" || !/^[a-f0-9]{64}$/.test(installed.integrity.checksum)
|
|
491
|
+
|| (installed.schema_registry && (!Array.isArray(installed.schema_registry.entries)
|
|
492
|
+
|| installed.schema_registry.entries.some(entry => !entry || typeof entry.path !== "string"
|
|
493
|
+
|| typeof entry.id !== "string" || typeof entry.name !== "string" || !/^[a-f0-9]{64}$/.test(entry.checksum ?? ""))))) {
|
|
494
|
+
throw new AppError("unhealthy_engine", "Executing engine snapshot manifest does not match its build", 1, { manifest: manifestFile });
|
|
495
|
+
}
|
|
496
|
+
const diagnostics = manifestDiagnostics(installed, { checkEntrypoint: false, verifyContent });
|
|
497
|
+
if (diagnostics.length)
|
|
498
|
+
throw new AppError("unhealthy_engine", "Executing engine snapshot is unhealthy", 1, { manifest: manifestFile, diagnostics });
|
|
499
|
+
return installed;
|
|
500
|
+
}
|
|
501
|
+
// Only an unmaterialized package has no snapshot proof. Canon and
|
|
502
|
+
// package-manager aliases are expected in development checkouts.
|
|
503
|
+
const manifest = buildManifest(context, build.package_name, build.version, packageRoot, packageRoot, packageRoot, null);
|
|
504
|
+
// An ephemeral routing choice has no snapshot proof. Hash bytes only when
|
|
505
|
+
// materializing/validating an installed snapshot, never on every CLI call.
|
|
506
|
+
manifest.integrity.mode = "degraded_local_development";
|
|
507
|
+
return manifest;
|
|
471
508
|
}
|
|
472
509
|
export function compatibilityContexts(context, input = {}) {
|
|
473
510
|
const env = input.env ?? process.env;
|
|
@@ -614,7 +651,7 @@ function option(parsed, key) {
|
|
|
614
651
|
function positional(parsed, index) {
|
|
615
652
|
return parsed.positional[index] ?? null;
|
|
616
653
|
}
|
|
617
|
-
function buildManifest(context, packageName, version, packageRoot, snapshotRoot, checksumRoot) {
|
|
654
|
+
function buildManifest(context, packageName, version, packageRoot, snapshotRoot, checksumRoot, checksum = engineSnapshotChecksum(checksumRoot)) {
|
|
618
655
|
return {
|
|
619
656
|
schema_id: engineManifestSchemaId,
|
|
620
657
|
package_name: packageName,
|
|
@@ -635,7 +672,7 @@ function buildManifest(context, packageName, version, packageRoot, snapshotRoot,
|
|
|
635
672
|
schema_registry: buildSchemaRegistry(checksumRoot),
|
|
636
673
|
integrity: {
|
|
637
674
|
source: "package_snapshot",
|
|
638
|
-
checksum
|
|
675
|
+
checksum,
|
|
639
676
|
mode: "full_content"
|
|
640
677
|
}
|
|
641
678
|
};
|
|
@@ -703,19 +740,23 @@ export function prepareForkEngine(engineVersion, integrityChecksum) {
|
|
|
703
740
|
throw new AppError("harness_runtime_unbuilt", "Build the CLI before preparing a fork", 1);
|
|
704
741
|
const files = packageSnapshotFiles(packageRoot);
|
|
705
742
|
if (integrityChecksum !== undefined) {
|
|
706
|
-
const checksum =
|
|
707
|
-
|
|
708
|
-
if (path.basename(relative) === "engine.json")
|
|
709
|
-
continue;
|
|
710
|
-
checksum.update(relative);
|
|
711
|
-
checksum.update("\0");
|
|
712
|
-
checksum.update(fs.readFileSync(source));
|
|
713
|
-
checksum.update("\0");
|
|
714
|
-
}
|
|
715
|
-
if (checksum.digest("hex") !== integrityChecksum)
|
|
743
|
+
const checksum = snapshotFilesChecksum(files);
|
|
744
|
+
if (checksum !== integrityChecksum)
|
|
716
745
|
throw new AppError("fork_engine_unavailable", "Requested engine checksum differs from the invoking CLI artifact", 2, { requested: integrityChecksum });
|
|
717
746
|
}
|
|
718
747
|
}
|
|
748
|
+
function snapshotFilesChecksum(files) {
|
|
749
|
+
const checksum = crypto.createHash("sha256");
|
|
750
|
+
for (const [relative, source] of [...files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
751
|
+
if (path.basename(relative) === "engine.json")
|
|
752
|
+
continue;
|
|
753
|
+
checksum.update(relative);
|
|
754
|
+
checksum.update("\0");
|
|
755
|
+
checksum.update(fs.readFileSync(source));
|
|
756
|
+
checksum.update("\0");
|
|
757
|
+
}
|
|
758
|
+
return checksum.digest("hex");
|
|
759
|
+
}
|
|
719
760
|
function readProductionDependencies(packageRoot) {
|
|
720
761
|
try {
|
|
721
762
|
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
@@ -795,6 +836,9 @@ function readInstalledManifests(root) {
|
|
|
795
836
|
if (!fs.statSync(packageRoot).isDirectory())
|
|
796
837
|
continue;
|
|
797
838
|
for (const versionDir of fs.readdirSync(packageRoot)) {
|
|
839
|
+
// Failed staging, activation backups and locks are not installations.
|
|
840
|
+
if (versionDir.startsWith(".") || /\.previous-\d+-\d+$/.test(versionDir) || versionDir.endsWith(".lock"))
|
|
841
|
+
continue;
|
|
798
842
|
const manifest = readManifest(path.join(packageRoot, versionDir, "engine.json"));
|
|
799
843
|
if (manifest)
|
|
800
844
|
manifests.push(manifest);
|
|
@@ -847,7 +891,7 @@ function manifestDiagnostics(manifest, options = {}) {
|
|
|
847
891
|
if (!manifest.integrity || manifest.integrity.mode !== "full_content" || !manifest.integrity.checksum) {
|
|
848
892
|
diagnostics.push("content_checksum_missing");
|
|
849
893
|
}
|
|
850
|
-
else if (fs.existsSync(snapshotRoot)) {
|
|
894
|
+
else if (options.verifyContent !== false && fs.existsSync(snapshotRoot)) {
|
|
851
895
|
try {
|
|
852
896
|
if (engineSnapshotChecksum(snapshotRoot) !== manifest.integrity.checksum)
|
|
853
897
|
diagnostics.push("content_checksum_mismatch");
|
|
@@ -861,6 +905,8 @@ function manifestDiagnostics(manifest, options = {}) {
|
|
|
861
905
|
const schemaPath = safeResolveSnapshotPath(manifest, entry.path);
|
|
862
906
|
if (!schemaPath || !fs.existsSync(schemaPath))
|
|
863
907
|
diagnostics.push(`schema_registry_missing:${entry.name}`);
|
|
908
|
+
else if (options.verifyContent === false)
|
|
909
|
+
continue;
|
|
864
910
|
else if (fileChecksum(schemaPath) !== entry.checksum)
|
|
865
911
|
diagnostics.push(`schema_registry_checksum_mismatch:${entry.name}`);
|
|
866
912
|
else {
|
|
@@ -887,7 +933,9 @@ function manifestDiagnostics(manifest, options = {}) {
|
|
|
887
933
|
stdio: ["ignore", "pipe", "pipe"],
|
|
888
934
|
timeout: 15_000
|
|
889
935
|
});
|
|
890
|
-
if (health.
|
|
936
|
+
if (health.error || health.signal)
|
|
937
|
+
diagnostics.push("entrypoint_probe_unavailable");
|
|
938
|
+
else if (health.status !== 0)
|
|
891
939
|
diagnostics.push("entrypoint_dependency_load_failed");
|
|
892
940
|
}
|
|
893
941
|
return diagnostics;
|
|
@@ -1072,6 +1120,8 @@ function collectFiles(root, current, files) {
|
|
|
1072
1120
|
if (entry.name === "engine.json")
|
|
1073
1121
|
continue;
|
|
1074
1122
|
const full = path.join(current, entry.name);
|
|
1123
|
+
if (entry.isSymbolicLink())
|
|
1124
|
+
throw new AppError("engine_integrity_symlink", "Pinned engine snapshot contains an unhashed symbolic link", 1, { path: path.relative(root, full) });
|
|
1075
1125
|
if (entry.isDirectory())
|
|
1076
1126
|
collectFiles(root, full, files);
|
|
1077
1127
|
else if (entry.isFile())
|
|
@@ -8,10 +8,11 @@ import { beginSnapshotImport, completeSnapshotImport } from "../storage/import-g
|
|
|
8
8
|
import { foreignKeyTransaction } from "../storage/foreign-key-transaction.js";
|
|
9
9
|
import { createContext } from "../runtime/context.js";
|
|
10
10
|
import { AppError } from "../shared/errors.js";
|
|
11
|
-
import { resolveProjectRoot, runtimeProtocolDir, runtimeRunDir } from "../storage/paths.js";
|
|
11
|
+
import { canonicalPath, resolveProjectRoot, runtimeProtocolDir, runtimeRunDir } from "../storage/paths.js";
|
|
12
12
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
13
13
|
import { freezeRunExecutionRouting, getFlowRunStatus, importFlowRunExecutionProfile, startFlowRun } from "./runs.js";
|
|
14
14
|
import { loadExecutionRoutingInput, resolveExecutionPolicy } from "./execution-policy.js";
|
|
15
|
+
import { validateRunOperationalDecision } from "./vnext-execution-profile.js";
|
|
15
16
|
import { readVnextFlowDefinition } from "./vnext-specify.js";
|
|
16
17
|
import { captureRecoveryDatabase, readRecoveryResourceEvidence } from "./recovery-snapshot-database.js";
|
|
17
18
|
import { resourceHome } from "./managed-processes.js";
|
|
@@ -19,6 +20,7 @@ import { codexHomeSharedEntries } from "./hooks.js";
|
|
|
19
20
|
import { prepareOutputFile } from "../cli/input-preparation.js";
|
|
20
21
|
import { localEnvironmentAllowed, projectServicePathFilter } from "./workspace-files.js";
|
|
21
22
|
import { cleanupScopeWriters } from "./scope-writer-cleanup.js";
|
|
23
|
+
import { isObservationOperation, nativeInventoryVersion, retainedDaemonOperations } from "./run-control.js";
|
|
22
24
|
/** Capture the only flow boundary which exists before `stage start --bootstrap` creates a RUN. */
|
|
23
25
|
export function prepareEvalBootstrapSnapshot(context, input) {
|
|
24
26
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
@@ -75,7 +77,7 @@ function bootstrapSourceVersion(projectRoot) {
|
|
|
75
77
|
treeHash(projectRoot, true),
|
|
76
78
|
treeModesHash(projectRoot, true),
|
|
77
79
|
git(projectRoot, ["symbolic-ref", "--quiet", "HEAD"]),
|
|
78
|
-
|
|
80
|
+
git(projectRoot, ["rev-parse", "HEAD"]),
|
|
79
81
|
indexEntries(projectRoot).toString("base64")
|
|
80
82
|
]);
|
|
81
83
|
}
|
|
@@ -108,7 +110,7 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
108
110
|
try {
|
|
109
111
|
input.boundary?.verify();
|
|
110
112
|
sourceVersion = input.boundary ? snapshotSourceVersion(context, input) : null;
|
|
111
|
-
const result = captureEvalRunSnapshot(context, { ...input, output: temporary });
|
|
113
|
+
const result = captureEvalRunSnapshot(context, { ...input, ...(sourceVersion ? { journalCuts: sourceVersion.journals, observationCuts: sourceVersion.observations } : {}), output: temporary });
|
|
112
114
|
if (input.boundary && sourceVersion) {
|
|
113
115
|
Object.assign(result, { consistency: "controller_boundary_writer_barrier@1", boundary_capture: { controller_id: input.boundary.controllerId, operation_id: input.boundary.operationId, boundary_key: input.boundary.boundaryKey, source_sha256: sourceVersion.sha256, observation: input.boundary.observation } });
|
|
114
116
|
writeJson(path.join(temporary, "snapshot.json"), Object.fromEntries(Object.entries(result).filter(([key]) => key !== "ok" && key !== "snapshot")));
|
|
@@ -126,7 +128,7 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
126
128
|
if (sourceVersion) {
|
|
127
129
|
let after;
|
|
128
130
|
try {
|
|
129
|
-
after = snapshotSourceVersion(context, input, sourceVersion.databases);
|
|
131
|
+
after = snapshotSourceVersion(context, input, sourceVersion.databases, sourceVersion.journals);
|
|
130
132
|
}
|
|
131
133
|
catch (error) {
|
|
132
134
|
if (error.code !== "ENOENT")
|
|
@@ -145,6 +147,13 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
145
147
|
}
|
|
146
148
|
catch (error) {
|
|
147
149
|
fs.rmSync(temporary, { recursive: true, force: true });
|
|
150
|
+
// Native journal publication creates its directory before requested.json.
|
|
151
|
+
// Retry a gap during any capture phase, without classifying an incomplete
|
|
152
|
+
// request as read-only. Persistent missing inputs still exhaust the budget.
|
|
153
|
+
if (input.boundary && error.code === "ENOENT") {
|
|
154
|
+
const missing = error.path;
|
|
155
|
+
throw new AppError("snapshot_source_changed", "Snapshot source was not fully published during boundary capture", 1, { components: [missing && path.isAbsolute(missing) && isInside(context.ddFlowHome, missing) ? `runtime/${path.relative(context.ddFlowHome, missing)}` : "source_path_disappeared"] });
|
|
156
|
+
}
|
|
148
157
|
throw error;
|
|
149
158
|
}
|
|
150
159
|
finally {
|
|
@@ -204,17 +213,22 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
204
213
|
fs.mkdirSync(output, { recursive: true, mode: 0o700 });
|
|
205
214
|
const resourceDatabase = path.join(context.ddFlowHome, "runtime.sqlite");
|
|
206
215
|
const databaseFiles = [context.db.path, resourceDatabase].flatMap(file => [file, `${file}-wal`, `${file}-shm`]);
|
|
207
|
-
const codexExclusions = codexHomeSnapshotExclusions(context, project.id, input.runId);
|
|
216
|
+
const codexExclusions = [...codexHomeSnapshotExclusions(context, project.id, input.runId), ...input.journalCuts?.keys() ?? [], ...input.observationCuts?.keys() ?? []];
|
|
208
217
|
if (recovery) {
|
|
209
218
|
if (typeof status.run.run_root !== "string" || !isInside(physicalPath(context.ddFlowHome), physicalPath(status.run.run_root)) || samePath(physicalPath(context.ddFlowHome), physicalPath(status.run.run_root)))
|
|
210
219
|
throw new AppError("snapshot_run_scope_invalid", "Recovery requires a RUN artifact root within its runtime", 1);
|
|
211
220
|
for (const source of new Set([status.run.run_root, runtimeRunDir(context.ddFlowHome, project.id, input.runId)])) {
|
|
212
221
|
if (fs.existsSync(source))
|
|
213
|
-
copyRuntimeTree(source, path.join(output, "runtime", path.relative(context.ddFlowHome, source)), true, codexExclusions);
|
|
222
|
+
copyRuntimeTree(source, path.join(output, "runtime", path.relative(context.ddFlowHome, source)), true, codexExclusions, [], input.observationCuts);
|
|
214
223
|
}
|
|
215
224
|
}
|
|
216
225
|
else
|
|
217
|
-
copyRuntimeTree(context.ddFlowHome, path.join(output, "runtime"), false, [...databaseFiles, ...codexExclusions], grokProviderHomes(context.ddFlowHome));
|
|
226
|
+
copyRuntimeTree(context.ddFlowHome, path.join(output, "runtime"), false, [...databaseFiles, ...codexExclusions], grokProviderHomes(context.ddFlowHome), input.observationCuts);
|
|
227
|
+
for (const [file, cut] of input.journalCuts ?? []) {
|
|
228
|
+
const target = path.join(output, "runtime", path.relative(context.ddFlowHome, file));
|
|
229
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
230
|
+
fs.writeFileSync(target, cut.payload, { mode: cut.mode });
|
|
231
|
+
}
|
|
218
232
|
// VACUUM INTO is a consistent SQLite read snapshot, including committed WAL
|
|
219
233
|
// pages. Copying the live database and its WAL separately is not atomic.
|
|
220
234
|
const databaseCopy = path.join(output, "runtime", path.relative(context.ddFlowHome, context.db.path));
|
|
@@ -275,6 +289,8 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
275
289
|
project_sha256: treeHash(path.join(output, "project")),
|
|
276
290
|
project_payload_policy: "service-files@1",
|
|
277
291
|
...(!recovery ? { runtime_payload_policy: "flow-evidence@1" } : {}),
|
|
292
|
+
...(input.journalCuts ? { journal_cuts: [...input.journalCuts].map(([file, cut]) => ({ path: path.relative(context.ddFlowHome, file), bytes: cut.payload.length, sha256: cut.sha256 })) } : {}),
|
|
293
|
+
...(input.observationCuts ? { observation_cuts: [...input.observationCuts].map(([file, receipt]) => ({ path: path.relative(context.ddFlowHome, file), receipt })) } : {}),
|
|
278
294
|
project_git: projectGit,
|
|
279
295
|
workspace,
|
|
280
296
|
payload_modes: { project: treeModesHash(path.join(output, "project")), runtime: treeModesHash(path.join(output, "runtime")), ...(workspace.kind === "git_worktree" ? { workspace: treeModesHash(path.join(output, "workspace")) } : {}) },
|
|
@@ -325,6 +341,8 @@ export function prepareEvalRunRestore(context, input, preparedManifest) {
|
|
|
325
341
|
const recovery = manifest.purpose === "recovery";
|
|
326
342
|
if (recovery && input.executionRoutingFile !== undefined)
|
|
327
343
|
throw new AppError("snapshot_recovery_policy_frozen", "Recovery must retain its frozen execution policy", 2);
|
|
344
|
+
if (recovery && input.operationalDecisionFile !== undefined)
|
|
345
|
+
throw new AppError("snapshot_recovery_policy_frozen", "Recovery cannot replace its accepted operational decision", 2);
|
|
328
346
|
if (recovery && input.recoveryId !== manifest.recovery_id)
|
|
329
347
|
throw new AppError("snapshot_recovery_requires_explicit_restore", "Recovery snapshots require their explicit --recovery-id", 1, { snapshot });
|
|
330
348
|
if (!recovery && input.recoveryId)
|
|
@@ -340,7 +358,9 @@ export function prepareEvalRunRestore(context, input, preparedManifest) {
|
|
|
340
358
|
const sourceRuntime = path.join(snapshot, "runtime");
|
|
341
359
|
const sourceProject = path.join(snapshot, "project");
|
|
342
360
|
const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
|
|
343
|
-
|
|
361
|
+
const acceptedOperational = recovery ? undefined : prepareSnapshotOperationalDecision({ snapshot, runId: manifest.run_id, projectRoot, now: context.now(), ...(input.operationalDecisionFile ? { file: input.operationalDecisionFile } : {}) });
|
|
362
|
+
const operationalDecisionBytes = acceptedOperational?.bytes;
|
|
363
|
+
let selectedProfile = acceptedOperational?.profile;
|
|
344
364
|
if (routingInput) {
|
|
345
365
|
// Resolve selected profiles before replacing the caller's fresh runtime.
|
|
346
366
|
const sourceDb = new DatabaseSync(path.join(sourceRuntime, "db.sqlite"), { readOnly: true });
|
|
@@ -350,7 +370,7 @@ export function prepareEvalRunRestore(context, input, preparedManifest) {
|
|
|
350
370
|
if (!index?.execution_profile || !["vnext_specify", "vnext_protocolize"].includes(index.flow_kind))
|
|
351
371
|
throw new AppError("execution_profile_not_frozen", "Stage-entry routing requires a vNext execution snapshot", 1);
|
|
352
372
|
assertStageEntry(index.stage_runs, manifest.stage_entry);
|
|
353
|
-
selectedProfile = freezeRunExecutionRouting(context.ddFlowHome, index.execution_profile, routingInput);
|
|
373
|
+
selectedProfile = freezeRunExecutionRouting(context.ddFlowHome, selectedProfile ?? index.execution_profile, routingInput);
|
|
354
374
|
for (const row of sourceDb.prepare("SELECT payload_json FROM works WHERE run_id = ? AND status = 'created'").all(manifest.run_id)) {
|
|
355
375
|
const payload = row.payload_json ? JSON.parse(row.payload_json) : null;
|
|
356
376
|
if (payload?.execution_override !== undefined)
|
|
@@ -368,7 +388,34 @@ export function prepareEvalRunRestore(context, input, preparedManifest) {
|
|
|
368
388
|
sourceDb.close();
|
|
369
389
|
}
|
|
370
390
|
}
|
|
371
|
-
return { snapshot, manifest, recovery, projectRoot, sourceRuntime, sourceProject, routingInput, selectedProfile };
|
|
391
|
+
return { snapshot, manifest, recovery, projectRoot, sourceRuntime, sourceProject, routingInput, selectedProfile, operationalDecisionBytes };
|
|
392
|
+
}
|
|
393
|
+
/** Shared no-effect scope preparation for direct restore and run fork. */
|
|
394
|
+
export function prepareSnapshotOperationalDecision(input) {
|
|
395
|
+
const sourceDb = new DatabaseSync(path.join(input.snapshot, "runtime", "db.sqlite"), { readOnly: true });
|
|
396
|
+
try {
|
|
397
|
+
const row = sourceDb.prepare("SELECT index_json FROM runs WHERE id = ?").get(input.runId);
|
|
398
|
+
const profile = row ? JSON.parse(row.index_json).execution_profile : undefined;
|
|
399
|
+
if (profile?.operational_decision && !input.file)
|
|
400
|
+
throw new AppError("snapshot_operational_acceptance_required", "A new stage-entry execution needs its own accepted operational decision", 2);
|
|
401
|
+
if (!input.file)
|
|
402
|
+
return undefined;
|
|
403
|
+
if (!profile)
|
|
404
|
+
throw new AppError("execution_profile_not_frozen", "Operational stage-entry decision needs a frozen vNext profile", 2);
|
|
405
|
+
const bytes = fs.readFileSync(input.file);
|
|
406
|
+
const decision = validateRunOperationalDecision(JSON.parse(bytes.toString("utf8")), profile.settings);
|
|
407
|
+
if (canonicalPath(decision.scope.project_root, false) !== canonicalPath(input.projectRoot, false))
|
|
408
|
+
throw new AppError("operational_decision_scope_mismatch", "Imported decision must name the new project root", 2);
|
|
409
|
+
const sourceProject = path.join(input.snapshot, "project"), policyFile = path.resolve(sourceProject, decision.policy.path);
|
|
410
|
+
if (!isInside(sourceProject, policyFile) || !fs.existsSync(policyFile) || !isInside(fs.realpathSync(sourceProject), fs.realpathSync(policyFile)) || fileHash(policyFile) !== decision.policy.sha256)
|
|
411
|
+
throw new AppError("operational_decision_policy_changed", "Imported decision policy must match the owned frozen project source", 2);
|
|
412
|
+
if (decision.exception?.expires_at && Date.parse(decision.exception.expires_at) <= Date.parse(input.now))
|
|
413
|
+
throw new AppError("operational_decision_expired", "Imported operational decision has explicitly expired", 2);
|
|
414
|
+
return { profile: { ...profile, operational_decision: decision }, bytes };
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
sourceDb.close();
|
|
418
|
+
}
|
|
372
419
|
}
|
|
373
420
|
function assertRunRestoreTargets(context, input, snapshot, projectRoot, manifest) {
|
|
374
421
|
assertRestoreTargetSeparation(snapshot, [projectRoot, context.ddFlowHome]);
|
|
@@ -392,6 +439,8 @@ function assertRunRestoreTargets(context, input, snapshot, projectRoot, manifest
|
|
|
392
439
|
}
|
|
393
440
|
export function restoreEvalRunSnapshot(context, input, prepared = prepareEvalRunRestore(context, input), prearmedImportId) {
|
|
394
441
|
const { snapshot, manifest, recovery, projectRoot, sourceRuntime, sourceProject, routingInput, selectedProfile } = prepared;
|
|
442
|
+
if (input.operationalDecisionFile && (!prepared.operationalDecisionBytes || !fs.readFileSync(input.operationalDecisionFile).equals(prepared.operationalDecisionBytes)))
|
|
443
|
+
throw new AppError("operational_decision_changed", "Accepted operational decision changed after import preparation", 2);
|
|
395
444
|
assertRunRestoreTargets(context, input, snapshot, projectRoot, manifest);
|
|
396
445
|
const sourceManifestSha256 = fileHash(path.join(snapshot, "snapshot.json"));
|
|
397
446
|
const importId = prearmedImportId ?? beginSnapshotImport({ home: context.ddFlowHome, projectRoot,
|
|
@@ -414,18 +463,36 @@ export function restoreEvalRunSnapshot(context, input, prepared = prepareEvalRun
|
|
|
414
463
|
const publicationRun = restored.db.get("SELECT project_id, run_root FROM runs WHERE id = ?", [manifest.run_id]);
|
|
415
464
|
if (!originalRunRoot || !publicationRun?.run_root || !isInside(manifest.dd_flow_home, originalRunRoot) || !isInside(context.ddFlowHome, publicationRun.run_root))
|
|
416
465
|
throw new AppError("snapshot_invalid", "Restored publication roots are not bound to their runtime", 1);
|
|
417
|
-
|
|
466
|
+
const pathMapping = [{ from: manifest.dd_flow_home, to: context.ddFlowHome }, { from: sourceWorkspaceRoot(manifest), to: workspaceRoot }, { from: manifest.project_root, to: projectRoot }];
|
|
467
|
+
restored.db.run("INSERT INTO audit_events (project_id, event_type, forced, payload_json, created_at) VALUES (?, 'flow_run.publication_relocation', 0, ?, ?)", [publicationRun.project_id, JSON.stringify({ run_id: manifest.run_id, from: originalRunRoot, to: publicationRun.run_root, source_manifest_sha256: sourceManifestSha256, import_id: importId, path_mapping: pathMapping }), restored.now()]);
|
|
418
468
|
registerProject(restored, { root: projectRoot });
|
|
419
469
|
requireProjectByRoot(restored, projectRoot);
|
|
420
|
-
if (selectedProfile &&
|
|
470
|
+
if (selectedProfile && runtimeLineage) {
|
|
421
471
|
const run = restored.db.get("SELECT run_root FROM runs WHERE id = ?", [manifest.run_id]);
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
if (
|
|
425
|
-
|
|
472
|
+
if (routingInput) {
|
|
473
|
+
const artifact = path.join(run.run_root, selectedProfile.routing_input.artifact);
|
|
474
|
+
if (fs.existsSync(artifact)) {
|
|
475
|
+
if (!fs.readFileSync(artifact).equals(routingInput.bytes))
|
|
476
|
+
throw new AppError("snapshot_checksum_mismatch", "Existing routing input does not match its checksum", 1);
|
|
477
|
+
}
|
|
478
|
+
else
|
|
479
|
+
fs.writeFileSync(artifact, routingInput.bytes, { flag: "wx", mode: 0o600 });
|
|
480
|
+
}
|
|
481
|
+
if (input.operationalDecisionFile) {
|
|
482
|
+
if (!prepared.operationalDecisionBytes || !fs.readFileSync(input.operationalDecisionFile).equals(prepared.operationalDecisionBytes))
|
|
483
|
+
throw new AppError("operational_decision_changed", "Accepted operational decision changed before import publication", 2);
|
|
484
|
+
const binding = { project_id: publicationRun.project_id, run_id: manifest.run_id, ...selectedProfile.operational_decision.scope };
|
|
485
|
+
const policyBytes = fs.readFileSync(path.resolve(projectRoot, selectedProfile.operational_decision.policy.path));
|
|
486
|
+
const policySource = { path: `operational-policy.${selectedProfile.operational_decision.policy.sha256}.source.md`, sha256: crypto.createHash("sha256").update(policyBytes).digest("hex") };
|
|
487
|
+
if (policySource.sha256 !== selectedProfile.operational_decision.policy.sha256)
|
|
488
|
+
throw new AppError("operational_decision_policy_changed", "Imported policy changed before binding", 2);
|
|
489
|
+
if (!fs.existsSync(path.join(run.run_root, policySource.path)))
|
|
490
|
+
fs.writeFileSync(path.join(run.run_root, policySource.path), policyBytes, { flag: "wx" });
|
|
491
|
+
const bytes = `${JSON.stringify({ schema_id: "dd-flow/run-operational-contract@1", binding, settings: { merge_delivery: selectedProfile.settings.merge_delivery, merge_cleanup: selectedProfile.settings.merge_cleanup }, decision: selectedProfile.operational_decision, policy_source: policySource }, null, 2)}\n`;
|
|
492
|
+
const artifact = `operational-contract.${crypto.createHash("sha256").update(bytes).digest("hex")}.json`;
|
|
493
|
+
fs.writeFileSync(path.join(run.run_root, artifact), bytes, { flag: "wx" });
|
|
494
|
+
selectedProfile.operational_contract = { path: artifact, sha256: crypto.createHash("sha256").update(bytes).digest("hex"), policy_source: policySource, binding };
|
|
426
495
|
}
|
|
427
|
-
else
|
|
428
|
-
fs.writeFileSync(artifact, routingInput.bytes, { flag: "wx", mode: 0o600 });
|
|
429
496
|
importFlowRunExecutionProfile(restored, { projectRoot, runId: manifest.run_id, stage: manifest.stage_entry, lineageFile: runtimeLineage, profile: selectedProfile });
|
|
430
497
|
}
|
|
431
498
|
const relocation = recovery ? {
|
|
@@ -637,13 +704,29 @@ function rewriteOperationalRouteReceipts(root, oldRoot, newRoot, oldWorkspace, n
|
|
|
637
704
|
};
|
|
638
705
|
visit(root);
|
|
639
706
|
}
|
|
640
|
-
function rewriteDatabaseColumn(context, table, column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
707
|
+
function rewriteDatabaseColumn(context, table, column, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
|
|
708
|
+
for (const row of context.db.all(`SELECT rowid AS rid, ${column} AS value FROM ${table} WHERE ${column} IS NOT NULL`)) {
|
|
709
|
+
if (!row.value)
|
|
710
|
+
continue;
|
|
711
|
+
let value;
|
|
712
|
+
if (column.endsWith("_json") || column === "index_json") {
|
|
713
|
+
const original = JSON.parse(row.value);
|
|
714
|
+
const mapped = rebaseStructuredValue(original, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
715
|
+
if (table === "runs" && column === "index_json" && original.execution_profile) {
|
|
716
|
+
// Accepted source/binding are logical provenance, not mutable physical locators.
|
|
717
|
+
if (original.execution_profile.operational_decision)
|
|
718
|
+
mapped.execution_profile.operational_decision = original.execution_profile.operational_decision;
|
|
719
|
+
if (original.execution_profile.operational_contract)
|
|
720
|
+
mapped.execution_profile.operational_contract = original.execution_profile.operational_contract;
|
|
721
|
+
}
|
|
722
|
+
value = JSON.stringify(mapped);
|
|
723
|
+
}
|
|
724
|
+
else
|
|
725
|
+
value = rebasePathString(row.value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
726
|
+
if (value !== row.value)
|
|
727
|
+
context.db.run(`UPDATE ${table} SET ${column} = ? WHERE rowid = ?`, [value, row.rid]);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
647
730
|
function rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) { if (typeof value === "string")
|
|
648
731
|
return rebasePathString(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome); if (Array.isArray(value))
|
|
649
732
|
return value.map((item) => rebaseStructuredValue(item, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome)); if (value && typeof value === "object")
|
|
@@ -720,7 +803,7 @@ function prepareSnapshotProjectGit(projectRoot) {
|
|
|
720
803
|
function snapshotProjectGit(projectRoot, output) {
|
|
721
804
|
const { branch, head } = prepareSnapshotProjectGit(projectRoot);
|
|
722
805
|
const bundle = path.join(output, "project.bundle");
|
|
723
|
-
runGit(projectRoot, ["bundle", "create", bundle,
|
|
806
|
+
runGit(projectRoot, ["bundle", "create", bundle, `refs/heads/${branch}`]);
|
|
724
807
|
return { branch, head, bundle_sha256: fileHash(bundle), ...snapshotIndex(projectRoot, output, "project") };
|
|
725
808
|
}
|
|
726
809
|
function restoreProjectGit(snapshot, source, projectRoot) {
|
|
@@ -910,6 +993,17 @@ function grokProviderHomes(runtimeRoot) {
|
|
|
910
993
|
visit(runtimeRoot);
|
|
911
994
|
return homes;
|
|
912
995
|
}
|
|
996
|
+
function nativeDaemonFile(file) {
|
|
997
|
+
if (!fs.lstatSync(file, { throwIfNoEntry: false })?.isFile())
|
|
998
|
+
return false;
|
|
999
|
+
try {
|
|
1000
|
+
const state = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1001
|
+
return typeof state.daemon_id === "string" && /^dd-(codex|zcode|grok|agy|opencode|droid)\/daemon-state@1$/.test(String(state.schema_id));
|
|
1002
|
+
}
|
|
1003
|
+
catch {
|
|
1004
|
+
return false;
|
|
1005
|
+
} // Unrecognized files retain full-byte protection.
|
|
1006
|
+
}
|
|
913
1007
|
function sqliteFile(file) {
|
|
914
1008
|
if (!fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
915
1009
|
return false;
|
|
@@ -943,7 +1037,7 @@ function sqliteValue(value) {
|
|
|
943
1037
|
* This intentionally applies to the small set of managed SQLite files, not
|
|
944
1038
|
* to arbitrary provider databases.
|
|
945
1039
|
*/
|
|
946
|
-
function sqliteSemanticFingerprint(db
|
|
1040
|
+
function sqliteSemanticFingerprint(db) {
|
|
947
1041
|
db.exec("PRAGMA busy_timeout = 4000");
|
|
948
1042
|
db.exec("BEGIN");
|
|
949
1043
|
try {
|
|
@@ -953,9 +1047,8 @@ function sqliteSemanticFingerprint(db, resourceRegistry = false) {
|
|
|
953
1047
|
.sort((left, right) => Number(left.cid ?? 0) - Number(right.cid ?? 0))
|
|
954
1048
|
.map(column => ({ name: String(column.name ?? ""), type: String(column.type ?? ""), notnull: Number(column.notnull ?? 0), default: sqliteValue(column.dflt_value ?? null), pk: Number(column.pk ?? 0) }));
|
|
955
1049
|
const names = columns.map(column => column.name);
|
|
956
|
-
const stableNames = resourceRegistry && row.name === "managed_processes" ? names.filter(name => name !== "lease_expires_at" && name !== "updated_at") : names;
|
|
957
1050
|
const rows = db.prepare(`SELECT * FROM ${sqliteIdentifier(row.name)}`).all()
|
|
958
|
-
.map(record =>
|
|
1051
|
+
.map(record => names.map(name => [name, sqliteValue(record[name])]))
|
|
959
1052
|
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
960
1053
|
return { name: row.name, columns, rows };
|
|
961
1054
|
});
|
|
@@ -983,8 +1076,9 @@ function runtimeSnapshotKind(file, excluded, providerHomes = []) {
|
|
|
983
1076
|
/** Productive writers are fenced separately. Compare exactly the copied
|
|
984
1077
|
* source set; SQLite serialization includes committed WAL pages without
|
|
985
1078
|
* mistaking a physical checkpoint for new logical content. */
|
|
986
|
-
function snapshotSourceVersion(context, input, retainedDatabases) {
|
|
1079
|
+
function snapshotSourceVersion(context, input, retainedDatabases, retainedJournals) {
|
|
987
1080
|
const databases = retainedDatabases ?? new Map();
|
|
1081
|
+
const journals = retainedJournals ?? new Map();
|
|
988
1082
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
989
1083
|
const project = requireProjectByRoot(context, projectRoot);
|
|
990
1084
|
const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
@@ -993,34 +1087,62 @@ function snapshotSourceVersion(context, input, retainedDatabases) {
|
|
|
993
1087
|
const entries = new Map();
|
|
994
1088
|
const excluded = codexHomeSnapshotExclusions(context, project.id, input.runId);
|
|
995
1089
|
const providerHomes = input.recoveryId === undefined ? grokProviderHomes(context.ddFlowHome) : [];
|
|
1090
|
+
const observations = new Map();
|
|
1091
|
+
const operationRoots = new Set();
|
|
996
1092
|
const visit = (directory) => {
|
|
1093
|
+
const daemon = path.join(directory, "daemon.json");
|
|
1094
|
+
const native = nativeDaemonFile(daemon);
|
|
1095
|
+
if (native) {
|
|
1096
|
+
operationRoots.add(path.join(directory, "operations"));
|
|
1097
|
+
for (const operation of retainedDaemonOperations(directory))
|
|
1098
|
+
if (isObservationOperation(operation.operation))
|
|
1099
|
+
observations.set(path.join(directory, "operations", crypto.createHash("sha256").update(String(operation.operation_id)).digest("hex")), operation);
|
|
1100
|
+
}
|
|
997
1101
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
998
1102
|
const file = path.join(directory, entry.name);
|
|
1103
|
+
// These diagnostic cuts are retained, but their creation cannot
|
|
1104
|
+
// invalidate a productive boundary. Unknown operations remain protected.
|
|
1105
|
+
if (observations.has(file))
|
|
1106
|
+
continue;
|
|
999
1107
|
const kind = runtimeSnapshotKind(file, excluded, providerHomes);
|
|
1000
1108
|
if (kind === "excluded" || kind === "sidecar")
|
|
1001
1109
|
continue;
|
|
1002
1110
|
const key = `runtime/${path.relative(context.ddFlowHome, file)}`;
|
|
1003
1111
|
const stat = fs.lstatSync(file);
|
|
1004
1112
|
if (stat.isDirectory()) {
|
|
1005
|
-
|
|
1113
|
+
if (!operationRoots.has(file))
|
|
1114
|
+
entries.set(key, "directory");
|
|
1006
1115
|
visit(file);
|
|
1007
1116
|
}
|
|
1008
1117
|
else if (stat.isSymbolicLink())
|
|
1009
1118
|
entries.set(key, `link:${fs.readlinkSync(file)}`);
|
|
1119
|
+
else if (native && file === daemon)
|
|
1120
|
+
entries.set(key, `daemon:${nativeInventoryVersion(directory)}:${stat.mode & 0o111}`);
|
|
1010
1121
|
else if (kind === "sqlite") {
|
|
1122
|
+
// Their productive identities are checked by boundary.verify(). A
|
|
1123
|
+
// consistent backup is evidence, not a demand for global DB silence.
|
|
1124
|
+
if (file === context.db.path || file === path.join(resourceHome(context), "runtime.sqlite")) {
|
|
1125
|
+
entries.set(key, `managed-sqlite:${stat.mode & 0o111}`);
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1011
1128
|
let db = databases.get(file);
|
|
1012
1129
|
if (!db) {
|
|
1013
1130
|
db = new DatabaseSync(file, { readOnly: true });
|
|
1014
1131
|
databases.set(file, db);
|
|
1015
1132
|
}
|
|
1016
|
-
// Resource heartbeats are copied consistently but are not productive
|
|
1017
|
-
// RUN writers; owner identity is fenced by boundary.verify().
|
|
1018
|
-
if (file === path.join(resourceHome(context), "runtime.sqlite")) {
|
|
1019
|
-
entries.set(key, `resource-sqlite:${sqliteSemanticFingerprint(db, true)}:${stat.mode & 0o111}`);
|
|
1020
|
-
continue;
|
|
1021
|
-
}
|
|
1022
1133
|
entries.set(key, `sqlite:${sqliteSemanticFingerprint(db)}:${stat.mode & 0o111}`);
|
|
1023
1134
|
}
|
|
1135
|
+
else if (stat.isFile() && ["adapter.events.jsonl", "events.jsonl", "model-observations.jsonl", "timeline.jsonl", "daemon.log", "controller.log"].includes(path.basename(file))) {
|
|
1136
|
+
const bytes = fs.readFileSync(file);
|
|
1137
|
+
let cut = journals.get(file);
|
|
1138
|
+
if (!cut && !retainedJournals) {
|
|
1139
|
+
const payload = bytes.subarray(0, bytes.lastIndexOf(10) + 1);
|
|
1140
|
+
cut = { payload, dev: stat.dev, ino: stat.ino, mode: stat.mode, sha256: crypto.createHash("sha256").update(payload).digest("hex") };
|
|
1141
|
+
journals.set(file, cut);
|
|
1142
|
+
}
|
|
1143
|
+
const valid = cut && cut.dev === stat.dev && cut.ino === stat.ino && bytes.length >= cut.payload.length && bytes.subarray(0, cut.payload.length).equals(cut.payload);
|
|
1144
|
+
entries.set(key, valid && cut ? `journal:${cut.sha256}:${cut.payload.length}:${stat.mode & 0o111}` : "journal:changed");
|
|
1145
|
+
}
|
|
1024
1146
|
else if (stat.isFile())
|
|
1025
1147
|
entries.set(key, `file:${crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex")}:${stat.mode & 0o111}`);
|
|
1026
1148
|
}
|
|
@@ -1029,9 +1151,9 @@ function snapshotSourceVersion(context, input, retainedDatabases) {
|
|
|
1029
1151
|
visit(context.ddFlowHome);
|
|
1030
1152
|
for (const root of new Set([projectRoot, run.workspace_root]))
|
|
1031
1153
|
entries.set(`source/${root}`, JSON.stringify([
|
|
1032
|
-
treeHash(root, true), treeModesHash(root, true), git(root, ["symbolic-ref", "--quiet", "HEAD"]),
|
|
1154
|
+
treeHash(root, true), treeModesHash(root, true), git(root, ["symbolic-ref", "--quiet", "HEAD"]), git(root, ["rev-parse", "HEAD"]), indexEntries(root).toString("base64")
|
|
1033
1155
|
]));
|
|
1034
|
-
return { entries, databases, sha256: crypto.createHash("sha256").update(JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b)))).digest("hex") };
|
|
1156
|
+
return { entries, databases, journals, observations, sha256: crypto.createHash("sha256").update(JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b)))).digest("hex") };
|
|
1035
1157
|
}
|
|
1036
1158
|
catch (error) {
|
|
1037
1159
|
if (!retainedDatabases)
|
|
@@ -1083,7 +1205,7 @@ function codexHomeSnapshotExclusions(context, projectId, runId) {
|
|
|
1083
1205
|
return excluded;
|
|
1084
1206
|
}
|
|
1085
1207
|
/** Copy a dedicated home or scoped recovery tree, preserving SQLite WAL commits. */
|
|
1086
|
-
function copyRuntimeTree(source, destination, scoped = true, excluded = [], providerHomes = []) {
|
|
1208
|
+
function copyRuntimeTree(source, destination, scoped = true, excluded = [], providerHomes = [], observationCuts) {
|
|
1087
1209
|
if (fs.lstatSync(source).isSymbolicLink())
|
|
1088
1210
|
throw new AppError("snapshot_runtime_symlink_outside_scope", "Recovery artifact roots must not be symbolic links", 1, { path: source });
|
|
1089
1211
|
const root = physicalPath(source);
|
|
@@ -1094,6 +1216,23 @@ function copyRuntimeTree(source, destination, scoped = true, excluded = [], prov
|
|
|
1094
1216
|
fs.cpSync(source, destination, {
|
|
1095
1217
|
recursive: true, verbatimSymlinks: true,
|
|
1096
1218
|
filter: file => {
|
|
1219
|
+
if (observationCuts && path.basename(path.dirname(file)) === "operations" && fs.lstatSync(file).isDirectory() && nativeDaemonFile(path.join(path.dirname(path.dirname(file)), "daemon.json"))) {
|
|
1220
|
+
let operations;
|
|
1221
|
+
try {
|
|
1222
|
+
operations = retainedDaemonOperations(path.dirname(path.dirname(file)));
|
|
1223
|
+
}
|
|
1224
|
+
catch (error) {
|
|
1225
|
+
if (error.code !== "ENOENT")
|
|
1226
|
+
throw error;
|
|
1227
|
+
throw new AppError("snapshot_source_changed", "Native operation was not fully published during capture", 1, { components: ["native_operation_publication"] });
|
|
1228
|
+
}
|
|
1229
|
+
const operation = operations.find(row => crypto.createHash("sha256").update(String(row.operation_id)).digest("hex") === path.basename(file));
|
|
1230
|
+
if (operation && isObservationOperation(operation.operation)) {
|
|
1231
|
+
if (!observationCuts.has(file))
|
|
1232
|
+
observationCuts.set(file, operation);
|
|
1233
|
+
return false;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1097
1236
|
const kind = runtimeSnapshotKind(file, excluded, providerHomes);
|
|
1098
1237
|
if (kind === "excluded" || kind === "sidecar")
|
|
1099
1238
|
return false;
|