@deksden-com/dd-flow-cli 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +25 -9
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +45 -29
- package/dist/cli/run-cli.js +229 -49
- package/dist/domain/entity-ids.js +4 -4
- package/dist/domain/flow-contract.js +502 -36
- package/dist/domain/validation.js +34 -0
- package/dist/protocol/local-files.js +8 -6
- package/dist/schemas/code-stage-report.schema.json +197 -2
- package/dist/schemas/flow-contract.schema.json +126 -0
- package/dist/schemas/flow-run-index-v3.schema.json +203 -0
- package/dist/schemas/flow-run-index.schema.json +22 -2
- package/dist/schemas/flow-run.schema.json +36 -0
- package/dist/schemas/merge-stage-report.schema.json +213 -2
- package/dist/schemas/plan-stage-report.schema.json +156 -2
- package/dist/schemas/release-impact.schema.json +16 -0
- package/dist/services/audit.js +3 -3
- package/dist/services/branch-context.js +17 -5
- package/dist/services/canon.js +0 -1
- package/dist/services/cleanup.js +6 -6
- package/dist/services/cli-operation-classifier.js +1 -1
- package/dist/services/compatibility-preflight.js +3 -75
- package/dist/services/dashboard.js +48 -11
- package/dist/services/engines.js +124 -13
- package/dist/services/hooks.js +6 -6
- package/dist/services/ids.js +40 -49
- package/dist/services/merge-queue.js +33 -26
- package/dist/services/merge-worker.js +2 -1
- package/dist/services/migrations.js +64 -0
- package/dist/services/plans.js +23 -16
- package/dist/services/projects.js +2 -2
- package/dist/services/prompts.js +322 -0
- package/dist/services/protocols.js +77 -46
- package/dist/services/run-projection.js +80 -0
- package/dist/services/runs.js +360 -22
- package/dist/services/schema-validation.js +35 -12
- package/dist/services/sessions.js +81 -3
- package/dist/services/status.js +32 -1
- package/dist/services/usage.js +233 -0
- package/dist/services/worktrees.js +24 -19
- package/dist/storage/database.js +223 -9
- package/package.json +1 -1
package/dist/services/engines.js
CHANGED
|
@@ -2,13 +2,16 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
5
6
|
import { getCliBuildInfo } from "./build-info.js";
|
|
6
7
|
import { AppError } from "../shared/errors.js";
|
|
7
8
|
import { ensureDir, engineStoreRoot, engineVersionRoot, resolveProjectRoot } from "../storage/paths.js";
|
|
8
9
|
import { classifyCliOperation } from "./cli-operation-classifier.js";
|
|
10
|
+
import { requireProtocol } from "./protocols.js";
|
|
9
11
|
export const engineManifestSchemaId = "dd-flow/engine-manifest@1";
|
|
10
12
|
const routerNativeFamilies = new Set(["engine", "version", "schema"]);
|
|
11
13
|
const sourceFile = fileURLToPath(import.meta.url);
|
|
14
|
+
const packageRequire = createRequire(sourceFile);
|
|
12
15
|
export function isEngineMode(env) {
|
|
13
16
|
return env.DD_FLOW_ENGINE_MODE === "1";
|
|
14
17
|
}
|
|
@@ -115,11 +118,11 @@ export function doctorEngines(context, input = {}) {
|
|
|
115
118
|
exit_code: checks.every((check) => check.status === "ok") && selection.status !== "missing" ? 0 : 1
|
|
116
119
|
};
|
|
117
120
|
}
|
|
118
|
-
export function routeArgsThroughEngine(context, args, io, stdin, env) {
|
|
121
|
+
export function routeArgsThroughEngine(context, args, io, stdin, env, resolvedProjectRoot) {
|
|
119
122
|
if (isEngineMode(env) || isRouterNativeCommand(args))
|
|
120
123
|
return Promise.resolve(null);
|
|
121
124
|
installCurrentEngine(context);
|
|
122
|
-
const selection = selectEngine(context, { projectRoot:
|
|
125
|
+
const selection = selectEngine(context, { projectRoot: resolvedProjectRoot ?? resolveOperationProjectRoot(context, args) ?? undefined });
|
|
123
126
|
if (selection.status === "missing") {
|
|
124
127
|
const classification = classifyCliOperation(args, env);
|
|
125
128
|
if (classification.mode === "read_only_diagnostics" || classification.mode === "mb_upgrade") {
|
|
@@ -170,6 +173,34 @@ export function selectEngine(context, input) {
|
|
|
170
173
|
diagnostics: selected ? [] : [`No installed engine satisfies ${packageName} ${requiredRange}`]
|
|
171
174
|
};
|
|
172
175
|
}
|
|
176
|
+
export function resolveOperationProjectRoot(context, args) {
|
|
177
|
+
const [family, command, ...rest] = args;
|
|
178
|
+
const parsed = parseLightArgs(rest);
|
|
179
|
+
const allArgs = parseLightArgs(args);
|
|
180
|
+
const explicitRoot = option(allArgs, "project-root") ?? option(allArgs, "root");
|
|
181
|
+
if (explicitRoot)
|
|
182
|
+
return explicitRoot;
|
|
183
|
+
let protocolId = null;
|
|
184
|
+
if (family === "protocol" && ["status", "branch-status", "transition", "sync-from-run", "ready-for-merge", "cancel", "implement"].includes(command ?? "")) {
|
|
185
|
+
protocolId = positional(parsed, 0);
|
|
186
|
+
}
|
|
187
|
+
else if (family === "transition") {
|
|
188
|
+
protocolId = positional(parseLightArgs([command ?? "", ...rest]), 0);
|
|
189
|
+
}
|
|
190
|
+
else if (family === "plan" && ["set", "status"].includes(command ?? "")) {
|
|
191
|
+
protocolId = positional(parsed, 0);
|
|
192
|
+
}
|
|
193
|
+
else if (family === "plan" && command === "item") {
|
|
194
|
+
protocolId = positional(parsed, 1);
|
|
195
|
+
}
|
|
196
|
+
else if (family === "worktree") {
|
|
197
|
+
protocolId = option(parsed, "protocol-id");
|
|
198
|
+
}
|
|
199
|
+
else if (family === "merge-queue" && ["complete", "note", "fail", "cancel"].includes(command ?? "")) {
|
|
200
|
+
protocolId = positional(parsed, 0);
|
|
201
|
+
}
|
|
202
|
+
return protocolId ? requireProtocol(context, protocolId).project_root : null;
|
|
203
|
+
}
|
|
173
204
|
function readCompatibilityForProject(projectRoot) {
|
|
174
205
|
let resolvedProjectRoot = null;
|
|
175
206
|
if (projectRoot) {
|
|
@@ -191,17 +222,38 @@ function readCompatibilityForProject(projectRoot) {
|
|
|
191
222
|
}
|
|
192
223
|
return null;
|
|
193
224
|
}
|
|
194
|
-
function
|
|
225
|
+
function parseLightArgs(args) {
|
|
226
|
+
const positional = [];
|
|
227
|
+
const options = new Map();
|
|
195
228
|
for (let index = 0; index < args.length; index += 1) {
|
|
196
|
-
const
|
|
197
|
-
if ((
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
229
|
+
const value = args[index];
|
|
230
|
+
if (value?.startsWith("--")) {
|
|
231
|
+
const equal = value.indexOf("=");
|
|
232
|
+
if (equal > 2) {
|
|
233
|
+
const key = value.slice(2, equal);
|
|
234
|
+
options.set(key, [...(options.get(key) ?? []), value.slice(equal + 1)]);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const key = value.slice(2);
|
|
238
|
+
const next = args[index + 1];
|
|
239
|
+
if (!next || next.startsWith("--"))
|
|
240
|
+
options.set(key, [...(options.get(key) ?? []), ""]);
|
|
241
|
+
else {
|
|
242
|
+
options.set(key, [...(options.get(key) ?? []), next]);
|
|
243
|
+
index += 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else if (value)
|
|
247
|
+
positional.push(value);
|
|
203
248
|
}
|
|
204
|
-
return
|
|
249
|
+
return { positional, options };
|
|
250
|
+
}
|
|
251
|
+
function option(parsed, key) {
|
|
252
|
+
const values = parsed.options.get(key);
|
|
253
|
+
return values?.[values.length - 1] || null;
|
|
254
|
+
}
|
|
255
|
+
function positional(parsed, index) {
|
|
256
|
+
return parsed.positional[index] ?? null;
|
|
205
257
|
}
|
|
206
258
|
function buildManifest(context, packageName, version, packageRoot, snapshotRoot, checksumRoot) {
|
|
207
259
|
return {
|
|
@@ -242,6 +294,63 @@ function copyPackageSnapshot(packageRoot, target) {
|
|
|
242
294
|
fs.copyFileSync(from, to);
|
|
243
295
|
}
|
|
244
296
|
}
|
|
297
|
+
copyProductionDependencies(packageRoot, target);
|
|
298
|
+
}
|
|
299
|
+
function copyProductionDependencies(packageRoot, target) {
|
|
300
|
+
const queue = [{ sourceRoot: packageRoot, targetRoot: target, ancestors: new Set() }];
|
|
301
|
+
const copied = new Set();
|
|
302
|
+
while (queue.length > 0) {
|
|
303
|
+
const current = queue.shift();
|
|
304
|
+
if (!current)
|
|
305
|
+
continue;
|
|
306
|
+
for (const dependency of readProductionDependencies(current.sourceRoot)) {
|
|
307
|
+
const sourceRoot = resolvePackageRoot(dependency, current.sourceRoot);
|
|
308
|
+
if (!sourceRoot)
|
|
309
|
+
throw new AppError("engine_install_failed", `Cannot resolve runtime dependency: ${dependency}`, 1);
|
|
310
|
+
const targetRoot = path.join(current.targetRoot, "node_modules", dependency);
|
|
311
|
+
const copyKey = `${sourceRoot}\n${targetRoot}`;
|
|
312
|
+
if (copied.has(copyKey))
|
|
313
|
+
continue;
|
|
314
|
+
copied.add(copyKey);
|
|
315
|
+
copyDir(sourceRoot, targetRoot, true);
|
|
316
|
+
if (current.ancestors.has(sourceRoot))
|
|
317
|
+
continue;
|
|
318
|
+
queue.push({ sourceRoot, targetRoot, ancestors: new Set([...current.ancestors, sourceRoot]) });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function readProductionDependencies(packageRoot) {
|
|
323
|
+
try {
|
|
324
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
325
|
+
const dependencies = new Set();
|
|
326
|
+
for (const field of ["dependencies", "optionalDependencies"]) {
|
|
327
|
+
const value = manifest[field];
|
|
328
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
329
|
+
continue;
|
|
330
|
+
for (const name of Object.keys(value))
|
|
331
|
+
dependencies.add(name);
|
|
332
|
+
}
|
|
333
|
+
return [...dependencies];
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return [];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function resolvePackageRoot(name, from) {
|
|
340
|
+
try {
|
|
341
|
+
let current = path.dirname(packageRequire.resolve(name, { paths: [from] }));
|
|
342
|
+
while (true) {
|
|
343
|
+
if (fs.existsSync(path.join(current, "package.json")))
|
|
344
|
+
return fs.realpathSync(current);
|
|
345
|
+
const parent = path.dirname(current);
|
|
346
|
+
if (parent === current)
|
|
347
|
+
return null;
|
|
348
|
+
current = parent;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
245
354
|
}
|
|
246
355
|
function inferInstallSource(packageRoot, ddFlowHome) {
|
|
247
356
|
const normalized = path.resolve(packageRoot);
|
|
@@ -265,13 +374,15 @@ function hasAncestorFile(start, name) {
|
|
|
265
374
|
current = parent;
|
|
266
375
|
}
|
|
267
376
|
}
|
|
268
|
-
function copyDir(from, to) {
|
|
377
|
+
function copyDir(from, to, skipNodeModules = false) {
|
|
269
378
|
ensureDir(to);
|
|
270
379
|
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
380
|
+
if (skipNodeModules && entry.name === "node_modules")
|
|
381
|
+
continue;
|
|
271
382
|
const source = path.join(from, entry.name);
|
|
272
383
|
const target = path.join(to, entry.name);
|
|
273
384
|
if (entry.isDirectory())
|
|
274
|
-
copyDir(source, target);
|
|
385
|
+
copyDir(source, target, skipNodeModules);
|
|
275
386
|
else if (entry.isFile())
|
|
276
387
|
fs.copyFileSync(source, target);
|
|
277
388
|
}
|
package/dist/services/hooks.js
CHANGED
|
@@ -695,7 +695,7 @@ function stopDecision(context, project, sessionId) {
|
|
|
695
695
|
if (!session || ["waiting_user", "blocked", "stopping", "stopped", "closed"].includes(session.status)) {
|
|
696
696
|
return {};
|
|
697
697
|
}
|
|
698
|
-
if (session.protocol_id && protocolAllowsStop(context, session.protocol_id)) {
|
|
698
|
+
if (session.protocol_id && protocolAllowsStop(context, project.id, session.protocol_id)) {
|
|
699
699
|
return {};
|
|
700
700
|
}
|
|
701
701
|
if (session.flow_kind === "merge_worker" && session.continuation_policy === "merge_queue") {
|
|
@@ -704,17 +704,17 @@ function stopDecision(context, project, sessionId) {
|
|
|
704
704
|
if (session.flow_kind === "merge_job" && session.continuation_policy === "merge_job") {
|
|
705
705
|
return stopContinuationDecision(context, project.id, sessionId, `merge-job:${session.protocol_id ?? session.session_id}:${session.next_action ?? "none"}`, `Continue dd-flow merge job ${session.protocol_id ?? ""}: complete merge work, close the protocol, and report terminal state.`);
|
|
706
706
|
}
|
|
707
|
-
const nextAction = session.next_action ?? nextActionFromProtocol(context, session.protocol_id);
|
|
707
|
+
const nextAction = session.next_action ?? nextActionFromProtocol(context, project.id, session.protocol_id);
|
|
708
708
|
if (!nextAction || nextAction === "none" || session.continuation_policy === "none") {
|
|
709
709
|
return {};
|
|
710
710
|
}
|
|
711
711
|
return stopContinuationDecision(context, project.id, sessionId, `${session.flow_kind}:${session.protocol_id ?? session.session_id}:${nextAction}`, `Continue dd-flow ${session.flow_kind}${session.protocol_id ? ` ${session.protocol_id}` : ""}: ${nextAction}`);
|
|
712
712
|
}
|
|
713
|
-
function nextActionFromProtocol(context, protocolId) {
|
|
713
|
+
function nextActionFromProtocol(context, projectId, protocolId) {
|
|
714
714
|
if (!protocolId) {
|
|
715
715
|
return null;
|
|
716
716
|
}
|
|
717
|
-
const protocol = context.db.get("SELECT id, status, stage, next_action FROM protocols WHERE id = ?", [protocolId]);
|
|
717
|
+
const protocol = context.db.get("SELECT id, status, stage, next_action FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
|
|
718
718
|
if (!protocol) {
|
|
719
719
|
return null;
|
|
720
720
|
}
|
|
@@ -723,8 +723,8 @@ function nextActionFromProtocol(context, protocolId) {
|
|
|
723
723
|
}
|
|
724
724
|
return protocol.next_action;
|
|
725
725
|
}
|
|
726
|
-
function protocolAllowsStop(context, protocolId) {
|
|
727
|
-
const protocol = context.db.get("SELECT status, stage FROM protocols WHERE id = ?", [protocolId]);
|
|
726
|
+
function protocolAllowsStop(context, projectId, protocolId) {
|
|
727
|
+
const protocol = context.db.get("SELECT status, stage FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
|
|
728
728
|
return Boolean(protocol && (["closed", "cancelled"].includes(protocol.status) || ["closed", "cancelled"].includes(protocol.stage)));
|
|
729
729
|
}
|
|
730
730
|
function stopContinuationDecision(context, projectId, sessionId, actionKey, reason, options = {}) {
|
package/dist/services/ids.js
CHANGED
|
@@ -2,10 +2,10 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { formatFullId, parseFullEntityId } from "../domain/entity-ids.js";
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
|
-
import {
|
|
5
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
6
6
|
const kindConfig = {
|
|
7
7
|
protocol: { type: "PRT", table: "protocols", fileRoot: ".memory-bank/protocol" },
|
|
8
|
-
run: { type: "RUN", table: "flow_runs"
|
|
8
|
+
run: { type: "RUN", table: "flow_runs" }
|
|
9
9
|
};
|
|
10
10
|
export function previewNextEntityId(context, input) {
|
|
11
11
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
@@ -13,51 +13,31 @@ export function previewNextEntityId(context, input) {
|
|
|
13
13
|
const slug = normalizeSlug(input.slug);
|
|
14
14
|
const config = kindConfig[kind];
|
|
15
15
|
const used = new Set();
|
|
16
|
-
|
|
16
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
|
|
17
|
+
for (const id of databaseIds(context, config.table, config.type, project?.id)) {
|
|
17
18
|
addSequence(used, id, config.type);
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
if (kind === "run") {
|
|
23
|
-
for (const id of homeRunIds(context, projectRoot)) {
|
|
20
|
+
if (config.fileRoot) {
|
|
21
|
+
for (const id of filesystemIds(projectRoot, config.fileRoot, config.type)) {
|
|
24
22
|
addSequence(used, id, config.type);
|
|
25
23
|
}
|
|
26
|
-
for (const id of filesystemIds(projectRoot, ".tasks", config.type)) {
|
|
27
|
-
addSequence(used, id, config.type);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
for (let sequence = 1; sequence <= 999; sequence += 1) {
|
|
31
|
-
if (used.has(sequence))
|
|
32
|
-
continue;
|
|
33
|
-
const id = formatFullId(config.type, sequence, slug);
|
|
34
|
-
const parsed = parseFullEntityId(id);
|
|
35
|
-
return {
|
|
36
|
-
ok: true,
|
|
37
|
-
project_root: projectRoot,
|
|
38
|
-
entity: {
|
|
39
|
-
kind,
|
|
40
|
-
type: config.type,
|
|
41
|
-
id,
|
|
42
|
-
short_id: parsed.shortId,
|
|
43
|
-
slug,
|
|
44
|
-
sequence,
|
|
45
|
-
reserved: false
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
throw new AppError("validation", `${config.type} id sequence exhausted`, 2);
|
|
50
|
-
}
|
|
51
|
-
function homeRunIds(context, projectRoot) {
|
|
52
|
-
const project = context.db.get("SELECT id FROM projects WHERE root = ? AND status = 'active'", [projectRoot]);
|
|
53
|
-
if (!project) {
|
|
54
|
-
return [];
|
|
55
24
|
}
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
25
|
+
const sequence = Math.max(0, ...used) + 1;
|
|
26
|
+
const id = formatFullId(config.type, sequence, slug);
|
|
27
|
+
const parsed = parseFullEntityId(id);
|
|
28
|
+
return {
|
|
29
|
+
ok: true,
|
|
30
|
+
project_root: projectRoot,
|
|
31
|
+
entity: {
|
|
32
|
+
kind,
|
|
33
|
+
type: config.type,
|
|
34
|
+
id,
|
|
35
|
+
short_id: parsed.shortId,
|
|
36
|
+
slug,
|
|
37
|
+
sequence,
|
|
38
|
+
reserved: false
|
|
39
|
+
}
|
|
40
|
+
};
|
|
61
41
|
}
|
|
62
42
|
function parseEntityKind(value) {
|
|
63
43
|
const normalized = value.toLowerCase();
|
|
@@ -67,20 +47,28 @@ function parseEntityKind(value) {
|
|
|
67
47
|
return "run";
|
|
68
48
|
throw new AppError("validation", "--type must be protocol or run", 2, { type: value });
|
|
69
49
|
}
|
|
70
|
-
function databaseIds(context, table, type) {
|
|
71
|
-
|
|
50
|
+
function databaseIds(context, table, type, projectId) {
|
|
51
|
+
if (!projectId)
|
|
52
|
+
return [];
|
|
53
|
+
return context.db
|
|
54
|
+
.all(`SELECT id FROM ${table} WHERE project_id = ? AND id LIKE ?`, [projectId, `${type}-%`])
|
|
55
|
+
.map((row) => row.id);
|
|
72
56
|
}
|
|
73
57
|
function filesystemIds(projectRoot, relativeRoot, type) {
|
|
74
58
|
const root = path.join(projectRoot, relativeRoot);
|
|
75
|
-
|
|
59
|
+
try {
|
|
60
|
+
return fs
|
|
61
|
+
.readdirSync(root)
|
|
62
|
+
.filter((entry) => entry.startsWith(`${type}-`))
|
|
63
|
+
.map((entry) => entry.replace(/\.md$/, ""));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
76
66
|
return [];
|
|
77
67
|
}
|
|
78
|
-
return fs
|
|
79
|
-
.readdirSync(root)
|
|
80
|
-
.filter((entry) => entry.startsWith(`${type}-`))
|
|
81
|
-
.map((entry) => entry.replace(/\.md$/, ""));
|
|
82
68
|
}
|
|
83
69
|
function addSequence(used, id, type) {
|
|
70
|
+
if (type === "PRT" && isLegacyDateProtocolId(id))
|
|
71
|
+
return;
|
|
84
72
|
try {
|
|
85
73
|
const parsed = parseFullEntityId(id);
|
|
86
74
|
if (parsed.type !== type)
|
|
@@ -91,6 +79,9 @@ function addSequence(used, id, type) {
|
|
|
91
79
|
return;
|
|
92
80
|
}
|
|
93
81
|
}
|
|
82
|
+
function isLegacyDateProtocolId(id) {
|
|
83
|
+
return /^PRT-\d{4}-\d{2}-\d{2}(?:-|$)/.test(id);
|
|
84
|
+
}
|
|
94
85
|
function normalizeSlug(value) {
|
|
95
86
|
const slug = value
|
|
96
87
|
.normalize("NFKD")
|
|
@@ -59,7 +59,7 @@ export function claimNextMergeJob(context, input) {
|
|
|
59
59
|
eventType: "merge_queue.claimed",
|
|
60
60
|
payload: { protocol_id: job.protocol_id, worker_id: input.workerId }
|
|
61
61
|
});
|
|
62
|
-
transitionClaimedProtocolToIntegration(context, job.protocol_id, input.workerId, now);
|
|
62
|
+
transitionClaimedProtocolToIntegration(context, project.id, job.protocol_id, input.workerId, now);
|
|
63
63
|
claimedProtocolId = job.protocol_id;
|
|
64
64
|
context.db.exec("COMMIT");
|
|
65
65
|
}
|
|
@@ -67,7 +67,7 @@ export function claimNextMergeJob(context, input) {
|
|
|
67
67
|
context.db.exec("ROLLBACK");
|
|
68
68
|
throw error;
|
|
69
69
|
}
|
|
70
|
-
return mergeQueueResult(claimedProtocolId ? queueJobByProtocol(context, claimedProtocolId) : null, {
|
|
70
|
+
return mergeQueueResult(claimedProtocolId ? queueJobByProtocol(context, project.id, claimedProtocolId) : null, {
|
|
71
71
|
ok: true,
|
|
72
72
|
outcome: claimedProtocolId ? "claimed" : "empty",
|
|
73
73
|
claimed: Boolean(claimedProtocolId)
|
|
@@ -105,7 +105,7 @@ export function claimMergeBundle(context, input) {
|
|
|
105
105
|
protocol_ids: protocolIds
|
|
106
106
|
});
|
|
107
107
|
}
|
|
108
|
-
transitionClaimedProtocolToIntegration(context, protocolId, input.workerId, now);
|
|
108
|
+
transitionClaimedProtocolToIntegration(context, project.id, protocolId, input.workerId, now);
|
|
109
109
|
appendAudit(context, {
|
|
110
110
|
protocolId,
|
|
111
111
|
projectId: project.id,
|
|
@@ -278,8 +278,9 @@ export async function waitNextMergeJob(context, input) {
|
|
|
278
278
|
}
|
|
279
279
|
}
|
|
280
280
|
export function completeMergeJob(context, input) {
|
|
281
|
-
const
|
|
282
|
-
const
|
|
281
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
282
|
+
const protocol = requireProtocol(context, input.protocolId, project.id);
|
|
283
|
+
const job = requireClaimedJob(context, protocol.project_id, input.protocolId, input.workerId);
|
|
283
284
|
requireLaneLockOwner(context, {
|
|
284
285
|
projectRoot: protocol.project_root,
|
|
285
286
|
lane: "merge",
|
|
@@ -308,7 +309,7 @@ export function completeMergeJob(context, input) {
|
|
|
308
309
|
payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary, flow_contract_id: flowContract.id }
|
|
309
310
|
});
|
|
310
311
|
const stop_after_current = stopAfterCurrentIfRequested(context, protocol.project_id, protocol.project_root, input.workerId, "merge complete after stop request");
|
|
311
|
-
return mergeQueueResult(queueJobByProtocol(context, protocol.id), {
|
|
312
|
+
return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), {
|
|
312
313
|
ok: true,
|
|
313
314
|
outcome: "merged",
|
|
314
315
|
merged: true,
|
|
@@ -335,8 +336,8 @@ export function completeMergeBundle(context, input) {
|
|
|
335
336
|
context.db.exec("BEGIN IMMEDIATE");
|
|
336
337
|
try {
|
|
337
338
|
for (const protocolId of protocolIds) {
|
|
338
|
-
const protocol = requireProtocol(context, protocolId);
|
|
339
|
-
const job = requireClaimedJob(context, protocolId, input.workerId);
|
|
339
|
+
const protocol = requireProtocol(context, protocolId, project.id);
|
|
340
|
+
const job = requireClaimedJob(context, project.id, protocolId, input.workerId);
|
|
340
341
|
context.db.run(`UPDATE merge_queue
|
|
341
342
|
SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
|
|
342
343
|
WHERE id = ?`, [input.summary, now, now, job.id]);
|
|
@@ -382,8 +383,9 @@ export function completeMergeBundle(context, input) {
|
|
|
382
383
|
});
|
|
383
384
|
}
|
|
384
385
|
export function noteMergeJob(context, input) {
|
|
385
|
-
const
|
|
386
|
-
const
|
|
386
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
387
|
+
const protocol = requireProtocol(context, input.protocolId, project.id);
|
|
388
|
+
const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
|
|
387
389
|
if (!job) {
|
|
388
390
|
throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
|
|
389
391
|
}
|
|
@@ -409,11 +411,12 @@ export function noteMergeJob(context, input) {
|
|
|
409
411
|
eventType: "merge_queue.note",
|
|
410
412
|
payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary }
|
|
411
413
|
});
|
|
412
|
-
return mergeQueueResult(queueJobByProtocol(context, protocol.id), { ok: true, outcome: "noted" });
|
|
414
|
+
return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), { ok: true, outcome: "noted" });
|
|
413
415
|
}
|
|
414
416
|
export function failMergeJob(context, input) {
|
|
415
|
-
const
|
|
416
|
-
const
|
|
417
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
418
|
+
const protocol = requireProtocol(context, input.protocolId, project.id);
|
|
419
|
+
const job = requireClaimedJob(context, protocol.project_id, input.protocolId, input.workerId);
|
|
417
420
|
requireLaneLockOwner(context, {
|
|
418
421
|
projectRoot: protocol.project_root,
|
|
419
422
|
lane: "merge",
|
|
@@ -443,7 +446,7 @@ export function failMergeJob(context, input) {
|
|
|
443
446
|
payload: { protocol_id: protocol.id, worker_id: input.workerId, requeue: input.requeue }
|
|
444
447
|
});
|
|
445
448
|
const stop_after_current = stopAfterCurrentIfRequested(context, protocol.project_id, protocol.project_root, input.workerId, "merge fail after stop request");
|
|
446
|
-
return mergeQueueResult(queueJobByProtocol(context, protocol.id), {
|
|
449
|
+
return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), {
|
|
447
450
|
ok: true,
|
|
448
451
|
outcome: input.requeue ? "requeued" : "failed",
|
|
449
452
|
stop_after_current
|
|
@@ -469,8 +472,8 @@ export function failMergeBundle(context, input) {
|
|
|
469
472
|
context.db.exec("BEGIN IMMEDIATE");
|
|
470
473
|
try {
|
|
471
474
|
for (const protocolId of protocolIds) {
|
|
472
|
-
const protocol = requireProtocol(context, protocolId);
|
|
473
|
-
const job = requireClaimedJob(context, protocolId, input.workerId);
|
|
475
|
+
const protocol = requireProtocol(context, protocolId, project.id);
|
|
476
|
+
const job = requireClaimedJob(context, project.id, protocolId, input.workerId);
|
|
474
477
|
context.db.run(`UPDATE merge_queue
|
|
475
478
|
SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
|
|
476
479
|
WHERE id = ?`, [status, input.reason, now, job.id]);
|
|
@@ -516,7 +519,8 @@ export function failMergeBundle(context, input) {
|
|
|
516
519
|
});
|
|
517
520
|
}
|
|
518
521
|
export function cancelMergeQueueJob(context, input) {
|
|
519
|
-
const
|
|
522
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
523
|
+
const protocol = requireProtocol(context, input.protocolId, project.id);
|
|
520
524
|
const reason = input.reason.trim();
|
|
521
525
|
if (!reason) {
|
|
522
526
|
throw new AppError("validation", "merge-queue cancel requires --reason", 2);
|
|
@@ -524,7 +528,7 @@ export function cancelMergeQueueJob(context, input) {
|
|
|
524
528
|
const now = context.now();
|
|
525
529
|
context.db.exec("BEGIN IMMEDIATE");
|
|
526
530
|
try {
|
|
527
|
-
const job = queueJobByProtocol(context, protocol.id);
|
|
531
|
+
const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
|
|
528
532
|
if (!job) {
|
|
529
533
|
throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
|
|
530
534
|
}
|
|
@@ -572,13 +576,13 @@ export function cancelMergeQueueJob(context, input) {
|
|
|
572
576
|
context.db.exec("ROLLBACK");
|
|
573
577
|
throw error;
|
|
574
578
|
}
|
|
575
|
-
return mergeQueueResult(queueJobByProtocol(context, protocol.id), { ok: true, outcome: "cancelled", cancelled: true });
|
|
579
|
+
return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), { ok: true, outcome: "cancelled", cancelled: true });
|
|
576
580
|
}
|
|
577
581
|
export function queueForProject(context, projectId) {
|
|
578
582
|
return context.db.all(`SELECT * FROM merge_queue WHERE project_id = ? ORDER BY created_at ASC, id ASC`, [projectId]);
|
|
579
583
|
}
|
|
580
|
-
function queueJobByProtocol(context, protocolId) {
|
|
581
|
-
return context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocolId]);
|
|
584
|
+
function queueJobByProtocol(context, projectId, protocolId) {
|
|
585
|
+
return context.db.get("SELECT * FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
|
|
582
586
|
}
|
|
583
587
|
function mergeQueueResult(job, extra) {
|
|
584
588
|
const queueItem = job ?? null;
|
|
@@ -609,7 +613,10 @@ function mergeBundleResult(context, input) {
|
|
|
609
613
|
...input,
|
|
610
614
|
protocol_ids: input.protocolIds,
|
|
611
615
|
branch_context: branchContext,
|
|
612
|
-
queue_items: input.protocolIds.map((protocolId) =>
|
|
616
|
+
queue_items: input.protocolIds.map((protocolId) => {
|
|
617
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
618
|
+
return queueJobByProtocol(context, project.id, protocolId) ?? null;
|
|
619
|
+
})
|
|
613
620
|
};
|
|
614
621
|
}
|
|
615
622
|
function claimedBundleProtocolIds(context, input) {
|
|
@@ -620,8 +627,8 @@ function claimedBundleProtocolIds(context, input) {
|
|
|
620
627
|
.map((protocol) => String(protocol.id))
|
|
621
628
|
.filter(Boolean);
|
|
622
629
|
}
|
|
623
|
-
function requireClaimedJob(context, protocolId, sessionId) {
|
|
624
|
-
const job = queueJobByProtocol(context, protocolId);
|
|
630
|
+
function requireClaimedJob(context, projectId, protocolId, sessionId) {
|
|
631
|
+
const job = queueJobByProtocol(context, projectId, protocolId);
|
|
625
632
|
if (!job || job.status !== "claimed") {
|
|
626
633
|
throw new AppError("merge_job_not_claimed", "Merge job is not claimed", 1, { protocol_id: protocolId });
|
|
627
634
|
}
|
|
@@ -633,8 +640,8 @@ function requireClaimedJob(context, protocolId, sessionId) {
|
|
|
633
640
|
}
|
|
634
641
|
return job;
|
|
635
642
|
}
|
|
636
|
-
function transitionClaimedProtocolToIntegration(context, protocolId, workerId, now) {
|
|
637
|
-
const protocol = requireProtocol(context, protocolId);
|
|
643
|
+
function transitionClaimedProtocolToIntegration(context, projectId, protocolId, workerId, now) {
|
|
644
|
+
const protocol = requireProtocol(context, protocolId, projectId);
|
|
638
645
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
639
646
|
const flowContract = loadProjectFlowContract(protocol.project_root);
|
|
640
647
|
if (!["ready_for_merge", "queued_for_merge"].includes(state.stage)) {
|
|
@@ -158,7 +158,8 @@ function withJobGuidance(context, job) {
|
|
|
158
158
|
if (!protocolId)
|
|
159
159
|
return job;
|
|
160
160
|
try {
|
|
161
|
-
const
|
|
161
|
+
const projectId = typeof job.project_id === "string" ? job.project_id : undefined;
|
|
162
|
+
const protocol = requireProtocol(context, protocolId, projectId);
|
|
162
163
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
163
164
|
return {
|
|
164
165
|
...job,
|
|
@@ -3,6 +3,70 @@ import path from "node:path";
|
|
|
3
3
|
import { AppError } from "../shared/errors.js";
|
|
4
4
|
import { requireProjectByRoot } from "./projects.js";
|
|
5
5
|
import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
|
|
6
|
+
import { resolveCanonRoot } from "./canon.js";
|
|
7
|
+
export function assessMigrationImpact(context, input) {
|
|
8
|
+
const root = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
|
|
9
|
+
if (!root.root)
|
|
10
|
+
throw new AppError("not_found", `Project root does not exist: ${input.projectRoot}`, 1);
|
|
11
|
+
const canon = resolveCanonRoot(context);
|
|
12
|
+
if (!canon.ok || !canon.canon)
|
|
13
|
+
throw new AppError("canon_unavailable", "Cannot resolve canonical Memory Bank for upgrade impact assessment", 1, { blockers: canon.blockers, bootstrap: canon.bootstrap });
|
|
14
|
+
const status = getProjectVersionStatus({ projectRoot: root.root, rootSource: root.root_source, canon: { root: canon.canon.root, metadata: canon.canon.metadata } });
|
|
15
|
+
const from = status?.memory_bank.version ?? null;
|
|
16
|
+
const to = input.targetVersion ?? canon.canon.version;
|
|
17
|
+
const forced = input.mode === "full_migration";
|
|
18
|
+
const impacts = readImpacts(path.join(canon.canon.memorybank_root, "release-impact"));
|
|
19
|
+
const chain = [];
|
|
20
|
+
const domains = new Set();
|
|
21
|
+
const blockers = [];
|
|
22
|
+
let mode = "patch";
|
|
23
|
+
if (!from || !to)
|
|
24
|
+
blockers.push("project or canonical Memory Bank version is unavailable");
|
|
25
|
+
let current = from;
|
|
26
|
+
while (current && to && current !== to) {
|
|
27
|
+
const impact = impacts.find((item) => item.from_version === current);
|
|
28
|
+
if (!impact) {
|
|
29
|
+
blockers.push(`missing adjacent release impact from ${current}`);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
chain.push(impact);
|
|
33
|
+
for (const domain of impact.domains)
|
|
34
|
+
domains.add(domain);
|
|
35
|
+
mode = stricterMode(mode, impact.minimum_upgrade_mode);
|
|
36
|
+
if (impact.path_migration || impact.runtime_migration)
|
|
37
|
+
mode = "full_migration";
|
|
38
|
+
current = impact.to_version;
|
|
39
|
+
}
|
|
40
|
+
if (current && to && current !== to)
|
|
41
|
+
blockers.push(`release impact chain does not reach ${to}`);
|
|
42
|
+
if (status?.flow_pack.status !== "present" && from !== to)
|
|
43
|
+
blockers.push("project flow-pack provenance is not present");
|
|
44
|
+
if (blockers.length > 0)
|
|
45
|
+
mode = "full_migration";
|
|
46
|
+
if (forced)
|
|
47
|
+
mode = "full_migration";
|
|
48
|
+
return { ok: true, schema_id: "dd-flow/mb-upgrade-impact-assessment@1", project_root: root.root, source_version: from, target_version: to, mode, forced_full_migration: forced, chain, domains: [...domains].sort(), blockers, required_checks: [...new Set(chain.flatMap((item) => item.required_checks))], next_action: mode === "full_migration" ? "run full mb-upgrade contour" : "run selected mb-upgrade contour" };
|
|
49
|
+
}
|
|
50
|
+
function readImpacts(dir) {
|
|
51
|
+
if (!fs.existsSync(dir))
|
|
52
|
+
return [];
|
|
53
|
+
return fs.readdirSync(dir).filter((file) => file.endsWith(".json")).flatMap((file) => {
|
|
54
|
+
try {
|
|
55
|
+
const value = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
|
56
|
+
if (value.schema_id !== "dd-flow/release-impact@1")
|
|
57
|
+
return [];
|
|
58
|
+
if (typeof value.from_version !== "string" || typeof value.to_version !== "string" || !Array.isArray(value.domains) || !Array.isArray(value.required_checks))
|
|
59
|
+
return [];
|
|
60
|
+
if (!["patch", "targeted_minor", "full_migration"].includes(String(value.minimum_upgrade_mode)))
|
|
61
|
+
return [];
|
|
62
|
+
return [{ from_version: value.from_version, to_version: value.to_version, minimum_upgrade_mode: value.minimum_upgrade_mode, domains: value.domains.filter((item) => typeof item === "string"), path_migration: value.path_migration === true, runtime_migration: value.runtime_migration === true, required_checks: value.required_checks.filter((item) => typeof item === "string") }];
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function stricterMode(left, right) { return ["patch", "targeted_minor", "full_migration"][Math.max(["patch", "targeted_minor", "full_migration"].indexOf(left), ["patch", "targeted_minor", "full_migration"].indexOf(right))]; }
|
|
6
70
|
export function planMigration(context, input) {
|
|
7
71
|
const rootResolution = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
|
|
8
72
|
if (!rootResolution.root) {
|