Skip to content

Commit fbf6062

Browse files
Aitomatesclaude
andcommitted
feat(phase-1): real-time file system watcher (AUTO-01, AUTO-02)
- FileWatcher class: chokidar v5 wrapper, two-layer noise filter - Ignores: node_modules, dist, .git, logs, tmp, coverage - Watches: ts, js, md, txt, prompt files - 5 new Vitest tests; 48 total passing - Wired into server init via RuntimeLogger + CommandCenterDashboard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7ee814b commit fbf6062

4 files changed

Lines changed: 339 additions & 2 deletions

File tree

universal-refiner/src/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
#!/usr/bin/env node
1+
#!/usr/bin/env node
22
import { PromptRefinerServer } from "./core/server.js";
33
import { CommandCenterDashboard } from "./core/dashboard.js";
44
import { CommitIngester } from "./history/commit-ingest.js";
55
import { CorrelationEngine } from "./history/correlation-engine.js";
66
import { LessonExtractor } from "./history/lesson-extractor.js";
7+
import { FileWatcher } from "./watcher/index.js";
8+
import { RuntimeLogger } from "./core/logger.js";
79
import * as path from "path";
810

911
// Start the Web Dashboard in the background
@@ -13,12 +15,21 @@ CommandCenterDashboard.start(port, rootPath);
1315

1416
const server = new PromptRefinerServer(rootPath);
1517

