@deksden-com/dd-flow-cli 0.1.0 → 0.2.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 +11 -0
- package/dist/build-info.json +11 -0
- package/dist/cli/help.js +63 -1
- package/dist/cli/run-cli.js +104 -0
- package/dist/schemas/archived-flow-manifest.schema.json +112 -0
- package/dist/schemas/merge-stage-report.schema.json +61 -1
- package/dist/schemas/plan-stage-report.schema.json +172 -0
- package/dist/schemas/project-flow-pack-manifest.schema.json +123 -0
- package/dist/schemas/status-report.schema.json +152 -0
- package/dist/services/build-info.js +91 -0
- package/dist/services/canon.js +227 -0
- package/dist/services/lanes.js +6 -2
- package/dist/services/merge-queue.js +59 -3
- package/dist/services/merge-worker.js +148 -0
- package/dist/services/protocols.js +12 -12
- package/dist/services/schema-validation.js +4 -5
- package/dist/services/sessions.js +3 -2
- package/dist/services/status.js +118 -0
- package/dist/services/version-status.js +258 -0
- package/dist/storage/database.js +7 -0
- package/package.json +2 -2
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
const canonRootKey = "canon.root";
|
|
5
|
+
export function registerCanonRoot(context, input) {
|
|
6
|
+
const resolution = resolveCanonRoot(context, { explicitRoot: input.root, allowRegistered: false });
|
|
7
|
+
if (!resolution.ok || !resolution.canon) {
|
|
8
|
+
return {
|
|
9
|
+
...resolution,
|
|
10
|
+
action: "register",
|
|
11
|
+
exit_code: 1
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const now = context.now();
|
|
15
|
+
context.db.run(`INSERT INTO runtime_config (key, value_json, created_at, updated_at)
|
|
16
|
+
VALUES (?, ?, ?, ?)
|
|
17
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
18
|
+
value_json = excluded.value_json,
|
|
19
|
+
updated_at = excluded.updated_at`, [canonRootKey, JSON.stringify({ root: resolution.canon.root }), now, now]);
|
|
20
|
+
return {
|
|
21
|
+
ok: true,
|
|
22
|
+
action: "register",
|
|
23
|
+
canon: {
|
|
24
|
+
...resolution.canon,
|
|
25
|
+
source: "registered"
|
|
26
|
+
},
|
|
27
|
+
db_path: context.db.path
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function getCanonStatus(context, input = {}) {
|
|
31
|
+
const resolution = resolveCanonRoot(context, { explicitRoot: input.root });
|
|
32
|
+
return {
|
|
33
|
+
...resolution,
|
|
34
|
+
dd_flow_home: context.ddFlowHome,
|
|
35
|
+
registered: readRegisteredCanonRoot(context)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function resolveCanonRoot(context, input = {}) {
|
|
39
|
+
const candidates = canonCandidates(context, input);
|
|
40
|
+
const validCandidates = candidates.filter((candidate) => candidate.valid && candidate.realpath);
|
|
41
|
+
const uniqueValid = uniqueBy(validCandidates, (candidate) => candidate.realpath ?? candidate.root);
|
|
42
|
+
const blockers = candidates.flatMap((candidate) => candidate.blockers);
|
|
43
|
+
if (uniqueValid.length === 1) {
|
|
44
|
+
const candidate = uniqueValid[0];
|
|
45
|
+
if (!candidate) {
|
|
46
|
+
throw new Error("Expected one canonical candidate.");
|
|
47
|
+
}
|
|
48
|
+
const root = candidate.realpath ?? candidate.root;
|
|
49
|
+
const metadata = readCanonMetadata(root);
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
canon: {
|
|
53
|
+
root,
|
|
54
|
+
source: candidate.source,
|
|
55
|
+
flow_root: path.join(root, "dd-flow"),
|
|
56
|
+
memorybank_root: root,
|
|
57
|
+
version: metadata.version,
|
|
58
|
+
commit: metadata.commit,
|
|
59
|
+
flow_contract: metadata.flow_contract,
|
|
60
|
+
metadata
|
|
61
|
+
},
|
|
62
|
+
candidates,
|
|
63
|
+
blockers: [],
|
|
64
|
+
bootstrap: []
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (uniqueValid.length > 1) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
exit_code: 1,
|
|
71
|
+
candidates,
|
|
72
|
+
blockers: [
|
|
73
|
+
"Multiple different canonical Memory Bank roots are configured. Pass --root explicitly or make DD_MEMORYBANK and the registered root point to the same checkout."
|
|
74
|
+
],
|
|
75
|
+
bootstrap: [
|
|
76
|
+
"dd-flow canon register --root \"$DD_MEMORYBANK\" --json",
|
|
77
|
+
"dd-flow canon resolve --root /absolute/path/to/dd-memorybank --json"
|
|
78
|
+
]
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
exit_code: 1,
|
|
84
|
+
candidates,
|
|
85
|
+
blockers: blockers.length > 0 ? blockers : ["Canonical Memory Bank root is not configured."],
|
|
86
|
+
bootstrap: [
|
|
87
|
+
"Clone the canonical dd-memorybank repository locally.",
|
|
88
|
+
"export DD_MEMORYBANK=/absolute/path/to/dd-memorybank",
|
|
89
|
+
"dd-flow canon register --root \"$DD_MEMORYBANK\" --json"
|
|
90
|
+
]
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function canonCandidates(context, input) {
|
|
94
|
+
const candidates = [];
|
|
95
|
+
if (input.explicitRoot) {
|
|
96
|
+
candidates.push(validateCanonCandidate("explicit", input.explicitRoot));
|
|
97
|
+
return candidates;
|
|
98
|
+
}
|
|
99
|
+
if (context.env.DD_MEMORYBANK) {
|
|
100
|
+
candidates.push(validateCanonCandidate("env", context.env.DD_MEMORYBANK));
|
|
101
|
+
}
|
|
102
|
+
if (input.allowRegistered !== false) {
|
|
103
|
+
const registered = readRegisteredCanonRoot(context);
|
|
104
|
+
if (registered?.root) {
|
|
105
|
+
candidates.push(validateCanonCandidate("registered", registered.root));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const known of knownCanonRoots()) {
|
|
109
|
+
candidates.push(validateCanonCandidate("known", known));
|
|
110
|
+
}
|
|
111
|
+
return uniqueBy(candidates, (candidate) => `${candidate.source}:${candidate.root}`);
|
|
112
|
+
}
|
|
113
|
+
function validateCanonCandidate(source, root) {
|
|
114
|
+
const absolute = path.resolve(root);
|
|
115
|
+
const blockers = [];
|
|
116
|
+
let realpath = null;
|
|
117
|
+
if (!fs.existsSync(absolute)) {
|
|
118
|
+
return { source, root: absolute, realpath, valid: false, blockers: [`${source} canonical root does not exist: ${absolute}`] };
|
|
119
|
+
}
|
|
120
|
+
realpath = fs.realpathSync(absolute);
|
|
121
|
+
if (!fs.statSync(realpath).isDirectory()) {
|
|
122
|
+
blockers.push(`${source} canonical root is not a directory: ${realpath}`);
|
|
123
|
+
}
|
|
124
|
+
const requiredFiles = [
|
|
125
|
+
"dd-flow/README.md",
|
|
126
|
+
"mbb/index.md",
|
|
127
|
+
"protocol/index.md",
|
|
128
|
+
"dd-flow/mb-init.md",
|
|
129
|
+
"dd-flow/mb-upgrade.md",
|
|
130
|
+
"dd-flow/mb-upgrade-review.md",
|
|
131
|
+
"dd-flow/mb-distill.md"
|
|
132
|
+
];
|
|
133
|
+
for (const relativePath of requiredFiles) {
|
|
134
|
+
const file = path.join(realpath, relativePath);
|
|
135
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
136
|
+
blockers.push(`${source} canonical root is missing ${relativePath}: ${realpath}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { source, root: absolute, realpath, valid: blockers.length === 0, blockers };
|
|
140
|
+
}
|
|
141
|
+
export function readCanonMetadata(root) {
|
|
142
|
+
const diagnostics = [];
|
|
143
|
+
const version = readTrimmed(path.join(root, "VERSION"));
|
|
144
|
+
if (!version)
|
|
145
|
+
diagnostics.push("canon_version_missing");
|
|
146
|
+
const commit = gitCommit(root);
|
|
147
|
+
if (!commit)
|
|
148
|
+
diagnostics.push("canon_commit_unknown");
|
|
149
|
+
const flowContract = readFlowContractId(root);
|
|
150
|
+
if (!flowContract)
|
|
151
|
+
diagnostics.push("flow_contract_unknown");
|
|
152
|
+
return {
|
|
153
|
+
version,
|
|
154
|
+
commit,
|
|
155
|
+
flow_contract: flowContract,
|
|
156
|
+
status: version && commit && flowContract ? "present" : diagnostics.length > 0 ? "degraded" : "unknown",
|
|
157
|
+
diagnostics
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function readTrimmed(file) {
|
|
161
|
+
try {
|
|
162
|
+
const value = fs.readFileSync(file, "utf8").trim();
|
|
163
|
+
return value.length > 0 ? value : null;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function readFlowContractId(root) {
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, "dd-flow", "flow-contract.json"), "utf8"));
|
|
172
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
173
|
+
const id = parsed.id;
|
|
174
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
function gitCommit(root) {
|
|
183
|
+
const result = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" });
|
|
184
|
+
return result.status === 0 ? result.stdout.trim() || null : null;
|
|
185
|
+
}
|
|
186
|
+
function readRegisteredCanonRoot(context) {
|
|
187
|
+
const row = context.db.get("SELECT key, value_json, updated_at FROM runtime_config WHERE key = ?", [canonRootKey]);
|
|
188
|
+
if (!row)
|
|
189
|
+
return null;
|
|
190
|
+
const value = JSON.parse(row.value_json);
|
|
191
|
+
return typeof value.root === "string" ? { root: value.root, updated_at: row.updated_at } : null;
|
|
192
|
+
}
|
|
193
|
+
function knownCanonRoots() {
|
|
194
|
+
const cwd = process.cwd();
|
|
195
|
+
const candidates = [];
|
|
196
|
+
for (const dir of ancestorDirs(cwd)) {
|
|
197
|
+
if (path.basename(dir) === "dd-memorybank") {
|
|
198
|
+
candidates.push(dir);
|
|
199
|
+
}
|
|
200
|
+
candidates.push(path.join(dir, "dd-memorybank"));
|
|
201
|
+
}
|
|
202
|
+
return uniqueBy(candidates.filter((candidate) => fs.existsSync(candidate)), (candidate) => fs.realpathSync(candidate));
|
|
203
|
+
}
|
|
204
|
+
function ancestorDirs(start) {
|
|
205
|
+
const dirs = [];
|
|
206
|
+
let current = path.resolve(start);
|
|
207
|
+
while (true) {
|
|
208
|
+
dirs.push(current);
|
|
209
|
+
const parent = path.dirname(current);
|
|
210
|
+
if (parent === current)
|
|
211
|
+
break;
|
|
212
|
+
current = parent;
|
|
213
|
+
}
|
|
214
|
+
return dirs;
|
|
215
|
+
}
|
|
216
|
+
function uniqueBy(items, keyFor) {
|
|
217
|
+
const seen = new Set();
|
|
218
|
+
const result = [];
|
|
219
|
+
for (const item of items) {
|
|
220
|
+
const key = keyFor(item);
|
|
221
|
+
if (!seen.has(key)) {
|
|
222
|
+
seen.add(key);
|
|
223
|
+
result.push(item);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
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
|
}
|
|
@@ -5,7 +5,7 @@ import { requireProjectByRoot } from "./projects.js";
|
|
|
5
5
|
import { persistProtocolState, 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,31 @@ 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
|
+
persistProtocolState(context, protocol, {
|
|
353
|
+
...state,
|
|
354
|
+
stage: "integration",
|
|
355
|
+
status: flowContract.stages.integration?.terminal ? "integration" : "running",
|
|
356
|
+
next_action: "run_integration",
|
|
357
|
+
updated_at: now
|
|
358
|
+
});
|
|
359
|
+
appendAudit(context, {
|
|
360
|
+
protocolId,
|
|
361
|
+
projectId: protocol.project_id,
|
|
362
|
+
eventType: "protocol.integration_claimed",
|
|
363
|
+
payload: { protocol_id: protocolId, worker_id: workerId, flow_contract_id: flowContract.id }
|
|
364
|
+
});
|
|
365
|
+
}
|
|
320
366
|
function releaseLaneLockIfOwned(context, input) {
|
|
321
367
|
try {
|
|
322
368
|
releaseLaneLock(context, input);
|
|
@@ -328,6 +374,16 @@ function releaseLaneLockIfOwned(context, input) {
|
|
|
328
374
|
throw error;
|
|
329
375
|
}
|
|
330
376
|
}
|
|
377
|
+
function stopAfterCurrentIfRequested(context, projectId, projectRoot, workerId, reason) {
|
|
378
|
+
const requested = context.db.get(`SELECT session_id FROM flow_sessions
|
|
379
|
+
WHERE project_id = ? AND worker_id = ? AND flow_kind = 'merge_worker'
|
|
380
|
+
AND status = 'stopping' AND current_stage = 'stop_after_current'
|
|
381
|
+
ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
|
|
382
|
+
if (!requested) {
|
|
383
|
+
return { requested: false };
|
|
384
|
+
}
|
|
385
|
+
return { requested: true, stop: stopMergeWorker(context, { projectRoot, workerId, reason }) };
|
|
386
|
+
}
|
|
331
387
|
async function delay(milliseconds) {
|
|
332
388
|
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
333
389
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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 { registerFlowSession, stopMergeWorker } from "./sessions.js";
|
|
8
|
+
export function getMergeWorkerStatus(context, input) {
|
|
9
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
10
|
+
return {
|
|
11
|
+
ok: true,
|
|
12
|
+
project: { id: project.id, root: project.root },
|
|
13
|
+
merge_worker: detectMergeWorkerState(context, project.id),
|
|
14
|
+
queue: queueForProject(context, project.id)
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function startMergeWorker(context, input) {
|
|
18
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
19
|
+
const workspacePath = resolveProjectRoot(input.workspacePath ?? project.root);
|
|
20
|
+
const detection = detectMergeWorkerState(context, project.id);
|
|
21
|
+
if (detection.state !== "clear") {
|
|
22
|
+
return { ok: true, started: false, reason: "merge_worker_already_active", merge_worker: detection, queue: queueForProject(context, project.id) };
|
|
23
|
+
}
|
|
24
|
+
ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath, branch: input.branch });
|
|
25
|
+
const registered = registerFlowSession(context, {
|
|
26
|
+
payloadJson: JSON.stringify({
|
|
27
|
+
project_root: project.root,
|
|
28
|
+
flow_kind: "merge_worker",
|
|
29
|
+
protocol_id: null,
|
|
30
|
+
worker_id: input.workerId,
|
|
31
|
+
workspace_path: workspacePath,
|
|
32
|
+
continuation_policy: "merge_queue",
|
|
33
|
+
current_stage: "waiting_for_merge_job",
|
|
34
|
+
next_action: "merge_queue_wait_next"
|
|
35
|
+
})
|
|
36
|
+
});
|
|
37
|
+
appendAudit(context, {
|
|
38
|
+
projectId: project.id,
|
|
39
|
+
eventType: "merge_worker.started",
|
|
40
|
+
payload: { project_id: project.id, worker_id: input.workerId, workspace_path: workspacePath }
|
|
41
|
+
});
|
|
42
|
+
return { ok: true, started: true, session: registered.session, merge_worker: detectMergeWorkerState(context, project.id) };
|
|
43
|
+
}
|
|
44
|
+
export function stopProjectMergeWorker(context, input) {
|
|
45
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
46
|
+
const workers = activeMergeWorkerSessions(context, project.id);
|
|
47
|
+
const targetWorkerId = input.workerId ?? uniqueWorkerId(workers);
|
|
48
|
+
if (!targetWorkerId) {
|
|
49
|
+
return { ok: true, stopped: false, reason: "no_active_merge_worker", merge_worker: detectMergeWorkerState(context, project.id) };
|
|
50
|
+
}
|
|
51
|
+
const targetSessions = workers.filter((session) => session.worker_id === targetWorkerId);
|
|
52
|
+
if (targetSessions.length === 0) {
|
|
53
|
+
throw new AppError("merge_worker_not_found", "Merge worker is not active", 1, { worker_id: targetWorkerId });
|
|
54
|
+
}
|
|
55
|
+
const claimed = claimedJobsForWorker(context, project.id, targetWorkerId);
|
|
56
|
+
if (claimed.length > 0) {
|
|
57
|
+
const now = context.now();
|
|
58
|
+
for (const session of targetSessions) {
|
|
59
|
+
context.db.run(`UPDATE flow_sessions
|
|
60
|
+
SET status = 'stopping', stop_reason = ?, current_stage = ?, next_action = ?, updated_at = ?
|
|
61
|
+
WHERE project_id = ? AND session_id = ?`, [input.reason, "stop_after_current", "finish_current_merge_job_then_stop", now, project.id, session.session_id]);
|
|
62
|
+
}
|
|
63
|
+
appendAudit(context, {
|
|
64
|
+
projectId: project.id,
|
|
65
|
+
eventType: "merge_worker.stop_after_current",
|
|
66
|
+
reason: input.reason,
|
|
67
|
+
payload: { project_id: project.id, worker_id: targetWorkerId, claimed_jobs: claimed.map((job) => job.protocol_id) }
|
|
68
|
+
});
|
|
69
|
+
return { ok: true, stopped: false, stop_after_current: true, worker_id: targetWorkerId, claimed_jobs: claimed, merge_worker: detectMergeWorkerState(context, project.id) };
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
...stopMergeWorker(context, { projectRoot: project.root, workerId: targetWorkerId, reason: input.reason }),
|
|
73
|
+
stopped: true,
|
|
74
|
+
worker_id: targetWorkerId,
|
|
75
|
+
merge_worker: detectMergeWorkerState(context, project.id)
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function oneShotMergeClaim(context, input) {
|
|
79
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
80
|
+
const workspacePath = resolveProjectRoot(input.workspacePath ?? project.root);
|
|
81
|
+
const detection = detectMergeWorkerState(context, project.id);
|
|
82
|
+
if (detection.state !== "clear") {
|
|
83
|
+
return { ok: true, claimed: false, mode: "status_only", reason: "active_merge_worker_or_lock", merge_worker: detection, queue: queueForProject(context, project.id) };
|
|
84
|
+
}
|
|
85
|
+
ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath });
|
|
86
|
+
const lock = acquireLaneLock(context, {
|
|
87
|
+
projectRoot: project.root,
|
|
88
|
+
lane: "merge",
|
|
89
|
+
workerId: input.workerId,
|
|
90
|
+
workspacePath,
|
|
91
|
+
ttlSeconds: 300,
|
|
92
|
+
reason: "one-shot merge"
|
|
93
|
+
});
|
|
94
|
+
const claimed = claimNextMergeJob(context, { projectRoot: project.root, workerId: input.workerId, workspacePath });
|
|
95
|
+
if (!claimed.job) {
|
|
96
|
+
releaseLaneLock(context, {
|
|
97
|
+
projectRoot: project.root,
|
|
98
|
+
lane: "merge",
|
|
99
|
+
workerId: input.workerId,
|
|
100
|
+
workspacePath,
|
|
101
|
+
reason: "one-shot merge no job"
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, claimed: Boolean(claimed.job), mode: "one_shot", job: claimed.job ?? null, lock: claimed.job ? lock.lock : null };
|
|
105
|
+
}
|
|
106
|
+
function detectMergeWorkerState(context, projectId) {
|
|
107
|
+
expireProjectLaneLocks(context, projectId);
|
|
108
|
+
const workers = activeMergeWorkerSessions(context, projectId);
|
|
109
|
+
const workerIds = [...new Set(workers.map((worker) => worker.worker_id).filter((value) => Boolean(value)))];
|
|
110
|
+
const claimed = context.db.all(`SELECT protocol_id, claimed_by_session_id, status, updated_at FROM merge_queue
|
|
111
|
+
WHERE project_id = ? AND status = 'claimed'
|
|
112
|
+
ORDER BY updated_at DESC, id DESC`, [projectId]);
|
|
113
|
+
const lock = context.db.get(`SELECT worker_id, status, expires_at, reason FROM lane_locks
|
|
114
|
+
WHERE project_id = ? AND lane = 'merge' AND status = 'active'
|
|
115
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId]);
|
|
116
|
+
if (workerIds.length > 1) {
|
|
117
|
+
return { state: "blocked", reason: "multiple_active_merge_workers", workers };
|
|
118
|
+
}
|
|
119
|
+
if (workerIds.length === 1) {
|
|
120
|
+
return { state: "active_worker", worker_id: workerIds[0], workers, claimed_jobs: claimed.filter((job) => job.claimed_by_session_id === workerIds[0]), lock: lock ?? null };
|
|
121
|
+
}
|
|
122
|
+
if (claimed.length > 0) {
|
|
123
|
+
return { state: "blocked", reason: "claimed_job_without_active_worker", claimed_jobs: claimed, lock: lock ?? null };
|
|
124
|
+
}
|
|
125
|
+
if (lock) {
|
|
126
|
+
return { state: "blocked", reason: "active_merge_lock_without_worker", lock };
|
|
127
|
+
}
|
|
128
|
+
return { state: "clear" };
|
|
129
|
+
}
|
|
130
|
+
function activeMergeWorkerSessions(context, projectId) {
|
|
131
|
+
return context.db.all(`SELECT session_id, worker_id, status, current_stage, next_action, updated_at FROM flow_sessions
|
|
132
|
+
WHERE project_id = ? AND flow_kind = 'merge_worker' AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')
|
|
133
|
+
ORDER BY updated_at DESC, rowid DESC`, [projectId]);
|
|
134
|
+
}
|
|
135
|
+
function claimedJobsForWorker(context, projectId, workerId) {
|
|
136
|
+
return context.db.all(`SELECT protocol_id, claimed_by_session_id, status, updated_at FROM merge_queue
|
|
137
|
+
WHERE project_id = ? AND status = 'claimed' AND claimed_by_session_id = ?
|
|
138
|
+
ORDER BY updated_at DESC, id DESC`, [projectId, workerId]);
|
|
139
|
+
}
|
|
140
|
+
function uniqueWorkerId(workers) {
|
|
141
|
+
const workerIds = [...new Set(workers.map((worker) => worker.worker_id).filter((value) => Boolean(value)))];
|
|
142
|
+
if (workerIds.length === 0)
|
|
143
|
+
return undefined;
|
|
144
|
+
if (workerIds.length > 1) {
|
|
145
|
+
throw new AppError("merge_worker_ambiguous", "Multiple active merge workers exist; pass --worker-id", 1, { worker_ids: workerIds });
|
|
146
|
+
}
|
|
147
|
+
return workerIds[0];
|
|
148
|
+
}
|
|
@@ -141,6 +141,18 @@ export function readyForMerge(context, input) {
|
|
|
141
141
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
142
142
|
const flowContract = flowContractForState(state);
|
|
143
143
|
const stage = requireStage(state.stage, flowContract);
|
|
144
|
+
const existingQueueJob = context.db.get("SELECT status FROM merge_queue WHERE protocol_id = ?", [
|
|
145
|
+
protocol.id
|
|
146
|
+
]);
|
|
147
|
+
if (existingQueueJob && ["claimed", "merged", "cancelled", "failed"].includes(existingQueueJob.status)) {
|
|
148
|
+
appendAudit(context, {
|
|
149
|
+
protocolId: protocol.id,
|
|
150
|
+
projectId: protocol.project_id,
|
|
151
|
+
eventType: "protocol.ready_for_merge_unchanged",
|
|
152
|
+
payload: { protocol_id: protocol.id, queue_status: existingQueueJob.status, flow_contract_id: flowContract.id }
|
|
153
|
+
});
|
|
154
|
+
return { ok: true, protocol_id: protocol.id, queue_status: existingQueueJob.status, state };
|
|
155
|
+
}
|
|
144
156
|
if (!flowContract.readiness.allowed_from.includes(stage)) {
|
|
145
157
|
throw new AppError("readiness_invalid_stage", "Protocol state does not allow ready-for-merge", 1, {
|
|
146
158
|
stage: state.stage,
|
|
@@ -166,18 +178,6 @@ export function readyForMerge(context, input) {
|
|
|
166
178
|
const nextStage = flowContract.readiness.target_stage;
|
|
167
179
|
const nextState = { ...state, stage: nextStage, status: statusForStage(nextStage, flowContract), updated_at: now };
|
|
168
180
|
persistProtocolState(context, protocol, nextState);
|
|
169
|
-
const existingQueueJob = context.db.get("SELECT status FROM merge_queue WHERE protocol_id = ?", [
|
|
170
|
-
protocol.id
|
|
171
|
-
]);
|
|
172
|
-
if (existingQueueJob && ["claimed", "merged", "cancelled", "failed"].includes(existingQueueJob.status)) {
|
|
173
|
-
appendAudit(context, {
|
|
174
|
-
protocolId: protocol.id,
|
|
175
|
-
projectId: protocol.project_id,
|
|
176
|
-
eventType: "protocol.ready_for_merge_unchanged",
|
|
177
|
-
payload: { protocol_id: protocol.id, queue_status: existingQueueJob.status, flow_contract_id: flowContract.id }
|
|
178
|
-
});
|
|
179
|
-
return { ok: true, protocol_id: protocol.id, queue_status: existingQueueJob.status, state: nextState };
|
|
180
|
-
}
|
|
181
181
|
context.db.run(`INSERT INTO merge_queue
|
|
182
182
|
(protocol_id, project_id, status, claimed_by_session_id, claimed_at, completed_at, created_at, updated_at)
|
|
183
183
|
VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?)
|
|
@@ -50,11 +50,10 @@ function resolveSchema(options) {
|
|
|
50
50
|
if (options.schemaDir) {
|
|
51
51
|
candidates.push({ path: path.join(path.resolve(options.schemaDir), fileName), source: "schema_dir" });
|
|
52
52
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
53
|
+
const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
|
|
54
|
+
candidates.push({ path: path.join(projectRoot, ".memory-bank", "dd-flow", "schemas", fileName), source: "project" });
|
|
55
|
+
candidates.push({ path: path.join(projectRoot, "dd-flow", "schemas", fileName), source: "canonical" });
|
|
56
|
+
candidates.push({ path: path.join(bundledSchemaDir(), fileName), source: "bundled" });
|
|
58
57
|
const found = candidates.find((candidate) => fs.existsSync(candidate.path));
|
|
59
58
|
if (!found) {
|
|
60
59
|
throw new AppError("schema_not_found", `Schema not found: ${options.schemaName}`, 2, {
|
|
@@ -60,8 +60,9 @@ export function stoppedMergeWorkerState(context, projectId, workerId) {
|
|
|
60
60
|
WHERE project_id = ? AND worker_id = ? AND flow_kind = 'merge_worker'
|
|
61
61
|
ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
|
|
62
62
|
return {
|
|
63
|
-
stopped: latest
|
|
64
|
-
reason: latest?.stop_reason ?? null
|
|
63
|
+
stopped: latest ? ["stopped", "stopping"].includes(latest.status) : false,
|
|
64
|
+
reason: latest?.stop_reason ?? null,
|
|
65
|
+
status: latest?.status ?? null
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
export function activeFlowSessionsForProject(context, projectId) {
|