/** * File watcher — monitors HQ directory for changes * Uses chokidar with debounced batching * * Active watcher path: TreeWatcher detects local changes, WatchPushDriver * schedules pushes, and PushEventEmitter publishes typed push events. */ import * as fs from "fs"; import { probeScopedWatchCoverage, type WatchCoverageProbeResult } from "./watch-coverage.js"; export { probeScopedWatchCoverage, type WatchCoverageProbeResult }; import { StateStoreLockError, type StateStoreRecord } from "./sync/state-store.js"; import { type PublishAttemptOutcome, type PushTransport } from "./sync/push-transport.js"; import type { EventDrivenPushFlagProvider } from "./sync/feature-flags.js"; import { type CloudTelemetryClient, type TelemetryClaims } from "./telemetry-events.js"; /** * Event-pass debounce defaults for the `--watch --event-push` runner loop. * * The quiet window collapses editor save bursts into one batch. The max-wait * ceiling still guarantees a continuous write stream syncs at least every * ~2 minutes. Both are operator-overridable; a missing, zero, or invalid * value falls back to the default. */ export declare const DEFAULT_EVENT_DEBOUNCE_MS = 60000; export declare const DEFAULT_EVENT_MAX_WAIT_MS = 120000; /** * The small-batch path remains a distinct, separately-tunable seam via * `HQ_SYNC_EVENT_SMALL_BATCH_DEBOUNCE_MS`. An operator can lower it for * latency, but it defaults to the same 60s window so ordinary edits are * batched as hard as bursts. */ export declare const DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS = 60000; export declare const DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS = 8; export declare const DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES: number; export declare const EVENT_DEBOUNCE_MS_ENV = "HQ_SYNC_EVENT_DEBOUNCE_MS"; export declare const EVENT_MAX_WAIT_MS_ENV = "HQ_SYNC_EVENT_MAX_WAIT_MS"; export declare const EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV = "HQ_SYNC_EVENT_SMALL_BATCH_DEBOUNCE_MS"; export declare const EVENT_SMALL_BATCH_MAX_PATHS_ENV = "HQ_SYNC_EVENT_SMALL_BATCH_MAX_PATHS"; export declare const EVENT_SMALL_BATCH_MAX_BYTES_ENV = "HQ_SYNC_EVENT_SMALL_BATCH_MAX_BYTES"; export interface EventDebounceConfig { /** Quiet window (ms): a batch settles this long after the LAST event. */ debounceMs: number; /** * Ceiling (ms) from the FIRST event of a window: a continuous event stream * that never goes quiet still emits a batch once this elapses. Never below * `debounceMs`. */ maxWaitMs: number; /** Short quiet window used only for a fully-known small batch. */ smallBatchDebounceMs: number; /** Maximum paths that remain eligible for the short quiet window. */ smallBatchMaxPaths: number; /** Maximum aggregate known payload bytes eligible for the short window. */ smallBatchMaxBytes: number; } /** * Resolve the event-pass debounce configuration from the environment. * `HQ_SYNC_EVENT_DEBOUNCE_MS` / `HQ_SYNC_EVENT_MAX_WAIT_MS`; 0, negative, * or non-numeric values fall back to the defaults. The max-wait ceiling is * floored at the quiet window so it can never fire before a single quiet * period has had a chance to settle. */ export declare function resolveEventDebounceConfig(env?: Record): EventDebounceConfig; /** * A Linux chokidar watcher derives its directory budget from the host's real * inotify allowance, leaving half for editors, dev servers, and other HQ * runners on the same uid. We retain the established 20k minimum only where * the kernel allowance can support it, and cap very large hosts so one watcher * cannot monopolize a million-watch machine. The cap is on distinct directories * admitted over this watcher's lifetime, preventing a high-churn tree from * slowly accumulating an unbounded set of kernel watches. */ export declare const DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS = 20000; export declare const TREE_WATCHER_MAX_WATCHED_PATHS_ENV = "HQ_SYNC_MAX_WATCHED_PATHS"; export declare const MAX_TREE_WATCHER_MAX_WATCHED_PATHS = 500000; export interface WatcherBudgetRuntime { platform: NodeJS.Platform; env: Record; readFileSync: (path: string, encoding: BufferEncoding) => string; } export declare function resolveMaxWatchedPaths(override: number | undefined, runtime?: WatcherBudgetRuntime): number; /** * Injectable clock seam (US-001). * * Production code uses the host timers; tests inject a {@link FakeClock} so the * debounce window can be advanced deterministically without real wall-clock * sleeps. US-002 (real chokidar watcher) and US-003 (runner wiring) build on * this same seam — keep the surface minimal and stable. */ export interface Clock { /** Schedule `fn` to run after `ms`. Returns an opaque handle. */ setTimeout(fn: () => void, ms: number): unknown; /** Cancel a previously scheduled timeout. Safe to call with a stale handle. */ clearTimeout(handle: unknown): void; /** Current epoch milliseconds. */ now(): number; } /** Real clock backed by host timers + Date.now. The production default. */ export declare const systemClock: Clock; /** * Deterministic clock for tests. Advance virtual time with {@link advance}; * any timers whose deadline has passed fire in scheduled order. No real timers * are ever created, so a test using only this clock leaks nothing. */ export declare class FakeClock implements Clock { private current; private nextId; private timers; now(): number; setTimeout(fn: () => void, ms: number): unknown; clearTimeout(handle: unknown): void; /** * Advance virtual time by `ms`, firing every timer whose deadline falls in * the interval (in deadline order). Timers scheduled by a firing callback are * honored within the same advance if their new deadline is still within the * advanced window. */ advance(ms: number): void; /** Number of timers still pending — a leak check for tests. */ pendingTimerCount(): number; } /** A push pass to run when a debounced change settles. May be async. */ export type PushFn = () => void | Promise; export interface WatchPushDriverOptions { /** Quiet window (ms) before a settled change triggers a push. */ debounceMs?: number; /** Clock seam — defaults to {@link systemClock}; tests inject {@link FakeClock}. */ clock?: Clock; /** Push pass to invoke when the window settles. */ push: PushFn; } /** * The reusable debounce + coalesce + concurrency-guard core of event-driven * push (US-001 seam). * * It is intentionally decoupled from chokidar and from S3: callers feed it * synthetic or real change notifications via {@link notifyChange}, and it * invokes the injected `push` fn at most once per quiet window. A push that is * still in flight is never overlapped — a change arriving mid-push is collapsed * and re-triggers a single follow-up pass after the next quiet window. * * US-002 wires a real chokidar watcher's events into {@link notifyChange}; * US-003 supplies the targeted-push `push` fn. Tests drive it directly with a * {@link FakeClock} and a spy `push` fn (no real S3, no 10-minute sleep). */ export declare class WatchPushDriver { private readonly debounceMs; private readonly clock; private readonly push; private timer; private pushing; private pendingWhilePushing; private disposed; constructor(opts: WatchPushDriverOptions); /** * Register a change. Resets the quiet window; the push fires `debounceMs` * after the LAST change in a burst, coalescing the burst to one push. */ notifyChange(): void; private fire; /** True while a push pass is executing. */ isPushing(): boolean; /** Cancel any pending debounce timer; idempotent. Leaves no timers behind. */ dispose(): void; } /** Decision for a single path: emit a change for it, or ignore it. */ export type WatchPathFilter = ((absolutePath: string, isDir?: boolean) => boolean) & { /** Optional path-free retained-entry counts for runner heap telemetry. */ censusSizes?: () => Record; }; export interface TreeWatcherWatchBudgetExceeded { maxWatchedPaths: number; watchedPaths: number; /** Absolute directory whose admission would exceed the cap. */ offendingPath: string; } /** * Tracks directories admitted to chokidar. Chokidar asks its ignored callback * twice: before stat (the descent gate) and after stat (where we can safely * account only for directories). This class is intentionally lifetime-bounded: * a directory that is deleted and recreated cannot turn one long-running * runner into an unbounded consumer. */ export declare class ChokidarWatchBudget { private readonly maxWatchedPaths; private readonly onExceeded; private readonly admitted; private exhausted; constructor(maxWatchedPaths: number, onExceeded: (info: TreeWatcherWatchBudgetExceeded) => void); admit(absolutePath: string): boolean; count(): number; } /** * Translate the emit filter into chokidar's descent predicate. * * Chokidar first calls this with no stats, before it has chosen whether to * recurse or emit an `add`. A newly created file can pass only the file-shaped * filter while a directory-only ignored path can pass only that same shape. * Keep either candidate alive for chokidar's stat-aware call, where the * directory branch remains authoritative and preserves the exact `.hqinclude` * ancestor matcher. */ export declare function toChokidarIgnored(shouldEmit: WatchPathFilter, hqRoot: string, budget?: ChokidarWatchBudget): (filePath: string, stats?: fs.Stats) => boolean; /** A started watch backend. `close()` releases its OS handle(s); idempotent. */ export interface WatchBackend { close(): void; /** Backend mode is surfaced in liveness logs; omitted by older test seams. */ mode?: "native" | "chokidar" | "polling"; /** Native recursive watches need rename-kind state; chokidar does not. */ needsKnownKinds: boolean; /** * True when the backend already populated those known kinds through * `onDirectorySeen` while planning its watch roots, so {@link TreeWatcher} * must NOT walk the tree a second time to seed them. */ seededKnownKinds?: boolean; /** Directories/OS handles admitted by this backend so degradation is auditable. */ watchedPathCount(): number; /** * Register a callback for the end of asynchronous initial discovery. Chokidar * discovers directories after `watch()` returns; scoped native backends are * ready synchronously and omit this hook. */ onReady?(listener: () => void): () => void; /** * Compare live directory identities against the handles this backend opened. * Missing or replaced paths mean the watcher is holding a stale tree. */ probeCoverage?(): WatchCoverageProbeResult; } export interface TreeWatcherDegradation { reason: "watch_budget_exhausted" | "inotify_enospc" | "initial_discovery_timeout"; maxWatchedPaths: number; watchedPaths: number; /** Full local path is deliberately log-only; telemetry receives its top level. */ offendingPath?: string; offendingTopLevel?: string; errorCode?: string; /** Whether the watcher remains alive through polling or is now stopped. */ action?: "polling_fallback" | "stopped"; } export interface TreeWatchBackendOptions { hqRoot: string; shouldEmit: WatchPathFilter; onEvent: (absolutePath: string, kind: BackendChangeKind) => void; onError: (err: unknown) => void; maxWatchedPaths: number; onWatchBudgetExceeded: (info: TreeWatcherWatchBudgetExceeded) => void; /** * Reports every in-scope directory a backend encounters while planning its * watch roots, so the known-kinds index can be built in that same pass. */ onDirectorySeen?: (absolutePath: string) => void; /** Rebuild through chokidar polling after an inotify backend cannot initialize. */ forcePolling?: boolean; } export type TreeWatchBackendFactory = (opts: TreeWatchBackendOptions) => WatchBackend; export type TreeChangeKind = "add" | "change" | "unlink" | "addDir" | "unlinkDir"; /** Exact journal revision observed when a live watcher reports a deletion. */ export interface LocalDeleteSnapshot { journalSlug: string; journalPath: string; /** Exact vault-relative root authorized by the originating watcher event. */ deleteScopeRoot?: string; absolutePath: string; remoteEtag: string; localHash: string; localKind: "file" | "symlink"; } export interface TreeChange { kind: TreeChangeKind; deleteSnapshots?: LocalDeleteSnapshot[]; /** * Deferred durable capture used by the production runner. It is intentionally * not awaited from the filesystem callback: the scoped drain resolves it * before it plans a push/pull for this batch. */ deleteSnapshotCapture?: Promise; } type BackendChangeKind = TreeChangeKind | "rename"; export type TreeWatchBackendChoice = "native-recursive" | "native-shallow" | "chokidar" | "polling"; /** * Which backend a platform gets. * * - macOS (FSEvents) and Windows (ReadDirectoryChangesW) watch a directory * recursively with ONE OS handle, so the scoped planner places a few * recursive watches over the in-scope tree. * - Linux has no recursive handle: a directory watch reports only its own * direct contents. The same scoped backend runs with one shallow inotify * watch per in-scope directory instead — and NONE per file, because the * directory watch already reports content changes to the files directly * inside it. chokidar's Linux mode took a watch per file as well, which * on a real HQ root meant ~950k watches for ~74k directories. * - Anything else falls back to chokidar; a forced polling rebuild always * goes through chokidar's polling backend. */ export declare function selectTreeWatchBackend(platform: string, forcePolling: boolean): TreeWatchBackendChoice; export interface ScopedWatchBackend extends WatchBackend { /** * Planned watches that could not be attached during the initial plan. Any * failure here means the scoped plan has a permanent hole, so the caller * discards it and falls back to chokidar rather than running with partial * coverage. In shallow mode only a systemic failure (the kernel's inotify * limits) counts: one unreadable directory is a hole worth logging, not a * reason to hand the whole box back to a per-file backend. */ attachFailures: number; probeCoverage(): WatchCoverageProbeResult; } export interface ScopedWatchOptions { /** * False on Linux, where `fs.watch` cannot recurse: plan one shallow watch per * in-scope directory, skip the whole-tree bootstrap watch (it cannot exist), * and scan a directory that appears later so files already inside it are * announced — a shallow parent watch only ever sees the directory itself. */ recursiveWatches?: boolean; /** * Hard cap on live watches. Counted in the unit that costs memory — one per * OS watch — so in shallow mode it is the directory count. Attaching stops * at the cap and `onWatchBudgetExceeded` fires exactly once. */ maxWatchedPaths?: number; onWatchBudgetExceeded?: (info: TreeWatcherWatchBudgetExceeded) => void; /** Directory reader seam for the plan walk — defaults to a real `readdirSync`. */ listChildDirs?: (dir: string) => string[]; } export declare function startScopedRecursiveWatch(hqRoot: string, shouldEmit: WatchPathFilter, onEvent: (absolutePath: string, kind: BackendChangeKind) => void, onError: (err: unknown) => void, onDirectorySeen?: (absolutePath: string) => void, opts?: ScopedWatchOptions): ScopedWatchBackend; /** * Build the composite emit-decision predicate. Returns true when a change to * `absolutePath` SHOULD wake the watcher (i.e. it survives every exclusion * layer). Pure and chokidar-free so the matching logic is unit-testable * directly. * * @param hqRoot sync root (== personal-vault root in personalMode). * @param personalMode when true, also applies the personal-vault default * exclusions and the excluded-top-level buckets. * @param options.includeCompanyPaths retains `companies//` below that * otherwise-excluded top-level bucket. The combined * `--companies` runner needs this: it syncs company * vaults and the personal vault in one process, while the * personal side must still prune unrelated `workspace/` * churn. */ export declare function createWatchPathFilter(hqRoot: string, personalMode?: boolean, options?: { includeCompanyPaths?: boolean; }): WatchPathFilter; /** * A full reconciliation can take 250 seconds on a busy outpost. At seven * changes per second that is 1,750 paths, so retain roughly nine such passes * before sacrificing path detail. The byte cap remains the authoritative * memory ceiling for the buffered path strings. */ export declare const DEFAULT_TREE_WATCHER_MAX_PENDING_PATHS = 16384; export declare const DEFAULT_TREE_WATCHER_MAX_PENDING_BYTES: number; export declare const TREE_WATCHER_MAX_PENDING_PATHS_ENV = "HQ_SYNC_MAX_PENDING_PATHS"; export declare const TREE_WATCHER_MAX_PENDING_BYTES_ENV = "HQ_SYNC_MAX_PENDING_BYTES"; /** Resolve the path-detail cap; a constructor override takes precedence over the environment. */ export declare function resolveMaxPendingPaths(override: number | undefined, env?: Record): number; /** Resolve the approximate path-string byte cap; a constructor override takes precedence. */ export declare function resolveMaxPendingBytes(override: number | undefined, env?: Record): number; /** Chokidar discovery is asynchronous; never leave a runner silently pending forever. */ export declare const DEFAULT_TREE_WATCHER_DISCOVERY_TIMEOUT_MS = 60000; /** * Cap on distinct dropped-route hints tracked through one overflow episode. * More distinct routes than this in a single burst means something is walking * the whole tree anyway — degrade to route-unknown (full reconcile) rather * than fan out an unbounded number of targeted passes. */ export declare const TREE_WATCHER_MAX_DROPPED_ROUTE_HINTS = 32; export interface TreeWatcherBacklogOverflow { /** Which guard rejected the newly observed path. */ capHit: "paths" | "bytes"; /** Cumulative number of paths discarded by this watcher process. */ totalDroppedPaths: number; pendingPaths: number; pendingBytes: number; maxPendingPaths: number; maxPendingBytes: number; /** Age of the current accumulated watcher batch when the drop occurred. */ inFlightPassMs: number; /** Number dropped from the current batch when this record was emitted. */ droppedPaths: number; droppedBytes: number; } export interface TreeWatcherOptions { /** Sync root to watch (== personal-vault root in personalMode). */ hqRoot: string; /** Quiet window (ms) before a settled burst emits one `changed` call. */ debounceMs?: number; /** Optional short quiet window for fully-known small batches. */ smallBatchDebounceMs?: number; /** Path cap for the optional short quiet window. */ smallBatchMaxPaths?: number; /** Payload-byte cap for the optional short quiet window. */ smallBatchMaxBytes?: number; /** * Max-wait ceiling (ms) measured from the FIRST event of a window: a * continuous event stream that keeps resetting the quiet window still emits * one coalesced batch once this elapses. Default: no ceiling (Infinity), * preserving the legacy pure-quiet-window behavior for existing callers. */ maxWaitMs?: number; /** Apply personal-vault default + top-level exclusions. */ personalMode?: boolean; /** * Keep company paths in scope while applying personal-vault exclusions to * the rest of the HQ root. Used by the `--companies` fanout watcher. */ includeCompanyPaths?: boolean; /** Clock seam — defaults to {@link systemClock}; tests inject {@link FakeClock}. */ clock?: Clock; /** * Pre-built path filter override (test seam). When omitted, one is built * from {@link createWatchPathFilter}. */ pathFilter?: WatchPathFilter; /** Maximum distinct paths retained in one debounce window. */ maxPendingPaths?: number; /** Approximate maximum path-string bytes retained in one debounce window. */ maxPendingBytes?: number; /** * Hard cap on live OS watches. On Linux the native folder-only backend takes * one inotify watch per in-scope directory and none per file, so this is the * directory count and it is the unit that costs memory. The chokidar fallback * counts distinct directories admitted over the instance lifetime instead. * Overrides `HQ_SYNC_MAX_WATCHED_PATHS` for callers that construct a watcher. */ maxWatchedPaths?: number; /** Maximum wait for chokidar's initial discovery before polling fallback. */ discoveryTimeoutMs?: number; /** Backlog overflow signal. Defaults to a console warning. */ onBacklogOverflow?: (info: TreeWatcherBacklogOverflow) => void; /** * Structured degradation/metric seam. It fires exactly once when the watch * cap disables event watching or when chokidar switches from inotify to its * polling backend after an inotify/discovery failure. */ onDegraded?: (info: TreeWatcherDegradation) => void; /** Optional cloud telemetry sink for the same degradation signal. */ telemetryClient?: CloudTelemetryClient | null; telemetryClaims?: TelemetryClaims | null; /** Test seam for a watch backend; production uses native/chokidar selection. */ backendFactory?: TreeWatchBackendFactory; captureLocalDeleteSnapshots?: (relativePath: string, kind: "unlink" | "unlinkDir") => LocalDeleteSnapshot[]; /** * Non-blocking production capture seam. The watcher serializes and bounds * these jobs; failures are attached to the batch for the runner to degrade * loudly without dropping the filesystem event. */ queueLocalDeleteSnapshots?: (relativePath: string, kind: "unlink" | "unlinkDir", isCurrent: () => boolean) => Promise; /** * Whether a FILE that just disappeared has no journal row at all. Such a * path was never synced: there is nothing to delete remotely and no * revision to bind, so its unlink is dropped before it costs a delete * snapshot capture, a pending slot, or a push. Atomic writers create and * remove these paths within one debounce window (on the controller they * were 370 of 380 agency watcher events per minute). Consulted only for * `unlink`, never `unlinkDir`: a directory without a row of its own may * still cover journaled descendants. Must not block; answering `false` * whenever the truth is not cheaply known is always safe. */ isUntrackedDelete?: (relativePath: string) => boolean; } /** * Chokidar-backed file watcher that emits a single debounced `changed` signal * after a {@link debounceMs} quiet window, coalescing bursts. It honors the * full exclusion stack via {@link createWatchPathFilter}, so excluded paths * (`.env`, `output/`, `.git/`, `companies/` in personalMode, …) never emit. * * Lifecycle: {@link start} (idempotent), {@link stop}, {@link dispose}. Stop * closes the chokidar watcher (releasing fds) and cancels any pending debounce * timer. dispose() is stop() + permanent shutdown. */ /** * One settled change-burst, handed to {@link TreeWatcher} listeners. Carries * the set of relative paths that changed during the quiet window so a listener * (e.g. {@link PushEventEmitter}) can build one PushEvent per path. `paths` is * absolute-path → relative-path; both are needed (relative for the wire shape, * absolute for hashing/statting the file on disk). */ export interface TreeChangeBatch { /** Map of absolutePath → relativePath for every path in the settled burst. */ paths: Map; /** Per-path watcher event and the captured pre-delete journal revision. */ changes?: Map; /** True when path detail was dropped after the watcher backlog cap was hit. */ overflowed?: boolean; /** * Route evidence for the dropped paths: one representative RELATIVE path per * sync route (company / personal) that lost at least one path to the * overflow cap. Present ONLY when EVERY dropped path contributed a hint — * i.e. the touched-route set is fully known despite the per-path detail * being gone — so a consumer can fan out per-route targeted syncs instead of * a full all-companies reconcile. Absent when the route set is unknown (a * backend resync overflow, or more distinct routes than the hint cap), in * which case only a full reconcile is sound. */ droppedRouteHints?: string[]; /** Count of paths dropped from the detailed batch after overflow. */ droppedPaths?: number; /** Approximate path-string bytes dropped from the detailed batch. */ droppedBytes?: number; } /** One bounded startup recovery scan, run after chokidar finishes discovery. */ export interface TreeWatcherWarmupCatchup { batch: TreeChangeBatch; /** In-scope filesystem entries inspected by the one necessary local scan. */ scannedPaths: number; /** The watcher stopped or was disposed before the scan completed. */ cancelled?: true; } /** * Listener invoked once per settled debounce window. * * Backwards compatible with the US-003 `WatcherSurface` contract: the first * argument is the OPTIONAL changed relative path the loop routes its targeted * push to (the first path of the burst; undefined when the window settled with * no captured path). US-008's {@link PushEventEmitter} consumes the SECOND * argument — the full {@link TreeChangeBatch} of every path in the burst — to * build one PushEvent per path. Listeners are free to ignore either argument. */ export type TreeChangeListener = (changedRelPath?: string, batch?: TreeChangeBatch) => void; export declare class TreeWatcher { private readonly hqRoot; private readonly debounceMs; private readonly smallBatchDebounceMs; private readonly smallBatchMaxPaths; private readonly smallBatchMaxBytes; private readonly maxWaitMs; private readonly clock; private readonly shouldEmit; private readonly maxPendingPaths; private readonly maxPendingBytes; private readonly maxWatchedPaths; private readonly discoveryTimeoutMs; private readonly onBacklogOverflow; /** The built-in logger records every dropped path; callback seams remain once/episode. */ private readonly logEveryBacklogDrop; private readonly onDegraded; private readonly telemetryClient; private readonly telemetryClaims; private readonly backendFactory?; private readonly captureLocalDeleteSnapshots?; private readonly queueLocalDeleteSnapshots?; private readonly isUntrackedDelete; private untrackedDeletesDroppedCount; private backend; private ready; private detachBackendReady; private discoveryTimer; /** Directory-admission watermark used to distinguish slow discovery from a stall. */ private discoveryWatchedPaths; private readyListeners; private timer; private listeners; /** Paths accumulated for the current (in-flight) debounce window. */ private pending; private pendingChanges; /** Latest known payload bytes per path; null makes the whole batch bulk. */ private pendingPayloadSizes; private knownKinds; private pendingBytes; private overflowed; private overflowLogged; private droppedPaths; private droppedBytes; private totalDroppedPaths; private pendingStartedAt; /** Representative dropped path per route key — see TreeChangeBatch.droppedRouteHints. */ private droppedRouteHints; /** True when at least one drop carried no usable route evidence. */ private droppedRoutesUnknown; private degraded; private pollingFallback; private disposed; /** Bumped by stop() so an in-progress yielding warm-up stands down. */ private warmupCatchupGeneration; private deleteSnapshotCaptureTail; private queuedDeleteSnapshotCaptures; /** * Invalidation markers for queued delete-snapshot captures, keyed by * absolute path. `epoch` is bumped by every newer revision of the path so a * capture still queued or running for an older one stands down; `inFlight` * counts the captures that hold the marker. A path is in this map ONLY while * at least one capture for it is in flight: the last one to finish retires * the marker whether or not it was superseded. Anything else written here * is a leak keyed by path — a long-lived runner sees hundreds of thousands * of distinct paths, and an atomic writer's temp names never repeat. */ private readonly deleteSnapshotEpochs; constructor(opts: TreeWatcherOptions); /** * Register a debounced-`changed` listener. Returns an unsubscribe fn. * * Listeners receive a {@link TreeChangeBatch} of the paths that changed in * the settled window. Existing US-003 callers that only need the "something * changed" signal can ignore the argument — the contract is backwards * compatible (a zero-arg callback still type-checks). */ onChange(listener: TreeChangeListener): () => void; /** * Begin watching. Idempotent — a second call while already running is a * no-op (no second watch backend, no leaked handles). * * Uses a SINGLE recursive `fs.watch` on macOS/Windows (1 OS handle for the * whole tree) and falls back to chokidar on Linux. See {@link startTreeWatch} * for why per-path watching is avoided (kqueue fd exhaustion → EMFILE). */ start(): void; private startBackend; private installBackend; /** Re-arm the no-progress discovery deadline without replacing the backend. */ private installDiscoveryProgressTimer; /** Register a listener for completion of the backend's initial path discovery. */ onReady(listener: () => void): () => void; private markReady; private handleBackendError; private topLevelForTelemetry; private degrade; private fallbackToPolling; private reportDegradation; private clearDiscoveryTimer; private signalBackendResync; /** * Test/seam entry point: feed a raw filesystem path as if the backend * reported it. Applies the emit filter then arms the debounce. Real watch * events route through here too — and for the recursive backend this is the * ONLY place filtering happens, so out-of-scope paths are dropped here. */ handleEvent(absolutePath: string, backendKind?: BackendChangeKind): void; private handleEventUnchecked; /** * Discard an `unlink` whose path has no journal row. Returns false — and * changes nothing — when the seam says the path is tracked or cannot answer. */ private dropUntrackedDelete; /** Cancel the current debounce window when nothing is left to emit. */ private disarm; /** * Invalidate any delete-snapshot capture still queued or running for this * path. Never inserts: a path with nothing in flight has nothing to * invalidate, and recording it would pin the path in memory for good. */ private supersedeDeleteSnapshotCapture; private enqueueDeleteSnapshotCapture; private seedKnownKinds; private recordBacklogOverflow; /** * Record which sync route a dropped path belonged to, keeping the batch's * touched-route set known even after per-path detail is gone. Bounded: the * hint map holds one representative path per top-level route, and if the * route itself can't be derived (or an unbounded route explosion is * detected) the whole overflow degrades to route-unknown — the consumer * then falls back to a full reconcile, exactly the pre-hint behavior. */ private noteDroppedRoute; /** * Absolute deadline (clock.now() epoch ms) by which the CURRENT window must * emit, regardless of further events. Set on the first arm of a window when * a max-wait ceiling is configured; null between windows. */ private windowDeadlineAt; private arm; private emit; private clearPending; private payloadSizeFor; private isSmallPendingBatch; /** * Recover mutations that happened while chokidar was building its initial * watch set. `ignoreInitial` intentionally suppresses those initial events, * so this makes one post-ready local pass and emits only timestamp-new paths. * * `journalRelativePaths` supplies the durable prior inventory: comparing it * with the paths observed by the same walk finds a warm-up deletion without a * second filesystem walk. Unchanged files never enter the returned batch, so * the runner retains the normal path-scoped push behavior. */ collectWarmupCatchup(sinceMs: number, journalRelativePaths?: Iterable): TreeWatcherWarmupCatchup; /** * Asynchronous form of {@link collectWarmupCatchup}. The warm-up inventory * can contain hundreds of thousands of journal rows, so yielding by a fixed * entry cadence keeps watcher readiness from monopolizing Node's event loop. * It keeps the synchronous method for narrow callers and preserves the same * change/deletion semantics for every observed or journal-only path. */ collectWarmupCatchupAsync(sinceMs: number, journalRelativePaths?: Iterable): Promise; /** True while the watch backend is active. */ isWatching(): boolean; /** Number of directories admitted by the active backend (zero when stopped). */ watchedPathCount(): number; /** Active backend mode, including the no-inotify polling fallback. */ watchMode(): "native" | "chokidar" | "polling" | "inactive" | "unknown"; /** * Check that the HQ root and every live watch still resolve to the inode * they were opened on. Does not emit deletes — a vanished tree must pull * from the vault, not push tombstones. */ probeCoverage(): WatchCoverageProbeResult; /** * Drop stale OS handles and re-open watches on the current tree. Callers * must recreate a missing HQ root first; this does not synthesize unlinks. */ rebuildCoverage(): void; /** Number of pending debounce timers — a leak check for tests. */ pendingTimerCount(): number; /** Number of native-rename directory hints retained for deletion handling. */ knownDirectoryCount(): number; /** File deletes discarded because the path had no journal row (`isUntrackedDelete`). */ untrackedDeletesDropped(): number; /** * Live entry counts of the watcher's long-lived per-path maps, for the runner * heap census. Fixed identifier keys, numeric values only — no path content. */ censusSizes(): Record; /** * Stop watching: close the watch backend (releasing its OS handle) and * cancel any pending debounce timer. Idempotent. The instance can be * restarted with {@link start} unless {@link dispose} was called. */ stop(): void; /** Permanent shutdown: stop() + drop listeners; further events are no-ops. */ dispose(): void; } /** Maximum paths remembered by one emitter to suppress unchanged announcements. */ export declare const DEFAULT_PUBLISHED_CONTENT_HASH_MAX_PATHS = 50000; /** Minimum delay between successful upsert announcements for one path. */ export declare const DEFAULT_PUBLISH_MIN_INTERVAL_MS = 60000; export declare const PUBLISH_MIN_INTERVAL_MS_ENV = "HQ_SYNC_PUBLISH_MIN_INTERVAL_MS"; /** Resolve the optional per-path announcement interval without accepting invalid values. */ export declare function resolvePublishMinIntervalMs(env?: Record): number; /** Limit one WAL frame to a modest size while collapsing a reconcile pass. */ export declare const DURABLE_PUBLISHED_HASH_BATCH_MAX_ENTRIES = 4096; /** A crash can lose at most this small durable-memo window under light load. */ export declare const DURABLE_PUBLISHED_HASH_BATCH_FLUSH_MS = 25; interface DurablePublishedContentHashState { hashes: Record; order: string[]; } /** * Bounded, crash-safe state of hashes whose push event transport acknowledged * publication. This is intentionally separate from the sync journal: a * journal row proves the object upload completed, while the event publish that * follows can still fail or time out. */ export declare class DurablePublishedContentHashes { private readonly store; private readonly clock; private readonly onBatchLockFailure; private readonly pending; private readonly batchRetries; private flushTimer; private flushPromise; private flushRequested; private microtaskFlushScheduled; private closed; private nextSequence; constructor(stateDir: string, scopeId: string, maxPaths: number, initialHashes: Readonly>, clock: Clock, onBatchLockFailure?: (error: StateStoreLockError, mutationCount: number, relativePath: string | undefined) => void); get(relativePath: string): string | undefined; get pendingRetryCount(): number; set(relativePath: string, contentHash: string, maxPaths: number, flushImmediately: boolean): Promise; setIfCurrent(relativePath: string, contentHash: string, maxPaths: number, shouldPersist: () => boolean): Promise; delete(relativePath: string, flushImmediately: boolean): Promise; deleteIfCurrent(relativePath: string, shouldPersist: () => boolean): Promise; dispose(): Promise; private enqueue; private requestMicrotaskFlush; private armFlushTimer; private clearFlushTimer; private requestFlush; private startFlush; private flushBatch; private settle; private handleBatchLockFailure; private retryBatch; private supersedeBatchRetries; private clearBatchRetries; } /** Test seam: compare the legacy pure reducer with the keyed replay reducer. */ export declare function replayDurablePublishedContentHashRecordsForTest(records: readonly StateStoreRecord[], keyed?: boolean): DurablePublishedContentHashState; export interface PushEventEmitterOptions { /** Tenant identifier stamped onto every PushEvent + checked against the flag. */ originTenantId: string; /** Device identifier stamped onto every PushEvent. */ originDeviceId: string; /** Transport that ships each PushEvent (US-007 NoopPushTransport / HttpPushTransport). */ transport: PushTransport; /** * Feature-flag seam. When `isEnabled(originTenantId)` is false the emitter is * dormant: {@link attach} subscribes nothing and {@link emitForBatch} is a * no-op. Defaults are NOT supplied here — the caller injects an * EventDrivenPushFlagProvider so dormancy is explicit. */ flagProvider: EventDrivenPushFlagProvider; /** * Returns the next monotonic sequence number for this device. Default: an * internal counter starting at 0. Inject to persist across daemon restarts. */ getSequenceNumber?: () => number; /** Clock for eventTimestamp. Default `() => new Date()`. */ now?: () => Date; /** Clock for per-path announcement coalescing. Defaults to the host clock. */ clock?: Clock; /** * Where publish failures + hash/stat errors go. Default `console.error`. * Receives the offending PushEvent (when known) so callers can correlate. */ onError?: (err: Error, ctx: { relativePath?: string; }) => void; /** One data-only record after every settled tree batch. */ onPublishOutcome?: (outcome: PublishBatchOutcome) => void; /** * Optional structured logger for the US-011 3-log diagnostic chain. When * supplied, the emitter logs `event=watcher.emit` (the 1st correlated link) * carrying the PushEvent's `sequenceNumber` — the same join key stamped by * the server `push.receive` log and the client `fanout.receive` log. Default: * no log (the daemon stays quiet unless wired with a logger). */ logger?: EmitterLogger; /** Optional hq-pro ACTION telemetry sink. Best-effort; never affects publish. */ telemetryClient?: CloudTelemetryClient | null; telemetryClaims?: TelemetryClaims | null; /** * Maximum paths remembered to suppress unchanged upsert announcements. * Applies to both the hot in-memory LRU and its durable counterpart. */ lastPublishedContentHashMaxPaths?: number; /** * Existing runner state directory for restart-durable publish suppression. * Omitted for lightweight callers and isolated unit tests. */ lastPublishedContentHashStateDir?: string; /** * Legacy eager seed for a brand-new durable state store. Kept for callers * outside the runner; runner code uses `publishedContentHashLookup` so it * never pins a full journal-sized object for a watch session. */ initialPublishedContentHashes?: Readonly>; /** * Lookup a journal-backed hash only after both bounded caches miss. Returning * `undefined` or throwing is fail-closed: the event is published normally. */ publishedContentHashLookup?: (relativePath: string) => string | undefined; /** * Minimum gap between successful upsert announcements for the same path. * Defaults to `HQ_SYNC_PUBLISH_MIN_INTERVAL_MS` (60 seconds when unset). */ publishMinIntervalMs?: number; } /** * Minimal structured logger surface for {@link PushEventEmitter}. A pino * `Logger` (from `./sync/logger.ts`) satisfies this; tests inject a fake. */ export interface EmitterLogger { info(obj: Record, msg?: string): void; } /** A path-safe per-attempt record carried by the one per-batch outcome line. */ export type PublishBatchAttempt = Partial & { sequenceNumber: number; }; /** Successful attempts retained in the batch outcome record for context. */ export declare const PUBLISH_OUTCOME_SUCCESS_SAMPLE_CAP = 3; /** * Conservative ceiling used by the large mixed-batch regression fixture. * Journald truncates records at 48 KiB, so the fixture must remain below it. */ export declare const PUBLISH_OUTCOME_JOURNALD_CEILING_BYTES: number; /** The authoritative denominator and attempt evidence for one settled tree batch. */ export interface PublishBatchOutcome { attempted: number; succeeded: number; failed: number; /** Full-set latency baseline for successful publishes. */ successfulElapsedMs: { min: number; median: number; max: number; } | null; /** Successful attempt records omitted from {@link attempts}; never silent. */ omitted: number; /** Every failed attempt plus at most {@link PUBLISH_OUTCOME_SUCCESS_SAMPLE_CAP} successes. */ attempts: PublishBatchAttempt[]; } /** * Reduces successful attempt evidence to a bounded sample while preserving all * failed attempts and the full successful latency distribution's key values. * Property order is intentional: aggregate denominators must survive a sink * that truncates the record before it reaches the attempt evidence. */ export declare function summarizePublishBatchOutcome(attempts: PublishBatchAttempt[]): PublishBatchOutcome; /** * Bridges {@link TreeWatcher} change batches to a {@link PushTransport} as * typed PushEvents. Construct once per daemon, then {@link attach} to a * running TreeWatcher (returns an unsubscribe fn). Flag-gated + failure-safe. */ export declare class PushEventEmitter { private static readonly MAX_CONCURRENT_PUBLISHES; private readonly originTenantId; private readonly originDeviceId; private readonly transport; private readonly flagProvider; private readonly now; private readonly clock; private readonly onError; private readonly onPublishOutcome; private readonly logger; private readonly telemetryClient; private readonly telemetryClaims; private readonly lastPublishedContentHashes; /** * Remote pull marks dominate an older local publish that is still in flight. * Bounded (shares the published-hash cap) so a long watch session cannot grow * this map without limit; values are drawn from * {@link publishedContentHashGenerationCounter}. */ private readonly publishedContentHashGenerations; /** Session-monotonic source of generation values; never resets, never reused. */ private publishedContentHashGenerationCounter; private readonly durablePublishedContentHashes; private readonly publishedContentHashLookup; private readonly lastPublishedContentHashMaxPaths; private readonly publishMinIntervalMs; /** * A captured event whose bytes were already uploaded by the scoped push that * called emitForBatch. Never retain only a path here: a later local rewrite * can otherwise make the trailing announcement describe bytes absent from S3. */ private readonly deferredPublishes; private readonly deferredPublishTimers; private disposed; private disposePromise; private _realtimeUnavailable; private readonly forbiddenRealtimeCompanyScopes; private internalSeq; private readonly nextSeq; private publishTail; constructor(opts: PushEventEmitterOptions); /** True iff event-driven push is enabled for this emitter's tenant. */ get enabled(): boolean; /** True after the server deliberately denies this account realtime publish. */ get realtimeUnavailable(): boolean; /** * Subscribe to a TreeWatcher's change batches. Dormant (no subscription) * when the flag is OFF for this tenant. Returns an unsubscribe fn (a no-op * when dormant). */ attach(watcher: TreeWatcher): () => void; /** * Build + ship one PushEvent per changed path in the batch. No-op when the * flag is OFF. Each path is independent: a hash/stat failure or a transport * publish rejection for one path is caught + surfaced via `onError` and does * NOT abort the others or propagate (the daemon must not crash; the cadence * poll covers any miss). */ emitForBatch(batch: TreeChangeBatch): Promise; /** * Record a remote upsert that has just been applied to the local tree. * * A pull writes the same bytes that the watcher will shortly observe. Treat * that write exactly like a successful local upsert publication so it cannot * be announced back to the remote as a new edit. Deliberately upsert-only: * a tombstone must never suppress a later recreation with the same bytes. */ markContentPublished(relativePath: string, contentHash: string): void; private emitEntries; private reportPublishOutcome; private emitOne; private publishEvent; private quiesceRealtimePublishing; private isRealtimeCompanyScopeForbidden; private forbidRealtimeCompanyScope; private openDurablePublishedContentHashes; private wasPublished; private setDurablePublishedContentHash; private deleteDurablePublishedContentHash; /** * True iff no mark of {@link relativePath} has happened at or after the * session-monotonic counter value the caller captured when it started. * Present generations compare directly; an ABSENT generation is treated as * unchanged only when NO evicted generation is newer than the captured * counter — so a path that was marked during flight and then evicted (its * entry collapsed to the absent sentinel) fails closed rather than * resurrecting a stale write over newer pull state. */ private publishedContentHashUnchangedSince; private isPublishedContentHashGenerationCurrent; /** Remember one latest-wins uploaded revision until its publish interval ends. */ private deferUpsert; private flushDeferredPublish; private clearDeferredPublish; /** Flush durable publication state, cancel trailing publishes, and become inert. */ dispose(): Promise; private waitForPublishDrain; private disposeDurablePublishedContentHashes; /** * Live entry counts of the long-lived per-path maps, for the runner heap * census. Fixed identifier keys, numeric values only — no path or content. */ censusSizes(): Record; private emitPublishFailureTelemetry; } //# sourceMappingURL=watcher.d.ts.map