@deksden-com/dd-flow-cli 0.2.0 → 0.3.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 +43 -0
- package/README.md +76 -5
- package/dist/build-info.json +10 -6
- package/dist/cli/help.js +165 -20
- package/dist/cli/run-cli.js +427 -17
- package/dist/schemas/compatibility.schema.json +105 -0
- package/dist/schemas/engine-manifest.schema.json +61 -0
- package/dist/schemas/flow-guidance.schema.json +90 -0
- package/dist/schemas/flow-run-index.schema.json +30 -4
- package/dist/schemas/global-dashboard-data.schema.json +126 -0
- package/dist/schemas/mb-sdlc-review-report.schema.json +242 -0
- package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
- package/dist/schemas/plan-stage-report.schema.json +83 -0
- package/dist/schemas/project-dashboard-data.schema.json +122 -0
- package/dist/schemas/project-flow-pack-manifest.schema.json +5 -1
- package/dist/schemas/project-summary.schema.json +73 -0
- package/dist/schemas/protocol-dashboard-data.schema.json +112 -0
- package/dist/schemas/status-report.schema.json +38 -2
- package/dist/schemas/version-report.schema.json +22 -0
- package/dist/services/build-info.js +26 -3
- package/dist/services/canon.js +93 -22
- package/dist/services/cleanup.js +45 -1
- package/dist/services/cli-operation-classifier.js +104 -0
- package/dist/services/compatibility-preflight.js +124 -0
- package/dist/services/config.js +31 -0
- package/dist/services/dashboard-targets.js +95 -0
- package/dist/services/dashboard.js +972 -10
- package/dist/services/engines.js +532 -0
- package/dist/services/flow-guidance.js +221 -0
- package/dist/services/hooks.js +1 -1
- package/dist/services/ids.js +106 -0
- package/dist/services/lanes.js +333 -1
- package/dist/services/merge-queue.js +106 -16
- package/dist/services/merge-worker.js +67 -4
- package/dist/services/migrations.js +231 -0
- package/dist/services/project-summary.js +122 -0
- package/dist/services/projects.js +44 -3
- package/dist/services/protocol-lifecycle.js +144 -0
- package/dist/services/protocols.js +660 -7
- package/dist/services/runs.js +98 -21
- package/dist/services/schema-validation.js +84 -4
- package/dist/services/sessions.js +21 -4
- package/dist/services/status.js +199 -1
- package/dist/services/version-status.js +59 -9
- package/dist/storage/database.js +31 -0
- package/dist/storage/paths.js +33 -0
- package/package.json +3 -2
package/dist/services/lanes.js
CHANGED
|
@@ -11,15 +11,20 @@ const defaultPollIntervalSeconds = 10;
|
|
|
11
11
|
export function getLaneStatus(context, input) {
|
|
12
12
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
13
13
|
expireStaleLocks(context, project.id);
|
|
14
|
+
expireStaleWaiters(context, project.id);
|
|
14
15
|
return {
|
|
15
16
|
ok: true,
|
|
16
17
|
lanes: lanesForProject(context, project.id, input.lane),
|
|
17
|
-
locks: laneLocksForProject(context, project.id, input.lane)
|
|
18
|
+
locks: laneLocksForProject(context, project.id, input.lane),
|
|
19
|
+
waiters: laneWaitersForProject(context, project.id, input.lane)
|
|
18
20
|
};
|
|
19
21
|
}
|
|
20
22
|
export function expireProjectLaneLocks(context, projectId) {
|
|
21
23
|
expireStaleLocks(context, projectId);
|
|
22
24
|
}
|
|
25
|
+
export function expireProjectLaneWaiters(context, projectId) {
|
|
26
|
+
expireStaleWaiters(context, projectId);
|
|
27
|
+
}
|
|
23
28
|
export function setLaneWorkspace(context, input) {
|
|
24
29
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
25
30
|
const lane = normalizeLane(input.lane);
|
|
@@ -172,6 +177,124 @@ export async function waitForLaneLock(context, input) {
|
|
|
172
177
|
await delay(pollIntervalSeconds * 1000);
|
|
173
178
|
}
|
|
174
179
|
}
|
|
180
|
+
export function getLaneWaiters(context, input) {
|
|
181
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
182
|
+
expireStaleLocks(context, project.id);
|
|
183
|
+
expireStaleWaiters(context, project.id);
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
lane: input.lane ? normalizeLane(input.lane) : null,
|
|
187
|
+
waiters: laneWaitersForProject(context, project.id, input.lane).map((waiter) => ({
|
|
188
|
+
...waiter,
|
|
189
|
+
position: waiter.status === "queued" ? queuedWaiterPosition(context, project.id, waiter.lane, waiter.id) : null
|
|
190
|
+
}))
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
export function cancelLaneWaiter(context, input) {
|
|
194
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
195
|
+
const lane = normalizeLane(input.lane);
|
|
196
|
+
const reason = input.reason.trim();
|
|
197
|
+
if (!reason) {
|
|
198
|
+
throw new AppError("validation", "lane waiter cancel requires --reason", 2);
|
|
199
|
+
}
|
|
200
|
+
expireStaleWaiters(context, project.id);
|
|
201
|
+
const now = context.now();
|
|
202
|
+
const waiter = activeLaneWaiterForWorker(context, project.id, lane, input.workerId);
|
|
203
|
+
if (!waiter) {
|
|
204
|
+
return { ok: true, cancelled: false, reason: "no_queued_waiter", lane, worker_id: input.workerId };
|
|
205
|
+
}
|
|
206
|
+
context.db.run(`UPDATE lane_waiters
|
|
207
|
+
SET status = 'cancelled', cancelled_at = ?, reason = ?, updated_at = ?
|
|
208
|
+
WHERE id = ? AND status = 'queued'`, [now, reason, now, waiter.id]);
|
|
209
|
+
appendAudit(context, {
|
|
210
|
+
projectId: project.id,
|
|
211
|
+
eventType: "lane_waiter.cancelled",
|
|
212
|
+
reason,
|
|
213
|
+
payload: { project_id: project.id, lane, worker_id: input.workerId, waiter_id: waiter.id }
|
|
214
|
+
});
|
|
215
|
+
return { ok: true, cancelled: true, waiter: laneWaiterById(context, waiter.id) };
|
|
216
|
+
}
|
|
217
|
+
export function cancelLaneWaitersForWorker(context, input) {
|
|
218
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
219
|
+
const now = context.now();
|
|
220
|
+
const waiters = context.db.all("SELECT * FROM lane_waiters WHERE project_id = ? AND worker_id = ? AND status = 'queued' ORDER BY id ASC", [project.id, input.workerId]);
|
|
221
|
+
if (waiters.length === 0) {
|
|
222
|
+
return { ok: true, cancelled: 0 };
|
|
223
|
+
}
|
|
224
|
+
context.db.run(`UPDATE lane_waiters
|
|
225
|
+
SET status = 'cancelled', cancelled_at = ?, reason = ?, updated_at = ?
|
|
226
|
+
WHERE project_id = ? AND worker_id = ? AND status = 'queued'`, [now, input.reason, now, project.id, input.workerId]);
|
|
227
|
+
appendAudit(context, {
|
|
228
|
+
projectId: project.id,
|
|
229
|
+
eventType: "lane_waiter.cancelled_for_worker",
|
|
230
|
+
reason: input.reason,
|
|
231
|
+
payload: { project_id: project.id, worker_id: input.workerId, waiter_ids: waiters.map((waiter) => waiter.id) }
|
|
232
|
+
});
|
|
233
|
+
return { ok: true, cancelled: waiters.length, waiter_ids: waiters.map((waiter) => waiter.id) };
|
|
234
|
+
}
|
|
235
|
+
export function expireLaneWaiterById(context, input) {
|
|
236
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
237
|
+
const now = context.now();
|
|
238
|
+
const result = context.db.run(`UPDATE lane_waiters
|
|
239
|
+
SET status = 'expired', reason = ?, updated_at = ?
|
|
240
|
+
WHERE project_id = ? AND id = ? AND status = 'queued'`, [input.reason, now, project.id, input.waiterId]);
|
|
241
|
+
if (result.changes === 1) {
|
|
242
|
+
appendAudit(context, {
|
|
243
|
+
projectId: project.id,
|
|
244
|
+
eventType: "lane_waiter.expired",
|
|
245
|
+
reason: input.reason,
|
|
246
|
+
payload: { project_id: project.id, waiter_id: input.waiterId }
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, expired: result.changes === 1, waiter_id: input.waiterId };
|
|
250
|
+
}
|
|
251
|
+
export async function waitAcquireLaneLock(context, input) {
|
|
252
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
253
|
+
const lane = normalizeLane(input.lane);
|
|
254
|
+
requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
|
|
255
|
+
assertWorkerMayAcquireLaneLock(context, project.id, lane, input.workerId);
|
|
256
|
+
const timeoutSeconds = normalizeNonNegativeNumber(input.timeoutSeconds, 0, "timeout");
|
|
257
|
+
const pollIntervalSeconds = normalizePositiveNumber(input.pollIntervalSeconds, defaultPollIntervalSeconds, "poll-interval");
|
|
258
|
+
const ttlSeconds = normalizePositiveNumber(input.ttlSeconds, defaultTtlSeconds, "ttl");
|
|
259
|
+
const startedAt = Date.now();
|
|
260
|
+
const waiterDeadline = timeoutSeconds > 0 ? new Date(startedAt + timeoutSeconds * 1000).toISOString() : null;
|
|
261
|
+
while (true) {
|
|
262
|
+
const result = tryAcquireQueuedLaneLock(context, {
|
|
263
|
+
projectRoot: project.root,
|
|
264
|
+
projectId: project.id,
|
|
265
|
+
lane,
|
|
266
|
+
workerId: input.workerId,
|
|
267
|
+
workspacePath: input.workspacePath,
|
|
268
|
+
ttlSeconds,
|
|
269
|
+
reason: input.reason,
|
|
270
|
+
waiterDeadline,
|
|
271
|
+
invocationStartedAt: new Date(startedAt).toISOString()
|
|
272
|
+
});
|
|
273
|
+
if (result.acquired) {
|
|
274
|
+
return result.payload;
|
|
275
|
+
}
|
|
276
|
+
if (result.payload.status === "expired" && timeoutSeconds > 0 && Date.now() - startedAt >= timeoutSeconds * 1000) {
|
|
277
|
+
return markLaneWaiterTimedOut(context, {
|
|
278
|
+
projectId: project.id,
|
|
279
|
+
lane,
|
|
280
|
+
workerId: input.workerId,
|
|
281
|
+
timeoutSeconds
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (result.payload.status === "cancelled" || result.payload.status === "expired") {
|
|
285
|
+
return result.payload;
|
|
286
|
+
}
|
|
287
|
+
if (timeoutSeconds > 0 && Date.now() - startedAt >= timeoutSeconds * 1000) {
|
|
288
|
+
return markLaneWaiterTimedOut(context, {
|
|
289
|
+
projectId: project.id,
|
|
290
|
+
lane,
|
|
291
|
+
workerId: input.workerId,
|
|
292
|
+
timeoutSeconds
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
await delay(pollIntervalSeconds * 1000);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
175
298
|
export function requireLaneLockOwner(context, input) {
|
|
176
299
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
177
300
|
const lane = normalizeLane(input.lane);
|
|
@@ -236,12 +359,85 @@ function laneLocksForProject(context, projectId, lane) {
|
|
|
236
359
|
}
|
|
237
360
|
return context.db.all(`SELECT * FROM lane_locks WHERE project_id = ? ORDER BY updated_at DESC, id DESC LIMIT 50`, [projectId]);
|
|
238
361
|
}
|
|
362
|
+
function laneWaitersForProject(context, projectId, lane) {
|
|
363
|
+
if (lane) {
|
|
364
|
+
return context.db.all(`SELECT * FROM lane_waiters
|
|
365
|
+
WHERE project_id = ? AND lane = ?
|
|
366
|
+
ORDER BY
|
|
367
|
+
CASE status WHEN 'queued' THEN 0 ELSE 1 END ASC,
|
|
368
|
+
CASE status WHEN 'queued' THEN queued_at ELSE updated_at END ASC,
|
|
369
|
+
id ASC
|
|
370
|
+
LIMIT 50`, [projectId, normalizeLane(lane)]);
|
|
371
|
+
}
|
|
372
|
+
return context.db.all(`SELECT * FROM lane_waiters
|
|
373
|
+
WHERE project_id = ?
|
|
374
|
+
ORDER BY
|
|
375
|
+
CASE status WHEN 'queued' THEN 0 ELSE 1 END ASC,
|
|
376
|
+
CASE status WHEN 'queued' THEN queued_at ELSE updated_at END ASC,
|
|
377
|
+
id ASC
|
|
378
|
+
LIMIT 100`, [projectId]);
|
|
379
|
+
}
|
|
239
380
|
function activeLaneLock(context, projectId, lane) {
|
|
240
381
|
expireStaleLocks(context, projectId);
|
|
241
382
|
return context.db.get(`SELECT * FROM lane_locks
|
|
242
383
|
WHERE project_id = ? AND lane = ? AND status = 'active'
|
|
243
384
|
ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId, lane]);
|
|
244
385
|
}
|
|
386
|
+
function activeLaneWaiterForWorker(context, projectId, lane, workerId) {
|
|
387
|
+
return context.db.get(`SELECT * FROM lane_waiters
|
|
388
|
+
WHERE project_id = ? AND lane = ? AND worker_id = ? AND status = 'queued'
|
|
389
|
+
ORDER BY id ASC LIMIT 1`, [projectId, lane, workerId]);
|
|
390
|
+
}
|
|
391
|
+
function latestLaneWaiterForWorker(context, projectId, lane, workerId) {
|
|
392
|
+
return context.db.get(`SELECT * FROM lane_waiters
|
|
393
|
+
WHERE project_id = ? AND lane = ? AND worker_id = ?
|
|
394
|
+
ORDER BY id DESC LIMIT 1`, [projectId, lane, workerId]);
|
|
395
|
+
}
|
|
396
|
+
function firstQueuedLaneWaiter(context, projectId, lane) {
|
|
397
|
+
return context.db.get(`SELECT * FROM lane_waiters
|
|
398
|
+
WHERE project_id = ? AND lane = ? AND status = 'queued'
|
|
399
|
+
ORDER BY queued_at ASC, id ASC LIMIT 1`, [projectId, lane]);
|
|
400
|
+
}
|
|
401
|
+
function laneWaiterById(context, id) {
|
|
402
|
+
return context.db.get("SELECT * FROM lane_waiters WHERE id = ?", [id]);
|
|
403
|
+
}
|
|
404
|
+
function queuedWaiterPosition(context, projectId, lane, waiterId) {
|
|
405
|
+
const waiters = context.db.all(`SELECT id FROM lane_waiters
|
|
406
|
+
WHERE project_id = ? AND lane = ? AND status = 'queued'
|
|
407
|
+
ORDER BY queued_at ASC, id ASC`, [projectId, lane]);
|
|
408
|
+
const index = waiters.findIndex((waiter) => waiter.id === waiterId);
|
|
409
|
+
return index >= 0 ? index + 1 : null;
|
|
410
|
+
}
|
|
411
|
+
function markLaneWaiterTimedOut(context, input) {
|
|
412
|
+
const now = context.now();
|
|
413
|
+
const latest = latestLaneWaiterForWorker(context, input.projectId, input.lane, input.workerId);
|
|
414
|
+
if (latest && ["queued", "expired"].includes(latest.status)) {
|
|
415
|
+
context.db.run(`UPDATE lane_waiters
|
|
416
|
+
SET status = 'timed_out', reason = ?, updated_at = ?
|
|
417
|
+
WHERE id = ? AND status IN ('queued', 'expired')`, ["lane lock wait-acquire timeout", now, latest.id]);
|
|
418
|
+
}
|
|
419
|
+
appendAudit(context, {
|
|
420
|
+
projectId: input.projectId,
|
|
421
|
+
eventType: "lane_waiter.wait_timeout",
|
|
422
|
+
payload: {
|
|
423
|
+
project_id: input.projectId,
|
|
424
|
+
lane: input.lane,
|
|
425
|
+
worker_id: input.workerId,
|
|
426
|
+
timeout_seconds: input.timeoutSeconds,
|
|
427
|
+
waiter_id: latest?.id ?? null
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
return {
|
|
431
|
+
ok: true,
|
|
432
|
+
acquired: false,
|
|
433
|
+
status: "timed_out",
|
|
434
|
+
timed_out: true,
|
|
435
|
+
lane: input.lane,
|
|
436
|
+
worker_id: input.workerId,
|
|
437
|
+
waiter: latestLaneWaiterForWorker(context, input.projectId, input.lane, input.workerId) ?? null,
|
|
438
|
+
lock: activeLaneLock(context, input.projectId, input.lane) ?? null
|
|
439
|
+
};
|
|
440
|
+
}
|
|
245
441
|
function requireOwnedActiveLock(context, projectId, lane, workerId, leaseToken) {
|
|
246
442
|
const lock = activeLaneLock(context, projectId, lane);
|
|
247
443
|
if (!lock) {
|
|
@@ -288,6 +484,142 @@ function expireStaleLocks(context, projectId) {
|
|
|
288
484
|
SET status = 'expired', updated_at = ?
|
|
289
485
|
WHERE project_id = ? AND status = 'active' AND expires_at <= ?`, [now, projectId, now]);
|
|
290
486
|
}
|
|
487
|
+
function expireStaleWaiters(context, projectId) {
|
|
488
|
+
const now = context.now();
|
|
489
|
+
context.db.run(`UPDATE lane_waiters
|
|
490
|
+
SET status = 'expired', updated_at = ?
|
|
491
|
+
WHERE project_id = ? AND status = 'queued' AND expires_at IS NOT NULL AND expires_at <= ?`, [now, projectId, now]);
|
|
492
|
+
}
|
|
493
|
+
function ensureQueuedLaneWaiter(context, input) {
|
|
494
|
+
const existing = activeLaneWaiterForWorker(context, input.projectId, input.lane, input.workerId);
|
|
495
|
+
if (existing) {
|
|
496
|
+
return existing;
|
|
497
|
+
}
|
|
498
|
+
const now = context.now();
|
|
499
|
+
context.db.run(`INSERT INTO lane_waiters
|
|
500
|
+
(project_id, lane, worker_id, status, queued_at, acquired_at, cancelled_at,
|
|
501
|
+
expires_at, reason, metadata_json, created_at, updated_at)
|
|
502
|
+
VALUES (?, ?, ?, 'queued', ?, NULL, NULL, ?, ?, '{}', ?, ?)`, [input.projectId, input.lane, input.workerId, now, input.expiresAt, input.reason, now, now]);
|
|
503
|
+
const waiter = activeLaneWaiterForWorker(context, input.projectId, input.lane, input.workerId);
|
|
504
|
+
if (!waiter) {
|
|
505
|
+
throw new AppError("lane_waiter_create_failed", "Failed to create lane waiter", 1, {
|
|
506
|
+
lane: input.lane,
|
|
507
|
+
worker_id: input.workerId
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
appendAudit(context, {
|
|
511
|
+
projectId: input.projectId,
|
|
512
|
+
eventType: "lane_waiter.queued",
|
|
513
|
+
reason: input.reason,
|
|
514
|
+
payload: { project_id: input.projectId, lane: input.lane, worker_id: input.workerId, waiter_id: waiter.id }
|
|
515
|
+
});
|
|
516
|
+
return waiter;
|
|
517
|
+
}
|
|
518
|
+
function tryAcquireQueuedLaneLock(context, input) {
|
|
519
|
+
expireStaleLocks(context, input.projectId);
|
|
520
|
+
expireStaleWaiters(context, input.projectId);
|
|
521
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
522
|
+
try {
|
|
523
|
+
const existingLock = activeLaneLock(context, input.projectId, input.lane);
|
|
524
|
+
if (existingLock?.worker_id === input.workerId) {
|
|
525
|
+
context.db.exec("COMMIT");
|
|
526
|
+
return {
|
|
527
|
+
acquired: true,
|
|
528
|
+
payload: {
|
|
529
|
+
ok: true,
|
|
530
|
+
acquired: true,
|
|
531
|
+
status: "acquired",
|
|
532
|
+
lane: input.lane,
|
|
533
|
+
worker_id: input.workerId,
|
|
534
|
+
waiter: null,
|
|
535
|
+
lock: existingLock,
|
|
536
|
+
reused: true,
|
|
537
|
+
position: 0
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
const latest = latestLaneWaiterForWorker(context, input.projectId, input.lane, input.workerId);
|
|
542
|
+
if (latest && ["cancelled", "expired"].includes(latest.status) && latest.updated_at >= input.invocationStartedAt) {
|
|
543
|
+
context.db.exec("COMMIT");
|
|
544
|
+
return {
|
|
545
|
+
acquired: false,
|
|
546
|
+
payload: {
|
|
547
|
+
ok: true,
|
|
548
|
+
acquired: false,
|
|
549
|
+
status: latest.status,
|
|
550
|
+
lane: input.lane,
|
|
551
|
+
worker_id: input.workerId,
|
|
552
|
+
waiter: latest,
|
|
553
|
+
lock: existingLock ?? null,
|
|
554
|
+
position: null
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
const waiter = ensureQueuedLaneWaiter(context, {
|
|
559
|
+
projectId: input.projectId,
|
|
560
|
+
lane: input.lane,
|
|
561
|
+
workerId: input.workerId,
|
|
562
|
+
reason: input.reason,
|
|
563
|
+
expiresAt: input.waiterDeadline
|
|
564
|
+
});
|
|
565
|
+
const head = firstQueuedLaneWaiter(context, input.projectId, input.lane);
|
|
566
|
+
if (!existingLock && head?.id === waiter.id) {
|
|
567
|
+
const lockResult = acquireLaneLock(context, {
|
|
568
|
+
projectRoot: input.projectRoot,
|
|
569
|
+
lane: input.lane,
|
|
570
|
+
workerId: input.workerId,
|
|
571
|
+
workspacePath: input.workspacePath,
|
|
572
|
+
ttlSeconds: input.ttlSeconds,
|
|
573
|
+
reason: input.reason
|
|
574
|
+
});
|
|
575
|
+
const now = context.now();
|
|
576
|
+
context.db.run(`UPDATE lane_waiters
|
|
577
|
+
SET status = 'acquired', acquired_at = ?, updated_at = ?
|
|
578
|
+
WHERE id = ? AND status = 'queued'`, [now, now, waiter.id]);
|
|
579
|
+
const acquiredWaiter = laneWaiterById(context, waiter.id);
|
|
580
|
+
appendAudit(context, {
|
|
581
|
+
projectId: input.projectId,
|
|
582
|
+
eventType: "lane_waiter.acquired",
|
|
583
|
+
reason: input.reason,
|
|
584
|
+
payload: { project_id: input.projectId, lane: input.lane, worker_id: input.workerId, waiter_id: waiter.id }
|
|
585
|
+
});
|
|
586
|
+
context.db.exec("COMMIT");
|
|
587
|
+
return {
|
|
588
|
+
acquired: true,
|
|
589
|
+
payload: {
|
|
590
|
+
ok: true,
|
|
591
|
+
acquired: true,
|
|
592
|
+
status: "acquired",
|
|
593
|
+
lane: input.lane,
|
|
594
|
+
worker_id: input.workerId,
|
|
595
|
+
waiter: acquiredWaiter,
|
|
596
|
+
lock: lockResult.lock,
|
|
597
|
+
reused: lockResult.reused,
|
|
598
|
+
position: 0
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
const position = queuedWaiterPosition(context, input.projectId, input.lane, waiter.id);
|
|
603
|
+
context.db.exec("COMMIT");
|
|
604
|
+
return {
|
|
605
|
+
acquired: false,
|
|
606
|
+
payload: {
|
|
607
|
+
ok: true,
|
|
608
|
+
acquired: false,
|
|
609
|
+
status: "queued",
|
|
610
|
+
lane: input.lane,
|
|
611
|
+
worker_id: input.workerId,
|
|
612
|
+
waiter,
|
|
613
|
+
lock: existingLock ?? null,
|
|
614
|
+
position
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
context.db.exec("ROLLBACK");
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
291
623
|
function resolveExistingPath(value) {
|
|
292
624
|
const absolute = path.resolve(value);
|
|
293
625
|
if (!fs.existsSync(absolute)) {
|
|
@@ -2,9 +2,9 @@ import { loadProjectFlowContract } from "../domain/flow-contract.js";
|
|
|
2
2
|
import { AppError } from "../shared/errors.js";
|
|
3
3
|
import { appendAudit } from "./audit.js";
|
|
4
4
|
import { requireProjectByRoot } from "./projects.js";
|
|
5
|
-
import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
5
|
+
import { persistProtocolState, protocolRunDiagnostics, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
-
import {
|
|
7
|
+
import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, requireLaneLockOwner, waitAcquireLaneLock } from "./lanes.js";
|
|
8
8
|
import { stopMergeWorker, stoppedMergeWorkerState } from "./sessions.js";
|
|
9
9
|
const mergeLockTtlSeconds = 300;
|
|
10
10
|
export function getMergeQueueStatus(context, input) {
|
|
@@ -40,14 +40,14 @@ export function claimNextMergeJob(context, input) {
|
|
|
40
40
|
LIMIT 1`, [project.id]);
|
|
41
41
|
if (!job) {
|
|
42
42
|
context.db.exec("COMMIT");
|
|
43
|
-
return { ok: true,
|
|
43
|
+
return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
|
|
44
44
|
}
|
|
45
45
|
const update = context.db.run(`UPDATE merge_queue
|
|
46
46
|
SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
|
|
47
47
|
WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
|
|
48
48
|
if (update.changes !== 1) {
|
|
49
49
|
context.db.exec("COMMIT");
|
|
50
|
-
return { ok: true,
|
|
50
|
+
return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
|
|
51
51
|
}
|
|
52
52
|
appendAudit(context, {
|
|
53
53
|
protocolId: job.protocol_id,
|
|
@@ -63,7 +63,11 @@ export function claimNextMergeJob(context, input) {
|
|
|
63
63
|
context.db.exec("ROLLBACK");
|
|
64
64
|
throw error;
|
|
65
65
|
}
|
|
66
|
-
return
|
|
66
|
+
return mergeQueueResult(claimedProtocolId ? queueJobByProtocol(context, claimedProtocolId) : null, {
|
|
67
|
+
ok: true,
|
|
68
|
+
outcome: claimedProtocolId ? "claimed" : "empty",
|
|
69
|
+
claimed: Boolean(claimedProtocolId)
|
|
70
|
+
});
|
|
67
71
|
}
|
|
68
72
|
export async function waitNextMergeJob(context, input) {
|
|
69
73
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -82,19 +86,43 @@ export async function waitNextMergeJob(context, input) {
|
|
|
82
86
|
}
|
|
83
87
|
const initialStopState = stoppedMergeWorkerState(context, project.id, input.workerId);
|
|
84
88
|
if (initialStopState.stopped) {
|
|
85
|
-
return
|
|
89
|
+
return mergeQueueResult(null, {
|
|
90
|
+
ok: true,
|
|
91
|
+
outcome: "stopped",
|
|
92
|
+
wait_requested: input.acquireLock,
|
|
93
|
+
waited: false,
|
|
94
|
+
timed_out: false,
|
|
95
|
+
stopped: true,
|
|
96
|
+
reason: initialStopState.reason
|
|
97
|
+
});
|
|
86
98
|
}
|
|
87
99
|
let releaseLockOnTimeout = false;
|
|
88
100
|
if (input.acquireLock) {
|
|
89
101
|
ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath: input.workspacePath });
|
|
90
|
-
const lockResult =
|
|
102
|
+
const lockResult = (await waitAcquireLaneLock(context, {
|
|
91
103
|
projectRoot: project.root,
|
|
92
104
|
lane: "merge",
|
|
93
105
|
workerId: input.workerId,
|
|
94
106
|
workspacePath: input.workspacePath,
|
|
95
107
|
ttlSeconds: mergeLockTtlSeconds,
|
|
108
|
+
timeoutSeconds,
|
|
109
|
+
pollIntervalSeconds,
|
|
96
110
|
reason: "merge queue wait-next"
|
|
97
|
-
});
|
|
111
|
+
}));
|
|
112
|
+
if (!lockResult.acquired) {
|
|
113
|
+
const outcome = lockResult.timed_out ? "wait_timeout" : "blocked";
|
|
114
|
+
return mergeQueueResult(null, {
|
|
115
|
+
ok: true,
|
|
116
|
+
outcome,
|
|
117
|
+
wait_requested: true,
|
|
118
|
+
waited: true,
|
|
119
|
+
timed_out: Boolean(lockResult.timed_out),
|
|
120
|
+
lane_waiter: lockResult.waiter ?? null,
|
|
121
|
+
lane_lock: lockResult.lock ?? null,
|
|
122
|
+
lane_waiter_status: lockResult.status ?? null,
|
|
123
|
+
position: lockResult.position ?? null
|
|
124
|
+
});
|
|
125
|
+
}
|
|
98
126
|
releaseLockOnTimeout = !lockResult.reused;
|
|
99
127
|
}
|
|
100
128
|
else {
|
|
@@ -124,7 +152,15 @@ export async function waitNextMergeJob(context, input) {
|
|
|
124
152
|
...(stopState.reason ? { reason: stopState.reason } : {}),
|
|
125
153
|
payload: { project_id: project.id, worker_id: input.workerId }
|
|
126
154
|
});
|
|
127
|
-
return
|
|
155
|
+
return mergeQueueResult(null, {
|
|
156
|
+
ok: true,
|
|
157
|
+
outcome: "stopped",
|
|
158
|
+
wait_requested: input.acquireLock,
|
|
159
|
+
waited: true,
|
|
160
|
+
timed_out: false,
|
|
161
|
+
stopped: true,
|
|
162
|
+
reason: stopState.reason
|
|
163
|
+
});
|
|
128
164
|
}
|
|
129
165
|
if (input.acquireLock) {
|
|
130
166
|
heartbeatLaneLock(context, {
|
|
@@ -141,7 +177,14 @@ export async function waitNextMergeJob(context, input) {
|
|
|
141
177
|
workspacePath: input.workspacePath
|
|
142
178
|
});
|
|
143
179
|
if (claimed.job) {
|
|
144
|
-
return {
|
|
180
|
+
return {
|
|
181
|
+
...claimed,
|
|
182
|
+
outcome: "claimed",
|
|
183
|
+
claimed: true,
|
|
184
|
+
wait_requested: input.acquireLock,
|
|
185
|
+
waited: true,
|
|
186
|
+
timed_out: false
|
|
187
|
+
};
|
|
145
188
|
}
|
|
146
189
|
if (timeoutSeconds > 0 && Date.now() - start >= timeoutSeconds * 1000) {
|
|
147
190
|
appendAudit(context, {
|
|
@@ -158,7 +201,13 @@ export async function waitNextMergeJob(context, input) {
|
|
|
158
201
|
reason: "merge queue wait-next timeout"
|
|
159
202
|
});
|
|
160
203
|
}
|
|
161
|
-
return
|
|
204
|
+
return mergeQueueResult(null, {
|
|
205
|
+
ok: true,
|
|
206
|
+
outcome: "wait_timeout",
|
|
207
|
+
wait_requested: input.acquireLock,
|
|
208
|
+
waited: true,
|
|
209
|
+
timed_out: true
|
|
210
|
+
});
|
|
162
211
|
}
|
|
163
212
|
await delay(pollIntervalSeconds * 1000);
|
|
164
213
|
}
|
|
@@ -194,7 +243,13 @@ export function completeMergeJob(context, input) {
|
|
|
194
243
|
payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary, flow_contract_id: flowContract.id }
|
|
195
244
|
});
|
|
196
245
|
const stop_after_current = stopAfterCurrentIfRequested(context, protocol.project_id, protocol.project_root, input.workerId, "merge complete after stop request");
|
|
197
|
-
return
|
|
246
|
+
return mergeQueueResult(queueJobByProtocol(context, protocol.id), {
|
|
247
|
+
ok: true,
|
|
248
|
+
outcome: "merged",
|
|
249
|
+
merged: true,
|
|
250
|
+
next_stage: targetStage,
|
|
251
|
+
stop_after_current
|
|
252
|
+
});
|
|
198
253
|
}
|
|
199
254
|
export function noteMergeJob(context, input) {
|
|
200
255
|
const protocol = requireProtocol(context, input.protocolId);
|
|
@@ -224,7 +279,7 @@ export function noteMergeJob(context, input) {
|
|
|
224
279
|
eventType: "merge_queue.note",
|
|
225
280
|
payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary }
|
|
226
281
|
});
|
|
227
|
-
return { ok: true,
|
|
282
|
+
return mergeQueueResult(queueJobByProtocol(context, protocol.id), { ok: true, outcome: "noted" });
|
|
228
283
|
}
|
|
229
284
|
export function failMergeJob(context, input) {
|
|
230
285
|
const protocol = requireProtocol(context, input.protocolId);
|
|
@@ -258,7 +313,11 @@ export function failMergeJob(context, input) {
|
|
|
258
313
|
payload: { protocol_id: protocol.id, worker_id: input.workerId, requeue: input.requeue }
|
|
259
314
|
});
|
|
260
315
|
const stop_after_current = stopAfterCurrentIfRequested(context, protocol.project_id, protocol.project_root, input.workerId, "merge fail after stop request");
|
|
261
|
-
return
|
|
316
|
+
return mergeQueueResult(queueJobByProtocol(context, protocol.id), {
|
|
317
|
+
ok: true,
|
|
318
|
+
outcome: input.requeue ? "requeued" : "failed",
|
|
319
|
+
stop_after_current
|
|
320
|
+
});
|
|
262
321
|
}
|
|
263
322
|
export function cancelMergeQueueJob(context, input) {
|
|
264
323
|
const protocol = requireProtocol(context, input.protocolId);
|
|
@@ -275,7 +334,7 @@ export function cancelMergeQueueJob(context, input) {
|
|
|
275
334
|
}
|
|
276
335
|
if (job.status === "cancelled") {
|
|
277
336
|
context.db.exec("COMMIT");
|
|
278
|
-
return { ok: true,
|
|
337
|
+
return mergeQueueResult(job, { ok: true, outcome: "cancelled", cancelled: false, reason: "already_cancelled" });
|
|
279
338
|
}
|
|
280
339
|
if (job.status === "claimed" && !input.force) {
|
|
281
340
|
if (!input.workerId) {
|
|
@@ -317,7 +376,7 @@ export function cancelMergeQueueJob(context, input) {
|
|
|
317
376
|
context.db.exec("ROLLBACK");
|
|
318
377
|
throw error;
|
|
319
378
|
}
|
|
320
|
-
return { ok: true,
|
|
379
|
+
return mergeQueueResult(queueJobByProtocol(context, protocol.id), { ok: true, outcome: "cancelled", cancelled: true });
|
|
321
380
|
}
|
|
322
381
|
export function queueForProject(context, projectId) {
|
|
323
382
|
return context.db.all(`SELECT * FROM merge_queue WHERE project_id = ? ORDER BY created_at ASC, id ASC`, [projectId]);
|
|
@@ -325,6 +384,29 @@ export function queueForProject(context, projectId) {
|
|
|
325
384
|
function queueJobByProtocol(context, protocolId) {
|
|
326
385
|
return context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocolId]);
|
|
327
386
|
}
|
|
387
|
+
function mergeQueueResult(job, extra) {
|
|
388
|
+
const queueItem = job ?? null;
|
|
389
|
+
return {
|
|
390
|
+
...extra,
|
|
391
|
+
queue_item: queueItem,
|
|
392
|
+
protocol: queueItem
|
|
393
|
+
? {
|
|
394
|
+
id: queueItem.protocol_id,
|
|
395
|
+
queue_status: queueItem.status,
|
|
396
|
+
project_id: queueItem.project_id
|
|
397
|
+
}
|
|
398
|
+
: null,
|
|
399
|
+
claim: queueItem?.claimed_by_session_id
|
|
400
|
+
? {
|
|
401
|
+
protocol_id: queueItem.protocol_id,
|
|
402
|
+
worker_id: queueItem.claimed_by_session_id,
|
|
403
|
+
claimed_at: queueItem.claimed_at,
|
|
404
|
+
status: queueItem.status
|
|
405
|
+
}
|
|
406
|
+
: null,
|
|
407
|
+
job: queueItem
|
|
408
|
+
};
|
|
409
|
+
}
|
|
328
410
|
function requireClaimedJob(context, protocolId, sessionId) {
|
|
329
411
|
const job = queueJobByProtocol(context, protocolId);
|
|
330
412
|
if (!job || job.status !== "claimed") {
|
|
@@ -349,6 +431,14 @@ function transitionClaimedProtocolToIntegration(context, protocolId, workerId, n
|
|
|
349
431
|
allowed: ["ready_for_merge", "queued_for_merge"]
|
|
350
432
|
});
|
|
351
433
|
}
|
|
434
|
+
const diagnostics = protocolRunDiagnostics(context, protocol, state).diagnostics;
|
|
435
|
+
const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
436
|
+
if (blockingDiagnostics.length > 0) {
|
|
437
|
+
throw new AppError("merge_protocol_run_mismatch", "Cannot claim a protocol while protocol and run evidence disagree", 1, {
|
|
438
|
+
protocol_id: protocolId,
|
|
439
|
+
diagnostics: blockingDiagnostics
|
|
440
|
+
});
|
|
441
|
+
}
|
|
352
442
|
persistProtocolState(context, protocol, {
|
|
353
443
|
...state,
|
|
354
444
|
stage: "integration",
|