Files
0xWheatyz 6c2e73d4ec feat(control,api,ui): worker liveness + run event stream (phase 3)
- worker: heartbeat every loop pass (workers registry); reaper pass
  every ~15s marks a silent worker's running runs crashed and flips
  agents stuck in 'working' to crashed (paused/blocked keep their
  still-accurate status). Idempotent via finish_run's running-guard;
  any surviving worker can reap; no auto-requeue (half-done runs may
  have pushed). Dead workers' registry rows are dropped once settled.
- api: GET /projects/{p}/agents/{name}/events - the persisted
  stream-json event log, oldest-first, cursor-paged by row id;
  AgentOut exposes session_id/worker_id
- frontend: Run events panel in the run detail (assistant text, tool
  chips, result footer with cost/turns, runner notices, raw lines),
  cursor-appended on the existing 5s poll; 'Crashed' filter + danger
  badge; crashed agents show their frozen last frame ('last output
  before crash'); static export regenerated

Suite 290 -> 296 green; next build clean.
2026-07-21 23:10:40 -04:00

439 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Runs — the inbox: a flat list of every agent across every project on the left, the
* selected agent's checkmark + log + answer/resume flow on the right. Maps the design's
* "Runs" pane to Handler's agent / checkmark / log model. */
"use client";
import { useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Callout, Stat, StatusBadge, Tabs, Textarea } from "@/components/ui";
import { fmtFull, shortSha, statusTone, timeAgo } from "@/lib/format";
import type { Agent, AgentEvent } from "@/lib/api";
const FILTERS = [
{ value: "all", label: "All" },
{ value: "needs", label: "Needs Input" },
{ value: "working", label: "Working" },
{ value: "done", label: "Done" },
{ value: "crashed", label: "Crashed" },
];
function matches(filter: string, status: string): boolean {
if (filter === "all") return true;
if (filter === "needs") return status === "paused_for_input";
if (filter === "working") return status === "working" || status === "running";
if (filter === "done") return status === "done" || status === "completed";
if (filter === "crashed") return status === "crashed" || status === "blocked";
return true;
}
export function RunsSection() {
const s = useDashboard();
const [filter, setFilter] = useState("all");
const runs = useMemo(() => {
const list = s.agents.filter((a) => matches(filter, a.status));
return [...list].sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
}, [s.agents, filter]);
const selected = s.selectedRun;
const needs = s.agents.filter((a) => a.status === "paused_for_input").length;
const working = s.agents.filter((a) => a.status === "working" || a.status === "running").length;
return (
<div className="runs">
<div className="runs-stats">
<div className="stat-row">
<div className="stat-cell">
<Stat value={s.agents.length} label="Runs tracked" />
</div>
<div className="stat-cell">
<Stat value={needs} label="Needs input" accent />
</div>
<div className="stat-cell">
<Stat value={working} label="Working" />
</div>
<div className="stat-cell">
<Stat value={s.projects.length} label="Repositories" />
</div>
</div>
</div>
<div className="split">
<div className="split-list">
<div className="split-list-head">
<div className="section-title" style={{ fontSize: "var(--text-lg)" }}>
Runs
</div>
<Tabs tabs={FILTERS} value={filter} onChange={setFilter} />
</div>
<div className="split-list-scroll">
{runs.length === 0 && <Callout tone="info">No runs match this filter.</Callout>}
{runs.map((a) => (
<RunRow
key={`${a.project_id}/${a.name}`}
agent={a}
selected={selected?.projectId === a.project_id && selected?.name === a.name}
onSelect={() => s.selectRun(a.project_id, a.name)}
/>
))}
</div>
</div>
<div className="split-detail">
{selected ? <RunDetail /> : <RunEmpty />}
</div>
</div>
</div>
);
}
function RunRow({
agent,
selected,
onSelect,
}: {
agent: Agent;
selected: boolean;
onSelect: () => void;
}) {
return (
<button className={`run-row${selected ? " selected" : ""}`} onClick={onSelect}>
<div className="run-row-top">
<span className="run-project">{agent.project_id}</span>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
{timeAgo(agent.created_at)}
</span>
</div>
<div className="truncate muted" style={{ fontSize: "var(--text-sm)" }}>
{agent.name}
{agent.role ? ` · ${agent.role}` : ""}
</div>
<div className="hstack" style={{ gap: 8 }}>
<StatusBadge status={agent.status} />
</div>
</button>
);
}
function RunEmpty() {
return (
<div style={{ padding: "60px 32px", color: "var(--text-muted)" }}>
Select a run to see its checkmark, log, and any open question.
</div>
);
}
function RunDetail() {
const s = useDashboard();
const run = s.selectedRun!;
const agent = s.agents.find((a) => a.project_id === run.projectId && a.name === run.name);
const cm = s.checkmark;
const [answer, setAnswer] = useState("");
const [busy, setBusy] = useState(false);
const isPaused = agent?.status === "paused_for_input";
const doAnswer = async (resume: boolean) => {
if (!answer.trim()) return;
setBusy(true);
const ok = await s.submitAnswer(answer.trim(), resume);
setBusy(false);
if (ok) setAnswer("");
};
return (
<>
<div
style={{
padding: "24px 28px",
borderBottom: "1px solid var(--border-default)",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div className="hstack">
<span style={{ color: "var(--accent)", fontWeight: "var(--fw-bold)", fontSize: "var(--text-xl)" }}>
{run.projectId}
</span>
<span className="faint">/</span>
<span style={{ color: "var(--text-heading)", fontWeight: "var(--fw-semibold)", fontSize: "var(--text-lg)" }}>
{run.name}
</span>
<StatusBadge status={agent?.status} />
{agent?.role && <Badge tone="info">{agent.role}</Badge>}
<span className="spacer" />
<Button size="sm" variant="secondary" onClick={() => s.killAgent(run.projectId, run.name)}>
Kill
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteAgent(run.projectId, run.name)}>
Delete row
</Button>
</div>
<div className="mono faint" style={{ fontSize: "var(--text-xs)" }}>
{agent?.working_dir ?? "—"} · created {fmtFull(agent?.created_at)}
</div>
</div>
<div style={{ padding: "20px 28px", display: "flex", flexDirection: "column", gap: 16 }}>
{/* Checkmark */}
<div>
<div className="eyebrow" style={{ marginBottom: 10 }}>
Checkmark
</div>
{s.checkmarkMissing && <Callout tone="info">No checkpoint recorded yet.</Callout>}
{cm && !s.checkmarkMissing && (
<dl className="kv">
<dt>Status</dt>
<dd>
<StatusBadge status={cm.status} />
</dd>
<dt>Where it stopped</dt>
<dd>{cm.where_it_stopped || "—"}</dd>
<dt>Open question</dt>
<dd>{cm.open_question || "—"}</dd>
<dt>Next steps</dt>
<dd>
{cm.next_steps && cm.next_steps.length > 0 ? (
<ul>
{cm.next_steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ul>
) : (
"—"
)}
</dd>
<dt>Tests</dt>
<dd className="hstack">
<Badge tone={statusTone(cm.tests_status)}>{cm.tests_status}</Badge>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
{cm.tested_at ? fmtFull(cm.tested_at) : ""}
</span>
</dd>
<dt>Build</dt>
<dd className="hstack">
<Badge tone={statusTone(cm.build_status)}>{cm.build_status}</Badge>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
{cm.built_at ? fmtFull(cm.built_at) : ""}
</span>
</dd>
<dt>Checkpoint at</dt>
<dd className="faint">{fmtFull(cm.checkpoint_at)}</dd>
</dl>
)}
</div>
{/* Answer / resume */}
{isPaused && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="eyebrow">Answer this question</div>
<Callout tone="danger">{cm?.open_question || "(no question text on the checkmark)"}</Callout>
<Textarea value={answer} onChange={setAnswer} rows={3} placeholder="Your answer…" />
<div className="hstack">
<Button variant="secondary" disabled={busy || !answer.trim()} onClick={() => doAnswer(false)}>
Answer
</Button>
<Button variant="primary" disabled={busy || !answer.trim()} onClick={() => doAnswer(true)}>
Answer &amp; Resume
</Button>
</div>
</div>
)}
{/* Headless run event stream (empty for legacy tmux agents) */}
{agent?.session_id && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="eyebrow">
Run events
{agent.worker_id ? (
<span className="faint mono" style={{ fontSize: "var(--text-xs)", marginLeft: 8 }}>
on {agent.worker_id}
</span>
) : null}
</div>
{s.events.length === 0 ? (
<div className="empty">No events yet.</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
maxHeight: 420,
overflow: "auto",
padding: "10px 12px",
background: "var(--surface-2, rgba(0,0,0,0.25))",
borderRadius: 6,
}}
>
{s.events.map((e) => (
<EventLine key={e.id} e={e} />
))}
</div>
)}
</div>
)}
{/* Log */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="eyebrow">Log · newest first</div>
{s.log.length === 0 ? (
<div className="empty">No log entries.</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>When</th>
<th>Status</th>
<th>Summary</th>
<th>Q / A</th>
<th>Push</th>
<th>CI</th>
</tr>
</thead>
<tbody>
{s.log.map((e) => (
<tr key={e.id}>
<td className="faint nowrap">{fmtFull(e.created_at)}</td>
<td>
<StatusBadge status={e.status} />
</td>
<td>{e.summary || "—"}</td>
<td>
{e.question && (
<div>
<strong>Q:</strong> {e.question}
</div>
)}
{e.answer && (
<div>
<strong>A:</strong> {e.answer}
</div>
)}
{!e.question && !e.answer && "—"}
</td>
<td className="mono">{shortSha(e.push_sha)}</td>
<td>
<Badge tone={statusTone(e.ci_status)}>{e.ci_status}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="pager">
<Button size="sm" variant="ghost" disabled={s.logOffset === 0} onClick={() => s.pageLog(-1)}>
Newer
</Button>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
offset {s.logOffset}
</span>
<Button size="sm" variant="ghost" disabled={s.log.length < 100} onClick={() => s.pageLog(1)}>
Older
</Button>
</div>
</div>
</div>
</>
);
}
/* One stream-json event, rendered by type: assistant text as prose, tool calls as chips,
* the result as a cost/turns footer, worker notices as callouts, raw lines verbatim. */
function EventLine({ e }: { e: AgentEvent }) {
const p = (e.payload ?? {}) as Record<string, any>;
const xs = { fontSize: "var(--text-xs)" } as const;
if (e.type === "system") {
return (
<div className="faint mono" style={xs}>
session {p.subtype ?? "event"}
{p.session_id ? ` · ${String(p.session_id).slice(0, 8)}` : ""}
{Array.isArray(p.tools) ? ` · ${p.tools.length} tools` : ""}
</div>
);
}
if (e.type === "assistant") {
const content = p.message?.content;
const blocks: any[] = Array.isArray(content) ? content : [];
const text = blocks
.filter((b) => b?.type === "text" && b.text)
.map((b) => b.text)
.join("\n");
const tools = blocks.filter((b) => b?.type === "tool_use");
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{text && (
<div style={{ fontSize: "var(--text-sm)", whiteSpace: "pre-wrap" }}>{text}</div>
)}
{tools.length > 0 && (
<div className="hstack" style={{ gap: 6, flexWrap: "wrap" }}>
{tools.map((t, i) => (
<Badge key={i} tone="info">
{t.name}
{t.input ? `: ${oneLine(t.input)}` : ""}
</Badge>
))}
</div>
)}
</div>
);
}
if (e.type === "result") {
const err = Boolean(p.is_error);
return (
<div className="hstack" style={{ gap: 8, flexWrap: "wrap" }}>
<Badge tone={err ? "danger" : "success"}>{err ? "run errored" : "run finished"}</Badge>
<span className="faint mono" style={xs}>
{p.num_turns != null ? `${p.num_turns} turns` : ""}
{p.total_cost_usd != null ? ` · $${Number(p.total_cost_usd).toFixed(4)}` : ""}
</span>
{typeof p.result === "string" && p.result && (
<span className="muted" style={{ ...xs, whiteSpace: "pre-wrap", width: "100%" }}>
{p.result}
</span>
)}
</div>
);
}
if (e.type === "worker") {
return (
<Callout tone="danger">
{p.notice ?? "runner notice"}
{p.stderr_tail ? (
<pre className="mono" style={{ ...xs, margin: "6px 0 0", whiteSpace: "pre-wrap" }}>
{p.stderr_tail}
</pre>
) : null}
</Callout>
);
}
if (e.type === "raw") {
return (
<div className="faint mono" style={{ ...xs, whiteSpace: "pre-wrap" }}>
{typeof p.line === "string" ? p.line.trimEnd() : JSON.stringify(p)}
</div>
);
}
// user (tool results) and anything future: a quiet one-liner, nothing lost, no noise.
return (
<div className="faint mono" style={xs}>
{e.type}
</div>
);
}
/* Compact single-line preview of a tool_use input object. */
function oneLine(input: unknown): string {
const s =
typeof input === "string"
? input
: (input as Record<string, unknown>)?.command
? String((input as Record<string, unknown>).command)
: JSON.stringify(input);
return s.length > 80 ? `${s.slice(0, 77)}` : s;
}