@deksden-com/dd-flow-cli 0.9.0-beta.135 → 0.9.0-beta.136

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -0
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/command-inputs.js +1 -0
  5. package/dist/cli/command-metadata.js +144 -0
  6. package/dist/cli/command-option-descriptions.js +186 -0
  7. package/dist/cli/completion-cli.js +70 -0
  8. package/dist/cli/completion.js +57 -0
  9. package/dist/cli/error-output.js +52 -0
  10. package/dist/cli/executor-onboarding.js +71 -0
  11. package/dist/cli/harnesses.js +84 -0
  12. package/dist/cli/models.js +39 -0
  13. package/dist/cli/operator-profiles.js +61 -0
  14. package/dist/cli/output-options.js +35 -0
  15. package/dist/cli/response-file.js +69 -0
  16. package/dist/cli/run-cli.js +5 -0
  17. package/dist/cli/setup.js +147 -0
  18. package/dist/services/cli-operation-classifier.js +4 -0
  19. package/dist/services/cli-upgrade-allowlist.js +27 -0
  20. package/dist/services/discovery/agy-discovery.js +2 -0
  21. package/dist/services/discovery/codex-discovery.js +2 -0
  22. package/dist/services/discovery/droid-discovery.js +2 -0
  23. package/dist/services/discovery/grok-discovery.js +2 -0
  24. package/dist/services/discovery/opencode-discovery.js +2 -0
  25. package/dist/services/discovery/static.js +31 -0
  26. package/dist/services/discovery/zcode-discovery.js +2 -0
  27. package/dist/services/engines.js +1 -1
  28. package/dist/services/executor-onboarding.js +56 -0
  29. package/dist/services/harness-discovery.js +29 -0
  30. package/dist/services/harness-doctor.js +75 -0
  31. package/dist/services/harness-probe-effects.js +51 -0
  32. package/dist/services/harness-version-probe.js +100 -0
  33. package/dist/services/hybrid-contracts.js +455 -0
  34. package/dist/services/installation-lock.js +92 -0
  35. package/dist/services/managed-processes.js +30 -0
  36. package/dist/services/model-catalog.js +109 -0
  37. package/dist/services/model-discovery.js +46 -0
  38. package/dist/services/operator-profiles.js +236 -0
  39. package/dist/services/release-archive-producer.js +52 -0
  40. package/dist/services/release-archive.js +143 -0
  41. package/dist/services/release-cache.js +142 -0
  42. package/dist/services/release-compatibility.js +18 -0
  43. package/dist/services/release-download.js +67 -0
  44. package/dist/services/release-envelope.js +104 -0
  45. package/dist/services/release-manifest.js +45 -0
  46. package/dist/services/release-payload.js +114 -0
  47. package/dist/services/release-producer.js +63 -0
  48. package/dist/services/release-provenance.js +160 -0
  49. package/dist/services/release-registry.js +66 -0
  50. package/dist/services/release-source.js +194 -0
  51. package/dist/services/run-controller.js +1 -0
  52. package/dist/services/run-health.js +149 -0
  53. package/dist/services/runs.js +61 -13
  54. package/dist/services/setup.js +377 -0
  55. package/dist/storage/database.js +18 -3
  56. package/dist/storage/writer-contract.js +8 -0
  57. package/package.json +1 -1
@@ -911,6 +911,46 @@ export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
911
911
  const project = requireProjectByRoot(context, run.project_root);
912
912
  persistRunState(context, project, run, index);
913
913
  }
