@deksden-com/dd-flow-cli 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +8 -1
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +20 -8
- package/dist/cli/run-cli.js +104 -24
- package/dist/domain/flow-contract.js +11 -0
- package/dist/runtime/context.js +8 -2
- package/dist/schemas/code-stage-report.schema.json +7 -2
- package/dist/schemas/engine-manifest.schema.json +22 -0
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
- package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
- package/dist/schemas/run-engine-binding.schema.json +37 -0
- package/dist/schemas/stage-prompt.schema.json +4 -4
- package/dist/services/canon.js +15 -1
- package/dist/services/cli-operation-classifier.js +52 -8
- package/dist/services/compatibility-preflight.js +1 -1
- package/dist/services/dashboard.js +2 -2
- package/dist/services/engines.js +408 -30
- package/dist/services/hooks.js +1 -5
- package/dist/services/lanes.js +0 -4
- package/dist/services/merge-queue.js +48 -0
- package/dist/services/merge-worker.js +3 -4
- package/dist/services/migrations.js +307 -44
- package/dist/services/plan-runtime.js +4 -4
- package/dist/services/plans.js +5 -3
- package/dist/services/protocols.js +23 -2
- package/dist/services/run-engine-bindings.js +157 -0
- package/dist/services/runs.js +21 -7
- package/dist/services/schema-validation.js +96 -0
- package/dist/services/stage-lifecycle.js +98 -10
- package/dist/services/status.js +8 -3
- package/dist/storage/database.js +32 -11
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AppError } from "../shared/errors.js";
|
|
2
2
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
3
3
|
import { appendAudit } from "./audit.js";
|
|
4
|
-
import { ensureLaneWorkspace, acquireLaneLock, releaseLaneLock
|
|
4
|
+
import { ensureLaneWorkspace, acquireLaneLock, releaseLaneLock } from "./lanes.js";
|
|
5
5
|
import { claimNextMergeJob, queueForProject } from "./merge-queue.js";
|
|
6
6
|
import { requireProjectByRoot } from "./projects.js";
|
|
7
7
|
import { buildStaticFlowGuidance } from "./flow-guidance.js";
|
|
@@ -175,15 +175,14 @@ function withJobGuidance(context, job) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
function detectMergeWorkerState(context, projectId) {
|
|
178
|
-
expireProjectLaneLocks(context, projectId);
|
|
179
178
|
const workers = activeMergeWorkerSessions(context, projectId);
|
|
180
179
|
const workerIds = [...new Set(workers.map((worker) => worker.worker_id).filter((value) => Boolean(value)))];
|
|
181
180
|
const claimed = context.db.all(`SELECT protocol_id, claimed_by_session_id, status, updated_at FROM merge_queue
|
|
182
181
|
WHERE project_id = ? AND status = 'claimed'
|
|
183
182
|
ORDER BY updated_at DESC, id DESC`, [projectId]);
|
|
184
183
|
const lock = context.db.get(`SELECT worker_id, status, expires_at, reason FROM lane_locks
|
|
185
|
-
WHERE project_id = ? AND lane = 'merge' AND status = 'active'
|
|
186
|
-
ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId]);
|
|
184
|
+
WHERE project_id = ? AND lane = 'merge' AND status = 'active' AND expires_at > ?
|
|
185
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId, context.now()]);
|
|
187
186
|
if (workerIds.length > 1) {
|
|
188
187
|
return { state: "blocked", reason: "multiple_active_merge_workers", workers };
|
|
189
188
|
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
3
4
|
import { AppError } from "../shared/errors.js";
|
|
4
5
|
import { requireProjectByRoot } from "./projects.js";
|
|
5
6
|
import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
|
|
6
7
|
import { resolveCanonRoot } from "./canon.js";
|
|
8
|
+
import { getDatabase } from "../storage/database.js";
|
|
9
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
10
|
+
import { classifyCliOperation } from "./cli-operation-classifier.js";
|
|
11
|
+
import { compatibilityContexts, selectEngine } from "./engines.js";
|
|
12
|
+
import { getCliBuildInfo } from "./build-info.js";
|
|
7
13
|
export function assessMigrationImpact(context, input) {
|
|
8
14
|
const root = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
|
|
9
15
|
if (!root.root)
|
|
@@ -78,14 +84,19 @@ export function planMigration(context, input) {
|
|
|
78
84
|
rootSource: rootResolution.root_source
|
|
79
85
|
});
|
|
80
86
|
const sourceVersion = input.sourceVersion ?? versionStatus?.memory_bank.version ?? "unknown";
|
|
81
|
-
const targetVersion = input.targetVersion ?? versionStatus?.memory_bank.version ?? "unknown";
|
|
82
|
-
const
|
|
87
|
+
const targetVersion = input.targetVersion ?? upgradeCanon(context).canon?.version ?? versionStatus?.memory_bank.version ?? "unknown";
|
|
88
|
+
const canon = upgradeCanon(context);
|
|
89
|
+
const impacts = canon.canon ? readImpacts(path.join(canon.canon.memorybank_root, "release-impact")) : [];
|
|
90
|
+
const chain = adjacentMigrationChain(sourceVersion, targetVersion, impacts);
|
|
83
91
|
const activeState = activeStateSummary(context, project.id);
|
|
84
92
|
const backup = backupEvidence(input);
|
|
93
|
+
const engine = migrationEngineEvidence(context, rootResolution.root, targetVersion, input.classification);
|
|
85
94
|
const blockers = [
|
|
86
95
|
...(chain.status === "unsupported" ? [`unsupported migration path: ${chain.reason}`] : []),
|
|
87
|
-
...(backup.status
|
|
88
|
-
...(
|
|
96
|
+
...(backup.status !== "present" ? ["verified backup evidence is required before runtime/home migration"] : []),
|
|
97
|
+
...(engine.target_canon_version !== targetVersion ? ["migration target version does not match the resolved target canon version"] : []),
|
|
98
|
+
...(engine.target_engine_available !== "selected" ? ["target upgrade engine is not selected"] : []),
|
|
99
|
+
...activeStateBlockers(activeState)
|
|
89
100
|
];
|
|
90
101
|
const status = blockers.length > 0 ? "blocked" : chain.steps.length === 0 ? "noop" : "ready";
|
|
91
102
|
return {
|
|
@@ -110,12 +121,7 @@ export function planMigration(context, input) {
|
|
|
110
121
|
chain: chain.steps,
|
|
111
122
|
blocked_reasons: blockers
|
|
112
123
|
},
|
|
113
|
-
engine
|
|
114
|
-
router_version: null,
|
|
115
|
-
selected_engine_version: null,
|
|
116
|
-
source_engine_available: "unknown",
|
|
117
|
-
target_engine_available: "unknown"
|
|
118
|
-
},
|
|
124
|
+
engine,
|
|
119
125
|
backup,
|
|
120
126
|
active_state: activeState,
|
|
121
127
|
primary_data: [
|
|
@@ -141,35 +147,134 @@ export function planMigration(context, input) {
|
|
|
141
147
|
}
|
|
142
148
|
};
|
|
143
149
|
}
|
|
150
|
+
export function applyMigration(context, input) {
|
|
151
|
+
const classification = input.classification ?? classifyCliOperation(["migration", "apply"], context.env);
|
|
152
|
+
if (classification.mode !== "mb_upgrade") {
|
|
153
|
+
throw new AppError("upgrade_authorization_required", "Migration apply requires explicit mb-upgrade authorization", 1, {
|
|
154
|
+
required_marker: "DD_FLOW_COMPATIBILITY_MODE=mb-upgrade"
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const report = input.reportFile
|
|
158
|
+
? readJsonObject(path.resolve(input.reportFile))
|
|
159
|
+
: planMigration(context, input);
|
|
160
|
+
const reportFile = input.reportFile ? path.resolve(input.reportFile) : null;
|
|
161
|
+
const reportValidation = validateMigrationReportObject(report);
|
|
162
|
+
if (reportValidation.errors.length > 0) {
|
|
163
|
+
throw new AppError("migration_report_invalid", "Migration report is not acceptable for apply", 2, { errors: reportValidation.errors, file: reportFile });
|
|
164
|
+
}
|
|
165
|
+
const migration = recordValue(report.migration);
|
|
166
|
+
const backup = recordValue(report.backup);
|
|
167
|
+
const activeState = recordValue(report.active_state);
|
|
168
|
+
const blockers = Array.isArray(migration?.blocked_reasons) ? migration.blocked_reasons.map(String) : [];
|
|
169
|
+
if (blockers.length > 0 || stringValue(migration?.status) === "blocked") {
|
|
170
|
+
throw new AppError("migration_blocked", "Migration apply is blocked by its verified report", 1, { blockers, file: reportFile });
|
|
171
|
+
}
|
|
172
|
+
const backupPath = stringValue(backup?.path);
|
|
173
|
+
const verifiedBackup = backupPath ? verifyBackupFile(backupPath) : null;
|
|
174
|
+
if (!verifiedBackup) {
|
|
175
|
+
throw new AppError("backup_invalid", "Migration apply requires a readable, non-empty backup file", 1, { path: backupPath });
|
|
176
|
+
}
|
|
177
|
+
if (activeState && hasActiveState(activeState)) {
|
|
178
|
+
throw new AppError("migration_active_state", "Migration apply requires a quiescent shared home", 1, { active_state: activeState });
|
|
179
|
+
}
|
|
180
|
+
const sourceVersion = stringValue(migration?.source_memory_bank_version);
|
|
181
|
+
const targetVersion = stringValue(migration?.target_memory_bank_version);
|
|
182
|
+
if (!sourceVersion || !targetVersion)
|
|
183
|
+
throw new AppError("migration_contract_invalid", "Migration source and target versions are required", 1);
|
|
184
|
+
const canon = upgradeCanon(context);
|
|
185
|
+
const impacts = canon.canon ? readImpacts(path.join(canon.canon.memorybank_root, "release-impact")) : [];
|
|
186
|
+
const chain = adjacentMigrationChain(sourceVersion, targetVersion, impacts);
|
|
187
|
+
if (chain.status === "unsupported")
|
|
188
|
+
throw new AppError("migration_chain_invalid", chain.reason ?? "Unsupported migration chain", 1);
|
|
189
|
+
const expectedEngine = migrationEngineEvidence(context, resolveProjectRoot(input.projectRoot), targetVersion, classification);
|
|
190
|
+
const reportEngine = recordValue(report.engine);
|
|
191
|
+
const engineErrors = validateEngineEvidence(reportEngine, expectedEngine, targetVersion);
|
|
192
|
+
if (engineErrors.length > 0) {
|
|
193
|
+
throw new AppError("migration_contract_invalid", "Migration report engine contract does not match the pinned upgrade target", 1, {
|
|
194
|
+
errors: engineErrors,
|
|
195
|
+
expected: expectedEngine,
|
|
196
|
+
actual: reportEngine
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
200
|
+
const writeDb = getDatabase(context.ddFlowHome, "migrate");
|
|
201
|
+
const already = writeDb.get("SELECT value_json FROM runtime_config WHERE key = ?", [applyKey(project.id, sourceVersion, targetVersion)]);
|
|
202
|
+
if (already) {
|
|
203
|
+
return { ok: true, schema_id: "dd-flow/mb-upgrade-migration-apply@1", status: "verified_noop", changed: false, backup: verifiedBackup, recovery: JSON.parse(already.value_json) };
|
|
204
|
+
}
|
|
205
|
+
const result = {
|
|
206
|
+
status: "applied",
|
|
207
|
+
source_version: sourceVersion,
|
|
208
|
+
target_version: targetVersion,
|
|
209
|
+
contract: {
|
|
210
|
+
flow_kind: "mb-upgrade",
|
|
211
|
+
mode: "mb-upgrade-only",
|
|
212
|
+
adjacent_only: true
|
|
213
|
+
},
|
|
214
|
+
engine: reportEngine,
|
|
215
|
+
applied_at: context.now(),
|
|
216
|
+
backup: verifiedBackup,
|
|
217
|
+
chain: chain.steps.map((step) => ({ ...step, status: "applied" }))
|
|
218
|
+
};
|
|
219
|
+
try {
|
|
220
|
+
writeDb.exec("BEGIN IMMEDIATE");
|
|
221
|
+
writeDb.run(`INSERT INTO runtime_config (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)`, [applyKey(project.id, sourceVersion, targetVersion), JSON.stringify(result), context.now(), context.now()]);
|
|
222
|
+
writeDb.exec("COMMIT");
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
try {
|
|
226
|
+
writeDb.exec("ROLLBACK");
|
|
227
|
+
}
|
|
228
|
+
catch { /* preserve the original failure */ }
|
|
229
|
+
throw new AppError("migration_apply_failed", "Migration apply failed before upgrade RUN creation or canon sync", 1, {
|
|
230
|
+
recovery: {
|
|
231
|
+
status: "required",
|
|
232
|
+
source_version: sourceVersion,
|
|
233
|
+
target_version: targetVersion,
|
|
234
|
+
backup: verifiedBackup,
|
|
235
|
+
rollback_route: "restore the verified backup before retrying mb-upgrade apply",
|
|
236
|
+
next_action: "inspect the failed apply and retry only with the same pinned target contract"
|
|
237
|
+
},
|
|
238
|
+
cause: String(error)
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return { ok: true, schema_id: "dd-flow/mb-upgrade-migration-apply@1", status: "applied", changed: true, backup: verifiedBackup, recovery: result };
|
|
242
|
+
}
|
|
243
|
+
export function assertUpgradeRunStorageReady(context, projectRoot) {
|
|
244
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(projectRoot)]);
|
|
245
|
+
if (!project)
|
|
246
|
+
throw new AppError("migration_not_applied", "mb-upgrade RUN requires a registered project with explicit migration apply", 1);
|
|
247
|
+
const status = getProjectVersionStatus({ projectRoot: resolveProjectRoot(projectRoot), rootSource: "explicit" });
|
|
248
|
+
const target = upgradeCanon(context).canon?.version;
|
|
249
|
+
const source = status?.memory_bank.version;
|
|
250
|
+
if (!source || !target)
|
|
251
|
+
throw new AppError("migration_not_applied", "mb-upgrade RUN requires source and target Memory Bank versions", 1);
|
|
252
|
+
const applied = context.db.get("SELECT value_json FROM runtime_config WHERE key = ?", [applyKey(project.id, source, target)]);
|
|
253
|
+
if (!applied)
|
|
254
|
+
throw new AppError("migration_not_applied", "mb-upgrade RUN requires explicit migration apply before RUN creation", 1, {
|
|
255
|
+
source_version: source,
|
|
256
|
+
target_version: target,
|
|
257
|
+
next_action: "run migration plan, verify and explicit migration apply first"
|
|
258
|
+
});
|
|
259
|
+
const value = recordValue(parseJsonValue(applied.value_json));
|
|
260
|
+
if (!value || !["applied", "verified_noop"].includes(stringValue(value.status) ?? "")) {
|
|
261
|
+
throw new AppError("migration_not_applied", "mb-upgrade RUN has no successful explicit migration apply evidence", 1, {
|
|
262
|
+
source_version: source,
|
|
263
|
+
target_version: target
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
144
267
|
export function verifyMigrationReport(_context, input) {
|
|
145
268
|
const filePath = path.resolve(input.file);
|
|
146
269
|
const report = readJsonObject(filePath);
|
|
270
|
+
const validation = validateMigrationReportObject(report);
|
|
147
271
|
const schemaId = stringValue(report.schema_id);
|
|
148
|
-
if (schemaId !== "dd-flow/mb-upgrade-migration-report@1") {
|
|
149
|
-
throw new AppError("validation", "Migration report schema_id is not dd-flow/mb-upgrade-migration-report@1", 2, {
|
|
150
|
-
file: filePath,
|
|
151
|
-
schema_id: schemaId
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
272
|
const migration = recordValue(report.migration);
|
|
155
273
|
const backup = recordValue(report.backup);
|
|
156
274
|
const chain = Array.isArray(migration?.chain) ? migration.chain : [];
|
|
157
275
|
const backupStatus = stringValue(backup?.status);
|
|
158
276
|
const blockedReasons = Array.isArray(migration?.blocked_reasons) ? migration.blocked_reasons.map(String) : [];
|
|
159
|
-
const errors =
|
|
160
|
-
if (!migration)
|
|
161
|
-
errors.push("migration block is required");
|
|
162
|
-
if (!backup)
|
|
163
|
-
errors.push("backup block is required");
|
|
164
|
-
if (backupStatus !== "present" && backupStatus !== "planned")
|
|
165
|
-
errors.push("backup.status must be present or planned");
|
|
166
|
-
if (migration?.adjacent_only !== true)
|
|
167
|
-
errors.push("migration.adjacent_only must be true");
|
|
168
|
-
if (chain.some((step) => !isAdjacentStep(step)))
|
|
169
|
-
errors.push("all migration.chain entries must be adjacent steps with from/to/status");
|
|
170
|
-
if (stringValue(migration?.status) === "ready" && blockedReasons.length > 0) {
|
|
171
|
-
errors.push("ready migration report must not contain blocked reasons");
|
|
172
|
-
}
|
|
277
|
+
const errors = validation.errors;
|
|
173
278
|
if (errors.length > 0) {
|
|
174
279
|
throw new AppError("migration_report_invalid", "Migration report is not acceptable", 2, { file: filePath, errors });
|
|
175
280
|
}
|
|
@@ -180,6 +285,7 @@ export function verifyMigrationReport(_context, input) {
|
|
|
180
285
|
verdict: blockedReasons.length > 0 ? "blocked_report_valid" : "accepted",
|
|
181
286
|
checks: {
|
|
182
287
|
backup: backupStatus,
|
|
288
|
+
backup_integrity: backupStatus === "present" && stringValue(backup?.path) ? verifyBackupFile(stringValue(backup?.path)) : null,
|
|
183
289
|
adjacent_chain_steps: chain.length,
|
|
184
290
|
blocked_reasons: blockedReasons.length
|
|
185
291
|
}
|
|
@@ -191,17 +297,135 @@ function backupEvidence(input) {
|
|
|
191
297
|
status: "missing",
|
|
192
298
|
path: null,
|
|
193
299
|
created_at: null,
|
|
300
|
+
size_bytes: null,
|
|
301
|
+
sha256: null,
|
|
194
302
|
rollback_route: "required before applying runtime/home migration"
|
|
195
303
|
};
|
|
196
304
|
}
|
|
305
|
+
const file = path.resolve(input.backupPath);
|
|
306
|
+
const verified = verifyBackupFile(file);
|
|
197
307
|
return {
|
|
198
|
-
status:
|
|
199
|
-
path:
|
|
200
|
-
created_at: input.backupCreatedAt ?? null,
|
|
308
|
+
status: verified ? "present" : "missing",
|
|
309
|
+
path: file,
|
|
310
|
+
created_at: input.backupCreatedAt ?? (verified ? new Date(fs.statSync(file).birthtimeMs).toISOString() : null),
|
|
311
|
+
size_bytes: verified?.size_bytes ?? null,
|
|
312
|
+
sha256: verified?.sha256 ?? null,
|
|
201
313
|
rollback_route: "restore backup before retrying migration"
|
|
202
314
|
};
|
|
203
315
|
}
|
|
204
|
-
function
|
|
316
|
+
function verifyBackupFile(file) {
|
|
317
|
+
try {
|
|
318
|
+
const stat = fs.statSync(file);
|
|
319
|
+
if (!stat.isFile() || stat.size <= 0)
|
|
320
|
+
return null;
|
|
321
|
+
return {
|
|
322
|
+
path: file,
|
|
323
|
+
size_bytes: stat.size,
|
|
324
|
+
sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"),
|
|
325
|
+
created_at: stat.birthtime.toISOString()
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function validateMigrationReportObject(report) {
|
|
333
|
+
const migration = recordValue(report.migration);
|
|
334
|
+
const backup = recordValue(report.backup);
|
|
335
|
+
const chain = Array.isArray(migration?.chain) ? migration.chain : [];
|
|
336
|
+
const backupStatus = stringValue(backup?.status);
|
|
337
|
+
const blockedReasons = Array.isArray(migration?.blocked_reasons) ? migration.blocked_reasons.map(String) : [];
|
|
338
|
+
const errors = [];
|
|
339
|
+
if (report.schema_id !== "dd-flow/mb-upgrade-migration-report@1")
|
|
340
|
+
errors.push("schema_id must be dd-flow/mb-upgrade-migration-report@1");
|
|
341
|
+
if (!migration)
|
|
342
|
+
errors.push("migration block is required");
|
|
343
|
+
if (!recordValue(report.engine))
|
|
344
|
+
errors.push("engine block is required");
|
|
345
|
+
if (!backup)
|
|
346
|
+
errors.push("backup block is required");
|
|
347
|
+
if (backupStatus !== "present")
|
|
348
|
+
errors.push("backup.status must be present");
|
|
349
|
+
if (backupStatus === "present") {
|
|
350
|
+
const backupPath = stringValue(backup?.path);
|
|
351
|
+
const verified = backupPath ? verifyBackupFile(backupPath) : null;
|
|
352
|
+
if (!verified)
|
|
353
|
+
errors.push("backup file must be readable and non-empty");
|
|
354
|
+
else {
|
|
355
|
+
if (backup?.size_bytes !== verified.size_bytes)
|
|
356
|
+
errors.push("backup.size_bytes does not match the verified backup");
|
|
357
|
+
if (backup?.sha256 !== verified.sha256)
|
|
358
|
+
errors.push("backup.sha256 does not match the verified backup");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (migration?.adjacent_only !== true)
|
|
362
|
+
errors.push("migration.adjacent_only must be true");
|
|
363
|
+
if (chain.some((step) => !isAdjacentStep(step)))
|
|
364
|
+
errors.push("all migration.chain entries must be adjacent steps with from/to/status");
|
|
365
|
+
const engine = recordValue(report.engine);
|
|
366
|
+
if (engine && stringValue(migration?.target_memory_bank_version) !== engine.target_canon_version) {
|
|
367
|
+
errors.push("migration.target_memory_bank_version must match engine.target_canon_version");
|
|
368
|
+
}
|
|
369
|
+
if (stringValue(migration?.status) === "ready" && blockedReasons.length > 0)
|
|
370
|
+
errors.push("ready migration report must not contain blocked reasons");
|
|
371
|
+
return { errors };
|
|
372
|
+
}
|
|
373
|
+
function migrationEngineEvidence(context, projectRoot, targetVersion, classification) {
|
|
374
|
+
const env = context.env;
|
|
375
|
+
const targetClassification = classification ?? classifyCliOperation(["migration", "plan"], env);
|
|
376
|
+
const current = selectEngine(context, { projectRoot, env }, {}, {
|
|
377
|
+
mode: "normal_write", operation: "project.current", family: "project", command: "current", action: null
|
|
378
|
+
});
|
|
379
|
+
const target = selectEngine(context, { projectRoot, env }, { allowCurrentInProcess: true }, targetClassification);
|
|
380
|
+
const contexts = compatibilityContexts(context, { projectRoot, env, classification: targetClassification });
|
|
381
|
+
const targetContext = recordValue(contexts.upgrade_target);
|
|
382
|
+
const canon = upgradeCanon(context).canon;
|
|
383
|
+
const targetCanonVersion = canon?.version ?? null;
|
|
384
|
+
return {
|
|
385
|
+
router_version: getCliBuildInfo().version,
|
|
386
|
+
source_engine_version: current.selected?.package_version ?? null,
|
|
387
|
+
target_engine_version: target.selected?.package_version ?? null,
|
|
388
|
+
selected_engine_version: target.selected?.package_version ?? null,
|
|
389
|
+
source_engine_available: current.status,
|
|
390
|
+
target_engine_available: target.status,
|
|
391
|
+
target_canon_version: targetCanonVersion,
|
|
392
|
+
target_canon_commit: canon?.commit ?? null,
|
|
393
|
+
target_compatibility_sha256: stringValue(targetContext?.canon && recordValue(targetContext.canon)?.compatibility_sha256),
|
|
394
|
+
effective_engine_version: recordValue(contexts.effective_execution)?.engine_version ?? null
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function validateEngineEvidence(actual, expected, targetVersion) {
|
|
398
|
+
const errors = [];
|
|
399
|
+
if (!actual)
|
|
400
|
+
return ["engine block is required"];
|
|
401
|
+
for (const key of ["target_engine_version", "selected_engine_version", "target_canon_version", "target_canon_commit", "target_compatibility_sha256"]) {
|
|
402
|
+
if (actual[key] !== expected[key])
|
|
403
|
+
errors.push(`engine.${key} does not match current target assessment`);
|
|
404
|
+
}
|
|
405
|
+
if (actual.target_canon_version !== targetVersion)
|
|
406
|
+
errors.push("engine.target_canon_version must match migration target version");
|
|
407
|
+
if (actual.target_engine_available !== "selected")
|
|
408
|
+
errors.push("engine.target_engine_available must be selected");
|
|
409
|
+
return errors;
|
|
410
|
+
}
|
|
411
|
+
function parseJsonValue(value) {
|
|
412
|
+
try {
|
|
413
|
+
return JSON.parse(value);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function upgradeCanon(context) {
|
|
420
|
+
return resolveCanonRoot(context, context.env.DD_MEMORYBANK ? { explicitRoot: context.env.DD_MEMORYBANK } : {});
|
|
421
|
+
}
|
|
422
|
+
function hasActiveState(active) {
|
|
423
|
+
return ["protocols", "runs", "merge_queue", "lane_locks", "lane_waiters", "merge_sessions", "sessions"].some((key) => Array.isArray(active[key]) && active[key].length > 0);
|
|
424
|
+
}
|
|
425
|
+
function applyKey(projectId, source, target) {
|
|
426
|
+
return `mb-upgrade.apply.${projectId}.${source}.${target}`;
|
|
427
|
+
}
|
|
428
|
+
function adjacentMigrationChain(source, target, impacts = []) {
|
|
205
429
|
if (source === target)
|
|
206
430
|
return { status: "ok", steps: [] };
|
|
207
431
|
const sourceParts = parseSemver(source);
|
|
@@ -209,8 +433,38 @@ function adjacentMigrationChain(source, target) {
|
|
|
209
433
|
if (!sourceParts || !targetParts) {
|
|
210
434
|
return { status: "unsupported", reason: "source or target version is not semver", steps: [] };
|
|
211
435
|
}
|
|
436
|
+
if (impacts.length > 0) {
|
|
437
|
+
const steps = [];
|
|
438
|
+
const visited = new Set();
|
|
439
|
+
let current = source;
|
|
440
|
+
while (current !== target) {
|
|
441
|
+
if (visited.has(current))
|
|
442
|
+
return { status: "unsupported", reason: "release impact chain contains a cycle", steps: [] };
|
|
443
|
+
visited.add(current);
|
|
444
|
+
const impact = impacts.find((item) => item.from_version === current);
|
|
445
|
+
if (!impact)
|
|
446
|
+
return { status: "unsupported", reason: `missing adjacent release impact from ${current}`, steps: [] };
|
|
447
|
+
steps.push({
|
|
448
|
+
id: `${impact.from_version}-to-${impact.to_version}`,
|
|
449
|
+
from: impact.from_version,
|
|
450
|
+
to: impact.to_version,
|
|
451
|
+
status: "planned",
|
|
452
|
+
minimum_upgrade_mode: impact.minimum_upgrade_mode,
|
|
453
|
+
domains: impact.domains,
|
|
454
|
+
path_migration: impact.path_migration,
|
|
455
|
+
runtime_migration: impact.runtime_migration,
|
|
456
|
+
required_checks: impact.required_checks
|
|
457
|
+
});
|
|
458
|
+
current = impact.to_version;
|
|
459
|
+
const next = parseSemver(current);
|
|
460
|
+
if (!next || compareParts(next, targetParts) > 0) {
|
|
461
|
+
return { status: "unsupported", reason: `release impact chain overshoots target ${target}`, steps: [] };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return { status: "ok", steps };
|
|
465
|
+
}
|
|
212
466
|
if (sourceParts.major !== targetParts.major) {
|
|
213
|
-
return { status: "unsupported", reason: "major version migration requires
|
|
467
|
+
return { status: "unsupported", reason: "major version migration requires canonical release-impact units", steps: [] };
|
|
214
468
|
}
|
|
215
469
|
if (targetParts.minor < sourceParts.minor || (targetParts.minor === sourceParts.minor && targetParts.patch < sourceParts.patch)) {
|
|
216
470
|
return { status: "unsupported", reason: "downgrade migrations are not supported", steps: [] };
|
|
@@ -229,24 +483,33 @@ function adjacentMigrationChain(source, target) {
|
|
|
229
483
|
}
|
|
230
484
|
return { status: "ok", steps };
|
|
231
485
|
}
|
|
232
|
-
function activeStateSummary(context,
|
|
486
|
+
function activeStateSummary(context, _projectId) {
|
|
233
487
|
return {
|
|
234
|
-
protocols: context.db.all("SELECT id, status, stage FROM protocols WHERE
|
|
235
|
-
runs: context.db.all("SELECT id, status, verdict FROM flow_runs WHERE
|
|
236
|
-
merge_queue: context.db.all("SELECT protocol_id, status FROM merge_queue WHERE
|
|
237
|
-
lane_locks: context.db.all("SELECT lane, worker_id, status FROM lane_locks WHERE
|
|
488
|
+
protocols: context.db.all("SELECT id, project_id, status, stage FROM protocols WHERE status NOT IN ('closed', 'cancelled') ORDER BY updated_at DESC"),
|
|
489
|
+
runs: context.db.all("SELECT id, project_id, status, verdict FROM flow_runs WHERE status = 'running' ORDER BY updated_at DESC"),
|
|
490
|
+
merge_queue: context.db.all("SELECT protocol_id, project_id, status FROM merge_queue WHERE status IN ('ready', 'claimed', 'requeued') ORDER BY updated_at DESC"),
|
|
491
|
+
lane_locks: context.db.all("SELECT lane, project_id, worker_id, status FROM lane_locks WHERE status = 'active' ORDER BY updated_at DESC"),
|
|
492
|
+
lane_waiters: context.db.all("SELECT id, lane, project_id, worker_id, status FROM lane_waiters WHERE status = 'queued' ORDER BY queued_at ASC, id ASC"),
|
|
493
|
+
merge_sessions: context.db.all("SELECT session_id, project_id, status, current_protocol_id FROM merge_sessions WHERE status IN ('starting', 'active', 'stopping') ORDER BY updated_at DESC"),
|
|
494
|
+
sessions: context.db.all("SELECT session_id, project_id, status FROM flow_sessions WHERE status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping') ORDER BY updated_at DESC")
|
|
238
495
|
};
|
|
239
496
|
}
|
|
240
497
|
function activeStateBlockers(active) {
|
|
241
498
|
const blockers = [];
|
|
242
499
|
if (active.protocols.length > 0)
|
|
243
|
-
blockers.push(`active protocols: ${active.protocols.map((item) => item.id).join(", ")}`);
|
|
500
|
+
blockers.push(`active protocols: ${active.protocols.map((item) => `${item.project_id}:${item.id}`).join(", ")}`);
|
|
244
501
|
if (active.runs.length > 0)
|
|
245
|
-
blockers.push(`active runs: ${active.runs.map((item) => item.id).join(", ")}`);
|
|
502
|
+
blockers.push(`active runs: ${active.runs.map((item) => `${item.project_id}:${item.id}`).join(", ")}`);
|
|
246
503
|
if (active.merge_queue.length > 0)
|
|
247
|
-
blockers.push(`active merge queue items: ${active.merge_queue.map((item) => item.protocol_id).join(", ")}`);
|
|
504
|
+
blockers.push(`active merge queue items: ${active.merge_queue.map((item) => `${item.project_id}:${item.protocol_id}`).join(", ")}`);
|
|
248
505
|
if (active.lane_locks.length > 0)
|
|
249
|
-
blockers.push(`active lane locks: ${active.lane_locks.map((item) => `${item.lane}:${item.worker_id}`).join(", ")}`);
|
|
506
|
+
blockers.push(`active lane locks: ${active.lane_locks.map((item) => `${item.project_id}:${item.lane}:${item.worker_id}`).join(", ")}`);
|
|
507
|
+
if (active.lane_waiters.length > 0)
|
|
508
|
+
blockers.push(`queued lane waiters: ${active.lane_waiters.map((item) => `${item.project_id}:${item.lane}:${item.worker_id}`).join(", ")}`);
|
|
509
|
+
if (active.merge_sessions.length > 0)
|
|
510
|
+
blockers.push(`active merge sessions: ${active.merge_sessions.map((item) => `${item.project_id}:${item.session_id}`).join(", ")}`);
|
|
511
|
+
if (active.sessions.length > 0)
|
|
512
|
+
blockers.push(`active sessions: ${active.sessions.map((item) => `${item.project_id}:${item.session_id}`).join(", ")}`);
|
|
250
513
|
return blockers;
|
|
251
514
|
}
|
|
252
515
|
function readJsonObject(filePath) {
|
|
@@ -54,8 +54,8 @@ export function boundCanonicalPlan(context, input) {
|
|
|
54
54
|
refreshProtocolRuns(context, input.projectId, input.protocolId);
|
|
55
55
|
return canonical;
|
|
56
56
|
}
|
|
57
|
-
export function planWithProgress(context, input) {
|
|
58
|
-
const canonical = boundCanonicalPlan(context, input);
|
|
57
|
+
export function planWithProgress(context, input, options = {}) {
|
|
58
|
+
const canonical = options.bind === false ? readCanonicalPlan(input.planPath, input.protocolId) : boundCanonicalPlan(context, input);
|
|
59
59
|
const rows = context.db.all(`SELECT item_id, plan_revision, plan_sha256, status, summary, evidence_json, block_reason, user_required
|
|
60
60
|
FROM plan_progress WHERE project_id = ? AND protocol_id = ?`, [input.projectId, input.protocolId]);
|
|
61
61
|
const progress = new Map(rows.map((row) => [row.item_id, row]));
|
|
@@ -67,8 +67,8 @@ export function planWithProgress(context, input) {
|
|
|
67
67
|
}
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
|
-
export function planSummary(context, input) {
|
|
71
|
-
const { canonical, plan } = planWithProgress(context, input);
|
|
70
|
+
export function planSummary(context, input, options = {}) {
|
|
71
|
+
const { canonical, plan } = planWithProgress(context, input, options);
|
|
72
72
|
return {
|
|
73
73
|
plan_id: plan.plan_id,
|
|
74
74
|
revision: canonical.revision,
|
package/dist/services/plans.js
CHANGED
|
@@ -7,11 +7,11 @@ import { resolveProjectRoot } from "../storage/paths.js";
|
|
|
7
7
|
import { planSummary, planWithProgress, updatePlanProgress } from "./plan-runtime.js";
|
|
8
8
|
export function getPlanStatus(context, input) {
|
|
9
9
|
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
10
|
-
const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path });
|
|
10
|
+
const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false });
|
|
11
11
|
return {
|
|
12
12
|
ok: true,
|
|
13
13
|
protocol_id: protocol.id,
|
|
14
|
-
plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }),
|
|
14
|
+
plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false }),
|
|
15
15
|
binding: { revision: current.canonical.revision, sha256: current.canonical.sha256, path: protocol.plan_path },
|
|
16
16
|
blocked_items: current.plan.items
|
|
17
17
|
.filter((item) => item.status === "blocked")
|
|
@@ -69,7 +69,9 @@ function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
|
|
|
69
69
|
}
|
|
70
70
|
function scopedProtocol(context, projectRoot, protocolId) {
|
|
71
71
|
const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
|
|
72
|
-
|
|
72
|
+
const protocol = requireProtocol(context, protocolId, project.id);
|
|
73
|
+
readProtocolRuntimeState(context, protocol);
|
|
74
|
+
return protocol;
|
|
73
75
|
}
|
|
74
76
|
function assertDependenciesClosed(plan, item) {
|
|
75
77
|
const openDependencies = item.depends_on
|
|
@@ -100,7 +100,7 @@ export function getProtocolStatus(context, input) {
|
|
|
100
100
|
diagnostics: [...runtime.diagnostics, ...runDiagnostics.diagnostics, ...lifecycle.diagnostics],
|
|
101
101
|
latest_run: runDiagnostics.latest_run,
|
|
102
102
|
state,
|
|
103
|
-
plan: plan ? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }) : state.plan,
|
|
103
|
+
plan: plan ? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false }) : state.plan,
|
|
104
104
|
merge_queue: queue,
|
|
105
105
|
branch_context: getProtocolBranchContext(context, { protocolId: protocol.id, projectRoot: project.root }).branch_context,
|
|
106
106
|
flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null }),
|
|
@@ -734,6 +734,7 @@ function releaseRelatedMergeLocks(context, protocol, reason) {
|
|
|
734
734
|
}
|
|
735
735
|
export function readProtocolRuntimeState(context, protocol) {
|
|
736
736
|
const diagnostics = [];
|
|
737
|
+
normalizeProtocolPlanPath(context, protocol, diagnostics);
|
|
737
738
|
try {
|
|
738
739
|
return { state: readStateFile(protocol.state_path, protocol.id), diagnostics };
|
|
739
740
|
}
|
|
@@ -764,7 +765,7 @@ export function readProtocolRuntimeState(context, protocol) {
|
|
|
764
765
|
}
|
|
765
766
|
const plan = canonicalPlanIfPresent(context, protocol);
|
|
766
767
|
const summary = plan
|
|
767
|
-
? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path })
|
|
768
|
+
? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false })
|
|
768
769
|
: { plan_id: null, total: 0, done: 0, blocked: 0 };
|
|
769
770
|
const flowContract = loadProjectFlowContract(protocol.project_root);
|
|
770
771
|
const state = {
|
|
@@ -782,6 +783,9 @@ export function readProtocolRuntimeState(context, protocol) {
|
|
|
782
783
|
flow_contract: flowContract,
|
|
783
784
|
updated_at: protocol.updated_at
|
|
784
785
|
};
|
|
786
|
+
if (!context.db.writable) {
|
|
787
|
+
return { state, diagnostics };
|
|
788
|
+
}
|
|
785
789
|
const stableStatePath = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
|
|
786
790
|
const stablePlanPath = planJsonPath(protocol.project_root, protocol.id);
|
|
787
791
|
ensureDir(path.dirname(stableStatePath));
|
|
@@ -798,6 +802,23 @@ export function readProtocolRuntimeState(context, protocol) {
|
|
|
798
802
|
});
|
|
799
803
|
return { state, diagnostics };
|
|
800
804
|
}
|
|
805
|
+
function normalizeProtocolPlanPath(context, protocol, diagnostics) {
|
|
806
|
+
const canonicalPlanPath = planJsonPath(protocol.project_root, protocol.id);
|
|
807
|
+
if (protocol.plan_path === canonicalPlanPath || !fs.existsSync(canonicalPlanPath))
|
|
808
|
+
return;
|
|
809
|
+
diagnostics.push({
|
|
810
|
+
code: "protocol_plan_relocated",
|
|
811
|
+
severity: "warning",
|
|
812
|
+
old_path: protocol.plan_path,
|
|
813
|
+
path: canonicalPlanPath,
|
|
814
|
+
source: "project_canonical_plan",
|
|
815
|
+
recommended_action: "use the project canonical plan as the sole semantic plan source"
|
|
816
|
+
});
|
|
817
|
+
if (context.db.writable) {
|
|
818
|
+
context.db.run("UPDATE protocols SET plan_path = ?, updated_at = ? WHERE project_id = ? AND id = ?", [canonicalPlanPath, context.now(), protocol.project_id, protocol.id]);
|
|
819
|
+
}
|
|
820
|
+
protocol.plan_path = canonicalPlanPath;
|
|
821
|
+
}
|
|
801
822
|
export function protocolRunDiagnostics(context, protocol, state) {
|
|
802
823
|
const runs = linkedRunsForProtocol(context, protocol, 5);
|
|
803
824
|
const latest = runs[0];
|