devist 0.26.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
import { useI18n } from "@/i18n/I18nProvider";
import { type Heartbeat, listHeartbeats } from "@/lib/queries";
import { useEffect, useState } from "react";

const REFRESH_MS = 10_000;

// Each daemon thread has its own heartbeat cadence; a single global
// "30s = fresh" threshold mis-classifies the slow ones. Fresh = within
// 3× the expected cadence, stale = within 9×, dead = beyond. Unknown
// thread names fall back to a 30s baseline (the old default).
const EXPECTED_CADENCE_MS: Record<string, number> = {
  main: 10_000,
  advice: 10_000,
  jobs: 5_000,
  verify: 20_000,
  consolidate: 30_000,
  audit: 30_000,
};
const DEFAULT_CADENCE_MS = 30_000;

function thresholds(thread: string): { fresh: number; stale: number } {
  const cadence = EXPECTED_CADENCE_MS[thread] ?? DEFAULT_CADENCE_MS;
  return { fresh: cadence * 3, stale: cadence * 9 };
}

const HEALTH_RANK: Record<Health, number> = {
  fresh: 0,
  stale: 1,
  dead: 2,
  missing: 3,
};

type Health = "fresh" | "stale" | "dead" | "missing";

function ageMs(iso: string): number {
  return Date.now() - new Date(iso).getTime();
}

function classify(beats: Heartbeat[]): {
  health: Health;
  perThread: { thread: string; client_id: string; ageMs: number; health: Health }[];
} {
  if (beats.length === 0) {
    return { health: "missing", perThread: [] };
  }
  const perThread = beats.map((b) => {
    const a = ageMs(b.last_beat_at);
    const { fresh, stale } = thresholds(b.thread);
    let h: Health;
    if (a < fresh) h = "fresh";
    else if (a < stale) h = "stale";
    else h = "dead";
    return { thread: b.thread, client_id: b.client_id, ageMs: a, health: h };
  });
  // Overall = worst per-thread classification (not worst absolute age).
  // A slow-cadence thread at the edge of its own healthy window
  // shouldn't drag the summary down if it's still "fresh" by its own
  // scale.
  const overall = perThread.reduce<Health>(
    (worst, t) => (HEALTH_RANK[t.health] > HEALTH_RANK[worst] ? t.health : worst),
    "fresh",
  );
  return { health: overall, perThread };
}

const COLORS: Record<Health, { dot: string; text: string }> = {
  fresh: { dot: "bg-green-500", text: "text-green-600" },
  stale: { dot: "bg-yellow-500", text: "text-yellow-600" },
  dead: { dot: "bg-red-500", text: "text-red-600" },
  missing: { dot: "bg-muted-foreground", text: "text-muted-foreground" },
};

export default function DaemonStatus() {
  const { t } = useI18n();
  const [beats, setBeats] = useState<Heartbeat[] | null>(null);
  const [showDetail, setShowDetail] = useState(false);
  const labels: Record<Health, string> = {
    fresh: t("daemon.running"),
    stale: t("daemon.slow"),
    dead: t("daemon.stopped"),
    missing: t("daemon.never"),
  };

  useEffect(() => {
    let alive = true;
    function tick() {
      listHeartbeats()
        .then((rows) => {
          if (alive) setBeats(rows);
        })
        .catch(() => {
          if (alive) setBeats([]);
        });
    }
    tick();
    const t = setInterval(tick, REFRESH_MS);
    return () => {
      alive = false;
      clearInterval(t);
    };
  }, []);

  if (beats === null) {
    return <div className="text-xs text-muted-foreground">{t("daemon.checking")}</div>;
  }

  const { health, perThread } = classify(beats);
  const c = COLORS[health];

  return (
    <div className="text-xs">
      <button
        type="button"
        onClick={() => setShowDetail((v) => !v)}
        className={`w-full flex items-center gap-2 ${c.text} hover:opacity-80`}
        title={t("daemon.detailHint")}
      >
        <span
          className={`inline-block w-2 h-2 rounded-full ${c.dot} ${health === "fresh" ? "animate-pulse" : ""}`}
        />
        <span className="truncate">{labels[health]}</span>
      </button>
      {showDetail && perThread.length > 0 && (
        <div className="mt-1 space-y-0.5 pl-4">
          {perThread.map((t) => (
            <div
              key={`${t.client_id}/${t.thread}`}
              className={`flex items-center justify-between ${COLORS[t.health].text}`}
            >
              <span className="font-mono">{t.thread}</span>
              <span>{Math.round(t.ageMs / 1000)}s</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}