ragcode-context-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +366 -0
- package/README.zh-CN.md +363 -0
- package/dist/src/cli/configure/app.d.ts +6 -0
- package/dist/src/cli/configure/app.js +81 -0
- package/dist/src/cli/configure/run.d.ts +5 -0
- package/dist/src/cli/configure/run.js +85 -0
- package/dist/src/cli/configure/state.d.ts +42 -0
- package/dist/src/cli/configure/state.js +174 -0
- package/dist/src/cli/configure.d.ts +31 -0
- package/dist/src/cli/configure.js +101 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.js +503 -0
- package/dist/src/cli/tui/index-progress.d.ts +12 -0
- package/dist/src/cli/tui/index-progress.js +49 -0
- package/dist/src/cli/tui/watch-status.d.ts +10 -0
- package/dist/src/cli/tui/watch-status.js +27 -0
- package/dist/src/cli/update.d.ts +18 -0
- package/dist/src/cli/update.js +111 -0
- package/dist/src/config/dotenv.d.ts +1 -0
- package/dist/src/config/dotenv.js +14 -0
- package/dist/src/config/graph-runtime.d.ts +13 -0
- package/dist/src/config/graph-runtime.js +29 -0
- package/dist/src/config/runtime-config.d.ts +87 -0
- package/dist/src/config/runtime-config.js +215 -0
- package/dist/src/config/semantic-runtime.d.ts +24 -0
- package/dist/src/config/semantic-runtime.js +89 -0
- package/dist/src/context/context-builder.d.ts +20 -0
- package/dist/src/context/context-builder.js +277 -0
- package/dist/src/context/expansion-policy.d.ts +6 -0
- package/dist/src/context/expansion-policy.js +49 -0
- package/dist/src/context/skeletonizer.d.ts +2 -0
- package/dist/src/context/skeletonizer.js +79 -0
- package/dist/src/context/snippet-renderer.d.ts +2 -0
- package/dist/src/context/snippet-renderer.js +67 -0
- package/dist/src/core/contracts.d.ts +74 -0
- package/dist/src/core/contracts.js +1 -0
- package/dist/src/core/engine.d.ts +64 -0
- package/dist/src/core/engine.js +442 -0
- package/dist/src/core/types.d.ts +490 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/diagnostics/doctor.d.ts +66 -0
- package/dist/src/diagnostics/doctor.js +193 -0
- package/dist/src/diagnostics/embedding-test.d.ts +24 -0
- package/dist/src/diagnostics/embedding-test.js +83 -0
- package/dist/src/graph/diff-files.d.ts +1 -0
- package/dist/src/graph/diff-files.js +14 -0
- package/dist/src/graph/impact-report.d.ts +10 -0
- package/dist/src/graph/impact-report.js +173 -0
- package/dist/src/graph/in-memory-graph-store.d.ts +36 -0
- package/dist/src/graph/in-memory-graph-store.js +395 -0
- package/dist/src/graph/owner-ranking.d.ts +2 -0
- package/dist/src/graph/owner-ranking.js +41 -0
- package/dist/src/graph/sqlite-graph-store.d.ts +51 -0
- package/dist/src/graph/sqlite-graph-store.js +724 -0
- package/dist/src/graph/sqlite-statements.d.ts +36 -0
- package/dist/src/graph/sqlite-statements.js +105 -0
- package/dist/src/graph/target-matcher.d.ts +13 -0
- package/dist/src/graph/target-matcher.js +64 -0
- package/dist/src/index.d.ts +32 -0
- package/dist/src/index.js +32 -0
- package/dist/src/indexing/analyzers/fallback-analyzer.d.ts +6 -0
- package/dist/src/indexing/analyzers/fallback-analyzer.js +45 -0
- package/dist/src/indexing/analyzers/go-treesitter-analyzer.d.ts +2 -0
- package/dist/src/indexing/analyzers/go-treesitter-analyzer.js +87 -0
- package/dist/src/indexing/analyzers/java-treesitter-analyzer.d.ts +2 -0
- package/dist/src/indexing/analyzers/java-treesitter-analyzer.js +88 -0
- package/dist/src/indexing/analyzers/python-treesitter-analyzer.d.ts +2 -0
- package/dist/src/indexing/analyzers/python-treesitter-analyzer.js +96 -0
- package/dist/src/indexing/analyzers/registry.d.ts +5 -0
- package/dist/src/indexing/analyzers/registry.js +23 -0
- package/dist/src/indexing/analyzers/rust-treesitter-analyzer.d.ts +2 -0
- package/dist/src/indexing/analyzers/rust-treesitter-analyzer.js +96 -0
- package/dist/src/indexing/analyzers/tree-sitter-base.d.ts +30 -0
- package/dist/src/indexing/analyzers/tree-sitter-base.js +163 -0
- package/dist/src/indexing/analyzers/types.d.ts +17 -0
- package/dist/src/indexing/analyzers/types.js +1 -0
- package/dist/src/indexing/analyzers/typescript-analyzer.d.ts +5 -0
- package/dist/src/indexing/analyzers/typescript-analyzer.js +199 -0
- package/dist/src/indexing/ast-analyzer.d.ts +11 -0
- package/dist/src/indexing/ast-analyzer.js +11 -0
- package/dist/src/indexing/chunker.d.ts +11 -0
- package/dist/src/indexing/chunker.js +157 -0
- package/dist/src/indexing/ignore-policy.d.ts +6 -0
- package/dist/src/indexing/ignore-policy.js +40 -0
- package/dist/src/indexing/indexer.d.ts +13 -0
- package/dist/src/indexing/indexer.js +189 -0
- package/dist/src/indexing/language.d.ts +3 -0
- package/dist/src/indexing/language.js +24 -0
- package/dist/src/indexing/scanner.d.ts +13 -0
- package/dist/src/indexing/scanner.js +87 -0
- package/dist/src/lsp/definition-resolver.d.ts +6 -0
- package/dist/src/lsp/definition-resolver.js +60 -0
- package/dist/src/lsp/typescript-language-service.d.ts +21 -0
- package/dist/src/lsp/typescript-language-service.js +82 -0
- package/dist/src/mcp/server.d.ts +11 -0
- package/dist/src/mcp/server.js +64 -0
- package/dist/src/mcp/tools.d.ts +266 -0
- package/dist/src/mcp/tools.js +309 -0
- package/dist/src/project/project-identity.d.ts +2 -0
- package/dist/src/project/project-identity.js +24 -0
- package/dist/src/project/project-registry.d.ts +12 -0
- package/dist/src/project/project-registry.js +49 -0
- package/dist/src/project/workspace-resolver.d.ts +20 -0
- package/dist/src/project/workspace-resolver.js +62 -0
- package/dist/src/retrieval/graph-reranker.d.ts +11 -0
- package/dist/src/retrieval/graph-reranker.js +0 -0
- package/dist/src/retrieval/hybrid-retriever.d.ts +31 -0
- package/dist/src/retrieval/hybrid-retriever.js +111 -0
- package/dist/src/retrieval/path-classification.d.ts +6 -0
- package/dist/src/retrieval/path-classification.js +22 -0
- package/dist/src/retrieval/query-matching.d.ts +22 -0
- package/dist/src/retrieval/query-matching.js +166 -0
- package/dist/src/retrieval/query-planner.d.ts +5 -0
- package/dist/src/retrieval/query-planner.js +77 -0
- package/dist/src/retrieval/ranking-signals.d.ts +19 -0
- package/dist/src/retrieval/ranking-signals.js +97 -0
- package/dist/src/retrieval/topology-distance.d.ts +21 -0
- package/dist/src/retrieval/topology-distance.js +116 -0
- package/dist/src/reuse/reuse-detector.d.ts +12 -0
- package/dist/src/reuse/reuse-detector.js +564 -0
- package/dist/src/semantic/deterministic-embedding.d.ts +7 -0
- package/dist/src/semantic/deterministic-embedding.js +31 -0
- package/dist/src/semantic/in-memory-semantic-store.d.ts +11 -0
- package/dist/src/semantic/in-memory-semantic-store.js +65 -0
- package/dist/src/semantic/lance-semantic-store.d.ts +131 -0
- package/dist/src/semantic/lance-semantic-store.js +623 -0
- package/dist/src/semantic/openai-compatible-embedding.d.ts +19 -0
- package/dist/src/semantic/openai-compatible-embedding.js +75 -0
- package/dist/src/service/service-identity.d.ts +13 -0
- package/dist/src/service/service-identity.js +48 -0
- package/dist/src/service/service-manager.d.ts +29 -0
- package/dist/src/service/service-manager.js +231 -0
- package/dist/src/service/service-templates.d.ts +22 -0
- package/dist/src/service/service-templates.js +101 -0
- package/dist/src/subgraph/impact-explainer.d.ts +2 -0
- package/dist/src/subgraph/impact-explainer.js +54 -0
- package/dist/src/subgraph/node-expander.d.ts +13 -0
- package/dist/src/subgraph/node-expander.js +139 -0
- package/dist/src/subgraph/output-preset.d.ts +3 -0
- package/dist/src/subgraph/output-preset.js +102 -0
- package/dist/src/subgraph/subgraph-builder.d.ts +17 -0
- package/dist/src/subgraph/subgraph-builder.js +688 -0
- package/dist/src/topology/export-index.d.ts +7 -0
- package/dist/src/topology/export-index.js +14 -0
- package/dist/src/topology/framework-topology.d.ts +3 -0
- package/dist/src/topology/framework-topology.js +460 -0
- package/dist/src/topology/import-resolver.d.ts +2 -0
- package/dist/src/topology/import-resolver.js +29 -0
- package/dist/src/topology/orm-topology.d.ts +3 -0
- package/dist/src/topology/orm-topology.js +200 -0
- package/dist/src/topology/runtime-topology.d.ts +3 -0
- package/dist/src/topology/runtime-topology.js +204 -0
- package/dist/src/topology/symbol-resolver.d.ts +6 -0
- package/dist/src/topology/symbol-resolver.js +74 -0
- package/dist/src/topology/test-topology.d.ts +2 -0
- package/dist/src/topology/test-topology.js +82 -0
- package/dist/src/utils/hash.d.ts +2 -0
- package/dist/src/utils/hash.js +7 -0
- package/dist/src/utils/path.d.ts +2 -0
- package/dist/src/utils/path.js +7 -0
- package/dist/src/watch/event-journal.d.ts +17 -0
- package/dist/src/watch/event-journal.js +81 -0
- package/dist/src/watch/file-event-coalescer.d.ts +9 -0
- package/dist/src/watch/file-event-coalescer.js +39 -0
- package/dist/src/watch/index-scheduler.d.ts +52 -0
- package/dist/src/watch/index-scheduler.js +190 -0
- package/dist/src/watch/watch-daemon.d.ts +73 -0
- package/dist/src/watch/watch-daemon.js +368 -0
- package/dist/src/watch/watcher-liveness.d.ts +47 -0
- package/dist/src/watch/watcher-liveness.js +168 -0
- package/dist/src/web/server.d.ts +1 -0
- package/dist/src/web/server.js +375 -0
- package/package.json +94 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import chokidar from "chokidar";
|
|
3
|
+
import { shouldIgnoreDirectory, shouldIgnoreFile } from "../indexing/ignore-policy.js";
|
|
4
|
+
import { normalizeRepoPath, normalizeUserPath } from "../utils/path.js";
|
|
5
|
+
import { FileEventJournal } from "./event-journal.js";
|
|
6
|
+
import { WatchIndexScheduler } from "./index-scheduler.js";
|
|
7
|
+
import { acquireWatcherLock, clearHeartbeat, writeHeartbeat } from "./watcher-liveness.js";
|
|
8
|
+
const DEFAULT_EVENT_FLUSH_MS = 250;
|
|
9
|
+
const DEFAULT_AWAIT_WRITE_FINISH_MS = 500;
|
|
10
|
+
const DEFAULT_MAX_FILE_BYTES = 1_000_000;
|
|
11
|
+
const DEFAULT_MAX_BUFFERED_EVENTS = 1_000;
|
|
12
|
+
const DEFAULT_MAX_FLUSH_WAIT_MS = 5_000;
|
|
13
|
+
const DEFAULT_FLUSH_RETRY_MAX_DELAY_MS = 30_000;
|
|
14
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
15
|
+
export class FileWatchDaemon {
|
|
16
|
+
engine;
|
|
17
|
+
options;
|
|
18
|
+
watcher;
|
|
19
|
+
journal;
|
|
20
|
+
scheduler;
|
|
21
|
+
repoRoot;
|
|
22
|
+
bufferedPaths = new Set();
|
|
23
|
+
flushTimer;
|
|
24
|
+
maxFlushTimer;
|
|
25
|
+
flushRetryTimer;
|
|
26
|
+
journalQueue = Promise.resolve();
|
|
27
|
+
pendingJournalEntries = [];
|
|
28
|
+
firstBufferedAtMs;
|
|
29
|
+
flushFailureCount = 0;
|
|
30
|
+
lastError;
|
|
31
|
+
running = false;
|
|
32
|
+
ready = false;
|
|
33
|
+
lockHandle;
|
|
34
|
+
heartbeatTimer;
|
|
35
|
+
lastIndexedAtMs;
|
|
36
|
+
constructor(engine, repoRoot, options = {}) {
|
|
37
|
+
this.engine = engine;
|
|
38
|
+
this.options = options;
|
|
39
|
+
this.repoRoot = path.resolve(repoRoot);
|
|
40
|
+
this.journal = options.journal ?? FileEventJournal.forRepo(this.repoRoot);
|
|
41
|
+
this.scheduler = new WatchIndexScheduler(engine, this.repoRoot, {
|
|
42
|
+
...options,
|
|
43
|
+
onStatus: (status) => {
|
|
44
|
+
if (status.lastIndexedAtMs)
|
|
45
|
+
this.lastIndexedAtMs = status.lastIndexedAtMs;
|
|
46
|
+
options.onStatus?.({
|
|
47
|
+
repoRoot: this.repoRoot,
|
|
48
|
+
running: this.running,
|
|
49
|
+
ready: this.ready,
|
|
50
|
+
bufferedEvents: this.bufferedPaths.size,
|
|
51
|
+
scheduler: status
|
|
52
|
+
});
|
|
53
|
+
// Refresh the heartbeat on every scheduler tick so an actively-indexing daemon publishes
|
|
54
|
+
// freshness faster than the idle interval, and so pending/indexing counts stay current.
|
|
55
|
+
if (this.running && this.options.manageLifecycleFiles !== false)
|
|
56
|
+
void this.publishHeartbeat();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async start() {
|
|
61
|
+
if (this.running)
|
|
62
|
+
return;
|
|
63
|
+
// Acquire the per-repo lock first, before any indexing work, so a second watcher on the same
|
|
64
|
+
// repo fails fast (throwing WatcherLockError) instead of racing the live one as a second writer.
|
|
65
|
+
if (this.options.manageLifecycleFiles !== false) {
|
|
66
|
+
this.lockHandle = acquireWatcherLock(this.repoRoot);
|
|
67
|
+
}
|
|
68
|
+
this.running = true;
|
|
69
|
+
await this.ensureIndexed();
|
|
70
|
+
await this.replayJournal();
|
|
71
|
+
this.scheduler.start();
|
|
72
|
+
this.startHeartbeat();
|
|
73
|
+
this.watcher = chokidar.watch(this.repoRoot, {
|
|
74
|
+
cwd: this.repoRoot,
|
|
75
|
+
persistent: true,
|
|
76
|
+
ignoreInitial: true,
|
|
77
|
+
followSymlinks: false,
|
|
78
|
+
usePolling: this.options.usePolling,
|
|
79
|
+
interval: this.options.pollIntervalMs,
|
|
80
|
+
atomic: true,
|
|
81
|
+
awaitWriteFinish: {
|
|
82
|
+
stabilityThreshold: this.options.awaitWriteFinishMs ?? DEFAULT_AWAIT_WRITE_FINISH_MS,
|
|
83
|
+
pollInterval: Math.min(100, this.options.awaitWriteFinishMs ?? DEFAULT_AWAIT_WRITE_FINISH_MS)
|
|
84
|
+
},
|
|
85
|
+
ignored: (candidate, stats) => this.isIgnored(candidate, stats)
|
|
86
|
+
});
|
|
87
|
+
this.watcher
|
|
88
|
+
.on("add", (filePath) => this.handleWatchEvent("add", filePath))
|
|
89
|
+
.on("change", (filePath) => this.handleWatchEvent("change", filePath))
|
|
90
|
+
.on("unlink", (filePath) => this.handleWatchEvent("unlink", filePath))
|
|
91
|
+
.on("addDir", (filePath) => this.handleWatchEvent("addDir", filePath))
|
|
92
|
+
.on("unlinkDir", (filePath) => this.handleWatchEvent("unlinkDir", filePath))
|
|
93
|
+
.on("ready", () => {
|
|
94
|
+
this.ready = true;
|
|
95
|
+
void this.emitStatus();
|
|
96
|
+
})
|
|
97
|
+
.on("error", (error) => {
|
|
98
|
+
this.options.onStatus?.({
|
|
99
|
+
repoRoot: this.repoRoot,
|
|
100
|
+
running: this.running,
|
|
101
|
+
ready: this.ready,
|
|
102
|
+
bufferedEvents: this.bufferedPaths.size,
|
|
103
|
+
scheduler: {
|
|
104
|
+
repoRoot: this.repoRoot,
|
|
105
|
+
running: true,
|
|
106
|
+
scheduled: false,
|
|
107
|
+
indexing: false,
|
|
108
|
+
pendingFiles: 0,
|
|
109
|
+
indexingFiles: 0,
|
|
110
|
+
lastError: this.setLastError(error)
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async stop() {
|
|
116
|
+
this.running = false;
|
|
117
|
+
if (this.flushTimer)
|
|
118
|
+
clearTimeout(this.flushTimer);
|
|
119
|
+
if (this.maxFlushTimer)
|
|
120
|
+
clearTimeout(this.maxFlushTimer);
|
|
121
|
+
if (this.flushRetryTimer)
|
|
122
|
+
clearTimeout(this.flushRetryTimer);
|
|
123
|
+
if (this.heartbeatTimer)
|
|
124
|
+
clearInterval(this.heartbeatTimer);
|
|
125
|
+
this.flushTimer = undefined;
|
|
126
|
+
this.maxFlushTimer = undefined;
|
|
127
|
+
this.flushRetryTimer = undefined;
|
|
128
|
+
this.heartbeatTimer = undefined;
|
|
129
|
+
await this.watcher?.close();
|
|
130
|
+
this.watcher = undefined;
|
|
131
|
+
try {
|
|
132
|
+
await this.flushBufferedEvents();
|
|
133
|
+
await this.journalQueue;
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await this.scheduler.stop();
|
|
137
|
+
this.ready = false;
|
|
138
|
+
await this.emitStatus();
|
|
139
|
+
// Tear down lifecycle files last, after the scheduler has drained, so a reader never sees
|
|
140
|
+
// a cleared heartbeat while the daemon is still writing the index. Clear the heartbeat
|
|
141
|
+
// before releasing the lock so the window where the lock exists without a heartbeat
|
|
142
|
+
// (which a reader would classify as "dead") is as small as possible.
|
|
143
|
+
//
|
|
144
|
+
// Only touch lifecycle files if WE own the lock. A second watcher that failed to acquire the
|
|
145
|
+
// lock (start() threw) reaches stop() via the CLI's shutdown path with lockHandle undefined —
|
|
146
|
+
// it must NOT clear the live watcher's heartbeat or release its lock.
|
|
147
|
+
if (this.options.manageLifecycleFiles !== false && this.lockHandle) {
|
|
148
|
+
await clearHeartbeat(this.repoRoot).catch(() => undefined);
|
|
149
|
+
this.lockHandle.release();
|
|
150
|
+
this.lockHandle = undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async status() {
|
|
155
|
+
return {
|
|
156
|
+
repoRoot: this.repoRoot,
|
|
157
|
+
running: this.running,
|
|
158
|
+
ready: this.ready,
|
|
159
|
+
bufferedEvents: this.bufferedPaths.size,
|
|
160
|
+
scheduler: await this.scheduler.status()
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// Publish a fresh heartbeat immediately, then on a fixed interval so doctor/dashboard/MCP can
|
|
164
|
+
// distinguish a live-but-idle daemon from a dead one even when no file events are flowing. The
|
|
165
|
+
// scheduler's own onStatus callback also refreshes the heartbeat on every tick (see constructor),
|
|
166
|
+
// so an actively-indexing daemon heartbeats more often than the interval.
|
|
167
|
+
startHeartbeat() {
|
|
168
|
+
if (this.options.manageLifecycleFiles === false)
|
|
169
|
+
return;
|
|
170
|
+
void this.publishHeartbeat();
|
|
171
|
+
const interval = this.options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
172
|
+
this.heartbeatTimer = setInterval(() => {
|
|
173
|
+
void this.publishHeartbeat();
|
|
174
|
+
}, interval);
|
|
175
|
+
this.heartbeatTimer.unref?.();
|
|
176
|
+
}
|
|
177
|
+
async publishHeartbeat() {
|
|
178
|
+
if (this.options.manageLifecycleFiles === false || !this.lockHandle)
|
|
179
|
+
return;
|
|
180
|
+
const scheduler = await this.scheduler.status().catch(() => undefined);
|
|
181
|
+
if (scheduler?.lastIndexedAtMs)
|
|
182
|
+
this.lastIndexedAtMs = scheduler.lastIndexedAtMs;
|
|
183
|
+
const heartbeat = {
|
|
184
|
+
pid: this.lockHandle.info.pid,
|
|
185
|
+
hostname: this.lockHandle.info.hostname,
|
|
186
|
+
repoRoot: this.repoRoot,
|
|
187
|
+
startedAtMs: this.lockHandle.info.startedAtMs,
|
|
188
|
+
lastHeartbeatMs: Date.now(),
|
|
189
|
+
lastIndexedAtMs: this.lastIndexedAtMs,
|
|
190
|
+
pendingFiles: scheduler?.pendingFiles ?? 0,
|
|
191
|
+
indexingFiles: scheduler?.indexingFiles ?? 0,
|
|
192
|
+
ready: this.ready,
|
|
193
|
+
lastError: this.lastError ?? scheduler?.lastError
|
|
194
|
+
};
|
|
195
|
+
await writeHeartbeat(this.repoRoot, heartbeat).catch((error) => {
|
|
196
|
+
// A heartbeat write failure shouldn't take down the watcher; surface it as lastError.
|
|
197
|
+
this.setLastError(error);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
async ensureIndexed() {
|
|
201
|
+
if (this.options.indexOnStart === false) {
|
|
202
|
+
await this.engine.indexStatus(this.repoRoot);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await this.engine.indexStatus(this.repoRoot);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
await this.engine.indexRepo(this.repoRoot);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async replayJournal() {
|
|
213
|
+
const replayed = await this.journal.replayPaths();
|
|
214
|
+
if (replayed.length === 0)
|
|
215
|
+
return;
|
|
216
|
+
await this.engine.recordFileEvents(this.repoRoot, replayed, this.options);
|
|
217
|
+
await this.journal.truncate();
|
|
218
|
+
this.scheduler.schedule(0);
|
|
219
|
+
}
|
|
220
|
+
handleWatchEvent(event, rawPath) {
|
|
221
|
+
void this.recordEvent(event, rawPath).catch((error) => {
|
|
222
|
+
this.setLastError(error);
|
|
223
|
+
void this.emitStatus();
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
async recordEvent(event, rawPath) {
|
|
227
|
+
const filePath = normalizeWatchPath(this.repoRoot, rawPath);
|
|
228
|
+
if (!filePath)
|
|
229
|
+
return;
|
|
230
|
+
const entry = { event, filePath, observedAtMs: Date.now() };
|
|
231
|
+
await this.enqueueJournalOperation(async () => {
|
|
232
|
+
this.pendingJournalEntries.push(entry);
|
|
233
|
+
await this.flushJournalEntriesLocked();
|
|
234
|
+
this.options.onEvent?.(entry);
|
|
235
|
+
this.bufferedPaths.add(filePath);
|
|
236
|
+
this.firstBufferedAtMs ??= entry.observedAtMs;
|
|
237
|
+
this.scheduleEventFlush();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
scheduleEventFlush() {
|
|
241
|
+
if (this.bufferedPaths.size >= (this.options.maxBufferedEvents ?? DEFAULT_MAX_BUFFERED_EVENTS)) {
|
|
242
|
+
if (this.flushTimer)
|
|
243
|
+
clearTimeout(this.flushTimer);
|
|
244
|
+
this.flushTimer = undefined;
|
|
245
|
+
void this.flushBufferedEvents().catch((error) => {
|
|
246
|
+
this.setLastError(error);
|
|
247
|
+
this.scheduleFlushRetry();
|
|
248
|
+
void this.emitStatus();
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (this.flushTimer)
|
|
253
|
+
clearTimeout(this.flushTimer);
|
|
254
|
+
this.flushTimer = setTimeout(() => {
|
|
255
|
+
this.flushTimer = undefined;
|
|
256
|
+
void this.flushBufferedEvents().catch((error) => {
|
|
257
|
+
this.setLastError(error);
|
|
258
|
+
this.scheduleFlushRetry();
|
|
259
|
+
void this.emitStatus();
|
|
260
|
+
});
|
|
261
|
+
}, this.options.flushEventsMs ?? DEFAULT_EVENT_FLUSH_MS);
|
|
262
|
+
if (!this.maxFlushTimer) {
|
|
263
|
+
const elapsed = this.firstBufferedAtMs ? Date.now() - this.firstBufferedAtMs : 0;
|
|
264
|
+
this.maxFlushTimer = setTimeout(() => {
|
|
265
|
+
this.maxFlushTimer = undefined;
|
|
266
|
+
if (this.flushTimer)
|
|
267
|
+
clearTimeout(this.flushTimer);
|
|
268
|
+
this.flushTimer = undefined;
|
|
269
|
+
void this.flushBufferedEvents().catch((error) => {
|
|
270
|
+
this.setLastError(error);
|
|
271
|
+
this.scheduleFlushRetry();
|
|
272
|
+
void this.emitStatus();
|
|
273
|
+
});
|
|
274
|
+
}, Math.max(0, (this.options.maxFlushWaitMs ?? DEFAULT_MAX_FLUSH_WAIT_MS) - elapsed));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async flushBufferedEvents() {
|
|
278
|
+
await this.enqueueJournalOperation(() => this.flushBufferedEventsLocked());
|
|
279
|
+
}
|
|
280
|
+
async flushBufferedEventsLocked() {
|
|
281
|
+
await this.flushJournalEntriesLocked();
|
|
282
|
+
if (this.bufferedPaths.size === 0)
|
|
283
|
+
return;
|
|
284
|
+
const paths = [...this.bufferedPaths].sort();
|
|
285
|
+
await this.engine.recordFileEvents(this.repoRoot, paths, this.options);
|
|
286
|
+
this.bufferedPaths.clear();
|
|
287
|
+
this.firstBufferedAtMs = undefined;
|
|
288
|
+
this.flushFailureCount = 0;
|
|
289
|
+
if (this.maxFlushTimer)
|
|
290
|
+
clearTimeout(this.maxFlushTimer);
|
|
291
|
+
if (this.flushRetryTimer)
|
|
292
|
+
clearTimeout(this.flushRetryTimer);
|
|
293
|
+
this.maxFlushTimer = undefined;
|
|
294
|
+
this.flushRetryTimer = undefined;
|
|
295
|
+
await this.journal.truncate();
|
|
296
|
+
this.lastError = undefined;
|
|
297
|
+
this.scheduler.schedule();
|
|
298
|
+
await this.emitStatus();
|
|
299
|
+
}
|
|
300
|
+
async flushJournalEntriesLocked() {
|
|
301
|
+
if (this.pendingJournalEntries.length === 0)
|
|
302
|
+
return;
|
|
303
|
+
const entries = this.pendingJournalEntries.splice(0);
|
|
304
|
+
try {
|
|
305
|
+
await this.journal.appendBatch(entries);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
this.pendingJournalEntries.unshift(...entries);
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
scheduleFlushRetry() {
|
|
313
|
+
if (!this.running || this.flushRetryTimer)
|
|
314
|
+
return;
|
|
315
|
+
this.flushFailureCount += 1;
|
|
316
|
+
const baseDelay = this.options.flushEventsMs ?? DEFAULT_EVENT_FLUSH_MS;
|
|
317
|
+
const delay = Math.min(baseDelay * 2 ** Math.max(0, this.flushFailureCount - 1), this.options.flushRetryMaxDelayMs ?? DEFAULT_FLUSH_RETRY_MAX_DELAY_MS);
|
|
318
|
+
this.flushRetryTimer = setTimeout(() => {
|
|
319
|
+
this.flushRetryTimer = undefined;
|
|
320
|
+
void this.flushBufferedEvents().catch((error) => {
|
|
321
|
+
this.setLastError(error);
|
|
322
|
+
this.scheduleFlushRetry();
|
|
323
|
+
void this.emitStatus();
|
|
324
|
+
});
|
|
325
|
+
}, delay);
|
|
326
|
+
}
|
|
327
|
+
isIgnored(candidate, stats) {
|
|
328
|
+
const relative = normalizeWatchPath(this.repoRoot, candidate);
|
|
329
|
+
if (!relative)
|
|
330
|
+
return false;
|
|
331
|
+
const parts = relative.split("/");
|
|
332
|
+
if (parts.some((part) => shouldIgnoreDirectory(part).ignored))
|
|
333
|
+
return true;
|
|
334
|
+
if (stats?.isDirectory())
|
|
335
|
+
return false;
|
|
336
|
+
if (stats?.isFile())
|
|
337
|
+
return shouldIgnoreFile(relative, this.options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES, stats.size).ignored;
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
async emitStatus() {
|
|
341
|
+
if (!this.options.onStatus)
|
|
342
|
+
return;
|
|
343
|
+
const status = await this.status();
|
|
344
|
+
if (this.lastError)
|
|
345
|
+
status.scheduler.lastError = this.lastError;
|
|
346
|
+
this.options.onStatus(status);
|
|
347
|
+
}
|
|
348
|
+
enqueueJournalOperation(operation) {
|
|
349
|
+
const next = this.journalQueue.then(operation, operation);
|
|
350
|
+
this.journalQueue = next.catch(() => undefined);
|
|
351
|
+
return next;
|
|
352
|
+
}
|
|
353
|
+
setLastError(error) {
|
|
354
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
355
|
+
return this.lastError;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function normalizeWatchPath(repoRoot, rawPath) {
|
|
359
|
+
const candidate = rawPath.trim();
|
|
360
|
+
if (!candidate || candidate === ".")
|
|
361
|
+
return undefined;
|
|
362
|
+
const relative = path.isAbsolute(candidate)
|
|
363
|
+
? normalizeRepoPath(repoRoot, path.resolve(candidate))
|
|
364
|
+
: normalizeUserPath(candidate);
|
|
365
|
+
if (!relative || relative === "." || relative.startsWith("..") || path.isAbsolute(relative))
|
|
366
|
+
return undefined;
|
|
367
|
+
return relative;
|
|
368
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export declare const WATCHER_LOCK_FILE = "watcher.lock";
|
|
2
|
+
export declare const WATCHER_HEARTBEAT_FILE = "watcher-heartbeat.json";
|
|
3
|
+
export declare const HEARTBEAT_STALE_MS = 30000;
|
|
4
|
+
export interface WatcherLockInfo {
|
|
5
|
+
pid: number;
|
|
6
|
+
hostname: string;
|
|
7
|
+
repoRoot: string;
|
|
8
|
+
startedAtMs: number;
|
|
9
|
+
}
|
|
10
|
+
export interface WatcherHeartbeat {
|
|
11
|
+
pid: number;
|
|
12
|
+
hostname: string;
|
|
13
|
+
repoRoot: string;
|
|
14
|
+
startedAtMs: number;
|
|
15
|
+
lastHeartbeatMs: number;
|
|
16
|
+
lastIndexedAtMs?: number;
|
|
17
|
+
pendingFiles: number;
|
|
18
|
+
indexingFiles: number;
|
|
19
|
+
ready: boolean;
|
|
20
|
+
lastError?: string;
|
|
21
|
+
}
|
|
22
|
+
export type WatcherLivenessState = "running" | "stale" | "dead" | "not_running";
|
|
23
|
+
export interface WatcherLiveness {
|
|
24
|
+
state: WatcherLivenessState;
|
|
25
|
+
/** True only when a lock holder process is actually alive on this host. */
|
|
26
|
+
processAlive: boolean;
|
|
27
|
+
/** True when a heartbeat exists and is within HEARTBEAT_STALE_MS. */
|
|
28
|
+
heartbeatFresh: boolean;
|
|
29
|
+
lock?: WatcherLockInfo;
|
|
30
|
+
heartbeat?: WatcherHeartbeat;
|
|
31
|
+
heartbeatAgeMs?: number;
|
|
32
|
+
}
|
|
33
|
+
export declare function watcherLockPath(repoRoot: string): string;
|
|
34
|
+
export declare function watcherHeartbeatPath(repoRoot: string): string;
|
|
35
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
36
|
+
export declare class WatcherLockError extends Error {
|
|
37
|
+
readonly existing: WatcherLockInfo;
|
|
38
|
+
constructor(message: string, existing: WatcherLockInfo);
|
|
39
|
+
}
|
|
40
|
+
export interface WatcherLockHandle {
|
|
41
|
+
readonly info: WatcherLockInfo;
|
|
42
|
+
release(): void;
|
|
43
|
+
}
|
|
44
|
+
export declare function acquireWatcherLock(repoRoot: string): WatcherLockHandle;
|
|
45
|
+
export declare function writeHeartbeat(repoRoot: string, heartbeat: WatcherHeartbeat): Promise<void>;
|
|
46
|
+
export declare function clearHeartbeat(repoRoot: string): Promise<void>;
|
|
47
|
+
export declare function readWatcherLiveness(repoRoot: string, nowMs?: number): Promise<WatcherLiveness>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
// Cross-process liveness for the watch daemon. Two files under <repo>/.ragcode/:
|
|
6
|
+
//
|
|
7
|
+
// watcher.lock — pid-stamped advisory lock. Acquired on `ragcode watch` start;
|
|
8
|
+
// a second watcher on the same repo refuses rather than double-indexing.
|
|
9
|
+
// watcher-heartbeat.json — periodically rewritten with last-heartbeat / last-index /
|
|
10
|
+
// backlog so doctor, dashboard, and MCP can answer
|
|
11
|
+
// "is a watcher alive and current?" with only a file read (no IPC).
|
|
12
|
+
//
|
|
13
|
+
// Both live inside .ragcode/, which the watcher's ignore policy already excludes, so writing
|
|
14
|
+
// them never feeds back as a file event.
|
|
15
|
+
export const WATCHER_LOCK_FILE = "watcher.lock";
|
|
16
|
+
export const WATCHER_HEARTBEAT_FILE = "watcher-heartbeat.json";
|
|
17
|
+
// A heartbeat older than this is treated as a dead/hung daemon. The daemon writes a heartbeat
|
|
18
|
+
// on every scheduler tick and on a fixed liveness interval, both well under this bound.
|
|
19
|
+
export const HEARTBEAT_STALE_MS = 30_000;
|
|
20
|
+
function ragcodeDir(repoRoot) {
|
|
21
|
+
return path.join(path.resolve(repoRoot), ".ragcode");
|
|
22
|
+
}
|
|
23
|
+
export function watcherLockPath(repoRoot) {
|
|
24
|
+
return path.join(ragcodeDir(repoRoot), WATCHER_LOCK_FILE);
|
|
25
|
+
}
|
|
26
|
+
export function watcherHeartbeatPath(repoRoot) {
|
|
27
|
+
return path.join(ragcodeDir(repoRoot), WATCHER_HEARTBEAT_FILE);
|
|
28
|
+
}
|
|
29
|
+
// `process.kill(pid, 0)` sends no signal; it only probes whether the pid exists and is
|
|
30
|
+
// signalable by us. ESRCH = gone, EPERM = alive but owned by another user (still "alive").
|
|
31
|
+
export function isProcessAlive(pid) {
|
|
32
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
33
|
+
return false;
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return error.code === "EPERM";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function readLockSync(repoRoot) {
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(watcherLockPath(repoRoot), "utf8");
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
if (typeof parsed.pid !== "number")
|
|
47
|
+
return undefined;
|
|
48
|
+
return {
|
|
49
|
+
pid: parsed.pid,
|
|
50
|
+
hostname: typeof parsed.hostname === "string" ? parsed.hostname : "",
|
|
51
|
+
repoRoot: typeof parsed.repoRoot === "string" ? parsed.repoRoot : path.resolve(repoRoot),
|
|
52
|
+
startedAtMs: typeof parsed.startedAtMs === "number" ? parsed.startedAtMs : 0
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export class WatcherLockError extends Error {
|
|
60
|
+
existing;
|
|
61
|
+
constructor(message, existing) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.existing = existing;
|
|
64
|
+
this.name = "WatcherLockError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Acquire the per-repo watcher lock. Throws WatcherLockError if a live watcher already holds it.
|
|
68
|
+
// A lock left behind by a crashed process (pid no longer alive, or a different host) is treated
|
|
69
|
+
// as stale and reclaimed. Acquisition is atomic via O_EXCL with a takeover fallback.
|
|
70
|
+
export function acquireWatcherLock(repoRoot) {
|
|
71
|
+
const resolvedRoot = path.resolve(repoRoot);
|
|
72
|
+
const lockPath = watcherLockPath(resolvedRoot);
|
|
73
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
74
|
+
const info = {
|
|
75
|
+
pid: process.pid,
|
|
76
|
+
hostname: os.hostname(),
|
|
77
|
+
repoRoot: resolvedRoot,
|
|
78
|
+
startedAtMs: Date.now()
|
|
79
|
+
};
|
|
80
|
+
const payload = `${JSON.stringify(info, null, 2)}\n`;
|
|
81
|
+
const writeExclusive = () => {
|
|
82
|
+
try {
|
|
83
|
+
fs.writeFileSync(lockPath, payload, { flag: "wx" });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error.code === "EEXIST")
|
|
88
|
+
return false;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
if (!writeExclusive()) {
|
|
93
|
+
const existing = readLockSync(resolvedRoot);
|
|
94
|
+
const heldByLiveProcess = existing !== undefined && existing.hostname === info.hostname && isProcessAlive(existing.pid);
|
|
95
|
+
if (heldByLiveProcess) {
|
|
96
|
+
throw new WatcherLockError(`A watcher is already running for ${resolvedRoot} (pid ${existing.pid}).`, existing);
|
|
97
|
+
}
|
|
98
|
+
// Stale lock (crashed pid, or a lock from another host that can't be verified here):
|
|
99
|
+
// reclaim it. The takeover write is not O_EXCL because we've decided the holder is gone.
|
|
100
|
+
fs.writeFileSync(lockPath, payload, { flag: "w" });
|
|
101
|
+
}
|
|
102
|
+
let released = false;
|
|
103
|
+
return {
|
|
104
|
+
info,
|
|
105
|
+
release() {
|
|
106
|
+
if (released)
|
|
107
|
+
return;
|
|
108
|
+
released = true;
|
|
109
|
+
// Only remove the lock if we still own it — guards against deleting a lock a newer
|
|
110
|
+
// watcher reclaimed after we were declared stale.
|
|
111
|
+
const current = readLockSync(resolvedRoot);
|
|
112
|
+
if (current && current.pid === info.pid && current.startedAtMs === info.startedAtMs) {
|
|
113
|
+
try {
|
|
114
|
+
fs.rmSync(lockPath, { force: true });
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Best-effort: a missing lock on release is fine.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export async function writeHeartbeat(repoRoot, heartbeat) {
|
|
124
|
+
const heartbeatPath = watcherHeartbeatPath(repoRoot);
|
|
125
|
+
await fsp.mkdir(path.dirname(heartbeatPath), { recursive: true });
|
|
126
|
+
await fsp.writeFile(heartbeatPath, `${JSON.stringify(heartbeat, null, 2)}\n`, "utf8");
|
|
127
|
+
}
|
|
128
|
+
export async function clearHeartbeat(repoRoot) {
|
|
129
|
+
await fsp.rm(watcherHeartbeatPath(repoRoot), { force: true }).catch(() => undefined);
|
|
130
|
+
}
|
|
131
|
+
async function readHeartbeat(repoRoot) {
|
|
132
|
+
try {
|
|
133
|
+
const raw = await fsp.readFile(watcherHeartbeatPath(repoRoot), "utf8");
|
|
134
|
+
const parsed = JSON.parse(raw);
|
|
135
|
+
if (typeof parsed.pid !== "number" || typeof parsed.lastHeartbeatMs !== "number")
|
|
136
|
+
return undefined;
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Read-only liveness probe used by doctor / dashboard / MCP. Combines the lock (does a holder
|
|
144
|
+
// process exist?) with the heartbeat (is it current?) into one verdict. Pure reads — never
|
|
145
|
+
// mutates lock or heartbeat, so it is safe to call from any process.
|
|
146
|
+
export async function readWatcherLiveness(repoRoot, nowMs = Date.now()) {
|
|
147
|
+
const lock = readLockSync(repoRoot);
|
|
148
|
+
const heartbeat = await readHeartbeat(repoRoot);
|
|
149
|
+
const sameHost = lock ? lock.hostname === os.hostname() : false;
|
|
150
|
+
// Off-host locks can't be probed with process.kill; trust a fresh heartbeat instead.
|
|
151
|
+
const processAlive = lock ? (sameHost ? isProcessAlive(lock.pid) : Boolean(heartbeat)) : false;
|
|
152
|
+
const heartbeatAgeMs = heartbeat ? Math.max(0, nowMs - heartbeat.lastHeartbeatMs) : undefined;
|
|
153
|
+
const heartbeatFresh = heartbeatAgeMs !== undefined && heartbeatAgeMs <= HEARTBEAT_STALE_MS;
|
|
154
|
+
let state;
|
|
155
|
+
if (!lock && !heartbeat) {
|
|
156
|
+
state = "not_running";
|
|
157
|
+
}
|
|
158
|
+
else if (processAlive && heartbeatFresh) {
|
|
159
|
+
state = "running";
|
|
160
|
+
}
|
|
161
|
+
else if (processAlive && !heartbeatFresh) {
|
|
162
|
+
state = "stale";
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
state = "dead";
|
|
166
|
+
}
|
|
167
|
+
return { state, processAlive, heartbeatFresh, lock, heartbeat, heartbeatAgeMs };
|
|
168
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|