feat(HAI-003): add file-system watcher to TaskStore for live dashboard updates
- Add watch() method using Node's built-in fs.watch with recursive option - Detect task.json changes and emit appropriate SSE events (created/moved/updated/deleted) - Debounce per-file changes (150ms) to coalesce rapid writes - Suppress duplicate events for in-process mutations via recentlyWritten set - Integrate watcher into dashboard server startup with clean SIGINT shutdown - Add @types/node to core package for proper TypeScript support
This commit is contained in:
@@ -15,6 +15,7 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
|||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
const store = new TaskStore(cwd);
|
const store = new TaskStore(cwd);
|
||||||
await store.init();
|
await store.init();
|
||||||
|
await store.watch();
|
||||||
|
|
||||||
// AI-powered merge handler
|
// AI-powered merge handler
|
||||||
const onMerge = (taskId: string) =>
|
const onMerge = (taskId: string) =>
|
||||||
@@ -26,6 +27,14 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
|||||||
// Start the web server with AI merge wired in
|
// Start the web server with AI merge wired in
|
||||||
const app = createServer(store, { onMerge });
|
const app = createServer(store, { onMerge });
|
||||||
|
|
||||||
|
// Clean shutdown for file watcher when engine is not active
|
||||||
|
if (!opts.engine) {
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
store.stopWatching();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Optionally start the AI engine
|
// Optionally start the AI engine
|
||||||
if (opts.engine) {
|
if (opts.engine) {
|
||||||
const triage = new TriageProcessor(store, cwd, {
|
const triage = new TriageProcessor(store, cwd, {
|
||||||
@@ -52,6 +61,7 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
|||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
triage.stop();
|
triage.stop();
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
|
store.stopWatching();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,6 +81,7 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
|||||||
} else {
|
} else {
|
||||||
console.log(` AI engine: off (use --engine to enable)`);
|
console.log(` AI engine: off (use --engine to enable)`);
|
||||||
}
|
}
|
||||||
|
console.log(` File watcher: ✓ active`);
|
||||||
console.log(` Press Ctrl+C to stop`);
|
console.log(` Press Ctrl+C to stop`);
|
||||||
console.log();
|
console.log();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
|
import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join, sep } from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync, watch, type FSWatcher } from "node:fs";
|
||||||
import type { Task, TaskDetail, TaskCreateInput, BoardConfig, Column, MergeResult } from "./types.js";
|
import type { Task, TaskDetail, TaskCreateInput, BoardConfig, Column, MergeResult } from "./types.js";
|
||||||
import { VALID_TRANSITIONS } from "./types.js";
|
import { VALID_TRANSITIONS } from "./types.js";
|
||||||
|
|
||||||
@@ -19,6 +19,17 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
private tasksDir: string;
|
private tasksDir: string;
|
||||||
private configPath: string;
|
private configPath: string;
|
||||||
|
|
||||||
|
/** File-system watcher instance */
|
||||||
|
private watcher: FSWatcher | null = null;
|
||||||
|
/** In-memory cache of tasks for diffing watcher events */
|
||||||
|
private taskCache: Map<string, Task> = new Map();
|
||||||
|
/** Paths recently written by in-process mutations (suppresses duplicate events) */
|
||||||
|
private recentlyWritten: Set<string> = new Set();
|
||||||
|
/** Pending debounce timers keyed by task ID */
|
||||||
|
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||||
|
/** Debounce interval in ms */
|
||||||
|
private debounceMs = 150;
|
||||||
|
|
||||||
constructor(private rootDir: string) {
|
constructor(private rootDir: string) {
|
||||||
super();
|
super();
|
||||||
this.haiDir = join(rootDir, ".hai");
|
this.haiDir = join(rootDir, ".hai");
|
||||||
@@ -69,7 +80,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
|
|
||||||
const dir = this.taskDir(id);
|
const dir = this.taskDir(id);
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
await writeFile(join(dir, "task.json"), JSON.stringify(task, null, 2));
|
const taskJsonPath = join(dir, "task.json");
|
||||||
|
this.suppressWatcher(taskJsonPath);
|
||||||
|
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
|
||||||
|
|
||||||
|
// Update cache if watcher is active
|
||||||
|
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
const prompt = task.column === "triage"
|
const prompt = task.column === "triage"
|
||||||
? `# ${id}: ${task.title}\n\n${task.description}\n`
|
? `# ${id}: ${task.title}\n\n${task.description}\n`
|
||||||
@@ -134,7 +150,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
task.column = toColumn;
|
task.column = toColumn;
|
||||||
task.updatedAt = new Date().toISOString();
|
task.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await writeFile(join(dir, "task.json"), JSON.stringify(task, null, 2));
|
const taskJsonPath = join(dir, "task.json");
|
||||||
|
this.suppressWatcher(taskJsonPath);
|
||||||
|
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
|
||||||
|
|
||||||
|
// Update cache if watcher is active
|
||||||
|
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
this.emit("task:moved", { task, from: fromColumn, to: toColumn });
|
this.emit("task:moved", { task, from: fromColumn, to: toColumn });
|
||||||
return task;
|
return task;
|
||||||
@@ -153,7 +174,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
if (updates.worktree !== undefined) task.worktree = updates.worktree;
|
if (updates.worktree !== undefined) task.worktree = updates.worktree;
|
||||||
task.updatedAt = new Date().toISOString();
|
task.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await writeFile(join(dir, "task.json"), JSON.stringify(task, null, 2));
|
const taskJsonPath = join(dir, "task.json");
|
||||||
|
this.suppressWatcher(taskJsonPath);
|
||||||
|
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
|
||||||
|
|
||||||
|
// Update cache if watcher is active
|
||||||
|
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
if (updates.prompt !== undefined) {
|
if (updates.prompt !== undefined) {
|
||||||
await writeFile(join(dir, "PROMPT.md"), updates.prompt);
|
await writeFile(join(dir, "PROMPT.md"), updates.prompt);
|
||||||
@@ -168,6 +194,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
const data = await readFile(join(dir, "task.json"), "utf-8");
|
const data = await readFile(join(dir, "task.json"), "utf-8");
|
||||||
const task = JSON.parse(data) as Task;
|
const task = JSON.parse(data) as Task;
|
||||||
|
|
||||||
|
const taskJsonPath = join(dir, "task.json");
|
||||||
|
this.suppressWatcher(taskJsonPath);
|
||||||
|
|
||||||
|
// Remove from cache if watcher is active
|
||||||
|
if (this.watcher) this.taskCache.delete(id);
|
||||||
|
|
||||||
const { rm } = await import("node:fs/promises");
|
const { rm } = await import("node:fs/promises");
|
||||||
await rm(dir, { recursive: true });
|
await rm(dir, { recursive: true });
|
||||||
|
|
||||||
@@ -282,10 +314,156 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
task.column = "done";
|
task.column = "done";
|
||||||
task.worktree = undefined;
|
task.worktree = undefined;
|
||||||
task.updatedAt = new Date().toISOString();
|
task.updatedAt = new Date().toISOString();
|
||||||
await writeFile(join(dir, "task.json"), JSON.stringify(task, null, 2));
|
|
||||||
|
const taskJsonPath = join(dir, "task.json");
|
||||||
|
this.suppressWatcher(taskJsonPath);
|
||||||
|
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
|
||||||
|
|
||||||
|
// Update cache if watcher is active
|
||||||
|
if (this.watcher) this.taskCache.set(task.id, { ...task });
|
||||||
|
|
||||||
this.emit("task:moved", { task, from: "in-review" as Column, to: "done" as Column });
|
this.emit("task:moved", { task, from: "in-review" as Column, to: "done" as Column });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── File-system watcher ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start watching the tasks directory for external changes.
|
||||||
|
* Populates the in-memory cache and begins emitting events for
|
||||||
|
* any task.json mutations made outside this process.
|
||||||
|
*/
|
||||||
|
async watch(): Promise<void> {
|
||||||
|
if (this.watcher) return; // already watching
|
||||||
|
|
||||||
|
// Populate cache with current state
|
||||||
|
const tasks = await this.listTasks();
|
||||||
|
this.taskCache.clear();
|
||||||
|
for (const task of tasks) {
|
||||||
|
this.taskCache.set(task.id, { ...task });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.watcher = watch(this.tasksDir, { recursive: true }, (_event, filename) => {
|
||||||
|
if (typeof filename !== "string") return;
|
||||||
|
this.handleFsChange(filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ignore watcher errors (e.g. dir deleted) – just stop watching
|
||||||
|
this.watcher.on("error", () => {
|
||||||
|
this.stopWatching();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// fs.watch may throw on some platforms; silently degrade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the file-system watcher and clean up.
|
||||||
|
*/
|
||||||
|
stopWatching(): void {
|
||||||
|
if (this.watcher) {
|
||||||
|
this.watcher.close();
|
||||||
|
this.watcher = null;
|
||||||
|
}
|
||||||
|
for (const timer of this.debounceTimers.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
this.debounceTimers.clear();
|
||||||
|
this.taskCache.clear();
|
||||||
|
this.recentlyWritten.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a file path as recently written by an in-process mutation
|
||||||
|
* so the watcher will skip it.
|
||||||
|
*/
|
||||||
|
private suppressWatcher(filePath: string): void {
|
||||||
|
this.recentlyWritten.add(filePath);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.recentlyWritten.delete(filePath);
|
||||||
|
}, this.debounceMs + 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a raw fs.watch callback. `filename` is relative to tasksDir.
|
||||||
|
*/
|
||||||
|
private handleFsChange(filename: string): void {
|
||||||
|
// We only care about task.json files
|
||||||
|
const parts = filename.split(sep);
|
||||||
|
// Normalize for platforms that may use forward slashes
|
||||||
|
const normalizedParts = parts.length === 1 ? filename.split("/") : parts;
|
||||||
|
|
||||||
|
if (normalizedParts.length < 2) return;
|
||||||
|
const taskId = normalizedParts[0];
|
||||||
|
const file = normalizedParts[normalizedParts.length - 1];
|
||||||
|
if (file !== "task.json") return;
|
||||||
|
if (!taskId.startsWith("HAI-")) return;
|
||||||
|
|
||||||
|
const fullPath = join(this.tasksDir, taskId, "task.json");
|
||||||
|
|
||||||
|
// Check suppression
|
||||||
|
if (this.recentlyWritten.has(fullPath)) return;
|
||||||
|
|
||||||
|
// Debounce per task ID
|
||||||
|
const existing = this.debounceTimers.get(taskId);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
|
||||||
|
this.debounceTimers.set(
|
||||||
|
taskId,
|
||||||
|
setTimeout(() => {
|
||||||
|
this.debounceTimers.delete(taskId);
|
||||||
|
this.processTaskChange(taskId, fullPath).catch(() => {
|
||||||
|
// Ignore errors (file may have been deleted mid-read)
|
||||||
|
});
|
||||||
|
}, this.debounceMs),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a task.json from disk and diff against the cache to emit the right event.
|
||||||
|
*/
|
||||||
|
private async processTaskChange(taskId: string, filePath: string): Promise<void> {
|
||||||
|
const cached = this.taskCache.get(taskId);
|
||||||
|
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
// Task was deleted
|
||||||
|
if (cached) {
|
||||||
|
this.taskCache.delete(taskId);
|
||||||
|
this.emit("task:deleted", cached);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let task: Task;
|
||||||
|
try {
|
||||||
|
const data = await readFile(filePath, "utf-8");
|
||||||
|
task = JSON.parse(data) as Task;
|
||||||
|
} catch {
|
||||||
|
return; // File not readable or invalid JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cached) {
|
||||||
|
// New task
|
||||||
|
this.taskCache.set(taskId, { ...task });
|
||||||
|
this.emit("task:created", task);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for column change → task:moved
|
||||||
|
if (cached.column !== task.column) {
|
||||||
|
const from = cached.column;
|
||||||
|
this.taskCache.set(taskId, { ...task });
|
||||||
|
this.emit("task:moved", { task, from, to: task.column });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for any other field change → task:updated
|
||||||
|
if (JSON.stringify(cached) !== JSON.stringify(task)) {
|
||||||
|
this.taskCache.set(taskId, { ...task });
|
||||||
|
this.emit("task:updated", task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getRootDir(): string {
|
getRootDir(): string {
|
||||||
return this.rootDir;
|
return this.rootDir;
|
||||||
}
|
}
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -36,6 +36,9 @@ importers:
|
|||||||
|
|
||||||
packages/core:
|
packages/core:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^25.5.0
|
||||||
|
version: 25.5.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
Reference in New Issue
Block a user