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,81 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { normalizeRepoPath, normalizeUserPath } from "../utils/path.js";
|
|
4
|
+
export class FileEventJournal {
|
|
5
|
+
journalPath;
|
|
6
|
+
repoRoot;
|
|
7
|
+
constructor(journalPath, repoRoot) {
|
|
8
|
+
this.journalPath = journalPath;
|
|
9
|
+
this.repoRoot = repoRoot;
|
|
10
|
+
}
|
|
11
|
+
static forRepo(repoRoot, fileName = "watch-events.jsonl") {
|
|
12
|
+
return new FileEventJournal(path.join(repoRoot, ".ragcode", fileName), path.resolve(repoRoot));
|
|
13
|
+
}
|
|
14
|
+
async append(entry) {
|
|
15
|
+
await this.appendBatch([entry]);
|
|
16
|
+
}
|
|
17
|
+
async appendBatch(entries) {
|
|
18
|
+
if (entries.length === 0)
|
|
19
|
+
return;
|
|
20
|
+
await fs.mkdir(path.dirname(this.journalPath), { recursive: true });
|
|
21
|
+
await fs.appendFile(this.journalPath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
|
|
22
|
+
}
|
|
23
|
+
async replay() {
|
|
24
|
+
const content = await fs.readFile(this.journalPath, "utf8").catch((error) => {
|
|
25
|
+
if (isNotFound(error))
|
|
26
|
+
return "";
|
|
27
|
+
throw error;
|
|
28
|
+
});
|
|
29
|
+
const entries = [];
|
|
30
|
+
for (const line of content.split(/\r?\n/)) {
|
|
31
|
+
if (!line.trim())
|
|
32
|
+
continue;
|
|
33
|
+
const parsed = parseEntry(line);
|
|
34
|
+
if (parsed)
|
|
35
|
+
entries.push(parsed);
|
|
36
|
+
}
|
|
37
|
+
return entries;
|
|
38
|
+
}
|
|
39
|
+
async replayPaths() {
|
|
40
|
+
const paths = new Set();
|
|
41
|
+
for (const entry of await this.replay()) {
|
|
42
|
+
paths.add(this.normalizeJournalPath(entry.filePath));
|
|
43
|
+
}
|
|
44
|
+
return [...paths].sort();
|
|
45
|
+
}
|
|
46
|
+
async truncate() {
|
|
47
|
+
await fs.mkdir(path.dirname(this.journalPath), { recursive: true });
|
|
48
|
+
await fs.writeFile(this.journalPath, "", "utf8");
|
|
49
|
+
}
|
|
50
|
+
normalizeJournalPath(filePath) {
|
|
51
|
+
if (this.repoRoot && path.isAbsolute(filePath))
|
|
52
|
+
return normalizeRepoPath(this.repoRoot, path.resolve(filePath));
|
|
53
|
+
return normalizeUserPath(filePath);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function parseEntry(line) {
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(line);
|
|
59
|
+
if (!parsed || typeof parsed !== "object")
|
|
60
|
+
return undefined;
|
|
61
|
+
const candidate = parsed;
|
|
62
|
+
if (!isWatchEvent(candidate.event))
|
|
63
|
+
return undefined;
|
|
64
|
+
if (typeof candidate.filePath !== "string" || !candidate.filePath.trim())
|
|
65
|
+
return undefined;
|
|
66
|
+
return {
|
|
67
|
+
event: candidate.event,
|
|
68
|
+
filePath: normalizeUserPath(candidate.filePath),
|
|
69
|
+
observedAtMs: typeof candidate.observedAtMs === "number" ? candidate.observedAtMs : Date.now()
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function isWatchEvent(value) {
|
|
77
|
+
return value === "add" || value === "change" || value === "unlink" || value === "addDir" || value === "unlinkDir";
|
|
78
|
+
}
|
|
79
|
+
function isNotFound(error) {
|
|
80
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
81
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { WatcherEventOptions } from "../core/types.js";
|
|
2
|
+
export interface CoalescedFileEvents {
|
|
3
|
+
dirtyFiles: string[];
|
|
4
|
+
burstMode: boolean;
|
|
5
|
+
droppedEvents: number;
|
|
6
|
+
eventCountByFile: Map<string, number>;
|
|
7
|
+
lastEventAtMs: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function coalesceFileEvents(repoRoot: string, filePaths: string[], options?: WatcherEventOptions): CoalescedFileEvents;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { normalizeRepoPath, normalizeUserPath } from "../utils/path.js";
|
|
3
|
+
const DEFAULT_BURST_THRESHOLD = 100;
|
|
4
|
+
const DEFAULT_MAX_DIRTY_FILES = 1_000;
|
|
5
|
+
export function coalesceFileEvents(repoRoot, filePaths, options = {}) {
|
|
6
|
+
const eventCountByFile = new Map();
|
|
7
|
+
for (const filePath of filePaths) {
|
|
8
|
+
const normalized = normalizeEventPath(repoRoot, filePath);
|
|
9
|
+
if (!normalized)
|
|
10
|
+
continue;
|
|
11
|
+
eventCountByFile.set(normalized, (eventCountByFile.get(normalized) ?? 0) + 1);
|
|
12
|
+
}
|
|
13
|
+
const maxDirtyFiles = options.maxDirtyFiles ?? DEFAULT_MAX_DIRTY_FILES;
|
|
14
|
+
const burstThreshold = options.burstThreshold ?? DEFAULT_BURST_THRESHOLD;
|
|
15
|
+
const allDirtyFiles = [...eventCountByFile.keys()].sort();
|
|
16
|
+
const dirtyFiles = allDirtyFiles.slice(0, maxDirtyFiles);
|
|
17
|
+
const droppedEvents = Math.max(0, allDirtyFiles.length - dirtyFiles.length);
|
|
18
|
+
const burstMode = allDirtyFiles.length >= burstThreshold || droppedEvents > 0;
|
|
19
|
+
const keptCounts = new Map(dirtyFiles.map((filePath) => [filePath, eventCountByFile.get(filePath) ?? 1]));
|
|
20
|
+
return {
|
|
21
|
+
dirtyFiles,
|
|
22
|
+
burstMode,
|
|
23
|
+
droppedEvents,
|
|
24
|
+
eventCountByFile: keptCounts,
|
|
25
|
+
lastEventAtMs: Date.now()
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function normalizeEventPath(repoRoot, filePath) {
|
|
29
|
+
const trimmed = filePath.trim();
|
|
30
|
+
if (!trimmed)
|
|
31
|
+
return undefined;
|
|
32
|
+
if (path.isAbsolute(trimmed)) {
|
|
33
|
+
const relative = normalizeRepoPath(repoRoot, path.resolve(trimmed));
|
|
34
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative))
|
|
35
|
+
return undefined;
|
|
36
|
+
return relative;
|
|
37
|
+
}
|
|
38
|
+
return normalizeUserPath(trimmed);
|
|
39
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ContextEngine } from "../core/contracts.js";
|
|
2
|
+
import type { RepoIndex, WatcherEventOptions } from "../core/types.js";
|
|
3
|
+
export interface WatchIndexSchedulerOptions extends WatcherEventOptions {
|
|
4
|
+
batchDelayMs?: number;
|
|
5
|
+
minQuietMs?: number;
|
|
6
|
+
maxBatchFiles?: number;
|
|
7
|
+
maxRetryAttempts?: number;
|
|
8
|
+
maxRetryDelayMs?: number;
|
|
9
|
+
autoIndex?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Idle re-scan interval. Even with no in-process chokidar events, the scheduler periodically
|
|
12
|
+
* re-reads dirty state from the shared store so that dirty files written by *another* process
|
|
13
|
+
* (an MCP client calling record_file_events, a manual `ragcode record-events`) get picked up by
|
|
14
|
+
* the live daemon. This is what makes the SQLite dirty table a cross-process work queue without
|
|
15
|
+
* any IPC. Set 0 to disable. Defaults to 3000ms.
|
|
16
|
+
*/
|
|
17
|
+
idlePollMs?: number;
|
|
18
|
+
onStatus?: (status: WatchIndexSchedulerStatus) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface WatchIndexSchedulerStatus {
|
|
21
|
+
repoRoot: string;
|
|
22
|
+
running: boolean;
|
|
23
|
+
scheduled: boolean;
|
|
24
|
+
indexing: boolean;
|
|
25
|
+
pendingFiles: number;
|
|
26
|
+
indexingFiles: number;
|
|
27
|
+
lastIndexedAtMs?: number;
|
|
28
|
+
lastError?: string;
|
|
29
|
+
}
|
|
30
|
+
export declare class WatchIndexScheduler {
|
|
31
|
+
private readonly engine;
|
|
32
|
+
private readonly repoRoot;
|
|
33
|
+
private readonly options;
|
|
34
|
+
private timer;
|
|
35
|
+
private idleTimer;
|
|
36
|
+
private running;
|
|
37
|
+
private indexing;
|
|
38
|
+
private lastIndexedAtMs;
|
|
39
|
+
private lastError;
|
|
40
|
+
private readonly failureAttemptsByFile;
|
|
41
|
+
constructor(engine: ContextEngine, repoRoot: string, options?: WatchIndexSchedulerOptions);
|
|
42
|
+
start(): void;
|
|
43
|
+
stop(): Promise<void>;
|
|
44
|
+
private startIdlePoll;
|
|
45
|
+
schedule(delayMs?: number): void;
|
|
46
|
+
flush(): Promise<RepoIndex | undefined>;
|
|
47
|
+
status(): Promise<WatchIndexSchedulerStatus>;
|
|
48
|
+
private requeueIndexingFiles;
|
|
49
|
+
private retryDelayMs;
|
|
50
|
+
private emitStatus;
|
|
51
|
+
private statusFromIndex;
|
|
52
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
const DEFAULT_BATCH_DELAY_MS = 750;
|
|
2
|
+
const DEFAULT_MIN_QUIET_MS = 250;
|
|
3
|
+
const DEFAULT_MAX_BATCH_FILES = 1_000;
|
|
4
|
+
const DEFAULT_MAX_RETRY_ATTEMPTS = 5;
|
|
5
|
+
const DEFAULT_MAX_RETRY_DELAY_MS = 30_000;
|
|
6
|
+
const DEFAULT_IDLE_POLL_MS = 3_000;
|
|
7
|
+
export class WatchIndexScheduler {
|
|
8
|
+
engine;
|
|
9
|
+
repoRoot;
|
|
10
|
+
options;
|
|
11
|
+
timer;
|
|
12
|
+
idleTimer;
|
|
13
|
+
running = false;
|
|
14
|
+
indexing = false;
|
|
15
|
+
lastIndexedAtMs;
|
|
16
|
+
lastError;
|
|
17
|
+
failureAttemptsByFile = new Map();
|
|
18
|
+
constructor(engine, repoRoot, options = {}) {
|
|
19
|
+
this.engine = engine;
|
|
20
|
+
this.repoRoot = repoRoot;
|
|
21
|
+
this.options = options;
|
|
22
|
+
}
|
|
23
|
+
start() {
|
|
24
|
+
if (this.running)
|
|
25
|
+
return;
|
|
26
|
+
this.running = true;
|
|
27
|
+
this.startIdlePoll();
|
|
28
|
+
this.schedule();
|
|
29
|
+
}
|
|
30
|
+
async stop() {
|
|
31
|
+
this.running = false;
|
|
32
|
+
if (this.timer)
|
|
33
|
+
clearTimeout(this.timer);
|
|
34
|
+
if (this.idleTimer)
|
|
35
|
+
clearInterval(this.idleTimer);
|
|
36
|
+
this.timer = undefined;
|
|
37
|
+
this.idleTimer = undefined;
|
|
38
|
+
while (this.indexing) {
|
|
39
|
+
await sleep(25);
|
|
40
|
+
}
|
|
41
|
+
await this.emitStatus();
|
|
42
|
+
}
|
|
43
|
+
// Low-frequency safety net: when no in-process event timer is armed, re-check the shared dirty
|
|
44
|
+
// store and arm a flush if another process queued work. Cheap (one indexStatus read) and skipped
|
|
45
|
+
// entirely while a flush is already scheduled or running, so it never competes with event flow.
|
|
46
|
+
startIdlePoll() {
|
|
47
|
+
const interval = this.options.idlePollMs ?? DEFAULT_IDLE_POLL_MS;
|
|
48
|
+
if (interval <= 0 || this.options.autoIndex === false)
|
|
49
|
+
return;
|
|
50
|
+
this.idleTimer = setInterval(() => {
|
|
51
|
+
if (!this.running || this.indexing || this.timer)
|
|
52
|
+
return;
|
|
53
|
+
void this.engine
|
|
54
|
+
.indexStatus(this.repoRoot)
|
|
55
|
+
.then((status) => {
|
|
56
|
+
if (this.running && !this.indexing && !this.timer && (status.pendingFileCount > 0 || status.indexingFileCount > 0)) {
|
|
57
|
+
this.schedule(0);
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
.catch(() => undefined);
|
|
61
|
+
}, interval);
|
|
62
|
+
// Don't keep the event loop alive solely for the idle poll; the watcher itself holds the process.
|
|
63
|
+
this.idleTimer.unref?.();
|
|
64
|
+
}
|
|
65
|
+
schedule(delayMs = this.options.batchDelayMs ?? DEFAULT_BATCH_DELAY_MS) {
|
|
66
|
+
if (!this.running)
|
|
67
|
+
return;
|
|
68
|
+
if (this.timer)
|
|
69
|
+
clearTimeout(this.timer);
|
|
70
|
+
this.timer = setTimeout(() => {
|
|
71
|
+
this.timer = undefined;
|
|
72
|
+
void this.flush();
|
|
73
|
+
}, delayMs);
|
|
74
|
+
void this.emitStatus(true);
|
|
75
|
+
}
|
|
76
|
+
async flush() {
|
|
77
|
+
if (!this.running || this.indexing || this.options.autoIndex === false)
|
|
78
|
+
return undefined;
|
|
79
|
+
this.indexing = true;
|
|
80
|
+
try {
|
|
81
|
+
const status = await this.engine.indexStatus(this.repoRoot);
|
|
82
|
+
const dirtyFiles = dirtyFilesForBatch(status, this.options.maxBatchFiles ?? DEFAULT_MAX_BATCH_FILES);
|
|
83
|
+
if (dirtyFiles.length === 0) {
|
|
84
|
+
this.lastError = undefined;
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const quietMs = Date.now() - latestDirtySeenAtMs(status);
|
|
88
|
+
if (quietMs < (this.options.minQuietMs ?? DEFAULT_MIN_QUIET_MS)) {
|
|
89
|
+
this.schedule((this.options.minQuietMs ?? DEFAULT_MIN_QUIET_MS) - quietMs);
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
await this.engine.markDirtyFilesIndexing(this.repoRoot, dirtyFiles);
|
|
93
|
+
const index = await this.engine.refreshIndex(this.repoRoot, { affectedFiles: dirtyFiles });
|
|
94
|
+
this.lastIndexedAtMs = index.indexedAtMs;
|
|
95
|
+
this.lastError = undefined;
|
|
96
|
+
for (const filePath of dirtyFiles)
|
|
97
|
+
this.failureAttemptsByFile.delete(filePath);
|
|
98
|
+
return index;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
102
|
+
await this.requeueIndexingFiles(this.lastError);
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
this.indexing = false;
|
|
107
|
+
await this.emitStatus();
|
|
108
|
+
if (this.running) {
|
|
109
|
+
const status = await this.engine.indexStatus(this.repoRoot).catch(() => undefined);
|
|
110
|
+
if (status && (status.pendingFileCount > 0 || status.indexingFileCount > 0))
|
|
111
|
+
this.schedule(this.retryDelayMs(status));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async status() {
|
|
116
|
+
return this.statusFromIndex(await this.engine.indexStatus(this.repoRoot).catch(() => undefined));
|
|
117
|
+
}
|
|
118
|
+
async requeueIndexingFiles(reason) {
|
|
119
|
+
const status = await this.engine.indexStatus(this.repoRoot).catch(() => undefined);
|
|
120
|
+
if (!status?.freshness.indexingFiles.length)
|
|
121
|
+
return;
|
|
122
|
+
const maxAttempts = this.options.maxRetryAttempts ?? DEFAULT_MAX_RETRY_ATTEMPTS;
|
|
123
|
+
const retryable = [];
|
|
124
|
+
const deadLetter = [];
|
|
125
|
+
for (const filePath of status.freshness.indexingFiles) {
|
|
126
|
+
const attempts = (this.failureAttemptsByFile.get(filePath) ?? 0) + 1;
|
|
127
|
+
this.failureAttemptsByFile.set(filePath, attempts);
|
|
128
|
+
if (attempts >= maxAttempts)
|
|
129
|
+
deadLetter.push(filePath);
|
|
130
|
+
else
|
|
131
|
+
retryable.push(filePath);
|
|
132
|
+
}
|
|
133
|
+
if (deadLetter.length > 0) {
|
|
134
|
+
await this.engine.markDirtyFilesDeadLetter(this.repoRoot, deadLetter, `background indexing failed ${maxAttempts} times: ${reason}`);
|
|
135
|
+
// Drop dead-lettered files from the attempt counter so it can't grow unbounded over a long watch.
|
|
136
|
+
for (const filePath of deadLetter)
|
|
137
|
+
this.failureAttemptsByFile.delete(filePath);
|
|
138
|
+
}
|
|
139
|
+
if (retryable.length > 0)
|
|
140
|
+
await this.engine.recordFileEvents(this.repoRoot, retryable, this.options);
|
|
141
|
+
}
|
|
142
|
+
retryDelayMs(status) {
|
|
143
|
+
const attempts = maxRetryAttemptsForFiles(this.failureAttemptsByFile, [...status.freshness.pendingFiles, ...status.freshness.indexingFiles]);
|
|
144
|
+
if (attempts === 0)
|
|
145
|
+
return this.options.batchDelayMs ?? DEFAULT_BATCH_DELAY_MS;
|
|
146
|
+
const baseDelay = this.options.batchDelayMs ?? DEFAULT_BATCH_DELAY_MS;
|
|
147
|
+
const delay = baseDelay * 2 ** Math.max(0, attempts - 1);
|
|
148
|
+
return Math.min(delay, this.options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS);
|
|
149
|
+
}
|
|
150
|
+
async emitStatus(scheduled = Boolean(this.timer)) {
|
|
151
|
+
if (!this.options.onStatus)
|
|
152
|
+
return;
|
|
153
|
+
this.options.onStatus(await this.statusFromIndex(await this.engine.indexStatus(this.repoRoot).catch(() => undefined), scheduled));
|
|
154
|
+
}
|
|
155
|
+
statusFromIndex(status, scheduled = Boolean(this.timer)) {
|
|
156
|
+
return {
|
|
157
|
+
repoRoot: this.repoRoot,
|
|
158
|
+
running: this.running,
|
|
159
|
+
scheduled,
|
|
160
|
+
indexing: this.indexing,
|
|
161
|
+
pendingFiles: status?.pendingFileCount ?? 0,
|
|
162
|
+
indexingFiles: status?.indexingFileCount ?? 0,
|
|
163
|
+
lastIndexedAtMs: this.lastIndexedAtMs,
|
|
164
|
+
lastError: this.lastError
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function dirtyFilesForBatch(status, maxBatchFiles) {
|
|
169
|
+
return [...new Set([...status.freshness.pendingFiles, ...status.freshness.indexingFiles])].sort().slice(0, maxBatchFiles);
|
|
170
|
+
}
|
|
171
|
+
function latestDirtySeenAtMs(status) {
|
|
172
|
+
let latest = 0;
|
|
173
|
+
for (const file of status.freshness.dirtyFiles) {
|
|
174
|
+
if (file.lastSeenAtMs > latest)
|
|
175
|
+
latest = file.lastSeenAtMs;
|
|
176
|
+
}
|
|
177
|
+
return latest;
|
|
178
|
+
}
|
|
179
|
+
function maxRetryAttemptsForFiles(attemptsByFile, filePaths) {
|
|
180
|
+
let maxAttempts = 0;
|
|
181
|
+
for (const filePath of filePaths) {
|
|
182
|
+
const attempts = attemptsByFile.get(filePath) ?? 0;
|
|
183
|
+
if (attempts > maxAttempts)
|
|
184
|
+
maxAttempts = attempts;
|
|
185
|
+
}
|
|
186
|
+
return maxAttempts;
|
|
187
|
+
}
|
|
188
|
+
function sleep(ms) {
|
|
189
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
190
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ContextEngine } from "../core/contracts.js";
|
|
2
|
+
import { FileEventJournal, type WatchEventJournalEntry } from "./event-journal.js";
|
|
3
|
+
import { type WatchIndexSchedulerOptions, type WatchIndexSchedulerStatus } from "./index-scheduler.js";
|
|
4
|
+
export interface FileWatchDaemonOptions extends Omit<WatchIndexSchedulerOptions, "onStatus"> {
|
|
5
|
+
awaitWriteFinishMs?: number;
|
|
6
|
+
pollIntervalMs?: number;
|
|
7
|
+
usePolling?: boolean;
|
|
8
|
+
flushEventsMs?: number;
|
|
9
|
+
maxBufferedEvents?: number;
|
|
10
|
+
maxFlushWaitMs?: number;
|
|
11
|
+
flushRetryMaxDelayMs?: number;
|
|
12
|
+
maxFileBytes?: number;
|
|
13
|
+
indexOnStart?: boolean;
|
|
14
|
+
/** Interval for refreshing the on-disk heartbeat even when idle. Defaults to 10s. */
|
|
15
|
+
heartbeatIntervalMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Acquire the per-repo watcher lock on start (refusing if another live watcher holds it) and
|
|
18
|
+
* publish a heartbeat file. Defaults to true. Set false for embedded/in-process usage where
|
|
19
|
+
* no cross-process coordination is needed (e.g. the dashboard's observation daemon).
|
|
20
|
+
*/
|
|
21
|
+
manageLifecycleFiles?: boolean;
|
|
22
|
+
journal?: FileEventJournal;
|
|
23
|
+
onEvent?: (event: WatchEventJournalEntry) => void;
|
|
24
|
+
onStatus?: (status: WatchDaemonStatus) => void;
|
|
25
|
+
}
|
|
26
|
+
export interface WatchDaemonStatus {
|
|
27
|
+
repoRoot: string;
|
|
28
|
+
running: boolean;
|
|
29
|
+
ready: boolean;
|
|
30
|
+
bufferedEvents: number;
|
|
31
|
+
scheduler: WatchIndexSchedulerStatus;
|
|
32
|
+
}
|
|
33
|
+
export declare class FileWatchDaemon {
|
|
34
|
+
private readonly engine;
|
|
35
|
+
private readonly options;
|
|
36
|
+
private watcher;
|
|
37
|
+
private readonly journal;
|
|
38
|
+
private readonly scheduler;
|
|
39
|
+
private readonly repoRoot;
|
|
40
|
+
private readonly bufferedPaths;
|
|
41
|
+
private flushTimer;
|
|
42
|
+
private maxFlushTimer;
|
|
43
|
+
private flushRetryTimer;
|
|
44
|
+
private journalQueue;
|
|
45
|
+
private pendingJournalEntries;
|
|
46
|
+
private firstBufferedAtMs;
|
|
47
|
+
private flushFailureCount;
|
|
48
|
+
private lastError;
|
|
49
|
+
private running;
|
|
50
|
+
private ready;
|
|
51
|
+
private lockHandle;
|
|
52
|
+
private heartbeatTimer;
|
|
53
|
+
private lastIndexedAtMs;
|
|
54
|
+
constructor(engine: ContextEngine, repoRoot: string, options?: FileWatchDaemonOptions);
|
|
55
|
+
start(): Promise<void>;
|
|
56
|
+
stop(): Promise<void>;
|
|
57
|
+
status(): Promise<WatchDaemonStatus>;
|
|
58
|
+
private startHeartbeat;
|
|
59
|
+
private publishHeartbeat;
|
|
60
|
+
private ensureIndexed;
|
|
61
|
+
private replayJournal;
|
|
62
|
+
private handleWatchEvent;
|
|
63
|
+
private recordEvent;
|
|
64
|
+
private scheduleEventFlush;
|
|
65
|
+
private flushBufferedEvents;
|
|
66
|
+
private flushBufferedEventsLocked;
|
|
67
|
+
private flushJournalEntriesLocked;
|
|
68
|
+
private scheduleFlushRetry;
|
|
69
|
+
private isIgnored;
|
|
70
|
+
private emitStatus;
|
|
71
|
+
private enqueueJournalOperation;
|
|
72
|
+
private setLastError;
|
|
73
|
+
}
|