feat(HAI-072): add agent log viewer with persistence, streaming, and UI

- Add agent log persistence layer with JSONL append/read and event emission in core store
- Add server-side SSE log streaming endpoint and REST route for fetching logs
- Create AgentLogViewer component and useAgentLogs hook for real-time log display
- Integrate log viewer into TaskDetailModal
- Fix pre-existing build and test errors
This commit is contained in:
Dustin Byrne
2026-03-26 00:36:48 -04:00
parent fbecff7255
commit 20264a854d
15 changed files with 744 additions and 8 deletions

View File

@@ -17,6 +17,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
...overrides,
} as unknown as TaskStore;
}
@@ -340,4 +341,36 @@ describe("Attachment routes", () => {
expect(res.status).toBe(404);
});
it("GET /tasks/:id/logs — returns agent logs", async () => {
const fakeLogs = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "HAI-001", text: "Hello", type: "text" },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "HAI-001", text: "Read", type: "tool" },
];
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue(fakeLogs);
const res = await GET(buildApp(), "/api/tasks/HAI-001/logs");
expect(res.status).toBe(200);
expect(res.body).toEqual(fakeLogs);
expect(store.getAgentLogs).toHaveBeenCalledWith("HAI-001");
});
it("GET /tasks/:id/logs — returns empty array when no logs", async () => {
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const res = await GET(buildApp(), "/api/tasks/HAI-001/logs");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("GET /tasks/:id/logs — returns 500 on store error", async () => {
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("disk error"));
const res = await GET(buildApp(), "/api/tasks/HAI-001/logs");
expect(res.status).toBe(500);
expect(res.body.error).toBe("disk error");
});
});

View File

@@ -174,6 +174,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// Get historical agent logs for a task
router.get("/tasks/:id/logs", async (req, res) => {
try {
const logs = await store.getAgentLogs(req.params.id);
res.json(logs);
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });
} else {
res.status(500).json({ error: err.message });
}
}
});
// Get single task with prompt content
router.get("/tasks/:id", async (req, res) => {
try {

View File

@@ -43,6 +43,35 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// Rate limiting — stricter limit on SSE connections
app.get("/api/events", rateLimit(RATE_LIMITS.sse), createSSE(store));
// Per-task SSE endpoint for live agent log streaming
app.get("/api/tasks/:id/logs/stream", (req, res) => {
const taskId = req.params.id;
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
res.write(": connected\n\n");
const onAgentLog = (entry: { taskId: string; text: string; type: string; timestamp: string }) => {
if (entry.taskId !== taskId) return;
res.write(`event: agent:log\ndata: ${JSON.stringify(entry)}\n\n`);
};
store.on("agent:log", onAgentLog);
const heartbeat = setInterval(() => {
res.write(": heartbeat\n\n");
}, 30_000);
req.on("close", () => {
clearInterval(heartbeat);
store.off("agent:log", onAgentLog);
});
});
// Rate limiting — mutation endpoints (POST/PUT/PATCH/DELETE)
app.use("/api", rateLimit(RATE_LIMITS.api));