daemon-manager.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. /**
  2. * Interactive daemon manager — the logic behind `codegraph daemon` / `daemons`.
  3. *
  4. * Kept separate from the CLI (which owns the @clack/prompts wiring) so the
  5. * selection/stop loop is unit-testable with a fake `select`: no TTY, no clack,
  6. * no real daemons. The CLI passes the real clack `select`/`isCancel` plus the
  7. * registry's list/stop functions.
  8. */
  9. import * as path from 'path';
  10. import type { DaemonRecord, StopResult } from './daemon-registry';
  11. /** Sentinel option values (not real roots, so they can't collide with a project path). */
  12. export const STOP_ALL = '__stop_all__';
  13. export const CANCEL = '__cancel__';
  14. export interface PickItem {
  15. value: string;
  16. label: string;
  17. hint?: string;
  18. }
  19. /** Compact uptime: `45s`, `12m`, `3h 5m`. */
  20. export function formatUptime(ms: number): string {
  21. const s = Math.max(0, Math.floor(ms / 1000));
  22. if (s < 60) return `${s}s`;
  23. const m = Math.floor(s / 60);
  24. if (m < 60) return `${m}m`;
  25. const h = Math.floor(m / 60);
  26. return `${h}h ${m % 60}m`;
  27. }
  28. /**
  29. * Build the ordered, UI-ready option list: the current project's daemon first
  30. * (so it's the auto-selected default), the rest newest-first, then "Stop all"
  31. * (only when there's more than one) and "Cancel".
  32. */
  33. export function buildPickItems(daemons: DaemonRecord[], cwdRoot: string | null, now: number): PickItem[] {
  34. const cwd = cwdRoot != null ? path.resolve(cwdRoot) : null;
  35. const ordered = [...daemons].sort((a, b) => {
  36. if (cwd) {
  37. const aCur = path.resolve(a.root) === cwd;
  38. const bCur = path.resolve(b.root) === cwd;
  39. if (aCur && !bCur) return -1;
  40. if (bCur && !aCur) return 1;
  41. }
  42. return b.startedAt - a.startedAt; // newest first
  43. });
  44. const items: PickItem[] = ordered.map((d) => {
  45. const current = cwd != null && path.resolve(d.root) === cwd;
  46. return {
  47. value: d.root,
  48. label: current ? `${d.root} (current project)` : d.root,
  49. hint: `pid ${d.pid} · up ${formatUptime(now - d.startedAt)} · Running`,
  50. };
  51. });
  52. if (items.length > 1) items.push({ value: STOP_ALL, label: 'Stop all', hint: '' });
  53. items.push({ value: CANCEL, label: 'Cancel', hint: '' });
  54. return items;
  55. }
  56. export interface PickerDeps {
  57. list: () => DaemonRecord[];
  58. stop: (root: string) => Promise<StopResult>;
  59. stopAll: () => Promise<StopResult[]>;
  60. /** Realpath'd root of the current project's daemon, or null. */
  61. cwdRoot: string | null;
  62. now: () => number;
  63. /** Render the picker; returns the chosen value or a cancel sentinel. */
  64. select: (opts: { message: string; options: PickItem[]; initialValue: string }) => Promise<unknown>;
  65. isCancel: (v: unknown) => boolean;
  66. /** Per-action note (e.g. "Stopped daemon …"). */
  67. note: (msg: string) => void;
  68. /** Final line + teardown (clack outro). */
  69. done: (msg: string) => void;
  70. }
  71. /**
  72. * Pick a daemon → stop it → re-prompt with what's left, until the user cancels
  73. * (Esc / Ctrl-C / "Cancel"), picks "Stop all", or nothing remains.
  74. */
  75. export async function runDaemonPicker(deps: PickerDeps): Promise<void> {
  76. for (;;) {
  77. const daemons = deps.list();
  78. if (daemons.length === 0) {
  79. deps.done('All daemons stopped.');
  80. return;
  81. }
  82. const items = buildPickItems(daemons, deps.cwdRoot, deps.now());
  83. const choice = await deps.select({
  84. message: 'Select a daemon to stop',
  85. options: items,
  86. initialValue: items[0]?.value ?? CANCEL, // daemons.length > 0 here, so items[0] is a daemon
  87. });
  88. if (deps.isCancel(choice) || choice === CANCEL) {
  89. deps.done('Cancelled.');
  90. return;
  91. }
  92. if (choice === STOP_ALL) {
  93. const results = await deps.stopAll();
  94. const n = results.filter((r) => r.outcome === 'term' || r.outcome === 'kill').length;
  95. deps.note(`Stopped ${n} daemon${n === 1 ? '' : 's'}.`);
  96. deps.done('Done.');
  97. return;
  98. }
  99. const result = await deps.stop(String(choice));
  100. const forced = result.outcome === 'kill' ? ', forced' : '';
  101. deps.note(`Stopped daemon (pid ${result.pid}${forced}) — ${choice}`);
  102. // Loop: the next iteration re-lists; if more remain it re-prompts, otherwise
  103. // the top-of-loop empty check prints "All daemons stopped."
  104. }
  105. }