@deksden-com/dd-flow-cli 0.9.0-beta.64 → 0.9.0-beta.74
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/dist/build-info.json +3 -3
- package/dist/cli/help.js +2 -0
- package/dist/cli/run-cli.js +15 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +4 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +4 -2
- package/dist/harness-runtime/bin/dd-droid.mjs +4 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -2
- package/dist/harness-runtime/bin/dd-opencode.mjs +4 -2
- package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
- package/dist/harness-runtime/lib/daemon-operations.mjs +108 -10
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +5 -4
- package/dist/harness-runtime/lib/dd-codex.mjs +38 -4
- package/dist/harness-runtime/lib/dd-droid.mjs +55 -20
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +3 -2
- package/dist/harness-runtime/lib/dd-grok.mjs +14 -8
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +43 -8
- package/dist/harness-runtime/lib/dd-opencode.mjs +5 -4
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +182 -16
- package/dist/harness-runtime/lib/dd-zcode.mjs +162 -63
- package/dist/harness-runtime/lib/driver-recovery.mjs +2 -2
- package/dist/harness-runtime/lib/managed-daemon.mjs +27 -5
- package/dist/harness-runtime/lib/operation-errors.mjs +1 -1
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/services/code-checks.js +120 -19
- package/dist/services/controller-fanout.js +11 -0
- package/dist/services/engines.js +40 -9
- package/dist/services/eval-snapshots.js +30 -47
- package/dist/services/harness-adapter.js +12 -6
- package/dist/services/lifecycle-invocations.js +12 -8
- package/dist/services/native-session-control.js +6 -0
- package/dist/services/projects.js +6 -2
- package/dist/services/recovery-observation-budget.js +9 -1
- package/dist/services/run-control-worker.js +19 -0
- package/dist/services/run-control.js +87 -13
- package/dist/services/run-controller-adapter.js +134 -7
- package/dist/services/run-controller.js +11 -3
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/runs.js +1 -11
- package/dist/services/schema-validation.js +1 -1
- package/dist/services/stage-lifecycle.js +14 -2
- package/dist/services/usage.js +20 -87
- package/dist/services/vnext-code-review.js +35 -27
- package/dist/services/vnext-code.js +62 -26
- package/dist/services/vnext-merge.js +8 -8
- package/dist/services/vnext-plan-review.js +16 -16
- package/dist/services/vnext-plan.js +51 -11
- package/dist/services/work-registry.js +48 -27
- package/dist/shared/errors.js +12 -0
- package/package.json +1 -1
|
@@ -2,7 +2,6 @@ import { managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { execFileSync } from "node:child_process";
|
|
6
5
|
import { AppError } from "../shared/errors.js";
|
|
7
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
8
7
|
import { requireProjectByRoot } from "./projects.js";
|
|
@@ -12,9 +11,9 @@ import { flowCommand } from "./stage-pause.js";
|
|
|
12
11
|
import { assertStageStartHookEvent } from "./hooks.js";
|
|
13
12
|
import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
|
|
14
13
|
import { addVnextCodeRepair, resumeAcceptedCodeStage } from "./vnext-code.js";
|
|
15
|
-
import { finalCodeCheckDeclarations, runCodeChecks, validateFailedCheckRetry,
|
|
14
|
+
import { finalCodeCheckDeclarations, runCodeChecks, validateFailedCheckRetry, workspaceChangedPaths } from "./code-checks.js";
|
|
16
15
|
import { addWorkBatch, bindStageCoordinatorWork, finishStageCoordinatorWork, refreshRunWorkProjection } from "./work-registry.js";
|
|
17
|
-
import { withStageSettlement
|
|
16
|
+
import { withStageSettlement } from "./runs.js";
|
|
18
17
|
import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
19
18
|
import { writeStageReport } from "./stage-report-renderer.js";
|
|
20
19
|
import { applyExternalStageContext } from "./stage-context.js";
|
|
@@ -55,7 +54,7 @@ export function startVnextCodeReview(context, input) {
|
|
|
55
54
|
freezeFlowRunReviewMode(context, { projectRoot, runId: run.id, review: "code", mode: mode.mode, source: mode.source, reason: mode.reason });
|
|
56
55
|
const promptPath = path.join(root, "stage-prompt.md");
|
|
57
56
|
const reviewContextPath = path.join(root, "review-context.json");
|
|
58
|
-
fs.writeFileSync(reviewContextPath, `${JSON.stringify({ schema_id: "dd-flow/code-review-context@1", cycle, mode: mode.mode, mode_source: mode.source, mode_reason: mode.reason,
|
|
57
|
+
fs.writeFileSync(reviewContextPath, `${JSON.stringify({ schema_id: "dd-flow/code-review-context@1", cycle, mode: mode.mode, mode_source: mode.source, mode_reason: mode.reason, ...(mode.mode !== "off" ? { read_only_input: { parent_work_id: rootWork.work_id } } : {}), code_report_sha256: sha256File(path.join(home, "05-code", "stage-report.json")) }, null, 2)}\n`);
|
|
59
58
|
if (mode.mode === "off") {
|
|
60
59
|
const prompt = `<stage_identity>\n- RUN: ${run.id}\n- stage: code-review\n- mode: off\n</stage_identity>\n\nCODE-REVIEW is disabled by the frozen RUN configuration. Finish with: ${finishCommand(context, run.id, projectRoot, path.join(root, "decision.json"))}\n`;
|
|
61
60
|
fs.writeFileSync(promptPath, prompt);
|
|
@@ -120,23 +119,23 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
120
119
|
const run = requireRun(context, project.id, input.runId);
|
|
121
120
|
const home = requireHome(run);
|
|
122
121
|
const root = path.join(home, stageDir);
|
|
122
|
+
const generation = assertRunMutationAllowed(context, project.id, run.id);
|
|
123
|
+
const reportPath = path.join(root, "stage-report.json");
|
|
124
|
+
if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath)) {
|
|
125
|
+
const report = readJson(reportPath);
|
|
126
|
+
const next = report.semantic?.next_action ?? "code_review_completed";
|
|
127
|
+
resumeAcceptedCodeStage(context, run, projectRoot, stage, report, next);
|
|
128
|
+
return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: reportPath, next_action: next };
|
|
129
|
+
}
|
|
123
130
|
const reviewContext = readJson(path.join(root, "review-context.json"));
|
|
124
131
|
const mode = reviewContext.mode;
|
|
125
132
|
if (!mode)
|
|
126
133
|
throw new AppError("runtime_missing", "CODE-REVIEW effective mode was not frozen at stage start", 1);
|
|
127
|
-
const generation = assertRunMutationAllowed(context, project.id, run.id);
|
|
128
134
|
if (reviewContext.code_report_sha256 !== sha256File(path.join(home, "05-code", "stage-report.json")))
|
|
129
135
|
throw new AppError("code_review_input_changed", "Accepted CODE report changed after CODE-REVIEW start", 2);
|
|
130
136
|
const handoffFailures = validateVnextCodeHandoff(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home });
|
|
131
137
|
if (handoffFailures.length)
|
|
132
138
|
throw new AppError("validation", "CODE-REVIEW handoff no longer matches the accepted PLAN", 2, { errors: handoffFailures });
|
|
133
|
-
const reportPath = path.join(root, "stage-report.json");
|
|
134
|
-
if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath)) {
|
|
135
|
-
const report = readJson(reportPath);
|
|
136
|
-
const next = report.semantic?.next_action ?? "code_review_completed";
|
|
137
|
-
resumeAcceptedCodeStage(context, run, projectRoot, stage, report, next);
|
|
138
|
-
return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: reportPath, next_action: next };
|
|
139
|
-
}
|
|
140
139
|
const rootWork = requireRootWork(context, project.id, run.id);
|
|
141
140
|
const coordinator = context.db.get("SELECT session_id, result_path FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [rootWork.work_id]);
|
|
142
141
|
if (!coordinator || path.resolve(coordinator.result_path ?? "") !== path.resolve(reportPath))
|
|
@@ -144,8 +143,8 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
144
143
|
const decisionFile = input.decisionFile ?? path.join(root, "decision.json");
|
|
145
144
|
if (!fs.existsSync(decisionFile) && mode === "off")
|
|
146
145
|
fs.writeFileSync(decisionFile, `${JSON.stringify({ schema_id: "dd-flow/code-review-decision@3", summary: "CODE-REVIEW is disabled by RUN configuration.", findings: [] }, null, 2)}\n`);
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
const decision = readCodeReviewDecision(decisionFile, reviewContext.accepted_decision, Boolean(input.decisionFile));
|
|
147
|
+
validateSchema({ schemaName: "code-review-decision", file: decisionFile, data: decision, projectRoot: run.workspace_root, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: home });
|
|
149
148
|
const cycle = reviewContext.cycle ?? "initial";
|
|
150
149
|
const reviewers = reviewerWorks(context, project.id, run.id, cycle);
|
|
151
150
|
if (mode !== "off") {
|
|
@@ -158,15 +157,18 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
158
157
|
const canonical = reviewDecisionForRepair(decision, reviewers);
|
|
159
158
|
const checkRefsByFinding = validateRepairChecks(context, { projectId: project.id, runId: run.id, decision: canonical.decision });
|
|
160
159
|
let repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
|
|
161
|
-
const decisionSha = sha256File(decisionFile);
|
|
162
160
|
const frozenShaFile = path.join(root, ".decision-sha256");
|
|
163
|
-
if (fs.existsSync(frozenShaFile) && fs.readFileSync(frozenShaFile, "utf8").trim() !==
|
|
161
|
+
if (!reviewContext.accepted_decision && fs.existsSync(frozenShaFile) && fs.readFileSync(frozenShaFile, "utf8").trim() !== sha256File(decisionFile))
|
|
164
162
|
throw new AppError("review_decision_changed", "The accepted CODE-REVIEW decision cannot change during repair", 2, { decision_file: decisionFile });
|
|
165
163
|
const fixIds = canonical.fix_ids;
|
|
166
164
|
if (fixIds.length && repairs.length === 0) {
|
|
165
|
+
// Accept the value once. Repairs consume this decision, not a mutable
|
|
166
|
+
// operator file. Reuse the existing context artifact and review cycle.
|
|
167
|
+
const contextFile = path.join(root, "review-context.json"), temporary = `${contextFile}.tmp-${crypto.randomUUID()}`;
|
|
168
|
+
fs.writeFileSync(temporary, `${JSON.stringify({ ...reviewContext, accepted_decision: decision }, null, 2)}\n`);
|
|
169
|
+
fs.renameSync(temporary, contextFile);
|
|
167
170
|
const repair = addVnextCodeReviewRepair(context, { projectRoot, runId: run.id, findingIds: fixIds, checkRefsByFinding, decisionReasonsByFinding: Object.fromEntries(canonical.decision.findings.map((item) => [item.finding_ref, item.reason])), objective: `Resolve accepted CODE-REVIEW findings: ${fixIds.join(", ")}` });
|
|
168
|
-
|
|
169
|
-
return { ok: true, run_id: run.id, stage, outcome: "repair_required", decision_sha256: decisionSha, repair, instruction: "Run the returned repair Work in one fresh child session. When it completes, invoke next.finish_command with the unchanged decision file.", next: { finish_command: finishCommand(context, run.id, projectRoot, decisionFile) } };
|
|
171
|
+
return { ok: true, run_id: run.id, stage, outcome: "repair_required", repair, instruction: "Run the returned repair Work in one fresh child session. When it completes, invoke next.finish_command; this review cycle uses the decision already accepted by the runtime.", next: { finish_command: finishCommand(context, run.id, projectRoot, decisionFile) } };
|
|
170
172
|
}
|
|
171
173
|
repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
|
|
172
174
|
const incompleteRepairs = repairs.filter((work) => work.status !== "completed");
|
|
@@ -175,7 +177,6 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
175
177
|
validateDecision(context, { decision: canonical.decision, reviewers: canonical.reviewers, repairs, workspaceRoot: run.workspace_root });
|
|
176
178
|
}
|
|
177
179
|
const outcome = decision.findings.some((item) => item.disposition === "defer") ? "accepted_with_DEF" : "accepted";
|
|
178
|
-
const assertInputs = stageInputGuard(context, project.id, run.id, () => [workspaceFingerprint(run.workspace_root), sha256File(decisionFile), sha256File(path.join(home, "05-code", "stage-report.json"))]);
|
|
179
180
|
const repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
|
|
180
181
|
const finalChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.checks) ? payload.checks : []; }));
|
|
181
182
|
if (input.retryCheckId) {
|
|
@@ -202,11 +203,15 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
202
203
|
// merge-gate error creates an irrecoverable `done` report with no MRG-*.
|
|
203
204
|
const acceptedPaths = changedPaths(run.workspace_root);
|
|
204
205
|
let mergeRequest = null;
|
|
205
|
-
|
|
206
|
+
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
206
207
|
const prepareMerge = nextAction === "start_merge" ? prepareVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths }) : null;
|
|
207
208
|
withStageSettlement(context, () => {
|
|
208
209
|
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
209
|
-
|
|
210
|
+
const currentReviewers = reviewerWorks(context, project.id, run.id, cycle);
|
|
211
|
+
const currentRepairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
|
|
212
|
+
if (currentReviewers.length !== reviewers.length || currentRepairs.length !== repairs.length || [...currentReviewers, ...currentRepairs].some(work => work.status !== "completed" || ![...reviewers, ...repairs].some(accepted => accepted.work_id === work.work_id && accepted.result === work.result))) {
|
|
213
|
+
throw new AppError("reviewer_jobs_incomplete", "Review or repair results changed while the final checks were running", 2);
|
|
214
|
+
}
|
|
210
215
|
mergeRequest = prepareMerge?.() ?? null;
|
|
211
216
|
writeReport(root, report);
|
|
212
217
|
completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
|
|
@@ -221,6 +226,14 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
221
226
|
});
|
|
222
227
|
return { ok: true, run_id: run.id, stage, outcome, report_path: path.join(root, "stage-report.json"), next_action: nextAction, ...(mergeRequest ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } } : {}) };
|
|
223
228
|
}
|
|
229
|
+
export function readCodeReviewDecision(file, accepted, explicit = false) {
|
|
230
|
+
if (!accepted)
|
|
231
|
+
return readJson(file);
|
|
232
|
+
if (explicit && fs.existsSync(file) && JSON.stringify(readJson(file)) !== JSON.stringify(accepted)) {
|
|
233
|
+
throw new AppError("review_decision_changed", "This review cycle already accepted a different decision", 2, { decision_file: file });
|
|
234
|
+
}
|
|
235
|
+
return accepted;
|
|
236
|
+
}
|
|
224
237
|
export function reviewGroups(home) {
|
|
225
238
|
const expected = new Set(baselineAspects);
|
|
226
239
|
const declared = [];
|
|
@@ -390,12 +403,7 @@ function effectiveMode(home, run) { const index = JSON.parse(run.index_json); if
|
|
|
390
403
|
return { mode: "standard", source: "project_policy", reason: "A failed integration gate requires independent review of its source repair." }; const configured = index.settings?.code_review; const requested = configured?.mode ?? "auto"; if (requested !== "auto")
|
|
391
404
|
return { mode: requested, source: configured?.source === "user_instruction" ? "user_instruction" : "project_policy", reason: configured?.reason ?? "Frozen RUN configuration." }; const assessments = findFiles(path.join(home, "03-plan"), "plan.json").map((file) => readJson(file)); const deep = assessments.some((plan) => plan.assessment?.failure_impact?.level === "high" || plan.assessment?.solution_uncertainty?.level === "high"); return { mode: deep ? "deep" : "standard", source: "plan_assessment", reason: deep ? "Accepted PLAN assessment records high impact or uncertainty." : "Accepted PLAN assessment requires the standard independent review." }; }
|
|
392
405
|
function executionStopTarget(run) { return JSON.parse(run.index_json).execution_profile?.settings?.stop_target ?? "code_review_completed"; }
|
|
393
|
-
function changedPaths(workspaceRoot) {
|
|
394
|
-
return execFileSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd: workspaceRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split(/\r?\n/).filter(Boolean).map((line) => line.slice(3).replace(/^.* -> /, ""));
|
|
395
|
-
}
|
|
396
|
-
catch {
|
|
397
|
-
return [];
|
|
398
|
-
} }
|
|
406
|
+
function changedPaths(workspaceRoot) { return workspaceChangedPaths(workspaceRoot); }
|
|
399
407
|
function sha256File(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
400
408
|
function stageStatus(run, name) { try {
|
|
401
409
|
return JSON.parse(run.index_json).stage_runs?.find((item) => item.stage === name)?.status ?? null;
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
-
import { stageInputGuard } from "./runs.js";
|
|
4
|
-
import { workspaceFingerprint } from "./code-checks.js";
|
|
5
3
|
import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
|
|
6
4
|
import fs from "node:fs";
|
|
7
5
|
import path from "node:path";
|
|
@@ -25,6 +23,22 @@ import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
|
|
|
25
23
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
26
24
|
const stage = "code";
|
|
27
25
|
const stageDir = vnextStageDirectory("code");
|
|
26
|
+
/** A controller prepares slow, deterministic project tooling before it asks an
|
|
27
|
+
* agent to enter CODE or a restored CODE_REVIEW. The subsequent lifecycle tool call remains the sole
|
|
28
|
+
* authority for attaching and binding the Stage. */
|
|
29
|
+
export async function prepareVnextCodeWorkspace(context, input) {
|
|
30
|
+
if (context.env.DD_FLOW_CODE_READINESS_OWNER !== input.ownerId) {
|
|
31
|
+
throw new AppError("code_readiness_owner_mismatch", "CODE readiness preparation must use its controller's frozen owner", 1, { owner_id: input.ownerId });
|
|
32
|
+
}
|
|
33
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
34
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
35
|
+
const run = requireRun(context, project.id, input.runId);
|
|
36
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
37
|
+
requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, stage: input.stage ?? stage });
|
|
38
|
+
const root = path.join(requireHome(run), stageDir);
|
|
39
|
+
fs.mkdirSync(root, { recursive: true });
|
|
40
|
+
return ensureCodeWorkspaceReady(context, project.id, run, root, [], input.progress, { force: input.stage !== "code-review" });
|
|
41
|
+
}
|
|
28
42
|
export async function startVnextCode(context, input) {
|
|
29
43
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
30
44
|
const project = requireProjectByRoot(context, projectRoot);
|
|
@@ -145,7 +159,6 @@ export async function finishVnextCode(context, input) {
|
|
|
145
159
|
throw new AppError("obligation_coverage_incomplete", "CODE graph does not cover every accepted obligation", 2, { missing });
|
|
146
160
|
}
|
|
147
161
|
// Reject a malformed semantic receipt before running the expensive gate.
|
|
148
|
-
const assertInputs = stageInputGuard(context, project.id, run.id, () => [workspaceFingerprint(run.workspace_root), crypto.createHash("sha256").update(fs.readFileSync(input.verificationFile)).digest("hex")]);
|
|
149
162
|
const verification = verificationForFinish(context, { file: input.verificationFile, projectRoot: run.workspace_root, runId: run.id, runRoot: home });
|
|
150
163
|
if (verification.verdict === "blocked") {
|
|
151
164
|
return { ok: true, run_id: run.id, stage, outcome: "blocked", verification, instruction: "CODE remains running. Resolve the stated external or user-input blocker in this same coordinator session, update code-verification.json, then invoke this same stage finish command again." };
|
|
@@ -192,12 +205,10 @@ export async function finishVnextCode(context, input) {
|
|
|
192
205
|
throw new AppError("code_acceptance_unresolved", "CODE cannot finish until every due acceptance criterion has current checks and evidence", 2, { unresolved: unresolvedAcceptance });
|
|
193
206
|
const now = context.now();
|
|
194
207
|
const timing = stageTiming(home, stage, now);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
if (missingReportedPaths.length)
|
|
200
|
-
throw new AppError("changed_path_not_materialized", "CODE Work reported paths that are not changed in the accepted workspace", 2, { paths: missingReportedPaths });
|
|
208
|
+
// Work result paths are provenance, not a mutable filesystem oracle. The
|
|
209
|
+
// stage reports one shared Git observation; ignored machine repairs are
|
|
210
|
+
// rejected before a Work can become completed.
|
|
211
|
+
const changedPaths = workspaceChangedPaths(run.workspace_root);
|
|
201
212
|
const next = nextAction(run, changedPaths);
|
|
202
213
|
const report = {
|
|
203
214
|
schema_id: "dd-flow/stage-report@1",
|
|
@@ -237,11 +248,14 @@ export async function finishVnextCode(context, input) {
|
|
|
237
248
|
// Do it before materialising a terminal stage report or changing RUN state,
|
|
238
249
|
// so an invalid merge policy leaves CODE safely running and retryable.
|
|
239
250
|
let mergeRequest = null;
|
|
240
|
-
|
|
251
|
+
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
241
252
|
const prepareMerge = next === "start_merge" ? prepareVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths: changedPaths }) : null;
|
|
242
253
|
withStageSettlement(context, () => {
|
|
243
254
|
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
244
|
-
|
|
255
|
+
const currentWorks = codeWorks(context, project.id, run.id);
|
|
256
|
+
if (currentWorks.length !== works.length || currentWorks.some(work => work.status !== "completed" || !works.some(accepted => accepted.work_id === work.work_id && accepted.result === work.result))) {
|
|
257
|
+
throw new AppError("code_graph_incomplete", "CODE results changed while the final checks were running", 2);
|
|
258
|
+
}
|
|
245
259
|
mergeRequest = prepareMerge?.() ?? null;
|
|
246
260
|
writeReport(root, report);
|
|
247
261
|
completeFlowRunStage(context, {
|
|
@@ -581,15 +595,45 @@ function requireRun(context, projectId, runId) {
|
|
|
581
595
|
return run;
|
|
582
596
|
}
|
|
583
597
|
/** CODE, not PROTOCOLIZE, makes a checkout ready for project tooling. */
|
|
584
|
-
|
|
598
|
+
export function isReusablePreparedCodeReadiness(value, expected) {
|
|
599
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
600
|
+
return false;
|
|
601
|
+
const receipt = value;
|
|
602
|
+
return receipt.schema_id === "dd-flow/workspace-readiness@2"
|
|
603
|
+
&& receipt.status === "passed"
|
|
604
|
+
&& receipt.prepared_by === expected.ownerId
|
|
605
|
+
&& receipt.workspace_root === expected.workspaceRoot
|
|
606
|
+
&& receipt.command === expected.command
|
|
607
|
+
&& receipt.policy_ref === expected.policyRef;
|
|
608
|
+
}
|
|
609
|
+
function readPreparedReadiness(file) {
|
|
610
|
+
try {
|
|
611
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async function ensureCodeWorkspaceReady(context, projectId, run, root, readinessChecks, progress, options = {}) {
|
|
585
618
|
const profile = JSON.parse(run.index_json);
|
|
586
619
|
const bootstrap = profile.execution_profile?.settings?.code_bootstrap;
|
|
587
620
|
if (!bootstrap || typeof bootstrap.command !== "string" || typeof bootstrap.policy_ref !== "string") {
|
|
588
621
|
throw new AppError("execution_profile_invalid", "CODE requires a frozen bootstrap command in the RUN execution profile", 1, { run_id: run.id });
|
|
589
622
|
}
|
|
590
623
|
const receiptPath = path.join(root, "workspace-readiness.json");
|
|
591
|
-
|
|
592
|
-
|
|
624
|
+
const ownerId = context.env.DD_FLOW_CODE_READINESS_OWNER;
|
|
625
|
+
if (!options.force && ownerId && isReusablePreparedCodeReadiness(readPreparedReadiness(receiptPath), {
|
|
626
|
+
ownerId,
|
|
627
|
+
workspaceRoot: run.workspace_root,
|
|
628
|
+
command: bootstrap.command,
|
|
629
|
+
policyRef: bootstrap.policy_ref
|
|
630
|
+
})) {
|
|
631
|
+
progress?.("workspace bootstrap already prepared by this controller");
|
|
632
|
+
return readPreparedReadiness(receiptPath);
|
|
633
|
+
}
|
|
634
|
+
// Unowned and stale receipts are never trusted: a direct stage entry still
|
|
635
|
+
// performs its own readiness check. Only the same controller can hand a
|
|
636
|
+
// freshly prepared workspace to its immediately following lifecycle call.
|
|
593
637
|
const startedAt = new Date().toISOString();
|
|
594
638
|
progress?.(`workspace bootstrap started: ${bootstrap.command}`);
|
|
595
639
|
const result = await runWorkspaceBootstrap(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, command: bootstrap.command, artifactDir: "05-code/readiness", progress });
|
|
@@ -597,7 +641,7 @@ async function ensureCodeWorkspaceReady(context, projectId, run, root, readiness
|
|
|
597
641
|
const checks = bootstrapPassed && readinessChecks.length
|
|
598
642
|
? await runCodeChecks(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, scope: "aggregate", artifactDir: "05-code/readiness", checks: readinessChecks, ...(progress ? { progress } : {}) })
|
|
599
643
|
: [];
|
|
600
|
-
const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", process_id: result.process_id, stdout_path: result.stdout_path, stderr_path: result.stderr_path, completion_path: result.completion_path, exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
|
|
644
|
+
const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", ...(ownerId ? { prepared_by: ownerId } : {}), process_id: result.process_id, stdout_path: result.stdout_path, stderr_path: result.stderr_path, completion_path: result.completion_path, exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
|
|
601
645
|
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
602
646
|
if (receipt.status !== "passed")
|
|
603
647
|
throw new AppError("workspace_readiness_failed", "CODE workspace bootstrap failed", 1, receipt);
|
|
@@ -615,8 +659,9 @@ export function finishCommand(context, runId, projectRoot, verificationFile, ret
|
|
|
615
659
|
}
|
|
616
660
|
function readVerification(context, input) {
|
|
617
661
|
const file = path.resolve(input.file);
|
|
618
|
-
|
|
619
|
-
|
|
662
|
+
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
663
|
+
validateSchema({ schemaName: "code-verification", file, data, projectRoot: input.projectRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.runRoot });
|
|
664
|
+
return data;
|
|
620
665
|
}
|
|
621
666
|
function verificationForFinish(context, input) {
|
|
622
667
|
return readVerification(context, input);
|
|
@@ -673,15 +718,6 @@ function changedPathsFromReport(file) {
|
|
|
673
718
|
return [];
|
|
674
719
|
}
|
|
675
720
|
}
|
|
676
|
-
function resultPaths(result) {
|
|
677
|
-
try {
|
|
678
|
-
const value = JSON.parse(result ?? "");
|
|
679
|
-
return Array.isArray(value.changed_paths) ? value.changed_paths.filter((item) => typeof item === "string") : [];
|
|
680
|
-
}
|
|
681
|
-
catch {
|
|
682
|
-
return [];
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
721
|
function unique(values) {
|
|
686
722
|
return [...new Set(values)];
|
|
687
723
|
}
|
|
@@ -16,7 +16,7 @@ import { validateSchema } from "./schema-validation.js";
|
|
|
16
16
|
import { writeStageReport } from "./stage-report-renderer.js";
|
|
17
17
|
import { bindStageCoordinatorWork, createChildWork, failWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
|
|
18
18
|
import { addVnextCodeRepair } from "./vnext-code.js";
|
|
19
|
-
import { checkReceiptEvidenceValid, checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
|
|
19
|
+
import { checkReceiptEvidenceValid, checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
|
|
20
20
|
import { applyExternalStageContext } from "./stage-context.js";
|
|
21
21
|
import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
|
|
22
22
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
@@ -72,11 +72,13 @@ export function prepareVnextMergeRequest(context, input) {
|
|
|
72
72
|
const frozen = JSON.parse(fs.readFileSync(freezeFile, "utf8"));
|
|
73
73
|
sourceCommit = frozen.run_id === run.id && typeof frozen.source_commit === "string" ? frozen.source_commit : "";
|
|
74
74
|
sourcePaths = Array.isArray(frozen.paths) ? frozen.paths : [];
|
|
75
|
-
|
|
75
|
+
// A retry sees a clean tree: the original paths are already in sourceCommit.
|
|
76
|
+
// Reuse that receipt, but never accept new edits merely because names match.
|
|
77
|
+
if (!sourceCommit || frozen.source_workspace !== run.workspace_root || frozen.source_branch !== sourceBranch || gitValue(run.workspace_root, ["rev-parse", "HEAD"]) !== sourceCommit || meaningfulStatus(run.workspace_root, ignored).length)
|
|
76
78
|
throw new AppError("merge_source_freeze_conflict", "Existing MERGE source freeze does not match the accepted source", 2, { freeze_file: freezeFile });
|
|
77
79
|
}
|
|
78
80
|
else {
|
|
79
|
-
sourcePaths =
|
|
81
|
+
sourcePaths = meaningfulStatus(run.workspace_root, ignored);
|
|
80
82
|
if (accepted) {
|
|
81
83
|
const unexpected = sourcePaths.filter((item) => !accepted.includes(item));
|
|
82
84
|
const absent = accepted.filter((item) => !sourcePaths.includes(item));
|
|
@@ -491,15 +493,13 @@ function requireOwnedActiveRequest(context, input) { const project = requireProj
|
|
|
491
493
|
function assertExecutionBaseline(request) { const head = gitValue(request.target_workspace, ["rev-parse", "HEAD"], true); if (request.checkpoint === "baseline_locked" && head !== request.execution_target_head)
|
|
492
494
|
throw new AppError("merge_target_changed", "Integration target changed after the lane baseline was locked", 1, { expected: request.execution_target_head, actual: head }); }
|
|
493
495
|
function recovery(context, request, code, detail) { context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code, detail }), context.now(), request.merge_request_id]); throw new AppError("merge_recovery_required", "MERGE mutation could not be reconciled automatically", 1, { merge_request_id: request.merge_request_id, code, detail }); }
|
|
494
|
-
function commitPendingSource(cwd, runId, ignored) { const paths = meaningfulStatus(cwd, ignored)
|
|
496
|
+
function commitPendingSource(cwd, runId, ignored) { const paths = meaningfulStatus(cwd, ignored); if (!paths.length)
|
|
495
497
|
return; git(cwd, ["add", "-A", "--", ...paths]); git(cwd, ["commit", "-m", `chore(${runId.toLowerCase()}): freeze accepted source`]); }
|
|
496
|
-
function commitIntegration(cwd, runId, ignored) { const mergeHead = fs.existsSync(path.join(gitDirectory(cwd), "MERGE_HEAD")); const paths = meaningfulStatus(cwd, ignored)
|
|
498
|
+
function commitIntegration(cwd, runId, ignored) { const mergeHead = fs.existsSync(path.join(gitDirectory(cwd), "MERGE_HEAD")); const paths = meaningfulStatus(cwd, ignored); if (!mergeHead && !paths.length)
|
|
497
499
|
return; if (paths.length)
|
|
498
500
|
git(cwd, ["add", "-A", "--", ...paths]); git(cwd, ["commit", "-m", `merge(${runId.toLowerCase()}): integrate accepted source`]); }
|
|
499
501
|
function ignoredGitPaths(context, projectId) { return [readProjectConfig(context, projectId).dashboard.markdown_path.replaceAll("\\", "/")]; }
|
|
500
|
-
function meaningfulStatus(cwd, ignored) {
|
|
501
|
-
return []; return result.stdout.split(/\r?\n/).filter(Boolean).filter((line) => !ignored.includes(statusPath(line))); }
|
|
502
|
-
function statusPath(line) { const value = line.slice(3); const renamed = value.includes(" -> ") ? value.slice(value.lastIndexOf(" -> ") + 4) : value; return renamed.replace(/^"|"$/g, ""); }
|
|
502
|
+
function meaningfulStatus(cwd, ignored) { return workspaceChangedPaths(cwd).filter((item) => !ignored.includes(item)); }
|
|
503
503
|
function unmerged(cwd) { const value = gitValue(cwd, ["diff", "--name-only", "--diff-filter=U"], true); return value.split(/\r?\n/).filter(Boolean); }
|
|
504
504
|
function gitDirectory(cwd) { const value = gitValue(cwd, ["rev-parse", "--git-dir"]); return path.resolve(cwd, value); }
|
|
505
505
|
function git(cwd, args) { try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
-
import { withStageSettlement
|
|
3
|
+
import { withStageSettlement } from "./runs.js";
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { AppError } from "../shared/errors.js";
|
|
@@ -11,7 +11,7 @@ import { addWorkBatch, bindStageCoordinatorWork, ensureWorkRegistry, refreshRunW
|
|
|
11
11
|
import { validateSchema } from "./schema-validation.js";
|
|
12
12
|
import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
|
|
13
13
|
import { assertStageStartHookEvent } from "./hooks.js";
|
|
14
|
-
import { validateVnextPlanArtifacts } from "./vnext-plan.js";
|
|
14
|
+
import { prepareVnextPlanArtifacts, validateVnextPlanArtifacts } from "./vnext-plan.js";
|
|
15
15
|
import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
|
|
16
16
|
import { nextWorkId } from "./ids.js";
|
|
17
17
|
import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
@@ -104,7 +104,7 @@ export function startVnextPlanReview(context, input) {
|
|
|
104
104
|
const workSessionId = String(binding.work_session_id);
|
|
105
105
|
const sessionId = String(binding.session_id);
|
|
106
106
|
const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: workId, dispatch: "plan-review", capacity_required: true });
|
|
107
|
-
writeJson(path.join(root, "work-context.json"), { schema_id: "dd-flow/plan-review-context@1", system: { run_id: run.id, work_id: workId, stage, requested_mode: requested, effective_mode: effective, plan_revision: currentPlanRevision(home, run.workspace_root), plan_checksum: planChecksum, batch_checksum: batchChecksum }, groups, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root, plan_root: planRoot } });
|
|
107
|
+
writeJson(path.join(root, "work-context.json"), { schema_id: "dd-flow/plan-review-context@1", read_only_input: { parent_work_id: workId }, system: { run_id: run.id, work_id: workId, stage, requested_mode: requested, effective_mode: effective, plan_revision: currentPlanRevision(home, run.workspace_root), plan_checksum: planChecksum, batch_checksum: batchChecksum }, groups, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root, plan_root: planRoot } });
|
|
108
108
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
109
109
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "work_session_started", stage, work_id: workId, id: workSessionId, session_id: sessionId });
|
|
110
110
|
return { ok: true, run_id: run.id, stage, outcome: "review_required", work_id: workId, id: workSessionId, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}), orchestration, plan_review: { requested_mode: requested, effective_mode: effective, policy_source: policySource(run), groups }, next: { dispatch_command: dispatchCommand(context, run.id, projectRoot), finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
|
|
@@ -173,6 +173,9 @@ export function recordVnextPlanReviewCapacity(context, input) {
|
|
|
173
173
|
return { ok: true, run_id: run.id, capacity: { available_slots: input.availableSlots, source: "harness_qualification" } };
|
|
174
174
|
}
|
|
175
175
|
export function finishVnextPlanReview(context, input) {
|
|
176
|
+
return withStageSettlement(context, () => finishPlanReviewOwned(context, input));
|
|
177
|
+
}
|
|
178
|
+
function finishPlanReviewOwned(context, input) {
|
|
176
179
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
177
180
|
const project = requireProjectByRoot(context, projectRoot);
|
|
178
181
|
const run = requireRun(context, projectRoot, input.runId);
|
|
@@ -184,7 +187,6 @@ export function finishVnextPlanReview(context, input) {
|
|
|
184
187
|
if (!parent || parent.status !== "running")
|
|
185
188
|
throw new AppError("invalid_work_state", "PLAN-REVIEW has no running parent Work", 2);
|
|
186
189
|
const decisionPath = path.resolve(input.decisionFile ?? path.join(root, "decision.json"));
|
|
187
|
-
const assertInputs = stageInputGuard(context, project.id, run.id, () => [planSetChecksum(home, run.workspace_root), checksum(batch), checksum(decisionPath), checksum(path.join(root, "work-context.json"))]);
|
|
188
190
|
const decision = readDecision(context, projectRoot, run.id, home, decisionPath);
|
|
189
191
|
const children = context.db.all("SELECT work_id, task, status, result, created_at FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ?", [project.id, run.id, parent.work_id]);
|
|
190
192
|
const contextFile = readJson(path.join(root, "work-context.json"));
|
|
@@ -198,8 +200,7 @@ export function finishVnextPlanReview(context, input) {
|
|
|
198
200
|
throw new AppError("stage_pause_required", "A user or decision blocker must pause the current PLAN-REVIEW Work with `dd-flow stage pause`; do not finish the stage as blocked", 2, { run_id: run.id, stage, work_id: parent.work_id });
|
|
199
201
|
}
|
|
200
202
|
if (["failed", "cancelled"].includes(decision.outcome)) {
|
|
201
|
-
preserveDecisionReceipt(root, decisionPath);
|
|
202
|
-
return withStageSettlement(context, () => { assertInputs(); return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision }); });
|
|
203
|
+
return withStageSettlement(context, () => { preserveDecisionReceipt(root, decisionPath); return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision }); });
|
|
203
204
|
}
|
|
204
205
|
if (children.some((child) => child.status === "created" || child.status === "running"))
|
|
205
206
|
throw new AppError("worker_jobs_incomplete", "PLAN-REVIEW finish requires all reviewer Works to settle", 1, { works: children.map((child) => ({ work_id: child.work_id, status: child.status })) });
|
|
@@ -233,21 +234,20 @@ export function finishVnextPlanReview(context, input) {
|
|
|
233
234
|
else if (decision.correction.status !== "not_required" || planRevision !== reviewedPlanRevision || currentPlanChecksum !== contextFile.system?.plan_checksum) {
|
|
234
235
|
throw new AppError("validation", "A clean PLAN review must not claim an unverified correction", 2);
|
|
235
236
|
}
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
238
|
-
throw new AppError("validation", "Corrected PLAN artifacts have validation errors", 2, { errors:
|
|
237
|
+
const prepared = prepareVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIdsForReview(home) });
|
|
238
|
+
if (prepared.failures.length)
|
|
239
|
+
throw new AppError("validation", "Corrected PLAN artifacts have validation errors", 2, { errors: prepared.failures });
|
|
239
240
|
if (needsCorrection) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
throw new AppError("stale_code_work_batch", "Applied PLAN correction must regenerate the proposed CODE batch", 2, { batch, initial_batch_checksum: initialBatchChecksum });
|
|
241
|
+
// Regeneration above is authoritative. A valid PLAN correction need not
|
|
242
|
+
// change the derived CODE tasks (for example, a clarified explanation).
|
|
243
243
|
if (decision.correction.changed_paths.some((item) => item.endsWith("code-work-batch.json")))
|
|
244
244
|
throw new AppError("validation", "Generated CODE batch must not be listed as an agent-authored correction", 2, { changed_paths: decision.correction.changed_paths });
|
|
245
245
|
}
|
|
246
|
-
preserveDecisionReceipt(root, decisionPath);
|
|
247
246
|
const finalPlanChecksum = planSetChecksum(home, run.workspace_root);
|
|
248
|
-
const finalBatchChecksum =
|
|
247
|
+
const finalBatchChecksum = prepared.batchChecksum;
|
|
249
248
|
return withStageSettlement(context, () => {
|
|
250
|
-
|
|
249
|
+
prepared.publish();
|
|
250
|
+
preserveDecisionReceipt(root, decisionPath);
|
|
251
251
|
const registered = registerCode(context, project.id, run.id, batch);
|
|
252
252
|
const now = context.now();
|
|
253
253
|
settleReviewParent(context, { projectRoot, projectId: project.id, runId: run.id, parentWorkId: parent.work_id, status: "completed", result: decision.summary, reason: "plan_review_completed", resultPath: path.join(root, "stage-report.json") });
|
|
@@ -393,7 +393,7 @@ catch {
|
|
|
393
393
|
function rootWorkId(context, projectId, runId) { const root = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NULL ORDER BY created_at LIMIT 1", [projectId, runId]); if (!root)
|
|
394
394
|
throw new AppError("runtime_missing", "vNext RUN has no root Work", 1); return root.work_id; }
|
|
395
395
|
function registerCode(context, projectId, runId, batch) { return addWorkBatch(context, { parentWorkId: rootWorkId(context, projectId, runId), file: batch }); }
|
|
396
|
-
function readDecision(context, projectRoot, runId, runRoot, file) { validateSchema({ schemaName: "plan-review-decision", file, projectRoot, runId, runRoot, ddFlowHome: context.ddFlowHome }); return
|
|
396
|
+
function readDecision(context, projectRoot, runId, runRoot, file) { const data = readJson(file); validateSchema({ schemaName: "plan-review-decision", file, data, projectRoot, runId, runRoot, ddFlowHome: context.ddFlowHome }); return data; }
|
|
397
397
|
function preserveDecisionReceipt(root, source) {
|
|
398
398
|
const receipt = path.join(root, "decision.receipt.json");
|
|
399
399
|
const bytes = fs.readFileSync(source);
|
|
@@ -6,7 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import { AppError } from "../shared/errors.js";
|
|
7
7
|
import { effectiveCheckDeclarations, readCodeCheckProfile, validateCheckDeclaration, validateCheckPlacement, validateCodeCheckCommands } from "./code-checks.js";
|
|
8
8
|
import { requireProjectByRoot } from "./projects.js";
|
|
9
|
-
import { resolveProjectRoot } from "../storage/paths.js";
|
|
9
|
+
import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
|
|
10
10
|
import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, getFlowRunVariables, gitFacts } from "./runs.js";
|
|
11
11
|
import { validateSchema } from "./schema-validation.js";
|
|
12
12
|
import { bindRunningWorkSession, bindStageCoordinatorWork, ensureWorkRegistry, refreshRunWorkProjection, validateWorkBatchFile } from "./work-registry.js";
|
|
@@ -151,6 +151,13 @@ function finishVnextPlanOwned(context, input) {
|
|
|
151
151
|
return { ok: true, run_id: run.id, protocols, next_action: "start_plan_review", next_command: reviewCommand, next: { kind: "start_stage", stage: "plan-review", command: reviewCommand } };
|
|
152
152
|
}
|
|
153
153
|
export function validateVnextPlanArtifacts(context, input) {
|
|
154
|
+
const prepared = prepareVnextPlanArtifacts(context, input);
|
|
155
|
+
if (!prepared.failures.length && input.publishBatch !== false)
|
|
156
|
+
prepared.publish();
|
|
157
|
+
return prepared.failures;
|
|
158
|
+
}
|
|
159
|
+
/** Validate in scratch files; publish only after the caller accepts its inputs. */
|
|
160
|
+
export function prepareVnextPlanArtifacts(context, input) {
|
|
154
161
|
const workspaceRoot = input.workspaceRoot ?? input.projectRoot;
|
|
155
162
|
const planFiles = input.protocols.map((id) => path.join(workspaceRoot, ".memory-bank", "protocol", id, "plan.json"));
|
|
156
163
|
const mapFiles = input.protocols.map((id) => path.join(input.home, "03-plan", id, "aspect-map.json"));
|
|
@@ -163,6 +170,7 @@ export function validateVnextPlanArtifacts(context, input) {
|
|
|
163
170
|
: []) : []) ?? [])
|
|
164
171
|
: undefined;
|
|
165
172
|
const failures = [];
|
|
173
|
+
const publications = new Map();
|
|
166
174
|
const plans = [];
|
|
167
175
|
for (const [index, file] of planFiles.entries()) {
|
|
168
176
|
const protocolId = input.protocols[index];
|
|
@@ -186,14 +194,22 @@ export function validateVnextPlanArtifacts(context, input) {
|
|
|
186
194
|
}
|
|
187
195
|
}
|
|
188
196
|
for (const file of mapFiles) {
|
|
197
|
+
const temporary = `${file}.tmp-${crypto.randomUUID()}`;
|
|
189
198
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
const bytes = normalizedAspectMapRefs(file, workspaceRoot, input.home, input.runId);
|
|
200
|
+
if (input.publishBatch === false && bytes !== fs.readFileSync(file, "utf8"))
|
|
201
|
+
throw new AppError("validation", "Accepted aspect-map requires normalization; regenerate it in PLAN", 2, { file });
|
|
202
|
+
fs.writeFileSync(temporary, bytes);
|
|
203
|
+
validateSchema({ schemaName: "plan-aspect-map", file: temporary, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
|
|
204
|
+
validateAspectMap(temporary, input.protocols, workspaceRoot);
|
|
205
|
+
publications.set(file, bytes);
|
|
193
206
|
}
|
|
194
207
|
catch (error) {
|
|
195
208
|
failures.push(validationFailure(file, error));
|
|
196
209
|
}
|
|
210
|
+
finally {
|
|
211
|
+
fs.rmSync(temporary, { force: true });
|
|
212
|
+
}
|
|
197
213
|
}
|
|
198
214
|
if (!failures.length) {
|
|
199
215
|
try {
|
|
@@ -213,7 +229,7 @@ export function validateVnextPlanArtifacts(context, input) {
|
|
|
213
229
|
validateSchema({ schemaName: "code-work-batch", file: temporaryBatch, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
|
|
214
230
|
validateWorkBatchFile(temporaryBatch);
|
|
215
231
|
if (input.publishBatch !== false) {
|
|
216
|
-
fs.
|
|
232
|
+
publications.set(batch, fs.readFileSync(temporaryBatch, "utf8"));
|
|
217
233
|
}
|
|
218
234
|
else if (!fs.existsSync(batch) || checksum(temporaryBatch) !== checksum(batch)) {
|
|
219
235
|
throw new AppError("stale_code_work_batch", "CODE batch no longer matches the accepted semantic PLAN", 2, { batch });
|
|
@@ -226,7 +242,29 @@ export function validateVnextPlanArtifacts(context, input) {
|
|
|
226
242
|
fs.rmSync(temporaryBatch, { force: true });
|
|
227
243
|
}
|
|
228
244
|
}
|
|
229
|
-
return failures
|
|
245
|
+
return { failures, batchChecksum: publications.has(batch) ? crypto.createHash("sha256").update(publications.get(batch)).digest("hex") : null,
|
|
246
|
+
publish: () => {
|
|
247
|
+
if (failures.length)
|
|
248
|
+
throw new AppError("validation", "Cannot publish invalid PLAN artifacts", 2, { errors: failures });
|
|
249
|
+
const published = [];
|
|
250
|
+
try {
|
|
251
|
+
for (const [file, bytes] of publications) {
|
|
252
|
+
const temporary = `${file}.tmp-${crypto.randomUUID()}`;
|
|
253
|
+
try {
|
|
254
|
+
fs.writeFileSync(temporary, bytes);
|
|
255
|
+
fs.renameSync(temporary, file);
|
|
256
|
+
published.push(file);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
fs.rmSync(temporary, { force: true });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch (cause) {
|
|
264
|
+
throw new AppError("plan_publication_failed", cause instanceof Error ? cause.message : String(cause), 1, { effect: "unknown", published_files: published, business_commit: false });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
230
268
|
}
|
|
231
269
|
/** CODE entry validates the same PLAN closure without changing an accepted batch. */
|
|
232
270
|
export function validateVnextCodeHandoff(context, input) {
|
|
@@ -375,13 +413,16 @@ function assertPlanIdentity(value, identity, file) {
|
|
|
375
413
|
if (JSON.stringify(value.source_refs) !== JSON.stringify(identity.source_refs))
|
|
376
414
|
throw new AppError("validation", "PLAN must preserve CLI-owned source_refs", 2, { file });
|
|
377
415
|
}
|
|
378
|
-
function
|
|
379
|
-
|
|
416
|
+
function normalizedAspectMapRefs(file, projectRoot, runHome, runId) {
|
|
417
|
+
projectRoot = canonicalPath(projectRoot);
|
|
418
|
+
runHome = canonicalPath(runHome);
|
|
419
|
+
const bytes = fs.readFileSync(file, "utf8");
|
|
420
|
+
const map = JSON.parse(bytes);
|
|
380
421
|
let changed = false;
|
|
381
422
|
const normalize = (value) => {
|
|
382
423
|
if (!path.isAbsolute(value))
|
|
383
424
|
return value;
|
|
384
|
-
const source =
|
|
425
|
+
const source = canonicalPath(value, false);
|
|
385
426
|
const inProject = source.startsWith(`${projectRoot}${path.sep}`);
|
|
386
427
|
const inRun = source.startsWith(`${runHome}${path.sep}`);
|
|
387
428
|
if (!inProject && !inRun)
|
|
@@ -394,8 +435,7 @@ function normalizeAspectMapRefs(file, projectRoot, runHome, runId) {
|
|
|
394
435
|
for (const aspect of map.aspects ?? [])
|
|
395
436
|
if (Array.isArray(aspect.planned_artifact_refs))
|
|
396
437
|
aspect.planned_artifact_refs = aspect.planned_artifact_refs.map(normalize);
|
|
397
|
-
|
|
398
|
-
fs.writeFileSync(file, `${JSON.stringify(map, null, 2)}\n`);
|
|
438
|
+
return changed ? `${JSON.stringify(map, null, 2)}\n` : bytes;
|
|
399
439
|
}
|
|
400
440
|
function projectCodeWorkBatch(input) {
|
|
401
441
|
const obligationMap = acceptedObligationMap(input.home);
|