@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 +486 -120
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/spec/openapi.json +14 -0
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.
|
|
1524
|
-
var SPEC_SHA = "
|
|
1525
|
-
var SPEC_FETCHED_AT = "2026-07-
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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
|
|
5412
|
-
const
|
|
5413
|
-
return
|
|
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
|
|
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
|
|
5425
|
-
await walk(
|
|
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
|
-
|
|
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 =
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
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
|
|
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 (
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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 (!
|
|
5775
|
+
if (!isNotFoundError2(error)) throw error;
|
|
5612
5776
|
}
|
|
5613
5777
|
const quarantineDigest = await hashDriveFile(quarantine).catch((error) => {
|
|
5614
|
-
if (
|
|
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 =
|
|
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 (!
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
5872
|
-
const
|
|
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
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
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
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
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
|
|
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
|
-
|
|
6675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|