Merge branch 'hai/hai-003'
# Conflicts: # packages/core/src/store.ts
This commit is contained in:
@@ -15,6 +15,7 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
||||
const cwd = process.cwd();
|
||||
const store = new TaskStore(cwd);
|
||||
await store.init();
|
||||
await store.watch();
|
||||
|
||||
// AI-powered merge handler
|
||||
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
|
||||
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
|
||||
if (opts.engine) {
|
||||
const triage = new TriageProcessor(store, cwd, {
|
||||
@@ -52,6 +61,7 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
||||
process.on("SIGINT", () => {
|
||||
triage.stop();
|
||||
scheduler.stop();
|
||||
store.stopWatching();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -71,6 +81,7 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
||||
} else {
|
||||
console.log(` AI engine: off (use --engine to enable)`);
|
||||
}
|
||||
console.log(` File watcher: ✓ active`);
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
console.log();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, STATUS_LABELS, STATUS_COLORS } from "./types.js";
|
||||
export type { Column, Task, TaskCreateInput, TaskDetail, BoardConfig, MergeResult, TaskStatus } from "./types.js";
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS } from "./types.js";
|
||||
export type { Column, Task, TaskCreateInput, TaskDetail, BoardConfig, MergeResult } from "./types.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { Task, TaskDetail, TaskCreateInput, BoardConfig, Column, MergeResult, TaskStatus } from "./types.js";
|
||||
import { join, sep } from "node:path";
|
||||
import { existsSync, watch, type FSWatcher } from "node:fs";
|
||||
import type { Task, TaskDetail, TaskCreateInput, BoardConfig, Column, MergeResult } from "./types.js";
|
||||
import { VALID_TRANSITIONS } from "./types.js";
|
||||
|
||||
export interface TaskStoreEvents {
|
||||
@@ -19,6 +19,17 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
private tasksDir: 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) {
|
||||
super();
|
||||
this.haiDir = join(rootDir, ".hai");
|
||||
@@ -63,14 +74,18 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
description: input.description || "",
|
||||
column: input.column || "triage",
|
||||
dependencies: input.dependencies || [],
|
||||
status: "idle",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const dir = this.taskDir(id);
|
||||
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"
|
||||
? `# ${id}: ${task.title}\n\n${task.description}\n`
|
||||
@@ -135,16 +150,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.column = toColumn;
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
// Auto-set status based on target column
|
||||
if (toColumn === "in-review" || toColumn === "done") {
|
||||
task.status = "complete";
|
||||
} else if (toColumn === "in-progress") {
|
||||
task.status = "executing";
|
||||
} else if (toColumn === "todo") {
|
||||
task.status = "idle";
|
||||
}
|
||||
const taskJsonPath = join(dir, "task.json");
|
||||
this.suppressWatcher(taskJsonPath);
|
||||
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
|
||||
|
||||
await writeFile(join(dir, "task.json"), 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 });
|
||||
return task;
|
||||
@@ -163,7 +174,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
if (updates.worktree !== undefined) task.worktree = updates.worktree;
|
||||
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) {
|
||||
await writeFile(join(dir, "PROMPT.md"), updates.prompt);
|
||||
@@ -173,25 +189,17 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return task;
|
||||
}
|
||||
|
||||
async setStatus(id: string, status: TaskStatus): Promise<Task> {
|
||||
const dir = this.taskDir(id);
|
||||
const data = await readFile(join(dir, "task.json"), "utf-8");
|
||||
const task = JSON.parse(data) as Task;
|
||||
|
||||
task.status = status;
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
await writeFile(join(dir, "task.json"), JSON.stringify(task, null, 2));
|
||||
|
||||
this.emit("task:updated", task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async deleteTask(id: string): Promise<Task> {
|
||||
const dir = this.taskDir(id);
|
||||
const data = await readFile(join(dir, "task.json"), "utf-8");
|
||||
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");
|
||||
await rm(dir, { recursive: true });
|
||||
|
||||
@@ -304,13 +312,158 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
private async moveToDone(task: Task, dir: string): Promise<void> {
|
||||
task.column = "done";
|
||||
task.status = "complete";
|
||||
task.worktree = undefined;
|
||||
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 });
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
return this.rootDir;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,12 @@
|
||||
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"] as const;
|
||||
export type Column = (typeof COLUMNS)[number];
|
||||
|
||||
export type TaskStatus = "idle" | "specifying" | "blocked" | "queued" | "executing" | "complete";
|
||||
|
||||
export const STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
idle: "Idle",
|
||||
specifying: "Specifying…",
|
||||
blocked: "Blocked",
|
||||
queued: "Queued",
|
||||
executing: "Executing…",
|
||||
complete: "Complete",
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<TaskStatus, string> = {
|
||||
idle: "var(--text-dim)",
|
||||
specifying: "var(--triage)",
|
||||
blocked: "#f85149",
|
||||
queued: "var(--todo)",
|
||||
executing: "var(--in-progress)",
|
||||
complete: "var(--in-review)",
|
||||
};
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
column: Column;
|
||||
dependencies: string[];
|
||||
status?: TaskStatus;
|
||||
worktree?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -36,6 +36,9 @@ importers:
|
||||
|
||||
packages/core:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^25.5.0
|
||||
version: 25.5.0
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
Reference in New Issue
Block a user