@wspc/cli 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -995,6 +995,15 @@ var driveManifestGet = (options) => (options.client ?? client).get({
995
995
  url: "/drive/libraries/{id}/manifest",
996
996
  ...options
997
997
  });
998
+ var driveFileMove = (options) => (options.client ?? client).post({
999
+ security: [{ scheme: "bearer", type: "http" }],
1000
+ url: "/drive/libraries/{id}/files/move",
1001
+ ...options,
1002
+ headers: {
1003
+ "Content-Type": "application/json",
1004
+ ...options.headers
1005
+ }
1006
+ });
998
1007
  var driveFileRestore = (options) => (options.client ?? client).post({
999
1008
  security: [{ scheme: "bearer", type: "http" }],
1000
1009
  url: "/drive/libraries/{id}/files/restore",
@@ -1520,9 +1529,9 @@ function createConsistencyFetch(opts) {
1520
1529
  }
1521
1530
 
1522
1531
  // src/version.ts
1523
- var VERSION = "0.1.14";
1524
- var SPEC_SHA = "bec760cd";
1525
- var SPEC_FETCHED_AT = "2026-07-08T06:11:14.456Z";
1532
+ var VERSION = "0.1.16";
1533
+ var SPEC_SHA = "74d4defc";
1534
+ var SPEC_FETCHED_AT = "2026-07-13T06:28:22.721Z";
1526
1535
  var API_BASE = "https://api.wspc.ai";
1527
1536
 
1528
1537
  // src/index.ts
@@ -3020,7 +3029,7 @@ var driveFileHistoryCommand = new Command29("history").description("Get drive fi
3020
3029
 
3021
3030
  // src/generated/cli/drive/manifest/get.ts
3022
3031
  import { Command as Command30 } from "commander";
3023
- var driveManifestGetCommand = new Command30("get").description("Get a drive library manifest").argument("<id>", "id").option("--limit <value>", "limit").option("--cursor <value>", "cursor").option("--include-deleted <value>", "include_deleted").option("--path-prefix <value>", "path_prefix").action(async (id, opts) => {
3032
+ var driveManifestGetCommand = new Command30("get").description("Get a drive library manifest").argument("<id>", "id").option("--limit <value>", "limit").option("--cursor <value>", "cursor").option("--include-deleted <value>", "include_deleted").option("--path-prefix <value>", "path_prefix").option("--since-cursor <value>", "since_cursor").action(async (id, opts) => {
3024
3033
  const client2 = await loadSdkClient();
3025
3034
  const result = await driveManifestGet({
3026
3035
  client: client2._rawClient,
@@ -3031,7 +3040,8 @@ var driveManifestGetCommand = new Command30("get").description("Get a drive libr
3031
3040
  limit: opts.limit,
3032
3041
  cursor: opts.cursor,
3033
3042
  include_deleted: opts.includeDeleted,
3034
- path_prefix: opts.pathPrefix
3043
+ path_prefix: opts.pathPrefix,
3044
+ since_cursor: opts.sinceCursor
3035
3045
  }
3036
3046
  });
3037
3047
  if (result.error || !result.response?.ok) {
@@ -4851,6 +4861,9 @@ async function expectJsonResult(result) {
4851
4861
  if (result.data == null) throw new Error("empty response");
4852
4862
  return result.data;
4853
4863
  }
4864
+ function syncClientHeaders(clientId) {
4865
+ return { "x-wspc-client": clientId === void 0 ? "drive-sync" : `drive-sync/${clientId}` };
4866
+ }
4854
4867
  function driveContentUrl(baseUrl, id) {
4855
4868
  const baseWithTrailingSlash = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
4856
4869
  return new URL(`drive/libraries/${encodeURIComponent(id)}/files/content`, baseWithTrailingSlash);
@@ -4858,6 +4871,7 @@ function driveContentUrl(baseUrl, id) {
4858
4871
  async function createDriveApi(opts = {}) {
4859
4872
  const client2 = await loadSdkClientWithAuthedFetch(opts);
4860
4873
  const rawClient = client2._rawClient;
4874
+ const clientHeaders = syncClientHeaders(opts.clientId);
4861
4875
  return {
4862
4876
  async getLibrary(id) {
4863
4877
  const result = await driveLibraryGet({
@@ -4866,11 +4880,14 @@ async function createDriveApi(opts = {}) {
4866
4880
  });
4867
4881
  return expectJsonResult(result);
4868
4882
  },
4869
- async getManifest(id, cursor) {
4883
+ async getManifest(id, cursor, sinceCursor) {
4884
+ const query = {};
4885
+ if (cursor !== void 0) query.cursor = cursor;
4886
+ if (sinceCursor !== void 0) query.since_cursor = sinceCursor;
4870
4887
  const result = await driveManifestGet({
4871
4888
  client: rawClient,
4872
4889
  path: { id },
4873
- ...cursor ? { query: { cursor } } : {}
4890
+ ...Object.keys(query).length > 0 ? { query } : {}
4874
4891
  });
4875
4892
  return expectJsonResult(result);
4876
4893
  },
@@ -4878,6 +4895,7 @@ async function createDriveApi(opts = {}) {
4878
4895
  const result = await driveFileDelete({
4879
4896
  client: rawClient,
4880
4897
  path: { id },
4898
+ headers: clientHeaders,
4881
4899
  body: {
4882
4900
  path,
4883
4901
  expected_entry_version: expectedEntryVersion
@@ -4885,6 +4903,19 @@ async function createDriveApi(opts = {}) {
4885
4903
  });
4886
4904
  return expectJsonResult(result);
4887
4905
  },
4906
+ async moveFile(id, fromPath, toPath, expectedEntryVersion) {
4907
+ const result = await driveFileMove({
4908
+ client: rawClient,
4909
+ path: { id },
4910
+ headers: clientHeaders,
4911
+ body: {
4912
+ from_path: fromPath,
4913
+ to_path: toPath,
4914
+ ...expectedEntryVersion === void 0 ? {} : { expected_entry_version: expectedEntryVersion }
4915
+ }
4916
+ });
4917
+ return expectJsonResult(result);
4918
+ },
4888
4919
  async uploadFile(id, path, body, sha256, expectedEntryVersion) {
4889
4920
  const url = driveContentUrl(client2.baseUrl, id);
4890
4921
  url.searchParams.set("path", path);
@@ -4895,7 +4926,8 @@ async function createDriveApi(opts = {}) {
4895
4926
  method: "PUT",
4896
4927
  headers: {
4897
4928
  "content-type": "application/octet-stream",
4898
- "x-drive-content-sha256": sha256
4929
+ "x-drive-content-sha256": sha256,
4930
+ ...clientHeaders
4899
4931
  },
4900
4932
  body
4901
4933
  });
@@ -5110,7 +5142,7 @@ function isOptionalIsoTimestamp(value) {
5110
5142
  }
5111
5143
  function isValidDriveState(value) {
5112
5144
  if (!isRecord2(value)) return false;
5113
- if (value.schema_version !== 1 || typeof value.library_id !== "string" || typeof value.created_at !== "string" || typeof value.updated_at !== "string" || !isRecord2(value.entries) || !isRecord2(value.conflicts) || value.realtime !== void 0 && !isDriveRealtimeState(value.realtime)) {
5145
+ if (value.schema_version !== 1 || typeof value.library_id !== "string" || typeof value.created_at !== "string" || typeof value.updated_at !== "string" || !isRecord2(value.entries) || !isRecord2(value.conflicts) || value.realtime !== void 0 && !isDriveRealtimeState(value.realtime) || value.scan_cache !== void 0 && !isRecord2(value.scan_cache) || value.manifest_cursor !== void 0 && typeof value.manifest_cursor !== "string") {
5114
5146
  return false;
5115
5147
  }
5116
5148
  for (const entry of Object.values(value.entries)) {
@@ -5119,8 +5151,16 @@ function isValidDriveState(value) {
5119
5151
  for (const conflict2 of Object.values(value.conflicts)) {
5120
5152
  if (!isDriveConflict(conflict2)) return false;
5121
5153
  }
5154
+ if (value.scan_cache !== void 0) {
5155
+ for (const entry of Object.values(value.scan_cache)) {
5156
+ if (!isDriveScanCacheEntry(entry)) return false;
5157
+ }
5158
+ }
5122
5159
  return true;
5123
5160
  }
5161
+ function isDriveScanCacheEntry(value) {
5162
+ return isRecord2(value) && typeof value.mtime_ms === "number" && typeof value.size_bytes === "number" && typeof value.sha256 === "string";
5163
+ }
5124
5164
 
5125
5165
  // src/handwritten/commands/drive/bind.ts
5126
5166
  async function assertExistingDirectory(path) {
@@ -5155,9 +5195,44 @@ function driveBindCommand() {
5155
5195
  // src/handwritten/commands/drive/sync.ts
5156
5196
  import { Command as Command80 } from "commander";
5157
5197
  import { mkdir as mkdir3, readFile as readFile4, rm as rm3, writeFile as writeFile3 } from "fs/promises";
5158
- import { basename as basename3, dirname as dirname2, join as join5, posix as pathPosix2, resolve as resolve3 } from "path";
5198
+ import { basename as basename3, dirname as dirname2, join as join6, posix as pathPosix3, resolve as resolve3 } from "path";
5159
5199
  import { createHash as createHash3, randomUUID as randomUUID4 } from "crypto";
5160
5200
 
5201
+ // src/handwritten/commands/drive/debug-log.ts
5202
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "fs";
5203
+ import { join as join3 } from "path";
5204
+ var noopDriveDebugLogger = { log() {
5205
+ } };
5206
+ var DEBUG_LOG_FILE = "debug.log";
5207
+ var MAX_DEBUG_LOG_BYTES = 10 * 1024 * 1024;
5208
+ function createDriveDebugLogger(root, options = {}) {
5209
+ const clock = options.clock ?? systemDriveClock;
5210
+ const maxBytes = options.maxBytes ?? MAX_DEBUG_LOG_BYTES;
5211
+ const dir = join3(root, DRIVE_DIR);
5212
+ const file = join3(dir, DEBUG_LOG_FILE);
5213
+ let warned = false;
5214
+ return {
5215
+ log(event, fields = {}) {
5216
+ try {
5217
+ mkdirSync(dir, { recursive: true });
5218
+ try {
5219
+ if (statSync(file).size > maxBytes) renameSync(file, `${file}.old`);
5220
+ } catch {
5221
+ }
5222
+ appendFileSync(file, `${JSON.stringify({ ts: driveIsoTimestamp(clock), event, ...fields })}
5223
+ `);
5224
+ } catch (error) {
5225
+ if (!warned) {
5226
+ warned = true;
5227
+ const message = error instanceof Error ? error.message : String(error);
5228
+ process.stderr.write(`wspc drive: debug log write failed: ${message}
5229
+ `);
5230
+ }
5231
+ }
5232
+ }
5233
+ };
5234
+ }
5235
+
5161
5236
  // src/handwritten/commands/drive/decision.ts
5162
5237
  function decideDriveAction(entry, local, remote) {
5163
5238
  if (!entry) return decideWithoutBase(local, remote);
@@ -5166,7 +5241,7 @@ function decideDriveAction(entry, local, remote) {
5166
5241
  const remoteStatus = getRemoteStatus(entry, remote);
5167
5242
  if (!remote) return decideRemoteMissing(localStatus);
5168
5243
  if (!local) return decideLocalMissing(entry, remoteStatus);
5169
- return decideBothPresent(entry, localStatus, remoteStatus);
5244
+ return decideBothPresent(entry, localStatus, remoteStatus, local, remote);
5170
5245
  }
5171
5246
  function decideWithoutBase(local, remote) {
5172
5247
  if (local && !remote) return { type: "upload_create", expectedEntryVersion: 0 };
@@ -5188,7 +5263,8 @@ function decideLocalMissing(entry, remoteStatus) {
5188
5263
  if (remoteStatus === "unchanged") return { type: "delete_remote", expectedEntryVersion: entry.entry_version };
5189
5264
  return { type: "conflict", reason: "remote_changed_before_delete" };
5190
5265
  }
5191
- function decideBothPresent(entry, localStatus, remoteStatus) {
5266
+ function decideBothPresent(entry, localStatus, remoteStatus, local, remote) {
5267
+ const converged = remote.content_sha256 !== void 0 && local.sha256 === remote.content_sha256;
5192
5268
  if (localStatus === "unchanged" && remoteStatus === "content_same_new_version") {
5193
5269
  return { type: "state_only" };
5194
5270
  }
@@ -5196,7 +5272,7 @@ function decideBothPresent(entry, localStatus, remoteStatus) {
5196
5272
  return { type: "conflict", reason: "remote_missing_entry_version" };
5197
5273
  }
5198
5274
  if (localStatus === "unknown" && remoteStatus !== "unchanged") {
5199
- return { type: "conflict", reason: "unknown_local_base_remote_changed" };
5275
+ return converged ? { type: "state_only" } : { type: "conflict", reason: "unknown_local_base_remote_changed" };
5200
5276
  }
5201
5277
  if (localStatus === "unknown") return { type: "conflict", reason: "unknown_local_base" };
5202
5278
  if (localStatus === "unchanged" && remoteStatus === "changed") return { type: "download" };
@@ -5204,7 +5280,7 @@ function decideBothPresent(entry, localStatus, remoteStatus) {
5204
5280
  return { type: "upload_update", expectedEntryVersion: entry.entry_version };
5205
5281
  }
5206
5282
  if (localStatus !== "unchanged" && remoteStatus !== "unchanged") {
5207
- return { type: "conflict", reason: "local_and_remote_changed" };
5283
+ return converged ? { type: "state_only" } : { type: "conflict", reason: "local_and_remote_changed" };
5208
5284
  }
5209
5285
  return { type: "unchanged" };
5210
5286
  }
@@ -5378,8 +5454,7 @@ function mergeText3(base, local, remote) {
5378
5454
  }
5379
5455
  function conflictCopyPath(path, side, timestamp, versionId) {
5380
5456
  const parsed = pathPosix.parse(path);
5381
- const shortVersionId = safeShortVersionId(versionId);
5382
- const fileName = `${parsed.name}.${side}-conflict-${timestamp}-${shortVersionId}${parsed.ext}`;
5457
+ const fileName = `${parsed.name}.${side}-conflict-${timestamp}-${safeVersionId(versionId)}${parsed.ext}`;
5383
5458
  if (parsed.dir === "") {
5384
5459
  return fileName;
5385
5460
  }
@@ -5408,25 +5483,33 @@ function hasTextHint(path, mimeType) {
5408
5483
  function normalizeLines(text) {
5409
5484
  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
5410
5485
  }
5411
- function safeShortVersionId(versionId) {
5412
- const safeVersionId = versionId.replace(/[^A-Za-z0-9_-]/g, "_");
5413
- return (safeVersionId.length > 0 ? safeVersionId : "unknown").slice(0, 8);
5486
+ function safeVersionId(versionId) {
5487
+ const safeVersionId2 = versionId.replace(/[^A-Za-z0-9_-]/g, "_");
5488
+ return safeVersionId2.length > 0 ? safeVersionId2 : "unknown";
5414
5489
  }
5415
5490
 
5416
5491
  // src/handwritten/commands/drive/scanner.ts
5417
5492
  import { createHash } from "crypto";
5418
5493
  import { constants as fsConstants } from "fs";
5419
5494
  import { open as open2, readdir, lstat } from "fs/promises";
5420
- import { join as join3 } from "path";
5495
+ import { join as join4, posix as pathPosix2 } from "path";
5421
5496
  async function scanDriveFiles(root, options = {}) {
5422
5497
  const candidates = [];
5423
5498
  const files = {};
5424
- const absRoot = root;
5425
- await walk(absRoot, "");
5499
+ const startDrivePath = options.startDrivePath ?? "";
5500
+ await walk(startDrivePath === "" ? root : join4(root, ...startDrivePath.split("/")), startDrivePath);
5426
5501
  await addNonCollidingFiles(candidates);
5427
5502
  return files;
5428
5503
  async function walk(currentPath, currentDrivePath) {
5429
- const entries = await readdir(currentPath, { withFileTypes: true });
5504
+ let entries;
5505
+ try {
5506
+ entries = await readdir(currentPath, { withFileTypes: true });
5507
+ } catch (error) {
5508
+ if (currentDrivePath === startDrivePath && startDrivePath !== "" && isNotFoundError(error)) return;
5509
+ if (currentDrivePath === "" || !isTransientScanError(error) || !options.onPathError) throw error;
5510
+ await options.onPathError(currentDrivePath, error);
5511
+ return;
5512
+ }
5430
5513
  entries.sort((left, right) => left.name.localeCompare(right.name));
5431
5514
  for (const entry of entries) {
5432
5515
  if (isExcludedRootEntry(currentDrivePath, entry)) {
@@ -5443,23 +5526,35 @@ async function scanDriveFiles(root, options = {}) {
5443
5526
  await options.onPathError(nextDrivePath, error);
5444
5527
  continue;
5445
5528
  }
5446
- const nextPath = join3(currentPath, entry.name);
5447
- const stats = await lstat(nextPath);
5448
- if (stats.isSymbolicLink()) {
5449
- continue;
5450
- }
5451
- if (stats.isDirectory()) {
5452
- await walk(nextPath, nextDrivePath);
5453
- continue;
5454
- }
5455
- if (!stats.isFile()) {
5456
- continue;
5457
- }
5458
- const digest = await hashDriveFile(nextPath);
5459
- if (!digest) {
5460
- continue;
5529
+ const nextPath = join4(currentPath, entry.name);
5530
+ try {
5531
+ const stats = await lstat(nextPath);
5532
+ if (stats.isSymbolicLink()) {
5533
+ continue;
5534
+ }
5535
+ if (stats.isDirectory()) {
5536
+ await walk(nextPath, nextDrivePath);
5537
+ continue;
5538
+ }
5539
+ if (!stats.isFile()) {
5540
+ continue;
5541
+ }
5542
+ const cached = options.cache?.[nextDrivePath];
5543
+ if (cached !== void 0 && cached.mtime_ms === stats.mtimeMs && cached.size_bytes === stats.size) {
5544
+ options.onCacheUpdate?.(nextDrivePath, cached);
5545
+ candidates.push({ path: nextDrivePath, entry: { sha256: cached.sha256, size_bytes: cached.size_bytes } });
5546
+ continue;
5547
+ }
5548
+ const digest = await hashDriveFile(nextPath);
5549
+ if (!digest) {
5550
+ continue;
5551
+ }
5552
+ options.onCacheUpdate?.(nextDrivePath, { mtime_ms: stats.mtimeMs, size_bytes: digest.sizeBytes, sha256: digest.sha256 });
5553
+ candidates.push({ path: nextDrivePath, entry: { sha256: digest.sha256, size_bytes: digest.sizeBytes } });
5554
+ } catch (error) {
5555
+ if (!isTransientScanError(error) || !options.onPathError) throw error;
5556
+ await options.onPathError(nextDrivePath, error);
5461
5557
  }
5462
- candidates.push({ path: nextDrivePath, entry: { sha256: digest.sha256, size_bytes: digest.sizeBytes } });
5463
5558
  }
5464
5559
  }
5465
5560
  async function addNonCollidingFiles(candidates2) {
@@ -5488,6 +5583,75 @@ async function scanDriveFiles(root, options = {}) {
5488
5583
  return currentDrivePath === "" && entry.name === DRIVE_DIR;
5489
5584
  }
5490
5585
  }
5586
+ async function rescanDriveFiles(root, dirtyPaths, options = {}) {
5587
+ const kept = { ...options.cache ?? {} };
5588
+ for (const dirtyPath of new Set(dirtyPaths)) {
5589
+ if (dirtyPath === "" || isInternalSyncArtifactName(pathPosix2.basename(dirtyPath))) continue;
5590
+ try {
5591
+ validateDrivePath(dirtyPath);
5592
+ } catch (error) {
5593
+ if (!options.onPathError) throw error;
5594
+ await options.onPathError(dirtyPath, error);
5595
+ continue;
5596
+ }
5597
+ removePathAndChildren(kept, dirtyPath);
5598
+ const absPath = join4(root, ...dirtyPath.split("/"));
5599
+ let stats;
5600
+ try {
5601
+ stats = await lstat(absPath);
5602
+ } catch (error) {
5603
+ if (isNotFoundError(error)) continue;
5604
+ if (!isTransientScanError(error) || !options.onPathError) throw error;
5605
+ await options.onPathError(dirtyPath, error);
5606
+ continue;
5607
+ }
5608
+ if (stats.isSymbolicLink()) continue;
5609
+ if (stats.isDirectory()) {
5610
+ await scanDriveFiles(root, {
5611
+ ...options,
5612
+ startDrivePath: dirtyPath,
5613
+ onCacheUpdate: (path, entry) => {
5614
+ kept[path] = entry;
5615
+ }
5616
+ });
5617
+ continue;
5618
+ }
5619
+ if (!stats.isFile()) continue;
5620
+ try {
5621
+ const cached = options.cache?.[dirtyPath];
5622
+ if (cached !== void 0 && cached.mtime_ms === stats.mtimeMs && cached.size_bytes === stats.size) {
5623
+ kept[dirtyPath] = cached;
5624
+ continue;
5625
+ }
5626
+ const digest = await hashDriveFile(absPath);
5627
+ if (!digest) continue;
5628
+ kept[dirtyPath] = { mtime_ms: stats.mtimeMs, size_bytes: digest.sizeBytes, sha256: digest.sha256 };
5629
+ } catch (error) {
5630
+ if (!isTransientScanError(error) || !options.onPathError) throw error;
5631
+ await options.onPathError(dirtyPath, error);
5632
+ }
5633
+ }
5634
+ const files = {};
5635
+ for (const [path, entry] of Object.entries(kept)) {
5636
+ options.onCacheUpdate?.(path, entry);
5637
+ files[path] = { sha256: entry.sha256, size_bytes: entry.size_bytes };
5638
+ }
5639
+ return files;
5640
+ }
5641
+ function removePathAndChildren(view, path) {
5642
+ delete view[path];
5643
+ const prefix = `${path}/`;
5644
+ for (const key of Object.keys(view)) {
5645
+ if (key.startsWith(prefix)) delete view[key];
5646
+ }
5647
+ }
5648
+ function isNotFoundError(error) {
5649
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
5650
+ }
5651
+ var TRANSIENT_SCAN_ERROR_CODES = /* @__PURE__ */ new Set(["ENOENT", "EPERM", "EBUSY"]);
5652
+ function isTransientScanError(error) {
5653
+ return error instanceof Error && "code" in error && typeof error.code === "string" && TRANSIENT_SCAN_ERROR_CODES.has(error.code);
5654
+ }
5491
5655
  function isInternalSyncArtifactName(name) {
5492
5656
  if (!name.startsWith(".") || !name.endsWith(".tmp")) return false;
5493
5657
  return name.includes(".wspc-download-") || name.includes(".wspc-backup-") || name.includes(".wspc-conflict-") || name.includes(".wspc-merge-");
@@ -5536,13 +5700,13 @@ async function hashDriveFile(path) {
5536
5700
  // src/handwritten/commands/drive/local-mutations.ts
5537
5701
  import { createWriteStream as createWriteStream2 } from "fs";
5538
5702
  import { link, mkdir as mkdir2, readFile as readFile3, rename as rename2, rm as rm2, unlink, writeFile as writeFile2 } from "fs/promises";
5539
- import { basename as basename2, dirname, join as join4 } from "path";
5703
+ import { basename as basename2, dirname, join as join5 } from "path";
5540
5704
  import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
5541
5705
  import { Readable as Readable2, Transform } from "stream";
5542
5706
  import { pipeline as pipeline2 } from "stream/promises";
5543
5707
  async function assertLocalStillScanned(localPath, scanned) {
5544
5708
  const snapshot = await hashDriveFile(localPath).catch((error) => {
5545
- if (isNotFoundError(error)) return void 0;
5709
+ if (isNotFoundError2(error)) return void 0;
5546
5710
  throw error;
5547
5711
  });
5548
5712
  if (!snapshot || snapshot.sha256 !== scanned.sha256 || snapshot.sizeBytes !== scanned.size_bytes) {
@@ -5552,7 +5716,7 @@ async function assertLocalStillScanned(localPath, scanned) {
5552
5716
  async function writeMergedLocalFile(root, path, bytes, digest, scanned, onLocalMutation) {
5553
5717
  const target = resolveInsideRoot(root, path);
5554
5718
  await mkdir2(dirname(target), { recursive: true });
5555
- const tmp = join4(dirname(target), `.${basename2(target)}.wspc-merge-${randomUUID3()}.tmp`);
5719
+ const tmp = join5(dirname(target), `.${basename2(target)}.wspc-merge-${randomUUID3()}.tmp`);
5556
5720
  try {
5557
5721
  await writeFile2(tmp, bytes, { flag: "wx" });
5558
5722
  return await installMergedLocalFile(root, path, tmp, scanned, digest, bytes.byteLength, onLocalMutation);
@@ -5570,7 +5734,7 @@ async function installMergedLocalFile(root, path, tmp, scanned, mergedSha256, me
5570
5734
  await rename2(target, backup);
5571
5735
  onLocalMutation();
5572
5736
  } catch (error) {
5573
- if (isNotFoundError(error)) {
5737
+ if (isNotFoundError2(error)) {
5574
5738
  throw new Error("local file changed after scan");
5575
5739
  }
5576
5740
  throw error;
@@ -5604,14 +5768,14 @@ async function installMergedLocalFile(root, path, tmp, scanned, mergedSha256, me
5604
5768
  }
5605
5769
  }
5606
5770
  async function restoreMergedLocalFile(target, backup, backupSha256, backupSizeBytes, mergedSha256, mergedSizeBytes) {
5607
- const quarantine = join4(dirname(target), `.${basename2(target)}.wspc-merge-restore-${randomUUID3()}.tmp`);
5771
+ const quarantine = join5(dirname(target), `.${basename2(target)}.wspc-merge-restore-${randomUUID3()}.tmp`);
5608
5772
  try {
5609
5773
  await rename2(target, quarantine);
5610
5774
  } catch (error) {
5611
- if (!isNotFoundError(error)) throw error;
5775
+ if (!isNotFoundError2(error)) throw error;
5612
5776
  }
5613
5777
  const quarantineDigest = await hashDriveFile(quarantine).catch((error) => {
5614
- if (isNotFoundError(error)) return void 0;
5778
+ if (isNotFoundError2(error)) return void 0;
5615
5779
  throw error;
5616
5780
  });
5617
5781
  if (quarantineDigest !== void 0 && (quarantineDigest.sha256 !== mergedSha256 || quarantineDigest.sizeBytes !== mergedSizeBytes)) {
@@ -5634,7 +5798,7 @@ async function restoreMergedLocalFile(target, backup, backupSha256, backupSizeBy
5634
5798
  async function downloadRemote(root, libraryId, path, api, expectedSha256, entry, onLocalMutation) {
5635
5799
  const target = resolveInsideRoot(root, path);
5636
5800
  await mkdir2(dirname(target), { recursive: true });
5637
- const tmp = join4(dirname(target), `.${basename2(target)}.wspc-download-${randomUUID3()}.tmp`);
5801
+ const tmp = join5(dirname(target), `.${basename2(target)}.wspc-download-${randomUUID3()}.tmp`);
5638
5802
  try {
5639
5803
  const response = await api.downloadFile(libraryId, path);
5640
5804
  if (!response.body) {
@@ -5673,7 +5837,7 @@ async function installDownloadedFile(root, path, tmp, entry, onLocalMutation) {
5673
5837
  await rename2(target, backup);
5674
5838
  onLocalMutation();
5675
5839
  } catch (error) {
5676
- if (!isNotFoundError(error)) throw error;
5840
+ if (!isNotFoundError2(error)) throw error;
5677
5841
  await installNoOverwrite(tmp, target, onLocalMutation);
5678
5842
  return;
5679
5843
  }
@@ -5718,7 +5882,7 @@ async function removeLocalIfStillBase(root, path, entry, onLocalMutation) {
5718
5882
  await rename2(target, backup);
5719
5883
  onLocalMutation();
5720
5884
  } catch (error) {
5721
- if (isNotFoundError(error)) {
5885
+ if (isNotFoundError2(error)) {
5722
5886
  throw new Error("local file changed before delete");
5723
5887
  }
5724
5888
  throw error;
@@ -5756,19 +5920,19 @@ async function restoreBackupWhenPossible(backup, target) {
5756
5920
  return true;
5757
5921
  } catch (error) {
5758
5922
  if (isAlreadyExistsError(error)) return false;
5759
- if (isNotFoundError(error)) return true;
5923
+ if (isNotFoundError2(error)) return true;
5760
5924
  return false;
5761
5925
  }
5762
5926
  }
5763
5927
  async function localFileExists(path) {
5764
5928
  const digest = await hashDriveFile(path).catch((error) => {
5765
- if (isNotFoundError(error)) return void 0;
5929
+ if (isNotFoundError2(error)) return void 0;
5766
5930
  throw error;
5767
5931
  });
5768
5932
  return digest !== void 0;
5769
5933
  }
5770
5934
  function localMutationBackupPath(target) {
5771
- return join4(dirname(target), `.${basename2(target)}.wspc-backup-${randomUUID3()}.tmp`);
5935
+ return join5(dirname(target), `.${basename2(target)}.wspc-backup-${randomUUID3()}.tmp`);
5772
5936
  }
5773
5937
  function expectedLocalBaseSha256(entry) {
5774
5938
  return entry?.last_local_sha256 ?? entry?.content_sha256;
@@ -5778,14 +5942,14 @@ async function readStableUploadBody(localPath, scanned) {
5778
5942
  throw new Error("local file missing from scan");
5779
5943
  }
5780
5944
  const snapshot = await hashDriveFile(localPath).catch((error) => {
5781
- if (isNotFoundError(error)) return void 0;
5945
+ if (isNotFoundError2(error)) return void 0;
5782
5946
  throw error;
5783
5947
  });
5784
5948
  if (!snapshot || snapshot.sha256 !== scanned.sha256 || snapshot.sizeBytes !== scanned.size_bytes) {
5785
5949
  throw new Error("local file changed after scan");
5786
5950
  }
5787
5951
  const body = await readFile3(localPath).catch((error) => {
5788
- if (isNotFoundError(error)) return void 0;
5952
+ if (isNotFoundError2(error)) return void 0;
5789
5953
  throw error;
5790
5954
  });
5791
5955
  if (!body) {
@@ -5802,7 +5966,7 @@ async function readStableUploadBody(localPath, scanned) {
5802
5966
  async function assertLocalSafeForDownload(root, path, entry) {
5803
5967
  const target = resolveInsideRoot(root, path);
5804
5968
  const digest = await hashDriveFile(target).catch((error) => {
5805
- if (isNotFoundError(error)) return void 0;
5969
+ if (isNotFoundError2(error)) return void 0;
5806
5970
  throw error;
5807
5971
  });
5808
5972
  if (!digest) return;
@@ -5815,14 +5979,14 @@ async function assertLocalSafeForDownload(root, path, entry) {
5815
5979
  }
5816
5980
  async function assertLocalAbsentBeforeRemoteDelete(root, path) {
5817
5981
  const digest = await hashDriveFile(resolveInsideRoot(root, path)).catch((error) => {
5818
- if (isNotFoundError(error)) return void 0;
5982
+ if (isNotFoundError2(error)) return void 0;
5819
5983
  throw error;
5820
5984
  });
5821
5985
  if (digest) {
5822
5986
  throw new Error("local file appeared before remote delete");
5823
5987
  }
5824
5988
  }
5825
- function isNotFoundError(error) {
5989
+ function isNotFoundError2(error) {
5826
5990
  return error instanceof Error && "code" in error && error.code === "ENOENT";
5827
5991
  }
5828
5992
  function isAlreadyExistsError(error) {
@@ -5846,31 +6010,82 @@ function emptySummary() {
5846
6010
  function isActionableAction(action) {
5847
6011
  return action.type !== "unchanged" && action.type !== "state_only" && action.type !== "remove_state";
5848
6012
  }
5849
- async function runDriveSyncOnce(root, api, clock = systemDriveClock, onProgress) {
6013
+ async function runDriveSyncOnce(root, api, clock = systemDriveClock, onProgress, debug = noopDriveDebugLogger, options = {}) {
5850
6014
  return withDriveLock(root, async () => {
5851
6015
  let state = await readDriveState(root);
5852
- const syncApi = api ?? await createDriveApi();
6016
+ const syncApi = api ?? await createDriveApi({ clientId: state.realtime?.client_id });
5853
6017
  const summary = emptySummary();
5854
6018
  const blockedPaths = /* @__PURE__ */ new Set();
5855
- const localFiles = await scanDriveFiles(root, {
6019
+ const scanStartedMs = Date.now();
6020
+ const nextScanCache = {};
6021
+ const scanOptions = {
6022
+ cache: state.scan_cache,
6023
+ onCacheUpdate: (path, entry) => {
6024
+ nextScanCache[path] = entry;
6025
+ },
5856
6026
  onPathError: async (path, error) => {
5857
- await recordPathError(summary, blockedPaths, path, error);
6027
+ await recordPathError(summary, blockedPaths, path, error, { debug, op: "scan" });
5858
6028
  }
5859
- });
5860
- const remoteFiles = await fetchRemoteManifest(root, state, syncApi, summary, blockedPaths);
6029
+ };
6030
+ const useIncrementalScan = options.dirtyPaths !== void 0 && state.scan_cache !== void 0;
6031
+ const localFiles = useIncrementalScan ? await rescanDriveFiles(root, options.dirtyPaths, scanOptions) : await scanDriveFiles(root, scanOptions);
6032
+ if (JSON.stringify(state.scan_cache ?? {}) !== JSON.stringify(nextScanCache)) {
6033
+ state = { ...state, scan_cache: nextScanCache };
6034
+ await writeDriveState(root, state, clock);
6035
+ }
6036
+ const scanMs = Date.now() - scanStartedMs;
6037
+ const manifestStartedMs = Date.now();
6038
+ const manifest = await fetchRemoteManifest(root, state, syncApi, summary, blockedPaths, debug);
6039
+ const remoteFiles = manifest.remoteFiles;
6040
+ if (manifest.manifestCursor !== void 0 && manifest.manifestCursor !== state.manifest_cursor) {
6041
+ state = { ...state, manifest_cursor: manifest.manifestCursor };
6042
+ await writeDriveState(root, state, clock);
6043
+ }
6044
+ const manifestMs = Date.now() - manifestStartedMs;
5861
6045
  const paths = Array.from(
5862
6046
  /* @__PURE__ */ new Set([...Object.keys(localFiles), ...Object.keys(remoteFiles), ...Object.keys(state.entries)])
5863
6047
  ).filter((path) => !blockedPaths.has(path)).sort((left, right) => left.localeCompare(right));
6048
+ const movedPaths = await applyRenamesAsMoves({
6049
+ root,
6050
+ state,
6051
+ api: syncApi,
6052
+ paths,
6053
+ localFiles,
6054
+ remoteFiles,
6055
+ summary,
6056
+ clock,
6057
+ debug,
6058
+ onStateChange: (nextState) => {
6059
+ state = nextState;
6060
+ }
6061
+ });
5864
6062
  const total = paths.filter(
5865
- (path) => isActionableAction(decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path]))
6063
+ (path) => !movedPaths.has(path) && isActionableAction(decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path]))
5866
6064
  ).length;
5867
6065
  let processed = 0;
5868
6066
  onProgress?.(processed, total);
6067
+ const processStartedMs = Date.now();
5869
6068
  for (const path of paths) {
6069
+ if (movedPaths.has(path)) continue;
5870
6070
  const remote = remoteFiles[path];
5871
- const action = decideDriveAction(state.entries[path], localFiles[path], remote);
5872
- const result = await processPath({ root, state, api: syncApi, path, action, remote, local: localFiles[path], summary, clock });
6071
+ const local = localFiles[path];
6072
+ const action = decideDriveAction(state.entries[path], local, remote);
6073
+ if (isActionableAction(action)) {
6074
+ debug.log("decision", decisionFields(path, action, state.entries[path], local, remote));
6075
+ }
6076
+ const previousConflict = state.conflicts[path];
6077
+ const result = await processPath({ root, state, api: syncApi, path, action, remote, local, summary, clock, debug });
5873
6078
  state = result.state;
6079
+ const recordedConflict = state.conflicts[path];
6080
+ if (recordedConflict !== void 0 && recordedConflict !== previousConflict) {
6081
+ debug.log("conflict", {
6082
+ path,
6083
+ reason: recordedConflict.reason,
6084
+ ...recordedConflict.type === void 0 ? {} : { type: recordedConflict.type },
6085
+ ...recordedConflict.strategy === void 0 ? {} : { strategy: recordedConflict.strategy },
6086
+ ...recordedConflict.conflict_paths === void 0 ? {} : { conflict_paths: recordedConflict.conflict_paths }
6087
+ });
6088
+ }
5874
6089
  if (isActionableAction(action)) {
5875
6090
  processed += 1;
5876
6091
  onProgress?.(processed, total);
@@ -5878,9 +6093,20 @@ async function runDriveSyncOnce(root, api, clock = systemDriveClock, onProgress)
5878
6093
  if (result.stop) break;
5879
6094
  }
5880
6095
  recordUnresolvedConflicts(summary, state);
6096
+ debug.log("sync_phases", { scan_ms: scanMs, manifest_ms: manifestMs, process_ms: Date.now() - processStartedMs });
5881
6097
  return summary;
5882
6098
  });
5883
6099
  }
6100
+ function decisionFields(path, action, entry, local, remote) {
6101
+ return {
6102
+ path,
6103
+ action: action.type,
6104
+ ..."reason" in action && action.reason !== void 0 ? { reason: action.reason } : {},
6105
+ ...entry === void 0 ? {} : { base_version_id: entry.current_version_id, base_entry_version: entry.entry_version, base_sha256: entry.content_sha256 },
6106
+ ...local === void 0 ? {} : { local_sha256: local.sha256, local_size_bytes: local.size_bytes },
6107
+ ...remote === void 0 ? {} : { remote_version_id: remote.current_version_id, remote_entry_version: remote.entry_version, remote_sha256: remote.content_sha256 }
6108
+ };
6109
+ }
5884
6110
  function driveSyncCommand(api) {
5885
6111
  const sync = new Command80("sync").description("Drive sync commands");
5886
6112
  sync.command("once").description("Run one Drive sync pass").argument("[path]", "local folder path", ".").action(async (path) => {
@@ -5892,24 +6118,64 @@ function driveSyncCommand(api) {
5892
6118
  });
5893
6119
  return sync;
5894
6120
  }
5895
- async function fetchRemoteManifest(root, state, api, summary, blockedPaths) {
6121
+ async function fetchRemoteManifest(root, state, api, summary, blockedPaths, debug) {
6122
+ if (state.manifest_cursor !== void 0) {
6123
+ const delta = await api.getManifest(state.library_id, void 0, state.manifest_cursor);
6124
+ if (delta.resync_required !== true) {
6125
+ const remoteFiles = remoteViewFromState(state);
6126
+ const changed = delta.entries.filter((entry) => entry.deleted_at === void 0);
6127
+ const normalized2 = normalizeRemoteManifest(root, changed);
6128
+ for (const pathError of normalized2.pathErrors) {
6129
+ await recordPathError(summary, blockedPaths, pathError.path, pathError.error, {
6130
+ appendPathResult: pathError.appendPathResult,
6131
+ debug,
6132
+ op: "manifest"
6133
+ });
6134
+ }
6135
+ for (const entry of delta.entries) {
6136
+ if (entry.deleted_at !== void 0) delete remoteFiles[entry.path];
6137
+ }
6138
+ Object.assign(remoteFiles, normalized2.remoteFiles);
6139
+ return { remoteFiles, manifestCursor: delta.latest_cursor ?? state.manifest_cursor };
6140
+ }
6141
+ }
5896
6142
  const entries = [];
5897
6143
  let cursor;
6144
+ let latestCursor;
5898
6145
  do {
5899
6146
  const page = await api.getManifest(state.library_id, cursor);
5900
6147
  entries.push(...page.entries);
6148
+ if (page.latest_cursor !== void 0) latestCursor = page.latest_cursor;
5901
6149
  cursor = page.next_cursor ?? void 0;
5902
6150
  } while (cursor !== void 0);
5903
6151
  const normalized = normalizeRemoteManifest(root, entries);
5904
6152
  for (const pathError of normalized.pathErrors) {
5905
6153
  await recordPathError(summary, blockedPaths, pathError.path, pathError.error, {
5906
- appendPathResult: pathError.appendPathResult
6154
+ appendPathResult: pathError.appendPathResult,
6155
+ debug,
6156
+ op: "manifest"
5907
6157
  });
5908
6158
  }
5909
- return normalized.remoteFiles;
6159
+ return { remoteFiles: normalized.remoteFiles, manifestCursor: latestCursor };
6160
+ }
6161
+ function remoteViewFromState(state) {
6162
+ const remoteFiles = {};
6163
+ for (const [path, entry] of Object.entries(state.entries)) {
6164
+ remoteFiles[path] = {
6165
+ id: entry.entry_id,
6166
+ path,
6167
+ kind: "file",
6168
+ entry_version: entry.entry_version,
6169
+ size_bytes: entry.size_bytes,
6170
+ updated_at: entry.last_synced_at,
6171
+ ...entry.current_version_id === void 0 ? {} : { current_version_id: entry.current_version_id },
6172
+ ...entry.content_sha256 === void 0 ? {} : { content_sha256: entry.content_sha256 }
6173
+ };
6174
+ }
6175
+ return remoteFiles;
5910
6176
  }
5911
6177
  async function processPath(args) {
5912
- const { root, state, api, path, action, remote, local, summary, clock } = args;
6178
+ const { root, state, api, path, action, remote, local, summary, clock, debug } = args;
5913
6179
  summary.paths.push({ path, action: action.type });
5914
6180
  let durableStateRequired = false;
5915
6181
  try {
@@ -6010,35 +6276,17 @@ async function processPath(args) {
6010
6276
  summary.conflicts += 1;
6011
6277
  return { state: nextState2, stop: false };
6012
6278
  }
6013
- const conflictCopyState = await recordRemoteConflictCopy({
6014
- root,
6015
- state,
6016
- api,
6017
- path,
6018
- reason: action.reason,
6019
- type: "edit_edit",
6020
- remote,
6021
- clock
6022
- });
6023
- if (conflictCopyState) {
6024
- summary.conflicts += 1;
6025
- return { state: conflictCopyState, stop: false };
6279
+ const resolved = await resolveConflictWithLocalAsMain({ root, state, api, path, remote, local, clock });
6280
+ if (resolved) {
6281
+ recordResolvedConflict(summary, debug, path, resolved.copyPath, action.reason, "edit_edit");
6282
+ return { state: resolved.state, stop: false };
6026
6283
  }
6027
6284
  }
6028
6285
  if (action.reason === "local_and_remote_without_base") {
6029
- const conflictCopyState = await recordRemoteConflictCopy({
6030
- root,
6031
- state,
6032
- api,
6033
- path,
6034
- reason: action.reason,
6035
- type: "create_create",
6036
- remote,
6037
- clock
6038
- });
6039
- if (conflictCopyState) {
6040
- summary.conflicts += 1;
6041
- return { state: conflictCopyState, stop: false };
6286
+ const resolved = await resolveConflictWithLocalAsMain({ root, state, api, path, remote, local, clock });
6287
+ if (resolved) {
6288
+ recordResolvedConflict(summary, debug, path, resolved.copyPath, action.reason, "create_create");
6289
+ return { state: resolved.state, stop: false };
6042
6290
  }
6043
6291
  }
6044
6292
  if (action.reason === "local_changed_remote_deleted") {
@@ -6078,11 +6326,11 @@ async function processPath(args) {
6078
6326
  summary.paths[summary.paths.length - 1] = { path, action: "conflict" };
6079
6327
  return { state: nextState, stop: false };
6080
6328
  } catch (writeError) {
6081
- await recordPathError(summary, void 0, path, writeError);
6329
+ await recordPathError(summary, void 0, path, writeError, { debug, op: "process" });
6082
6330
  return { state, stop: durableStateRequired };
6083
6331
  }
6084
6332
  }
6085
- await recordPathError(summary, void 0, path, error);
6333
+ await recordPathError(summary, void 0, path, error, { debug, op: "process" });
6086
6334
  return { state, stop: durableStateRequired };
6087
6335
  }
6088
6336
  return { state, stop: false };
@@ -6144,6 +6392,84 @@ async function downloadBytes(api, libraryId, path, versionId) {
6144
6392
  const response = await api.downloadFile(libraryId, path, versionId);
6145
6393
  return new Uint8Array(await response.arrayBuffer());
6146
6394
  }
6395
+ async function applyRenamesAsMoves(args) {
6396
+ const { root, api, paths, localFiles, remoteFiles, summary, clock, debug, onStateChange } = args;
6397
+ const movedPaths = /* @__PURE__ */ new Set();
6398
+ if (api.moveFile === void 0) return movedPaths;
6399
+ let state = args.state;
6400
+ const deletesBySha = /* @__PURE__ */ new Map();
6401
+ const createsBySha = /* @__PURE__ */ new Map();
6402
+ for (const path of paths) {
6403
+ const action = decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path]);
6404
+ if (action.type === "delete_remote") {
6405
+ const sha = state.entries[path]?.last_local_sha256 ?? state.entries[path]?.content_sha256;
6406
+ if (sha !== void 0) deletesBySha.set(sha, [...deletesBySha.get(sha) ?? [], path]);
6407
+ }
6408
+ if (action.type === "upload_create") {
6409
+ const sha = localFiles[path]?.sha256;
6410
+ if (sha !== void 0) createsBySha.set(sha, [...createsBySha.get(sha) ?? [], path]);
6411
+ }
6412
+ }
6413
+ for (const [sha, fromPaths] of deletesBySha) {
6414
+ const toPaths = createsBySha.get(sha);
6415
+ if (fromPaths.length !== 1 || toPaths === void 0 || toPaths.length !== 1) continue;
6416
+ const fromPath = fromPaths[0];
6417
+ const toPath = toPaths[0];
6418
+ const entry = state.entries[fromPath];
6419
+ const local = localFiles[toPath];
6420
+ if (entry === void 0 || local === void 0) continue;
6421
+ try {
6422
+ const moved = await api.moveFile(state.library_id, fromPath, toPath, entry.entry_version);
6423
+ const nextState = cloneDriveState(state);
6424
+ delete nextState.entries[fromPath];
6425
+ delete nextState.conflicts[fromPath];
6426
+ nextState.entries[toPath] = stateEntryFromRemote(moved.entry, local.sha256, clock);
6427
+ delete nextState.conflicts[toPath];
6428
+ await writeDriveState(root, nextState, clock);
6429
+ state = nextState;
6430
+ onStateChange(nextState);
6431
+ movedPaths.add(fromPath);
6432
+ movedPaths.add(toPath);
6433
+ summary.paths.push({ path: toPath, action: "move" });
6434
+ debug.log("decision", { path: toPath, action: "move", from_path: fromPath });
6435
+ } catch (error) {
6436
+ debug.log("error", { path: toPath, op: "move", message: errorMessage(error) });
6437
+ }
6438
+ }
6439
+ return movedPaths;
6440
+ }
6441
+ function recordResolvedConflict(summary, debug, path, copyPath, reason, type) {
6442
+ summary.conflicts += 1;
6443
+ summary.conflict_paths.push(copyPath);
6444
+ const lastPath = summary.paths.at(-1);
6445
+ if (lastPath?.path === path) {
6446
+ lastPath.conflict_paths = [copyPath];
6447
+ }
6448
+ debug.log("conflict", { path, reason, type, strategy: "conflict_copy", conflict_paths: [copyPath] });
6449
+ }
6450
+ async function resolveConflictWithLocalAsMain(args) {
6451
+ const { root, state, api, path, remote, local, clock } = args;
6452
+ const remoteVersionId = remote?.current_version_id;
6453
+ if (!remote || remoteVersionId === void 0 || !local) {
6454
+ return void 0;
6455
+ }
6456
+ const previous = state.conflicts[path];
6457
+ let copyPath;
6458
+ if (previous !== void 0 && await canReuseConflictCopy(root, previous, remoteVersionId)) {
6459
+ copyPath = previous.conflict_paths[0];
6460
+ } else {
6461
+ const remoteBytes = await downloadBytes(api, state.library_id, path, remoteVersionId);
6462
+ copyPath = await writeConflictCopy(root, path, "remote", remoteVersionId, remoteBytes, clock);
6463
+ }
6464
+ const localPath = resolveInsideRoot(root, path);
6465
+ const { body, digest } = await readStableUploadBody(localPath, local);
6466
+ const uploaded = await api.uploadFile(state.library_id, path, body, digest, remote.entry_version);
6467
+ const nextState = cloneDriveState(state);
6468
+ nextState.entries[path] = stateEntryFromRemote(uploaded.entry, digest, clock);
6469
+ delete nextState.conflicts[path];
6470
+ await writeDriveState(root, nextState, clock);
6471
+ return { state: nextState, copyPath };
6472
+ }
6147
6473
  async function recordRemoteConflictCopy(args) {
6148
6474
  const { root, state, api, path, reason, type, remote, clock } = args;
6149
6475
  const entry = state.entries[path];
@@ -6194,7 +6520,7 @@ async function writeConflictCopy(root, path, side, versionId, bytes, clock) {
6194
6520
  const target = resolveInsideRoot(root, candidate);
6195
6521
  await mkdir3(dirname2(target), { recursive: true });
6196
6522
  for (; ; ) {
6197
- const tmp = join5(dirname2(target), `.${basename3(target)}.wspc-conflict-${randomUUID4()}.tmp`);
6523
+ const tmp = join6(dirname2(target), `.${basename3(target)}.wspc-conflict-${randomUUID4()}.tmp`);
6198
6524
  let tmpWritten = false;
6199
6525
  try {
6200
6526
  await writeFile3(tmp, bytes, { flag: "wx" });
@@ -6219,12 +6545,12 @@ function conflictCopyPathWithSuffix(path, suffix) {
6219
6545
  if (suffix === 1) {
6220
6546
  return path;
6221
6547
  }
6222
- const parsed = pathPosix2.parse(path);
6548
+ const parsed = pathPosix3.parse(path);
6223
6549
  const fileName = `${parsed.name}-${suffix}${parsed.ext}`;
6224
6550
  if (parsed.dir === "") {
6225
6551
  return fileName;
6226
6552
  }
6227
- return pathPosix2.join(parsed.dir, fileName);
6553
+ return pathPosix3.join(parsed.dir, fileName);
6228
6554
  }
6229
6555
  function isExpectedVersionDownloadMissing(error) {
6230
6556
  const structured = error;
@@ -6302,7 +6628,13 @@ async function recordPathError(summary, blockedPaths, path, error, options = {})
6302
6628
  } else {
6303
6629
  summary.paths.push({ path, action: "error" });
6304
6630
  }
6305
- void errorMessage(error);
6631
+ const code = error instanceof Error && "code" in error ? error.code : void 0;
6632
+ options.debug?.log("error", {
6633
+ path,
6634
+ ...options.op === void 0 ? {} : { op: options.op },
6635
+ ...code === void 0 ? {} : { code },
6636
+ message: errorMessage(error)
6637
+ });
6306
6638
  summary.errors += 1;
6307
6639
  }
6308
6640
  function conflict(reason, remote, clock) {
@@ -6338,7 +6670,7 @@ function containsVersionConflict(value) {
6338
6670
  import { Command as Command81 } from "commander";
6339
6671
  import chokidar from "chokidar";
6340
6672
  import { watch as fsWatch } from "fs";
6341
- import { relative as relative2, resolve as resolve4 } from "path";
6673
+ import { basename as basename4, relative as relative2, resolve as resolve4 } from "path";
6342
6674
 
6343
6675
  // src/handwritten/commands/drive/realtime.ts
6344
6676
  function createDriveRealtimeSource(args) {
@@ -6435,12 +6767,15 @@ function createDriveRealtimeSource(args) {
6435
6767
  return;
6436
6768
  }
6437
6769
  if (message.type === "library_changed") {
6438
- handlers?.onEvent({
6439
- debounce_ms: 2e3,
6440
- reason: "library_changed",
6441
- ...message.cursor === void 0 ? {} : { cursor: message.cursor },
6442
- ...message.path === void 0 ? {} : { path: message.path }
6443
- });
6770
+ const ownEcho = message.origin_client_id !== void 0 && message.origin_client_id === currentRealtime.client_id;
6771
+ if (!ownEcho) {
6772
+ handlers?.onEvent({
6773
+ debounce_ms: 2e3,
6774
+ reason: "library_changed",
6775
+ ...message.cursor === void 0 ? {} : { cursor: message.cursor },
6776
+ ...message.path === void 0 ? {} : { path: message.path }
6777
+ });
6778
+ }
6444
6779
  if (message.cursor !== void 0) {
6445
6780
  await persistBestEffort({ ...currentRealtime, last_cursor: message.cursor, last_event_at: driveIsoTimestamp(clock) });
6446
6781
  }
@@ -6527,7 +6862,8 @@ function parseDriveRealtimeMessage(raw) {
6527
6862
  return {
6528
6863
  type: "library_changed",
6529
6864
  ...cursor === void 0 ? {} : { cursor },
6530
- ...typeof value.path === "string" ? { path: value.path } : {}
6865
+ ...typeof value.path === "string" ? { path: value.path } : {},
6866
+ ...typeof value.origin_client_id === "string" ? { origin_client_id: value.origin_client_id } : {}
6531
6867
  };
6532
6868
  }
6533
6869
  if (messageType === "resync_required") {
@@ -6636,7 +6972,8 @@ function webSocketError(event) {
6636
6972
 
6637
6973
  // src/handwritten/commands/drive/watch.ts
6638
6974
  async function runDriveWatch(root, options = {}) {
6639
- const runSync = options.runSync ?? ((syncRoot, onProgress) => runDriveSyncOnce(syncRoot, void 0, void 0, onProgress));
6975
+ const dbg = options.debugLogger ?? (options.debug ? createDriveDebugLogger(root) : noopDriveDebugLogger);
6976
+ const runSync = options.runSync ?? ((syncRoot, onProgress, dirtyPaths2) => runDriveSyncOnce(syncRoot, void 0, void 0, onProgress, dbg, dirtyPaths2 === void 0 ? {} : { dirtyPaths: dirtyPaths2 }));
6640
6977
  const debounceMs = options.debounceMs ?? 500;
6641
6978
  const remoteDebounceMs = options.remoteDebounceMs ?? 2e3;
6642
6979
  const emit = options.onEvent ?? ((event) => {
@@ -6647,6 +6984,8 @@ async function runDriveWatch(root, options = {}) {
6647
6984
  }
6648
6985
  render({ kind: "drive_watch", display: { shape: "object" } }, event);
6649
6986
  });
6987
+ let nextTrigger = "initial";
6988
+ const dirtyPaths = /* @__PURE__ */ new Set();
6650
6989
  let debounceTimer;
6651
6990
  let debounceDeadlineMs;
6652
6991
  let retryTimer;
@@ -6671,14 +7010,34 @@ async function runDriveWatch(root, options = {}) {
6671
7010
  rerunRequested = false;
6672
7011
  try {
6673
7012
  emit({ kind: "drive_sync_start" });
6674
- const summary = await runSync(root, (processed, total) => {
6675
- emit({ kind: "drive_sync_progress", processed, total });
6676
- });
7013
+ dbg.log("sync_start", { trigger: nextTrigger });
7014
+ const startedAtMs = Date.now();
7015
+ const dirtySnapshot = nextTrigger === "local" ? [...dirtyPaths] : void 0;
7016
+ dirtyPaths.clear();
7017
+ const summary = await runSync(
7018
+ root,
7019
+ (processed, total) => {
7020
+ emit({ kind: "drive_sync_progress", processed, total });
7021
+ },
7022
+ dirtySnapshot
7023
+ );
6677
7024
  emit({ kind: "drive_sync_once", ...summary });
7025
+ dbg.log("sync_end", {
7026
+ duration_ms: Date.now() - startedAtMs,
7027
+ uploaded: summary.uploaded,
7028
+ downloaded: summary.downloaded,
7029
+ deleted: summary.deleted,
7030
+ merged: summary.merged,
7031
+ unchanged: summary.unchanged,
7032
+ conflicts: summary.conflicts,
7033
+ errors: summary.errors
7034
+ });
6678
7035
  backoffMs = 1e3;
6679
7036
  } catch (error) {
6680
7037
  if (isAuthError(error) || isFatalWatchError(error) || !isRetryableWatchError(error)) throw error;
6681
7038
  emit({ kind: "drive_watch_retry", delay_ms: backoffMs, error: errorMessage2(error) });
7039
+ dbg.log("retry", { delay_ms: backoffMs, error: errorMessage2(error) });
7040
+ nextTrigger = "retry";
6682
7041
  if (stopped) return;
6683
7042
  await waitForManagedTimer(backoffMs);
6684
7043
  if (stopped) return;
@@ -6696,7 +7055,8 @@ async function runDriveWatch(root, options = {}) {
6696
7055
  debounceTimer = void 0;
6697
7056
  debounceDeadlineMs = void 0;
6698
7057
  }
6699
- function scheduleSync(delayMs) {
7058
+ function scheduleSync(delayMs, trigger) {
7059
+ nextTrigger = trigger;
6700
7060
  if (running) {
6701
7061
  rerunRequested = true;
6702
7062
  return;
@@ -6767,7 +7127,11 @@ async function runDriveWatch(root, options = {}) {
6767
7127
  source = options.source ?? createDefaultWatchSource(root);
6768
7128
  source.onChange((path) => {
6769
7129
  if (isDriveInternalPath(root, path)) return;
6770
- scheduleSync(debounceMs);
7130
+ if (isInternalSyncArtifactName(basename4(path))) return;
7131
+ const drivePath = relative2(root, path).replace(/\\/g, "/");
7132
+ dirtyPaths.add(drivePath);
7133
+ dbg.log("fs_event", { path: drivePath });
7134
+ scheduleSync(debounceMs, "local");
6771
7135
  });
6772
7136
  emit({ kind: "drive_watch_started", root, library_id: state.library_id });
6773
7137
  realtimeSource?.start({
@@ -6776,11 +7140,12 @@ async function runDriveWatch(root, options = {}) {
6776
7140
  },
6777
7141
  onEvent(event) {
6778
7142
  emit(realtimeEvent(event));
7143
+ dbg.log("realtime_event", { ...event });
6779
7144
  if (event.immediate) {
6780
- scheduleSync(0);
7145
+ scheduleSync(0, "remote");
6781
7146
  return;
6782
7147
  }
6783
- scheduleSync(event.debounce_ms ?? remoteDebounceMs);
7148
+ scheduleSync(event.debounce_ms ?? remoteDebounceMs, "remote");
6784
7149
  },
6785
7150
  onReconnect(delayMs, error) {
6786
7151
  emit({ kind: "drive_realtime_reconnecting", delay_ms: delayMs, error });
@@ -6809,8 +7174,9 @@ async function runDriveWatch(root, options = {}) {
6809
7174
  }
6810
7175
  }
6811
7176
  function driveWatchCommand(options = {}) {
6812
- return new Command81("watch").description("Watch a bound Drive folder and sync local changes").argument("[path]", "local folder path", ".").action(async (path) => {
6813
- await runDriveWatch(resolve4(path), options);
7177
+ return new Command81("watch").description("Watch a bound Drive folder and sync local changes").argument("[path]", "local folder path", ".").option("--debug", "append debug NDJSON events to <folder>/.wspc-drive/debug.log").action(async (path, flags) => {
7178
+ const debug = options.debug ?? (flags.debug === true || process.env.WSPC_DRIVE_DEBUG === "1");
7179
+ await runDriveWatch(resolve4(path), { ...options, debug });
6814
7180
  });
6815
7181
  }
6816
7182
  function createDefaultWatchSource(root) {