@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,365 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { continuationPolicies, flowKinds } from "../domain/contracts.js";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { parseJsonObject } from "../shared/json.js";
|
|
6
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
+
import { appendAudit } from "./audit.js";
|
|
8
|
+
export function registerFlowSession(context, input) {
|
|
9
|
+
const payload = decodeFlowSessionPayload(input);
|
|
10
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(payload.project_root));
|
|
11
|
+
const session = upsertFlowSession(context, project, payload, input.sessionId);
|
|
12
|
+
appendAudit(context, {
|
|
13
|
+
projectId: project.id,
|
|
14
|
+
eventType: "flow_session.registered",
|
|
15
|
+
payload: {
|
|
16
|
+
project_id: project.id,
|
|
17
|
+
session_id: session.session_id,
|
|
18
|
+
flow_kind: session.flow_kind,
|
|
19
|
+
run_id: session.run_id,
|
|
20
|
+
worker_id: session.worker_id,
|
|
21
|
+
continuation_policy: session.continuation_policy
|
|
22
|
+
},
|
|
23
|
+
...(session.protocol_id ? { protocolId: session.protocol_id } : {})
|
|
24
|
+
});
|
|
25
|
+
return { ok: true, session };
|
|
26
|
+
}
|
|
27
|
+
export function getFlowSessionStatus(context, input) {
|
|
28
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
29
|
+
return {
|
|
30
|
+
ok: true,
|
|
31
|
+
sessions: flowSessionsForProject(context, project.id, {
|
|
32
|
+
sessionId: input.sessionId,
|
|
33
|
+
workerId: input.workerId
|
|
34
|
+
})
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function stopFlowSession(context, input) {
|
|
38
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
39
|
+
const session = requireFlowSession(context, project.id, input.sessionId);
|
|
40
|
+
markSessionStopped(context, project, session, input.reason);
|
|
41
|
+
return { ok: true, session: flowSessionById(context, project.id, input.sessionId) };
|
|
42
|
+
}
|
|
43
|
+
export function stopMergeWorker(context, input) {
|
|
44
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
45
|
+
const sessions = flowSessionsForProject(context, project.id, { workerId: input.workerId }).filter((session) => session.flow_kind === "merge_worker" && ["active", "pending", "stopping"].includes(session.status));
|
|
46
|
+
let lock_release = { ok: true, released: false, reason: "no_active_session" };
|
|
47
|
+
for (const session of sessions) {
|
|
48
|
+
lock_release = markSessionStopped(context, project, session, input.reason) ?? lock_release;
|
|
49
|
+
}
|
|
50
|
+
appendAudit(context, {
|
|
51
|
+
projectId: project.id,
|
|
52
|
+
eventType: "flow_session.merge_worker_stopped",
|
|
53
|
+
reason: input.reason,
|
|
54
|
+
payload: { project_id: project.id, worker_id: input.workerId, stopped_sessions: sessions.length, lock_release }
|
|
55
|
+
});
|
|
56
|
+
return { ok: true, stopped_sessions: sessions.length, lock_release };
|
|
57
|
+
}
|
|
58
|
+
export function stoppedMergeWorkerState(context, projectId, workerId) {
|
|
59
|
+
const latest = context.db.get(`SELECT status, stop_reason FROM flow_sessions
|
|
60
|
+
WHERE project_id = ? AND worker_id = ? AND flow_kind = 'merge_worker'
|
|
61
|
+
ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
|
|
62
|
+
return {
|
|
63
|
+
stopped: latest?.status === "stopped",
|
|
64
|
+
reason: latest?.stop_reason ?? null
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function activeFlowSessionsForProject(context, projectId) {
|
|
68
|
+
return context.db.all(`SELECT * FROM flow_sessions
|
|
69
|
+
WHERE project_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')
|
|
70
|
+
ORDER BY updated_at DESC`, [projectId]);
|
|
71
|
+
}
|
|
72
|
+
export function flowSessionsForProject(context, projectId, filter = {}) {
|
|
73
|
+
if (filter.sessionId) {
|
|
74
|
+
return context.db.all("SELECT * FROM flow_sessions WHERE project_id = ? AND session_id = ? ORDER BY updated_at DESC", [projectId, filter.sessionId]);
|
|
75
|
+
}
|
|
76
|
+
if (filter.workerId) {
|
|
77
|
+
return context.db.all("SELECT * FROM flow_sessions WHERE project_id = ? AND worker_id = ? ORDER BY updated_at DESC", [projectId, filter.workerId]);
|
|
78
|
+
}
|
|
79
|
+
return context.db.all("SELECT * FROM flow_sessions WHERE project_id = ? ORDER BY updated_at DESC", [projectId]);
|
|
80
|
+
}
|
|
81
|
+
export function flowSessionById(context, projectId, sessionId) {
|
|
82
|
+
return context.db.get("SELECT * FROM flow_sessions WHERE project_id = ? AND session_id = ?", [
|
|
83
|
+
projectId,
|
|
84
|
+
sessionId
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
export function updateFlowSessionContinuation(context, projectId, sessionId, actionKey) {
|
|
88
|
+
const session = flowSessionById(context, projectId, sessionId);
|
|
89
|
+
if (!session) {
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
const actionHash = hashString(actionKey);
|
|
93
|
+
const nextCount = session.last_action_hash === actionHash ? session.continuation_count + 1 : 1;
|
|
94
|
+
context.db.run(`UPDATE flow_sessions
|
|
95
|
+
SET continuation_count = ?, last_action_hash = ?, updated_at = ?
|
|
96
|
+
WHERE project_id = ? AND session_id = ?`, [nextCount, actionHash, context.now(), projectId, sessionId]);
|
|
97
|
+
return nextCount;
|
|
98
|
+
}
|
|
99
|
+
export function recordPendingFlowSessionBinding(context, project, input) {
|
|
100
|
+
const payload = flowSessionPayloadFromRegisterCommand(input.command);
|
|
101
|
+
if (!payload) {
|
|
102
|
+
return { recorded: false };
|
|
103
|
+
}
|
|
104
|
+
const payloadRoot = resolveProjectRoot(payload.project_root);
|
|
105
|
+
if (payloadRoot !== project.root) {
|
|
106
|
+
throw new AppError("project_mismatch", "Session register payload project_root does not match hook project", 1, {
|
|
107
|
+
expected: project.root,
|
|
108
|
+
actual: payloadRoot
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const now = context.now();
|
|
112
|
+
context.db.run(`INSERT INTO pending_flow_session_bindings
|
|
113
|
+
(session_id, project_id, payload_json, cwd, transcript_path, turn_id, status, created_at, updated_at)
|
|
114
|
+
VALUES (?, ?, ?, ?, ?, ?, 'observed', ?, ?)
|
|
115
|
+
ON CONFLICT(session_id, project_id) DO UPDATE SET
|
|
116
|
+
payload_json = excluded.payload_json,
|
|
117
|
+
cwd = COALESCE(excluded.cwd, cwd),
|
|
118
|
+
transcript_path = COALESCE(excluded.transcript_path, transcript_path),
|
|
119
|
+
turn_id = excluded.turn_id,
|
|
120
|
+
status = 'observed',
|
|
121
|
+
updated_at = excluded.updated_at`, [
|
|
122
|
+
input.sessionId,
|
|
123
|
+
project.id,
|
|
124
|
+
JSON.stringify(sanitizeSessionPayload(payload)),
|
|
125
|
+
input.cwd ?? payload.cwd ?? null,
|
|
126
|
+
input.transcriptPath ?? payload.transcript_path ?? null,
|
|
127
|
+
input.turnId ?? null,
|
|
128
|
+
now,
|
|
129
|
+
now
|
|
130
|
+
]);
|
|
131
|
+
return { recorded: true, payload };
|
|
132
|
+
}
|
|
133
|
+
export function confirmPendingFlowSessionBinding(context, project, input) {
|
|
134
|
+
const pending = context.db.get("SELECT * FROM pending_flow_session_bindings WHERE project_id = ? AND session_id = ? AND status = 'observed'", [project.id, input.sessionId]);
|
|
135
|
+
if (!pending) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const payload = normalizeFlowSessionPayload(JSON.parse(pending.payload_json));
|
|
139
|
+
const session = upsertFlowSession(context, project, {
|
|
140
|
+
...payload,
|
|
141
|
+
cwd: pending.cwd ?? payload.cwd ?? null,
|
|
142
|
+
transcript_path: pending.transcript_path ?? payload.transcript_path ?? null
|
|
143
|
+
}, input.sessionId);
|
|
144
|
+
context.db.run(`UPDATE pending_flow_session_bindings SET status = 'confirmed', updated_at = ?
|
|
145
|
+
WHERE project_id = ? AND session_id = ?`, [context.now(), project.id, input.sessionId]);
|
|
146
|
+
appendAudit(context, {
|
|
147
|
+
projectId: project.id,
|
|
148
|
+
eventType: "flow_session.bound",
|
|
149
|
+
payload: { project_id: project.id, session_id: input.sessionId, flow_kind: session.flow_kind },
|
|
150
|
+
...(session.protocol_id ? { protocolId: session.protocol_id } : {})
|
|
151
|
+
});
|
|
152
|
+
return session;
|
|
153
|
+
}
|
|
154
|
+
export function flowSessionPayloadFromRegisterCommand(command) {
|
|
155
|
+
if (!/\bdd-flow\s+session\s+register\b/.test(command)) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
const payloadBase64 = optionFromCommand(command, "payload-base64");
|
|
159
|
+
const payloadJson = optionFromCommand(command, "payload-json");
|
|
160
|
+
const payloadFile = optionFromCommand(command, "payload-file");
|
|
161
|
+
if (!payloadBase64 && !payloadJson && !payloadFile) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
return decodeFlowSessionPayload({ payloadBase64, payloadJson, payloadFile });
|
|
165
|
+
}
|
|
166
|
+
function decodeFlowSessionPayload(input) {
|
|
167
|
+
if (input.payloadFile) {
|
|
168
|
+
return normalizeFlowSessionPayload(parseJsonObject(fs.readFileSync(input.payloadFile, "utf8"), "session payload"));
|
|
169
|
+
}
|
|
170
|
+
if (input.payloadBase64) {
|
|
171
|
+
if (/\$[A-Za-z_{(]/.test(input.payloadBase64)) {
|
|
172
|
+
throw new AppError("validation", "session register --payload-base64 must be a literal base64 value because Codex PreToolUse hooks run before shell variable expansion; use --payload-file from prompts", 2);
|
|
173
|
+
}
|
|
174
|
+
return normalizeFlowSessionPayload(parseJsonObject(Buffer.from(input.payloadBase64, "base64").toString("utf8"), "session payload"));
|
|
175
|
+
}
|
|
176
|
+
if (input.payloadJson) {
|
|
177
|
+
return normalizeFlowSessionPayload(parseJsonObject(input.payloadJson, "session payload"));
|
|
178
|
+
}
|
|
179
|
+
throw new AppError("validation", "session register requires --payload-file, --payload-base64, or --payload-json", 2);
|
|
180
|
+
}
|
|
181
|
+
function normalizeFlowSessionPayload(payload) {
|
|
182
|
+
const projectRoot = requiredStringField(payload, "project_root");
|
|
183
|
+
const flowKind = requiredStringField(payload, "flow_kind");
|
|
184
|
+
const continuationPolicy = requiredStringField(payload, "continuation_policy");
|
|
185
|
+
if (!flowKinds.includes(flowKind)) {
|
|
186
|
+
throw new AppError("validation", "flow_kind is not supported", 2, { flow_kind: flowKind, allowed: flowKinds });
|
|
187
|
+
}
|
|
188
|
+
if (!continuationPolicies.includes(continuationPolicy)) {
|
|
189
|
+
throw new AppError("validation", "continuation_policy is not supported", 2, {
|
|
190
|
+
continuation_policy: continuationPolicy,
|
|
191
|
+
allowed: continuationPolicies
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const metadata = payload.metadata && typeof payload.metadata === "object" && !Array.isArray(payload.metadata) ? payload.metadata : {};
|
|
195
|
+
const sessionId = stringField(payload, "session_id", false);
|
|
196
|
+
return {
|
|
197
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
198
|
+
project_root: projectRoot,
|
|
199
|
+
flow_kind: flowKind,
|
|
200
|
+
run_id: stringField(payload, "run_id", false) ?? null,
|
|
201
|
+
protocol_id: stringField(payload, "protocol_id", false) ?? null,
|
|
202
|
+
worker_id: stringField(payload, "worker_id", false) ?? null,
|
|
203
|
+
workspace_path: stringField(payload, "workspace_path", false) ?? null,
|
|
204
|
+
continuation_policy: continuationPolicy,
|
|
205
|
+
current_stage: stringField(payload, "current_stage", false) ?? null,
|
|
206
|
+
next_action: redactString(stringField(payload, "next_action", false) ?? null),
|
|
207
|
+
transcript_path: stringField(payload, "transcript_path", false) ?? null,
|
|
208
|
+
cwd: stringField(payload, "cwd", false) ?? null,
|
|
209
|
+
metadata: sanitizeValue(metadata)
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function upsertFlowSession(context, project, payload, forcedSessionId) {
|
|
213
|
+
const now = context.now();
|
|
214
|
+
const sessionId = forcedSessionId ?? payload.session_id ?? payload.worker_id ?? payload.protocol_id ?? crypto.randomUUID();
|
|
215
|
+
const workspacePath = payload.workspace_path ?? payload.cwd ?? project.root;
|
|
216
|
+
context.db.run(`INSERT INTO flow_sessions
|
|
217
|
+
(session_id, project_id, project_root, flow_kind, status, run_id, protocol_id, worker_id, workspace_path,
|
|
218
|
+
continuation_policy, current_stage, next_action, last_action_hash, continuation_count, stop_reason,
|
|
219
|
+
transcript_path, cwd, metadata_json, created_at, updated_at, stopped_at)
|
|
220
|
+
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, NULL, 0, NULL, ?, ?, ?, ?, ?, NULL)
|
|
221
|
+
ON CONFLICT(session_id, project_id) DO UPDATE SET
|
|
222
|
+
project_root = excluded.project_root,
|
|
223
|
+
flow_kind = excluded.flow_kind,
|
|
224
|
+
status = 'active',
|
|
225
|
+
run_id = excluded.run_id,
|
|
226
|
+
protocol_id = excluded.protocol_id,
|
|
227
|
+
worker_id = excluded.worker_id,
|
|
228
|
+
workspace_path = excluded.workspace_path,
|
|
229
|
+
continuation_policy = excluded.continuation_policy,
|
|
230
|
+
current_stage = excluded.current_stage,
|
|
231
|
+
next_action = excluded.next_action,
|
|
232
|
+
transcript_path = COALESCE(excluded.transcript_path, transcript_path),
|
|
233
|
+
cwd = COALESCE(excluded.cwd, cwd),
|
|
234
|
+
metadata_json = excluded.metadata_json,
|
|
235
|
+
updated_at = excluded.updated_at,
|
|
236
|
+
stop_reason = NULL,
|
|
237
|
+
stopped_at = NULL`, [
|
|
238
|
+
sessionId,
|
|
239
|
+
project.id,
|
|
240
|
+
project.root,
|
|
241
|
+
payload.flow_kind,
|
|
242
|
+
payload.run_id ?? null,
|
|
243
|
+
payload.protocol_id,
|
|
244
|
+
payload.worker_id,
|
|
245
|
+
workspacePath,
|
|
246
|
+
payload.continuation_policy,
|
|
247
|
+
payload.current_stage ?? null,
|
|
248
|
+
redactString(payload.next_action ?? null),
|
|
249
|
+
payload.transcript_path ?? null,
|
|
250
|
+
payload.cwd ?? null,
|
|
251
|
+
JSON.stringify(payload.metadata ?? {}),
|
|
252
|
+
now,
|
|
253
|
+
now
|
|
254
|
+
]);
|
|
255
|
+
return requireFlowSession(context, project.id, sessionId);
|
|
256
|
+
}
|
|
257
|
+
function requireFlowSession(context, projectId, sessionId) {
|
|
258
|
+
const session = flowSessionById(context, projectId, sessionId);
|
|
259
|
+
if (!session) {
|
|
260
|
+
throw new AppError("not_found", `Flow session is not registered: ${sessionId}`, 1);
|
|
261
|
+
}
|
|
262
|
+
return session;
|
|
263
|
+
}
|
|
264
|
+
function markSessionStopped(context, project, session, reason) {
|
|
265
|
+
const now = context.now();
|
|
266
|
+
context.db.run(`UPDATE flow_sessions
|
|
267
|
+
SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
268
|
+
WHERE project_id = ? AND session_id = ?`, [reason, now, now, project.id, session.session_id]);
|
|
269
|
+
let lockRelease = undefined;
|
|
270
|
+
if ((session.flow_kind === "merge_worker" || session.flow_kind === "merge_job") && session.worker_id) {
|
|
271
|
+
lockRelease = releaseMergeLockIfOwned(context, project.root, session.worker_id, reason);
|
|
272
|
+
}
|
|
273
|
+
appendAudit(context, {
|
|
274
|
+
projectId: project.id,
|
|
275
|
+
eventType: "flow_session.stopped",
|
|
276
|
+
reason,
|
|
277
|
+
payload: { project_id: project.id, session_id: session.session_id, flow_kind: session.flow_kind },
|
|
278
|
+
...(session.protocol_id ? { protocolId: session.protocol_id } : {})
|
|
279
|
+
});
|
|
280
|
+
return lockRelease;
|
|
281
|
+
}
|
|
282
|
+
function releaseMergeLockIfOwned(context, projectRoot, workerId, reason) {
|
|
283
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
|
|
284
|
+
const lock = context.db.get(`SELECT id, worker_id FROM lane_locks
|
|
285
|
+
WHERE project_id = ? AND lane = 'merge' AND status = 'active'
|
|
286
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`, [project.id]);
|
|
287
|
+
if (!lock) {
|
|
288
|
+
return { ok: true, released: false, reason: "lane_lock_not_active" };
|
|
289
|
+
}
|
|
290
|
+
if (lock.worker_id !== workerId) {
|
|
291
|
+
return { ok: true, released: false, reason: "lane_lock_owned_by_other_worker", worker_id: lock.worker_id };
|
|
292
|
+
}
|
|
293
|
+
const now = context.now();
|
|
294
|
+
context.db.run(`UPDATE lane_locks SET status = 'released', released_at = ?, reason = ?, updated_at = ? WHERE id = ?`, [now, reason, now, lock.id]);
|
|
295
|
+
appendAudit(context, {
|
|
296
|
+
projectId: project.id,
|
|
297
|
+
eventType: "lane_lock.released",
|
|
298
|
+
reason,
|
|
299
|
+
payload: { project_id: project.id, lane: "merge", worker_id: workerId, lock_id: lock.id }
|
|
300
|
+
});
|
|
301
|
+
return { ok: true, released: true, lock_id: lock.id };
|
|
302
|
+
}
|
|
303
|
+
function optionFromCommand(command, key) {
|
|
304
|
+
const pattern = new RegExp(`--${key}(?:\\s+|=)(?:"([^"]*)"|'([^']*)'|([^\\s]+))`);
|
|
305
|
+
const match = command.match(pattern);
|
|
306
|
+
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
307
|
+
}
|
|
308
|
+
function sanitizeSessionPayload(payload) {
|
|
309
|
+
return {
|
|
310
|
+
...payload,
|
|
311
|
+
next_action: redactString(payload.next_action ?? null),
|
|
312
|
+
metadata: sanitizeValue(payload.metadata ?? {})
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function sanitizeValue(value) {
|
|
316
|
+
if (Array.isArray(value)) {
|
|
317
|
+
return value.map(sanitizeValue);
|
|
318
|
+
}
|
|
319
|
+
if (value && typeof value === "object") {
|
|
320
|
+
const next = {};
|
|
321
|
+
for (const [key, child] of Object.entries(value)) {
|
|
322
|
+
next[key] = secretKey(key) ? "<redacted>" : sanitizeValue(child);
|
|
323
|
+
}
|
|
324
|
+
return next;
|
|
325
|
+
}
|
|
326
|
+
return typeof value === "string" ? redactString(value) : value;
|
|
327
|
+
}
|
|
328
|
+
function secretKey(key) {
|
|
329
|
+
return /(token|secret|password|api[_-]?key|authorization|auth)/i.test(key);
|
|
330
|
+
}
|
|
331
|
+
function redactString(value) {
|
|
332
|
+
if (value === null) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
return value
|
|
336
|
+
.replace(/(token|secret|password|api[_-]?key)=\S+/gi, "$1=<redacted>")
|
|
337
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, "Bearer <redacted>");
|
|
338
|
+
}
|
|
339
|
+
function stringField(payload, key, required) {
|
|
340
|
+
const value = payload[key];
|
|
341
|
+
if (typeof value === "string" && value.length > 0) {
|
|
342
|
+
return value;
|
|
343
|
+
}
|
|
344
|
+
if (required) {
|
|
345
|
+
throw new AppError("validation", `session payload requires ${key}`, 2);
|
|
346
|
+
}
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
function requiredStringField(payload, key) {
|
|
350
|
+
const value = stringField(payload, key, true);
|
|
351
|
+
if (!value) {
|
|
352
|
+
throw new AppError("validation", `session payload requires ${key}`, 2);
|
|
353
|
+
}
|
|
354
|
+
return value;
|
|
355
|
+
}
|
|
356
|
+
function hashString(value) {
|
|
357
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
358
|
+
}
|
|
359
|
+
function requireProjectByRoot(context, root) {
|
|
360
|
+
const project = context.db.get("SELECT id, root FROM projects WHERE root = ?", [root]);
|
|
361
|
+
if (!project) {
|
|
362
|
+
throw new AppError("not_found", `Project is not registered: ${root}`, 1);
|
|
363
|
+
}
|
|
364
|
+
return project;
|
|
365
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { flowContractForState } from "../domain/flow-contract.js";
|
|
5
|
+
import { requireStage } from "../domain/validation.js";
|
|
6
|
+
import { AppError } from "../shared/errors.js";
|
|
7
|
+
import { projectCheckoutRoot } from "../storage/paths.js";
|
|
8
|
+
import { appendAudit } from "./audit.js";
|
|
9
|
+
import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
10
|
+
const worktrunkBinEnv = "DD_FLOW_WORKTRUNK_BIN";
|
|
11
|
+
export function planWorktree(context, input) {
|
|
12
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
13
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
14
|
+
const suggestedPath = path.join(projectCheckoutRoot(context.ddFlowHome, protocol.project_id), "worktrees", protocol.id, path.basename(protocol.project_root));
|
|
15
|
+
return {
|
|
16
|
+
ok: true,
|
|
17
|
+
protocol_id: protocol.id,
|
|
18
|
+
project_id: protocol.project_id,
|
|
19
|
+
route_git: state.route.git,
|
|
20
|
+
suggested: {
|
|
21
|
+
integration_branch: state.workspace.integration_branch ?? "main",
|
|
22
|
+
worktree_path: suggestedPath,
|
|
23
|
+
bootstrap_status: "pending",
|
|
24
|
+
owner: "dd-flow",
|
|
25
|
+
purpose: "feature"
|
|
26
|
+
},
|
|
27
|
+
worktrunk: detectWorktrunk(context)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function createWorktreeRecord(context, input) {
|
|
31
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
32
|
+
const existing = activeWorktreeRecord(context, protocol.id);
|
|
33
|
+
if (existing) {
|
|
34
|
+
throw new AppError("worktree_already_exists", "Protocol already has an active worktree record", 1, {
|
|
35
|
+
protocol_id: protocol.id,
|
|
36
|
+
worktree_path: existing.worktree_path
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const worktrunk = requireWorktrunk(context);
|
|
40
|
+
const worktreePath = path.resolve(input.path);
|
|
41
|
+
if (isTrackedProjectPath(protocol.project_root, worktreePath)) {
|
|
42
|
+
throw new AppError("worktree_active", "Worktree path is inside tracked project files", 1, { path: worktreePath });
|
|
43
|
+
}
|
|
44
|
+
const command = [worktrunk.bin, "worktree", "create", "--branch", input.branch, "--base", input.base, "--path", worktreePath];
|
|
45
|
+
const result = runExternal(command);
|
|
46
|
+
if (result.exit_code !== 0) {
|
|
47
|
+
throw new AppError("tool_unavailable", "Worktrunk command failed", 1, { command: sanitizeCommand(command), result });
|
|
48
|
+
}
|
|
49
|
+
const now = context.now();
|
|
50
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
51
|
+
context.db.run(`INSERT INTO worktree_records
|
|
52
|
+
(protocol_id, project_id, integration_branch, feature_branch, base_ref, worktree_path,
|
|
53
|
+
worktrunk_metadata_json, bootstrap_status, status, last_command_result_json, created_at, updated_at, closed_at)
|
|
54
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 'active', ?, ?, ?, NULL)`, [
|
|
55
|
+
protocol.id,
|
|
56
|
+
protocol.project_id,
|
|
57
|
+
typeof state.workspace.integration_branch === "string" ? state.workspace.integration_branch : null,
|
|
58
|
+
input.branch,
|
|
59
|
+
input.base,
|
|
60
|
+
worktreePath,
|
|
61
|
+
JSON.stringify({ bin: worktrunk.bin }),
|
|
62
|
+
JSON.stringify(result),
|
|
63
|
+
now,
|
|
64
|
+
now
|
|
65
|
+
]);
|
|
66
|
+
persistProtocolState(context, protocol, {
|
|
67
|
+
...state,
|
|
68
|
+
workspace: {
|
|
69
|
+
...state.workspace,
|
|
70
|
+
feature_branch: input.branch,
|
|
71
|
+
worktree_path: worktreePath,
|
|
72
|
+
base_commit: input.base,
|
|
73
|
+
worktrunk: { bin: worktrunk.bin },
|
|
74
|
+
bootstrap: { status: "pending" }
|
|
75
|
+
},
|
|
76
|
+
updated_at: now
|
|
77
|
+
});
|
|
78
|
+
appendAudit(context, {
|
|
79
|
+
protocolId: protocol.id,
|
|
80
|
+
projectId: protocol.project_id,
|
|
81
|
+
eventType: "worktree.created",
|
|
82
|
+
payload: { protocol_id: protocol.id, feature_branch: input.branch, base: input.base, path: worktreePath }
|
|
83
|
+
});
|
|
84
|
+
return { ok: true, worktree: activeWorktreeRecord(context, protocol.id) };
|
|
85
|
+
}
|
|
86
|
+
export function getWorktreeStatus(context, input) {
|
|
87
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
88
|
+
return { ok: true, protocol_id: protocol.id, worktree: worktreeRecord(context, protocol.id), worktrunk: detectWorktrunk(context) };
|
|
89
|
+
}
|
|
90
|
+
export function bootstrapWorktree(context, input) {
|
|
91
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
92
|
+
const record = activeWorktreeRecord(context, protocol.id);
|
|
93
|
+
if (!record) {
|
|
94
|
+
throw new AppError("not_found", `Worktree record is not active for protocol: ${protocol.id}`, 1);
|
|
95
|
+
}
|
|
96
|
+
const now = context.now();
|
|
97
|
+
const result = {
|
|
98
|
+
exit_code: 0,
|
|
99
|
+
stdout: "No project bootstrap command configured.",
|
|
100
|
+
stderr: ""
|
|
101
|
+
};
|
|
102
|
+
context.db.run(`UPDATE worktree_records
|
|
103
|
+
SET bootstrap_status = 'succeeded', last_command_result_json = ?, updated_at = ?
|
|
104
|
+
WHERE protocol_id = ?`, [JSON.stringify(result), now, protocol.id]);
|
|
105
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
106
|
+
persistProtocolState(context, protocol, {
|
|
107
|
+
...state,
|
|
108
|
+
workspace: {
|
|
109
|
+
...state.workspace,
|
|
110
|
+
bootstrap: { status: "succeeded", commands: [], blockers: [] }
|
|
111
|
+
},
|
|
112
|
+
updated_at: now
|
|
113
|
+
});
|
|
114
|
+
appendAudit(context, {
|
|
115
|
+
protocolId: protocol.id,
|
|
116
|
+
projectId: protocol.project_id,
|
|
117
|
+
eventType: "worktree.bootstrap_succeeded",
|
|
118
|
+
payload: { protocol_id: protocol.id, worktree_path: record.worktree_path }
|
|
119
|
+
});
|
|
120
|
+
return { ok: true, worktree: worktreeRecord(context, protocol.id) };
|
|
121
|
+
}
|
|
122
|
+
export function closeWorktree(context, input) {
|
|
123
|
+
if (!["keep", "remove"].includes(input.mode)) {
|
|
124
|
+
throw new AppError("validation", "--mode must be keep or remove", 2);
|
|
125
|
+
}
|
|
126
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
127
|
+
const record = activeWorktreeRecord(context, protocol.id);
|
|
128
|
+
if (!record) {
|
|
129
|
+
throw new AppError("not_found", `Worktree record is not active for protocol: ${protocol.id}`, 1);
|
|
130
|
+
}
|
|
131
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
132
|
+
const stage = requireStage(state.stage, flowContractForState(state));
|
|
133
|
+
if (input.mode === "remove" && !["integration", "closed"].includes(stage)) {
|
|
134
|
+
throw new AppError("worktree_active", "Refusing to remove worktree before protocol is merged or closed", 1, {
|
|
135
|
+
stage: state.stage
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const now = context.now();
|
|
139
|
+
const nextStatus = input.mode === "keep" ? "kept" : fs.existsSync(record.worktree_path) ? "kept" : "removed";
|
|
140
|
+
context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ? WHERE protocol_id = ?`, [nextStatus, now, now, protocol.id]);
|
|
141
|
+
appendAudit(context, {
|
|
142
|
+
protocolId: protocol.id,
|
|
143
|
+
projectId: protocol.project_id,
|
|
144
|
+
eventType: "worktree.closed",
|
|
145
|
+
payload: { protocol_id: protocol.id, mode: input.mode, status: nextStatus }
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
ok: true,
|
|
149
|
+
worktree: worktreeRecord(context, protocol.id),
|
|
150
|
+
outcome: {
|
|
151
|
+
status: nextStatus,
|
|
152
|
+
reason: nextStatus === "removed" ? "path_absent" : input.mode === "remove" ? "physical_checkout_kept" : "keep_requested"
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export function worktreeRecordsForProject(context, projectId) {
|
|
157
|
+
return context.db.all(`SELECT * FROM worktree_records WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
|
|
158
|
+
}
|
|
159
|
+
export function worktreeRecord(context, protocolId) {
|
|
160
|
+
return context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocolId]);
|
|
161
|
+
}
|
|
162
|
+
function activeWorktreeRecord(context, protocolId) {
|
|
163
|
+
return context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ? AND status = 'active'", [protocolId]);
|
|
164
|
+
}
|
|
165
|
+
function detectWorktrunk(context) {
|
|
166
|
+
const configured = context.env[worktrunkBinEnv];
|
|
167
|
+
if (configured && fs.existsSync(configured)) {
|
|
168
|
+
return { available: true, bin: configured };
|
|
169
|
+
}
|
|
170
|
+
const probe = spawnSync("worktrunk", ["--version"], { encoding: "utf8" });
|
|
171
|
+
return probe.error ? { available: false, bin: null } : { available: true, bin: "worktrunk" };
|
|
172
|
+
}
|
|
173
|
+
function requireWorktrunk(context) {
|
|
174
|
+
const detected = detectWorktrunk(context);
|
|
175
|
+
if (!detected.available || !detected.bin) {
|
|
176
|
+
throw new AppError("tool_unavailable", "Worktrunk executable is unavailable", 1, {
|
|
177
|
+
tool: "worktrunk",
|
|
178
|
+
next_action: `Set ${worktrunkBinEnv} to a Worktrunk-compatible executable for local proof.`
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return { bin: detected.bin };
|
|
182
|
+
}
|
|
183
|
+
function runExternal(command) {
|
|
184
|
+
const [bin, ...args] = command;
|
|
185
|
+
if (!bin) {
|
|
186
|
+
return { exit_code: null, stdout: "", stderr: "Missing executable." };
|
|
187
|
+
}
|
|
188
|
+
const result = spawnSync(bin, args, { encoding: "utf8" });
|
|
189
|
+
return {
|
|
190
|
+
exit_code: typeof result.status === "number" ? result.status : null,
|
|
191
|
+
stdout: sanitizeOutput(result.stdout),
|
|
192
|
+
stderr: sanitizeOutput(result.stderr || result.error?.message || "")
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function sanitizeCommand(command) {
|
|
196
|
+
return command.map((part) => (part.includes("=") ? part.replace(/=.*/, "=<redacted>") : part));
|
|
197
|
+
}
|
|
198
|
+
function sanitizeOutput(value) {
|
|
199
|
+
return value.replace(/(token|secret|password)=\S+/gi, "$1=<redacted>");
|
|
200
|
+
}
|
|
201
|
+
function isTrackedProjectPath(projectRoot, candidate) {
|
|
202
|
+
const relative = path.relative(projectRoot, candidate);
|
|
203
|
+
return Boolean(relative && !relative.startsWith("..") && !path.isAbsolute(relative) && !relative.startsWith(".tasks"));
|
|
204
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export class AppError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
exitCode;
|
|
4
|
+
details;
|
|
5
|
+
constructor(code, message, exitCode = 1, details = {}) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.exitCode = exitCode;
|
|
9
|
+
this.details = details;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function isAppError(error) {
|
|
13
|
+
return error instanceof AppError;
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AppError } from "./errors.js";
|
|
2
|
+
export function writeJson(stream, value) {
|
|
3
|
+
stream.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
4
|
+
}
|
|
5
|
+
export function parseJsonObject(text, label) {
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = JSON.parse(text);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
throw new AppError("validation", `Invalid JSON in ${label}: ${String(error)}`, 2);
|
|
12
|
+
}
|
|
13
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
14
|
+
throw new AppError("validation", `${label} must be a JSON object`, 2);
|
|
15
|
+
}
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|