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

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry } from "./types.js";
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry } from "./types.js";
export { TaskStore } from "./store.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";

View File

@@ -400,4 +400,50 @@ describe("TaskStore", () => {
expect(updated.blockedBy).toBeUndefined();
});
});
describe("agent log persistence", () => {
it("appendAgentLog creates agent.log and getAgentLogs reads it back", async () => {
const task = await createTestTask();
await store.appendAgentLog(task.id, "Hello world", "text");
await store.appendAgentLog(task.id, "Read", "tool");
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(2);
expect(logs[0].text).toBe("Hello world");
expect(logs[0].type).toBe("text");
expect(logs[0].taskId).toBe(task.id);
expect(logs[1].text).toBe("Read");
expect(logs[1].type).toBe("tool");
});
it("getAgentLogs returns empty array when no log file exists", async () => {
const task = await createTestTask();
const logs = await store.getAgentLogs(task.id);
expect(logs).toEqual([]);
});
it("appendAgentLog emits agent:log event", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("agent:log", (entry) => events.push(entry));
await store.appendAgentLog(task.id, "delta text", "text");
expect(events).toHaveLength(1);
expect(events[0].text).toBe("delta text");
expect(events[0].type).toBe("text");
expect(events[0].taskId).toBe(task.id);
});
it("handles multiple appends correctly (JSONL format)", async () => {
const task = await createTestTask();
for (let i = 0; i < 5; i++) {
await store.appendAgentLog(task.id, `chunk ${i}`, "text");
}
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(5);
expect(logs[4].text).toBe("chunk 4");
});
});
});

View File

