feat(FN-1045): add clickable agent run history with inline log viewer
- Add backend API endpoint for time-range filtered agent log retrieval (GET /api/agents/:id/logs) - Add fetchAgentRunLogs API function in dashboard client - Make RunsTab run cards clickable with inline log viewer accordion - Make AgentRunHistory run rows clickable to expand and view logs - Enhance AgentDetailView with collapsible log sections and time-range filtering - Add comprehensive tests for store, routes, and component click behavior
This commit is contained in:
@@ -1836,6 +1836,11 @@ export function fetchAgentRunDetail(agentId: string, runId: string, projectId?:
|
||||
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`, projectId));
|
||||
}
|
||||
|
||||
/** Fetch agent logs for a specific run's time window */
|
||||
export function fetchAgentRunLogs(agentId: string, runId: string, projectId?: string): Promise<AgentLogEntry[]> {
|
||||
return api<AgentLogEntry[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/logs`, projectId));
|
||||
}
|
||||
|
||||
/** Manually start a heartbeat run for an agent */
|
||||
export function startAgentRun(agentId: string, projectId?: string): Promise<AgentHeartbeatRun> {
|
||||
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs`, projectId), {
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import {
|
||||
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
|
||||
Settings, FileText, ActivitySquare, X, Copy,
|
||||
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch
|
||||
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch,
|
||||
ChevronDown, ChevronRight
|
||||
} from "lucide-react";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentChildren } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
|
||||
/**
|
||||
* Simple className utility - joins class names conditionally
|
||||
@@ -377,6 +379,8 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
runs={runs}
|
||||
activeRun={activeRun}
|
||||
addToast={addToast}
|
||||
agentId={agent.id}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -673,12 +677,40 @@ function LogEntry({ entry, showTimestamp }: { entry: AgentLogEntry; showTimestam
|
||||
function RunsTab({
|
||||
runs,
|
||||
activeRun,
|
||||
addToast
|
||||
addToast,
|
||||
agentId,
|
||||
projectId,
|
||||
}: {
|
||||
runs: AgentHeartbeatRun[];
|
||||
activeRun?: AgentHeartbeatRun;
|
||||
addToast: (msg: string, type?: "success" | "error") => void;
|
||||
agentId: string;
|
||||
projectId?: string;
|
||||
}) {
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
const [runLogs, setRunLogs] = useState<AgentLogEntry[]>([]);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
|
||||
const handleRunClick = useCallback(async (runId: string) => {
|
||||
if (selectedRunId === runId) {
|
||||
setSelectedRunId(null);
|
||||
setRunLogs([]);
|
||||
return;
|
||||
}
|
||||
setSelectedRunId(runId);
|
||||
setIsLoadingLogs(true);
|
||||
setRunLogs([]);
|
||||
try {
|
||||
const logs = await fetchAgentRunLogs(agentId, runId, projectId);
|
||||
setRunLogs(logs);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to load run logs: ${err.message}`, "error");
|
||||
setRunLogs([]);
|
||||
} finally {
|
||||
setIsLoadingLogs(false);
|
||||
}
|
||||
}, [selectedRunId, agentId, projectId, addToast]);
|
||||
|
||||
if (runs.length === 0 && !activeRun) {
|
||||
return (
|
||||
<div className="runs-empty">
|
||||
@@ -693,50 +725,86 @@ function RunsTab({
|
||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="runs-tab">
|
||||
{activeRun && (
|
||||
<div className="run-card run-card--active">
|
||||
const renderRunCard = (run: AgentHeartbeatRun, index: number, isActive: boolean) => {
|
||||
const statusInfo = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.completed;
|
||||
const StatusIcon = statusInfo.icon;
|
||||
const duration = run.endedAt
|
||||
? formatDuration(new Date(run.startedAt), new Date(run.endedAt))
|
||||
: "In progress";
|
||||
const isSelected = selectedRunId === run.id;
|
||||
|
||||
return (
|
||||
<div key={run.id}>
|
||||
<div
|
||||
className={cn("run-card", isActive && "run-card--active", isSelected && "run-card--selected")}
|
||||
onClick={() => void handleRunClick(run.id)}
|
||||
style={{ cursor: "pointer" }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isSelected}
|
||||
aria-label={`${isActive ? "Active" : ""} run ${run.id.slice(0, 8)}, ${run.status}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
void handleRunClick(run.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="run-header">
|
||||
<span className="run-live-indicator">
|
||||
<span className="live-dot" />
|
||||
Live Run
|
||||
</span>
|
||||
<span className="run-status active">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Active
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
{isSelected ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
{isActive ? (
|
||||
<span className="run-live-indicator">
|
||||
<span className="live-dot" />
|
||||
Live Run
|
||||
</span>
|
||||
) : (
|
||||
<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>
|
||||
<div className="run-details">
|
||||
<span>Started {relativeTime(activeRun.startedAt)}</span>
|
||||
<span>Started {relativeTime(run.startedAt)}</span>
|
||||
<span>•</span>
|
||||
<span>{duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedRuns.map((run, i) => {
|
||||
const statusInfo = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.completed;
|
||||
const StatusIcon = statusInfo.icon;
|
||||
const duration = run.endedAt
|
||||
? formatDuration(new Date(run.startedAt), new Date(run.endedAt))
|
||||
: "In progress";
|
||||
|
||||
return (
|
||||
<div key={run.id} className="run-card">
|
||||
<div className="run-header">
|
||||
<span className="run-id">#{i + 1} {run.id.slice(0, 8)}</span>
|
||||
<span className={cn("run-status", run.status)}>
|
||||
<StatusIcon size={14} className={statusInfo.color} />
|
||||
{run.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="run-details">
|
||||
<span>Started {relativeTime(run.startedAt)}</span>
|
||||
<span>•</span>
|
||||
<span>{duration}</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div
|
||||
className="run-logs-container"
|
||||
style={{
|
||||
padding: "12px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderBottom: "1px solid var(--border-color)",
|
||||
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>
|
||||
</div>
|
||||
) : runLogs.length === 0 ? (
|
||||
<div className="text-muted" style={{ padding: "12px 0", fontStyle: "italic" }}>
|
||||
No logs available for this run
|
||||
</div>
|
||||
) : (
|
||||
<AgentLogViewer entries={runLogs} loading={false} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="runs-tab">
|
||||
{activeRun && renderRunCard(activeRun, 0, true)}
|
||||
{sortedRuns.map((run, i) => renderRunCard(run, activeRun ? i + 1 : i, false))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { fetchAgentRuns } from "../api";
|
||||
interface AgentRunHistoryProps {
|
||||
agentId: string;
|
||||
projectId?: string;
|
||||
/** Optional callback when a run row is clicked */
|
||||
onRunClick?: (runId: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
|
||||
@@ -15,7 +17,7 @@ const STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }>
|
||||
terminated: { icon: Square, color: "var(--text-muted, #8b949e)" },
|
||||
};
|
||||
|
||||
export function AgentRunHistory({ agentId, projectId }: AgentRunHistoryProps) {
|
||||
export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHistoryProps) {
|
||||
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -46,7 +48,20 @@ export function AgentRunHistory({ agentId, projectId }: AgentRunHistoryProps) {
|
||||
const usage = run.usageJson;
|
||||
|
||||
return (
|
||||
<div key={run.id} className="agent-run-row">
|
||||
<div
|
||||
key={run.id}
|
||||
className={onRunClick ? "agent-run-row agent-run-row--clickable" : "agent-run-row"}
|
||||
onClick={onRunClick ? () => onRunClick(run.id) : undefined}
|
||||
role={onRunClick ? "button" : undefined}
|
||||
tabIndex={onRunClick ? 0 : undefined}
|
||||
aria-label={onRunClick ? `Run ${run.id.slice(0, 8)}, ${run.status}` : undefined}
|
||||
onKeyDown={onRunClick ? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRunClick(run.id);
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
<StatusIcon size={16} style={{ color: statusInfo.color }} className={run.status === "active" ? "animate-spin" : ""} />
|
||||
<div className="agent-run-info">
|
||||
<span className="agent-run-id">{run.id}</span>
|
||||
|
||||
@@ -12,13 +12,23 @@ vi.mock("../../api", () => ({
|
||||
updateAgentState: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
fetchAgentLogs: vi.fn(),
|
||||
fetchAgentRunLogs: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchAgent, updateAgent, updateAgentState } from "../../api";
|
||||
vi.mock("../AgentLogViewer", () => ({
|
||||
AgentLogViewer: ({ entries }: { entries: Array<{ text: string }> }) => (
|
||||
<div data-testid="agent-log-viewer">
|
||||
{entries.map((e, i) => <span key={i}>{e.text}</span>)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs } from "../../api";
|
||||
|
||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||
const mockUpdateAgent = vi.mocked(updateAgent);
|
||||
const mockUpdateAgentState = vi.mocked(updateAgentState);
|
||||
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
|
||||
|
||||
describe("AgentDetailView", () => {
|
||||
const createMockAgent = (overrides: Partial<{
|
||||
@@ -882,4 +892,221 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Runs Tab — Click to show logs ──────────────────────────────────
|
||||
|
||||
describe("Runs Tab — click to show logs", () => {
|
||||
const navigateToRuns = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Runs")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText("Runs"));
|
||||
};
|
||||
|
||||
it("shows run cards as clickable with chevron indicators", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToRuns(user);
|
||||
|
||||
await waitFor(() => {
|
||||
// Completed run card should be clickable (has role="button")
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const runButtons = buttons.filter(btn => btn.getAttribute("aria-label")?.includes("run"));
|
||||
expect(runButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches and displays logs when clicking a completed run", async () => {
|
||||
const mockLogs = [
|
||||
{ timestamp: "2024-01-01T00:01:00.000Z", taskId: "FN-001", text: "Starting task execution", type: "text" },
|
||||
{ timestamp: "2024-01-01T00:02:00.000Z", taskId: "FN-001", text: "Read file: src/index.ts", type: "tool" },
|
||||
];
|
||||
mockFetchAgentRunLogs.mockResolvedValue(mockLogs);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToRuns(user);
|
||||
|
||||
// Wait for run cards to render
|
||||
await waitFor(() => {
|
||||
const runButtons = screen.getAllByRole("button").filter(
|
||||
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
|
||||
);
|
||||
expect(runButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Click the completed run
|
||||
const completedRunButton = screen.getAllByRole("button").find(
|
||||
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
|
||||
)!;
|
||||
await user.click(completedRunButton);
|
||||
|
||||
// Verify fetchAgentRunLogs was called
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgentRunLogs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify logs appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Starting task execution")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while fetching run logs", async () => {
|
||||
// Create a promise that won't resolve immediately
|
||||
let resolveLogs: (value: any) => void;
|
||||
mockFetchAgentRunLogs.mockImplementation(() => new Promise(r => { resolveLogs = r; }));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToRuns(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const runButtons = screen.getAllByRole("button").filter(
|
||||
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
|
||||
);
|
||||
expect(runButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const completedRunButton = screen.getAllByRole("button").find(
|
||||
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
|
||||
)!;
|
||||
await user.click(completedRunButton);
|
||||
|
||||
// Should show loading state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Loading logs...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Resolve to clean up
|
||||
resolveLogs!([]);
|
||||
});
|
||||
|
||||
it("shows empty message when no logs available for a run", async () => {
|
||||
mockFetchAgentRunLogs.mockResolvedValue([]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToRuns(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const runButtons = screen.getAllByRole("button").filter(
|
||||
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
|
||||
);
|
||||
expect(runButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const completedRunButton = screen.getAllByRole("button").find(
|
||||
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
|
||||
)!;
|
||||
await user.click(completedRunButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No logs available for this run")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses log viewer when clicking the same run again", async () => {
|
||||
mockFetchAgentRunLogs.mockResolvedValue([
|
||||
{ timestamp: "2024-01-01T00:01:00.000Z", taskId: "FN-001", text: "Test log entry", type: "text" },
|
||||
]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToRuns(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const runButtons = screen.getAllByRole("button").filter(
|
||||
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
|
||||
);
|
||||
expect(runButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const completedRunButton = screen.getAllByRole("button").find(
|
||||
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
|
||||
)!;
|
||||
|
||||
// Click to expand
|
||||
await user.click(completedRunButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test log entry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click to collapse
|
||||
await user.click(completedRunButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Test log entry")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows toast on fetch error", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockFetchAgentRunLogs.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={addToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToRuns(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const runButtons = screen.getAllByRole("button").filter(
|
||||
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
|
||||
);
|
||||
expect(runButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const completedRunButton = screen.getAllByRole("button").find(
|
||||
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
|
||||
)!;
|
||||
await user.click(completedRunButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to load run logs"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
|
||||
addSteeringComment: vi.fn(),
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
@@ -7802,3 +7803,128 @@ describe("POST /api/agents/:id/runs", () => {
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/agents/:id/runs/:runId/logs", () => {
|
||||
let tempDir: string;
|
||||
let fusionDir: string;
|
||||
let agentId: string;
|
||||
let taskId: string;
|
||||
let runId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-run-logs-"));
|
||||
fusionDir = join(tempDir, ".fusion");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.createAgent({
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
});
|
||||
agentId = agent.id;
|
||||
|
||||
// Start a run, complete it, and record its ID
|
||||
const run = await agentStore.startHeartbeatRun(agentId);
|
||||
runId = run.id;
|
||||
|
||||
// End the run with a context snapshot containing a taskId
|
||||
run.endedAt = new Date().toISOString();
|
||||
run.status = "completed";
|
||||
run.contextSnapshot = { taskId: "FN-001", projectId: "test-project" };
|
||||
await agentStore.saveRun(run);
|
||||
|
||||
taskId = "FN-001";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([
|
||||
{
|
||||
timestamp: "2024-01-01T00:01:00.000Z",
|
||||
taskId: "FN-001",
|
||||
text: "Starting task execution",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
timestamp: "2024-01-01T00:02:00.000Z",
|
||||
taskId: "FN-001",
|
||||
text: "Read file: src/index.ts",
|
||||
type: "tool",
|
||||
},
|
||||
]),
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns logs for a valid run with contextSnapshot.taskId", async () => {
|
||||
const res = await REQUEST(buildApp(), "GET", `/api/agents/${agentId}/runs/${runId}/logs`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(2);
|
||||
expect(res.body[0]).toMatchObject({ taskId: "FN-001", type: "text" });
|
||||
expect(res.body[1]).toMatchObject({ taskId: "FN-001", type: "tool" });
|
||||
});
|
||||
|
||||
it("returns empty array for run without contextSnapshot.taskId", async () => {
|
||||
// Create a run without contextSnapshot
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const run = await agentStore.startHeartbeatRun(agentId);
|
||||
run.endedAt = new Date().toISOString();
|
||||
run.status = "completed";
|
||||
// No contextSnapshot
|
||||
await agentStore.saveRun(run);
|
||||
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await REQUEST(app, "GET", `/api/agents/${agentId}/runs/${run.id}/logs`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent run", async () => {
|
||||
const res = await REQUEST(buildApp(), "GET", `/api/agents/${agentId}/runs/run-nonexistent/logs`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("Run not found");
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent agent", async () => {
|
||||
const res = await REQUEST(buildApp(), "GET", `/api/agents/agent-nonexistent/runs/${runId}/logs`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("handles store errors gracefully", async () => {
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue("/nonexistent/path"),
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await REQUEST(app, "GET", `/api/agents/${agentId}/runs/${runId}/logs`);
|
||||
|
||||
// Either 404 or 500 depending on where the error occurs
|
||||
expect([404, 500]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7230,6 +7230,50 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/runs/:runId/logs
|
||||
* Get agent log entries for a specific run's time window.
|
||||
* Uses the run's contextSnapshot.taskId to locate the task's agent log,
|
||||
* then filters entries by the run's startedAt/endedAt timestamps.
|
||||
* Returns an empty array if the run has no associated task.
|
||||
*/
|
||||
router.get("/agents/:id/runs/:runId/logs", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const run = await agentStore.getRunDetail(req.params.id, req.params.runId);
|
||||
if (!run) {
|
||||
res.status(404).json({ error: "Run not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Only use the run's context snapshot for task ID — do not fall back
|
||||
// to agent.taskId since that represents the agent's *current* task,
|
||||
// not the task active during a historical run.
|
||||
const taskId = run.contextSnapshot?.taskId as string | undefined;
|
||||
if (!taskId) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const logs = await scopedStore.getAgentLogsByTimeRange(
|
||||
taskId,
|
||||
run.startedAt,
|
||||
run.endedAt,
|
||||
);
|
||||
res.json(logs);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/children
|
||||
* Fetch agents that report to a given agent (parent-child hierarchy).
|
||||
|
||||
Reference in New Issue
Block a user