@deksden-com/dd-flow-cli 0.3.1 → 0.4.1
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/CHANGELOG.md +28 -0
- package/README.md +25 -9
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +58 -32
- package/dist/cli/run-cli.js +282 -50
- package/dist/domain/entity-ids.js +4 -4
- package/dist/domain/flow-contract.js +502 -36
- package/dist/domain/validation.js +34 -0
- package/dist/protocol/local-files.js +8 -6
- package/dist/schemas/code-stage-report.schema.json +197 -2
- package/dist/schemas/flow-contract.schema.json +126 -0
- package/dist/schemas/flow-run-index-v3.schema.json +203 -0
- package/dist/schemas/flow-run-index.schema.json +22 -2
- package/dist/schemas/flow-run.schema.json +36 -0
- package/dist/schemas/merge-stage-report.schema.json +213 -2
- package/dist/schemas/plan-stage-report.schema.json +156 -2
- package/dist/schemas/release-impact.schema.json +16 -0
- package/dist/services/audit.js +3 -3
- package/dist/services/branch-context.js +266 -0
- package/dist/services/canon.js +0 -1
- package/dist/services/cleanup.js +6 -6
- package/dist/services/cli-operation-classifier.js +1 -1
- package/dist/services/compatibility-preflight.js +8 -77
- package/dist/services/dashboard.js +48 -11
- package/dist/services/engines.js +123 -13
- package/dist/services/hooks.js +6 -6
- package/dist/services/ids.js +40 -49
- package/dist/services/merge-queue.js +240 -20
- package/dist/services/merge-worker.js +12 -4
- package/dist/services/migrations.js +64 -0
- package/dist/services/plans.js +23 -16
- package/dist/services/projects.js +2 -2
- package/dist/services/prompts.js +322 -0
- package/dist/services/protocols.js +78 -42
- package/dist/services/run-projection.js +80 -0
- package/dist/services/runs.js +360 -22
- package/dist/services/schema-validation.js +35 -12
- package/dist/services/sessions.js +81 -3
- package/dist/services/status.js +32 -1
- package/dist/services/usage.js +233 -0
- package/dist/services/worktrees.js +24 -19
- package/dist/storage/database.js +223 -9
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import { persistProtocolState, protocolRunDiagnostics, readProtocolRuntimeState,
|
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
7
|
import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, requireLaneLockOwner, waitAcquireLaneLock } from "./lanes.js";
|
|
8
8
|
import { stopMergeWorker, stoppedMergeWorkerState } from "./sessions.js";
|
|
9
|
+
import { getProtocolBranchContext, requireClaimableBranchBundle } from "./branch-context.js";
|
|
9
10
|
const mergeLockTtlSeconds = 300;
|
|
10
11
|
export function getMergeQueueStatus(context, input) {
|
|
11
12
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -14,6 +15,9 @@ export function getMergeQueueStatus(context, input) {
|
|
|
14
15
|
queue: queueForProject(context, project.id)
|
|
15
16
|
};
|
|
16
17
|
}
|
|
18
|
+
export function getMergeBundleStatus(context, input) {
|
|
19
|
+
return getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath });
|
|
20
|
+
}
|
|
17
21
|
export function claimNextMergeJob(context, input) {
|
|
18
22
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
19
23
|
const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
|
|
@@ -55,7 +59,7 @@ export function claimNextMergeJob(context, input) {
|
|
|
55
59
|
eventType: "merge_queue.claimed",
|
|
56
60
|
payload: { protocol_id: job.protocol_id, worker_id: input.workerId }
|
|
57
61
|
});
|
|
58
|
-
transitionClaimedProtocolToIntegration(context, job.protocol_id, input.workerId, now);
|
|
62
|
+
transitionClaimedProtocolToIntegration(context, project.id, job.protocol_id, input.workerId, now);
|
|
59
63
|
claimedProtocolId = job.protocol_id;
|
|
60
64
|
context.db.exec("COMMIT");
|
|
61
65
|
}
|
|
@@ -63,12 +67,73 @@ export function claimNextMergeJob(context, input) {
|
|
|
63
67
|
context.db.exec("ROLLBACK");
|
|
64
68
|
throw error;
|
|
65
69
|
}
|
|
66
|
-
return mergeQueueResult(claimedProtocolId ? queueJobByProtocol(context, claimedProtocolId) : null, {
|
|
70
|
+
return mergeQueueResult(claimedProtocolId ? queueJobByProtocol(context, project.id, claimedProtocolId) : null, {
|
|
67
71
|
ok: true,
|
|
68
72
|
outcome: claimedProtocolId ? "claimed" : "empty",
|
|
69
73
|
claimed: Boolean(claimedProtocolId)
|
|
70
74
|
});
|
|
71
75
|
}
|
|
76
|
+
export function claimMergeBundle(context, input) {
|
|
77
|
+
const { project, protocolIds } = requireClaimableBranchBundle(context, {
|
|
78
|
+
projectRoot: input.projectRoot,
|
|
79
|
+
workspacePath: input.workspacePath
|
|
80
|
+
});
|
|
81
|
+
const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
|
|
82
|
+
if (stopState.stopped) {
|
|
83
|
+
throw new AppError("merge_worker_stopped", "Stopped or stopping merge worker cannot claim a merge bundle", 1, {
|
|
84
|
+
worker_id: input.workerId,
|
|
85
|
+
status: stopState.status,
|
|
86
|
+
reason: stopState.reason
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
requireLaneLockOwner(context, {
|
|
90
|
+
projectRoot: project.root,
|
|
91
|
+
lane: "merge",
|
|
92
|
+
workerId: input.workerId,
|
|
93
|
+
workspacePath: input.workspacePath
|
|
94
|
+
});
|
|
95
|
+
const now = context.now();
|
|
96
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
97
|
+
try {
|
|
98
|
+
for (const protocolId of protocolIds) {
|
|
99
|
+
const update = context.db.run(`UPDATE merge_queue
|
|
100
|
+
SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
|
|
101
|
+
WHERE protocol_id = ? AND project_id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, protocolId, project.id]);
|
|
102
|
+
if (update.changes !== 1) {
|
|
103
|
+
throw new AppError("merge_bundle_claim_race", "Branch bundle changed while claiming", 1, {
|
|
104
|
+
protocol_id: protocolId,
|
|
105
|
+
protocol_ids: protocolIds
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
transitionClaimedProtocolToIntegration(context, project.id, protocolId, input.workerId, now);
|
|
109
|
+
appendAudit(context, {
|
|
110
|
+
protocolId,
|
|
111
|
+
projectId: project.id,
|
|
112
|
+
eventType: "merge_bundle.claimed_protocol",
|
|
113
|
+
payload: { protocol_id: protocolId, worker_id: input.workerId, protocol_ids: protocolIds }
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
appendAudit(context, {
|
|
117
|
+
projectId: project.id,
|
|
118
|
+
eventType: "merge_bundle.claimed",
|
|
119
|
+
payload: { project_id: project.id, worker_id: input.workerId, protocol_ids: protocolIds, workspace_path: input.workspacePath }
|
|
120
|
+
});
|
|
121
|
+
context.db.exec("COMMIT");
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
context.db.exec("ROLLBACK");
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
return mergeBundleResult(context, {
|
|
128
|
+
ok: true,
|
|
129
|
+
outcome: "claimed",
|
|
130
|
+
claimed: true,
|
|
131
|
+
projectRoot: project.root,
|
|
132
|
+
workspacePath: input.workspacePath,
|
|
133
|
+
workerId: input.workerId,
|
|
134
|
+
protocolIds
|
|
135
|
+
});
|
|
136
|
+
}
|
|
72
137
|
export async function waitNextMergeJob(context, input) {
|
|
73
138
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
74
139
|
const timeoutSeconds = input.timeoutSeconds ?? 0;
|
|
@@ -213,8 +278,9 @@ export async function waitNextMergeJob(context, input) {
|
|
|
213
278
|
}
|
|
214
279
|
}
|
|
215
280
|
export function completeMergeJob(context, input) {
|
|
216
|
-
const
|
|
217
|
-
const
|
|
281
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
282
|
+
const protocol = requireProtocol(context, input.protocolId, project.id);
|
|
283
|
+
const job = requireClaimedJob(context, protocol.project_id, input.protocolId, input.workerId);
|
|
218
284
|
requireLaneLockOwner(context, {
|
|
219
285
|
projectRoot: protocol.project_root,
|
|
220
286
|
lane: "merge",
|
|
@@ -243,7 +309,7 @@ export function completeMergeJob(context, input) {
|
|
|
243
309
|
payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary, flow_contract_id: flowContract.id }
|
|
244
310
|
});
|
|
245
311
|
const stop_after_current = stopAfterCurrentIfRequested(context, protocol.project_id, protocol.project_root, input.workerId, "merge complete after stop request");
|
|
246
|
-
return mergeQueueResult(queueJobByProtocol(context, protocol.id), {
|
|
312
|
+
return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), {
|
|
247
313
|
ok: true,
|
|
248
314
|
outcome: "merged",
|
|
249
315
|
merged: true,
|
|
@@ -251,9 +317,75 @@ export function completeMergeJob(context, input) {
|
|
|
251
317
|
stop_after_current
|
|
252
318
|
});
|
|
253
319
|
}
|
|
320
|
+
export function completeMergeBundle(context, input) {
|
|
321
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
322
|
+
requireLaneLockOwner(context, {
|
|
323
|
+
projectRoot: project.root,
|
|
324
|
+
lane: "merge",
|
|
325
|
+
workerId: input.workerId,
|
|
326
|
+
workspacePath: input.workspacePath
|
|
327
|
+
});
|
|
328
|
+
const protocolIds = claimedBundleProtocolIds(context, { projectRoot: project.root, workspacePath: input.workspacePath, workerId: input.workerId });
|
|
329
|
+
if (protocolIds.length === 0) {
|
|
330
|
+
throw new AppError("merge_bundle_not_claimed", "No claimed protocols found for this branch bundle and worker", 1, {
|
|
331
|
+
worker_id: input.workerId,
|
|
332
|
+
workspace_path: input.workspacePath
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const now = context.now();
|
|
336
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
337
|
+
try {
|
|
338
|
+
for (const protocolId of protocolIds) {
|
|
339
|
+
const protocol = requireProtocol(context, protocolId, project.id);
|
|
340
|
+
const job = requireClaimedJob(context, project.id, protocolId, input.workerId);
|
|
341
|
+
context.db.run(`UPDATE merge_queue
|
|
342
|
+
SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
|
|
343
|
+
WHERE id = ?`, [input.summary, now, now, job.id]);
|
|
344
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
345
|
+
const flowContract = loadProjectFlowContract(protocol.project_root);
|
|
346
|
+
const targetStage = flowContract.merge_queue.complete.target_stage;
|
|
347
|
+
const targetStatus = flowContract.stages[targetStage]?.terminal ? targetStage : "running";
|
|
348
|
+
persistProtocolState(context, protocol, {
|
|
349
|
+
...state,
|
|
350
|
+
stage: targetStage,
|
|
351
|
+
status: targetStatus,
|
|
352
|
+
next_action: flowContract.merge_queue.complete.next_action,
|
|
353
|
+
updated_at: now
|
|
354
|
+
});
|
|
355
|
+
appendAudit(context, {
|
|
356
|
+
protocolId,
|
|
357
|
+
projectId: project.id,
|
|
358
|
+
eventType: "merge_bundle.completed_protocol",
|
|
359
|
+
payload: { protocol_id: protocolId, worker_id: input.workerId, summary: input.summary, protocol_ids: protocolIds }
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
appendAudit(context, {
|
|
363
|
+
projectId: project.id,
|
|
364
|
+
eventType: "merge_bundle.completed",
|
|
365
|
+
payload: { project_id: project.id, worker_id: input.workerId, summary: input.summary, protocol_ids: protocolIds }
|
|
366
|
+
});
|
|
367
|
+
context.db.exec("COMMIT");
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
context.db.exec("ROLLBACK");
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle complete after stop request");
|
|
374
|
+
return mergeBundleResult(context, {
|
|
375
|
+
ok: true,
|
|
376
|
+
outcome: "merged",
|
|
377
|
+
merged: true,
|
|
378
|
+
projectRoot: project.root,
|
|
379
|
+
workspacePath: input.workspacePath,
|
|
380
|
+
workerId: input.workerId,
|
|
381
|
+
protocolIds,
|
|
382
|
+
stop_after_current
|
|
383
|
+
});
|
|
384
|
+
}
|
|
254
385
|
export function noteMergeJob(context, input) {
|
|
255
|
-
const
|
|
256
|
-
const
|
|
386
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
387
|
+
const protocol = requireProtocol(context, input.protocolId, project.id);
|
|
388
|
+
const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
|
|
257
389
|
if (!job) {
|
|
258
390
|
throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
|
|
259
391
|
}
|
|
@@ -279,11 +411,12 @@ export function noteMergeJob(context, input) {
|
|
|
279
411
|
eventType: "merge_queue.note",
|
|
280
412
|
payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary }
|
|
281
413
|
});
|
|
282
|
-
return mergeQueueResult(queueJobByProtocol(context, protocol.id), { ok: true, outcome: "noted" });
|
|
414
|
+
return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), { ok: true, outcome: "noted" });
|
|
283
415
|
}
|
|
284
416
|
export function failMergeJob(context, input) {
|
|
285
|
-
const
|
|
286
|
-
const
|
|
417
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
418
|
+
const protocol = requireProtocol(context, input.protocolId, project.id);
|
|
419
|
+
const job = requireClaimedJob(context, protocol.project_id, input.protocolId, input.workerId);
|
|
287
420
|
requireLaneLockOwner(context, {
|
|
288
421
|
projectRoot: protocol.project_root,
|
|
289
422
|
lane: "merge",
|
|
@@ -313,14 +446,81 @@ export function failMergeJob(context, input) {
|
|
|
313
446
|
payload: { protocol_id: protocol.id, worker_id: input.workerId, requeue: input.requeue }
|
|
314
447
|
});
|
|
315
448
|
const stop_after_current = stopAfterCurrentIfRequested(context, protocol.project_id, protocol.project_root, input.workerId, "merge fail after stop request");
|
|
316
|
-
return mergeQueueResult(queueJobByProtocol(context, protocol.id), {
|
|
449
|
+
return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), {
|
|
317
450
|
ok: true,
|
|
318
451
|
outcome: input.requeue ? "requeued" : "failed",
|
|
319
452
|
stop_after_current
|
|
320
453
|
});
|
|
321
454
|
}
|
|
455
|
+
export function failMergeBundle(context, input) {
|
|
456
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
457
|
+
requireLaneLockOwner(context, {
|
|
458
|
+
projectRoot: project.root,
|
|
459
|
+
lane: "merge",
|
|
460
|
+
workerId: input.workerId,
|
|
461
|
+
workspacePath: input.workspacePath
|
|
462
|
+
});
|
|
463
|
+
const protocolIds = claimedBundleProtocolIds(context, { projectRoot: project.root, workspacePath: input.workspacePath, workerId: input.workerId });
|
|
464
|
+
if (protocolIds.length === 0) {
|
|
465
|
+
throw new AppError("merge_bundle_not_claimed", "No claimed protocols found for this branch bundle and worker", 1, {
|
|
466
|
+
worker_id: input.workerId,
|
|
467
|
+
workspace_path: input.workspacePath
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const now = context.now();
|
|
471
|
+
const status = input.requeue ? "requeued" : "failed";
|
|
472
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
473
|
+
try {
|
|
474
|
+
for (const protocolId of protocolIds) {
|
|
475
|
+
const protocol = requireProtocol(context, protocolId, project.id);
|
|
476
|
+
const job = requireClaimedJob(context, project.id, protocolId, input.workerId);
|
|
477
|
+
context.db.run(`UPDATE merge_queue
|
|
478
|
+
SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
|
|
479
|
+
WHERE id = ?`, [status, input.reason, now, job.id]);
|
|
480
|
+
if (input.requeue) {
|
|
481
|
+
const state = readProtocolRuntimeState(context, protocol).state;
|
|
482
|
+
persistProtocolState(context, protocol, {
|
|
483
|
+
...state,
|
|
484
|
+
stage: "ready_for_merge",
|
|
485
|
+
status: "running",
|
|
486
|
+
next_action: "merge_requeued",
|
|
487
|
+
updated_at: now
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
appendAudit(context, {
|
|
491
|
+
protocolId,
|
|
492
|
+
projectId: project.id,
|
|
493
|
+
eventType: "merge_bundle.failed_protocol",
|
|
494
|
+
reason: input.reason,
|
|
495
|
+
payload: { protocol_id: protocolId, worker_id: input.workerId, requeue: input.requeue, protocol_ids: protocolIds }
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
appendAudit(context, {
|
|
499
|
+
projectId: project.id,
|
|
500
|
+
eventType: "merge_bundle.failed",
|
|
501
|
+
reason: input.reason,
|
|
502
|
+
payload: { project_id: project.id, worker_id: input.workerId, requeue: input.requeue, protocol_ids: protocolIds }
|
|
503
|
+
});
|
|
504
|
+
context.db.exec("COMMIT");
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
context.db.exec("ROLLBACK");
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle fail after stop request");
|
|
511
|
+
return mergeBundleResult(context, {
|
|
512
|
+
ok: true,
|
|
513
|
+
outcome: input.requeue ? "requeued" : "failed",
|
|
514
|
+
projectRoot: project.root,
|
|
515
|
+
workspacePath: input.workspacePath,
|
|
516
|
+
workerId: input.workerId,
|
|
517
|
+
protocolIds,
|
|
518
|
+
stop_after_current
|
|
519
|
+
});
|
|
520
|
+
}
|
|
322
521
|
export function cancelMergeQueueJob(context, input) {
|
|
323
|
-
const
|
|
522
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
523
|
+
const protocol = requireProtocol(context, input.protocolId, project.id);
|
|
324
524
|
const reason = input.reason.trim();
|
|
325
525
|
if (!reason) {
|
|
326
526
|
throw new AppError("validation", "merge-queue cancel requires --reason", 2);
|
|
@@ -328,7 +528,7 @@ export function cancelMergeQueueJob(context, input) {
|
|
|
328
528
|
const now = context.now();
|
|
329
529
|
context.db.exec("BEGIN IMMEDIATE");
|
|
330
530
|
try {
|
|
331
|
-
const job = queueJobByProtocol(context, protocol.id);
|
|
531
|
+
const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
|
|
332
532
|
if (!job) {
|
|
333
533
|
throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
|
|
334
534
|
}
|
|
@@ -376,13 +576,13 @@ export function cancelMergeQueueJob(context, input) {
|
|
|
376
576
|
context.db.exec("ROLLBACK");
|
|
377
577
|
throw error;
|
|
378
578
|
}
|
|
379
|
-
return mergeQueueResult(queueJobByProtocol(context, protocol.id), { ok: true, outcome: "cancelled", cancelled: true });
|
|
579
|
+
return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), { ok: true, outcome: "cancelled", cancelled: true });
|
|
380
580
|
}
|
|
381
581
|
export function queueForProject(context, projectId) {
|
|
382
582
|
return context.db.all(`SELECT * FROM merge_queue WHERE project_id = ? ORDER BY created_at ASC, id ASC`, [projectId]);
|
|
383
583
|
}
|
|
384
|
-
function queueJobByProtocol(context, protocolId) {
|
|
385
|
-
return context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocolId]);
|
|
584
|
+
function queueJobByProtocol(context, projectId, protocolId) {
|
|
585
|
+
return context.db.get("SELECT * FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
|
|
386
586
|
}
|
|
387
587
|
function mergeQueueResult(job, extra) {
|
|
388
588
|
const queueItem = job ?? null;
|
|
@@ -407,8 +607,28 @@ function mergeQueueResult(job, extra) {
|
|
|
407
607
|
job: queueItem
|
|
408
608
|
};
|
|
409
609
|
}
|
|
410
|
-
function
|
|
411
|
-
const
|
|
610
|
+
function mergeBundleResult(context, input) {
|
|
611
|
+
const branchContext = getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath }).branch_context;
|
|
612
|
+
return {
|
|
613
|
+
...input,
|
|
614
|
+
protocol_ids: input.protocolIds,
|
|
615
|
+
branch_context: branchContext,
|
|
616
|
+
queue_items: input.protocolIds.map((protocolId) => {
|
|
617
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
618
|
+
return queueJobByProtocol(context, project.id, protocolId) ?? null;
|
|
619
|
+
})
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
function claimedBundleProtocolIds(context, input) {
|
|
623
|
+
const branchContext = getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath }).branch_context;
|
|
624
|
+
const protocols = Array.isArray(branchContext.protocols) ? branchContext.protocols : [];
|
|
625
|
+
return protocols
|
|
626
|
+
.filter((protocol) => protocol.queue_status === "claimed" && protocol.queue_claimed_by === input.workerId)
|
|
627
|
+
.map((protocol) => String(protocol.id))
|
|
628
|
+
.filter(Boolean);
|
|
629
|
+
}
|
|
630
|
+
function requireClaimedJob(context, projectId, protocolId, sessionId) {
|
|
631
|
+
const job = queueJobByProtocol(context, projectId, protocolId);
|
|
412
632
|
if (!job || job.status !== "claimed") {
|
|
413
633
|
throw new AppError("merge_job_not_claimed", "Merge job is not claimed", 1, { protocol_id: protocolId });
|
|
414
634
|
}
|
|
@@ -420,8 +640,8 @@ function requireClaimedJob(context, protocolId, sessionId) {
|
|
|
420
640
|
}
|
|
421
641
|
return job;
|
|
422
642
|
}
|
|
423
|
-
function transitionClaimedProtocolToIntegration(context, protocolId, workerId, now) {
|
|
424
|
-
const protocol = requireProtocol(context, protocolId);
|
|
643
|
+
function transitionClaimedProtocolToIntegration(context, projectId, protocolId, workerId, now) {
|
|
644
|
+
const protocol = requireProtocol(context, protocolId, projectId);
|
|
425
645
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
426
646
|
const flowContract = loadProjectFlowContract(protocol.project_root);
|
|
427
647
|
if (!["ready_for_merge", "queued_for_merge"].includes(state.stage)) {
|
|
@@ -7,13 +7,17 @@ import { requireProjectByRoot } from "./projects.js";
|
|
|
7
7
|
import { buildStaticFlowGuidance } from "./flow-guidance.js";
|
|
8
8
|
import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
9
9
|
import { registerFlowSession, stopMergeWorker } from "./sessions.js";
|
|
10
|
+
import { getProtocolBranchContext } from "./branch-context.js";
|
|
10
11
|
export function getMergeWorkerStatus(context, input) {
|
|
11
12
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
12
13
|
return {
|
|
13
14
|
ok: true,
|
|
14
15
|
project: { id: project.id, root: project.root },
|
|
15
16
|
merge_worker: detectMergeWorkerState(context, project.id),
|
|
16
|
-
queue: queueForProject(context, project.id).map((job) => withJobGuidance(context, { ...job }))
|
|
17
|
+
queue: queueForProject(context, project.id).map((job) => withJobGuidance(context, { ...job })),
|
|
18
|
+
branch_context: input.workspacePath
|
|
19
|
+
? getProtocolBranchContext(context, { projectRoot: project.root, workspacePath: input.workspacePath }).branch_context
|
|
20
|
+
: null
|
|
17
21
|
};
|
|
18
22
|
}
|
|
19
23
|
export function startMergeWorker(context, input) {
|
|
@@ -29,7 +33,8 @@ export function startMergeWorker(context, input) {
|
|
|
29
33
|
outcome: "status_only",
|
|
30
34
|
reason: "merge_worker_already_active",
|
|
31
35
|
merge_worker: detection,
|
|
32
|
-
queue: queueForProject(context, project.id)
|
|
36
|
+
queue: queueForProject(context, project.id),
|
|
37
|
+
branch_context: getProtocolBranchContext(context, { projectRoot: project.root, workspacePath }).branch_context
|
|
33
38
|
};
|
|
34
39
|
}
|
|
35
40
|
ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath, branch: input.branch });
|
|
@@ -98,7 +103,8 @@ export function oneShotMergeClaim(context, input) {
|
|
|
98
103
|
outcome: "status_only",
|
|
99
104
|
reason: "active_merge_worker_or_lock",
|
|
100
105
|
merge_worker: detection,
|
|
101
|
-
queue: queueForProject(context, project.id)
|
|
106
|
+
queue: queueForProject(context, project.id),
|
|
107
|
+
branch_context: getProtocolBranchContext(context, { projectRoot: project.root, workspacePath }).branch_context
|
|
102
108
|
};
|
|
103
109
|
}
|
|
104
110
|
ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath });
|
|
@@ -143,6 +149,7 @@ export function oneShotMergeClaim(context, input) {
|
|
|
143
149
|
: null,
|
|
144
150
|
job: claimed.job ? withJobGuidance(context, { ...claimed.job }) : null,
|
|
145
151
|
lock: claimed.job ? lock.lock : null,
|
|
152
|
+
branch_context: getProtocolBranchContext(context, { projectRoot: project.root, workspacePath }).branch_context,
|
|
146
153
|
flow_guidance: claimed.job ? withJobGuidance(context, { ...claimed.job }).flow_guidance : undefined
|
|
147
154
|
};
|
|
148
155
|
}
|
|
@@ -151,7 +158,8 @@ function withJobGuidance(context, job) {
|
|
|
151
158
|
if (!protocolId)
|
|
152
159
|
return job;
|
|
153
160
|
try {
|
|
154
|
-
const
|
|
161
|
+
const projectId = typeof job.project_id === "string" ? job.project_id : undefined;
|
|
162
|
+
const protocol = requireProtocol(context, protocolId, projectId);
|
|
155
163
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
156
164
|
return {
|
|
157
165
|
...job,
|
|
@@ -3,6 +3,70 @@ import path from "node:path";
|
|
|
3
3
|
import { AppError } from "../shared/errors.js";
|
|
4
4
|
import { requireProjectByRoot } from "./projects.js";
|
|
5
5
|
import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
|
|
6
|
+
import { resolveCanonRoot } from "./canon.js";
|
|
7
|
+
export function assessMigrationImpact(context, input) {
|
|
8
|
+
const root = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
|
|
9
|
+
if (!root.root)
|
|
10
|
+
throw new AppError("not_found", `Project root does not exist: ${input.projectRoot}`, 1);
|
|
11
|
+
const canon = resolveCanonRoot(context);
|
|
12
|
+
if (!canon.ok || !canon.canon)
|
|
13
|
+
throw new AppError("canon_unavailable", "Cannot resolve canonical Memory Bank for upgrade impact assessment", 1, { blockers: canon.blockers, bootstrap: canon.bootstrap });
|
|
14
|
+
const status = getProjectVersionStatus({ projectRoot: root.root, rootSource: root.root_source, canon: { root: canon.canon.root, metadata: canon.canon.metadata } });
|
|
15
|
+
const from = status?.memory_bank.version ?? null;
|
|
16
|
+
const to = input.targetVersion ?? canon.canon.version;
|
|
17
|
+
const forced = input.mode === "full_migration";
|
|
18
|
+
const impacts = readImpacts(path.join(canon.canon.memorybank_root, "release-impact"));
|
|
19
|
+
const chain = [];
|
|
20
|
+
const domains = new Set();
|
|
21
|
+
const blockers = [];
|
|
22
|
+
let mode = "patch";
|
|
23
|
+
if (!from || !to)
|
|
24
|
+
blockers.push("project or canonical Memory Bank version is unavailable");
|
|
25
|
+
let current = from;
|
|
26
|
+
while (current && to && current !== to) {
|
|
27
|
+
const impact = impacts.find((item) => item.from_version === current);
|
|
28
|
+
if (!impact) {
|
|
29
|
+
blockers.push(`missing adjacent release impact from ${current}`);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
chain.push(impact);
|
|
33
|
+
for (const domain of impact.domains)
|
|
34
|
+
domains.add(domain);
|
|
35
|
+
mode = stricterMode(mode, impact.minimum_upgrade_mode);
|
|
36
|
+
if (impact.path_migration || impact.runtime_migration)
|
|
37
|
+
mode = "full_migration";
|
|
38
|
+
current = impact.to_version;
|
|
39
|
+
}
|
|
40
|
+
if (current && to && current !== to)
|
|
41
|
+
blockers.push(`release impact chain does not reach ${to}`);
|
|
42
|
+
if (status?.flow_pack.status !== "present" && from !== to)
|
|
43
|
+
blockers.push("project flow-pack provenance is not present");
|
|
44
|
+
if (blockers.length > 0)
|
|
45
|
+
mode = "full_migration";
|
|
46
|
+
if (forced)
|
|
47
|
+
mode = "full_migration";
|
|
48
|
+
return { ok: true, schema_id: "dd-flow/mb-upgrade-impact-assessment@1", project_root: root.root, source_version: from, target_version: to, mode, forced_full_migration: forced, chain, domains: [...domains].sort(), blockers, required_checks: [...new Set(chain.flatMap((item) => item.required_checks))], next_action: mode === "full_migration" ? "run full mb-upgrade contour" : "run selected mb-upgrade contour" };
|
|
49
|
+
}
|
|
50
|
+
function readImpacts(dir) {
|
|
51
|
+
if (!fs.existsSync(dir))
|
|
52
|
+
return [];
|
|
53
|
+
return fs.readdirSync(dir).filter((file) => file.endsWith(".json")).flatMap((file) => {
|
|
54
|
+
try {
|
|
55
|
+
const value = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
|
56
|
+
if (value.schema_id !== "dd-flow/release-impact@1")
|
|
57
|
+
return [];
|
|
58
|
+
if (typeof value.from_version !== "string" || typeof value.to_version !== "string" || !Array.isArray(value.domains) || !Array.isArray(value.required_checks))
|
|
59
|
+
return [];
|
|
60
|
+
if (!["patch", "targeted_minor", "full_migration"].includes(String(value.minimum_upgrade_mode)))
|
|
61
|
+
return [];
|
|
62
|
+
return [{ from_version: value.from_version, to_version: value.to_version, minimum_upgrade_mode: value.minimum_upgrade_mode, domains: value.domains.filter((item) => typeof item === "string"), path_migration: value.path_migration === true, runtime_migration: value.runtime_migration === true, required_checks: value.required_checks.filter((item) => typeof item === "string") }];
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function stricterMode(left, right) { return ["patch", "targeted_minor", "full_migration"][Math.max(["patch", "targeted_minor", "full_migration"].indexOf(left), ["patch", "targeted_minor", "full_migration"].indexOf(right))]; }
|
|
6
70
|
export function planMigration(context, input) {
|
|
7
71
|
const rootResolution = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
|
|
8
72
|
if (!rootResolution.root) {
|
package/dist/services/plans.js
CHANGED
|
@@ -6,15 +6,17 @@ import { parseJsonObject } from "../shared/json.js";
|
|
|
6
6
|
import { ensureReadableFile } from "../storage/database.js";
|
|
7
7
|
import { appendAudit } from "./audit.js";
|
|
8
8
|
import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
9
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
10
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
9
11
|
import { writePlanFile } from "../protocol/local-files.js";
|
|
10
12
|
export function setProtocolPlan(context, input) {
|
|
11
|
-
const protocol =
|
|
13
|
+
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
12
14
|
ensureReadableFile(input.file);
|
|
13
15
|
const plan = validatePlan(parseJsonObject(fs.readFileSync(input.file, "utf8"), input.file), protocol.id);
|
|
14
16
|
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()]);
|
|
17
|
+
context.db.run(`INSERT INTO plans (project_id, protocol_id, plan_json, updated_at)
|
|
18
|
+
VALUES (?, ?, ?, ?)
|
|
19
|
+
ON CONFLICT(project_id, protocol_id) DO UPDATE SET plan_json = excluded.plan_json, updated_at = excluded.updated_at`, [protocol.project_id, protocol.id, JSON.stringify(plan), context.now()]);
|
|
18
20
|
updateStatePlanSummary(context, protocol, plan);
|
|
19
21
|
appendAudit(context, {
|
|
20
22
|
protocolId: protocol.id,
|
|
@@ -25,8 +27,8 @@ export function setProtocolPlan(context, input) {
|
|
|
25
27
|
return { ok: true, protocol_id: protocol.id, plan: summarizePlan(plan) };
|
|
26
28
|
}
|
|
27
29
|
export function getPlanStatus(context, input) {
|
|
28
|
-
const protocol =
|
|
29
|
-
const plan = requireStoredPlan(context, protocol.id);
|
|
30
|
+
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
31
|
+
const plan = requireStoredPlan(context, protocol.project_id, protocol.id);
|
|
30
32
|
return {
|
|
31
33
|
ok: true,
|
|
32
34
|
protocol_id: protocol.id,
|
|
@@ -43,7 +45,7 @@ export function getPlanStatus(context, input) {
|
|
|
43
45
|
};
|
|
44
46
|
}
|
|
45
47
|
export function startPlanItem(context, input) {
|
|
46
|
-
return updatePlanItem(context, input.protocolId, input.itemId, (item, plan) => {
|
|
48
|
+
return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
|
|
47
49
|
assertDependenciesClosed(plan, item);
|
|
48
50
|
return {
|
|
49
51
|
...item,
|
|
@@ -53,7 +55,7 @@ export function startPlanItem(context, input) {
|
|
|
53
55
|
});
|
|
54
56
|
}
|
|
55
57
|
export function completePlanItem(context, input) {
|
|
56
|
-
return updatePlanItem(context, input.protocolId, input.itemId, (item, plan) => {
|
|
58
|
+
return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
|
|
57
59
|
assertDependenciesClosed(plan, item);
|
|
58
60
|
return {
|
|
59
61
|
...item,
|
|
@@ -64,7 +66,7 @@ export function completePlanItem(context, input) {
|
|
|
64
66
|
});
|
|
65
67
|
}
|
|
66
68
|
export function blockPlanItem(context, input) {
|
|
67
|
-
return updatePlanItem(context, input.protocolId, input.itemId, (item) => ({
|
|
69
|
+
return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item) => ({
|
|
68
70
|
...item,
|
|
69
71
|
status: "blocked",
|
|
70
72
|
summary: input.reason,
|
|
@@ -73,15 +75,15 @@ export function blockPlanItem(context, input) {
|
|
|
73
75
|
}));
|
|
74
76
|
}
|
|
75
77
|
export function skipPlanItem(context, input) {
|
|
76
|
-
return updatePlanItem(context, input.protocolId, input.itemId, (item) => ({
|
|
78
|
+
return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item) => ({
|
|
77
79
|
...item,
|
|
78
80
|
status: "skipped",
|
|
79
81
|
summary: input.reason
|
|
80
82
|
}));
|
|
81
83
|
}
|
|
82
|
-
function updatePlanItem(context, protocolId, itemId, transform) {
|
|
83
|
-
const protocol =
|
|
84
|
-
const plan = requireStoredPlan(context, protocol.id);
|
|
84
|
+
function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
|
|
85
|
+
const protocol = scopedProtocol(context, projectRoot, protocolId);
|
|
86
|
+
const plan = requireStoredPlan(context, protocol.project_id, protocol.id);
|
|
85
87
|
const item = plan.items.find((candidate) => candidate.id === itemId);
|
|
86
88
|
if (!item) {
|
|
87
89
|
throw new AppError("not_found", `Plan item is not found: ${itemId}`, 1);
|
|
@@ -92,9 +94,10 @@ function updatePlanItem(context, protocolId, itemId, transform) {
|
|
|
92
94
|
};
|
|
93
95
|
const validated = validatePlan(nextPlan, protocol.id);
|
|
94
96
|
writePlanFile(protocol.plan_path, validated);
|
|
95
|
-
context.db.run("UPDATE plans SET plan_json = ?, updated_at = ? WHERE protocol_id = ?", [
|
|
97
|
+
context.db.run("UPDATE plans SET plan_json = ?, updated_at = ? WHERE project_id = ? AND protocol_id = ?", [
|
|
96
98
|
JSON.stringify(validated),
|
|
97
99
|
context.now(),
|
|
100
|
+
protocol.project_id,
|
|
98
101
|
protocol.id
|
|
99
102
|
]);
|
|
100
103
|
updateStatePlanSummary(context, protocol, validated);
|
|
@@ -111,8 +114,12 @@ function updatePlanItem(context, protocolId, itemId, transform) {
|
|
|
111
114
|
});
|
|
112
115
|
return { ok: true, protocol_id: protocol.id, item: validated.items.find((candidate) => candidate.id === itemId) };
|
|
113
116
|
}
|
|
114
|
-
function
|
|
115
|
-
const
|
|
117
|
+
function scopedProtocol(context, projectRoot, protocolId) {
|
|
118
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
|
|
119
|
+
return requireProtocol(context, protocolId, project.id);
|
|
120
|
+
}
|
|
121
|
+
function requireStoredPlan(context, projectId, protocolId) {
|
|
122
|
+
const row = context.db.get("SELECT plan_json FROM plans WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
|
|
116
123
|
if (!row) {
|
|
117
124
|
throw new AppError("not_found", `Plan is not attached to protocol: ${protocolId}`, 1);
|
|
118
125
|
}
|
|
@@ -91,7 +91,7 @@ export function resolveProject(context, input) {
|
|
|
91
91
|
}
|
|
92
92
|
const details = { input: input.idOrAlias };
|
|
93
93
|
if (!isFullEntityId(input.idOrAlias) && !isShortEntityId(input.idOrAlias)) {
|
|
94
|
-
details.expected = "Use full id PRJ
|
|
94
|
+
details.expected = "Use full id PRJ-<sequence>-slug, short alias PRJ-<sequence>, slug, root path, or --root for root-based commands.";
|
|
95
95
|
}
|
|
96
96
|
throw new AppError("not_found", `Project is not registered: ${input.idOrAlias}`, 1, details);
|
|
97
97
|
}
|
|
@@ -108,7 +108,7 @@ export function requireProjectByReference(context, reference) {
|
|
|
108
108
|
}
|
|
109
109
|
throw new AppError("not_found", `Project is not registered: ${reference}`, 1, {
|
|
110
110
|
input: reference,
|
|
111
|
-
expected: "Use full id PRJ
|
|
111
|
+
expected: "Use full id PRJ-<sequence>-slug, short alias PRJ-<sequence>, slug, or project root path."
|
|
112
112
|
});
|
|
113
113
|
}
|
|
114
114
|
export function migrateProjectIds(context, input) {
|