/** * Conflict resolution for hq share/sync (VLT-5 US-002). * * Interactive prompts in terminal mode; deterministic resolution via * --on-conflict flag for worker/skill callers. */ import { CandidateUploader, type CandidateFetch, type CandidateTransportAuthorization } from "../sync/candidate-uploader.js"; export declare class RealtimeConflictError extends Error { readonly code: "INVALID_PATH" | "CASE_COLLISION" | "CONFLICT_NAME_COLLISION" | "CANDIDATE_UNAVAILABLE"; constructor(code: "INVALID_PATH" | "CASE_COLLISION" | "CONFLICT_NAME_COLLISION" | "CANDIDATE_UNAVAILABLE", message?: string, options?: ErrorOptions); } export interface RealtimeConflictPathInput { scopeUid: string; canonicalPath: string; attemptId: string; } export interface CreatorCandidateRecovery { apiUrl: string; authorization: CandidateTransportAuthorization; fetchImpl?: CandidateFetch; /** Injectable only so recovery can share the already-bound candidate reader. */ candidateReader?: Pick; } export interface PreserveRealtimeConflictInput extends RealtimeConflictPathInput { /** Root of the scope; the conflict sibling is always contained beneath it. */ root: string; /** SHA-256 from the immutable attempt request, never a best-effort local hash. */ expectedSha256: `sha256:${string}`; expectedSize: number; /** Preferred exact source: the fsynced immutable local attempt spool. */ spoolPath?: string; /** Used only when the local spool is unavailable or fails exact verification. */ creatorCandidate?: CreatorCandidateRecovery; /** Must be set by a caller operating on a case-insensitive filesystem. */ caseInsensitiveFilesystem: boolean; /** Mirrors the platform's actual folding rule and makes that rule testable. */ caseFold?: (value: string) => string; /** * Appends the `conflict-preserved` WAL/state-store fact. It must not resolve * until that fact is durable; acknowledgement is intentionally after it. */ recordConflictPreserved(record: PreservedRealtimeConflict): Promise; } export interface PreservedRealtimeConflict { canonicalPath: string; siblingPath: string; sha256: `sha256:${string}`; source: "local-attempt-spool" | "creator-candidate"; } /** Exact V2 canonical path contract, duplicated here so recovery has no V1 path dependency. */ export declare function canonicalizeRealtimeConflictPath(rawPath: string): string; /** RFC 4648 lower-case Base32 without padding, as frozen by protocol v2. */ export declare function lowercaseBase32NoPadding(bytes: Uint8Array): string; /** * The stable sibling name for a losing V2 mutation. It deliberately includes * no clock, hostname, ETag, or local filesystem fact. */ export declare function realtimeConflictSiblingPath(input: RealtimeConflictPathInput): string; /** Acknowledgement is sent only after sibling bytes and its containing directory are fsynced. */ export declare function acknowledgeCreatorCandidate(recovery: CreatorCandidateRecovery, attemptId: string, checksum: `sha256:${string}`): Promise; /** * Recovers a losing mutation from the immutable spool or creator-only exact * candidate, publishes its deterministic sibling, then releases server-side * candidate retention with a monotonic acknowledgement. */ export declare function preserveRealtimeConflict(input: PreserveRealtimeConflictInput): Promise; /** * `publish-local` is the fenced middle ground between `keep` and `overwrite`. * * A pull-keep stamps the peer's etag on the journal entry while the kept local * body never matched it (`localDiverges`, #137). That combination is terminal * under the other strategies: push refuses the divergent local, pull sees the * remote unchanged so it never replaces the entry, and `keep` re-arms the flag * on every pass — the file can never be published, and the only documented * escape (a genuine download) discards the local edit. * * `overwrite` does unstick it, but it is unconditional: on a 412 it retries the * PUT with no precondition, so a remote that legitimately advanced is clobbered. * * `publish-local` elects the local body as the new authority ONLY while the * remote still sits at the etag the keep recorded. If the remote moved, consent * was given against a version that no longer exists, so the write fails closed * and the operator is asked again rather than silently overruling a peer. */ export type ConflictStrategy = "overwrite" | "keep" | "abort" | "publish-local"; export interface ConflictInfo { path: string; localHash?: string; remoteHash?: string; localModified?: Date; remoteModified?: Date; direction: "push" | "pull"; } export type ConflictResolution = "overwrite" | "keep" | "skip" | "diff" | "abort" | "publish-local"; export interface PickWinnerInput { /** Local body; `null` when unreadable (symlink, vanished, …). */ localBytes: Buffer | null; /** Remote (cloud / twin) body; `null` when unavailable. */ remoteBytes: Buffer | null; localMtime?: Date | number | null; remoteMtime?: Date | number | null; } /** * Why a side won. Recorded verbatim in the conflict-index row so a human (or * `/resolve-conflicts`) can audit an automatic decision afterwards. * * version — both bodies carry a frontmatter `version:` integer; higher won * mtime — no comparable version; the newer modification time won * identical — bodies are byte-equal (no real divergence); local kept * default — nothing to compare (equal/unknown mtimes); remote kept, the * historical `keep` outcome */ export type ConflictDecisionReason = "version" | "mtime" | "identical" | "default"; export interface ConflictDecision { winner: "local" | "remote"; reason: ConflictDecisionReason; localVersion?: number; remoteVersion?: number; } /** * Extract a markdown frontmatter `version:` integer. The body must open with a * `---` fence on its first line; the first `version: ` line inside the * fence wins. Anything else (no fence, unterminated fence, non-integer value) * yields `null`, which makes the caller fall back to mtime. Bounded to the * first 16 KiB so a large binary never gets scanned as text. */ export declare function parseFrontmatterVersion(body: Buffer | null): number | null; /** * Modification times closer than this are a tie. S3 `LastModified` carries * whole seconds while a local mtime carries sub-millisecond precision (and * Node rounds it when building the `Date`), so a sub-second gap is noise, not * evidence of which body is newer. */ export declare const MTIME_TIE_TOLERANCE_MS = 2000; /** * Decide which side of a two-sided conflict becomes the live body. * * The historical engine returned the caller's `--on-conflict` flag verbatim * (every HQ caller passes `keep`), so whichever side that flag named won * regardless of content — a local policy at frontmatter `version: 8` stayed * live while the cloud copy at `version: 10` was parked as a twin. This is * the content-aware replacement used whenever the strategy is unset or * `keep` (see {@link usesVersionAwareWinner}): * * 1. both bodies carry a frontmatter `version:` integer → the higher wins; * 2. otherwise the newer mtime wins (gaps within {@link MTIME_TIE_TOLERANCE_MS} * are a tie — S3 timestamps are whole seconds); * 3. identical bodies are no divergence at all — local is kept as-is; * 4. otherwise (equal or unknown mtimes) the REMOTE body wins the working * name, matching what `keep` already meant on every caller before this * comparison existed (cloud takes the live path, the displaced local body * stays recoverable). Never a data-loss choice: the caller parks the * loser regardless. * * Pure and synchronous so the reconciler can run it offline against a legacy * twin exactly as sync runs it against a freshly probed remote body. */ export declare function pickWinner(input: PickWinnerInput): ConflictDecision; /** * True when the caller should let {@link pickWinner} decide instead of * treating the strategy as a fixed side. `keep` is the version-aware default * (it is what every HQ caller passes); an UNSET strategy is version-aware too * unless a human is on a TTY, where the interactive prompt still runs. * `overwrite`, `abort` and `publish-local` keep their explicit meaning. */ export declare function usesVersionAwareWinner(strategy: ConflictStrategy | undefined): boolean; /** * Resolve a conflict interactively or via strategy flag. * * In non-interactive mode (strategy provided), returns deterministically: * overwrite → "overwrite" * keep → "keep" * abort → "abort" * publish-local → "publish-local" * * In interactive mode (strategy undefined), prompts the user. * * Every strategy is mapped explicitly. The previous form passed any non-`abort` * value straight through, so an unrecognised string became a "resolution" that * no call site handled and silently fell through to the unfenced write path. */ export declare function resolveConflict(conflict: ConflictInfo, strategy?: ConflictStrategy): Promise; /** * Show a simple diff between local and remote content. * Returns the content strings for display. */ export declare function showDiff(localPath: string, remoteContent: Buffer): void; //# sourceMappingURL=conflict.d.ts.map