@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.100
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 +561 -0
- package/README.md +68 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +305 -0
- package/dist/cli/help.js +91 -15
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/run-cli.js +1506 -334
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/DD-ZCODE.md +99 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +39 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +267 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +70 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
- package/dist/runtime/context.js +3 -1
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-decision.schema.json +1 -1
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +75 -21
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +676 -79
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +134 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +125 -80
- package/dist/services/eval-snapshots.js +838 -74
- package/dist/services/execution-policy.js +170 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +609 -310
- package/dist/services/lanes.js +62 -53
- package/dist/services/lifecycle-command.js +103 -17
- package/dist/services/lifecycle-contract.js +30 -0
- package/dist/services/lifecycle-invocations.js +1128 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +425 -0
- package/dist/services/merge-queue.js +179 -104
- package/dist/services/merge-server.js +78 -27
- package/dist/services/migrations.js +14 -9
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/projects.js +18 -5
- package/dist/services/prompts.js +27 -18
- package/dist/services/protocols.js +64 -20
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/repair-intents.js +40 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +222 -0
- package/dist/services/run-controller-capture.js +136 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +803 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +138 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-projection.js +10 -8
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +343 -0
- package/dist/services/runs.js +437 -81
- package/dist/services/runtime-budget.js +439 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +565 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +243 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +21 -19
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +61 -80
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +44 -18
- package/dist/services/stage-lifecycle.js +112 -108
- package/dist/services/stage-pause.js +117 -73
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/usage.js +108 -132
- package/dist/services/vnext-code-review.js +245 -98
- package/dist/services/vnext-code.js +355 -257
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +166 -26
- package/dist/services/vnext-merge.js +413 -147
- package/dist/services/vnext-plan-review.js +182 -109
- package/dist/services/vnext-plan.js +199 -82
- package/dist/services/vnext-protocolize.js +133 -60
- package/dist/services/vnext-specify.js +94 -71
- package/dist/services/work-registry.js +813 -160
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/worktrees.js +85 -35
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +569 -16
- package/dist/storage/paths.js +17 -4
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +24 -13
- package/tools/audit-runtime-fix-boundaries.mjs +96 -0
- package/tools/repair-paused-run-status.mjs +59 -0
package/dist/services/runs.js
CHANGED
|
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
|
|
6
6
|
import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
|
|
7
7
|
import { AppError } from "../shared/errors.js";
|
|
8
|
-
import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
8
|
+
import { ensureDir, canonicalPath, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { appendAudit } from "./audit.js";
|
|
10
10
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
11
11
|
import { buildRunFlowGuidance } from "./flow-guidance.js";
|
|
@@ -15,6 +15,10 @@ import { refreshRunSessionProjection } from "./run-projection.js";
|
|
|
15
15
|
import { resolveCanonRoot } from "./canon.js";
|
|
16
16
|
import { bindCurrentEngineToRun } from "./engines.js";
|
|
17
17
|
import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
|
|
18
|
+
import { assertSameSessionMerge, loadExecutionRoutingInput, resolveStageTransition, snapshotAgentProfiles } from "./execution-policy.js";
|
|
19
|
+
import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
|
|
20
|
+
import { publicSessionIdentity } from "./session-identity.js";
|
|
21
|
+
import { flowCommand } from "./stage-pause.js";
|
|
18
22
|
const runSchemaId = "dd-flow/flow-run@3";
|
|
19
23
|
const runtimeSchemaId = "dd-flow/flow-run@3";
|
|
20
24
|
const runIdType = "RUN";
|
|
@@ -33,22 +37,26 @@ const allowedRunFlowKinds = [
|
|
|
33
37
|
"review",
|
|
34
38
|
"custom"
|
|
35
39
|
];
|
|
36
|
-
export function
|
|
40
|
+
export function prepareFlowRunStart(context, input) {
|
|
41
|
+
const subjectId = requiredPlain(input.subjectId, "subject-id");
|
|
42
|
+
const subjectType = requiredPlain(input.subjectType, "subject-type");
|
|
43
|
+
const slug = normalizeSlug(input.slug);
|
|
44
|
+
for (const [name, value] of [["task profile", input.taskProfile], ["protocol overrides", input.protocolOverrides], ["RUN overrides", input.runOverrides]]) {
|
|
45
|
+
if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value)))
|
|
46
|
+
throw new AppError("validation", `${name} must be a JSON object`, 2);
|
|
47
|
+
}
|
|
37
48
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
49
|
+
const flowKind = parseRunFlowKind(input.flowKind);
|
|
50
|
+
const vnext = flowKind === "vnext_protocolize" || flowKind === "vnext_specify";
|
|
51
|
+
if (input.executionRoutingFile !== undefined && !vnext)
|
|
52
|
+
throw new AppError("usage", "Execution routing input is supported only for vNext RUNs", 2);
|
|
53
|
+
const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
|
|
54
|
+
const executionProfile = vnext ? snapshotVnextExecutionProfile(projectRoot, context.ddFlowHome, routingInput) : undefined;
|
|
38
55
|
const flowContract = input.flowKind === "mb-upgrade"
|
|
39
56
|
? loadUpgradeFlowContract(context, projectRoot)
|
|
40
57
|
: loadProjectFlowContract(projectRoot);
|
|
41
|
-
registerProject(context, { root: projectRoot });
|
|
42
|
-
const project = requireProjectByRoot(context, projectRoot);
|
|
43
58
|
const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
|
|
44
|
-
const flowKind = parseRunFlowKind(input.flowKind);
|
|
45
|
-
const slug = normalizeSlug(input.slug);
|
|
46
|
-
const runId = nextRunId(context, project.id, slug);
|
|
47
|
-
const { shortId } = parseFullEntityId(runId);
|
|
48
59
|
const now = context.now();
|
|
49
|
-
const executionProfile = (flowKind === "vnext_protocolize" || flowKind === "vnext_specify")
|
|
50
|
-
? snapshotVnextExecutionProfile(projectRoot)
|
|
51
|
-
: undefined;
|
|
52
60
|
const flowFlags = resolveFlowFlags(flowContract, {
|
|
53
61
|
flowKind,
|
|
54
62
|
...(input.preset ? { preset: input.preset } : {}),
|
|
@@ -58,6 +66,14 @@ export function startFlowRun(context, input) {
|
|
|
58
66
|
snapshotRevision: 1,
|
|
59
67
|
now
|
|
60
68
|
});
|
|
69
|
+
return { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags };
|
|
70
|
+
}
|
|
71
|
+
export function startFlowRun(context, input, prepared = prepareFlowRunStart(context, input)) {
|
|
72
|
+
const { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags } = prepared;
|
|
73
|
+
registerProject(context, { root: projectRoot });
|
|
74
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
75
|
+
const runId = nextRunId(context, project.id, slug);
|
|
76
|
+
const { shortId } = parseFullEntityId(runId);
|
|
61
77
|
const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
|
|
62
78
|
const runDirRelative = path.posix.join("runs", runId);
|
|
63
79
|
const runtimePath = projectRunJsonPath(context.ddFlowHome, project.id, runId);
|
|
@@ -67,8 +83,8 @@ export function startFlowRun(context, input) {
|
|
|
67
83
|
short_id: shortId,
|
|
68
84
|
flow_kind: flowKind,
|
|
69
85
|
subject: {
|
|
70
|
-
id:
|
|
71
|
-
title:
|
|
86
|
+
id: subjectId,
|
|
87
|
+
title: subjectId
|
|
72
88
|
},
|
|
73
89
|
project: {
|
|
74
90
|
id: project.id,
|
|
@@ -124,7 +140,9 @@ export function startFlowRun(context, input) {
|
|
|
124
140
|
};
|
|
125
141
|
ensureDir(path.dirname(runtimePath));
|
|
126
142
|
ensureDir(runHome);
|
|
127
|
-
|
|
143
|
+
if (routingInput)
|
|
144
|
+
fs.writeFileSync(path.join(runHome, "execution-routing.input.json"), routingInput.bytes, { flag: "wx", mode: 0o600 });
|
|
145
|
+
bindCurrentEngineToRun(context, { projectRoot, runId, runRoot: runHome });
|
|
128
146
|
const persistedIndex = persistedVnextIndex(index);
|
|
129
147
|
writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
|
|
130
148
|
context.db.run(`INSERT INTO runs
|
|
@@ -139,7 +157,7 @@ export function startFlowRun(context, input) {
|
|
|
139
157
|
project.root,
|
|
140
158
|
workspaceRoot,
|
|
141
159
|
flowKind,
|
|
142
|
-
|
|
160
|
+
subjectType,
|
|
143
161
|
index.subject.id,
|
|
144
162
|
index.status,
|
|
145
163
|
index.verdict,
|
|
@@ -171,10 +189,10 @@ export function startFlowRun(context, input) {
|
|
|
171
189
|
});
|
|
172
190
|
return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
|
|
173
191
|
}
|
|
174
|
-
function snapshotVnextExecutionProfile(projectRoot) {
|
|
192
|
+
function snapshotVnextExecutionProfile(projectRoot, ddFlowHome, routingInput) {
|
|
175
193
|
const profile = loadVnextExecutionProfile(projectRoot);
|
|
176
194
|
const sourcePath = executionProfilePath(projectRoot);
|
|
177
|
-
return {
|
|
195
|
+
return freezeRunExecutionRouting(ddFlowHome, {
|
|
178
196
|
schema_id: "dd-flow/run-execution-profile@1",
|
|
179
197
|
source_path: sourcePath,
|
|
180
198
|
source_checksum: crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"),
|
|
@@ -186,9 +204,41 @@ function snapshotVnextExecutionProfile(projectRoot) {
|
|
|
186
204
|
merge_delivery: profile.merge_delivery,
|
|
187
205
|
merge_cleanup: profile.merge_cleanup,
|
|
188
206
|
stop_target: profile.stop_target,
|
|
189
|
-
code_bootstrap: profile.code_bootstrap
|
|
207
|
+
code_bootstrap: profile.code_bootstrap,
|
|
208
|
+
...(profile.execution ? { execution: profile.execution } : {})
|
|
190
209
|
}
|
|
191
|
-
};
|
|
210
|
+
}, routingInput);
|
|
211
|
+
}
|
|
212
|
+
/** Fresh RUN and fresh stage-entry experiments freeze through the same resolver. */
|
|
213
|
+
export function freezeRunExecutionRouting(ddFlowHome, base, input) {
|
|
214
|
+
const execution = input?.execution ?? base.settings.execution;
|
|
215
|
+
const profiles = execution ? snapshotAgentProfiles(ddFlowHome, execution) : undefined;
|
|
216
|
+
if (execution && profiles && base.settings.merge_mode === "same_session" && base.settings.stop_target === "merge_completed") {
|
|
217
|
+
const mode = base.settings.code_review_mode;
|
|
218
|
+
assertSameSessionMerge({ ddFlowHome, policy: execution, profiles, previousStages: mode === "off" ? ["code"] : mode === "auto" ? ["code", "code-review"] : ["code-review"] });
|
|
219
|
+
}
|
|
220
|
+
return { ...base, ...(profiles ? { agent_profiles: profiles } : {}),
|
|
221
|
+
...(input ? { routing_input: { source_path: input.source_path, source_checksum: input.source_checksum, artifact: "execution-routing.input.json" } } : {}),
|
|
222
|
+
settings: { ...base.settings, ...(execution ? { execution } : {}) } };
|
|
223
|
+
}
|
|
224
|
+
/** Called only after a stage-entry import has archived and detached source owners. */
|
|
225
|
+
export function importFlowRunExecutionProfile(context, input) {
|
|
226
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
227
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
228
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
229
|
+
const index = authoritativeIndex(run);
|
|
230
|
+
const lineage = JSON.parse(fs.readFileSync(input.lineageFile, "utf8"));
|
|
231
|
+
if (lineage.schema_id !== "dd-flow/snapshot-runtime-lineage@1" || lineage.run_id !== run.id || path.resolve(lineage.restored_runtime ?? "") !== path.resolve(context.ddFlowHome)
|
|
232
|
+
|| !vnextStages.some(stage => stage.id === input.stage)
|
|
233
|
+
|| (context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_controllers'") && context.db.get("SELECT 1 FROM run_controllers WHERE project_id = ? AND run_id = ? LIMIT 1", [project.id, run.id]))
|
|
234
|
+
|| index.stage_runs.some(stage => stage.stage === input.stage || !["done", "skipped"].includes(stage.status))
|
|
235
|
+
|| context.db.get("SELECT 1 FROM sessions WHERE project_id = ? AND run_id = ? AND status IN ('active','idle') LIMIT 1", [project.id, run.id])) {
|
|
236
|
+
throw new AppError("snapshot_execution_active", "Execution policy can be selected only at a detached stage-entry import", 1);
|
|
237
|
+
}
|
|
238
|
+
index.execution_profile = { ...input.profile, stage_entry_import: { stage: input.stage, lineage_file: input.lineageFile } };
|
|
239
|
+
index.updated_at = context.now();
|
|
240
|
+
persistRunState(context, project, run, index);
|
|
241
|
+
appendRunTimeline(run.run_root, { at: index.updated_at, type: "execution_policy_imported", stage: input.stage, lineage_file: input.lineageFile, source_checksum: input.profile.routing_input?.source_checksum });
|
|
192
242
|
}
|
|
193
243
|
/**
|
|
194
244
|
* A vNext route may materialize a feature checkout after its semantic
|
|
@@ -198,6 +248,7 @@ function snapshotVnextExecutionProfile(projectRoot) {
|
|
|
198
248
|
export function rebindFlowRunWorkspace(context, input) {
|
|
199
249
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
200
250
|
const run = resolveRun(context, project.id, input.runId);
|
|
251
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
201
252
|
const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot);
|
|
202
253
|
const index = authoritativeIndex(run);
|
|
203
254
|
const now = context.now();
|
|
@@ -227,7 +278,41 @@ export function getFlowRunStatus(context, input) {
|
|
|
227
278
|
const run = resolveRun(context, project.id, input.runId);
|
|
228
279
|
const index = authoritativeIndex(run);
|
|
229
280
|
const runtime = readRuntimeSnapshot(run.runtime_path);
|
|
230
|
-
return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
|
|
281
|
+
return { ok: true, run: flowRunSummary(run), index, continuation: runContinuation(context, run, index), ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
|
|
282
|
+
}
|
|
283
|
+
/** A read-only projection of the next legal lifecycle action.
|
|
284
|
+
* It is derived from the persisted RUN/Work facts so controllers never infer a
|
|
285
|
+
* transition from a model's prose or a hard-coded stage array. */
|
|
286
|
+
function runContinuation(context, run, index) {
|
|
287
|
+
if (["done", "failed", "cancelled"].includes(run.status))
|
|
288
|
+
return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: run.status, command: null };
|
|
289
|
+
const control = runControlDirective(context, run.project_id, run.id);
|
|
290
|
+
if (control)
|
|
291
|
+
return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "operator_control", command: null, control };
|
|
292
|
+
const active = index.stage_runs.find((stage) => !["done", "skipped"].includes(stage.status));
|
|
293
|
+
if (active) {
|
|
294
|
+
const runningWork = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND status = 'running' ORDER BY updated_at DESC LIMIT 1", [run.project_id, run.id]);
|
|
295
|
+
const kind = active.status === "running" ? "continue_stage" : active.status === "paused" || active.status === "waiting_for_user" ? "paused" : "blocked";
|
|
296
|
+
return { kind, stage: active.stage, attempt: active.attempt ?? null, work_id: active.pause?.work_id ?? runningWork?.work_id ?? null, reason: active.status, command: null };
|
|
297
|
+
}
|
|
298
|
+
if (run.status === "waiting_for_user" || run.status === "paused")
|
|
299
|
+
return { kind: "paused", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
|
|
300
|
+
if (run.status === "blocked")
|
|
301
|
+
return { kind: "blocked", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
|
|
302
|
+
if (run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize") {
|
|
303
|
+
const completed = index.stage_runs.filter((stage) => stage.status === "done" || stage.status === "skipped").sort((left, right) => right.order - left.order)[0];
|
|
304
|
+
const next = completed ? vnextStages.find((stage) => stage.id === completed.stage)?.next.find((candidate) => candidate !== "plan-review" || index.settings?.plan_review?.mode !== "off") : "specify";
|
|
305
|
+
if (!next)
|
|
306
|
+
return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
|
|
307
|
+
const command = `${flowCommand(context)} stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
|
|
308
|
+
const profile = index.execution_profile;
|
|
309
|
+
const execution = profile?.settings.execution ? profile.agent_profiles
|
|
310
|
+
? { status: "resolved", ...resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, ...(completed ? { fromStage: completed.stage } : {}), stage: next, stageSessionMode: profile.settings.stage_session_mode, mergeMode: profile.settings.merge_mode }) }
|
|
311
|
+
: { status: "unfrozen", reason: "execution_profile_not_frozen" }
|
|
312
|
+
: undefined;
|
|
313
|
+
return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command, ...(execution ? { execution } : {}) };
|
|
314
|
+
}
|
|
315
|
+
return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "no_vnext_continuation", command: null };
|
|
231
316
|
}
|
|
232
317
|
export function getFlowRunConfig(context, input) {
|
|
233
318
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -248,9 +333,12 @@ export function getFlowRunVariables(context, input) {
|
|
|
248
333
|
return { ok: true, run_id: run.id, variables };
|
|
249
334
|
}
|
|
250
335
|
export function setFlowRunVariable(context, input) {
|
|
336
|
+
return setRunVariable(context, input, input.value, prepareFlowRunVariable(context, input));
|
|
337
|
+
}
|
|
338
|
+
export function prepareFlowRunVariable(context, input) {
|
|
251
339
|
if (!input.key.startsWith("user."))
|
|
252
340
|
throw new AppError("validation", "Only user.* RUN variables may be set directly", 2, { key: input.key });
|
|
253
|
-
return
|
|
341
|
+
return prepareRunVariable(context, input);
|
|
254
342
|
}
|
|
255
343
|
/** Controllers own policy.* and runtime.* values; agents never call this directly. */
|
|
256
344
|
export function setRuntimeRunVariable(context, input) {
|
|
@@ -259,6 +347,25 @@ export function setRuntimeRunVariable(context, input) {
|
|
|
259
347
|
return setRunVariable(context, input, input.value);
|
|
260
348
|
}
|
|
261
349
|
export function setFlowRunConfig(context, input) {
|
|
350
|
+
const { reason, project, run, index } = prepareFlowRunConfig(context, input);
|
|
351
|
+
const now = context.now();
|
|
352
|
+
const settings = runSettings(index);
|
|
353
|
+
const previous = input.key === "plan_review.mode" ? settings.plan_review : settings.code_review;
|
|
354
|
+
const mode = input.value;
|
|
355
|
+
const source = input.source ?? "user_instruction";
|
|
356
|
+
if (previous.mode === mode && previous.reason === reason && previous.source === source) {
|
|
357
|
+
return { ok: true, run_id: run.id, settings, idempotent: true };
|
|
358
|
+
}
|
|
359
|
+
index.settings = input.key === "plan_review.mode"
|
|
360
|
+
? { ...settings, plan_review: { mode, source, reason, updated_at: now } }
|
|
361
|
+
: { ...settings, code_review: { mode, source, reason, updated_at: now } };
|
|
362
|
+
index.updated_at = now;
|
|
363
|
+
persistRunState(context, project, run, index);
|
|
364
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.config_set", payload: { run_id: run.id, key: input.key, value: mode, source, reason } });
|
|
365
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
|
|
366
|
+
return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
|
|
367
|
+
}
|
|
368
|
+
export function prepareFlowRunConfig(context, input) {
|
|
262
369
|
if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
|
|
263
370
|
throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
|
|
264
371
|
}
|
|
@@ -268,27 +375,30 @@ export function setFlowRunConfig(context, input) {
|
|
|
268
375
|
const reason = requiredPlain(input.reason, "reason");
|
|
269
376
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
270
377
|
const run = resolveRun(context, project.id, input.runId);
|
|
378
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
271
379
|
const index = authoritativeIndex(run);
|
|
272
380
|
const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
|
|
273
381
|
if (index.stage_runs.some((stage) => stage.stage === stageName)) {
|
|
274
382
|
throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
|
|
275
383
|
}
|
|
276
|
-
|
|
384
|
+
return { reason, project, run, index };
|
|
385
|
+
}
|
|
386
|
+
/** Freeze an automatic review request once from already accepted policy or PLAN facts. */
|
|
387
|
+
export function freezeFlowRunReviewMode(context, input) {
|
|
388
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
389
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
390
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
391
|
+
const index = authoritativeIndex(run);
|
|
277
392
|
const settings = runSettings(index);
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
index.settings = input.key === "plan_review.mode"
|
|
285
|
-
? { ...settings, plan_review: { mode, source, reason, updated_at: now } }
|
|
286
|
-
: { ...settings, code_review: { mode, source, reason, updated_at: now } };
|
|
393
|
+
const key = input.review === "plan" ? "plan_review" : "code_review";
|
|
394
|
+
const current = settings[key];
|
|
395
|
+
if (current.mode !== "auto")
|
|
396
|
+
return;
|
|
397
|
+
const now = context.now();
|
|
398
|
+
index.settings = { ...settings, [key]: { mode: input.mode, source: input.source, reason: requiredPlain(input.reason, "reason"), updated_at: now } };
|
|
287
399
|
index.updated_at = now;
|
|
288
400
|
persistRunState(context, project, run, index);
|
|
289
|
-
appendAudit(context, { projectId: project.id, eventType: "flow_run.
|
|
290
|
-
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
|
|
291
|
-
return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
|
|
401
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.review_mode_frozen", payload: { run_id: run.id, review: input.review, mode: input.mode, source: input.source, reason: input.reason } });
|
|
292
402
|
}
|
|
293
403
|
export function getFlowRunFlagsStatus(context, input) {
|
|
294
404
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -319,8 +429,9 @@ export function getFlowRunFlagsStatus(context, input) {
|
|
|
319
429
|
export function reviseFlowRunFlags(context, input) {
|
|
320
430
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
321
431
|
const run = resolveRun(context, project.id, input.runId);
|
|
432
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
322
433
|
const idempotencyKey = requiredPlain(input.idempotencyKey, "idempotency-key");
|
|
323
|
-
context.db.
|
|
434
|
+
context.db.beginWriteTransaction();
|
|
324
435
|
let committed = false;
|
|
325
436
|
try {
|
|
326
437
|
const existingMutation = context.db.get("SELECT result_json FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? AND idempotency_key = ?", [project.id, run.id, idempotencyKey]);
|
|
@@ -441,17 +552,24 @@ export function listFlowRuns(context, input) {
|
|
|
441
552
|
.map(flowRunSummary);
|
|
442
553
|
return { ok: true, project, runs };
|
|
443
554
|
}
|
|
444
|
-
export function
|
|
555
|
+
export function prepareFlowRunStageAttachment(context, input) {
|
|
445
556
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
446
557
|
const run = resolveRun(context, project.id, input.runId);
|
|
558
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
447
559
|
const index = authoritativeIndex(run);
|
|
448
|
-
const now = context.now();
|
|
449
560
|
const stage = requiredPlain(input.stage, "stage");
|
|
450
|
-
const dir =
|
|
561
|
+
const dir = prepareStageDirectory(input.dir);
|
|
451
562
|
const status = parseStageStatus(input.status);
|
|
452
563
|
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
564
|
+
assertVnextStageStart(run, index, stage, status, existing);
|
|
565
|
+
return { project, run, index, stage, dir, status, existing };
|
|
566
|
+
}
|
|
567
|
+
export function attachFlowRunStage(context, input) {
|
|
568
|
+
// Mutable stage state is re-read immediately before archiving/persisting.
|
|
569
|
+
const { project, run, index, stage, dir, status, existing } = prepareFlowRunStageAttachment(context, input);
|
|
570
|
+
const now = context.now();
|
|
453
571
|
if (status === "running" && existing) {
|
|
454
|
-
archiveExistingStageAttempt(runArtifactRoot(run), dir);
|
|
572
|
+
archiveExistingStageAttempt(context, project.id, run.id, runArtifactRoot(run), dir);
|
|
455
573
|
}
|
|
456
574
|
const stageRun = {
|
|
457
575
|
...(existing ?? { order: index.stage_runs.length + 1 }),
|
|
@@ -487,17 +605,39 @@ export function attachFlowRunStage(context, input) {
|
|
|
487
605
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
488
606
|
return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
489
607
|
}
|
|
490
|
-
|
|
608
|
+
/** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
|
|
609
|
+
export function withStageSettlement(context, operation) {
|
|
610
|
+
return context.db.writeTransaction(operation);
|
|
611
|
+
}
|
|
612
|
+
export function prepareFlowRunStageCompletion(context, input) {
|
|
613
|
+
for (const [name, value] of Object.entries({ stageReport: input.stageReport, data: input.data, dataSchemaId: input.dataSchemaId, report: input.report })) {
|
|
614
|
+
if (value !== undefined)
|
|
615
|
+
requiredPlain(value, name);
|
|
616
|
+
}
|
|
617
|
+
if (input.aliases !== undefined) {
|
|
618
|
+
if (!Array.isArray(input.aliases))
|
|
619
|
+
throw new AppError("validation", "artifact aliases must be an array", 2);
|
|
620
|
+
for (const alias of input.aliases)
|
|
621
|
+
requiredPlain(alias, "artifact alias");
|
|
622
|
+
}
|
|
491
623
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
492
624
|
const run = resolveRun(context, project.id, input.runId);
|
|
625
|
+
if (["done", "failed", "skipped"].includes(input.status))
|
|
626
|
+
assertStageSettlementAllowed(context, project.id, run.id, input.stage);
|
|
627
|
+
else
|
|
628
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
493
629
|
const index = authoritativeIndex(run);
|
|
494
|
-
const now = context.now();
|
|
495
630
|
const stage = requiredPlain(input.stage, "stage");
|
|
496
631
|
const status = parseStageStatus(input.status);
|
|
497
632
|
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
498
633
|
if (!existing) {
|
|
499
634
|
throw new AppError("not_found", `Run stage is not attached: ${stage}`, 1, { run_id: run.id, stage });
|
|
500
635
|
}
|
|
636
|
+
return { project, run, index, stage, status, existing };
|
|
637
|
+
}
|
|
638
|
+
export function completeFlowRunStage(context, input) {
|
|
639
|
+
const { project, run, index, stage, status, existing } = prepareFlowRunStageCompletion(context, input);
|
|
640
|
+
const now = context.now();
|
|
501
641
|
const stageRun = {
|
|
502
642
|
...existing,
|
|
503
643
|
status,
|
|
@@ -505,6 +645,7 @@ export function completeFlowRunStage(context, input) {
|
|
|
505
645
|
...(input.data ? { data: input.data } : {}),
|
|
506
646
|
...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
|
|
507
647
|
...(input.report ? { report: input.report } : {}),
|
|
648
|
+
...(input.usage ? { usage: input.usage } : {}),
|
|
508
649
|
...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
|
|
509
650
|
updated_at: now,
|
|
510
651
|
...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
|
|
@@ -547,6 +688,7 @@ export function completeFlowRunStage(context, input) {
|
|
|
547
688
|
export function pauseFlowRunStage(context, input) {
|
|
548
689
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
549
690
|
const run = resolveRun(context, project.id, input.runId);
|
|
691
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
550
692
|
const index = authoritativeIndex(run);
|
|
551
693
|
const stage = requiredPlain(input.stage, "stage");
|
|
552
694
|
const stageRun = index.stage_runs.find((item) => item.stage === stage);
|
|
@@ -582,6 +724,7 @@ export function pauseFlowRunStage(context, input) {
|
|
|
582
724
|
export function resumeFlowRunStage(context, input) {
|
|
583
725
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
584
726
|
const run = resolveRun(context, project.id, input.runId);
|
|
727
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
585
728
|
const index = authoritativeIndex(run);
|
|
586
729
|
const stage = requiredPlain(input.stage, "stage");
|
|
587
730
|
const stageRun = index.stage_runs.find((item) => item.stage === stage);
|
|
@@ -610,14 +753,23 @@ export function resumeFlowRunStage(context, input) {
|
|
|
610
753
|
return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
|
|
611
754
|
}
|
|
612
755
|
/** Pause the current stage for a non-user runtime blocker. */
|
|
613
|
-
export function
|
|
756
|
+
export function prepareFlowRunStageBlock(context, input) {
|
|
757
|
+
if (!["engine", "harness", "environment"].includes(input.kind) || typeof input.retryable !== "boolean")
|
|
758
|
+
throw new AppError("validation", "stage block requires a valid kind and boolean retryable", 2);
|
|
759
|
+
requiredPlain(input.code, "code");
|
|
760
|
+
requiredPlain(input.summary, "summary");
|
|
614
761
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
615
762
|
const run = resolveRun(context, project.id, input.runId);
|
|
763
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
616
764
|
const index = authoritativeIndex(run);
|
|
617
765
|
const stage = requiredPlain(input.stage, "stage");
|
|
618
766
|
const stageRun = index.stage_runs.find((item) => item.stage === stage);
|
|
619
767
|
if (!stageRun || stageRun.status !== "running")
|
|
620
768
|
throw new AppError("invalid_stage_state", "Only a running stage can be blocked", 1, { run_id: run.id, stage, status: stageRun?.status ?? "missing" });
|
|
769
|
+
return { project, run, index, stage, stageRun };
|
|
770
|
+
}
|
|
771
|
+
export function blockFlowRunStage(context, input, prepared = prepareFlowRunStageBlock(context, input)) {
|
|
772
|
+
const { project, run, index, stage, stageRun } = prepared;
|
|
621
773
|
const now = context.now();
|
|
622
774
|
stageRun.status = "blocked";
|
|
623
775
|
stageRun.updated_at = now;
|
|
@@ -635,16 +787,20 @@ export function blockFlowRunStage(context, input) {
|
|
|
635
787
|
return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
|
|
636
788
|
}
|
|
637
789
|
/** Continue the same stage after its non-user blocker was repaired externally. */
|
|
638
|
-
export function
|
|
790
|
+
export function prepareFlowRunStageUnblock(context, input) {
|
|
639
791
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
640
792
|
const run = resolveRun(context, project.id, input.runId);
|
|
793
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
641
794
|
const index = authoritativeIndex(run);
|
|
642
795
|
const stage = requiredPlain(input.stage, "stage");
|
|
643
796
|
const stageRun = index.stage_runs.find((item) => item.stage === stage);
|
|
644
797
|
if (!stageRun || stageRun.status !== "blocked" || !stageRun.blocker || stageRun.blocker.work_id !== input.workId)
|
|
645
798
|
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" });
|
|
799
|
+
return { project, run, index, stage, stageRun, blocker: stageRun.blocker };
|
|
800
|
+
}
|
|
801
|
+
export function unblockFlowRunStage(context, input, prepared = prepareFlowRunStageUnblock(context, input)) {
|
|
802
|
+
const { project, run, index, stage, stageRun, blocker } = prepared;
|
|
646
803
|
const now = context.now();
|
|
647
|
-
const blocker = stageRun.blocker;
|
|
648
804
|
const blockedMs = durationMs(blocker.blocked_at, now) ?? 0;
|
|
649
805
|
stageRun.status = "running";
|
|
650
806
|
stageRun.updated_at = now;
|
|
@@ -668,15 +824,50 @@ function operationalRunStatus(context, projectId, runId) {
|
|
|
668
824
|
const leaves = unfinished.filter((work) => !parents.has(work.work_id));
|
|
669
825
|
return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
|
|
670
826
|
}
|
|
671
|
-
|
|
672
|
-
|
|
827
|
+
/** Keep the logical RUN projection aligned with recovery authority. Stage/Work
|
|
828
|
+
* evidence and verdicts survive a reversible stop; terminal runs never reopen. */
|
|
829
|
+
export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
|
|
830
|
+
const run = resolveRun(context, projectId, runId);
|
|
831
|
+
if (!["running", "paused"].includes(run.status))
|
|
832
|
+
return;
|
|
833
|
+
const index = authoritativeIndex(run);
|
|
834
|
+
const status = paused || index.stage_runs.some(stage => ["paused", "waiting_for_user", "blocked"].includes(stage.status))
|
|
835
|
+
? "paused" : operationalRunStatus(context, projectId, runId);
|
|
836
|
+
if (run.status === status && index.status === status)
|
|
837
|
+
return;
|
|
838
|
+
index.status = status;
|
|
839
|
+
index.updated_at = context.now();
|
|
840
|
+
const project = requireProjectByRoot(context, run.project_root);
|
|
841
|
+
persistRunState(context, project, run, index);
|
|
842
|
+
}
|
|
843
|
+
export function prepareFlowRunCompletion(context, input) {
|
|
844
|
+
if (input.manualOverrideReason !== undefined) {
|
|
845
|
+
requiredPlain(input.manualOverrideReason, "override reason");
|
|
846
|
+
if (!["cancelled", "failed"].includes(input.status))
|
|
847
|
+
throw new AppError("validation", "Manual RUN override may only cancel or fail a RUN", 2);
|
|
848
|
+
}
|
|
849
|
+
if (input.verdict !== undefined)
|
|
850
|
+
requiredPlain(input.verdict, "verdict");
|
|
851
|
+
if (input.nextAction !== undefined)
|
|
852
|
+
requiredPlain(input.nextAction, "next action");
|
|
853
|
+
const project = requireProjectByRoot(context, input.manualOverrideReason ? canonicalPath(input.projectRoot, false) : resolveProjectRoot(input.projectRoot));
|
|
673
854
|
const run = resolveRun(context, project.id, input.runId);
|
|
855
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
674
856
|
const index = authoritativeIndex(run);
|
|
675
|
-
const now = context.now();
|
|
676
857
|
const status = parseRunStatus(input.status);
|
|
677
|
-
if (!['done', '
|
|
678
|
-
throw new AppError('validation', 'completeFlowRun accepts
|
|
858
|
+
if (!['done', 'cancelled', 'failed'].includes(status)) {
|
|
859
|
+
throw new AppError('validation', 'completeFlowRun accepts only done, cancelled, or failed; use stage block/unblock for a recoverable blocker', 2, { status });
|
|
860
|
+
}
|
|
861
|
+
if (!input.manualOverrideReason) {
|
|
862
|
+
const activeWork = context.db.get("SELECT work_id, status FROM works WHERE project_id = ? AND run_id = ? AND status IN ('created', 'running', 'paused') LIMIT 1", [project.id, run.id]);
|
|
863
|
+
if (activeWork)
|
|
864
|
+
throw new AppError("active_work", "RUN cannot complete while Work remains active", 2, { run_id: run.id, work_id: activeWork.work_id, status: activeWork.status });
|
|
679
865
|
}
|
|
866
|
+
return { project, run, index, status };
|
|
867
|
+
}
|
|
868
|
+
export function completeFlowRun(context, input) {
|
|
869
|
+
const { project, run, index, status } = prepareFlowRunCompletion(context, input);
|
|
870
|
+
const now = context.now();
|
|
680
871
|
if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
|
|
681
872
|
closeOpenStagesForOverride(index, status, now);
|
|
682
873
|
closeOpenWorksForOverride(context, project.id, run.id, status, now);
|
|
@@ -685,7 +876,7 @@ export function completeFlowRun(context, input) {
|
|
|
685
876
|
index.verdict = input.verdict ?? (status === "done" ? "accepted" : status);
|
|
686
877
|
index.next_action = input.nextAction ?? null;
|
|
687
878
|
index.updated_at = now;
|
|
688
|
-
if (["done", "
|
|
879
|
+
if (["done", "cancelled", "failed"].includes(status)) {
|
|
689
880
|
index.completed_at = now;
|
|
690
881
|
index.finished_at = now;
|
|
691
882
|
index.duration_ms = durationMs(index.started_at ?? run.created_at, now);
|
|
@@ -719,21 +910,90 @@ export function advanceFlowRun(context, input) {
|
|
|
719
910
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
720
911
|
const run = resolveRun(context, project.id, input.runId);
|
|
721
912
|
const index = authoritativeIndex(run);
|
|
913
|
+
if (input.settlementStage && input.status === "running" && index.current_stage === input.settlementStage && index.stage_runs.some(stage => stage.stage === input.settlementStage && ["done", "skipped"].includes(stage.status)))
|
|
914
|
+
assertStageSettlementAllowed(context, project.id, run.id, input.settlementStage);
|
|
915
|
+
else
|
|
916
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
917
|
+
const control = runControlDirective(context, project.id, run.id);
|
|
722
918
|
const now = context.now();
|
|
723
919
|
index.status = input.status;
|
|
724
920
|
index.verdict = input.verdict;
|
|
725
|
-
index.next_action = input.nextAction ?? null;
|
|
921
|
+
index.next_action = control ? null : input.nextAction ?? null;
|
|
726
922
|
index.updated_at = now;
|
|
727
923
|
persistRunState(context, project, run, index);
|
|
728
|
-
appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status:
|
|
729
|
-
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status:
|
|
924
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: index.status, verdict: input.verdict, next_action: index.next_action } });
|
|
925
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
|
|
730
926
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
731
|
-
return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
927
|
+
return { ok: true, ...(control ? { control } : {}), run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* The only legal backward edge in vNext: an integration gate exposed a
|
|
931
|
+
* product defect, so the source must be repaired and reviewed again. Normal
|
|
932
|
+
* stage start remains fail-closed for completed stages.
|
|
933
|
+
*/
|
|
934
|
+
export function prepareVnextMergeSourceRepairAttempt(context, input) {
|
|
935
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
936
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
937
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
938
|
+
const index = authoritativeIndex(run);
|
|
939
|
+
const priorRepair = index.variables?.["merge.source_repair"];
|
|
940
|
+
if (priorRepair?.merge_request_id === input.mergeRequestId && priorRepair.cycle)
|
|
941
|
+
return { run_id: run.id, cycle: priorRepair.cycle };
|
|
942
|
+
const code = index.stage_runs.find((item) => item.stage === "code");
|
|
943
|
+
const review = index.stage_runs.find((item) => item.stage === "code-review");
|
|
944
|
+
const merge = index.stage_runs.find((item) => item.stage === "merge");
|
|
945
|
+
if (!code || code.status !== "done" || !review || review.status !== "done" || !merge || merge.status !== "running") {
|
|
946
|
+
throw new AppError("invalid_merge_repair_state", "Source repair requires accepted CODE/CODE-REVIEW and a running MERGE attempt", 2, {
|
|
947
|
+
run_id: run.id,
|
|
948
|
+
code: code?.status ?? null,
|
|
949
|
+
code_review: review?.status ?? null,
|
|
950
|
+
merge: merge?.status ?? null
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
const root = runArtifactRoot(run);
|
|
954
|
+
for (const stage of ["code", "code-review", "merge"])
|
|
955
|
+
archiveExistingStageAttempt(context, project.id, run.id, root, vnextStageDirectory(stage));
|
|
956
|
+
const now = context.now();
|
|
957
|
+
const restarted = {
|
|
958
|
+
...code,
|
|
959
|
+
status: "running",
|
|
960
|
+
started_at: now,
|
|
961
|
+
updated_at: now,
|
|
962
|
+
attempt: nextAttempt(code)
|
|
963
|
+
};
|
|
964
|
+
delete restarted.completed_at;
|
|
965
|
+
delete restarted.duration_ms;
|
|
966
|
+
delete restarted.stage_report;
|
|
967
|
+
delete restarted.data;
|
|
968
|
+
delete restarted.report;
|
|
969
|
+
delete restarted.artifact_aliases;
|
|
970
|
+
const cycle = `MRR-${input.mergeRequestId}`;
|
|
971
|
+
index.stage_runs = index.stage_runs.filter((item) => item.stage !== "code" && item.stage !== "code-review" && item.stage !== "merge");
|
|
972
|
+
index.stage_runs.push(restarted);
|
|
973
|
+
index.variables = { ...(index.variables ?? {}), "merge.source_repair": { cycle, merge_request_id: input.mergeRequestId, prepared_at: now } };
|
|
974
|
+
index.status = "running";
|
|
975
|
+
index.verdict = "merge_source_repair";
|
|
976
|
+
index.next_action = "start_code_repair";
|
|
977
|
+
index.current_stage = "code";
|
|
978
|
+
index.updated_at = now;
|
|
979
|
+
index.attempts = index.stage_runs.map((item) => ({
|
|
980
|
+
stage: item.stage,
|
|
981
|
+
attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1,
|
|
982
|
+
root: item.dir,
|
|
983
|
+
archive: null,
|
|
984
|
+
status: item.status,
|
|
985
|
+
started_at: item.started_at ?? now,
|
|
986
|
+
finished_at: item.completed_at ?? null
|
|
987
|
+
}));
|
|
988
|
+
persistRunState(context, project, run, index);
|
|
989
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.merge_source_repair_prepared", payload: { run_id: run.id, merge_request_id: input.mergeRequestId, cycle } });
|
|
990
|
+
appendRunTimeline(root, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
|
|
991
|
+
return { run_id: run.id, cycle };
|
|
732
992
|
}
|
|
733
993
|
function closeOpenStagesForOverride(index, status, now) {
|
|
734
994
|
const stageStatus = status === "cancelled" ? "skipped" : "failed";
|
|
735
995
|
for (const stage of index.stage_runs) {
|
|
736
|
-
if (stage.status !== "running" && stage.status !== "pending")
|
|
996
|
+
if (stage.status !== "running" && stage.status !== "pending" && stage.status !== "paused" && stage.status !== "blocked")
|
|
737
997
|
continue;
|
|
738
998
|
stage.status = stageStatus;
|
|
739
999
|
stage.updated_at = now;
|
|
@@ -755,7 +1015,7 @@ function closeOpenWorksForOverride(context, projectId, runId, status, now) {
|
|
|
755
1015
|
context.db.run(`UPDATE works
|
|
756
1016
|
SET status = ?, updated_at = ?, completed_at = ?
|
|
757
1017
|
WHERE project_id = ? AND run_id = ?
|
|
758
|
-
AND status IN ('created', 'running')`, [workStatus, now, now, projectId, runId]);
|
|
1018
|
+
AND status IN ('created', 'running', 'paused')`, [workStatus, now, now, projectId, runId]);
|
|
759
1019
|
context.db.run(`UPDATE work_sessions
|
|
760
1020
|
SET status = ?, updated_at = ?, completed_at = ?
|
|
761
1021
|
WHERE work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)
|
|
@@ -881,7 +1141,21 @@ export function getFlowRunSessions(context, input) {
|
|
|
881
1141
|
s.agent_id, s.worker_id, s.provider, s.model, s.reasoning, s.mode,
|
|
882
1142
|
s.agent_type, s.transcript_path
|
|
883
1143
|
ORDER BY s.created_at, s.session_id`, [project.id, run.id]);
|
|
884
|
-
|
|
1144
|
+
const identities = new Map(sessions.map((row) => [String(row.session_id), publicSessionIdentity({
|
|
1145
|
+
harness: String(row.harness),
|
|
1146
|
+
provider_session_id: typeof row.provider_session_id === "string" ? row.provider_session_id : null,
|
|
1147
|
+
session_id: String(row.session_id)
|
|
1148
|
+
})]));
|
|
1149
|
+
return {
|
|
1150
|
+
ok: true,
|
|
1151
|
+
schema_id: "dd-flow/run-sessions@4",
|
|
1152
|
+
run_id: run.id,
|
|
1153
|
+
sessions: sessions.map(({ session_id, harness: _harness, provider_session_id: _providerSessionId, parent_session_id, ...row }) => ({
|
|
1154
|
+
...row,
|
|
1155
|
+
session: identities.get(String(session_id)) ?? null,
|
|
1156
|
+
...(typeof parent_session_id === "string" ? { parent_session: identities.get(parent_session_id) ?? null } : {})
|
|
1157
|
+
}))
|
|
1158
|
+
};
|
|
885
1159
|
}
|
|
886
1160
|
export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
|
|
887
1161
|
const run = requireRunById(context, projectId, runId);
|
|
@@ -980,6 +1254,10 @@ function vnextSpecifyGuidance(run, index) {
|
|
|
980
1254
|
};
|
|
981
1255
|
}
|
|
982
1256
|
function persistRunState(context, project, run, index, options = {}) {
|
|
1257
|
+
// Fenced productive admission is a logical pause, even while physical work
|
|
1258
|
+
// drains. Late admitted Stage results must not revive a stopped RUN.
|
|
1259
|
+
if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
|
|
1260
|
+
index.status = "paused";
|
|
983
1261
|
const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
|
|
984
1262
|
const previousRuntime = readRuntimeSnapshot(runtimePath);
|
|
985
1263
|
const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
|
|
@@ -990,22 +1268,30 @@ function persistRunState(context, project, run, index, options = {}) {
|
|
|
990
1268
|
}
|
|
991
1269
|
const persistedIndex = persistedVnextIndex(index);
|
|
992
1270
|
ensureDir(path.dirname(runtimePath));
|
|
993
|
-
|
|
994
|
-
|
|
1271
|
+
context.db.exec("SAVEPOINT persist_run_state");
|
|
1272
|
+
try {
|
|
1273
|
+
context.db.run(`UPDATE runs
|
|
995
1274
|
SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
|
|
996
1275
|
updated_at = ?, completed_at = ?
|
|
997
1276
|
WHERE project_id = ? AND id = ?`, [
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1277
|
+
index.status,
|
|
1278
|
+
index.verdict,
|
|
1279
|
+
index.next_action,
|
|
1280
|
+
JSON.stringify(persistedIndex),
|
|
1281
|
+
runtimePath,
|
|
1282
|
+
runtimePath,
|
|
1283
|
+
index.updated_at,
|
|
1284
|
+
index.completed_at ?? null,
|
|
1285
|
+
project.id,
|
|
1286
|
+
run.id
|
|
1287
|
+
]);
|
|
1288
|
+
writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
|
|
1289
|
+
context.db.exec("RELEASE persist_run_state");
|
|
1290
|
+
}
|
|
1291
|
+
catch (error) {
|
|
1292
|
+
context.db.exec("ROLLBACK TO persist_run_state; RELEASE persist_run_state");
|
|
1293
|
+
throw error;
|
|
1294
|
+
}
|
|
1009
1295
|
}
|
|
1010
1296
|
/** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
|
|
1011
1297
|
function persistedVnextIndex(index) {
|
|
@@ -1047,10 +1333,15 @@ function materializedRunVariables(index) {
|
|
|
1047
1333
|
...(index.variables ?? {})
|
|
1048
1334
|
};
|
|
1049
1335
|
}
|
|
1050
|
-
function
|
|
1336
|
+
function prepareRunVariable(context, input) {
|
|
1051
1337
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
1052
1338
|
const run = resolveRun(context, project.id, input.runId);
|
|
1339
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
1053
1340
|
const index = authoritativeIndex(run);
|
|
1341
|
+
return { project, run, index };
|
|
1342
|
+
}
|
|
1343
|
+
function setRunVariable(context, input, value, prepared = prepareRunVariable(context, input)) {
|
|
1344
|
+
const { project, run, index } = prepared;
|
|
1054
1345
|
if (index.variables?.[input.key] === value)
|
|
1055
1346
|
return { ok: true, run_id: run.id, key: input.key, value, idempotent: true };
|
|
1056
1347
|
index.variables = { ...(index.variables ?? {}), [input.key]: value };
|
|
@@ -1101,10 +1392,12 @@ function readRuntimeSnapshot(file) {
|
|
|
1101
1392
|
function authoritativeIndex(run) {
|
|
1102
1393
|
try {
|
|
1103
1394
|
const state = JSON.parse(run.index_json);
|
|
1395
|
+
if (!state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.stage_runs))
|
|
1396
|
+
throw new Error("RUN index must contain stage_runs");
|
|
1104
1397
|
return { ...state, schema_id: runSchemaId };
|
|
1105
1398
|
}
|
|
1106
1399
|
catch (error) {
|
|
1107
|
-
throw new AppError("
|
|
1400
|
+
throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
|
|
1108
1401
|
}
|
|
1109
1402
|
}
|
|
1110
1403
|
function sanitizeTimelineValue(value, key) {
|
|
@@ -1133,6 +1426,28 @@ function nextAttempt(existing) {
|
|
|
1133
1426
|
const number = Number(existing.attempt.replace("try-", ""));
|
|
1134
1427
|
return `try-${String(Number.isFinite(number) ? number + 1 : 1).padStart(3, "0")}`;
|
|
1135
1428
|
}
|
|
1429
|
+
function assertVnextStageStart(run, index, stage, status, existing) {
|
|
1430
|
+
if (status !== "running" || (run.flow_kind !== "vnext_specify" && run.flow_kind !== "vnext_protocolize"))
|
|
1431
|
+
return;
|
|
1432
|
+
if (!vnextStages.some((candidate) => candidate.id === stage))
|
|
1433
|
+
return;
|
|
1434
|
+
if (existing) {
|
|
1435
|
+
if (existing.status === "running")
|
|
1436
|
+
throw new AppError("stage_already_running", "The same stage attempt is already running; continue it instead of starting another attempt", 2, { run_id: run.id, stage, attempt: existing.attempt ?? null });
|
|
1437
|
+
throw new AppError("illegal_stage_transition", "A completed or paused vNext stage cannot be started as a new implicit attempt", 2, { run_id: run.id, stage, status: existing.status });
|
|
1438
|
+
}
|
|
1439
|
+
const completed = index.stage_runs.filter((candidate) => candidate.status === "done" || candidate.status === "skipped").sort((left, right) => right.order - left.order)[0];
|
|
1440
|
+
if (!completed) {
|
|
1441
|
+
if (stage === "specify")
|
|
1442
|
+
return;
|
|
1443
|
+
throw new AppError("illegal_stage_transition", "The vNext flow must start with SPECIFY", 2, { run_id: run.id, stage });
|
|
1444
|
+
}
|
|
1445
|
+
if (!isLegalVnextTransition(completed.stage, stage))
|
|
1446
|
+
throw new AppError("illegal_stage_transition", "The requested stage is not a legal successor of the last accepted stage", 2, { run_id: run.id, from: completed.stage, to: stage });
|
|
1447
|
+
const unfinished = index.stage_runs.find((candidate) => !["done", "skipped"].includes(candidate.status));
|
|
1448
|
+
if (unfinished)
|
|
1449
|
+
throw new AppError("illegal_stage_transition", "Another stage is still active", 2, { run_id: run.id, active_stage: unfinished.stage, active_status: unfinished.status, to: stage });
|
|
1450
|
+
}
|
|
1136
1451
|
function upsertStage(index, stageRun) {
|
|
1137
1452
|
const position = index.stage_runs.findIndex((item) => item.stage === stageRun.stage);
|
|
1138
1453
|
if (position === -1) {
|
|
@@ -1143,7 +1458,7 @@ function upsertStage(index, stageRun) {
|
|
|
1143
1458
|
}
|
|
1144
1459
|
index.stage_runs.sort((a, b) => a.order - b.order);
|
|
1145
1460
|
}
|
|
1146
|
-
function resolveRun(context, projectId, idOrAlias) {
|
|
1461
|
+
export function resolveRun(context, projectId, idOrAlias) {
|
|
1147
1462
|
if (isFullEntityId(idOrAlias)) {
|
|
1148
1463
|
return requireRunById(context, projectId, idOrAlias);
|
|
1149
1464
|
}
|
|
@@ -1201,7 +1516,7 @@ function flowRunSummary(run) {
|
|
|
1201
1516
|
runtime_path: run.runtime_path,
|
|
1202
1517
|
run_dir: run.run_dir,
|
|
1203
1518
|
run_index_path: run.run_index_path,
|
|
1204
|
-
|
|
1519
|
+
run_root: run.run_root,
|
|
1205
1520
|
layout_version: run.layout_version ?? null,
|
|
1206
1521
|
artifact_root_kind: run.artifact_root_kind ?? null,
|
|
1207
1522
|
created_at: run.created_at,
|
|
@@ -1237,7 +1552,7 @@ function parseStageStatus(value) {
|
|
|
1237
1552
|
}
|
|
1238
1553
|
return value;
|
|
1239
1554
|
}
|
|
1240
|
-
function
|
|
1555
|
+
export function prepareStageDirectory(value) {
|
|
1241
1556
|
const dir = requiredPlain(value, "dir");
|
|
1242
1557
|
if (!/^\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(dir)) {
|
|
1243
1558
|
throw new AppError("validation", "--dir must use NN-stage-slug format", 2, { dir });
|
|
@@ -1245,6 +1560,8 @@ function requiredStageDir(value) {
|
|
|
1245
1560
|
return dir;
|
|
1246
1561
|
}
|
|
1247
1562
|
function requiredPlain(value, label) {
|
|
1563
|
+
if (typeof value !== "string")
|
|
1564
|
+
throw new AppError("validation", `${label} must be a string`, 2);
|
|
1248
1565
|
const trimmed = value.trim();
|
|
1249
1566
|
if (!trimmed) {
|
|
1250
1567
|
throw new AppError("validation", `${label} must not be empty`, 2);
|
|
@@ -1252,6 +1569,8 @@ function requiredPlain(value, label) {
|
|
|
1252
1569
|
return trimmed;
|
|
1253
1570
|
}
|
|
1254
1571
|
function normalizeSlug(value) {
|
|
1572
|
+
if (typeof value !== "string")
|
|
1573
|
+
throw new AppError("validation", "--slug must be a string", 2);
|
|
1255
1574
|
const slug = value
|
|
1256
1575
|
.normalize("NFKD")
|
|
1257
1576
|
.replace(/[\u0300-\u036f]/g, "")
|
|
@@ -1269,26 +1588,59 @@ function resolveWorkspaceRoot(workspaceRoot) {
|
|
|
1269
1588
|
if (!fs.existsSync(absolute)) {
|
|
1270
1589
|
throw new AppError("not_found", `Workspace root does not exist: ${absolute}`, 1);
|
|
1271
1590
|
}
|
|
1591
|
+
if (!fs.statSync(absolute).isDirectory())
|
|
1592
|
+
throw new AppError("validation", "Workspace root must be a directory", 2, { workspace_root: absolute });
|
|
1272
1593
|
return fs.realpathSync(absolute);
|
|
1273
1594
|
}
|
|
1274
|
-
|
|
1595
|
+
/** Move one completed stage attempt and update only known durable path columns.
|
|
1596
|
+
* Receipt bytes remain immutable; readers resolve their new location through the DB. */
|
|
1597
|
+
export function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
|
|
1275
1598
|
const currentStageDir = path.join(runRoot, stageDir);
|
|
1276
1599
|
if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
|
|
1277
1600
|
return;
|
|
1278
1601
|
}
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1602
|
+
const journal = path.join(runRoot, `.archive-${stageDir}.json`);
|
|
1603
|
+
const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
|
|
1604
|
+
const entries = pending?.entries ?? fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
|
|
1605
|
+
if (entries.length === 0 && !pending) {
|
|
1281
1606
|
return;
|
|
1282
1607
|
}
|
|
1283
1608
|
let attempt = 1;
|
|
1284
1609
|
while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
|
|
1285
1610
|
attempt += 1;
|
|
1286
1611
|
}
|
|
1287
|
-
const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
|
|
1612
|
+
const archiveDir = pending?.archiveDir ?? path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
|
|
1613
|
+
if (!pending) {
|
|
1614
|
+
const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
1615
|
+
const prior = stored ? JSON.parse(stored.index_json) : null;
|
|
1616
|
+
writeJsonFile(journal, { archiveDir, entries, stage_run: prior?.stage_runs.find((stage) => stage.dir === stageDir) ?? null });
|
|
1617
|
+
}
|
|
1288
1618
|
ensureDir(archiveDir);
|
|
1289
1619
|
for (const entry of entries) {
|
|
1290
|
-
|
|
1620
|
+
const source = path.join(currentStageDir, entry);
|
|
1621
|
+
const target = path.join(archiveDir, entry);
|
|
1622
|
+
if (fs.existsSync(source)) {
|
|
1623
|
+
if (fs.existsSync(target))
|
|
1624
|
+
throw new AppError("archive_conflict", "Both live and archived artifact exist; preserve both for recovery", 2, { source, target });
|
|
1625
|
+
fs.renameSync(source, target);
|
|
1626
|
+
}
|
|
1627
|
+
else if (!fs.existsSync(target))
|
|
1628
|
+
throw new AppError("archive_artifact_missing", "Artifact is absent from both archive locations", 2, { source, target });
|
|
1291
1629
|
}
|
|
1630
|
+
const oldPrefix = `${currentStageDir}${path.sep}`;
|
|
1631
|
+
const newPrefix = `${archiveDir}${path.sep}`;
|
|
1632
|
+
const rewrite = (table, column, where, values) => {
|
|
1633
|
+
context.db.run(`UPDATE ${table} SET ${column} = ? || substr(${column}, ?) WHERE ${column} LIKE ? AND substr(${column}, ?, 4) != 'try-' AND ${where}`, [newPrefix, oldPrefix.length + 1, `${oldPrefix}%`, oldPrefix.length + 1, ...values]);
|
|
1634
|
+
};
|
|
1635
|
+
const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
|
|
1636
|
+
rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
|
|
1637
|
+
rewrite("work_sessions", "result_path", workScope, [projectId, runId]);
|
|
1638
|
+
const receiptScope = "project_id = ? AND run_id = ?";
|
|
1639
|
+
for (const column of ["stdout_path", "stderr_path", "receipt_path"])
|
|
1640
|
+
rewrite("check_receipts", column, receiptScope, [projectId, runId]);
|
|
1641
|
+
// Retain the original stage facts with its immutable artifacts; current
|
|
1642
|
+
// stage_runs stays a projection of the current attempt, not a second ledger.
|
|
1643
|
+
fs.renameSync(journal, path.join(archiveDir, "archive.json"));
|
|
1292
1644
|
}
|
|
1293
1645
|
function writeJsonFile(file, value) {
|
|
1294
1646
|
const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
@@ -1296,7 +1648,9 @@ function writeJsonFile(file, value) {
|
|
|
1296
1648
|
fs.renameSync(tmpFile, file);
|
|
1297
1649
|
}
|
|
1298
1650
|
function runArtifactRoot(run) {
|
|
1299
|
-
|
|
1651
|
+
if (!run.run_root)
|
|
1652
|
+
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: run.id });
|
|
1653
|
+
return run.run_root;
|
|
1300
1654
|
}
|
|
1301
1655
|
export function gitFacts(workspaceRoot) {
|
|
1302
1656
|
const read = (args) => {
|
|
@@ -1311,3 +1665,5 @@ export function gitFacts(workspaceRoot) {
|
|
|
1311
1665
|
status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
|
|
1312
1666
|
};
|
|
1313
1667
|
}
|
|
1668
|
+
import { assertRunMutationAllowed, assertStageSettlementAllowed, recoveryGuard } from "./run-recovery.js";
|
|
1669
|
+
import { runControlDirective } from "./run-control.js";
|