@deksden-com/dd-flow-cli 0.1.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 +274 -0
- package/dist/cli/help.js +308 -0
- package/dist/cli/run-cli.js +945 -0
- package/dist/cli.js +4 -0
- package/dist/domain/contracts.js +57 -0
- package/dist/domain/entity-ids.js +47 -0
- package/dist/domain/flow-contract.js +233 -0
- package/dist/domain/validation.js +91 -0
- package/dist/protocol/local-files.js +141 -0
- package/dist/runtime/context.js +11 -0
- package/dist/schemas/code-stage-report.schema.json +181 -0
- package/dist/schemas/flow-run-index.schema.json +129 -0
- package/dist/schemas/mb-upgrade-review-data.schema.json +813 -0
- package/dist/schemas/memorybank-permissions-preflight.schema.json +154 -0
- package/dist/schemas/merge-stage-report.schema.json +135 -0
- package/dist/services/audit.js +19 -0
- package/dist/services/cleanup.js +310 -0
- package/dist/services/config.js +143 -0
- package/dist/services/dashboard.js +436 -0
- package/dist/services/hooks.js +929 -0
- package/dist/services/lanes.js +327 -0
- package/dist/services/memory-permissions.js +344 -0
- package/dist/services/merge-queue.js +333 -0
- package/dist/services/plans.js +149 -0
- package/dist/services/projects.js +286 -0
- package/dist/services/protocols.js +606 -0
- package/dist/services/runs.js +359 -0
- package/dist/services/schema-validation.js +185 -0
- package/dist/services/sessions.js +365 -0
- package/dist/services/worktrees.js +204 -0
- package/dist/shared/errors.js +14 -0
- package/dist/shared/json.js +17 -0
- package/dist/storage/database.js +325 -0
- package/dist/storage/paths.js +56 -0
- package/package.json +44 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { canTransition } from "../domain/contracts.js";
|
|
5
|
+
import { defaultFlowContract, flowContractForState, loadProjectFlowContract } from "../domain/flow-contract.js";
|
|
6
|
+
import { requireStage } from "../domain/validation.js";
|
|
7
|
+
import { AppError } from "../shared/errors.js";
|
|
8
|
+
import { parseJsonObject } from "../shared/json.js";
|
|
9
|
+
import { ensureReadableFile } from "../storage/database.js";
|
|
10
|
+
import { ensureDir, resolveProjectRoot, runtimePlanJsonPath, runtimeProtocolDir, runtimeStateJsonPath } from "../storage/paths.js";
|
|
11
|
+
import { appendAudit, getAuditEvents } from "./audit.js";
|
|
12
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
13
|
+
import { ensureRuntimeProtocolFiles, readPlanFile, readStateFile, writeState } from "../protocol/local-files.js";
|
|
14
|
+
import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProject, codexHomeProfilesForProject, codexHookEventsForProject, hookStatusForProject } from "./hooks.js";
|
|
15
|
+
export function registerProtocol(context, input) {
|
|
16
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
17
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
18
|
+
const now = context.now();
|
|
19
|
+
const workspacePath = input.workspacePath ? resolveProjectRoot(input.workspacePath) : undefined;
|
|
20
|
+
const idRoot = workspacePath ?? projectRoot;
|
|
21
|
+
const protocolId = inferProtocolId(idRoot, input.handshakeId);
|
|
22
|
+
const runtimeDir = runtimeProtocolDir(context.ddFlowHome, project.id, protocolId);
|
|
23
|
+
const state = ensureRuntimeProtocolFiles({
|
|
24
|
+
runtimeDir,
|
|
25
|
+
protocolId,
|
|
26
|
+
projectRoot,
|
|
27
|
+
handshakeId: input.handshakeId,
|
|
28
|
+
workspacePath,
|
|
29
|
+
now
|
|
30
|
+
});
|
|
31
|
+
const existing = findProtocol(context, protocolId);
|
|
32
|
+
const params = [
|
|
33
|
+
protocolId,
|
|
34
|
+
input.handshakeId,
|
|
35
|
+
project.id,
|
|
36
|
+
projectRoot,
|
|
37
|
+
state.status,
|
|
38
|
+
state.stage,
|
|
39
|
+
state.next_action,
|
|
40
|
+
JSON.stringify(state.route),
|
|
41
|
+
JSON.stringify(state.workspace),
|
|
42
|
+
JSON.stringify(state.blockers),
|
|
43
|
+
JSON.stringify(state.active_def),
|
|
44
|
+
runtimeStateJsonPath(context.ddFlowHome, project.id, protocolId),
|
|
45
|
+
runtimePlanJsonPath(context.ddFlowHome, project.id, protocolId),
|
|
46
|
+
existing?.created_at ?? now,
|
|
47
|
+
now
|
|
48
|
+
];
|
|
49
|
+
context.db.run(`INSERT INTO protocols
|
|
50
|
+
(id, handshake_id, project_id, project_root, status, stage, next_action,
|
|
51
|
+
route_json, workspace_json, blockers_json, active_def_json, state_path, plan_path,
|
|
52
|
+
created_at, updated_at)
|
|
53
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
54
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
55
|
+
handshake_id = excluded.handshake_id,
|
|
56
|
+
project_id = excluded.project_id,
|
|
57
|
+
project_root = excluded.project_root,
|
|
58
|
+
status = excluded.status,
|
|
59
|
+
stage = excluded.stage,
|
|
60
|
+
next_action = excluded.next_action,
|
|
61
|
+
route_json = excluded.route_json,
|
|
62
|
+
workspace_json = excluded.workspace_json,
|
|
63
|
+
blockers_json = excluded.blockers_json,
|
|
64
|
+
active_def_json = excluded.active_def_json,
|
|
65
|
+
state_path = excluded.state_path,
|
|
66
|
+
plan_path = excluded.plan_path,
|
|
67
|
+
updated_at = excluded.updated_at`, params);
|
|
68
|
+
appendAudit(context, {
|
|
69
|
+
protocolId,
|
|
70
|
+
projectId: project.id,
|
|
71
|
+
eventType: existing ? "protocol.registration_refreshed" : "protocol.registered",
|
|
72
|
+
payload: {
|
|
73
|
+
protocol_id: protocolId,
|
|
74
|
+
handshake_id: input.handshakeId,
|
|
75
|
+
project_root: projectRoot,
|
|
76
|
+
workspace_path: workspacePath ?? null,
|
|
77
|
+
runtime_dir: runtimeDir
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return { ok: true, protocol: protocolStatusPayload(requireProtocol(context, protocolId)), state };
|
|
81
|
+
}
|
|
82
|
+
export function getProtocolStatus(context, input) {
|
|
83
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
84
|
+
const runtime = readProtocolRuntimeState(context, protocol);
|
|
85
|
+
const state = runtime.state;
|
|
86
|
+
const plan = readPlanFile(protocol.plan_path);
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
protocol: protocolStatusPayload(protocol),
|
|
90
|
+
diagnostics: runtime.diagnostics,
|
|
91
|
+
state,
|
|
92
|
+
plan: plan ? summarizePlan(plan) : state.plan,
|
|
93
|
+
merge_queue: context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocol.id]),
|
|
94
|
+
worktree: context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocol.id]),
|
|
95
|
+
hook_status: hookStatusForProject(context, protocol.project_id),
|
|
96
|
+
codex_home_profiles: codexHomeProfilesForProject(context, protocol.project_id),
|
|
97
|
+
flow_sessions: activeFlowSessionBindingsForProject(context, protocol.project_id).filter((session) => session.protocol_id === protocol.id),
|
|
98
|
+
codex_session_bindings: activeCodexSessionBindingsForProject(context, protocol.project_id).filter((binding) => binding.protocol_id === protocol.id),
|
|
99
|
+
codex_hook_events: codexHookEventsForProject(context, protocol.project_id).filter((event) => event.protocol_id === protocol.id),
|
|
100
|
+
audit: getAuditEvents(context, protocol.id)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export function transitionProtocol(context, input) {
|
|
104
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
105
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
106
|
+
const flowContract = flowContractForState(state);
|
|
107
|
+
const from = requireStage(state.stage, flowContract);
|
|
108
|
+
const to = requireStage(input.to, flowContract);
|
|
109
|
+
if (!input.force && !canTransition(from, to, flowContract)) {
|
|
110
|
+
throw new AppError("invalid_transition", `Transition ${from} -> ${to} requires --force --reason`, 1);
|
|
111
|
+
}
|
|
112
|
+
if (input.force && (!input.reason || input.reason.trim().length === 0)) {
|
|
113
|
+
throw new AppError("validation", "Forced transitions require --reason", 2);
|
|
114
|
+
}
|
|
115
|
+
ensureReadableFile(input.jsonFile);
|
|
116
|
+
const payload = parseJsonObject(fs.readFileSync(input.jsonFile, "utf8"), input.jsonFile);
|
|
117
|
+
const nextState = {
|
|
118
|
+
...state,
|
|
119
|
+
stage: to,
|
|
120
|
+
status: statusForStage(to, flowContract),
|
|
121
|
+
next_action: typeof payload.next_action === "string" ? payload.next_action : state.next_action,
|
|
122
|
+
route: objectOrExisting(payload.route, state.route),
|
|
123
|
+
workspace: objectOrExisting(payload.workspace, state.workspace),
|
|
124
|
+
blockers: Array.isArray(payload.blockers) ? payload.blockers : state.blockers,
|
|
125
|
+
active_def: Array.isArray(payload.active_def) ? payload.active_def : state.active_def,
|
|
126
|
+
updated_at: context.now()
|
|
127
|
+
};
|
|
128
|
+
persistProtocolState(context, protocol, nextState);
|
|
129
|
+
appendAudit(context, {
|
|
130
|
+
protocolId: protocol.id,
|
|
131
|
+
projectId: protocol.project_id,
|
|
132
|
+
eventType: "protocol.transition",
|
|
133
|
+
forced: input.force,
|
|
134
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
135
|
+
payload: { from, to, payload, flow_contract_id: flowContract.id, flow_contract_version: flowContract.version }
|
|
136
|
+
});
|
|
137
|
+
return { ok: true, protocol_id: protocol.id, from, to, forced: input.force, state: nextState };
|
|
138
|
+
}
|
|
139
|
+
export function readyForMerge(context, input) {
|
|
140
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
141
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
142
|
+
const flowContract = flowContractForState(state);
|
|
143
|
+
const stage = requireStage(state.stage, flowContract);
|
|
144
|
+
if (!flowContract.readiness.allowed_from.includes(stage)) {
|
|
145
|
+
throw new AppError("readiness_invalid_stage", "Protocol state does not allow ready-for-merge", 1, {
|
|
146
|
+
stage: state.stage,
|
|
147
|
+
allowed: flowContract.readiness.allowed_from,
|
|
148
|
+
flow_contract_id: flowContract.id
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const missing = readinessMissingFields(state);
|
|
152
|
+
if (missing.length > 0) {
|
|
153
|
+
throw new AppError("readiness_missing_fields", "Protocol is missing explicit readiness fields", 1, { missing });
|
|
154
|
+
}
|
|
155
|
+
if (state.active_def.length > 0) {
|
|
156
|
+
throw new AppError("readiness_blocked", "Protocol has open deferrals", 1, { active_def: state.active_def });
|
|
157
|
+
}
|
|
158
|
+
const plan = readPlanFile(protocol.plan_path);
|
|
159
|
+
const openRequired = plan?.items.filter((item) => item.required && !["done", "skipped"].includes(item.status)) ?? [];
|
|
160
|
+
if (openRequired.length > 0) {
|
|
161
|
+
throw new AppError("readiness_plan_open", "Protocol has open required plan items", 1, {
|
|
162
|
+
items: openRequired.map((item) => ({ id: item.id, status: item.status }))
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const now = context.now();
|
|
166
|
+
const nextStage = flowContract.readiness.target_stage;
|
|
167
|
+
const nextState = { ...state, stage: nextStage, status: statusForStage(nextStage, flowContract), updated_at: now };
|
|
168
|
+
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
|
+
context.db.run(`INSERT INTO merge_queue
|
|
182
|
+
(protocol_id, project_id, status, claimed_by_session_id, claimed_at, completed_at, created_at, updated_at)
|
|
183
|
+
VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?)
|
|
184
|
+
ON CONFLICT(protocol_id) DO UPDATE SET
|
|
185
|
+
status = excluded.status,
|
|
186
|
+
claimed_by_session_id = NULL,
|
|
187
|
+
claimed_at = NULL,
|
|
188
|
+
completed_at = NULL,
|
|
189
|
+
updated_at = excluded.updated_at`, [protocol.id, protocol.project_id, "ready", now, now]);
|
|
190
|
+
appendAudit(context, {
|
|
191
|
+
protocolId: protocol.id,
|
|
192
|
+
projectId: protocol.project_id,
|
|
193
|
+
eventType: "protocol.ready_for_merge",
|
|
194
|
+
payload: { protocol_id: protocol.id, queue_status: "ready", flow_contract_id: flowContract.id }
|
|
195
|
+
});
|
|
196
|
+
return { ok: true, protocol_id: protocol.id, queue_status: "ready", state: nextState };
|
|
197
|
+
}
|
|
198
|
+
export function cancelProtocol(context, input) {
|
|
199
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
200
|
+
const reason = input.reason.trim();
|
|
201
|
+
if (!reason) {
|
|
202
|
+
throw new AppError("validation", "protocol cancel requires --reason", 2);
|
|
203
|
+
}
|
|
204
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
205
|
+
const now = context.now();
|
|
206
|
+
const worktree = context.db.get("SELECT worktree_path, status FROM worktree_records WHERE protocol_id = ?", [protocol.id]);
|
|
207
|
+
const worktreeRemoval = input.worktree === "remove" ? removeProtocolWorktreeBeforeCancel(protocol, state, worktree, input.force) : undefined;
|
|
208
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
209
|
+
try {
|
|
210
|
+
const nextState = {
|
|
211
|
+
...state,
|
|
212
|
+
stage: "cancelled",
|
|
213
|
+
status: "cancelled",
|
|
214
|
+
next_action: "cancelled",
|
|
215
|
+
updated_at: now
|
|
216
|
+
};
|
|
217
|
+
persistProtocolState(context, protocol, nextState);
|
|
218
|
+
let cancelledQueue = { ok: true, skipped: true, reason: "option_disabled" };
|
|
219
|
+
if (input.cancelQueue) {
|
|
220
|
+
cancelledQueue = cancelQueueForProtocol(context, protocol, reason, input.force);
|
|
221
|
+
}
|
|
222
|
+
let closedSessions = 0;
|
|
223
|
+
if (input.closeSessions) {
|
|
224
|
+
const result = context.db.run(`UPDATE flow_sessions
|
|
225
|
+
SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
226
|
+
WHERE project_id = ?
|
|
227
|
+
AND protocol_id = ?
|
|
228
|
+
AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, protocol.project_id, protocol.id]);
|
|
229
|
+
closedSessions = Number(result.changes);
|
|
230
|
+
}
|
|
231
|
+
const lockRelease = input.releaseLocks
|
|
232
|
+
? releaseRelatedMergeLocks(context, protocol, reason)
|
|
233
|
+
: { ok: true, skipped: true, reason: "option_disabled" };
|
|
234
|
+
let worktreeOutcome = { ok: true, skipped: true, reason: input.worktree === "keep" ? "keep_requested" : "no_active_worktree" };
|
|
235
|
+
if (input.worktree === "remove") {
|
|
236
|
+
const nextWorktreeStatus = worktreeRemoval?.status ?? "removed";
|
|
237
|
+
const result = worktree
|
|
238
|
+
? context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ?
|
|
239
|
+
WHERE protocol_id = ? AND status = 'active'`, [nextWorktreeStatus, now, now, protocol.id])
|
|
240
|
+
: { changes: 0 };
|
|
241
|
+
worktreeOutcome = {
|
|
242
|
+
ok: true,
|
|
243
|
+
changed: result.changes === 1,
|
|
244
|
+
status: nextWorktreeStatus,
|
|
245
|
+
reason: worktreeRemoval?.reason ?? "path_absent_or_no_record",
|
|
246
|
+
source: worktreeRemoval?.source ?? "none",
|
|
247
|
+
path: worktreeRemoval?.path ?? null,
|
|
248
|
+
physical_removed: worktreeRemoval?.physical_removed ?? false,
|
|
249
|
+
branch: worktreeRemoval?.branch ?? { ok: true, skipped: true, reason: "no_feature_branch" }
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
appendAudit(context, {
|
|
253
|
+
protocolId: protocol.id,
|
|
254
|
+
projectId: protocol.project_id,
|
|
255
|
+
eventType: "protocol.cancelled",
|
|
256
|
+
reason,
|
|
257
|
+
forced: input.force,
|
|
258
|
+
payload: {
|
|
259
|
+
protocol_id: protocol.id,
|
|
260
|
+
close_sessions: input.closeSessions,
|
|
261
|
+
cancel_queue: input.cancelQueue,
|
|
262
|
+
release_locks: input.releaseLocks,
|
|
263
|
+
worktree: input.worktree,
|
|
264
|
+
closed_sessions: closedSessions,
|
|
265
|
+
queue: cancelledQueue,
|
|
266
|
+
lock_release: lockRelease,
|
|
267
|
+
worktree_outcome: worktreeOutcome
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
context.db.exec("COMMIT");
|
|
271
|
+
return {
|
|
272
|
+
ok: true,
|
|
273
|
+
protocol_id: protocol.id,
|
|
274
|
+
state: nextState,
|
|
275
|
+
queue: cancelledQueue,
|
|
276
|
+
closed_sessions: closedSessions,
|
|
277
|
+
lock_release: lockRelease,
|
|
278
|
+
worktree_outcome: worktreeOutcome,
|
|
279
|
+
worktree: context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocol.id]) ?? null
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
context.db.exec("ROLLBACK");
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
export function requireProtocol(context, protocolId) {
|
|
288
|
+
const protocol = findProtocol(context, protocolId);
|
|
289
|
+
if (!protocol) {
|
|
290
|
+
throw new AppError("not_found", `Protocol is not registered: ${protocolId}`, 1);
|
|
291
|
+
}
|
|
292
|
+
return protocol;
|
|
293
|
+
}
|
|
294
|
+
function cancelQueueForProtocol(context, protocol, reason, force) {
|
|
295
|
+
const job = context.db.get("SELECT id, status, claimed_by_session_id FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
|
|
296
|
+
if (!job) {
|
|
297
|
+
return { ok: true, skipped: true, reason: "no_queue_job" };
|
|
298
|
+
}
|
|
299
|
+
if (job.status === "cancelled") {
|
|
300
|
+
return { ok: true, cancelled: false, reason: "already_cancelled", job };
|
|
301
|
+
}
|
|
302
|
+
if (job.status === "claimed" && !force) {
|
|
303
|
+
throw new AppError("merge_job_claimed", "Claimed queue cancellation requires --force --reason", 1, {
|
|
304
|
+
protocol_id: protocol.id,
|
|
305
|
+
claimed_by_session_id: job.claimed_by_session_id
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (["merged", "failed"].includes(job.status) && !force) {
|
|
309
|
+
throw new AppError("merge_job_terminal", `Queue job is ${job.status}; cancellation requires --force --reason`, 1, {
|
|
310
|
+
protocol_id: protocol.id,
|
|
311
|
+
status: job.status
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
context.db.run("UPDATE merge_queue SET status = 'cancelled', last_reason = ?, updated_at = ? WHERE id = ?", [
|
|
315
|
+
reason,
|
|
316
|
+
context.now(),
|
|
317
|
+
job.id
|
|
318
|
+
]);
|
|
319
|
+
appendAudit(context, {
|
|
320
|
+
protocolId: protocol.id,
|
|
321
|
+
projectId: protocol.project_id,
|
|
322
|
+
eventType: "merge_queue.cancelled",
|
|
323
|
+
reason,
|
|
324
|
+
forced: force,
|
|
325
|
+
payload: { protocol_id: protocol.id, from: job.status, source: "protocol_cancel" }
|
|
326
|
+
});
|
|
327
|
+
return { ok: true, cancelled: true, from: job.status };
|
|
328
|
+
}
|
|
329
|
+
function removeProtocolWorktreeBeforeCancel(protocol, state, record, force) {
|
|
330
|
+
const stateWorkspace = state.workspace;
|
|
331
|
+
const rawPath = record?.worktree_path || (typeof stateWorkspace.worktree_path === "string" ? stateWorkspace.worktree_path : "");
|
|
332
|
+
const source = record?.worktree_path ? "record" : "state";
|
|
333
|
+
if (!rawPath) {
|
|
334
|
+
return {
|
|
335
|
+
status: "removed",
|
|
336
|
+
reason: "path_absent_or_no_record",
|
|
337
|
+
source: "state",
|
|
338
|
+
path: "",
|
|
339
|
+
physical_removed: false,
|
|
340
|
+
branch: { ok: true, skipped: true, reason: "no_feature_branch" }
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
const worktreePath = path.resolve(rawPath);
|
|
344
|
+
const projectRoot = path.resolve(protocol.project_root);
|
|
345
|
+
if (worktreePath === projectRoot) {
|
|
346
|
+
throw new AppError("worktree_is_project_root", "Refusing to remove the stable project root as a feature worktree", 1, {
|
|
347
|
+
path: worktreePath,
|
|
348
|
+
project_root: projectRoot
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
const cwd = path.resolve(process.cwd());
|
|
352
|
+
if (cwd === worktreePath || cwd.startsWith(`${worktreePath}${path.sep}`)) {
|
|
353
|
+
throw new AppError("worktree_current_cwd", "Refusing to remove the current process working directory", 1, {
|
|
354
|
+
path: worktreePath,
|
|
355
|
+
cwd
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
const existsBefore = fs.existsSync(worktreePath);
|
|
359
|
+
if (existsBefore && !force) {
|
|
360
|
+
const dirty = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], { encoding: "utf8" });
|
|
361
|
+
if (dirty.status === 0 && dirty.stdout.trim().length > 0) {
|
|
362
|
+
throw new AppError("worktree_dirty", "Refusing to remove dirty worktree without --force --reason", 1, {
|
|
363
|
+
path: worktreePath
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
let physicalRemoved = false;
|
|
368
|
+
if (existsBefore) {
|
|
369
|
+
const args = ["-C", projectRoot, "worktree", "remove"];
|
|
370
|
+
if (force)
|
|
371
|
+
args.push("--force");
|
|
372
|
+
args.push(worktreePath);
|
|
373
|
+
const removed = spawnSync("git", args, { encoding: "utf8" });
|
|
374
|
+
if (removed.status !== 0) {
|
|
375
|
+
throw new AppError("worktree_remove_failed", "Git failed to remove the feature worktree", 1, {
|
|
376
|
+
path: worktreePath,
|
|
377
|
+
exit_code: removed.status,
|
|
378
|
+
stderr: removed.stderr.trim()
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
physicalRemoved = !fs.existsSync(worktreePath);
|
|
382
|
+
}
|
|
383
|
+
const featureBranch = record?.feature_branch ||
|
|
384
|
+
(typeof stateWorkspace.feature_branch === "string" && stateWorkspace.feature_branch.trim()
|
|
385
|
+
? stateWorkspace.feature_branch.trim()
|
|
386
|
+
: "");
|
|
387
|
+
const branch = removeLocalFeatureBranch(projectRoot, featureBranch, force);
|
|
388
|
+
const existsAfter = fs.existsSync(worktreePath);
|
|
389
|
+
return {
|
|
390
|
+
status: existsAfter ? "kept" : "removed",
|
|
391
|
+
reason: existsAfter ? "physical_checkout_kept" : existsBefore ? "physical_checkout_removed" : "path_absent",
|
|
392
|
+
source,
|
|
393
|
+
path: worktreePath,
|
|
394
|
+
physical_removed: physicalRemoved,
|
|
395
|
+
branch
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function removeLocalFeatureBranch(projectRoot, branch, force) {
|
|
399
|
+
if (!branch)
|
|
400
|
+
return { ok: true, skipped: true, reason: "no_feature_branch" };
|
|
401
|
+
const listed = spawnSync("git", ["-C", projectRoot, "branch", "--list", branch], { encoding: "utf8" });
|
|
402
|
+
if (listed.status !== 0 || listed.stdout.trim().length === 0) {
|
|
403
|
+
return { ok: true, skipped: true, reason: "branch_absent", branch };
|
|
404
|
+
}
|
|
405
|
+
const deleted = spawnSync("git", ["-C", projectRoot, "branch", force ? "-D" : "-d", branch], { encoding: "utf8" });
|
|
406
|
+
if (deleted.status !== 0) {
|
|
407
|
+
return {
|
|
408
|
+
ok: false,
|
|
409
|
+
skipped: true,
|
|
410
|
+
reason: force ? "branch_delete_failed" : "branch_not_merged",
|
|
411
|
+
branch,
|
|
412
|
+
stderr: deleted.stderr.trim()
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return { ok: true, skipped: false, reason: force ? "branch_force_deleted" : "branch_deleted", branch };
|
|
416
|
+
}
|
|
417
|
+
function releaseRelatedMergeLocks(context, protocol, reason) {
|
|
418
|
+
const workers = context.db.all(`SELECT DISTINCT worker_id FROM flow_sessions
|
|
419
|
+
WHERE project_id = ? AND protocol_id = ? AND worker_id IS NOT NULL`, [protocol.project_id, protocol.id]).map((row) => row.worker_id).filter((workerId) => Boolean(workerId));
|
|
420
|
+
const job = context.db.get("SELECT claimed_by_session_id FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
|
|
421
|
+
if (job?.claimed_by_session_id) {
|
|
422
|
+
workers.push(job.claimed_by_session_id);
|
|
423
|
+
}
|
|
424
|
+
const uniqueWorkers = [...new Set(workers)];
|
|
425
|
+
const released = [];
|
|
426
|
+
const now = context.now();
|
|
427
|
+
for (const workerId of uniqueWorkers) {
|
|
428
|
+
const lock = context.db.get(`SELECT id, worker_id FROM lane_locks
|
|
429
|
+
WHERE project_id = ? AND lane = 'merge' AND status = 'active' AND worker_id = ?
|
|
430
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`, [protocol.project_id, workerId]);
|
|
431
|
+
if (!lock) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
context.db.run(`UPDATE lane_locks SET status = 'released', released_at = ?, reason = ?, updated_at = ? WHERE id = ?`, [now, reason, now, lock.id]);
|
|
435
|
+
appendAudit(context, {
|
|
436
|
+
projectId: protocol.project_id,
|
|
437
|
+
eventType: "lane_lock.released",
|
|
438
|
+
reason,
|
|
439
|
+
payload: { project_id: protocol.project_id, lane: "merge", worker_id: workerId, lock_id: lock.id, source: "protocol_cancel" }
|
|
440
|
+
});
|
|
441
|
+
released.push({ lock_id: lock.id, worker_id: workerId });
|
|
442
|
+
}
|
|
443
|
+
return { ok: true, released: released.length, locks: released };
|
|
444
|
+
}
|
|
445
|
+
export function readProtocolRuntimeState(context, protocol) {
|
|
446
|
+
const diagnostics = [];
|
|
447
|
+
try {
|
|
448
|
+
return { state: readStateFile(protocol.state_path, protocol.id), diagnostics };
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
const stableStatePath = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
|
|
452
|
+
if (protocol.state_path !== stableStatePath && fs.existsSync(stableStatePath)) {
|
|
453
|
+
diagnostics.push({
|
|
454
|
+
code: "runtime_state_relocated",
|
|
455
|
+
severity: "warning",
|
|
456
|
+
old_path: protocol.state_path,
|
|
457
|
+
path: stableStatePath,
|
|
458
|
+
source: "stable_runtime_state",
|
|
459
|
+
recommended_action: "legacy protocol state_path was moved to stable runtime storage"
|
|
460
|
+
});
|
|
461
|
+
const stablePlanPath = runtimePlanJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
|
|
462
|
+
context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE id = ?", [stableStatePath, stablePlanPath, protocol.id]);
|
|
463
|
+
protocol.state_path = stableStatePath;
|
|
464
|
+
protocol.plan_path = stablePlanPath;
|
|
465
|
+
return { state: readStateFile(stableStatePath, protocol.id), diagnostics };
|
|
466
|
+
}
|
|
467
|
+
diagnostics.push({
|
|
468
|
+
code: "runtime_state_missing",
|
|
469
|
+
severity: "warning",
|
|
470
|
+
path: protocol.state_path,
|
|
471
|
+
source: "protocols.state_path",
|
|
472
|
+
recommended_action: "runtime state reconstructed from SQLite; run cleanup scan/apply if this persists"
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
const plan = context.db.get("SELECT plan_json FROM plans WHERE protocol_id = ?", [protocol.id]);
|
|
476
|
+
const planSummary = plan
|
|
477
|
+
? planSummaryForState(JSON.parse(plan.plan_json))
|
|
478
|
+
: { plan_id: null, total: 0, done: 0, blocked: 0 };
|
|
479
|
+
const flowContract = loadProjectFlowContract(protocol.project_root);
|
|
480
|
+
const state = {
|
|
481
|
+
schema_version: "0.1.0",
|
|
482
|
+
protocol_id: protocol.id,
|
|
483
|
+
project_root: protocol.project_root,
|
|
484
|
+
status: protocol.status,
|
|
485
|
+
stage: requireStage(protocol.stage, flowContract),
|
|
486
|
+
next_action: protocol.next_action,
|
|
487
|
+
route: JSON.parse(protocol.route_json),
|
|
488
|
+
workspace: JSON.parse(protocol.workspace_json),
|
|
489
|
+
plan: planSummary,
|
|
490
|
+
blockers: JSON.parse(protocol.blockers_json),
|
|
491
|
+
active_def: JSON.parse(protocol.active_def_json),
|
|
492
|
+
flow_contract: flowContract,
|
|
493
|
+
updated_at: protocol.updated_at
|
|
494
|
+
};
|
|
495
|
+
const stableStatePath = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
|
|
496
|
+
const stablePlanPath = runtimePlanJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
|
|
497
|
+
ensureDir(path.dirname(stableStatePath));
|
|
498
|
+
writeState(stableStatePath, state);
|
|
499
|
+
context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE id = ?", [stableStatePath, stablePlanPath, protocol.id]);
|
|
500
|
+
const repairedFrom = protocol.state_path;
|
|
501
|
+
protocol.state_path = stableStatePath;
|
|
502
|
+
protocol.plan_path = stablePlanPath;
|
|
503
|
+
appendAudit(context, {
|
|
504
|
+
protocolId: protocol.id,
|
|
505
|
+
projectId: protocol.project_id,
|
|
506
|
+
eventType: "protocol.runtime_state_repaired",
|
|
507
|
+
payload: { protocol_id: protocol.id, path: stableStatePath, repaired_from: repairedFrom, source: "sqlite" }
|
|
508
|
+
});
|
|
509
|
+
return { state, diagnostics };
|
|
510
|
+
}
|
|
511
|
+
export function persistProtocolState(context, protocol, state) {
|
|
512
|
+
ensureDir(path.dirname(protocol.state_path));
|
|
513
|
+
writeState(protocol.state_path, state);
|
|
514
|
+
context.db.run(`UPDATE protocols SET
|
|
515
|
+
status = ?, stage = ?, next_action = ?, route_json = ?, workspace_json = ?,
|
|
516
|
+
blockers_json = ?, active_def_json = ?, updated_at = ?
|
|
517
|
+
WHERE id = ?`, [
|
|
518
|
+
state.status,
|
|
519
|
+
state.stage,
|
|
520
|
+
state.next_action,
|
|
521
|
+
JSON.stringify(state.route),
|
|
522
|
+
JSON.stringify(state.workspace),
|
|
523
|
+
JSON.stringify(state.blockers),
|
|
524
|
+
JSON.stringify(state.active_def),
|
|
525
|
+
state.updated_at,
|
|
526
|
+
protocol.id
|
|
527
|
+
]);
|
|
528
|
+
if (state.status === "closed" || state.stage === "closed") {
|
|
529
|
+
const now = context.now();
|
|
530
|
+
context.db.run(`UPDATE flow_sessions
|
|
531
|
+
SET status = 'stopped', stop_reason = 'protocol closed', updated_at = ?, stopped_at = ?
|
|
532
|
+
WHERE project_id = ?
|
|
533
|
+
AND protocol_id = ?
|
|
534
|
+
AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [now, now, protocol.project_id, protocol.id]);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function findProtocol(context, protocolId) {
|
|
538
|
+
return context.db.get("SELECT * FROM protocols WHERE id = ?", [protocolId]);
|
|
539
|
+
}
|
|
540
|
+
function inferProtocolId(projectRoot, handshakeId) {
|
|
541
|
+
const directDir = path.join(projectRoot, ".memory-bank", "protocol", handshakeId);
|
|
542
|
+
if (fs.existsSync(directDir)) {
|
|
543
|
+
return handshakeId;
|
|
544
|
+
}
|
|
545
|
+
if (handshakeId.startsWith("PRT-")) {
|
|
546
|
+
return handshakeId;
|
|
547
|
+
}
|
|
548
|
+
return `PRT-${handshakeId.replace(/[^a-zA-Z0-9_-]+/g, "-")}`;
|
|
549
|
+
}
|
|
550
|
+
function statusForStage(stage, flowContract = defaultFlowContract) {
|
|
551
|
+
if (stage === "blocked" ||
|
|
552
|
+
stage === "waiting_for_user" ||
|
|
553
|
+
stage === "closed" ||
|
|
554
|
+
stage === "cancelled" ||
|
|
555
|
+
stage === "ready_for_merge" ||
|
|
556
|
+
flowContract.stages[stage]?.terminal) {
|
|
557
|
+
return stage;
|
|
558
|
+
}
|
|
559
|
+
return "running";
|
|
560
|
+
}
|
|
561
|
+
function objectOrExisting(value, existing) {
|
|
562
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : existing;
|
|
563
|
+
}
|
|
564
|
+
function protocolStatusPayload(protocol) {
|
|
565
|
+
return {
|
|
566
|
+
id: protocol.id,
|
|
567
|
+
handshake_id: protocol.handshake_id,
|
|
568
|
+
project_id: protocol.project_id,
|
|
569
|
+
project_root: protocol.project_root,
|
|
570
|
+
status: protocol.status,
|
|
571
|
+
stage: protocol.stage,
|
|
572
|
+
next_action: protocol.next_action,
|
|
573
|
+
route: JSON.parse(protocol.route_json),
|
|
574
|
+
workspace: JSON.parse(protocol.workspace_json),
|
|
575
|
+
state_path: protocol.state_path,
|
|
576
|
+
plan_path: protocol.plan_path,
|
|
577
|
+
updated_at: protocol.updated_at
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
function summarizePlan(plan) {
|
|
581
|
+
return planSummaryForState(plan);
|
|
582
|
+
}
|
|
583
|
+
function planSummaryForState(plan) {
|
|
584
|
+
return {
|
|
585
|
+
plan_id: plan.plan_id,
|
|
586
|
+
total: plan.items.length,
|
|
587
|
+
done: plan.items.filter((item) => item.status === "done").length,
|
|
588
|
+
blocked: plan.items.filter((item) => item.status === "blocked").length
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
function readinessMissingFields(state) {
|
|
592
|
+
const missing = [];
|
|
593
|
+
if (!state.protocol_id)
|
|
594
|
+
missing.push("protocol_id");
|
|
595
|
+
if (!state.project_root)
|
|
596
|
+
missing.push("project_root");
|
|
597
|
+
if (state.route.git === "feature_worktree") {
|
|
598
|
+
if (typeof state.workspace.feature_branch !== "string" || state.workspace.feature_branch.length === 0) {
|
|
599
|
+
missing.push("workspace.feature_branch");
|
|
600
|
+
}
|
|
601
|
+
if (typeof state.workspace.worktree_path !== "string" || state.workspace.worktree_path.length === 0) {
|
|
602
|
+
missing.push("workspace.worktree_path");
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return missing;
|
|
606
|
+
}
|