diff --git a/src/handwritten/commands/drive/api.ts b/src/handwritten/commands/drive/api.ts index 8e7cc13..b4b2d80 100644 --- a/src/handwritten/commands/drive/api.ts +++ b/src/handwritten/commands/drive/api.ts @@ -1,15 +1,24 @@ import { loadSdkClientWithAuthedFetch } from "../../auth/load-sdk-client.js" import { driveFileDelete, + driveFileMove, driveLibraryGet, driveManifestGet, } from "../../../generated/sdk/index.js" -import type { DeleteDriveFileResponse, DriveLibrary, DriveManifestResponse, UploadDriveFileResponse } from "../../../generated/sdk/index.js" +import type { + DeleteDriveFileResponse, + DriveLibrary, + DriveManifestResponse, + MoveDriveFileResponse, + UploadDriveFileResponse, +} from "../../../generated/sdk/index.js" import type { ConfigStore } from "../../config/index.js" export interface DriveApiOptions { store?: ConfigStore fetchImpl?: typeof fetch + // Watch session client id; lets the server tag events for echo suppression. + clientId?: string } type JsonResult = { @@ -31,7 +40,9 @@ async function expectJsonResult(result: JsonResult): Promise { // Lets the server attribute drive file operations to the sync loop instead // of generic API traffic (drive_file_operation actor: "sync" vs "api"). -const SYNC_CLIENT_HEADERS = { "x-wspc-client": "drive-sync" } as const +function syncClientHeaders(clientId?: string): { "x-wspc-client": string } { + return { "x-wspc-client": clientId === undefined ? "drive-sync" : `drive-sync/${clientId}` } +} function driveContentUrl(baseUrl: string, id: string): URL { const baseWithTrailingSlash = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/` @@ -41,6 +52,7 @@ function driveContentUrl(baseUrl: string, id: string): URL { export async function createDriveApi(opts: DriveApiOptions = {}) { const client = await loadSdkClientWithAuthedFetch(opts) const rawClient = client._rawClient as never + const clientHeaders = syncClientHeaders(opts.clientId) return { async getLibrary(id: string): Promise { @@ -50,11 +62,20 @@ export async function createDriveApi(opts: DriveApiOptions = {}) { }) return expectJsonResult(result) }, - async getManifest(id: string, cursor?: string): Promise { + async getManifest( + id: string, + cursor?: string, + sinceCursor?: string, + ): Promise { + const query: Record = {} + if (cursor !== undefined) query.cursor = cursor + // since_cursor / latest_cursor / resync_required exist server-side but are + // not in the generated SDK types yet; widen until the spec is regenerated. + if (sinceCursor !== undefined) query.since_cursor = sinceCursor const result = await driveManifestGet({ client: rawClient, path: { id }, - ...(cursor ? { query: { cursor } } : {}), + ...(Object.keys(query).length > 0 ? { query: query as never } : {}), }) return expectJsonResult(result) }, @@ -62,7 +83,7 @@ export async function createDriveApi(opts: DriveApiOptions = {}) { const result = await driveFileDelete({ client: rawClient, path: { id }, - headers: SYNC_CLIENT_HEADERS, + headers: clientHeaders, body: { path, expected_entry_version: expectedEntryVersion, @@ -70,6 +91,24 @@ export async function createDriveApi(opts: DriveApiOptions = {}) { }) return expectJsonResult(result) }, + async moveFile( + id: string, + fromPath: string, + toPath: string, + expectedEntryVersion?: number, + ): Promise { + const result = await driveFileMove({ + client: rawClient, + path: { id }, + headers: clientHeaders, + body: { + from_path: fromPath, + to_path: toPath, + ...(expectedEntryVersion === undefined ? {} : { expected_entry_version: expectedEntryVersion }), + }, + }) + return expectJsonResult(result) + }, async uploadFile( id: string, path: string, @@ -88,7 +127,7 @@ export async function createDriveApi(opts: DriveApiOptions = {}) { headers: { "content-type": "application/octet-stream", "x-drive-content-sha256": sha256, - ...SYNC_CLIENT_HEADERS, + ...clientHeaders, }, body, }) diff --git a/src/handwritten/commands/drive/decision.ts b/src/handwritten/commands/drive/decision.ts index afaf0a5..683ff3a 100644 --- a/src/handwritten/commands/drive/decision.ts +++ b/src/handwritten/commands/drive/decision.ts @@ -41,7 +41,7 @@ export function decideDriveAction( if (!remote) return decideRemoteMissing(localStatus) if (!local) return decideLocalMissing(entry, remoteStatus) - return decideBothPresent(entry, localStatus, remoteStatus) + return decideBothPresent(entry, localStatus, remoteStatus, local, remote) } function decideWithoutBase(local: DecisionLocal | undefined, remote: DecisionRemote | undefined): DriveAction { @@ -75,7 +75,13 @@ function decideBothPresent( entry: DecisionEntry, localStatus: LocalStatus, remoteStatus: RemoteStatus, + local: DecisionLocal, + remote: DecisionRemote, ): DriveAction { + // Identical bytes on both sides means the conflict is already converged, + // whatever the base says; only the state entry needs to catch up. + const converged = remote.content_sha256 !== undefined && local.sha256 === remote.content_sha256 + if (localStatus === "unchanged" && remoteStatus === "content_same_new_version") { return { type: "state_only" } } @@ -83,7 +89,7 @@ function decideBothPresent( return { type: "conflict", reason: "remote_missing_entry_version" } } if (localStatus === "unknown" && remoteStatus !== "unchanged") { - return { type: "conflict", reason: "unknown_local_base_remote_changed" } + return converged ? { type: "state_only" } : { type: "conflict", reason: "unknown_local_base_remote_changed" } } if (localStatus === "unknown") return { type: "conflict", reason: "unknown_local_base" } if (localStatus === "unchanged" && remoteStatus === "changed") return { type: "download" } @@ -91,7 +97,7 @@ function decideBothPresent( return { type: "upload_update", expectedEntryVersion: entry.entry_version } } if (localStatus !== "unchanged" && remoteStatus !== "unchanged") { - return { type: "conflict", reason: "local_and_remote_changed" } + return converged ? { type: "state_only" } : { type: "conflict", reason: "local_and_remote_changed" } } return { type: "unchanged" } diff --git a/src/handwritten/commands/drive/merge.ts b/src/handwritten/commands/drive/merge.ts index b87642e..cc3d43a 100644 --- a/src/handwritten/commands/drive/merge.ts +++ b/src/handwritten/commands/drive/merge.ts @@ -72,8 +72,8 @@ export function mergeText3(base: string, local: string, remote: string): MergeTe export function conflictCopyPath(path: string, side: ConflictSide, timestamp: string, versionId: string): string { const parsed = pathPosix.parse(path) - const shortVersionId = safeShortVersionId(versionId) - const fileName = `${parsed.name}.${side}-conflict-${timestamp}-${shortVersionId}${parsed.ext}` + // Full version id: truncated ids collide across conflicts on the same file. + const fileName = `${parsed.name}.${side}-conflict-${timestamp}-${safeVersionId(versionId)}${parsed.ext}` if (parsed.dir === "") { return fileName @@ -107,7 +107,7 @@ function normalizeLines(text: string): string[] { return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n") } -function safeShortVersionId(versionId: string): string { +function safeVersionId(versionId: string): string { const safeVersionId = versionId.replace(/[^A-Za-z0-9_-]/g, "_") - return (safeVersionId.length > 0 ? safeVersionId : "unknown").slice(0, 8) + return safeVersionId.length > 0 ? safeVersionId : "unknown" } diff --git a/src/handwritten/commands/drive/realtime.ts b/src/handwritten/commands/drive/realtime.ts index bbe014a..6a9240e 100644 --- a/src/handwritten/commands/drive/realtime.ts +++ b/src/handwritten/commands/drive/realtime.ts @@ -4,7 +4,7 @@ import type { DriveRealtimeSource } from "./watch.js" export type DriveRealtimeMessage = | { type: "ready"; cursor?: string; replayed: number } - | { type: "library_changed"; cursor?: string; path?: string } + | { type: "library_changed"; cursor?: string; path?: string; origin_client_id?: string } | { type: "resync_required"; cursor?: string; reason?: string } | { type: "error"; code?: string; message?: string } | { type: "unknown"; message_type?: string } @@ -138,12 +138,17 @@ export function createDriveRealtimeSource(args: { return } if (message.type === "library_changed") { - handlers?.onEvent({ - debounce_ms: 2000, - reason: "library_changed", - ...(message.cursor === undefined ? {} : { cursor: message.cursor }), - ...(message.path === undefined ? {} : { path: message.path }), - }) + // Own echo: this client caused the change, so a sync would be a no-op; + // only the cursor needs to advance. + const ownEcho = message.origin_client_id !== undefined && message.origin_client_id === currentRealtime.client_id + if (!ownEcho) { + handlers?.onEvent({ + debounce_ms: 2000, + reason: "library_changed", + ...(message.cursor === undefined ? {} : { cursor: message.cursor }), + ...(message.path === undefined ? {} : { path: message.path }), + }) + } if (message.cursor !== undefined) { await persistBestEffort({ ...currentRealtime, last_cursor: message.cursor, last_event_at: driveIsoTimestamp(clock) }) } @@ -235,6 +240,7 @@ export function parseDriveRealtimeMessage(raw: string): DriveRealtimeMessage { type: "library_changed", ...(cursor === undefined ? {} : { cursor }), ...(typeof value.path === "string" ? { path: value.path } : {}), + ...(typeof value.origin_client_id === "string" ? { origin_client_id: value.origin_client_id } : {}), } } if (messageType === "resync_required") { diff --git a/src/handwritten/commands/drive/scanner.ts b/src/handwritten/commands/drive/scanner.ts index d2f11e2..053a1de 100644 --- a/src/handwritten/commands/drive/scanner.ts +++ b/src/handwritten/commands/drive/scanner.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto" import { constants as fsConstants } from "node:fs" import { type Dirent } from "node:fs" import { open, readdir, lstat } from "node:fs/promises" -import { join } from "node:path" +import { join, posix as pathPosix } from "node:path" import { DRIVE_DIR } from "./state.js" import { validateDrivePath } from "./path-policy.js" @@ -11,20 +11,42 @@ export interface DriveFileEntry { sha256: string } +export interface ScanCacheEntry { + mtime_ms: number + size_bytes: number + sha256: string +} + export interface ScanDriveFilesOptions { onPathError?: (path: string, error: unknown) => void | Promise + // Hash cache: entries whose mtime+size match are not re-read. + cache?: Record + onCacheUpdate?: (path: string, entry: ScanCacheEntry) => void + // Scan only this subtree instead of the whole root (drive path, "" = root). + startDrivePath?: string } export async function scanDriveFiles(root: string, options: ScanDriveFilesOptions = {}): Promise> { const candidates: Array<{ path: string; entry: DriveFileEntry }> = [] const files: Record = {} - const absRoot = root - await walk(absRoot, "") + const startDrivePath = options.startDrivePath ?? "" + await walk(startDrivePath === "" ? root : join(root, ...startDrivePath.split("/")), startDrivePath) await addNonCollidingFiles(candidates) return files async function walk(currentPath: string, currentDrivePath: string): Promise { - const entries = await readdir(currentPath, { withFileTypes: true }) + let entries + try { + entries = await readdir(currentPath, { withFileTypes: true }) + } catch (error) { + // The root itself failing to read must abort: an empty scan would look + // like "everything was deleted locally" and propagate remote deletes. + // A vanished subtree start is fine: it just scans as empty. + if (currentDrivePath === startDrivePath && startDrivePath !== "" && isNotFoundError(error)) return + if (currentDrivePath === "" || !isTransientScanError(error) || !options.onPathError) throw error + await options.onPathError(currentDrivePath, error) + return + } entries.sort((left, right) => left.name.localeCompare(right.name)) for (const entry of entries) { if (isExcludedRootEntry(currentDrivePath, entry)) { @@ -43,26 +65,41 @@ export async function scanDriveFiles(root: string, options: ScanDriveFilesOption continue } const nextPath = join(currentPath, entry.name) - const stats = await lstat(nextPath) + try { + const stats = await lstat(nextPath) - if (stats.isSymbolicLink()) { - continue - } + if (stats.isSymbolicLink()) { + continue + } - if (stats.isDirectory()) { - await walk(nextPath, nextDrivePath) - continue - } + if (stats.isDirectory()) { + await walk(nextPath, nextDrivePath) + continue + } - if (!stats.isFile()) { - continue - } + if (!stats.isFile()) { + continue + } - const digest = await hashDriveFile(nextPath) - if (!digest) { - continue + const cached = options.cache?.[nextDrivePath] + if (cached !== undefined && cached.mtime_ms === stats.mtimeMs && cached.size_bytes === stats.size) { + options.onCacheUpdate?.(nextDrivePath, cached) + candidates.push({ path: nextDrivePath, entry: { sha256: cached.sha256, size_bytes: cached.size_bytes } }) + continue + } + + const digest = await hashDriveFile(nextPath) + if (!digest) { + continue + } + options.onCacheUpdate?.(nextDrivePath, { mtime_ms: stats.mtimeMs, size_bytes: digest.sizeBytes, sha256: digest.sha256 }) + candidates.push({ path: nextDrivePath, entry: { sha256: digest.sha256, size_bytes: digest.sizeBytes } }) + } catch (error) { + // Files can vanish or lock mid-scan (rename transitions, editors + // holding locks); skip and let the next sync pass reconcile them. + if (!isTransientScanError(error) || !options.onPathError) throw error + await options.onPathError(nextDrivePath, error) } - candidates.push({ path: nextDrivePath, entry: { sha256: digest.sha256, size_bytes: digest.sizeBytes } }) } } @@ -95,7 +132,100 @@ export async function scanDriveFiles(root: string, options: ScanDriveFilesOption } } -function isInternalSyncArtifactName(name: string): boolean { +// Incremental rescan for watch mode: start from the cached full view and +// re-stat only the dirty paths (plus their subtrees for directories). Missed +// fs events self-heal on the next full scan (initial / retry triggers). +export async function rescanDriveFiles( + root: string, + dirtyPaths: string[], + options: ScanDriveFilesOptions = {}, +): Promise> { + const kept: Record = { ...(options.cache ?? {}) } + + for (const dirtyPath of new Set(dirtyPaths)) { + if (dirtyPath === "" || isInternalSyncArtifactName(pathPosix.basename(dirtyPath))) continue + try { + validateDrivePath(dirtyPath) + } catch (error) { + if (!options.onPathError) throw error + await options.onPathError(dirtyPath, error) + continue + } + + removePathAndChildren(kept, dirtyPath) + const absPath = join(root, ...dirtyPath.split("/")) + let stats + try { + stats = await lstat(absPath) + } catch (error) { + if (isNotFoundError(error)) continue + if (!isTransientScanError(error) || !options.onPathError) throw error + await options.onPathError(dirtyPath, error) + continue + } + + if (stats.isSymbolicLink()) continue + + if (stats.isDirectory()) { + await scanDriveFiles(root, { + ...options, + startDrivePath: dirtyPath, + onCacheUpdate: (path, entry) => { + kept[path] = entry + }, + }) + continue + } + + if (!stats.isFile()) continue + + try { + const cached = options.cache?.[dirtyPath] + if (cached !== undefined && cached.mtime_ms === stats.mtimeMs && cached.size_bytes === stats.size) { + kept[dirtyPath] = cached + continue + } + const digest = await hashDriveFile(absPath) + if (!digest) continue + kept[dirtyPath] = { mtime_ms: stats.mtimeMs, size_bytes: digest.sizeBytes, sha256: digest.sha256 } + } catch (error) { + if (!isTransientScanError(error) || !options.onPathError) throw error + await options.onPathError(dirtyPath, error) + } + } + + const files: Record = {} + for (const [path, entry] of Object.entries(kept)) { + options.onCacheUpdate?.(path, entry) + files[path] = { sha256: entry.sha256, size_bytes: entry.size_bytes } + } + return files +} + +function removePathAndChildren(view: Record, path: string): void { + delete view[path] + const prefix = `${path}/` + for (const key of Object.keys(view)) { + if (key.startsWith(prefix)) delete view[key] + } +} + +function isNotFoundError(error: unknown): boolean { + return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT" +} + +const TRANSIENT_SCAN_ERROR_CODES = new Set(["ENOENT", "EPERM", "EBUSY"]) + +function isTransientScanError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + typeof (error as NodeJS.ErrnoException).code === "string" && + TRANSIENT_SCAN_ERROR_CODES.has((error as NodeJS.ErrnoException).code as string) + ) +} + +export function isInternalSyncArtifactName(name: string): boolean { if (!name.startsWith(".") || !name.endsWith(".tmp")) return false return ( name.includes(".wspc-download-") || diff --git a/src/handwritten/commands/drive/state.ts b/src/handwritten/commands/drive/state.ts index 349355c..8ef2c40 100644 --- a/src/handwritten/commands/drive/state.ts +++ b/src/handwritten/commands/drive/state.ts @@ -37,6 +37,12 @@ export interface DriveRealtimeState { last_event_at?: string } +export interface DriveScanCacheEntry { + mtime_ms: number + size_bytes: number + sha256: string +} + export interface DriveState { schema_version: 1 library_id: string @@ -45,6 +51,9 @@ export interface DriveState { entries: Record conflicts: Record realtime?: DriveRealtimeState + scan_cache?: Record + // Latest manifest cursor seen; enables since_cursor delta fetches. + manifest_cursor?: string } export function statePath(root: string): string { @@ -269,7 +278,9 @@ function isValidDriveState(value: unknown): value is DriveState { typeof value.updated_at !== "string" || !isRecord(value.entries) || !isRecord(value.conflicts) || - (value.realtime !== undefined && !isDriveRealtimeState(value.realtime)) + (value.realtime !== undefined && !isDriveRealtimeState(value.realtime)) || + (value.scan_cache !== undefined && !isRecord(value.scan_cache)) || + (value.manifest_cursor !== undefined && typeof value.manifest_cursor !== "string") ) { return false } @@ -279,5 +290,19 @@ function isValidDriveState(value: unknown): value is DriveState { for (const conflict of Object.values(value.conflicts)) { if (!isDriveConflict(conflict)) return false } + if (value.scan_cache !== undefined) { + for (const entry of Object.values(value.scan_cache)) { + if (!isDriveScanCacheEntry(entry)) return false + } + } return true } + +function isDriveScanCacheEntry(value: unknown): value is DriveScanCacheEntry { + return ( + isRecord(value) && + typeof value.mtime_ms === "number" && + typeof value.size_bytes === "number" && + typeof value.sha256 === "string" + ) +} diff --git a/src/handwritten/commands/drive/sync.ts b/src/handwritten/commands/drive/sync.ts index 7d7b355..2b3cfd3 100644 --- a/src/handwritten/commands/drive/sync.ts +++ b/src/handwritten/commands/drive/sync.ts @@ -9,7 +9,7 @@ import { decideDriveAction, type DriveAction } from "./decision.js" import { normalizeRemoteManifest } from "./manifest.js" import { classifyMergeText, conflictCopyPath, mergeText3 } from "./merge.js" import { resolveInsideRoot, validateDrivePath } from "./path-policy.js" -import { scanDriveFiles } from "./scanner.js" +import { rescanDriveFiles, scanDriveFiles } from "./scanner.js" import { assertLocalAbsentBeforeRemoteDelete, assertLocalSafeForDownload, @@ -26,17 +26,25 @@ import { writeDriveState, withDriveLock, type DriveConflict, + type DriveScanCacheEntry, type DriveState, type DriveStateEntry, } from "./state.js" import { render } from "../../output/render.js" -import type { DriveManifestResponse, UploadDriveFileResponse } from "../../../generated/sdk/index.js" +import type { DriveManifestResponse, MoveDriveFileResponse, UploadDriveFileResponse } from "../../../generated/sdk/index.js" type RemoteEntry = DriveManifestResponse["entries"][number] type ProcessPathResult = { state: DriveState; stop: boolean } +// The manifest endpoint gained delta fields (latest_cursor / resync_required) +// that the generated SDK type does not model yet. +export type DriveManifestResult = DriveManifestResponse & { + latest_cursor?: string + resync_required?: boolean +} + export interface DriveSyncApi { - getManifest(id: string, cursor?: string): Promise + getManifest(id: string, cursor?: string, sinceCursor?: string): Promise uploadFile( id: string, path: string, @@ -46,9 +54,15 @@ export interface DriveSyncApi { ): Promise downloadFile(id: string, path: string, versionId?: string): Promise deleteFile(id: string, path: string, expectedEntryVersion: number): Promise + moveFile?( + id: string, + fromPath: string, + toPath: string, + expectedEntryVersion?: number, + ): Promise } -export type DriveSyncPathAction = DriveAction["type"] | "error" | "merged" +export type DriveSyncPathAction = DriveAction["type"] | "error" | "merged" | "move" export interface DriveSyncSummary { uploaded: number @@ -85,27 +99,52 @@ function isActionableAction(action: DriveAction): boolean { return action.type !== "unchanged" && action.type !== "state_only" && action.type !== "remove_state" } +export interface DriveSyncOnceOptions { + // Incremental scan: only these paths are re-stat/hashed; the rest of the + // local view comes from state.scan_cache. Requires a warm cache. + dirtyPaths?: string[] +} + export async function runDriveSyncOnce( root: string, api?: DriveSyncApi, clock: DriveClock = systemDriveClock, onProgress?: DriveSyncProgress, debug: DriveDebugLogger = noopDriveDebugLogger, + options: DriveSyncOnceOptions = {}, ): Promise { return withDriveLock(root, async () => { let state = await readDriveState(root) - const syncApi = api ?? (await createDriveApi()) + const syncApi = api ?? (await createDriveApi({ clientId: state.realtime?.client_id })) const summary = emptySummary() const blockedPaths = new Set() const scanStartedMs = Date.now() - const localFiles = await scanDriveFiles(root, { - onPathError: async (path, error) => { - await recordPathError(summary, blockedPaths, path, error) + const nextScanCache: Record = {} + const scanOptions = { + cache: state.scan_cache, + onCacheUpdate: (path: string, entry: DriveScanCacheEntry) => { + nextScanCache[path] = entry }, - }) + onPathError: async (path: string, error: unknown) => { + await recordPathError(summary, blockedPaths, path, error, { debug, op: "scan" }) + }, + } + const useIncrementalScan = options.dirtyPaths !== undefined && state.scan_cache !== undefined + const localFiles = useIncrementalScan + ? await rescanDriveFiles(root, options.dirtyPaths!, scanOptions) + : await scanDriveFiles(root, scanOptions) + if (JSON.stringify(state.scan_cache ?? {}) !== JSON.stringify(nextScanCache)) { + state = { ...state, scan_cache: nextScanCache } + await writeDriveState(root, state, clock) + } const scanMs = Date.now() - scanStartedMs const manifestStartedMs = Date.now() - const remoteFiles = await fetchRemoteManifest(root, state, syncApi, summary, blockedPaths) + const manifest = await fetchRemoteManifest(root, state, syncApi, summary, blockedPaths, debug) + const remoteFiles = manifest.remoteFiles + if (manifest.manifestCursor !== undefined && manifest.manifestCursor !== state.manifest_cursor) { + state = { ...state, manifest_cursor: manifest.manifestCursor } + await writeDriveState(root, state, clock) + } const manifestMs = Date.now() - manifestStartedMs const paths = Array.from( new Set([...Object.keys(localFiles), ...Object.keys(remoteFiles), ...Object.keys(state.entries)]), @@ -113,16 +152,34 @@ export async function runDriveSyncOnce( .filter((path) => !blockedPaths.has(path)) .sort((left, right) => left.localeCompare(right)) + const movedPaths = await applyRenamesAsMoves({ + root, + state, + api: syncApi, + paths, + localFiles, + remoteFiles, + summary, + clock, + debug, + onStateChange: (nextState) => { + state = nextState + }, + }) + // decideDriveAction is pure and reads only this path's slices of the // initial state, so this pre-pass total matches the loop's actions. - const total = paths.filter((path) => - isActionableAction(decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path])), + const total = paths.filter( + (path) => + !movedPaths.has(path) && + isActionableAction(decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path])), ).length let processed = 0 onProgress?.(processed, total) const processStartedMs = Date.now() for (const path of paths) { + if (movedPaths.has(path)) continue const remote = remoteFiles[path] const local = localFiles[path] const action = decideDriveAction(state.entries[path], local, remote) @@ -130,7 +187,7 @@ export async function runDriveSyncOnce( debug.log("decision", decisionFields(path, action, state.entries[path], local, remote)) } const previousConflict = state.conflicts[path] - const result = await processPath({ root, state, api: syncApi, path, action, remote, local, summary, clock }) + const result = await processPath({ root, state, api: syncApi, path, action, remote, local, summary, clock, debug }) state = result.state const recordedConflict = state.conflicts[path] if (recordedConflict !== undefined && recordedConflict !== previousConflict) { @@ -198,12 +255,37 @@ async function fetchRemoteManifest( api: DriveSyncApi, summary: DriveSyncSummary, blockedPaths: Set, -): Promise> { + debug: DriveDebugLogger, +): Promise<{ remoteFiles: Record; manifestCursor: string | undefined }> { + if (state.manifest_cursor !== undefined) { + const delta = await api.getManifest(state.library_id, undefined, state.manifest_cursor) + if (delta.resync_required !== true) { + const remoteFiles = remoteViewFromState(state) + const changed = delta.entries.filter((entry) => entry.deleted_at === undefined) + const normalized = normalizeRemoteManifest(root, changed) + for (const pathError of normalized.pathErrors) { + await recordPathError(summary, blockedPaths, pathError.path, pathError.error, { + appendPathResult: pathError.appendPathResult, + debug, + op: "manifest", + }) + } + for (const entry of delta.entries) { + if (entry.deleted_at !== undefined) delete remoteFiles[entry.path] + } + Object.assign(remoteFiles, normalized.remoteFiles) + return { remoteFiles, manifestCursor: delta.latest_cursor ?? state.manifest_cursor } + } + // resync_required: cursor pruned or invalid, fall back to a full fetch. + } + const entries: RemoteEntry[] = [] let cursor: string | undefined + let latestCursor: string | undefined do { const page = await api.getManifest(state.library_id, cursor) entries.push(...page.entries) + if (page.latest_cursor !== undefined) latestCursor = page.latest_cursor cursor = page.next_cursor ?? undefined } while (cursor !== undefined) @@ -211,9 +293,30 @@ async function fetchRemoteManifest( for (const pathError of normalized.pathErrors) { await recordPathError(summary, blockedPaths, pathError.path, pathError.error, { appendPathResult: pathError.appendPathResult, + debug, + op: "manifest", }) } - return normalized.remoteFiles + return { remoteFiles: normalized.remoteFiles, manifestCursor: latestCursor } +} + +// Reconstructs the last-known remote view from base state so a manifest delta +// only has to carry what changed since the stored cursor. +function remoteViewFromState(state: DriveState): Record { + const remoteFiles: Record = {} + for (const [path, entry] of Object.entries(state.entries)) { + remoteFiles[path] = { + id: entry.entry_id, + path, + kind: "file", + entry_version: entry.entry_version, + size_bytes: entry.size_bytes, + updated_at: entry.last_synced_at, + ...(entry.current_version_id === undefined ? {} : { current_version_id: entry.current_version_id }), + ...(entry.content_sha256 === undefined ? {} : { content_sha256: entry.content_sha256 }), + } + } + return remoteFiles } async function processPath(args: { @@ -226,8 +329,9 @@ async function processPath(args: { local: { sha256: string; size_bytes: number } | undefined summary: DriveSyncSummary clock: DriveClock + debug: DriveDebugLogger }): Promise { - const { root, state, api, path, action, remote, local, summary, clock } = args + const { root, state, api, path, action, remote, local, summary, clock, debug } = args summary.paths.push({ path, action: action.type }) let durableStateRequired = false @@ -335,35 +439,17 @@ async function processPath(args: { summary.conflicts += 1 return { state: nextState, stop: false } } - const conflictCopyState = await recordRemoteConflictCopy({ - root, - state, - api, - path, - reason: action.reason, - type: "edit_edit", - remote, - clock, - }) - if (conflictCopyState) { - summary.conflicts += 1 - return { state: conflictCopyState, stop: false } + const resolved = await resolveConflictWithLocalAsMain({ root, state, api, path, remote, local, clock }) + if (resolved) { + recordResolvedConflict(summary, debug, path, resolved.copyPath, action.reason, "edit_edit") + return { state: resolved.state, stop: false } } } if (action.reason === "local_and_remote_without_base") { - const conflictCopyState = await recordRemoteConflictCopy({ - root, - state, - api, - path, - reason: action.reason, - type: "create_create", - remote, - clock, - }) - if (conflictCopyState) { - summary.conflicts += 1 - return { state: conflictCopyState, stop: false } + const resolved = await resolveConflictWithLocalAsMain({ root, state, api, path, remote, local, clock }) + if (resolved) { + recordResolvedConflict(summary, debug, path, resolved.copyPath, action.reason, "create_create") + return { state: resolved.state, stop: false } } } if (action.reason === "local_changed_remote_deleted") { @@ -404,11 +490,11 @@ async function processPath(args: { summary.paths[summary.paths.length - 1] = { path, action: "conflict" } return { state: nextState, stop: false } } catch (writeError) { - await recordPathError(summary, undefined, path, writeError) + await recordPathError(summary, undefined, path, writeError, { debug, op: "process" }) return { state, stop: durableStateRequired } } } - await recordPathError(summary, undefined, path, error) + await recordPathError(summary, undefined, path, error, { debug, op: "process" }) return { state, stop: durableStateRequired } } return { state, stop: false } @@ -486,6 +572,127 @@ async function downloadBytes(api: DriveSyncApi, libraryId: string, path: string, return new Uint8Array(await response.arrayBuffer()) } +// Detects local renames (a delete_remote and an upload_create with the same +// content hash) and applies them via the server move API, preserving version +// history and skipping a full re-upload. Only unambiguous 1:1 hash pairs are +// moved; anything else falls back to normal upload + delete processing. +async function applyRenamesAsMoves(args: { + root: string + state: DriveState + api: DriveSyncApi + paths: string[] + localFiles: Record + remoteFiles: Record + summary: DriveSyncSummary + clock: DriveClock + debug: DriveDebugLogger + onStateChange: (state: DriveState) => void +}): Promise> { + const { root, api, paths, localFiles, remoteFiles, summary, clock, debug, onStateChange } = args + const movedPaths = new Set() + if (api.moveFile === undefined) return movedPaths + + let state = args.state + const deletesBySha = new Map() + const createsBySha = new Map() + for (const path of paths) { + const action = decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path]) + if (action.type === "delete_remote") { + const sha = state.entries[path]?.last_local_sha256 ?? state.entries[path]?.content_sha256 + if (sha !== undefined) deletesBySha.set(sha, [...(deletesBySha.get(sha) ?? []), path]) + } + if (action.type === "upload_create") { + const sha = localFiles[path]?.sha256 + if (sha !== undefined) createsBySha.set(sha, [...(createsBySha.get(sha) ?? []), path]) + } + } + + for (const [sha, fromPaths] of deletesBySha) { + const toPaths = createsBySha.get(sha) + if (fromPaths.length !== 1 || toPaths === undefined || toPaths.length !== 1) continue + const fromPath = fromPaths[0]! + const toPath = toPaths[0]! + const entry = state.entries[fromPath] + const local = localFiles[toPath] + if (entry === undefined || local === undefined) continue + try { + const moved = await api.moveFile(state.library_id, fromPath, toPath, entry.entry_version) + const nextState = cloneDriveState(state) + delete nextState.entries[fromPath] + delete nextState.conflicts[fromPath] + nextState.entries[toPath] = stateEntryFromRemote(moved.entry, local.sha256, clock) + delete nextState.conflicts[toPath] + await writeDriveState(root, nextState, clock) + state = nextState + onStateChange(nextState) + movedPaths.add(fromPath) + movedPaths.add(toPath) + summary.paths.push({ path: toPath, action: "move" }) + debug.log("decision", { path: toPath, action: "move", from_path: fromPath }) + } catch (error) { + // Move is an optimization: on any failure fall back to upload + delete. + debug.log("error", { path: toPath, op: "move", message: errorMessage(error) }) + } + } + return movedPaths +} + +function recordResolvedConflict( + summary: DriveSyncSummary, + debug: DriveDebugLogger, + path: string, + copyPath: string, + reason: string, + type: NonNullable, +): void { + summary.conflicts += 1 + summary.conflict_paths.push(copyPath) + const lastPath = summary.paths.at(-1) + if (lastPath?.path === path) { + lastPath.conflict_paths = [copyPath] + } + debug.log("conflict", { path, reason, type, strategy: "conflict_copy", conflict_paths: [copyPath] }) +} + +// Resolves an edit/edit or create/create conflict in one pass: the remote +// version is preserved as a conflict copy, the local content is uploaded as +// the new main version, and the base state advances so the next sync does not +// re-detect the same conflict. +async function resolveConflictWithLocalAsMain(args: { + root: string + state: DriveState + api: DriveSyncApi + path: string + remote: RemoteEntry | undefined + local: { sha256: string; size_bytes: number } | undefined + clock: DriveClock +}): Promise<{ state: DriveState; copyPath: string } | undefined> { + const { root, state, api, path, remote, local, clock } = args + const remoteVersionId = remote?.current_version_id + if (!remote || remoteVersionId === undefined || !local) { + return undefined + } + + const previous = state.conflicts[path] + let copyPath: string + if (previous !== undefined && (await canReuseConflictCopy(root, previous, remoteVersionId))) { + copyPath = previous.conflict_paths![0]! + } else { + const remoteBytes = await downloadBytes(api, state.library_id, path, remoteVersionId) + copyPath = await writeConflictCopy(root, path, "remote", remoteVersionId, remoteBytes, clock) + } + + const localPath = resolveInsideRoot(root, path) + const { body, digest } = await readStableUploadBody(localPath, local) + const uploaded = await api.uploadFile(state.library_id, path, body, digest, remote.entry_version) + + const nextState = cloneDriveState(state) + nextState.entries[path] = stateEntryFromRemote(uploaded.entry, digest, clock) + delete nextState.conflicts[path] + await writeDriveState(root, nextState, clock) + return { state: nextState, copyPath } +} + async function recordRemoteConflictCopy(args: { root: string state: DriveState @@ -694,7 +901,7 @@ async function recordPathError( blockedPaths: Set | undefined, path: string, error: unknown, - options: { appendPathResult?: boolean } = {}, + options: { appendPathResult?: boolean; debug?: DriveDebugLogger; op?: string } = {}, ): Promise { blockedPaths?.add(path) const lastPath = summary.paths.at(-1) @@ -703,7 +910,13 @@ async function recordPathError( } else { summary.paths.push({ path, action: "error" }) } - void errorMessage(error) + const code = error instanceof Error && "code" in error ? (error as NodeJS.ErrnoException).code : undefined + options.debug?.log("error", { + path, + ...(options.op === undefined ? {} : { op: options.op }), + ...(code === undefined ? {} : { code }), + message: errorMessage(error), + }) summary.errors += 1 } diff --git a/src/handwritten/commands/drive/watch.ts b/src/handwritten/commands/drive/watch.ts index 5cdb4dc..7905409 100644 --- a/src/handwritten/commands/drive/watch.ts +++ b/src/handwritten/commands/drive/watch.ts @@ -1,11 +1,12 @@ import { Command } from "commander" import chokidar from "chokidar" import { watch as fsWatch } from "node:fs" -import { relative, resolve } from "node:path" +import { basename, relative, resolve } from "node:path" import { loadRealtimeAuthHeaders } from "../../auth/load-sdk-client.js" import { render, shouldOutputJson } from "../../output/render.js" import { createDriveDebugLogger, noopDriveDebugLogger, type DriveDebugLogger } from "./debug-log.js" import { createDriveRealtimeSource } from "./realtime.js" +import { isInternalSyncArtifactName } from "./scanner.js" import { DRIVE_DIR, ensureDriveRealtimeState, readDriveState, writeDriveRealtimeState } from "./state.js" import { runDriveSyncOnce, type DriveSyncProgress, type DriveSyncSummary } from "./sync.js" @@ -35,7 +36,7 @@ export interface DriveWatchOptions { source?: DriveWatchSource realtimeSource?: DriveRealtimeSource readState?: typeof readDriveState - runSync?: (root: string, onProgress?: DriveSyncProgress) => Promise + runSync?: (root: string, onProgress?: DriveSyncProgress, dirtyPaths?: string[]) => Promise once?: boolean debounceMs?: number remoteDebounceMs?: number @@ -48,7 +49,8 @@ export async function runDriveWatch(root: string, options: DriveWatchOptions = { const dbg = options.debugLogger ?? (options.debug ? createDriveDebugLogger(root) : noopDriveDebugLogger) const runSync = options.runSync ?? - ((syncRoot: string, onProgress?: DriveSyncProgress) => runDriveSyncOnce(syncRoot, undefined, undefined, onProgress, dbg)) + ((syncRoot: string, onProgress?: DriveSyncProgress, dirtyPaths?: string[]) => + runDriveSyncOnce(syncRoot, undefined, undefined, onProgress, dbg, dirtyPaths === undefined ? {} : { dirtyPaths })) const debounceMs = options.debounceMs ?? 500 const remoteDebounceMs = options.remoteDebounceMs ?? 2000 // Watch is a long-lived event stream: json mode must emit NDJSON (one event @@ -64,6 +66,10 @@ export async function runDriveWatch(root: string, options: DriveWatchOptions = { render({ kind: "drive_watch", display: { shape: "object" } }, event) }) let nextTrigger = "initial" + // Paths touched by fs events since the last successful sync. Local-trigger + // syncs re-stat only these; other triggers (initial/retry/remote) do a full + // reconciliation walk that also self-heals any missed events. + const dirtyPaths = new Set() let debounceTimer: NodeJS.Timeout | undefined let debounceDeadlineMs: number | undefined let retryTimer: NodeJS.Timeout | undefined @@ -92,9 +98,15 @@ export async function runDriveWatch(root: string, options: DriveWatchOptions = { emit({ kind: "drive_sync_start" }) dbg.log("sync_start", { trigger: nextTrigger }) const startedAtMs = Date.now() - const summary = await runSync(root, (processed, total) => { - emit({ kind: "drive_sync_progress", processed, total }) - }) + const dirtySnapshot = nextTrigger === "local" ? [...dirtyPaths] : undefined + dirtyPaths.clear() + const summary = await runSync( + root, + (processed, total) => { + emit({ kind: "drive_sync_progress", processed, total }) + }, + dirtySnapshot, + ) emit({ kind: "drive_sync_once", ...summary }) dbg.log("sync_end", { duration_ms: Date.now() - startedAtMs, @@ -207,7 +219,12 @@ export async function runDriveWatch(root: string, options: DriveWatchOptions = { source = options.source ?? createDefaultWatchSource(root) source.onChange((path) => { if (isDriveInternalPath(root, path)) return - dbg.log("fs_event", { path: relative(root, path) }) + // Download/backup/merge temp files are our own writes; reacting to them + // would chain an extra sync after every applied remote change. + if (isInternalSyncArtifactName(basename(path))) return + const drivePath = relative(root, path).replace(/\\/g, "/") + dirtyPaths.add(drivePath) + dbg.log("fs_event", { path: drivePath }) scheduleSync(debounceMs, "local") }) diff --git a/test/handwritten/drive/api.test.ts b/test/handwritten/drive/api.test.ts index c906e01..f4c2b25 100644 --- a/test/handwritten/drive/api.test.ts +++ b/test/handwritten/drive/api.test.ts @@ -57,7 +57,7 @@ function mkReq(input: RequestInfo | URL, init?: RequestInit): Request { return new Request(input, init) } -async function mkDriveApi(fetchImpl: typeof fetch, apiBase = "https://api.wspc.ai") { +async function mkDriveApi(fetchImpl: typeof fetch, apiBase = "https://api.wspc.ai", clientId?: string) { const dir = await mkdtemp(join(tmpdir(), "wspc-drive-api-")) const store = new ConfigStore({ configDir: dir }) await store.write({ @@ -73,7 +73,7 @@ async function mkDriveApi(fetchImpl: typeof fetch, apiBase = "https://api.wspc.a }, }, }) - return createDriveApi({ store, fetchImpl }) + return createDriveApi({ store, fetchImpl, ...(clientId === undefined ? {} : { clientId }) }) } describe("createDriveApi", () => { @@ -165,6 +165,25 @@ describe("createDriveApi", () => { expect(seenHeaders).toEqual(["drive-sync", "drive-sync"]) }) + it("appends the session client id to x-wspc-client when provided", async () => { + const seenHeaders: Array = [] + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const req = mkReq(input, init) + seenHeaders.push(req.headers.get("x-wspc-client")) + const payload = req.method === "PUT" ? DRIVE_UPLOAD : DRIVE_DELETE + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as typeof fetch + + const api = await mkDriveApi(fetchImpl, "https://api.wspc.ai", "drvcli_abc123") + await api.uploadFile("lib_1", "notes/hello.txt", new TextEncoder().encode("hello"), "3a6eb7", 2) + await api.deleteFile("lib_1", "notes/hello.txt", 2) + + expect(seenHeaders).toEqual(["drive-sync/drvcli_abc123", "drive-sync/drvcli_abc123"]) + }) + it("throws useful errors for failed JSON SDK calls", async () => { const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ error: { code: "SERVER_ERROR", message: "boom" } }), { diff --git a/test/handwritten/drive/decision.test.ts b/test/handwritten/drive/decision.test.ts index 65ab124..0f8c567 100644 --- a/test/handwritten/drive/decision.test.ts +++ b/test/handwritten/drive/decision.test.ts @@ -154,4 +154,20 @@ describe("drive decision", () => { reason: "local_and_remote_changed", }) }) + + it("treats identical local and remote content as converged instead of conflicting", () => { + const entry: DecisionEntry = { entry_version: 1, content_sha256: "old", last_local_sha256: "old" } + + expect(decideDriveAction(entry, { sha256: "same" }, { content_sha256: "same", entry_version: 2 })).toEqual({ + type: "state_only", + }) + }) + + it("treats identical content as converged even when the local base is unknown", () => { + const entry: DecisionEntry = { entry_version: 1 } + + expect(decideDriveAction(entry, { sha256: "same" }, { content_sha256: "same", entry_version: 2 })).toEqual({ + type: "state_only", + }) + }) }) diff --git a/test/handwritten/drive/merge.test.ts b/test/handwritten/drive/merge.test.ts index f5d84a7..ea41ee0 100644 --- a/test/handwritten/drive/merge.test.ts +++ b/test/handwritten/drive/merge.test.ts @@ -36,9 +36,12 @@ describe("drive merge helpers", () => { expect("text" in result).toBe(false) }) - it("builds conflict copy paths next to the original path", () => { + it("builds conflict copy paths next to the original path with the full version id", () => { expect(conflictCopyPath("notes/today.md", "remote", "20260621T101000Z", "ver_remote")).toBe( - "notes/today.remote-conflict-20260621T101000Z-ver_remo.md", + "notes/today.remote-conflict-20260621T101000Z-ver_remote.md", ) + expect( + conflictCopyPath("large-doc-1.md", "remote", "20260713T044616Z", "fvr_01KXCWQTX8K7NMK8VW3EKR2166"), + ).toBe("large-doc-1.remote-conflict-20260713T044616Z-fvr_01KXCWQTX8K7NMK8VW3EKR2166.md") }) }) diff --git a/test/handwritten/drive/realtime.test.ts b/test/handwritten/drive/realtime.test.ts index 2ab1763..965fd66 100644 --- a/test/handwritten/drive/realtime.test.ts +++ b/test/handwritten/drive/realtime.test.ts @@ -248,6 +248,51 @@ describe("drive realtime helpers", () => { expect(events).toContainEqual({ debounce_ms: 2000, cursor: "c2", path: "notes.md", reason: "library_changed" }) }) + it("suppresses own-echo library_changed events but still advances the cursor", async () => { + const connect = fakeConnector() + const updates: DriveRealtimeState[] = [] + const events: unknown[] = [] + const source = createDriveRealtimeSource(sourceArgs({ + connect, + clock: { now: () => DateTime.fromISO("2026-06-21T10:05:00.000Z", { setZone: true }) }, + writeRealtimeState: async (next) => { + updates.push(next) + }, + })) + + await source.start(realtimeHandlers(events)) + connect.connections[0]?.handlers.message(JSON.stringify({ + type: "library_changed", + cursor: "c9", + path: "notes.md", + origin_client_id: "drvcli_abc", + })) + await flushPromises() + + expect(events).toEqual([]) + expect(updates).toEqual([{ + client_id: "drvcli_abc", + last_cursor: "c9", + last_event_at: "2026-06-21T10:05:00.000Z", + }]) + }) + + it("still emits library_changed from other clients", async () => { + const connect = fakeConnector() + const events: unknown[] = [] + const source = createDriveRealtimeSource(sourceArgs({ connect })) + + await source.start(realtimeHandlers(events)) + connect.connections[0]?.handlers.message(JSON.stringify({ + type: "library_changed", + cursor: "c10", + origin_client_id: "drvcli_other", + })) + await flushPromises() + + expect(events).toContainEqual({ debounce_ms: 2000, cursor: "c10", reason: "library_changed" }) + }) + it("emits library_changed events even when cursor persistence is locked", async () => { const connect = fakeConnector() const events: unknown[] = [] diff --git a/test/handwritten/drive/scanner.test.ts b/test/handwritten/drive/scanner.test.ts index 2e6f01d..49f85aa 100644 --- a/test/handwritten/drive/scanner.test.ts +++ b/test/handwritten/drive/scanner.test.ts @@ -142,21 +142,155 @@ describe("drive scanner", () => { { path: "A.txt", message: expect.stringContaining("LOCAL_PATH_CASE_CONFLICT") }, ]) }) + it("reuses the cached hash when mtime and size are unchanged", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-scan-cache-")) + await writeFile(join(root, "notes.txt"), "hello") + const { lstat } = await import("node:fs/promises") + const stats = await lstat(join(root, "notes.txt")) + + const files = await scanDriveFiles(root, { + cache: { + "notes.txt": { mtime_ms: stats.mtimeMs, size_bytes: stats.size, sha256: "cached-sha" }, + }, + }) + + expect(files["notes.txt"]).toEqual({ sha256: "cached-sha", size_bytes: stats.size }) + }) + + it("rehashes when mtime or size differ from the cache and reports fresh cache entries", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-scan-cache-miss-")) + await writeFile(join(root, "notes.txt"), "hello") + const updates: Array<{ path: string; sha256: string }> = [] + + const files = await scanDriveFiles(root, { + cache: { + "notes.txt": { mtime_ms: 1, size_bytes: 5, sha256: "stale-sha" }, + }, + onCacheUpdate: (path, entry) => updates.push({ path, sha256: entry.sha256 }), + }) + + expect(files["notes.txt"]).toEqual({ sha256: sha256("hello"), size_bytes: 5 }) + expect(updates).toEqual([{ path: "notes.txt", sha256: sha256("hello") }]) + }) + + it("reports and skips a file that disappears between readdir and lstat", async () => { + const errors: Array<{ path: string; code?: string }> = [] + const mockedScanDriveFiles = await importScannerWithMockFiles( + { "gone.txt": "gone", "ok.txt": "ok" }, + { + lstatError: (path) => (path.endsWith("gone.txt") ? errnoError("ENOENT") : undefined), + }, + ) + + const files = await mockedScanDriveFiles("/mock", { + onPathError: (path, error) => { + errors.push({ path, code: (error as NodeJS.ErrnoException).code }) + }, + }) + + expect(files).toEqual({ "ok.txt": { sha256: sha256("ok"), size_bytes: 2 } }) + expect(errors).toEqual([{ path: "gone.txt", code: "ENOENT" }]) + }) + + it("reports and skips a locked file during hashing", async () => { + const errors: Array<{ path: string; code?: string }> = [] + const mockedScanDriveFiles = await importScannerWithMockFiles( + { "locked.txt": "locked", "ok.txt": "ok" }, + { + openError: (path) => (path.endsWith("locked.txt") ? errnoError("EBUSY") : undefined), + }, + ) + + const files = await mockedScanDriveFiles("/mock", { + onPathError: (path, error) => { + errors.push({ path, code: (error as NodeJS.ErrnoException).code }) + }, + }) + + expect(files).toEqual({ "ok.txt": { sha256: sha256("ok"), size_bytes: 2 } }) + expect(errors).toEqual([{ path: "locked.txt", code: "EBUSY" }]) + }) + + it("reports and skips a directory that disappears mid-walk", async () => { + const errors: Array<{ path: string; code?: string }> = [] + const mockedScanDriveFiles = await importScannerWithMockFiles( + { "ok.txt": "ok" }, + { + extraDirs: ["renamed-away"], + readdirError: (path) => (path.endsWith("renamed-away") ? errnoError("ENOENT") : undefined), + }, + ) + + const files = await mockedScanDriveFiles("/mock", { + onPathError: (path, error) => { + errors.push({ path, code: (error as NodeJS.ErrnoException).code }) + }, + }) + + expect(files).toEqual({ "ok.txt": { sha256: sha256("ok"), size_bytes: 2 } }) + expect(errors).toEqual([{ path: "renamed-away", code: "ENOENT" }]) + }) + + it("still fails the scan when the root itself cannot be read", async () => { + const mockedScanDriveFiles = await importScannerWithMockFiles( + {}, + { + readdirError: () => errnoError("ENOENT"), + }, + ) + + await expect(mockedScanDriveFiles("/mock", { onPathError: () => undefined })).rejects.toMatchObject({ code: "ENOENT" }) + }) + + it("rethrows unexpected fs error codes even with a handler", async () => { + const mockedScanDriveFiles = await importScannerWithMockFiles( + { "weird.txt": "weird" }, + { + lstatError: (path) => (path.endsWith("weird.txt") ? errnoError("EIO") : undefined), + }, + ) + + await expect(mockedScanDriveFiles("/mock", { onPathError: () => undefined })).rejects.toMatchObject({ code: "EIO" }) + }) }) -async function importScannerWithMockFiles(files: Record): Promise { +function errnoError(code: string): NodeJS.ErrnoException { + const error = new Error(`${code}: mock fs error`) as NodeJS.ErrnoException + error.code = code + return error +} + +interface MockFsFailures { + lstatError?: (path: string) => Error | undefined + openError?: (path: string) => Error | undefined + readdirError?: (path: string) => Error | undefined + extraDirs?: string[] +} + +async function importScannerWithMockFiles(files: Record, failures: MockFsFailures = {}): Promise { vi.resetModules() vi.doMock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal() return { ...actual, - readdir: vi.fn(async () => - Object.keys(files).map((name) => ({ - name, - })), - ), - lstat: vi.fn(async () => fakeStats()), + readdir: vi.fn(async (path: string) => { + const failure = failures.readdirError?.(String(path)) + if (failure) throw failure + if (basename(String(path)) !== "mock") return [] + return [ + ...Object.keys(files).map((name) => ({ name })), + ...(failures.extraDirs ?? []).map((name) => ({ name, isDir: true })), + ] + }), + lstat: vi.fn(async (path: string) => { + const failure = failures.lstatError?.(String(path)) + if (failure) throw failure + const isDir = (failures.extraDirs ?? []).includes(basename(String(path))) + return fakeStats(isDir) + }), open: vi.fn(async (path: string) => { + const failure = failures.openError?.(String(path)) + if (failure) throw failure const name = basename(path) const content = files[name] if (content === undefined) throw new Error(`unexpected mock file: ${path}`) @@ -172,11 +306,11 @@ async function importScannerWithMockFiles(files: Record): Promis return imported.scanDriveFiles } -function fakeStats() { +function fakeStats(isDirectory = false) { return { isSymbolicLink: () => false, - isDirectory: () => false, - isFile: () => true, + isDirectory: () => isDirectory, + isFile: () => !isDirectory, ino: 1, dev: 1, } diff --git a/test/handwritten/drive/sync.test.ts b/test/handwritten/drive/sync.test.ts index 52d4fe9..4c46cdc 100644 --- a/test/handwritten/drive/sync.test.ts +++ b/test/handwritten/drive/sync.test.ts @@ -114,23 +114,35 @@ type ManifestEntry = { type TestDriveSyncApi = DriveSyncApi & { manifests: string[] + deltas: string[] uploads: Array<{ id: string; path: string; sha256: string; expectedEntryVersion?: number }> deletes: Array<{ id: string; path: string; expectedEntryVersion: number }> downloads: Map } -function mkApi(manifestPages: Array<{ entries: ManifestEntry[]; next_cursor?: string | null }>): TestDriveSyncApi { +function mkApi( + manifestPages: Array<{ + entries: ManifestEntry[] + next_cursor?: string | null + latest_cursor?: string + resync_required?: boolean + }>, +): TestDriveSyncApi { const downloads = new Map() const api: TestDriveSyncApi = { manifests: [], + deltas: [], uploads: [], deletes: [], downloads, - async getManifest(_id, cursor) { - api.manifests.push(cursor ?? "") + async getManifest(_id, cursor, sinceCursor) { + if (sinceCursor !== undefined) api.deltas.push(sinceCursor) + else api.manifests.push(cursor ?? "") const page = manifestPages.shift() if (!page) throw new Error("unexpected manifest page") return { + ...(page.latest_cursor === undefined ? {} : { latest_cursor: page.latest_cursor }), + ...(page.resync_required === undefined ? {} : { resync_required: page.resync_required }), library: { id: "lib_1", org_id: "org_1", @@ -342,26 +354,25 @@ describe("drive sync once", () => { expect((await readDriveState(root)).entries["a.txt"]).toEqual(state.entries["a.txt"]) }) - it("conflict action records conflict and does not mutate existing state entry", async () => { + it("resolves a non-mergeable edit/edit conflict directly with a conflict copy", async () => { const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-conflict-")) const state = await initDriveState(root, "lib_1") - state.entries["notes.txt"] = stateEntry("notes.txt", "base", 1) + state.entries["notes.bin"] = stateEntry("notes.bin", "base", 1) await writeDriveState(root, state) - await writeFile(join(root, "notes.txt"), "local") - const api = mkApi([{ entries: [entry("notes.txt", "remote", 2)] }]) - api.downloads.set("notes.txt@ver_1", "base") - api.downloads.set("notes.txt@ver_2", "remote") + await writeFile(join(root, "notes.bin"), "local") + const api = mkApi([{ entries: [entry("notes.bin", "remote", 2)] }]) + api.downloads.set("notes.bin@ver_1", "base") + api.downloads.set("notes.bin@ver_2", "remote") const result = await runDriveSyncOnce(root, api) expect(result.conflicts).toBe(1) + expect(result.merged).toBe(0) + expect(api.uploads).toEqual([{ id: "lib_1", path: "notes.bin", sha256: sha256("local"), expectedEntryVersion: 2 }]) const after = await readDriveState(root) - expect(after.entries["notes.txt"]).toEqual(state.entries["notes.txt"]) - expect(after.conflicts["notes.txt"]).toMatchObject({ - reason: "local_and_remote_changed", - remote_entry_version: 2, - remote_version_id: "ver_2", - }) + expect(after.conflicts["notes.bin"]).toBeUndefined() + expect(after.entries["notes.bin"]).toMatchObject({ entry_version: 3, last_local_sha256: sha256("local") }) + expect(await readFile(join(root, result.conflict_paths[0]!), "utf8")).toBe("remote") }) it("clean merges local and remote text edits, uploads merged content with remote entry version, and updates state", async () => { @@ -384,7 +395,7 @@ describe("drive sync once", () => { expect((await readDriveState(root)).conflicts["notes.md"]).toBeUndefined() }) - it("writes a remote conflict copy for unclean edit/edit merges without changing the canonical local file", async () => { + it("resolves an unclean edit/edit merge by uploading local as main and keeping a remote conflict copy", async () => { const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-conflict-copy-")) const state = await initDriveState(root, "lib_1") state.entries["notes.md"] = stateEntry("notes.md", "old\n", 1) @@ -404,18 +415,40 @@ describe("drive sync once", () => { expect(result.paths).toEqual([ { path: "notes.md", action: "conflict", conflict_paths: [result.conflict_paths[0]!] }, ]) - expect(api.uploads).toEqual([]) - expect((await readDriveState(root)).conflicts["notes.md"]).toMatchObject({ - reason: "local_and_remote_changed", - type: "edit_edit", - strategy: "conflict_copy", - base_version_id: "ver_1", - remote_version_id: "ver_2", - remote_entry_version: 2, - conflict_paths: [result.conflict_paths[0]!], + expect(api.uploads).toEqual([{ id: "lib_1", path: "notes.md", sha256: sha256("local\n"), expectedEntryVersion: 2 }]) + const nextState = await readDriveState(root) + expect(nextState.conflicts["notes.md"]).toBeUndefined() + expect(nextState.entries["notes.md"]).toMatchObject({ + entry_version: 3, + content_sha256: sha256("local\n"), + last_local_sha256: sha256("local\n"), }) }) + it("does not report the same edit/edit conflict again on the next sync", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-conflict-once-")) + const state = await initDriveState(root, "lib_1") + state.entries["notes.md"] = stateEntry("notes.md", "old\n", 1) + await writeDriveState(root, state) + await writeFile(join(root, "notes.md"), "local\n") + const remoteBefore = entry("notes.md", "remote\n", 2) + const remoteAfter = entry("notes.md", "local\n", 3) + const api = mkApi([{ entries: [remoteBefore] }, { entries: [remoteAfter] }]) + api.downloads.set("notes.md@ver_1", "old\n") + api.downloads.set("notes.md@ver_2", "remote\n") + + const first = await runDriveSyncOnce(root, api, conflictClock) + expect(first.conflicts).toBe(1) + + const second = await runDriveSyncOnce(root, api, conflictClock) + expect(second.conflicts).toBe(0) + // The conflict copy is a new local file, uploaded like any other create. + expect(api.uploads.map((upload) => upload.path)).toEqual([ + "notes.md", + "notes.remote-conflict-20260621T101000Z-ver_2.md", + ]) + }) + it("writes a remote conflict copy for create/create without a shared base", async () => { const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-create-create-")) await initDriveState(root, "lib_1") @@ -430,70 +463,41 @@ describe("drive sync once", () => { expect(result.conflicts).toBe(1) expect(result.conflict_paths[0]).toMatch(/^notes\.remote-conflict-\d{8}T\d{6}Z-ver_2\.md$/) expect(await readFile(join(root, result.conflict_paths[0]!), "utf8")).toBe("remote\n") - expect(api.uploads).toEqual([]) - expect((await readDriveState(root)).conflicts["notes.md"]).toMatchObject({ - reason: "local_and_remote_without_base", - type: "create_create", - strategy: "conflict_copy", - remote_version_id: "ver_2", - remote_entry_version: 2, - conflict_paths: [result.conflict_paths[0]!], - }) + expect(api.uploads).toEqual([{ id: "lib_1", path: "notes.md", sha256: sha256("local\n"), expectedEntryVersion: 2 }]) + const nextState = await readDriveState(root) + expect(nextState.conflicts["notes.md"]).toBeUndefined() + expect(nextState.entries["notes.md"]).toMatchObject({ entry_version: 3, last_local_sha256: sha256("local\n") }) }) - it("reuses an existing conflict copy for the same unresolved edit/edit conflict", async () => { + it("reuses an already written conflict copy when the main upload previously failed", async () => { const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-conflict-copy-reuse-")) const state = await initDriveState(root, "lib_1") state.entries["notes.md"] = stateEntry("notes.md", "old\n", 1) + state.conflicts["notes.md"] = { + detected_at: "2026-06-21T10:10:00.000Z", + reason: "local_and_remote_changed", + type: "edit_edit", + strategy: "conflict_copy", + remote_version_id: "ver_2", + remote_entry_version: 2, + conflict_paths: ["notes.remote-conflict-20260621T101000Z-ver_2.md"], + } await writeDriveState(root, state) await writeFile(join(root, "notes.md"), "local\n") + await writeFile(join(root, "notes.remote-conflict-20260621T101000Z-ver_2.md"), "remote\n") const remote = entry("notes.md", "remote\n", 2) - const api = mkApi([{ entries: [remote] }, { entries: [remote] }]) + const api = mkApi([{ entries: [remote] }]) api.downloads.set("notes.md@ver_1", "old\n") api.downloads.set("notes.md@ver_2", "remote\n") - const first = await runDriveSyncOnce(root, api, conflictClock) - const firstState = await readDriveState(root) - const second = await runDriveSyncOnce(root, api, conflictClock) - const secondState = await readDriveState(root) - - expect(first.conflict_paths).toEqual(["notes.remote-conflict-20260621T101000Z-ver_2.md"]) - expect(second.conflict_paths).toEqual(first.conflict_paths) - expect(second.conflicts).toBe(1) - expect(second.paths).toContainEqual({ - path: "notes.md", - action: "conflict", - conflict_paths: first.conflict_paths, - }) - expect(firstState.conflicts["notes.md"]?.conflict_paths).toEqual(first.conflict_paths) - expect(secondState.conflicts["notes.md"]?.conflict_paths).toEqual(first.conflict_paths) - expect(secondState.conflicts["notes.md"]).toEqual(firstState.conflicts["notes.md"]) + const result = await runDriveSyncOnce(root, api, conflictClock) + + expect(result.conflicts).toBe(1) expect((await readdir(root)).filter((name) => name.includes(".remote-conflict-"))).toEqual([ "notes.remote-conflict-20260621T101000Z-ver_2.md", ]) - }) - - it("recreates a missing conflict copy for the same unresolved edit/edit conflict", async () => { - const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-conflict-copy-missing-")) - const state = await initDriveState(root, "lib_1") - state.entries["notes.md"] = stateEntry("notes.md", "old\n", 1) - await writeDriveState(root, state) - await writeFile(join(root, "notes.md"), "local\n") - const remote = entry("notes.md", "remote\n", 2) - const api = mkApi([{ entries: [remote] }, { entries: [remote] }]) - api.downloads.set("notes.md@ver_1", "old\n") - api.downloads.set("notes.md@ver_2", "remote\n") - const originalDownloadFile = api.downloadFile - api.downloadFile = vi.fn(originalDownloadFile) - - const first = await runDriveSyncOnce(root, api, conflictClock) - await unlink(join(root, first.conflict_paths[0]!)) - const second = await runDriveSyncOnce(root, api, conflictClock) - - expect(second.conflict_paths).toEqual(first.conflict_paths) - expect(await readFile(join(root, second.conflict_paths[0]!), "utf8")).toBe("remote\n") - expect(api.downloadFile).toHaveBeenCalledWith("lib_1", "notes.md", "ver_2") - expect(vi.mocked(api.downloadFile).mock.calls.filter((call) => call[2] === "ver_2")).toHaveLength(4) + expect(api.uploads.map((upload) => upload.path)).toContain("notes.md") + expect((await readDriveState(root)).conflicts["notes.md"]).toBeUndefined() }) it("adds a numeric suffix when the conflict copy path already exists", async () => { @@ -545,7 +549,7 @@ describe("drive sync once", () => { expect((await readDriveState(root)).entries["notes.md"]).toEqual(state.entries["notes.md"]) }) - it("records a conflict when a versioned base download is missing", async () => { + it("resolves via conflict copy when a versioned base download is missing", async () => { const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-merge-missing-base-")) const state = await initDriveState(root, "lib_1") state.entries["notes.md"] = stateEntry("notes.md", "a\nb\nc\n", 1) @@ -565,16 +569,14 @@ describe("drive sync once", () => { expect(result.conflicts).toBe(1) expect(result.errors).toBe(0) expect(result.conflict_paths[0]).toMatch(/^notes\.remote-conflict-\d{8}T\d{6}Z-ver_2\.md$/) - expect(api.uploads).toEqual([]) + expect(api.uploads).toEqual([ + { id: "lib_1", path: "notes.md", sha256: sha256("a\nlocal\nb\nc\n"), expectedEntryVersion: 2 }, + ]) expect(await readFile(join(root, "notes.md"), "utf8")).toBe("a\nlocal\nb\nc\n") expect(await readFile(join(root, result.conflict_paths[0]!), "utf8")).toBe("a\nb\nremote\nc\n") - expect((await readDriveState(root)).conflicts["notes.md"]).toMatchObject({ - reason: "local_and_remote_changed", - type: "edit_edit", - strategy: "conflict_copy", - remote_version_id: "ver_2", - conflict_paths: [result.conflict_paths[0]!], - }) + const after = await readDriveState(root) + expect(after.conflicts["notes.md"]).toBeUndefined() + expect(after.entries["notes.md"]).toMatchObject({ entry_version: 3 }) }) it("records a remote tombstone conflict when local changed and remote deleted", async () => { @@ -1224,6 +1226,259 @@ describe("drive sync once", () => { expect(after.entries["b.txt"]).toBeUndefined() }) + it("uses the move API for a same-hash delete + create pair instead of reupload", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-move-")) + const state = await initDriveState(root, "lib_1") + state.entries["old-name.md"] = stateEntry("old-name.md", "same content\n", 1) + await writeDriveState(root, state) + await writeFile(join(root, "new-name.md"), "same content\n") + const api = mkApi([{ entries: [entry("old-name.md", "same content\n", 1)] }]) + const moves: Array<{ from: string; to: string; expected?: number }> = [] + api.moveFile = async (_id, fromPath, toPath, expectedEntryVersion) => { + moves.push({ from: fromPath, to: toPath, expected: expectedEntryVersion }) + return { entry: entry(toPath, "same content\n", 2), result: "moved" as const } + } + + const result = await runDriveSyncOnce(root, api) + + expect(moves).toEqual([{ from: "old-name.md", to: "new-name.md", expected: 1 }]) + expect(api.uploads).toEqual([]) + expect(api.deletes).toEqual([]) + expect(result.errors).toBe(0) + const after = await readDriveState(root) + expect(after.entries["old-name.md"]).toBeUndefined() + expect(after.entries["new-name.md"]).toMatchObject({ entry_version: 2 }) + }) + + it("falls back to upload + delete when the move API fails", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-move-fallback-")) + const state = await initDriveState(root, "lib_1") + state.entries["old-name.md"] = stateEntry("old-name.md", "same content\n", 1) + await writeDriveState(root, state) + await writeFile(join(root, "new-name.md"), "same content\n") + const api = mkApi([{ entries: [entry("old-name.md", "same content\n", 1)] }]) + api.moveFile = async () => { + throw new Error("HTTP 500: move failed") + } + + const result = await runDriveSyncOnce(root, api) + + expect(result.errors).toBe(0) + expect(api.uploads.map((upload) => upload.path)).toEqual(["new-name.md"]) + expect(api.deletes.map((del) => del.path)).toEqual(["old-name.md"]) + }) + + it("does not pair ambiguous same-hash renames", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-move-ambiguous-")) + const state = await initDriveState(root, "lib_1") + state.entries["a.md"] = stateEntry("a.md", "dup\n", 1) + state.entries["b.md"] = stateEntry("b.md", "dup\n", 1) + await writeDriveState(root, state) + await writeFile(join(root, "c.md"), "dup\n") + await writeFile(join(root, "d.md"), "dup\n") + const api = mkApi([ + { entries: [entry("a.md", "dup\n", 1), entry("b.md", "dup\n", 1)] }, + ]) + const moves: unknown[] = [] + api.moveFile = async (_id, fromPath, toPath) => { + moves.push([fromPath, toPath]) + return { entry: entry(toPath, "dup\n", 2), result: "moved" as const } + } + + await runDriveSyncOnce(root, api) + + expect(moves).toEqual([]) + expect(api.uploads.map((upload) => upload.path).sort()).toEqual(["c.md", "d.md"]) + expect(api.deletes.map((del) => del.path).sort()).toEqual(["a.md", "b.md"]) + }) + + it("persists the scan hash cache in state.json across syncs", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-scan-cache-")) + await initDriveState(root, "lib_1") + await writeFile(join(root, "notes.txt"), "hello") + const api = mkApi([{ entries: [] }, { entries: [entry("notes.txt", "hello", 1)] }]) + + await runDriveSyncOnce(root, api) + + const state = await readDriveState(root) + expect(state.scan_cache?.["notes.txt"]).toMatchObject({ + sha256: sha256("hello"), + size_bytes: 5, + mtime_ms: expect.any(Number), + }) + + // Second sync with unchanged file keeps the cache entry. + await runDriveSyncOnce(root, api) + const second = await readDriveState(root) + expect(second.scan_cache?.["notes.txt"]?.sha256).toBe(sha256("hello")) + }) + + it("fetches a manifest delta when a manifest cursor is stored", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-delta-")) + const state = await initDriveState(root, "lib_1") + state.entries["a.txt"] = stateEntry("a.txt", "aaa", 1) + state.manifest_cursor = "000000000000000005" + await writeDriveState(root, state) + await writeFile(join(root, "a.txt"), "aaa") + const api = mkApi([ + { entries: [entry("b.txt", "bbb", 1)], latest_cursor: "000000000000000007" }, + ]) + api.downloads.set("b.txt", "bbb") + + const result = await runDriveSyncOnce(root, api) + + expect(api.deltas).toEqual(["000000000000000005"]) + expect(api.manifests).toEqual([]) + // a.txt is not in the delta but must survive via the synthesized view. + expect(await readFile(join(root, "a.txt"), "utf8")).toBe("aaa") + expect(result.downloaded).toBe(1) + expect(await readFile(join(root, "b.txt"), "utf8")).toBe("bbb") + expect((await readDriveState(root)).manifest_cursor).toBe("000000000000000007") + }) + + it("falls back to a full manifest when the delta cursor expired", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-delta-resync-")) + const state = await initDriveState(root, "lib_1") + state.entries["a.txt"] = stateEntry("a.txt", "aaa", 1) + state.manifest_cursor = "000000000000000001" + await writeDriveState(root, state) + await writeFile(join(root, "a.txt"), "aaa") + const api = mkApi([ + { entries: [], resync_required: true, latest_cursor: "000000000000000009" }, + { entries: [entry("a.txt", "aaa", 1)], latest_cursor: "000000000000000009" }, + ]) + + const result = await runDriveSyncOnce(root, api) + + expect(api.deltas).toEqual(["000000000000000001"]) + expect(api.manifests).toEqual([""]) + expect(result.errors).toBe(0) + expect((await readDriveState(root)).manifest_cursor).toBe("000000000000000009") + }) + + it("applies deleted entries from a manifest delta", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-delta-delete-")) + const state = await initDriveState(root, "lib_1") + state.entries["a.txt"] = stateEntry("a.txt", "aaa", 1) + state.manifest_cursor = "000000000000000005" + await writeDriveState(root, state) + await writeFile(join(root, "a.txt"), "aaa") + const deleted = { ...entry("a.txt", "aaa", 2), deleted_at: "2026-07-13T00:00:00.000Z" } + const api = mkApi([{ entries: [deleted], latest_cursor: "000000000000000008" }]) + + const result = await runDriveSyncOnce(root, api) + + expect(result.deleted).toBe(1) + const { localFileExists } = await import("../../../src/handwritten/commands/drive/local-mutations.js") + expect(await localFileExists(join(root, "a.txt"))).toBe(false) + }) + + it("stores the manifest cursor from a full fetch", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-cursor-store-")) + await initDriveState(root, "lib_1") + await writeFile(join(root, "n.txt"), "n") + const api = mkApi([{ entries: [], latest_cursor: "000000000000000003" }]) + + await runDriveSyncOnce(root, api) + + expect((await readDriveState(root)).manifest_cursor).toBe("000000000000000003") + }) + + it("rescans only dirty paths when a dirty set is provided", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-dirty-")) + await initDriveState(root, "lib_1") + await writeFile(join(root, "a.txt"), "aaa") + await writeFile(join(root, "b.txt"), "bbb") + const seedApi = mkApi([{ entries: [] }]) + await runDriveSyncOnce(root, seedApi) + + // Tamper a.txt behind the watcher's back; only b.txt is reported dirty. + await writeFile(join(root, "a.txt"), "tampered-aaa") + await writeFile(join(root, "b.txt"), "bbb-changed") + const api = mkApi([ + { entries: [entry("a.txt", "aaa", 1), entry("b.txt", "bbb", 1)] }, + ]) + + const result = await runDriveSyncOnce(root, api, undefined, undefined, undefined, { + dirtyPaths: ["b.txt"], + }) + + expect(api.uploads.map((upload) => upload.path)).toEqual(["b.txt"]) + expect(result.errors).toBe(0) + }) + + it("treats a dirty path that vanished from disk as locally deleted", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-dirty-delete-")) + const state = await initDriveState(root, "lib_1") + state.entries["gone.txt"] = stateEntry("gone.txt", "gone", 1) + state.scan_cache = { + "gone.txt": { mtime_ms: 1, size_bytes: 4, sha256: sha256("gone") }, + } + await writeDriveState(root, state) + const api = mkApi([{ entries: [entry("gone.txt", "gone", 1)] }]) + + const result = await runDriveSyncOnce(root, api, undefined, undefined, undefined, { + dirtyPaths: ["gone.txt"], + }) + + expect(api.deletes.map((del) => del.path)).toEqual(["gone.txt"]) + expect(result.errors).toBe(0) + }) + + it("rescans a dirty directory subtree", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-dirty-dir-")) + await initDriveState(root, "lib_1") + await mkdir(join(root, "docs"), { recursive: true }) + await writeFile(join(root, "docs", "x.md"), "x") + const seedApi = mkApi([{ entries: [] }]) + await runDriveSyncOnce(root, seedApi) + + await writeFile(join(root, "docs", "y.md"), "y") + const api = mkApi([{ entries: [entry("docs/x.md", "x", 1)] }]) + + const result = await runDriveSyncOnce(root, api, undefined, undefined, undefined, { + dirtyPaths: ["docs"], + }) + + expect(api.uploads.map((upload) => upload.path)).toEqual(["docs/y.md"]) + expect(result.errors).toBe(0) + }) + + it("logs a debug error event for every recorded path error", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-error-event-")) + await initDriveState(root, "lib_1") + await writeFile(join(root, "notes.txt"), "hello") + const api = mkApi([{ entries: [] }]) + api.uploadFile = async () => { + throw new Error("upload exploded") + } + const events: Array<{ event: string; fields?: Record }> = [] + const debug = { log: (event: string, fields?: Record) => events.push({ event, fields }) } + + const summary = await runDriveSyncOnce(root, api, undefined, undefined, debug) + + expect(summary.errors).toBe(1) + const errorEvents = events.filter((candidate) => candidate.event === "error") + expect(errorEvents).toHaveLength(1) + expect(errorEvents[0]?.fields).toMatchObject({ path: "notes.txt", message: expect.stringContaining("upload exploded") }) + }) + + it("logs a debug error event with the fs code for scan path errors", async () => { + const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-scan-error-event-")) + await initDriveState(root, "lib_1") + scannerControl.injectLocalPathError = "gone.txt" + const api = mkApi([{ entries: [] }]) + const events: Array<{ event: string; fields?: Record }> = [] + const debug = { log: (event: string, fields?: Record) => events.push({ event, fields }) } + + const summary = await runDriveSyncOnce(root, api, undefined, undefined, debug) + + expect(summary.errors).toBe(1) + const errorEvents = events.filter((candidate) => candidate.event === "error") + expect(errorEvents).toHaveLength(1) + expect(errorEvents[0]?.fields).toMatchObject({ path: "gone.txt", message: expect.any(String) }) + }) + it("renders command summary and sets exit code for conflicts", async () => { const root = await mkdtemp(join(tmpdir(), "wspc-drive-sync-command-")) const state = await initDriveState(root, "lib_1") diff --git a/test/handwritten/drive/watch.test.ts b/test/handwritten/drive/watch.test.ts index f0c4910..3977e15 100644 --- a/test/handwritten/drive/watch.test.ts +++ b/test/handwritten/drive/watch.test.ts @@ -127,7 +127,7 @@ describe("drive watch", () => { await runDriveWatch("/tmp/root", { source, runSync, readState, onEvent, once: true }) expect(runSync).toHaveBeenCalledTimes(1) - expect(runSync).toHaveBeenCalledWith("/tmp/root", expect.any(Function)) + expect(runSync).toHaveBeenCalledWith("/tmp/root", expect.any(Function), undefined) }) it("emits drive_sync_progress events from the sync progress callback", async () => { @@ -266,6 +266,61 @@ describe("drive watch", () => { await watching }) + it("passes accumulated dirty paths to local-triggered syncs only", async () => { + const source = fakeSource() + const onEvent = vi.fn() + const dirtyByCall: Array = [] + const runSync = vi.fn(async (_root: string, _onProgress?: unknown, dirtyPaths?: string[]) => { + dirtyByCall.push(dirtyPaths) + return syncSummary() + }) + const watching = runDriveWatch("/tmp/root", { + source, + realtimeSource: fakeRealtimeSource(), + runSync, + readState, + onEvent, + }) + await source.waitForSubscription() + await Promise.resolve() + await Promise.resolve() + + source.emit("/tmp/root/notes/a.txt") + source.emit("/tmp/root/b.txt") + await vi.advanceTimersByTimeAsync(600) + + expect(dirtyByCall[0]).toBeUndefined() + expect(dirtyByCall[1]?.sort()).toEqual(["b.txt", "notes/a.txt"]) + process.emit("SIGINT") + await watching + }) + + it("ignores fs events for internal sync temp artifacts", async () => { + const source = fakeSource() + const onEvent = vi.fn() + const runSync = vi.fn(async () => syncSummary()) + const watching = runDriveWatch("/tmp/root", { + source, + realtimeSource: fakeRealtimeSource(), + runSync, + readState, + onEvent, + }) + await source.waitForSubscription() + await Promise.resolve() + await Promise.resolve() + + source.emit(".notes.md.wspc-download-abc.tmp") + source.emit("sub/.readme.md.wspc-backup-def.tmp") + source.emit(".readme.md.wspc-merge-xyz.tmp") + await vi.advanceTimersByTimeAsync(600) + + // Only the initial sync ran; temp artifacts never schedule another. + expect(runSync).toHaveBeenCalledTimes(1) + process.emit("SIGINT") + await watching + }) + it("removes the counterpart signal listener on shutdown", async () => { const source = fakeSource() const onEvent = vi.fn()