@@ -1,9 +1,9 @@
import { EventEmitter } from "node:events";
import { execSync } from "node:child_process";
import { mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
import { join, sep } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, BoardConfig, Column, MergeResult, Settings } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
export interface TaskStoreEvents {
@@ -12,6 +12,7 @@ export interface TaskStoreEvents {
"task:updated": [task: Task];
"task:deleted": [task: Task];
"task:merged": [result: MergeResult];
"agent:log": [entry: AgentLogEntry];
}
export class TaskStore extends EventEmitter<TaskStoreEvents> {
@@ -839,6 +840,52 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
/**
* Append an agent log entry to the task's agent log file (JSONL format).
* Each entry is a single JSON line appended to `.hai/tasks/{ID}/agent.log`.
* Also emits an `agent:log` event for live streaming.
*
* @param taskId - The task ID (e.g. "HAI-001")
* @param text - The text content (delta for "text", tool name for "tool")
* @param type - Whether this is a "text" delta or a "tool" invocation marker
*/
async appendAgentLog(taskId: string, text: string, type: "text" | "tool"): Promise<void> {
const entry: AgentLogEntry = {
timestamp: new Date().toISOString(),
taskId,
text,
type,
};
const dir = this.taskDir(taskId);
const logPath = join(dir, "agent.log");
await appendFile(logPath, JSON.stringify(entry) + "\n");
this.emit("agent:log", entry);
}
/**
* Read all historical agent log entries for a task from its agent log file.
* Returns entries in chronological order (oldest first).
*
* @param taskId - The task ID (e.g. "HAI-001")
* @returns Array of agent log entries, empty if no log file exists
*/
async getAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
const dir = this.taskDir(taskId);
const logPath = join(dir, "agent.log");
if (!existsSync(logPath)) return [];
const content = await readFile(logPath, "utf-8");
const entries: AgentLogEntry[] = [];
for (const line of content.split("\n")) {
if (!line.trim()) continue;
try {
entries.push(JSON.parse(line) as AgentLogEntry);
} catch {
// skip malformed lines
}
}
return entries;
}
getRootDir(): string {
return this.rootDir;
}

View File

@@ -14,6 +14,18 @@ export interface TaskLogEntry {
outcome?: string;
}
/** A single chunk of agent output (text delta or tool invocation) persisted to disk. */
export interface AgentLogEntry {
/** ISO-8601 timestamp of when the entry was recorded */
timestamp: string;
/** The task this log entry belongs to */
taskId: string;
/** The text content (delta for "text", tool name for "tool") */
text: string;
/** Whether this is a text delta or a tool invocation marker */
type: "text" | "tool";
}
export interface TaskAttachment {
filename: string;
originalName: string;

View File

@@ -1,4 +1,4 @@
import type { Task, TaskDetail, TaskAttachment, TaskCreateInput, Column, MergeResult, Settings } from "@hai/core";
import type { Task, TaskDetail, TaskAttachment, TaskCreateInput, AgentLogEntry, Column, MergeResult, Settings } from "@hai/core";
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`/api${path}`, {
@@ -93,3 +93,7 @@ export async function uploadAttachment(id: string, file: File): Promise<TaskAtta
export async function deleteAttachment(id: string, filename: string): Promise<Task> {
return api<Task>(`/tasks/${id}/attachments/${filename}`, { method: "DELETE" });
}
export function fetchAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
return api<AgentLogEntry[]>(`/tasks/${taskId}/logs`);
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import type { AgentLogEntry } from "@hai/core";
interface AgentLogViewerProps {
entries: AgentLogEntry[];
loading: boolean;
}
/**
* Renders agent log entries in a scrollable, monospace container.
* Auto-scrolls to the bottom as new entries arrive, but pauses
* auto-scroll when the user scrolls up (scroll-lock).
*/
export function AgentLogViewer({ entries, loading }: AgentLogViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
// Auto-scroll to bottom when new entries arrive (if scroll-lock is not active)
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [entries, autoScroll]);
const handleScroll = () => {
const el = containerRef.current;
if (!el) return;
// If the user is within 50px of the bottom, re-enable auto-scroll
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
setAutoScroll(atBottom);
};
if (loading && entries.length === 0) {
return (
<div className="agent-log-viewer" data-testid="agent-log-viewer">
<div className="agent-log-loading">Loading agent logs</div>
</div>
);
}
if (entries.length === 0) {
return (
<div className="agent-log-viewer" data-testid="agent-log-viewer">
<div className="agent-log-empty">No agent output yet.</div>
</div>
);
}
return (
<div
className="agent-log-viewer"
data-testid="agent-log-viewer"
ref={containerRef}
onScroll={handleScroll}
style={{
fontFamily: "monospace",
fontSize: "13px",
lineHeight: "1.5",
overflowY: "auto",
maxHeight: "500px",
padding: "12px",
background: "var(--bg-secondary, #1a1a2e)",
borderRadius: "6px",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{entries.map((entry, i) =>
entry.type === "tool" ? (
<div
key={i}
className="agent-log-tool"
style={{
color: "var(--accent, #7c5cbf)",
margin: "4px 0",
padding: "2px 6px",
borderLeft: "3px solid var(--accent, #7c5cbf)",
background: "rgba(124, 92, 191, 0.08)",
}}
>
{entry.text}
</div>
) : (
<span key={i} className="agent-log-text">
{entry.text}
</span>
),
)}
</div>
);
}

View File

@@ -5,6 +5,8 @@ import type { Task, TaskDetail, TaskAttachment, Column, MergeResult } from "@hai
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@hai/core";
import { uploadAttachment, deleteAttachment, updateTask } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs";
import { AgentLogViewer } from "./AgentLogViewer";
function formatTimestamp(iso: string): string {
const date = new Date(iso);
@@ -52,11 +54,16 @@ export function TaskDetailModal({
onRetryTask,
addToast,
}: TaskDetailModalProps) {
const [activeTab, setActiveTab] = useState<"definition" | "agent-log">("definition");
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
const [uploading, setUploading] = useState(false);
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
const [showDepDropdown, setShowDepDropdown] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { entries: agentLogEntries, loading: agentLogLoading } = useAgentLogs(
task.id,
activeTab === "agent-log",
);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -235,6 +242,46 @@ export function TaskDetailModal({
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
{new Date(task.updatedAt).toLocaleDateString()}
</div>
<div className="detail-tabs" style={{ display: "flex", gap: "0", borderBottom: "1px solid var(--border, #333)", marginBottom: "12px" }}>
<button
className={`detail-tab${activeTab === "definition" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("definition")}
style={{
padding: "8px 16px",
background: "none",
border: "none",
borderBottom: activeTab === "definition" ? "2px solid var(--accent, #7c5cbf)" : "2px solid transparent",
color: activeTab === "definition" ? "var(--text-primary, #fff)" : "var(--text-secondary, #888)",
cursor: "pointer",
fontSize: "14px",
fontWeight: activeTab === "definition" ? 600 : 400,
}}
>
Definition
</button>
<button
className={`detail-tab${activeTab === "agent-log" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("agent-log")}
style={{
padding: "8px 16px",
background: "none",
border: "none",
borderBottom: activeTab === "agent-log" ? "2px solid var(--accent, #7c5cbf)" : "2px solid transparent",
color: activeTab === "agent-log" ? "var(--text-primary, #fff)" : "var(--text-secondary, #888)",
cursor: "pointer",
fontSize: "14px",
fontWeight: activeTab === "agent-log" ? 600 : 400,
}}
>
Agent Log
</button>
</div>
{activeTab === "agent-log" ? (
<div className="detail-section">
<AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} />
</div>
) : (
<>
<div className="detail-section">
{task.prompt ? (
<div className="markdown-body">
@@ -400,6 +447,8 @@ export function TaskDetailModal({
<div className="detail-log-empty">(no activity)</div>
)}
</div>
</>
)}
</div>
<div className="modal-actions">
<button className="btn btn-danger btn-sm" onClick={handleDelete}>

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { AgentLogViewer } from "../AgentLogViewer";
import type { AgentLogEntry } from "@hai/core";
function makeEntry(overrides: Partial<AgentLogEntry> = {}): AgentLogEntry {
return {
timestamp: "2026-01-01T00:00:00Z",
taskId: "HAI-001",
text: "Hello world",
type: "text",
...overrides,
};
}
describe("AgentLogViewer", () => {
it("shows loading message when loading with no entries", () => {
render(<AgentLogViewer entries={[]} loading={true} />);
expect(screen.getByText("Loading agent logs…")).toBeTruthy();
});
it("shows empty message when no entries and not loading", () => {
render(<AgentLogViewer entries={[]} loading={false} />);
expect(screen.getByText("No agent output yet.")).toBeTruthy();
});
it("renders text entries as spans", () => {
const entries = [
makeEntry({ text: "first chunk" }),
makeEntry({ text: "second chunk" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(2);
expect(textSpans[0].textContent).toBe("first chunk");
expect(textSpans[1].textContent).toBe("second chunk");
});
it("renders tool entries with distinct styling", () => {
const entries = [
makeEntry({ text: "Read", type: "tool" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const toolDiv = container.querySelector(".agent-log-tool");
expect(toolDiv).toBeTruthy();
expect(toolDiv!.textContent).toContain("Read");
});
it("renders a mix of text and tool entries", () => {
const entries = [
makeEntry({ text: "Starting...", type: "text" }),
makeEntry({ text: "Bash", type: "tool" }),
makeEntry({ text: "Done!", type: "text" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
expect(container.querySelectorAll(".agent-log-text")).toHaveLength(2);
expect(container.querySelectorAll(".agent-log-tool")).toHaveLength(1);
});
it("has a monospace font family", () => {
const entries = [makeEntry()];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement;
expect(viewer.style.fontFamily).toBe("monospace");
});
});

View File

@@ -7,6 +7,11 @@ vi.mock("../../api", () => ({
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
updateTask: vi.fn().mockResolvedValue({}),
fetchAgentLogs: vi.fn().mockResolvedValue([]),
}));
vi.mock("../../hooks/useAgentLogs", () => ({
useAgentLogs: vi.fn(() => ({ entries: [], loading: false, clear: vi.fn() })),
}));
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
@@ -481,8 +486,6 @@ describe("TaskDetailModal", () => {
expect(updateTask).toHaveBeenCalledWith("HAI-099", { dependencies: ["HAI-002"] });
});
});
});
});
it("activity list does not have nested scroll constraints", () => {
const { container } = render(
@@ -508,4 +511,84 @@ describe("TaskDetailModal", () => {
expect(style.overflowY).not.toBe("auto");
expect(style.maxHeight).toBe("");
});
describe("tab toggle", () => {
it("defaults to the Definition tab", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(screen.getByText("Definition")).toBeTruthy();
expect(screen.getByText("Agent Log")).toBeTruthy();
// Definition content should be visible
expect(container.querySelector(".markdown-body")).toBeTruthy();
// Agent log viewer should not be visible
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
});
it("switches to Agent Log tab and back", async () => {
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
const mockUseAgentLogs = vi.mocked(useAgentLogs);
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
// Click Agent Log tab
fireEvent.click(screen.getByText("Agent Log"));
// Agent log viewer should appear
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy();
// Definition content should be hidden
expect(container.querySelector(".markdown-body")).toBeNull();
// Click Definition tab to go back
fireEvent.click(screen.getByText("Definition"));
// Definition content should reappear
expect(container.querySelector(".markdown-body")).toBeTruthy();
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
});
it("passes enabled=true to useAgentLogs only when Agent Log tab is active", async () => {
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
const mockUseAgentLogs = vi.mocked(useAgentLogs);
mockUseAgentLogs.mockClear();
const { rerender } = render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
// Default: Definition tab active → enabled should be false
const initialCall = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(initialCall[1]).toBe(false);
// Switch to Agent Log tab
fireEvent.click(screen.getByText("Agent Log"));
const afterSwitch = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(afterSwitch[1]).toBe(true);
});
});
});

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useAgentLogs } from "../useAgentLogs";
import { fetchAgentLogs } from "../../api";
// Mock the api module
vi.mock("../../api", () => ({
fetchAgentLogs: vi.fn().mockResolvedValue([]),
}));
const mockFetchAgentLogs = vi.mocked(fetchAgentLogs);
// Mock EventSource
class MockEventSource {
static instances: MockEventSource[] = [];
url: string;
listeners: Record<string, ((e: any) => void)[]> = {};
readyState = 0;
close = vi.fn();
constructor(url: string) {
this.url = url;
this.readyState = 1;
MockEventSource.instances.push(this);
}
addEventListener(event: string, fn: (e: any) => void) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(fn);
}
// Helper to simulate a server event
_emit(event: string, data: any) {
for (const fn of this.listeners[event] || []) {
fn({ data: JSON.stringify(data) });
}
}
}
const originalEventSource = globalThis.EventSource;
beforeEach(() => {
MockEventSource.instances = [];
(globalThis as any).EventSource = MockEventSource;
mockFetchAgentLogs.mockReset().mockResolvedValue([]);
});
afterEach(() => {
(globalThis as any).EventSource = originalEventSource;
});
describe("useAgentLogs", () => {
it("does not fetch or connect when enabled=false", () => {
const { result } = renderHook(() => useAgentLogs("HAI-001", false));
expect(mockFetchAgentLogs).not.toHaveBeenCalled();
expect(MockEventSource.instances).toHaveLength(0);
expect(result.current.entries).toEqual([]);
});
it("fetches historical logs and opens SSE when enabled=true", async () => {
const historicalLogs = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "HAI-001", text: "old", type: "text" as const },
];
mockFetchAgentLogs.mockResolvedValueOnce(historicalLogs);
const { result } = renderHook(() => useAgentLogs("HAI-001", true));
await waitFor(() => {
expect(result.current.entries).toEqual(historicalLogs);
});
expect(mockFetchAgentLogs).toHaveBeenCalledWith("HAI-001");
expect(MockEventSource.instances).toHaveLength(1);
expect(MockEventSource.instances[0].url).toBe("/api/tasks/HAI-001/logs/stream");
});
it("appends live SSE entries to historical entries", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([
{ timestamp: "2026-01-01T00:00:00Z", taskId: "HAI-001", text: "old", type: "text" as const },
]);
const { result } = renderHook(() => useAgentLogs("HAI-001", true));
await waitFor(() => {
expect(result.current.entries).toHaveLength(1);
});
const es = MockEventSource.instances[0];
act(() => {
es._emit("agent:log", {
timestamp: "2026-01-01T00:01:00Z",
taskId: "HAI-001",
text: "new",
type: "text",
});
});
expect(result.current.entries).toHaveLength(2);
expect(result.current.entries[1].text).toBe("new");
});
it("closes SSE when enabled changes to false", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]);
const { rerender } = renderHook(
({ enabled }) => useAgentLogs("HAI-001", enabled),
{ initialProps: { enabled: true } },
);
await waitFor(() => {
expect(MockEventSource.instances).toHaveLength(1);
});
const es = MockEventSource.instances[0];
rerender({ enabled: false });
expect(es.close).toHaveBeenCalled();
});
it("closes SSE on unmount", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]);
const { unmount } = renderHook(() => useAgentLogs("HAI-001", true));
await waitFor(() => {
expect(MockEventSource.instances).toHaveLength(1);
});
const es = MockEventSource.instances[0];
unmount();
expect(es.close).toHaveBeenCalled();
});
it("does not fetch when taskId is null", () => {
renderHook(() => useAgentLogs(null, true));
expect(mockFetchAgentLogs).not.toHaveBeenCalled();
expect(MockEventSource.instances).toHaveLength(0);
});
});

View File

@@ -0,0 +1,75 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { AgentLogEntry } from "@hai/core";
import { fetchAgentLogs } from "../api";
/**
* Hook that manages agent log fetching and live SSE streaming for a task.
*
* When `enabled` is true:
* 1. Fetches historical logs via GET /api/tasks/:id/logs
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
* 3. Merges historical + live entries in order
*
* When `enabled` becomes false or the component unmounts, the EventSource
* is closed to avoid unnecessary SSE connections.
*/
export function useAgentLogs(taskId: string | null, enabled: boolean) {
const [entries, setEntries] = useState<AgentLogEntry[]>([]);
const [loading, setLoading] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!taskId || !enabled) {
// Close any existing connection when disabled
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
return;
}
let cancelled = false;
async function init() {
setLoading(true);
try {
const historical = await fetchAgentLogs(taskId!);
if (cancelled) return;
setEntries(historical);
} catch {
if (cancelled) return;
setEntries([]);
} finally {
if (!cancelled) setLoading(false);
}
// Open SSE connection for live updates
const es = new EventSource(`/api/tasks/${taskId}/logs/stream`);
eventSourceRef.current = es;
es.addEventListener("agent:log", (e) => {
if (cancelled) return;
try {
const entry: AgentLogEntry = JSON.parse(e.data);
setEntries((prev) => [...prev, entry]);
} catch {
// skip malformed events
}
});
}
init();
return () => {
cancelled = true;
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [taskId, enabled]);
const clear = useCallback(() => setEntries([]), []);
return { entries, loading, clear };
}

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));

