@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,333 @@
|
|
|
1
|
+
import { loadProjectFlowContract } from "../domain/flow-contract.js";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
import { appendAudit } from "./audit.js";
|
|
4
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
5
|
+
import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
6
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
+
import { acquireLaneLock, ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, requireLaneLockOwner } from "./lanes.js";
|
|
8
|
+
import { stoppedMergeWorkerState } from "./sessions.js";
|
|
9
|
+
const mergeLockTtlSeconds = 300;
|
|
10
|
+
export function getMergeQueueStatus(context, input) {
|
|
11
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
12
|
+
return {
|
|
13
|
+
ok: true,
|
|
14
|
+
queue: queueForProject(context, project.id)
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function claimNextMergeJob(context, input) {
|
|
18
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
19
|
+
requireLaneLockOwner(context, {
|
|
20
|
+
projectRoot: project.root,
|
|
21
|
+
lane: "merge",
|
|
22
|
+
workerId: input.workerId,
|
|
23
|
+
workspacePath: input.workspacePath
|
|
24
|
+
});
|
|
25
|
+
const now = context.now();
|
|
26
|
+
let claimedProtocolId = null;
|
|
27
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
28
|
+
try {
|
|
29
|
+
const job = context.db.get(`SELECT * FROM merge_queue
|
|
30
|
+
WHERE project_id = ? AND status IN ('ready', 'requeued')
|
|
31
|
+
ORDER BY created_at ASC, id ASC
|
|
32
|
+
LIMIT 1`, [project.id]);
|
|
33
|
+
if (!job) {
|
|
34
|
+
context.db.exec("COMMIT");
|
|
35
|
+
return { ok: true, job: null };
|
|
36
|
+
}
|
|
37
|
+
const update = context.db.run(`UPDATE merge_queue
|
|
38
|
+
SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
|
|
39
|
+
WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
|
|
40
|
+
if (update.changes !== 1) {
|
|
41
|
+
context.db.exec("COMMIT");
|
|
42
|
+
return { ok: true, job: null };
|
|
43
|
+
}
|
|
44
|
+
appendAudit(context, {
|
|
45
|
+
protocolId: job.protocol_id,
|
|
46
|
+
projectId: project.id,
|
|
47
|
+
eventType: "merge_queue.claimed",
|
|
48
|
+
payload: { protocol_id: job.protocol_id, worker_id: input.workerId }
|
|
49
|
+
});
|
|
50
|
+
claimedProtocolId = job.protocol_id;
|
|
51
|
+
context.db.exec("COMMIT");
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
context.db.exec("ROLLBACK");
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, job: claimedProtocolId ? queueJobByProtocol(context, claimedProtocolId) : null };
|
|
58
|
+
}
|
|
59
|
+
export async function waitNextMergeJob(context, input) {
|
|
60
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
61
|
+
const timeoutSeconds = input.timeoutSeconds ?? 0;
|
|
62
|
+
const pollIntervalSeconds = input.pollIntervalSeconds ?? 10;
|
|
63
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) {
|
|
64
|
+
throw new AppError("validation", "--timeout must be zero or a positive number", 2);
|
|
65
|
+
}
|
|
66
|
+
if (!Number.isFinite(pollIntervalSeconds) || pollIntervalSeconds <= 0) {
|
|
67
|
+
throw new AppError("validation", "--poll-interval must be a positive number", 2);
|
|
68
|
+
}
|
|
69
|
+
if (input.acquireLock && pollIntervalSeconds >= mergeLockTtlSeconds) {
|
|
70
|
+
throw new AppError("validation", "--poll-interval must be lower than the merge lock TTL when --acquire-lock true", 2, {
|
|
71
|
+
lock_ttl_seconds: mergeLockTtlSeconds
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const initialStopState = stoppedMergeWorkerState(context, project.id, input.workerId);
|
|
75
|
+
if (initialStopState.stopped) {
|
|
76
|
+
return { ok: true, job: null, waited: false, timed_out: false, stopped: true, reason: initialStopState.reason };
|
|
77
|
+
}
|
|
78
|
+
let releaseLockOnTimeout = false;
|
|
79
|
+
if (input.acquireLock) {
|
|
80
|
+
ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath: input.workspacePath });
|
|
81
|
+
const lockResult = acquireLaneLock(context, {
|
|
82
|
+
projectRoot: project.root,
|
|
83
|
+
lane: "merge",
|
|
84
|
+
workerId: input.workerId,
|
|
85
|
+
workspacePath: input.workspacePath,
|
|
86
|
+
ttlSeconds: mergeLockTtlSeconds,
|
|
87
|
+
reason: "merge queue wait-next"
|
|
88
|
+
});
|
|
89
|
+
releaseLockOnTimeout = !lockResult.reused;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
requireLaneLockOwner(context, {
|
|
93
|
+
projectRoot: project.root,
|
|
94
|
+
lane: "merge",
|
|
95
|
+
workerId: input.workerId,
|
|
96
|
+
workspacePath: input.workspacePath
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const start = Date.now();
|
|
100
|
+
while (true) {
|
|
101
|
+
const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
|
|
102
|
+
if (stopState.stopped) {
|
|
103
|
+
if (input.acquireLock) {
|
|
104
|
+
releaseLaneLockIfOwned(context, {
|
|
105
|
+
projectRoot: project.root,
|
|
106
|
+
lane: "merge",
|
|
107
|
+
workerId: input.workerId,
|
|
108
|
+
workspacePath: input.workspacePath,
|
|
109
|
+
reason: stopState.reason ?? "merge worker stopped"
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
appendAudit(context, {
|
|
113
|
+
projectId: project.id,
|
|
114
|
+
eventType: "merge_queue.wait_stopped",
|
|
115
|
+
...(stopState.reason ? { reason: stopState.reason } : {}),
|
|
116
|
+
payload: { project_id: project.id, worker_id: input.workerId }
|
|
117
|
+
});
|
|
118
|
+
return { ok: true, job: null, waited: true, timed_out: false, stopped: true, reason: stopState.reason };
|
|
119
|
+
}
|
|
120
|
+
if (input.acquireLock) {
|
|
121
|
+
heartbeatLaneLock(context, {
|
|
122
|
+
projectRoot: project.root,
|
|
123
|
+
lane: "merge",
|
|
124
|
+
workerId: input.workerId,
|
|
125
|
+
workspacePath: input.workspacePath,
|
|
126
|
+
ttlSeconds: mergeLockTtlSeconds
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const claimed = claimNextMergeJob(context, {
|
|
130
|
+
projectRoot: project.root,
|
|
131
|
+
workerId: input.workerId,
|
|
132
|
+
workspacePath: input.workspacePath
|
|
133
|
+
});
|
|
134
|
+
if (claimed.job) {
|
|
135
|
+
return { ...claimed, waited: true, timed_out: false };
|
|
136
|
+
}
|
|
137
|
+
if (timeoutSeconds > 0 && Date.now() - start >= timeoutSeconds * 1000) {
|
|
138
|
+
appendAudit(context, {
|
|
139
|
+
projectId: project.id,
|
|
140
|
+
eventType: "merge_queue.wait_timeout",
|
|
141
|
+
payload: { project_id: project.id, worker_id: input.workerId, timeout_seconds: timeoutSeconds }
|
|
142
|
+
});
|
|
143
|
+
if (input.acquireLock && releaseLockOnTimeout) {
|
|
144
|
+
releaseLaneLock(context, {
|
|
145
|
+
projectRoot: project.root,
|
|
146
|
+
lane: "merge",
|
|
147
|
+
workerId: input.workerId,
|
|
148
|
+
workspacePath: input.workspacePath,
|
|
149
|
+
reason: "merge queue wait-next timeout"
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return { ok: true, job: null, waited: true, timed_out: true };
|
|
153
|
+
}
|
|
154
|
+
await delay(pollIntervalSeconds * 1000);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export function completeMergeJob(context, input) {
|
|
158
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
159
|
+
const job = requireClaimedJob(context, input.protocolId, input.workerId);
|
|
160
|
+
requireLaneLockOwner(context, {
|
|
161
|
+
projectRoot: protocol.project_root,
|
|
162
|
+
lane: "merge",
|
|
163
|
+
workerId: input.workerId,
|
|
164
|
+
workspacePath: input.workspacePath
|
|
165
|
+
});
|
|
166
|
+
const now = context.now();
|
|
167
|
+
context.db.run(`UPDATE merge_queue
|
|
168
|
+
SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
|
|
169
|
+
WHERE id = ?`, [input.summary, now, now, job.id]);
|
|
170
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
171
|
+
const flowContract = loadProjectFlowContract(protocol.project_root);
|
|
172
|
+
const targetStage = flowContract.merge_queue.complete.target_stage;
|
|
173
|
+
const targetStatus = flowContract.stages[targetStage]?.terminal ? targetStage : "running";
|
|
174
|
+
persistProtocolState(context, protocol, {
|
|
175
|
+
...state,
|
|
176
|
+
stage: targetStage,
|
|
177
|
+
status: targetStatus,
|
|
178
|
+
next_action: flowContract.merge_queue.complete.next_action,
|
|
179
|
+
updated_at: now
|
|
180
|
+
});
|
|
181
|
+
appendAudit(context, {
|
|
182
|
+
protocolId: protocol.id,
|
|
183
|
+
projectId: protocol.project_id,
|
|
184
|
+
eventType: "merge_queue.completed",
|
|
185
|
+
payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary, flow_contract_id: flowContract.id }
|
|
186
|
+
});
|
|
187
|
+
return { ok: true, job: queueJobByProtocol(context, protocol.id), next_stage: targetStage };
|
|
188
|
+
}
|
|
189
|
+
export function noteMergeJob(context, input) {
|
|
190
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
191
|
+
const job = queueJobByProtocol(context, protocol.id);
|
|
192
|
+
if (!job) {
|
|
193
|
+
throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
|
|
194
|
+
}
|
|
195
|
+
if (job.status !== "merged") {
|
|
196
|
+
throw new AppError("merge_job_not_merged", "Merge queue note requires a merged job", 1, {
|
|
197
|
+
protocol_id: protocol.id,
|
|
198
|
+
status: job.status
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (job.claimed_by_session_id !== input.workerId) {
|
|
202
|
+
throw new AppError("merge_job_claimed_by_other_session", "Merge job is owned by another worker", 1, {
|
|
203
|
+
protocol_id: protocol.id,
|
|
204
|
+
claimed_by_session_id: job.claimed_by_session_id
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const now = context.now();
|
|
208
|
+
context.db.run(`UPDATE merge_queue
|
|
209
|
+
SET last_reason = ?, updated_at = ?
|
|
210
|
+
WHERE id = ?`, [input.summary, now, job.id]);
|
|
211
|
+
appendAudit(context, {
|
|
212
|
+
protocolId: protocol.id,
|
|
213
|
+
projectId: protocol.project_id,
|
|
214
|
+
eventType: "merge_queue.note",
|
|
215
|
+
payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary }
|
|
216
|
+
});
|
|
217
|
+
return { ok: true, job: queueJobByProtocol(context, protocol.id) };
|
|
218
|
+
}
|
|
219
|
+
export function failMergeJob(context, input) {
|
|
220
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
221
|
+
const job = requireClaimedJob(context, input.protocolId, input.workerId);
|
|
222
|
+
requireLaneLockOwner(context, {
|
|
223
|
+
projectRoot: protocol.project_root,
|
|
224
|
+
lane: "merge",
|
|
225
|
+
workerId: input.workerId,
|
|
226
|
+
workspacePath: input.workspacePath
|
|
227
|
+
});
|
|
228
|
+
const now = context.now();
|
|
229
|
+
const status = input.requeue ? "requeued" : "failed";
|
|
230
|
+
context.db.run(`UPDATE merge_queue
|
|
231
|
+
SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
|
|
232
|
+
WHERE id = ?`, [status, input.reason, now, job.id]);
|
|
233
|
+
appendAudit(context, {
|
|
234
|
+
protocolId: protocol.id,
|
|
235
|
+
projectId: protocol.project_id,
|
|
236
|
+
eventType: "merge_queue.failed",
|
|
237
|
+
reason: input.reason,
|
|
238
|
+
payload: { protocol_id: protocol.id, worker_id: input.workerId, requeue: input.requeue }
|
|
239
|
+
});
|
|
240
|
+
return { ok: true, job: queueJobByProtocol(context, protocol.id) };
|
|
241
|
+
}
|
|
242
|
+
export function cancelMergeQueueJob(context, input) {
|
|
243
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
244
|
+
const reason = input.reason.trim();
|
|
245
|
+
if (!reason) {
|
|
246
|
+
throw new AppError("validation", "merge-queue cancel requires --reason", 2);
|
|
247
|
+
}
|
|
248
|
+
const now = context.now();
|
|
249
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
250
|
+
try {
|
|
251
|
+
const job = queueJobByProtocol(context, protocol.id);
|
|
252
|
+
if (!job) {
|
|
253
|
+
throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
|
|
254
|
+
}
|
|
255
|
+
if (job.status === "cancelled") {
|
|
256
|
+
context.db.exec("COMMIT");
|
|
257
|
+
return { ok: true, job, cancelled: false, reason: "already_cancelled" };
|
|
258
|
+
}
|
|
259
|
+
if (job.status === "claimed" && !input.force) {
|
|
260
|
+
if (!input.workerId) {
|
|
261
|
+
throw new AppError("merge_job_claimed", "Claimed jobs require --worker-id or --force", 1, { protocol_id: protocol.id });
|
|
262
|
+
}
|
|
263
|
+
if (job.claimed_by_session_id !== input.workerId) {
|
|
264
|
+
throw new AppError("merge_job_claimed_by_other_session", "Merge job is claimed by another worker", 1, {
|
|
265
|
+
protocol_id: protocol.id,
|
|
266
|
+
claimed_by_session_id: job.claimed_by_session_id
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
requireLaneLockOwner(context, {
|
|
270
|
+
projectRoot: protocol.project_root,
|
|
271
|
+
lane: "merge",
|
|
272
|
+
workerId: input.workerId,
|
|
273
|
+
workspacePath: input.workspacePath
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (["merged", "failed"].includes(job.status) && !input.force) {
|
|
277
|
+
throw new AppError("merge_job_terminal", `Merge job is ${job.status}; cancellation requires --force --reason`, 1, {
|
|
278
|
+
protocol_id: protocol.id,
|
|
279
|
+
status: job.status
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
context.db.run(`UPDATE merge_queue
|
|
283
|
+
SET status = 'cancelled', last_reason = ?, updated_at = ?
|
|
284
|
+
WHERE id = ?`, [reason, now, job.id]);
|
|
285
|
+
appendAudit(context, {
|
|
286
|
+
protocolId: protocol.id,
|
|
287
|
+
projectId: protocol.project_id,
|
|
288
|
+
eventType: "merge_queue.cancelled",
|
|
289
|
+
reason,
|
|
290
|
+
forced: input.force,
|
|
291
|
+
payload: { protocol_id: protocol.id, from: job.status, worker_id: input.workerId ?? null }
|
|
292
|
+
});
|
|
293
|
+
context.db.exec("COMMIT");
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
context.db.exec("ROLLBACK");
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
return { ok: true, job: queueJobByProtocol(context, protocol.id), cancelled: true };
|
|
300
|
+
}
|
|
301
|
+
export function queueForProject(context, projectId) {
|
|
302
|
+
return context.db.all(`SELECT * FROM merge_queue WHERE project_id = ? ORDER BY created_at ASC, id ASC`, [projectId]);
|
|
303
|
+
}
|
|
304
|
+
function queueJobByProtocol(context, protocolId) {
|
|
305
|
+
return context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocolId]);
|
|
306
|
+
}
|
|
307
|
+
function requireClaimedJob(context, protocolId, sessionId) {
|
|
308
|
+
const job = queueJobByProtocol(context, protocolId);
|
|
309
|
+
if (!job || job.status !== "claimed") {
|
|
310
|
+
throw new AppError("merge_job_not_claimed", "Merge job is not claimed", 1, { protocol_id: protocolId });
|
|
311
|
+
}
|
|
312
|
+
if (job.claimed_by_session_id !== sessionId) {
|
|
313
|
+
throw new AppError("merge_job_claimed_by_other_session", "Merge job is claimed by another session", 1, {
|
|
314
|
+
protocol_id: protocolId,
|
|
315
|
+
claimed_by_session_id: job.claimed_by_session_id
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return job;
|
|
319
|
+
}
|
|
320
|
+
function releaseLaneLockIfOwned(context, input) {
|
|
321
|
+
try {
|
|
322
|
+
releaseLaneLock(context, input);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (error instanceof AppError && ["lane_lock_not_active", "lane_lock_owned_by_other_worker"].includes(error.code)) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function delay(milliseconds) {
|
|
332
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
333
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { schemaVersion } from "../domain/contracts.js";
|
|
3
|
+
import { validatePlan } from "../domain/validation.js";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { parseJsonObject } from "../shared/json.js";
|
|
6
|
+
import { ensureReadableFile } from "../storage/database.js";
|
|
7
|
+
import { appendAudit } from "./audit.js";
|
|
8
|
+
import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
9
|
+
import { writePlanFile } from "../protocol/local-files.js";
|
|
10
|
+
export function setProtocolPlan(context, input) {
|
|
11
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
12
|
+
ensureReadableFile(input.file);
|
|
13
|
+
const plan = validatePlan(parseJsonObject(fs.readFileSync(input.file, "utf8"), input.file), protocol.id);
|
|
14
|
+
writePlanFile(protocol.plan_path, plan);
|
|
15
|
+
context.db.run(`INSERT INTO plans (protocol_id, plan_json, updated_at)
|
|
16
|
+
VALUES (?, ?, ?)
|
|
17
|
+
ON CONFLICT(protocol_id) DO UPDATE SET plan_json = excluded.plan_json, updated_at = excluded.updated_at`, [protocol.id, JSON.stringify(plan), context.now()]);
|
|
18
|
+
updateStatePlanSummary(context, protocol, plan);
|
|
19
|
+
appendAudit(context, {
|
|
20
|
+
protocolId: protocol.id,
|
|
21
|
+
projectId: protocol.project_id,
|
|
22
|
+
eventType: "plan.set",
|
|
23
|
+
payload: { protocol_id: protocol.id, plan_id: plan.plan_id, total: plan.items.length }
|
|
24
|
+
});
|
|
25
|
+
return { ok: true, protocol_id: protocol.id, plan: summarizePlan(plan) };
|
|
26
|
+
}
|
|
27
|
+
export function getPlanStatus(context, input) {
|
|
28
|
+
const protocol = requireProtocol(context, input.protocolId);
|
|
29
|
+
const plan = requireStoredPlan(context, protocol.id);
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
protocol_id: protocol.id,
|
|
33
|
+
plan: summarizePlan(plan),
|
|
34
|
+
blocked_items: plan.items
|
|
35
|
+
.filter((item) => item.status === "blocked")
|
|
36
|
+
.map((item) => ({
|
|
37
|
+
id: item.id,
|
|
38
|
+
title: item.title,
|
|
39
|
+
reason: item.block_reason ?? "",
|
|
40
|
+
user_required: item.user_required ?? false
|
|
41
|
+
})),
|
|
42
|
+
items: plan.items
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function startPlanItem(context, input) {
|
|
46
|
+
return updatePlanItem(context, input.protocolId, input.itemId, (item, plan) => {
|
|
47
|
+
assertDependenciesClosed(plan, item);
|
|
48
|
+
return {
|
|
49
|
+
...item,
|
|
50
|
+
status: "in_progress",
|
|
51
|
+
summary: item.summary || "Started."
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export function completePlanItem(context, input) {
|
|
56
|
+
return updatePlanItem(context, input.protocolId, input.itemId, (item, plan) => {
|
|
57
|
+
assertDependenciesClosed(plan, item);
|
|
58
|
+
return {
|
|
59
|
+
...item,
|
|
60
|
+
status: "done",
|
|
61
|
+
summary: input.summary,
|
|
62
|
+
evidence: [...new Set([...item.evidence, ...input.evidence])]
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export function blockPlanItem(context, input) {
|
|
67
|
+
return updatePlanItem(context, input.protocolId, input.itemId, (item) => ({
|
|
68
|
+
...item,
|
|
69
|
+
status: "blocked",
|
|
70
|
+
summary: input.reason,
|
|
71
|
+
block_reason: input.reason,
|
|
72
|
+
user_required: input.userRequired
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
export function skipPlanItem(context, input) {
|
|
76
|
+
return updatePlanItem(context, input.protocolId, input.itemId, (item) => ({
|
|
77
|
+
...item,
|
|
78
|
+
status: "skipped",
|
|
79
|
+
summary: input.reason
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
function updatePlanItem(context, protocolId, itemId, transform) {
|
|
83
|
+
const protocol = requireProtocol(context, protocolId);
|
|
84
|
+
const plan = requireStoredPlan(context, protocol.id);
|
|
85
|
+
const item = plan.items.find((candidate) => candidate.id === itemId);
|
|
86
|
+
if (!item) {
|
|
87
|
+
throw new AppError("not_found", `Plan item is not found: ${itemId}`, 1);
|
|
88
|
+
}
|
|
89
|
+
const nextPlan = {
|
|
90
|
+
...plan,
|
|
91
|
+
items: plan.items.map((candidate) => (candidate.id === itemId ? transform(candidate, plan) : candidate))
|
|
92
|
+
};
|
|
93
|
+
const validated = validatePlan(nextPlan, protocol.id);
|
|
94
|
+
writePlanFile(protocol.plan_path, validated);
|
|
95
|
+
context.db.run("UPDATE plans SET plan_json = ?, updated_at = ? WHERE protocol_id = ?", [
|
|
96
|
+
JSON.stringify(validated),
|
|
97
|
+
context.now(),
|
|
98
|
+
protocol.id
|
|
99
|
+
]);
|
|
100
|
+
updateStatePlanSummary(context, protocol, validated);
|
|
101
|
+
appendAudit(context, {
|
|
102
|
+
protocolId: protocol.id,
|
|
103
|
+
projectId: protocol.project_id,
|
|
104
|
+
eventType: "plan.item.updated",
|
|
105
|
+
payload: {
|
|
106
|
+
protocol_id: protocol.id,
|
|
107
|
+
item_id: itemId,
|
|
108
|
+
from: item.status,
|
|
109
|
+
to: validated.items.find((candidate) => candidate.id === itemId)?.status
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
return { ok: true, protocol_id: protocol.id, item: validated.items.find((candidate) => candidate.id === itemId) };
|
|
113
|
+
}
|
|
114
|
+
function requireStoredPlan(context, protocolId) {
|
|
115
|
+
const row = context.db.get("SELECT plan_json FROM plans WHERE protocol_id = ?", [protocolId]);
|
|
116
|
+
if (!row) {
|
|
117
|
+
throw new AppError("not_found", `Plan is not attached to protocol: ${protocolId}`, 1);
|
|
118
|
+
}
|
|
119
|
+
return validatePlan(parseJsonObject(row.plan_json, `stored plan for ${protocolId}`), protocolId);
|
|
120
|
+
}
|
|
121
|
+
function assertDependenciesClosed(plan, item) {
|
|
122
|
+
const openDependencies = item.depends_on
|
|
123
|
+
.map((dependencyId) => plan.items.find((candidate) => candidate.id === dependencyId))
|
|
124
|
+
.filter((dependency) => Boolean(dependency))
|
|
125
|
+
.filter((dependency) => !["done", "skipped"].includes(dependency.status));
|
|
126
|
+
if (openDependencies.length > 0) {
|
|
127
|
+
throw new AppError("plan_dependency_open", `Plan item ${item.id} has open dependencies`, 1, {
|
|
128
|
+
item_id: item.id,
|
|
129
|
+
dependencies: openDependencies.map((dependency) => ({ id: dependency.id, status: dependency.status }))
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function updateStatePlanSummary(context, protocol, plan) {
|
|
134
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
135
|
+
persistProtocolState(context, protocol, {
|
|
136
|
+
...state,
|
|
137
|
+
schema_version: state.schema_version || schemaVersion,
|
|
138
|
+
plan: summarizePlan(plan),
|
|
139
|
+
updated_at: context.now()
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function summarizePlan(plan) {
|
|
143
|
+
return {
|
|
144
|
+
plan_id: plan.plan_id,
|
|
145
|
+
total: plan.items.length,
|
|
146
|
+
done: plan.items.filter((item) => item.status === "done").length,
|
|
147
|
+
blocked: plan.items.filter((item) => item.status === "blocked").length
|
|
148
|
+
};
|
|
149
|
+
}
|