/* Dashboard state: one store owns the API client, the loaded data for every section, * the 5s polling loop, and all mutating actions. Control actions enqueue a command and * poll it to done/failed, surfacing the outcome in the command banner (`cmd`). */ "use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { AuthError, createClient, type Agent, type ApiError, type Approval, type Checkmark, type Command, type Host, type LogEntry, type Project, type Schedule, type SharedContext, } from "@/lib/api"; export type Section = | "runs" | "repositories" | "agents" | "schedules" | "approvals" | "servers" | "activity" | "shared" | "login"; /* The claude web-login flow, driven through the login_start / login_submit commands. * idle → starting → awaiting (have URL) → submitting → done | error */ export type ClaudeLoginStatus = | "idle" | "starting" | "awaiting" | "submitting" | "done" | "error"; export interface ClaudeLoginState { status: ClaudeLoginStatus; url: string; message: string; } export interface RunAgent extends Agent {} export interface CmdState { text: string; error: boolean; busy: boolean; } const LOG_LIMIT = 100; const POLL_MS = 5000; interface StoreValue { section: Section; setSection: (s: Section) => void; projects: Project[]; agents: RunAgent[]; // every agent across every project selectedProjectId: string; selectProject: (id: string) => void; // Runs inbox selectedRun: { projectId: string; name: string } | null; selectRun: (projectId: string, name: string) => void; checkmark: Checkmark | null; checkmarkMissing: boolean; log: LogEntry[]; logOffset: number; pageLog: (dir: 1 | -1) => void; approvals: Approval[]; hosts: Host[]; commands: Command[]; schedules: Schedule[]; shared: { log: LogEntry[]; context: SharedContext[] }; cmd: CmdState; lastError: string; loading: boolean; refresh: () => void; // actions spawnAgent: (body: SpawnBody) => Promise; killAgent: (projectId: string, name: string) => Promise; deleteAgent: (projectId: string, name: string) => Promise; submitAnswer: (answer: string, resume: boolean) => Promise; createProject: (b: NewProjectBody) => Promise; updateProject: (id: string, b: Omit) => Promise; deleteProject: (id: string) => Promise; syncProject: (id: string) => Promise; submitApproval: (b: ApprovalBody) => Promise; createHost: (b: HostBody) => Promise; updateHost: (hostname: string, b: Omit) => Promise; deleteHost: (hostname: string) => Promise; createSchedule: (projectId: string, b: ScheduleBody) => Promise; updateSchedule: (id: number, b: Partial & { enabled?: boolean }) => Promise; deleteSchedule: (id: number) => Promise; pollCi: () => Promise; setSharedKey: (key: string, value: string) => Promise; // Claude web-login claudeLogin: ClaudeLoginState; startClaudeLogin: () => Promise; submitClaudeCode: (code: string) => Promise; resetClaudeLogin: () => void; } export interface SpawnBody { name: string; role: string; placement: "worktree" | "subdir"; worktree: string; subdir: string; task: string; } export interface ProjectBody { id: string; root_dir: string; git_remote: string; credential_ref: string; } /* New-project form. "server" mode = pick a configured git server + owner/name (Handler * derives the remote, picks the disk location, and clones); "manual" = the old fields. */ export interface NewProjectBody { mode: "server" | "manual"; git_server: string; repo: string; id: string; root_dir: string; git_remote: string; credential_ref: string; /* Bootstrap tooling: after the clone, run an agent that writes + commits + pushes a * .mise.toml with a [tasks.test] task for the repo's stack. Needs a git remote. */ init_mise: boolean; } export interface ScheduleBody { name_prefix: string; task: string; interval_seconds: number; role: string; } export interface ApprovalBody { branch: string; status: string; agent_name: string; sha: string; note: string; } export interface HostBody { hostname: string; forge_type: string; token_env_var: string; base_url: string; /* Write-only: encrypted at rest server-side, never echoed back. Blank = no change. */ token: string; /* Create: mint a deploy keypair. Update: replace the existing one. */ generate_ssh_key: boolean; } const Ctx = createContext(null); export function useDashboard(): StoreValue { const v = useContext(Ctx); if (!v) throw new Error("useDashboard outside provider"); return v; } export function DashboardProvider({ token, onUnauthorized, children, }: { token: string; onUnauthorized: () => void; children: ReactNode; }) { const client = useMemo(() => createClient(token, onUnauthorized), [token, onUnauthorized]); const clientRef = useRef(client); clientRef.current = client; const [section, setSectionRaw] = useState
("runs"); const [projects, setProjects] = useState([]); const [agents, setAgents] = useState([]); const [selectedProjectId, setSelectedProjectId] = useState(""); const [selectedRun, setSelectedRun] = useState<{ projectId: string; name: string } | null>(null); const [checkmark, setCheckmark] = useState(null); const [checkmarkMissing, setCheckmarkMissing] = useState(false); const [log, setLog] = useState([]); const [logOffset, setLogOffset] = useState(0); const [approvals, setApprovals] = useState([]); const [hosts, setHosts] = useState([]); const [commands, setCommands] = useState([]); const [schedules, setSchedules] = useState([]); const [shared, setShared] = useState<{ log: LogEntry[]; context: SharedContext[] }>({ log: [], context: [], }); const [cmd, setCmd] = useState({ text: "", error: false, busy: false }); const [lastError, setLastError] = useState(""); const [loading, setLoading] = useState(true); const [claudeLogin, setClaudeLogin] = useState({ status: "idle", url: "", message: "", }); // Keep polling loop reading fresh values without re-subscribing every render. const sectionRef = useRef(section); sectionRef.current = section; const selectedProjectRef = useRef(selectedProjectId); selectedProjectRef.current = selectedProjectId; const selectedRunRef = useRef(selectedRun); selectedRunRef.current = selectedRun; const logOffsetRef = useRef(logOffset); logOffsetRef.current = logOffset; const swallow = (e: unknown) => { if (!(e instanceof AuthError)) setLastError((e as Error).message); }; const loadProjects = useCallback(async () => { try { const ps = await clientRef.current.api("/projects"); setProjects(ps); setLastError(""); setSelectedProjectId((cur) => cur || (ps[0]?.id ?? "")); } catch (e) { swallow(e); } }, []); const loadAgents = useCallback(async (projectList: Project[]) => { try { const results = await Promise.all( projectList.map((p) => clientRef.current .api(`/projects/${encodeURIComponent(p.id)}/agents`) .catch(() => [] as Agent[]), ), ); setAgents(results.flat()); } catch (e) { swallow(e); } }, []); const loadRun = useCallback(async (projectId: string, name: string) => { const path = `/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(name)}`; try { const cm = await clientRef.current.api(`${path}/checkmark`); setCheckmark(cm); setCheckmarkMissing(false); } catch (e) { if (e instanceof AuthError) return; if ((e as ApiError).status === 404) { setCheckmark(null); setCheckmarkMissing(true); } else swallow(e); } try { const entries = await clientRef.current.api( `${path}/log?limit=${LOG_LIMIT}&offset=${logOffsetRef.current}`, ); setLog(entries); } catch (e) { swallow(e); } }, []); const loadApprovals = useCallback(async (projectId: string) => { if (!projectId) { setApprovals([]); return; } try { setApprovals( await clientRef.current.api( `/projects/${encodeURIComponent(projectId)}/approvals`, ), ); } catch (e) { swallow(e); } }, []); const loadHosts = useCallback(async () => { try { setHosts(await clientRef.current.api("/hosts")); } catch (e) { swallow(e); } }, []); const loadCommands = useCallback(async () => { try { setCommands(await clientRef.current.api("/commands?limit=50")); } catch (e) { swallow(e); } }, []); const loadSchedules = useCallback(async () => { try { setSchedules(await clientRef.current.api("/schedules")); } catch (e) { swallow(e); } }, []); const loadShared = useCallback(async () => { try { const [logRows, context] = await Promise.all([ clientRef.current.api("/shared/log"), clientRef.current.api("/shared/context"), ]); setShared({ log: logRows, context }); } catch (e) { swallow(e); } }, []); /* One refresh cycle for whatever section is active (plus always-cheap projects/agents * so the nav counts and inbox stay live). */ const tick = useCallback(async () => { const ps = await clientRef.current .api("/projects") .catch((e) => { swallow(e); return null; }); if (ps) { setProjects(ps); setSelectedProjectId((cur) => cur || (ps[0]?.id ?? "")); await loadAgents(ps); } const s = sectionRef.current; const run = selectedRunRef.current; if (run) await loadRun(run.projectId, run.name); if (s === "approvals") await loadApprovals(selectedProjectRef.current); if (s === "servers") await loadHosts(); if (s === "activity") await loadCommands(); if (s === "schedules") await loadSchedules(); if (s === "shared") await loadShared(); }, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared]); // Initial load + polling loop. The first tick populates projects *and* agents (and the // active section) up front, so the Runs inbox is filled without waiting a poll interval. useEffect(() => { let alive = true; (async () => { setLoading(true); await tick(); if (alive) setLoading(false); })(); const id = setInterval(() => { if (!document.hidden) void tick(); }, POLL_MS); return () => { alive = false; clearInterval(id); }; }, [tick]); const setSection = useCallback( (s: Section) => { setSectionRaw(s); setCmd({ text: "", error: false, busy: false }); if (s === "approvals") void loadApprovals(selectedProjectRef.current); if (s === "servers") void loadHosts(); if (s === "activity") void loadCommands(); if (s === "schedules") void loadSchedules(); if (s === "shared") void loadShared(); }, [loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared], ); const selectProject = useCallback( (id: string) => { setSelectedProjectId(id); if (sectionRef.current === "approvals") void loadApprovals(id); }, [loadApprovals], ); const selectRun = useCallback( (projectId: string, name: string) => { setSelectedRun({ projectId, name }); setLogOffset(0); logOffsetRef.current = 0; setCheckmark(null); setCheckmarkMissing(false); setLog([]); void loadRun(projectId, name); }, [loadRun], ); const pageLog = useCallback( (dir: 1 | -1) => { const next = Math.max(0, logOffset + dir * LOG_LIMIT); if (next === logOffset) return; setLogOffset(next); logOffsetRef.current = next; const run = selectedRunRef.current; if (run) void loadRun(run.projectId, run.name); }, [logOffset, loadRun], ); const refresh = useCallback(() => { void tick(); }, [tick]); // ---- control actions (enqueue + track) ---- const enqueueAndTrack = useCallback( async (path: string, body: unknown, label: string): Promise => { setCmd({ text: `${label}: queued…`, error: false, busy: true }); try { const command = await clientRef.current.api(path, { method: "POST", body }); const final = await clientRef.current.trackCommand(command.id); if (!final) { setCmd({ text: `${label}: still running (see Activity). Is the worker up?`, error: false, busy: false, }); return null; } const ok = final.status === "done"; const detail = final.error || (final.result ? JSON.stringify(final.result) : ""); setCmd({ text: `${label} ${ok ? "done" : "failed"}${detail ? " — " + detail : ""}`, error: !ok, busy: false, }); return final; } catch (e) { if (e instanceof AuthError) return null; setCmd({ text: `${label} failed: ${(e as Error).message}`, error: true, busy: false }); return null; } }, [], ); const spawnAgent = useCallback( async (f: SpawnBody) => { const body: Record = { name: f.name.trim(), role: f.role || null, task: f.task.trim() || null, }; if (f.placement === "worktree" && f.worktree.trim()) body.worktree = f.worktree.trim(); if (f.placement === "subdir" && f.subdir.trim()) body.subdir = f.subdir.trim(); const p = encodeURIComponent(selectedProjectRef.current); const final = await enqueueAndTrack(`/projects/${p}/agents/spawn`, body, `spawn ${body.name}`); await loadAgents(projects); return final?.status === "done"; }, [enqueueAndTrack, loadAgents, projects], ); const killAgent = useCallback( async (projectId: string, name: string) => { const p = encodeURIComponent(projectId); await enqueueAndTrack(`/projects/${p}/agents/${encodeURIComponent(name)}/kill`, undefined, `kill ${name}`); await loadAgents(projects); }, [enqueueAndTrack, loadAgents, projects], ); const deleteAgent = useCallback( async (projectId: string, name: string) => { const p = encodeURIComponent(projectId); try { await clientRef.current.api(`/projects/${p}/agents/${encodeURIComponent(name)}`, { method: "DELETE", }); setCmd({ text: `agent '${name}' row deleted`, error: false, busy: false }); if (selectedRunRef.current?.name === name) setSelectedRun(null); await loadAgents(projects); } catch (e) { if (e instanceof AuthError) return; setCmd({ text: (e as Error).message, error: true, busy: false }); } }, [loadAgents, projects], ); const submitAnswer = useCallback( async (answer: string, resume: boolean) => { const run = selectedRunRef.current; if (!run) return false; const path = `/projects/${encodeURIComponent(run.projectId)}/agents/${encodeURIComponent(run.name)}`; try { await clientRef.current.api(`${path}/answer`, { method: "POST", body: { answer } }); if (resume) { await enqueueAndTrack(`${path}/resume`, { answer }, "resume"); } else { setCmd({ text: "Answer saved (agent still paused).", error: false, busy: false }); } await loadAgents(projects); await loadRun(run.projectId, run.name); return true; } catch (e) { if (e instanceof AuthError) return false; setCmd({ text: (e as Error).message, error: true, busy: false }); return false; } }, [enqueueAndTrack, loadAgents, loadRun, projects], ); const createProject = useCallback( async (b: NewProjectBody) => { try { const body: Record = b.mode === "server" ? { git_server: b.git_server, repo: b.repo.trim(), id: b.id.trim() || null, credential_ref: b.credential_ref.trim() || null, init_mise: b.init_mise, } : { id: b.id.trim(), root_dir: b.root_dir.trim(), git_remote: b.git_remote.trim() || null, credential_ref: b.credential_ref.trim() || null, init_mise: b.init_mise, }; const created = await clientRef.current.api("/projects", { method: "POST", body, }); await loadProjects(); // Server mode enqueues a clone; follow it so the operator sees the repo land. if (created.sync_command_id != null) { setCmd({ text: `repository '${created.id}': cloning…`, error: false, busy: true }); const final = await clientRef.current.trackCommand(created.sync_command_id); if (!final) { setCmd({ text: `repository '${created.id}' registered; clone still running (see Activity). Is the worker up?`, error: false, busy: false, }); } else if (final.status === "done") { setCmd({ text: `repository '${created.id}' registered and cloned`, error: false, busy: false, }); } else { setCmd({ text: `repository '${created.id}' registered but the clone failed — ${final.error ?? ""}`, error: true, busy: false, }); } } else { setCmd({ text: `repository '${created.id}' registered`, error: false, busy: false }); } // "Initialize mise": follow the bootstrap agent's launch so the operator knows a // .mise.toml is being written, committed, and pushed for them. if (created.mise_init_command_id != null) { setCmd({ text: `repository '${created.id}': launching a mise-init agent to create .mise.toml…`, error: false, busy: true, }); const mise = await clientRef.current.trackCommand(created.mise_init_command_id); if (!mise) { setCmd({ text: `repository '${created.id}' registered; mise-init still starting (see Activity). Is the worker up?`, error: false, busy: false, }); } else if (mise.status === "done") { setCmd({ text: `repository '${created.id}' registered; a mise-init agent is now writing, committing, and pushing .mise.toml (watch it in Runs).`, error: false, busy: false, }); } else { setCmd({ text: `repository '${created.id}' registered but the mise-init agent failed to launch — ${mise.error ?? ""}`, error: true, busy: false, }); } } return true; } catch (e) { if (e instanceof AuthError) return false; setCmd({ text: (e as Error).message, error: true, busy: false }); return false; } }, [loadProjects], ); const syncProject = useCallback( async (id: string) => { await enqueueAndTrack(`/projects/${encodeURIComponent(id)}/sync`, undefined, `pull ${id}`); }, [enqueueAndTrack], ); const updateProject = useCallback( async (id: string, b: Omit) => { try { await clientRef.current.api(`/projects/${encodeURIComponent(id)}`, { method: "PATCH", body: { root_dir: b.root_dir.trim(), git_remote: b.git_remote.trim() || null, credential_ref: b.credential_ref.trim() || null, }, }); setCmd({ text: `repository '${id}' updated`, error: false, busy: false }); await loadProjects(); return true; } catch (e) { if (e instanceof AuthError) return false; setCmd({ text: (e as Error).message, error: true, busy: false }); return false; } }, [loadProjects], ); const deleteProject = useCallback( async (id: string) => { try { await clientRef.current.api(`/projects/${encodeURIComponent(id)}`, { method: "DELETE" }); setCmd({ text: `repository '${id}' removed`, error: false, busy: false }); setSelectedProjectId((cur) => (cur === id ? "" : cur)); await loadProjects(); } catch (e) { if (e instanceof AuthError) return; setCmd({ text: (e as Error).message, error: true, busy: false }); } }, [loadProjects], ); const submitApproval = useCallback( async (b: ApprovalBody) => { const p = encodeURIComponent(selectedProjectRef.current); await enqueueAndTrack( `/projects/${p}/approvals`, { branch: b.branch.trim(), status: b.status, agent_name: b.agent_name.trim() || null, sha: b.sha.trim() || null, note: b.note.trim() || null, }, `${b.status} ${b.branch}`, ); await loadApprovals(selectedProjectRef.current); }, [enqueueAndTrack, loadApprovals], ); const createHost = useCallback( async (b: HostBody) => { try { await clientRef.current.api("/hosts", { method: "POST", body: { hostname: b.hostname.trim(), forge_type: b.forge_type, token_env_var: b.token_env_var.trim() || null, base_url: b.base_url.trim() || null, token: b.token.trim() || null, generate_ssh_key: b.generate_ssh_key, }, }); setCmd({ text: `git server '${b.hostname}' added`, error: false, busy: false }); await loadHosts(); return true; } catch (e) { if (e instanceof AuthError) return false; setCmd({ text: (e as Error).message, error: true, busy: false }); return false; } }, [loadHosts], ); const updateHost = useCallback( async (hostname: string, b: Omit) => { try { const body: Record = { forge_type: b.forge_type, token_env_var: b.token_env_var.trim() || null, base_url: b.base_url.trim() || null, }; if (b.token.trim()) body.token = b.token.trim(); if (b.generate_ssh_key) body.regenerate_ssh_key = true; await clientRef.current.api(`/hosts/${encodeURIComponent(hostname)}`, { method: "PATCH", body, }); setCmd({ text: `git server '${hostname}' updated`, error: false, busy: false }); await loadHosts(); return true; } catch (e) { if (e instanceof AuthError) return false; setCmd({ text: (e as Error).message, error: true, busy: false }); return false; } }, [loadHosts], ); const createSchedule = useCallback( async (projectId: string, b: ScheduleBody) => { try { await clientRef.current.api(`/projects/${encodeURIComponent(projectId)}/schedules`, { method: "POST", body: { name_prefix: b.name_prefix.trim(), task: b.task.trim(), interval_seconds: b.interval_seconds, role: b.role || null, }, }); setCmd({ text: `schedule '${b.name_prefix}' created — first run on the worker's next pass`, error: false, busy: false, }); await loadSchedules(); return true; } catch (e) { if (e instanceof AuthError) return false; setCmd({ text: (e as Error).message, error: true, busy: false }); return false; } }, [loadSchedules], ); const updateSchedule = useCallback( async (id: number, b: Partial & { enabled?: boolean }) => { try { await clientRef.current.api(`/schedules/${id}`, { method: "PATCH", body: b }); await loadSchedules(); return true; } catch (e) { if (e instanceof AuthError) return false; setCmd({ text: (e as Error).message, error: true, busy: false }); return false; } }, [loadSchedules], ); const deleteSchedule = useCallback( async (id: number) => { try { await clientRef.current.api(`/schedules/${id}`, { method: "DELETE" }); setCmd({ text: `schedule ${id} removed`, error: false, busy: false }); await loadSchedules(); } catch (e) { if (e instanceof AuthError) return; setCmd({ text: (e as Error).message, error: true, busy: false }); } }, [loadSchedules], ); const deleteHost = useCallback( async (hostname: string) => { try { await clientRef.current.api(`/hosts/${encodeURIComponent(hostname)}`, { method: "DELETE" }); setCmd({ text: `git server '${hostname}' removed`, error: false, busy: false }); await loadHosts(); } catch (e) { if (e instanceof AuthError) return; setCmd({ text: (e as Error).message, error: true, busy: false }); } }, [loadHosts], ); const pollCi = useCallback(async () => { await enqueueAndTrack("/poll-ci", undefined, "poll-ci (all projects)"); await loadCommands(); }, [enqueueAndTrack, loadCommands]); // ---- claude web-login (login_start / login_submit through the command queue) ---- const startClaudeLogin = useCallback(async () => { setClaudeLogin({ status: "starting", url: "", message: "Opening `claude /login` in the control container and selecting the subscription account…", }); try { const command = await clientRef.current.api("/login/start", { method: "POST" }); // login_start boots claude, drives the menu, and scrapes the URL — allow ~90s // (worker claim latency + boot waits + URL timeout). const final = await clientRef.current.trackCommand(command.id, { attempts: 180 }); if (!final) { setClaudeLogin({ status: "error", url: "", message: "Still starting (see Activity). Is the control worker running?", }); return; } if (final.status !== "done") { setClaudeLogin({ status: "error", url: "", message: final.error || "Failed to start login." }); return; } const url = final.result && typeof final.result.url === "string" ? final.result.url : ""; if (!url) { setClaudeLogin({ status: "error", url: "", message: "No login URL was returned by claude." }); return; } setClaudeLogin({ status: "awaiting", url, message: "Authorize in the window below (or open it in a new tab), then paste the code claude gives you.", }); } catch (e) { if (e instanceof AuthError) return; setClaudeLogin({ status: "error", url: "", message: (e as Error).message }); } }, []); const submitClaudeCode = useCallback(async (code: string) => { const trimmed = code.trim(); if (!trimmed) return false; setClaudeLogin((c) => ({ ...c, status: "submitting", message: "Submitting the authorization code…" })); try { const command = await clientRef.current.api("/login/submit", { method: "POST", body: { code: trimmed }, }); const final = await clientRef.current.trackCommand(command.id, { attempts: 60 }); if (!final) { setClaudeLogin((c) => ({ ...c, status: "awaiting", message: "Submit still running (see Activity). Is the control worker running?", })); return false; } if (final.status === "done") { setClaudeLogin({ status: "done", url: "", message: "Claude Code is now logged in on the host — new agents will use this account.", }); return true; } setClaudeLogin((c) => ({ ...c, status: "awaiting", message: final.error || "Login was not confirmed. Re-check the code, or restart the flow.", })); return false; } catch (e) { if (e instanceof AuthError) return false; setClaudeLogin((c) => ({ ...c, status: "awaiting", message: (e as Error).message })); return false; } }, []); const resetClaudeLogin = useCallback(() => { setClaudeLogin({ status: "idle", url: "", message: "" }); }, []); const setSharedKey = useCallback( async (key: string, value: string) => { try { await clientRef.current.api(`/shared/context/${encodeURIComponent(key)}`, { method: "PUT", body: { value }, }); setCmd({ text: `shared context '${key}' set`, error: false, busy: false }); await loadShared(); return true; } catch (e) { if (e instanceof AuthError) return false; setCmd({ text: (e as Error).message, error: true, busy: false }); return false; } }, [loadShared], ); const value: StoreValue = { section, setSection, projects, agents, selectedProjectId, selectProject, selectedRun, selectRun, checkmark, checkmarkMissing, log, logOffset, pageLog, approvals, hosts, commands, schedules, shared, cmd, lastError, loading, refresh, spawnAgent, killAgent, deleteAgent, submitAnswer, createProject, updateProject, deleteProject, syncProject, submitApproval, createHost, updateHost, deleteHost, createSchedule, updateSchedule, deleteSchedule, pollCi, setSharedKey, claudeLogin, startClaudeLogin, submitClaudeCode, resetClaudeLogin, }; return {children}; }