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 ────────────────────────────────────────────────────────────
|
// ── Agent API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats } from "@fusion/core";
|
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 };
|
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource };
|
||||||
|
|
||||||
function withProjectId(path: string, projectId?: string): string {
|
function withProjectId(path: string, projectId?: string): string {
|
||||||
if (!projectId) return path;
|
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 */
|
/** 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), {
|
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs`, projectId), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ source: "manual", triggerDetail: "Agent activated via dashboard" }),
|
body: JSON.stringify({ source, triggerDetail }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
ChevronDown, ChevronRight
|
ChevronDown, ChevronRight
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
|
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 { Agent } from "../api";
|
||||||
import type { AgentLogEntry } from "@fusion/core";
|
import type { AgentLogEntry } from "@fusion/core";
|
||||||
import { AgentLogViewer } from "./AgentLogViewer";
|
import { AgentLogViewer } from "./AgentLogViewer";
|
||||||
@@ -237,8 +237,6 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
|
|
||||||
const stateStyle = STATE_COLORS[agent.state];
|
const stateStyle = STATE_COLORS[agent.state];
|
||||||
const health = getHealthStatus();
|
const health = getHealthStatus();
|
||||||
const runs = (agent as any).completedRuns || [];
|
|
||||||
const activeRun = (agent as any).activeRun;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="agent-detail-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
<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" && (
|
{activeTab === "runs" && (
|
||||||
<RunsTab
|
<RunsTab
|
||||||
runs={runs}
|
|
||||||
activeRun={activeRun}
|
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
agentId={agent.id}
|
agentId={agent.id}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
agentState={agent.state}
|
||||||
|
agentName={agent.name}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -675,48 +673,125 @@ function LogEntry({ entry, showTimestamp }: { entry: AgentLogEntry; showTimestam
|
|||||||
// ── Runs Tab ───────────────────────────────────────────────────────────────
|
// ── Runs Tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function RunsTab({
|
function RunsTab({
|
||||||
runs,
|
|
||||||
activeRun,
|
|
||||||
addToast,
|
addToast,
|
||||||
agentId,
|
agentId,
|
||||||
projectId,
|
projectId,
|
||||||
|
agentState,
|
||||||
|
agentName,
|
||||||
}: {
|
}: {
|
||||||
runs: AgentHeartbeatRun[];
|
|
||||||
activeRun?: AgentHeartbeatRun;
|
|
||||||
addToast: (msg: string, type?: "success" | "error") => void;
|
addToast: (msg: string, type?: "success" | "error") => void;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
projectId?: 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 [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||||
const [runLogs, setRunLogs] = useState<AgentLogEntry[]>([]);
|
const [runLogs, setRunLogs] = useState<AgentLogEntry[]>([]);
|
||||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
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) => {
|
const handleRunClick = useCallback(async (runId: string) => {
|
||||||
if (selectedRunId === runId) {
|
if (selectedRunId === runId) {
|
||||||
setSelectedRunId(null);
|
setSelectedRunId(null);
|
||||||
setRunLogs([]);
|
setRunLogs([]);
|
||||||
|
setDetailRun(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedRunId(runId);
|
setSelectedRunId(runId);
|
||||||
setIsLoadingLogs(true);
|
setIsLoadingLogs(true);
|
||||||
|
setIsLoadingDetail(true);
|
||||||
setRunLogs([]);
|
setRunLogs([]);
|
||||||
|
setDetailRun(null);
|
||||||
try {
|
try {
|
||||||
const logs = await fetchAgentRunLogs(agentId, runId, projectId);
|
const [logs, detail] = await Promise.all([
|
||||||
|
fetchAgentRunLogs(agentId, runId, projectId),
|
||||||
|
fetchAgentRunDetail(agentId, runId, projectId),
|
||||||
|
]);
|
||||||
setRunLogs(logs);
|
setRunLogs(logs);
|
||||||
|
setDetailRun(detail);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
addToast(`Failed to load run logs: ${err.message}`, "error");
|
addToast(`Failed to load run details: ${err.message}`, "error");
|
||||||
setRunLogs([]);
|
setRunLogs([]);
|
||||||
|
setDetailRun(null);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingLogs(false);
|
setIsLoadingLogs(false);
|
||||||
|
setIsLoadingDetail(false);
|
||||||
}
|
}
|
||||||
}, [selectedRunId, agentId, projectId, addToast]);
|
}, [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 (
|
return (
|
||||||
<div className="runs-empty">
|
<div className="runs-tab">
|
||||||
<Activity size={48} opacity={0.3} />
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "24px", justifyContent: "center" }}>
|
||||||
<p>No runs yet</p>
|
<Loader2 size={16} className="animate-spin" />
|
||||||
<p className="text-muted">Heartbeat runs will appear here</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -725,6 +800,20 @@ function RunsTab({
|
|||||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
(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 renderRunCard = (run: AgentHeartbeatRun, index: number, isActive: boolean) => {
|
||||||
const statusInfo = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.completed;
|
const statusInfo = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.completed;
|
||||||
const StatusIcon = statusInfo.icon;
|
const StatusIcon = statusInfo.icon;
|
||||||
@@ -762,15 +851,28 @@ function RunsTab({
|
|||||||
<span className="run-id">#{index + 1} {run.id.slice(0, 8)}</span>
|
<span className="run-id">#{index + 1} {run.id.slice(0, 8)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={cn("run-status", run.status)}>
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
<StatusIcon size={14} className={statusInfo.color} style={run.status === "active" ? { color: statusInfo.color } : undefined} />
|
{run.invocationSource && (
|
||||||
{run.status}
|
<span className="badge" style={{ fontSize: "10px", padding: "1px 6px" }}>
|
||||||
</span>
|
{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>
|
||||||
<div className="run-details">
|
<div className="run-details">
|
||||||
<span>Started {relativeTime(run.startedAt)}</span>
|
<span>Started {relativeTime(run.startedAt)}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{duration}</span>
|
<span>{duration}</span>
|
||||||
|
{run.triggerDetail && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-muted">{run.triggerDetail}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
@@ -783,18 +885,137 @@ function RunsTab({
|
|||||||
borderTop: "1px solid var(--border-color)",
|
borderTop: "1px solid var(--border-color)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLoadingLogs ? (
|
{/* Execution Details */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "12px 0" }}>
|
{isLoadingDetail ? (
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 0" }}>
|
||||||
<span className="text-muted">Loading logs...</span>
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
<span className="text-muted">Loading details...</span>
|
||||||
</div>
|
</div>
|
||||||
) : runLogs.length === 0 ? (
|
) : detailRun && (
|
||||||
<div className="text-muted" style={{ padding: "12px 0", fontStyle: "italic" }}>
|
<div style={{ marginBottom: "12px" }}>
|
||||||
No logs available for this run
|
{/* 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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -803,8 +1024,23 @@ function RunsTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="runs-tab">
|
<div className="runs-tab">
|
||||||
{activeRun && renderRunCard(activeRun, 0, true)}
|
{canRunHeartbeat && (
|
||||||
{sortedRuns.map((run, i) => renderRunCard(run, activeRun ? i + 1 : i, false))}
|
<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>
|
</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 getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
|
||||||
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "🤖";
|
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.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
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn--sm"
|
||||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||||
@@ -481,6 +501,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
)}
|
)}
|
||||||
{agent.state === "running" && (
|
{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
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn--sm"
|
||||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||||
@@ -641,6 +671,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
)}
|
)}
|
||||||
{agent.state === "active" && (
|
{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
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn--sm"
|
||||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||||
@@ -677,6 +717,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
)}
|
)}
|
||||||
{agent.state === "running" && (
|
{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
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn--sm"
|
||||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ vi.mock("../../api", () => ({
|
|||||||
deleteAgent: vi.fn(),
|
deleteAgent: vi.fn(),
|
||||||
fetchAgentLogs: vi.fn(),
|
fetchAgentLogs: vi.fn(),
|
||||||
fetchAgentRunLogs: vi.fn(),
|
fetchAgentRunLogs: vi.fn(),
|
||||||
|
fetchAgentRuns: vi.fn(),
|
||||||
|
fetchAgentRunDetail: vi.fn(),
|
||||||
|
startAgentRun: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../AgentLogViewer", () => ({
|
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 mockFetchAgent = vi.mocked(fetchAgent);
|
||||||
const mockUpdateAgent = vi.mocked(updateAgent);
|
const mockUpdateAgent = vi.mocked(updateAgent);
|
||||||
const mockUpdateAgentState = vi.mocked(updateAgentState);
|
const mockUpdateAgentState = vi.mocked(updateAgentState);
|
||||||
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
|
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
|
||||||
|
const mockFetchAgentRuns = vi.mocked(fetchAgentRuns);
|
||||||
|
const mockFetchAgentRunDetail = vi.mocked(fetchAgentRunDetail);
|
||||||
|
|
||||||
describe("AgentDetailView", () => {
|
describe("AgentDetailView", () => {
|
||||||
const createMockAgent = (overrides: Partial<{
|
const createMockAgent = (overrides: Partial<{
|
||||||
@@ -71,9 +76,16 @@ describe("AgentDetailView", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockFetchAgent.mockResolvedValue(createMockAgent());
|
const mockAgent = createMockAgent();
|
||||||
|
mockFetchAgent.mockResolvedValue(mockAgent);
|
||||||
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "paused" }));
|
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "paused" }));
|
||||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
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", () => {
|
it("shows loading state initially", () => {
|
||||||
@@ -1077,6 +1089,7 @@ describe("AgentDetailView", () => {
|
|||||||
it("shows toast on fetch error", async () => {
|
it("shows toast on fetch error", async () => {
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
mockFetchAgentRunLogs.mockRejectedValue(new Error("Network error"));
|
mockFetchAgentRunLogs.mockRejectedValue(new Error("Network error"));
|
||||||
|
mockFetchAgentRunDetail.mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
@@ -1103,7 +1116,7 @@ describe("AgentDetailView", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(addToast).toHaveBeenCalledWith(
|
expect(addToast).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("Failed to load run logs"),
|
expect.stringContaining("Failed to load run details"),
|
||||||
"error",
|
"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
|
// Get GitHub token from options or env
|
||||||
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
|
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)
|
// Scheduler config (includes persisted settings)
|
||||||
router.get("/config", async (req, res) => {
|
router.get("/config", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -7110,10 +7114,14 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
/**
|
/**
|
||||||
* POST /api/agents/:id/heartbeat
|
* POST /api/agents/:id/heartbeat
|
||||||
* Record a heartbeat for an agent.
|
* 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) => {
|
router.post("/agents/:id/heartbeat", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status = "ok" } = req.body;
|
const { status = "ok", triggerExecution } = req.body;
|
||||||
|
|
||||||
const scopedStore = await getScopedStore(req);
|
const scopedStore = await getScopedStore(req);
|
||||||
const { AgentStore } = await import("@fusion/core");
|
const { AgentStore } = await import("@fusion/core");
|
||||||
@@ -7121,7 +7129,26 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
await agentStore.init();
|
await agentStore.init();
|
||||||
|
|
||||||
const event = await agentStore.recordHeartbeat(req.params.id, status as "ok" | "missed" | "recovered");
|
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) {
|
} catch (err: any) {
|
||||||
if (err.message?.includes("not found")) {
|
if (err.message?.includes("not found")) {
|
||||||
res.status(404).json({ error: err.message });
|
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
|
* POST /api/agents/:id/runs
|
||||||
* Manually start a heartbeat run for an agent.
|
* Manually start a heartbeat run for an agent.
|
||||||
* Body: { source?: HeartbeatInvocationSource, triggerDetail?: string }
|
* 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) => {
|
router.post("/agents/:id/runs", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { source, triggerDetail } = req.body || {};
|
const { source, triggerDetail } = req.body || {};
|
||||||
|
const invocationSource = source ?? "on_demand";
|
||||||
|
const trigger = triggerDetail ?? "Triggered from dashboard";
|
||||||
|
|
||||||
const scopedStore = await getScopedStore(req);
|
if (hasHeartbeatExecutor && heartbeatMonitor) {
|
||||||
const { AgentStore } = await import("@fusion/core");
|
// Delegate to HeartbeatMonitor for enriched run creation
|
||||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
const run = await heartbeatMonitor.startRun(req.params.id, {
|
||||||
await agentStore.init();
|
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
|
res.status(201).json(run);
|
||||||
if (source) {
|
|
||||||
(run as any).invocationSource = source;
|
|
||||||
} else {
|
} else {
|
||||||
(run as any).invocationSource = "on_demand";
|
// Fallback: record-only behavior without HeartbeatMonitor
|
||||||
}
|
const scopedStore = await getScopedStore(req);
|
||||||
if (triggerDetail) {
|
const { AgentStore } = await import("@fusion/core");
|
||||||
(run as any).triggerDetail = triggerDetail;
|
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||||
}
|
await agentStore.init();
|
||||||
|
|
||||||
await agentStore.saveRun(run);
|
const run = await agentStore.startHeartbeatRun(req.params.id);
|
||||||
res.status(201).json(run);
|
|
||||||
|
// 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) {
|
} catch (err: any) {
|
||||||
if (err.message?.includes("not found")) {
|
if (err.message?.includes("not found")) {
|
||||||
res.status(404).json({ error: err.message });
|
res.status(404).json({ error: err.message });
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ export interface ServerOptions {
|
|||||||
start(): void;
|
start(): void;
|
||||||
stop(): 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> & {
|
type DashboardExpressApp = ReturnType<typeof express> & {
|
||||||
|
|||||||
Reference in New Issue
Block a user