@deksden-com/dd-flow-cli 0.9.0-beta.38 → 0.9.0-beta.40
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 +22 -1
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +42 -1
- package/dist/cli/run-cli.js +147 -17
- package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +4 -3
- package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -3
- package/dist/harness-runtime/bin/dd-opencode.mjs +4 -3
- package/dist/harness-runtime/bin/dd-zcode.mjs +4 -3
- package/dist/harness-runtime/lib/daemon-operations.mjs +48 -9
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +49 -12
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +57 -16
- package/dist/harness-runtime/lib/dd-codex.mjs +10 -2
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +3 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -6
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +4 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +8 -5
- package/dist/harness-runtime/lib/managed-daemon.mjs +125 -6
- package/dist/harness-runtime/lib/process-json.mjs +1 -1
- package/dist/harness-runtime/lib/session-settlement.mjs +4 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/services/cleanup.js +7 -4
- package/dist/services/cli-operation-classifier.js +3 -1
- package/dist/services/code-checks.js +33 -7
- package/dist/services/controller-fanout.js +105 -0
- package/dist/services/engines.js +2 -0
- package/dist/services/eval-snapshots.js +379 -36
- package/dist/services/execution-policy.js +122 -6
- package/dist/services/external-work-launch.js +33 -12
- package/dist/services/harness-adapter.js +49 -2
- package/dist/services/hooks.js +5 -5
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +54 -7
- package/dist/services/merge-server.js +53 -30
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +31 -0
- package/dist/services/protocols.js +9 -0
- package/dist/services/recovery-snapshot-database.js +103 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +297 -0
- package/dist/services/run-control.js +752 -0
- package/dist/services/run-controller-adapter.js +79 -0
- package/dist/services/run-controller-capture.js +117 -0
- package/dist/services/run-controller-process.js +178 -0
- package/dist/services/run-controller-recovery.js +219 -0
- package/dist/services/run-controller-state.js +31 -0
- package/dist/services/run-controller.js +539 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +33 -9
- package/dist/services/runs.js +65 -13
- package/dist/services/runtime-budget.js +362 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +431 -0
- package/dist/services/runtime-scope-resume.js +543 -0
- package/dist/services/runtime-scope-stop.js +95 -0
- package/dist/services/runtime-scope-worker.js +165 -0
- package/dist/services/runtime-service.js +4 -0
- package/dist/services/vnext-code-review.js +1 -1
- package/dist/services/vnext-code.js +1 -1
- package/dist/services/vnext-execution-profile.js +2 -35
- package/dist/services/vnext-fanout.js +23 -7
- package/dist/services/vnext-plan-review.js +12 -9
- package/dist/services/vnext-plan.js +1 -1
- package/dist/services/vnext-protocolize.js +10 -4
- package/dist/services/vnext-specify.js +12 -5
- package/dist/services/work-registry.js +26 -8
- package/dist/services/workspace-bootstrap.js +10 -1
- package/dist/storage/database.js +160 -13
- package/dist/storage/writer-contract.js +76 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ import { resolveProjectRoot, writeJsonAtomic } from "../storage/paths.js";
|
|
|
9
9
|
import { preflightMemoryPermissions } from "./memory-permissions.js";
|
|
10
10
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
11
11
|
import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
|
|
12
|
+
import { resolveStageTransition } from "./execution-policy.js";
|
|
12
13
|
import { validateSchema } from "./schema-validation.js";
|
|
13
14
|
import { bindRunningWorkSession, refreshRunWorkProjection } from "./work-registry.js";
|
|
14
15
|
import { nextWorkId } from "./ids.js";
|
|
@@ -65,7 +66,7 @@ export function launchVnextSpecify(context, input) {
|
|
|
65
66
|
if (preflight.ok !== true)
|
|
66
67
|
throw new AppError("permission_preflight_failed", "SPECIFY workspace is not writable", 1, { preflight });
|
|
67
68
|
const grounding = projectGrounding(projectRoot);
|
|
68
|
-
const handoff = executionSnapshot(context, run.id);
|
|
69
|
+
const handoff = executionSnapshot(context, run.project_id, run.id);
|
|
69
70
|
const workContext = {
|
|
70
71
|
schema_id: "dd-flow/work-context@1",
|
|
71
72
|
system: { run_id: run.id, work_id: workId, stage: stageId },
|
|
@@ -187,7 +188,7 @@ export function submitVnextSpecify(context, input) {
|
|
|
187
188
|
appendFlowRunTimelineEvent(context, run.project_id, run.id, { type: "work_completed", work_id: work.work_id, outcome, stage: stageId });
|
|
188
189
|
if (continuesToProtocolize) {
|
|
189
190
|
const nextWork = requireWork(context, work.work_id);
|
|
190
|
-
advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "specified", nextAction: "start_protocolize" });
|
|
191
|
+
advanceFlowRun(context, { settlementStage: "specify", projectRoot, runId: run.id, status: "running", verdict: "specified", nextAction: "start_protocolize" });
|
|
191
192
|
refreshRunWorkProjection(context, run.project_id, run.id);
|
|
192
193
|
const nextCommand = `${flowCommand(context)} stage start ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --json`;
|
|
193
194
|
return {
|
|
@@ -562,12 +563,18 @@ export function readVnextFlowDefinition(projectRoot) {
|
|
|
562
563
|
}
|
|
563
564
|
return null;
|
|
564
565
|
}
|
|
565
|
-
function executionSnapshot(context, runId) {
|
|
566
|
-
const row = context.db.get("SELECT index_json FROM runs WHERE id = ?", [runId]);
|
|
566
|
+
function executionSnapshot(context, projectId, runId) {
|
|
567
|
+
const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
567
568
|
const snapshot = row ? JSON.parse(row.index_json) : null;
|
|
568
|
-
const
|
|
569
|
+
const profile = snapshot?.execution_profile;
|
|
570
|
+
let handoff = profile?.settings.stage_session_mode;
|
|
569
571
|
if (handoff !== "same_session" && handoff !== "new_session")
|
|
570
572
|
throw new AppError("execution_profile_invalid", "RUN has no frozen stage session mode", 1, { run_id: runId });
|
|
573
|
+
if (profile?.settings.execution) {
|
|
574
|
+
if (!profile.agent_profiles)
|
|
575
|
+
throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
|
|
576
|
+
handoff = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, fromStage: "specify", stage: "protocolize", stageSessionMode: handoff, mergeMode: profile.settings.merge_mode }).session_mode;
|
|
577
|
+
}
|
|
571
578
|
return { stage_handoff: { effective: handoff, source: "run_override" } };
|
|
572
579
|
}
|
|
573
580
|
function executionSnapshotFromPath(contextPath, workId) {
|
|
@@ -9,11 +9,13 @@ import { refreshRunSessionProjection } from "./run-projection.js";
|
|
|
9
9
|
import { readCodeCheckProfile, runCodeChecks, workspaceFingerprint } from "./code-checks.js";
|
|
10
10
|
import { nextWorkId, nextWorkIds } from "./ids.js";
|
|
11
11
|
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
12
|
+
import { resolveStageTransition } from "./execution-policy.js";
|
|
12
13
|
import { appendAudit } from "./audit.js";
|
|
13
14
|
import { flowCommand } from "./stage-pause.js";
|
|
14
15
|
import { assertPortableArtifactRef } from "./portable-refs.js";
|
|
15
16
|
import { publicSessionIdentity } from "./session-identity.js";
|
|
16
|
-
import { activateRecoveryWork, assertRecoveryHookCurrent, assertRunMutationAllowed, recoveryGuard, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
|
|
17
|
+
import { activateRecoveryWork, assertRecoveryHookCurrent, assertRunMutationAllowed, assertWorkSettlementAllowed, recoveryGuard, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
|
|
18
|
+
import { runControlDirective } from "./run-control.js";
|
|
17
19
|
const workColumns = "work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, created_at, started_at, updated_at, completed_at";
|
|
18
20
|
export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
|
|
19
21
|
function ownsPhysicalWorkSession(context, projectId, owner, identity) {
|
|
@@ -304,7 +306,7 @@ export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.rep
|
|
|
304
306
|
* same captured runtime as their parent stage.
|
|
305
307
|
*/
|
|
306
308
|
export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); const recovery = recoveryGuard(context, work.project_id, work.run_id); return `${flowCommand(context)} work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)}${recovery ? ` --recovery-id ${JSON.stringify(recovery.recovery_id)}` : ""} --json`; }
|
|
307
|
-
export function
|
|
309
|
+
export function assertWorkLaunchReady(context, id) {
|
|
308
310
|
ensureWorkRegistry(context);
|
|
309
311
|
const work = requireWork(context, id);
|
|
310
312
|
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
@@ -315,6 +317,10 @@ export function startWork(context, id, input) {
|
|
|
315
317
|
const ready = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
|
|
316
318
|
throw new AppError("work_not_ready", "Work cannot start until dependencies complete and overlapping planned coordination areas are free", 2, { work_id: id, blockers, ready });
|
|
317
319
|
}
|
|
320
|
+
return work;
|
|
321
|
+
}
|
|
322
|
+
export function startWork(context, id, input) {
|
|
323
|
+
const work = assertWorkLaunchReady(context, id);
|
|
318
324
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
319
325
|
if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
|
|
320
326
|
throw new AppError("project_mismatch", "work start project root does not match its RUN", 1, { work_id: id });
|
|
@@ -403,9 +409,20 @@ export function bindStageCoordinatorWork(context, input) {
|
|
|
403
409
|
const work = requireWork(context, input.workId);
|
|
404
410
|
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
405
411
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
406
|
-
claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
407
412
|
const snapshot = JSON.parse((context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json) ?? "{}");
|
|
408
|
-
const
|
|
413
|
+
const profile = snapshot.execution_profile;
|
|
414
|
+
const previous = context.db.get("SELECT ws.session_id, s.current_stage FROM work_sessions ws JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? AND ws.status = 'running' ORDER BY ws.created_at DESC LIMIT 1", [work.project_id, work.work_id]);
|
|
415
|
+
let sessionMode = profile?.settings.stage_session_mode ?? "same_session";
|
|
416
|
+
if (profile?.settings.execution) {
|
|
417
|
+
if (!profile.agent_profiles)
|
|
418
|
+
throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
|
|
419
|
+
sessionMode = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, ...(previous?.current_stage ? { fromStage: previous.current_stage } : {}), stage: input.stage, stageSessionMode: sessionMode, mergeMode: profile.settings.merge_mode }).session_mode;
|
|
420
|
+
}
|
|
421
|
+
const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
|
|
422
|
+
if (previous?.current_stage && previous.current_stage !== input.stage && sessionMode === "new_session" && previous.session_id === identity.sessionId)
|
|
423
|
+
throw new AppError("stage_handoff_required", "This stage requires a fresh coordinator Session under its frozen execution policy", 1, { from_stage: previous.current_stage, stage: input.stage });
|
|
424
|
+
claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
425
|
+
const binding = bindRunningWorkSession(context, { ...input, allowStageHandoff: sessionMode === "new_session" });
|
|
409
426
|
context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, binding.session_id]);
|
|
410
427
|
refreshRunSessionProjection(context, work.project_id, work.run_id);
|
|
411
428
|
return binding;
|
|
@@ -556,7 +573,7 @@ export function retryWork(context, id, reason) {
|
|
|
556
573
|
}
|
|
557
574
|
async function settle(context, id, status, result, progress) {
|
|
558
575
|
const work = requireWork(context, id);
|
|
559
|
-
|
|
576
|
+
assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
|
|
560
577
|
id = work.work_id;
|
|
561
578
|
if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
|
|
562
579
|
throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
|
|
@@ -597,7 +614,7 @@ async function settle(context, id, status, result, progress) {
|
|
|
597
614
|
const now = context.now();
|
|
598
615
|
context.db.exec("BEGIN IMMEDIATE");
|
|
599
616
|
try {
|
|
600
|
-
|
|
617
|
+
assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
|
|
601
618
|
if (link && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND status = 'running'", [link.id]))
|
|
602
619
|
throw new AppError("work_generation_stale", "Work Session was interrupted or superseded while checks ran", 1, { work_id: id });
|
|
603
620
|
if (link?.result_path && work.parent_work_id !== null)
|
|
@@ -618,11 +635,12 @@ async function settle(context, id, status, result, progress) {
|
|
|
618
635
|
materializePendingWorkResults(context, work.project_id, work.run_id);
|
|
619
636
|
settleRecoveryWork(context, work.work_id, status);
|
|
620
637
|
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
621
|
-
const
|
|
638
|
+
const control = runControlDirective(context, work.project_id, work.run_id);
|
|
639
|
+
const newlyReady = control ? [] : context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, launch_policy: candidate.launch_policy, start_command: workStartCommand(context, candidate) }));
|
|
622
640
|
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: `work_${status}`, work_id: work.work_id, session_id: link?.session_id ?? null, ...(coordinationDrift.length ? { coordination_drift: coordinationDrift } : {}) });
|
|
623
641
|
for (const ready of newlyReady)
|
|
624
642
|
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
|
|
625
|
-
return { ok: true, work_id: id, status, checks: receipts, coordination: { planned_write_areas: codePacket(work)?.planned_write_areas ?? [], drift: coordinationDrift }, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
|
|
643
|
+
return { ok: true, ...(control ? { control } : {}), work_id: id, status, checks: receipts, coordination: { planned_write_areas: codePacket(work)?.planned_write_areas ?? [], drift: coordinationDrift }, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
|
|
626
644
|
}
|
|
627
645
|
function recoveryPrompt(recovery) { return ["<recovery_identity>", `- recovery_id: ${recovery.recoveryId}`, `- execution_generation: ${recovery.generation}`, "This Work was interrupted and is now an authorized recovery segment of the same assignment.", "</recovery_identity>", "", "<recovery_instructions>", `Read the retained pre-interruption evidence at ${recovery.evidencePath}.`, "Before changing files or rerunning a command, inspect the workspace, receipts, logs, and partial artifacts. Do not assume the last command succeeded or failed merely because its reply was lost.", "Preserve accepted work. Do not repeat completed Work, create undeclared Work, alter the original requirements, or edit dd-flow runtime state/evidence.", "Observe an existing check or finish instead of duplicating it. Retry only through an exact runtime retry command after establishing that its prior outcome permits it.", "Record the concrete unresolved operation rather than guessing if an external effect cannot be reconciled.", "</recovery_instructions>"].join("\n"); }
|
|
628
646
|
function plannedAreaDrift(packet, result) {
|
|
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
5
6
|
import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
|
|
6
7
|
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedOwnerIsAlive, managedProcessStatus, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
|
|
7
8
|
/** CODE and MERGE prepare their own checkout through the same managed executor.
|
|
@@ -24,13 +25,21 @@ export async function runWorkspaceBootstrap(context, input) {
|
|
|
24
25
|
finishManagedProcess(context, { id: pending.id, leaseToken: pending.lease_token, state: exitCode === 0 ? "stopped" : "failed", reason: error ?? "bootstrap_completion_reconciled" });
|
|
25
26
|
return result(pending.id, pending.stdout_path, pending.stderr_path, completionPath, exitCode, error);
|
|
26
27
|
}
|
|
28
|
+
const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
|
|
27
29
|
const directory = path.join(input.runHome, input.artifactDir, `bootstrap-${crypto.randomUUID()}`);
|
|
28
30
|
fs.mkdirSync(directory, { recursive: true });
|
|
29
31
|
const stdoutPath = path.join(directory, "stdout.log"), stderrPath = path.join(directory, "stderr.log"), completionPath = path.join(directory, "completion.json");
|
|
30
32
|
const managed = registerManagedProcess(context, { kind: "workspace-bootstrap", ownerId: operationId, ownerPid: process.pid, operationId, uniqueActiveOperation: true, projectId: input.projectId, runId: input.runId, stdoutPath, stderrPath, metadata: { command: input.command, workspace_root: input.workspaceRoot } });
|
|
31
33
|
const stdout = fs.openSync(stdoutPath, "a"), stderr = fs.openSync(stderrPath, "a");
|
|
32
34
|
try {
|
|
33
|
-
|
|
35
|
+
try {
|
|
36
|
+
input.progress?.(`workspace bootstrap preparing: ${input.command}; logs: ${directory}`);
|
|
37
|
+
assertRunMutationAllowed(context, input.projectId, input.runId, generation);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: "failed", reason: "dispatch_fenced_before_spawn" });
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
34
43
|
const completed = await runCheck(input.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_CHECK_COMPLETION_FILE: completionPath }, stdout, stderr, elapsed => input.progress?.(`workspace bootstrap still running (${elapsed}s); logs: ${directory}; next report in 15s`), pid => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
|
|
35
44
|
const current = managedProcessStatus(context).find(item => item.id === managed.id);
|
|
36
45
|
if (processTreeIsAlive(current))
|
package/dist/storage/database.js
CHANGED
|
@@ -4,9 +4,86 @@ import crypto from "node:crypto";
|
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { ensureDir } from "./paths.js";
|
|
6
6
|
import { AppError } from "../shared/errors.js";
|
|
7
|
+
import { executeStoreSchema, readStoreWriterContract, registerStoreWriter } from "./writer-contract.js";
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const { DatabaseSync } = require("node:sqlite");
|
|
9
10
|
const resourceDatabases = new Map();
|
|
11
|
+
/** RUN authority stays in its existing store; process leases stay in runtime.sqlite. */
|
|
12
|
+
export function ensureRunControlStorage(db) {
|
|
13
|
+
db.execSchema(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS run_controls (
|
|
15
|
+
control_id TEXT PRIMARY KEY,
|
|
16
|
+
project_id TEXT NOT NULL, run_id TEXT NOT NULL,
|
|
17
|
+
request_id TEXT NOT NULL, request_hash TEXT NOT NULL, request_json TEXT NOT NULL,
|
|
18
|
+
recovery_id TEXT NOT NULL REFERENCES run_recovery_guards(recovery_id),
|
|
19
|
+
action TEXT NOT NULL, status TEXT NOT NULL,
|
|
20
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
21
|
+
UNIQUE(project_id, run_id, request_id),
|
|
22
|
+
FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
|
|
23
|
+
);
|
|
24
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_run_control_active
|
|
25
|
+
ON run_controls(project_id, run_id) WHERE status IN ('requested','settled','resuming');
|
|
26
|
+
CREATE TABLE IF NOT EXISTS run_control_admissions (
|
|
27
|
+
control_id TEXT NOT NULL REFERENCES run_controls(control_id),
|
|
28
|
+
work_session_id TEXT NOT NULL REFERENCES work_sessions(id),
|
|
29
|
+
work_id TEXT NOT NULL REFERENCES works(work_id),
|
|
30
|
+
session_id TEXT NOT NULL, admitted_at TEXT NOT NULL,
|
|
31
|
+
PRIMARY KEY(control_id, work_session_id)
|
|
32
|
+
);
|
|
33
|
+
CREATE TABLE IF NOT EXISTS run_control_stage_admissions (
|
|
34
|
+
control_id TEXT NOT NULL REFERENCES run_controls(control_id),
|
|
35
|
+
stage TEXT NOT NULL, attempt TEXT NOT NULL,
|
|
36
|
+
work_session_id TEXT NOT NULL REFERENCES work_sessions(id),
|
|
37
|
+
PRIMARY KEY(control_id, stage)
|
|
38
|
+
);
|
|
39
|
+
CREATE TABLE IF NOT EXISTS run_control_operations (
|
|
40
|
+
operation_id TEXT PRIMARY KEY, control_id TEXT NOT NULL REFERENCES run_controls(control_id),
|
|
41
|
+
target_json TEXT NOT NULL, status TEXT NOT NULL, receipt_json TEXT, error_json TEXT,
|
|
42
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
43
|
+
);
|
|
44
|
+
CREATE TABLE IF NOT EXISTS run_control_workers (
|
|
45
|
+
control_id TEXT PRIMARY KEY REFERENCES run_controls(control_id),
|
|
46
|
+
owner_token TEXT NOT NULL, owner_pid INTEGER,
|
|
47
|
+
status TEXT NOT NULL, process_id TEXT, process_lease_token TEXT,
|
|
48
|
+
resource_home TEXT NOT NULL, state_dir TEXT NOT NULL,
|
|
49
|
+
snapshot_json TEXT, error_json TEXT,
|
|
50
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
51
|
+
);
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
export function ensureRunControllerStorage(db) {
|
|
55
|
+
db.execSchema(`
|
|
56
|
+
CREATE TABLE IF NOT EXISTS run_controllers (
|
|
57
|
+
controller_id TEXT PRIMARY KEY,
|
|
58
|
+
project_id TEXT NOT NULL, run_id TEXT NOT NULL,
|
|
59
|
+
request_id TEXT NOT NULL, request_hash TEXT NOT NULL,
|
|
60
|
+
generation INTEGER NOT NULL, status TEXT NOT NULL,
|
|
61
|
+
owner_token TEXT NOT NULL, process_id TEXT, process_lease_token TEXT,
|
|
62
|
+
owner_pid INTEGER, manifest_json TEXT NOT NULL, state_json TEXT NOT NULL,
|
|
63
|
+
state_dir TEXT NOT NULL, last_error_json TEXT,
|
|
64
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
65
|
+
FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id),
|
|
66
|
+
UNIQUE(project_id, run_id, request_id)
|
|
67
|
+
);
|
|
68
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_run_controller_owner
|
|
69
|
+
ON run_controllers(project_id, run_id)
|
|
70
|
+
WHERE status NOT IN ('completed', 'cancelled', 'superseded');
|
|
71
|
+
CREATE TABLE IF NOT EXISTS run_controller_events (
|
|
72
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
73
|
+
controller_id TEXT NOT NULL REFERENCES run_controllers(controller_id),
|
|
74
|
+
event_type TEXT NOT NULL, data_json TEXT NOT NULL, created_at TEXT NOT NULL
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_run_controller_events
|
|
77
|
+
ON run_controller_events(controller_id, sequence);
|
|
78
|
+
CREATE TABLE IF NOT EXISTS run_controller_operations (
|
|
79
|
+
operation_id TEXT PRIMARY KEY,
|
|
80
|
+
controller_id TEXT NOT NULL REFERENCES run_controllers(controller_id),
|
|
81
|
+
generation INTEGER NOT NULL, input_hash TEXT NOT NULL,
|
|
82
|
+
input_json TEXT NOT NULL, status TEXT NOT NULL,
|
|
83
|
+
receipt_json TEXT, error_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
84
|
+
);
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
10
87
|
/** Recovery guards apply to every RUN kind, including custom eval fixtures. */
|
|
11
88
|
export function ensureRecoveryStorage(db) {
|
|
12
89
|
db.exec(`
|
|
@@ -29,6 +106,7 @@ export function ensureRecoveryStorage(db) {
|
|
|
29
106
|
`);
|
|
30
107
|
ensureColumn(db, "run_recovery_bindings", "packet_json", "ALTER TABLE run_recovery_bindings ADD COLUMN packet_json TEXT");
|
|
31
108
|
ensureColumn(db, "run_recovery_bindings", "packet_sha256", "ALTER TABLE run_recovery_bindings ADD COLUMN packet_sha256 TEXT");
|
|
109
|
+
ensureColumn(db, "run_recovery_bindings", "runtime_dispatch_json", "ALTER TABLE run_recovery_bindings ADD COLUMN runtime_dispatch_json TEXT");
|
|
32
110
|
}
|
|
33
111
|
/** Creates the single Work/Session authority used by a fresh vNext beta runtime. */
|
|
34
112
|
export function ensureVnextWorkStorage(db) {
|
|
@@ -39,7 +117,7 @@ export function ensureVnextWorkStorage(db) {
|
|
|
39
117
|
if (existing && (!/task TEXT NOT NULL/.test(existing.sql) || /launch_token|adapter_session_id/.test(existing.sql))) {
|
|
40
118
|
throw new AppError("beta_storage_recreate_required", "This beta requires a fresh DD_FLOW_HOME; the existing works schema is not compatible", 1);
|
|
41
119
|
}
|
|
42
|
-
db.
|
|
120
|
+
db.execSchema(`
|
|
43
121
|
CREATE TABLE IF NOT EXISTS works (
|
|
44
122
|
work_id TEXT PRIMARY KEY,
|
|
45
123
|
project_id TEXT NOT NULL,
|
|
@@ -119,7 +197,7 @@ export function ensureVnextWorkStorage(db) {
|
|
|
119
197
|
/** Durable intent for a provider-root external Work. It is deliberately
|
|
120
198
|
* separate from `work_sessions`: only a trusted hook may bind that Session. */
|
|
121
199
|
export function ensureWorkLaunchStorage(db) {
|
|
122
|
-
db.
|
|
200
|
+
db.execSchema(`
|
|
123
201
|
CREATE TABLE IF NOT EXISTS work_launches (
|
|
124
202
|
operation_id TEXT PRIMARY KEY,
|
|
125
203
|
work_id TEXT NOT NULL UNIQUE,
|
|
@@ -146,12 +224,40 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
146
224
|
if (!exists || mode === "migrate")
|
|
147
225
|
ensureDir(ddFlowHome);
|
|
148
226
|
const db = mode === "read_existing" ? new DatabaseSync(dbPath, { readOnly: true }) : new DatabaseSync(dbPath);
|
|
149
|
-
|
|
227
|
+
db.exec("PRAGMA busy_timeout = 4000");
|
|
228
|
+
if (mode !== "read_existing") {
|
|
229
|
+
try {
|
|
230
|
+
registerStoreWriter(db);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
db.close();
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
configureDatabase(db, mode);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
db.close();
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
150
244
|
// Internal additive schema changes must be available to every write command.
|
|
151
245
|
// Higher-level Memory Bank migrations remain explicit in services/migrations.
|
|
152
246
|
if (mode !== "read_existing") {
|
|
153
|
-
|
|
154
|
-
|
|
247
|
+
try {
|
|
248
|
+
const initialize = () => { migrate(db); ensureRecoveryStorage(db); };
|
|
249
|
+
const fresh = !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
|
|
250
|
+
if (fresh || readStoreWriterContract(db) !== null)
|
|
251
|
+
executeStoreSchema(db, initialize, fresh);
|
|
252
|
+
else
|
|
253
|
+
initialize();
|
|
254
|
+
// This migration owns a VACUUM backup and a separate data transaction.
|
|
255
|
+
migrateSessionStorageKeys(db, dbPath);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
db.close();
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
155
261
|
}
|
|
156
262
|
if (mode === "read_existing")
|
|
157
263
|
db.exec("PRAGMA query_only = ON");
|
|
@@ -161,6 +267,7 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
161
267
|
writable: mode !== "read_existing",
|
|
162
268
|
close: () => db.close?.(),
|
|
163
269
|
exec: (sql) => db.exec(sql),
|
|
270
|
+
execSchema: (sql) => executeStoreSchema(db, sql),
|
|
164
271
|
run: (sql, params = []) => db.prepare(sql).run(...params),
|
|
165
272
|
get: (sql, params = []) => db.prepare(sql).get(...params),
|
|
166
273
|
all: (sql, params = []) => db.prepare(sql).all(...params)
|
|
@@ -178,9 +285,25 @@ export function getResourceDatabase(resourceHome) {
|
|
|
178
285
|
if (cached)
|
|
179
286
|
return cached;
|
|
180
287
|
const db = new DatabaseSync(dbPath);
|
|
181
|
-
|
|
288
|
+
db.exec("PRAGMA busy_timeout = 4000");
|
|
289
|
+
try {
|
|
290
|
+
registerStoreWriter(db);
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
db.close();
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
configureDatabase(db, "initialize");
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
db.close();
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
182
303
|
db.exec("PRAGMA foreign_keys = ON");
|
|
183
|
-
db.
|
|
304
|
+
const fresh = !db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
|
|
305
|
+
try {
|
|
306
|
+
executeStoreSchema(db, `
|
|
184
307
|
CREATE TABLE IF NOT EXISTS managed_processes (
|
|
185
308
|
id TEXT PRIMARY KEY,
|
|
186
309
|
kind TEXT NOT NULL,
|
|
@@ -219,12 +342,18 @@ export function getResourceDatabase(resourceHome) {
|
|
|
219
342
|
);
|
|
220
343
|
CREATE INDEX IF NOT EXISTS idx_managed_resources_lease
|
|
221
344
|
ON managed_resources(lease_expires_at, updated_at);
|
|
222
|
-
|
|
345
|
+
`, fresh);
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
db.close();
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
223
351
|
const database = {
|
|
224
352
|
path: dbPath,
|
|
225
353
|
writable: true,
|
|
226
354
|
close: () => { resourceDatabases.delete(dbPath); db.close?.(); },
|
|
227
355
|
exec: (sql) => db.exec(sql),
|
|
356
|
+
execSchema: (sql) => executeStoreSchema(db, sql),
|
|
228
357
|
run: (sql, params = []) => db.prepare(sql).run(...params),
|
|
229
358
|
get: (sql, params = []) => db.prepare(sql).get(...params),
|
|
230
359
|
all: (sql, params = []) => db.prepare(sql).all(...params)
|
|
@@ -237,19 +366,34 @@ function emptyReadOnlyDatabase(dbPath) {
|
|
|
237
366
|
path: dbPath,
|
|
238
367
|
writable: false,
|
|
239
368
|
exec: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
|
|
369
|
+
execSchema: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
|
|
240
370
|
run: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
|
|
241
371
|
get: () => undefined,
|
|
242
372
|
all: () => []
|
|
243
373
|
};
|
|
244
374
|
}
|
|
245
375
|
function configureDatabase(db, mode) {
|
|
246
|
-
db.exec("PRAGMA busy_timeout = 4000");
|
|
247
376
|
if (mode !== "read_existing") {
|
|
248
|
-
|
|
377
|
+
// Concurrent first openers can collide while changing journal mode, even
|
|
378
|
+
// with busy_timeout set. Retrying this configuration does not replay writes.
|
|
379
|
+
const deadline = performance.now() + 4000, pause = new Int32Array(new SharedArrayBuffer(4));
|
|
380
|
+
for (;;) {
|
|
381
|
+
try {
|
|
382
|
+
const current = db.prepare("PRAGMA journal_mode").get();
|
|
383
|
+
if (current.journal_mode !== "wal")
|
|
384
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
if ((error.errcode ?? 0) % 256 !== 5 || performance.now() >= deadline)
|
|
389
|
+
throw error;
|
|
390
|
+
Atomics.wait(pause, 0, 0, Math.min(25, Math.max(1, deadline - performance.now())));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
249
393
|
db.exec("PRAGMA synchronous = NORMAL");
|
|
250
394
|
}
|
|
251
395
|
}
|
|
252
|
-
function migrate(db
|
|
396
|
+
function migrate(db) {
|
|
253
397
|
db.exec(`
|
|
254
398
|
CREATE TABLE IF NOT EXISTS projects (
|
|
255
399
|
id TEXT PRIMARY KEY,
|
|
@@ -855,7 +999,6 @@ function migrate(db, dbPath) {
|
|
|
855
999
|
ensureColumn(db, "sessions", "plan_item_id", "ALTER TABLE sessions ADD COLUMN plan_item_id TEXT");
|
|
856
1000
|
ensureColumn(db, "sessions", "session_kind", "ALTER TABLE sessions ADD COLUMN session_kind TEXT");
|
|
857
1001
|
ensureColumn(db, "sessions", "coverage_units_json", "ALTER TABLE sessions ADD COLUMN coverage_units_json TEXT NOT NULL DEFAULT '[]'");
|
|
858
|
-
migrateSessionStorageKeys(db, dbPath);
|
|
859
1002
|
ensureColumn(db, "usage", "cache_read_input_tokens", "ALTER TABLE usage ADD COLUMN cache_read_input_tokens INTEGER");
|
|
860
1003
|
ensureColumn(db, "usage", "cache_write_input_tokens", "ALTER TABLE usage ADD COLUMN cache_write_input_tokens INTEGER");
|
|
861
1004
|
ensureColumn(db, "usage", "uncached_input_tokens", "ALTER TABLE usage ADD COLUMN uncached_input_tokens INTEGER");
|
|
@@ -871,7 +1014,11 @@ function migrate(db, dbPath) {
|
|
|
871
1014
|
ensureColumn(db, "merge_requests", "replacement_of_merge_request_id", "ALTER TABLE merge_requests ADD COLUMN replacement_of_merge_request_id TEXT");
|
|
872
1015
|
// A failed integration gate may create a new immutable source candidate in
|
|
873
1016
|
// the same RUN. The historical unique index made that repair path impossible.
|
|
874
|
-
|
|
1017
|
+
// Rebuilding an already-current index changes schema/data_version on every
|
|
1018
|
+
// CLI observation and prevents a controller's writer barrier from settling.
|
|
1019
|
+
const mergeRunIndex = db.prepare("PRAGMA index_list(merge_requests)").all().find(index => index.name === "idx_merge_requests_run");
|
|
1020
|
+
if (mergeRunIndex?.unique)
|
|
1021
|
+
db.exec("DROP INDEX idx_merge_requests_run");
|
|
875
1022
|
db.exec("CREATE INDEX IF NOT EXISTS idx_merge_requests_run ON merge_requests(project_id, run_id, created_at)");
|
|
876
1023
|
ensureColumn(db, "runs", "layout_version", "ALTER TABLE runs ADD COLUMN layout_version TEXT");
|
|
877
1024
|
ensureColumn(db, "runs", "artifact_root_kind", "ALTER TABLE runs ADD COLUMN artifact_root_kind TEXT");
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
const version = 1;
|
|
4
|
+
const table = "dd_flow_writer_contract";
|
|
5
|
+
const quote = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
6
|
+
/** Publish additive schema and its writer fences in the same transaction. */
|
|
7
|
+
export function executeStoreSchema(db, sql, initialize = false) {
|
|
8
|
+
const ownsTransaction = !db.isTransaction;
|
|
9
|
+
db.exec(ownsTransaction ? "BEGIN IMMEDIATE" : "SAVEPOINT dd_flow_schema_change");
|
|
10
|
+
try {
|
|
11
|
+
const protectedStore = readStoreWriterContract(db) !== null;
|
|
12
|
+
if (initialize && !protectedStore && db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get()) {
|
|
13
|
+
throw new AppError("store_initialization_conflict", "Another writer initialized this store; explicit migration is required", 1);
|
|
14
|
+
}
|
|
15
|
+
const before = db.prepare("PRAGMA schema_version").get();
|
|
16
|
+
if (typeof sql === "string")
|
|
17
|
+
db.exec(sql);
|
|
18
|
+
else
|
|
19
|
+
sql();
|
|
20
|
+
const after = db.prepare("PRAGMA schema_version").get();
|
|
21
|
+
if (initialize || (protectedStore && before.schema_version !== after.schema_version))
|
|
22
|
+
installStoreWriterContract(db);
|
|
23
|
+
db.exec(ownsTransaction ? "COMMIT" : "RELEASE dd_flow_schema_change");
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
db.exec(ownsTransaction ? "ROLLBACK" : "ROLLBACK TO dd_flow_schema_change; RELEASE dd_flow_schema_change");
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Registration is connection-local and never changes historical input bytes. */
|
|
31
|
+
export function registerStoreWriter(db) {
|
|
32
|
+
const current = readStoreWriterContract(db);
|
|
33
|
+
if (current !== null && current !== version)
|
|
34
|
+
throw new AppError("store_writer_incompatible", "This CLI cannot write the store's required contract", 1, { required: current, supported: version });
|
|
35
|
+
db.function("dd_flow_writer_version", () => version);
|
|
36
|
+
}
|
|
37
|
+
export function readStoreWriterContract(db) {
|
|
38
|
+
if (!db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table))
|
|
39
|
+
return null;
|
|
40
|
+
const rows = db.prepare(`SELECT id, version FROM ${table}`).all();
|
|
41
|
+
if (rows.length !== 1 || rows[0]?.id !== 1 || !Number.isSafeInteger(rows[0].version) || rows[0].version < 1)
|
|
42
|
+
throw new AppError("store_writer_contract_invalid", "Store writer contract is malformed", 1);
|
|
43
|
+
return rows[0].version;
|
|
44
|
+
}
|
|
45
|
+
/** Install only at an explicit migration boundary; ordinary reads never call it. */
|
|
46
|
+
export function installStoreWriterContract(db) {
|
|
47
|
+
registerStoreWriter(db);
|
|
48
|
+
const ownsTransaction = !db.isTransaction;
|
|
49
|
+
if (ownsTransaction)
|
|
50
|
+
db.exec("BEGIN IMMEDIATE");
|
|
51
|
+
try {
|
|
52
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${table} (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL);
|
|
53
|
+
INSERT OR IGNORE INTO ${table} (id, version) VALUES (1, ${version});`);
|
|
54
|
+
if (readStoreWriterContract(db) !== version)
|
|
55
|
+
throw new AppError("store_writer_incompatible", "Store writer contract changed before migration", 1);
|
|
56
|
+
const tables = db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
|
|
57
|
+
for (const target of tables) {
|
|
58
|
+
const key = crypto.createHash("sha256").update(target.name).digest("hex").slice(0, 24);
|
|
59
|
+
for (const action of ["INSERT", "UPDATE", "DELETE"]) {
|
|
60
|
+
const trigger = quote(`dd_flow_writer_${key}_${action.toLowerCase()}`);
|
|
61
|
+
db.exec(`DROP TRIGGER IF EXISTS ${trigger};
|
|
62
|
+
CREATE TRIGGER ${trigger} BEFORE ${action} ON ${quote(target.name)} BEGIN
|
|
63
|
+
SELECT CASE WHEN dd_flow_writer_version() <> (SELECT version FROM ${table} WHERE id = 1)
|
|
64
|
+
OR (SELECT COUNT(*) FROM ${table}) <> 1 THEN RAISE(ABORT, 'store_writer_incompatible') END;
|
|
65
|
+
END;`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (ownsTransaction)
|
|
69
|
+
db.exec("COMMIT");
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (ownsTransaction)
|
|
73
|
+
db.exec("ROLLBACK");
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { installStoreWriterContract, readStoreWriterContract, registerStoreWriter } from "./writer-contract.js";
|
|
7
|
+
/** Explicit one-store cutover. The caller must separately drain external owners. */
|
|
8
|
+
export function migrateStoreWriter(file, backup) {
|
|
9
|
+
if (!path.isAbsolute(file) || !path.isAbsolute(backup) || file === backup || !fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
10
|
+
throw new AppError("store_migration_path_invalid", "Migration requires an existing database and a new absolute backup path", 1);
|
|
11
|
+
const source = fs.realpathSync(file);
|
|
12
|
+
const destination = path.join(fs.realpathSync(path.dirname(backup)), path.basename(backup));
|
|
13
|
+
if (fs.lstatSync(file).isSymbolicLink() || [source, `${source}-wal`, `${source}-shm`, `${source}-journal`].includes(destination))
|
|
14
|
+
throw new AppError("store_migration_path_invalid", "Backup cannot alias the source database or its SQLite sidecars", 1);
|
|
15
|
+
try {
|
|
16
|
+
const existing = fs.lstatSync(backup);
|
|
17
|
+
if (!existing.isFile() || existing.isSymbolicLink())
|
|
18
|
+
throw new AppError("store_migration_path_invalid", "Backup path already exists", 1);
|
|
19
|
+
const reader = new DatabaseSync(file, { readOnly: true });
|
|
20
|
+
try {
|
|
21
|
+
const recorded = reader.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'dd_flow_writer_migrations'").get()
|
|
22
|
+
? reader.prepare("SELECT receipt_json FROM dd_flow_writer_migrations WHERE backup_path = ?").get(destination) : null;
|
|
23
|
+
const receipt = recorded ? JSON.parse(String(recorded.receipt_json)) : null;
|
|
24
|
+
if (!receipt || receipt.database !== file || receipt.backup !== backup || receipt.writer_version !== readStoreWriterContract(reader) || receipt.backup_sha256 !== crypto.createHash("sha256").update(fs.readFileSync(backup)).digest("hex")) {
|
|
25
|
+
throw new AppError("store_migration_path_invalid", "Existing backup has no matching committed migration receipt", 1);
|
|
26
|
+
}
|
|
27
|
+
return receipt;
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
reader.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code !== "ENOENT")
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
const db = new DatabaseSync(file);
|
|
38
|
+
try {
|
|
39
|
+
db.exec("PRAGMA busy_timeout = 1000");
|
|
40
|
+
registerStoreWriter(db);
|
|
41
|
+
db.exec("BEGIN IMMEDIATE");
|
|
42
|
+
try {
|
|
43
|
+
const previous = readStoreWriterContract(db);
|
|
44
|
+
const tables = new Set(db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table'").all().map(row => row.name));
|
|
45
|
+
const gates = [
|
|
46
|
+
["managed_processes", "state IS NULL OR state NOT IN ('stopped','failed')"],
|
|
47
|
+
["run_controllers", "status IS NULL OR status NOT IN ('completed','stop_target_reached','cancelled','superseded')"],
|
|
48
|
+
["run_control_workers", "status IS NULL OR status NOT IN ('completed','failed','superseded')"],
|
|
49
|
+
["work_sessions", "status IS NULL OR status NOT IN ('completed','failed','cancelled')"]
|
|
50
|
+
];
|
|
51
|
+
for (const [table, predicate] of gates) {
|
|
52
|
+
if (tables.has(table) && db.prepare(`SELECT 1 FROM ${table} WHERE ${predicate} LIMIT 1`).get())
|
|
53
|
+
throw new AppError("store_migration_not_drained", "Store still contains unresolved writer ownership", 1, { table });
|
|
54
|
+
}
|
|
55
|
+
// A separate reader sees committed WAL while this connection prevents new writes.
|
|
56
|
+
const reader = new DatabaseSync(file, { readOnly: true });
|
|
57
|
+
try {
|
|
58
|
+
reader.exec(`VACUUM INTO '${backup.replaceAll("'", "''")}'`);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
reader.close();
|
|
62
|
+
}
|
|
63
|
+
const descriptor = fs.openSync(backup, "r");
|
|
64
|
+
try {
|
|
65
|
+
fs.fsyncSync(descriptor);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
fs.closeSync(descriptor);
|
|
69
|
+
}
|
|
70
|
+
const directory = fs.openSync(path.dirname(backup), "r");
|
|
71
|
+
try {
|
|
72
|
+
fs.fsyncSync(directory);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
fs.closeSync(directory);
|
|
76
|
+
}
|
|
77
|
+
const verification = new DatabaseSync(backup, { readOnly: true });
|
|
78
|
+
try {
|
|
79
|
+
const check = verification.prepare("PRAGMA integrity_check").get();
|
|
80
|
+
if (Object.values(check)[0] !== "ok" || readStoreWriterContract(verification) !== previous)
|
|
81
|
+
throw new AppError("store_migration_backup_invalid", "Backup failed integrity or contract verification", 1);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
verification.close();
|
|
85
|
+
}
|
|
86
|
+
const receipt = { database: file, backup, backup_sha256: crypto.createHash("sha256").update(fs.readFileSync(backup)).digest("hex"), previous_version: previous, writer_version: 1 };
|
|
87
|
+
db.exec("CREATE TABLE IF NOT EXISTS dd_flow_writer_migrations (backup_path TEXT PRIMARY KEY, receipt_json TEXT NOT NULL)");
|
|
88
|
+
db.prepare("INSERT INTO dd_flow_writer_migrations VALUES (?, ?)").run(destination, JSON.stringify(receipt));
|
|
89
|
+
installStoreWriterContract(db);
|
|
90
|
+
db.exec("COMMIT");
|
|
91
|
+
return receipt;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (db.isTransaction)
|
|
95
|
+
db.exec("ROLLBACK");
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
db.close();
|
|
101
|
+
}
|
|
102
|
+
}
|