#!/usr/bin/env node /** * hq-sync-runner — machine-targeted entrypoint for `@indigoai-us/hq-cloud` * (ADR-0001). * * The AppBar Sync menubar (Tauri + Rust) spawns this binary as a subprocess * and reads ndjson events from BOTH stdout and stderr (see "Channels" * below). The protocol is intentionally narrow and versioned-by-shape, not * by tooling — no chalk, no colors, no human prose. If you want to invoke * sync as a human, use `hq sync` in `@indigoai-us/hq-cli`. * * Flags: * --companies Fan out across every membership the caller has * --company Sync a single company (alternative to --companies) * --on-conflict abort | overwrite | keep | publish-local (default: abort) * --hq-root Local HQ directory (default: $HOME/hq) * --skip-personal Drop the personal target from the --companies * fanout. Combined with HQ_SYNC_SKIP_PERSONAL env * (either truthy disables personal sync). No-op * outside --companies mode. * --json Ignored — ndjson on stdout is the default and * only output mode. Accepted for symmetry with the * AppBar's argv in case someone passes it. * * Channels (one JSON object per line): * stdout — protocol stream: * setup-needed caller signed in but has no person entity yet * fanout-plan list of companies we're about to sync * progress per-file download * complete per-company summary * all-complete aggregate summary after fanout * manifest-upload per-scope sync-manifest audit outcome, AFTER * all-complete. None on older consumers. * stderr — diagnostic stream: * error per-file or per-company error * auth-error no valid token available (interactive login disabled) * * Why the split: error-class events go to stderr so the menubar's Sentry * breadcrumb pipeline picks them up automatically (see hq-sync * src-tauri/src/commands/sync.rs `ProcessEvent::Stderr` handler). The * single Sentry capture at runner-exit then ships one #hq-alerts issue * with the full per-file → company → exit error trail attached, instead * of requiring per-event capture calls in the menubar. * * Exit code: * 0 — event stream describes the outcome. The runner finished its protocol * without any company throwing. Includes setup-needed, auth-error, and * runs where every company completed OR cleanly returned `aborted: true` * (a `--on-conflict abort` policy decision is not an error). * 1 — argv parse error or unrecoverable pre-sync failure. * 2 — at least one company threw a deterministic, non-transport failure * mid-stream (e.g. mid-fanout 401, denied access, S3 5xx after retries). * The all-complete event carries * `partial: true` and per-company partial counts captured from * `progress` events before the throw, so consumers parsing ndjson see * what actually transferred. This is distinct from exit 0 with * `partial: true` (clean conflict-aborts) — exit 2 is "something * unexpected happened", which the Tauri menubar converts to a Sentry * alert. Conflict-aborts intentionally do NOT alert. */ import { type VaultServiceConfig, type Membership, type EntityInfo, type PendingInviteByEmail, type MembershipSyncConfig, type ExplicitGrant } from "../index.js"; import { resolvePullScope, readPinnedPrefixes, SESSIONS_SCOPE_PREFIX, type PullScope } from "../sync/pull-scope.js"; import { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths } from "../personal-vault.js"; import type { SyncOptions, SyncResult, SyncProgressEvent } from "../cli/sync.js"; import type { ShareOptions, ShareResult } from "../cli/share.js"; import { reindexAfterSync } from "../qmd-reindex.js"; import type { ReindexOptions as QmdReindexOptions } from "../qmd-reindex.js"; import type { ReindexOptions, ReindexResult } from "../cli/reindex.js"; import type { TelemetryEventsBatch, TelemetryEventsIngestResult } from "../vault-client.js"; import { type PostSyncFlagEvaluator } from "./sync-runner-post-sync-flags.js"; import { type ManifestUploadEventPayload, type RunPostSyncManifestUploadsOptions } from "./sync-runner-manifest.js"; import type { Clock, LocalDeleteSnapshot, TreeChangeBatch, WatchCoverageProbeResult } from "../watcher.js"; import type { PushReceiver, SyncBatchEngineFn } from "../sync/push-receiver.js"; import { type EventSyncHandles, type StartEventSyncOptions } from "../sync/event-sync.js"; import { migratePersonalVaultJournal, type JournalMaintenanceOptions, type JournalMaintenanceResult } from "../journal.js"; import { type RunnerTarget, type SetupNeededReason } from "./sync-runner-planning.js"; export { resolveSkipCompanies } from "./sync-runner-planning.js"; import { type SeedActiveCompanyOptions, type SeedActiveCompanyResult } from "../active-company.js"; import { type ManifestReconcileOptions, type ManifestReconcileResult } from "../manifest-reconcile.js"; import type { GcObserver, HeapReaders, IntervalScheduler } from "./sync-runner-heap-telemetry.js"; import { type AuthWaitEscalation } from "./sync-runner-auth-wait.js"; export { buildTargetedPullArgv, buildTargetedPushArgv, routeChangeToTarget, } from "./sync-runner-watch-routes.js"; /** * Sync direction for a run. * * - `pull`: download-only (legacy `hq sync` behaviour, and the default for * back-compat with pre-5.1.11 callers of the runner). * - `push`: upload-only. Walks the company folder and sends every file whose * local hash differs from the journal (skipUnchanged). * - `both`: push first, then pull. "Sync Now" in the menubar app targets this. * Push runs first so the subsequent pull doesn't redownload files we were * about to replace; if a company aborts on push conflict, pull is skipped * for that company but the fanout continues. */ export type Direction = "pull" | "push" | "both"; /** * A durable full-pass boundary that may hand the root lock to realtime work. * `true` means realtime ran and a pass-local continuation must re-read durable * state before it resumes; older checkpoints that return nothing stay valid. */ export type CooperativePassCheckpoint = () => Promise; /** * Delete-propagation policy honored by the push leg of bidirectional sync. * * Default `"currency-gated"` in 5.25 — flipped from `"owned-only"` after * one machine (Indigo / corey) ran the 5.24 code path through real syncs * for a week without surfacing surprise behavior. Currency-gated does a * per-file ETag HEAD before propagating any local-delete to S3: if the * remote object's current ETag no longer matches the journal's last- * recorded one, the delete is refused and the next pull leg re-pulls the * file via the standard 3-way merge path. This is strictly safer than * `owned-only` (which propagates any local-delete the journal can prove * came from this device) — the only delete-class that changes behavior * is "deleted locally + modified remotely by another device", which * previously destroyed remote work and now becomes a pull-and-conflict. * * Env override `HQ_SYNC_DELETE_POLICY=owned-only|all|currency-gated` is * also the rollback knob — anyone surprised by 5.25's flip can revert * to `owned-only` without redeploying. `all` is the unsafe-mirror mode * previously used by the runner pre-5.20 — included only as an * emergency reconcile lever, not a recommended default. */ export type DeletePropagationPolicy = "currency-gated" | "owned-only" | "all"; export declare function resolveDeletePolicy(): DeletePropagationPolicy; /** * Resolve whether to skip the personal target in a `--companies` fanout. * * Two inputs combine: the `--skip-personal` CLI flag (parsed into * `ParsedArgs.skipPersonal`) and the `HQ_SYNC_SKIP_PERSONAL` env var. Either * being truthy skips the personal target — flag wins on conflict (CLI * flag is the explicit-for-this-invocation knob, env is the persistent * default usually set by the menubar in the spawned child process). * * Env truthy values: `1`, `true`, `yes` (case-insensitive). Anything else * (including missing) is treated as falsy — same shape as classic * Unix opt-in env conventions; conservative to avoid surprising opt-outs. * * Use case: the menubar app exposes a "Sync personal vault" toggle in * Settings (default ON, matching the auto-provisioning UX). When the user * flips it off, the menubar spawns `hq sync` with this env set so the * fanout drops the personal target before walking the user's entire HQ * tree (a sync that would otherwise scan thousands of files, including * the new personal-vault default exclusions, just to do nothing useful). */ export declare function resolveSkipPersonal(flag: boolean): boolean; export { selectObjectIOFactory } from "../company-vault-transport.js"; export { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths }; /** * Every event the runner emits. Channel routing (stdout vs stderr) is * decided inside `runRunner`'s `emit` helper based on the event's `type` * — see the doc-block on the file header for the split. * * The `company` field is present on every event except `setup-needed` / * `auth-error` / `fanout-plan` / `all-complete` (which describe the whole * run) — consumers should treat its absence as "meta-event, not tied to a * specific company". */ export type RunnerEvent = { /** * The run cannot proceed. `reason` and `pendingInviteCount` are additive * and optional by design: a consumer that only reads `type` behaves * exactly as before, while /setup and /hq-sync can tell the user WHY they * look solo — most usefully, that an invite is still waiting. */ type: "setup-needed"; reason?: SetupNeededReason; pendingInviteCount?: number; } | { type: "auth-error"; message: string; } /** * One-per-run stderr diagnostic stating the effective bandwidth-governor * policy (HQ_SYNC_MAX_BYTES_PER_SEC / HQ_SYNC_BANDWIDTH_PERCENT / * HQ_SYNC_MAX_CONCURRENCY). Emitted only when a policy is configured, so * consumers that predate it never see it. */ | { type: "bandwidth-policy"; message: string; } | { /** * The resolved fanout plan. Carries whole `RunnerTarget`s: the runtime * payload has always been the plan entries verbatim, so the previous * `{uid, slug, name?}` declaration understated the wire contract (it * already omitted `bucketName`, `personalMode`, and `journalSlug`). * Consumers deserialize leniently and ignore fields they do not know. */ type: "fanout-plan"; companies: RunnerTarget[]; } | { /** * A company was deliberately omitted by the desktop's per-workspace * Off toggle. This is an observable policy outcome, not a sync failure. */ type: "company-skipped"; company: string; reason: "skip-companies"; } | ({ /** * Stage-1 results for a single company's sync/share pass. Emitted once * before any `progress` events for that company arrive — once for the * pull phase (download counts) and once for the push phase (upload * counts) when `--direction both`. Consumers (the menubar) sum the * non-zero fields across all `plan` events seen for a fanout to render * an accurate "X of Y files" denominator before transfers begin. */ type: "plan"; company: string; } & Omit, "type">) | ({ type: "progress"; company: string; } & Omit, "type">) | { /** Local maintenance or long-running sync heartbeat; never a file transfer. */ type: "maintenance-progress"; company: string; bytesProcessed: number; totalBytes: number; } | ({ type: "error"; company?: string; /** * Informational stderr breadcrumb, not a transfer failure. The menubar * and exit-code rollup must ignore these. Used for personal-vault * company-folder exclusions and for a vault rate-limit (or other * retryable HTTP status) on an upload, download, delete, or tombstone * HEAD verify — the key is left for the next sync. */ diagnostic?: true; } & Omit, "type">) | { /** * A per-company transport failure that is retryable, not a deterministic * sync error. This deliberately stays off the `error` channel: exit 2 * means a real partial sync failure, while this pass returns EX_TEMPFAIL * (75) and is retried by the watch loop. Mirrors the non-error treatment * of deliberate skip events. */ type: "transient-network"; company: string; path: "(company)"; message: string; } | ({ type: "conflict"; company: string; } & Omit, "type">) | { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null; }>; } | { type: "scope-excluded"; company: string; count: number; samplePaths: string[]; } | { type: "personal-vault-journal-purge-skipped"; company: string; attempted: number; totalRows: number; } | { type: "personal-vault-journal-pruned"; company: string; count: number; byReason: Record; samplePaths: string[]; } | ({ type: "scope-materialization-gap"; company: string; } & Omit, "type">) | { type: "ignore-excluded"; company: string; count: number; totalExcluded: number; samplePaths: string[]; } | ({ type: "skip-invalid-scoped-key"; company: string; } & Omit, "type">) | ({ type: "skip-junk-key-spelling"; company: string; } & Omit, "type">) | ({ type: "not-shipped"; company: string; } & Omit, "type">) | ({ type: "push-refused-server-owned"; company: string; } & Omit, "type">) | ({ type: "push-refused-server-owned-summary"; company: string; } & Omit, "type">) | ({ type: "skip-symlink-privilege"; company: string; } & Omit, "type">) | ({ type: "skip-rename-blocked"; company: string; } & Omit, "type">) | ({ type: "skip-vanished-remote"; company: string; } & Omit, "type">) | ({ type: "skip-archived-session-log"; company: string; } & Omit, "type">) | ({ type: "complete"; company: string; /** * Upload counters. Always emitted (0 when the run was pull-only) so * downstream consumers don't need to conditionally read the field. */ filesUploaded: number; bytesUploaded: number; /** * Push-side counters added in 5.25. Always emitted as numbers (0 * when no push leg ran). Tauri's `SyncCompleteEvent` carries them * as Option for back-compat with <5.25 engines that don't * include them; structural-typing-wise, the union just adds * properties on top of `SyncResult`. */ filesTombstoned: number; filesRefusedStale: number; /** * Paths corresponding to `filesRefusedStale`, capped at 50 (mirrors * `newFiles` cap). Surfaced on the `complete` event so operators * can triage the recurring `filesRefusedStale: 205` signal that * the 5.33.0 deep-test flagged as untriageable — the count alone * is impossible to investigate after the per-file * `delete-refused-stale-etag` events scroll off. */ filesRefusedStalePaths: string[]; filesExcludedByPolicy: number; capThrottledMs?: number; peakHeapUsedMb?: number; } & SyncResult) | { type: "all-complete"; companiesAttempted: number; filesDownloaded: number; bytesDownloaded: number; /** Always emitted; 0 when no push phase ran. */ filesUploaded: number; bytesUploaded: number; /** * Conflict file paths aggregated across every company in the run. * Always emitted; empty array when no conflicts were detected. Lets * the menubar UI render a flat list without re-walking per-company * `complete` events. */ conflictPaths: Array<{ company: string; path: string; direction: "pull" | "push"; }>; errors: Array<{ company: string; message: string; }>; /** * Retryable company-leg transport failures. Kept separate from `errors` * so consumers can surface an incomplete pass without calling it a * deterministic sync error. Always present for backward-compatible, * additive protocol evolution. */ transient: Array<{ company: string; message: string; }>; /** * True when at least one company in the fanout did not complete cleanly * — either it returned `aborted: true` (e.g. conflict-abort) or its sync * function threw mid-stream (e.g. mid-fanout 401). When `partial: true`, * the totals above include partial counts captured from `progress` events * before the abort, NOT just companies that emitted a clean `complete`. * * Automated monitors should check this field — `errors.length > 0` alone * isn't sufficient because a `aborted: true` return doesn't push to * `errors` (it's a clean conflict-abort, not an exception). */ partial: boolean; /** * Per-company breakdown of the fanout. Always present, one entry per * planned company, in fanout order. Lets consumers reconcile per-company * partial counts with the aggregate without re-walking `complete` / * `error` event streams. The `status` field is the canonical signal: * - "complete" — sync returned cleanly, `aborted: false` * - "aborted" — sync returned cleanly with `aborted: true` (conflict-abort) * - "errored" — sync threw mid-stream; counts are sourced from progress * events seen before the throw * - "transient-network" — a retryable transport failure interrupted the * leg; see `transient` for its diagnostic */ companies: Array<{ company: string; status: "complete" | "aborted" | "errored" | "transient-network"; filesDownloaded: number; bytesDownloaded: number; filesUploaded: number; bytesUploaded: number; }>; } | { /** * Emitted at most ONCE per fanout, AFTER `all-complete`, when the * post-sync conflict-ledger prune leaves one or more PRESERVED conflict * variants that a human still has to resolve (`kept > 0`). The prune * self-heals the ledger (drops orphaned + byte-identical false-positive * rows), but genuine divergences are conservatively KEPT — and pre-fix * that residual set was silent, so the reporter of feedback_d2082110 * finished a full sync with "20 older preserved conflict entries" still * on disk and no signal they were there. This event reconciles and * surfaces the remaining count so the operator knows to run * `/resolve-conflicts`. Ledger-global (not per-company — the index lives * at the HQ root). Not emitted when the ledger is clean (`kept === 0`). * * `count` is the number of preserved conflict rows still awaiting * resolution; `samplePaths` carries up to 10 original (non-mirror) paths * for display. */ type: "conflicts-remaining"; count: number; samplePaths: string[]; } | ({ /** * Sync-reconciliation audit (US-004): the outcome of ONE scope's * manifest upload pass, emitted at most once per scope, AFTER * `all-complete`, and only for a scope whose sync leg finished cleanly. * * Diagnostic-only and strictly additive: it is never an `error`, it must * never route through the menubar's `is_alertable_error` classifier, and * deployed desktop builds that predate it ignore the unknown type (every * parse site is `if let Ok(...)`). None on older consumers. * * `status` is the pass verdict, not a sync verdict — `throttled` * (the 24h per-scope spacing), `disabled` (kill switch) and * `soft_skipped` (server has no manifest endpoint yet) are all normal * steady-state answers, and even `failed` means only that this audit * sample was lost, never that the sync failed. * * Every field is numeric or a fixed token (company slug included) — * never a path, filename, or error message. */ type: "manifest-upload"; } & ManifestUploadEventPayload) | { /** * Phase-level diagnostic from the post-sync manifest tail step. * * This carries the reasons a pass never ran at all — no enrolled * client-health installation, an identity lookup that threw, a phase that * threw — which are NOT per-scope outcomes and so have no * `manifest-upload` event to ride on. * * It is deliberately its OWN type and NOT an `error`. The tail step is a * best-effort audit that runs after `all-complete`; routing its * housekeeping through the runner's `error` channel put a `type:"error"` * line on stderr on every host without a menubar heartbeat, which the * menubar feeds to Sentry and which made "the sync emitted an error" * true for a completely clean sync. Containment is the contract: the tail * may describe itself, and may never speak for the sync. * * Additive: deployed desktop builds that predate it ignore the unknown * type (every parse site is `if let Ok(...)`). */ type: "manifest-upload-diagnostic"; /** Fixed event token, e.g. `runner.manifest_upload.not_enrolled`. */ event: string; /** Human-readable reason. Never a path, filename, or company. */ message: string; } | { /** * Diagnostic-only heap-pressure crossing. Emitted at most once per rising * crossing of the sampler's configured fraction of V8's heap-size limit, * carrying the census sites ranked by growth since the watch session's * first sample. Deliberately NOT an `error`: it must never route through * the menubar's `is_alertable_error` classifier or raise a user-facing * sync failure. It is additive — deployed desktop builds that predate it * ignore the unknown type (every parse site is `if let Ok(...)`). * * Every field is numeric except the fixed census-site identifier tokens * (never a path, filename, company, or message). */ type: "heap-pressure"; usedHeapBytes: number; heapLimitBytes: number; /** min(declared --max-old-space-size, reported limit); additive. */ effectiveLimitBytes?: number; /** Declared --max-old-space-size in bytes, or 0 when none; additive. */ declaredCeilingBytes?: number; usedFraction: number; thresholdFraction: number; rssBytes: number; externalBytes: number; uptimeMs: number; topSites: Array<{ site: string; count: number; growth: number; }>; } | { /** * The self-recycling governor's verdict. The runner is exiting cleanly * (code 1, no signal) to let the desktop supervisor respawn it BEFORE V8 * aborts. `trigger` names which signal fired: `"live"` is the primary * post-GC live path (the live fraction held across the sustain window past * the uptime floor); `"emergency"` is the instantaneous abort-adjacent * backstop; `"footprint"` is the resident-footprint tier (an absolute rss * budget, for a non-heap burst the old-space fractions cannot see). Like * `heap-pressure` this is additive and deliberately NOT an * `error`: it must never route through the menubar's `is_alertable_error` * classifier, and deployed desktop builds that predate it ignore the * unknown type (`if let Ok(...)`). * * Every field is numeric except `trigger` (a fixed token) and the fixed * census-site identifier tokens (never a path, filename, company, message). */ type: "heap-recycle"; /** * The detection tier. `"pinned"` and `"footprint"` are additive (an * instantaneous heap tier for the starved synchronous stretch, and the * resident-footprint rss-budget tier respectively); deployed desktop builds * that predate them treat the token like any other benign recycle token. */ trigger: "live" | "pinned" | "emergency" | "footprint"; /** * How the verdict was actuated — additive so predates-it desktop builds * ignore it. `"cooperative"` is honoured at a pass boundary; `"latch_timeout"` * and `"hard_fraction"` are the bounded hard-exit backstops. Absent means * cooperative. */ reason?: "cooperative" | "latch_timeout" | "hard_fraction"; usedHeapBytes: number; liveHeapBytes: number; heapLimitBytes: number; /** min(declared --max-old-space-size, reported limit); additive. */ effectiveLimitBytes?: number; /** Declared --max-old-space-size in bytes, or 0 when none; additive. */ declaredCeilingBytes?: number; usedFraction: number; liveFraction: number; recycleLiveFraction: number; /** The configured pinned-tier floor; additive. */ pinnedFraction?: number; emergencyFraction: number; /** The configured hard-exit fraction; additive. */ hardExitFraction?: number; /** * The configured resident-footprint cooperative recycle budget in bytes; * additive, `0` when the footprint tier is disabled. Present on a * `"footprint"` trigger and carried on every recycle for provenance. */ footprintRecycleBytes?: number; /** The configured resident-footprint hard-exit budget in bytes; additive, 0 when off. */ footprintHardExitBytes?: number; sustainWindowMs: number; observedSustainMs: number; minUptimeMs: number; /** Observations since the cooperative latch armed; additive (`latch_timeout`). */ postLatchObservations?: number; uptimeMs: number; rssBytes: number; externalBytes: number; arrayBuffersBytes: number; rssGrowthBytes: number; externalGrowthBytes: number; arrayBuffersGrowthBytes: number; censusTotal: number; topSites: Array<{ site: string; count: number; growth: number; }>; }; /** * The narrow VaultClient surface the runner actually uses. Declared here (not * `Pick`) because `Pick` preserves the *entire* `entity` * accessor object — but the runner needs only `entity.get`, * `entity.findInMyNamespace`, and `entity.listByType`; forcing test stubs to * also implement `findBySlug`/`create` would be dishonest about the real * dependency. Keep this interface in sync with the real VaultClient method * signatures (both return types come straight from the SDK). */ export interface VaultClientSurface { listMyMemberships: () => Promise; listMyPendingInvitesByEmail: () => Promise; claimPendingInvitesByEmail: (personUid: string) => Promise; ensureMyPersonEntity: (hints: { ownerSub: string; displayName: string; }) => Promise; entity: { get: (uid: string) => Promise; /** Caller-namespace-only slug lookup for routed --company values. */ findInMyNamespace?: (type: string, slug: string) => Promise; listByType: (type: string) => Promise; }; getMembershipSyncConfig?: (membershipId: string) => Promise; listMyExplicitGrants?: (companyUid: string) => Promise; postTelemetryEvents?: (batch: TelemetryEventsBatch, options?: { timeoutMs?: number; }) => Promise; } interface RunnerDiagnostic { component: string; event: string; message: string; err?: unknown; context?: Record; } type RunnerDiagnosticReporter = (diagnostic: RunnerDiagnostic) => void; export { resolvePullScope, readPinnedPrefixes, SESSIONS_SCOPE_PREFIX }; export type { PullScope }; /** * Internal-only result used between a watch pass and its owning loop. The * process does not exit: `runRunnerWithLoop` parks in auth-wait, rechecks * the session, and restarts the watch startup sequence in-process. One-shot * callers keep `authRequiredExitCode ?? 0` so desktop/manual consumers still * treat auth-required as a handled UX state rather than a crash. */ export declare const AUTH_REQUIRED_PASS_EXIT = 18; /** Minimal shape of the claims we read off the Cognito idToken. */ interface IdTokenClaims { sub?: string; email?: string; name?: string; given_name?: string; family_name?: string; /** * Entity-bound machine-principal claims. A headless agent or Outpost * authenticates with machine credentials; its idToken carries the machine's * own entity binding here. The runner preserves those authenticated claims * during planning. Agent claims additionally drive `--personal` target * resolution to the agent's OWN entity (`custom:entityUid`, `agt_*`) instead * of the person-only canonical pick. */ "custom:entityType"?: string; "custom:entityUid"?: string; } export interface RunnerDeps { /** Where to write ndjson events. Defaults to `process.stdout`. */ stdout?: { write: (chunk: string) => boolean | void; }; /** Where to write diagnostics. Defaults to `process.stderr`. */ stderr?: { write: (chunk: string) => boolean | void; }; /** Resolve a valid access token. Defaults to `getValidAccessToken` non-interactive. */ getAccessToken?: () => Promise; /** Invalidate only the rejected token generation. Injectable for tests. */ clearSession?: (tokenFingerprint?: string) => void; /** * Read the caller's identity claims (sub/email/name) off the cached Cognito * idToken. Defaults to decoding `loadCachedTokens().idToken`. Returns `null` * when no cached tokens exist — the runner will then skip the claim-dance * and fall through to the usual listMyMemberships path. */ getIdTokenClaims?: () => IdTokenClaims | null; /** * Produce a VaultClient-like object. Defaults to `new VaultClient(config)`. * Tests inject a stub here — the runner only calls the methods listed in * `VaultClientSurface`. */ createVaultClient?: (config: VaultServiceConfig) => VaultClientSurface; /** Sync function. Defaults to `cli/sync.sync`. */ sync?: (options: SyncOptions) => Promise; /** Test seam for the mandatory FILE_TOMBSTONE authority read. */ fetchFileTombstones?: typeof import("../cli/tombstones.js").fetchFileTombstones; /** Native post-fanout reindex function. Defaults to `cli/reindex.reindex`. */ reindex?: (options: ReindexOptions) => ReindexResult; /** QMD post-sync reindex function. Defaults to `qmd-reindex.reindexAfterSync`. */ qmdReindex?: (hqRoot: string, options: QmdReindexOptions) => ReturnType; /** * Shared registry evaluator for the three post-sync tail gates. Production * creates one after it has an auth-token getter; tests provide fixed states. */ postSyncFlags?: PostSyncFlagEvaluator; /** * Merge successfully pulled cloud companies into the local manifest after * the complete fanout. Injectable so runner tests do not touch a developer's * HQ root. */ reconcileManifest?: (options: ManifestReconcileOptions) => Promise; /** Internal test seam for `.hq/config.json` activeCompany seeding. */ seedActiveCompany?: (options: SeedActiveCompanyOptions) => SeedActiveCompanyResult; /** Internal: set when runRunner is invoked under the per-root operation lock. */ operationLockAlreadyHeld?: boolean; /** Internal watch-loop result override; one-shot callers keep exit 0. */ authRequiredExitCode?: number; /** Internal watch-loop callback carrying uncapped per-path push outcomes. */ onPassResult?: (result: RunnerPassResult) => void; /** Internal watch-loop liveness cadence; omitted for one-shot callers. */ fanoutHeartbeatIntervalMs?: number; /** * Internal one-shot readiness signal for watch mode. It fires after the * required journal repair and personal-vault seed complete, before fanout * can begin durable work. The watch loop uses it to arm event ingress while * preserving the guarded scheduler as the only writer admission path. */ onJournalsReady?: () => void; /** * Internal watch-loop suppression seam. Reports durable pull writes as each * target leg settles, before a cooperative checkpoint can admit watcher * work that would otherwise echo the initial pull. */ onAppliedUpserts?: (upserts: ReadonlyArray<{ relativePath: string; contentHash: string; }>) => void; /** * Internal watch-loop hand-off seam. It is called only after a complete * target leg has durably settled, never while a journal mutation is active. */ cooperativeCheckpoint?: CooperativePassCheckpoint; /** Automatic pre-fanout journal repair. Injectable for runner tests. */ repairJournalStateIfNeeded?: (slug: string, options?: JournalMaintenanceOptions) => JournalMaintenanceResult; /** One-time legacy personal journal seed. Injectable to prove repair deferral. */ migratePersonalVaultJournal?: typeof migratePersonalVaultJournal; /** Share function (push phase). Defaults to `cli/share.share`. */ share?: (options: ShareOptions) => Promise; /** * Telemetry collector — runs just before the `all-complete` emit. Default * implementation calls `collectAndSendTelemetry` from `../telemetry.js` * using the real VaultClient; tests that inject `createVaultClient` are * implicitly opted out (the default skips when the client isn't a real * `VaultClient`). Tests that want to assert telemetry behavior should pass * an explicit stub here. */ collectTelemetry?: () => Promise; /** Maximum time to wait for pre-completion telemetry. Defaults to 120s. */ telemetryTimeoutMs?: number; /** * Post-sync manifest upload tail step (US-004). Defaults to * {@link runPostSyncManifestUploads}. Injected by tests so a runner unit * test never walks a tree or opens a socket for the audit. */ runManifestUploads?: (options: RunPostSyncManifestUploadsOptions) => Promise; } export interface RunnerPassResult { pushPathResults: Array<{ relativePath: string; status: "accepted" | "refused"; operation: "delete" | "tombstone"; reason?: string; }>; /** Company legs that threw before finishing their push/pull work. */ companyFailures?: Array<{ company: string; message: string; }>; /** Remote upserts applied by this pass, normalized for the live watcher. */ appliedUpserts?: Array<{ relativePath: string; contentHash: string; }>; } export interface RunnerPassOutcome { exitCode: number; result?: RunnerPassResult; } export declare function defaultCollectTelemetry(client: VaultClientSurface, clientIsStub: boolean, hqRoot: string, reportDiagnostic?: RunnerDiagnosticReporter, fallbackCompany?: string, agentSelfUid?: string): Promise; export declare function runRunner(argv: string[], deps?: RunnerDeps): Promise; /** * Test/event-driven seam (US-001). * * `runRunnerWithLoop` performs an unbounded cadence loop in production. The * initial pass is immediate; on-time passes retain their monotonic deadlines, * while an overrun pass restarts its cooldown from completion. Watcher and * receiver signals interrupt the wait but still use the same guarded pass * queue. To test it deterministically, both waiting and monotonic time are * injectable. * * A test (or US-003's wiring) injects a fake sleep that resolves immediately * and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`, * so the loop can be exercised without a real 10-minute wait. */ export interface RunnerLoopDeps { /** Sleep `ms` between passes. Default: host setTimeout. */ sleep?: (ms: number) => Promise; /** Test seam for full-pass CPU/heap caps. Production builds a default controller. */ fullPassCaps?: import("../sync/full-pass-caps.js").FullPassCapController; /** Where watch-idle heartbeat events are written. Defaults to process.stdout. */ stdout?: { write: (chunk: string) => boolean | void; }; /** Where auth-wait operational diagnostics are written. Defaults to process.stderr. */ stderr?: { write: (chunk: string) => boolean | void; }; /** Test seam for the watch-idle heartbeat cadence. Default: 30 seconds. */ idleHeartbeatIntervalMs?: number; /** * Monotonic milliseconds source for cadence deadlines. Defaults to * `performance.now()`; tests inject it to prove missed-deadline behavior * without depending on wall-clock time. */ monotonicNow?: () => number; /** * 1-minute whole-box load average source for the adaptive remote-poll * backoff (used only when `--poll-remote-ms` is omitted). Defaults to * `os.loadavg()[0]`. Tests inject a fixed sample to assert the interval * scales with load without depending on the host's real load. */ sampleLoadAvg?: () => number; /** * Run a single sync pass. Defaults to {@link runRunner}. Injected by tests * (and the event-push wiring) so the poll loop and the watcher-triggered * targeted push share one seam and one in-flight guard. The default ignores * `deps` and forwards just the argv to `runRunner`. */ runPass?: (passArgv: string[], checkpoint?: CooperativePassCheckpoint, onJournalsReady?: () => void, onAppliedUpserts?: (upserts: ReadonlyArray<{ relativePath: string; contentHash: string; }>) => void) => Promise; /** * Opt an injected pass into the durable hand-off callback. The production * runner always supports it; test seams opt in only when they model a safe * checkpoint, keeping legacy one-argument spies observably unchanged. */ runPassSupportsCooperativeCheckpoint?: boolean; /** Stage-0 backup retention backstop: a dry-run only, never a sync-tick action. */ runBackupPrune?: (hqRoot: string) => Promise; /** * Clock seam for the event-push watcher's debounce window. Defaults to * {@link systemClock}; tests inject a `FakeClock` to advance the window * deterministically. Only consulted when `--event-push` is on. */ clock?: Clock; /** * Factory for the file watcher used in event-push mode. Defaults to a real * {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same * `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs. */ createWatcher?: (opts: { hqRoot: string; debounceMs: number; /** Short quiet window for a fully-known small watcher batch. */ smallBatchDebounceMs: number; smallBatchMaxPaths: number; smallBatchMaxBytes: number; /** Max-wait ceiling for a continuous event stream — see TreeWatcherOptions.maxWaitMs. */ maxWaitMs: number; clock: Clock; /** Apply personal-vault exclusions outside of included company trees. */ personalMode: boolean; /** Keep company trees in scope for --companies / --company fanout. */ includeCompanyPaths: boolean; /** Called when the watcher disables itself after startup. */ onDegraded?: (info: { reason: string; watchedPaths: number; maxWatchedPaths: number; action?: "polling_fallback" | "stopped"; }) => void; captureLocalDeleteSnapshots: (relativePath: string, kind: "unlink" | "unlinkDir") => LocalDeleteSnapshot[]; /** Queue a yielding durable capture; the scoped drain awaits it before use. */ queueLocalDeleteSnapshots: (relativePath: string, kind: "unlink" | "unlinkDir", isCurrent: () => boolean) => Promise; /** * Synchronous, non-blocking "this vanished file has no journal row" check. * See TreeWatcherOptions.isUntrackedDelete. */ isUntrackedDelete: (relativePath: string) => boolean; }) => WatcherSurface; /** * Register a one-shot shutdown signal handler. Defaults to listening for * SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert * clean teardown without sending real signals. The returned fn detaches the * handler (called during teardown so tests don't leak listeners). */ onShutdownSignal?: (handler: () => void) => () => void; /** * Factory for the Phase 2 pull-on-event receiver (US-009). Defaults to a * {@link NoopPushReceiver} — the daemon ships the receiver SEAM wired into * the lifecycle (start after the watcher, dispose before exit) but stays * DORMANT by default: the per-client SQS queue is provisioned server-side * (an unbuilt follow-up) and the receiver is feature-flag gated. A future * menubar/CLI release injects an {@link SqsPushReceiver} here once a queue * URL is available. Only consulted when `--event-push` is on. * * The factory is handed a {@link SyncBatchEngineFn} that bridges a received * batch to a TARGETED pull pass (`--company --direction pull`, * or a personal `--companies --direction pull`) routed by the event's * `relativePath`, funneled through the same in-flight guard as the poll * loop and the watcher push so a pull-on-event never overlaps an in-flight * pass. */ createReceiver?: (opts: { syncBatchFn: SyncBatchEngineFn; hqRoot: string; /** Reports receive activity so the watch cadence can return to backoff after an empty receive. */ onReceiveActivity: (hasMessages: boolean) => void; }) => PushReceiver; /** * Phase 3 (US-017/US-018/US-019): factory for the event-driven publish + * pull wiring, consulted only when `--event-push` is on AND the rollout * gate ({@link resolveEventSync}) passes for the signed-in account. * Defaults to the real {@link defaultStartEventSync}. Tests inject a stub * to assert gate behavior without network/AWS. */ startEventSync?: (opts: StartEventSyncOptions) => Promise; /** * Dormant V2 scheduler factory. It receives the same guarded queue used by * polling and watcher work; its drain must never invoke the legacy runner. * Server-issued inventory decides whether a caller supplies this factory. */ startRealtimeScheduler?: (opts: { hqRoot: string; runGuarded: (drain: () => Promise) => Promise; }) => Promise; /** * Identity-claims source for the Phase 3 rollout gate (the loop has no * RunnerDeps; mirror of RunnerDeps.getIdTokenClaims). Defaults to reading * the cached Cognito idToken. */ getIdTokenClaims?: () => IdTokenClaims | null; /** * Access-token source for the Phase 3 vault API calls (publish transport + * subscribe). Defaults to {@link getValidAccessToken} non-interactive. * Watch-mode auth-wait also uses this as the only periodic recheck. */ getAccessToken?: () => Promise; /** * First auth-wait recheck delay. Defaults to * {@link AUTH_WAIT_INITIAL_RECHECK_MS} (60s). Tests inject a compressed * cadence so they do not wait a real minute. */ authWaitInitialRecheckMs?: number; /** * Steady auth-wait recheck delay after the first probe. Defaults to * {@link AUTH_WAIT_STEADY_RECHECK_MS} (5 minutes). */ authWaitSteadyRecheckMs?: number; /** * Minimum gap between auth-wait liveness lines. Defaults to * {@link AUTH_WAIT_LIVENESS_INTERVAL_MS} (30 minutes). */ authWaitLivenessIntervalMs?: number; /** Auth-wait diagnostic sink. Defaults to one stderr line per message. */ authWaitLog?: (line: string) => void; /** * Auth-wait escalation sink. Defaults to the structured runner diagnostic * reporter so the existing sync-supervisor error-tracker path receives it. */ authWaitReporter?: (escalation: AuthWaitEscalation) => void; /** Clock for auth-wait liveness. Defaults to Date.now. */ authWaitNow?: () => number; /** * Heap readers for the telemetry sampler and self-recycling governor. * Defaults to the real `node:v8`/`process`/`performance` readers. Tests * inject a deterministic rising reader (with its own monotonic clock) to * drive the governor to a recycle without real allocation or heap growth. * When supplied, its `monotonicNowMs` also becomes the sampler's session * start, so uptime is measured on the injected clock. */ heapReaders?: HeapReaders; /** * Interval scheduler for the heap sampler. Defaults to real `setInterval` * (unref'd). Tests inject a manual scheduler so sampler ticks — and the * recycle verdict they can produce — are driven deterministically with no * real timer. */ heapSamplingScheduler?: IntervalScheduler; /** * Major-GC observer that drives the governor's PRIMARY post-GC live-heap * path. Defaults to the real `perf_hooks` observer. Tests inject a manual * observer they fire by hand, so the live recycle verdict is driven * deterministically with no real GC. */ heapGcObserver?: GcObserver; } export interface RealtimeSchedulerHandle { /** A V2 signal only sets bounded V2 work; it carries no legacy fallback. */ signal(source: "watcher" | "wake" | "high-water" | "reconnect" | "retry"): void; /** The runner calls this on its five-minute monotonic backstop cadence. */ checkHighWater(): Promise; /** Identity/runner shutdown must dispose receiver and positive authority. */ dispose(): Promise; } /** * The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies * it; tests inject a lighter stub. Kept narrow so the loop never reaches past * the lifecycle + change-subscription contract. * * `onChange`'s listener receives an OPTIONAL changed relative path. The real * {@link TreeWatcher} emits a bare debounced signal (no path) — in that case * the loop routes the targeted push to the personal vault. A path-aware * watcher (or a test stub) can pass the changed `companies//...` * relative path so the loop targets just that company. */ export interface WatcherSurface { onChange(listener: (changedRelPath?: string, batch?: TreeChangeBatch) => void): () => void; start(): void; stop(): void; dispose(): void; /** Whether the backend is currently attached, when the implementation can report it. */ isWatching?(): boolean; /** Number of directory paths admitted by the active backend, when known. */ watchedPathCount?(): number; /** Active backend mode, including chokidar's polling fallback, when known. */ watchMode?(): "native" | "chokidar" | "polling" | "inactive" | "unknown"; /** Live entry counts of the watcher's long-lived per-path maps, for heap census. */ censusSizes?(): Record; /** File deletes the watcher discarded because the path had no journal row. */ untrackedDeletesDropped?(): number; /** Called after asynchronous initial path discovery, when the implementation supports it. */ onReady?(listener: () => void): () => void; /** Compare live watch identities against the HQ root currently on disk. */ probeCoverage?(): WatchCoverageProbeResult; /** Drop stale OS handles and re-open watches. Does not emit deletes. */ rebuildCoverage?(): void; /** * One post-discovery recovery scan for writes invisible during `ignoreInitial`. * The returned batch uses the same normal scoped-drain path as live events. */ collectWarmupCatchup?(sinceMs: number, journalRelativePaths: Iterable): { batch: TreeChangeBatch; scannedPaths: number; cancelled?: true; }; /** Yielding form used by production TreeWatcher for large warm-up inventories. */ collectWarmupCatchupAsync?(sinceMs: number, journalRelativePaths: Iterable): Promise<{ batch: TreeChangeBatch; scannedPaths: number; cancelled?: true; }>; } export declare function runRunnerWithLoop(argv: string[], deps?: RunnerLoopDeps): Promise; //# sourceMappingURL=sync-runner.d.ts.map