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;