@deksden-com/dd-flow-cli 0.9.0-beta.104 → 0.9.0-beta.106
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 +12 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/run-cli.js +23 -8
- package/dist/harness-runtime/bin/dd-agy.mjs +1 -1
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -3
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +3 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +10 -1
- package/dist/protocol/local-files.js +18 -9
- package/dist/runtime/context.js +2 -2
- package/dist/services/cleanup.js +81 -32
- package/dist/services/code-checks.js +18 -19
- package/dist/services/continuation-outcome.js +1 -1
- package/dist/services/controller-fanout.js +12 -4
- package/dist/services/eval-snapshots.js +69 -41
- package/dist/services/execution-policy.js +12 -1
- package/dist/services/external-work-launch.js +2 -1
- package/dist/services/hooks.js +153 -43
- package/dist/services/lanes.js +23 -18
- package/dist/services/lifecycle-command.js +11 -2
- package/dist/services/lifecycle-invocations.js +291 -54
- package/dist/services/managed-processes.js +35 -23
- package/dist/services/merge-queue.js +71 -67
- package/dist/services/merge-server.js +5 -12
- package/dist/services/migrations.js +28 -12
- package/dist/services/plan-runtime.js +8 -4
- package/dist/services/projects.js +3 -11
- package/dist/services/protocols.js +98 -68
- package/dist/services/recovery-snapshot-database.js +43 -8
- package/dist/services/run-control-worker.js +15 -32
- package/dist/services/run-control.js +3 -17
- package/dist/services/run-controller-capture.js +58 -27
- package/dist/services/run-controller-process.js +21 -30
- package/dist/services/run-controller-recovery.js +36 -37
- package/dist/services/run-controller.js +83 -88
- package/dist/services/run-fork.js +10 -6
- package/dist/services/run-projection.js +22 -64
- package/dist/services/run-recovery-runtime.js +25 -14
- package/dist/services/run-recovery.js +56 -54
- package/dist/services/runs.js +218 -160
- package/dist/services/runtime-budget.js +19 -45
- package/dist/services/runtime-scope-capture.js +8 -6
- package/dist/services/runtime-scope-control.js +49 -39
- package/dist/services/runtime-scope-resume.js +134 -122
- package/dist/services/runtime-scope-worker.js +48 -49
- package/dist/services/scope-writer-cleanup.js +26 -0
- package/dist/services/stage-context.js +6 -1
- package/dist/services/stage-lifecycle.js +1 -1
- package/dist/services/stage-pause.js +13 -29
- package/dist/services/stage-report-renderer.js +116 -6
- package/dist/services/usage.js +2 -9
- package/dist/services/vnext-code-review.js +10 -7
- package/dist/services/vnext-code.js +9 -17
- package/dist/services/vnext-merge.js +64 -21
- package/dist/services/vnext-plan-review.js +43 -30
- package/dist/services/vnext-plan.js +28 -24
- package/dist/services/vnext-protocolize.js +183 -20
- package/dist/services/vnext-specify.js +27 -19
- package/dist/services/work-registry.js +229 -150
- package/dist/storage/database.js +286 -56
- package/dist/storage/foreign-key-transaction.js +39 -0
- package/dist/storage/immutable-input.js +45 -0
- package/dist/storage/import-guard.js +102 -0
- package/dist/storage/native-cleanup.js +52 -0
- package/dist/storage/projection-lock.js +73 -0
- package/dist/storage/writer-contract.js +3 -3
- package/dist/storage/writer-migration.js +24 -41
- package/package.json +1 -1
- package/tools/repair-paused-run-status.mjs +48 -23
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
/** Outside the replaced runtime tree, so a copied source receipt cannot make
|
|
6
|
+
* an incomplete target look ready. A failed target is diagnostic evidence. */
|
|
7
|
+
export function importGuardPath(home) {
|
|
8
|
+
const absolute = canonicalHome(home);
|
|
9
|
+
return path.join(path.dirname(absolute), `.${path.basename(absolute)}.dd-flow-import.json`);
|
|
10
|
+
}
|
|
11
|
+
function canonicalHome(home) {
|
|
12
|
+
let existing = path.resolve(home);
|
|
13
|
+
const suffix = [];
|
|
14
|
+
while (!fs.existsSync(existing)) {
|
|
15
|
+
suffix.unshift(path.basename(existing));
|
|
16
|
+
existing = path.dirname(existing);
|
|
17
|
+
}
|
|
18
|
+
return path.join(fs.realpathSync(existing), ...suffix);
|
|
19
|
+
}
|
|
20
|
+
function readImportGuard(home) {
|
|
21
|
+
const file = importGuardPath(home);
|
|
22
|
+
if (!fs.existsSync(file))
|
|
23
|
+
return null;
|
|
24
|
+
let guard;
|
|
25
|
+
try {
|
|
26
|
+
guard = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new AppError("snapshot_import_incomplete", "Snapshot import guard is unreadable; preserve this target for diagnosis", 1, { guard_path: file });
|
|
30
|
+
}
|
|
31
|
+
if (guard.schema_id !== "dd-flow/import-guard@1" || guard.runtime_home !== canonicalHome(home)
|
|
32
|
+
|| !/^[0-9a-f-]{36}$/.test(guard.import_id) || !/^[a-f0-9]{64}$/.test(guard.source_manifest_sha256)
|
|
33
|
+
|| !path.isAbsolute(guard.project_root) || !path.isAbsolute(guard.workspace_root)
|
|
34
|
+
|| !["in_progress", "completed"].includes(guard.status)) {
|
|
35
|
+
throw new AppError("snapshot_import_incomplete", "Snapshot import guard does not identify this target", 1, { guard_path: file });
|
|
36
|
+
}
|
|
37
|
+
return guard;
|
|
38
|
+
}
|
|
39
|
+
export function assertImportAdmission(home, permit) {
|
|
40
|
+
const guard = readImportGuard(home);
|
|
41
|
+
if (guard?.status === "in_progress" && guard.import_id !== permit)
|
|
42
|
+
throw new AppError("snapshot_import_incomplete", "Snapshot import has not completed; preserve this target for diagnosis and restore into fresh targets", 1, { guard_path: importGuardPath(home), import_id: guard.import_id });
|
|
43
|
+
}
|
|
44
|
+
function durableWrite(file, bytes, create) {
|
|
45
|
+
const target = create ? file : `${file}.tmp-${crypto.randomUUID()}`;
|
|
46
|
+
try {
|
|
47
|
+
fs.writeFileSync(target, bytes, { flag: "wx", mode: 0o600 });
|
|
48
|
+
const descriptor = fs.openSync(target, "r");
|
|
49
|
+
try {
|
|
50
|
+
fs.fsyncSync(descriptor);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
fs.closeSync(descriptor);
|
|
54
|
+
}
|
|
55
|
+
if (!create)
|
|
56
|
+
fs.renameSync(target, file);
|
|
57
|
+
const directory = fs.openSync(path.dirname(file), "r");
|
|
58
|
+
try {
|
|
59
|
+
fs.fsyncSync(directory);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
fs.closeSync(directory);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
if (!create)
|
|
67
|
+
fs.rmSync(target, { force: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function beginSnapshotImport(input) {
|
|
71
|
+
const importId = crypto.randomUUID();
|
|
72
|
+
const guard = { schema_id: "dd-flow/import-guard@1", import_id: importId, status: "in_progress", source_manifest_sha256: input.sourceManifestSha256,
|
|
73
|
+
runtime_home: canonicalHome(input.home), project_root: path.resolve(input.projectRoot), workspace_root: path.resolve(input.workspaceRoot) };
|
|
74
|
+
durableWrite(importGuardPath(input.home), `${JSON.stringify(guard)}\n`, true);
|
|
75
|
+
return importId;
|
|
76
|
+
}
|
|
77
|
+
/** A fork with its own matching intent may rebuild a completed import when
|
|
78
|
+
* its final fork receipt was lost. Fence the existing home before removing any
|
|
79
|
+
* copied files; an interrupted rebuild remains closed to productive opens. */
|
|
80
|
+
export function rearmCompletedSnapshotImport(input) {
|
|
81
|
+
const guard = readImportGuard(input.home);
|
|
82
|
+
if (!guard)
|
|
83
|
+
return null;
|
|
84
|
+
if (guard.status !== "completed" || guard.project_root !== path.resolve(input.projectRoot)
|
|
85
|
+
|| guard.workspace_root !== path.resolve(input.workspaceRoot)
|
|
86
|
+
|| guard.source_manifest_sha256 !== input.sourceManifestSha256) {
|
|
87
|
+
throw new AppError("snapshot_import_incomplete", "Existing import does not match this completed fork; preserve it for diagnosis", 1, { guard_path: importGuardPath(input.home) });
|
|
88
|
+
}
|
|
89
|
+
const importId = crypto.randomUUID();
|
|
90
|
+
durableWrite(importGuardPath(input.home), `${JSON.stringify({ ...guard, import_id: importId, status: "in_progress" })}\n`, false);
|
|
91
|
+
return importId;
|
|
92
|
+
}
|
|
93
|
+
export function completeSnapshotImport(home, importId, completed) {
|
|
94
|
+
const guard = readImportGuard(home);
|
|
95
|
+
if (!guard || guard.import_id !== importId || guard.status !== "in_progress"
|
|
96
|
+
|| guard.project_root !== path.resolve(completed.projectRoot)
|
|
97
|
+
|| guard.workspace_root !== path.resolve(completed.workspaceRoot)
|
|
98
|
+
|| guard.source_manifest_sha256 !== completed.sourceManifestSha256) {
|
|
99
|
+
throw new AppError("snapshot_import_incomplete", "Import completion does not match its source and target paths", 1, { guard_path: importGuardPath(home) });
|
|
100
|
+
}
|
|
101
|
+
durableWrite(importGuardPath(home), `${JSON.stringify({ ...guard, status: "completed" })}\n`, false);
|
|
102
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
2
|
+
/** Cleanup must not replace the operation's primary failure, including thrown null/undefined. */
|
|
3
|
+
export function withStoreCleanup(operation, cleanup, details = () => ({})) {
|
|
4
|
+
let failed = false;
|
|
5
|
+
let primary;
|
|
6
|
+
let result;
|
|
7
|
+
try {
|
|
8
|
+
result = operation();
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
failed = true;
|
|
12
|
+
primary = error;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
cleanup();
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
const secondary = { ...details(), store_cleanup_errors: [errorRecord(error)] };
|
|
19
|
+
if (failed) {
|
|
20
|
+
const original = errorRecord(primary);
|
|
21
|
+
throw new AppError(original.code, original.message, original.exitCode, {
|
|
22
|
+
...original.details, ...secondary,
|
|
23
|
+
store_cleanup_errors: [...(Array.isArray(original.details.store_cleanup_errors) ? original.details.store_cleanup_errors : []), ...secondary.store_cleanup_errors]
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
throw new AppError("store_cleanup_failed", "Store operation completed but cleanup failed", 1, secondary);
|
|
27
|
+
}
|
|
28
|
+
if (failed)
|
|
29
|
+
throw primary;
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
/** A failed rollback makes even an enclosing savepoint's connection unsafe to reuse. */
|
|
33
|
+
export function rollbackStoreTransaction(db, sql, primary) {
|
|
34
|
+
try {
|
|
35
|
+
db.exec(sql);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
let closeError;
|
|
39
|
+
try {
|
|
40
|
+
db.close();
|
|
41
|
+
}
|
|
42
|
+
catch (close) {
|
|
43
|
+
closeError = errorRecord(close);
|
|
44
|
+
}
|
|
45
|
+
const original = errorRecord(primary);
|
|
46
|
+
throw new AppError(original.code, original.message, original.exitCode, {
|
|
47
|
+
...original.details, transaction_cleanup_error: errorRecord(error),
|
|
48
|
+
...(closeError ? { transaction_close_error: closeError } : {})
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
throw primary;
|
|
52
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { processStartedAt } from "../services/managed-processes.js";
|
|
5
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
6
|
+
/** One filesystem publisher per entity; no SQLite writer is held while waiting. */
|
|
7
|
+
export function withProjectionLock(lockPath, operation) {
|
|
8
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
9
|
+
const owner = { pid: process.pid, started_at: selfStartedAt() };
|
|
10
|
+
const wait = new Int32Array(new SharedArrayBuffer(4));
|
|
11
|
+
const observed = new Map();
|
|
12
|
+
for (let tries = 0;; tries++) {
|
|
13
|
+
try {
|
|
14
|
+
fs.mkdirSync(lockPath);
|
|
15
|
+
try {
|
|
16
|
+
fs.writeFileSync(`${lockPath}/owner.json`, JSON.stringify(owner));
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
fs.rmdirSync(lockPath);
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code !== "EEXIST")
|
|
26
|
+
throw error;
|
|
27
|
+
let stale = false;
|
|
28
|
+
let observedInode;
|
|
29
|
+
try {
|
|
30
|
+
observedInode = fs.statSync(lockPath).ino;
|
|
31
|
+
const current = JSON.parse(fs.readFileSync(`${lockPath}/owner.json`, "utf8"));
|
|
32
|
+
try {
|
|
33
|
+
process.kill(current.pid, 0);
|
|
34
|
+
if (!observed.has(current.pid))
|
|
35
|
+
observed.set(current.pid, processStartedAt(current.pid));
|
|
36
|
+
stale = Boolean(current.started_at && observed.get(current.pid) !== current.started_at);
|
|
37
|
+
}
|
|
38
|
+
catch (probe) {
|
|
39
|
+
stale = probe.code === "ESRCH";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
stale = false;
|
|
44
|
+
}
|
|
45
|
+
if (stale) {
|
|
46
|
+
try {
|
|
47
|
+
if (observedInode !== undefined && fs.statSync(lockPath).ino === observedInode) {
|
|
48
|
+
const moved = `${lockPath}.stale-${crypto.randomUUID()}`;
|
|
49
|
+
fs.renameSync(lockPath, moved);
|
|
50
|
+
fs.rmSync(moved, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch { /* Another publisher may own it now. */ }
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (tries >= 80)
|
|
57
|
+
throw new AppError("publication_pending", "Projection publisher is busy", 1, { effect: "committed", lock_path: lockPath });
|
|
58
|
+
Atomics.wait(wait, 0, 0, 50);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
return operation();
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new AppError("publication_pending", "Committed projection needs reconciliation", 1, { effect: "committed", cause: errorRecord(error), lock_path: lockPath });
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
let ownStart;
|
|
72
|
+
function selfStartedAt() { if (ownStart === undefined)
|
|
73
|
+
ownStart = processStartedAt(process.pid); return ownStart; }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { AppError } from "../shared/errors.js";
|
|
3
|
+
import { rollbackStoreTransaction } from "./native-cleanup.js";
|
|
3
4
|
export const STORE_WRITER_VERSION = 2;
|
|
4
5
|
const version = STORE_WRITER_VERSION;
|
|
5
6
|
const table = "dd_flow_writer_contract";
|
|
@@ -24,8 +25,7 @@ export function executeStoreSchema(db, sql, initialize = false) {
|
|
|
24
25
|
db.exec(ownsTransaction ? "COMMIT" : "RELEASE dd_flow_schema_change");
|
|
25
26
|
}
|
|
26
27
|
catch (error) {
|
|
27
|
-
db
|
|
28
|
-
throw error;
|
|
28
|
+
rollbackStoreTransaction(db, ownsTransaction ? "ROLLBACK" : "ROLLBACK TO dd_flow_schema_change; RELEASE dd_flow_schema_change", error);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
/** Registration is connection-local and never changes historical input bytes. */
|
|
@@ -71,7 +71,7 @@ export function installStoreWriterContract(db) {
|
|
|
71
71
|
}
|
|
72
72
|
catch (error) {
|
|
73
73
|
if (ownsTransaction)
|
|
74
|
-
db
|
|
74
|
+
rollbackStoreTransaction(db, "ROLLBACK", error);
|
|
75
75
|
throw error;
|
|
76
76
|
}
|
|
77
77
|
}
|
|
@@ -4,6 +4,7 @@ import crypto from "node:crypto";
|
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
6
|
import { installStoreWriterContract, readStoreWriterContract, registerStoreWriter, STORE_WRITER_VERSION } from "./writer-contract.js";
|
|
7
|
+
import { rollbackStoreTransaction, withStoreCleanup } from "./native-cleanup.js";
|
|
7
8
|
/** Explicit one-store cutover. The caller must separately drain external owners. */
|
|
8
9
|
export function migrateStoreWriter(file, backup) {
|
|
9
10
|
if (!path.isAbsolute(file) || !path.isAbsolute(backup) || file === backup || !fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
@@ -12,12 +13,19 @@ export function migrateStoreWriter(file, backup) {
|
|
|
12
13
|
const destination = path.join(fs.realpathSync(path.dirname(backup)), path.basename(backup));
|
|
13
14
|
if (fs.lstatSync(file).isSymbolicLink() || [source, `${source}-wal`, `${source}-shm`, `${source}-journal`].includes(destination))
|
|
14
15
|
throw new AppError("store_migration_path_invalid", "Backup cannot alias the source database or its SQLite sidecars", 1);
|
|
16
|
+
let existing;
|
|
15
17
|
try {
|
|
16
|
-
|
|
18
|
+
existing = fs.lstatSync(backup);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code !== "ENOENT")
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
if (existing) {
|
|
17
25
|
if (!existing.isFile() || existing.isSymbolicLink())
|
|
18
26
|
throw new AppError("store_migration_path_invalid", "Backup path already exists", 1);
|
|
19
27
|
const reader = new DatabaseSync(file, { readOnly: true });
|
|
20
|
-
|
|
28
|
+
return withStoreCleanup(() => {
|
|
21
29
|
const recorded = reader.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'dd_flow_writer_migrations'").get()
|
|
22
30
|
? reader.prepare("SELECT receipt_json FROM dd_flow_writer_migrations WHERE backup_path = ?").get(destination) : null;
|
|
23
31
|
const receipt = recorded ? JSON.parse(String(recorded.receipt_json)) : null;
|
|
@@ -25,17 +33,11 @@ export function migrateStoreWriter(file, backup) {
|
|
|
25
33
|
throw new AppError("store_migration_path_invalid", "Existing backup has no matching committed migration receipt", 1);
|
|
26
34
|
}
|
|
27
35
|
return receipt;
|
|
28
|
-
}
|
|
29
|
-
finally {
|
|
30
|
-
reader.close();
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
catch (error) {
|
|
34
|
-
if (error.code !== "ENOENT")
|
|
35
|
-
throw error;
|
|
36
|
+
}, () => reader.close());
|
|
36
37
|
}
|
|
37
38
|
const db = new DatabaseSync(file);
|
|
38
|
-
|
|
39
|
+
let committed = false;
|
|
40
|
+
return withStoreCleanup(() => {
|
|
39
41
|
db.exec("PRAGMA busy_timeout = 1000");
|
|
40
42
|
registerStoreWriter(db);
|
|
41
43
|
db.exec("BEGIN IMMEDIATE");
|
|
@@ -52,51 +54,32 @@ export function migrateStoreWriter(file, backup) {
|
|
|
52
54
|
if (tables.has(table) && db.prepare(`SELECT 1 FROM ${table} WHERE ${predicate} LIMIT 1`).get())
|
|
53
55
|
throw new AppError("store_migration_not_drained", "Store still contains unresolved writer ownership", 1, { table });
|
|
54
56
|
}
|
|
57
|
+
// Explicit drained cutover, not a live-runtime writer path: keep backup, durability
|
|
58
|
+
// and hash inside this barrier so the receipt describes exactly the fenced source.
|
|
55
59
|
// A separate reader sees committed WAL while this connection prevents new writes.
|
|
56
60
|
const reader = new DatabaseSync(file, { readOnly: true });
|
|
57
|
-
|
|
58
|
-
reader.exec(`VACUUM INTO '${backup.replaceAll("'", "''")}'`);
|
|
59
|
-
}
|
|
60
|
-
finally {
|
|
61
|
-
reader.close();
|
|
62
|
-
}
|
|
61
|
+
withStoreCleanup(() => reader.exec(`VACUUM INTO '${backup.replaceAll("'", "''")}'`), () => reader.close());
|
|
63
62
|
const descriptor = fs.openSync(backup, "r");
|
|
64
|
-
|
|
65
|
-
fs.fsyncSync(descriptor);
|
|
66
|
-
}
|
|
67
|
-
finally {
|
|
68
|
-
fs.closeSync(descriptor);
|
|
69
|
-
}
|
|
63
|
+
withStoreCleanup(() => fs.fsyncSync(descriptor), () => fs.closeSync(descriptor));
|
|
70
64
|
const directory = fs.openSync(path.dirname(backup), "r");
|
|
71
|
-
|
|
72
|
-
fs.fsyncSync(directory);
|
|
73
|
-
}
|
|
74
|
-
finally {
|
|
75
|
-
fs.closeSync(directory);
|
|
76
|
-
}
|
|
65
|
+
withStoreCleanup(() => fs.fsyncSync(directory), () => fs.closeSync(directory));
|
|
77
66
|
const verification = new DatabaseSync(backup, { readOnly: true });
|
|
78
|
-
|
|
67
|
+
withStoreCleanup(() => {
|
|
79
68
|
const check = verification.prepare("PRAGMA integrity_check").get();
|
|
80
69
|
if (Object.values(check)[0] !== "ok" || readStoreWriterContract(verification) !== previous)
|
|
81
70
|
throw new AppError("store_migration_backup_invalid", "Backup failed integrity or contract verification", 1);
|
|
82
|
-
}
|
|
83
|
-
finally {
|
|
84
|
-
verification.close();
|
|
85
|
-
}
|
|
71
|
+
}, () => verification.close());
|
|
86
72
|
const receipt = { database: file, backup, backup_sha256: crypto.createHash("sha256").update(fs.readFileSync(backup)).digest("hex"), previous_version: previous, writer_version: STORE_WRITER_VERSION };
|
|
87
73
|
db.exec("CREATE TABLE IF NOT EXISTS dd_flow_writer_migrations (backup_path TEXT PRIMARY KEY, receipt_json TEXT NOT NULL)");
|
|
88
74
|
db.prepare("INSERT INTO dd_flow_writer_migrations VALUES (?, ?)").run(destination, JSON.stringify(receipt));
|
|
89
75
|
installStoreWriterContract(db);
|
|
90
76
|
db.exec("COMMIT");
|
|
77
|
+
committed = true;
|
|
91
78
|
return receipt;
|
|
92
79
|
}
|
|
93
80
|
catch (error) {
|
|
94
|
-
|
|
95
|
-
db.exec("ROLLBACK");
|
|
96
|
-
throw error;
|
|
81
|
+
rollbackStoreTransaction(db, "ROLLBACK", error);
|
|
97
82
|
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
db.close();
|
|
101
|
-
}
|
|
83
|
+
}, () => { if (db.isOpen)
|
|
84
|
+
db.close(); }, () => ({ effect: committed ? "committed" : "uncommitted" }));
|
|
102
85
|
}
|
package/package.json
CHANGED
|
@@ -5,27 +5,26 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
7
|
import { parseArgs } from 'node:util';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { createContext } from '../dist/runtime/context.js';
|
|
9
|
+
import { appendFlowRunTimelineEvent, gitFacts, publishRunProjections } from '../dist/services/runs.js';
|
|
10
|
+
import { appendAudit } from '../dist/services/audit.js';
|
|
11
11
|
|
|
12
12
|
const { values } = parseArgs({ options: { home: { type: 'string' }, run: { type: 'string' }, project: { type: 'string' }, apply: { type: 'boolean', default: false }, 'cancel-abandoned': { type: 'boolean', default: false } } });
|
|
13
13
|
if (!values.home || !values.run || !values.project) throw new Error('Usage: --home <runtime> --project <project-id> --run <run-id> [--apply]');
|
|
14
|
-
const home = fs.realpathSync(values.home)
|
|
15
|
-
const
|
|
16
|
-
|
|
14
|
+
const home = fs.realpathSync(values.home);
|
|
15
|
+
const context = createContext({ ...process.env, DD_FLOW_HOME: home }, values.apply ? 'hook' : 'read_existing');
|
|
16
|
+
const db = context.db;
|
|
17
|
+
let runtimePath, original, backup, report;
|
|
17
18
|
try {
|
|
18
|
-
db.
|
|
19
|
-
if (values.apply) { registerStoreWriter(db); db.exec('BEGIN IMMEDIATE'); }
|
|
20
|
-
const run = db.prepare('SELECT * FROM runs WHERE project_id=? AND id=?').get(values.project, values.run);
|
|
19
|
+
const run = db.get('SELECT * FROM runs WHERE project_id=? AND id=?', [values.project, values.run]);
|
|
21
20
|
if (!run) throw new Error('RUN not found');
|
|
22
|
-
const guard = db.
|
|
21
|
+
const guard = db.get('SELECT * FROM run_recovery_guards WHERE project_id=? AND run_id=? ORDER BY generation DESC LIMIT 1', [values.project, values.run]);
|
|
23
22
|
const cancel = values['cancel-abandoned'];
|
|
24
23
|
if (cancel) {
|
|
25
24
|
if (fs.existsSync(run.project_root)) throw new Error('Abandoned-fixture cancellation requires an absent project directory');
|
|
26
25
|
for (const table of ['works', 'sessions', 'run_controllers']) {
|
|
27
|
-
if (!db.
|
|
28
|
-
if (db.
|
|
26
|
+
if (!db.get("SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?", [table])) continue;
|
|
27
|
+
if (db.get(`SELECT 1 FROM ${table} WHERE project_id=? AND run_id=? LIMIT 1`, [values.project, values.run])) throw new Error('Abandoned-fixture cancellation requires no Work, Session or controller history');
|
|
29
28
|
}
|
|
30
29
|
if (guard) throw new Error('A recovery-owned RUN requires recovery reconciliation');
|
|
31
30
|
}
|
|
@@ -41,19 +40,45 @@ try {
|
|
|
41
40
|
if (values.apply) {
|
|
42
41
|
backup = `${runtimePath}.status-repair-${crypto.randomUUID()}.json`;
|
|
43
42
|
fs.writeFileSync(backup, JSON.stringify({ recovery_id: guard?.recovery_id ?? null, row: run, runtime: original.toString('utf8') }), { flag: 'wx', mode: 0o600 });
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
const git = cancel ? gitFacts(run.workspace_root) : null;
|
|
44
|
+
db.writeTransaction(() => {
|
|
45
|
+
const current = db.get('SELECT * FROM runs WHERE project_id=? AND id=?', [values.project, values.run]);
|
|
46
|
+
const currentGuard = db.get('SELECT * FROM run_recovery_guards WHERE project_id=? AND run_id=? ORDER BY generation DESC LIMIT 1', [values.project, values.run]);
|
|
47
|
+
if (!current || current.status !== 'running' || current.index_json !== run.index_json || current.runtime_path !== run.runtime_path) throw new Error('RUN changed after repair preparation');
|
|
48
|
+
if (cancel) {
|
|
49
|
+
if (currentGuard) throw new Error('A recovery-owned RUN requires recovery reconciliation');
|
|
50
|
+
for (const table of ['works', 'sessions', 'run_controllers']) {
|
|
51
|
+
if (db.get("SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?", [table]) && db.get(`SELECT 1 FROM ${table} WHERE project_id=? AND run_id=? LIMIT 1`, [values.project, values.run])) throw new Error('Abandoned-fixture cancellation requires no Work, Session or controller history');
|
|
52
|
+
}
|
|
53
|
+
} else if (!currentGuard || currentGuard.recovery_id !== guard.recovery_id || currentGuard.generation !== guard.generation || currentGuard.status !== guard.status) throw new Error('Recovery guard changed after repair preparation');
|
|
54
|
+
const index = JSON.parse(current.index_json), now = context.now();
|
|
55
|
+
if (index.status !== 'running') throw new Error('RUN index changed after repair preparation');
|
|
56
|
+
index.status = cancel ? 'cancelled' : 'paused';
|
|
57
|
+
index.updated_at = now;
|
|
58
|
+
index.runtime_revision = (index.runtime_revision ?? 0) + 1;
|
|
59
|
+
if (cancel) {
|
|
60
|
+
for (const stage of index.stage_runs) if (['running', 'pending', 'paused', 'blocked'].includes(stage.status)) {
|
|
61
|
+
stage.status = 'skipped'; stage.updated_at = now; stage.completed_at = now;
|
|
62
|
+
const elapsed = Date.parse(now) - Date.parse(stage.started_at ?? '');
|
|
63
|
+
stage.duration_ms = Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : null;
|
|
64
|
+
}
|
|
65
|
+
if (!['vnext_specify', 'vnext_protocolize'].includes(index.flow_kind)) index.attempts = index.stage_runs.map(stage => ({ stage: stage.stage, attempt_number: Number((stage.attempt ?? 'try-001').replace('try-', '')) || 1, root: stage.dir, archive: null, status: stage.status, started_at: stage.started_at ?? now, finished_at: stage.completed_at ?? null }));
|
|
66
|
+
index.verdict = 'cancelled'; index.next_action = null; index.completed_at = now; index.finished_at = now;
|
|
67
|
+
const elapsed = Date.parse(now) - Date.parse(index.started_at ?? current.created_at);
|
|
68
|
+
index.duration_ms = Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : null;
|
|
69
|
+
index.execution = { project_root: index.execution?.project_root ?? current.project_root, workspace_root: index.execution?.workspace_root ?? current.workspace_root, git };
|
|
70
|
+
}
|
|
71
|
+
const updated = db.run('UPDATE runs SET status=?, verdict=?, next_action=?, index_json=?, updated_at=?, completed_at=? WHERE project_id=? AND id=? AND status=? AND index_json=?', [index.status, index.verdict, index.next_action, JSON.stringify(index), now, index.completed_at ?? null, values.project, values.run, 'running', current.index_json]);
|
|
72
|
+
if (updated.changes !== 1) throw new Error('RUN changed before repair commit');
|
|
73
|
+
if (cancel) {
|
|
74
|
+
const reason = 'Operator requested cleanup of abandoned fixture: project removed; no Work, Session or controller history.';
|
|
75
|
+
appendAudit(context, { projectId: values.project, eventType: 'flow_run.manual_override', payload: { run_id: run.id, status: 'cancelled', verdict: 'cancelled', next_action: null, reason } });
|
|
76
|
+
appendFlowRunTimelineEvent(context, values.project, run.id, { at: now, type: 'run_manual_override', status: 'cancelled', reason });
|
|
77
|
+
}
|
|
78
|
+
if (!cancel) db.afterCommit(() => publishRunProjections(context, values.project, run.id));
|
|
79
|
+
}, 'repair-paused-run-status');
|
|
50
80
|
}
|
|
51
81
|
report = { run_id: run.id, from: 'running', to: cancel ? 'cancelled' : 'paused', recovery_id: guard?.recovery_id ?? null, applied: values.apply, backup };
|
|
52
82
|
}
|
|
53
|
-
if (values.apply) db.exec('COMMIT');
|
|
54
83
|
console.log(JSON.stringify(report));
|
|
55
|
-
} catch (error) {
|
|
56
|
-
if (values.apply && db.isTransaction) db.exec('ROLLBACK');
|
|
57
|
-
if (changed && original) { const temporary = `${runtimePath}.repair-rollback`; fs.writeFileSync(temporary, original); fs.renameSync(temporary, runtimePath); }
|
|
58
|
-
throw error;
|
|
59
84
|
} finally { db.close(); }
|