/** * `hq sync` command — pull everything allowed from entity vault (VLT-5 US-002). * * Pulls all files the caller's STS session policy permits. * Never auto-overwrites local changes — prompts on conflict. */ import { type SpooledRemoteFiles } from "../sync/remote-file-spool.js"; import type { VaultServiceConfig, SyncJournal } from "../types.js"; import { type SyncMode } from "../vault-client.js"; import { type TelemetryClaims } from "../telemetry-events.js"; import type { RemoteFile } from "../s3.js"; import { type ScopePrefixInput } from "../prefix-coalesce.js"; import { type TransferSemaphore } from "../sync-core.js"; import type { ConflictDecision, ConflictStrategy, ConflictResolution } from "./conflict.js"; import { type CompanyTombstone } from "./tombstones.js"; import { MutationClient, type MutationStatus } from "../sync/mutation-client.js"; import { DurableApplyEngine, type DurableApplyContent } from "../sync/durable-apply.js"; import type { DeltaPage, DeltaRecord } from "../sync/delta-client.js"; import { type ReconcileApplyCheckpoint } from "../sync/reconcile-cursor.js"; /** The desktop sidecar invokes exactly `hq-cloud sync mutation --stdin-json`. */ export declare const SYNC_MUTATION_STDIN_COMMAND: readonly ["sync", "mutation", "--stdin-json"]; /** * JSON cannot carry the immutable Uint8Array required by a V2 upsert. The * sidecar therefore puts standard, padded base64 in this top-level envelope * field; it is decoded locally and never included in the prepare request. */ export declare const SYNC_MUTATION_CONTENT_BASE64_FIELD = "contentBase64"; /** Reject aliases/extra flags: the packaged sidecar contract is byte-for-byte stable. */ export declare function isSyncMutationStdinCommand(argv: readonly string[]): boolean; export interface SyncMutationStdinClient { submit(input: { canonicalPath: string; op: "upsert" | "delete"; bytes?: Uint8Array; }): Promise<{ requestDigest: string; status: MutationStatus; }>; } /** * Decode the one-request/one-result sidecar boundary. The process launcher * owns stdin/stdout; this pure adapter makes its exact JSON contract testable. * It intentionally has no V1 or S3-coordinate alternative. */ export declare function runSyncMutationStdinJson(stdinJson: string, client: SyncMutationStdinClient): Promise; /** Compile-time anchor proving the CLI surface calls the concrete V2 client. */ export declare function createSyncMutationClient(options: ConstructorParameters[0]): MutationClient; /** * The V2 pull runner enters through this durable page/cursor seam; U58 adds * scheduling around it. The legacy LIST-based sync() path remains untouched. */ export declare function applyRealtimeDeltaPage(engine: DurableApplyEngine, page: Pick, fetchContent: (delta: DeltaRecord) => Promise): Promise; /** * Per-file events emitted by `sync()` as it progresses. * * When `SyncOptions.onEvent` is set, these events are delivered to the caller * in place of the default human-readable `console.log` / `console.error` * output. This is the seam that lets `hq-sync-runner` stream ndjson to the * AppBar menubar without the engine knowing anything about ndjson (ADR-0001). * * The human CLI (`hq sync`) leaves `onEvent` undefined and falls through to * `defaultConsoleLogger` below, which preserves the existing tty output. * * A single `plan` event is emitted once at the start of every run, before * any `progress`/`conflict`/`error` events. It carries the totals derived * from a Stage-1 classification pass so consumers can render an accurate * progress denominator before transfers begin (the menubar's "Preparing * sync…" pre-pass becomes obsolete once the runner forwards this). */ export type SyncProgressEvent = { type: "plan"; /** Files this run intends to download (pull-only; 0 from share). */ filesToDownload: number; bytesToDownload: number; /** Files this run intends to upload (push-only; 0 from sync). */ filesToUpload: number; bytesToUpload: number; /** Files classified as no-op (ignored, unchanged, local-only on pull). */ filesToSkip: number; /** * Files known up-front to be conflicts. Pull-side fills this from the * 3-way merge against the journal; push-side leaves it 0 because * conflict detection requires a remote HEAD that runs in Stage 2. */ filesToConflict: number; /** * Remote keys this run intends to delete from S3 (push-only with * `propagateDeletes`; 0 from sync). A key is scheduled for deletion when * its journal entry exists, the local file is gone, and the key falls * within the share scope. The bucket has versioning enabled so the * delete is soft (a delete-marker is written; prior versions remain * recoverable). */ filesToDelete: number; } | { type: "progress"; path: string; bytes: number; message?: string; /** True when this event reports a remote DeleteObject (no upload). */ deleted?: boolean; /** * Transfer direction for this file, stamped by the runner's per-company * tagger from the in-flight phase: `"up"` for a push (local→S3 upload), * `"down"` for a pull (S3→local download). Optional because the inner * `share()`/`sync()` emitters don't know the run-level phase — only the * runner's `tagAndEmit` does, and it sets this when re-emitting to stdout. * Consumers (the menubar activity log) use it to label each file * uploaded vs downloaded. */ direction?: "up" | "down"; /** * Email of the file's author, read from the S3 object's `created-by` * user-metadata. Only set on the download/pull path (a downloaded file * was authored by whoever uploaded it); push-side progress has no author * because the uploader is the local user. `null`/absent when the object * carries no `created-by`. The menubar activity log shows this so the * user sees who authored each file they received. */ author?: string | null; } | { type: "error"; path: string; message: string; /** * Informational breadcrumb, not a transfer failure. The runner's * menubar and exit-code rollup ignore these — they do not land in * `errors[]` and do not force PARTIAL_SYNC_EXIT (2). Used when a * planned upload, download, delete, or tombstone HEAD verify is * deferred because of a vault rate limit or other retryable HTTP * status; the key is retried next sync. */ diagnostic?: true; } | { type: "conflict"; path: string; direction: "pull" | "push"; resolution: ConflictResolution; /** Present when the version-aware winner decided (strategy unset/keep). */ decision?: ConflictDecision; } | { /** * Emitted when a client push names a server-owned path. These paths are * produced by server ingestion/automation and are read-only to normal * sync, so the local body is deliberately not uploaded. This is a * refusal, not reconciliation: no claim is made that a remote object * exists or that its bytes match the local file. */ type: "push-refused-server-owned"; path: string; nextStep: "use-source-ingestion" | "change-upstream-and-pull"; } | { /** * One bounded summary for the recurring push runner. Server-owned paths * are deliberately not uploaded, but emitting a refusal for every * generated source file turns a healthy recurring pass into log churn. * The count is exact; samplePaths is display-only. */ type: "push-refused-server-owned-summary"; count: number; samplePaths: string[]; } | { /** * Emitted when a planner-flagged pull conflict turned out to be a false * positive: the remote bytes fetched for the conflict mirror hashed * byte-for-byte identical to the local file, so there is nothing to * resolve. The planner can only see journal-relative deltas (local hash * != journal hash AND remote etag != journal etag), so a stale journal * baseline makes identical content look like a both-sides change. Known * stale-baseline triggers: a shared-journal cross-root collision (the * personal vault + `companies/personal` sharing one journal), an * mtime-rounding fast-path miss, KMS/multipart etag churn on a no-op * re-upload, a second machine advancing S3 + its own journal, or a * manual revert. The executor re-stamps the journal baseline (so neither * side looks changed next run) and skips — no conflict counted, no * `.conflict-*` mirror written. Counted as a skip; surfaced here purely * for observability so a UI can explain why N "conflicts" silently * resolved themselves. */ type: "reconciled"; path: string; direction: "pull" | "push"; } | { type: "new-files"; files: Array<{ path: string; bytes: number; addedBy: string | null; }>; } | { /** * Emitted by the `currency-gated` delete policy when a delete candidate * is refused. Two reasons, discriminated by `reason`: * * - `"stale-etag"`: the local file is missing but the remote object's * current ETag no longer matches the journal's last-recorded etag. * Some other device (or another out-of-band write) modified the * remote object since this machine last synced it. The pull leg of * `sync now` will re-pull naturally via the same `hasRemoteChanged` * path that powers conflict detection. Journal entry is left intact * so the pull can use it as the baseline for the 3-way merge. * `journalEtag` and `remoteEtag` are real ETag strings. * * - `"legacy-no-etag"`: the journal entry predates remoteEtag tracking * (no `remoteEtag` recorded). We can't prove currency without an * etag, so the delete is refused in the safe direction. A future * sync that picks up an etag for this entry can re-evaluate. * `journalEtag` and `remoteEtag` are sentinel strings * (`` / ``) — do not render as ETags. * Consumers should branch on `reason`, not on the etag values. * * - `"bulk-asymmetry"`: the bulk-asymmetry circuit-breaker tripped * (see `delete-refused-bulk-asymmetry`). Each candidate that would * have been deleted also surfaces here for path-level visibility. * `journalEtag` and `remoteEtag` are sentinel strings * (`` / ``) since no HEAD was issued. */ type: "delete-refused-stale-etag"; path: string; journalEtag: string; remoteEtag: string; reason: "stale-etag" | "legacy-no-etag" | "bulk-asymmetry" | "divergent-local" | "missing-delete-intent" | "intent-changed" | "recreated-locally" | "marker-mismatch"; } | { /** * Emitted at most ONCE per `share()` call when the bulk-asymmetry * circuit-breaker tripped: a large fraction of the in-scope journal * entries are missing locally, suggesting the local mirror is corrupt * (moved hqRoot, partial restore, fresh clone over inherited state, * unmounted volume, accidental `rm -rf`) rather than a deliberate * delete. The engine refuses to convert the missing entries into * remote `DeleteObject` calls; every refused candidate also surfaces * via `delete-refused-stale-etag` with `reason: "bulk-asymmetry"`. * * Trip condition: `candidates >= 10 AND candidates / inScope >= 0.10`. * Bypass: set `HQ_SYNC_DELETE_BULK_OVERRIDE=1` (truthy: `1|true|yes`, * case-insensitive) OR pass `propagateDeletePolicy: "all"` (the * existing emergency-reconcile policy which already opts out of * safety gates). * * `candidates` is the number of journal entries whose local file is * missing AND would have been delete-candidates absent the guard; * `inScope` is the total journal entries under the share's scope * roots; `ratio = candidates / inScope`; `samplePaths` carries up to * 10 refused keys for diagnostic display. */ type: "delete-refused-bulk-asymmetry"; candidates: number; inScope: number; ratio: number; samplePaths: string[]; } | { /** * Emitted at most ONCE per `share()` call (push leg of a sync run) when * `personalMode === true` and the personal-vault default-exclusion list * blocked one or more files that would otherwise have uploaded. Gives * the UI a single summary signal — "N files quietly excluded by default * policy" — without firing one event per excluded file (which would * dominate the event stream on first-sync of a dirty tree). * * `count` is the total number of paths the exclusion filter rejected * (deduplicated across the walk). `samplePaths` carries up to 10 * forward-slash-separated relative paths for diagnostic display. `byId` * is a per-exclusion-rule breakdown so the UI can render which class * of exclusion did the work (secret / machine-local / scratch / …). * * Not emitted when `count === 0` — silent on a clean tree. */ type: "personal-vault-out-of-policy"; count: number; samplePaths: string[]; byId: Record; } | { /** * Emitted at most ONCE per personal-vault PUSH leg when the journal * carried baseline rows OUTSIDE the current push scope and they were * purged so the baseline converges. These are stale entries written by an * engine that predates an exclusion rule — `.claude/worktrees/`, the * `workspace/` top-level exclusion, `.hq-conflicts/`, `.conflict-*` * sidecars, `node_modules/` — that nothing else removes: the walk already * skips them, `computeDeletePlan` skips them without dropping the row, and * the journal was written back verbatim, so they were re-`lstat`'d and * re-diffed on every push forever. Purging them stops the never-converging * baseline. Informational, not an error — the remote object (if any) is an * inert orphan the pull leg already refuses. * * `count` is the number of journal rows dropped; `byReason` breaks it down * (ignore-filtered / ignore-filtered-present / excluded-top-level); * `ignore-filtered-present` rows drain in per-pass batches, so a large * backlog emits this over several consecutive passes. `samplePaths` carries * up to 10 for display. Not emitted when `count === 0` — silent on a clean * journal. */ type: "personal-vault-journal-pruned"; count: number; samplePaths: string[]; byReason: Record; } | { /** * Emitted at most ONCE per `share()` push leg when the bulk-purge circuit * breaker VETOED a mass journal purge — dropping `attempted` of * `totalRows` rows would have exceeded the guard fraction, which signals a * transient enumeration/filter fault rather than a real scope change. The * journal is left intact so sync resumes; this warns that a genuine scope * shrink (if that is what it was) was deferred, not applied. */ type: "personal-vault-journal-purge-skipped"; attempted: number; totalRows: number; } | { /** * Emitted at most ONCE per `share()` call (push leg) when the run is * scoped to a member/guest's ACL prefixes (`prefixSet`) and one or more * candidate paths fell OUTSIDE the granted prefixes. Those paths are * skipped from the upload (and delete) plan instead of being PUT — the * vended child credential is scoped to the granted prefixes, so pushing * them would draw the server's correct 403 `SCOPE_EXCEEDS_PARENT` and * (pre-fix) abort the WHOLE company. This is the push-side analogue of * the pull-side `skip-out-of-scope` action. * * `count` is the number of distinct company-relative paths/prefixes the * scope filter excluded; `samplePaths` carries up to 10 for diagnostic * display. Informational, NOT an error — the company still syncs its * in-scope subset and the run exits 0. * * Not emitted when `count === 0` — silent on a fully in-scope tree. */ type: "scope-excluded"; count: number; samplePaths: string[]; } | { /** * Emitted at most ONCE per PULL leg when the leg ran under a * membership-scoped `syncMode` (`"shared"` or `"custom"` — never `"all"`) * AND one or more remote keys were withheld BY THAT MEMBERSHIP SCOPE * (`skip-out-of-scope` with `reason: "membership-scope"`). This turns the * silent shared-vs-all gap into a * VISIBLE, actionable surface. The reporter of feedback_d2082110 lost * files across devices precisely because four memberships defaulted to * `shared` rather than `all`, so not all vault content materialized on the * second device — and nothing told them; they had to notice the gap and * flip each membership to `all` by hand. This event names the gap and the * lever (raise the membership's access level to `all`) so FULL * materialization is a deliberate, visible choice rather than a silent * omission. * * `count` is the number of remote keys the MEMBERSHIP SCOPE withheld on * this leg; `samplePaths` carries up to 10 company-relative keys for * display; `syncMode` is the active scoped mode. Push-only keys * (`sessions/`, US-006) are deliberately EXCLUDED from both — they are * withheld in every mode including `all`, so the lever this event names * would not materialize them. `count` is therefore ≤ * `SyncResult.filesOutOfScope`, which keeps its both-causes meaning. * Distinct from the push-side `scope-excluded` (which reports what a * grantee could not PUSH). Not emitted in `all` mode or when nothing was * withheld by the membership scope — no gap, no noise. */ type: "scope-materialization-gap"; count: number; samplePaths: string[]; syncMode: SyncMode; } | { /** * Emitted at most ONCE per `share()` push leg when the base ignore * filter dropped one or more NOTEWORTHY paths: content that does not * match expected build/VCS/cache noise classes. Surfaces an over-broad * exclusion that would otherwise drop content silently (DEV-1791). * * `count` is the number of distinct noteworthy paths; `totalExcluded` * is all base-ignore rejections including expected noise; `samplePaths` * carries up to 10 noteworthy paths. Not emitted when `count === 0`. */ type: "ignore-excluded"; count: number; totalExcluded: number; samplePaths: string[]; } | { /** * Emitted by the PUSH leg (`share()`) once per key whose upload was * suppressed because an authoritative FILE_TOMBSTONE marks it deleted and * the local copy is still the deleted baseline (journal hash unchanged). * Without this, a behind peer that still holds the file would re-upload it, * and the pull planner's timestamp-only re-create heuristic * (`isRemoteRecreateAfterTombstone`) would treat the re-upload as a genuine * re-create and resurrect the key for everyone. The deleter's own stale * local copy is cleaned by the pull leg's `tombstone-delete`; this event * records that the push refused to resurrect it. `deletedAt` is the * tombstone's delete time. */ type: "upload-suppressed-tombstone"; path: string; deletedAt: string; } | { /** * Emitted by the PUSH leg (`share()`) once per local file skipped because * it exceeds the max sync size (`isWithinSizeLimit`, 50 MB default). This * is a permanent, benign, user-policy outcome — the file is too big to * sync and will be over the cap on every pass — so it is NOT an error and * the run exits 0. It was previously emitted as `type: "error"`, which * pushed it into the runner's `errors[]` and made every watch pass return * exit 2; the menubar supervisor then reported that as * "auto-sync watcher exited unexpectedly (code=Some(2))" on every tick — * the HQ-SYNC-4 flood across user machines. Surfaced here purely for visibility * (`bytes` is the offending file's size) so a UI/CLI can tell the user * which file was too big without treating it as a failure. */ type: "skip-size-limit"; path: string; bytes: number; } | { /** * Emitted once per key skipped because it is permanently unstorable. * * PULL leg: a remote key the server will never presign (for example, one * with control characters) — bucket poisoning from an outdated direct-S3 * client. PUSH leg: a local key the upload validator would reject, so * the upload can only ever throw — for example a control-character path * such as a macOS Finder `Icon\r` file. Like * `skip-size-limit`, this is deliberately NOT `type: "error"` — one bad * object must never error a whole company sync (erroring gated * heartbeat toolset refreshes fleet-wide in the incident). Surfaced for * visibility + cloud telemetry; per policy * hq-alert-baseline-calibration the healthy baseline is ZERO such keys, * so any occurrence is signal. */ type: "skip-invalid-scoped-key"; path: string; /** Server-compatible invalid-key code, such as INVALID_KEY_CONTROL_CHARS. */ errorCode?: string; } | { /** * Emitted by the PULL leg once per remote key skipped because its * spelling percent-decodes to the same logical path as an * already-journaled key with a DIFFERENT spelling — the mixed-version * amplifier (a pre-codec peer minting `%3A`/`%253A` twins of a * canonical `:` key). Downloading the twin would materialize a junk * local dir and fork the journal into a second key family for one * logical path, so the established spelling wins and the twin is * skipped. Like `skip-size-limit`, this is deliberately NOT * `type: "error"` (policy hq-sync-deliberate-skip-not-fatal-error-exit2: * a recurring benign per-file skip emitted as an error lands in the * runner's `errors[]`, forces exit 2 on every pass, and the menubar * Sentry-alerts each time). The US-003 vault doctor is the cleanup * path that collapses the junk family itself. */ type: "skip-junk-key-spelling"; path: string; /** The already-journaled spelling that owns this logical path. */ journaledKey: string; } | { /** * Emitted when local content is deliberately not transferred and would * otherwise vanish from sync output without a trace (feedback_258e4a86 / * feedback_a51cb63d — an hour lost because a "Pushed 0 file(s)" success * named nothing that was skipped). Push batches by reason; pull emits an * unreadable link by its remote key. `reason` distinguishes the cause: * * - `"unreachable-path"`: a path the caller EXPLICITLY named exists on * disk but the resolver could not place it under the company folder, * or could not find it under any base. This is actionable signal — the * named target was NOT shipped — so the CLI may treat it as an error. * - `"linked-subtree"`: a directory symlink whose target lives OUTSIDE * the company folder was recorded as a link but its contents were not * descended/uploaded. They sync via their own repo, not the vault; * this is informational, not an error. * - `"unreadable-link"`: the OS identified a local symbolic link but * did not expose a readable target. The link is skipped rather than * dereferenced, and the company leg remains complete. * - `"skill-registration-denied"`: this machine cannot reserve an * identity for an unstamped canonical company skill. The skill is * excluded so an unstamped copy cannot reach S3; other files proceed. * - `"skill-registration-invalid"`: the service permanently rejected * an unstamped canonical company skill's current bytes. The skill is * excluded so it cannot reach S3; other files proceed. `message`, * when supplied, identifies the service's validation reason. * - `"skill-registration-conflict"`: a canonical company skill's stamped * identity is not the one registered for its path (a copied or renamed * pre-stamped skill), and the client's non-landed re-identify fallback * was also refused. Its content already synced; only its skill metadata * was not reconciled this pass. Deliberate per-object skip, not fatal — * the company leg completes and the marker is cleared so it cannot loop. * - `"skill-registration-failed"`: a canonical company skill's pending * post-upload metadata reconciliation failed on this pass for a reason * other than a refusal — a 5xx, a transport failure, a failed remote * read during recovery. The skill keeps its retry marker and is * retried on the next pass; other files proceed and the company leg * completes. `message` carries the failure, including the path when * the service named one. Before this reason existed the same failure * escaped the preflight and aborted the whole company leg every pass. * * `count` is the number of distinct paths for that reason; `samplePaths` * carries up to 10 for display. Not emitted when `count === 0`. */ type: "not-shipped"; reason: "unreachable-path" | "unreadable-link" | "linked-subtree" | "outside-vault-scope" | "skill-registration-denied" | "skill-registration-invalid" | "skill-registration-conflict" | "skill-registration-failed"; count: number; samplePaths: string[]; /** Validation detail supplied while classifying an invalid registration. */ message?: string; } | { /** * Emitted by the PULL leg once per vault symlink record that could not be * materialized because this Windows host lacks * SeCreateSymbolicLinkPrivilege — Developer Mode is off and the process is * not elevated, so a file-flavored NTFS symlink (the only shape a * non-directory target mints) throws EPERM. Like `skip-size-limit` and * `skip-junk-key-spelling`, this is deliberately NOT `type: "error"` * (policy hq-sync-deliberate-skip-not-fatal-error-exit2): a per-object * platform-privilege refusal counted as an error lands in the runner's * `errors[]`, forces PARTIAL_SYNC_EXIT (2) on every pass, and the desktop * menubar Sentry-alerts `[sync] hq-sync-runner exited with code 2` on each * pass (Sentry HQ-DESKTOP-54). The link's TARGET already exists locally as * a regular file, so no content is lost — only the alias is missing, and * the object is left UNJOURNALED so a later pass (once the privilege * exists) materializes and journals it without any manual reset. `remedy` * is a fixed, content-free instruction for the user. */ type: "skip-symlink-privilege"; path: string; /** The symlink target from the vault record (relative wire path). */ target: string; /** Fixed remediation text (enable Developer Mode / run once elevated). */ remedy: string; } | { /** * Emitted by the PULL leg (download install OR conflict-keep preserve) * once per object whose Windows rename was refused by a transient sharing * violation (EPERM/EBUSY). The refusal surfaces immediately on the first * attempt (no retry, no synchronous backoff — see * renameOrRaiseWindowsBlocked); recovery is the next sync pass, once the * file is released. Like `skip-symlink-privilege` this is deliberately NOT * `type: "error"` (policy hq-sync-deliberate-skip-not-fatal-error-exit2): * a per-object refusal counted as an error lands in the runner's * `errors[]`, forces PARTIAL_SYNC_EXIT (2), and the desktop menubar * Sentry-alerts `[sync] hq-sync-runner exited with code 2` every pass * (Sentry HQ-DESKTOP-54, second path). The downloaded bytes are intact in * the staged temp and the operator's local body is untouched, so no * content is lost; the object is left UNJOURNALED (and a skipped conflict * leaves its journal entry untouched with `localDiverges` NOT armed) so a * later pass, once the file is released, converges without any manual * reset. `remedy` is a fixed, content-free instruction for the user. */ type: "skip-rename-blocked"; path: string; /** Fixed remediation text (a program is holding the file open). */ remedy: string; } | { /** * Emitted by the PULL leg (download install OR conflict-convergence * probe) once per vault key that was in the LIST and gone by the GET — * the LIST→GET race on short-lived inbox files such as * `inbox/pending/jobrun-*.json`. Like `skip-symlink-privilege` this is * deliberately NOT `type: "error"` (policy * hq-sync-deliberate-skip-not-fatal-error-exit2): treating a vanished * object as an error lands the company in the runner's `errors[]`, * forces PARTIAL_SYNC_EXIT (2), and the fleet monitor pages * `component-sync: degraded` even though the next pass simply no longer * lists the key. The object is left UNJOURNALED so a later pass that * still lists it retries with no manual reset. `message` is the * describeError surface of the NoSuchKey/404. */ type: "skip-vanished-remote"; path: string; message: string; } | { /** * Push-side twin of the pull planner's `skip-archived-session-log`: the * remote transcript sits in DEEP_ARCHIVE, so its body cannot be fetched * and a `keep` conflict could never adopt it. The local file is left * untouched and unjournaled; nothing is uploaded, renamed, or errored. */ type: "skip-archived-session-log"; path: string; }; export interface SyncOptions { /** Company slug or UID (defaults to active company from config) */ company?: string; /** Non-interactive conflict strategy */ onConflict?: ConflictStrategy; /** Vault service config */ vaultConfig: VaultServiceConfig; /** HQ root directory */ hqRoot: string; /** * Per-file event callback. When present, suppresses the default * `console.log`/`console.error` human output — the caller is expected to * render events themselves (e.g. emit ndjson to stdout). When absent, the * default human logger is used. See `SyncProgressEvent`. */ onEvent?: (event: SyncProgressEvent) => void; /** * Optional run-scoped transfer limiter. The company fanout supplies one * when HQ_SYNC_MAX_CONCURRENCY is set so independently-started engines still * share the governor's hard ceiling. */ transferSemaphore?: TransferSemaphore; /** * Optional shared progress sink. Fanout supplies an aggregate writer so * concurrent legs do not race to overwrite sync-progress.json. */ progressRecorder?: (event: SyncProgressEvent) => void; /** * Watch-runner liveness callback for long synchronous planning loops. * The caller owns throttling and protocol emission; one-shot CLI callers * leave this undefined. */ onHeartbeat?: () => void; /** * Watch-runner hand-off seam for an unscoped full pull. Invoked at LIST * page boundaries, every 500 planned rows, and after each drained download * chunk. Returning true means realtime ran; this pull overlays the journal * delta and continues the same LIST/plan/queue instead of re-LISTing S3. */ cooperativeCheckpoint?: () => Promise; /** * Delete authorization policy shared with the preceding push leg. The runner * passes its resolved policy into both engines so a watcherless pull can use * the same ETag-currency proof as a watcherless push. Direct `sync()` calls * retain the conservative `owned-only` default. */ propagateDeletePolicy?: "currency-gated" | "owned-only" | "all"; /** * When true, the caller is syncing against the caller's person-entity * bucket. Pulled keys whose path starts with `companies/` are local * (non-cloud) companies that sync to the personal vault by default — * they are allowed through EXCEPT for slugs in `teamSyncedSlugs` (which * are cloud-backed orphans, see below). `companies/manifest.yaml` is * always allowed (routing source-of-truth). */ personalMode?: boolean; /** * Slugs of companies the operator has an active team-bucket Membership * for. Only consulted when `personalMode === true`: keys under * `companies/{slug}/...` for any slug in this set are dropped as orphans * from a pre-promotion personal-bucket fallback (the company became * cloud-true). The push-side decommission cycle removes these from the * bucket; this filter prevents them from re-downloading into the same * disk paths the team-bucket pull manages. */ teamSyncedSlugs?: ReadonlySet; /** * Override for the per-slug journal file name. Defaults to `ctx.slug`. * sync-runner passes `journalSlug: "personal"` for the personal slot so * TS runner and Rust first-push share idempotency state. */ journalSlug?: string; /** * Pre-fetched FILE_TOMBSTONE map for this vault. The combined runner injects * the same map into push and pull so both legs make one deletion decision * from one server snapshot. Direct callers may omit it; sync() then fetches * the correct company or personal scope itself. */ fileTombstones?: ReadonlyMap; /** * Effective sync mode for this leg (US-005 wiring). Defaults to `"all"` * when absent, preserving the legacy full-bucket pull. The runner resolves * this from the membership's sync-config (`getMembershipSyncConfig`). * * SECURITY NOTE: this is a footprint/UX filter, NOT an authorization * boundary. The security boundary is the server (STS credential scope + * ACL). An owner's STS is wide (role-bypass), so this client-side scope is * what makes selective download durable for owners — but it never grants * access beyond what STS already permits. */ syncMode?: SyncMode; /** * Coalesced, COMPANY-RELATIVE prefixes the current pull is scoped to when * `syncMode` is `"shared"` or `"custom"` (same namespace as `RemoteFile.key` * and the per-slug journal keys — e.g. `"knowledge/"`, `"projects/x/"`). * Ignored when `syncMode` is `"all"`. The runner derives this from the * caller's explicit grants (`shared`) or `customPaths` (`custom`) and is * responsible for normalizing into the company-relative namespace. * * A `shared` leg with an empty/undefined `prefixSet` means "nothing is * shared with me" → download nothing. The runner MUST fall back to `"all"` * (not empty `"shared"`) on any grant-resolution error, so a transient * failure can never silently prune the local tree. */ prefixSet?: ScopePrefixInput[]; /** * Company-relative prefixes SUBTRACTED from the effective pull scope in EVERY * mode — the push-only set (US-006). Carries `sessions/`: session transcripts * are pushed into the vault but never auto-pulled onto grantee laptops, even * under `syncMode: "all"` (which otherwise pulls the whole bucket). A remote * key downloads iff it is covered by the inclusion scope (`prefixSet`, or all * of `all` mode) AND NOT covered by any `excludePrefixes` entry; excluded * keys are classified `skip-out-of-scope`. * * CRITICAL: this set is applied ONLY to the download filter. It is * deliberately NOT fed into the scope-shrink pass (which keeps using the * inclusion `prefixSet` + pins), so a locally-authored (`direction:"up"`) or * pinned on-demand-materialized session is NEVER orphaned/pruned — it is * merely not re-pulled. The runner derives this from * `resolvePullScope().excludePrefixes`. */ excludePrefixes?: ScopePrefixInput[]; /** * TRANSIENT per-run narrowing for a targeted pull (`--scope-path`). * Company-relative paths (file or directory, same namespace as * `RemoteFile.key`) this run should list/plan — e.g. the coalesced subtree a * watcher batch just touched. The effective LISTED scope is the intersection * of these paths with the durable membership scope (`syncMode`/`prefixSet`), * so a targeted pull can never widen access, only shrink work. * * CRITICAL contract — this is a listing/plan filter, NOT a scope change: * - scope-shrink keeps evaluating against the durable `prefixSet`, so a * targeted pull never orphans/prunes anything outside the target; * - the appended PullRecord stamps the durable `prefixSet`, so the NEXT * (full) pull does not see a phantom narrow→widen scope transition; * - remote-deletion detection (the journal-vs-LIST tombstone sweep) only * considers journal keys covered by the LISTED scope, so a file absent * from a narrowed listing is never mistaken for a remote delete. * * Empty/undefined means no narrowing (the durable scope is listed). */ pullPrefixes?: string[]; /** * When the effective scope shrinks relative to the last pull and the shrink * would orphan locally-modified ("dirty") files, `sync()` aborts with a * `ScopeShrinkBlockedError` by default. Set `true` to proceed anyway: * dirty files are LEFT ON DISK and only their journal entries are * tombstoned. Mirrors `hq sync narrow --force`. */ forceScopeShrink?: boolean; /** * How `sync()` handles a scope shrink (US-005 / DEV-1768): * * - `"block"` (default) — a human is present (foreground `hq sync`). Dirty * out-of-scope orphans, or a clean prune over the safety cap, raise a * structured error whose advice is followable from a terminal. Clean * orphans within the cap are QUARANTINED (moved, not deleted). * - `"auto-recover"` — the background menubar runner, which can take no * interactive flag. NEVER throws on a shrink: dirty orphans are kept on * disk + un-tracked, clean orphans are quarantined, and the bulk-prune * cap is bypassed (quarantine is non-destructive). This is what clears an * already-wedged journal on the next sync, idempotently and without data * loss — the recovery seam for the all→shared seed bug. * * Both policies are non-destructive for CLEAN files (quarantine, never * silent delete) — the deliberate `hq sync narrow --apply` ritual is the only * path that hard-deletes, and it confirms first. */ scopeShrinkPolicy?: "block" | "auto-recover"; /** * The caller's own Cognito `sub`, used by the scope-shrink authorship guard * so a scope shrink never prunes content the caller authored. Injected by the * entry point — the runner sources it from its decoded idToken claims (the * same sub stamped onto uploads as `created-by-sub`). The engine never reads * it from disk, so it stays pure/hermetic; undefined degrades safely. */ callerSub?: string; /** * Optional decoded Cognito claims used only for action telemetry attribution * (agentUid when `custom:entityType=agent`). Sync behavior never branches on * this field. */ telemetryClaims?: TelemetryClaims | null; /** * Skip the post-sync `reindex()` refresh (skill wrappers + personal overlay * mirrors + workers registry). By default, when a sync changes on-disk * sources (downloads, tombstones, or scope-orphan removals), `sync()` * re-runs reindex so the generated `.claude/skills/:` wrappers * stay in sync. An orchestrator syncing many companies in one pass can set * this and run `reindex()` once itself instead of per-company. */ skipReindex?: boolean; /** * Internal runner seam: true only when the caller already holds the * per-root operation lock for this sync pass. */ operationLockAlreadyHeld?: boolean; /** * Opt-in bounded reconcile apply checkpoint. Callers pair this with exact * `pullPrefixes`; the cursor advances only after this leg persists its * journal outcomes. */ reconcileApply?: ReconcileApplyCheckpoint; } export interface SyncResult { filesDownloaded: number; bytesDownloaded: number; filesSkipped: number; conflicts: number; /** * Paths (remote keys) that were detected as conflicts during this run. * Always populated when `conflicts > 0` so callers can surface them in UI * or logs without re-streaming the per-file events. */ conflictPaths: string[]; aborted: boolean; /** * Files classified as "new" during pull — i.e. the remote file had no * local counterpart at classification time. Additive field; empty array * when no new files were detected or on push-only syncs. */ newFiles: Array<{ path: string; bytes: number; }>; /** Convenience count: `newFiles.length`. */ newFilesCount: number; /** * Count of remote keys refused at planning time because they matched * `EPHEMERAL_PATH_PATTERN` (conflict-mirror files that must never round- * trip through the bucket). Mirrors `ShareResult.filesExcludedByPolicy` * so push and pull report the same shape. Pre-fix this count was always * 0 on the pull side and legacy `.conflict-*` litter rode every sync — * see Bug #2 in workspace/reports/hq-cloud-5.33.0-deep-test.md. */ filesExcludedByPolicy: number; /** * Count of journal-known keys applied as local deletes during this pull * because the remote LIST no longer contains them — the cross-machine * delete-propagation signal that Bug #9 closes. The peer's push leg * removed the object from S3 (`hq sync` push side verified-to-work in * the deep-test addendum), but pre-fix the pull side never enumerated * "what's missing-from-remote-that-was-there-before", so the file * lingered locally forever. Always 0 when no journal-known keys have * disappeared from the remote. */ filesTombstoned: number; /** * Count of remote keys NOT downloaded this run because they fall outside * the effective `syncMode` scope (US-005). Always 0 in `all` mode. Distinct * from `filesSkipped` (which measures "unchanged on this run") so consumers * can render a "N outside your sync scope" line. The matching local cleanup * of previously-downloaded-now-out-of-scope files is reported via * `scopeOrphansRemoved`. */ filesOutOfScope: number; /** * Clean local orphans deleted this run because a scope shrink moved them * outside the effective scope (US-005). 0 when scope did not shrink. */ scopeOrphansRemoved: number; /** * Dirty (locally-modified) orphans that a scope shrink would have pruned. * When `forceScopeShrink` is false these are surfaced via a thrown * `ScopeShrinkBlockedError` and the leg never reaches this result; when * true they are left on disk and tombstoned, and counted here. */ scopeOrphansBlocked: number; /** * Remote keys whose local on-disk counterpart changed during this pull. * Includes downloads, tombstone deletes, and clean scope-orphan removals. */ changedPaths?: string[]; /** * Regular-file remote upserts written during this pull, keyed in the * journal's vault namespace with the journal's unprefixed SHA-256 hash. */ appliedUpserts?: Array<{ path: string; hash: string; }>; } /** * Resolve the auto-prune safety cap (US-005 bulk-delete guard). An automatic * scope shrink that would delete more than this many CLEAN local files in one * pull is refused with `ScopeShrinkLargePruneError`. Default 100; `0` (or a * non-positive / unparseable value) disables the cap (unlimited). Override via * `HQ_SYNC_MAX_AUTO_PRUNE`. */ export declare function resolveAutoPruneCap(): number; /** Fixed-token census for the single retained targeted-pull projection. */ export declare function targetedJournalSessionCensusSizes(): Record; /** * A full-pull hand-off unit. Every member has finished its transfer and the * resulting journal delta is durable before realtime can acquire the writer. */ export declare const FULL_PULL_COOPERATIVE_CHECKPOINT_BATCH_SIZE = 500; /** * ListObjectsV2 page size (AWS MaxKeys default). Full-pull LIST yields the * root lock at each page boundary so queued realtime work is not stuck behind * a 30-minute personal-vault spool. */ export declare const FULL_PULL_LIST_PAGE_CHECKPOINT_SIZE = 1000; /** * While a LIST page HTTPS request is outstanding, yield to queued realtime * this often so a stalled socket cannot freeze watcher work for minutes. * Matches the realtime authorization timeout. */ export declare const LIST_COOPERATIVE_YIELD_MS = 15000; export declare function setListCooperativeYieldMsForTesting(ms: number | undefined): void; declare function enableCooperativeRowVisits(): void; declare function disableCooperativeRowVisits(): void; declare function snapshotCooperativeRowVisits(): { list: Map; plan: Map; }; /** * Test-only export. Kept under `_testing` so the public surface stays `sync()`. * Do NOT import from `_testing` outside of tests in this package. */ export declare const _testing: { enableCooperativeRowVisits: typeof enableCooperativeRowVisits; disableCooperativeRowVisits: typeof disableCooperativeRowVisits; cooperativeRowVisits: typeof snapshotCooperativeRowVisits; cooperativePhase: () => "list" | "plan" | "download" | null; computePullPlan: typeof computePullPlan; }; /** * Best-effort report of the files that were new to this drive during the sync, * so the HQ Sync app can show a persistent cross-session "new files" history. * * POSTs to `${apiUrl}/v1/notify/file-added`, which writes per-recipient * FILE_EVENT rows for the calling user (the one the files are new for). Fully * non-fatal: any error, non-2xx, or timeout is swallowed — the durable signal * is the synced file itself; this is only a notification mirror. Bounded by a * 5s timeout PER request so a hung endpoint can't stall sync completion. No-op * when there are no new files. * * Large reports are split into chunks of at most NOTIFY_FILE_ADDED_MAX_BATCH * files (the server's per-report cap). Each chunk is POSTed independently and * best-effort, so one failing/oversized batch can never block the others or the * sync. Exported only so the chunking can be unit-tested directly. */ export declare function reportNewFilesToNotify(vaultConfig: VaultServiceConfig, companyUid: string, companySlug: string, files: Array<{ path: string; bytes: number; addedBy: string | null; }>, telemetryClaims?: TelemetryClaims | null): Promise; /** * Sync (pull) all allowed files from the entity vault. */ export declare function sync(options: SyncOptions): Promise; /** * List the remote objects the current pull can actually materialize. * * Historically this was ALWAYS a full-bucket `listRemoteFiles(ctx)` — even for * a `shared`/`custom` membership whose effective scope is a handful of * prefixes — so every pull materialized the entire remote listing (tens of * thousands of RemoteFile objects on large vaults) only for `computePullPlan` * to discard most of it as `skip-out-of-scope`. Scoped pulls now list ONLY the * in-scope prefixes via `listRemoteForScope` (per-prefix ListObjectsV2 calls, * bounded parallel, deduped), which bounds the listing's memory to the scope's * actual footprint. * * `all` mode spools one paged LIST to private temporary storage and collects * directory-overlay ancestors. Classification replays that snapshot only after * the journal refresh above. This preserves freshness even for a row listed * early whose watcher delete commits during a later page. * * An empty `listedPrefixSet` in a scoped mode means "nothing in scope" — * issue zero LIST calls. Consumers already treat an empty listing correctly: * the deletion sweep in `computePullPlan` only considers journal keys covered * by the LISTED scope, so keys absent because they were never listed can never * be mistaken for remote deletes. */ type RemoteFileSource = RemoteFile[] | SpooledRemoteFiles; /** * Stage-1 classification for a single remote object. Each remote file falls * into exactly one bucket; the executor in `sync()` switches on `action` to * decide what to do. `localHash` is carried on `conflict` items so the * executor can hand it to `resolveConflict` without re-hashing. */ type LocalSnapshot = { kind: "absent" | "directory" | "other"; } | { kind: "file" | "symlink"; hash: string; }; /** * Why a remote key was classified `skip-out-of-scope`. The two causes look * identical in the count but have OPPOSITE remedies, so they must never be * reported as one: `membership-scope` is fixed by raising the membership's * access level to `all`; `push-only` is not fixed by that at all. */ type OutOfScopeReason = "membership-scope" | "push-only"; type PullPlanItem = { action: "download"; remoteFile: RemoteFile; localPath: string; isNew: boolean; localSnapshot: LocalSnapshot; } | { action: "skip-ignored"; remoteFile: RemoteFile; localPath: string; } | { action: "skip-archived-session-log"; remoteFile: RemoteFile; localPath: string; } | { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string; } | { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string; } | { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string; } | { action: "skip-unreadable-link"; remoteFile: RemoteFile; localPath: string; } | { action: "skip-stale-overlay-marker"; remoteFile: RemoteFile; localPath: string; } | { action: "skip-overlay-marker-with-children"; remoteFile: RemoteFile; localPath: string; } | { action: "skip-excluded-policy"; remoteFile: RemoteFile; localPath: string; } | { action: "skip-out-of-scope"; remoteFile: RemoteFile; localPath: string; reason: OutOfScopeReason; } | { action: "skip-junk-key-spelling"; remoteFile: RemoteFile; localPath: string; journaledKey: string; } | { action: "tombstone-delete"; remoteFile: RemoteFile; localPath: string; localSnapshot: LocalSnapshot; } | { action: "conflict"; remoteFile: RemoteFile; localPath: string; localHash: string; localMtime: Date; localSize: number; localSnapshot: LocalSnapshot; }; interface PullPlan { items: PullPlanItem[]; /** Ordinary streamed skips omitted from `items`; seed executor counters. */ preplannedFilesSkipped: number; /** Streamed out-of-scope skips omitted from `items`; seed executor counters. */ preplannedFilesOutOfScope: number; filesToDownload: number; bytesToDownload: number; filesToSkip: number; filesToConflict: number; /** Files classified as new (no local counterpart at classification time). */ newFiles: Array<{ path: string; bytes: number; }>; newFilesCount: number; /** Count of remote keys refused by ephemeral-mirror policy. */ filesExcludedByPolicy: number; /** Count of remote keys skipped because they fall outside the sync scope. */ filesOutOfScope: number; /** * Journal-known keys missing from the remote LIST. The executor will * apply each as a local delete (file or symlink) + journal removal, * propagating the peer's push-side delete cross-machine (Bug #9). * Carried on the plan so the executor can iterate without re-walking. */ tombstones: Array<{ key: string; localPath: string; localSnapshot: LocalSnapshot; }>; /** * Count of `tombstone-delete` items — remote keys present in the LIST but * suppressed by a FILE_TOMBSTONE (delete-resync). Surfaced on the plan event's * `filesToDelete` axis; the per-item executor applies the local delete. */ filesToTombstoneDelete: number; /** * Remote keys skipped because the server will never presign them. Counted in * `filesExcludedByPolicy`; carried with the server-compatible error code so * the caller can log one warning per key and emit telemetry. Healthy * baseline is an empty array (policy * hq-alert-baseline-calibration: zero expected, any occurrence is signal). */ invalidRemoteKeys: Array<{ path: string; errorCode: string; }>; } /** * win32 only: true when an existing local symlink's TARGET exists but the * link cannot be traversed as that target's type — the file-flavor-link-at- * a-directory shape minted while the target was absent (or by an older * client). A dangling link is NOT broken-flavored (there is nothing to * repair until the target appears). POSIX links are flavorless: always * false off win32. */ export declare function win32SymlinkFlavorBroken(localPath: string, win32?: boolean): boolean; declare function computePullPlan(remoteFiles: RemoteFileSource, journal: SyncJournal, companyRoot: string, shouldSync: (filePath: string, isDir?: boolean) => boolean, personalMode: boolean, teamSyncedSlugs: ReadonlySet | null, prefixSet: readonly ScopePrefixInput[], fileTombstones?: ReadonlyMap, excludePrefixes?: readonly ScopePrefixInput[], propagateDeletePolicy?: "currency-gated" | "owned-only" | "all", onHeartbeat?: () => void, journalRowCount?: number, cooperativeCheckpoint?: () => Promise, onCooperativeHandoff?: () => void, deleteDenominatorKeys?: ReadonlySet): Promise; export {}; //# sourceMappingURL=sync.d.ts.map