@deksden-com/dd-flow-cli 0.3.0 → 0.4.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 +55 -0
- package/README.md +74 -5
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +116 -18
- package/dist/cli/run-cli.js +361 -29
- package/dist/schemas/compatibility.schema.json +81 -2
- package/dist/schemas/engine-manifest.schema.json +61 -0
- package/dist/schemas/flow-guidance.schema.json +17 -0
- package/dist/schemas/global-dashboard-data.schema.json +60 -2
- package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
- package/dist/schemas/project-dashboard-data.schema.json +26 -2
- package/dist/schemas/project-summary.schema.json +73 -0
- package/dist/schemas/protocol-dashboard-data.schema.json +25 -2
- package/dist/schemas/status-report.schema.json +4 -2
- package/dist/services/branch-context.js +254 -0
- package/dist/services/cleanup.js +31 -0
- package/dist/services/cli-operation-classifier.js +104 -0
- package/dist/services/compatibility-preflight.js +127 -0
- package/dist/services/config.js +6 -0
- package/dist/services/dashboard-targets.js +95 -0
- package/dist/services/dashboard.js +376 -59
- package/dist/services/engines.js +532 -0
- package/dist/services/flow-guidance.js +8 -1
- package/dist/services/hooks.js +1 -1
- package/dist/services/lanes.js +333 -1
- package/dist/services/merge-queue.js +310 -15
- package/dist/services/merge-worker.js +44 -3
- package/dist/services/migrations.js +231 -0
- package/dist/services/project-summary.js +122 -0
- package/dist/services/projects.js +41 -6
- package/dist/services/protocol-lifecycle.js +144 -0
- package/dist/services/protocols.js +34 -7
- package/dist/services/sessions.js +21 -4
- package/dist/services/status.js +10 -0
- package/dist/services/version-status.js +39 -15
- package/dist/storage/database.js +25 -0
- package/dist/storage/paths.js +12 -0
- package/package.json +3 -2
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
6
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
7
|
+
import { normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
|
|
8
|
+
export function getProtocolBranchContext(context, input) {
|
|
9
|
+
const resolved = resolveBranchContextInput(context, input);
|
|
10
|
+
const rows = context.db.all(`SELECT id, project_id, project_root, status, stage, next_action, workspace_json, route_json, active_def_json, updated_at
|
|
11
|
+
FROM protocols
|
|
12
|
+
WHERE project_id = ?
|
|
13
|
+
ORDER BY updated_at ASC, id ASC`, [resolved.project.id]);
|
|
14
|
+
const queues = new Map(context.db
|
|
15
|
+
.all(`SELECT protocol_id, status, claimed_by_session_id, claimed_at, attempts_count, updated_at
|
|
16
|
+
FROM merge_queue
|
|
17
|
+
WHERE project_id = ?`, [resolved.project.id])
|
|
18
|
+
.map((row) => [row.protocol_id, row]));
|
|
19
|
+
const worktrees = new Map(context.db
|
|
20
|
+
.all(`SELECT protocol_id, integration_branch, feature_branch, base_ref, worktree_path, status
|
|
21
|
+
FROM worktree_records
|
|
22
|
+
WHERE project_id = ?`, [resolved.project.id])
|
|
23
|
+
.map((row) => [row.protocol_id, row]));
|
|
24
|
+
const sessions = context.db.all(`SELECT protocol_id, worker_id, flow_kind, status, current_stage, next_action
|
|
25
|
+
FROM flow_sessions
|
|
26
|
+
WHERE project_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [resolved.project.id]);
|
|
27
|
+
const sessionsByProtocol = new Map();
|
|
28
|
+
for (const session of sessions) {
|
|
29
|
+
if (!session.protocol_id)
|
|
30
|
+
continue;
|
|
31
|
+
sessionsByProtocol.set(session.protocol_id, [...(sessionsByProtocol.get(session.protocol_id) ?? []), session]);
|
|
32
|
+
}
|
|
33
|
+
const candidates = rows.map((row) => summarizeProtocol(row, queues.get(row.id), worktrees.get(row.id), sessionsByProtocol.get(row.id) ?? []));
|
|
34
|
+
const protocols = candidates.filter((candidate) => protocolMatchesBranch(candidate, resolved));
|
|
35
|
+
const eligible = protocols.filter((item) => item.bundle_eligible).map((item) => item.id);
|
|
36
|
+
const notReady = protocols.filter((item) => !item.bundle_eligible && !terminalStage(item.stage) && item.queue_status !== "claimed").map((item) => item.id);
|
|
37
|
+
const claimed = protocols.filter((item) => item.queue_status === "claimed").map((item) => item.id);
|
|
38
|
+
const blocked = protocols.filter((item) => item.stage === "blocked" || item.bundle_blockers.length > 0).map((item) => item.id);
|
|
39
|
+
const diagnostics = branchDiagnostics(resolved);
|
|
40
|
+
const diagnosticBlockers = diagnostics.filter((item) => item.severity === "error").map((item) => String(item.code));
|
|
41
|
+
const claimable = eligible.length > 0 && notReady.length === 0 && claimed.length === 0 && blocked.length === 0 && diagnosticBlockers.length === 0;
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
branch_context: {
|
|
45
|
+
current_protocol_id: resolved.protocol?.id ?? null,
|
|
46
|
+
project_id: resolved.project.id,
|
|
47
|
+
project_root: resolved.project.root,
|
|
48
|
+
integration_branch: resolved.integrationBranch,
|
|
49
|
+
feature_branch: resolved.featureBranch,
|
|
50
|
+
worktree_path: resolved.workspacePath,
|
|
51
|
+
grouping_key: resolved.groupingKey,
|
|
52
|
+
git: resolved.git,
|
|
53
|
+
diagnostics,
|
|
54
|
+
protocols,
|
|
55
|
+
merge_bundle: {
|
|
56
|
+
eligible_protocols: eligible,
|
|
57
|
+
not_ready_protocols: notReady,
|
|
58
|
+
claimed_protocols: claimed,
|
|
59
|
+
blocked_protocols: blocked,
|
|
60
|
+
claimable,
|
|
61
|
+
reason: claimable ? "claimable" : bundleReason(eligible, notReady, claimed, blocked, diagnosticBlockers)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function requireClaimableBranchBundle(context, input) {
|
|
67
|
+
const branchContext = getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath });
|
|
68
|
+
const contextBody = branchContext.branch_context;
|
|
69
|
+
const bundle = contextBody.merge_bundle;
|
|
70
|
+
if (bundle.claimable !== true) {
|
|
71
|
+
throw new AppError("merge_bundle_not_claimable", "Branch bundle is not claimable", 1, { branch_context: contextBody });
|
|
72
|
+
}
|
|
73
|
+
const protocolIds = Array.isArray(bundle.eligible_protocols) ? bundle.eligible_protocols.map(String) : [];
|
|
74
|
+
if (protocolIds.length === 0) {
|
|
75
|
+
throw new AppError("merge_bundle_empty", "Branch bundle has no ready protocols", 1, { branch_context: contextBody });
|
|
76
|
+
}
|
|
77
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
78
|
+
return { project, branchContext, protocolIds };
|
|
79
|
+
}
|
|
80
|
+
function resolveBranchContextInput(context, input) {
|
|
81
|
+
if (input.protocolId) {
|
|
82
|
+
const protocol = context.db.get(`SELECT id, project_id, project_root, status, stage, next_action, workspace_json, route_json, active_def_json, updated_at
|
|
83
|
+
FROM protocols
|
|
84
|
+
WHERE id = ?`, [input.protocolId]);
|
|
85
|
+
if (!protocol) {
|
|
86
|
+
throw new AppError("not_found", `Protocol is not registered: ${input.protocolId}`, 1, { protocol_id: input.protocolId });
|
|
87
|
+
}
|
|
88
|
+
const project = requireProjectByRoot(context, protocol.project_root);
|
|
89
|
+
const workspace = parseRecord(protocol.workspace_json);
|
|
90
|
+
const worktree = context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocol.id]);
|
|
91
|
+
const workspacePath = normalizePath(input.workspacePath ?? stringValue(worktree?.worktree_path) ?? stringValue(workspace.worktree_path));
|
|
92
|
+
const git = gitFacts(workspacePath ?? project.root);
|
|
93
|
+
const featureBranch = stringValue(worktree?.feature_branch) ?? stringValue(workspace.feature_branch) ?? stringValue(git.actual_branch);
|
|
94
|
+
return {
|
|
95
|
+
project,
|
|
96
|
+
protocol,
|
|
97
|
+
featureBranch,
|
|
98
|
+
workspacePath,
|
|
99
|
+
integrationBranch: stringValue(worktree?.integration_branch) ?? stringValue(workspace.integration_branch),
|
|
100
|
+
groupingKey: groupingKey(project.id, featureBranch, workspacePath),
|
|
101
|
+
git: { ...git, matches_runtime: branchMatches(featureBranch, stringValue(git.actual_branch)) }
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (!input.projectRoot) {
|
|
105
|
+
throw new AppError("usage", "branch context requires <protocol-id> or --project-root", 2);
|
|
106
|
+
}
|
|
107
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
108
|
+
const workspacePath = normalizePath(input.workspacePath ?? project.root);
|
|
109
|
+
const git = gitFacts(workspacePath ?? project.root);
|
|
110
|
+
const featureBranch = stringValue(git.actual_branch);
|
|
111
|
+
return {
|
|
112
|
+
project,
|
|
113
|
+
protocol: null,
|
|
114
|
+
featureBranch,
|
|
115
|
+
workspacePath,
|
|
116
|
+
integrationBranch: null,
|
|
117
|
+
groupingKey: groupingKey(project.id, featureBranch, workspacePath),
|
|
118
|
+
git: { ...git, matches_runtime: true }
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function summarizeProtocol(row, queue, worktree, activeSessions) {
|
|
122
|
+
const workspace = parseRecord(row.workspace_json);
|
|
123
|
+
const featureBranch = stringValue(worktree?.feature_branch) ?? stringValue(workspace.feature_branch);
|
|
124
|
+
const worktreePath = normalizePath(stringValue(worktree?.worktree_path) ?? stringValue(workspace.worktree_path));
|
|
125
|
+
const activeDef = parseJsonArray(row.active_def_json);
|
|
126
|
+
const queueStatus = queue?.status ?? null;
|
|
127
|
+
const bundleBlockers = bundleBlockersFor(row.stage, queueStatus, activeDef.length);
|
|
128
|
+
return {
|
|
129
|
+
id: row.id,
|
|
130
|
+
stage: row.stage,
|
|
131
|
+
status: row.status,
|
|
132
|
+
lifecycle: normalizeProtocolLifecycle({ rawStage: row.stage, rawStatus: row.status, queueStatus }),
|
|
133
|
+
queue_status: queueStatus,
|
|
134
|
+
queue_claimed_by: queue?.claimed_by_session_id ?? null,
|
|
135
|
+
active_sessions: activeSessions,
|
|
136
|
+
integration_branch: stringValue(worktree?.integration_branch) ?? stringValue(workspace.integration_branch),
|
|
137
|
+
feature_branch: featureBranch,
|
|
138
|
+
worktree_path: worktreePath,
|
|
139
|
+
bundle_eligible: ["ready_for_merge", "queued_for_merge"].includes(row.stage) && ["ready", "requeued"].includes(queueStatus ?? "") && activeDef.length === 0,
|
|
140
|
+
bundle_blockers: bundleBlockers,
|
|
141
|
+
updated_at: row.updated_at
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function protocolMatchesBranch(item, resolved) {
|
|
145
|
+
if (resolved.featureBranch && item.feature_branch) {
|
|
146
|
+
return item.feature_branch === resolved.featureBranch;
|
|
147
|
+
}
|
|
148
|
+
if (resolved.workspacePath && item.worktree_path) {
|
|
149
|
+
return item.worktree_path === resolved.workspacePath;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
function gitFacts(workspacePath) {
|
|
154
|
+
if (!fs.existsSync(workspacePath)) {
|
|
155
|
+
return { status: "missing_workspace", actual_branch: null, dirty: null, path: workspacePath };
|
|
156
|
+
}
|
|
157
|
+
const branch = spawnSync("git", ["-C", workspacePath, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
158
|
+
if (branch.status !== 0) {
|
|
159
|
+
return { status: "not_git_checkout", actual_branch: null, dirty: null, path: workspacePath };
|
|
160
|
+
}
|
|
161
|
+
const status = spawnSync("git", ["-C", workspacePath, "status", "--porcelain"], { encoding: "utf8" });
|
|
162
|
+
return {
|
|
163
|
+
status: "ok",
|
|
164
|
+
actual_branch: branch.stdout.trim() || null,
|
|
165
|
+
dirty: status.status === 0 ? status.stdout.trim().length > 0 : null,
|
|
166
|
+
path: workspacePath
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function branchDiagnostics(resolved) {
|
|
170
|
+
const diagnostics = [];
|
|
171
|
+
if (resolved.git.status !== "ok") {
|
|
172
|
+
diagnostics.push({ code: "git_workspace_degraded", severity: "warning", summary: `Git workspace status is ${String(resolved.git.status)}` });
|
|
173
|
+
}
|
|
174
|
+
if (resolved.featureBranch && resolved.git.actual_branch && resolved.featureBranch !== resolved.git.actual_branch) {
|
|
175
|
+
diagnostics.push({
|
|
176
|
+
code: "branch_runtime_mismatch",
|
|
177
|
+
severity: "error",
|
|
178
|
+
summary: `Runtime branch ${resolved.featureBranch} does not match Git branch ${String(resolved.git.actual_branch)}.`
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return diagnostics;
|
|
182
|
+
}
|
|
183
|
+
function bundleBlockersFor(stage, queueStatus, activeDefCount) {
|
|
184
|
+
const blockers = [];
|
|
185
|
+
if (activeDefCount > 0)
|
|
186
|
+
blockers.push("active_def");
|
|
187
|
+
if (stage === "blocked")
|
|
188
|
+
blockers.push("protocol_blocked");
|
|
189
|
+
if (queueStatus === "claimed")
|
|
190
|
+
blockers.push("already_claimed");
|
|
191
|
+
return blockers;
|
|
192
|
+
}
|
|
193
|
+
function bundleReason(eligible, notReady, claimed, blocked, diagnostics) {
|
|
194
|
+
if (diagnostics.length > 0)
|
|
195
|
+
return diagnostics[0] ?? "diagnostic_blocker";
|
|
196
|
+
if (claimed.length > 0)
|
|
197
|
+
return "branch_has_claimed_protocols";
|
|
198
|
+
if (blocked.length > 0)
|
|
199
|
+
return "branch_has_blocked_protocols";
|
|
200
|
+
if (notReady.length > 0)
|
|
201
|
+
return "branch_has_not_ready_protocols";
|
|
202
|
+
if (eligible.length === 0)
|
|
203
|
+
return "no_ready_protocols";
|
|
204
|
+
return "unknown";
|
|
205
|
+
}
|
|
206
|
+
function terminalStage(stage) {
|
|
207
|
+
return ["closed", "cancelled"].includes(stage);
|
|
208
|
+
}
|
|
209
|
+
function groupingKey(projectId, featureBranch, workspacePath) {
|
|
210
|
+
if (featureBranch)
|
|
211
|
+
return `${projectId}:branch:${featureBranch}`;
|
|
212
|
+
if (workspacePath)
|
|
213
|
+
return `${projectId}:worktree:${workspacePath}`;
|
|
214
|
+
return `${projectId}:unknown`;
|
|
215
|
+
}
|
|
216
|
+
function branchMatches(expected, actual) {
|
|
217
|
+
if (!expected || !actual)
|
|
218
|
+
return null;
|
|
219
|
+
return expected === actual;
|
|
220
|
+
}
|
|
221
|
+
function normalizePath(value) {
|
|
222
|
+
if (!value)
|
|
223
|
+
return null;
|
|
224
|
+
const absolute = path.resolve(value);
|
|
225
|
+
if (fs.existsSync(absolute))
|
|
226
|
+
return fs.realpathSync(absolute);
|
|
227
|
+
const parent = path.dirname(absolute);
|
|
228
|
+
if (fs.existsSync(parent))
|
|
229
|
+
return path.join(fs.realpathSync(parent), path.basename(absolute));
|
|
230
|
+
return absolute;
|
|
231
|
+
}
|
|
232
|
+
function parseRecord(value) {
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(value);
|
|
235
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return {};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function parseJsonArray(value) {
|
|
242
|
+
if (typeof value !== "string")
|
|
243
|
+
return [];
|
|
244
|
+
try {
|
|
245
|
+
const parsed = JSON.parse(value);
|
|
246
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function stringValue(value) {
|
|
253
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
254
|
+
}
|
package/dist/services/cleanup.js
CHANGED
|
@@ -13,6 +13,7 @@ const actionKinds = new Set([
|
|
|
13
13
|
"cancel_queue_job",
|
|
14
14
|
"close_flow_session",
|
|
15
15
|
"expire_lane_lock",
|
|
16
|
+
"expire_lane_waiter",
|
|
16
17
|
"close_worktree_record"
|
|
17
18
|
]);
|
|
18
19
|
export function cleanupScan(context, input) {
|
|
@@ -98,6 +99,18 @@ export function cleanupScan(context, input) {
|
|
|
98
99
|
action: { kind: "expire_lane_lock", project_id: project.id, lock_id: lock.id }
|
|
99
100
|
});
|
|
100
101
|
}
|
|
102
|
+
const expiredWaiters = context.db.all(`SELECT id, lane, worker_id, expires_at FROM lane_waiters
|
|
103
|
+
WHERE project_id = ? AND status = 'queued' AND expires_at IS NOT NULL AND expires_at <= ?
|
|
104
|
+
ORDER BY id ASC`, [project.id, now]);
|
|
105
|
+
for (const waiter of expiredWaiters) {
|
|
106
|
+
pushFinding(findings, actions, "expired_lane_waiter", "warning", {
|
|
107
|
+
waiter_id: waiter.id,
|
|
108
|
+
lane: waiter.lane,
|
|
109
|
+
worker_id: waiter.worker_id,
|
|
110
|
+
expires_at: waiter.expires_at,
|
|
111
|
+
action: { kind: "expire_lane_waiter", project_id: project.id, waiter_id: waiter.id }
|
|
112
|
+
});
|
|
113
|
+
}
|
|
101
114
|
const staleWorktrees = context.db.all(`SELECT wr.protocol_id, wr.worktree_path FROM worktree_records wr
|
|
102
115
|
JOIN protocols p ON p.id = wr.protocol_id
|
|
103
116
|
WHERE wr.project_id = ?
|
|
@@ -285,6 +298,24 @@ function applyAction(context, action, reason, force, now) {
|
|
|
285
298
|
lock_id: action.lock_id
|
|
286
299
|
};
|
|
287
300
|
}
|
|
301
|
+
if (action.kind === "expire_lane_waiter" && typeof action.waiter_id === "number") {
|
|
302
|
+
const result = context.db.run("UPDATE lane_waiters SET status = 'expired', reason = ?, updated_at = ? WHERE project_id = ? AND id = ? AND status = 'queued'", [reason, now, action.project_id, action.waiter_id]);
|
|
303
|
+
if (result.changes === 1) {
|
|
304
|
+
appendAudit(context, {
|
|
305
|
+
projectId: action.project_id,
|
|
306
|
+
eventType: "lane_waiter.expired",
|
|
307
|
+
reason,
|
|
308
|
+
forced: force,
|
|
309
|
+
payload: { waiter_id: action.waiter_id, source: "cleanup.apply" }
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
kind: action.kind,
|
|
314
|
+
changed: result.changes === 1,
|
|
315
|
+
...(result.changes === 1 ? {} : { skipped: true, reason: "already_inactive" }),
|
|
316
|
+
waiter_id: action.waiter_id
|
|
317
|
+
};
|
|
318
|
+
}
|
|
288
319
|
if (action.kind === "close_worktree_record" && action.protocol_id) {
|
|
289
320
|
const worktreePath = action.worktree_path ?? context.db.get("SELECT worktree_path FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [action.project_id, action.protocol_id])?.worktree_path;
|
|
290
321
|
const status = worktreePath && fs.existsSync(worktreePath) ? "kept" : "removed";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export function classifyCliOperation(args, env = process.env) {
|
|
2
|
+
const positional = positionalArgs(args);
|
|
3
|
+
const [family = null, command = null, action = null] = positional;
|
|
4
|
+
const operation = [family, command, action].filter(Boolean).join(".") || "help";
|
|
5
|
+
if (isMbUpgradeMode(args, env)) {
|
|
6
|
+
return { mode: "mb_upgrade", operation, family, command, action };
|
|
7
|
+
}
|
|
8
|
+
if (isRouterNative(args)) {
|
|
9
|
+
return { mode: "router_native", operation, family, command, action };
|
|
10
|
+
}
|
|
11
|
+
if (isReadOnlyDiagnostic(family, command, action)) {
|
|
12
|
+
return { mode: "read_only_diagnostics", operation, family, command, action };
|
|
13
|
+
}
|
|
14
|
+
return { mode: "normal_write", operation, family, command, action };
|
|
15
|
+
}
|
|
16
|
+
function positionalArgs(args) {
|
|
17
|
+
const positional = [];
|
|
18
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
19
|
+
const value = args[index];
|
|
20
|
+
if (!value)
|
|
21
|
+
continue;
|
|
22
|
+
if (value.startsWith("--")) {
|
|
23
|
+
if (!value.includes("=") && args[index + 1] && !args[index + 1]?.startsWith("--"))
|
|
24
|
+
index += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
positional.push(value);
|
|
28
|
+
}
|
|
29
|
+
return positional;
|
|
30
|
+
}
|
|
31
|
+
export function isReadOnlyCliOperation(args, env = process.env) {
|
|
32
|
+
return classifyCliOperation(args, env).mode === "read_only_diagnostics";
|
|
33
|
+
}
|
|
34
|
+
export function isMbUpgradeCliOperation(args, env = process.env) {
|
|
35
|
+
return classifyCliOperation(args, env).mode === "mb_upgrade";
|
|
36
|
+
}
|
|
37
|
+
function isMbUpgradeMode(args, env) {
|
|
38
|
+
if (env.DD_FLOW_COMPATIBILITY_MODE === "mb-upgrade" || env.DD_FLOW_MB_UPGRADE_MODE === "1") {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
42
|
+
const arg = args[index];
|
|
43
|
+
if (arg === "--compatibility-mode" && args[index + 1] === "mb-upgrade")
|
|
44
|
+
return true;
|
|
45
|
+
if (arg === "--mb-upgrade-mode")
|
|
46
|
+
return true;
|
|
47
|
+
if (arg?.startsWith("--compatibility-mode="))
|
|
48
|
+
return arg.slice("--compatibility-mode=".length) === "mb-upgrade";
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
function isRouterNative(args) {
|
|
53
|
+
const first = args[0];
|
|
54
|
+
if (!first || first === "--version")
|
|
55
|
+
return true;
|
|
56
|
+
if (first === "--help" || first === "-h")
|
|
57
|
+
return true;
|
|
58
|
+
if (args.includes("--help") || args.includes("-h"))
|
|
59
|
+
return true;
|
|
60
|
+
return ["engine", "version", "schema"].includes(first);
|
|
61
|
+
}
|
|
62
|
+
function isReadOnlyDiagnostic(family, command, action) {
|
|
63
|
+
if (!family)
|
|
64
|
+
return true;
|
|
65
|
+
if (family === "status" || family === "version" || family === "id" || family === "integration")
|
|
66
|
+
return true;
|
|
67
|
+
if (family === "canon")
|
|
68
|
+
return command === "status" || command === "resolve";
|
|
69
|
+
if (family === "project")
|
|
70
|
+
return command === "status" || command === "resolve";
|
|
71
|
+
if (family === "protocol")
|
|
72
|
+
return ["status", "ready", "blockers", "implement"].includes(command ?? "");
|
|
73
|
+
if (family === "plan")
|
|
74
|
+
return command === "status";
|
|
75
|
+
if (family === "run")
|
|
76
|
+
return command === "status" || command === "list";
|
|
77
|
+
if (family === "lane") {
|
|
78
|
+
return (command === "status" ||
|
|
79
|
+
command === "waiters" ||
|
|
80
|
+
(command === "lock" && action === "status") ||
|
|
81
|
+
(command === "workspace" && action === "check"));
|
|
82
|
+
}
|
|
83
|
+
if (family === "merge")
|
|
84
|
+
return command === "status";
|
|
85
|
+
if (family === "merge-worker")
|
|
86
|
+
return command === "status";
|
|
87
|
+
if (family === "merge-queue")
|
|
88
|
+
return command === "status";
|
|
89
|
+
if (family === "migration")
|
|
90
|
+
return ["plan", "report", "verify"].includes(command ?? "");
|
|
91
|
+
if (family === "session")
|
|
92
|
+
return command === "status";
|
|
93
|
+
if (family === "dashboard")
|
|
94
|
+
return command === "data" || command === "open";
|
|
95
|
+
if (family === "memory")
|
|
96
|
+
return command === "permissions" && action === "preflight";
|
|
97
|
+
if (family === "codex" && command === "hooks")
|
|
98
|
+
return action === "print" || action === "status";
|
|
99
|
+
if (family === "codex" && command === "home")
|
|
100
|
+
return ["plan", "status", "print-env"].includes(action ?? "");
|
|
101
|
+
if (family === "worktree")
|
|
102
|
+
return command === "plan" || command === "status";
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
import { getCliBuildInfo } from "./build-info.js";
|
|
3
|
+
import { classifyCliOperation } from "./cli-operation-classifier.js";
|
|
4
|
+
import { selectEngine } from "./engines.js";
|
|
5
|
+
import { requireProtocol } from "./protocols.js";
|
|
6
|
+
export function preflightCliCompatibility(context, args, env = process.env) {
|
|
7
|
+
const classification = classifyCliOperation(args, env);
|
|
8
|
+
if (classification.mode === "router_native") {
|
|
9
|
+
return { ok: true, classification, compatibility: null };
|
|
10
|
+
}
|
|
11
|
+
const projectRoot = resolveOperationProjectRoot(context, args);
|
|
12
|
+
if (!projectRoot) {
|
|
13
|
+
return { ok: true, classification, compatibility: null };
|
|
14
|
+
}
|
|
15
|
+
const selection = selectEngine(context, { projectRoot });
|
|
16
|
+
const compatibility = compatibilityReport(selection, classification);
|
|
17
|
+
if (classification.mode === "read_only_diagnostics" || classification.mode === "mb_upgrade") {
|
|
18
|
+
return { ok: true, classification, compatibility };
|
|
19
|
+
}
|
|
20
|
+
if (selection.status !== "selected" || !selection.selected) {
|
|
21
|
+
throw new AppError("compatibility_preflight_failed", "dd-flow compatibility preflight blocked a state-changing command", 1, {
|
|
22
|
+
compatibility,
|
|
23
|
+
remediation: remediationForSelection(selection)
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return { ok: true, classification, compatibility };
|
|
27
|
+
}
|
|
28
|
+
export function compatibilityReport(selection, classification) {
|
|
29
|
+
const build = getCliBuildInfo();
|
|
30
|
+
const normalWriteAllowed = selection.status === "selected" && Boolean(selection.selected);
|
|
31
|
+
return {
|
|
32
|
+
verdict: selection.status === "selected" ? "ok" : "incompatible",
|
|
33
|
+
memorybank_version: selection.memory_bank_version,
|
|
34
|
+
router_version: build.version,
|
|
35
|
+
cli_version: build.version,
|
|
36
|
+
engine_version: selection.selected?.engine_version ?? selection.selected?.package_version ?? null,
|
|
37
|
+
engine_resolution: selection.status,
|
|
38
|
+
required_engine_range: selection.required_range,
|
|
39
|
+
recommended_engine_version: selection.recommended_version,
|
|
40
|
+
allowed_modes: normalWriteAllowed
|
|
41
|
+
? ["read_only_diagnostics", "normal_write", "mb_upgrade"]
|
|
42
|
+
: ["read_only_diagnostics", "mb_upgrade"],
|
|
43
|
+
operation_mode: classification.mode,
|
|
44
|
+
blocked_operation: classification.mode === "normal_write" && !normalWriteAllowed ? classification.operation : null,
|
|
45
|
+
install_hint: selection.install_hint,
|
|
46
|
+
project_root: selection.project_root,
|
|
47
|
+
diagnostics: selection.diagnostics
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function remediationForSelection(selection) {
|
|
51
|
+
return {
|
|
52
|
+
install_hint: selection.install_hint,
|
|
53
|
+
mb_upgrade_mode: "Run through mb-upgrade with --compatibility-mode mb-upgrade only when intentionally migrating the project.",
|
|
54
|
+
diagnostics: selection.diagnostics
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function resolveOperationProjectRoot(context, args) {
|
|
58
|
+
const [family, command, ...rest] = args;
|
|
59
|
+
const parsed = parseLightArgs(rest);
|
|
60
|
+
const explicitRoot = option(parsed, "project-root") ?? option(parsed, "root");
|
|
61
|
+
if (explicitRoot)
|
|
62
|
+
return explicitRoot;
|
|
63
|
+
if (family === "protocol") {
|
|
64
|
+
const protocolId = positional(parsed, 0);
|
|
65
|
+
if (protocolId && command && ["ready-for-merge", "cancel", "status"].includes(command)) {
|
|
66
|
+
return requireProtocol(context, protocolId).project_root;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (family === "transition") {
|
|
70
|
+
const transitionParsed = parseLightArgs([command ?? "", ...rest]);
|
|
71
|
+
const protocolId = positional(transitionParsed, 0);
|
|
72
|
+
return protocolId ? requireProtocol(context, protocolId).project_root : null;
|
|
73
|
+
}
|
|
74
|
+
if (family === "plan" && (command === "set" || command === "status")) {
|
|
75
|
+
const protocolId = positional(parsed, 0);
|
|
76
|
+
return protocolId ? requireProtocol(context, protocolId).project_root : null;
|
|
77
|
+
}
|
|
78
|
+
if (family === "plan" && command === "item") {
|
|
79
|
+
const protocolId = positional(parsed, 1);
|
|
80
|
+
return protocolId ? requireProtocol(context, protocolId).project_root : null;
|
|
81
|
+
}
|
|
82
|
+
if (family === "merge-queue" && ["complete", "fail", "cancel", "note"].includes(command ?? "")) {
|
|
83
|
+
const protocolId = positional(parsed, 0);
|
|
84
|
+
return protocolId ? requireProtocol(context, protocolId).project_root : null;
|
|
85
|
+
}
|
|
86
|
+
if (family === "worktree") {
|
|
87
|
+
const protocolId = option(parsed, "protocol-id");
|
|
88
|
+
return protocolId ? requireProtocol(context, protocolId).project_root : null;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function parseLightArgs(args) {
|
|
93
|
+
const positional = [];
|
|
94
|
+
const options = new Map();
|
|
95
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
96
|
+
const value = args[index];
|
|
97
|
+
if (value?.startsWith("--")) {
|
|
98
|
+
const equal = value.indexOf("=");
|
|
99
|
+
if (equal > 2) {
|
|
100
|
+
const key = value.slice(2, equal);
|
|
101
|
+
options.set(key, [...(options.get(key) ?? []), value.slice(equal + 1)]);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const key = value.slice(2);
|
|
105
|
+
const next = args[index + 1];
|
|
106
|
+
if (!next || next.startsWith("--")) {
|
|
107
|
+
options.set(key, [...(options.get(key) ?? []), ""]);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
options.set(key, [...(options.get(key) ?? []), next]);
|
|
111
|
+
index += 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (value) {
|
|
115
|
+
positional.push(value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { positional, options };
|
|
119
|
+
}
|
|
120
|
+
function option(parsed, key) {
|
|
121
|
+
const values = parsed.options.get(key);
|
|
122
|
+
const value = values?.[values.length - 1];
|
|
123
|
+
return value ? value : null;
|
|
124
|
+
}
|
|
125
|
+
function positional(parsed, index) {
|
|
126
|
+
return parsed.positional[index] ?? null;
|
|
127
|
+
}
|
package/dist/services/config.js
CHANGED
|
@@ -62,6 +62,12 @@ export function projectDashboardJsonPath(context, projectId) {
|
|
|
62
62
|
export function projectDashboardHtmlPath(context, projectId) {
|
|
63
63
|
return path.join(projectDashboardDir(context.ddFlowHome, projectId), "project-dashboard.html");
|
|
64
64
|
}
|
|
65
|
+
export function projectSummaryDir(context, projectId) {
|
|
66
|
+
return path.join(projectHome(context.ddFlowHome, projectId), "summary");
|
|
67
|
+
}
|
|
68
|
+
export function projectSummaryJsonPath(context, projectId) {
|
|
69
|
+
return path.join(projectSummaryDir(context, projectId), "project-summary.json");
|
|
70
|
+
}
|
|
65
71
|
export function protocolDashboardJsonPath(context, projectId, protocolId) {
|
|
66
72
|
return path.join(projectDashboardDir(context.ddFlowHome, projectId), "protocols", `${protocolId}.json`);
|
|
67
73
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
3
|
+
import { requireProjectByReference, requireProjectByRoot } from "./projects.js";
|
|
4
|
+
export function resolveDashboardTarget(context, input) {
|
|
5
|
+
if (input.all) {
|
|
6
|
+
if (input.action !== "refresh") {
|
|
7
|
+
throw new AppError("validation", "--all is supported only for dashboard refresh", 2, {
|
|
8
|
+
preferred_command: "dd-flow dashboard refresh --all",
|
|
9
|
+
related_commands: relatedDashboardCommands("refresh")
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
if (input.project || input.projectRoot || input.protocol || input.global) {
|
|
13
|
+
throw new AppError("validation", "--all cannot be combined with --project, --project-root, --protocol, or --global", 2, {
|
|
14
|
+
related_commands: relatedDashboardCommands("refresh")
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return { kind: "all_projects" };
|
|
18
|
+
}
|
|
19
|
+
if (input.global && (input.project || input.projectRoot || input.protocol)) {
|
|
20
|
+
throw new AppError("validation", "--global cannot be combined with project or protocol targets", 2, {
|
|
21
|
+
related_commands: relatedDashboardCommands(input.action)
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const project = resolveDashboardProject(context, input.project, input.projectRoot);
|
|
25
|
+
if (input.protocol) {
|
|
26
|
+
if (!project) {
|
|
27
|
+
throw new AppError("validation", "--protocol requires --project or --project-root", 2, {
|
|
28
|
+
related_commands: relatedDashboardCommands(input.action, "project")
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return { kind: "protocol", project, protocolId: input.protocol };
|
|
32
|
+
}
|
|
33
|
+
if (project) {
|
|
34
|
+
return { kind: "project", project };
|
|
35
|
+
}
|
|
36
|
+
return { kind: "global" };
|
|
37
|
+
}
|
|
38
|
+
export function dashboardTargetSummary(target) {
|
|
39
|
+
if (target.kind === "project") {
|
|
40
|
+
return { kind: "project", project_id: target.project.id, project_root: target.project.root };
|
|
41
|
+
}
|
|
42
|
+
if (target.kind === "protocol") {
|
|
43
|
+
return { kind: "protocol", project_id: target.project.id, project_root: target.project.root, protocol_id: target.protocolId };
|
|
44
|
+
}
|
|
45
|
+
return { kind: target.kind };
|
|
46
|
+
}
|
|
47
|
+
export function preferredDashboardCommand(action, target) {
|
|
48
|
+
if (target.kind === "project") {
|
|
49
|
+
return `dd-flow dashboard ${action} --project <project>`;
|
|
50
|
+
}
|
|
51
|
+
if (target.kind === "protocol") {
|
|
52
|
+
return `dd-flow dashboard ${action} --project <project> --protocol <protocol>`;
|
|
53
|
+
}
|
|
54
|
+
if (target.kind === "all_projects") {
|
|
55
|
+
return "dd-flow dashboard refresh --all";
|
|
56
|
+
}
|
|
57
|
+
return `dd-flow dashboard ${action}`;
|
|
58
|
+
}
|
|
59
|
+
export function relatedDashboardCommands(action, targetKind = "global") {
|
|
60
|
+
if (targetKind === "all_projects") {
|
|
61
|
+
return ["dd-flow dashboard refresh --all", "dd-flow dashboard open", "dd-flow dashboard open --project <project>"];
|
|
62
|
+
}
|
|
63
|
+
if (targetKind === "project" || targetKind === "protocol") {
|
|
64
|
+
return uniqueCommands([
|
|
65
|
+
`dd-flow dashboard ${action} --project <project>`,
|
|
66
|
+
"dd-flow dashboard refresh --project <project>",
|
|
67
|
+
"dd-flow dashboard open --project <project>",
|
|
68
|
+
"dd-flow dashboard data --project <project> --json"
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
return uniqueCommands([
|
|
72
|
+
`dd-flow dashboard ${action}`,
|
|
73
|
+
"dd-flow dashboard open",
|
|
74
|
+
"dd-flow dashboard refresh",
|
|
75
|
+
"dd-flow dashboard refresh --all",
|
|
76
|
+
"dd-flow dashboard open --project <project>"
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
function resolveDashboardProject(context, project, projectRoot) {
|
|
80
|
+
if (!project && !projectRoot) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const byProject = project ? requireProjectByReference(context, project) : null;
|
|
84
|
+
const byRoot = projectRoot ? requireProjectByRoot(context, resolveProjectRoot(projectRoot)) : null;
|
|
85
|
+
if (byProject && byRoot && byProject.id !== byRoot.id) {
|
|
86
|
+
throw new AppError("validation", "--project and --project-root resolve to different projects", 2, {
|
|
87
|
+
project: { id: byProject.id, root: byProject.root },
|
|
88
|
+
project_root: { id: byRoot.id, root: byRoot.root }
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return byProject ?? byRoot;
|
|
92
|
+
}
|
|
93
|
+
function uniqueCommands(commands) {
|
|
94
|
+
return Array.from(new Set(commands));
|
|
95
|
+
}
|