2026-07-09 11:02:20 +08:00
|
|
|
import fs from 'node:fs';
|
|
|
|
|
import os from 'node:os';
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
import chokidarModule from 'chokidar';
|
2026-06-13 03:42:01 +08:00
|
|
|
|
|
|
|
|
const DEFAULT_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
|
|
|
const DEFAULT_DEBOUNCE_MS = 2000;
|
|
|
|
|
const DEFAULT_STABILITY_MS = 500;
|
|
|
|
|
const DEFAULT_HEARTBEAT_MS = 30000;
|
|
|
|
|
const DEFAULT_WATCH_RETRY_MS = 5000;
|
|
|
|
|
|
|
|
|
|
function createIndexerService({
|
|
|
|
|
projectsDir = DEFAULT_PROJECTS_DIR,
|
2026-06-17 23:40:36 +08:00
|
|
|
watchDirs = [projectsDir],
|
2026-06-13 03:42:01 +08:00
|
|
|
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
|
|
|
stabilityMs = DEFAULT_STABILITY_MS,
|
|
|
|
|
heartbeatMs = DEFAULT_HEARTBEAT_MS,
|
|
|
|
|
watchRetryMs = DEFAULT_WATCH_RETRY_MS,
|
|
|
|
|
buildIndex,
|
|
|
|
|
writeHeartbeat = () => {},
|
|
|
|
|
watchProjects,
|
|
|
|
|
chokidar,
|
|
|
|
|
timers = {
|
|
|
|
|
setTimeout,
|
|
|
|
|
clearTimeout,
|
|
|
|
|
setInterval,
|
|
|
|
|
clearInterval,
|
|
|
|
|
},
|
|
|
|
|
logger = console,
|
|
|
|
|
} = {}) {
|
|
|
|
|
if (typeof buildIndex !== 'function') throw new Error('createIndexerService() requires buildIndex');
|
|
|
|
|
const watch = watchProjects || ((onChange) => {
|
2026-06-17 23:40:36 +08:00
|
|
|
const roots = [...new Set((Array.isArray(watchDirs) ? watchDirs : [watchDirs]).filter(Boolean))];
|
|
|
|
|
const existingRoots = roots.filter(root => fs.existsSync(root));
|
|
|
|
|
if (!existingRoots.length) return null;
|
|
|
|
|
const watchers = [];
|
2026-06-13 03:42:01 +08:00
|
|
|
const onFileChange = (filename) => {
|
|
|
|
|
const name = filename ? String(filename) : '';
|
|
|
|
|
if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) onChange(name);
|
|
|
|
|
};
|
2026-06-17 23:40:36 +08:00
|
|
|
for (const root of existingRoots) {
|
2026-07-09 11:02:20 +08:00
|
|
|
const watcher = (chokidar || chokidarModule).watch(root, {
|
2026-06-17 23:40:36 +08:00
|
|
|
cwd: root,
|
|
|
|
|
ignoreInitial: true,
|
|
|
|
|
awaitWriteFinish: {
|
|
|
|
|
stabilityThreshold: Math.max(stabilityMs, 500),
|
|
|
|
|
pollInterval: 100,
|
|
|
|
|
},
|
|
|
|
|
ignored: (targetPath, stats) => {
|
|
|
|
|
if (stats?.isDirectory()) return false;
|
|
|
|
|
if (!stats) return false;
|
|
|
|
|
return !String(targetPath).endsWith('.jsonl') && !String(targetPath).endsWith('.json');
|
|
|
|
|
},
|
2026-06-13 03:42:01 +08:00
|
|
|
});
|
2026-06-17 23:40:36 +08:00
|
|
|
watcher
|
|
|
|
|
.on('add', onFileChange)
|
|
|
|
|
.on('change', onFileChange)
|
|
|
|
|
.on('unlink', onFileChange)
|
|
|
|
|
.on('error', (error) => {
|
|
|
|
|
logger.warn?.(`Obelisk watcher failed: ${error.message}`);
|
|
|
|
|
});
|
|
|
|
|
watchers.push(watcher);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
close() {
|
|
|
|
|
return Promise.all(watchers.map(w => Promise.resolve(w.close?.())));
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-06-13 03:42:01 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let buildTimer = null;
|
|
|
|
|
let stabilityTimer = null;
|
|
|
|
|
let heartbeatTimer = null;
|
|
|
|
|
let watchRetryTimer = null;
|
|
|
|
|
let watcher = null;
|
|
|
|
|
let stopped = false;
|
|
|
|
|
let running = false;
|
|
|
|
|
let pending = false;
|
|
|
|
|
let lastReason = null;
|
2026-06-14 03:16:49 +08:00
|
|
|
let changedPaths = new Set();
|
2026-06-13 03:42:01 +08:00
|
|
|
let idlePromise = Promise.resolve();
|
|
|
|
|
|
2026-06-14 03:16:49 +08:00
|
|
|
const addChangedPath = (changedPath) => {
|
|
|
|
|
if (Array.isArray(changedPath)) {
|
|
|
|
|
for (const p of changedPath) addChangedPath(p);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const name = changedPath ? String(changedPath) : '';
|
|
|
|
|
if (name) changedPaths.add(name);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const takeChangedPaths = () => {
|
|
|
|
|
if (!changedPaths.size) return undefined;
|
|
|
|
|
const paths = [...changedPaths];
|
|
|
|
|
changedPaths = new Set();
|
|
|
|
|
return paths;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const runBuildNow = (reason = 'manual', paths = undefined) => {
|
|
|
|
|
addChangedPath(paths);
|
2026-06-13 03:42:01 +08:00
|
|
|
if (stopped) return idlePromise;
|
|
|
|
|
if (running) {
|
|
|
|
|
pending = true;
|
|
|
|
|
return idlePromise;
|
|
|
|
|
}
|
|
|
|
|
running = true;
|
|
|
|
|
pending = false;
|
2026-06-14 03:16:49 +08:00
|
|
|
const buildChangedPaths = takeChangedPaths();
|
2026-06-13 03:42:01 +08:00
|
|
|
idlePromise = (async () => {
|
2026-06-14 03:16:49 +08:00
|
|
|
await buildIndex({ reason, changedPaths: buildChangedPaths });
|
2026-06-13 03:42:01 +08:00
|
|
|
writeHeartbeat();
|
|
|
|
|
})()
|
|
|
|
|
.catch((error) => {
|
2026-07-09 12:46:02 +08:00
|
|
|
// A build in flight when the service is stopped (e.g. a manual rebuild
|
|
|
|
|
// tears down the worker) is a deliberate cancellation, not a failure.
|
|
|
|
|
if (!stopped) logger.warn?.(`Obelisk index build failed: ${error.message}`);
|
2026-06-13 03:42:01 +08:00
|
|
|
})
|
|
|
|
|
.finally(() => {
|
|
|
|
|
running = false;
|
|
|
|
|
if (pending && !stopped) {
|
|
|
|
|
pending = false;
|
|
|
|
|
runBuildNow('pending');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return idlePromise;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-14 03:16:49 +08:00
|
|
|
const scheduleBuild = (reason = 'change', changedPath = undefined) => {
|
2026-06-13 03:42:01 +08:00
|
|
|
if (stopped) return;
|
2026-06-14 03:16:49 +08:00
|
|
|
addChangedPath(changedPath);
|
2026-06-13 03:42:01 +08:00
|
|
|
lastReason = reason;
|
|
|
|
|
if (running) pending = true;
|
|
|
|
|
if (buildTimer) timers.clearTimeout(buildTimer);
|
|
|
|
|
if (stabilityTimer) timers.clearTimeout(stabilityTimer);
|
|
|
|
|
buildTimer = timers.setTimeout(() => {
|
|
|
|
|
buildTimer = null;
|
|
|
|
|
if (stabilityMs <= 0) {
|
|
|
|
|
runBuildNow(lastReason || reason);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
stabilityTimer = timers.setTimeout(() => {
|
|
|
|
|
stabilityTimer = null;
|
|
|
|
|
runBuildNow(lastReason || reason);
|
|
|
|
|
}, stabilityMs);
|
|
|
|
|
}, debounceMs);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const startWatching = () => {
|
|
|
|
|
if (stopped || watcher) return;
|
2026-06-14 03:16:49 +08:00
|
|
|
watcher = watch((changedPath) => scheduleBuild('watch', changedPath));
|
2026-06-13 03:42:01 +08:00
|
|
|
if (!watcher) {
|
|
|
|
|
watchRetryTimer = timers.setTimeout(() => {
|
|
|
|
|
watchRetryTimer = null;
|
|
|
|
|
startWatching();
|
|
|
|
|
}, watchRetryMs);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const start = ({ buildOnStart = true } = {}) => {
|
|
|
|
|
stopped = false;
|
|
|
|
|
if (buildOnStart) scheduleBuild('startup');
|
|
|
|
|
startWatching();
|
|
|
|
|
if (typeof timers.setInterval === 'function') {
|
|
|
|
|
heartbeatTimer = timers.setInterval(() => {
|
|
|
|
|
try {
|
|
|
|
|
writeHeartbeat();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.warn?.(`Obelisk heartbeat failed: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}, heartbeatMs);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const stop = () => {
|
|
|
|
|
stopped = true;
|
|
|
|
|
pending = false;
|
|
|
|
|
if (buildTimer) timers.clearTimeout(buildTimer);
|
|
|
|
|
buildTimer = null;
|
|
|
|
|
if (stabilityTimer) timers.clearTimeout(stabilityTimer);
|
|
|
|
|
stabilityTimer = null;
|
|
|
|
|
if (watchRetryTimer) timers.clearTimeout(watchRetryTimer);
|
|
|
|
|
watchRetryTimer = null;
|
|
|
|
|
if (heartbeatTimer && typeof timers.clearInterval === 'function') timers.clearInterval(heartbeatTimer);
|
|
|
|
|
heartbeatTimer = null;
|
|
|
|
|
if (watcher?.close) watcher.close();
|
|
|
|
|
watcher = null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
start,
|
|
|
|
|
stop,
|
|
|
|
|
scheduleBuild,
|
|
|
|
|
runBuildNow,
|
|
|
|
|
idle: () => idlePromise,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:02:20 +08:00
|
|
|
export { createIndexerService };
|