@deksden-com/dd-flow-cli 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/build-info.json +15 -0
- package/dist/cli/help.js +133 -14
- package/dist/cli/run-cli.js +242 -8
- package/dist/schemas/archived-flow-manifest.schema.json +112 -0
- package/dist/schemas/compatibility.schema.json +26 -0
- package/dist/schemas/flow-guidance.schema.json +73 -0
- package/dist/schemas/flow-run-index.schema.json +30 -4
- package/dist/schemas/global-dashboard-data.schema.json +68 -0
- package/dist/schemas/mb-sdlc-review-report.schema.json +242 -0
- package/dist/schemas/merge-stage-report.schema.json +61 -1
- package/dist/schemas/plan-stage-report.schema.json +255 -0
- package/dist/schemas/project-dashboard-data.schema.json +98 -0
- package/dist/schemas/project-flow-pack-manifest.schema.json +127 -0
- package/dist/schemas/protocol-dashboard-data.schema.json +89 -0
- package/dist/schemas/status-report.schema.json +186 -0
- package/dist/schemas/version-report.schema.json +22 -0
- package/dist/services/build-info.js +114 -0
- package/dist/services/canon.js +298 -0
- package/dist/services/cleanup.js +14 -1
- package/dist/services/config.js +25 -0
- package/dist/services/dashboard.js +655 -10
- package/dist/services/flow-guidance.js +214 -0
- package/dist/services/ids.js +106 -0
- package/dist/services/lanes.js +6 -2
- package/dist/services/merge-queue.js +68 -4
- package/dist/services/merge-worker.js +177 -0
- package/dist/services/projects.js +7 -1
- package/dist/services/protocols.js +648 -17
- package/dist/services/runs.js +98 -21
- package/dist/services/schema-validation.js +88 -9
- package/dist/services/sessions.js +3 -2
- package/dist/services/status.js +306 -0
- package/dist/services/version-status.js +284 -0
- package/dist/storage/database.js +13 -0
- package/dist/storage/paths.js +21 -0
- package/package.json +2 -2
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { defaultFlowContract, flowContractForState } from "../domain/flow-contract.js";
|
|
3
|
+
export function buildProtocolFlowGuidance(input) {
|
|
4
|
+
const contract = flowContractForState(input.state);
|
|
5
|
+
const stageChain = latestRunStageChain(input.latestRun);
|
|
6
|
+
return buildFlowGuidance({
|
|
7
|
+
currentStage: input.state.stage,
|
|
8
|
+
contract,
|
|
9
|
+
stageChain,
|
|
10
|
+
runId: stringValue(input.latestRun?.id),
|
|
11
|
+
queueStatus: input.queueStatus ?? null
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export function buildRunFlowGuidance(input) {
|
|
15
|
+
const contract = input.contract ?? defaultFlowContract;
|
|
16
|
+
const chain = input.stageRuns.map((stage) => `${String(stage.stage ?? "unknown")}:${String(stage.status ?? "unknown")}`);
|
|
17
|
+
const currentStage = input.protocolStage ?? inferStageFromRun(input.stageRuns);
|
|
18
|
+
return buildFlowGuidance({
|
|
19
|
+
currentStage,
|
|
20
|
+
contract,
|
|
21
|
+
stageChain: chain,
|
|
22
|
+
runId: input.runId ?? null,
|
|
23
|
+
runDir: input.runDir ?? null,
|
|
24
|
+
stageRuns: input.stageRuns
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function buildStaticFlowGuidance(input) {
|
|
28
|
+
return buildFlowGuidance({
|
|
29
|
+
currentStage: input.stage,
|
|
30
|
+
contract: input.contract ?? defaultFlowContract,
|
|
31
|
+
stageChain: [],
|
|
32
|
+
runId: null,
|
|
33
|
+
queueStatus: input.queueStatus ?? null
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function buildFlowGuidance(input) {
|
|
37
|
+
const planDone = hasDoneStage(input.stageChain, "plan");
|
|
38
|
+
const codeDone = hasDoneStage(input.stageChain, "code") || hasDoneStage(input.stageChain, "implementation") || hasDoneStage(input.stageChain, "readiness");
|
|
39
|
+
const evidence = evidencePaths(input.runId, input.runDir ?? null, input.stageRuns);
|
|
40
|
+
if (input.currentStage === "closed" || input.currentStage === "cancelled") {
|
|
41
|
+
return guidance(input, {
|
|
42
|
+
action: "none",
|
|
43
|
+
prompt: "none",
|
|
44
|
+
evidence: [],
|
|
45
|
+
guards: [{ id: "flow_terminal", status: "not_applicable", summary: `Protocol is ${input.currentStage}.` }],
|
|
46
|
+
missing: []
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (input.currentStage === "ready_for_merge" || input.currentStage === "queued_for_merge") {
|
|
50
|
+
return guidance(input, {
|
|
51
|
+
action: input.currentStage === "queued_for_merge" ? "wait for or run merge worker" : "run merge flow",
|
|
52
|
+
prompt: ".memory-bank/dd-flow/merge.md",
|
|
53
|
+
evidence: evidence.code.length > 0 ? evidence.code : defaultEvidence(input.runId, input.runDir ?? null, "03-code"),
|
|
54
|
+
guards: [
|
|
55
|
+
{
|
|
56
|
+
id: "merge_requires_ready_for_merge",
|
|
57
|
+
status: "pass",
|
|
58
|
+
summary: input.queueStatus ? `Protocol is ${input.currentStage}; queue status is ${input.queueStatus}.` : `Protocol is ${input.currentStage}.`
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
missing: []
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (input.currentStage === "integration") {
|
|
65
|
+
return guidance(input, {
|
|
66
|
+
action: "finish merge job",
|
|
67
|
+
prompt: ".memory-bank/dd-flow/merge/job.md",
|
|
68
|
+
evidence: evidence.code.length > 0 ? evidence.code : defaultEvidence(input.runId, input.runDir ?? null, "03-code"),
|
|
69
|
+
guards: [{ id: "merge_requires_ready_for_merge", status: "pass", summary: "Merge job is in integration stage." }],
|
|
70
|
+
missing: []
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (input.currentStage === "readiness") {
|
|
74
|
+
return guidance(input, {
|
|
75
|
+
action: "complete readiness and mark ready for merge",
|
|
76
|
+
prompt: ".memory-bank/dd-flow/code/readiness.md",
|
|
77
|
+
evidence: evidence.code.length > 0 ? evidence.code : defaultEvidence(input.runId, input.runDir ?? null, "03-code"),
|
|
78
|
+
guards: [{ id: "merge_requires_ready_for_merge", status: codeDone ? "unknown" : "fail", summary: codeDone ? "Code evidence exists; readiness must still be completed." : "Code evidence is missing." }],
|
|
79
|
+
missing: codeDone ? ["ready_for_merge transition"] : ["code stage report", "ready_for_merge transition"]
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (input.currentStage === "implementation" || input.currentStage === "hardening") {
|
|
83
|
+
return guidance(input, {
|
|
84
|
+
action: "continue code flow",
|
|
85
|
+
prompt: input.currentStage === "hardening" ? ".memory-bank/dd-flow/finish.md" : ".memory-bank/dd-flow/code.md",
|
|
86
|
+
evidence: evidence.plan.length > 0 ? evidence.plan : defaultEvidence(input.runId, input.runDir ?? null, "02-plan"),
|
|
87
|
+
guards: [{
|
|
88
|
+
id: "code_flow_requires_plan_ready",
|
|
89
|
+
status: planDone ? "pass" : "unknown",
|
|
90
|
+
summary: planDone ? "Plan stage evidence exists." : "Plan evidence was not found in the linked run."
|
|
91
|
+
}],
|
|
92
|
+
missing: planDone ? [] : ["plan stage report", "ready_for_code verdict"]
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (input.currentStage === "plan") {
|
|
96
|
+
return guidance(input, {
|
|
97
|
+
action: planDone ? "run code flow" : "complete plan flow",
|
|
98
|
+
prompt: planDone ? ".memory-bank/dd-flow/code.md" : ".memory-bank/dd-flow/plan.md",
|
|
99
|
+
evidence: evidence.plan.length > 0 ? evidence.plan : defaultEvidence(input.runId, input.runDir ?? null, "02-plan"),
|
|
100
|
+
guards: [{
|
|
101
|
+
id: "code_flow_requires_plan_ready",
|
|
102
|
+
status: planDone ? "pass" : "fail",
|
|
103
|
+
summary: planDone ? "Plan stage report exists in linked run evidence." : "Plan stage evidence is not complete."
|
|
104
|
+
}],
|
|
105
|
+
missing: planDone ? [] : ["plan stage report", "ready_for_code verdict"]
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (input.currentStage === "specify" || input.currentStage === "priming" || input.currentStage === "prime" || input.currentStage === "registered") {
|
|
109
|
+
const action = input.currentStage === "registered" ? "run protocol/specify flow" : "run plan flow after specification is complete";
|
|
110
|
+
return guidance(input, {
|
|
111
|
+
action,
|
|
112
|
+
prompt: input.currentStage === "registered" ? ".memory-bank/dd-flow/protocol.md" : ".memory-bank/dd-flow/plan.md",
|
|
113
|
+
evidence: [],
|
|
114
|
+
guards: [{
|
|
115
|
+
id: "plan_requires_protocol_and_specification",
|
|
116
|
+
status: input.currentStage === "registered" ? "unknown" : "pass",
|
|
117
|
+
summary: input.currentStage === "registered" ? "Protocol is registered; specification evidence must be checked by the prompt." : "Protocol has progressed past registration."
|
|
118
|
+
}],
|
|
119
|
+
missing: input.currentStage === "registered" ? ["specification evidence"] : []
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (input.currentStage === "blocked") {
|
|
123
|
+
return guidance(input, {
|
|
124
|
+
action: "resolve blocker and continue named safe flow",
|
|
125
|
+
prompt: "depends_on_blocker",
|
|
126
|
+
evidence: [],
|
|
127
|
+
guards: [{ id: "flow_blocked", status: "fail", summary: "Protocol is blocked." }],
|
|
128
|
+
missing: ["blocker resolution"]
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (input.currentStage === "waiting_for_user") {
|
|
132
|
+
return guidance(input, {
|
|
133
|
+
action: "collect user answer",
|
|
134
|
+
prompt: "active_protocol_context",
|
|
135
|
+
evidence: [],
|
|
136
|
+
guards: [{ id: "flow_waiting_for_user", status: "unknown", summary: "Protocol is waiting for user input." }],
|
|
137
|
+
missing: ["user answer"]
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return guidance(input, {
|
|
141
|
+
action: `continue ${input.currentStage}`,
|
|
142
|
+
prompt: "active_flow_prompt",
|
|
143
|
+
evidence: [],
|
|
144
|
+
guards: [{ id: "flow_guidance_unknown_stage_policy", status: "unknown", summary: `No specific guidance rule for stage ${input.currentStage}.` }],
|
|
145
|
+
missing: []
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function guidance(input, value) {
|
|
149
|
+
return {
|
|
150
|
+
current_stage: input.currentStage,
|
|
151
|
+
allowed_next_stages: input.contract.transitions[input.currentStage] ?? [],
|
|
152
|
+
recommended_next_action: value.action,
|
|
153
|
+
recommended_prompt: value.prompt,
|
|
154
|
+
required_predecessor_evidence: value.evidence,
|
|
155
|
+
guards: value.guards,
|
|
156
|
+
blocked_if_missing: value.missing
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function latestRunStageChain(run) {
|
|
160
|
+
return Array.isArray(run?.stage_chain) ? run.stage_chain.filter((item) => typeof item === "string") : [];
|
|
161
|
+
}
|
|
162
|
+
function hasDoneStage(chain, stage) {
|
|
163
|
+
return chain.includes(`${stage}:done`);
|
|
164
|
+
}
|
|
165
|
+
function inferStageFromRun(stageRuns) {
|
|
166
|
+
const done = new Set(stageRuns.filter((stage) => stage.status === "done").map((stage) => stage.stage));
|
|
167
|
+
if (done.has("merge"))
|
|
168
|
+
return "closed";
|
|
169
|
+
if (done.has("code") || done.has("implementation") || done.has("readiness"))
|
|
170
|
+
return "ready_for_merge";
|
|
171
|
+
if (done.has("plan"))
|
|
172
|
+
return "plan";
|
|
173
|
+
return "registered";
|
|
174
|
+
}
|
|
175
|
+
function evidencePaths(runId, runDir, stageRuns) {
|
|
176
|
+
const result = { plan: [], code: [] };
|
|
177
|
+
if (!stageRuns)
|
|
178
|
+
return result;
|
|
179
|
+
for (const stage of stageRuns) {
|
|
180
|
+
const bucket = stage.stage === "plan" ? result.plan : ["code", "implementation", "readiness"].includes(String(stage.stage)) ? result.code : null;
|
|
181
|
+
if (!bucket)
|
|
182
|
+
continue;
|
|
183
|
+
const dir = stage.dir ? path.posix.join(runDir ?? legacyRunDir(runId), stage.dir) : stage.dir;
|
|
184
|
+
for (const field of [stage.data, stage.stage_report, stage.report]) {
|
|
185
|
+
if (field)
|
|
186
|
+
bucket.push(toEvidencePath(runId, runDir, stage.dir, field, dir));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
function toEvidencePath(runId, runDir, stageDir, field, prefixedStageDir) {
|
|
192
|
+
if (!runId)
|
|
193
|
+
return field;
|
|
194
|
+
if (field.startsWith(".tasks/dd-flow-runs/"))
|
|
195
|
+
return field;
|
|
196
|
+
if (field.startsWith("runs/"))
|
|
197
|
+
return field;
|
|
198
|
+
if (stageDir && field.startsWith(`${stageDir}/`)) {
|
|
199
|
+
return path.posix.join(runDir ?? legacyRunDir(runId), field);
|
|
200
|
+
}
|
|
201
|
+
return prefixedStageDir ? path.posix.join(prefixedStageDir, field) : field;
|
|
202
|
+
}
|
|
203
|
+
function defaultEvidence(runId, runDir, dir) {
|
|
204
|
+
if (!runId)
|
|
205
|
+
return [`<RUN>/${dir}/stage-report.json`, `<RUN>/${dir}/stage-report.html`];
|
|
206
|
+
const base = path.posix.join(runDir ?? legacyRunDir(runId), dir);
|
|
207
|
+
return [path.posix.join(base, "stage-report.json"), path.posix.join(base, "stage-report.html")];
|
|
208
|
+
}
|
|
209
|
+
function legacyRunDir(runId) {
|
|
210
|
+
return runId ? path.posix.join(".tasks/dd-flow-runs", runId) : "<RUN>";
|
|
211
|
+
}
|
|
212
|
+
function stringValue(value) {
|
|
213
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
214
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { formatFullId, parseFullEntityId } from "../domain/entity-ids.js";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { projectRunHome, resolveProjectRoot } from "../storage/paths.js";
|
|
6
|
+
const kindConfig = {
|
|
7
|
+
protocol: { type: "PRT", table: "protocols", fileRoot: ".memory-bank/protocol" },
|
|
8
|
+
run: { type: "RUN", table: "flow_runs", fileRoot: ".tasks/dd-flow-runs" }
|
|
9
|
+
};
|
|
10
|
+
export function previewNextEntityId(context, input) {
|
|
11
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
12
|
+
const kind = parseEntityKind(input.type);
|
|
13
|
+
const slug = normalizeSlug(input.slug);
|
|
14
|
+
const config = kindConfig[kind];
|
|
15
|
+
const used = new Set();
|
|
16
|
+
for (const id of databaseIds(context, config.table, config.type)) {
|
|
17
|
+
addSequence(used, id, config.type);
|
|
18
|
+
}
|
|
19
|
+
for (const id of filesystemIds(projectRoot, config.fileRoot, config.type)) {
|
|
20
|
+
addSequence(used, id, config.type);
|
|
21
|
+
}
|
|
22
|
+
if (kind === "run") {
|
|
23
|
+
for (const id of homeRunIds(context, projectRoot)) {
|
|
24
|
+
addSequence(used, id, config.type);
|
|
25
|
+
}
|
|
26
|
+
for (const id of filesystemIds(projectRoot, ".tasks", config.type)) {
|
|
27
|
+
addSequence(used, id, config.type);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (let sequence = 1; sequence <= 999; sequence += 1) {
|
|
31
|
+
if (used.has(sequence))
|
|
32
|
+
continue;
|
|
33
|
+
const id = formatFullId(config.type, sequence, slug);
|
|
34
|
+
const parsed = parseFullEntityId(id);
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
project_root: projectRoot,
|
|
38
|
+
entity: {
|
|
39
|
+
kind,
|
|
40
|
+
type: config.type,
|
|
41
|
+
id,
|
|
42
|
+
short_id: parsed.shortId,
|
|
43
|
+
slug,
|
|
44
|
+
sequence,
|
|
45
|
+
reserved: false
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
throw new AppError("validation", `${config.type} id sequence exhausted`, 2);
|
|
50
|
+
}
|
|
51
|
+
function homeRunIds(context, projectRoot) {
|
|
52
|
+
const project = context.db.get("SELECT id FROM projects WHERE root = ? AND status = 'active'", [projectRoot]);
|
|
53
|
+
if (!project) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const root = projectRunHome(context.ddFlowHome, project.id, "__placeholder__").replace(/__placeholder__$/, "");
|
|
57
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
return fs.readdirSync(root).filter((entry) => entry.startsWith("RUN-"));
|
|
61
|
+
}
|
|
62
|
+
function parseEntityKind(value) {
|
|
63
|
+
const normalized = value.toLowerCase();
|
|
64
|
+
if (normalized === "protocol" || normalized === "prt")
|
|
65
|
+
return "protocol";
|
|
66
|
+
if (normalized === "run")
|
|
67
|
+
return "run";
|
|
68
|
+
throw new AppError("validation", "--type must be protocol or run", 2, { type: value });
|
|
69
|
+
}
|
|
70
|
+
function databaseIds(context, table, type) {
|
|
71
|
+
return context.db.all(`SELECT id FROM ${table} WHERE id LIKE ?`, [`${type}-%`]).map((row) => row.id);
|
|
72
|
+
}
|
|
73
|
+
function filesystemIds(projectRoot, relativeRoot, type) {
|
|
74
|
+
const root = path.join(projectRoot, relativeRoot);
|
|
75
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
return fs
|
|
79
|
+
.readdirSync(root)
|
|
80
|
+
.filter((entry) => entry.startsWith(`${type}-`))
|
|
81
|
+
.map((entry) => entry.replace(/\.md$/, ""));
|
|
82
|
+
}
|
|
83
|
+
function addSequence(used, id, type) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = parseFullEntityId(id);
|
|
86
|
+
if (parsed.type !== type)
|
|
87
|
+
return;
|
|
88
|
+
used.add(Number(parsed.shortId.slice(type.length + 1)));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function normalizeSlug(value) {
|
|
95
|
+
const slug = value
|
|
96
|
+
.normalize("NFKD")
|
|
97
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
100
|
+
.replace(/^-+|-+$/g, "")
|
|
101
|
+
.replace(/-{2,}/g, "-");
|
|
102
|
+
if (!slug) {
|
|
103
|
+
throw new AppError("validation", "--slug must contain at least one ASCII letter or digit", 2);
|
|
104
|
+
}
|
|
105
|
+
return slug;
|
|
106
|
+
}
|
package/dist/services/lanes.js
CHANGED
|
@@ -17,6 +17,9 @@ export function getLaneStatus(context, input) {
|
|
|
17
17
|
locks: laneLocksForProject(context, project.id, input.lane)
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
+
export function expireProjectLaneLocks(context, projectId) {
|
|
21
|
+
expireStaleLocks(context, projectId);
|
|
22
|
+
}
|
|
20
23
|
export function setLaneWorkspace(context, input) {
|
|
21
24
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
22
25
|
const lane = normalizeLane(input.lane);
|
|
@@ -267,10 +270,11 @@ function assertWorkerMayAcquireLaneLock(context, projectId, lane, workerId) {
|
|
|
267
270
|
const latest = context.db.get(`SELECT status, stop_reason, flow_kind FROM flow_sessions
|
|
268
271
|
WHERE project_id = ? AND worker_id = ? AND flow_kind IN ('merge_worker', 'merge_job')
|
|
269
272
|
ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
|
|
270
|
-
if (latest
|
|
271
|
-
throw new AppError("merge_worker_stopped", "Stopped merge worker cannot acquire the merge lane lock until it registers a new active session", 1, {
|
|
273
|
+
if (latest && ["stopped", "stopping"].includes(latest.status)) {
|
|
274
|
+
throw new AppError("merge_worker_stopped", "Stopped or stopping merge worker cannot acquire the merge lane lock until it registers a new active session", 1, {
|
|
272
275
|
worker_id: workerId,
|
|
273
276
|
flow_kind: latest.flow_kind,
|
|
277
|
+
status: latest.status,
|
|
274
278
|
stop_reason: latest.stop_reason
|
|
275
279
|
});
|
|
276
280
|
}
|
|
@@ -2,10 +2,10 @@ import { loadProjectFlowContract } from "../domain/flow-contract.js";
|
|
|
2
2
|
import { AppError } from "../shared/errors.js";
|
|
3
3
|
import { appendAudit } from "./audit.js";
|
|
4
4
|
import { requireProjectByRoot } from "./projects.js";
|
|
5
|
-
import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
5
|
+
import { persistProtocolState, protocolRunDiagnostics, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
7
|
import { acquireLaneLock, ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, requireLaneLockOwner } from "./lanes.js";
|
|
8
|
-
import { stoppedMergeWorkerState } from "./sessions.js";
|
|
8
|
+
import { stopMergeWorker, stoppedMergeWorkerState } from "./sessions.js";
|
|
9
9
|
const mergeLockTtlSeconds = 300;
|
|
10
10
|
export function getMergeQueueStatus(context, input) {
|
|
11
11
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -16,6 +16,14 @@ export function getMergeQueueStatus(context, input) {
|
|
|
16
16
|
}
|
|
17
17
|
export function claimNextMergeJob(context, input) {
|
|
18
18
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
19
|
+
const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
|
|
20
|
+
if (stopState.stopped) {
|
|
21
|
+
throw new AppError("merge_worker_stopped", "Stopped or stopping merge worker cannot claim another merge job", 1, {
|
|
22
|
+
worker_id: input.workerId,
|
|
23
|
+
status: stopState.status,
|
|
24
|
+
reason: stopState.reason
|
|
25
|
+
});
|
|
26
|
+
}
|
|
19
27
|
requireLaneLockOwner(context, {
|
|
20
28
|
projectRoot: project.root,
|
|
21
29
|
lane: "merge",
|
|
@@ -47,6 +55,7 @@ export function claimNextMergeJob(context, input) {
|
|
|
47
55
|
eventType: "merge_queue.claimed",
|
|
48
56
|
payload: { protocol_id: job.protocol_id, worker_id: input.workerId }
|
|
49
57
|
});
|
|
58
|
+
transitionClaimedProtocolToIntegration(context, job.protocol_id, input.workerId, now);
|
|
50
59
|
claimedProtocolId = job.protocol_id;
|
|
51
60
|
context.db.exec("COMMIT");
|
|
52
61
|
}
|
|
@@ -184,7 +193,8 @@ export function completeMergeJob(context, input) {
|
|
|
184
193
|
eventType: "merge_queue.completed",
|
|
185
194
|
payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary, flow_contract_id: flowContract.id }
|
|
186
195
|
});
|
|
187
|
-
|
|
196
|
+
const stop_after_current = stopAfterCurrentIfRequested(context, protocol.project_id, protocol.project_root, input.workerId, "merge complete after stop request");
|
|
197
|
+
return { ok: true, job: queueJobByProtocol(context, protocol.id), next_stage: targetStage, stop_after_current };
|
|
188
198
|
}
|
|
189
199
|
export function noteMergeJob(context, input) {
|
|
190
200
|
const protocol = requireProtocol(context, input.protocolId);
|
|
@@ -230,6 +240,16 @@ export function failMergeJob(context, input) {
|
|
|
230
240
|
context.db.run(`UPDATE merge_queue
|
|
231
241
|
SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
|
|
232
242
|
WHERE id = ?`, [status, input.reason, now, job.id]);
|
|
243
|
+
if (input.requeue) {
|
|
244
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
245
|
+
persistProtocolState(context, protocol, {
|
|
246
|
+
...state,
|
|
247
|
+
stage: "ready_for_merge",
|
|
248
|
+
status: "running",
|
|
249
|
+
next_action: "merge_requeued",
|
|
250
|
+
updated_at: now
|
|
251
|
+
});
|
|
252
|
+
}
|
|
233
253
|
appendAudit(context, {
|
|
234
254
|
protocolId: protocol.id,
|
|
235
255
|
projectId: protocol.project_id,
|
|
@@ -237,7 +257,8 @@ export function failMergeJob(context, input) {
|
|
|
237
257
|
reason: input.reason,
|
|
238
258
|
payload: { protocol_id: protocol.id, worker_id: input.workerId, requeue: input.requeue }
|
|
239
259
|
});
|
|
240
|
-
|
|
260
|
+
const stop_after_current = stopAfterCurrentIfRequested(context, protocol.project_id, protocol.project_root, input.workerId, "merge fail after stop request");
|
|
261
|
+
return { ok: true, job: queueJobByProtocol(context, protocol.id), stop_after_current };
|
|
241
262
|
}
|
|
242
263
|
export function cancelMergeQueueJob(context, input) {
|
|
243
264
|
const protocol = requireProtocol(context, input.protocolId);
|
|
@@ -317,6 +338,39 @@ function requireClaimedJob(context, protocolId, sessionId) {
|
|
|
317
338
|
}
|
|
318
339
|
return job;
|
|
319
340
|
}
|
|
341
|
+
function transitionClaimedProtocolToIntegration(context, protocolId, workerId, now) {
|
|
342
|
+
const protocol = requireProtocol(context, protocolId);
|
|
343
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
344
|
+
const flowContract = loadProjectFlowContract(protocol.project_root);
|
|
345
|
+
if (!["ready_for_merge", "queued_for_merge"].includes(state.stage)) {
|
|
346
|
+
throw new AppError("merge_protocol_not_ready", "Cannot claim a protocol whose runtime state is not ready for merge", 1, {
|
|
347
|
+
protocol_id: protocolId,
|
|
348
|
+
stage: state.stage,
|
|
349
|
+
allowed: ["ready_for_merge", "queued_for_merge"]
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const diagnostics = protocolRunDiagnostics(context, protocol, state).diagnostics;
|
|
353
|
+
const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
354
|
+
if (blockingDiagnostics.length > 0) {
|
|
355
|
+
throw new AppError("merge_protocol_run_mismatch", "Cannot claim a protocol while protocol and run evidence disagree", 1, {
|
|
356
|
+
protocol_id: protocolId,
|
|
357
|
+
diagnostics: blockingDiagnostics
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
persistProtocolState(context, protocol, {
|
|
361
|
+
...state,
|
|
362
|
+
stage: "integration",
|
|
363
|
+
status: flowContract.stages.integration?.terminal ? "integration" : "running",
|
|
364
|
+
next_action: "run_integration",
|
|
365
|
+
updated_at: now
|
|
366
|
+
});
|
|
367
|
+
appendAudit(context, {
|
|
368
|
+
protocolId,
|
|
369
|
+
projectId: protocol.project_id,
|
|
370
|
+
eventType: "protocol.integration_claimed",
|
|
371
|
+
payload: { protocol_id: protocolId, worker_id: workerId, flow_contract_id: flowContract.id }
|
|
372
|
+
});
|
|
373
|
+
}
|
|
320
374
|
function releaseLaneLockIfOwned(context, input) {
|
|
321
375
|
try {
|
|
322
376
|
releaseLaneLock(context, input);
|
|
@@ -328,6 +382,16 @@ function releaseLaneLockIfOwned(context, input) {
|
|
|
328
382
|
throw error;
|
|
329
383
|
}
|
|
330
384
|
}
|
|
385
|
+
function stopAfterCurrentIfRequested(context, projectId, projectRoot, workerId, reason) {
|
|
386
|
+
const requested = context.db.get(`SELECT session_id FROM flow_sessions
|
|
387
|
+
WHERE project_id = ? AND worker_id = ? AND flow_kind = 'merge_worker'
|
|
388
|
+
AND status = 'stopping' AND current_stage = 'stop_after_current'
|
|
389
|
+
ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
|
|
390
|
+
if (!requested) {
|
|
391
|
+
return { requested: false };
|
|
392
|
+
}
|
|
393
|
+
return { requested: true, stop: stopMergeWorker(context, { projectRoot, workerId, reason }) };
|
|
394
|
+
}
|
|
331
395
|
async function delay(milliseconds) {
|
|
332
396
|
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
333
397
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
3
|
+
import { appendAudit } from "./audit.js";
|
|
4
|
+
import { ensureLaneWorkspace, acquireLaneLock, releaseLaneLock, expireProjectLaneLocks } from "./lanes.js";
|
|
5
|
+
import { claimNextMergeJob, queueForProject } from "./merge-queue.js";
|
|
6
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
7
|
+
import { buildStaticFlowGuidance } from "./flow-guidance.js";
|
|
8
|
+
import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
9
|
+
import { registerFlowSession, stopMergeWorker } from "./sessions.js";
|
|
10
|
+
export function getMergeWorkerStatus(context, input) {
|
|
11
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
12
|
+
return {
|
|
13
|
+
ok: true,
|
|
14
|
+
project: { id: project.id, root: project.root },
|
|
15
|
+
merge_worker: detectMergeWorkerState(context, project.id),
|
|
16
|
+
queue: queueForProject(context, project.id).map((job) => withJobGuidance(context, { ...job }))
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function startMergeWorker(context, input) {
|
|
20
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
21
|
+
const workspacePath = resolveProjectRoot(input.workspacePath ?? project.root);
|
|
22
|
+
const detection = detectMergeWorkerState(context, project.id);
|
|
23
|
+
if (detection.state !== "clear") {
|
|
24
|
+
return { ok: true, started: false, reason: "merge_worker_already_active", merge_worker: detection, queue: queueForProject(context, project.id) };
|
|
25
|
+
}
|
|
26
|
+
ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath, branch: input.branch });
|
|
27
|
+
const registered = registerFlowSession(context, {
|
|
28
|
+
payloadJson: JSON.stringify({
|
|
29
|
+
project_root: project.root,
|
|
30
|
+
flow_kind: "merge_worker",
|
|
31
|
+
protocol_id: null,
|
|
32
|
+
worker_id: input.workerId,
|
|
33
|
+
workspace_path: workspacePath,
|
|
34
|
+
continuation_policy: "merge_queue",
|
|
35
|
+
current_stage: "waiting_for_merge_job",
|
|
36
|
+
next_action: "merge_queue_wait_next"
|
|
37
|
+
})
|
|
38
|
+
});
|
|
39
|
+
appendAudit(context, {
|
|
40
|
+
projectId: project.id,
|
|
41
|
+
eventType: "merge_worker.started",
|
|
42
|
+
payload: { project_id: project.id, worker_id: input.workerId, workspace_path: workspacePath }
|
|
43
|
+
});
|
|
44
|
+
return { ok: true, started: true, session: registered.session, merge_worker: detectMergeWorkerState(context, project.id) };
|
|
45
|
+
}
|
|
46
|
+
export function stopProjectMergeWorker(context, input) {
|
|
47
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
48
|
+
const workers = activeMergeWorkerSessions(context, project.id);
|
|
49
|
+
const targetWorkerId = input.workerId ?? uniqueWorkerId(workers);
|
|
50
|
+
if (!targetWorkerId) {
|
|
51
|
+
return { ok: true, stopped: false, reason: "no_active_merge_worker", merge_worker: detectMergeWorkerState(context, project.id) };
|
|
52
|
+
}
|
|
53
|
+
const targetSessions = workers.filter((session) => session.worker_id === targetWorkerId);
|
|
54
|
+
if (targetSessions.length === 0) {
|
|
55
|
+
throw new AppError("merge_worker_not_found", "Merge worker is not active", 1, { worker_id: targetWorkerId });
|
|
56
|
+
}
|
|
57
|
+
const claimed = claimedJobsForWorker(context, project.id, targetWorkerId);
|
|
58
|
+
if (claimed.length > 0) {
|
|
59
|
+
const now = context.now();
|
|
60
|
+
for (const session of targetSessions) {
|
|
61
|
+
context.db.run(`UPDATE flow_sessions
|
|
62
|
+
SET status = 'stopping', stop_reason = ?, current_stage = ?, next_action = ?, updated_at = ?
|
|
63
|
+
WHERE project_id = ? AND session_id = ?`, [input.reason, "stop_after_current", "finish_current_merge_job_then_stop", now, project.id, session.session_id]);
|
|
64
|
+
}
|
|
65
|
+
appendAudit(context, {
|
|
66
|
+
projectId: project.id,
|
|
67
|
+
eventType: "merge_worker.stop_after_current",
|
|
68
|
+
reason: input.reason,
|
|
69
|
+
payload: { project_id: project.id, worker_id: targetWorkerId, claimed_jobs: claimed.map((job) => job.protocol_id) }
|
|
70
|
+
});
|
|
71
|
+
return { ok: true, stopped: false, stop_after_current: true, worker_id: targetWorkerId, claimed_jobs: claimed, merge_worker: detectMergeWorkerState(context, project.id) };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
...stopMergeWorker(context, { projectRoot: project.root, workerId: targetWorkerId, reason: input.reason }),
|
|
75
|
+
stopped: true,
|
|
76
|
+
worker_id: targetWorkerId,
|
|
77
|
+
merge_worker: detectMergeWorkerState(context, project.id)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function oneShotMergeClaim(context, input) {
|
|
81
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
82
|
+
const workspacePath = resolveProjectRoot(input.workspacePath ?? project.root);
|
|
83
|
+
const detection = detectMergeWorkerState(context, project.id);
|
|
84
|
+
if (detection.state !== "clear") {
|
|
85
|
+
return { ok: true, claimed: false, mode: "status_only", reason: "active_merge_worker_or_lock", merge_worker: detection, queue: queueForProject(context, project.id) };
|
|
86
|
+
}
|
|
87
|
+
ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath });
|
|
88
|
+
const lock = acquireLaneLock(context, {
|
|
89
|
+
projectRoot: project.root,
|
|
90
|
+
lane: "merge",
|
|
91
|
+
workerId: input.workerId,
|
|
92
|
+
workspacePath,
|
|
93
|
+
ttlSeconds: 300,
|
|
94
|
+
reason: "one-shot merge"
|
|
95
|
+
});
|
|
96
|
+
const claimed = claimNextMergeJob(context, { projectRoot: project.root, workerId: input.workerId, workspacePath });
|
|
97
|
+
if (!claimed.job) {
|
|
98
|
+
releaseLaneLock(context, {
|
|
99
|
+
projectRoot: project.root,
|
|
100
|
+
lane: "merge",
|
|
101
|
+
workerId: input.workerId,
|
|
102
|
+
workspacePath,
|
|
103
|
+
reason: "one-shot merge no job"
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
claimed: Boolean(claimed.job),
|
|
109
|
+
mode: "one_shot",
|
|
110
|
+
job: claimed.job ? withJobGuidance(context, { ...claimed.job }) : null,
|
|
111
|
+
lock: claimed.job ? lock.lock : null,
|
|
112
|
+
flow_guidance: claimed.job ? withJobGuidance(context, { ...claimed.job }).flow_guidance : undefined
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function withJobGuidance(context, job) {
|
|
116
|
+
const protocolId = typeof job.protocol_id === "string" ? job.protocol_id : "";
|
|
117
|
+
if (!protocolId)
|
|
118
|
+
return job;
|
|
119
|
+
try {
|
|
120
|
+
const protocol = requireProtocol(context, protocolId);
|
|
121
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
122
|
+
return {
|
|
123
|
+
...job,
|
|
124
|
+
flow_guidance: buildStaticFlowGuidance({
|
|
125
|
+
stage: state.stage,
|
|
126
|
+
...(state.flow_contract ? { contract: state.flow_contract } : {}),
|
|
127
|
+
queueStatus: String(job.status ?? "")
|
|
128
|
+
})
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return job;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function detectMergeWorkerState(context, projectId) {
|
|
136
|
+
expireProjectLaneLocks(context, projectId);
|
|
137
|
+
const workers = activeMergeWorkerSessions(context, projectId);
|
|
138
|
+
const workerIds = [...new Set(workers.map((worker) => worker.worker_id).filter((value) => Boolean(value)))];
|
|
139
|
+
const claimed = context.db.all(`SELECT protocol_id, claimed_by_session_id, status, updated_at FROM merge_queue
|
|
140
|
+
WHERE project_id = ? AND status = 'claimed'
|
|
141
|
+
ORDER BY updated_at DESC, id DESC`, [projectId]);
|
|
142
|
+
const lock = context.db.get(`SELECT worker_id, status, expires_at, reason FROM lane_locks
|
|
143
|
+
WHERE project_id = ? AND lane = 'merge' AND status = 'active'
|
|
144
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId]);
|
|
145
|
+
if (workerIds.length > 1) {
|
|
146
|
+
return { state: "blocked", reason: "multiple_active_merge_workers", workers };
|
|
147
|
+
}
|
|
148
|
+
if (workerIds.length === 1) {
|
|
149
|
+
return { state: "active_worker", worker_id: workerIds[0], workers, claimed_jobs: claimed.filter((job) => job.claimed_by_session_id === workerIds[0]), lock: lock ?? null };
|
|
150
|
+
}
|
|
151
|
+
if (claimed.length > 0) {
|
|
152
|
+
return { state: "blocked", reason: "claimed_job_without_active_worker", claimed_jobs: claimed, lock: lock ?? null };
|
|
153
|
+
}
|
|
154
|
+
if (lock) {
|
|
155
|
+
return { state: "blocked", reason: "active_merge_lock_without_worker", lock };
|
|
156
|
+
}
|
|
157
|
+
return { state: "clear" };
|
|
158
|
+
}
|
|
159
|
+
function activeMergeWorkerSessions(context, projectId) {
|
|
160
|
+
return context.db.all(`SELECT session_id, worker_id, status, current_stage, next_action, updated_at FROM flow_sessions
|
|
161
|
+
WHERE project_id = ? AND flow_kind = 'merge_worker' AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')
|
|
162
|
+
ORDER BY updated_at DESC, rowid DESC`, [projectId]);
|
|
163
|
+
}
|
|
164
|
+
function claimedJobsForWorker(context, projectId, workerId) {
|
|
165
|
+
return context.db.all(`SELECT protocol_id, claimed_by_session_id, status, updated_at FROM merge_queue
|
|
166
|
+
WHERE project_id = ? AND status = 'claimed' AND claimed_by_session_id = ?
|
|
167
|
+
ORDER BY updated_at DESC, id DESC`, [projectId, workerId]);
|
|
168
|
+
}
|
|
169
|
+
function uniqueWorkerId(workers) {
|
|
170
|
+
const workerIds = [...new Set(workers.map((worker) => worker.worker_id).filter((value) => Boolean(value)))];
|
|
171
|
+
if (workerIds.length === 0)
|
|
172
|
+
return undefined;
|
|
173
|
+
if (workerIds.length > 1) {
|
|
174
|
+
throw new AppError("merge_worker_ambiguous", "Multiple active merge workers exist; pass --worker-id", 1, { worker_ids: workerIds });
|
|
175
|
+
}
|
|
176
|
+
return workerIds[0];
|
|
177
|
+
}
|