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:
125
packages/dashboard/app/__tests__/agent-runs-ui.test.ts
Normal file
125
packages/dashboard/app/__tests__/agent-runs-ui.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// ── Static analysis tests for agent run UI ────────────────────────────
|
||||
// These tests verify that the agent run UI components are properly wired
|
||||
// by analyzing the source files for the expected patterns.
|
||||
|
||||
const agentsViewPath = path.join(__dirname, "../components/AgentsView.tsx");
|
||||
const agentDetailViewPath = path.join(__dirname, "../components/AgentDetailView.tsx");
|
||||
const apiPath = path.join(__dirname, "../api.ts");
|
||||
|
||||
const agentsViewContent = fs.readFileSync(agentsViewPath, "utf-8");
|
||||
const agentDetailViewContent = fs.readFileSync(agentDetailViewPath, "utf-8");
|
||||
const apiContent = fs.readFileSync(apiPath, "utf-8");
|
||||
|
||||
describe("Agent runs UI — static analysis", () => {
|
||||
describe("startAgentRun API function", () => {
|
||||
it("accepts optional source and triggerDetail parameters", () => {
|
||||
expect(apiContent).toMatch(/startAgentRun\s*\(\s*agentId.*projectId\?/s);
|
||||
expect(apiContent).toMatch(/options\?\.\s*source/);
|
||||
expect(apiContent).toMatch(/options\?\.\s*triggerDetail/);
|
||||
});
|
||||
|
||||
it("exports HeartbeatInvocationSource type", () => {
|
||||
expect(apiContent).toMatch(/export type.*HeartbeatInvocationSource/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentsView Run Heartbeat button", () => {
|
||||
it("has handleRunHeartbeat function", () => {
|
||||
expect(agentsViewContent).toContain("handleRunHeartbeat");
|
||||
});
|
||||
|
||||
it("calls startAgentRun with on_demand source", () => {
|
||||
expect(agentsViewContent).toMatch(/startAgentRun.*on_demand/);
|
||||
expect(agentsViewContent).toMatch(/startAgentRun.*Triggered from dashboard/);
|
||||
});
|
||||
|
||||
it("shows Run Heartbeat button for active agents with taskId", () => {
|
||||
// The button should appear in the active state block
|
||||
// and should be conditioned on agent.taskId
|
||||
const activeBlock = agentsViewContent.match(/agent\.state === "active"[\s\S]*?agent\.state === "paused"/)?.[0] ?? "";
|
||||
expect(activeBlock).toContain("handleRunHeartbeat");
|
||||
expect(activeBlock).toContain("agent.taskId");
|
||||
});
|
||||
|
||||
it("shows disabled button for running agents", () => {
|
||||
const runningBlock = agentsViewContent.match(/agent\.state === "running"[\s\S]*?agent\.state === "error"/)?.[0] ?? "";
|
||||
expect(runningBlock).toContain("disabled");
|
||||
});
|
||||
|
||||
it("uses Activity icon for the Run Heartbeat button", () => {
|
||||
expect(agentsViewContent).toContain("handleRunHeartbeat");
|
||||
// Activity icon is imported and used in run heartbeat buttons
|
||||
expect(agentsViewContent).toMatch(/from.*lucide-react/);
|
||||
expect(agentsViewContent).toContain("Activity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentDetailView RunsTab", () => {
|
||||
it("loads runs via fetchAgentRuns API", () => {
|
||||
expect(agentDetailViewContent).toMatch(/fetchAgentRuns/);
|
||||
});
|
||||
|
||||
it("loads run detail via fetchAgentRunDetail API", () => {
|
||||
expect(agentDetailViewContent).toMatch(/fetchAgentRunDetail/);
|
||||
});
|
||||
|
||||
it("has startAgentRun import for Run Heartbeat button", () => {
|
||||
expect(agentDetailViewContent).toMatch(/import.*startAgentRun.*from.*api/);
|
||||
});
|
||||
|
||||
it("has Run Heartbeat button in runs tab", () => {
|
||||
expect(agentDetailViewContent).toMatch(/Run Heartbeat/);
|
||||
expect(agentDetailViewContent).toMatch(/handleRunHeartbeat/);
|
||||
});
|
||||
|
||||
it("displays stdoutExcerpt in pre block", () => {
|
||||
expect(agentDetailViewContent).toContain("stdoutExcerpt");
|
||||
});
|
||||
|
||||
it("displays stderrExcerpt", () => {
|
||||
expect(agentDetailViewContent).toContain("stderrExcerpt");
|
||||
});
|
||||
|
||||
it("displays token usage (usageJson)", () => {
|
||||
expect(agentDetailViewContent).toContain("usageJson");
|
||||
expect(agentDetailViewContent).toMatch(/inputTokens|outputTokens|cachedTokens/);
|
||||
});
|
||||
|
||||
it("displays resultJson", () => {
|
||||
expect(agentDetailViewContent).toContain("resultJson");
|
||||
});
|
||||
|
||||
it("displays contextSnapshot", () => {
|
||||
expect(agentDetailViewContent).toContain("contextSnapshot");
|
||||
});
|
||||
|
||||
it("has polling for active runs", () => {
|
||||
expect(agentDetailViewContent).toContain("setInterval");
|
||||
expect(agentDetailViewContent).toContain("5000");
|
||||
});
|
||||
|
||||
it("has empty state for no runs", () => {
|
||||
expect(agentDetailViewContent).toContain("No runs yet");
|
||||
});
|
||||
|
||||
it("has empty state for no output captured", () => {
|
||||
expect(agentDetailViewContent).toContain("No output captured");
|
||||
});
|
||||
|
||||
it("has invocation source badge", () => {
|
||||
expect(agentDetailViewContent).toContain("invocationSource");
|
||||
});
|
||||
|
||||
it("has trigger detail display", () => {
|
||||
expect(agentDetailViewContent).toContain("triggerDetail");
|
||||
});
|
||||
|
||||
it("has no-runs empty state", () => {
|
||||
expect(agentDetailViewContent).toContain("No runs yet");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1747,8 +1747,8 @@ export function cancelSubtaskBreakdown(sessionId: string, projectId?: string): P
|
||||
|
||||
// ── Agent API ────────────────────────────────────────────────────────────
|
||||
|
||||
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats } from "@fusion/core";
|
||||
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats };
|
||||
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource } from "@fusion/core";
|
||||
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource };
|
||||
|
||||
function withProjectId(path: string, projectId?: string): string {
|
||||
if (!projectId) return path;
|
||||
@@ -1846,10 +1846,16 @@ export function fetchAgentRunLogs(agentId: string, runId: string, projectId?: st
|
||||
}
|
||||
|
||||
/** Manually start a heartbeat run for an agent */
|
||||
export function startAgentRun(agentId: string, projectId?: string): Promise<AgentHeartbeatRun> {
|
||||
export function startAgentRun(
|
||||
agentId: string,
|
||||
projectId?: string,
|
||||
options?: { source?: HeartbeatInvocationSource; triggerDetail?: string },
|
||||
): Promise<AgentHeartbeatRun> {
|
||||
const source = options?.source ?? "manual";
|
||||
const triggerDetail = options?.triggerDetail ?? "Agent activated via dashboard";
|
||||
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source: "manual", triggerDetail: "Agent activated via dashboard" }),
|
||||
body: JSON.stringify({ source, triggerDetail }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
358
packages/dashboard/src/__tests__/routes-agent-runs.test.ts
Normal file
358
packages/dashboard/src/__tests__/routes-agent-runs.test.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { request } from "../test-request.js";
|
||||
|
||||
// ── Mock @fusion/core for agent runs ─────────────────────────────────
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockStartHeartbeatRun = vi.fn();
|
||||
const mockSaveRun = vi.fn();
|
||||
const mockGetRecentRuns = vi.fn();
|
||||
const mockGetRunDetail = vi.fn();
|
||||
const mockRecordHeartbeat = vi.fn();
|
||||
const mockUpdateAgentState = vi.fn();
|
||||
const mockListAgents = vi.fn().mockResolvedValue([]);
|
||||
|
||||
vi.mock("@fusion/core", () => {
|
||||
return {
|
||||
AgentStore: class MockAgentStore {
|
||||
init = mockInit;
|
||||
startHeartbeatRun = mockStartHeartbeatRun;
|
||||
saveRun = mockSaveRun;
|
||||
getRecentRuns = mockGetRecentRuns;
|
||||
getRunDetail = mockGetRunDetail;
|
||||
recordHeartbeat = mockRecordHeartbeat;
|
||||
updateAgentState = mockUpdateAgentState;
|
||||
listAgents = mockListAgents;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ── Mock Store ────────────────────────────────────────────────────────
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-1059-test";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-1059-test/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
get: vi.fn(),
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function createMockRun(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "run-001",
|
||||
agentId: "agent-001",
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Agent runs routes (without HeartbeatMonitor)", () => {
|
||||
let store: MockStore;
|
||||
let app: ReturnType<typeof import("../server.js").createServer>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockListAgents.mockResolvedValue([]);
|
||||
|
||||
store = new MockStore();
|
||||
const { createServer } = await import("../server.js");
|
||||
app = createServer(store as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/runs", () => {
|
||||
it("returns 201 with run record (fallback behavior without HeartbeatMonitor)", async () => {
|
||||
const mockRun = createMockRun();
|
||||
mockStartHeartbeatRun.mockResolvedValue(mockRun);
|
||||
mockSaveRun.mockResolvedValue(undefined);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/runs",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect((response.body as any).id).toBe("run-001");
|
||||
expect((response.body as any).invocationSource).toBe("on_demand");
|
||||
});
|
||||
|
||||
it("enriches run with source and triggerDetail from body", async () => {
|
||||
const mockRun = createMockRun();
|
||||
mockStartHeartbeatRun.mockResolvedValue(mockRun);
|
||||
mockSaveRun.mockResolvedValue(undefined);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/runs",
|
||||
JSON.stringify({ source: "timer", triggerDetail: "Scheduled check" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect((response.body as any).invocationSource).toBe("timer");
|
||||
});
|
||||
|
||||
it("returns 404 when agent not found", async () => {
|
||||
mockStartHeartbeatRun.mockRejectedValue(new Error("Agent agent-999 not found"));
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-999/runs",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect((response.body as any).error).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/heartbeat", () => {
|
||||
it("records heartbeat and returns event", async () => {
|
||||
const mockEvent = { id: "evt-001", agentId: "agent-001", status: "ok", timestamp: "2026-01-01T00:00:00.000Z" };
|
||||
mockRecordHeartbeat.mockResolvedValue(mockEvent);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/heartbeat",
|
||||
JSON.stringify({ status: "ok" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((response.body as any).id).toBe("evt-001");
|
||||
expect(mockRecordHeartbeat).toHaveBeenCalledWith("agent-001", "ok");
|
||||
});
|
||||
|
||||
it("records heartbeat with default status when not provided", async () => {
|
||||
const mockEvent = { id: "evt-001", agentId: "agent-001", status: "ok", timestamp: "2026-01-01T00:00:00.000Z" };
|
||||
mockRecordHeartbeat.mockResolvedValue(mockEvent);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/heartbeat",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRecordHeartbeat).toHaveBeenCalledWith("agent-001", "ok");
|
||||
});
|
||||
|
||||
it("returns 404 when agent not found", async () => {
|
||||
mockRecordHeartbeat.mockRejectedValue(new Error("Agent not found"));
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-999/heartbeat",
|
||||
JSON.stringify({ status: "ok" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("without HeartbeatMonitor, triggerExecution does nothing extra", async () => {
|
||||
const mockEvent = { id: "evt-001", agentId: "agent-001", status: "ok", timestamp: "2026-01-01T00:00:00.000Z" };
|
||||
mockRecordHeartbeat.mockResolvedValue(mockEvent);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/heartbeat",
|
||||
JSON.stringify({ status: "ok", triggerExecution: true }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
// Returns just the event (no run since no HeartbeatMonitor)
|
||||
expect(response.status).toBe(200);
|
||||
expect((response.body as any).id).toBe("evt-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/agents/:id/runs", () => {
|
||||
it("returns run list", async () => {
|
||||
const mockRuns = [
|
||||
createMockRun({ id: "run-001", status: "completed", endedAt: "2026-01-01T00:05:00.000Z" }),
|
||||
createMockRun({ id: "run-002", status: "active" }),
|
||||
];
|
||||
mockGetRecentRuns.mockResolvedValue(mockRuns);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/agent-001/runs");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect((response.body as any[]).length).toBe(2);
|
||||
});
|
||||
|
||||
it("respects limit query parameter", async () => {
|
||||
mockGetRecentRuns.mockResolvedValue([]);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/agent-001/runs?limit=5");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetRecentRuns).toHaveBeenCalledWith("agent-001", 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/agents/:id/runs/:runId", () => {
|
||||
it("returns detailed run", async () => {
|
||||
const mockRun = createMockRun({
|
||||
id: "run-001",
|
||||
status: "completed",
|
||||
endedAt: "2026-01-01T00:05:00.000Z",
|
||||
stdoutExcerpt: "Task completed successfully",
|
||||
usageJson: { inputTokens: 100, outputTokens: 50, cachedTokens: 0 },
|
||||
});
|
||||
mockGetRunDetail.mockResolvedValue(mockRun);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-001");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((response.body as any).id).toBe("run-001");
|
||||
expect((response.body as any).stdoutExcerpt).toBe("Task completed successfully");
|
||||
});
|
||||
|
||||
it("returns 404 when run not found", async () => {
|
||||
mockGetRunDetail.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-999");
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect((response.body as any).error).toBe("Run not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
let store: MockStore;
|
||||
let app: ReturnType<typeof import("../server.js").createServer>;
|
||||
let mockStartRun: ReturnType<typeof vi.fn>;
|
||||
let mockExecuteHeartbeat: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockListAgents.mockResolvedValue([]);
|
||||
|
||||
mockStartRun = vi.fn();
|
||||
mockExecuteHeartbeat = vi.fn();
|
||||
|
||||
store = new MockStore();
|
||||
const { createServer } = await import("../server.js");
|
||||
app = createServer(store as any, {
|
||||
heartbeatMonitor: {
|
||||
startRun: mockStartRun,
|
||||
executeHeartbeat: mockExecuteHeartbeat,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/runs", () => {
|
||||
it("delegates to heartbeatMonitor.startRun when available", async () => {
|
||||
const mockRun = createMockRun({ invocationSource: "on_demand", triggerDetail: "Triggered from dashboard" });
|
||||
mockStartRun.mockResolvedValue(mockRun);
|
||||
mockExecuteHeartbeat.mockResolvedValue({ ...mockRun, status: "completed" });
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/runs",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockStartRun).toHaveBeenCalledWith("agent-001", {
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
});
|
||||
// executeHeartbeat should be called fire-and-forget
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes custom source and triggerDetail to heartbeatMonitor", async () => {
|
||||
const mockRun = createMockRun();
|
||||
mockStartRun.mockResolvedValue(mockRun);
|
||||
mockExecuteHeartbeat.mockResolvedValue(mockRun);
|
||||
|
||||
await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/runs",
|
||||
JSON.stringify({ source: "timer", triggerDetail: "Scheduled run" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(mockStartRun).toHaveBeenCalledWith("agent-001", {
|
||||
source: "timer",
|
||||
triggerDetail: "Scheduled run",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/heartbeat with triggerExecution", () => {
|
||||
it("triggers execution when triggerExecution=true and HeartbeatMonitor available", async () => {
|
||||
const mockEvent = { id: "evt-001", agentId: "agent-001", status: "ok", timestamp: "2026-01-01T00:00:00.000Z" };
|
||||
mockRecordHeartbeat.mockResolvedValue(mockEvent);
|
||||
const mockRun = createMockRun({ invocationSource: "on_demand" });
|
||||
mockStartRun.mockResolvedValue(mockRun);
|
||||
mockExecuteHeartbeat.mockResolvedValue(mockRun);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/heartbeat",
|
||||
JSON.stringify({ status: "ok", triggerExecution: true }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockStartRun).toHaveBeenCalled();
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalled();
|
||||
// Response should include both event and run
|
||||
expect((response.body as any).event).toBeDefined();
|
||||
expect((response.body as any).run).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1336,6 +1336,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// Get GitHub token from options or env
|
||||
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
|
||||
|
||||
// HeartbeatMonitor for triggering agent execution runs
|
||||
const heartbeatMonitor = options?.heartbeatMonitor;
|
||||
const hasHeartbeatExecutor = Boolean(heartbeatMonitor);
|
||||
|
||||
// Scheduler config (includes persisted settings)
|
||||
router.get("/config", async (req, res) => {
|
||||
try {
|
||||
@@ -7110,10 +7114,14 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
/**
|
||||
* POST /api/agents/:id/heartbeat
|
||||
* Record a heartbeat for an agent.
|
||||
* Body: { status?: "ok"|"missed"|"recovered", triggerExecution?: boolean }
|
||||
*
|
||||
* When triggerExecution is true AND HeartbeatMonitor is available,
|
||||
* also starts a heartbeat run after recording the heartbeat event.
|
||||
*/
|
||||
router.post("/agents/:id/heartbeat", async (req, res) => {
|
||||
try {
|
||||
const { status = "ok" } = req.body;
|
||||
const { status = "ok", triggerExecution } = req.body;
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
@@ -7121,7 +7129,26 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
await agentStore.init();
|
||||
|
||||
const event = await agentStore.recordHeartbeat(req.params.id, status as "ok" | "missed" | "recovered");
|
||||
res.json(event);
|
||||
|
||||
// Optionally trigger execution
|
||||
let run: import("@fusion/core").AgentHeartbeatRun | undefined;
|
||||
if (triggerExecution && hasHeartbeatExecutor && heartbeatMonitor) {
|
||||
run = await heartbeatMonitor.startRun(req.params.id, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
});
|
||||
|
||||
// Fire-and-forget execution
|
||||
void heartbeatMonitor.executeHeartbeat({
|
||||
agentId: req.params.id,
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
}).catch((err: any) => {
|
||||
console.error(`[heartbeat] Background execution failed for ${req.params.id}:`, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
res.json(run ? { event, run } : event);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
@@ -7180,30 +7207,54 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
* POST /api/agents/:id/runs
|
||||
* Manually start a heartbeat run for an agent.
|
||||
* Body: { source?: HeartbeatInvocationSource, triggerDetail?: string }
|
||||
*
|
||||
* When HeartbeatMonitor is available, delegates to startRun() which enriches
|
||||
* the run with execution context, transitions the agent to "running", and
|
||||
* fires the onRunStarted event. The route returns the run immediately with
|
||||
* "active" status while execution continues in the background via
|
||||
* executeHeartbeat() fire-and-forget.
|
||||
*/
|
||||
router.post("/agents/:id/runs", async (req, res) => {
|
||||
try {
|
||||
const { source, triggerDetail } = req.body || {};
|
||||
const invocationSource = source ?? "on_demand";
|
||||
const trigger = triggerDetail ?? "Triggered from dashboard";
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
if (hasHeartbeatExecutor && heartbeatMonitor) {
|
||||
// Delegate to HeartbeatMonitor for enriched run creation
|
||||
const run = await heartbeatMonitor.startRun(req.params.id, {
|
||||
source: invocationSource,
|
||||
triggerDetail: trigger,
|
||||
});
|
||||
|
||||
const run = await agentStore.startHeartbeatRun(req.params.id);
|
||||
// Fire-and-forget execution in the background
|
||||
void heartbeatMonitor.executeHeartbeat({
|
||||
agentId: req.params.id,
|
||||
source: invocationSource,
|
||||
triggerDetail: trigger,
|
||||
}).catch((err: any) => {
|
||||
console.error(`[heartbeat] Background execution failed for ${req.params.id}:`, err.message);
|
||||
});
|
||||
|
||||
// Enrich with invocation source and trigger detail
|
||||
if (source) {
|
||||
(run as any).invocationSource = source;
|
||||
res.status(201).json(run);
|
||||
} else {
|
||||
(run as any).invocationSource = "on_demand";
|
||||
}
|
||||
if (triggerDetail) {
|
||||
(run as any).triggerDetail = triggerDetail;
|
||||
}
|
||||
// Fallback: record-only behavior without HeartbeatMonitor
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
await agentStore.saveRun(run);
|
||||
res.status(201).json(run);
|
||||
const run = await agentStore.startHeartbeatRun(req.params.id);
|
||||
|
||||
// Enrich with invocation source and trigger detail
|
||||
(run as any).invocationSource = invocationSource;
|
||||
if (triggerDetail) {
|
||||
(run as any).triggerDetail = triggerDetail;
|
||||
}
|
||||
|
||||
await agentStore.saveRun(run);
|
||||
res.status(201).json(run);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
|
||||
@@ -50,6 +50,11 @@ export interface ServerOptions {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
};
|
||||
/** Optional HeartbeatMonitor for triggering agent execution runs */
|
||||
heartbeatMonitor?: {
|
||||
startRun(agentId: string, options?: { source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
executeHeartbeat(options: { agentId: string; source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; taskId?: string }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
};
|
||||
}
|
||||
|
||||
type DashboardExpressApp = ReturnType<typeof express> & {
|
||||
|
||||
Reference in New Issue
Block a user