/** * McpWatcher — incremental re-indexer for the MCP server's --watch mode. * * Watches source files for changes and incrementally updates: * 1. signatures in llm-context.json (always) * 2. vector index (only when embed: true and an embedding server is reachable) * * The call graph is deliberately excluded — rebuilding it requires full * tree-sitter analysis of all call sites and is too expensive for a watch loop. * It stays current via the post-commit hook (openlore analyze --force --embed). * * Spec 13.1 (watch-mode performance): freshness is O(change), not O(repo). * • Per-file events COALESCE into one batched flush (single debounce timer + * hard max-batch ceiling), so a burst / branch-switch runs the pipeline once, * not once per file. * • The patched llm-context is handed to the MCP read cache in place * (primeContextCache), so the next tool call is a cache HIT — no 2.1 MB * cold re-parse — even after the disk write. * • Vector updates are row-level (VectorIndex.updateFiles), not a full-corpus * read+overwrite, and run on a separate lower-priority lane so signature * freshness never blocks on embedding. * • VCS-flood / bulk batches are detected and collapsed to a single refresh. * • stderr emits one summary line per batch by default (per-file detail behind * OPENLORE_WATCH_DEBUG). */ /** * How many times one file event is re-queued after a failed flush before the * watcher gives up on it (change: harden-watcher-flush-durability, issue #451). * * A drained batch used to be unrecoverable: `flush` empties `pending` before the * first await, and only two error classes — a spec-index lock timeout and a busy * SQLite — put the paths back. Every other failure (a Windows `EPERM` on the * artifact rename, a sharing violation on the lock's hard link, ENOSPC) reached * one stderr line and the change was gone until the next full `analyze`. A * transient error must cost freshness for one debounce, not until someone * notices. The bound is what keeps a DETERMINISTIC failure from becoming a hot * retry loop: after it, the watcher discloses the drop and — where a graph store * exists — records the files as stale, so the staleness outlives the log line. */ export declare const WATCH_MAX_EVENT_RETRIES = 3; /** * The budget for a failure that is TRANSIENT BY CONSTRUCTION, rather than possibly * deterministic (issue #457). * * The bound above exists to stop a deterministic failure — ENOSPC, a genuine permission * error — becoming a hot retry loop. Windows rename contention is not that. Measured on * Windows: any open descriptor on the destination blocks the atomic replace, share-delete * included, and the holder is usually OUR OWN reader — a tool call serving * `llm-context.json` while the watcher publishes it. That descriptor WILL close. Waiting is * the only way through, because there is no share mode a reader can adopt to step aside. * * Spending the same 3-strike budget on it is what turned a recoverable wait into an * abandoned change: three attempts against a reader that happens to be busy, and the file * was dropped until a full `analyze`. A read-heavy period must cost freshness for a few * debounces, not until someone notices. * * Still BOUNDED, so the hot-loop protection the smaller budget buys is not given up: a * destination held forever still ends in one loud, stale-recorded drop, just later. */ export declare const WATCH_MAX_CONTENTION_RETRIES = 12; export interface McpWatcherOptions { /** Absolute path to the project root being watched */ rootPath: string; /** Absolute path to .openlore/analysis/ — where llm-context.json lives */ outputPath?: string; /** Configured OpenSpec root, relative to rootPath (default: openspec). */ openspecPath?: string; /** Milliseconds to debounce file-change events (default: WATCH_DEBOUNCE_MS) */ debounceMs?: number; /** Hard flush ceiling under a continuous change stream (default: WATCH_MAX_BATCH_MS) */ maxBatchMs?: number; /** Batch size that trips VCS-flood handling (default: WATCH_BULK_THRESHOLD) */ bulkThreshold?: number; /** Run the live vector update; false = signatures-only (default: true) */ embed?: boolean; /** Above this many watched source files, auto-degrade to signatures-only */ embedFileCeiling?: number; /** * Per-changed-file closure work budget (default DEFAULT_CLOSURE_BUDGET). The * max number of other files one save re-resolves before the rest are marked * explicitly stale. Exposed mainly so tests can force the budget-exceeded path. */ closureBudget?: number; /** Extra glob patterns to ignore in addition to defaults */ ignore?: string[]; /** * Fired after each coalesced batch is flushed to disk (signatures + vector). * Lets a host — e.g. the `openlore serve` daemon — schedule heavier work, such * as a debounced full call-graph re-analyze, off the watcher's own lane. The * watcher deliberately excludes the call graph (too expensive synchronously), * so this is the seam where continuous call-graph freshness is layered on. */ onBatchFlushed?: (changedAbsPaths: string[]) => void; /** * Call-graph freshness without the commit hook (change: make-index-self-healing). * Fired — debounced and coalesced — when the graph has fallen behind in a way an * incremental patch cannot repair: a `.git` HEAD ref change (branch switch / pull) * or a stale region that crossed the incremental work budget. A host that already * owns a rebuild coordinator (the `serve` daemon) wires this to its coordinator so * the two rebuild paths coalesce. When provided, the watcher delegates the rebuild * to this callback and does NOT spawn one itself. */ onGraphStale?: (reason: GraphStaleReason) => void; /** * When true AND no `onGraphStale` host handler is provided, the watcher itself * spawns the debounced, coalesced background `analyze --reanalyze` on a graph-stale * trigger (a repeatable singleflight, distinct from the once-per-process schema- * reset heal). Set by the in-process MCP watcher, which — unlike `serve` — has no * rebuild coordinator of its own, so its graph would otherwise age with every * branch switch. Default false: the plain signatures-only watcher is unchanged. */ selfRebuild?: boolean; } /** Why the call graph fell behind in a way only a full rebuild can repair. */ export type GraphStaleReason = 'head-change' | 'stale-region'; export interface RepositoryDeltaResult { changedFiles: string[]; deletedFiles: string[]; closureFiles: string[]; staleFiles: string[]; } /** * True if a root-relative path should never be watched. Evaluated as a cheap * segment scan before any FD is opened, so it stays allocation-light. A path is * ignored if ANY of its segments is a known build/dependency/VCS directory * name, or it has a test-file suffix. Exported for testing. * * @param relPath path relative to the watch root (forward- or back-slashed) */ export declare function isIgnoredRelPath(relPath: string): boolean; export declare class McpWatcher { private readonly rootPath; /** rootPath as libuv must see it. Equal to rootPath unless the caller passed an alias. */ private readonly watchRoot; private readonly outputPath; private readonly openspecRoot; private readonly contextPath; private readonly debounceMs; private readonly maxBatchMs; private readonly bulkThreshold; private readonly embedFileCeiling; private readonly closureBudget; private readonly extraIgnore; private readonly debug; private readonly onBatchFlushed?; private readonly onGraphStale?; private readonly selfRebuild; private fsWatcher?; private gitWatcher?; private graphStaleTimer?; private graphStalePendingReason?; private graphStaleDeadline?; private graphRebuildRunning; private graphRebuildPending; private rebuildChildren; private pending; private pendingDeletions; private debounceTimer?; private maxBatchTimer?; private running; private flushPromise?; private stopping; private vcsBulkFlag; private vcsSettling; private eventRetries; private flushStallTimer?; private appliedClosureFiles?; private embed; private embedDegraded; private embedFiles; private embedNodes; private embedTimer?; private embedRunning; private embedPromise?; private lastEmbedContext?; constructor(options: McpWatcherOptions); start(): Promise; stop(): Promise; /** * Re-spell a path chokidar reported under watchRoot back onto rootPath. * * The whole rest of this class computes relative(this.rootPath, ...) and derives node ids * from the result. When watchRoot differs (an 8.3 or symlinked root — see * canonicalWatchRoot) an unmapped event path escapes the root: relative() returns a * `..\..\` walk, isConfinedPath rejects it, and the change is silently dropped. * * A no-op in the ordinary case, where the two roots are the same string. */ private fromWatchRoot; /** * Add a changed path to the pending set and (re)arm a single debounce timer, * plus a one-shot hard ceiling so a continuous stream still flushes. */ private enqueue; /** Queue a file deletion for the next flush (reuses the same debounce). */ private enqueueDeletion; /** * Create a self-expiring timer that never by itself keeps the process alive * (change: fix-process-exit-lifecycle). Every watcher timer is short-lived and * cleared on stop(); unref'ing them means a missed teardown degrades to an * early exit rather than a process that outlives its transport. */ private armTimer; private armFlush; /** A .git ref changed — settle, then flush whatever changed as one bulk batch. */ private onVcsEvent; /** * Drain the pending set into a single batch. Single-flight: if a flush is * already running, leave the new paths in `pending` and reschedule once it * finishes — never interleave two flushes. * * DURABILITY (issue #451): the drain is destructive — `pending` is emptied * before the first await — so every exit from the batch must either commit the * work, re-queue it, or say out loud that it was dropped. There is no fourth * option in which a change simply disappears. */ private flush; /** * Say once, on stderr, that a flush has been running implausibly long. * * The wait itself is legitimate — `acquireAnalysisLock` blocks until a full * `analyze` finishes its artifact write — so this neither cancels nor shortens * it. It only removes the silence: a wedged holder otherwise leaves the * watcher single-flight-blocked with a growing queue and no output at all. */ private armFlushStallDisclosure; /** * Put a batch that failed for an unrecognized reason back in the queue. * * Bounded by {@link WATCH_MAX_EVENT_RETRIES} so a deterministic failure * degrades to one loud drop instead of a retry loop. The re-queue is what * `flush`'s `finally` sees, so the retry rides the existing debounce re-arm — * no second scheduling path. */ private deferFailedBatch; /** * Disclose files the batch could not read, and put them back for one more try. * * `discloseOnly` is the explicit-caller lane (`handleChange`, * `applyRepositoryDelta`). Those await a single pass and have no debounce to * ride, so re-queueing would schedule work their caller never asked for — and * `applyRepositoryDelta` already reports its own unapplied files as stale, so * a second stale marking here would only duplicate it. They get the * disclosure; what to do about it is theirs to decide. */ private deferUnreadable; /** * Give up on file events the watcher could not apply, leaving a signal that * outlives the log line. * * A stderr line in a long-lived daemon is not a signal an agent can read. Where * a graph store exists, the abandoned files are recorded stale through the same * mechanism `fallbackBulkBatch` uses, so every later freshness read discloses * them instead of serving a silently incomplete index. * * PRE-CONDITION: the analysis lock is NOT held by the caller — this acquires it. * Both call sites (the pre-lock candidate loop in `handleBatch`, and `flush`'s * error handler) run outside the artifact critical section. */ private abandonEvents; /** * Retry a contended SQLite batch, then put it back in the in-memory queue. * A long external write lock delays freshness but never loses the file events. * * This handles the two RECOGNIZED contention classes. Everything else is * re-queued one level up, by `flush`'s error handler — the claim that no event * is silently lost holds for the whole lane, not just for these two branches * (it did not before issue #451). */ private flushBatchWithBusyRetry; /** * A VCS-scale batch is cheaper and safer as one full rebuild than as hundreds * of incremental swaps. Persist the whole affected region as stale before * handing it to the host's existing coalesced rebuild lane. */ private fallbackBulkBatch; /** * Re-index a single changed file. Exposed for unit testing without needing a * real file watcher; flushes synchronously so callers observe the update on * disk immediately. Internally this is just a batch of one. */ handleChange(absPath: string): Promise; /** * Apply an already-bounded, repository-relative delta through the exact same * mutation lanes as watch mode. This does not start a filesystem watcher and * never schedules a full rebuild; callers receive the explicit stale region. * (change: add-incremental-bundle-delta) */ applyRepositoryDelta(changedFiles: readonly string[], deletedFiles: readonly string[]): Promise; /** * Process a coalesced batch of changed files as ONE pipeline pass: * • per-file incremental edge update (content-hash skip), all under one open * EdgeStore; * • ONE signature patch + ONE llm-context persist + ONE read-cache handoff; * • ONE vector update (inline when syncFlush, else on the embed lane). */ private handleBatch; /** * The spec index is full-build-only. Keep that bounded behavior and persist an * honest receipt instead of silently serving stale spec rows. */ private recordSpecIndexChanges; /** * Self-heal a schema-reset graph by spawning one detached `analyze --reanalyze` * (BM25-only, no network). Runs at most once per process (`backgroundRebuild * Triggered`); a spawn failure logs and falls back to the existing "run * analyze" note rather than retrying — no thundering herd, no loop (B10). */ private scheduleBackgroundRebuild; /** Test-only: drive the graph-stale trigger without a real git/fs event. */ _triggerGraphStaleForTesting(reason: GraphStaleReason): void; /** * Hand a read-time freshness mismatch to this host's existing stale-region * repair lane (change: disclose-stale-serving-on-cold-reads). The caller has * already confined and normalized these repository-relative paths. Marking * them preserves the factual stale region while the coalesced full rebuild is * pending; the method returns true only when this watcher actually owns a * rebuild lane, so response wording never promises an unscheduled repair. */ requestColdReadRepair(staleFiles: readonly string[]): boolean; /** * Schedule a debounced, coalesced full-graph rebuild after a trigger an * incremental patch cannot repair (change: make-index-self-healing). Rapid * successive triggers (a `git pull` touching many refs) collapse into one * rebuild. No-op unless a host wired `onGraphStale` or `selfRebuild` is set, so * the plain signatures-only watcher is byte-for-byte unchanged. */ private scheduleGraphRebuild; /** * Repeatable singleflight full `analyze --reanalyze` (BM25-only, no network) for the * in-process watcher, which has no host rebuild coordinator. Distinct from the * once-per-process schema-reset heal: this must re-fire across a session (every * branch switch), so it coalesces a trigger that arrives mid-rebuild into one * follow-up run rather than latching forever. Never throws. */ private spawnGraphRebuild; /** * True when this watcher writes to the canonical `/.openlore/analysis` * layout that the MCP read handlers cache against. Only then is the shared * in-memory read cache (primeContextCache) the right channel to prime; a custom * `outputPath` (tests / non-standard installs) writes only to disk. */ private get usesStandardLayout(); /** * Load the context the watcher is about to patch. This ALWAYS reads fresh from * disk — never through the shared read cache — because the cache is a read-path * (tool-call) optimization, and patching a possibly-stale cached object could * silently drop signatures written by a concurrent `analyze` between events. * The writer reads ground truth; persistContext then primes the read cache with * the result so the next tool call is still a hit (Step 2a, G1). */ private loadContext; private persistContext; private scheduleEmbed; private runEmbedLane; /** * Row-level vector update for the changed files only (Step 3). Falls back to a * silent no-op when no embedding service and no index are available. */ private updateVectors; /** * Row-level literal-text line update for the changed files. No-op when the * text-line index has not been built. Never throws into the batch loop. */ private updateTextLines; /** * Incrementally patch dependency-graph.json's file→file import edges for the * changed files. `get_file_dependencies` reads that static artifact, so without * this an import edit goes stale until a full `analyze`. O(change): re-resolve * each changed file's imports (reusing the builder's `computeFileImportEdges`, * so resolution can't drift), replace that file's import edges, and recompute * in/out-degree. HTTP- and call-graph-synthesized edges are preserved (the * watcher does not rebuild them). Global metrics (pageRank, betweenness, * clusters) are O(graph) and deliberately left to the next full `analyze`. * No-op when no dependency graph exists. Never throws into the batch loop. */ private snapshotVerdictFiles; private buildVerdictBasis; private updateDependencyGraph; /** * Keep style-fingerprint.json live for the changed (and deleted) files (change: * add-codebase-style-fingerprint). Re-tally each changed file's idioms with the same extractor * the full build uses; splice it into the persisted raw per-file counters; drop deleted/now- * unsupported files; then re-roll-up byLanguage + per-file + regions, reusing the STORED * file→region map (communities are O(graph), refreshed on the next full analyze — a brand-new * file is simply unattributed to a region until then). Best-effort + atomic; never throws into * the batch. No-op when no fingerprint exists yet (a full analyze creates it). */ private updateStyleFingerprint; /** * Keep dynamic-boundary.json live for the changed (and deleted) files (change: * disclose-dynamic-boundary-regions). Same lane shape as {@link updateParseHealth}, and for the * same reason: this artifact is ABSENT on a repository with no site, so the lane must CREATE it * when a save introduces the first one and DELETE it when the last one goes. A newly-added `eval` * is disclosed on the next conclusion without a full re-analyze; removing it clears the * disclosure. Best-effort + atomic; a failure is disclosed on stderr, never thrown into the batch. */ private updateDynamicBoundary; /** * Keep parse-health.json live for the changed (and deleted) files (change: * add-parse-health-boundary-disclosure). Unlike the style fingerprint (which every supported repo * has), this artifact is ABSENT on a clean repo — so this lane must be able to CREATE it when a * changed file newly degrades, and DELETE it when the last degraded file is repaired or removed. * Re-tally each changed file with the same dispatch the full build uses; a changed file that is * now clean drops its entry. Best-effort + atomic; never throws into the batch. */ private updateParseHealth; /** * Reconcile file DELETIONS across every lane so a removed file leaves no * phantom state: call-graph nodes/edges (incoming and outgoing), signatures, * text-line rows, vector rows, and dependency-graph node + edges. Best-effort; * a failure in one lane does not block the others. */ private handleDeletions; /** * Republish the generation manifest at the commit point of an incremental write. * * The watcher rewrites the SAME required artifacts a full analyze publishes, so * leaving the manifest alone would keep the old generation id on new content: a * multi-artifact reader would then validate before/after against an identity that * never moved and label a mixed read `ok`, and every cache keyed on the * generation id would keep serving superseded structure. Always called inside the * artifact lock, after the whole write set is durable. */ private republishGeneration; /** * Remove deleted files' nodes and any edge referencing them from * dependency-graph.json, recompute degrees, and persist atomically. */ private removeFromDependencyGraph; /** Bounded count of watched source files; stops early once `cap` is exceeded. */ private countSourceFiles; } /** * Re-parse changedFile + the given callerFiles (the closure the caller already * bounded by the work budget — fix-transitive-incremental-staleness). Returns * fresh edges (all files in the subset) and nodes (changedFile only — callerFiles * nodes are untouched since their function signatures didn't change). * * Exported for unit testing (locks the HTML-blanking node-refresh contract). */ export declare function buildGraphSubset(changedRel: string, changedContent: string, callerFiles: string[], rootDir: string, resolutionNodes?: import('../analyzer/call-graph.js').FunctionNode[], resolutionClasses?: import('../analyzer/call-graph.js').ClassNode[]): Promise<{ edges: import('../analyzer/call-graph.js').CallEdge[]; nodes: import('../analyzer/call-graph.js').FunctionNode[]; cfgs: Array<{ functionId: string; filePath: string; cfg: import('../analyzer/cfg.js').FunctionCfg; }>; classes: import('../analyzer/call-graph.js').ClassNode[]; inheritanceEdges: import('../analyzer/call-graph.js').InheritanceEdge[]; /** * callerFiles the caller asked to re-resolve but that could NOT be read * (permissions / transient I/O / a lock). The caller must NOT delete-and-empty * these — it preserves their edges and marks them stale instead, so an * unreadable file is never silently emptied-and-asserted-fresh * (fix-transitive-incremental-staleness). */ skipped: string[]; analyzedFileHashes: Map; }>; //# sourceMappingURL=mcp-watcher.d.ts.map