@deksden-com/dd-flow-cli 0.7.0 → 0.8.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 +666 -0
- package/README.md +7 -2
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +88 -10
- package/dist/cli/run-cli.js +523 -28
- package/dist/domain/stage-catalog.js +22 -0
- package/dist/domain/validation.js +1 -1
- package/dist/schemas/code-review-decision.schema.json +26 -0
- package/dist/schemas/code-review-result.schema.json +14 -0
- package/dist/schemas/code-verification.schema.json +14 -0
- package/dist/schemas/code-work-batch.schema.json +24 -0
- package/dist/schemas/code-work-result.schema.json +16 -0
- package/dist/schemas/compatibility.schema.json +32 -0
- package/dist/schemas/flow-contract.schema.json +9 -5
- package/dist/schemas/flow-run.schema.json +16 -123
- package/dist/schemas/plan-aspect-map.schema.json +22 -0
- package/dist/schemas/plan-review-decision.schema.json +14 -0
- package/dist/schemas/plan-review-result.schema.json +42 -0
- package/dist/schemas/protocol-plan.schema.json +15 -182
- package/dist/schemas/stage-finish-input.schema.json +16 -2
- package/dist/schemas/stage-report.schema.json +8 -7
- package/dist/schemas/stage-start-response.schema.json +4 -2
- package/dist/schemas/status-report.schema.json +76 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
- package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
- package/dist/schemas/vnext-specify.schema.json +45 -0
- package/dist/services/branch-context.js +1 -1
- package/dist/services/cleanup.js +8 -8
- package/dist/services/cli-operation-classifier.js +10 -2
- package/dist/services/code-checks.js +244 -0
- package/dist/services/config.js +7 -1
- package/dist/services/dashboard.js +12 -12
- package/dist/services/engines.js +1 -1
- package/dist/services/eval-snapshots.js +404 -0
- package/dist/services/hooks.js +774 -18
- package/dist/services/ids.js +16 -6
- package/dist/services/lanes.js +1 -1
- package/dist/services/merge-queue.js +5 -5
- package/dist/services/merge-worker.js +2 -2
- package/dist/services/migrations.js +2 -2
- package/dist/services/plan-runtime.js +1 -1
- package/dist/services/projects.js +4 -4
- package/dist/services/prompts.js +17 -11
- package/dist/services/protocols.js +8 -8
- package/dist/services/run-projection.js +49 -13
- package/dist/services/runs.js +504 -51
- package/dist/services/schema-validation.js +21 -3
- package/dist/services/sessions.js +51 -12
- package/dist/services/stage-blocker.js +57 -0
- package/dist/services/stage-context.js +90 -0
- package/dist/services/stage-lifecycle.js +198 -75
- package/dist/services/stage-pause.js +175 -0
- package/dist/services/stage-report-renderer.js +65 -0
- package/dist/services/usage.js +526 -18
- package/dist/services/vnext-code-review.js +305 -0
- package/dist/services/vnext-code.js +686 -0
- package/dist/services/vnext-contracts.js +1 -0
- package/dist/services/vnext-execution-profile.js +27 -0
- package/dist/services/vnext-fanout.js +79 -0
- package/dist/services/vnext-plan-review.js +499 -0
- package/dist/services/vnext-plan.js +552 -0
- package/dist/services/vnext-protocolize.js +542 -0
- package/dist/services/vnext-specify.js +595 -0
- package/dist/services/vnext-workspace-policy.js +87 -0
- package/dist/services/work-registry.js +522 -0
- package/dist/services/worktrees.js +58 -37
- package/dist/storage/database.js +263 -34
- package/dist/storage/paths.js +47 -1
- package/package.json +12 -12
package/dist/services/cleanup.js
CHANGED
|
@@ -68,7 +68,7 @@ export function cleanupScan(context, input) {
|
|
|
68
68
|
action: { kind: "cancel_queue_job", project_id: project.id, protocol_id: job.protocol_id }
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
|
-
const staleSessions = context.db.all(`SELECT session_id, protocol_id, workspace_path FROM
|
|
71
|
+
const staleSessions = context.db.all(`SELECT session_id, protocol_id, workspace_path FROM sessions
|
|
72
72
|
WHERE project_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')
|
|
73
73
|
ORDER BY updated_at ASC, session_id ASC`, [project.id]);
|
|
74
74
|
for (const session of staleSessions) {
|
|
@@ -87,7 +87,7 @@ export function cleanupScan(context, input) {
|
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
-
const staleRuns = context.db.all(`SELECT id, runtime_path, run_dir, workspace_root FROM
|
|
90
|
+
const staleRuns = context.db.all(`SELECT id, runtime_path, run_dir, workspace_root FROM runs
|
|
91
91
|
WHERE project_id = ? AND status = 'running'
|
|
92
92
|
ORDER BY updated_at ASC, id ASC`, [project.id]);
|
|
93
93
|
for (const run of staleRuns) {
|
|
@@ -306,7 +306,7 @@ function applyAction(context, action, reason, force, now) {
|
|
|
306
306
|
return { kind: action.kind, changed: true, protocol_id: action.protocol_id };
|
|
307
307
|
}
|
|
308
308
|
if (action.kind === "close_flow_session" && action.session_id) {
|
|
309
|
-
const result = context.db.run(`UPDATE
|
|
309
|
+
const result = context.db.run(`UPDATE sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
310
310
|
WHERE project_id = ? AND session_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, action.project_id, action.session_id]);
|
|
311
311
|
if (result.changes !== 1) {
|
|
312
312
|
return { kind: action.kind, changed: false, skipped: true, reason: "already_stopped", session_id: action.session_id };
|
|
@@ -321,13 +321,13 @@ function applyAction(context, action, reason, force, now) {
|
|
|
321
321
|
return { kind: action.kind, changed: true, session_id: action.session_id };
|
|
322
322
|
}
|
|
323
323
|
if (action.kind === "discard_stale_run" && action.run_id) {
|
|
324
|
-
const run = context.db.get("SELECT status, runtime_path, run_dir, workspace_root, index_json FROM
|
|
324
|
+
const run = context.db.get("SELECT status, runtime_path, run_dir, workspace_root, index_json FROM runs WHERE project_id = ? AND id = ?", [action.project_id, action.run_id]);
|
|
325
325
|
if (!run) {
|
|
326
326
|
return { kind: action.kind, changed: false, skipped: true, reason: "run_not_found", run_id: action.run_id };
|
|
327
327
|
}
|
|
328
328
|
if (run.status === "discarded") {
|
|
329
329
|
const index = discardedRunIndex(run.index_json, reason, now);
|
|
330
|
-
context.db.run("UPDATE
|
|
330
|
+
context.db.run("UPDATE runs SET verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?) WHERE project_id = ? AND id = ?", [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
|
|
331
331
|
if (fs.existsSync(run.runtime_path))
|
|
332
332
|
fs.writeFileSync(run.runtime_path, `${JSON.stringify(index, null, 2)}\n`);
|
|
333
333
|
return { kind: action.kind, changed: true, run_id: action.run_id };
|
|
@@ -339,11 +339,11 @@ function applyAction(context, action, reason, force, now) {
|
|
|
339
339
|
return { kind: action.kind, changed: false, skipped: true, reason: "runtime_or_workspace_reappeared", run_id: action.run_id };
|
|
340
340
|
}
|
|
341
341
|
const index = discardedRunIndex(run.index_json, reason, now);
|
|
342
|
-
context.db.run(`UPDATE
|
|
342
|
+
context.db.run(`UPDATE runs SET status = 'discarded', verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = ?
|
|
343
343
|
WHERE project_id = ? AND id = ? AND status = 'running'`, [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
|
|
344
344
|
fs.mkdirSync(run.run_dir, { recursive: true });
|
|
345
345
|
fs.writeFileSync(run.runtime_path, `${JSON.stringify(index, null, 2)}\n`);
|
|
346
|
-
context.db.run(`UPDATE
|
|
346
|
+
context.db.run(`UPDATE sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
347
347
|
WHERE project_id = ? AND run_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, action.project_id, action.run_id]);
|
|
348
348
|
context.db.run("UPDATE flow_session_segments SET ended_at = ? WHERE project_id = ? AND run_id = ? AND ended_at IS NULL", [now, action.project_id, action.run_id]);
|
|
349
349
|
appendAudit(context, {
|
|
@@ -398,7 +398,7 @@ function applyAction(context, action, reason, force, now) {
|
|
|
398
398
|
}
|
|
399
399
|
function discardedRunIndex(indexJson, reason, now) {
|
|
400
400
|
const index = JSON.parse(indexJson);
|
|
401
|
-
index.schema_id = "dd-flow/flow-run@
|
|
401
|
+
index.schema_id = "dd-flow/flow-run@3";
|
|
402
402
|
index.status = "discarded";
|
|
403
403
|
index.verdict = "discarded";
|
|
404
404
|
index.next_action = reason;
|
|
@@ -70,7 +70,7 @@ function isUpgradeAllowlisted(args) {
|
|
|
70
70
|
return true;
|
|
71
71
|
if (family === "run" && command === "start")
|
|
72
72
|
return optionValue(args, "flow-kind") === "mb-upgrade";
|
|
73
|
-
if (family === "stage" && ["start", "finish"].includes(command ?? ""))
|
|
73
|
+
if (family === "stage" && ["start", "pause", "resume", "finish"].includes(command ?? ""))
|
|
74
74
|
return hasRunBinding(args);
|
|
75
75
|
if (family === "session" && ["status", "register", "stop", "usage"].includes(command ?? ""))
|
|
76
76
|
return hasRunBinding(args);
|
|
@@ -101,7 +101,15 @@ function isRouterNative(args) {
|
|
|
101
101
|
return true;
|
|
102
102
|
if (["engine", "version", "schema"].includes(first))
|
|
103
103
|
return true;
|
|
104
|
-
return first === "codex" && args[1] === "hook" && args[2] === "handle"
|
|
104
|
+
return (first === "codex" && args[1] === "hook" && args[2] === "handle")
|
|
105
|
+
|| (first === "zcode" && args[1] === "event" && args[2] === "handle")
|
|
106
|
+
|| (first === "zcode" && args[1] === "usage" && args[2] === "ingest")
|
|
107
|
+
|| (first === "grok" && args[1] === "event" && args[2] === "handle")
|
|
108
|
+
|| (first === "grok" && args[1] === "usage" && args[2] === "ingest")
|
|
109
|
+
|| (first === "opencode" && args[1] === "event" && args[2] === "handle")
|
|
110
|
+
|| (first === "opencode" && args[1] === "usage" && args[2] === "ingest")
|
|
111
|
+
|| (first === "agy" && args[1] === "event" && args[2] === "handle")
|
|
112
|
+
|| (first === "agy" && args[1] === "usage" && args[2] === "ingest");
|
|
105
113
|
}
|
|
106
114
|
function isReadOnlyDiagnostic(family, command, action) {
|
|
107
115
|
if (!family)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
/**
|
|
7
|
+
* A local CODE gate must not share a mutable database with another checkout.
|
|
8
|
+
* The project command consumes this opaque suffix only for its local/test
|
|
9
|
+
* database target; preview and production-like targets remain unchanged.
|
|
10
|
+
*/
|
|
11
|
+
export function codeExecutionEnvironment(workspaceRoot) {
|
|
12
|
+
const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12);
|
|
13
|
+
return { ...process.env, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix };
|
|
14
|
+
}
|
|
15
|
+
export function validateCodeCheckCommands(workspaceRoot, commands) {
|
|
16
|
+
const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
17
|
+
// Keep one resolved command for every declared check. Two declarations may
|
|
18
|
+
// deliberately share a command while carrying different acceptance roles.
|
|
19
|
+
if (!fs.existsSync(file))
|
|
20
|
+
return [...commands];
|
|
21
|
+
const profile = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
22
|
+
if (profile.schema_id !== "dd-flow/code-check-profile@4")
|
|
23
|
+
throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: profile.schema_id ?? null });
|
|
24
|
+
const aliases = profile.aliases && typeof profile.aliases === "object" && !Array.isArray(profile.aliases)
|
|
25
|
+
? profile.aliases
|
|
26
|
+
: {};
|
|
27
|
+
const required = profile.require_alias_for && typeof profile.require_alias_for === "object" && !Array.isArray(profile.require_alias_for)
|
|
28
|
+
? profile.require_alias_for
|
|
29
|
+
: {};
|
|
30
|
+
return commands.map((command) => {
|
|
31
|
+
if (command.startsWith("@check/")) {
|
|
32
|
+
const template = aliases[command];
|
|
33
|
+
if (typeof template !== "string" || !template.trim())
|
|
34
|
+
throw new AppError("unknown_code_check_alias", "CODE check alias is not defined by the project profile", 2, { alias: command, file });
|
|
35
|
+
return command;
|
|
36
|
+
}
|
|
37
|
+
for (const [prefix, alias] of Object.entries(required)) {
|
|
38
|
+
if (command === prefix || command.startsWith(`${prefix} `)) {
|
|
39
|
+
throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: String(alias), file });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return command;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
|
|
46
|
+
const validated = validateCodeCheckCommands(workspaceRoot, commands);
|
|
47
|
+
const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
48
|
+
if (!fs.existsSync(file))
|
|
49
|
+
return validated;
|
|
50
|
+
const profile = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
51
|
+
const aliases = profile.aliases && typeof profile.aliases === "object" && !Array.isArray(profile.aliases)
|
|
52
|
+
? profile.aliases
|
|
53
|
+
: {};
|
|
54
|
+
return validated.map((command) => command.startsWith("@check/")
|
|
55
|
+
? String(aliases[command]).replaceAll("{run_id}", runId)
|
|
56
|
+
: command);
|
|
57
|
+
}
|
|
58
|
+
export async function runCodeChecks(context, input) {
|
|
59
|
+
const receipts = [];
|
|
60
|
+
for (const check of input.checks)
|
|
61
|
+
validateCheckDeclaration(input.workspaceRoot, check);
|
|
62
|
+
const commands = resolveCodeCheckCommands(input.workspaceRoot, input.runId, input.checks.map((check) => check.command));
|
|
63
|
+
for (let index = 0; index < commands.length; index += 1) {
|
|
64
|
+
const declaration = input.checks[index];
|
|
65
|
+
const command = commands[index];
|
|
66
|
+
const ordinal = (context.db.get("SELECT COUNT(*) AS count FROM check_receipts WHERE work_id IS ? AND run_id = ?", [input.workId ?? null, input.runId])?.count ?? 0) + 1;
|
|
67
|
+
const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
|
|
68
|
+
const id = `${input.workId ?? input.runId}/${localId}`;
|
|
69
|
+
const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
|
|
70
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
71
|
+
const stdoutPath = path.join(directory, "stdout.log");
|
|
72
|
+
const stderrPath = path.join(directory, "stderr.log");
|
|
73
|
+
const receiptPath = path.join(directory, "receipt.json");
|
|
74
|
+
const evidenceDir = path.join(directory, "artifacts");
|
|
75
|
+
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
76
|
+
const startedAt = context.now();
|
|
77
|
+
input.progress?.(`check ${index + 1}/${commands.length} started: ${command}`);
|
|
78
|
+
const stdout = fs.createWriteStream(stdoutPath);
|
|
79
|
+
const stderr = fs.createWriteStream(stderrPath);
|
|
80
|
+
const result = await runCheck(command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir }, stdout, stderr, (elapsed) => {
|
|
81
|
+
input.progress?.(`check ${index + 1}/${commands.length} still running (${elapsed}s): ${command}`);
|
|
82
|
+
});
|
|
83
|
+
await Promise.all([closeStream(stdout), closeStream(stderr)]);
|
|
84
|
+
const finishedAt = context.now();
|
|
85
|
+
const exitCode = result.exitCode;
|
|
86
|
+
const artifacts = collectRequiredArtifacts(evidenceDir, declaration.required_artifacts ?? []);
|
|
87
|
+
const status = exitCode === 0 && !result.error && artifacts.complete ? "passed" : "failed";
|
|
88
|
+
if (!artifacts.complete)
|
|
89
|
+
fs.appendFileSync(stderrPath, `\nmissing required evidence artifacts: ${artifacts.missing.join(", ")}\n`);
|
|
90
|
+
if (result.error)
|
|
91
|
+
fs.appendFileSync(stderrPath, `\n${result.error}\n`);
|
|
92
|
+
const receipt = {
|
|
93
|
+
id,
|
|
94
|
+
local_id: localId,
|
|
95
|
+
declaration_id: declaration.id,
|
|
96
|
+
scope: input.scope,
|
|
97
|
+
work_id: input.workId ?? null,
|
|
98
|
+
command,
|
|
99
|
+
status,
|
|
100
|
+
exit_code: exitCode,
|
|
101
|
+
stdout_path: stdoutPath,
|
|
102
|
+
stderr_path: stderrPath,
|
|
103
|
+
receipt_path: receiptPath,
|
|
104
|
+
workspace_fingerprint: workspaceFingerprint(input.workspaceRoot),
|
|
105
|
+
artifacts: artifacts.items,
|
|
106
|
+
started_at: startedAt,
|
|
107
|
+
finished_at: finishedAt
|
|
108
|
+
};
|
|
109
|
+
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
110
|
+
context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, command, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, declaration.id, command, status, exitCode, stdoutPath, stderrPath, receiptPath, receipt.workspace_fingerprint, JSON.stringify(receipt.artifacts), startedAt, finishedAt]);
|
|
111
|
+
receipts.push(receipt);
|
|
112
|
+
input.progress?.(`check ${index + 1}/${commands.length} ${status}: ${command}`);
|
|
113
|
+
}
|
|
114
|
+
return receipts;
|
|
115
|
+
}
|
|
116
|
+
export function aggregateCheckDeclarations(workspaceRoot) {
|
|
117
|
+
const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
118
|
+
if (!fs.existsSync(file))
|
|
119
|
+
return [];
|
|
120
|
+
const profile = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
121
|
+
if (profile.schema_id !== "dd-flow/code-check-profile@4")
|
|
122
|
+
throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: profile.schema_id ?? null });
|
|
123
|
+
if (!Array.isArray(profile.aggregate_commands) || !profile.aggregate_commands.every((command) => typeof command === "string" && command.trim()))
|
|
124
|
+
throw new AppError("invalid_code_check_profile", "aggregate_commands must be an array of commands", 2, { file });
|
|
125
|
+
return profile.aggregate_commands.map((command, index) => ({ id: `CHK-POLICY-CODE-${String(index + 1).padStart(3, "0")}`, command, purpose: "Mandatory project-wide policy gate.", run_at: "code", availability: "available" }));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A project-wide gate can observe files owned by more than one CODE Work.
|
|
129
|
+
* Keep it at the CODE/readiness fan-in, where the engine can create a repair
|
|
130
|
+
* Work with the failed receipt and the right project-local repair context.
|
|
131
|
+
*/
|
|
132
|
+
export function validateCheckPlacement(workspaceRoot, checks) {
|
|
133
|
+
const aggregate = new Set(aggregateCheckDeclarations(workspaceRoot).map((check) => check.command));
|
|
134
|
+
for (const check of checks) {
|
|
135
|
+
if (check.run_at === "work" && check.availability === "available" && aggregate.has(check.command)) {
|
|
136
|
+
throw new AppError("aggregate_check_requires_code_gate", "A project aggregate check must run at CODE or readiness, not inside one scoped Work", 2, {
|
|
137
|
+
check_id: check.id,
|
|
138
|
+
command: check.command,
|
|
139
|
+
run_at: check.run_at
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function checksForRunAt(checks, runAt) {
|
|
145
|
+
return checks.filter((check) => check.run_at === runAt);
|
|
146
|
+
}
|
|
147
|
+
/** One SSOT for the final CODE gate, reused after CODE-REVIEW repairs. */
|
|
148
|
+
export function finalCodeCheckDeclarations(workspaceRoot, declared) {
|
|
149
|
+
const selected = [...aggregateCheckDeclarations(workspaceRoot), ...declared.filter((check) => check.run_at === "code" || check.run_at === "readiness")];
|
|
150
|
+
const unique = new Map();
|
|
151
|
+
for (const check of selected)
|
|
152
|
+
if (!unique.has(check.id))
|
|
153
|
+
unique.set(check.id, check);
|
|
154
|
+
return [...unique.values()];
|
|
155
|
+
}
|
|
156
|
+
/** Prevent an unchanged failed stage gate from rerunning expensive commands. */
|
|
157
|
+
export function unchangedFinalGateFailures(context, input) {
|
|
158
|
+
const wanted = new Set(input.declarations.map((check) => check.id));
|
|
159
|
+
const current = workspaceFingerprint(input.workspaceRoot);
|
|
160
|
+
const latest = new Map();
|
|
161
|
+
for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((item) => item.scope === "aggregate" && wanted.has(item.declaration_id)))
|
|
162
|
+
latest.set(receipt.declaration_id, receipt);
|
|
163
|
+
return [...latest.values()].filter((receipt) => receipt.status === "failed" && receipt.workspace_fingerprint === current);
|
|
164
|
+
}
|
|
165
|
+
export function validateCheckDeclaration(workspaceRoot, check) {
|
|
166
|
+
if (check.availability !== "planned") {
|
|
167
|
+
validateCodeCheckCommands(workspaceRoot, [check.command]);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!check.command.startsWith("@check/")) {
|
|
171
|
+
throw new AppError("planned_check_requires_alias", "A planned CODE check must declare a new @check/... alias", 2, { check_id: check.id, command: check.command });
|
|
172
|
+
}
|
|
173
|
+
if (!check.definition?.trim()) {
|
|
174
|
+
throw new AppError("planned_check_definition_missing", "A planned CODE check must declare its exact alias definition", 2, { check_id: check.id, command: check.command });
|
|
175
|
+
}
|
|
176
|
+
const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
177
|
+
const profile = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
|
|
178
|
+
const aliases = profile.aliases && typeof profile.aliases === "object" && !Array.isArray(profile.aliases) ? profile.aliases : {};
|
|
179
|
+
const actual = aliases[check.command];
|
|
180
|
+
if (typeof actual !== "string")
|
|
181
|
+
throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
|
|
182
|
+
if (check.definition && actual !== check.definition)
|
|
183
|
+
throw new AppError("planned_check_definition_mismatch", "A planned check alias does not match its declared definition", 2, { check_id: check.id, command: check.command, expected: check.definition, actual });
|
|
184
|
+
}
|
|
185
|
+
function collectRequiredArtifacts(root, required) {
|
|
186
|
+
const missing = [];
|
|
187
|
+
const items = required.map((relative) => {
|
|
188
|
+
if (path.isAbsolute(relative) || relative.split(path.sep).includes(".."))
|
|
189
|
+
throw new AppError("invalid_evidence_path", "Required evidence paths must be relative to DD_FLOW_EVIDENCE_DIR", 2, { path: relative });
|
|
190
|
+
const file = path.join(root, relative);
|
|
191
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
192
|
+
missing.push(relative);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return { path: relative, sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") };
|
|
196
|
+
}).filter((item) => item !== null);
|
|
197
|
+
return { complete: missing.length === 0, missing, items };
|
|
198
|
+
}
|
|
199
|
+
export function workspaceFingerprint(root) {
|
|
200
|
+
const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
201
|
+
const listing = git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root);
|
|
202
|
+
const hash = crypto.createHash("sha256");
|
|
203
|
+
for (const relative of listing) {
|
|
204
|
+
const file = path.join(root, relative);
|
|
205
|
+
hash.update(relative).update("\0");
|
|
206
|
+
hash.update(fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : "<missing>");
|
|
207
|
+
hash.update("\0");
|
|
208
|
+
}
|
|
209
|
+
return hash.digest("hex");
|
|
210
|
+
}
|
|
211
|
+
function listFiles(root, current = root) {
|
|
212
|
+
return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => {
|
|
213
|
+
if ([".git", "node_modules"].includes(entry.name))
|
|
214
|
+
return [];
|
|
215
|
+
const absolute = path.join(current, entry.name);
|
|
216
|
+
return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)];
|
|
217
|
+
}).sort();
|
|
218
|
+
}
|
|
219
|
+
function runCheck(command, cwd, environment, stdout, stderr, heartbeat) {
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
const started = Date.now();
|
|
222
|
+
const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"] });
|
|
223
|
+
child.stdout.pipe(stdout, { end: false });
|
|
224
|
+
child.stderr.pipe(stderr, { end: false });
|
|
225
|
+
const progress = setInterval(() => heartbeat(Math.floor((Date.now() - started) / 1000)), 15_000);
|
|
226
|
+
let timedOut = false;
|
|
227
|
+
const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, 15 * 60 * 1000);
|
|
228
|
+
let error = null;
|
|
229
|
+
child.once("error", (value) => { error = value.message; });
|
|
230
|
+
child.once("close", (exitCode, signal) => {
|
|
231
|
+
clearInterval(progress);
|
|
232
|
+
clearTimeout(timeout);
|
|
233
|
+
resolve({ exitCode, error: error ?? (timedOut ? "timed out after 900 seconds" : signal ? `terminated by ${signal}` : null) });
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
function closeStream(stream) {
|
|
238
|
+
return new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve()));
|
|
239
|
+
}
|
|
240
|
+
export function checkReceipts(context, input) {
|
|
241
|
+
const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
|
|
242
|
+
const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
|
|
243
|
+
return context.db.all(`SELECT id, declaration_id, scope, work_id, command, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((receipt) => ({ ...receipt, local_id: receipt.id.slice(receipt.id.lastIndexOf("/") + 1), artifacts: JSON.parse(receipt.artifacts_json) }));
|
|
244
|
+
}
|
package/dist/services/config.js
CHANGED
|
@@ -38,7 +38,10 @@ export function setProjectConfigValue(context, project, input) {
|
|
|
38
38
|
return getProjectConfigStatus(context, project);
|
|
39
39
|
}
|
|
40
40
|
export function readProjectConfig(context, projectId) {
|
|
41
|
-
|
|
41
|
+
// Status and dashboard reads intentionally open SQLite read-only. Legacy
|
|
42
|
+
// cleanup is a write-side concern and must not make those commands fail.
|
|
43
|
+
if (context.db.writable)
|
|
44
|
+
migrateLegacyConfig(context, projectId);
|
|
42
45
|
const config = structuredClone(defaultProjectConfig);
|
|
43
46
|
for (const row of configOverrides(context, projectId)) {
|
|
44
47
|
applyConfigValue(config, row.key, JSON.parse(row.value_json));
|
|
@@ -139,6 +142,9 @@ function applyConfigValue(config, key, value) {
|
|
|
139
142
|
}
|
|
140
143
|
}
|
|
141
144
|
function migrateLegacyConfig(context, projectId) {
|
|
145
|
+
// Handoff is now frozen from project-workspace.json for vNext. Delete the
|
|
146
|
+
// old runtime override rather than leaving a misleading inert setting.
|
|
147
|
+
context.db.run("DELETE FROM project_config WHERE project_id = ? AND key = 'execution.stage_handoff'", [projectId]);
|
|
142
148
|
const legacyRows = context.db.all(`SELECT key, value_json, updated_at FROM project_config
|
|
143
149
|
WHERE project_id = ? AND key IN (
|
|
144
150
|
'integrations.cmux.dashboard',
|
|
@@ -380,7 +380,7 @@ function buildGlobalDashboardData(context, htmlPath) {
|
|
|
380
380
|
metric("total_projects", activeProjects.length, "known", "projects.status=active"),
|
|
381
381
|
metric("unsupported_projects", unsupportedProjects.filter((project) => project.status === "active").length, "known", "project summaries"),
|
|
382
382
|
metric("active_protocols", projectCards.reduce((sum, card) => sum + Number(card.metrics.active_protocols), 0), "known", "protocols"),
|
|
383
|
-
metric("running_sessions", projectCards.reduce((sum, card) => sum + Number(card.metrics.sessions), 0), "known", "
|
|
383
|
+
metric("running_sessions", projectCards.reduce((sum, card) => sum + Number(card.metrics.sessions), 0), "known", "sessions"),
|
|
384
384
|
metric("active_locks", projectCards.reduce((sum, card) => sum + Number(card.metrics.locks), 0), "known", "lane_locks"),
|
|
385
385
|
metric("open_defs", projectCards.reduce((sum, card) => sum + Number(card.metrics.open_defs), 0), "known", "protocols.active_def/blockers")
|
|
386
386
|
],
|
|
@@ -578,8 +578,8 @@ function buildProjectDashboardData(context, project, htmlPath) {
|
|
|
578
578
|
metric("active_locks", locks.filter((lock) => lock.status === "active").length, "known", "lane_locks"),
|
|
579
579
|
metric("queued_waiters", waiters.filter((waiter) => waiter.status === "queued").length, "known", "lane_waiters"),
|
|
580
580
|
metric("open_defs", openDefs, "known", "protocols.active_def/blockers"),
|
|
581
|
-
metric("running_sessions", sessions.length, "known", "
|
|
582
|
-
metric("review_runs", reviewRuns.length, "known", "
|
|
581
|
+
metric("running_sessions", sessions.length, "known", "sessions"),
|
|
582
|
+
metric("review_runs", reviewRuns.length, "known", "runs.flow_kind=mb-sdlc-review|review")
|
|
583
583
|
],
|
|
584
584
|
lifecycle_summary: lifecycleSummary(activeProtocols),
|
|
585
585
|
resource_summary: resourceSummary(queue, locks, waiters),
|
|
@@ -625,7 +625,7 @@ function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
|
|
|
625
625
|
const queueItem = queueForProject(context, project.id).find((item) => item.protocol_id === protocolId) ?? null;
|
|
626
626
|
const runDiagnostics = protocolRunDiagnostics(context, runtimeProtocol, runtimeState);
|
|
627
627
|
const runs = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
|
|
628
|
-
FROM
|
|
628
|
+
FROM runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
|
|
629
629
|
ORDER BY updated_at DESC, id DESC`, [project.id, protocolId]);
|
|
630
630
|
const warnings = [...runDiagnostics.diagnostics];
|
|
631
631
|
const canonicalRuns = runs.filter((run) => {
|
|
@@ -714,11 +714,11 @@ function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
|
|
|
714
714
|
run_history: runHistory,
|
|
715
715
|
stage_pipeline: latestStages(runHistory),
|
|
716
716
|
metrics: [
|
|
717
|
-
metric("runs", canonicalRuns.length, "known", "
|
|
717
|
+
metric("runs", canonicalRuns.length, "known", "runs"),
|
|
718
718
|
metric("open_defs", jsonArray(protocol.active_def_json).length, "known", "protocols.active_def_json"),
|
|
719
719
|
metric("blockers", jsonArray(protocol.blockers_json).length, "known", "protocols.blockers_json"),
|
|
720
|
-
metric("completed_runs", canonicalRuns.filter((run) => run.status === "done").length, "known", "
|
|
721
|
-
metric("review_runs", reviewRuns.length, "known", "
|
|
720
|
+
metric("completed_runs", canonicalRuns.filter((run) => run.status === "done").length, "known", "runs"),
|
|
721
|
+
metric("review_runs", reviewRuns.length, "known", "runs.flow_kind=mb-sdlc-review|review")
|
|
722
722
|
],
|
|
723
723
|
warnings
|
|
724
724
|
};
|
|
@@ -734,7 +734,7 @@ function renderProjectDashboardMarkdown(context, project, output) {
|
|
|
734
734
|
const worktrees = context.db.all(`SELECT protocol_id, feature_branch, worktree_path, bootstrap_status, status, updated_at
|
|
735
735
|
FROM worktree_records WHERE project_id = ? ORDER BY updated_at DESC LIMIT 10`, [project.id]);
|
|
736
736
|
const hookEvents = context.db.all(`SELECT protocol_id, session_id, event_name, tool_name, status, sanitized_summary, created_at
|
|
737
|
-
FROM
|
|
737
|
+
FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 10`, [project.id]);
|
|
738
738
|
const activeDefs = protocols.flatMap((protocol) => jsonArray(protocol.active_def_json).map((entry) => ({ protocol: protocol.id, entry })));
|
|
739
739
|
const blockers = protocols.flatMap((protocol) => jsonArray(protocol.blockers_json).map((entry) => ({ protocol: protocol.id, entry })));
|
|
740
740
|
const activeLocks = locks.filter((lock) => lock.status === "active");
|
|
@@ -1053,7 +1053,7 @@ function buildProtocolCard(context, project, protocol, generatePage) {
|
|
|
1053
1053
|
const activeDefCount = jsonArray(protocol.active_def_json).length;
|
|
1054
1054
|
const blockerCount = jsonArray(protocol.blockers_json).length;
|
|
1055
1055
|
const runs = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
|
|
1056
|
-
FROM
|
|
1056
|
+
FROM runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
|
|
1057
1057
|
ORDER BY updated_at DESC, id DESC`, [project.id, protocol.id]);
|
|
1058
1058
|
const latestRun = runs.find((run) => isCanonicalRun(run));
|
|
1059
1059
|
let diagnostics = [];
|
|
@@ -1102,7 +1102,7 @@ function buildProtocolCard(context, project, protocol, generatePage) {
|
|
|
1102
1102
|
}
|
|
1103
1103
|
function recentReviewRunsForProject(context, projectId, limit) {
|
|
1104
1104
|
const runs = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
|
|
1105
|
-
FROM
|
|
1105
|
+
FROM runs
|
|
1106
1106
|
WHERE project_id = ? AND flow_kind IN ('mb-sdlc-review', 'review')
|
|
1107
1107
|
ORDER BY updated_at DESC, id DESC
|
|
1108
1108
|
LIMIT ?`, [projectId, limit]);
|
|
@@ -1110,7 +1110,7 @@ function recentReviewRunsForProject(context, projectId, limit) {
|
|
|
1110
1110
|
}
|
|
1111
1111
|
function recentReviewRunsForProtocol(context, projectId, protocolId, limit) {
|
|
1112
1112
|
const runs = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
|
|
1113
|
-
FROM
|
|
1113
|
+
FROM runs
|
|
1114
1114
|
WHERE project_id = ?
|
|
1115
1115
|
AND flow_kind IN ('mb-sdlc-review', 'review')
|
|
1116
1116
|
AND subject_type = 'protocol'
|
|
@@ -1258,7 +1258,7 @@ function safeRunIndex(text, warnings, runId) {
|
|
|
1258
1258
|
function isCanonicalRun(run) {
|
|
1259
1259
|
try {
|
|
1260
1260
|
const parsed = JSON.parse(run.index_json);
|
|
1261
|
-
return parsed?.schema_id === "dd-flow/flow-run@
|
|
1261
|
+
return parsed?.schema_id === "dd-flow/flow-run@3";
|
|
1262
1262
|
}
|
|
1263
1263
|
catch {
|
|
1264
1264
|
return false;
|
package/dist/services/engines.js
CHANGED
|
@@ -164,7 +164,7 @@ export function routeArgsThroughEngine(context, args, io, stdin, env, resolvedPr
|
|
|
164
164
|
const entrypoint = resolveManifestEntrypoint(bound.manifest);
|
|
165
165
|
return spawnEngine(bound.manifest, entrypoint, args, io, stdin, env, getCliBuildInfo().version);
|
|
166
166
|
}
|
|
167
|
-
const selection = selectEngine(context, { projectRoot: resolvedProjectRoot ?? resolveExplicitProjectRoot(args) ?? undefined }, { allowCurrentInProcess: true }, classification);
|
|
167
|
+
const selection = selectEngine(context, { projectRoot: resolvedProjectRoot ?? resolveExplicitProjectRoot(args) ?? undefined, env }, { allowCurrentInProcess: true }, classification);
|
|
168
168
|
if (selection.status !== "selected") {
|
|
169
169
|
if (classification.mode === "read_only_diagnostics" || (classification.mode === "mb_upgrade" && isUpgradeDiagnostic(args))) {
|
|
170
170
|
return Promise.resolve(null);
|