feat(FN-1059): add manual heartbeat run trigger with execution details in agent UI

- Wire heartbeatMonitor into ServerOptions and pass through to API routes
- Add POST /api/agents/:id/runs route to trigger heartbeat execution with optional source/triggerDetail
- Update API client startAgentRun to accept optional source and triggerDetail parameters
- Add Run Heartbeat button to AgentsView and enhance AgentDetailView RunsTab with execution details and polling
- Add route handler tests for agent runs and UI static analysis tests
- Support run history display with status, duration, trigger type, and result details
This commit is contained in:
gsxdsm
2026-04-07 14:38:12 -07:00
parent d8f4a63e1f
commit f34c86345c
8 changed files with 899 additions and 55 deletions

View File

@@ -6,7 +6,7 @@ import {
ChevronDown, ChevronRight
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
@@ -237,8 +237,6 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
const stateStyle = STATE_COLORS[agent.state];
const health = getHealthStatus();
const runs = (agent as any).completedRuns || [];
const activeRun = (agent as any).activeRun;
return (
<div className="agent-detail-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
@@ -376,11 +374,11 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
{activeTab === "runs" && (
<RunsTab
runs={runs}
activeRun={activeRun}
addToast={addToast}
agentId={agent.id}
projectId={projectId}
agentState={agent.state}
agentName={agent.name}
/>
)}
@@ -675,48 +673,125 @@ function LogEntry({ entry, showTimestamp }: { entry: AgentLogEntry; showTimestam
// ── Runs Tab ───────────────────────────────────────────────────────────────
function RunsTab({
runs,
activeRun,
addToast,
agentId,
projectId,
agentState,
agentName,
}: {
runs: AgentHeartbeatRun[];
activeRun?: AgentHeartbeatRun;
addToast: (msg: string, type?: "success" | "error") => void;
agentId: string;
projectId?: string;
agentState?: AgentState;
agentName?: string;
}) {
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
const [isLoadingRuns, setIsLoadingRuns] = useState(true);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [runLogs, setRunLogs] = useState<AgentLogEntry[]>([]);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [detailRun, setDetailRun] = useState<AgentHeartbeatRun | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
// Load runs on mount
const loadRuns = useCallback(async () => {
try {
const data = await fetchAgentRuns(agentId, 50, projectId);
setRuns(data);
} catch (err: any) {
addToast(`Failed to load runs: ${err.message}`, "error");
} finally {
setIsLoadingRuns(false);
}
}, [agentId, projectId, addToast]);
useEffect(() => {
void loadRuns();
}, [loadRuns]);
// Poll for active runs
const hasActiveRun = runs.some(r => r.status === "active");
useEffect(() => {
if (!hasActiveRun) return;
const interval = setInterval(() => {
void loadRuns();
}, 5000);
return () => clearInterval(interval);
}, [hasActiveRun, loadRuns]);
// Load run detail when a run is selected
const handleRunClick = useCallback(async (runId: string) => {
if (selectedRunId === runId) {
setSelectedRunId(null);
setRunLogs([]);
setDetailRun(null);
return;
}
setSelectedRunId(runId);
setIsLoadingLogs(true);
setIsLoadingDetail(true);
setRunLogs([]);
setDetailRun(null);
try {
const logs = await fetchAgentRunLogs(agentId, runId, projectId);
const [logs, detail] = await Promise.all([
fetchAgentRunLogs(agentId, runId, projectId),
fetchAgentRunDetail(agentId, runId, projectId),
]);
setRunLogs(logs);
setDetailRun(detail);
} catch (err: any) {
addToast(`Failed to load run logs: ${err.message}`, "error");
addToast(`Failed to load run details: ${err.message}`, "error");
setRunLogs([]);
setDetailRun(null);
} finally {
setIsLoadingLogs(false);
setIsLoadingDetail(false);
}
}, [selectedRunId, agentId, projectId, addToast]);
if (runs.length === 0 && !activeRun) {
const handleRunHeartbeat = async () => {
try {
await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" });
addToast(`Heartbeat run started for ${agentName ?? agentId}`, "success");
setIsLoadingRuns(true);
void loadRuns();
} catch (err: any) {
addToast(`Failed to start heartbeat run: ${err.message}`, "error");
}
};
const canRunHeartbeat = agentState === "active" || agentState === "idle";
if (isLoadingRuns && runs.length === 0) {
return (
<div className="runs-empty">
<Activity size={48} opacity={0.3} />
<p>No runs yet</p>
<p className="text-muted">Heartbeat runs will appear here</p>
<div className="runs-tab">
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "24px", justifyContent: "center" }}>
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading runs...</span>
</div>
</div>
);
}
if (runs.length === 0) {
return (
<div className="runs-tab">
{canRunHeartbeat && (
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border-color)" }}>
<button
className="btn btn--sm btn--primary"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run heartbeat for ${agentName ?? agentId}`}
>
<Activity size={14} /> Run Heartbeat
</button>
</div>
)}
<div className="runs-empty">
<Activity size={48} opacity={0.3} />
<p>No runs yet</p>
<p className="text-muted">Heartbeat runs will appear here</p>
</div>
</div>
);
}
@@ -725,6 +800,20 @@ function RunsTab({
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
const activeRuns = sortedRuns.filter(r => r.status === "active");
const completedRuns = sortedRuns.filter(r => r.status !== "active");
const renderUsage = (usage: { inputTokens: number; outputTokens: number; cachedTokens: number } | undefined) => {
if (!usage) return null;
return (
<div style={{ fontSize: "12px", color: "var(--text-secondary)", display: "flex", gap: "12px", flexWrap: "wrap" }}>
<span>Input: {usage.inputTokens.toLocaleString()}</span>
<span>Output: {usage.outputTokens.toLocaleString()}</span>
{usage.cachedTokens > 0 && <span>Cached: {usage.cachedTokens.toLocaleString()}</span>}
</div>
);
};
const renderRunCard = (run: AgentHeartbeatRun, index: number, isActive: boolean) => {
const statusInfo = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.completed;
const StatusIcon = statusInfo.icon;
@@ -762,15 +851,28 @@ function RunsTab({
<span className="run-id">#{index + 1} {run.id.slice(0, 8)}</span>
)}
</div>
<span className={cn("run-status", run.status)}>
<StatusIcon size={14} className={statusInfo.color} style={run.status === "active" ? { color: statusInfo.color } : undefined} />
{run.status}
</span>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
{run.invocationSource && (
<span className="badge" style={{ fontSize: "10px", padding: "1px 6px" }}>
{run.invocationSource}
</span>
)}
<span className={cn("run-status", run.status)}>
<StatusIcon size={14} className={statusInfo.color} style={run.status === "active" ? { color: statusInfo.color } : undefined} />
{run.status}
</span>
</div>
</div>
<div className="run-details">
<span>Started {relativeTime(run.startedAt)}</span>
<span></span>
<span>{duration}</span>
{run.triggerDetail && (
<>
<span></span>
<span className="text-muted">{run.triggerDetail}</span>
</>
)}
</div>
</div>
{isSelected && (
@@ -783,18 +885,137 @@ function RunsTab({
borderTop: "1px solid var(--border-color)",
}}
>
{isLoadingLogs ? (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "12px 0" }}>
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading logs...</span>
{/* Execution Details */}
{isLoadingDetail ? (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 0" }}>
<Loader2 size={14} className="animate-spin" />
<span className="text-muted">Loading details...</span>
</div>
) : runLogs.length === 0 ? (
<div className="text-muted" style={{ padding: "12px 0", fontStyle: "italic" }}>
No logs available for this run
) : detailRun && (
<div style={{ marginBottom: "12px" }}>
{/* Token Usage */}
{detailRun.usageJson && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Token Usage
</div>
{renderUsage(detailRun.usageJson)}
</div>
)}
{/* Output */}
{detailRun.stdoutExcerpt && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Output
</div>
<pre style={{
background: "var(--bg-tertiary, #161b22)",
padding: "8px 12px",
borderRadius: "6px",
fontSize: "12px",
maxHeight: "200px",
overflow: "auto",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{detailRun.stdoutExcerpt.length > 2000
? `${detailRun.stdoutExcerpt.slice(0, 2000)}\n\n... (truncated, ${detailRun.stdoutExcerpt.length} chars total)`
: detailRun.stdoutExcerpt}
</pre>
</div>
)}
{/* Errors */}
{detailRun.stderrExcerpt && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--color-error, #f85149)", marginBottom: "4px" }}>
Errors
</div>
<pre style={{
background: "rgba(248, 81, 73, 0.1)",
color: "var(--color-error, #f85149)",
padding: "8px 12px",
borderRadius: "6px",
fontSize: "12px",
maxHeight: "200px",
overflow: "auto",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{detailRun.stderrExcerpt}
</pre>
</div>
)}
{/* Result */}
{detailRun.resultJson && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Result
</div>
<pre style={{
background: "var(--bg-tertiary, #161b22)",
padding: "8px 12px",
borderRadius: "6px",
fontSize: "12px",
maxHeight: "200px",
overflow: "auto",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{JSON.stringify(detailRun.resultJson, null, 2)}
</pre>
</div>
)}
{/* Context */}
{detailRun.contextSnapshot && Object.keys(detailRun.contextSnapshot).length > 0 && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Context
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px 12px", fontSize: "12px" }}>
{Object.entries(detailRun.contextSnapshot).map(([key, value]) => (
<span key={key}>
<span className="text-muted">{key}:</span>{" "}
<span>{String(value)}</span>
</span>
))}
</div>
</div>
)}
{/* No output state */}
{!detailRun.stdoutExcerpt && !detailRun.stderrExcerpt && !detailRun.resultJson && (
<div className="text-muted" style={{ padding: "8px 0", fontStyle: "italic", fontSize: "12px" }}>
No output captured
</div>
)}
</div>
) : (
<AgentLogViewer entries={runLogs} loading={false} />
)}
{/* Run Logs */}
<div style={{ borderTop: "1px solid var(--border-color)", paddingTop: "8px", marginTop: "4px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Agent Logs
</div>
{isLoadingLogs ? (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 0" }}>
<Loader2 size={14} className="animate-spin" />
<span className="text-muted">Loading logs...</span>
</div>
) : runLogs.length === 0 ? (
<div className="text-muted" style={{ padding: "8px 0", fontStyle: "italic" }}>
No logs available for this run
</div>
) : (
<AgentLogViewer entries={runLogs} loading={false} />
)}
</div>
</div>
)}
</div>
@@ -803,8 +1024,23 @@ function RunsTab({
return (
<div className="runs-tab">
{activeRun && renderRunCard(activeRun, 0, true)}
{sortedRuns.map((run, i) => renderRunCard(run, activeRun ? i + 1 : i, false))}
{canRunHeartbeat && (
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border-color)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
{runs.length} run{runs.length !== 1 ? "s" : ""}
{hasActiveRun && <span className="run-live-indicator" style={{ marginLeft: "8px" }}><span className="live-dot" />Live</span>}
</span>
<button
className="btn btn--sm btn--primary"
onClick={() => void handleRunHeartbeat()}
aria-label={`Run heartbeat for ${agentName ?? agentId}`}
>
<Activity size={14} /> Run Heartbeat
</button>
</div>
)}
{activeRuns.map((run, i) => renderRunCard(run, i, true))}
{completedRuns.map((run, i) => renderRunCard(run, activeRuns.length + i, false))}
</div>
);
}

View File

@@ -222,6 +222,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
}
};
const handleRunHeartbeat = async (agentId: string, agentName: string) => {
try {
await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" });
addToast(`Heartbeat run started for ${agentName}`, "success");
void loadAgents();
} catch (err: any) {
addToast(`Failed to start heartbeat run: ${err.message}`, "error");
}
};
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "🤖";
@@ -445,6 +455,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
)}
{agent.state === "active" && (
<>
{agent.taskId && (
<button
className="btn btn--sm"
onClick={() => void handleRunHeartbeat(agent.id, agent.name)}
title="Run Heartbeat"
aria-label={`Run heartbeat for ${agent.name}`}
>
<Activity size={14} />
</button>
)}
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
@@ -481,6 +501,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
)}
{agent.state === "running" && (
<>
{agent.taskId && (
<button
className="btn btn--sm"
disabled
title="Run in progress"
aria-label={`Heartbeat run in progress for ${agent.name}`}
>
<Activity size={14} />
</button>
)}
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
@@ -641,6 +671,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
)}
{agent.state === "active" && (
<>
{agent.taskId && (
<button
className="btn btn--sm"
onClick={() => void handleRunHeartbeat(agent.id, agent.name)}
title="Run Heartbeat"
aria-label={`Run heartbeat for ${agent.name}`}
>
<Activity size={14} /> Run
</button>
)}
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
@@ -677,6 +717,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
)}
{agent.state === "running" && (
<>
{agent.taskId && (
<button
className="btn btn--sm"
disabled
title="Run in progress"
aria-label={`Heartbeat run in progress for ${agent.name}`}
>
<Activity size={14} /> Running
</button>
)}
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}

View File

@@ -13,6 +13,9 @@ vi.mock("../../api", () => ({
deleteAgent: vi.fn(),
fetchAgentLogs: vi.fn(),
fetchAgentRunLogs: vi.fn(),
fetchAgentRuns: vi.fn(),
fetchAgentRunDetail: vi.fn(),
startAgentRun: vi.fn(),
}));
vi.mock("../AgentLogViewer", () => ({
@@ -23,12 +26,14 @@ vi.mock("../AgentLogViewer", () => ({
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs } from "../../api";
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
const mockUpdateAgentState = vi.mocked(updateAgentState);
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
const mockFetchAgentRuns = vi.mocked(fetchAgentRuns);
const mockFetchAgentRunDetail = vi.mocked(fetchAgentRunDetail);
describe("AgentDetailView", () => {
const createMockAgent = (overrides: Partial<{
@@ -71,9 +76,16 @@ describe("AgentDetailView", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchAgent.mockResolvedValue(createMockAgent());
const mockAgent = createMockAgent();
mockFetchAgent.mockResolvedValue(mockAgent);
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "paused" }));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
// Default: return runs from mock agent
mockFetchAgentRuns.mockResolvedValue([
...(mockAgent.activeRun ? [mockAgent.activeRun] : []),
...mockAgent.completedRuns,
]);
mockFetchAgentRunDetail.mockResolvedValue(mockAgent.completedRuns[0]);
});
it("shows loading state initially", () => {
@@ -1077,6 +1089,7 @@ describe("AgentDetailView", () => {
it("shows toast on fetch error", async () => {
const addToast = vi.fn();
mockFetchAgentRunLogs.mockRejectedValue(new Error("Network error"));
mockFetchAgentRunDetail.mockRejectedValue(new Error("Network error"));
const user = userEvent.setup();
render(
@@ -1103,7 +1116,7 @@ describe("AgentDetailView", () => {
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to load run logs"),
expect.stringContaining("Failed to load run details"),
"error",
);
});