914
+ /** Reconciliation repairs projections under the same revision CAS as normal
915
+ * lifecycle writes. Unknown physical ownership never closes child records. */
916
+ export function reconcileStoredFlowRun(context, expected, health, transition, childrenSettled = false) {
917
+ const run = resolveRun(context, expected.project_id, expected.id);
918
+ if (run.index_json !== expected.index_json || run.status !== expected.status)
919
+ throw new AppError("run_revision_changed", "RUN changed during health observation", 1);
920
+ const index = authoritativeIndex(run), now = context.now();
921
+ index.health = health;
922
+ if (transition && !["done", "failed", "cancelled", "discarded"].includes(run.status)) {
923
+ index.status = transition;
924
+ if (transition === "failed") {
925
+ index.verdict = "failed";
926
+ // Observation time is not the historical exit time. Without a retained
927
+ // finish receipt, keep timing unknown rather than manufacturing days of work.
928
+ delete index.completed_at;
929
+ delete index.finished_at;
930
+ index.duration_ms = null;
931
+ index.timing_status = 'legacy_incomplete';
932
+ closeOpenStagesForOverride(index, "failed", now, false);
933
+ closeOpenWorksForOverride(context, run.project_id, run.id, "failed", now, false);
934
+ }
935
+ else if (childrenSettled && health.state === "owner_lost") {
936
+ for (const stage of index.stage_runs)
937
+ if (stage.status === "running") {
938
+ stage.status = "paused";
939
+ stage.updated_at = now;
940
+ }
941
+ }
942
+ }
943
+ if (childrenSettled && ["failed", "cancelled", "discarded"].includes(index.status)) {
944
+ closeOpenStagesForOverride(index, index.status === "failed" ? "failed" : "cancelled", now, false);
945
+ closeOpenWorksForOverride(context, run.project_id, run.id, index.status === "failed" ? "failed" : "cancelled", now, false);
946
+ context.db.run("UPDATE sessions SET status = 'stopped', stop_reason = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND status IN ('pending','active','waiting_user','blocked','stopping')", [`run_${index.status}`, now, run.project_id, run.id]);
947
+ context.db.run("UPDATE check_receipts SET status = 'aborted' WHERE project_id = ? AND run_id = ? AND status = 'running'", [run.project_id, run.id]);
948
+ }
949
+ // Health observation is not semantic progress or a new completion timestamp.
950
+ const project = requireProjectByRoot(context, run.project_root);
951
+ persistRunState(context, project, run, index, { healthOnly: true });
952
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.health_reconciled", payload: { run_id: run.id, previous_status: run.status, status: index.status, health } });
953
+ }
914
954
  export function prepareFlowRunCompletion(context, input) {
915
955
  if (input.manualOverrideReason !== undefined) {
916
956
  requiredPlain(input.manualOverrideReason, "override reason");
@@ -1061,15 +1101,21 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
1061
1101
  appendRunTimeline(context, project.id, run.id, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
1062
1102
  return { run_id: run.id, cycle };
1063
1103
  }
1064
- function closeOpenStagesForOverride(index, status, now) {
1104
+ function closeOpenStagesForOverride(index, status, now, timingKnown = true) {
1065
1105
  const stageStatus = status === "cancelled" ? "skipped" : "failed";
1066
1106
  for (const stage of index.stage_runs) {
1067
- if (stage.status !== "running" && stage.status !== "pending" && stage.status !== "paused" && stage.status !== "blocked")
1107
+ if (stage.status !== "running" && stage.status !== "paused" && stage.status !== "waiting_for_user" && stage.status !== "blocked")
1068
1108
  continue;
1069
1109
  stage.status = stageStatus;
1070
1110
  stage.updated_at = now;
1071
- stage.completed_at = now;
1072
- stage.duration_ms = durationMs(stage.started_at ?? null, now);
1111
+ if (timingKnown) {
1112
+ stage.completed_at = now;
1113
+ stage.duration_ms = durationMs(stage.started_at ?? null, now);
1114
+ }
1115
+ else {
1116
+ delete stage.completed_at;
1117
+ stage.duration_ms = null;
1118
+ }
1073
1119
  }
1074
1120
  index.attempts = index.stage_runs.map((stage) => ({
1075
1121
  stage: stage.stage,
@@ -1077,20 +1123,20 @@ function closeOpenStagesForOverride(index, status, now) {
1077
1123
  root: stage.dir,
1078
1124
  archive: null,
1079
1125
  status: stage.status,
1080
- started_at: stage.started_at ?? now,
1126
+ started_at: stage.started_at ?? null,
1081
1127
  finished_at: stage.completed_at ?? null
1082
1128
  }));
1083
1129
  }
1084
- function closeOpenWorksForOverride(context, projectId, runId, status, now) {
1130
+ function closeOpenWorksForOverride(context, projectId, runId, status, now, timingKnown = true) {
1085
1131
  const workStatus = status === "cancelled" ? "cancelled" : "failed";
1086
1132
  context.db.run(`UPDATE works
1087
- SET status = ?, updated_at = ?, completed_at = ?
1133
+ SET status = ?, updated_at = ?, completed_at = CASE WHEN ? = 1 THEN ? ELSE completed_at END
1088
1134
  WHERE project_id = ? AND run_id = ?
1089
- AND status IN ('created', 'running', 'paused')`, [workStatus, now, now, projectId, runId]);
1135
+ AND status IN ('created', 'running', 'paused')`, [workStatus, now, timingKnown ? 1 : 0, now, projectId, runId]);
1090
1136
  context.db.run(`UPDATE work_sessions
1091
- SET status = ?, updated_at = ?, completed_at = ?
1137
+ SET status = ?, updated_at = ?, completed_at = CASE WHEN ? = 1 THEN ? ELSE completed_at END
1092
1138
  WHERE work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)
1093
- AND status <> 'completed'`, [workStatus, now, now, projectId, runId]);
1139
+ AND status <> 'completed'`, [workStatus, now, timingKnown ? 1 : 0, now, projectId, runId]);
1094
1140
  }
1095
1141
  function closeRunOrchestrators(context, projectId, run, status, now) {
1096
1142
  if (!["done", "blocked", "cancelled", "failed"].includes(status))
@@ -1327,8 +1373,10 @@ function vnextSpecifyGuidance(run, index) {
1327
1373
  function persistRunState(context, project, run, index, options = {}) {
1328
1374
  // Fenced productive admission is a logical pause, even while physical work
1329
1375
  // drains. Late admitted Stage results must not revive a stopped RUN.
1330
- if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1376
+ if (index.status === "running" && (!options.healthOnly || context.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_recovery_guards'")) && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1331
1377
  index.status = "paused";
1378
+ if (!options.healthOnly)
1379
+ delete index.health;
1332
1380
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
1333
1381
  context.db.writeTransaction(() => {
1334
1382
  const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]);
@@ -1344,7 +1392,7 @@ function persistRunState(context, project, run, index, options = {}) {
1344
1392
  if (options.flagRevision)
1345
1393
  history.push(options.flagRevision);
1346
1394
  index.flag_revision_history = history.slice(-32);
1347
- const updated = context.db.run(`UPDATE runs SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?, updated_at = ?, completed_at = ?
1395
+ const updated = options.healthOnly ? context.db.run("UPDATE runs SET status = ?, verdict = ?, next_action = ?, index_json = ?, updated_at = ?, completed_at = ? WHERE project_id = ? AND id = ? AND index_json = ?", [index.status, index.verdict, index.next_action, JSON.stringify(persistedVnextIndex(index)), index.updated_at, index.completed_at ?? null, project.id, run.id, stored.index_json]) : context.db.run(`UPDATE runs SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?, updated_at = ?, completed_at = ?
1348
1396
  WHERE project_id = ? AND id = ? AND index_json = ?`, [index.status, index.verdict, index.next_action, JSON.stringify(persistedVnextIndex(index)), runtimePath, runtimePath, index.updated_at, index.completed_at ?? null, project.id, run.id, stored.index_json]);
1349
1397
  if (updated.changes !== 1)
1350
1398
  throw new AppError("run_revision_changed", "RUN changed during preparation", 1, { run_id: run.id });
@@ -1431,7 +1479,7 @@ export function publishRunProjections(context, projectId, runId) {
1431
1479
  if (!latest)
1432
1480
  throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
1433
1481
  const index = authoritativeIndex(latest);
1434
- const mutations = context.db.all("SELECT resulting_revision, result_json, created_at, idempotency_key FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? ORDER BY resulting_revision DESC LIMIT 32", [projectId, runId]);
1482
+ const mutations = context.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'flow_run_flag_mutations'") ? context.db.all("SELECT resulting_revision, result_json, created_at, idempotency_key FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? ORDER BY resulting_revision DESC LIMIT 32", [projectId, runId]) : [];
1435
1483
  const history = mutations.reverse().map((row) => ({
1436
1484
  revision: row.resulting_revision,
1437
1485
  checksum: String(JSON.parse(row.result_json).snapshot_checksum ?? ""),
@@ -0,0 +1,377 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { hybridDigest, validateHybridShape } from "./hybrid-contracts.js";
6
+ import { admitReleaseManifest } from "./release-manifest.js";
7
+ import { validateReleasePayloadIdentity } from "./release-payload.js";
8
+ import { publishReleaseCache, verifyPublishedReleaseCache } from "./release-cache.js";
9
+ import { acquireInstallationLock } from "./installation-lock.js";
10
+ import { authenticateReleaseEnvelope } from "./release-envelope.js";
11
+ import { prepareOperatorProfile, validateProfileCreationIntent, commitProfileCreation, readOperatorProfiles } from "./operator-profiles.js";
12
+ /** Production ingress authenticates the source before constructing any installation authority. */
13
+ export function installAuthenticatedRelease(home, input) {
14
+ const authenticated = authenticateReleaseEnvelope(input.envelope, input.trustedProducers, input.compatibility);
15
+ return installRelease(home, { requestId: input.requestId, action: input.action,
16
+ manifest: authenticated.manifest, manifestBytes: authenticated.manifestBytes, authority: authenticated.authority,
17
+ canonArchive: input.canonArchive, engineArchive: input.engineArchive,
18
+ ...(input.executor !== undefined ? { executor: input.executor } : {}),
19
+ ...(input.profileCreation !== undefined ? { profileCreation: input.profileCreation } : {}),
20
+ ...(input.deferredOnboarding ? { deferredOnboarding: true } : {}),
21
+ ...(input.onCheckpoint ? { onCheckpoint: input.onCheckpoint } : {}) });
22
+ }
23
+ function refuse(reason, effect = "no_effect") {
24
+ throw new AppError("setup_conflict", "Setup request cannot proceed", 1, { reason, phase: "setup", effect, help: "setup", next_action: "dd-flow setup status --json" });
25
+ }
26
+ const bytesDigest = (bytes) => createHash("sha256").update(bytes).digest("hex");
27
+ function manifestFromBytes(bytes) {
28
+ try {
29
+ return JSON.parse(bytes.toString("utf8"));
30
+ }
31
+ catch {
32
+ return refuse("invalid_manifest_bytes");
33
+ }
34
+ }
35
+ function regularJson(file) {
36
+ if (!fs.lstatSync(file).isFile())
37
+ refuse("unsafe_setup_file");
38
+ try {
39
+ return JSON.parse(fs.readFileSync(file, "utf8"));
40
+ }
41
+ catch {
42
+ return refuse("invalid_setup_file");
43
+ }
44
+ }
45
+ function atomicJson(file, value) {
46
+ const temporary = `${file}.${randomUUID()}.tmp`;
47
+ const fd = fs.openSync(temporary, "wx", 0o600);
48
+ try {
49
+ try {
50
+ fs.writeFileSync(fd, JSON.stringify(value));
51
+ fs.fsyncSync(fd);
52
+ }
53
+ finally {
54
+ fs.closeSync(fd);
55
+ }
56
+ fs.renameSync(temporary, file);
57
+ }
58
+ finally {
59
+ if (fs.existsSync(temporary))
60
+ fs.unlinkSync(temporary);
61
+ }
62
+ }
63
+ function readSetupRequest(file) {
64
+ const value = regularJson(file);
65
+ const required = ["schema_id", "request_id", "identity", "previous_selection", "phase", "installation"];
66
+ if (!value || typeof value !== "object" || Array.isArray(value)
67
+ || required.some(key => !Object.hasOwn(value, key))
68
+ || Object.keys(value).some(key => ![...required, "executor", "profileCreation", "deferredOnboarding"].includes(key)))
69
+ refuse("invalid_request_record");
70
+ const request = value;
71
+ if (Object.hasOwn(request, "deferredOnboarding") && (request.deferredOnboarding !== true || Object.hasOwn(request, "executor") || Object.hasOwn(request, "profileCreation")))
72
+ refuse("invalid_deferred_onboarding");
73
+ if (request.schema_id !== "dd-flow/setup-request@1"
74
+ || typeof request.request_id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$/.test(request.request_id)
75
+ || typeof request.identity !== "string" || !/^[a-f0-9]{64}$/.test(request.identity)
76
+ || request.previous_selection !== null && (typeof request.previous_selection !== "string" || !/^[a-f0-9]{64}$/.test(request.previous_selection))
77
+ || !["prepared", "canon_cached", "engine_cached", "profiles_saved", "committed"].includes(request.phase)
78
+ || !request.installation || typeof request.installation !== "object" || Array.isArray(request.installation))
79
+ refuse("invalid_request_record");
80
+ if (Object.hasOwn(request, "executor") && request.executor !== null) {
81
+ try {
82
+ prepareOperatorProfile(request.executor);
83
+ }
84
+ catch {
85
+ refuse("invalid_request_executor");
86
+ }
87
+ }
88
+ if (Object.hasOwn(request, "profileCreation")) {
89
+ try {
90
+ validateProfileCreationIntent(request.profileCreation);
91
+ }
92
+ catch {
93
+ refuse("invalid_request_profile_creation");
94
+ }
95
+ if (!request.executor || hybridDigest(request.executor) !== hybridDigest(request.profileCreation.profile))
96
+ refuse("request_profile_executor_mismatch");
97
+ }
98
+ return request;
99
+ }
100
+ /** Read retained executor consent for an exact retry, without installation or profile effects.
101
+ * Full source/installation request identity is still checked by installRelease.
102
+ */
103
+ export function readRetainedSetupExecutor(home, requestId) {
104
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$/.test(requestId))
105
+ refuse("invalid_request");
106
+ const root = path.resolve(home), directory = path.join(root, "setup-requests");
107
+ for (const candidate of [root, directory])
108
+ if (fs.existsSync(candidate) && !fs.lstatSync(candidate).isDirectory())
109
+ refuse("unsafe_setup_directory");
110
+ const file = path.join(directory, `${requestId}.json`);
111
+ if (!fs.existsSync(file))
112
+ return null;
113
+ const request = readSetupRequest(file);
114
+ if (request.request_id !== requestId)
115
+ refuse("request_identity_conflict");
116
+ if (!Object.hasOwn(request, "executor"))
117
+ return null;
118
+ return { executor: structuredClone(request.executor ?? null),
119
+ ...(request.profileCreation ? { profileCreation: structuredClone(request.profileCreation) } : {}) };
120
+ }
121
+ /** A deferred request is immutable installation consent, not an implicit skip. */
122
+ export function hasDeferredSetupOnboarding(home, requestId) {
123
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$/.test(requestId))
124
+ refuse("invalid_request");
125
+ const root = path.resolve(home), directory = path.join(root, "setup-requests");
126
+ for (const candidate of [root, directory])
127
+ if (fs.existsSync(candidate) && !fs.lstatSync(candidate).isDirectory())
128
+ refuse("unsafe_setup_directory");
129
+ const file = path.join(directory, `${requestId}.json`);
130
+ if (!fs.existsSync(file))
131
+ return false;
132
+ const request = readSetupRequest(file);
133
+ if (request.request_id !== requestId)
134
+ refuse("request_identity_conflict");
135
+ return request.deferredOnboarding === true;
136
+ }
137
+ function deferredRequest(home, requestId) {
138
+ if (!hasDeferredSetupOnboarding(home, requestId))
139
+ refuse("onboarding_not_deferred");
140
+ const request = readSetupRequest(path.join(path.resolve(home), "setup-requests", `${requestId}.json`));
141
+ if (request.phase !== "committed")
142
+ refuse("installation_not_committed");
143
+ return request;
144
+ }
145
+ /** Retained consent allows recovery without asking again or importing current defaults. */
146
+ export function readSetupOnboarding(home, requestId) {
147
+ const request = deferredRequest(home, requestId);
148
+ const directory = path.join(path.resolve(home), "setup-onboarding");
149
+ if (fs.existsSync(directory) && !fs.lstatSync(directory).isDirectory())
150
+ refuse("unsafe_onboarding_directory");
151
+ const file = path.join(directory, `${requestId}.json`);
152
+ if (!fs.existsSync(file))
153
+ return null;
154
+ const receipt = regularJson(file);
155
+ if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)
156
+ || Object.keys(receipt).some(key => !["schema_id", "request_id", "installation_identity", "phase", "executor", "profileCreation"].includes(key))
157
+ || receipt.schema_id !== "dd-flow/setup-onboarding@1" || receipt.request_id !== requestId
158
+ || receipt.installation_identity !== request.identity || !["selected", "committed"].includes(receipt.phase)
159
+ || !Object.hasOwn(receipt, "executor"))
160
+ refuse("invalid_onboarding_receipt");
161
+ try {
162
+ if (receipt.executor !== null)
163
+ prepareOperatorProfile(receipt.executor);
164
+ if (receipt.profileCreation !== undefined) {
165
+ validateProfileCreationIntent(receipt.profileCreation);
166
+ if (!receipt.executor || hybridDigest(receipt.executor) !== hybridDigest(receipt.profileCreation.profile))
167
+ refuse("onboarding_profile_mismatch");
168
+ }
169
+ }
170
+ catch {
171
+ refuse("invalid_onboarding_receipt");
172
+ }
173
+ return structuredClone(receipt);
174
+ }
175
+ /** Persist human consent before profile effects; replay never restores later profile edits. */
176
+ export function commitSetupOnboarding(home, requestId, selection) {
177
+ const request = deferredRequest(home, requestId), root = path.resolve(home);
178
+ const executor = selection.executor === null ? null : prepareOperatorProfile(selection.executor);
179
+ if (selection.profileCreation) {
180
+ validateProfileCreationIntent(selection.profileCreation);
181
+ if (!executor || hybridDigest(executor) !== hybridDigest(selection.profileCreation.profile))
182
+ refuse("onboarding_profile_mismatch");
183
+ }
184
+ const candidate = { schema_id: "dd-flow/setup-onboarding@1", request_id: requestId,
185
+ installation_identity: request.identity, phase: "selected", executor,
186
+ ...(selection.profileCreation ? { profileCreation: selection.profileCreation } : {}) };
187
+ const lease = acquireInstallationLock(path.join(root, "setup.lock"), hybridDigest({ request_id: requestId, identity: request.identity }));
188
+ let failure, result;
189
+ try {
190
+ const retained = readSetupOnboarding(root, requestId);
191
+ if (retained && hybridDigest({ ...retained, phase: "selected" }) !== hybridDigest(candidate))
192
+ refuse("onboarding_selection_conflict");
193
+ if (retained?.phase === "committed")
194
+ result = retained;
195
+ else {
196
+ if (hybridDigest(readSetupStatus(root).installation) !== hybridDigest(request.installation))
197
+ refuse("onboarding_installation_changed");
198
+ const directory = path.join(root, "setup-onboarding");
199
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
200
+ const file = path.join(directory, `${requestId}.json`);
201
+ if (!retained)
202
+ atomicJson(file, candidate);
203
+ if (candidate.profileCreation)
204
+ commitProfileCreation(root, candidate.profileCreation);
205
+ result = { ...candidate, phase: "committed" };
206
+ atomicJson(file, result);
207
+ }
208
+ }
209
+ catch (error) {
210
+ failure = error instanceof AppError ? error : new AppError("setup_onboarding_interrupted", "Installed release is retained; executor onboarding requires reconciliation", 1, { phase: "executor_onboarding", effect: "unknown", request_id: requestId, help: "setup", next_action: "Replay the exact setup request" });
211
+ }
212
+ finally {
213
+ try {
214
+ lease.release();
215
+ }
216
+ catch {
217
+ if (failure)
218
+ failure.details.cleanup = "not_confirmed";
219
+ else
220
+ failure = new AppError("setup_cleanup_failed", "Onboarding owner cleanup requires reconciliation", 1, { effect: "unknown", cleanup: "not_confirmed", request_id: requestId, help: "setup" });
221
+ }
222
+ }
223
+ if (failure)
224
+ throw failure;
225
+ return result;
226
+ }
227
+ /** Read-only global readiness. No SQLite connection, migration, project registration or probes. */
228
+ export function readSetupStatus(home) {
229
+ const root = path.resolve(home);
230
+ if (fs.existsSync(root) && !fs.lstatSync(root).isDirectory())
231
+ refuse("unsafe_setup_home");
232
+ const file = path.join(root, "installation.json");
233
+ const installation = fs.existsSync(file) ? regularJson(file) : null;
234
+ if (installation) {
235
+ if (!installation.manifest || typeof installation.manifest_bytes_base64 !== "string" || validateHybridShape("release-manifest", installation.manifest).length ||
236
+ installation.canon_root !== path.join(root, "release-cache", "canon", installation.manifest.canon.version, "payload") ||
237
+ installation.engine_root !== path.join(root, "release-cache", "engine", installation.manifest.engine.version, "payload"))
238
+ refuse("invalid_installation_selection");
239
+ const bytes = Buffer.from(installation.manifest_bytes_base64, "base64");
240
+ if (bytes.toString("base64") !== installation.manifest_bytes_base64 || bytesDigest(bytes) !== installation.manifest_sha256 ||
241
+ hybridDigest(manifestFromBytes(bytes)) !== hybridDigest(installation.manifest))
242
+ refuse("invalid_installation_selection");
243
+ }
244
+ let readiness = installation ? "installed" : "not_installed";
245
+ if (installation) {
246
+ try {
247
+ verifyPublishedReleaseCache(path.dirname(installation.canon_root), installation.manifest.canon.archive);
248
+ verifyPublishedReleaseCache(path.dirname(installation.engine_root), installation.manifest.engine.archive);
249
+ }
250
+ catch {
251
+ readiness = "degraded";
252
+ }
253
+ }
254
+ const existingStorage = fs.existsSync(path.join(root, "db.sqlite"));
255
+ return { installation, installation_readiness: readiness,
256
+ storage_readiness: existingStorage ? "unknown_requires_diagnostic" : "not_initialized",
257
+ runtime_readiness: "unknown_requires_qualification",
258
+ next_action: !installation ? "dd-flow setup --help" : existingStorage ? "dd-flow migration plan --help" : "dd-flow engine doctor --json",
259
+ ...(existingStorage ? { storage_continuation: { kind: "storage_admission",
260
+ status: "requires_diagnostic", reason: "existing_storage_not_inspected",
261
+ help_command: "dd-flow migration plan --help", automatic_migration: false } } : {}) };
262
+ }
263
+ /** Exact-request installation transaction. Selected immutable caches do not affect existing RUNs. */
264
+ export function installRelease(home, input) {
265
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$/.test(input.requestId) || !["install", "update"].includes(input.action))
266
+ refuse("invalid_request");
267
+ const executor = input.executor === undefined ? undefined : input.executor === null ? null : prepareOperatorProfile(input.executor);
268
+ const executorBinding = executor === undefined ? {} : { executor: structuredClone(executor) };
269
+ if (input.deferredOnboarding !== undefined && (input.deferredOnboarding !== true || executor !== undefined || input.profileCreation !== undefined))
270
+ refuse("invalid_deferred_onboarding");
271
+ const onboardingBinding = input.deferredOnboarding ? { deferredOnboarding: true } : {};
272
+ if (input.profileCreation !== undefined) {
273
+ validateProfileCreationIntent(input.profileCreation);
274
+ if (!executor || hybridDigest(executor) !== hybridDigest(input.profileCreation.profile))
275
+ refuse("profile_executor_mismatch");
276
+ }
277
+ const profileBinding = input.profileCreation === undefined ? {} : { profileCreation: structuredClone(input.profileCreation) };
278
+ const manifest = admitReleaseManifest(input.manifest, input.authority);
279
+ if (!Buffer.isBuffer(input.manifestBytes) || hybridDigest(manifestFromBytes(input.manifestBytes)) !== hybridDigest(manifest))
280
+ refuse("manifest_bytes_mismatch");
281
+ // Both archives must be valid before even creating the home or publishing the first cache.
282
+ validateReleasePayloadIdentity(manifest, input.canonArchive, input.engineArchive);
283
+ const root = path.resolve(home), requests = path.join(root, "setup-requests"), lock = path.join(root, "setup.lock");
284
+ const installation = { manifest, manifest_sha256: bytesDigest(input.manifestBytes), manifest_bytes_base64: input.manifestBytes.toString("base64"),
285
+ canon_root: path.join(root, "release-cache", "canon", manifest.canon.version, "payload"),
286
+ engine_root: path.join(root, "release-cache", "engine", manifest.engine.version, "payload") };
287
+ const identity = hybridDigest({ action: input.action, installation, ...executorBinding, ...profileBinding, ...onboardingBinding });
288
+ readSetupStatus(root);
289
+ for (const directory of [root, requests]) {
290
+ if (fs.existsSync(directory) && !fs.lstatSync(directory).isDirectory())
291
+ refuse("unsafe_setup_directory");
292
+ }
293
+ if (input.profileCreation) {
294
+ const existingFile = path.join(requests, `${input.requestId}.json`);
295
+ const retained = fs.existsSync(existingFile) ? readSetupRequest(existingFile) : null;
296
+ if (retained?.phase !== "committed") {
297
+ const revision = readOperatorProfiles(root).revision;
298
+ if (revision !== input.profileCreation.before.revision && revision !== input.profileCreation.after.revision)
299
+ refuse("profile_selection_changed");
300
+ }
301
+ }
302
+ fs.mkdirSync(requests, { recursive: true, mode: 0o700 });
303
+ const lease = acquireInstallationLock(lock, hybridDigest({ request_id: input.requestId, identity }));
304
+ let failure;
305
+ const perform = () => {
306
+ const file = path.join(requests, `${input.requestId}.json`);
307
+ const current = readSetupStatus(root).installation;
308
+ let request = { schema_id: "dd-flow/setup-request@1", request_id: input.requestId, identity,
309
+ previous_selection: current ? hybridDigest(current) : null, phase: "prepared", installation, ...executorBinding, ...profileBinding, ...onboardingBinding };
310
+ if (fs.existsSync(file)) {
311
+ request = readSetupRequest(file);
312
+ if (request.schema_id !== "dd-flow/setup-request@1" || request.request_id !== input.requestId || request.identity !== identity || hybridDigest(request.installation) !== hybridDigest(installation))
313
+ refuse("request_identity_conflict");
314
+ if (request.deferredOnboarding !== input.deferredOnboarding)
315
+ refuse("request_onboarding_conflict");
316
+ if (hybridDigest({ executor: request.executor ?? null, present: Object.hasOwn(request, "executor") }) !==
317
+ hybridDigest({ executor: executor ?? null, present: executor !== undefined }))
318
+ refuse("request_executor_conflict");
319
+ if (hybridDigest({ profileCreation: request.profileCreation ?? null }) !== hybridDigest({ profileCreation: input.profileCreation ?? null }))
320
+ refuse("request_profile_creation_conflict");
321
+ if (!["prepared", "canon_cached", "engine_cached", "profiles_saved", "committed"].includes(request.phase))
322
+ refuse("invalid_request_phase");
323
+ }
324
+ // Replay still revalidates retained payload bytes; it never reactivates an older selection.
325
+ if (request.phase === "committed") {
326
+ publishReleaseCache(root, "canon", manifest.canon.version, manifest.canon.archive, input.canonArchive);
327
+ publishReleaseCache(root, "engine", manifest.engine.version, manifest.engine.archive, input.engineArchive);
328
+ return { ok: true, replayed: true, installation: request.installation, ...executorBinding };
329
+ }
330
+ const selectedDigest = current ? hybridDigest(current) : null;
331
+ if (selectedDigest !== request.previous_selection && selectedDigest !== hybridDigest(installation))
332
+ refuse("selection_changed_since_request");
333
+ if (input.action === "install" && current && selectedDigest !== hybridDigest(installation))
334
+ refuse("existing_installation_requires_update");
335
+ const checkpoint = (phase) => {
336
+ request.phase = phase;
337
+ atomicJson(file, request);
338
+ input.onCheckpoint?.(phase);
339
+ };
340
+ checkpoint("prepared");
341
+ publishReleaseCache(root, "canon", manifest.canon.version, manifest.canon.archive, input.canonArchive);
342
+ checkpoint("canon_cached");
343
+ publishReleaseCache(root, "engine", manifest.engine.version, manifest.engine.archive, input.engineArchive);
344
+ checkpoint("engine_cached");
345
+ if (request.profileCreation) {
346
+ commitProfileCreation(root, request.profileCreation);
347
+ checkpoint("profiles_saved");
348
+ }
349
+ atomicJson(path.join(root, "installation.json"), installation);
350
+ checkpoint("committed");
351
+ return { ok: true, replayed: false, installation, ...executorBinding };
352
+ };
353
+ let result;
354
+ try {
355
+ result = perform();
356
+ }
357
+ catch (error) {
358
+ failure = error instanceof AppError ? error : new AppError("setup_interrupted", "Setup request requires reconciliation", 1, { phase: "setup", effect: "unknown", request_id: input.requestId, next_action: "Replay the exact setup request", help: "setup" });
359
+ }
360
+ finally {
361
+ try {
362
+ lease.release();
363
+ }
364
+ catch {
365
+ // Cleanup is secondary to the retained operation result. Never leak a
366
+ // raw filesystem cause or erase the original failure with an unlink error.
367
+ if (failure)
368
+ failure.details.cleanup = "not_confirmed";
369
+ else
370
+ failure = new AppError("setup_cleanup_failed", "Setup owner cleanup requires reconciliation", 1, { phase: "setup", effect: "unknown", cleanup: "not_confirmed", request_id: input.requestId,
371
+ next_action: "Inspect dd-flow setup status --json; replay the exact request after its owner exits", help: "setup" });
372
+ }
373
+ }
374
+ if (failure)
375
+ throw failure;
376
+ return result;
377
+ }
@@ -5,7 +5,7 @@ import { channel } from "node:diagnostics_channel";
5
5
  import { createRequire } from "node:module";
6
6
  import { ensureDir } from "./paths.js";
7
7
  import { AppError } from "../shared/errors.js";
8
- import { executeStoreSchema, readStoreWriterContract, registerStoreWriter, STORE_WRITER_VERSION } from "./writer-contract.js";
8
+ import { executeStoreSchema, readStoreWriterContract, registerStoreWriter, registerReconciliationWriter, STORE_WRITER_VERSION } from "./writer-contract.js";
9
9
  import { assertImportAdmission } from "./import-guard.js";
10
10
  import { withStoreCleanup, rollbackStoreTransaction } from "./native-cleanup.js";
11
11
  const workContinuationStorageSql = `
@@ -354,6 +354,8 @@ export function getDatabase(ddFlowHome, mode = "initialize", importPermitId, hoo
354
354
  }
355
355
  if (mode === "hook" && !exists)
356
356
  throw new AppError("hook_storage_unprepared", "Prepare the runtime store before starting native hooks", 1);
357
+ if (mode === "reconcile" && !exists)
358
+ throw new AppError("reconciliation_storage_unprepared", "Reconciliation cannot create a runtime store", 1);
357
359
  if (!exists || mode === "migrate")
358
360
  ensureDir(ddFlowHome);
359
361
  const db = mode === "read_existing" ? new DatabaseSync(dbPath, { readOnly: true }) : new DatabaseSync(dbPath);
@@ -365,7 +367,10 @@ export function getDatabase(ddFlowHome, mode = "initialize", importPermitId, hoo
365
367
  }
366
368
  if (mode !== "read_existing") {
367
369
  try {
368
- registerStoreWriter(db);
370
+ if (mode === "reconcile")
371
+ registerReconciliationWriter(db);
372
+ else
373
+ registerStoreWriter(db);
369
374
  }
370
375
  catch (error) {
371
376
  return closeAfterOpenFailure(db, error);
@@ -379,7 +384,7 @@ export function getDatabase(ddFlowHome, mode = "initialize", importPermitId, hoo
379
384
  }
380
385
  // Internal additive schema changes must be available to every write command.
381
386
  // Higher-level Memory Bank migrations remain explicit in services/migrations.
382
- if (mode !== "read_existing") {
387
+ if (mode !== "read_existing" && mode !== "reconcile") {
383
388
  try {
384
389
  const initialize = () => { migrate(db); ensureRecoveryStorage(db); ensureLifecycleInvocationStorage(db); };
385
390
  if (mode !== "hook") {
@@ -402,6 +407,16 @@ export function getDatabase(ddFlowHome, mode = "initialize", importPermitId, hoo
402
407
  }
403
408
  if (mode === "read_existing")
404
409
  db.exec("PRAGMA query_only = ON");
410
+ if (mode === "reconcile") {
411
+ try {
412
+ db.prepare("SELECT id, project_id, status, verdict, next_action, index_json, updated_at, completed_at FROM runs LIMIT 0");
413
+ db.prepare("SELECT id, root FROM projects LIMIT 0");
414
+ db.prepare("SELECT id, event_type, payload_json FROM audit_events LIMIT 0");
415
+ }
416
+ catch (error) {
417
+ return closeAfterOpenFailure(db, error);
418
+ }
419
+ }
405
420
  db.exec("PRAGMA foreign_keys = ON");
406
421
  return databaseFacade(dbPath, mode !== "read_existing", db, undefined, deadline);
407
422
  }
@@ -35,6 +35,14 @@ export function registerStoreWriter(db) {
35
35
  throw new AppError("store_writer_incompatible", "This CLI cannot write the store's required contract", 1, { required: current, supported: version });
36
36
  db.function("dd_flow_writer_version", () => version);
37
37
  }
38
+ /** Health maintenance uses only verified legacy lifecycle columns and never
39
+ * migrates schema. Keep the retained writer fence, including version 1. */
40
+ export function registerReconciliationWriter(db) {
41
+ const current = readStoreWriterContract(db);
42
+ if (current !== null && current !== 1 && current !== version)
43
+ throw new AppError("store_writer_incompatible", "Unsupported reconciliation writer contract", 1, { required: current, supported: [1, version] });
44
+ db.function("dd_flow_writer_version", () => current ?? version);
45
+ }
38
46
  export function readStoreWriterContract(db) {
39
47
  if (!db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table))
40
48
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.135",
3
+ "version": "0.9.0-beta.136",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.23.0",