@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.74
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 +396 -0
- package/README.md +65 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +81 -9
- package/dist/cli/run-cli.js +442 -46
- package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +470 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +70 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/runtime/context.js +3 -1
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/cleanup.js +8 -5
- package/dist/services/cli-operation-classifier.js +11 -1
- package/dist/services/code-checks.js +543 -96
- package/dist/services/controller-fanout.js +120 -0
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +50 -19
- package/dist/services/eval-snapshots.js +639 -60
- package/dist/services/execution-policy.js +177 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +413 -168
- package/dist/services/lanes.js +1 -1
- package/dist/services/lifecycle-command.js +74 -6
- package/dist/services/lifecycle-invocations.js +637 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +138 -22
- package/dist/services/merge-queue.js +6 -6
- package/dist/services/merge-server.js +75 -29
- package/dist/services/migrations.js +1 -1
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/projects.js +7 -3
- package/dist/services/prompts.js +4 -2
- package/dist/services/protocols.js +11 -2
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +211 -0
- package/dist/services/run-controller-capture.js +134 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +31 -0
- package/dist/services/run-controller.js +733 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +327 -0
- package/dist/services/runs.js +280 -40
- package/dist/services/runtime-budget.js +362 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +543 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +210 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +12 -12
- package/dist/services/stage-context.js +2 -2
- package/dist/services/stage-lifecycle.js +32 -10
- package/dist/services/stage-pause.js +25 -13
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +149 -89
- package/dist/services/vnext-code.js +223 -215
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +135 -21
- package/dist/services/vnext-merge.js +258 -89
- package/dist/services/vnext-plan-review.js +75 -64
- package/dist/services/vnext-plan.js +77 -29
- package/dist/services/vnext-protocolize.js +21 -14
- package/dist/services/vnext-specify.js +26 -16
- package/dist/services/work-registry.js +592 -154
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +341 -31
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +20 -13
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -140,7 +140,7 @@ export function migrateProjectIds(context, input) {
|
|
|
140
140
|
throw new AppError("conflict", `Target project id already exists: ${target.id}`, 1, plan);
|
|
141
141
|
}
|
|
142
142
|
context.db.exec("PRAGMA foreign_keys = OFF");
|
|
143
|
-
context.db.
|
|
143
|
+
context.db.beginWriteTransaction();
|
|
144
144
|
try {
|
|
145
145
|
context.db.run("UPDATE projects SET id = ?, short_id = ?, slug = ?, state_root = ?, updated_at = ? WHERE id = ?", [target.id, target.short_id, target.slug, target.state_root, now, oldId]);
|
|
146
146
|
for (const table of projectReferenceTables()) {
|
|
@@ -232,9 +232,13 @@ export function findProjectByRoot(context, root) {
|
|
|
232
232
|
return context.db.get("SELECT * FROM projects WHERE root = ?", [root]);
|
|
233
233
|
}
|
|
234
234
|
export function requireProjectByRoot(context, root) {
|
|
235
|
-
|
|
235
|
+
// Callers may receive a path through a native adapter or shell. Resolve
|
|
236
|
+
// aliases such as macOS `/var` -> `/private/var` before comparing with the
|
|
237
|
+
// canonical root persisted in the project registry.
|
|
238
|
+
const canonical = normalizeStoredRoot(root);
|
|
239
|
+
const project = findProjectByRoot(context, canonical);
|
|
236
240
|
if (!project) {
|
|
237
|
-
throw new AppError("not_found", `Project is not registered: ${
|
|
241
|
+
throw new AppError("not_found", `Project is not registered: ${canonical}`, 1);
|
|
238
242
|
}
|
|
239
243
|
return project;
|
|
240
244
|
}
|
package/dist/services/prompts.js
CHANGED
|
@@ -136,7 +136,9 @@ export function renderWorkerPrompt(context, input) {
|
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
138
|
function runHomePath(run) {
|
|
139
|
-
|
|
139
|
+
if (!run.run_root)
|
|
140
|
+
throw new AppError("runtime_missing", "RUN has no artifact root", 1, { run_id: run.id });
|
|
141
|
+
return run.run_root;
|
|
140
142
|
}
|
|
141
143
|
function readWorkerTask(taskFile, runHome) {
|
|
142
144
|
const resolved = path.resolve(taskFile);
|
|
@@ -204,7 +206,7 @@ function protocolIdForRun(context, projectId, runId) {
|
|
|
204
206
|
return run.subject_id;
|
|
205
207
|
}
|
|
206
208
|
function requireRun(context, projectId, runId) {
|
|
207
|
-
const row = context.db.get("SELECT id, project_id, subject_type, subject_id, workspace_root,
|
|
209
|
+
const row = context.db.get("SELECT id, project_id, subject_type, subject_id, workspace_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
|
|
208
210
|
if (!row)
|
|
209
211
|
throw new AppError("not_found", `Run is not found: ${runId}`, 1);
|
|
210
212
|
return row;
|
|
@@ -416,7 +416,7 @@ export function syncProtocolFromRun(context, input) {
|
|
|
416
416
|
next_action: nextActionForSyncedTarget(targetStage),
|
|
417
417
|
updated_at: now
|
|
418
418
|
};
|
|
419
|
-
context.db.
|
|
419
|
+
context.db.beginWriteTransaction();
|
|
420
420
|
try {
|
|
421
421
|
persistProtocolState(context, protocol, nextState);
|
|
422
422
|
if (targetStage === "ready_for_merge") {
|
|
@@ -486,7 +486,7 @@ export function cancelProtocol(context, input) {
|
|
|
486
486
|
const now = context.now();
|
|
487
487
|
const worktree = context.db.get("SELECT worktree_path, status FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
|
|
488
488
|
const worktreeRemoval = input.worktree === "remove" ? removeProtocolWorktreeBeforeCancel(protocol, state, worktree, input.force) : undefined;
|
|
489
|
-
context.db.
|
|
489
|
+
context.db.beginWriteTransaction();
|
|
490
490
|
try {
|
|
491
491
|
const nextState = {
|
|
492
492
|
...state,
|
|
@@ -733,9 +733,18 @@ function releaseRelatedMergeLocks(context, protocol, reason) {
|
|
|
733
733
|
return { ok: true, released: released.length, locks: released };
|
|
734
734
|
}
|
|
735
735
|
export function readProtocolRuntimeState(context, protocol) {
|
|
736
|
+
const expected = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
|
|
737
|
+
if (protocol.state_path !== expected)
|
|
738
|
+
throw new AppError("protocol_state_migration_required", "Protocol runtime path requires explicit cleanup scan/apply before use", 1, { protocol_id: protocol.id, path: protocol.state_path, expected_path: expected });
|
|
739
|
+
return repairProtocolRuntimeState(context, protocol);
|
|
740
|
+
}
|
|
741
|
+
/** Explicit cleanup migration; productive readers accept only the current path. */
|
|
742
|
+
export function repairProtocolRuntimeState(context, protocol) {
|
|
736
743
|
const diagnostics = [];
|
|
737
744
|
normalizeProtocolPlanPath(context, protocol, diagnostics);
|
|
738
745
|
try {
|
|
746
|
+
if (protocol.state_path !== runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id))
|
|
747
|
+
throw new AppError("protocol_state_migration_required", "Retired state files are not runtime authority", 1);
|
|
739
748
|
return { state: readStateFile(protocol.state_path, protocol.id), diagnostics };
|
|
740
749
|
}
|
|
741
750
|
catch {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
export const recoveryObservationBudgetMs = 120_000;
|
|
4
|
+
const activeObservation = new AsyncLocalStorage();
|
|
5
|
+
export const withRecoveryObservation = (budget, action) => activeObservation.run(budget, action);
|
|
6
|
+
export const recoveryRpcTimeout = (maximum) => {
|
|
7
|
+
const observation = activeObservation.getStore();
|
|
8
|
+
// An unbounded productive transport wait is still bounded while recovery is
|
|
9
|
+
// actively reconciling. Outside recovery, its owner policy decides when to
|
|
10
|
+
// stop rather than a hidden adapter timer.
|
|
11
|
+
if (maximum === null)
|
|
12
|
+
return observation ? observation.timeout() : null;
|
|
13
|
+
return observation?.timeout(maximum ?? 30_000) ?? maximum ?? 30_000;
|
|
14
|
+
};
|
|
15
|
+
/** Call only while holding the existing owner-claim transaction. A replay of
|
|
16
|
+
* an explicit request never replenishes time, even after another request. */
|
|
17
|
+
export function renewRecoveryObservation(snapshot, requestId) {
|
|
18
|
+
if (!requestId.trim() || requestId.length > 200)
|
|
19
|
+
throw new AppError("usage", "Reconciliation requires a nonempty request ID of at most 200 characters", 2);
|
|
20
|
+
const previous = snapshot.recovery_observation;
|
|
21
|
+
const ids = previous?.reconciliation_ids ?? [];
|
|
22
|
+
if (!Array.isArray(ids) || ids.some(id => typeof id !== "string"))
|
|
23
|
+
throw new AppError("recovery_observation_invalid", "Retained reconciliation identities are malformed", 1);
|
|
24
|
+
if (ids.includes(requestId))
|
|
25
|
+
return snapshot;
|
|
26
|
+
return { ...snapshot, recovery_observation: { budget_ms: recoveryObservationBudgetMs, remaining_ms: recoveryObservationBudgetMs, reconciliation_ids: [...ids, requestId], poll_index: 0 } };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Measures only time this owner can actually observe. Large scheduler gaps
|
|
30
|
+
* reset the sample instead of turning host sleep into evidence about a remote
|
|
31
|
+
* process. Persisting the remaining budget makes a recovered owner continue
|
|
32
|
+
* reconciliation rather than silently receiving a new unlimited wait.
|
|
33
|
+
*/
|
|
34
|
+
export function recoveryObservationBudget(saved, monotonic = () => performance.now()) {
|
|
35
|
+
const prior = saved && typeof saved === "object" ? saved.recovery_observation : null;
|
|
36
|
+
let remaining = typeof prior?.remaining_ms === "number" && Number.isFinite(prior.remaining_ms)
|
|
37
|
+
? Math.max(0, Math.min(recoveryObservationBudgetMs, prior.remaining_ms))
|
|
38
|
+
: prior == null ? recoveryObservationBudgetMs : 0;
|
|
39
|
+
let last = monotonic();
|
|
40
|
+
let pollIndex = Number.isSafeInteger(prior?.poll_index) && prior.poll_index >= 0 ? prior.poll_index : 0;
|
|
41
|
+
const observation = {
|
|
42
|
+
exhausted: () => {
|
|
43
|
+
const current = monotonic();
|
|
44
|
+
const elapsed = current - last;
|
|
45
|
+
last = current;
|
|
46
|
+
// A gap means this process was not observing; it never proves progress
|
|
47
|
+
// or death and therefore does not consume the reconciliation budget.
|
|
48
|
+
if (elapsed >= 0 && elapsed <= 60_000)
|
|
49
|
+
remaining = Math.max(0, remaining - elapsed);
|
|
50
|
+
return remaining === 0;
|
|
51
|
+
},
|
|
52
|
+
state: () => ({ budget_ms: recoveryObservationBudgetMs, remaining_ms: Math.ceil(remaining), reconciliation_ids: prior?.reconciliation_ids ?? [], poll_index: pollIndex }),
|
|
53
|
+
timeout: (maximum = 30_000) => {
|
|
54
|
+
if (observation.exhausted())
|
|
55
|
+
throw new AppError("recovery_observation_budget_exhausted", "Recovery observation budget exhausted; explicit reconciliation is required", 1);
|
|
56
|
+
return Math.max(1, Math.min(maximum, Math.ceil(remaining)));
|
|
57
|
+
},
|
|
58
|
+
nextDelay: () => Math.min([1000, 2000, 5000, 10000][Math.min(pollIndex++, 3)], Math.ceil(remaining)),
|
|
59
|
+
};
|
|
60
|
+
return observation;
|
|
61
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { registerStoreWriter } from "../storage/writer-contract.js";
|
|
6
|
+
import { managedProcessRuntimeHome } from "./managed-processes.js";
|
|
7
|
+
/** Export one RUN's relational evidence, never mutate the live authority. */
|
|
8
|
+
export function captureRecoveryDatabase(source, output, projectId, runId, protocolIds = []) {
|
|
9
|
+
source.exec(`VACUUM INTO '${output.replace(/'/g, "''")}'`);
|
|
10
|
+
const db = new DatabaseSync(output);
|
|
11
|
+
let complete = false;
|
|
12
|
+
try {
|
|
13
|
+
registerStoreWriter(db);
|
|
14
|
+
db.exec("PRAGMA foreign_keys = OFF; PRAGMA secure_delete = ON; BEGIN IMMEDIATE");
|
|
15
|
+
db.exec("CREATE TEMP TABLE capture_scope (project_id TEXT, run_id TEXT)");
|
|
16
|
+
db.prepare("INSERT INTO capture_scope VALUES (?, ?)").run(projectId, runId);
|
|
17
|
+
db.exec("CREATE TEMP TABLE capture_protocols (id TEXT)");
|
|
18
|
+
for (const id of protocolIds)
|
|
19
|
+
db.prepare("INSERT INTO capture_protocols VALUES (?)").run(id);
|
|
20
|
+
if (!db.prepare("SELECT 1 FROM runs WHERE (project_id, id) IN (SELECT * FROM capture_scope)").get())
|
|
21
|
+
throw new AppError("snapshot_run_missing", "Recovery export requires the selected RUN", 1);
|
|
22
|
+
const ownRun = "(project_id, run_id) IN (SELECT * FROM capture_scope)";
|
|
23
|
+
const ownProject = "project_id IN (SELECT project_id FROM capture_scope)";
|
|
24
|
+
const works = `SELECT work_id FROM works WHERE ${ownRun}`;
|
|
25
|
+
if (db.prepare(`SELECT 1 FROM sessions WHERE ${ownProject} AND run_id IS NOT NULL AND run_id NOT IN (SELECT run_id FROM capture_scope) AND session_id IN (SELECT session_id FROM work_sessions WHERE work_id IN (${works})) LIMIT 1`).get())
|
|
26
|
+
throw new AppError("snapshot_scope_conflict", "Selected Work refers to a Session currently bound to another RUN", 1);
|
|
27
|
+
const sessions = `SELECT session_id FROM sessions WHERE ${ownProject} AND (run_id IN (SELECT run_id FROM capture_scope) OR session_id IN (SELECT session_id FROM work_sessions WHERE work_id IN (${works})))`;
|
|
28
|
+
const nativeSessions = `SELECT harness, provider_session_id FROM sessions WHERE ${ownProject} AND session_id IN (${sessions})`;
|
|
29
|
+
const invocations = "json_extract(scope_json, '$.runId') IN (SELECT run_id FROM capture_scope) AND json_extract(scope_json, '$.projectRoot') IN (SELECT root FROM projects WHERE id IN (SELECT project_id FROM capture_scope))";
|
|
30
|
+
const invocationReceipts = db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'lifecycle_invocations'").get()
|
|
31
|
+
? ` OR event_key IN (SELECT event_key FROM lifecycle_invocations WHERE ${invocations})` : "";
|
|
32
|
+
const protocols = `SELECT id FROM capture_protocols UNION SELECT subject_id FROM runs WHERE (project_id, id) IN (SELECT * FROM capture_scope) UNION SELECT value FROM merge_requests, json_each(merge_requests.protocol_ids_json) WHERE ${ownRun} UNION SELECT protocol_id FROM flow_jobs WHERE ${ownRun}`;
|
|
33
|
+
const rules = {
|
|
34
|
+
dd_flow_writer_contract: "1",
|
|
35
|
+
dd_flow_writer_migrations: "0",
|
|
36
|
+
projects: "id IN (SELECT project_id FROM capture_scope)",
|
|
37
|
+
runs: "(project_id, id) IN (SELECT * FROM capture_scope)",
|
|
38
|
+
sessions: `${ownProject} AND session_id IN (${sessions})`,
|
|
39
|
+
work_sessions: `work_id IN (${works})`, work_launches: `work_id IN (${works})`,
|
|
40
|
+
work_recovery_segments: `work_id IN (${works})`,
|
|
41
|
+
run_recovery_bindings: `recovery_id IN (SELECT recovery_id FROM run_recovery_guards WHERE ${ownRun})`,
|
|
42
|
+
lifecycle_invocations: invocations,
|
|
43
|
+
hook_events: `${ownProject} AND (session_id IN (${sessions}) OR (harness, provider_session_id) IN (${nativeSessions})${invocationReceipts})`,
|
|
44
|
+
harness_usage_snapshots: `${ownProject} AND (harness, provider_session_id) IN (${nativeSessions})`,
|
|
45
|
+
protocols: `${ownProject} AND id IN (${protocols})`,
|
|
46
|
+
project_config: ownProject,
|
|
47
|
+
// Host installations, caches, queue owners and leases are not portable RUN authority.
|
|
48
|
+
runtime_config: "0", hook_installations: "0", codex_home_profiles: "0", codex_session_bindings: "0",
|
|
49
|
+
merge_servers: "0", merge_sessions: "0", merge_queue: "0", lanes: "0", lane_locks: "0", lane_waiters: "0",
|
|
50
|
+
sqlite_sequence: "0"
|
|
51
|
+
};
|
|
52
|
+
for (const table of ["works", "check_receipts", "merge_requests", "flow_run_flag_mutations", "usage", "flow_session_segments", "flow_jobs", "run_recovery_guards", "run_controllers", "run_controls"])
|
|
53
|
+
rules[table] = ownRun;
|
|
54
|
+
for (const table of ["plan_bindings", "plan_progress", "worktree_records", "audit_events"])
|
|
55
|
+
rules[table] = `${ownProject} AND protocol_id IN (${protocols})`;
|
|
56
|
+
for (const table of ["run_controller_events", "run_controller_operations"])
|
|
57
|
+
rules[table] = `controller_id IN (SELECT controller_id FROM run_controllers WHERE ${ownRun})`;
|
|
58
|
+
for (const table of ["run_control_admissions", "run_control_stage_admissions", "run_control_operations", "run_control_workers"])
|
|
59
|
+
rules[table] = `control_id IN (SELECT control_id FROM run_controls WHERE ${ownRun})`;
|
|
60
|
+
const tables = db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table'").all();
|
|
61
|
+
const quote = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
62
|
+
// Evaluate every relationship before deleting parents, including historical bindings.
|
|
63
|
+
for (const { name } of tables) {
|
|
64
|
+
const predicate = rules[name];
|
|
65
|
+
if (predicate === undefined && db.prepare(`SELECT 1 FROM ${quote(name)} LIMIT 1`).get())
|
|
66
|
+
throw new AppError("snapshot_scope_unknown", "Recovery export has no scope rule for a populated table", 1, { table: name });
|
|
67
|
+
db.exec(`CREATE TEMP TABLE ${quote(`keep_${name}`)} AS SELECT rowid AS retained_id FROM ${quote(name)} WHERE ${predicate ?? "0"}`);
|
|
68
|
+
}
|
|
69
|
+
for (const { name } of tables)
|
|
70
|
+
db.exec(`DELETE FROM ${quote(name)} WHERE rowid NOT IN (SELECT retained_id FROM ${quote(`keep_${name}`)})`);
|
|
71
|
+
const issues = db.prepare("PRAGMA foreign_key_check").all();
|
|
72
|
+
if (issues.length)
|
|
73
|
+
throw new AppError("snapshot_scope_inconsistent", "Scoped recovery export has unresolved relational references", 1, { issues });
|
|
74
|
+
db.exec("COMMIT; VACUUM");
|
|
75
|
+
complete = true;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
db.close();
|
|
79
|
+
if (!complete)
|
|
80
|
+
fs.rmSync(output, { force: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Physical leases are evidence, not authority to resurrect on another host. */
|
|
84
|
+
export function readRecoveryResourceEvidence(resourceHome, projectId, runId, authority) {
|
|
85
|
+
const file = path.join(resourceHome, "runtime.sqlite");
|
|
86
|
+
const scope = { schema_id: "dd-flow/recovery-resource-evidence@1", source_home: resourceHome, dd_flow_home: authority.ddFlowHome, project_id: projectId, run_id: runId };
|
|
87
|
+
if (!fs.existsSync(file))
|
|
88
|
+
return { ...scope, database_present: false, processes: [], resources: [] };
|
|
89
|
+
const db = new DatabaseSync(file, { readOnly: true });
|
|
90
|
+
try {
|
|
91
|
+
db.exec("PRAGMA busy_timeout = 4000; BEGIN");
|
|
92
|
+
const candidates = db.prepare("SELECT * FROM managed_processes WHERE project_id = ? AND run_id = ? ORDER BY id").all(projectId, runId).map(record => ({ record, home: managedProcessRuntimeHome(record, authority) }));
|
|
93
|
+
if (candidates.some(item => item.home === null))
|
|
94
|
+
throw new AppError("snapshot_resource_scope_unproven", "A retained process has no proven runtime-home identity", 1, { resource_home: resourceHome });
|
|
95
|
+
const processes = candidates.filter(item => item.home === authority.ddFlowHome).map(item => item.record);
|
|
96
|
+
const ids = new Set(processes.map(record => record.id)), owners = new Set(processes.map(record => record.owner_id));
|
|
97
|
+
const resources = db.prepare("SELECT r.* FROM managed_resources r JOIN managed_processes p ON p.id = r.process_id WHERE p.project_id = ? AND p.run_id = ? ORDER BY r.resource_kind, r.resource_key").all(projectId, runId).filter(row => typeof row.process_id === "string" && ids.has(row.process_id));
|
|
98
|
+
const unbound = db.prepare("SELECT owner_id FROM managed_resources WHERE process_id IS NULL AND owner_id IN (SELECT owner_id FROM managed_processes WHERE project_id = ? AND run_id = ?)").all(projectId, runId).some(row => typeof row.owner_id === "string" && owners.has(row.owner_id));
|
|
99
|
+
if (unbound)
|
|
100
|
+
throw new AppError("snapshot_resource_scope_unproven", "A retained resource has no process binding proving its RUN scope", 1, { resource_home: resourceHome });
|
|
101
|
+
db.exec("COMMIT");
|
|
102
|
+
return { ...scope, database_present: true, processes, resources };
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
db.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3
|
+
const records = (value) => Array.isArray(value) ? value.map(object) : [];
|
|
4
|
+
const text = (value) => typeof value === "string" && value.length ? value : null;
|
|
5
|
+
/** Pure projection of durable evidence. Never infer quiescence from an ACK,
|
|
6
|
+
* a terminal RUN label, or a settlement captured before a subsequent resume. */
|
|
7
|
+
export function controlStatusReceipt(observation, request = {}) {
|
|
8
|
+
const control = object(observation.control), worker = object(observation.worker), resume = object(observation.resume_operation);
|
|
9
|
+
const present = Boolean(control.control_id), current = control.current === true;
|
|
10
|
+
const activeResume = Boolean(resume.operation_id);
|
|
11
|
+
const historical = !current || activeResume || ["resuming", "resumed"].includes(String(control.status));
|
|
12
|
+
const settlement = object(control.settlement);
|
|
13
|
+
const snapshot = historical ? {} : control.admission === "sealed" ? settlement : object(worker.snapshot);
|
|
14
|
+
const nodes = records(snapshot.nodes).map((node, index) => {
|
|
15
|
+
const identity = Object.fromEntries(["operation_id", "controller_id", "process_id", "check_id", "merge_request_id", "work_id", "work_session_id", "session_id", "harness", "state_dir", "resource_home"].filter(key => node[key] !== undefined).map(key => [key, node[key]]));
|
|
16
|
+
const settled = node.status === "completed";
|
|
17
|
+
const role = ["subject", "worker", "merge", "judge", "probe", "check", "process"].includes(String(node.role)) ? node.role : node.check_id ? "check" : node.merge_request_id ? "merge" : node.process_id ? "process" : "unknown";
|
|
18
|
+
return { id: `node:${crypto.createHash("sha256").update(JSON.stringify(Object.keys(identity).length ? identity : { index, reason: node.reason })).digest("hex")}`, role, status: settled ? "completed" : node.status === "outcome_unknown" ? "unknown" : "pending", acknowledged: settled || object(node.receipt).cancelled === true, settled, reason: settled ? null : text(node.reason) ?? "node_settlement_required", details: node };
|
|
19
|
+
});
|
|
20
|
+
const settled = present && current && !historical && control.status === "settled" && control.admission === "sealed" && Boolean(control.capture_path) && settlement.settled === true && Array.isArray(settlement.nodes) && nodes.every(node => node.settled);
|
|
21
|
+
let state;
|
|
22
|
+
if (!present)
|
|
23
|
+
state = request.action && ["done", "failed", "cancelled"].includes(String(observation.run_status)) ? "terminal_noop" : "not_requested";
|
|
24
|
+
else if (!current)
|
|
25
|
+
state = "superseded";
|
|
26
|
+
else if (resume.status === "failed" || worker.status === "failed")
|
|
27
|
+
state = "recovery_required";
|
|
28
|
+
else if (control.status === "resumed" && control.admission === "recovered" && resume.status === "completed")
|
|
29
|
+
state = "running";
|
|
30
|
+
else if (activeResume || control.status === "resuming")
|
|
31
|
+
state = "resuming";
|
|
32
|
+
else if (settled)
|
|
33
|
+
state = control.requested_mode === "pause" ? "paused" : "stopped";
|
|
34
|
+
else
|
|
35
|
+
state = control.requested_mode === "pause" ? "pause_requested" : "stop_requested";
|
|
36
|
+
const reasons = settled ? [] : [...new Set([
|
|
37
|
+
...nodes.filter(node => !node.settled).map(node => node.reason),
|
|
38
|
+
...(Array.isArray(snapshot.pending_reasons) ? snapshot.pending_reasons.filter((reason) => typeof reason === "string" && Boolean(reason)) : []),
|
|
39
|
+
...(!present ? [state === "terminal_noop" ? "terminal_settlement_not_observed" : "control_not_requested"]
|
|
40
|
+
: !current ? ["control_superseded"]
|
|
41
|
+
: state === "running" ? ["run_active"]
|
|
42
|
+
: state === "resuming" ? ["recovery_acceptance_pending"]
|
|
43
|
+
: state === "recovery_required" ? [text(object(resume.error).code) ?? text(object(worker.error).code) ?? "control_recovery_required"]
|
|
44
|
+
: control.admission === "sealed" ? ["recovery_capture_required"] : ["settlement_observation_required"])
|
|
45
|
+
])];
|
|
46
|
+
return {
|
|
47
|
+
...observation, schema_id: "dd-flow/run-control-receipt@1", ok: true,
|
|
48
|
+
request_id: request.requestId ?? text(resume.request_id) ?? text(control.request_id), control_id: text(control.control_id),
|
|
49
|
+
generation: Number.isSafeInteger(control.generation) ? control.generation : 0,
|
|
50
|
+
requested_mode: request.action ?? (activeResume ? "resume" : text(control.requested_mode)),
|
|
51
|
+
applied_mode: settled ? control.requested_mode : state === "running" ? "resume" : null,
|
|
52
|
+
state, settled, nodes, unsettled_reasons: reasons,
|
|
53
|
+
resumability: settled || state === "recovery_required" ? "recovery_required" : ["running", "not_requested", "terminal_noop", "superseded"].includes(state) ? "not_applicable" : "pending",
|
|
54
|
+
recovery_id: text(control.recovery_id)
|
|
55
|
+
};
|
|
56
|
+
}
|