View File

@@ -314,14 +314,54 @@ export class TaskExecutor {
this.createReviewStepTool(task.id, worktreePath, detail.prompt),
];
// ── Agent log buffering ──────────────────────────────────────────
// Buffer text deltas and flush to disk periodically to avoid
// excessive I/O from many small writes.
let textBuffer = "";
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const FLUSH_INTERVAL_MS = 500;
const FLUSH_SIZE_BYTES = 1024;
const flushTextBuffer = async () => {
if (textBuffer.length === 0) return;
const chunk = textBuffer;
textBuffer = "";
try {
await this.store.appendAgentLog(task.id, chunk, "text");
} catch { /* best-effort persistence */ }
};
const scheduleFlush = () => {
if (flushTimer) return;
flushTimer = setTimeout(async () => {
flushTimer = null;
await flushTextBuffer();
}, FLUSH_INTERVAL_MS);
};
const agentWork = async () => {
const { session } = await createHaiAgent({
cwd: worktreePath,
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
tools: "coding",
customTools,
onText: (delta) => this.options.onAgentText?.(task.id, delta),
onToolStart: (name) => this.options.onAgentTool?.(task.id, name),
onText: (delta) => {
this.options.onAgentText?.(task.id, delta);
textBuffer += delta;
if (textBuffer.length >= FLUSH_SIZE_BYTES) {
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
flushTextBuffer();
} else {
scheduleFlush();
}
},
onToolStart: (name) => {
this.options.onAgentTool?.(task.id, name);
// Flush any pending text before recording the tool entry
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
flushTextBuffer();
this.store.appendAgentLog(task.id, name, "tool").catch(() => {});
},
});
try {
@@ -339,6 +379,9 @@ export class TaskExecutor {
this.options.onComplete?.(task);
}
} finally {
// Flush remaining buffered text before disposing the session
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
await flushTextBuffer();
session.dispose();
}
};