@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/runs.js
CHANGED
|
@@ -10,12 +10,13 @@ import { appendAudit } from "./audit.js";
|
|
|
10
10
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
11
11
|
import { buildRunFlowGuidance } from "./flow-guidance.js";
|
|
12
12
|
import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
13
|
-
import {
|
|
13
|
+
import { recalculateRunUsage } from "./usage.js";
|
|
14
14
|
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
15
15
|
import { resolveCanonRoot } from "./canon.js";
|
|
16
16
|
import { bindCurrentEngineToRun } from "./engines.js";
|
|
17
|
-
|
|
18
|
-
const
|
|
17
|
+
import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
|
|
18
|
+
const runSchemaId = "dd-flow/flow-run@3";
|
|
19
|
+
const runtimeSchemaId = "dd-flow/flow-run@3";
|
|
19
20
|
const runIdType = "RUN";
|
|
20
21
|
const allowedRunFlowKinds = [
|
|
21
22
|
"mb_sdlc",
|
|
@@ -27,6 +28,8 @@ const allowedRunFlowKinds = [
|
|
|
27
28
|
"mb-distill",
|
|
28
29
|
"mb-upgrade-review",
|
|
29
30
|
"mb-sdlc-review",
|
|
31
|
+
"vnext_specify",
|
|
32
|
+
"vnext_protocolize",
|
|
30
33
|
"review",
|
|
31
34
|
"custom"
|
|
32
35
|
];
|
|
@@ -43,6 +46,9 @@ export function startFlowRun(context, input) {
|
|
|
43
46
|
const runId = nextRunId(context, project.id, slug);
|
|
44
47
|
const { shortId } = parseFullEntityId(runId);
|
|
45
48
|
const now = context.now();
|
|
49
|
+
const executionProfile = (flowKind === "vnext_protocolize" || flowKind === "vnext_specify")
|
|
50
|
+
? snapshotVnextExecutionProfile(projectRoot)
|
|
51
|
+
: undefined;
|
|
46
52
|
const flowFlags = resolveFlowFlags(flowContract, {
|
|
47
53
|
flowKind,
|
|
48
54
|
...(input.preset ? { preset: input.preset } : {}),
|
|
@@ -90,7 +96,6 @@ export function startFlowRun(context, input) {
|
|
|
90
96
|
},
|
|
91
97
|
stage_runs: [],
|
|
92
98
|
sessions: [],
|
|
93
|
-
artifacts: { stage_prompt: path.join(runHome, "stage-prompt.md"), stage_report_json: path.join(runHome, "stage-report.json"), stage_report_md: path.join(runHome, "stage-report.md"), stage_report_html: path.join(runHome, "stage-report.html"), summary: path.join(runHome, "summary.md") },
|
|
94
99
|
status: "running",
|
|
95
100
|
verdict: "pending",
|
|
96
101
|
next_action: input.nextAction ?? null,
|
|
@@ -103,21 +108,30 @@ export function startFlowRun(context, input) {
|
|
|
103
108
|
snapshot_checksum: flowFlags.snapshot_checksum,
|
|
104
109
|
reconciliation_status: "reconciled",
|
|
105
110
|
flow_flags: flowFlags,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
settings: {
|
|
112
|
+
plan_review: { mode: executionProfile?.settings.plan_review_mode ?? "auto", source: "default", reason: "Project execution profile default.", updated_at: now },
|
|
113
|
+
code_review: { mode: executionProfile?.settings.code_review_mode ?? "auto", source: "default", reason: "Project execution profile default.", updated_at: now }
|
|
114
|
+
},
|
|
115
|
+
...(executionProfile ? { execution_profile: executionProfile } : {}),
|
|
116
|
+
artifacts: {
|
|
117
|
+
stage_prompt: "stage-prompt.md",
|
|
118
|
+
stage_report_json: "stage-report.json",
|
|
119
|
+
stage_report_md: "stage-report.md",
|
|
120
|
+
stage_report_html: "stage-report.html",
|
|
121
|
+
summary: "summary.md"
|
|
122
|
+
},
|
|
110
123
|
timeline_path: "timeline.jsonl"
|
|
111
124
|
};
|
|
112
125
|
ensureDir(path.dirname(runtimePath));
|
|
113
126
|
ensureDir(runHome);
|
|
114
127
|
bindCurrentEngineToRun(context, { projectRoot, runId, runHome });
|
|
115
|
-
|
|
116
|
-
|
|
128
|
+
const persistedIndex = persistedVnextIndex(index);
|
|
129
|
+
writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
|
|
130
|
+
context.db.run(`INSERT INTO runs
|
|
117
131
|
(id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
|
|
118
|
-
status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, layout_version, artifact_root_kind,
|
|
132
|
+
status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, run_root, layout_version, artifact_root_kind,
|
|
119
133
|
index_json, created_at, updated_at, completed_at)
|
|
120
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, [
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, [
|
|
121
135
|
runId,
|
|
122
136
|
shortId,
|
|
123
137
|
slug,
|
|
@@ -134,9 +148,10 @@ export function startFlowRun(context, input) {
|
|
|
134
148
|
runHome,
|
|
135
149
|
runtimePath,
|
|
136
150
|
runHome,
|
|
151
|
+
runHome,
|
|
137
152
|
"home_run_v2",
|
|
138
153
|
"dd_flow_home",
|
|
139
|
-
JSON.stringify(
|
|
154
|
+
JSON.stringify(persistedIndex),
|
|
140
155
|
now,
|
|
141
156
|
now
|
|
142
157
|
]);
|
|
@@ -156,6 +171,43 @@ export function startFlowRun(context, input) {
|
|
|
156
171
|
});
|
|
157
172
|
return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
|
|
158
173
|
}
|
|
174
|
+
function snapshotVnextExecutionProfile(projectRoot) {
|
|
175
|
+
const profile = loadVnextExecutionProfile(projectRoot);
|
|
176
|
+
const sourcePath = executionProfilePath(projectRoot);
|
|
177
|
+
return {
|
|
178
|
+
schema_id: "dd-flow/run-execution-profile@1",
|
|
179
|
+
source_path: sourcePath,
|
|
180
|
+
source_checksum: crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"),
|
|
181
|
+
settings: {
|
|
182
|
+
stage_session_mode: profile.stage_session_mode,
|
|
183
|
+
plan_review_mode: profile.plan_review_mode,
|
|
184
|
+
code_review_mode: profile.code_review_mode,
|
|
185
|
+
stop_target: profile.stop_target,
|
|
186
|
+
code_bootstrap: profile.code_bootstrap
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* A vNext route may materialize a feature checkout after its semantic
|
|
192
|
+
* PROTOCOLIZE decision. Keep project identity stable while moving the RUN's
|
|
193
|
+
* concrete write workspace before PLAN is allowed to start.
|
|
194
|
+
*/
|
|
195
|
+
export function rebindFlowRunWorkspace(context, input) {
|
|
196
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
197
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
198
|
+
const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot);
|
|
199
|
+
const index = authoritativeIndex(run);
|
|
200
|
+
const now = context.now();
|
|
201
|
+
const facts = gitFacts(workspaceRoot);
|
|
202
|
+
index.workspace = { ...index.workspace, workspace_path: workspaceRoot, branch: facts.branch ?? "unknown", base: facts.head ?? "unknown", manager: "official-worktrunk" };
|
|
203
|
+
index.execution = { ...index.execution, project_root: project.root, workspace_root: workspaceRoot, git: facts };
|
|
204
|
+
index.updated_at = now;
|
|
205
|
+
context.db.run("UPDATE runs SET workspace_root = ? WHERE project_id = ? AND id = ?", [workspaceRoot, project.id, run.id]);
|
|
206
|
+
persistRunState(context, project, { ...run, workspace_root: workspaceRoot }, index);
|
|
207
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.workspace_rebound", payload: { run_id: run.id, workspace_root: workspaceRoot, reason: input.reason } });
|
|
208
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "workspace_rebound", run_id: run.id, workspace_root: workspaceRoot, reason: input.reason, git: facts });
|
|
209
|
+
return { ok: true, run_id: run.id, project_root: project.root, workspace_root: workspaceRoot, git: facts };
|
|
210
|
+
}
|
|
159
211
|
function loadUpgradeFlowContract(context, projectRoot) {
|
|
160
212
|
const canon = resolveCanonRoot(context, context.env.DD_MEMORYBANK ? { explicitRoot: context.env.DD_MEMORYBANK } : {});
|
|
161
213
|
if (!canon.ok || !canon.canon) {
|
|
@@ -174,6 +226,67 @@ export function getFlowRunStatus(context, input) {
|
|
|
174
226
|
const runtime = readRuntimeSnapshot(run.runtime_path);
|
|
175
227
|
return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
|
|
176
228
|
}
|
|
229
|
+
export function getFlowRunConfig(context, input) {
|
|
230
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
231
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
232
|
+
const index = authoritativeIndex(run);
|
|
233
|
+
return { ok: true, run_id: run.id, settings: runSettings(index) };
|
|
234
|
+
}
|
|
235
|
+
/** RUN variables are one small shared context bag; policy values are projected, not duplicated. */
|
|
236
|
+
export function getFlowRunVariables(context, input) {
|
|
237
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
238
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
239
|
+
const variables = materializedRunVariables(authoritativeIndex(run));
|
|
240
|
+
if (input.key) {
|
|
241
|
+
if (!(input.key in variables))
|
|
242
|
+
throw new AppError("not_found", "RUN variable is not set", 1, { key: input.key, run_id: run.id });
|
|
243
|
+
return { ok: true, run_id: run.id, key: input.key, value: variables[input.key] };
|
|
244
|
+
}
|
|
245
|
+
return { ok: true, run_id: run.id, variables };
|
|
246
|
+
}
|
|
247
|
+
export function setFlowRunVariable(context, input) {
|
|
248
|
+
if (!input.key.startsWith("user."))
|
|
249
|
+
throw new AppError("validation", "Only user.* RUN variables may be set directly", 2, { key: input.key });
|
|
250
|
+
return setRunVariable(context, input, input.value);
|
|
251
|
+
}
|
|
252
|
+
/** Controllers own policy.* and runtime.* values; agents never call this directly. */
|
|
253
|
+
export function setRuntimeRunVariable(context, input) {
|
|
254
|
+
if (!input.key.startsWith("runtime."))
|
|
255
|
+
throw new AppError("validation", "Controller runtime variables must use runtime.*", 2, { key: input.key });
|
|
256
|
+
return setRunVariable(context, input, input.value);
|
|
257
|
+
}
|
|
258
|
+
export function setFlowRunConfig(context, input) {
|
|
259
|
+
if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
|
|
260
|
+
throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
|
|
261
|
+
}
|
|
262
|
+
if (!["auto", "off", "standard", "deep"].includes(input.value)) {
|
|
263
|
+
throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
|
|
264
|
+
}
|
|
265
|
+
const reason = requiredPlain(input.reason, "reason");
|
|
266
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
267
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
268
|
+
const index = authoritativeIndex(run);
|
|
269
|
+
const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
|
|
270
|
+
if (index.stage_runs.some((stage) => stage.stage === stageName)) {
|
|
271
|
+
throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
|
|
272
|
+
}
|
|
273
|
+
const now = context.now();
|
|
274
|
+
const settings = runSettings(index);
|
|
275
|
+
const previous = input.key === "plan_review.mode" ? settings.plan_review : settings.code_review;
|
|
276
|
+
const mode = input.value;
|
|
277
|
+
const source = input.source ?? "user_instruction";
|
|
278
|
+
if (previous.mode === mode && previous.reason === reason && previous.source === source) {
|
|
279
|
+
return { ok: true, run_id: run.id, settings, idempotent: true };
|
|
280
|
+
}
|
|
281
|
+
index.settings = input.key === "plan_review.mode"
|
|
282
|
+
? { ...settings, plan_review: { mode, source, reason, updated_at: now } }
|
|
283
|
+
: { ...settings, code_review: { mode, source, reason, updated_at: now } };
|
|
284
|
+
index.updated_at = now;
|
|
285
|
+
persistRunState(context, project, run, index);
|
|
286
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.config_set", payload: { run_id: run.id, key: input.key, value: mode, source, reason } });
|
|
287
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
|
|
288
|
+
return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
|
|
289
|
+
}
|
|
177
290
|
export function getFlowRunFlagsStatus(context, input) {
|
|
178
291
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
179
292
|
const run = resolveRun(context, project.id, input.runId);
|
|
@@ -319,7 +432,7 @@ export function reviseFlowRunFlags(context, input) {
|
|
|
319
432
|
export function listFlowRuns(context, input) {
|
|
320
433
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
321
434
|
const runs = context.db
|
|
322
|
-
.all(`SELECT * FROM
|
|
435
|
+
.all(`SELECT * FROM runs
|
|
323
436
|
WHERE project_id = ?
|
|
324
437
|
ORDER BY updated_at DESC, id DESC`, [project.id])
|
|
325
438
|
.map(flowRunSummary);
|
|
@@ -363,16 +476,11 @@ export function attachFlowRunStage(context, input) {
|
|
|
363
476
|
payload: { run_id: run.id, stage, dir, status }
|
|
364
477
|
});
|
|
365
478
|
appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
project.id,
|
|
371
|
-
run.id
|
|
372
|
-
]);
|
|
479
|
+
// A stage transition must not rewrite every Session in the RUN. A RUN may
|
|
480
|
+
// contain independent reviewer/worker sessions; their stage is set by the
|
|
481
|
+
// stage/work binding that actually owns that Session.
|
|
482
|
+
if (status === "running")
|
|
373
483
|
refreshRunSessionProjection(context, project.id, run.id);
|
|
374
|
-
checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "stage_started", stage, stageAttempt: stageRun.attempt ?? null });
|
|
375
|
-
}
|
|
376
484
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
377
485
|
return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
378
486
|
}
|
|
@@ -396,11 +504,23 @@ export function completeFlowRunStage(context, input) {
|
|
|
396
504
|
...(input.report ? { report: input.report } : {}),
|
|
397
505
|
...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
|
|
398
506
|
updated_at: now,
|
|
399
|
-
...(status === "done" || status === "blocked" || status === "failed" || status === "skipped"
|
|
507
|
+
...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
|
|
400
508
|
? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git: gitFacts(run.workspace_root) }
|
|
401
509
|
: {})
|
|
402
510
|
};
|
|
403
511
|
upsertStage(index, stageRun);
|
|
512
|
+
if (status === "waiting_for_user") {
|
|
513
|
+
index.status = "waiting_for_user";
|
|
514
|
+
index.verdict = "waiting_for_user";
|
|
515
|
+
}
|
|
516
|
+
else if (status === "blocked" || status === "failed") {
|
|
517
|
+
index.status = status;
|
|
518
|
+
index.verdict = status;
|
|
519
|
+
}
|
|
520
|
+
else if (status === "done" && index.status === "waiting_for_user") {
|
|
521
|
+
index.status = "running";
|
|
522
|
+
index.verdict = "running";
|
|
523
|
+
}
|
|
404
524
|
index.current_stage = stage;
|
|
405
525
|
index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
|
|
406
526
|
index.updated_at = now;
|
|
@@ -417,16 +537,147 @@ export function completeFlowRunStage(context, input) {
|
|
|
417
537
|
payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
|
|
418
538
|
});
|
|
419
539
|
appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
|
|
420
|
-
checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "stage_finished", stage, stageAttempt: stageRun.attempt ?? null });
|
|
421
540
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
422
541
|
return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
423
542
|
}
|
|
543
|
+
/** Pause one live stage without completing it or creating a new attempt. */
|
|
544
|
+
export function pauseFlowRunStage(context, input) {
|
|
545
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
546
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
547
|
+
const index = authoritativeIndex(run);
|
|
548
|
+
const stage = requiredPlain(input.stage, "stage");
|
|
549
|
+
const stageRun = index.stage_runs.find((item) => item.stage === stage);
|
|
550
|
+
if (!stageRun || stageRun.status !== "running") {
|
|
551
|
+
throw new AppError("invalid_stage_state", "Only a running stage can pause", 1, { run_id: run.id, stage, status: stageRun?.status ?? "missing" });
|
|
552
|
+
}
|
|
553
|
+
const now = context.now();
|
|
554
|
+
stageRun.status = "paused";
|
|
555
|
+
stageRun.updated_at = now;
|
|
556
|
+
stageRun.pause = {
|
|
557
|
+
id: input.pauseId,
|
|
558
|
+
reason: "waiting_for_user",
|
|
559
|
+
work_id: input.workId,
|
|
560
|
+
question_path: input.questionPath,
|
|
561
|
+
answer_path: null,
|
|
562
|
+
paused_at: now,
|
|
563
|
+
resumed_at: null
|
|
564
|
+
};
|
|
565
|
+
index.pause = stageRun.pause;
|
|
566
|
+
index.status = operationalRunStatus(context, project.id, run.id);
|
|
567
|
+
index.verdict = index.status === "paused" ? "paused" : "running";
|
|
568
|
+
index.next_action = "await_user_answer";
|
|
569
|
+
index.current_stage = stage;
|
|
570
|
+
index.updated_at = now;
|
|
571
|
+
index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
|
|
572
|
+
persistRunState(context, project, run, index);
|
|
573
|
+
refreshRunSessionProjection(context, project.id, run.id);
|
|
574
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_paused", payload: { run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, reason: "waiting_for_user" } });
|
|
575
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_waiting_for_user", run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, question_path: input.questionPath });
|
|
576
|
+
return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
|
|
577
|
+
}
|
|
578
|
+
/** Resume the same stage, Work and attempt after a user answer is recorded. */
|
|
579
|
+
export function resumeFlowRunStage(context, input) {
|
|
580
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
581
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
582
|
+
const index = authoritativeIndex(run);
|
|
583
|
+
const stage = requiredPlain(input.stage, "stage");
|
|
584
|
+
const stageRun = index.stage_runs.find((item) => item.stage === stage);
|
|
585
|
+
if (!stageRun || stageRun.status !== "paused" || !stageRun.pause || stageRun.pause.work_id !== input.workId) {
|
|
586
|
+
throw new AppError("invalid_stage_state", "Stage resume requires its matching paused Work", 1, { run_id: run.id, stage, work_id: input.workId, status: stageRun?.status ?? "missing" });
|
|
587
|
+
}
|
|
588
|
+
const now = context.now();
|
|
589
|
+
const pause = stageRun.pause;
|
|
590
|
+
const pausedMs = durationMs(pause.paused_at, now) ?? 0;
|
|
591
|
+
stageRun.status = "running";
|
|
592
|
+
stageRun.updated_at = now;
|
|
593
|
+
stageRun.paused_ms = (stageRun.paused_ms ?? 0) + pausedMs;
|
|
594
|
+
delete stageRun.pause;
|
|
595
|
+
index.pause = undefined;
|
|
596
|
+
index.status = "running";
|
|
597
|
+
index.verdict = "running";
|
|
598
|
+
index.next_action = `continue_${stage}`;
|
|
599
|
+
index.current_stage = stage;
|
|
600
|
+
index.updated_at = now;
|
|
601
|
+
index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
|
|
602
|
+
persistRunState(context, project, run, index);
|
|
603
|
+
refreshRunSessionProjection(context, project.id, run.id);
|
|
604
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_resumed", payload: { run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath } });
|
|
605
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "user_answer_received", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath });
|
|
606
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_resumed", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, paused_ms: pausedMs });
|
|
607
|
+
return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
|
|
608
|
+
}
|
|
609
|
+
/** Pause the current stage for a non-user runtime blocker. */
|
|
610
|
+
export function blockFlowRunStage(context, input) {
|
|
611
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
612
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
613
|
+
const index = authoritativeIndex(run);
|
|
614
|
+
const stage = requiredPlain(input.stage, "stage");
|
|
615
|
+
const stageRun = index.stage_runs.find((item) => item.stage === stage);
|
|
616
|
+
if (!stageRun || stageRun.status !== "running")
|
|
617
|
+
throw new AppError("invalid_stage_state", "Only a running stage can be blocked", 1, { run_id: run.id, stage, status: stageRun?.status ?? "missing" });
|
|
618
|
+
const now = context.now();
|
|
619
|
+
stageRun.status = "blocked";
|
|
620
|
+
stageRun.updated_at = now;
|
|
621
|
+
stageRun.blocker = { kind: input.kind, code: input.code, summary: input.summary, retryable: input.retryable, work_id: input.workId, blocked_at: now, resumed_at: null };
|
|
622
|
+
index.blocker = stageRun.blocker;
|
|
623
|
+
index.status = "paused";
|
|
624
|
+
index.verdict = "paused";
|
|
625
|
+
index.next_action = "resolve_blocker_then_unblock_same_stage";
|
|
626
|
+
index.current_stage = stage;
|
|
627
|
+
index.updated_at = now;
|
|
628
|
+
persistRunState(context, project, run, index);
|
|
629
|
+
refreshRunSessionProjection(context, project.id, run.id);
|
|
630
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_blocked", payload: { run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, retryable: input.retryable } });
|
|
631
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_blocked", run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, summary: input.summary, retryable: input.retryable });
|
|
632
|
+
return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
|
|
633
|
+
}
|
|
634
|
+
/** Continue the same stage after its non-user blocker was repaired externally. */
|
|
635
|
+
export function unblockFlowRunStage(context, input) {
|
|
636
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
637
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
638
|
+
const index = authoritativeIndex(run);
|
|
639
|
+
const stage = requiredPlain(input.stage, "stage");
|
|
640
|
+
const stageRun = index.stage_runs.find((item) => item.stage === stage);
|
|
641
|
+
if (!stageRun || stageRun.status !== "blocked" || !stageRun.blocker || stageRun.blocker.work_id !== input.workId)
|
|
642
|
+
throw new AppError("invalid_stage_state", "Stage unblock requires its matching runtime blocker", 1, { run_id: run.id, stage, work_id: input.workId, status: stageRun?.status ?? "missing" });
|
|
643
|
+
const now = context.now();
|
|
644
|
+
const blocker = stageRun.blocker;
|
|
645
|
+
const blockedMs = durationMs(blocker.blocked_at, now) ?? 0;
|
|
646
|
+
stageRun.status = "running";
|
|
647
|
+
stageRun.updated_at = now;
|
|
648
|
+
stageRun.paused_ms = (stageRun.paused_ms ?? 0) + blockedMs;
|
|
649
|
+
delete stageRun.blocker;
|
|
650
|
+
index.blocker = undefined;
|
|
651
|
+
index.status = "running";
|
|
652
|
+
index.verdict = "running";
|
|
653
|
+
index.next_action = `continue_${stage}`;
|
|
654
|
+
index.current_stage = stage;
|
|
655
|
+
index.updated_at = now;
|
|
656
|
+
persistRunState(context, project, run, index);
|
|
657
|
+
refreshRunSessionProjection(context, project.id, run.id);
|
|
658
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_unblocked", payload: { run_id: run.id, stage, work_id: input.workId, code: blocker.code } });
|
|
659
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_unblocked", run_id: run.id, stage, work_id: input.workId, code: blocker.code, blocked_ms: blockedMs });
|
|
660
|
+
return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
|
|
661
|
+
}
|
|
662
|
+
function operationalRunStatus(context, projectId, runId) {
|
|
663
|
+
const unfinished = context.db.all("SELECT work_id, status, parent_work_id FROM works WHERE project_id = ? AND run_id = ? AND status IN ('created', 'running', 'paused')", [projectId, runId]);
|
|
664
|
+
const parents = new Set(unfinished.map((work) => work.parent_work_id).filter((id) => Boolean(id)));
|
|
665
|
+
const leaves = unfinished.filter((work) => !parents.has(work.work_id));
|
|
666
|
+
return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
|
|
667
|
+
}
|
|
424
668
|
export function completeFlowRun(context, input) {
|
|
425
669
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
426
670
|
const run = resolveRun(context, project.id, input.runId);
|
|
427
671
|
const index = authoritativeIndex(run);
|
|
428
672
|
const now = context.now();
|
|
429
673
|
const status = parseRunStatus(input.status);
|
|
674
|
+
if (!['done', 'blocked', 'cancelled', 'failed'].includes(status)) {
|
|
675
|
+
throw new AppError('validation', 'completeFlowRun accepts terminal statuses only; use advanceFlowRun for a live RUN', 2, { status });
|
|
676
|
+
}
|
|
677
|
+
if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
|
|
678
|
+
closeOpenStagesForOverride(index, status, now);
|
|
679
|
+
closeOpenWorksForOverride(context, project.id, run.id, status, now);
|
|
680
|
+
}
|
|
430
681
|
index.status = status;
|
|
431
682
|
index.verdict = input.verdict ?? (status === "done" ? "accepted" : status);
|
|
432
683
|
index.next_action = input.nextAction ?? null;
|
|
@@ -443,27 +694,80 @@ export function completeFlowRun(context, input) {
|
|
|
443
694
|
}
|
|
444
695
|
persistRunState(context, project, run, index);
|
|
445
696
|
closeRunOrchestrators(context, project.id, run, status, now);
|
|
697
|
+
const usageProjection = recalculateRunUsage(context, { projectId: project.id, runId: run.id });
|
|
698
|
+
const usage = {
|
|
699
|
+
status: usageProjection.status ?? "unavailable",
|
|
700
|
+
session_count: Array.isArray(usageProjection.sessions) ? usageProjection.sessions.length : 0,
|
|
701
|
+
coverage: usageProjection.coverage ?? {},
|
|
702
|
+
session_reconciliation: usageProjection.session_reconciliation ?? {}
|
|
703
|
+
};
|
|
446
704
|
refreshRunSessionProjection(context, project.id, run.id);
|
|
447
705
|
appendAudit(context, {
|
|
448
706
|
projectId: project.id,
|
|
449
|
-
eventType: "flow_run.completed",
|
|
450
|
-
payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action }
|
|
707
|
+
eventType: input.manualOverrideReason ? "flow_run.manual_override" : "flow_run.completed",
|
|
708
|
+
payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) }
|
|
451
709
|
});
|
|
452
|
-
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_completed", run_id: run.id, status });
|
|
453
|
-
|
|
710
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: input.manualOverrideReason ? "run_manual_override" : "run_completed", run_id: run.id, status, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) });
|
|
711
|
+
const updatedRun = requireRunById(context, project.id, run.id);
|
|
712
|
+
return { ok: true, run: flowRunSummary(updatedRun), index, usage, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
713
|
+
}
|
|
714
|
+
/** Update a live RUN between stages without manufacturing terminal evidence. */
|
|
715
|
+
export function advanceFlowRun(context, input) {
|
|
716
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
717
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
718
|
+
const index = authoritativeIndex(run);
|
|
719
|
+
const now = context.now();
|
|
720
|
+
index.status = input.status;
|
|
721
|
+
index.verdict = input.verdict;
|
|
722
|
+
index.next_action = input.nextAction ?? null;
|
|
723
|
+
index.updated_at = now;
|
|
724
|
+
persistRunState(context, project, run, index);
|
|
725
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: input.status, verdict: input.verdict, next_action: index.next_action } });
|
|
726
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: input.status, next_action: index.next_action });
|
|
454
727
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
455
728
|
return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
456
729
|
}
|
|
730
|
+
function closeOpenStagesForOverride(index, status, now) {
|
|
731
|
+
const stageStatus = status === "cancelled" ? "skipped" : "failed";
|
|
732
|
+
for (const stage of index.stage_runs) {
|
|
733
|
+
if (stage.status !== "running" && stage.status !== "pending")
|
|
734
|
+
continue;
|
|
735
|
+
stage.status = stageStatus;
|
|
736
|
+
stage.updated_at = now;
|
|
737
|
+
stage.completed_at = now;
|
|
738
|
+
stage.duration_ms = durationMs(stage.started_at ?? null, now);
|
|
739
|
+
}
|
|
740
|
+
index.attempts = index.stage_runs.map((stage) => ({
|
|
741
|
+
stage: stage.stage,
|
|
742
|
+
attempt_number: Number((stage.attempt ?? "try-001").replace("try-", "")) || 1,
|
|
743
|
+
root: stage.dir,
|
|
744
|
+
archive: null,
|
|
745
|
+
status: stage.status,
|
|
746
|
+
started_at: stage.started_at ?? now,
|
|
747
|
+
finished_at: stage.completed_at ?? null
|
|
748
|
+
}));
|
|
749
|
+
}
|
|
750
|
+
function closeOpenWorksForOverride(context, projectId, runId, status, now) {
|
|
751
|
+
const workStatus = status === "cancelled" ? "cancelled" : "failed";
|
|
752
|
+
context.db.run(`UPDATE works
|
|
753
|
+
SET status = ?, updated_at = ?, completed_at = ?
|
|
754
|
+
WHERE project_id = ? AND run_id = ?
|
|
755
|
+
AND status IN ('created', 'running')`, [workStatus, now, now, projectId, runId]);
|
|
756
|
+
context.db.run(`UPDATE work_sessions
|
|
757
|
+
SET status = ?, updated_at = ?, completed_at = ?
|
|
758
|
+
WHERE work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)
|
|
759
|
+
AND status <> 'completed'`, [workStatus, now, now, projectId, runId]);
|
|
760
|
+
}
|
|
457
761
|
function closeRunOrchestrators(context, projectId, run, status, now) {
|
|
458
762
|
if (!["done", "blocked", "cancelled", "failed"].includes(status))
|
|
459
763
|
return;
|
|
460
|
-
const active = context.db.all(`SELECT session_id FROM
|
|
764
|
+
const active = context.db.all(`SELECT session_id FROM sessions
|
|
461
765
|
WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
|
|
462
766
|
AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [projectId, run.id]);
|
|
463
767
|
if (active.length === 0)
|
|
464
768
|
return;
|
|
465
769
|
const reason = `run_${status}`;
|
|
466
|
-
context.db.run(`UPDATE
|
|
770
|
+
context.db.run(`UPDATE sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
467
771
|
WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
|
|
468
772
|
AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, projectId, run.id]);
|
|
469
773
|
context.db.run(`UPDATE flow_session_segments SET ended_at = ?
|
|
@@ -528,7 +832,7 @@ export function getFlowRunTimeline(context, input) {
|
|
|
528
832
|
if (!hidden.has("sessions"))
|
|
529
833
|
report.sessions = sessions;
|
|
530
834
|
if (!hidden.has("usage"))
|
|
531
|
-
report.usage =
|
|
835
|
+
report.usage = { status: "available_via_stat_usage", command: `dd-flow stat usage --run ${run.id} --project-root ${JSON.stringify(run.project_root)} --json` };
|
|
532
836
|
if (!hidden.has("artifacts"))
|
|
533
837
|
report.artifacts = index.stage_runs.map((stage) => ({
|
|
534
838
|
stage: stage.stage,
|
|
@@ -540,16 +844,51 @@ export function getFlowRunTimeline(context, input) {
|
|
|
540
844
|
}));
|
|
541
845
|
return report;
|
|
542
846
|
}
|
|
543
|
-
export function
|
|
847
|
+
export function getStoredFlowRunUsage(context, input) {
|
|
544
848
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
545
849
|
const run = resolveRun(context, project.id, input.runId);
|
|
546
|
-
|
|
850
|
+
const index = authoritativeIndex(run);
|
|
851
|
+
const stageNames = input.stage?.split(",").map((value) => value.trim()).filter(Boolean) ?? [];
|
|
852
|
+
const stageRuns = stageNames.map((name) => index.stage_runs.find((item) => item.stage === name));
|
|
853
|
+
if (stageNames.length && stageRuns.some((stage) => !stage?.started_at))
|
|
854
|
+
throw new AppError("not_found", "RUN stage timing is unavailable", 1, { run_id: run.id, stages: stageNames });
|
|
855
|
+
const starts = stageRuns.map((stage) => stage.started_at).sort();
|
|
856
|
+
const finishes = stageRuns.map((stage) => stage.completed_at).filter((value) => Boolean(value)).sort();
|
|
857
|
+
const usage = recalculateRunUsage(context, {
|
|
858
|
+
projectId: project.id,
|
|
859
|
+
runId: run.id,
|
|
860
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
861
|
+
...(starts.length ? { stage: { name: stageNames.join(","), startedAt: starts[0], completedAt: finishes.length === stageRuns.length ? finishes.at(-1) : null } } : {})
|
|
862
|
+
});
|
|
863
|
+
refreshRunSessionProjection(context, project.id, run.id);
|
|
864
|
+
return usage;
|
|
865
|
+
}
|
|
866
|
+
export function getFlowRunSessions(context, input) {
|
|
867
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
868
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
869
|
+
const sessions = context.db.all(`SELECT s.session_id, s.harness, s.provider_session_id, s.parent_session_id,
|
|
870
|
+
s.agent_id, s.worker_id, s.provider, s.model, s.reasoning, s.mode,
|
|
871
|
+
s.agent_type, s.transcript_path,
|
|
872
|
+
COUNT(ws.id) AS work_link_count
|
|
873
|
+
FROM sessions s
|
|
874
|
+
JOIN work_sessions ws ON ws.session_id = s.session_id
|
|
875
|
+
JOIN works w ON w.work_id = ws.work_id
|
|
876
|
+
WHERE w.project_id = ? AND w.run_id = ?
|
|
877
|
+
GROUP BY s.session_id, s.harness, s.provider_session_id, s.parent_session_id,
|
|
878
|
+
s.agent_id, s.worker_id, s.provider, s.model, s.reasoning, s.mode,
|
|
879
|
+
s.agent_type, s.transcript_path
|
|
880
|
+
ORDER BY s.created_at, s.session_id`, [project.id, run.id]);
|
|
881
|
+
return { ok: true, schema_id: "dd-flow/run-sessions@3", run_id: run.id, sessions };
|
|
547
882
|
}
|
|
548
883
|
export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
|
|
549
884
|
const run = requireRunById(context, projectId, runId);
|
|
550
885
|
appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
|
|
551
886
|
}
|
|
552
887
|
function guidanceForRun(context, run, index) {
|
|
888
|
+
if (run.flow_kind === "vnext_specify")
|
|
889
|
+
return vnextSpecifyGuidance(run, index);
|
|
890
|
+
if (run.flow_kind === "vnext_protocolize")
|
|
891
|
+
return vnextProtocolizeGuidance(run, index);
|
|
553
892
|
if (run.status === "discarded") {
|
|
554
893
|
return buildRunFlowGuidance({
|
|
555
894
|
stageRuns: index.stage_runs,
|
|
@@ -564,7 +903,9 @@ function guidanceForRun(context, run, index) {
|
|
|
564
903
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
565
904
|
return buildRunFlowGuidance({
|
|
566
905
|
stageRuns: index.stage_runs,
|
|
567
|
-
protocolStage: state.
|
|
906
|
+
protocolStage: state.status === "waiting_for_user" || state.status === "blocked" || state.status === "failed"
|
|
907
|
+
? state.status
|
|
908
|
+
: state.stage,
|
|
568
909
|
...(state.flow_contract ? { contract: state.flow_contract } : {}),
|
|
569
910
|
runId: run.id,
|
|
570
911
|
runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id)
|
|
@@ -577,6 +918,62 @@ function guidanceForRun(context, run, index) {
|
|
|
577
918
|
}
|
|
578
919
|
return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id) });
|
|
579
920
|
}
|
|
921
|
+
function vnextProtocolizeGuidance(run, index) {
|
|
922
|
+
const specifyDone = index.stage_runs.some((stage) => stage.stage === "specify" && stage.status === "done");
|
|
923
|
+
const protocolizeDone = index.stage_runs.some((stage) => stage.stage === "protocolize" && stage.status === "done");
|
|
924
|
+
const planDone = index.stage_runs.some((stage) => stage.stage === "plan" && stage.status === "done");
|
|
925
|
+
const planReviewDone = index.stage_runs.some((stage) => stage.stage === "plan-review" && stage.status === "done");
|
|
926
|
+
const codeDone = index.stage_runs.some((stage) => stage.stage === "code" && stage.status === "done");
|
|
927
|
+
const codeReviewDone = index.stage_runs.some((stage) => stage.stage === "code-review" && stage.status === "done");
|
|
928
|
+
const nextStage = !specifyDone ? "specify" : !protocolizeDone ? "protocolize" : !planDone ? "plan" : !planReviewDone ? "plan-review" : !codeDone ? "code" : !codeReviewDone ? "code-review" : null;
|
|
929
|
+
const currentStage = index.current_stage ?? [...index.stage_runs].reverse().find((stage) => stage.status === "running" || stage.status === "done")?.stage ?? nextStage;
|
|
930
|
+
const terminal = run.status === "done" || run.status === "cancelled" || run.status === "failed" || run.status === "discarded";
|
|
931
|
+
const paused = run.status === "paused";
|
|
932
|
+
const blocked = paused && Boolean(index.blocker);
|
|
933
|
+
return {
|
|
934
|
+
current_stage: currentStage,
|
|
935
|
+
lifecycle: { flow: "mb_sdlc_vnext_protocolize", stage: currentStage, substage: null, status: run.status, terminal, source: "vnext_run" },
|
|
936
|
+
allowed_next_stages: terminal || paused || !nextStage ? [] : [nextStage],
|
|
937
|
+
recommended_next_action: terminal || !nextStage ? "none" : blocked ? "resolve_blocker_then_unblock_same_stage" : paused ? "await_user_answer_then_resume_same_stage" : `start_${nextStage.replace("-", "_")}`,
|
|
938
|
+
recommended_prompt: terminal || !nextStage ? "none" : blocked ? "unblock_command_from_blocker" : paused ? "resume_command_from_pause" : `.memory-bank/dd-flow/vnext/${nextStage}.md`,
|
|
939
|
+
required_predecessor_evidence: !specifyDone ? [] : !protocolizeDone ? ["01-specify/specify.json"] : !planDone ? ["01-specify/specify.json", "02-protocolize/protocolize-result.json"] : !planReviewDone ? ["03-plan/stage-report.json"] : !codeDone ? ["04-plan-review/stage-report.json"] : ["05-code/stage-report.json"],
|
|
940
|
+
guards: [],
|
|
941
|
+
blocked_if_missing: []
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
function vnextSpecifyGuidance(run, index) {
|
|
945
|
+
const terminal = ["done", "failed", "cancelled", "discarded"].includes(run.status);
|
|
946
|
+
const next = run.status === "paused" && index.blocker
|
|
947
|
+
? "resolve_blocker_then_unblock_same_stage"
|
|
948
|
+
: run.status === "paused"
|
|
949
|
+
? "await_user_answer_then_resume_same_stage"
|
|
950
|
+
: run.status === "waiting_for_user"
|
|
951
|
+
? "await_user_answer"
|
|
952
|
+
: run.status === "done"
|
|
953
|
+
? "start_protocolize"
|
|
954
|
+
: run.status === "running"
|
|
955
|
+
? "complete_work_session"
|
|
956
|
+
: terminal
|
|
957
|
+
? "none"
|
|
958
|
+
: "inspect_run_state";
|
|
959
|
+
return {
|
|
960
|
+
current_stage: "specify",
|
|
961
|
+
lifecycle: {
|
|
962
|
+
flow: "mb_sdlc_vnext_specify",
|
|
963
|
+
stage: "specify",
|
|
964
|
+
substage: null,
|
|
965
|
+
status: run.status,
|
|
966
|
+
terminal,
|
|
967
|
+
source: "vnext_run"
|
|
968
|
+
},
|
|
969
|
+
allowed_next_stages: terminal ? [] : ["specify"],
|
|
970
|
+
recommended_next_action: next,
|
|
971
|
+
recommended_prompt: terminal ? "none" : ".memory-bank/dd-flow/vnext/specify.md",
|
|
972
|
+
required_predecessor_evidence: [],
|
|
973
|
+
guards: [],
|
|
974
|
+
blocked_if_missing: []
|
|
975
|
+
};
|
|
976
|
+
}
|
|
580
977
|
function persistRunState(context, project, run, index, options = {}) {
|
|
581
978
|
const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
|
|
582
979
|
const previousRuntime = readRuntimeSnapshot(runtimePath);
|
|
@@ -586,16 +983,17 @@ function persistRunState(context, project, run, index, options = {}) {
|
|
|
586
983
|
if (options.flagRevision) {
|
|
587
984
|
history.push(options.flagRevision);
|
|
588
985
|
}
|
|
986
|
+
const persistedIndex = persistedVnextIndex(index);
|
|
589
987
|
ensureDir(path.dirname(runtimePath));
|
|
590
|
-
writeJsonFile(runtimePath, runtimeSnapshotForIndex(
|
|
591
|
-
context.db.run(`UPDATE
|
|
988
|
+
writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
|
|
989
|
+
context.db.run(`UPDATE runs
|
|
592
990
|
SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
|
|
593
991
|
updated_at = ?, completed_at = ?
|
|
594
992
|
WHERE project_id = ? AND id = ?`, [
|
|
595
993
|
index.status,
|
|
596
994
|
index.verdict,
|
|
597
995
|
index.next_action,
|
|
598
|
-
JSON.stringify(
|
|
996
|
+
JSON.stringify(persistedIndex),
|
|
599
997
|
runtimePath,
|
|
600
998
|
runtimePath,
|
|
601
999
|
index.updated_at,
|
|
@@ -604,6 +1002,58 @@ function persistRunState(context, project, run, index, options = {}) {
|
|
|
604
1002
|
run.id
|
|
605
1003
|
]);
|
|
606
1004
|
}
|
|
1005
|
+
/** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
|
|
1006
|
+
function persistedVnextIndex(index) {
|
|
1007
|
+
if (index.flow_kind !== "vnext_specify" && index.flow_kind !== "vnext_protocolize")
|
|
1008
|
+
return index;
|
|
1009
|
+
const persisted = structuredClone(index);
|
|
1010
|
+
delete persisted.current_stage;
|
|
1011
|
+
delete persisted.attempts;
|
|
1012
|
+
delete persisted.workers;
|
|
1013
|
+
delete persisted.session_coverage;
|
|
1014
|
+
delete persisted.usage_coverage;
|
|
1015
|
+
return persisted;
|
|
1016
|
+
}
|
|
1017
|
+
function runSettings(index) {
|
|
1018
|
+
const planReview = index.settings?.plan_review;
|
|
1019
|
+
const codeReview = index.settings?.code_review;
|
|
1020
|
+
if (planReview && codeReview)
|
|
1021
|
+
return { plan_review: planReview, code_review: codeReview };
|
|
1022
|
+
return {
|
|
1023
|
+
plan_review: {
|
|
1024
|
+
mode: "auto",
|
|
1025
|
+
source: "default",
|
|
1026
|
+
reason: "No explicit review preference was recorded.",
|
|
1027
|
+
updated_at: index.created_at
|
|
1028
|
+
},
|
|
1029
|
+
code_review: codeReview ?? {
|
|
1030
|
+
mode: index.execution_profile?.settings.code_review_mode ?? "auto",
|
|
1031
|
+
source: "default",
|
|
1032
|
+
reason: "No explicit review preference was recorded.",
|
|
1033
|
+
updated_at: index.created_at
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
function materializedRunVariables(index) {
|
|
1038
|
+
const settings = runSettings(index);
|
|
1039
|
+
return {
|
|
1040
|
+
"policy.plan_review.requested_mode": settings.plan_review.mode,
|
|
1041
|
+
"policy.code_review.requested_mode": settings.code_review.mode,
|
|
1042
|
+
...(index.variables ?? {})
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
function setRunVariable(context, input, value) {
|
|
1046
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
1047
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
1048
|
+
const index = authoritativeIndex(run);
|
|
1049
|
+
if (index.variables?.[input.key] === value)
|
|
1050
|
+
return { ok: true, run_id: run.id, key: input.key, value, idempotent: true };
|
|
1051
|
+
index.variables = { ...(index.variables ?? {}), [input.key]: value };
|
|
1052
|
+
index.updated_at = context.now();
|
|
1053
|
+
persistRunState(context, project, run, index);
|
|
1054
|
+
appendRunTimeline(runArtifactRoot(run), { at: index.updated_at, type: "run_variable_set", run_id: run.id, key: input.key });
|
|
1055
|
+
return { ok: true, run_id: run.id, key: input.key, value, idempotent: false };
|
|
1056
|
+
}
|
|
607
1057
|
function appendRunTimeline(runHome, event) {
|
|
608
1058
|
ensureDir(runHome);
|
|
609
1059
|
const timelinePath = path.join(runHome, "timeline.jsonl");
|
|
@@ -644,11 +1094,13 @@ function readRuntimeSnapshot(file) {
|
|
|
644
1094
|
}
|
|
645
1095
|
}
|
|
646
1096
|
function authoritativeIndex(run) {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1097
|
+
try {
|
|
1098
|
+
const state = JSON.parse(run.index_json);
|
|
1099
|
+
return { ...state, schema_id: runSchemaId };
|
|
1100
|
+
}
|
|
1101
|
+
catch (error) {
|
|
1102
|
+
throw new AppError("validation", "RUN mechanical state in SQLite is missing or invalid", 2, { run_id: run.id, cause: String(error) });
|
|
650
1103
|
}
|
|
651
|
-
return { ...runtime, schema_id: runSchemaId };
|
|
652
1104
|
}
|
|
653
1105
|
function sanitizeTimelineValue(value, key) {
|
|
654
1106
|
if (key && /(secret|password|authorization|cookie|api[_-]?key|private[_-]?key|access[_-]?token|refresh[_-]?token|transcript|prompt|tool[_-]?output|document[_-]?content)/i.test(key)) {
|
|
@@ -691,7 +1143,7 @@ function resolveRun(context, projectId, idOrAlias) {
|
|
|
691
1143
|
return requireRunById(context, projectId, idOrAlias);
|
|
692
1144
|
}
|
|
693
1145
|
if (isShortEntityId(idOrAlias)) {
|
|
694
|
-
const matches = context.db.all("SELECT * FROM
|
|
1146
|
+
const matches = context.db.all("SELECT * FROM runs WHERE project_id = ? AND short_id = ? ORDER BY updated_at DESC", [
|
|
695
1147
|
projectId,
|
|
696
1148
|
idOrAlias
|
|
697
1149
|
]);
|
|
@@ -706,14 +1158,14 @@ function resolveRun(context, projectId, idOrAlias) {
|
|
|
706
1158
|
throw new AppError("not_found", `Run is not registered: ${idOrAlias}`, 1, { run_id: idOrAlias });
|
|
707
1159
|
}
|
|
708
1160
|
function requireRunById(context, projectId, runId) {
|
|
709
|
-
const run = context.db.get("SELECT * FROM
|
|
1161
|
+
const run = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
710
1162
|
if (!run) {
|
|
711
1163
|
throw new AppError("not_found", `Run is not registered: ${runId}`, 1, { run_id: runId });
|
|
712
1164
|
}
|
|
713
1165
|
return run;
|
|
714
1166
|
}
|
|
715
1167
|
function nextRunId(context, projectId, slug) {
|
|
716
|
-
const rows = context.db.all("SELECT id FROM
|
|
1168
|
+
const rows = context.db.all("SELECT id FROM runs WHERE project_id = ? AND id LIKE 'RUN-%'", [projectId]);
|
|
717
1169
|
const max = rows.reduce((value, row) => {
|
|
718
1170
|
try {
|
|
719
1171
|
const parsed = parseFullEntityId(row.id);
|
|
@@ -763,19 +1215,19 @@ function parseRunFlowKind(value) {
|
|
|
763
1215
|
return normalized;
|
|
764
1216
|
}
|
|
765
1217
|
function parseRunStatus(value) {
|
|
766
|
-
if (!["running", "done", "blocked", "cancelled", "failed"].includes(value)) {
|
|
1218
|
+
if (!["running", "paused", "waiting_for_user", "done", "blocked", "cancelled", "failed"].includes(value)) {
|
|
767
1219
|
throw new AppError("validation", "run status is not supported", 2, {
|
|
768
1220
|
status: value,
|
|
769
|
-
allowed: ["running", "done", "blocked", "cancelled", "failed"]
|
|
1221
|
+
allowed: ["running", "paused", "waiting_for_user", "done", "blocked", "cancelled", "failed"]
|
|
770
1222
|
});
|
|
771
1223
|
}
|
|
772
1224
|
return value;
|
|
773
1225
|
}
|
|
774
1226
|
function parseStageStatus(value) {
|
|
775
|
-
if (!["pending", "running", "done", "blocked", "skipped", "failed"].includes(value)) {
|
|
1227
|
+
if (!["pending", "running", "paused", "waiting_for_user", "done", "blocked", "skipped", "failed"].includes(value)) {
|
|
776
1228
|
throw new AppError("validation", "stage status is not supported", 2, {
|
|
777
1229
|
status: value,
|
|
778
|
-
allowed: ["pending", "running", "done", "blocked", "skipped", "failed"]
|
|
1230
|
+
allowed: ["pending", "running", "paused", "waiting_for_user", "done", "blocked", "skipped", "failed"]
|
|
779
1231
|
});
|
|
780
1232
|
}
|
|
781
1233
|
return value;
|
|
@@ -841,12 +1293,13 @@ function writeJsonFile(file, value) {
|
|
|
841
1293
|
function runArtifactRoot(run) {
|
|
842
1294
|
return run.run_home_path ?? path.dirname(run.runtime_path);
|
|
843
1295
|
}
|
|
844
|
-
function gitFacts(workspaceRoot) {
|
|
1296
|
+
export function gitFacts(workspaceRoot) {
|
|
845
1297
|
const read = (args) => {
|
|
846
1298
|
const result = spawnSync("git", ["-C", workspaceRoot, ...args.split(" ")], { encoding: "utf8" });
|
|
847
1299
|
return result.status === 0 ? result.stdout.trim() || null : null;
|
|
848
1300
|
};
|
|
849
|
-
const
|
|
1301
|
+
const statusResult = spawnSync("git", ["-C", workspaceRoot, "status", "--porcelain", "--untracked-files=all"], { encoding: "utf8" });
|
|
1302
|
+
const status = statusResult.status === 0 ? statusResult.stdout.trim() : null;
|
|
850
1303
|
return {
|
|
851
1304
|
branch: read("branch --show-current"),
|
|
852
1305
|
head: read("rev-parse HEAD"),
|