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:
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user