@deksden-com/dd-flow-cli 0.9.0-beta.104 → 0.9.0-beta.105
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 +6 -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 +2 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +8 -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 +3 -3
- package/dist/services/eval-snapshots.js +69 -41
- package/dist/services/execution-policy.js +12 -1
- package/dist/services/hooks.js +150 -42
- 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 +3 -11
- 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 +81 -86
- package/dist/services/run-fork.js +10 -6
- package/dist/services/run-projection.js +19 -64
- package/dist/services/run-recovery-runtime.js +24 -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
package/dist/storage/database.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
|
+
import { channel } from "node:diagnostics_channel";
|
|
4
5
|
import { createRequire } from "node:module";
|
|
5
6
|
import { ensureDir } from "./paths.js";
|
|
6
7
|
import { AppError } from "../shared/errors.js";
|
|
7
8
|
import { executeStoreSchema, readStoreWriterContract, registerStoreWriter } from "./writer-contract.js";
|
|
9
|
+
import { assertImportAdmission } from "./import-guard.js";
|
|
10
|
+
import { withStoreCleanup, rollbackStoreTransaction } from "./native-cleanup.js";
|
|
8
11
|
const workContinuationStorageSql = `
|
|
9
12
|
CREATE TABLE IF NOT EXISTS stage_work_memberships (
|
|
10
13
|
project_id TEXT NOT NULL, run_id TEXT NOT NULL, work_id TEXT NOT NULL REFERENCES works(work_id),
|
|
@@ -17,9 +20,73 @@ const workContinuationStorageSql = `
|
|
|
17
20
|
payload_json TEXT NOT NULL, work_id TEXT NOT NULL REFERENCES works(work_id), created_at TEXT NOT NULL
|
|
18
21
|
);
|
|
19
22
|
`;
|
|
23
|
+
function closeAfterOpenFailure(db, error) {
|
|
24
|
+
return withStoreCleanup(() => { throw error; }, () => { if (db.isOpen)
|
|
25
|
+
db.close(); });
|
|
26
|
+
}
|
|
20
27
|
const require = createRequire(import.meta.url);
|
|
21
28
|
const { DatabaseSync } = require("node:sqlite");
|
|
22
29
|
const resourceDatabases = new Map();
|
|
30
|
+
const publicationScopes = [];
|
|
31
|
+
/** Defer committed cross-store projections until the surrounding writer exits. */
|
|
32
|
+
export function withDeferredPublications(operation) {
|
|
33
|
+
const pending = [];
|
|
34
|
+
publicationScopes.push(pending);
|
|
35
|
+
let value;
|
|
36
|
+
let primary;
|
|
37
|
+
let failed = false;
|
|
38
|
+
try {
|
|
39
|
+
value = operation();
|
|
40
|
+
if (value && typeof value.then === "function")
|
|
41
|
+
throw new AppError("write_transaction_async", "Deferred publication scope must be synchronous", 1);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
primary = error;
|
|
45
|
+
failed = true;
|
|
46
|
+
}
|
|
47
|
+
publicationScopes.pop();
|
|
48
|
+
const parent = publicationScopes.at(-1);
|
|
49
|
+
if (parent)
|
|
50
|
+
parent.push(...pending);
|
|
51
|
+
else {
|
|
52
|
+
try {
|
|
53
|
+
executePublications(pending);
|
|
54
|
+
}
|
|
55
|
+
catch (publicationError) {
|
|
56
|
+
if (failed && primary instanceof AppError)
|
|
57
|
+
throw new AppError(primary.code, primary.message, primary.exitCode, { ...primary.details, effect: "partial", committed_publications: pending.length, publication_pending: true, publication_error: errorDetails(publicationError) });
|
|
58
|
+
if (failed)
|
|
59
|
+
throw new AppError("cross_store_failed", "Cross-store mutation failed and committed projections need reconciliation", 1, { effect: "partial", cause: errorDetails(primary), committed_publications: pending.length, publication_pending: true, publication_error: errorDetails(publicationError) });
|
|
60
|
+
throw publicationError;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (failed && pending.length && primary instanceof AppError)
|
|
64
|
+
throw new AppError(primary.code, primary.message, primary.exitCode, { ...primary.details, effect: "partial", committed_publications: pending.length });
|
|
65
|
+
if (failed && pending.length)
|
|
66
|
+
throw new AppError("cross_store_failed", "Cross-store mutation failed after a committed inner write", 1, { effect: "partial", cause: errorDetails(primary), committed_publications: pending.length });
|
|
67
|
+
if (failed)
|
|
68
|
+
throw primary;
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function executePublications(pending) {
|
|
72
|
+
let firstError;
|
|
73
|
+
let failed = false;
|
|
74
|
+
for (const operation of pending) {
|
|
75
|
+
try {
|
|
76
|
+
operation();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (!failed)
|
|
80
|
+
firstError = error;
|
|
81
|
+
failed = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (failed) {
|
|
85
|
+
if (firstError instanceof AppError && firstError.details.effect === "committed")
|
|
86
|
+
throw firstError;
|
|
87
|
+
throw new AppError("publication_pending", "SQL committed but a required projection needs reconciliation", 1, { effect: "committed", publication_pending: true, cause: errorDetails(firstError) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
23
90
|
/** Invocation authority is prepared once with the runtime schema. Native
|
|
24
91
|
* hooks must only verify this table; they never create it on the hot path. */
|
|
25
92
|
export function ensureLifecycleInvocationStorage(db) {
|
|
@@ -32,6 +99,11 @@ export function ensureLifecycleInvocationStorage(db) {
|
|
|
32
99
|
);
|
|
33
100
|
CREATE INDEX IF NOT EXISTS idx_lifecycle_invocations_scope
|
|
34
101
|
ON lifecycle_invocations(scope_json, fingerprint, created_at);
|
|
102
|
+
CREATE TABLE IF NOT EXISTS lifecycle_packet_assignments (
|
|
103
|
+
packet_sha256 TEXT NOT NULL,
|
|
104
|
+
invocation_id TEXT NOT NULL REFERENCES lifecycle_invocations(id),
|
|
105
|
+
PRIMARY KEY(packet_sha256, invocation_id)
|
|
106
|
+
);
|
|
35
107
|
`);
|
|
36
108
|
}
|
|
37
109
|
/** RUN authority stays in its existing store; process leases stay in runtime.sqlite. */
|
|
@@ -174,6 +246,8 @@ export function ensureVnextWorkStorage(db) {
|
|
|
174
246
|
prompt_path TEXT NOT NULL,
|
|
175
247
|
result_path TEXT,
|
|
176
248
|
start_receipt_json TEXT,
|
|
249
|
+
start_publication_status TEXT,
|
|
250
|
+
result_receipt_json TEXT,
|
|
177
251
|
created_at TEXT NOT NULL,
|
|
178
252
|
updated_at TEXT NOT NULL,
|
|
179
253
|
completed_at TEXT,
|
|
@@ -230,6 +304,10 @@ export function ensureVnextWorkStorage(db) {
|
|
|
230
304
|
const columns = db.all("PRAGMA table_info(work_sessions)");
|
|
231
305
|
if (!columns.some((column) => column.name === "start_receipt_json"))
|
|
232
306
|
db.execSchema("ALTER TABLE work_sessions ADD COLUMN start_receipt_json TEXT");
|
|
307
|
+
if (!columns.some((column) => column.name === "start_publication_status"))
|
|
308
|
+
db.execSchema("ALTER TABLE work_sessions ADD COLUMN start_publication_status TEXT");
|
|
309
|
+
if (!columns.some((column) => column.name === "result_receipt_json"))
|
|
310
|
+
db.execSchema("ALTER TABLE work_sessions ADD COLUMN result_receipt_json TEXT");
|
|
233
311
|
});
|
|
234
312
|
}
|
|
235
313
|
/** Durable intent for a provider-root external Work. It is deliberately
|
|
@@ -253,7 +331,9 @@ export function ensureWorkLaunchStorage(db) {
|
|
|
253
331
|
CREATE INDEX IF NOT EXISTS idx_work_launches_status ON work_launches(status, updated_at DESC);
|
|
254
332
|
`);
|
|
255
333
|
}
|
|
256
|
-
export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
334
|
+
export function getDatabase(ddFlowHome, mode = "initialize", importPermitId) {
|
|
335
|
+
if (mode !== "read_existing")
|
|
336
|
+
assertImportAdmission(ddFlowHome, importPermitId);
|
|
257
337
|
const dbPath = path.join(ddFlowHome, "db.sqlite");
|
|
258
338
|
const exists = fs.existsSync(dbPath);
|
|
259
339
|
if (mode === "read_existing" && !exists) {
|
|
@@ -270,16 +350,14 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
270
350
|
registerStoreWriter(db);
|
|
271
351
|
}
|
|
272
352
|
catch (error) {
|
|
273
|
-
db
|
|
274
|
-
throw error;
|
|
353
|
+
return closeAfterOpenFailure(db, error);
|
|
275
354
|
}
|
|
276
355
|
}
|
|
277
356
|
try {
|
|
278
357
|
configureDatabase(db, mode);
|
|
279
358
|
}
|
|
280
359
|
catch (error) {
|
|
281
|
-
db
|
|
282
|
-
throw error;
|
|
360
|
+
return closeAfterOpenFailure(db, error);
|
|
283
361
|
}
|
|
284
362
|
// Internal additive schema changes must be available to every write command.
|
|
285
363
|
// Higher-level Memory Bank migrations remain explicit in services/migrations.
|
|
@@ -300,8 +378,7 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
300
378
|
}
|
|
301
379
|
}
|
|
302
380
|
catch (error) {
|
|
303
|
-
db
|
|
304
|
-
throw error;
|
|
381
|
+
return closeAfterOpenFailure(db, error);
|
|
305
382
|
}
|
|
306
383
|
}
|
|
307
384
|
if (mode === "read_existing")
|
|
@@ -316,7 +393,7 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
316
393
|
* preparation error instead of an implicit migration race.
|
|
317
394
|
*/
|
|
318
395
|
function assertHookStoreReady(db) {
|
|
319
|
-
const required = ["projects", "hook_events", "codex_session_bindings", "sessions", "flow_session_segments", "lifecycle_invocations"];
|
|
396
|
+
const required = ["projects", "hook_events", "codex_session_bindings", "sessions", "flow_session_segments", "lifecycle_invocations", "lifecycle_packet_assignments"];
|
|
320
397
|
const missing = required.filter((table) => !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table));
|
|
321
398
|
if (missing.length)
|
|
322
399
|
throw new AppError("hook_storage_unprepared", "Managed store is not prepared for synchronous hook admission", 1, { missing_tables: missing, next_action: "Open the runtime with a normal dd-flow command and complete migration before starting the native Session." });
|
|
@@ -346,7 +423,7 @@ export function withResourceReadSnapshot(resourceHome, read) {
|
|
|
346
423
|
if (!cached && !fs.existsSync(dbPath))
|
|
347
424
|
return read(emptyReadOnlyDatabase(dbPath));
|
|
348
425
|
const db = cached ?? new DatabaseSync(dbPath, { readOnly: true });
|
|
349
|
-
|
|
426
|
+
const operation = () => {
|
|
350
427
|
if (!cached)
|
|
351
428
|
db.exec("PRAGMA busy_timeout = 4000; PRAGMA query_only = ON");
|
|
352
429
|
// A second process can observe SQLite's file before the initializing
|
|
@@ -359,31 +436,22 @@ export function withResourceReadSnapshot(resourceHome, read) {
|
|
|
359
436
|
const reader = cached
|
|
360
437
|
? { ...emptyReadOnlyDatabase(dbPath), get: cached.get, all: cached.all }
|
|
361
438
|
: databaseFacade(dbPath, false, db);
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
db.exec("ROLLBACK TO resource_read_snapshot");
|
|
372
|
-
db.exec("RELEASE resource_read_snapshot");
|
|
373
|
-
throw error;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
finally {
|
|
377
|
-
if (!cached)
|
|
378
|
-
db.close?.();
|
|
379
|
-
}
|
|
439
|
+
// The facade owns savepoint cleanup and poisons a connection when rollback
|
|
440
|
+
// fails; the callback only receives the read-only view.
|
|
441
|
+
return (cached ?? reader).readSnapshot(() => read(reader));
|
|
442
|
+
};
|
|
443
|
+
if (cached)
|
|
444
|
+
return operation();
|
|
445
|
+
const native = db;
|
|
446
|
+
return withStoreCleanup(operation, () => { if (native.isOpen)
|
|
447
|
+
native.close(); });
|
|
380
448
|
}
|
|
381
449
|
export function getResourceDatabase(resourceHome, mode = "initialize") {
|
|
382
450
|
const dbPath = path.join(resourceHome, "runtime.sqlite");
|
|
383
451
|
const cached = resourceDatabases.get(dbPath);
|
|
384
|
-
if (mode === "read_existing") {
|
|
452
|
+
if (mode === "read_existing" || mode === "read_isolated") {
|
|
385
453
|
// Reuse a current transaction's view without exposing its write capability.
|
|
386
|
-
if (cached)
|
|
454
|
+
if (cached && mode === "read_existing")
|
|
387
455
|
return { ...emptyReadOnlyDatabase(dbPath), get: cached.get, all: cached.all };
|
|
388
456
|
if (!fs.existsSync(dbPath))
|
|
389
457
|
return emptyReadOnlyDatabase(dbPath);
|
|
@@ -396,8 +464,7 @@ export function getResourceDatabase(resourceHome, mode = "initialize") {
|
|
|
396
464
|
}
|
|
397
465
|
}
|
|
398
466
|
catch (error) {
|
|
399
|
-
db
|
|
400
|
-
throw error;
|
|
467
|
+
return closeAfterOpenFailure(db, error);
|
|
401
468
|
}
|
|
402
469
|
return databaseFacade(dbPath, false, db);
|
|
403
470
|
}
|
|
@@ -410,15 +477,13 @@ export function getResourceDatabase(resourceHome, mode = "initialize") {
|
|
|
410
477
|
registerStoreWriter(db);
|
|
411
478
|
}
|
|
412
479
|
catch (error) {
|
|
413
|
-
db
|
|
414
|
-
throw error;
|
|
480
|
+
return closeAfterOpenFailure(db, error);
|
|
415
481
|
}
|
|
416
482
|
try {
|
|
417
483
|
configureDatabase(db, "initialize");
|
|
418
484
|
}
|
|
419
485
|
catch (error) {
|
|
420
|
-
db
|
|
421
|
-
throw error;
|
|
486
|
+
return closeAfterOpenFailure(db, error);
|
|
422
487
|
}
|
|
423
488
|
db.exec("PRAGMA foreign_keys = ON");
|
|
424
489
|
const fresh = !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
|
|
@@ -465,8 +530,7 @@ export function getResourceDatabase(resourceHome, mode = "initialize") {
|
|
|
465
530
|
`, fresh);
|
|
466
531
|
}
|
|
467
532
|
catch (error) {
|
|
468
|
-
db
|
|
469
|
-
throw error;
|
|
533
|
+
return closeAfterOpenFailure(db, error);
|
|
470
534
|
}
|
|
471
535
|
const database = databaseFacade(dbPath, true, db, () => {
|
|
472
536
|
resourceDatabases.delete(dbPath);
|
|
@@ -483,6 +547,8 @@ function emptyReadOnlyDatabase(dbPath) {
|
|
|
483
547
|
execSchema: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
|
|
484
548
|
beginWriteTransaction: () => { throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath }); },
|
|
485
549
|
writeTransaction: () => { throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath }); },
|
|
550
|
+
readSnapshot: (operation) => operation(),
|
|
551
|
+
afterCommit: () => { throw new AppError("read_only_storage", "Read-only access cannot publish absent dd-flow storage", 1, { path: dbPath }); },
|
|
486
552
|
run: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
|
|
487
553
|
get: () => undefined,
|
|
488
554
|
all: () => []
|
|
@@ -491,11 +557,31 @@ function emptyReadOnlyDatabase(dbPath) {
|
|
|
491
557
|
function databaseFacade(dbPath, writable, db, close = () => db.close?.()) {
|
|
492
558
|
let reserved = false;
|
|
493
559
|
let poisoned = false;
|
|
560
|
+
let closed = false;
|
|
561
|
+
const closeOnce = () => { if (closed || !db.isOpen) {
|
|
562
|
+
closed = true;
|
|
563
|
+
return;
|
|
564
|
+
} closed = true; close(); };
|
|
494
565
|
let savepoint = 0;
|
|
495
|
-
|
|
496
|
-
|
|
566
|
+
let afterCommit = [];
|
|
567
|
+
let manualFrames = [];
|
|
568
|
+
let readDepth = 0;
|
|
569
|
+
const writerTiming = channel("dd-flow.sqlite.writer");
|
|
570
|
+
const publishCommitted = () => {
|
|
571
|
+
const pending = afterCommit;
|
|
572
|
+
afterCommit = [];
|
|
573
|
+
const scope = publicationScopes.at(-1);
|
|
574
|
+
if (scope)
|
|
575
|
+
scope.push(...pending);
|
|
576
|
+
else
|
|
577
|
+
executePublications(pending);
|
|
578
|
+
};
|
|
579
|
+
const assertUsable = () => { if (poisoned || closed || !db.isOpen)
|
|
580
|
+
throw new AppError("write_transaction_unresolved", "Transaction cleanup failed or the connection is closed; reopen the connection before continuing", 1); };
|
|
497
581
|
const beginWriteTransaction = () => {
|
|
498
582
|
assertUsable();
|
|
583
|
+
if (readDepth)
|
|
584
|
+
throw new AppError("read_snapshot_write", "Cannot reserve a writer inside a read snapshot", 1);
|
|
499
585
|
if (!writable)
|
|
500
586
|
throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath });
|
|
501
587
|
db.exec("BEGIN IMMEDIATE");
|
|
@@ -507,20 +593,32 @@ function databaseFacade(dbPath, writable, db, close = () => db.close?.()) {
|
|
|
507
593
|
}
|
|
508
594
|
catch (cleanup) {
|
|
509
595
|
poisoned = true;
|
|
596
|
+
let closeError;
|
|
597
|
+
try {
|
|
598
|
+
closeOnce();
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
closeError = errorDetails(error);
|
|
602
|
+
}
|
|
603
|
+
const secondary = { transaction_cleanup_error: errorDetails(cleanup), ...(closeError ? { transaction_close_error: closeError } : {}) };
|
|
510
604
|
if (original instanceof AppError)
|
|
511
|
-
throw new AppError(original.code, original.message, original.exitCode, { ...original.details,
|
|
512
|
-
throw new AppError("write_transaction_failed", "Write transaction failed and SQLite cleanup also failed", 1, { cause: errorDetails(original),
|
|
605
|
+
throw new AppError(original.code, original.message, original.exitCode, { ...original.details, ...secondary });
|
|
606
|
+
throw new AppError("write_transaction_failed", "Write transaction failed and SQLite cleanup also failed", 1, { cause: errorDetails(original), ...secondary });
|
|
513
607
|
}
|
|
514
608
|
throw original;
|
|
515
609
|
};
|
|
516
|
-
const writeTransaction = (operation) => {
|
|
610
|
+
const writeTransaction = (operation, operationName = "writeTransaction") => {
|
|
517
611
|
assertUsable();
|
|
612
|
+
if (readDepth)
|
|
613
|
+
throw new AppError("read_snapshot_write", "Cannot reserve a writer inside a read snapshot", 1);
|
|
518
614
|
if (!writable)
|
|
519
615
|
throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath });
|
|
520
616
|
if (db.isTransaction && !reserved)
|
|
521
617
|
throw new AppError("write_transaction_unowned", "Cannot write inside an unowned transaction or read snapshot", 1);
|
|
522
618
|
const nested = db.isTransaction;
|
|
619
|
+
const callbackLength = afterCommit.length;
|
|
523
620
|
const marker = `dd_flow_write_${++savepoint}`;
|
|
621
|
+
const startedAt = performance.now();
|
|
524
622
|
try {
|
|
525
623
|
if (nested)
|
|
526
624
|
db.exec(`SAVEPOINT ${marker}`);
|
|
@@ -542,9 +640,17 @@ function databaseFacade(dbPath, writable, db, close = () => db.close?.()) {
|
|
|
542
640
|
db.exec(`RELEASE ${marker}`);
|
|
543
641
|
else
|
|
544
642
|
db.exec("COMMIT");
|
|
643
|
+
phase = "committed";
|
|
644
|
+
if (!nested && writerTiming.hasSubscribers)
|
|
645
|
+
writerTiming.publish({ operation: operationName, wall_time_ms: performance.now() - startedAt, path: dbPath });
|
|
646
|
+
if (!nested)
|
|
647
|
+
publishCommitted();
|
|
545
648
|
return value;
|
|
546
649
|
}
|
|
547
650
|
catch (error) {
|
|
651
|
+
if (phase === "committed")
|
|
652
|
+
throw error;
|
|
653
|
+
afterCommit.length = callbackLength;
|
|
548
654
|
try {
|
|
549
655
|
return rollback(nested ? `ROLLBACK TO ${marker}; RELEASE ${marker}` : "ROLLBACK", error);
|
|
550
656
|
}
|
|
@@ -555,28 +661,134 @@ function databaseFacade(dbPath, writable, db, close = () => db.close?.()) {
|
|
|
555
661
|
}
|
|
556
662
|
}
|
|
557
663
|
finally {
|
|
558
|
-
if (!db.isTransaction)
|
|
664
|
+
if (closed || !db.isOpen || !db.isTransaction) {
|
|
559
665
|
reserved = false;
|
|
666
|
+
if (phase !== "committed") {
|
|
667
|
+
afterCommit = [];
|
|
668
|
+
manualFrames = [];
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
const readSnapshot = (operation) => {
|
|
674
|
+
assertUsable();
|
|
675
|
+
const nested = db.isTransaction;
|
|
676
|
+
const marker = `dd_flow_read_${++savepoint}`;
|
|
677
|
+
const callbackLength = afterCommit.length;
|
|
678
|
+
try {
|
|
679
|
+
db.exec(nested ? `SAVEPOINT ${marker}` : "BEGIN");
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
throw new AppError("read_snapshot_failed", "Cannot start SQLite read snapshot", 1, { cause: errorDetails(error) });
|
|
683
|
+
}
|
|
684
|
+
readDepth += 1;
|
|
685
|
+
let phase = "body";
|
|
686
|
+
try {
|
|
687
|
+
const value = operation();
|
|
688
|
+
if (value && typeof value.then === "function") {
|
|
689
|
+
void Promise.resolve(value).catch(() => { });
|
|
690
|
+
throw new AppError("read_snapshot_async", "Read snapshot callback must be synchronous", 1);
|
|
691
|
+
}
|
|
692
|
+
phase = "commit";
|
|
693
|
+
db.exec(nested ? `RELEASE ${marker}` : "COMMIT");
|
|
694
|
+
phase = "committed";
|
|
695
|
+
return value;
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
if (phase === "committed")
|
|
699
|
+
throw error;
|
|
700
|
+
afterCommit.length = callbackLength;
|
|
701
|
+
return rollback(nested ? `ROLLBACK TO ${marker}; RELEASE ${marker}` : "ROLLBACK", error);
|
|
702
|
+
}
|
|
703
|
+
finally {
|
|
704
|
+
readDepth -= 1;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
const applyManualBoundary = (sql) => {
|
|
708
|
+
for (const statement of sql.split(";")) {
|
|
709
|
+
const save = /^\s*SAVEPOINT\s+([\w-]+)/i.exec(statement);
|
|
710
|
+
if (save) {
|
|
711
|
+
manualFrames.push({ name: save[1], length: afterCommit.length });
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
const rollbackTo = /^\s*ROLLBACK\s+TO(?:\s+SAVEPOINT)?\s+([\w-]+)/i.exec(statement);
|
|
715
|
+
if (rollbackTo) {
|
|
716
|
+
const frame = manualFrames.findLastIndex((item) => item.name === rollbackTo[1]);
|
|
717
|
+
if (frame >= 0) {
|
|
718
|
+
afterCommit.length = manualFrames[frame].length;
|
|
719
|
+
manualFrames.length = frame + 1;
|
|
720
|
+
}
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const release = /^\s*RELEASE(?:\s+SAVEPOINT)?\s+([\w-]+)/i.exec(statement);
|
|
724
|
+
if (release) {
|
|
725
|
+
const frame = manualFrames.findLastIndex((item) => item.name === release[1]);
|
|
726
|
+
if (frame >= 0)
|
|
727
|
+
manualFrames.length = frame;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (/^\s*ROLLBACK\b/i.test(statement)) {
|
|
731
|
+
afterCommit = [];
|
|
732
|
+
manualFrames = [];
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (/^\s*COMMIT\b/i.test(statement)) {
|
|
736
|
+
manualFrames = [];
|
|
737
|
+
publishCommitted();
|
|
738
|
+
}
|
|
560
739
|
}
|
|
561
740
|
};
|
|
562
741
|
return {
|
|
563
742
|
path: dbPath,
|
|
564
743
|
writable,
|
|
565
|
-
close,
|
|
566
|
-
exec: (sql) => {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
744
|
+
close: closeOnce,
|
|
745
|
+
exec: (sql) => {
|
|
746
|
+
assertUsable();
|
|
747
|
+
if (readDepth)
|
|
748
|
+
throw new AppError("read_snapshot_write", "Cannot execute SQL inside a read snapshot", 1);
|
|
749
|
+
try {
|
|
750
|
+
db.exec(sql);
|
|
751
|
+
applyManualBoundary(sql);
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
if (/^\s*ROLLBACK\b/i.test(sql)) {
|
|
755
|
+
poisoned = true;
|
|
756
|
+
try {
|
|
757
|
+
closeOnce();
|
|
758
|
+
}
|
|
759
|
+
catch { /* Keep the primary SQLite failure. */ }
|
|
760
|
+
}
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
finally {
|
|
764
|
+
if (closed || !db.isOpen || !db.isTransaction) {
|
|
765
|
+
reserved = false;
|
|
766
|
+
if (!/^\s*COMMIT\b/i.test(sql)) {
|
|
767
|
+
afterCommit = [];
|
|
768
|
+
manualFrames = [];
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
},
|
|
573
773
|
execSchema: (sql) => { assertUsable(); if (db.isTransaction && !reserved)
|
|
574
774
|
throw new AppError("write_transaction_unowned", "Cannot change schema inside an unowned transaction", 1); executeStoreSchema(db, sql); },
|
|
575
775
|
beginWriteTransaction,
|
|
576
776
|
writeTransaction,
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
777
|
+
readSnapshot,
|
|
778
|
+
afterCommit: (operation) => { assertUsable(); if (readDepth)
|
|
779
|
+
throw new AppError("read_snapshot_write", "Cannot queue publication inside a read snapshot", 1); if (db.isTransaction)
|
|
780
|
+
afterCommit.push(operation);
|
|
781
|
+
else {
|
|
782
|
+
const scope = publicationScopes.at(-1);
|
|
783
|
+
if (scope)
|
|
784
|
+
scope.push(operation);
|
|
785
|
+
else
|
|
786
|
+
executePublications([operation]);
|
|
787
|
+
} },
|
|
788
|
+
run: (sql, params = []) => { assertUsable(); if (readDepth)
|
|
789
|
+
throw new AppError("read_snapshot_write", "Cannot write inside a read snapshot", 1); return db.prepare(sql).run(...params); },
|
|
790
|
+
get: (sql, params = []) => { assertUsable(); return db.prepare(sql).get(...params); },
|
|
791
|
+
all: (sql, params = []) => { assertUsable(); return db.prepare(sql).all(...params); }
|
|
580
792
|
};
|
|
581
793
|
}
|
|
582
794
|
function errorDetails(error) {
|
|
@@ -640,6 +852,7 @@ function migrate(db) {
|
|
|
640
852
|
CREATE TABLE IF NOT EXISTS plan_bindings (
|
|
641
853
|
project_id TEXT NOT NULL,
|
|
642
854
|
protocol_id TEXT NOT NULL,
|
|
855
|
+
plan_id TEXT,
|
|
643
856
|
plan_revision INTEGER NOT NULL,
|
|
644
857
|
plan_sha256 TEXT NOT NULL,
|
|
645
858
|
plan_path TEXT NOT NULL,
|
|
@@ -1221,6 +1434,22 @@ function migrate(db) {
|
|
|
1221
1434
|
ensureColumn(db, "usage", "source_mtime_ms", "ALTER TABLE usage ADD COLUMN source_mtime_ms INTEGER");
|
|
1222
1435
|
db.prepare("UPDATE usage SET cache_read_input_tokens = cached_input_tokens WHERE cache_read_input_tokens IS NULL AND cached_input_tokens IS NOT NULL").run();
|
|
1223
1436
|
ensureColumn(db, "runs", "run_home_path", "ALTER TABLE runs ADD COLUMN run_home_path TEXT");
|
|
1437
|
+
ensureColumn(db, "plan_bindings", "plan_id", "ALTER TABLE plan_bindings ADD COLUMN plan_id TEXT");
|
|
1438
|
+
// Historical RUN projections already retain the canonical identifier. Use
|
|
1439
|
+
// those SQL bytes to backfill without opening plan files under the writer.
|
|
1440
|
+
for (const row of db.prepare("SELECT project_id, protocol_id FROM plan_bindings WHERE plan_id IS NULL").all()) {
|
|
1441
|
+
const runs = db.prepare("SELECT index_json FROM runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ? ORDER BY updated_at DESC").all(row.project_id, row.protocol_id);
|
|
1442
|
+
for (const run of runs) {
|
|
1443
|
+
try {
|
|
1444
|
+
const planId = JSON.parse(run.index_json).plan_ref?.plan_id;
|
|
1445
|
+
if (typeof planId !== "string" || !planId || planId === "unknown-plan")
|
|
1446
|
+
continue;
|
|
1447
|
+
db.prepare("UPDATE plan_bindings SET plan_id = ? WHERE project_id = ? AND protocol_id = ? AND plan_id IS NULL").run(planId, row.project_id, row.protocol_id);
|
|
1448
|
+
break;
|
|
1449
|
+
}
|
|
1450
|
+
catch { /* A malformed historical RUN cannot establish plan identity. */ }
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1224
1453
|
ensureColumn(db, "runs", "run_root", "ALTER TABLE runs ADD COLUMN run_root TEXT");
|
|
1225
1454
|
db.prepare("UPDATE runs SET run_root = COALESCE(run_root, run_home_path, run_dir) WHERE run_root IS NULL OR run_root = ''").run();
|
|
1226
1455
|
ensureColumn(db, "merge_requests", "accepted_tree", "ALTER TABLE merge_requests ADD COLUMN accepted_tree TEXT");
|
|
@@ -1237,6 +1466,8 @@ function migrate(db) {
|
|
|
1237
1466
|
ensureColumn(db, "runs", "artifact_root_kind", "ALTER TABLE runs ADD COLUMN artifact_root_kind TEXT");
|
|
1238
1467
|
ensureColumn(db, "works", "payload_json", "ALTER TABLE works ADD COLUMN payload_json TEXT");
|
|
1239
1468
|
ensureColumn(db, "work_sessions", "start_receipt_json", "ALTER TABLE work_sessions ADD COLUMN start_receipt_json TEXT");
|
|
1469
|
+
ensureColumn(db, "work_sessions", "start_publication_status", "ALTER TABLE work_sessions ADD COLUMN start_publication_status TEXT");
|
|
1470
|
+
ensureColumn(db, "work_sessions", "result_receipt_json", "ALTER TABLE work_sessions ADD COLUMN result_receipt_json TEXT");
|
|
1240
1471
|
ensureColumn(db, "check_receipts", "declaration_id", "ALTER TABLE check_receipts ADD COLUMN declaration_id TEXT NOT NULL DEFAULT 'CHK-LEGACY'");
|
|
1241
1472
|
ensureColumn(db, "check_receipts", "workspace_fingerprint", "ALTER TABLE check_receipts ADD COLUMN workspace_fingerprint TEXT NOT NULL DEFAULT ''");
|
|
1242
1473
|
ensureColumn(db, "check_receipts", "artifacts_json", "ALTER TABLE check_receipts ADD COLUMN artifacts_json TEXT NOT NULL DEFAULT '[]'");
|
|
@@ -1329,8 +1560,7 @@ function migrateSessionStorageKeys(db, dbPath) {
|
|
|
1329
1560
|
db.exec("COMMIT");
|
|
1330
1561
|
}
|
|
1331
1562
|
catch (error) {
|
|
1332
|
-
db
|
|
1333
|
-
throw error;
|
|
1563
|
+
return rollbackStoreTransaction(db, "ROLLBACK", error);
|
|
1334
1564
|
}
|
|
1335
1565
|
}
|
|
1336
1566
|
function storageSessionKey(harness, native) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
/** Relocation changes related keys together; restore connection policy even
|
|
3
|
+
* when BEGIN or rollback fails, and never reuse a connection with unknown FK mode. */
|
|
4
|
+
export function foreignKeyTransaction(db, operation) {
|
|
5
|
+
const foreignKeys = db.get("PRAGMA foreign_keys").foreign_keys;
|
|
6
|
+
let value, primary, failed = false, committed = false;
|
|
7
|
+
try {
|
|
8
|
+
db.exec("PRAGMA foreign_keys = OFF");
|
|
9
|
+
if (db.get("PRAGMA foreign_keys")?.foreign_keys !== 0)
|
|
10
|
+
throw new AppError("foreign_key_transaction_active", "Key relocation requires its own connection transaction", 1);
|
|
11
|
+
value = db.writeTransaction(() => { db.afterCommit(() => { committed = true; }); return operation(); });
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
primary = error;
|
|
15
|
+
failed = true;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
db.exec(`PRAGMA foreign_keys = ${foreignKeys ? "ON" : "OFF"}`);
|
|
19
|
+
if (db.get("PRAGMA foreign_keys")?.foreign_keys !== foreignKeys)
|
|
20
|
+
throw new Error("Foreign-key enforcement was not restored");
|
|
21
|
+
}
|
|
22
|
+
catch (restore) {
|
|
23
|
+
const details = { foreign_keys_restore_error: String(restore), effect: committed ? "committed" : "unknown" };
|
|
24
|
+
try {
|
|
25
|
+
db.close?.();
|
|
26
|
+
}
|
|
27
|
+
catch (close) {
|
|
28
|
+
details.connection_close_error = String(close);
|
|
29
|
+
}
|
|
30
|
+
if (primary instanceof AppError)
|
|
31
|
+
Object.assign(primary.details, details);
|
|
32
|
+
else
|
|
33
|
+
primary = new AppError("foreign_key_restoration_failed", primary instanceof Error ? primary.message : "Cannot restore foreign-key enforcement", 1, { ...details, ...(failed ? { cause: String(primary) } : {}) });
|
|
34
|
+
failed = true;
|
|
35
|
+
}
|
|
36
|
+
if (failed)
|
|
37
|
+
throw primary;
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
/** Durable immutable candidate; only a later SQL receipt grants it authority. */
|
|
6
|
+
export function publishImmutableInput(file, bytes, conflictCode) {
|
|
7
|
+
const expected = crypto.createHash("sha256").update(bytes).digest("hex");
|
|
8
|
+
const verify = () => {
|
|
9
|
+
const stat = fs.lstatSync(file);
|
|
10
|
+
if (!stat.isFile() || stat.isSymbolicLink() || crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") !== expected)
|
|
11
|
+
throw new AppError(conflictCode, "Immutable input differs from the accepted bytes", 1, { path: file, expected_sha256: expected });
|
|
12
|
+
};
|
|
13
|
+
const temporary = `${file}.tmp-${crypto.randomUUID()}`;
|
|
14
|
+
try {
|
|
15
|
+
fs.writeFileSync(temporary, bytes, { flag: "wx", mode: 0o600 });
|
|
16
|
+
const descriptor = fs.openSync(temporary, "r");
|
|
17
|
+
try {
|
|
18
|
+
fs.fsyncSync(descriptor);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
fs.closeSync(descriptor);
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
fs.linkSync(temporary, file);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error.code !== "EEXIST")
|
|
28
|
+
throw error;
|
|
29
|
+
verify();
|
|
30
|
+
}
|
|
31
|
+
const directory = fs.openSync(path.dirname(file), "r");
|
|
32
|
+
try {
|
|
33
|
+
fs.fsyncSync(directory);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
fs.closeSync(directory);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
try {
|
|
41
|
+
fs.rmSync(temporary, { force: true });
|
|
42
|
+
}
|
|
43
|
+
catch { /* Unreferenced staging garbage must not mask the accepted input or primary error. */ }
|
|
44
|
+
}
|
|
45
|
+
}
|