@deksden-com/dd-flow-cli 0.9.0-beta.75 → 0.9.0-beta.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +6 -6
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +1 -1
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1015 -423
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
- package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
- package/dist/harness-runtime/lib/dd-droid.mjs +2 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.mjs +13 -4
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +49 -8
- package/dist/services/cli-operation-classifier.js +12 -24
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/config.js +11 -8
- package/dist/services/controller-fanout.js +2 -2
- package/dist/services/dashboard.js +94 -31
- package/dist/services/engines.js +75 -61
- package/dist/services/eval-snapshots.js +138 -38
- package/dist/services/execution-policy.js +5 -12
- package/dist/services/hooks.js +226 -203
- package/dist/services/lanes.js +60 -52
- package/dist/services/lifecycle-command.js +13 -1
- package/dist/services/lifecycle-invocations.js +285 -94
- package/dist/services/managed-processes.js +158 -18
- package/dist/services/merge-queue.js +173 -98
- package/dist/services/migrations.js +13 -8
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +11 -2
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +54 -19
- package/dist/services/recovery-observation-budget.js +1 -1
- package/dist/services/run-controller-adapter.js +31 -20
- package/dist/services/run-controller-state.js +5 -0
- package/dist/services/run-controller.js +102 -56
- package/dist/services/run-fork.js +36 -22
- package/dist/services/run-recovery.js +32 -17
- package/dist/services/runs.js +110 -38
- package/dist/services/runtime-budget.js +120 -48
- package/dist/services/runtime-scope-capture.js +2 -2
- package/dist/services/runtime-scope-control.js +2 -2
- package/dist/services/runtime-scope-resume.js +34 -12
- package/dist/services/runtime-scope-worker.js +49 -16
- package/dist/services/runtime-service.js +8 -7
- package/dist/services/schema-validation.js +9 -7
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +42 -16
- package/dist/services/stage-lifecycle.js +82 -100
- package/dist/services/stage-pause.js +54 -24
- package/dist/services/vnext-code-review.js +82 -32
- package/dist/services/vnext-code.js +78 -45
- package/dist/services/vnext-fanout.js +13 -12
- package/dist/services/vnext-merge.js +68 -35
- package/dist/services/vnext-plan-review.js +84 -41
- package/dist/services/vnext-plan.js +91 -70
- package/dist/services/vnext-protocolize.js +116 -50
- package/dist/services/vnext-specify.js +70 -57
- package/dist/services/work-registry.js +195 -92
- package/dist/services/worktrees.js +85 -35
- package/dist/storage/database.js +104 -10
- package/dist/storage/paths.js +17 -4
- package/package.json +1 -1
package/dist/services/plans.js
CHANGED
|
@@ -4,7 +4,7 @@ import { appendAudit } from "./audit.js";
|
|
|
4
4
|
import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
5
5
|
import { requireProjectByRoot } from "./projects.js";
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
-
import { planSummary, planWithProgress, updatePlanProgress } from "./plan-runtime.js";
|
|
7
|
+
import { planSummary, planWithProgress, prepareCanonicalPlan, updatePlanProgress } from "./plan-runtime.js";
|
|
8
8
|
export function getPlanStatus(context, input) {
|
|
9
9
|
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
10
10
|
const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false });
|
|
@@ -20,40 +20,43 @@ export function getPlanStatus(context, input) {
|
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
22
|
export function startPlanItem(context, input) {
|
|
23
|
-
return updatePlanItem(context,
|
|
24
|
-
assertDependenciesClosed(plan, item);
|
|
25
|
-
return { ...item, status: "in_progress", summary: item.summary || "Started." };
|
|
26
|
-
});
|
|
23
|
+
return updatePlanItem(context, { ...input, action: "start" });
|
|
27
24
|
}
|
|
28
25
|
export function completePlanItem(context, input) {
|
|
29
|
-
return updatePlanItem(context,
|
|
30
|
-
assertDependenciesClosed(plan, item);
|
|
31
|
-
return { ...item, status: "done", summary: input.summary, evidence: [...new Set([...item.evidence, ...input.evidence])] };
|
|
32
|
-
});
|
|
26
|
+
return updatePlanItem(context, { ...input, action: "done" });
|
|
33
27
|
}
|
|
34
28
|
export function blockPlanItem(context, input) {
|
|
35
|
-
return updatePlanItem(context,
|
|
36
|
-
...item,
|
|
37
|
-
status: "blocked",
|
|
38
|
-
summary: input.reason,
|
|
39
|
-
block_reason: input.reason,
|
|
40
|
-
user_required: input.userRequired
|
|
41
|
-
}));
|
|
29
|
+
return updatePlanItem(context, { ...input, action: "block" });
|
|
42
30
|
}
|
|
43
31
|
export function skipPlanItem(context, input) {
|
|
44
|
-
return updatePlanItem(context,
|
|
45
|
-
...item,
|
|
46
|
-
status: "skipped",
|
|
47
|
-
summary: input.reason
|
|
48
|
-
}));
|
|
32
|
+
return updatePlanItem(context, { ...input, action: "skip" });
|
|
49
33
|
}
|
|
50
|
-
function
|
|
34
|
+
export function preparePlanItemMutation(context, input) {
|
|
35
|
+
const { projectRoot, protocolId, itemId } = input;
|
|
36
|
+
const text = (value) => typeof value === "string" && value.trim().length > 0;
|
|
37
|
+
if (!text(itemId) || !["start", "done", "block", "skip"].includes(input.action) ||
|
|
38
|
+
(input.action === "done" && (!text(input.summary) || !Array.isArray(input.evidence) || !input.evidence.every(text))) ||
|
|
39
|
+
((input.action === "block" || input.action === "skip") && !text(input.reason)) ||
|
|
40
|
+
(input.action === "block" && typeof input.userRequired !== "boolean")) {
|
|
41
|
+
throw new AppError("validation", "Plan item requires a valid action, item ID, nonempty summary/reason, text evidence and boolean userRequired", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
|
|
42
|
+
}
|
|
51
43
|
const protocol = scopedProtocol(context, projectRoot, protocolId);
|
|
52
|
-
const
|
|
44
|
+
const scope = { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path };
|
|
45
|
+
const current = planWithProgress(context, scope, { bind: false });
|
|
46
|
+
prepareCanonicalPlan(context, scope, current.canonical);
|
|
53
47
|
const item = current.plan.items.find((candidate) => candidate.id === itemId);
|
|
54
48
|
if (!item)
|
|
55
49
|
throw new AppError("not_found", `Plan item is not found: ${itemId}`, 1);
|
|
56
|
-
|
|
50
|
+
if (input.action === "start" || input.action === "done")
|
|
51
|
+
assertDependenciesClosed(current.plan, item);
|
|
52
|
+
const nextItem = input.action === "start" ? { ...item, status: "in_progress", summary: item.summary || "Started." }
|
|
53
|
+
: input.action === "done" ? { ...item, status: "done", summary: input.summary, evidence: [...new Set([...item.evidence, ...input.evidence])] }
|
|
54
|
+
: input.action === "block" ? { ...item, status: "blocked", summary: input.reason, block_reason: input.reason, user_required: input.userRequired }
|
|
55
|
+
: { ...item, status: "skipped", summary: input.reason };
|
|
56
|
+
return { protocol, item, nextItem };
|
|
57
|
+
}
|
|
58
|
+
export function updatePlanItem(context, input) {
|
|
59
|
+
const { protocol, item, nextItem } = preparePlanItemMutation(context, input);
|
|
57
60
|
updatePlanProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path, item: nextItem });
|
|
58
61
|
if (nextItem.status === "done") {
|
|
59
62
|
context.db.run("UPDATE flow_jobs SET status = 'done', finished_at = COALESCE(finished_at, ?), updated_at = ? WHERE project_id = ? AND protocol_id = ? AND plan_item_id = ?", [context.now(), context.now(), protocol.project_id, protocol.id, nextItem.id]);
|
|
@@ -63,7 +66,7 @@ function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
|
|
|
63
66
|
protocolId: protocol.id,
|
|
64
67
|
projectId: protocol.project_id,
|
|
65
68
|
eventType: "plan.progress.updated",
|
|
66
|
-
payload: { protocol_id: protocol.id, item_id: itemId, from: item.status, to: nextItem.status }
|
|
69
|
+
payload: { protocol_id: protocol.id, item_id: input.itemId, from: item.status, to: nextItem.status }
|
|
67
70
|
});
|
|
68
71
|
return { ok: true, protocol_id: protocol.id, item: nextItem, plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }) };
|
|
69
72
|
}
|
|
@@ -37,8 +37,13 @@ export function registerProject(context, input) {
|
|
|
37
37
|
}
|
|
38
38
|
return { ok: true, project: { ...project, status: "active", updated_at: now }, db_path: context.db.path };
|
|
39
39
|
}
|
|
40
|
-
export function
|
|
41
|
-
|
|
40
|
+
export function prepareProjectArchive(context, input) {
|
|
41
|
+
if (Boolean(input.idOrAlias) === Boolean(input.root))
|
|
42
|
+
throw new AppError("usage", "Project archive requires exactly one project ID/alias or --root", 2);
|
|
43
|
+
for (const value of [input.idOrAlias, input.root])
|
|
44
|
+
if (value !== undefined && (typeof value !== "string" || !value.trim()))
|
|
45
|
+
throw new AppError("validation", "Project archive target must be a non-empty string", 2);
|
|
46
|
+
const reason = typeof input.reason === "string" ? input.reason.trim() : "";
|
|
42
47
|
if (!reason) {
|
|
43
48
|
throw new AppError("validation", "project archive requires --reason", 2);
|
|
44
49
|
}
|
|
@@ -50,6 +55,10 @@ export function archiveProject(context, input) {
|
|
|
50
55
|
if (!project) {
|
|
51
56
|
throw new AppError("not_found", "Project is not registered.", 1, { root: input.root, input: input.idOrAlias });
|
|
52
57
|
}
|
|
58
|
+
return { project, reason };
|
|
59
|
+
}
|
|
60
|
+
export function archiveProject(context, input) {
|
|
61
|
+
const { project, reason } = prepareProjectArchive(context, input);
|
|
53
62
|
const now = context.now();
|
|
54
63
|
const result = context.db.run("UPDATE projects SET status = 'archived', updated_at = ? WHERE id = ? AND status <> 'archived'", [now, project.id]);
|
|
55
64
|
if (result.changes === 1) {
|
package/dist/services/prompts.js
CHANGED
|
@@ -9,7 +9,8 @@ import { validatePlanItem } from "../domain/validation.js";
|
|
|
9
9
|
import { AppError } from "../shared/errors.js";
|
|
10
10
|
import { parseJsonObject } from "../shared/json.js";
|
|
11
11
|
import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
12
|
-
import { planWithProgress } from "./plan-runtime.js";
|
|
12
|
+
import { boundCanonicalPlan, planWithProgress, prepareCanonicalPlan } from "./plan-runtime.js";
|
|
13
|
+
import { prepareTextFile } from "../cli/input-preparation.js";
|
|
13
14
|
import { registerFlowJob } from "./sessions.js";
|
|
14
15
|
const profiles = {
|
|
15
16
|
code_implementation: {
|
|
@@ -22,7 +23,7 @@ const profiles = {
|
|
|
22
23
|
static_files: [".memory-bank/dd-flow/common/worker-session.md", ".memory-bank/dd-flow/workers/verify.md"]
|
|
23
24
|
}
|
|
24
25
|
};
|
|
25
|
-
export function
|
|
26
|
+
export function prepareWorkerPrompt(context, input) {
|
|
26
27
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
27
28
|
const project = requireProjectByRoot(context, projectRoot);
|
|
28
29
|
const protocol = requireProtocol(context, protocolIdForRun(context, project.id, input.runId), project.id);
|
|
@@ -43,8 +44,10 @@ export function renderWorkerPrompt(context, input) {
|
|
|
43
44
|
projectId: protocol.project_id,
|
|
44
45
|
protocolId: protocol.id,
|
|
45
46
|
planPath: protocol.plan_path
|
|
46
|
-
});
|
|
47
|
+
}, { bind: false });
|
|
47
48
|
const plan = planRuntime?.plan ?? null;
|
|
49
|
+
if (planRuntime)
|
|
50
|
+
prepareCanonicalPlan(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, planRuntime.canonical);
|
|
48
51
|
const genericTask = input.taskFile ? readWorkerTask(input.taskFile, runHomePath(run)) : null;
|
|
49
52
|
const item = genericTask?.item ?? plan?.items.find((candidate) => candidate.id === input.planItemId);
|
|
50
53
|
if (!item)
|
|
@@ -64,13 +67,6 @@ export function renderWorkerPrompt(context, input) {
|
|
|
64
67
|
const profile = profiles[input.profile];
|
|
65
68
|
if (!profile)
|
|
66
69
|
throw new AppError("prompt_profile_unsupported", `Unsupported prompt profile: ${input.profile}`, 2);
|
|
67
|
-
const job = registerFlowJob(context, {
|
|
68
|
-
projectId: protocol.project_id,
|
|
69
|
-
runId: run.id,
|
|
70
|
-
protocolId: protocol.id,
|
|
71
|
-
planItemId: item.id,
|
|
72
|
-
groupId: item.execution_context.planned_write_areas[0] ?? null
|
|
73
|
-
});
|
|
74
70
|
const staticInputs = profile.static_files.map((file) => readStaticInput(projectRoot, file));
|
|
75
71
|
const requiredRead = item.execution_context.required_read.map((file) => checkedReference(projectRoot, runHomePath(run), file, "required_read", true));
|
|
76
72
|
const discoveryBoundary = item.execution_context.discovery_boundary.map((file) => checkedReference(projectRoot, runHomePath(run), file, "discovery_boundary", false));
|
|
@@ -78,7 +74,6 @@ export function renderWorkerPrompt(context, input) {
|
|
|
78
74
|
const runHome = runHomePath(run);
|
|
79
75
|
const outputDir = path.join(runHome, stage.dir, "subagents", item.id);
|
|
80
76
|
assertWithin(runHome, outputDir, "output directory");
|
|
81
|
-
ensureDir(outputDir);
|
|
82
77
|
const prompt = renderPrompt({
|
|
83
78
|
item,
|
|
84
79
|
input,
|
|
@@ -90,6 +85,20 @@ export function renderWorkerPrompt(context, input) {
|
|
|
90
85
|
staticInputs,
|
|
91
86
|
...(genericTask ? { handoff: genericTask.handoff } : {})
|
|
92
87
|
});
|
|
88
|
+
return { protocol, run, item, plan, planRuntime, genericTask, workspaceRoot, staticInputs, requiredRead, discoveryBoundary, plannedWriteAreas, outputDir, prompt, canonVersion: readCanonVersion(projectRoot) };
|
|
89
|
+
}
|
|
90
|
+
export function renderWorkerPrompt(context, input, prepared = prepareWorkerPrompt(context, input)) {
|
|
91
|
+
const { protocol, run, item, plan, planRuntime, genericTask, workspaceRoot, staticInputs, requiredRead, discoveryBoundary, plannedWriteAreas, outputDir, prompt, canonVersion } = prepared;
|
|
92
|
+
if (planRuntime)
|
|
93
|
+
boundCanonicalPlan(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, planRuntime.canonical);
|
|
94
|
+
const job = registerFlowJob(context, {
|
|
95
|
+
projectId: protocol.project_id,
|
|
96
|
+
runId: run.id,
|
|
97
|
+
protocolId: protocol.id,
|
|
98
|
+
planItemId: item.id,
|
|
99
|
+
groupId: item.execution_context.planned_write_areas[0] ?? null
|
|
100
|
+
});
|
|
101
|
+
ensureDir(outputDir);
|
|
93
102
|
const promptPath = path.join(outputDir, "launch-prompt.md");
|
|
94
103
|
const stackPath = path.join(outputDir, "prompt-stack.json");
|
|
95
104
|
const reportPath = path.join(outputDir, "render-report.json");
|
|
@@ -104,7 +113,7 @@ export function renderWorkerPrompt(context, input) {
|
|
|
104
113
|
plan_item_id: input.taskFile ? null : item.id,
|
|
105
114
|
task_id: input.taskFile ? item.id : null,
|
|
106
115
|
job_id: job.job_id,
|
|
107
|
-
canon_version:
|
|
116
|
+
canon_version: canonVersion,
|
|
108
117
|
renderer_version: getCliVersionReport().cli.version,
|
|
109
118
|
static_inputs: staticInputs.map(({ path: inputPath, sha256 }) => ({ path: inputPath, sha256 })),
|
|
110
119
|
validation: { required_read: requiredRead, discovery_boundary: discoveryBoundary, planned_write_areas: plannedWriteAreas },
|
|
@@ -143,9 +152,7 @@ function runHomePath(run) {
|
|
|
143
152
|
function readWorkerTask(taskFile, runHome) {
|
|
144
153
|
const resolved = path.resolve(taskFile);
|
|
145
154
|
assertWithin(runHome, resolved, "worker task file");
|
|
146
|
-
|
|
147
|
-
throw new AppError("not_found", "Worker task file is missing", 1, { path: taskFile });
|
|
148
|
-
const task = parseJsonObject(fs.readFileSync(resolved, "utf8"), resolved);
|
|
155
|
+
const task = parseJsonObject(prepareTextFile(resolved, "worker task").text, resolved);
|
|
149
156
|
if (task.schema_id !== "dd-flow/worker-task@1" || !task.task || typeof task.task !== "object") {
|
|
150
157
|
throw new AppError("validation", "Worker task must use dd-flow/worker-task@1 with a task object", 2, { path: taskFile });
|
|
151
158
|
}
|
|
@@ -180,7 +187,7 @@ function validateWorkerTaskHandoff(value, runHome) {
|
|
|
180
187
|
const reportPath = path.resolve(report.report_path);
|
|
181
188
|
assertWithin(runHome, reportPath, "predecessor report");
|
|
182
189
|
if (!fs.existsSync(reportPath))
|
|
183
|
-
throw new AppError("
|
|
190
|
+
throw new AppError("runtime_artifact_missing", "Accepted predecessor report is missing", 1, { path: report.report_path });
|
|
184
191
|
return { aspect_id: report.aspect_id, verdict, report_path: reportPath };
|
|
185
192
|
});
|
|
186
193
|
const recovery = handoff.recovery_attempt_paths ?? [];
|
|
@@ -6,9 +6,8 @@ import { canTransition } from "../domain/contracts.js";
|
|
|
6
6
|
import { defaultFlowContract, flowContractForState, loadProjectFlowContract } from "../domain/flow-contract.js";
|
|
7
7
|
import { requireStage } from "../domain/validation.js";
|
|
8
8
|
import { AppError } from "../shared/errors.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { ensureDir, planJsonPath, resolveProjectRoot, runtimeProtocolDir, runtimeStateJsonPath } from "../storage/paths.js";
|
|
9
|
+
import { prepareJsonFile } from "../cli/input-preparation.js";
|
|
10
|
+
import { ensureDir, planJsonPath, resolveProjectRoot, resolveProtocolPlanPath, runtimeProtocolDir, runtimeStateJsonPath } from "../storage/paths.js";
|
|
12
11
|
import { appendAudit, getAuditEvents } from "./audit.js";
|
|
13
12
|
import { requireProjectByRoot } from "./projects.js";
|
|
14
13
|
import { ensureRuntimeProtocolFiles, readStateFile, writeState } from "../protocol/local-files.js";
|
|
@@ -17,14 +16,20 @@ import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProje
|
|
|
17
16
|
import { buildProtocolFlowGuidance } from "./flow-guidance.js";
|
|
18
17
|
import { lifecycleIsTerminalFailure, lifecycleIsTerminalSuccess, normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
|
|
19
18
|
import { getProtocolBranchContext } from "./branch-context.js";
|
|
20
|
-
export function
|
|
19
|
+
export function prepareProtocolRegistration(context, input) {
|
|
20
|
+
if (typeof input.handshakeId !== "string" || !input.handshakeId.trim() || /[/\\\0]/.test(input.handshakeId) || [".", ".."].includes(input.handshakeId))
|
|
21
|
+
throw new AppError("validation", "Protocol handshake must be a non-empty identifier, not a path", 2);
|
|
21
22
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
22
23
|
const project = requireProjectByRoot(context, projectRoot);
|
|
23
|
-
const now = context.now();
|
|
24
24
|
const workspacePath = input.workspacePath ? resolveProjectRoot(input.workspacePath) : undefined;
|
|
25
25
|
const idRoot = workspacePath ?? projectRoot;
|
|
26
26
|
const protocolId = inferProtocolId(idRoot, input.handshakeId);
|
|
27
27
|
const runtimeDir = runtimeProtocolDir(context.ddFlowHome, project.id, protocolId);
|
|
28
|
+
return { projectRoot, project, workspacePath, protocolId, runtimeDir };
|
|
29
|
+
}
|
|
30
|
+
export function registerProtocol(context, input) {
|
|
31
|
+
const { projectRoot, project, workspacePath, protocolId, runtimeDir } = prepareProtocolRegistration(context, input);
|
|
32
|
+
const now = context.now();
|
|
28
33
|
const state = ensureRuntimeProtocolFiles({
|
|
29
34
|
runtimeDir,
|
|
30
35
|
protocolId,
|
|
@@ -243,7 +248,22 @@ export function implementProtocol(context, input) {
|
|
|
243
248
|
}
|
|
244
249
|
};
|
|
245
250
|
}
|
|
246
|
-
export function
|
|
251
|
+
export function prepareProtocolTransition(context, input) {
|
|
252
|
+
const { value } = prepareJsonFile(input.jsonFile, "Protocol transition payload");
|
|
253
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
254
|
+
throw new AppError("validation", "Protocol transition payload must be an object", 2);
|
|
255
|
+
const payload = value;
|
|
256
|
+
for (const [key, field] of Object.entries(payload)) {
|
|
257
|
+
const valid = key === "next_action" ? typeof field === "string"
|
|
258
|
+
: ["route", "workspace"].includes(key) ? Boolean(field && typeof field === "object" && !Array.isArray(field))
|
|
259
|
+
: ["blockers", "active_def"].includes(key) && Array.isArray(field);
|
|
260
|
+
if (!valid)
|
|
261
|
+
throw new AppError("validation", `Unsupported field or invalid value in protocol transition: ${key}`, 2, { file: input.jsonFile, parameter: key });
|
|
262
|
+
}
|
|
263
|
+
protocolTransitionState(context, input);
|
|
264
|
+
return payload;
|
|
265
|
+
}
|
|
266
|
+
function protocolTransitionState(context, input) {
|
|
247
267
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
248
268
|
const protocol = requireProtocol(context, input.protocolId, project.id);
|
|
249
269
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
@@ -256,8 +276,10 @@ export function transitionProtocol(context, input) {
|
|
|
256
276
|
if (input.force && (!input.reason || input.reason.trim().length === 0)) {
|
|
257
277
|
throw new AppError("validation", "Forced transitions require --reason", 2);
|
|
258
278
|
}
|
|
259
|
-
|
|
260
|
-
|
|
279
|
+
return { project, protocol, state, flowContract, from, to };
|
|
280
|
+
}
|
|
281
|
+
export function transitionProtocol(context, input, payload = prepareProtocolTransition(context, input)) {
|
|
282
|
+
const { project, protocol, state, flowContract, from, to } = protocolTransitionState(context, input);
|
|
261
283
|
const nextState = {
|
|
262
284
|
...state,
|
|
263
285
|
stage: to,
|
|
@@ -579,7 +601,10 @@ export function requireProtocol(context, protocolId, projectId) {
|
|
|
579
601
|
candidates: matches.map((protocol) => ({ project_id: protocol.project_id, project_root: protocol.project_root }))
|
|
580
602
|
});
|
|
581
603
|
}
|
|
582
|
-
return matches[0];
|
|
604
|
+
return resolvedProtocolRecord(matches[0]);
|
|
605
|
+
}
|
|
606
|
+
function resolvedProtocolRecord(protocol) {
|
|
607
|
+
return { ...protocol, plan_path: resolveProtocolPlanPath(protocol.project_root, protocol.id, protocol.plan_path) };
|
|
583
608
|
}
|
|
584
609
|
function cancelQueueForProtocol(context, protocol, reason, force) {
|
|
585
610
|
const job = context.db.get("SELECT id, status, claimed_by_session_id FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
|
|
@@ -736,12 +761,20 @@ export function readProtocolRuntimeState(context, protocol) {
|
|
|
736
761
|
const expected = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
|
|
737
762
|
if (protocol.state_path !== expected)
|
|
738
763
|
throw new AppError("protocol_state_migration_required", "Protocol runtime path requires explicit cleanup scan/apply before use", 1, { protocol_id: protocol.id, path: protocol.state_path, expected_path: expected });
|
|
739
|
-
return
|
|
764
|
+
return resolveProtocolRuntimeState(context, { ...protocol }, false);
|
|
740
765
|
}
|
|
741
766
|
/** Explicit cleanup migration; productive readers accept only the current path. */
|
|
742
767
|
export function repairProtocolRuntimeState(context, protocol) {
|
|
768
|
+
// Readers project the canonical plan path without migrating storage. Repair
|
|
769
|
+
// must start from the stored locations, not that already-normalized view.
|
|
770
|
+
const stored = context.db.get("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [protocol.project_id, protocol.id]);
|
|
771
|
+
if (stored)
|
|
772
|
+
Object.assign(protocol, stored);
|
|
773
|
+
return resolveProtocolRuntimeState(context, protocol, true);
|
|
774
|
+
}
|
|
775
|
+
function resolveProtocolRuntimeState(context, protocol, repair) {
|
|
743
776
|
const diagnostics = [];
|
|
744
|
-
normalizeProtocolPlanPath(context, protocol, diagnostics);
|
|
777
|
+
normalizeProtocolPlanPath(context, protocol, diagnostics, repair);
|
|
745
778
|
try {
|
|
746
779
|
if (protocol.state_path !== runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id))
|
|
747
780
|
throw new AppError("protocol_state_migration_required", "Retired state files are not runtime authority", 1);
|
|
@@ -759,7 +792,8 @@ export function repairProtocolRuntimeState(context, protocol) {
|
|
|
759
792
|
recommended_action: "legacy protocol state_path was moved to stable runtime storage"
|
|
760
793
|
});
|
|
761
794
|
const stablePlanPath = planJsonPath(protocol.project_root, protocol.id);
|
|
762
|
-
|
|
795
|
+
if (repair && context.db.writable)
|
|
796
|
+
context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE project_id = ? AND id = ?", [stableStatePath, stablePlanPath, protocol.project_id, protocol.id]);
|
|
763
797
|
protocol.state_path = stableStatePath;
|
|
764
798
|
protocol.plan_path = stablePlanPath;
|
|
765
799
|
return { state: readStateFile(stableStatePath, protocol.id), diagnostics };
|
|
@@ -792,7 +826,7 @@ export function repairProtocolRuntimeState(context, protocol) {
|
|
|
792
826
|
flow_contract: flowContract,
|
|
793
827
|
updated_at: protocol.updated_at
|
|
794
828
|
};
|
|
795
|
-
if (!context.db.writable) {
|
|
829
|
+
if (!repair || !context.db.writable) {
|
|
796
830
|
return { state, diagnostics };
|
|
797
831
|
}
|
|
798
832
|
const stableStatePath = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
|
|
@@ -811,9 +845,9 @@ export function repairProtocolRuntimeState(context, protocol) {
|
|
|
811
845
|
});
|
|
812
846
|
return { state, diagnostics };
|
|
813
847
|
}
|
|
814
|
-
function normalizeProtocolPlanPath(context, protocol, diagnostics) {
|
|
815
|
-
const canonicalPlanPath =
|
|
816
|
-
if (protocol.plan_path === canonicalPlanPath
|
|
848
|
+
function normalizeProtocolPlanPath(context, protocol, diagnostics, repair) {
|
|
849
|
+
const canonicalPlanPath = resolveProtocolPlanPath(protocol.project_root, protocol.id, protocol.plan_path);
|
|
850
|
+
if (protocol.plan_path === canonicalPlanPath)
|
|
817
851
|
return;
|
|
818
852
|
diagnostics.push({
|
|
819
853
|
code: "protocol_plan_relocated",
|
|
@@ -823,7 +857,7 @@ function normalizeProtocolPlanPath(context, protocol, diagnostics) {
|
|
|
823
857
|
source: "project_canonical_plan",
|
|
824
858
|
recommended_action: "use the project canonical plan as the sole semantic plan source"
|
|
825
859
|
});
|
|
826
|
-
if (context.db.writable) {
|
|
860
|
+
if (repair && context.db.writable) {
|
|
827
861
|
context.db.run("UPDATE protocols SET plan_path = ?, updated_at = ? WHERE project_id = ? AND id = ?", [canonicalPlanPath, context.now(), protocol.project_id, protocol.id]);
|
|
828
862
|
}
|
|
829
863
|
protocol.plan_path = canonicalPlanPath;
|
|
@@ -884,10 +918,11 @@ export function persistProtocolState(context, protocol, state) {
|
|
|
884
918
|
}
|
|
885
919
|
function findProtocol(context, protocolId, projectId) {
|
|
886
920
|
if (projectId) {
|
|
887
|
-
|
|
921
|
+
const protocol = context.db.get("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
|
|
922
|
+
return protocol ? resolvedProtocolRecord(protocol) : undefined;
|
|
888
923
|
}
|
|
889
924
|
const matches = context.db.all("SELECT * FROM protocols WHERE id = ? ORDER BY project_id", [protocolId]);
|
|
890
|
-
return matches.length === 1 ? matches[0] : undefined;
|
|
925
|
+
return matches.length === 1 ? resolvedProtocolRecord(matches[0]) : undefined;
|
|
891
926
|
}
|
|
892
927
|
function protocolDocumentsForProject(projectRoot) {
|
|
893
928
|
const protocolRoot = path.join(projectRoot, ".memory-bank", "protocol");
|
|
@@ -15,7 +15,7 @@ export const recoveryRpcTimeout = (maximum) => {
|
|
|
15
15
|
/** Call only while holding the existing owner-claim transaction. A replay of
|
|
16
16
|
* an explicit request never replenishes time, even after another request. */
|
|
17
17
|
export function renewRecoveryObservation(snapshot, requestId) {
|
|
18
|
-
if (!requestId.trim() || requestId.length > 200)
|
|
18
|
+
if (typeof requestId !== "string" || !requestId.trim() || requestId.length > 200)
|
|
19
19
|
throw new AppError("usage", "Reconciliation requires a nonempty request ID of at most 200 characters", 2);
|
|
20
20
|
const previous = snapshot.recovery_observation;
|
|
21
21
|
const ids = previous?.reconciliation_ids ?? [];
|
|
@@ -103,7 +103,7 @@ async function recoverCompletedOperation(context, manifest, session, executable,
|
|
|
103
103
|
// is reconciled by run-control after the owner is fenced; probing it from the
|
|
104
104
|
// failing transport path would be a second adapter invocation and can itself
|
|
105
105
|
// be mistaken for replay.
|
|
106
|
-
if (args[0] === "session" &&
|
|
106
|
+
if (args[0] === "session" && ["create", "inspect", "status"].includes(args[1]))
|
|
107
107
|
return null;
|
|
108
108
|
// A detached adapter without a native daemon has no durable operation
|
|
109
109
|
// journal to inspect. Avoid a second adapter invocation in that case; the
|
|
@@ -131,6 +131,10 @@ async function awaitSettlement(context, row, manifest, session, executable, args
|
|
|
131
131
|
while (["pending", "blocked"].includes(String(settlement(current)?.state))) {
|
|
132
132
|
if (signal?.aborted)
|
|
133
133
|
throw new AppError("harness_settlement_interrupted", "Harness settlement was interrupted", 1, { operation_id: operationId });
|
|
134
|
+
// A completed parent with live children is normal controller waiting, not
|
|
135
|
+
// failed bookkeeping. Keep its budget reserved until a later inspection.
|
|
136
|
+
if (args[0] === "session" && ["prompt", "inspect", "status"].includes(args[1]) && settlement(current)?.reason === "tree_unsettled")
|
|
137
|
+
return { ...initial, ...current, ...(initial.assistant_text !== undefined ? { assistant_text: initial.assistant_text } : {}) };
|
|
134
138
|
if (Date.now() >= deadline)
|
|
135
139
|
throw new AppError("settlement_recovery_required", "Completed provider operation requires settlement recovery", 1, { operation_id: operationId, settlement: settlement(current) });
|
|
136
140
|
await new Promise(resolve => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
|
|
@@ -141,9 +145,14 @@ async function awaitSettlement(context, row, manifest, session, executable, args
|
|
|
141
145
|
try {
|
|
142
146
|
// This only retries durable bookkeeping against the same operation
|
|
143
147
|
// receipt. It cannot replay the provider request.
|
|
144
|
-
|
|
148
|
+
const inspect = args[0] === "session" && ["inspect", "status"].includes(args[1]);
|
|
149
|
+
current = await runHarnessAdapter({ executable, args: inspect ? settlementInspectArgs(args) : settlementRetryArgs(args, operationId), env: controllerEnvironment(context, manifest, session), operationId: `${operationId}:settlement`, runtimeOwner: session.owner, timeoutMs: Math.min(30_000, Math.max(1, deadline - Date.now())), ...(signal ? { signal } : {}) });
|
|
150
|
+
if (inspect)
|
|
151
|
+
validateReceipt(current, session, args);
|
|
145
152
|
}
|
|
146
153
|
catch (error) {
|
|
154
|
+
if (error instanceof AppError && ["session_identity_mismatch", "profile_integrity_violation"].includes(error.code))
|
|
155
|
+
throw error;
|
|
147
156
|
current = { settlement: { state: "pending", reason: "inspection_error", error: errorRecord(error) } };
|
|
148
157
|
}
|
|
149
158
|
if (!(["pending", "blocked"].includes(String(settlement(current)?.state))))
|
|
@@ -154,12 +163,15 @@ async function awaitSettlement(context, row, manifest, session, executable, args
|
|
|
154
163
|
continue;
|
|
155
164
|
try {
|
|
156
165
|
current = await runHarnessAdapter({ executable, args: settlementInspectArgs(args), env: controllerEnvironment(context, manifest, session), operationId: `${operationId}:settlement-inspect`, runtimeOwner: session.owner, timeoutMs: Math.min(30_000, Math.max(1, deadline - Date.now())), ...(signal ? { signal } : {}) });
|
|
166
|
+
validateReceipt(current, session, args);
|
|
157
167
|
}
|
|
158
168
|
catch (error) {
|
|
169
|
+
if (error instanceof AppError && ["session_identity_mismatch", "profile_integrity_violation"].includes(error.code))
|
|
170
|
+
throw error;
|
|
159
171
|
current = { settlement: { state: "pending", reason: "inspection_error", error: errorRecord(error) } };
|
|
160
172
|
}
|
|
161
173
|
}
|
|
162
|
-
return current;
|
|
174
|
+
return adapterSessionId(current) ? { ...initial, ...current, ...(initial.assistant_text !== undefined ? { assistant_text: initial.assistant_text } : {}) } : { ...initial, settlement: settlement(current) };
|
|
163
175
|
}
|
|
164
176
|
export async function controllerAdapter(context, row, manifest, session, args, assertOwner, retainedOperationId, signal) {
|
|
165
177
|
assertOwner?.();
|
|
@@ -173,33 +185,32 @@ export async function controllerAdapter(context, row, manifest, session, args, a
|
|
|
173
185
|
appendEvent(context, row.controller_id, "operation_requested", { operation_id: operationId, operation: args.slice(0, 2), session_id: session.id || null });
|
|
174
186
|
try {
|
|
175
187
|
assertOwner?.();
|
|
176
|
-
let receipt
|
|
188
|
+
let receipt;
|
|
189
|
+
let recoveredOperation = false;
|
|
190
|
+
try {
|
|
191
|
+
receipt = await runHarnessAdapter({ executable, args, env: controllerEnvironment(context, manifest, session), operationId, runtimeOwner: session.owner, timeoutMs: args[0] === "session" && args[1] === "prompt" ? null : 120_000, ...(signal ? { signal } : {}) });
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
const retained = await recoverCompletedOperation(context, manifest, session, executable, args, operationId, signal);
|
|
195
|
+
if (!retained)
|
|
196
|
+
throw error;
|
|
197
|
+
receipt = retained;
|
|
198
|
+
recoveredOperation = true;
|
|
199
|
+
}
|
|
200
|
+
validateReceipt(receipt, session, args);
|
|
201
|
+
context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(receipt), context.now(), operationId]);
|
|
177
202
|
if (["pending", "blocked"].includes(String(settlement(receipt)?.state))) {
|
|
178
203
|
const recovered = await awaitSettlement(context, row, manifest, session, executable, args, operationId, receipt, assertOwner, signal);
|
|
179
|
-
receipt =
|
|
204
|
+
receipt = recovered;
|
|
180
205
|
}
|
|
181
206
|
context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(receipt), context.now(), operationId]);
|
|
182
207
|
validateReceipt(receipt, session, args);
|
|
183
208
|
context.db.run("UPDATE run_controller_operations SET status = 'completed', updated_at = ? WHERE operation_id = ?", [context.now(), operationId]);
|
|
184
209
|
assertOwner?.();
|
|
185
|
-
appendEvent(context, row.controller_id, "operation_completed", { operation_id: operationId, operation: args.slice(0, 2), session_id: adapterSessionId(receipt) });
|
|
210
|
+
appendEvent(context, row.controller_id, recoveredOperation ? "operation_recovered" : "operation_completed", { operation_id: operationId, operation: args.slice(0, 2), session_id: adapterSessionId(receipt) });
|
|
186
211
|
return receipt;
|
|
187
212
|
}
|
|
188
213
|
catch (error) {
|
|
189
|
-
const retained = await recoverCompletedOperation(context, manifest, session, executable, args, operationId, signal);
|
|
190
|
-
if (retained) {
|
|
191
|
-
let receipt = retained;
|
|
192
|
-
if (["pending", "blocked"].includes(String(settlement(receipt)?.state))) {
|
|
193
|
-
const recovered = await awaitSettlement(context, row, manifest, session, executable, args, operationId, receipt, assertOwner, signal);
|
|
194
|
-
receipt = { ...receipt, settlement: settlement(recovered) };
|
|
195
|
-
}
|
|
196
|
-
context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(receipt), context.now(), operationId]);
|
|
197
|
-
validateReceipt(receipt, session, args);
|
|
198
|
-
context.db.run("UPDATE run_controller_operations SET status = 'completed', error_json = NULL, updated_at = ? WHERE operation_id = ?", [context.now(), operationId]);
|
|
199
|
-
assertOwner?.();
|
|
200
|
-
appendEvent(context, row.controller_id, "operation_recovered", { operation_id: operationId, operation: args.slice(0, 2), session_id: adapterSessionId(receipt) });
|
|
201
|
-
return receipt;
|
|
202
|
-
}
|
|
203
214
|
try {
|
|
204
215
|
context.db.run("UPDATE run_controller_operations SET status = CASE WHEN status = 'dispatching' THEN 'outcome_unknown' ELSE status END, error_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(errorRecord(error)), context.now(), operationId]);
|
|
205
216
|
}
|
|
@@ -20,6 +20,11 @@ export function controllerReceipt(row) {
|
|
|
20
20
|
}
|
|
21
21
|
export function appendEvent(context, id, type, data) { context.db.run("INSERT INTO run_controller_events (controller_id, event_type, data_json, created_at) VALUES (?, ?, ?, ?)", [id, type, JSON.stringify(data), context.now()]); }
|
|
22
22
|
export function saveState(context, row, state) { context.db.run("UPDATE run_controllers SET state_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [JSON.stringify(state), context.now(), row.controller_id, row.owner_token]); }
|
|
23
|
+
export function retainControllerContinuation(state, fingerprint, error) {
|
|
24
|
+
if (state.last_continuation === fingerprint)
|
|
25
|
+
throw new AppError(error.code, error.message, 1, error.details);
|
|
26
|
+
state.last_continuation = fingerprint;
|
|
27
|
+
}
|
|
23
28
|
export function digest(value) { return digestBytes(JSON.stringify(value)); }
|
|
24
29
|
export function digestBytes(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
|
|
25
30
|
export function errorRecord(error) {
|