Ver Fonte

feat(cli): one interactive `codegraph daemon` command, replaces stop/list (#863)

Collapses the unreleased daemon controls into a single interactive command.
`codegraph daemon` (alias `daemons`) opens an arrow-key picker (current project's
daemon first, pre-selected), enter stops it, or pick "Stop all"; non-TTY prints a
plain list. Removes stop/list/ps; reuses the unchanged daemon-registry machinery;
the pick->stop loop is in daemon-manager.ts behind an injectable select (unit
tested). Validated live on macOS/Linux (real clack picker driven via pty) and
Windows (real runDaemonPicker + stopDaemonAt against a real daemon). Closes #845
follow-up.
Colby Mchenry há 1 semana atrás
pai
commit
ff288ac711
4 ficheiros alterados com 267 adições e 58 exclusões
  1. 1 1
      CHANGELOG.md
  2. 113 0
      __tests__/daemon-manager.test.ts
  3. 36 57
      src/bin/codegraph.ts
  4. 117 0
      src/mcp/daemon-manager.ts

+ 1 - 1
CHANGELOG.md

@@ -11,7 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
-- New `codegraph list` and `codegraph stop` commands for managing the background daemon. `codegraph list` (alias `ps`) shows every running CodeGraph daemon — project, pid, version, uptime — with `--json` for scripting. `codegraph stop` stops the daemon for the current project (or `codegraph stop <path>`, or `codegraph stop --all` to stop every daemon on the machine). Previously the only way to shut a daemon down was to hunt for its pid and `kill` it by hand. (#845)
+- New `codegraph daemon` command (alias `daemons`) — an interactive manager for the background daemons. It shows what's running (your current project's daemon first, pre-selected), and you arrow-key to one and press enter to stop it, or pick "Stop all". Previously the only way to shut a daemon down was to hunt for its pid and `kill` it by hand. (#845)
 - The CodeGraph MCP server now self-heals if its main thread ever locks up. A lightweight watchdog notices when the process has stopped responding and stops it so a fresh one starts on your next request — it can no longer sit pinned at 100% CPU with no way to recover. Tune the detection window with `CODEGRAPH_WATCHDOG_TIMEOUT_MS`, or turn it off entirely with `CODEGRAPH_NO_WATCHDOG=1`. (#850)
 
 ### Fixes

+ 113 - 0
__tests__/daemon-manager.test.ts

@@ -0,0 +1,113 @@
+import { describe, it, expect } from 'vitest';
+import {
+  formatUptime,
+  buildPickItems,
+  runDaemonPicker,
+  STOP_ALL,
+  CANCEL,
+  type PickerDeps,
+} from '../src/mcp/daemon-manager';
+import type { DaemonRecord, StopResult } from '../src/mcp/daemon-registry';
+
+const rec = (root: string, pid: number, startedAt: number): DaemonRecord => ({
+  root, pid, version: '1.0.0', socketPath: `${root}/.codegraph/daemon.sock`, startedAt,
+});
+
+describe('formatUptime', () => {
+  it('formats seconds / minutes / hours', () => {
+    expect(formatUptime(45_000)).toBe('45s');
+    expect(formatUptime(12 * 60_000)).toBe('12m');
+    expect(formatUptime((3 * 60 + 5) * 60_000)).toBe('3h 5m');
+  });
+});
+
+describe('buildPickItems', () => {
+  const old = rec('/p/old', 1, 1000);
+  const fresh = rec('/p/new', 2, 2000);
+  const cwd = rec('/p/cwd', 3, 500);
+
+  it('orders newest-first and appends Stop all + Cancel', () => {
+    const items = buildPickItems([old, fresh], null, 3000);
+    expect(items.map((i) => i.value)).toEqual(['/p/new', '/p/old', STOP_ALL, CANCEL]);
+    expect(items[0].hint).toContain('pid 2');
+    expect(items[0].hint).toContain('Running');
+  });
+
+  it('omits Stop all for a single daemon (but keeps Cancel)', () => {
+    expect(buildPickItems([old], null, 3000).map((i) => i.value)).toEqual(['/p/old', CANCEL]);
+  });
+
+  it('floats the current project to the top, auto-selected and labelled', () => {
+    const items = buildPickItems([old, fresh, cwd], '/p/cwd', 3000);
+    expect(items[0].value).toBe('/p/cwd');
+    expect(items[0].label).toContain('(current project)');
+    expect(items.slice(1, 3).map((i) => i.value)).toEqual(['/p/new', '/p/old']); // rest newest-first
+  });
+});
+
+describe('runDaemonPicker', () => {
+  // A fake registry whose list shrinks as daemons are stopped (like the real one).
+  function harness(initial: DaemonRecord[], choices: unknown[]) {
+    let daemons = [...initial];
+    const stopped: string[] = [];
+    const notes: string[] = [];
+    let doneMsg = '';
+    let i = 0;
+    const CANCEL_SYMBOL = Symbol('cancel');
+    const deps: PickerDeps = {
+      list: () => daemons,
+      stop: async (root): Promise<StopResult> => {
+        daemons = daemons.filter((d) => d.root !== root);
+        stopped.push(root);
+        return { root, pid: 0, outcome: 'term' };
+      },
+      stopAll: async (): Promise<StopResult[]> => {
+        const all = daemons.map((d) => ({ root: d.root, pid: d.pid, outcome: 'term' as const }));
+        daemons = [];
+        stopped.push('ALL');
+        return all;
+      },
+      cwdRoot: null,
+      now: () => 5000,
+      select: async () => choices[i++],
+      isCancel: (v) => v === CANCEL_SYMBOL,
+      note: (m) => notes.push(m),
+      done: (m) => { doneMsg = m; },
+    };
+    return { deps, stopped, notes, getDone: () => doneMsg, CANCEL_SYMBOL };
+  }
+
+  it('stops the chosen daemon, then re-prompts and exits on Cancel', async () => {
+    const h = harness([rec('/p/a', 1, 1), rec('/p/b', 2, 2)], ['/p/b', CANCEL]);
+    await runDaemonPicker(h.deps);
+    expect(h.stopped).toEqual(['/p/b']);
+    expect(h.getDone()).toContain('Cancelled');
+  });
+
+  it('keeps stopping until none remain', async () => {
+    const h = harness([rec('/p/a', 1, 1), rec('/p/b', 2, 2)], ['/p/a', '/p/b']);
+    await runDaemonPicker(h.deps);
+    expect(h.stopped).toEqual(['/p/a', '/p/b']);
+    expect(h.getDone()).toContain('All daemons stopped');
+  });
+
+  it('Stop all stops everything in one shot', async () => {
+    const h = harness([rec('/p/a', 1, 1), rec('/p/b', 2, 2)], [STOP_ALL]);
+    await runDaemonPicker(h.deps);
+    expect(h.stopped).toEqual(['ALL']);
+    expect(h.getDone()).toBe('Done.');
+  });
+
+  it('Cancel (and Esc/Ctrl-C) stop nothing', async () => {
+    const h1 = harness([rec('/p/a', 1, 1)], [CANCEL]);
+    await runDaemonPicker(h1.deps);
+    expect(h1.stopped).toEqual([]);
+    expect(h1.getDone()).toContain('Cancelled');
+
+    const h2 = harness([rec('/p/a', 1, 1)], [/* will use the cancel symbol */]);
+    h2.deps.select = async () => h2.CANCEL_SYMBOL;
+    await runDaemonPicker(h2.deps);
+    expect(h2.stopped).toEqual([]);
+    expect(h2.getDone()).toContain('Cancelled');
+  });
+});

+ 36 - 57
src/bin/codegraph.ts

@@ -1269,72 +1269,51 @@ function printFileTree(
 }
 
 /**
- * codegraph stop — stop the background daemon for a project (or --all).
+ * codegraph daemon — interactive manager for the background daemons. Arrow keys
+ * to pick one (the current project's daemon floats to the top, auto-selected),
+ * enter to stop it. Falls back to a plain list when output isn't a TTY.
  */
 program
-  .command('stop [path]')
-  .description('Stop the background CodeGraph daemon for a project (defaults to the current one)')
-  .option('-a, --all', 'Stop every running CodeGraph daemon on this machine')
-  .action(async (pathArg: string | undefined, options: { all?: boolean }) => {
-    const { stopDaemonAt, stopAllDaemons } = await import('../mcp/daemon-registry');
-    try {
-      if (options.all) {
-        const results = await stopAllDaemons();
-        const stopped = results.filter((r) => r.outcome === 'term' || r.outcome === 'kill');
-        if (stopped.length === 0) {
-          info('No running CodeGraph daemons.');
-          return;
-        }
-        for (const r of stopped) {
-          success(`Stopped daemon (pid ${r.pid}${r.outcome === 'kill' ? ', forced' : ''}) — ${r.root}`);
-        }
-        return;
-      }
+  .command('daemon')
+  .aliases(['daemons'])
+  .description('Manage running CodeGraph background daemons — pick one and press enter to stop it')
+  .action(async () => {
+    const { listDaemons, stopDaemonAt, stopAllDaemons } = await import('../mcp/daemon-registry');
+    const { runDaemonPicker } = await import('../mcp/daemon-manager');
 
-      const found = findNearestCodeGraphRoot(path.resolve(pathArg || process.cwd()));
-      if (!found) {
-        error('No CodeGraph project found here. Run inside a project, pass a path, or use --all.');
-        process.exit(1);
-      }
-      let root = found;
-      try { root = fs.realpathSync(found); } catch { /* fall back to the un-realpath'd root */ }
-
-      const result = await stopDaemonAt(root);
-      if (result.outcome === 'no-daemon' || result.outcome === 'not-running') {
-        info(`No daemon running for ${root}.`);
-      } else {
-        success(`Stopped daemon (pid ${result.pid}${result.outcome === 'kill' ? ', forced' : ''}) for ${root}.`);
-      }
-    } catch (err) {
-      error(`Failed to stop daemon: ${err instanceof Error ? err.message : String(err)}`);
-      process.exit(1);
-    }
-  });
-
-/**
- * codegraph list — show running background daemons.
- */
-program
-  .command('list')
-  .alias('ps')
-  .description('List running CodeGraph background daemons')
-  .option('--json', 'Output as JSON')
-  .action(async (options: { json?: boolean }) => {
-    const { listDaemons } = await import('../mcp/daemon-registry');
     const daemons = listDaemons();
-
-    if (options.json) {
-      process.stdout.write(JSON.stringify(daemons, null, 2) + '\n');
-      return;
-    }
     if (daemons.length === 0) {
       info('No CodeGraph daemons running.');
       return;
     }
-    for (const d of daemons) {
-      console.log(`pid ${d.pid}  v${d.version}  up ${formatDuration(Date.now() - d.startedAt)}  ${d.root}`);
+
+    // No TTY (piped / CI / non-interactive) — can't do arrow-key selection, so
+    // just print what's running instead of crashing on a prompt with no input.
+    if (!process.stdout.isTTY || !process.stdin.isTTY) {
+      for (const d of daemons) {
+        console.log(`pid ${d.pid}  v${d.version}  up ${formatDuration(Date.now() - d.startedAt)}  ${d.root}`);
+      }
+      return;
     }
-    info('Stop one with "codegraph stop <path>", or all with "codegraph stop --all".');
+
+    // The current project's daemon floats to the top and is pre-selected.
+    let cwdRoot: string | null = null;
+    const found = findNearestCodeGraphRoot(process.cwd());
+    if (found) { try { cwdRoot = fs.realpathSync(found); } catch { cwdRoot = found; } }
+
+    const clack = await importESM('@clack/prompts');
+    clack.intro('CodeGraph daemons');
+    await runDaemonPicker({
+      list: listDaemons,
+      stop: stopDaemonAt,
+      stopAll: stopAllDaemons,
+      cwdRoot,
+      now: () => Date.now(),
+      select: (opts) => clack.select(opts),
+      isCancel: (v) => clack.isCancel(v),
+      note: (m) => clack.log.success(m),
+      done: (m) => clack.outro(m),
+    });
   });
 
 /**

+ 117 - 0
src/mcp/daemon-manager.ts

@@ -0,0 +1,117 @@
+/**
+ * Interactive daemon manager — the logic behind `codegraph daemon` / `daemons`.
+ *
+ * Kept separate from the CLI (which owns the @clack/prompts wiring) so the
+ * selection/stop loop is unit-testable with a fake `select`: no TTY, no clack,
+ * no real daemons. The CLI passes the real clack `select`/`isCancel` plus the
+ * registry's list/stop functions.
+ */
+import * as path from 'path';
+import type { DaemonRecord, StopResult } from './daemon-registry';
+
+/** Sentinel option values (not real roots, so they can't collide with a project path). */
+export const STOP_ALL = '__stop_all__';
+export const CANCEL = '__cancel__';
+
+export interface PickItem {
+  value: string;
+  label: string;
+  hint?: string;
+}
+
+/** Compact uptime: `45s`, `12m`, `3h 5m`. */
+export function formatUptime(ms: number): string {
+  const s = Math.max(0, Math.floor(ms / 1000));
+  if (s < 60) return `${s}s`;
+  const m = Math.floor(s / 60);
+  if (m < 60) return `${m}m`;
+  const h = Math.floor(m / 60);
+  return `${h}h ${m % 60}m`;
+}
+
+/**
+ * Build the ordered, UI-ready option list: the current project's daemon first
+ * (so it's the auto-selected default), the rest newest-first, then "Stop all"
+ * (only when there's more than one) and "Cancel".
+ */
+export function buildPickItems(daemons: DaemonRecord[], cwdRoot: string | null, now: number): PickItem[] {
+  const cwd = cwdRoot != null ? path.resolve(cwdRoot) : null;
+  const ordered = [...daemons].sort((a, b) => {
+    if (cwd) {
+      const aCur = path.resolve(a.root) === cwd;
+      const bCur = path.resolve(b.root) === cwd;
+      if (aCur && !bCur) return -1;
+      if (bCur && !aCur) return 1;
+    }
+    return b.startedAt - a.startedAt; // newest first
+  });
+
+  const items: PickItem[] = ordered.map((d) => {
+    const current = cwd != null && path.resolve(d.root) === cwd;
+    return {
+      value: d.root,
+      label: current ? `${d.root}  (current project)` : d.root,
+      hint: `pid ${d.pid} · up ${formatUptime(now - d.startedAt)} · Running`,
+    };
+  });
+
+  if (items.length > 1) items.push({ value: STOP_ALL, label: 'Stop all', hint: '' });
+  items.push({ value: CANCEL, label: 'Cancel', hint: '' });
+  return items;
+}
+
+export interface PickerDeps {
+  list: () => DaemonRecord[];
+  stop: (root: string) => Promise<StopResult>;
+  stopAll: () => Promise<StopResult[]>;
+  /** Realpath'd root of the current project's daemon, or null. */
+  cwdRoot: string | null;
+  now: () => number;
+  /** Render the picker; returns the chosen value or a cancel sentinel. */
+  select: (opts: { message: string; options: PickItem[]; initialValue: string }) => Promise<unknown>;
+  isCancel: (v: unknown) => boolean;
+  /** Per-action note (e.g. "Stopped daemon …"). */
+  note: (msg: string) => void;
+  /** Final line + teardown (clack outro). */
+  done: (msg: string) => void;
+}
+
+/**
+ * Pick a daemon → stop it → re-prompt with what's left, until the user cancels
+ * (Esc / Ctrl-C / "Cancel"), picks "Stop all", or nothing remains.
+ */
+export async function runDaemonPicker(deps: PickerDeps): Promise<void> {
+  for (;;) {
+    const daemons = deps.list();
+    if (daemons.length === 0) {
+      deps.done('All daemons stopped.');
+      return;
+    }
+
+    const items = buildPickItems(daemons, deps.cwdRoot, deps.now());
+    const choice = await deps.select({
+      message: 'Select a daemon to stop',
+      options: items,
+      initialValue: items[0]?.value ?? CANCEL, // daemons.length > 0 here, so items[0] is a daemon
+    });
+
+    if (deps.isCancel(choice) || choice === CANCEL) {
+      deps.done('Cancelled.');
+      return;
+    }
+
+    if (choice === STOP_ALL) {
+      const results = await deps.stopAll();
+      const n = results.filter((r) => r.outcome === 'term' || r.outcome === 'kill').length;
+      deps.note(`Stopped ${n} daemon${n === 1 ? '' : 's'}.`);
+      deps.done('Done.');
+      return;
+    }
+
+    const result = await deps.stop(String(choice));
+    const forced = result.outcome === 'kill' ? ', forced' : '';
+    deps.note(`Stopped daemon (pid ${result.pid}${forced}) — ${choice}`);
+    // Loop: the next iteration re-lists; if more remain it re-prompts, otherwise
+    // the top-of-loop empty check prints "All daemons stopped."
+  }
+}