@deksden-com/dd-flow-cli 0.3.0 → 0.4.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +74 -5
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +116 -18
  5. package/dist/cli/run-cli.js +361 -29
  6. package/dist/schemas/compatibility.schema.json +81 -2
  7. package/dist/schemas/engine-manifest.schema.json +61 -0
  8. package/dist/schemas/flow-guidance.schema.json +17 -0
  9. package/dist/schemas/global-dashboard-data.schema.json +60 -2
  10. package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
  11. package/dist/schemas/project-dashboard-data.schema.json +26 -2
  12. package/dist/schemas/project-summary.schema.json +73 -0
  13. package/dist/schemas/protocol-dashboard-data.schema.json +25 -2
  14. package/dist/schemas/status-report.schema.json +4 -2
  15. package/dist/services/branch-context.js +254 -0
  16. package/dist/services/cleanup.js +31 -0
  17. package/dist/services/cli-operation-classifier.js +104 -0
  18. package/dist/services/compatibility-preflight.js +127 -0
  19. package/dist/services/config.js +6 -0
  20. package/dist/services/dashboard-targets.js +95 -0
  21. package/dist/services/dashboard.js +376 -59
  22. package/dist/services/engines.js +532 -0
  23. package/dist/services/flow-guidance.js +8 -1
  24. package/dist/services/hooks.js +1 -1
  25. package/dist/services/lanes.js +333 -1
  26. package/dist/services/merge-queue.js +310 -15
  27. package/dist/services/merge-worker.js +44 -3
  28. package/dist/services/migrations.js +231 -0
  29. package/dist/services/project-summary.js +122 -0
  30. package/dist/services/projects.js +41 -6
  31. package/dist/services/protocol-lifecycle.js +144 -0
  32. package/dist/services/protocols.js +34 -7
  33. package/dist/services/sessions.js +21 -4
  34. package/dist/services/status.js +10 -0
  35. package/dist/services/version-status.js +39 -15
  36. package/dist/storage/database.js +25 -0
  37. package/dist/storage/paths.js +12 -0
  38. package/package.json +3 -2
@@ -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)) {