18+
// Phase 1 (AUTO-01, AUTO-02): Real-time file system watcher
19+
// Starts watching for meaningful source/prompt file changes and logs them.
20+
const fileWatcher = new FileWatcher(rootPath);
21+
fileWatcher.on("change", (evt) => {
22+
RuntimeLogger.info(`[FS] ${evt.event}: ${evt.path}`);
23+
CommandCenterDashboard.log(`[FS] ${evt.event}: ${path.relative(rootPath, evt.path)}`);
24+
});
25+
fileWatcher.start();
26+
1627
// Initial background ingestion and correlation
1728
async function runBackgroundTasks() {
1829
await CommitIngester.ingestLatest(rootPath);
1930
const correlation = new CorrelationEngine();
2031
await correlation.correlateAll();
21-
32+
2233
const extractor = new LessonExtractor((task, prompt, tokens) => server.requestModelText(task, prompt, tokens));
2334
await extractor.extractNewLessons();
2435
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { EventEmitter } from "node:events";
2+
import { watch as chokidarWatch, FSWatcher } from "chokidar";
3+
import { RuntimeLogger } from "../core/logger.js";
4+
5+
// ---------------------------------------------------------------------------
6+
// Types
7+
// ---------------------------------------------------------------------------
8+
9+
export type FileEventKind = "add" | "change" | "unlink";
10+
11+
export interface FileChangeEvent {
12+
path: string;
13+
event: FileEventKind;
14+
timestamp: Date;
15+
}
16+
17+
// ---------------------------------------------------------------------------
18+
// Constants
19+
// ---------------------------------------------------------------------------
20+
21+
/** File extensions considered "meaningful" for AUTO-01 */
22+
export const MEANINGFUL_EXTENSIONS = new Set([".ts", ".js", ".md", ".txt", ".prompt"]);
23+
24+
/**
25+
* Path segments that mark noise directories (AUTO-02).
26+
* Used as a secondary in-process guard after chokidar's ignore patterns.
27+
*/
28+
export const NOISE_PATH_SEGMENTS = ["node_modules", "dist", ".git", "coverage"];
29+
30+
/** File suffixes that mark noise files (AUTO-02). */
31+
export const NOISE_SUFFIXES = [".log", ".tmp"];
32+
33+
/** Paths and patterns passed to chokidar `ignored` option (AUTO-02). */
34+
const CHOKIDAR_IGNORE: (string | RegExp)[] = [
35+
"**/node_modules/**",
36+
"**/dist/**",
37+
"**/.git/**",
38+
"**/*.log",
39+
"**/*.tmp",
40+
"**/coverage/**",
41+
];
42+
43+
// ---------------------------------------------------------------------------
44+
// FileWatcher
45+
// ---------------------------------------------------------------------------
46+
47+
/**
48+
* FileWatcher wraps chokidar with a focused, typed EventEmitter interface.
49+
*
50+
* Responsibilities:
51+
* - Detect meaningful source/prompt file changes (AUTO-01)
52+
* - Filter transient/noise paths before emitting (AUTO-02)
53+
*
54+
* Usage:
55+
* const watcher = new FileWatcher('/path/to/project');
56+
* watcher.on('change', (evt) => console.log(evt));
57+
* watcher.start();
58+
* // later
59+
* await watcher.stop();
60+
*/
61+
export class FileWatcher extends EventEmitter {
62+
private readonly rootPath: string;
63+
private inner: FSWatcher | null = null;
64+
65+
constructor(rootPath: string) {
66+
super();
67+
this.rootPath = rootPath;
68+
}
69+
70+
// ------------------------------------------------------------------
71+
// Public API
72+
// ------------------------------------------------------------------
73+
74+
/** Start watching. Idempotent — calling twice is a no-op. */
75+
public start(): void {
76+
if (this.inner) {
77+
return;
78+
}
79+
80+
RuntimeLogger.info("[FileWatcher] Starting file system watcher", {
81+
rootPath: this.rootPath,
82+
});
83+
84+
this.inner = chokidarWatch(this.rootPath, {
85+
ignored: CHOKIDAR_IGNORE,
86+
persistent: true,
87+
ignoreInitial: true,
88+
awaitWriteFinish: {
89+
stabilityThreshold: 100,
90+
pollInterval: 50,
91+
},
92+
});
93+
94+
this.inner.on("add", (filePath) => this.emitChange("add", filePath));
95+
this.inner.on("change", (filePath) => this.emitChange("change", filePath));
96+
this.inner.on("unlink", (filePath) => this.emitChange("unlink", filePath));
97+
this.inner.on("error", (err: unknown) => {
98+
RuntimeLogger.error("[FileWatcher] Watcher error", err);
99+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
100+
});
101+
}
102+
103+
/** Stop watching and release all resources. */
104+
public async stop(): Promise<void> {
105+
if (!this.inner) {
106+
return;
107+
}
108+
await this.inner.close();
109+
this.inner = null;
110+
RuntimeLogger.info("[FileWatcher] Stopped file system watcher");
111+
}
112+
113+
// ------------------------------------------------------------------
114+
// Internal helpers
115+
// ------------------------------------------------------------------
116+
117+
/**
118+
* Apply both AUTO-01 and AUTO-02 filters before emitting.
119+
*
120+
* Two-layer defence:
121+
* 1. chokidar `ignored` patterns (coarse, path-glob based)
122+
* 2. In-process extension + segment checks (precise, cross-platform)
123+
*
124+
* The in-process layer catches edge cases where chokidar's glob ignore
125+
* may lag (e.g. directories created before the watcher starts on Windows).
126+
*/
127+
private emitChange(kind: FileEventKind, filePath: string): void {
128+
// Normalise separators for consistent matching on Windows
129+
const normalised = filePath.replace(/\\/g, "/");
130+
131+
// AUTO-02: segment-level noise filter
132+
for (const seg of NOISE_PATH_SEGMENTS) {
133+
if (normalised.includes(`/${seg}/`) || normalised.includes(`/${seg}`)) {
134+
return;
135+
}
136+
}
137+
138+
// AUTO-01: extension filter — only emit for meaningful file types
139+
const dotIdx = normalised.lastIndexOf(".");
140+
const ext = dotIdx >= 0 ? normalised.slice(dotIdx).toLowerCase() : "";
141+
142+
// AUTO-02: suffix-level noise filter (e.g. .log, .tmp)
143+
if (NOISE_SUFFIXES.includes(ext)) {
144+
return;
145+
}
146+
147+
if (!MEANINGFUL_EXTENSIONS.has(ext)) {
148+
return;
149+
}
150+
151+
const evt: FileChangeEvent = {
152+
path: filePath,
153+
event: kind,
154+
timestamp: new Date(),
155+
};
156+
RuntimeLogger.debug(`[FileWatcher] ${kind}: ${filePath}`);
157+
this.emit("change", evt);
158+
}
159+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { FileWatcher } from "./file-watcher.js";
2+
export type { FileChangeEvent, FileEventKind } from "./file-watcher.js";
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import * as fs from "node:fs";
3+
import * as path from "node:path";
4+
import * as os from "node:os";
5+
import { FileWatcher, FileChangeEvent } from "../src/watcher/file-watcher.js";
6+
7+
// ---------------------------------------------------------------------------
8+
// Helpers
9+
// ---------------------------------------------------------------------------
10+
11+
/**
12+
* Wait up to `timeout` ms for `predicate` to return true, polling every `interval` ms.
13+
* Provides a clear assertion failure message on timeout.
14+
*/
15+
function waitFor(
16+
predicate: () => boolean,
17+
timeout = 6000,
18+
interval = 50
19+
): Promise<void> {
20+
return new Promise((resolve, reject) => {
21+
const deadline = Date.now() + timeout;
22+
const timer = setInterval(() => {
23+
if (predicate()) {
24+
clearInterval(timer);
25+
resolve();
26+
} else if (Date.now() > deadline) {
27+
clearInterval(timer);
28+
reject(new Error("waitFor: timeout exceeded"));
29+
}
30+
}, interval);
31+
});
32+
}
33+
34+
/**
35+
* Wait for chokidar to finish setting up FS listeners.
36+
* Windows FSEvents/polling can need 500-1500ms before reliably firing.
37+
*/
38+
const WATCHER_SETTLE_MS = 1500;
39+
40+
// ---------------------------------------------------------------------------
41+
// Suite
42+
// ---------------------------------------------------------------------------
43+
44+
describe("FileWatcher", () => {
45+
let tmpDir: string;
46+
let watcher: FileWatcher;
47+
48+
beforeEach(() => {
49+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fw-test-"));
50+
});
51+
52+
afterEach(async () => {
53+
if (watcher) {
54+
await watcher.stop();
55+
}
56+
fs.rmSync(tmpDir, { recursive: true, force: true });
57+
});
58+
59+
// -----------------------------------------------------------------------
60+
// AUTO-01: detect meaningful file changes
61+
// -----------------------------------------------------------------------
62+
63+
it("detects a file write as a 'change' event (AUTO-01)", async () => {
64+
// Create file before watcher starts so initial scan won't fire 'add'
65+
const filePath = path.join(tmpDir, "test.ts");
66+
fs.writeFileSync(filePath, "// initial");
67+
68+
const events: FileChangeEvent[] = [];
69+
watcher = new FileWatcher(tmpDir);
70+
watcher.on("change", (evt) => events.push(evt));
71+
watcher.start();
72+
73+
// Allow chokidar to finish indexing the directory
74+
await new Promise((r) => setTimeout(r, WATCHER_SETTLE_MS));
75+
76+
// Modify the file
77+
fs.writeFileSync(filePath, "// updated");
78+
79+
// Wait for event
80+
await waitFor(() => events.some((e) => e.event === "change" && e.path.endsWith("test.ts")));
81+
82+
const hit = events.find((e) => e.event === "change");
83+
expect(hit).toBeDefined();
84+
expect(hit!.path).toContain("test.ts");
85+
expect(hit!.timestamp).toBeInstanceOf(Date);
86+
}, 15_000);
87+
88+
it("detects a new file as an 'add' event (AUTO-01)", async () => {
89+
const events: FileChangeEvent[] = [];
90+
watcher = new FileWatcher(tmpDir);
91+
watcher.on("change", (evt) => events.push(evt));
92+
watcher.start();
93+
94+
await new Promise((r) => setTimeout(r, WATCHER_SETTLE_MS));
95+
96+
fs.writeFileSync(path.join(tmpDir, "new.ts"), "export {}");
97+
98+
await waitFor(() => events.some((e) => e.event === "add" && e.path.endsWith("new.ts")));
99+
100+
const hit = events.find((e) => e.event === "add");
101+
expect(hit).toBeDefined();
102+
expect(hit!.event).toBe("add");
103+
expect(hit!.timestamp).toBeInstanceOf(Date);
104+
}, 15_000);
105+
106+
// -----------------------------------------------------------------------
107+
// AUTO-02: noise filter
108+
// -----------------------------------------------------------------------
109+
110+
it("does NOT emit events for paths inside node_modules (AUTO-02)", async () => {
111+
const nmDir = path.join(tmpDir, "node_modules", "some-pkg");
112+
fs.mkdirSync(nmDir, { recursive: true });
113+
114+
const events: FileChangeEvent[] = [];
115+
watcher = new FileWatcher(tmpDir);
116+
watcher.on("change", (evt) => events.push(evt));
117+
watcher.start();
118+
119+
await new Promise((r) => setTimeout(r, WATCHER_SETTLE_MS));
120+
121+
fs.writeFileSync(path.join(nmDir, "index.ts"), "// ignored");
122+
123+
// Wait and assert silence
124+
await new Promise((r) => setTimeout(r, 800));
125+
const nmEvents = events.filter((e) => e.path.includes("node_modules"));
126+
expect(nmEvents).toHaveLength(0);
127+
}, 15_000);
128+
129+
it("does NOT emit events for *.log files (AUTO-02)", async () => {
130+
const events: FileChangeEvent[] = [];
131+
watcher = new FileWatcher(tmpDir);
132+
watcher.on("change", (evt) => events.push(evt));
133+
watcher.start();
134+
135+
await new Promise((r) => setTimeout(r, WATCHER_SETTLE_MS));
136+
137+
fs.writeFileSync(path.join(tmpDir, "debug.log"), "some log line");
138+
139+
await new Promise((r) => setTimeout(r, 800));
140+
expect(events.filter((e) => e.path.endsWith(".log"))).toHaveLength(0);
141+
}, 15_000);
142+
143+
// -----------------------------------------------------------------------
144+
// stop() — no further events after stop
145+
// -----------------------------------------------------------------------
146+
147+
it("stop() prevents further events from being emitted", async () => {
148+
const filePath = path.join(tmpDir, "track.ts");
149+
fs.writeFileSync(filePath, "// v1");
150+
151+
const events: FileChangeEvent[] = [];
152+
watcher = new FileWatcher(tmpDir);
153+
watcher.on("change", (evt) => events.push(evt));
154+
watcher.start();
155+
156+
await new Promise((r) => setTimeout(r, WATCHER_SETTLE_MS));
157+
await watcher.stop();
158+
159+
const countBefore = events.length;
160+
fs.writeFileSync(filePath, "// v2");
161+
await new Promise((r) => setTimeout(r, 800));
162+
163+
expect(events.length).toBe(countBefore);
164+
}, 15_000);
165+
});

0 commit comments

Comments
 (0)