@deksden-com/dd-flow-cli 0.9.0-beta.13 → 0.9.0-beta.134
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 +687 -0
- package/README.md +101 -1
- package/dist/build-info.json +10 -10
- package/dist/cli/argument-values.js +38 -0
- package/dist/cli/command-inputs.js +306 -0
- package/dist/cli/help.js +88 -12
- package/dist/cli/hook-ingress.js +78 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/native-hook-ingress.js +80 -0
- package/dist/cli/run-cli.js +1465 -450
- package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
- package/dist/cli/runtime-maintenance-ingress.js +52 -0
- package/dist/cli/runtime-process.js +153 -0
- package/dist/cli.js +17 -2
- package/dist/domain/stage-catalog.js +1 -1
- package/dist/harness-runtime/DD-ZCODE.md +99 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +56 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +27 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +34 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +40 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +22 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +87 -0
- package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
- package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
- package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
- package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
- package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
- package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +370 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +693 -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 +190 -0
- package/dist/harness-runtime/lib/dd-codex.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +805 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +128 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +561 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +490 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +177 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +255 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +128 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +703 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +1128 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +139 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
- package/dist/harness-runtime/lib/lease-renewal.mjs +36 -0
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
- package/dist/harness-runtime/lib/lifecycle-command.mjs +272 -0
- package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
- package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
- package/dist/harness-runtime/lib/managed-daemon.d.mts +12 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +619 -0
- package/dist/harness-runtime/lib/model-observations.mjs +101 -0
- package/dist/harness-runtime/lib/native-children.d.mts +14 -0
- package/dist/harness-runtime/lib/native-children.mjs +94 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +7 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +251 -0
- package/dist/harness-runtime/lib/native-streams.mjs +45 -0
- package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +92 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +116 -0
- package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
- package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
- package/dist/harness-runtime/lib/process-json.d.mts +5 -0
- package/dist/harness-runtime/lib/process-json.mjs +201 -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/setup-progress.d.mts +9 -0
- package/dist/harness-runtime/lib/setup-progress.mjs +51 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +21 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +228 -0
- package/dist/harness-runtime/lib/zcode-usage.mjs +55 -0
- package/dist/protocol/local-files.js +18 -9
- package/dist/runtime/context.js +8 -4
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-verification.schema.json +7 -5
- package/dist/schemas/code-work-batch.schema.json +2 -2
- package/dist/schemas/flow-run.schema.json +2 -0
- package/dist/schemas/plan-review-decision.schema.json +3 -3
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +118 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/branch-context.js +3 -3
- package/dist/services/canon.js +11 -8
- package/dist/services/check-runner-evidence.js +121 -0
- package/dist/services/cleanup.js +133 -40
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +851 -109
- package/dist/services/codex-capacity.js +367 -0
- package/dist/services/codex-hook-delivery.js +32 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/compatibility-preflight.js +13 -3
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +136 -0
- package/dist/services/dashboard.js +135 -56
- package/dist/services/engines.js +245 -119
- package/dist/services/eval-snapshots.js +1146 -105
- package/dist/services/execution-policy.js +181 -0
- package/dist/services/external-work-launch.js +230 -0
- package/dist/services/harness-adapter.js +329 -36
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +753 -325
- package/dist/services/lanes.js +78 -65
- package/dist/services/lifecycle-command.js +1 -288
- package/dist/services/lifecycle-contract.js +1 -0
- package/dist/services/lifecycle-invocations.js +1828 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-lease-monitor.js +133 -0
- package/dist/services/managed-processes.js +409 -51
- package/dist/services/merge-queue.js +228 -149
- package/dist/services/merge-server.js +163 -35
- package/dist/services/migrations.js +37 -16
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +26 -11
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +20 -15
- package/dist/services/prompts.js +28 -17
- package/dist/services/protocols.js +163 -87
- package/dist/services/recovery-observation-budget.js +121 -0
- package/dist/services/recovery-snapshot-database.js +145 -0
- package/dist/services/repair-intents.js +42 -0
- package/dist/services/review-copy.js +157 -0
- package/dist/services/review-deferral.js +23 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +411 -0
- package/dist/services/run-control.js +985 -0
- package/dist/services/run-controller-adapter.js +399 -0
- package/dist/services/run-controller-capture.js +179 -0
- package/dist/services/run-controller-process.js +187 -0
- package/dist/services/run-controller-recovery.js +289 -0
- package/dist/services/run-controller-state.js +97 -0
- package/dist/services/run-controller.js +1035 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +147 -0
- package/dist/services/run-observations.js +152 -0
- package/dist/services/run-projection.js +22 -64
- package/dist/services/run-recovery-guard.js +75 -0
- package/dist/services/run-recovery-runtime.js +112 -0
- package/dist/services/run-recovery.js +349 -0
- package/dist/services/runs.js +558 -217
- package/dist/services/runtime-budget.js +430 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +52 -0
- package/dist/services/runtime-scope-control.js +468 -0
- package/dist/services/runtime-scope-resume.js +599 -0
- package/dist/services/runtime-scope-stop.js +180 -0
- package/dist/services/runtime-scope-worker.js +287 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/scope-writer-cleanup.js +27 -0
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +185 -19
- package/dist/services/stage-lifecycle.js +98 -107
- package/dist/services/stage-pause.js +89 -75
- package/dist/services/stage-report-renderer.js +158 -6
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/tooling.js +32 -23
- package/dist/services/usage.js +49 -101
- package/dist/services/verification-matrix.js +374 -0
- package/dist/services/vnext-code-review.js +222 -87
- package/dist/services/vnext-code.js +324 -251
- package/dist/services/vnext-contracts.js +10 -1
- package/dist/services/vnext-execution-profile.js +79 -4
- package/dist/services/vnext-fanout.js +182 -17
- package/dist/services/vnext-merge.js +335 -113
- package/dist/services/vnext-plan-review.js +276 -126
- package/dist/services/vnext-plan.js +227 -102
- package/dist/services/vnext-protocolize.js +335 -82
- package/dist/services/vnext-specify.js +119 -81
- package/dist/services/work-registry.js +1083 -194
- package/dist/services/workspace-bootstrap.js +87 -0
- package/dist/services/workspace-files.js +85 -0
- package/dist/services/worktrees.js +98 -44
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +17 -0
- package/dist/storage/agent-output.js +26 -0
- package/dist/storage/database.js +785 -37
- package/dist/storage/foreign-key-transaction.js +39 -0
- package/dist/storage/immutable-input.js +45 -0
- package/dist/storage/import-guard.js +102 -0
- package/dist/storage/native-cleanup.js +52 -0
- package/dist/storage/paths.js +17 -4
- package/dist/storage/projection-lock.js +73 -0
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +85 -0
- package/package.json +30 -13
- package/tools/repair-paused-run-status.mjs +84 -0
package/dist/services/runs.js
CHANGED
|
@@ -2,10 +2,12 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { withProjectionLock } from "../storage/projection-lock.js";
|
|
6
|
+
import { reconcileRunStageReports } from "./stage-report-renderer.js";
|
|
5
7
|
import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
|
|
6
8
|
import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
|
|
7
9
|
import { AppError } from "../shared/errors.js";
|
|
8
|
-
import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
10
|
+
import { ensureDir, canonicalPath, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
9
11
|
import { appendAudit } from "./audit.js";
|
|
10
12
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
11
13
|
import { buildRunFlowGuidance } from "./flow-guidance.js";
|
|
@@ -14,9 +16,12 @@ import { recalculateRunUsage } from "./usage.js";
|
|
|
14
16
|
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
15
17
|
import { resolveCanonRoot } from "./canon.js";
|
|
16
18
|
import { bindCurrentEngineToRun } from "./engines.js";
|
|
17
|
-
import { executionProfilePath,
|
|
19
|
+
import { assertOperationalDecisionSource, executionProfilePath, loadVnextExecutionProfileSnapshot } from "./vnext-execution-profile.js";
|
|
20
|
+
import { loadVnextWorkspacePolicy } from "./vnext-workspace-policy.js";
|
|
21
|
+
import { assertSameSessionMerge, loadExecutionRoutingInput, resolveStageTransition, snapshotAgentProfiles } from "./execution-policy.js";
|
|
18
22
|
import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
|
|
19
23
|
import { publicSessionIdentity } from "./session-identity.js";
|
|
24
|
+
import { flowCommand } from "./stage-pause.js";
|
|
20
25
|
const runSchemaId = "dd-flow/flow-run@3";
|
|
21
26
|
const runtimeSchemaId = "dd-flow/flow-run@3";
|
|
22
27
|
const runIdType = "RUN";
|
|
@@ -35,22 +40,26 @@ const allowedRunFlowKinds = [
|
|
|
35
40
|
"review",
|
|
36
41
|
"custom"
|
|
37
42
|
];
|
|
38
|
-
export function
|
|
43
|
+
export function prepareFlowRunStart(context, input) {
|
|
44
|
+
const subjectId = requiredPlain(input.subjectId, "subject-id");
|
|
45
|
+
const subjectType = requiredPlain(input.subjectType, "subject-type");
|
|
46
|
+
const slug = normalizeSlug(input.slug);
|
|
47
|
+
for (const [name, value] of [["task profile", input.taskProfile], ["protocol overrides", input.protocolOverrides], ["RUN overrides", input.runOverrides]]) {
|
|
48
|
+
if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value)))
|
|
49
|
+
throw new AppError("validation", `${name} must be a JSON object`, 2);
|
|
50
|
+
}
|
|
39
51
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
52
|
+
const flowKind = parseRunFlowKind(input.flowKind);
|
|
53
|
+
const vnext = flowKind === "vnext_protocolize" || flowKind === "vnext_specify";
|
|
54
|
+
if (input.executionRoutingFile !== undefined && !vnext)
|
|
55
|
+
throw new AppError("usage", "Execution routing input is supported only for vNext RUNs", 2);
|
|
56
|
+
const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
|
|
57
|
+
const executionProfile = vnext ? snapshotVnextExecutionProfile(projectRoot, context.ddFlowHome, routingInput, context.now()) : undefined;
|
|
40
58
|
const flowContract = input.flowKind === "mb-upgrade"
|
|
41
59
|
? loadUpgradeFlowContract(context, projectRoot)
|
|
42
60
|
: loadProjectFlowContract(projectRoot);
|
|
43
|
-
registerProject(context, { root: projectRoot });
|
|
44
|
-
const project = requireProjectByRoot(context, projectRoot);
|
|
45
61
|
const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
|
|
46
|
-
const flowKind = parseRunFlowKind(input.flowKind);
|
|
47
|
-
const slug = normalizeSlug(input.slug);
|
|
48
|
-
const runId = nextRunId(context, project.id, slug);
|
|
49
|
-
const { shortId } = parseFullEntityId(runId);
|
|
50
62
|
const now = context.now();
|
|
51
|
-
const executionProfile = (flowKind === "vnext_protocolize" || flowKind === "vnext_specify")
|
|
52
|
-
? snapshotVnextExecutionProfile(projectRoot)
|
|
53
|
-
: undefined;
|
|
54
63
|
const flowFlags = resolveFlowFlags(flowContract, {
|
|
55
64
|
flowKind,
|
|
56
65
|
...(input.preset ? { preset: input.preset } : {}),
|
|
@@ -60,17 +69,42 @@ export function startFlowRun(context, input) {
|
|
|
60
69
|
snapshotRevision: 1,
|
|
61
70
|
now
|
|
62
71
|
});
|
|
72
|
+
return { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags };
|
|
73
|
+
}
|
|
74
|
+
export function startFlowRun(context, input, prepared = prepareFlowRunStart(context, input)) {
|
|
75
|
+
const { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags } = prepared;
|
|
76
|
+
// Ordinary settings are already frozen by preparation; only scoped authority
|
|
77
|
+
// requires its live acceptance source to survive final binding unchanged.
|
|
78
|
+
if (executionProfile?.operational_decision) {
|
|
79
|
+
if (crypto.createHash("sha256").update(fs.readFileSync(executionProfile.source_path)).digest("hex") !== executionProfile.source_checksum)
|
|
80
|
+
throw new AppError("execution_profile_changed", "Execution profile changed after RUN preparation", 2);
|
|
81
|
+
if (executionProfile.operational_decision)
|
|
82
|
+
assertOperationalDecisionSource(executionProfile.operational_decision, projectRoot, context.now());
|
|
83
|
+
}
|
|
84
|
+
registerProject(context, { root: projectRoot });
|
|
85
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
86
|
+
const runId = nextRunId(context, project.id, slug);
|
|
87
|
+
const { shortId } = parseFullEntityId(runId);
|
|
63
88
|
const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
|
|
64
89
|
const runDirRelative = path.posix.join("runs", runId);
|
|
65
90
|
const runtimePath = projectRunJsonPath(context.ddFlowHome, project.id, runId);
|
|
91
|
+
const policyBytes = executionProfile?.operational_decision ? fs.readFileSync(path.resolve(projectRoot, executionProfile.operational_decision.policy.path)) : undefined;
|
|
92
|
+
const policySource = policyBytes ? { path: "operational-policy.source.md", sha256: crypto.createHash("sha256").update(policyBytes).digest("hex") } : undefined;
|
|
93
|
+
if (policySource && policySource.sha256 !== executionProfile?.operational_decision?.policy.sha256)
|
|
94
|
+
throw new AppError("operational_decision_policy_changed", "Policy changed during final RUN binding", 2);
|
|
95
|
+
const binding = { project_id: project.id, run_id: runId, project_root: projectRoot, ...(executionProfile?.operational_decision?.scope.eval_id ? { eval_id: executionProfile.operational_decision.scope.eval_id } : {}), ...(executionProfile?.operational_decision?.scope.execution_id ? { execution_id: executionProfile.operational_decision.scope.execution_id } : {}) };
|
|
96
|
+
const operationalBytes = executionProfile ? `${JSON.stringify({ schema_id: "dd-flow/run-operational-contract@1", binding, settings: { merge_delivery: executionProfile.settings.merge_delivery, merge_cleanup: executionProfile.settings.merge_cleanup }, ...(executionProfile.operational_decision ? { decision: executionProfile.operational_decision, policy_source: policySource } : {}) }, null, 2)}\n` : undefined;
|
|
97
|
+
if (executionProfile && operationalBytes)
|
|
98
|
+
executionProfile.operational_contract = { path: "operational-contract.json", sha256: crypto.createHash("sha256").update(operationalBytes).digest("hex"), ...(policySource ? { policy_source: policySource } : {}), binding };
|
|
66
99
|
const index = {
|
|
67
100
|
schema_id: runSchemaId,
|
|
68
101
|
run_id: runId,
|
|
69
102
|
short_id: shortId,
|
|
70
103
|
flow_kind: flowKind,
|
|
104
|
+
...(["vnext_specify", "vnext_protocolize"].includes(flowKind) ? { verification_matrix_contract: "dd-flow/verification-matrix@1", final_check_coverage_contract: "dd-flow/final-check-coverage@1" } : {}),
|
|
71
105
|
subject: {
|
|
72
|
-
id:
|
|
73
|
-
title:
|
|
106
|
+
id: subjectId,
|
|
107
|
+
title: subjectId
|
|
74
108
|
},
|
|
75
109
|
project: {
|
|
76
110
|
id: project.id,
|
|
@@ -126,9 +160,14 @@ export function startFlowRun(context, input) {
|
|
|
126
160
|
};
|
|
127
161
|
ensureDir(path.dirname(runtimePath));
|
|
128
162
|
ensureDir(runHome);
|
|
163
|
+
if (policyBytes && policySource)
|
|
164
|
+
fs.writeFileSync(path.join(runHome, policySource.path), policyBytes, { flag: "wx" });
|
|
165
|
+
if (operationalBytes)
|
|
166
|
+
fs.writeFileSync(path.join(runHome, "operational-contract.json"), operationalBytes, { flag: "wx" });
|
|
167
|
+
if (routingInput)
|
|
168
|
+
fs.writeFileSync(path.join(runHome, "execution-routing.input.json"), routingInput.bytes, { flag: "wx", mode: 0o600 });
|
|
129
169
|
bindCurrentEngineToRun(context, { projectRoot, runId, runRoot: runHome });
|
|
130
170
|
const persistedIndex = persistedVnextIndex(index);
|
|
131
|
-
writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
|
|
132
171
|
context.db.run(`INSERT INTO runs
|
|
133
172
|
(id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
|
|
134
173
|
status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, run_root, layout_version, artifact_root_kind,
|
|
@@ -141,7 +180,7 @@ export function startFlowRun(context, input) {
|
|
|
141
180
|
project.root,
|
|
142
181
|
workspaceRoot,
|
|
143
182
|
flowKind,
|
|
144
|
-
|
|
183
|
+
subjectType,
|
|
145
184
|
index.subject.id,
|
|
146
185
|
index.status,
|
|
147
186
|
index.verdict,
|
|
@@ -162,8 +201,9 @@ export function startFlowRun(context, input) {
|
|
|
162
201
|
eventType: "flow_run.started",
|
|
163
202
|
payload: { run_id: runId, flow_kind: flowKind, subject: index.subject, workspace_root: workspaceRoot, run_home: runHome }
|
|
164
203
|
});
|
|
165
|
-
|
|
166
|
-
appendRunTimeline(
|
|
204
|
+
context.db.afterCommit(() => publishRunProjections(context, project.id, runId));
|
|
205
|
+
appendRunTimeline(context, project.id, runId, { at: now, type: "run_started", run_id: runId });
|
|
206
|
+
appendRunTimeline(context, project.id, runId, {
|
|
167
207
|
at: now,
|
|
168
208
|
type: "flow_flags_resolved",
|
|
169
209
|
run_id: runId,
|
|
@@ -173,13 +213,16 @@ export function startFlowRun(context, input) {
|
|
|
173
213
|
});
|
|
174
214
|
return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
|
|
175
215
|
}
|
|
176
|
-
function snapshotVnextExecutionProfile(projectRoot) {
|
|
177
|
-
const profile =
|
|
216
|
+
function snapshotVnextExecutionProfile(projectRoot, ddFlowHome, routingInput, now = new Date().toISOString()) {
|
|
217
|
+
const { profile, bytes } = loadVnextExecutionProfileSnapshot(projectRoot);
|
|
218
|
+
if (profile.operational_decision)
|
|
219
|
+
assertOperationalDecisionSource(profile.operational_decision, projectRoot, now);
|
|
178
220
|
const sourcePath = executionProfilePath(projectRoot);
|
|
179
|
-
|
|
221
|
+
const frozen = freezeRunExecutionRouting(ddFlowHome, {
|
|
180
222
|
schema_id: "dd-flow/run-execution-profile@1",
|
|
181
223
|
source_path: sourcePath,
|
|
182
|
-
source_checksum: crypto.createHash("sha256").update(
|
|
224
|
+
source_checksum: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
225
|
+
...(profile.operational_decision ? { operational_decision: profile.operational_decision } : {}),
|
|
183
226
|
settings: {
|
|
184
227
|
stage_session_mode: profile.stage_session_mode,
|
|
185
228
|
plan_review_mode: profile.plan_review_mode,
|
|
@@ -188,9 +231,47 @@ function snapshotVnextExecutionProfile(projectRoot) {
|
|
|
188
231
|
merge_delivery: profile.merge_delivery,
|
|
189
232
|
merge_cleanup: profile.merge_cleanup,
|
|
190
233
|
stop_target: profile.stop_target,
|
|
191
|
-
code_bootstrap: profile.code_bootstrap
|
|
234
|
+
code_bootstrap: profile.code_bootstrap,
|
|
235
|
+
...(profile.execution ? { execution: profile.execution } : {})
|
|
192
236
|
}
|
|
193
|
-
};
|
|
237
|
+
}, routingInput);
|
|
238
|
+
if (frozen.settings.merge_mode === "same_session" && frozen.settings.stop_target === "merge_completed"
|
|
239
|
+
&& fs.existsSync(path.join(projectRoot, ".memory-bank", "dd-flow", "project-workspace.json"))
|
|
240
|
+
&& loadVnextWorkspacePolicy(projectRoot).workspace.route === "feature_worktree") {
|
|
241
|
+
throw new AppError("merge_session_workspace_conflict", "Feature-worktree MERGE targets the integration checkout; use server MERGE or a qualified new Session", 2, { project_root: projectRoot });
|
|
242
|
+
}
|
|
243
|
+
return frozen;
|
|
244
|
+
}
|
|
245
|
+
/** Fresh RUN and fresh stage-entry experiments freeze through the same resolver. */
|
|
246
|
+
export function freezeRunExecutionRouting(ddFlowHome, base, input) {
|
|
247
|
+
const execution = input?.execution ?? base.settings.execution;
|
|
248
|
+
const profiles = execution ? snapshotAgentProfiles(ddFlowHome, execution) : undefined;
|
|
249
|
+
if (execution && profiles && base.settings.merge_mode === "same_session" && base.settings.stop_target === "merge_completed") {
|
|
250
|
+
const mode = base.settings.code_review_mode;
|
|
251
|
+
assertSameSessionMerge({ ddFlowHome, policy: execution, profiles, previousStages: mode === "off" ? ["code"] : mode === "auto" ? ["code", "code-review"] : ["code-review"] });
|
|
252
|
+
}
|
|
253
|
+
return { ...base, ...(profiles ? { agent_profiles: profiles } : {}),
|
|
254
|
+
...(input ? { routing_input: { source_path: input.source_path, source_checksum: input.source_checksum, artifact: "execution-routing.input.json" } } : {}),
|
|
255
|
+
settings: { ...base.settings, ...(execution ? { execution } : {}) } };
|
|
256
|
+
}
|
|
257
|
+
/** Called only after a stage-entry import has archived and detached source owners. */
|
|
258
|
+
export function importFlowRunExecutionProfile(context, input) {
|
|
259
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
260
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
261
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
262
|
+
const index = authoritativeIndex(run);
|
|
263
|
+
const lineage = JSON.parse(fs.readFileSync(input.lineageFile, "utf8"));
|
|
264
|
+
if (lineage.schema_id !== "dd-flow/snapshot-runtime-lineage@1" || lineage.run_id !== run.id || path.resolve(lineage.restored_runtime ?? "") !== path.resolve(context.ddFlowHome)
|
|
265
|
+
|| !vnextStages.some(stage => stage.id === input.stage)
|
|
266
|
+
|| (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]))
|
|
267
|
+
|| index.stage_runs.some(stage => stage.stage === input.stage || !["done", "skipped"].includes(stage.status))
|
|
268
|
+
|| context.db.get("SELECT 1 FROM sessions WHERE project_id = ? AND run_id = ? AND status IN ('active','idle') LIMIT 1", [project.id, run.id])) {
|
|
269
|
+
throw new AppError("snapshot_execution_active", "Execution policy can be selected only at a detached stage-entry import", 1);
|
|
270
|
+
}
|
|
271
|
+
index.execution_profile = { ...input.profile, stage_entry_import: { stage: input.stage, lineage_file: input.lineageFile } };
|
|
272
|
+
index.updated_at = context.now();
|
|
273
|
+
persistRunState(context, project, run, index);
|
|
274
|
+
appendRunTimeline(context, project.id, run.id, { at: index.updated_at, type: "execution_policy_imported", stage: input.stage, lineage_file: input.lineageFile, source_checksum: input.profile.routing_input?.source_checksum });
|
|
194
275
|
}
|
|
195
276
|
/**
|
|
196
277
|
* A vNext route may materialize a feature checkout after its semantic
|
|
@@ -200,6 +281,7 @@ function snapshotVnextExecutionProfile(projectRoot) {
|
|
|
200
281
|
export function rebindFlowRunWorkspace(context, input) {
|
|
201
282
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
202
283
|
const run = resolveRun(context, project.id, input.runId);
|
|
284
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
203
285
|
const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot);
|
|
204
286
|
const index = authoritativeIndex(run);
|
|
205
287
|
const now = context.now();
|
|
@@ -210,7 +292,7 @@ export function rebindFlowRunWorkspace(context, input) {
|
|
|
210
292
|
context.db.run("UPDATE runs SET workspace_root = ? WHERE project_id = ? AND id = ?", [workspaceRoot, project.id, run.id]);
|
|
211
293
|
persistRunState(context, project, { ...run, workspace_root: workspaceRoot }, index);
|
|
212
294
|
appendAudit(context, { projectId: project.id, eventType: "flow_run.workspace_rebound", payload: { run_id: run.id, workspace_root: workspaceRoot, reason: input.reason } });
|
|
213
|
-
appendRunTimeline(
|
|
295
|
+
appendRunTimeline(context, project.id, run.id, { at: now, type: "workspace_rebound", run_id: run.id, workspace_root: workspaceRoot, reason: input.reason, git: facts });
|
|
214
296
|
return { ok: true, run_id: run.id, project_root: project.root, workspace_root: workspaceRoot, git: facts };
|
|
215
297
|
}
|
|
216
298
|
function loadUpgradeFlowContract(context, projectRoot) {
|
|
@@ -229,7 +311,41 @@ export function getFlowRunStatus(context, input) {
|
|
|
229
311
|
const run = resolveRun(context, project.id, input.runId);
|
|
230
312
|
const index = authoritativeIndex(run);
|
|
231
313
|
const runtime = readRuntimeSnapshot(run.runtime_path);
|
|
232
|
-
return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
|
|
314
|
+
return { ok: true, run: flowRunSummary(run), index, continuation: runContinuation(context, run, index), ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
|
|
315
|
+
}
|
|
316
|
+
/** A read-only projection of the next legal lifecycle action.
|
|
317
|
+
* It is derived from the persisted RUN/Work facts so controllers never infer a
|
|
318
|
+
* transition from a model's prose or a hard-coded stage array. */
|
|
319
|
+
function runContinuation(context, run, index) {
|
|
320
|
+
if (["done", "failed", "cancelled"].includes(run.status))
|
|
321
|
+
return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: run.status, command: null };
|
|
322
|
+
const control = runControlDirective(context, run.project_id, run.id);
|
|
323
|
+
if (control)
|
|
324
|
+
return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "operator_control", command: null, control };
|
|
325
|
+
const active = index.stage_runs.find((stage) => !["done", "skipped"].includes(stage.status));
|
|
326
|
+
if (active) {
|
|
327
|
+
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]);
|
|
328
|
+
const kind = active.status === "running" ? "continue_stage" : active.status === "paused" || active.status === "waiting_for_user" ? "paused" : "blocked";
|
|
329
|
+
return { kind, stage: active.stage, attempt: active.attempt ?? null, work_id: active.pause?.work_id ?? runningWork?.work_id ?? null, reason: active.status, command: null };
|
|
330
|
+
}
|
|
331
|
+
if (run.status === "waiting_for_user" || run.status === "paused")
|
|
332
|
+
return { kind: "paused", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
|
|
333
|
+
if (run.status === "blocked")
|
|
334
|
+
return { kind: "blocked", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
|
|
335
|
+
if (run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize") {
|
|
336
|
+
const completed = index.stage_runs.filter((stage) => stage.status === "done" || stage.status === "skipped").sort((left, right) => right.order - left.order)[0];
|
|
337
|
+
const next = completed ? vnextStages.find((stage) => stage.id === completed.stage)?.next.find((candidate) => candidate !== "plan-review" || index.settings?.plan_review?.mode !== "off") : "specify";
|
|
338
|
+
if (!next)
|
|
339
|
+
return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
|
|
340
|
+
const command = `${flowCommand(context)} stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
|
|
341
|
+
const profile = index.execution_profile;
|
|
342
|
+
const execution = profile?.settings.execution ? profile.agent_profiles
|
|
343
|
+
? { status: "resolved", ...resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, projectRoot: run.project_root, workspaceRoot: run.workspace_root, ...(completed ? { fromStage: completed.stage } : {}), stage: next, stageSessionMode: profile.settings.stage_session_mode, mergeMode: profile.settings.merge_mode }) }
|
|
344
|
+
: { status: "unfrozen", reason: "execution_profile_not_frozen" }
|
|
345
|
+
: undefined;
|
|
346
|
+
return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command, ...(execution ? { execution } : {}) };
|
|
347
|
+
}
|
|
348
|
+
return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "no_vnext_continuation", command: null };
|
|
233
349
|
}
|
|
234
350
|
export function getFlowRunConfig(context, input) {
|
|
235
351
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -250,9 +366,12 @@ export function getFlowRunVariables(context, input) {
|
|
|
250
366
|
return { ok: true, run_id: run.id, variables };
|
|
251
367
|
}
|
|
252
368
|
export function setFlowRunVariable(context, input) {
|
|
369
|
+
return setRunVariable(context, input, input.value, prepareFlowRunVariable(context, input));
|
|
370
|
+
}
|
|
371
|
+
export function prepareFlowRunVariable(context, input) {
|
|
253
372
|
if (!input.key.startsWith("user."))
|
|
254
373
|
throw new AppError("validation", "Only user.* RUN variables may be set directly", 2, { key: input.key });
|
|
255
|
-
return
|
|
374
|
+
return prepareRunVariable(context, input);
|
|
256
375
|
}
|
|
257
376
|
/** Controllers own policy.* and runtime.* values; agents never call this directly. */
|
|
258
377
|
export function setRuntimeRunVariable(context, input) {
|
|
@@ -261,20 +380,7 @@ export function setRuntimeRunVariable(context, input) {
|
|
|
261
380
|
return setRunVariable(context, input, input.value);
|
|
262
381
|
}
|
|
263
382
|
export function setFlowRunConfig(context, input) {
|
|
264
|
-
|
|
265
|
-
throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
|
|
266
|
-
}
|
|
267
|
-
if (!["auto", "off", "standard", "deep"].includes(input.value)) {
|
|
268
|
-
throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
|
|
269
|
-
}
|
|
270
|
-
const reason = requiredPlain(input.reason, "reason");
|
|
271
|
-
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
272
|
-
const run = resolveRun(context, project.id, input.runId);
|
|
273
|
-
const index = authoritativeIndex(run);
|
|
274
|
-
const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
|
|
275
|
-
if (index.stage_runs.some((stage) => stage.stage === stageName)) {
|
|
276
|
-
throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
|
|
277
|
-
}
|
|
383
|
+
const { reason, project, run, index } = prepareFlowRunConfig(context, input);
|
|
278
384
|
const now = context.now();
|
|
279
385
|
const settings = runSettings(index);
|
|
280
386
|
const previous = input.key === "plan_review.mode" ? settings.plan_review : settings.code_review;
|
|
@@ -289,13 +395,32 @@ export function setFlowRunConfig(context, input) {
|
|
|
289
395
|
index.updated_at = now;
|
|
290
396
|
persistRunState(context, project, run, index);
|
|
291
397
|
appendAudit(context, { projectId: project.id, eventType: "flow_run.config_set", payload: { run_id: run.id, key: input.key, value: mode, source, reason } });
|
|
292
|
-
appendRunTimeline(
|
|
398
|
+
appendRunTimeline(context, project.id, run.id, { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
|
|
293
399
|
return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
|
|
294
400
|
}
|
|
401
|
+
export function prepareFlowRunConfig(context, input) {
|
|
402
|
+
if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
|
|
403
|
+
throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
|
|
404
|
+
}
|
|
405
|
+
if (!["auto", "off", "standard", "deep"].includes(input.value)) {
|
|
406
|
+
throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
|
|
407
|
+
}
|
|
408
|
+
const reason = requiredPlain(input.reason, "reason");
|
|
409
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
410
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
411
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
412
|
+
const index = authoritativeIndex(run);
|
|
413
|
+
const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
|
|
414
|
+
if (index.stage_runs.some((stage) => stage.stage === stageName)) {
|
|
415
|
+
throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
|
|
416
|
+
}
|
|
417
|
+
return { reason, project, run, index };
|
|
418
|
+
}
|
|
295
419
|
/** Freeze an automatic review request once from already accepted policy or PLAN facts. */
|
|
296
420
|
export function freezeFlowRunReviewMode(context, input) {
|
|
297
421
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
298
422
|
const run = resolveRun(context, project.id, input.runId);
|
|
423
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
299
424
|
const index = authoritativeIndex(run);
|
|
300
425
|
const settings = runSettings(index);
|
|
301
426
|
const key = input.review === "plan" ? "plan_review" : "code_review";
|
|
@@ -312,8 +437,8 @@ export function getFlowRunFlagsStatus(context, input) {
|
|
|
312
437
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
313
438
|
const run = resolveRun(context, project.id, input.runId);
|
|
314
439
|
const index = authoritativeIndex(run);
|
|
315
|
-
const
|
|
316
|
-
const flags =
|
|
440
|
+
const legacyRuntime = index.flow_flags ? null : readRuntimeSnapshot(run.runtime_path);
|
|
441
|
+
const flags = index.flow_flags ?? legacyRuntime?.flow_flags;
|
|
317
442
|
if (!flags) {
|
|
318
443
|
return {
|
|
319
444
|
ok: true,
|
|
@@ -331,29 +456,31 @@ export function getFlowRunFlagsStatus(context, input) {
|
|
|
331
456
|
snapshot_revision: flags.snapshot_revision,
|
|
332
457
|
snapshot_checksum: flags.snapshot_checksum,
|
|
333
458
|
flow_flags: flags,
|
|
334
|
-
runtime_revision:
|
|
459
|
+
runtime_revision: index.runtime_revision ?? legacyRuntime?.runtime_revision ?? null
|
|
335
460
|
};
|
|
336
461
|
}
|
|
337
462
|
export function reviseFlowRunFlags(context, input) {
|
|
338
463
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
339
464
|
const run = resolveRun(context, project.id, input.runId);
|
|
465
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
340
466
|
const idempotencyKey = requiredPlain(input.idempotencyKey, "idempotency-key");
|
|
341
|
-
context.db.
|
|
342
|
-
|
|
343
|
-
|
|
467
|
+
const priorMutation = 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]);
|
|
468
|
+
if (priorMutation)
|
|
469
|
+
return { ...JSON.parse(priorMutation.result_json), idempotent: true };
|
|
470
|
+
// Canonical contract bytes are prepared before taking the writer. Revision
|
|
471
|
+
// and idempotency are compared again against SQL authority under the lock.
|
|
472
|
+
const contract = loadProjectFlowContract(run.project_root);
|
|
473
|
+
return context.db.writeTransaction(() => {
|
|
344
474
|
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]);
|
|
345
475
|
if (existingMutation) {
|
|
346
476
|
const result = { ...JSON.parse(existingMutation.result_json), idempotent: true };
|
|
347
|
-
context.db.exec("COMMIT");
|
|
348
|
-
committed = true;
|
|
349
477
|
return result;
|
|
350
478
|
}
|
|
351
479
|
// Read the authority only after the write lock: two callers with the same
|
|
352
480
|
// expected revision must not both pass the compare-and-set check.
|
|
353
481
|
const lockedRun = requireRunById(context, project.id, run.id);
|
|
354
482
|
const index = authoritativeIndex(lockedRun);
|
|
355
|
-
const
|
|
356
|
-
const current = runtime?.flow_flags ?? index.flow_flags;
|
|
483
|
+
const current = index.flow_flags;
|
|
357
484
|
if (!current) {
|
|
358
485
|
throw new AppError("validation", "Legacy RUN has no flow-flag snapshot; revise is unavailable", 2, { run_id: run.id });
|
|
359
486
|
}
|
|
@@ -370,7 +497,6 @@ export function reviseFlowRunFlags(context, input) {
|
|
|
370
497
|
});
|
|
371
498
|
}
|
|
372
499
|
const now = context.now();
|
|
373
|
-
const contract = loadProjectFlowContract(lockedRun.project_root);
|
|
374
500
|
const next = resolveFlowFlags(contract, {
|
|
375
501
|
flowKind: index.flow_kind,
|
|
376
502
|
preset: input.preset ?? current.preset.applied,
|
|
@@ -426,14 +552,12 @@ export function reviseFlowRunFlags(context, input) {
|
|
|
426
552
|
context.db.run(`INSERT INTO flow_run_flag_mutations
|
|
427
553
|
(project_id, run_id, idempotency_key, expected_revision, resulting_revision, result_json, created_at)
|
|
428
554
|
VALUES (?, ?, ?, ?, ?, ?, ?)`, [project.id, run.id, idempotencyKey, input.expectedRevision, next.snapshot_revision, JSON.stringify(result), now]);
|
|
429
|
-
context.db.exec("COMMIT");
|
|
430
|
-
committed = true;
|
|
431
555
|
appendAudit(context, {
|
|
432
556
|
projectId: project.id,
|
|
433
557
|
eventType: "flow_run.flags_revised",
|
|
434
558
|
payload: { run_id: run.id, snapshot_revision: next.snapshot_revision, snapshot_checksum: next.snapshot_checksum, ...(reason ? { reason } : {}) }
|
|
435
559
|
});
|
|
436
|
-
appendRunTimeline(
|
|
560
|
+
appendRunTimeline(context, project.id, run.id, {
|
|
437
561
|
at: now,
|
|
438
562
|
type: "flow_flags_revised",
|
|
439
563
|
run_id: run.id,
|
|
@@ -443,12 +567,7 @@ export function reviseFlowRunFlags(context, input) {
|
|
|
443
567
|
...(reason ? { reason } : {})
|
|
444
568
|
});
|
|
445
569
|
return result;
|
|
446
|
-
}
|
|
447
|
-
catch (error) {
|
|
448
|
-
if (!committed)
|
|
449
|
-
context.db.exec("ROLLBACK");
|
|
450
|
-
throw error;
|
|
451
|
-
}
|
|
570
|
+
}, "runs.reviseFlowFlags");
|
|
452
571
|
}
|
|
453
572
|
export function listFlowRuns(context, input) {
|
|
454
573
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -459,113 +578,188 @@ export function listFlowRuns(context, input) {
|
|
|
459
578
|
.map(flowRunSummary);
|
|
460
579
|
return { ok: true, project, runs };
|
|
461
580
|
}
|
|
462
|
-
export function
|
|
581
|
+
export function prepareFlowRunStageAttachment(context, input, archive = false) {
|
|
463
582
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
464
583
|
const run = resolveRun(context, project.id, input.runId);
|
|
465
|
-
const
|
|
466
|
-
const now = context.now();
|
|
584
|
+
const generation = assertRunMutationAllowed(context, project.id, run.id);
|
|
467
585
|
const stage = requiredPlain(input.stage, "stage");
|
|
468
|
-
const dir =
|
|
586
|
+
const dir = prepareStageDirectory(input.dir);
|
|
587
|
+
const index = authoritativeIndex(run);
|
|
469
588
|
const status = parseStageStatus(input.status);
|
|
470
589
|
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
471
590
|
assertVnextStageStart(run, index, stage, status, existing);
|
|
472
|
-
if (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
stage,
|
|
478
|
-
dir,
|
|
479
|
-
status,
|
|
480
|
-
...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
|
|
481
|
-
updated_at: now
|
|
482
|
-
};
|
|
483
|
-
if (status === "running") {
|
|
484
|
-
stageRun.started_at = now;
|
|
485
|
-
delete stageRun.completed_at;
|
|
486
|
-
stageRun.duration_ms = null;
|
|
487
|
-
stageRun.git = gitFacts(run.workspace_root);
|
|
488
|
-
stageRun.attempt = nextAttempt(existing);
|
|
591
|
+
if (archive) {
|
|
592
|
+
const runRoot = runArtifactRoot(run);
|
|
593
|
+
if (fs.existsSync(path.join(runRoot, `.archive-${dir}.json`)) || status === "running" && existing) {
|
|
594
|
+
archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
|
|
595
|
+
}
|
|
489
596
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
597
|
+
return { project, run, index, stage, dir, status, existing, generation, attempt: status === "running" ? nextAttempt(existing) : existing?.attempt ?? "try-001", git: status === "running" ? gitFacts(run.workspace_root) : undefined };
|
|
598
|
+
}
|
|
599
|
+
export function attachFlowRunStage(context, input, prepared = prepareFlowRunStageAttachment(context, input, true)) {
|
|
600
|
+
// Filesystem archive and Git inspection are preparation, never writer work.
|
|
601
|
+
const { project, run, stage, dir, status, generation, git } = prepared;
|
|
602
|
+
const attached = context.db.writeTransaction(() => {
|
|
603
|
+
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
604
|
+
const latest = requireRunById(context, project.id, run.id);
|
|
605
|
+
const index = authoritativeIndex(latest);
|
|
606
|
+
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
607
|
+
if ((index.runtime_revision ?? 0) !== (prepared.index.runtime_revision ?? 0) || existing?.attempt !== prepared.existing?.attempt) {
|
|
608
|
+
throw new AppError("stage_revision_changed", "Stage changed during start preparation", 1, { run_id: run.id, stage });
|
|
609
|
+
}
|
|
610
|
+
assertVnextStageStart(latest, index, stage, status, existing);
|
|
611
|
+
const now = context.now();
|
|
612
|
+
const stageRun = {
|
|
613
|
+
...(existing ?? { order: index.stage_runs.length + 1 }),
|
|
614
|
+
stage,
|
|
615
|
+
dir,
|
|
616
|
+
status,
|
|
617
|
+
...(input.inputSha256 ? { input_sha256: input.inputSha256 } : {}),
|
|
618
|
+
...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
|
|
619
|
+
updated_at: now
|
|
620
|
+
};
|
|
621
|
+
if (status === "running") {
|
|
622
|
+
stageRun.started_at = now;
|
|
623
|
+
delete stageRun.completed_at;
|
|
624
|
+
stageRun.duration_ms = null;
|
|
625
|
+
if (git)
|
|
626
|
+
stageRun.git = git;
|
|
627
|
+
stageRun.attempt = prepared.attempt;
|
|
628
|
+
}
|
|
629
|
+
upsertStage(index, stageRun);
|
|
630
|
+
index.current_stage = stage;
|
|
631
|
+
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 }));
|
|
632
|
+
index.updated_at = now;
|
|
633
|
+
persistRunState(context, project, run, index);
|
|
634
|
+
appendAudit(context, {
|
|
635
|
+
projectId: project.id,
|
|
636
|
+
eventType: "flow_run.stage_attached",
|
|
637
|
+
payload: { run_id: run.id, stage, dir, status }
|
|
638
|
+
});
|
|
639
|
+
appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
|
|
640
|
+
context.db.afterCommit(() => reconcileRunStageReports(context, project.id, run.id, stage));
|
|
641
|
+
// A stage transition must not rewrite every Session in the RUN. A RUN may
|
|
642
|
+
// contain independent reviewer/worker sessions; their stage is set by the
|
|
643
|
+
// stage/work binding that actually owns that Session.
|
|
644
|
+
if (status === "running")
|
|
645
|
+
context.db.afterCommit(() => refreshRunSessionProjection(context, project.id, run.id));
|
|
646
|
+
return { stageRun, index };
|
|
647
|
+
}, "stage.attach");
|
|
506
648
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
507
|
-
return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
649
|
+
return { ok: true, run: flowRunSummary(updatedRun), stage_run: attached.stageRun, index: attached.index, flow_guidance: guidanceForRun(context, updatedRun, attached.index) };
|
|
650
|
+
}
|
|
651
|
+
/** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
|
|
652
|
+
export function withStageSettlement(context, operation) {
|
|
653
|
+
return context.db.writeTransaction(operation, "stage.settle");
|
|
508
654
|
}
|
|
509
|
-
export function
|
|
655
|
+
export function prepareFlowRunStageCompletion(context, input) {
|
|
656
|
+
for (const [name, value] of Object.entries({ stageReport: input.stageReport, data: input.data, dataSchemaId: input.dataSchemaId, report: input.report })) {
|
|
657
|
+
if (value !== undefined)
|
|
658
|
+
requiredPlain(value, name);
|
|
659
|
+
}
|
|
660
|
+
if (input.aliases !== undefined) {
|
|
661
|
+
if (!Array.isArray(input.aliases))
|
|
662
|
+
throw new AppError("validation", "artifact aliases must be an array", 2);
|
|
663
|
+
for (const alias of input.aliases)
|
|
664
|
+
requiredPlain(alias, "artifact alias");
|
|
665
|
+
}
|
|
510
666
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
511
667
|
const run = resolveRun(context, project.id, input.runId);
|
|
668
|
+
if (["done", "failed", "skipped"].includes(input.status))
|
|
669
|
+
assertStageSettlementAllowed(context, project.id, run.id, input.stage);
|
|
670
|
+
else
|
|
671
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
512
672
|
const index = authoritativeIndex(run);
|
|
513
|
-
const now = context.now();
|
|
514
673
|
const stage = requiredPlain(input.stage, "stage");
|
|
515
674
|
const status = parseStageStatus(input.status);
|
|
516
675
|
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
517
676
|
if (!existing) {
|
|
518
677
|
throw new AppError("not_found", `Run stage is not attached: ${stage}`, 1, { run_id: run.id, stage });
|
|
519
678
|
}
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
index
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
679
|
+
const dataPath = input.data ? path.join(runArtifactRoot(run), existing.dir, input.data) : null;
|
|
680
|
+
const dataSha256 = input.dataSha256 ?? (dataPath && fs.existsSync(dataPath) ? crypto.createHash("sha256").update(fs.readFileSync(dataPath)).digest("hex") : null);
|
|
681
|
+
if (dataSha256 && !/^[a-f0-9]{64}$/.test(dataSha256))
|
|
682
|
+
throw new AppError("validation", "Stage data SHA-256 is invalid", 2);
|
|
683
|
+
return { project, run, index, stage, status, existing, dataSha256, git: input.git ?? gitFacts(run.workspace_root), generation: ["done", "failed", "skipped"].includes(status) ? assertStageSettlementAllowed(context, project.id, run.id, stage) : assertRunMutationAllowed(context, project.id, run.id) };
|
|
684
|
+
}
|
|
685
|
+
function stageUsageProjection(context, run, stage, startedAt) {
|
|
686
|
+
if (!startedAt)
|
|
687
|
+
return { status: "unavailable", input_tokens: null, cached_input_tokens: null, output_tokens: null, total_tokens: null, session_count: 0, observed_at: context.now() };
|
|
688
|
+
const value = recalculateRunUsage(context, { projectId: run.project_id, runId: run.id, stage: { name: stage, startedAt, completedAt: context.now() } });
|
|
689
|
+
const totals = value.totals, coverage = value.coverage;
|
|
690
|
+
const count = (name) => typeof coverage?.[name] === "number" ? coverage[name] : 0;
|
|
691
|
+
const measured = count("measured"), other = Object.values(coverage ?? {}).reduce((total, item) => total + (typeof item === "number" ? item : 0), 0) - measured;
|
|
692
|
+
const tokens = (name) => typeof totals?.[name] === "number" ? totals[name] : null;
|
|
693
|
+
return { status: measured === 0 ? "unavailable" : other > 0 || ["input_tokens", "cache_read_input_tokens", "output_tokens", "total_tokens"].some(name => tokens(name) === null) ? "partial" : "measured", input_tokens: tokens("input_tokens"), cached_input_tokens: tokens("cache_read_input_tokens"), output_tokens: tokens("output_tokens"), total_tokens: tokens("total_tokens"), session_count: Array.isArray(value.sessions) ? value.sessions.length : 0, observed_at: typeof value.observed_at === "string" ? value.observed_at : context.now() };
|
|
694
|
+
}
|
|
695
|
+
export function completeFlowRunStage(context, input, prepared = prepareFlowRunStageCompletion(context, input)) {
|
|
696
|
+
const { project, run, stage, status, dataSha256, git, generation } = prepared;
|
|
697
|
+
const completed = context.db.writeTransaction(() => {
|
|
698
|
+
if (["done", "failed", "skipped"].includes(status))
|
|
699
|
+
assertStageSettlementAllowed(context, project.id, run.id, stage, generation);
|
|
700
|
+
else
|
|
701
|
+
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
702
|
+
const latest = requireRunById(context, project.id, run.id);
|
|
703
|
+
const index = authoritativeIndex(latest);
|
|
704
|
+
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
705
|
+
if (!existing || existing.attempt !== prepared.existing.attempt || index.runtime_revision !== prepared.index.runtime_revision) {
|
|
706
|
+
throw new AppError("stage_revision_changed", "Stage attempt changed during finish preparation", 1, { run_id: run.id, stage, expected_attempt: prepared.existing.attempt, actual_attempt: existing?.attempt ?? null });
|
|
707
|
+
}
|
|
708
|
+
const now = context.now();
|
|
709
|
+
const stageRun = {
|
|
710
|
+
...existing,
|
|
711
|
+
status,
|
|
712
|
+
...(input.stageReport ? { stage_report: input.stageReport } : {}),
|
|
713
|
+
...(input.data ? { data: input.data } : {}),
|
|
714
|
+
...(dataSha256 ? { data_sha256: dataSha256 } : {}),
|
|
715
|
+
...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
|
|
716
|
+
...(input.report ? { report: input.report } : {}),
|
|
717
|
+
usage: input.usage ?? stageUsageProjection(context, latest, stage, existing.started_at),
|
|
718
|
+
...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
|
|
719
|
+
updated_at: now,
|
|
720
|
+
...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
|
|
721
|
+
? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git }
|
|
722
|
+
: {})
|
|
723
|
+
};
|
|
724
|
+
upsertStage(index, stageRun);
|
|
725
|
+
if (status === "waiting_for_user") {
|
|
726
|
+
index.status = "waiting_for_user";
|
|
727
|
+
index.verdict = "waiting_for_user";
|
|
728
|
+
}
|
|
729
|
+
else if (status === "blocked" || status === "failed") {
|
|
730
|
+
index.status = status;
|
|
731
|
+
index.verdict = status;
|
|
732
|
+
}
|
|
733
|
+
else if (status === "done" && index.status === "waiting_for_user") {
|
|
734
|
+
index.status = "running";
|
|
735
|
+
index.verdict = "running";
|
|
736
|
+
}
|
|
737
|
+
index.current_stage = stage;
|
|
738
|
+
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 }));
|
|
739
|
+
index.updated_at = now;
|
|
740
|
+
index.execution = {
|
|
741
|
+
project_root: index.execution?.project_root ?? run.project_root,
|
|
742
|
+
workspace_root: index.execution?.workspace_root ?? run.workspace_root,
|
|
743
|
+
git
|
|
744
|
+
};
|
|
745
|
+
persistRunState(context, project, latest, index);
|
|
746
|
+
context.db.afterCommit(() => refreshRunSessionProjection(context, project.id, run.id));
|
|
747
|
+
appendAudit(context, {
|
|
748
|
+
projectId: project.id,
|
|
749
|
+
eventType: "flow_run.stage_completed",
|
|
750
|
+
payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
|
|
751
|
+
});
|
|
752
|
+
appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
|
|
753
|
+
return { index, stageRun };
|
|
754
|
+
}, "runs.completeFlowRunStage");
|
|
562
755
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
563
|
-
return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
756
|
+
return { ok: true, run: flowRunSummary(updatedRun), stage_run: completed.stageRun, index: completed.index, flow_guidance: guidanceForRun(context, updatedRun, completed.index) };
|
|
564
757
|
}
|
|
565
758
|
/** Pause one live stage without completing it or creating a new attempt. */
|
|
566
759
|
export function pauseFlowRunStage(context, input) {
|
|
567
760
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
568
761
|
const run = resolveRun(context, project.id, input.runId);
|
|
762
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
569
763
|
const index = authoritativeIndex(run);
|
|
570
764
|
const stage = requiredPlain(input.stage, "stage");
|
|
571
765
|
const stageRun = index.stage_runs.find((item) => item.stage === stage);
|
|
@@ -594,13 +788,14 @@ export function pauseFlowRunStage(context, input) {
|
|
|
594
788
|
persistRunState(context, project, run, index);
|
|
595
789
|
refreshRunSessionProjection(context, project.id, run.id);
|
|
596
790
|
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" } });
|
|
597
|
-
appendRunTimeline(
|
|
791
|
+
appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_waiting_for_user", run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, question_path: input.questionPath });
|
|
598
792
|
return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
|
|
599
793
|
}
|
|
600
794
|
/** Resume the same stage, Work and attempt after a user answer is recorded. */
|
|
601
795
|
export function resumeFlowRunStage(context, input) {
|
|
602
796
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
603
797
|
const run = resolveRun(context, project.id, input.runId);
|
|
798
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
604
799
|
const index = authoritativeIndex(run);
|
|
605
800
|
const stage = requiredPlain(input.stage, "stage");
|
|
606
801
|
const stageRun = index.stage_runs.find((item) => item.stage === stage);
|
|
@@ -624,19 +819,28 @@ export function resumeFlowRunStage(context, input) {
|
|
|
624
819
|
persistRunState(context, project, run, index);
|
|
625
820
|
refreshRunSessionProjection(context, project.id, run.id);
|
|
626
821
|
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 } });
|
|
627
|
-
appendRunTimeline(
|
|
628
|
-
appendRunTimeline(
|
|
822
|
+
appendRunTimeline(context, project.id, run.id, { at: now, type: "user_answer_received", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath });
|
|
823
|
+
appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_resumed", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, paused_ms: pausedMs });
|
|
629
824
|
return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
|
|
630
825
|
}
|
|
631
826
|
/** Pause the current stage for a non-user runtime blocker. */
|
|
632
|
-
export function
|
|
827
|
+
export function prepareFlowRunStageBlock(context, input) {
|
|
828
|
+
if (!["engine", "harness", "environment"].includes(input.kind) || typeof input.retryable !== "boolean")
|
|
829
|
+
throw new AppError("validation", "stage block requires a valid kind and boolean retryable", 2);
|
|
830
|
+
requiredPlain(input.code, "code");
|
|
831
|
+
requiredPlain(input.summary, "summary");
|
|
633
832
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
634
833
|
const run = resolveRun(context, project.id, input.runId);
|
|
834
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
635
835
|
const index = authoritativeIndex(run);
|
|
636
836
|
const stage = requiredPlain(input.stage, "stage");
|
|
637
837
|
const stageRun = index.stage_runs.find((item) => item.stage === stage);
|
|
638
838
|
if (!stageRun || stageRun.status !== "running")
|
|
639
839
|
throw new AppError("invalid_stage_state", "Only a running stage can be blocked", 1, { run_id: run.id, stage, status: stageRun?.status ?? "missing" });
|
|
840
|
+
return { project, run, index, stage, stageRun };
|
|
841
|
+
}
|
|
842
|
+
export function blockFlowRunStage(context, input, prepared = prepareFlowRunStageBlock(context, input)) {
|
|
843
|
+
const { project, run, index, stage, stageRun } = prepared;
|
|
640
844
|
const now = context.now();
|
|
641
845
|
stageRun.status = "blocked";
|
|
642
846
|
stageRun.updated_at = now;
|
|
@@ -650,20 +854,24 @@ export function blockFlowRunStage(context, input) {
|
|
|
650
854
|
persistRunState(context, project, run, index);
|
|
651
855
|
refreshRunSessionProjection(context, project.id, run.id);
|
|
652
856
|
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 } });
|
|
653
|
-
appendRunTimeline(
|
|
857
|
+
appendRunTimeline(context, project.id, run.id, { 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 });
|
|
654
858
|
return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
|
|
655
859
|
}
|
|
656
860
|
/** Continue the same stage after its non-user blocker was repaired externally. */
|
|
657
|
-
export function
|
|
861
|
+
export function prepareFlowRunStageUnblock(context, input) {
|
|
658
862
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
659
863
|
const run = resolveRun(context, project.id, input.runId);
|
|
864
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
660
865
|
const index = authoritativeIndex(run);
|
|
661
866
|
const stage = requiredPlain(input.stage, "stage");
|
|
662
867
|
const stageRun = index.stage_runs.find((item) => item.stage === stage);
|
|
663
868
|
if (!stageRun || stageRun.status !== "blocked" || !stageRun.blocker || stageRun.blocker.work_id !== input.workId)
|
|
664
869
|
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" });
|
|
870
|
+
return { project, run, index, stage, stageRun, blocker: stageRun.blocker };
|
|
871
|
+
}
|
|
872
|
+
export function unblockFlowRunStage(context, input, prepared = prepareFlowRunStageUnblock(context, input)) {
|
|
873
|
+
const { project, run, index, stage, stageRun, blocker } = prepared;
|
|
665
874
|
const now = context.now();
|
|
666
|
-
const blocker = stageRun.blocker;
|
|
667
875
|
const blockedMs = durationMs(blocker.blocked_at, now) ?? 0;
|
|
668
876
|
stageRun.status = "running";
|
|
669
877
|
stageRun.updated_at = now;
|
|
@@ -678,7 +886,7 @@ export function unblockFlowRunStage(context, input) {
|
|
|
678
886
|
persistRunState(context, project, run, index);
|
|
679
887
|
refreshRunSessionProjection(context, project.id, run.id);
|
|
680
888
|
appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_unblocked", payload: { run_id: run.id, stage, work_id: input.workId, code: blocker.code } });
|
|
681
|
-
appendRunTimeline(
|
|
889
|
+
appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_unblocked", run_id: run.id, stage, work_id: input.workId, code: blocker.code, blocked_ms: blockedMs });
|
|
682
890
|
return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
|
|
683
891
|
}
|
|
684
892
|
function operationalRunStatus(context, projectId, runId) {
|
|
@@ -687,11 +895,36 @@ function operationalRunStatus(context, projectId, runId) {
|
|
|
687
895
|
const leaves = unfinished.filter((work) => !parents.has(work.work_id));
|
|
688
896
|
return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
|
|
689
897
|
}
|
|
690
|
-
|
|
691
|
-
|
|
898
|
+
/** Keep the logical RUN projection aligned with recovery authority. Stage/Work
|
|
899
|
+
* evidence and verdicts survive a reversible stop; terminal runs never reopen. */
|
|
900
|
+
export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
|
|
901
|
+
const run = resolveRun(context, projectId, runId);
|
|
902
|
+
if (!["running", "paused"].includes(run.status))
|
|
903
|
+
return;
|
|
904
|
+
const index = authoritativeIndex(run);
|
|
905
|
+
const status = paused || index.stage_runs.some(stage => ["paused", "waiting_for_user", "blocked"].includes(stage.status))
|
|
906
|
+
? "paused" : operationalRunStatus(context, projectId, runId);
|
|
907
|
+
if (run.status === status && index.status === status)
|
|
908
|
+
return;
|
|
909
|
+
index.status = status;
|
|
910
|
+
index.updated_at = context.now();
|
|
911
|
+
const project = requireProjectByRoot(context, run.project_root);
|
|
912
|
+
persistRunState(context, project, run, index);
|
|
913
|
+
}
|
|
914
|
+
export function prepareFlowRunCompletion(context, input) {
|
|
915
|
+
if (input.manualOverrideReason !== undefined) {
|
|
916
|
+
requiredPlain(input.manualOverrideReason, "override reason");
|
|
917
|
+
if (!["cancelled", "failed"].includes(input.status))
|
|
918
|
+
throw new AppError("validation", "Manual RUN override may only cancel or fail a RUN", 2);
|
|
919
|
+
}
|
|
920
|
+
if (input.verdict !== undefined)
|
|
921
|
+
requiredPlain(input.verdict, "verdict");
|
|
922
|
+
if (input.nextAction !== undefined)
|
|
923
|
+
requiredPlain(input.nextAction, "next action");
|
|
924
|
+
const project = requireProjectByRoot(context, input.manualOverrideReason ? canonicalPath(input.projectRoot, false) : resolveProjectRoot(input.projectRoot));
|
|
692
925
|
const run = resolveRun(context, project.id, input.runId);
|
|
926
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
693
927
|
const index = authoritativeIndex(run);
|
|
694
|
-
const now = context.now();
|
|
695
928
|
const status = parseRunStatus(input.status);
|
|
696
929
|
if (!['done', 'cancelled', 'failed'].includes(status)) {
|
|
697
930
|
throw new AppError('validation', 'completeFlowRun accepts only done, cancelled, or failed; use stage block/unblock for a recoverable blocker', 2, { status });
|
|
@@ -701,6 +934,11 @@ export function completeFlowRun(context, input) {
|
|
|
701
934
|
if (activeWork)
|
|
702
935
|
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 });
|
|
703
936
|
}
|
|
937
|
+
return { project, run, index, status };
|
|
938
|
+
}
|
|
939
|
+
export function completeFlowRun(context, input) {
|
|
940
|
+
const { project, run, index, status } = prepareFlowRunCompletion(context, input);
|
|
941
|
+
const now = context.now();
|
|
704
942
|
if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
|
|
705
943
|
closeOpenStagesForOverride(index, status, now);
|
|
706
944
|
closeOpenWorksForOverride(context, project.id, run.id, status, now);
|
|
@@ -734,7 +972,7 @@ export function completeFlowRun(context, input) {
|
|
|
734
972
|
eventType: input.manualOverrideReason ? "flow_run.manual_override" : "flow_run.completed",
|
|
735
973
|
payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) }
|
|
736
974
|
});
|
|
737
|
-
appendRunTimeline(
|
|
975
|
+
appendRunTimeline(context, project.id, run.id, { at: now, type: input.manualOverrideReason ? "run_manual_override" : "run_completed", run_id: run.id, status, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) });
|
|
738
976
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
739
977
|
return { ok: true, run: flowRunSummary(updatedRun), index, usage, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
740
978
|
}
|
|
@@ -743,16 +981,21 @@ export function advanceFlowRun(context, input) {
|
|
|
743
981
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
744
982
|
const run = resolveRun(context, project.id, input.runId);
|
|
745
983
|
const index = authoritativeIndex(run);
|
|
984
|
+
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)))
|
|
985
|
+
assertStageSettlementAllowed(context, project.id, run.id, input.settlementStage);
|
|
986
|
+
else
|
|
987
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
988
|
+
const control = runControlDirective(context, project.id, run.id);
|
|
746
989
|
const now = context.now();
|
|
747
990
|
index.status = input.status;
|
|
748
991
|
index.verdict = input.verdict;
|
|
749
|
-
index.next_action = input.nextAction ?? null;
|
|
992
|
+
index.next_action = control ? null : input.nextAction ?? null;
|
|
750
993
|
index.updated_at = now;
|
|
751
994
|
persistRunState(context, project, run, index);
|
|
752
|
-
appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status:
|
|
753
|
-
appendRunTimeline(
|
|
995
|
+
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 } });
|
|
996
|
+
appendRunTimeline(context, project.id, run.id, { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
|
|
754
997
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
755
|
-
return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
998
|
+
return { ok: true, ...(control ? { control } : {}), run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
756
999
|
}
|
|
757
1000
|
/**
|
|
758
1001
|
* The only legal backward edge in vNext: an integration gate exposed a
|
|
@@ -762,7 +1005,11 @@ export function advanceFlowRun(context, input) {
|
|
|
762
1005
|
export function prepareVnextMergeSourceRepairAttempt(context, input) {
|
|
763
1006
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
764
1007
|
const run = resolveRun(context, project.id, input.runId);
|
|
1008
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
765
1009
|
const index = authoritativeIndex(run);
|
|
1010
|
+
const priorRepair = index.variables?.["merge.source_repair"];
|
|
1011
|
+
if (priorRepair?.merge_request_id === input.mergeRequestId && priorRepair.cycle)
|
|
1012
|
+
return { run_id: run.id, cycle: priorRepair.cycle };
|
|
766
1013
|
const code = index.stage_runs.find((item) => item.stage === "code");
|
|
767
1014
|
const review = index.stage_runs.find((item) => item.stage === "code-review");
|
|
768
1015
|
const merge = index.stage_runs.find((item) => item.stage === "merge");
|
|
@@ -776,7 +1023,7 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
|
|
|
776
1023
|
}
|
|
777
1024
|
const root = runArtifactRoot(run);
|
|
778
1025
|
for (const stage of ["code", "code-review", "merge"])
|
|
779
|
-
archiveExistingStageAttempt(root, vnextStageDirectory(stage));
|
|
1026
|
+
archiveExistingStageAttempt(context, project.id, run.id, root, vnextStageDirectory(stage));
|
|
780
1027
|
const now = context.now();
|
|
781
1028
|
const restarted = {
|
|
782
1029
|
...code,
|
|
@@ -811,7 +1058,7 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
|
|
|
811
1058
|
}));
|
|
812
1059
|
persistRunState(context, project, run, index);
|
|
813
1060
|
appendAudit(context, { projectId: project.id, eventType: "flow_run.merge_source_repair_prepared", payload: { run_id: run.id, merge_request_id: input.mergeRequestId, cycle } });
|
|
814
|
-
appendRunTimeline(
|
|
1061
|
+
appendRunTimeline(context, project.id, run.id, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
|
|
815
1062
|
return { run_id: run.id, cycle };
|
|
816
1063
|
}
|
|
817
1064
|
function closeOpenStagesForOverride(index, status, now) {
|
|
@@ -860,7 +1107,7 @@ function closeRunOrchestrators(context, projectId, run, status, now) {
|
|
|
860
1107
|
context.db.run(`UPDATE flow_session_segments SET ended_at = ?
|
|
861
1108
|
WHERE project_id = ? AND run_id = ? AND session_id IN (${active.map(() => "?").join(", ")}) AND ended_at IS NULL`, [now, projectId, run.id, ...active.map((session) => session.session_id)]);
|
|
862
1109
|
for (const session of active) {
|
|
863
|
-
appendRunTimeline(
|
|
1110
|
+
appendRunTimeline(context, projectId, run.id, {
|
|
864
1111
|
at: now,
|
|
865
1112
|
type: "session_stopped",
|
|
866
1113
|
run_id: run.id,
|
|
@@ -983,7 +1230,7 @@ export function getFlowRunSessions(context, input) {
|
|
|
983
1230
|
}
|
|
984
1231
|
export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
|
|
985
1232
|
const run = requireRunById(context, projectId, runId);
|
|
986
|
-
appendRunTimeline(
|
|
1233
|
+
appendRunTimeline(context, projectId, run.id, { run_id: run.id, ...event });
|
|
987
1234
|
}
|
|
988
1235
|
function guidanceForRun(context, run, index) {
|
|
989
1236
|
if (run.flow_kind === "vnext_specify")
|
|
@@ -1078,32 +1325,31 @@ function vnextSpecifyGuidance(run, index) {
|
|
|
1078
1325
|
};
|
|
1079
1326
|
}
|
|
1080
1327
|
function persistRunState(context, project, run, index, options = {}) {
|
|
1328
|
+
// Fenced productive admission is a logical pause, even while physical work
|
|
1329
|
+
// drains. Late admitted Stage results must not revive a stopped RUN.
|
|
1330
|
+
if (index.status === "running" && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
|
|
1331
|
+
index.status = "paused";
|
|
1081
1332
|
const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
index.updated_at,
|
|
1103
|
-
index.completed_at ?? null,
|
|
1104
|
-
project.id,
|
|
1105
|
-
run.id
|
|
1106
|
-
]);
|
|
1333
|
+
context.db.writeTransaction(() => {
|
|
1334
|
+
const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]);
|
|
1335
|
+
if (!stored)
|
|
1336
|
+
throw new AppError("not_found", "RUN is not registered", 1, { run_id: run.id });
|
|
1337
|
+
const previous = JSON.parse(stored.index_json);
|
|
1338
|
+
const actualRevision = previous.runtime_revision ?? 0;
|
|
1339
|
+
const expectedRevision = index.runtime_revision ?? 0;
|
|
1340
|
+
if (actualRevision !== expectedRevision)
|
|
1341
|
+
throw new AppError("run_revision_changed", "RUN changed during preparation", 1, { run_id: run.id, expected_revision: expectedRevision, actual_revision: actualRevision });
|
|
1342
|
+
index.runtime_revision = actualRevision + 1;
|
|
1343
|
+
const history = [...(previous.flag_revision_history ?? [])];
|
|
1344
|
+
if (options.flagRevision)
|
|
1345
|
+
history.push(options.flagRevision);
|
|
1346
|
+
index.flag_revision_history = history.slice(-32);
|
|
1347
|
+
const updated = context.db.run(`UPDATE runs SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?, updated_at = ?, completed_at = ?
|
|
1348
|
+
WHERE project_id = ? AND id = ? AND index_json = ?`, [index.status, index.verdict, index.next_action, JSON.stringify(persistedVnextIndex(index)), runtimePath, runtimePath, index.updated_at, index.completed_at ?? null, project.id, run.id, stored.index_json]);
|
|
1349
|
+
if (updated.changes !== 1)
|
|
1350
|
+
throw new AppError("run_revision_changed", "RUN changed during preparation", 1, { run_id: run.id });
|
|
1351
|
+
context.db.afterCommit(() => publishRunProjections(context, project.id, run.id));
|
|
1352
|
+
}, "runs.persistRunState");
|
|
1107
1353
|
}
|
|
1108
1354
|
/** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
|
|
1109
1355
|
function persistedVnextIndex(index) {
|
|
@@ -1145,35 +1391,64 @@ function materializedRunVariables(index) {
|
|
|
1145
1391
|
...(index.variables ?? {})
|
|
1146
1392
|
};
|
|
1147
1393
|
}
|
|
1148
|
-
function
|
|
1394
|
+
function prepareRunVariable(context, input) {
|
|
1149
1395
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
1150
1396
|
const run = resolveRun(context, project.id, input.runId);
|
|
1397
|
+
assertRunMutationAllowed(context, project.id, run.id);
|
|
1151
1398
|
const index = authoritativeIndex(run);
|
|
1399
|
+
return { project, run, index };
|
|
1400
|
+
}
|
|
1401
|
+
function setRunVariable(context, input, value, prepared = prepareRunVariable(context, input)) {
|
|
1402
|
+
const { project, run, index } = prepared;
|
|
1152
1403
|
if (index.variables?.[input.key] === value)
|
|
1153
1404
|
return { ok: true, run_id: run.id, key: input.key, value, idempotent: true };
|
|
1154
1405
|
index.variables = { ...(index.variables ?? {}), [input.key]: value };
|
|
1155
1406
|
index.updated_at = context.now();
|
|
1156
1407
|
persistRunState(context, project, run, index);
|
|
1157
|
-
appendRunTimeline(
|
|
1408
|
+
appendRunTimeline(context, project.id, run.id, { at: index.updated_at, type: "run_variable_set", run_id: run.id, key: input.key });
|
|
1158
1409
|
return { ok: true, run_id: run.id, key: input.key, value, idempotent: false };
|
|
1159
1410
|
}
|
|
1160
|
-
function appendRunTimeline(
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1411
|
+
function appendRunTimeline(context, projectId, runId, event) {
|
|
1412
|
+
context.db.writeTransaction(() => {
|
|
1413
|
+
const last = context.db.get("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.timeline' AND json_extract(payload_json, '$.run_id') = ? ORDER BY id DESC LIMIT 1", [projectId, runId]);
|
|
1414
|
+
const sequence = last ? Number(JSON.parse(last.payload_json).sequence) + 1 : 1;
|
|
1415
|
+
const safeEvent = sanitizeTimelineValue(event);
|
|
1416
|
+
const enriched = { event_id: crypto.randomUUID(), sequence, at: typeof safeEvent.at === "string" ? safeEvent.at : context.now(), ...safeEvent };
|
|
1417
|
+
appendAudit(context, { projectId, eventType: "flow_run.timeline", payload: { run_id: runId, sequence, event: enriched } });
|
|
1418
|
+
context.db.afterCommit(() => publishRunProjections(context, projectId, runId));
|
|
1419
|
+
}, "runs.appendRunTimeline");
|
|
1420
|
+
}
|
|
1421
|
+
/** Rebuild projections from committed SQL after taking a per-RUN filesystem lock. */
|
|
1422
|
+
export function publishRunProjections(context, projectId, runId) {
|
|
1423
|
+
const run = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
1424
|
+
if (!run)
|
|
1425
|
+
throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
|
|
1426
|
+
const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, projectId, runId);
|
|
1427
|
+
ensureDir(path.dirname(runtimePath));
|
|
1428
|
+
withProjectionLock(`${runtimePath}.publish-lock`, () => {
|
|
1429
|
+
// A delayed publisher must observe the latest commit after acquiring its lock.
|
|
1430
|
+
const latest = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
1431
|
+
if (!latest)
|
|
1432
|
+
throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
|
|
1433
|
+
const index = authoritativeIndex(latest);
|
|
1434
|
+
const mutations = context.db.all("SELECT resulting_revision, result_json, created_at, idempotency_key FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? ORDER BY resulting_revision DESC LIMIT 32", [projectId, runId]);
|
|
1435
|
+
const history = mutations.reverse().map((row) => ({
|
|
1436
|
+
revision: row.resulting_revision,
|
|
1437
|
+
checksum: String(JSON.parse(row.result_json).snapshot_checksum ?? ""),
|
|
1438
|
+
at: row.created_at,
|
|
1439
|
+
idempotency_key: row.idempotency_key
|
|
1440
|
+
}));
|
|
1441
|
+
writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, index.runtime_revision ?? 1, history.length ? history : index.flag_revision_history ?? []));
|
|
1442
|
+
const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.timeline' AND json_extract(payload_json, '$.run_id') = ? ORDER BY id", [projectId, runId]);
|
|
1443
|
+
if (latest.run_root) {
|
|
1444
|
+
const timelinePath = path.join(latest.run_root, "timeline.jsonl");
|
|
1445
|
+
ensureDir(path.dirname(timelinePath));
|
|
1446
|
+
const bytes = rows.map((row) => JSON.stringify(JSON.parse(row.payload_json).event)).join("\n");
|
|
1447
|
+
const temporary = `${timelinePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
1448
|
+
fs.writeFileSync(temporary, bytes ? `${bytes}\n` : "");
|
|
1449
|
+
fs.renameSync(temporary, timelinePath);
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1177
1452
|
}
|
|
1178
1453
|
function runtimeSnapshotForIndex(index, runtimeRevision, flagRevisionHistory = []) {
|
|
1179
1454
|
const rest = { ...index };
|
|
@@ -1199,10 +1474,12 @@ function readRuntimeSnapshot(file) {
|
|
|
1199
1474
|
function authoritativeIndex(run) {
|
|
1200
1475
|
try {
|
|
1201
1476
|
const state = JSON.parse(run.index_json);
|
|
1477
|
+
if (!state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.stage_runs))
|
|
1478
|
+
throw new Error("RUN index must contain stage_runs");
|
|
1202
1479
|
return { ...state, schema_id: runSchemaId };
|
|
1203
1480
|
}
|
|
1204
1481
|
catch (error) {
|
|
1205
|
-
throw new AppError("
|
|
1482
|
+
throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
|
|
1206
1483
|
}
|
|
1207
1484
|
}
|
|
1208
1485
|
function sanitizeTimelineValue(value, key) {
|
|
@@ -1263,7 +1540,7 @@ function upsertStage(index, stageRun) {
|
|
|
1263
1540
|
}
|
|
1264
1541
|
index.stage_runs.sort((a, b) => a.order - b.order);
|
|
1265
1542
|
}
|
|
1266
|
-
function resolveRun(context, projectId, idOrAlias) {
|
|
1543
|
+
export function resolveRun(context, projectId, idOrAlias) {
|
|
1267
1544
|
if (isFullEntityId(idOrAlias)) {
|
|
1268
1545
|
return requireRunById(context, projectId, idOrAlias);
|
|
1269
1546
|
}
|
|
@@ -1357,7 +1634,7 @@ function parseStageStatus(value) {
|
|
|
1357
1634
|
}
|
|
1358
1635
|
return value;
|
|
1359
1636
|
}
|
|
1360
|
-
function
|
|
1637
|
+
export function prepareStageDirectory(value) {
|
|
1361
1638
|
const dir = requiredPlain(value, "dir");
|
|
1362
1639
|
if (!/^\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(dir)) {
|
|
1363
1640
|
throw new AppError("validation", "--dir must use NN-stage-slug format", 2, { dir });
|
|
@@ -1365,6 +1642,8 @@ function requiredStageDir(value) {
|
|
|
1365
1642
|
return dir;
|
|
1366
1643
|
}
|
|
1367
1644
|
function requiredPlain(value, label) {
|
|
1645
|
+
if (typeof value !== "string")
|
|
1646
|
+
throw new AppError("validation", `${label} must be a string`, 2);
|
|
1368
1647
|
const trimmed = value.trim();
|
|
1369
1648
|
if (!trimmed) {
|
|
1370
1649
|
throw new AppError("validation", `${label} must not be empty`, 2);
|
|
@@ -1372,6 +1651,8 @@ function requiredPlain(value, label) {
|
|
|
1372
1651
|
return trimmed;
|
|
1373
1652
|
}
|
|
1374
1653
|
function normalizeSlug(value) {
|
|
1654
|
+
if (typeof value !== "string")
|
|
1655
|
+
throw new AppError("validation", "--slug must be a string", 2);
|
|
1375
1656
|
const slug = value
|
|
1376
1657
|
.normalize("NFKD")
|
|
1377
1658
|
.replace(/[\u0300-\u036f]/g, "")
|
|
@@ -1389,26 +1670,84 @@ function resolveWorkspaceRoot(workspaceRoot) {
|
|
|
1389
1670
|
if (!fs.existsSync(absolute)) {
|
|
1390
1671
|
throw new AppError("not_found", `Workspace root does not exist: ${absolute}`, 1);
|
|
1391
1672
|
}
|
|
1673
|
+
if (!fs.statSync(absolute).isDirectory())
|
|
1674
|
+
throw new AppError("validation", "Workspace root must be a directory", 2, { workspace_root: absolute });
|
|
1392
1675
|
return fs.realpathSync(absolute);
|
|
1393
1676
|
}
|
|
1394
|
-
|
|
1677
|
+
/** Move one completed stage attempt and update only known durable path columns.
|
|
1678
|
+
* Receipt bytes remain immutable; readers resolve their new location through the DB. */
|
|
1679
|
+
export function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
|
|
1395
1680
|
const currentStageDir = path.join(runRoot, stageDir);
|
|
1396
1681
|
if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
|
|
1397
1682
|
return;
|
|
1398
1683
|
}
|
|
1399
|
-
const
|
|
1400
|
-
|
|
1684
|
+
const journal = path.join(runRoot, `.archive-${stageDir}.json`);
|
|
1685
|
+
const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
|
|
1686
|
+
const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
1687
|
+
const prior = row ? JSON.parse(row.index_json) : null;
|
|
1688
|
+
const live = prior?.stage_runs.find((stage) => stage.dir === stageDir);
|
|
1689
|
+
const generation = row ? assertRunMutationAllowed(context, projectId, runId) : 0;
|
|
1690
|
+
if (pending && (pending.project_id && pending.project_id !== projectId || pending.run_id && pending.run_id !== runId))
|
|
1691
|
+
throw new AppError("archive_owner_changed", "Archive journal belongs to another RUN", 1, { journal });
|
|
1692
|
+
if (pending?.generation !== undefined && pending.generation !== generation)
|
|
1693
|
+
throw new AppError("archive_owner_changed", "RUN generation changed while an archive was pending", 1, { journal, expected_generation: pending.generation, generation });
|
|
1694
|
+
if (pending?.prior_attempt && live?.attempt !== pending.prior_attempt && live?.attempt !== pending.next_attempt)
|
|
1695
|
+
throw new AppError("archive_owner_changed", "Stage attempt changed while an archive was pending", 1, { journal, prior_attempt: pending.prior_attempt, actual_attempt: live?.attempt ?? null });
|
|
1696
|
+
const entries = pending?.entries ?? fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
|
|
1697
|
+
if (entries.length === 0 && !pending) {
|
|
1401
1698
|
return;
|
|
1402
1699
|
}
|
|
1403
1700
|
let attempt = 1;
|
|
1404
1701
|
while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
|
|
1405
1702
|
attempt += 1;
|
|
1406
1703
|
}
|
|
1407
|
-
const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
|
|
1704
|
+
const archiveDir = pending?.archiveDir ?? path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
|
|
1705
|
+
const intent = pending ?? { archiveDir, entries, stage_run: live ?? null, project_id: projectId, run_id: runId, prior_attempt: live?.attempt ?? null, next_attempt: nextAttempt(live), runtime_revision: prior?.runtime_revision ?? 0, generation };
|
|
1706
|
+
if (!pending) {
|
|
1707
|
+
writeJsonFile(journal, intent);
|
|
1708
|
+
}
|
|
1408
1709
|
ensureDir(archiveDir);
|
|
1409
1710
|
for (const entry of entries) {
|
|
1410
|
-
|
|
1711
|
+
const source = path.join(currentStageDir, entry);
|
|
1712
|
+
const target = path.join(archiveDir, entry);
|
|
1713
|
+
if (fs.existsSync(source)) {
|
|
1714
|
+
if (fs.existsSync(target))
|
|
1715
|
+
throw new AppError("archive_conflict", "Both live and archived artifact exist; preserve both for recovery", 2, { source, target });
|
|
1716
|
+
fs.renameSync(source, target);
|
|
1717
|
+
}
|
|
1718
|
+
else if (!fs.existsSync(target))
|
|
1719
|
+
throw new AppError("archive_artifact_missing", "Artifact is absent from both archive locations", 2, { source, target });
|
|
1411
1720
|
}
|
|
1721
|
+
context.db.writeTransaction(() => {
|
|
1722
|
+
const current = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
1723
|
+
const currentIndex = current ? JSON.parse(current.index_json) : null;
|
|
1724
|
+
const currentAttempt = currentIndex?.stage_runs.find((stage) => stage.dir === stageDir)?.attempt;
|
|
1725
|
+
if (currentIndex && intent.runtime_revision !== undefined && (currentIndex.runtime_revision ?? 0) !== intent.runtime_revision && currentAttempt !== intent.next_attempt)
|
|
1726
|
+
throw new AppError("archive_owner_changed", "RUN revision changed while an archive was pending", 1, { journal });
|
|
1727
|
+
if (intent.prior_attempt && currentAttempt !== intent.prior_attempt && currentAttempt !== intent.next_attempt)
|
|
1728
|
+
throw new AppError("archive_owner_changed", "Stage attempt changed while an archive was pending", 1, { journal });
|
|
1729
|
+
if (row && generation !== assertRunMutationAllowed(context, projectId, runId))
|
|
1730
|
+
throw new AppError("archive_owner_changed", "RUN generation changed while an archive was pending", 1, { journal });
|
|
1731
|
+
if (intent.next_attempt === undefined || currentAttempt !== intent.next_attempt) {
|
|
1732
|
+
const oldPrefix = `${currentStageDir}${path.sep}`;
|
|
1733
|
+
const newPrefix = `${archiveDir}${path.sep}`;
|
|
1734
|
+
const rewrite = (table, column, where, values) => {
|
|
1735
|
+
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]);
|
|
1736
|
+
};
|
|
1737
|
+
const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
|
|
1738
|
+
rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
|
|
1739
|
+
rewrite("work_sessions", "result_path", workScope, [projectId, runId]);
|
|
1740
|
+
const receiptScope = "project_id = ? AND run_id = ?";
|
|
1741
|
+
for (const column of ["stdout_path", "stderr_path", "receipt_path"])
|
|
1742
|
+
rewrite("check_receipts", column, receiptScope, [projectId, runId]);
|
|
1743
|
+
}
|
|
1744
|
+
context.db.afterCommit(() => {
|
|
1745
|
+
if (!fs.existsSync(journal))
|
|
1746
|
+
return;
|
|
1747
|
+
// Retain original Stage facts with the immutable attempt after SQL paths commit.
|
|
1748
|
+
fs.renameSync(journal, path.join(archiveDir, "archive.json"));
|
|
1749
|
+
});
|
|
1750
|
+
});
|
|
1412
1751
|
}
|
|
1413
1752
|
function writeJsonFile(file, value) {
|
|
1414
1753
|
const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
@@ -1433,3 +1772,5 @@ export function gitFacts(workspaceRoot) {
|
|
|
1433
1772
|
status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
|
|
1434
1773
|
};
|
|
1435
1774
|
}
|
|
1775
|
+
import { assertRunMutationAllowed, assertStageSettlementAllowed, recoveryGuard } from "./run-recovery.js";
|
|
1776
|
+
import { runControlDirective } from "./run-control.js";
|