@wspc/cli 0.1.2 → 0.1.3
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/README.md +26 -9
- package/dist/cli.js +1160 -740
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1464,9 +1464,9 @@ function createConsistencyFetch(opts) {
|
|
|
1464
1464
|
}
|
|
1465
1465
|
|
|
1466
1466
|
// src/version.ts
|
|
1467
|
-
var VERSION = "0.1.
|
|
1467
|
+
var VERSION = "0.1.3";
|
|
1468
1468
|
var SPEC_SHA = "e0bbc1e3";
|
|
1469
|
-
var SPEC_FETCHED_AT = "2026-06-
|
|
1469
|
+
var SPEC_FETCHED_AT = "2026-06-22T01:23:52.693Z";
|
|
1470
1470
|
var API_BASE = "https://api.wspc.ai";
|
|
1471
1471
|
|
|
1472
1472
|
// src/index.ts
|
|
@@ -1620,8 +1620,19 @@ async function loadAuthedFetch(opts = {}) {
|
|
|
1620
1620
|
const { fetch: fetch2, baseUrl } = await loadClientParts(opts);
|
|
1621
1621
|
return { fetch: fetch2, baseUrl };
|
|
1622
1622
|
}
|
|
1623
|
+
async function loadRealtimeAuthHeaders(opts = {}) {
|
|
1624
|
+
const { baseUrl, authInterceptor } = await loadClientParts(opts);
|
|
1625
|
+
const verifyUrl = new URL(opts.verifyPath ?? "/auth/me", `${baseUrl.replace(/\/$/, "")}/`);
|
|
1626
|
+
const verifyResponse = await authInterceptor.execute(new Request(verifyUrl));
|
|
1627
|
+
if (!verifyResponse.ok) {
|
|
1628
|
+
throw new Error(`realtime auth failed: HTTP ${verifyResponse.status}`);
|
|
1629
|
+
}
|
|
1630
|
+
const request = await authInterceptor.onRequest(new Request(baseUrl));
|
|
1631
|
+
return { baseUrl, headers: request.headers };
|
|
1632
|
+
}
|
|
1623
1633
|
async function loadSdkClientWithAuthedFetch(opts = {}) {
|
|
1624
|
-
|
|
1634
|
+
const { _rawClient, fetch: fetch2, baseUrl } = await loadClientParts(opts);
|
|
1635
|
+
return { _rawClient, fetch: fetch2, baseUrl };
|
|
1625
1636
|
}
|
|
1626
1637
|
async function loadClientParts(opts = {}) {
|
|
1627
1638
|
const store = opts.store ?? new ConfigStore();
|
|
@@ -1641,7 +1652,7 @@ async function loadClientParts(opts = {}) {
|
|
|
1641
1652
|
fetch: authedFetch
|
|
1642
1653
|
})
|
|
1643
1654
|
);
|
|
1644
|
-
return { _rawClient: rawClient, fetch: authedFetch, baseUrl: resolved.apiBase };
|
|
1655
|
+
return { _rawClient: rawClient, fetch: authedFetch, baseUrl: resolved.apiBase, authInterceptor: interceptor };
|
|
1645
1656
|
}
|
|
1646
1657
|
|
|
1647
1658
|
// src/handwritten/output/primitives.ts
|
|
@@ -1867,22 +1878,24 @@ function render(ctx, data) {
|
|
|
1867
1878
|
renderPaginationFooter(data);
|
|
1868
1879
|
}
|
|
1869
1880
|
function renderPaginationFooter(data) {
|
|
1870
|
-
if (data
|
|
1871
|
-
const
|
|
1872
|
-
if (
|
|
1873
|
-
process.stdout.write(dim(` \u2026 more results \u2014 re-run with --cursor ${
|
|
1874
|
-
}
|
|
1875
|
-
const id =
|
|
1876
|
-
|
|
1881
|
+
if (!isRecord(data)) return;
|
|
1882
|
+
const nextCursor = getString(data, "next_cursor");
|
|
1883
|
+
if (nextCursor !== void 0 && nextCursor.length > 0) {
|
|
1884
|
+
process.stdout.write(dim(` \u2026 more results \u2014 re-run with --cursor ${nextCursor}`) + "\n");
|
|
1885
|
+
}
|
|
1886
|
+
const id = getString(data, "id") ?? "<id>";
|
|
1887
|
+
const childrenNextCursor = getString(data, "children_next_cursor");
|
|
1888
|
+
if (childrenNextCursor !== void 0 && childrenNextCursor.length > 0) {
|
|
1877
1889
|
process.stdout.write(dim(` \u2026 more children \u2014 wspc todo ls --parent ${id}`) + "\n");
|
|
1878
1890
|
}
|
|
1879
|
-
|
|
1891
|
+
const commentsNextCursor = getString(data, "comments_next_cursor");
|
|
1892
|
+
if (commentsNextCursor !== void 0 && commentsNextCursor.length > 0) {
|
|
1880
1893
|
process.stdout.write(dim(` \u2026 more comments \u2014 wspc todo comment ls ${id}`) + "\n");
|
|
1881
1894
|
}
|
|
1882
1895
|
}
|
|
1883
1896
|
function drillDataPath(data, dataPath) {
|
|
1884
1897
|
if (!dataPath) return data;
|
|
1885
|
-
if (data
|
|
1898
|
+
if (!isRecord(data)) return data;
|
|
1886
1899
|
const value = data[dataPath];
|
|
1887
1900
|
return value === void 0 ? data : value;
|
|
1888
1901
|
}
|
|
@@ -1910,9 +1923,9 @@ function renderGeneric(data, hints) {
|
|
|
1910
1923
|
}
|
|
1911
1924
|
function detectShape(data) {
|
|
1912
1925
|
if (Array.isArray(data)) return "list";
|
|
1913
|
-
if (
|
|
1926
|
+
if (isRecord(data)) {
|
|
1914
1927
|
const keys = Object.keys(data);
|
|
1915
|
-
const arrayKeys = keys.filter((k) =>
|
|
1928
|
+
const arrayKeys = keys.filter((k) => getArray(data, k) !== void 0);
|
|
1916
1929
|
if (arrayKeys.length === 1) return "list";
|
|
1917
1930
|
return "object";
|
|
1918
1931
|
}
|
|
@@ -1920,9 +1933,10 @@ function detectShape(data) {
|
|
|
1920
1933
|
}
|
|
1921
1934
|
function extractItems(data) {
|
|
1922
1935
|
if (Array.isArray(data)) return data;
|
|
1923
|
-
if (
|
|
1924
|
-
for (const
|
|
1925
|
-
|
|
1936
|
+
if (isRecord(data)) {
|
|
1937
|
+
for (const key of Object.keys(data)) {
|
|
1938
|
+
const value = getArray(data, key);
|
|
1939
|
+
if (value !== void 0) return value;
|
|
1926
1940
|
}
|
|
1927
1941
|
}
|
|
1928
1942
|
return [];
|
|
@@ -1933,13 +1947,13 @@ function renderList(data, hints) {
|
|
|
1933
1947
|
process.stdout.write(dim(" " + (hints?.emptyMessage ?? "no items")) + "\n");
|
|
1934
1948
|
return;
|
|
1935
1949
|
}
|
|
1936
|
-
const first = items[0];
|
|
1950
|
+
const first = isRecord(items[0]) ? items[0] : {};
|
|
1937
1951
|
const columns = pickColumns(first, hints?.columns);
|
|
1938
1952
|
const format = hints?.format ?? {};
|
|
1939
1953
|
const headers = columns.map((c) => c.toUpperCase());
|
|
1940
1954
|
const rows = items.map(
|
|
1941
1955
|
(item) => columns.map(
|
|
1942
|
-
(col) => formatCell(item[col], format[col], hints?.enumColorMap?.[col])
|
|
1956
|
+
(col) => formatCell(isRecord(item) ? item[col] : void 0, format[col], hints?.enumColorMap?.[col])
|
|
1943
1957
|
)
|
|
1944
1958
|
);
|
|
1945
1959
|
process.stdout.write(table(headers, rows));
|
|
@@ -1957,7 +1971,7 @@ function pickColumns(first, hint) {
|
|
|
1957
1971
|
return ordered.slice(0, 5);
|
|
1958
1972
|
}
|
|
1959
1973
|
function renderObject(data, hints) {
|
|
1960
|
-
if (
|
|
1974
|
+
if (!isRecord(data)) {
|
|
1961
1975
|
renderScalar(data);
|
|
1962
1976
|
return;
|
|
1963
1977
|
}
|
|
@@ -1966,7 +1980,7 @@ function renderObject(data, hints) {
|
|
|
1966
1980
|
if (topKeys.length === 1) {
|
|
1967
1981
|
const onlyKey = topKeys[0];
|
|
1968
1982
|
const inner = obj[onlyKey];
|
|
1969
|
-
if (
|
|
1983
|
+
if (isRecord(inner)) {
|
|
1970
1984
|
obj = inner;
|
|
1971
1985
|
}
|
|
1972
1986
|
}
|
|
@@ -1977,7 +1991,10 @@ function renderObject(data, hints) {
|
|
|
1977
1991
|
}
|
|
1978
1992
|
const format = hints?.format ?? {};
|
|
1979
1993
|
const arrayFields = hints?.fields ? [] : Object.keys(obj).filter(
|
|
1980
|
-
(k) =>
|
|
1994
|
+
(k) => {
|
|
1995
|
+
const value = getArray(obj, k);
|
|
1996
|
+
return value !== void 0 && value.length > 0;
|
|
1997
|
+
}
|
|
1981
1998
|
);
|
|
1982
1999
|
const formatted = fields.map((f) => [
|
|
1983
2000
|
f,
|
|
@@ -2006,7 +2023,8 @@ function renderObject(data, hints) {
|
|
|
2006
2023
|
for (const f of arrayFields) {
|
|
2007
2024
|
const uncapped = f === "children" || f === "comments";
|
|
2008
2025
|
const max = uncapped ? Number.POSITIVE_INFINITY : ARRAY_FIELD_MAX_ITEMS;
|
|
2009
|
-
|
|
2026
|
+
const items = getArray(obj, f);
|
|
2027
|
+
if (items !== void 0) renderArrayField(f, items, labelWidth, max);
|
|
2010
2028
|
}
|
|
2011
2029
|
const hadAbove = inlineFinal.length > 0 || arrayFields.length > 0;
|
|
2012
2030
|
blocks.forEach(([f, value], i) => {
|
|
@@ -2058,28 +2076,31 @@ function formatArrayItem(item) {
|
|
|
2058
2076
|
return JSON.stringify(item);
|
|
2059
2077
|
}
|
|
2060
2078
|
function formatTodoLike(item) {
|
|
2061
|
-
if (!
|
|
2062
|
-
const
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
const status =
|
|
2066
|
-
|
|
2079
|
+
if (!isRecord(item)) return null;
|
|
2080
|
+
const idValue = getString(item, "id");
|
|
2081
|
+
const title = getString(item, "title");
|
|
2082
|
+
if (idValue === void 0 || title === void 0) return null;
|
|
2083
|
+
const status = getString(item, "status");
|
|
2084
|
+
const id = idShort(idValue);
|
|
2085
|
+
const statusText = status === void 0 ? "" : statusBadge(status);
|
|
2086
|
+
return statusText ? `${id} ${statusText} ${title}` : `${id} ${title}`;
|
|
2067
2087
|
}
|
|
2068
2088
|
function formatCommentLike(item) {
|
|
2069
|
-
if (!
|
|
2070
|
-
const
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
const
|
|
2074
|
-
const
|
|
2089
|
+
if (!isRecord(item)) return null;
|
|
2090
|
+
const idValue = getString(item, "id");
|
|
2091
|
+
const content = getString(item, "content");
|
|
2092
|
+
if (idValue === void 0 || content === void 0) return null;
|
|
2093
|
+
const id = idShort(idValue);
|
|
2094
|
+
const when = item.created_at !== void 0 ? `${relativeTime(item.created_at)} ` : "";
|
|
2095
|
+
const snippet = truncate(content, 60);
|
|
2075
2096
|
return `${id} ${when}${snippet}`;
|
|
2076
2097
|
}
|
|
2077
2098
|
function formatAttendeeLike(item) {
|
|
2078
|
-
if (!
|
|
2079
|
-
const
|
|
2080
|
-
const email = typeof rec.email === "string" ? rec.email : null;
|
|
2099
|
+
if (!isRecord(item)) return null;
|
|
2100
|
+
const email = getString(item, "email");
|
|
2081
2101
|
if (!email) return null;
|
|
2082
|
-
const
|
|
2102
|
+
const displayName = getString(item, "display_name");
|
|
2103
|
+
const name = displayName !== void 0 && displayName.length > 0 ? displayName : void 0;
|
|
2083
2104
|
return name ? `${name} <${email}>` : `<${email}>`;
|
|
2084
2105
|
}
|
|
2085
2106
|
function renderScalar(data) {
|
|
@@ -2100,6 +2121,17 @@ function renderScalar(data) {
|
|
|
2100
2121
|
function isScalar(v) {
|
|
2101
2122
|
return v === null || typeof v !== "object" && typeof v !== "function";
|
|
2102
2123
|
}
|
|
2124
|
+
function isRecord(value) {
|
|
2125
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2126
|
+
}
|
|
2127
|
+
function getString(record, key) {
|
|
2128
|
+
const value = record[key];
|
|
2129
|
+
return typeof value === "string" ? value : void 0;
|
|
2130
|
+
}
|
|
2131
|
+
function getArray(record, key) {
|
|
2132
|
+
const value = record[key];
|
|
2133
|
+
return Array.isArray(value) ? value : void 0;
|
|
2134
|
+
}
|
|
2103
2135
|
function formatCell(value, fmt, colorMap, opts) {
|
|
2104
2136
|
if (fmt !== "enum-badge" && (value === void 0 || value === null)) return dim("\u2014");
|
|
2105
2137
|
switch (fmt) {
|
|
@@ -4595,6 +4627,29 @@ async function createDriveApi(opts = {}) {
|
|
|
4595
4627
|
import { mkdir, open, readFile as readFile2, rename, rm, writeFile } from "fs/promises";
|
|
4596
4628
|
import { randomUUID } from "crypto";
|
|
4597
4629
|
import { join as join2 } from "path";
|
|
4630
|
+
import { DateTime as DateTime4 } from "luxon";
|
|
4631
|
+
|
|
4632
|
+
// src/handwritten/commands/drive/clock.ts
|
|
4633
|
+
import { DateTime as DateTime3 } from "luxon";
|
|
4634
|
+
var systemDriveClock = {
|
|
4635
|
+
now: () => DateTime3.utc()
|
|
4636
|
+
};
|
|
4637
|
+
function driveIsoTimestamp(clock = systemDriveClock) {
|
|
4638
|
+
const timestamp = clock.now().toISO();
|
|
4639
|
+
if (timestamp === null) {
|
|
4640
|
+
throw new Error("invalid drive clock timestamp");
|
|
4641
|
+
}
|
|
4642
|
+
return timestamp;
|
|
4643
|
+
}
|
|
4644
|
+
function driveConflictTimestamp(clock = systemDriveClock) {
|
|
4645
|
+
const timestamp = clock.now().toUTC();
|
|
4646
|
+
if (!timestamp.isValid) {
|
|
4647
|
+
throw new Error("invalid drive clock timestamp");
|
|
4648
|
+
}
|
|
4649
|
+
return timestamp.toFormat("yyyyLLdd'T'HHmmss'Z'");
|
|
4650
|
+
}
|
|
4651
|
+
|
|
4652
|
+
// src/handwritten/commands/drive/state.ts
|
|
4598
4653
|
var DRIVE_DIR = ".wspc-drive";
|
|
4599
4654
|
var STATE_FILE = "state.json";
|
|
4600
4655
|
function statePath(root) {
|
|
@@ -4608,13 +4663,13 @@ async function readDriveState(root) {
|
|
|
4608
4663
|
}
|
|
4609
4664
|
return parsed;
|
|
4610
4665
|
}
|
|
4611
|
-
async function writeDriveState(root, state) {
|
|
4666
|
+
async function writeDriveState(root, state, clock) {
|
|
4612
4667
|
await mkdir(join2(root, DRIVE_DIR), { recursive: true });
|
|
4613
4668
|
const tmp = join2(root, DRIVE_DIR, `state.json.tmp-${process.pid}-${randomUUID()}`);
|
|
4614
4669
|
const snapshot = JSON.stringify(
|
|
4615
4670
|
{
|
|
4616
4671
|
...state,
|
|
4617
|
-
updated_at: (
|
|
4672
|
+
updated_at: driveIsoTimestamp(clock)
|
|
4618
4673
|
},
|
|
4619
4674
|
null,
|
|
4620
4675
|
2
|
|
@@ -4633,7 +4688,7 @@ async function writeDriveState(root, state) {
|
|
|
4633
4688
|
await rm(tmp, { force: true });
|
|
4634
4689
|
}
|
|
4635
4690
|
}
|
|
4636
|
-
async function initDriveState(root, libraryId) {
|
|
4691
|
+
async function initDriveState(root, libraryId, clock) {
|
|
4637
4692
|
await mkdir(join2(root, DRIVE_DIR), { recursive: true });
|
|
4638
4693
|
try {
|
|
4639
4694
|
const existing = await readDriveState(root);
|
|
@@ -4644,7 +4699,7 @@ async function initDriveState(root, libraryId) {
|
|
|
4644
4699
|
} catch (err) {
|
|
4645
4700
|
if (err.code !== "ENOENT") throw err;
|
|
4646
4701
|
}
|
|
4647
|
-
const now = (
|
|
4702
|
+
const now = driveIsoTimestamp(clock);
|
|
4648
4703
|
const state = {
|
|
4649
4704
|
schema_version: 1,
|
|
4650
4705
|
library_id: libraryId,
|
|
@@ -4653,9 +4708,32 @@ async function initDriveState(root, libraryId) {
|
|
|
4653
4708
|
entries: {},
|
|
4654
4709
|
conflicts: {}
|
|
4655
4710
|
};
|
|
4656
|
-
await writeDriveState(root, state);
|
|
4711
|
+
await writeDriveState(root, state, clock);
|
|
4657
4712
|
return state;
|
|
4658
4713
|
}
|
|
4714
|
+
async function ensureDriveRealtimeState(root) {
|
|
4715
|
+
return withDriveLock(root, async () => {
|
|
4716
|
+
const state = await readDriveState(root);
|
|
4717
|
+
if (state.realtime?.client_id !== void 0) {
|
|
4718
|
+
return state;
|
|
4719
|
+
}
|
|
4720
|
+
const next = {
|
|
4721
|
+
...state,
|
|
4722
|
+
realtime: {
|
|
4723
|
+
...state.realtime,
|
|
4724
|
+
client_id: `drvcli_${randomUUID().replace(/-/g, "")}`
|
|
4725
|
+
}
|
|
4726
|
+
};
|
|
4727
|
+
await writeDriveState(root, next);
|
|
4728
|
+
return next;
|
|
4729
|
+
});
|
|
4730
|
+
}
|
|
4731
|
+
async function writeDriveRealtimeState(root, realtime) {
|
|
4732
|
+
await withDriveLock(root, async () => {
|
|
4733
|
+
const current = await readDriveState(root);
|
|
4734
|
+
await writeDriveState(root, { ...current, realtime });
|
|
4735
|
+
});
|
|
4736
|
+
}
|
|
4659
4737
|
async function withDriveLock(root, fn) {
|
|
4660
4738
|
await mkdir(join2(root, DRIVE_DIR), { recursive: true });
|
|
4661
4739
|
const lockFile = join2(root, DRIVE_DIR, "sync.lock");
|
|
@@ -4674,18 +4752,28 @@ async function withDriveLock(root, fn) {
|
|
|
4674
4752
|
});
|
|
4675
4753
|
}
|
|
4676
4754
|
}
|
|
4677
|
-
function
|
|
4755
|
+
function isRecord2(value) {
|
|
4678
4756
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4679
4757
|
}
|
|
4680
4758
|
function isDriveStateEntry(value) {
|
|
4681
|
-
return
|
|
4759
|
+
return isRecord2(value) && typeof value.entry_id === "string" && typeof value.entry_version === "number" && typeof value.size_bytes === "number" && typeof value.last_synced_at === "string" && value.status === "synced" && (value.current_version_id === void 0 || typeof value.current_version_id === "string") && (value.content_sha256 === void 0 || typeof value.content_sha256 === "string") && (value.last_local_sha256 === void 0 || typeof value.last_local_sha256 === "string");
|
|
4682
4760
|
}
|
|
4683
4761
|
function isDriveConflict(value) {
|
|
4684
|
-
return
|
|
4762
|
+
return isRecord2(value) && typeof value.detected_at === "string" && typeof value.reason === "string" && (value.type === void 0 || value.type === "edit_edit" || value.type === "create_create" || value.type === "delete_edit" || value.type === "edit_delete") && (value.strategy === void 0 || value.strategy === "clean_merge" || value.strategy === "conflict_copy" || value.strategy === "record_only") && (value.base_version_id === void 0 || typeof value.base_version_id === "string") && (value.remote_entry_version === void 0 || typeof value.remote_entry_version === "number") && (value.remote_version_id === void 0 || typeof value.remote_version_id === "string") && (value.conflict_paths === void 0 || Array.isArray(value.conflict_paths) && value.conflict_paths.every((path) => typeof path === "string"));
|
|
4763
|
+
}
|
|
4764
|
+
function isDriveRealtimeState(value) {
|
|
4765
|
+
const allowedKeys = /* @__PURE__ */ new Set(["client_id", "last_cursor", "last_connected_at", "last_event_at"]);
|
|
4766
|
+
return isRecord2(value) && Object.keys(value).every((key) => allowedKeys.has(key)) && (value.client_id === void 0 || typeof value.client_id === "string" && /^drvcli_[A-Za-z0-9_-]+$/.test(value.client_id)) && (value.last_cursor === void 0 || typeof value.last_cursor === "string") && isOptionalIsoTimestamp(value.last_connected_at) && isOptionalIsoTimestamp(value.last_event_at);
|
|
4767
|
+
}
|
|
4768
|
+
function isOptionalIsoTimestamp(value) {
|
|
4769
|
+
if (value === void 0) return true;
|
|
4770
|
+
if (typeof value !== "string") return false;
|
|
4771
|
+
if (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value)) return false;
|
|
4772
|
+
return DateTime4.fromISO(value, { setZone: true }).isValid;
|
|
4685
4773
|
}
|
|
4686
4774
|
function isValidDriveState(value) {
|
|
4687
|
-
if (!
|
|
4688
|
-
if (value.schema_version !== 1 || typeof value.library_id !== "string" || typeof value.created_at !== "string" || typeof value.updated_at !== "string" || !
|
|
4775
|
+
if (!isRecord2(value)) return false;
|
|
4776
|
+
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)) {
|
|
4689
4777
|
return false;
|
|
4690
4778
|
}
|
|
4691
4779
|
for (const entry of Object.values(value.entries)) {
|
|
@@ -4729,12 +4817,9 @@ function driveBindCommand() {
|
|
|
4729
4817
|
|
|
4730
4818
|
// src/handwritten/commands/drive/sync.ts
|
|
4731
4819
|
import { Command as Command71 } from "commander";
|
|
4732
|
-
import {
|
|
4733
|
-
import {
|
|
4734
|
-
import {
|
|
4735
|
-
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
4736
|
-
import { Readable as Readable2, Transform } from "stream";
|
|
4737
|
-
import { pipeline as pipeline2 } from "stream/promises";
|
|
4820
|
+
import { mkdir as mkdir3, readFile as readFile4, rm as rm3, writeFile as writeFile3 } from "fs/promises";
|
|
4821
|
+
import { basename as basename3, dirname as dirname2, join as join5, posix as pathPosix2, resolve as resolve3 } from "path";
|
|
4822
|
+
import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
|
|
4738
4823
|
|
|
4739
4824
|
// src/handwritten/commands/drive/decision.ts
|
|
4740
4825
|
function decideDriveAction(entry, local, remote) {
|
|
@@ -4806,6 +4891,96 @@ function getRemoteStatus(entry, remote) {
|
|
|
4806
4891
|
return "changed";
|
|
4807
4892
|
}
|
|
4808
4893
|
|
|
4894
|
+
// src/handwritten/commands/drive/path-policy.ts
|
|
4895
|
+
import { isAbsolute, relative, resolve as resolve2, sep } from "path";
|
|
4896
|
+
var UTF8_SEGMENT_LIMIT = 255;
|
|
4897
|
+
var UTF8_PATH_LIMIT = 1024;
|
|
4898
|
+
var CONTROL_CHARS = /[\0-\x1f\x7f]/;
|
|
4899
|
+
var WINDOWS_DRIVE_PREFIX = /^[a-zA-Z]:/;
|
|
4900
|
+
var UNC_PREFIX = /^\\\\/;
|
|
4901
|
+
var ABSOLUTE_POSIX_DOUBLE_SLASH = /^\/\//;
|
|
4902
|
+
function validateDrivePath(drivePath) {
|
|
4903
|
+
if (drivePath.length === 0) {
|
|
4904
|
+
throw new Error("invalid drive path: empty");
|
|
4905
|
+
}
|
|
4906
|
+
if (isAbsolute(drivePath) || ABSOLUTE_POSIX_DOUBLE_SLASH.test(drivePath)) {
|
|
4907
|
+
throw new Error(`invalid drive path: ${drivePath}`);
|
|
4908
|
+
}
|
|
4909
|
+
if (drivePath.includes("\\")) {
|
|
4910
|
+
throw new Error("invalid drive path: backslash");
|
|
4911
|
+
}
|
|
4912
|
+
if (WINDOWS_DRIVE_PREFIX.test(drivePath) || UNC_PREFIX.test(drivePath)) {
|
|
4913
|
+
throw new Error(`invalid drive path: ${drivePath}`);
|
|
4914
|
+
}
|
|
4915
|
+
if (CONTROL_CHARS.test(drivePath)) {
|
|
4916
|
+
throw new Error(`invalid drive path: control character`);
|
|
4917
|
+
}
|
|
4918
|
+
if (Buffer.byteLength(drivePath, "utf8") > UTF8_PATH_LIMIT) {
|
|
4919
|
+
throw new Error(`invalid drive path: exceeds ${UTF8_PATH_LIMIT} bytes`);
|
|
4920
|
+
}
|
|
4921
|
+
const segments = drivePath.split("/");
|
|
4922
|
+
if (segments.some((segment) => segment.length === 0)) {
|
|
4923
|
+
throw new Error("invalid drive path: empty segment");
|
|
4924
|
+
}
|
|
4925
|
+
if (segments.some((segment) => segment === "." || segment === "..")) {
|
|
4926
|
+
throw new Error("invalid drive path: relative segment");
|
|
4927
|
+
}
|
|
4928
|
+
if (segments.some((segment) => Buffer.byteLength(segment, "utf8") > UTF8_SEGMENT_LIMIT)) {
|
|
4929
|
+
throw new Error(`invalid drive path: segment exceeds ${UTF8_SEGMENT_LIMIT} bytes`);
|
|
4930
|
+
}
|
|
4931
|
+
return drivePath;
|
|
4932
|
+
}
|
|
4933
|
+
function resolveInsideRoot(root, drivePath) {
|
|
4934
|
+
const normalizedPath = validateDrivePath(drivePath);
|
|
4935
|
+
const absoluteRoot = resolve2(root);
|
|
4936
|
+
const absolutePath = resolve2(absoluteRoot, normalizedPath);
|
|
4937
|
+
const relativePath = relative(absoluteRoot, absolutePath);
|
|
4938
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
|
|
4939
|
+
throw new Error(`drive path escapes root: ${drivePath}`);
|
|
4940
|
+
}
|
|
4941
|
+
return absolutePath;
|
|
4942
|
+
}
|
|
4943
|
+
|
|
4944
|
+
// src/handwritten/commands/drive/manifest.ts
|
|
4945
|
+
function normalizeRemoteManifest(root, entries) {
|
|
4946
|
+
const candidates = [];
|
|
4947
|
+
const remoteFiles = {};
|
|
4948
|
+
const pathErrors = [];
|
|
4949
|
+
for (const entry of entries) {
|
|
4950
|
+
try {
|
|
4951
|
+
validateDrivePath(entry.path);
|
|
4952
|
+
resolveInsideRoot(root, entry.path);
|
|
4953
|
+
candidates.push(entry);
|
|
4954
|
+
} catch (error) {
|
|
4955
|
+
pathErrors.push({ path: entry.path, error: error instanceof Error ? error : new Error(String(error)) });
|
|
4956
|
+
}
|
|
4957
|
+
}
|
|
4958
|
+
const byCaseFoldedPath = /* @__PURE__ */ new Map();
|
|
4959
|
+
for (const entry of candidates) {
|
|
4960
|
+
const folded = entry.path.toLowerCase();
|
|
4961
|
+
const group = byCaseFoldedPath.get(folded) ?? [];
|
|
4962
|
+
group.push(entry);
|
|
4963
|
+
byCaseFoldedPath.set(folded, group);
|
|
4964
|
+
}
|
|
4965
|
+
for (const group of byCaseFoldedPath.values()) {
|
|
4966
|
+
if (group.length > 1) {
|
|
4967
|
+
const hasExactDuplicate = new Set(group.map((entry2) => entry2.path)).size < group.length;
|
|
4968
|
+
const reason = hasExactDuplicate ? "REMOTE_PATH_DUPLICATE" : "REMOTE_PATH_CASE_CONFLICT";
|
|
4969
|
+
for (const entry2 of group.sort((left, right) => left.path.localeCompare(right.path))) {
|
|
4970
|
+
pathErrors.push({
|
|
4971
|
+
path: entry2.path,
|
|
4972
|
+
error: new Error(`${reason}: ${entry2.path}`),
|
|
4973
|
+
appendPathResult: true
|
|
4974
|
+
});
|
|
4975
|
+
}
|
|
4976
|
+
continue;
|
|
4977
|
+
}
|
|
4978
|
+
const [entry] = group;
|
|
4979
|
+
if (entry) remoteFiles[entry.path] = entry;
|
|
4980
|
+
}
|
|
4981
|
+
return { remoteFiles, pathErrors };
|
|
4982
|
+
}
|
|
4983
|
+
|
|
4809
4984
|
// src/handwritten/commands/drive/merge.ts
|
|
4810
4985
|
import { posix as pathPosix } from "path";
|
|
4811
4986
|
import { TextDecoder } from "util";
|
|
@@ -4864,9 +5039,8 @@ function mergeText3(base, local, remote) {
|
|
|
4864
5039
|
}
|
|
4865
5040
|
return { clean: true, text: mergedLines.join(localNewline) };
|
|
4866
5041
|
}
|
|
4867
|
-
function conflictCopyPath(path, side,
|
|
5042
|
+
function conflictCopyPath(path, side, timestamp, versionId) {
|
|
4868
5043
|
const parsed = pathPosix.parse(path);
|
|
4869
|
-
const timestamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
4870
5044
|
const shortVersionId = safeShortVersionId(versionId);
|
|
4871
5045
|
const fileName = `${parsed.name}.${side}-conflict-${timestamp}-${shortVersionId}${parsed.ext}`;
|
|
4872
5046
|
if (parsed.dir === "") {
|
|
@@ -4902,56 +5076,6 @@ function safeShortVersionId(versionId) {
|
|
|
4902
5076
|
return (safeVersionId.length > 0 ? safeVersionId : "unknown").slice(0, 8);
|
|
4903
5077
|
}
|
|
4904
5078
|
|
|
4905
|
-
// src/handwritten/commands/drive/path-policy.ts
|
|
4906
|
-
import { isAbsolute, relative, resolve as resolve2, sep } from "path";
|
|
4907
|
-
var UTF8_SEGMENT_LIMIT = 255;
|
|
4908
|
-
var UTF8_PATH_LIMIT = 1024;
|
|
4909
|
-
var CONTROL_CHARS = /[\0-\x1f\x7f]/;
|
|
4910
|
-
var WINDOWS_DRIVE_PREFIX = /^[a-zA-Z]:/;
|
|
4911
|
-
var UNC_PREFIX = /^\\\\/;
|
|
4912
|
-
var ABSOLUTE_POSIX_DOUBLE_SLASH = /^\/\//;
|
|
4913
|
-
function validateDrivePath(drivePath) {
|
|
4914
|
-
if (drivePath.length === 0) {
|
|
4915
|
-
throw new Error("invalid drive path: empty");
|
|
4916
|
-
}
|
|
4917
|
-
if (isAbsolute(drivePath) || ABSOLUTE_POSIX_DOUBLE_SLASH.test(drivePath)) {
|
|
4918
|
-
throw new Error(`invalid drive path: ${drivePath}`);
|
|
4919
|
-
}
|
|
4920
|
-
if (drivePath.includes("\\")) {
|
|
4921
|
-
throw new Error("invalid drive path: backslash");
|
|
4922
|
-
}
|
|
4923
|
-
if (WINDOWS_DRIVE_PREFIX.test(drivePath) || UNC_PREFIX.test(drivePath)) {
|
|
4924
|
-
throw new Error(`invalid drive path: ${drivePath}`);
|
|
4925
|
-
}
|
|
4926
|
-
if (CONTROL_CHARS.test(drivePath)) {
|
|
4927
|
-
throw new Error(`invalid drive path: control character`);
|
|
4928
|
-
}
|
|
4929
|
-
if (Buffer.byteLength(drivePath, "utf8") > UTF8_PATH_LIMIT) {
|
|
4930
|
-
throw new Error(`invalid drive path: exceeds ${UTF8_PATH_LIMIT} bytes`);
|
|
4931
|
-
}
|
|
4932
|
-
const segments = drivePath.split("/");
|
|
4933
|
-
if (segments.some((segment) => segment.length === 0)) {
|
|
4934
|
-
throw new Error("invalid drive path: empty segment");
|
|
4935
|
-
}
|
|
4936
|
-
if (segments.some((segment) => segment === "." || segment === "..")) {
|
|
4937
|
-
throw new Error("invalid drive path: relative segment");
|
|
4938
|
-
}
|
|
4939
|
-
if (segments.some((segment) => Buffer.byteLength(segment, "utf8") > UTF8_SEGMENT_LIMIT)) {
|
|
4940
|
-
throw new Error(`invalid drive path: segment exceeds ${UTF8_SEGMENT_LIMIT} bytes`);
|
|
4941
|
-
}
|
|
4942
|
-
return drivePath;
|
|
4943
|
-
}
|
|
4944
|
-
function resolveInsideRoot(root, drivePath) {
|
|
4945
|
-
const normalizedPath = validateDrivePath(drivePath);
|
|
4946
|
-
const absoluteRoot = resolve2(root);
|
|
4947
|
-
const absolutePath = resolve2(absoluteRoot, normalizedPath);
|
|
4948
|
-
const relativePath = relative(absoluteRoot, absolutePath);
|
|
4949
|
-
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
|
|
4950
|
-
throw new Error(`drive path escapes root: ${drivePath}`);
|
|
4951
|
-
}
|
|
4952
|
-
return absolutePath;
|
|
4953
|
-
}
|
|
4954
|
-
|
|
4955
5079
|
// src/handwritten/commands/drive/scanner.ts
|
|
4956
5080
|
import { createHash } from "crypto";
|
|
4957
5081
|
import { constants as fsConstants } from "fs";
|
|
@@ -5072,484 +5196,47 @@ async function hashDriveFile(path) {
|
|
|
5072
5196
|
}
|
|
5073
5197
|
}
|
|
5074
5198
|
|
|
5075
|
-
// src/handwritten/commands/drive/
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
paths: []
|
|
5087
|
-
};
|
|
5088
|
-
}
|
|
5089
|
-
async function runDriveSyncOnce(root, api) {
|
|
5090
|
-
return withDriveLock(root, async () => {
|
|
5091
|
-
let state = await readDriveState(root);
|
|
5092
|
-
const syncApi = api ?? await createDriveApi();
|
|
5093
|
-
const summary = emptySummary();
|
|
5094
|
-
const blockedPaths = /* @__PURE__ */ new Set();
|
|
5095
|
-
const localFiles = await scanDriveFiles(root, {
|
|
5096
|
-
onPathError: async (path, error) => {
|
|
5097
|
-
await recordPathError(summary, blockedPaths, path, error);
|
|
5098
|
-
}
|
|
5099
|
-
});
|
|
5100
|
-
const remoteFiles = await fetchRemoteManifest(root, state, syncApi, summary, blockedPaths);
|
|
5101
|
-
const paths = Array.from(
|
|
5102
|
-
/* @__PURE__ */ new Set([...Object.keys(localFiles), ...Object.keys(remoteFiles), ...Object.keys(state.entries)])
|
|
5103
|
-
).filter((path) => !blockedPaths.has(path)).sort((left, right) => left.localeCompare(right));
|
|
5104
|
-
for (const path of paths) {
|
|
5105
|
-
const remote = remoteFiles[path];
|
|
5106
|
-
const action = decideDriveAction(state.entries[path], localFiles[path], remote);
|
|
5107
|
-
const result = await processPath({ root, state, api: syncApi, path, action, remote, local: localFiles[path], summary });
|
|
5108
|
-
state = result.state;
|
|
5109
|
-
if (result.stop) break;
|
|
5110
|
-
}
|
|
5111
|
-
recordUnresolvedConflicts(summary, state);
|
|
5112
|
-
return summary;
|
|
5199
|
+
// src/handwritten/commands/drive/local-mutations.ts
|
|
5200
|
+
import { createWriteStream as createWriteStream2 } from "fs";
|
|
5201
|
+
import { link, mkdir as mkdir2, readFile as readFile3, rename as rename2, rm as rm2, unlink, writeFile as writeFile2 } from "fs/promises";
|
|
5202
|
+
import { basename as basename2, dirname, join as join4 } from "path";
|
|
5203
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
5204
|
+
import { Readable as Readable2, Transform } from "stream";
|
|
5205
|
+
import { pipeline as pipeline2 } from "stream/promises";
|
|
5206
|
+
async function assertLocalStillScanned(localPath, scanned) {
|
|
5207
|
+
const snapshot = await hashDriveFile(localPath).catch((error) => {
|
|
5208
|
+
if (isNotFoundError(error)) return void 0;
|
|
5209
|
+
throw error;
|
|
5113
5210
|
});
|
|
5211
|
+
if (!snapshot || snapshot.sha256 !== scanned.sha256 || snapshot.sizeBytes !== scanned.size_bytes) {
|
|
5212
|
+
throw new Error("local file changed after scan");
|
|
5213
|
+
}
|
|
5114
5214
|
}
|
|
5115
|
-
function
|
|
5116
|
-
const
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5215
|
+
async function writeMergedLocalFile(root, path, bytes, digest, scanned, onLocalMutation) {
|
|
5216
|
+
const target = resolveInsideRoot(root, path);
|
|
5217
|
+
await mkdir2(dirname(target), { recursive: true });
|
|
5218
|
+
const tmp = join4(dirname(target), `.${basename2(target)}.wspc-merge-${randomUUID2()}.tmp`);
|
|
5219
|
+
try {
|
|
5220
|
+
await writeFile2(tmp, bytes, { flag: "wx" });
|
|
5221
|
+
return await installMergedLocalFile(root, path, tmp, scanned, digest, bytes.byteLength, onLocalMutation);
|
|
5222
|
+
} finally {
|
|
5223
|
+
await rm2(tmp, { force: true }).catch(() => {
|
|
5224
|
+
});
|
|
5225
|
+
}
|
|
5125
5226
|
}
|
|
5126
|
-
async function
|
|
5127
|
-
const
|
|
5128
|
-
const
|
|
5129
|
-
let
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
await recordPathError(summary, blockedPaths, entry.path, error);
|
|
5227
|
+
async function installMergedLocalFile(root, path, tmp, scanned, mergedSha256, mergedSizeBytes, onLocalMutation) {
|
|
5228
|
+
const target = resolveInsideRoot(root, path);
|
|
5229
|
+
const backup = localMutationBackupPath(target);
|
|
5230
|
+
let backupIsScannedLocal = false;
|
|
5231
|
+
try {
|
|
5232
|
+
try {
|
|
5233
|
+
await rename2(target, backup);
|
|
5234
|
+
onLocalMutation();
|
|
5235
|
+
} catch (error) {
|
|
5236
|
+
if (isNotFoundError(error)) {
|
|
5237
|
+
throw new Error("local file changed after scan");
|
|
5138
5238
|
}
|
|
5139
|
-
|
|
5140
|
-
cursor = page.next_cursor ?? void 0;
|
|
5141
|
-
} while (cursor !== void 0);
|
|
5142
|
-
const byCaseFoldedPath = /* @__PURE__ */ new Map();
|
|
5143
|
-
for (const entry of candidates) {
|
|
5144
|
-
const folded = entry.path.toLowerCase();
|
|
5145
|
-
const group = byCaseFoldedPath.get(folded) ?? [];
|
|
5146
|
-
group.push(entry);
|
|
5147
|
-
byCaseFoldedPath.set(folded, group);
|
|
5148
|
-
}
|
|
5149
|
-
for (const group of byCaseFoldedPath.values()) {
|
|
5150
|
-
const exactPathCounts = /* @__PURE__ */ new Map();
|
|
5151
|
-
for (const entry2 of group) {
|
|
5152
|
-
exactPathCounts.set(entry2.path, (exactPathCounts.get(entry2.path) ?? 0) + 1);
|
|
5153
|
-
}
|
|
5154
|
-
if (group.length > 1) {
|
|
5155
|
-
const hasExactDuplicate = Array.from(exactPathCounts.values()).some((count) => count > 1);
|
|
5156
|
-
const reason = hasExactDuplicate ? "REMOTE_PATH_DUPLICATE" : "REMOTE_PATH_CASE_CONFLICT";
|
|
5157
|
-
for (const entry2 of group.sort((left, right) => left.path.localeCompare(right.path))) {
|
|
5158
|
-
await recordPathError(summary, blockedPaths, entry2.path, new Error(`${reason}: ${entry2.path}`), {
|
|
5159
|
-
appendPathResult: true
|
|
5160
|
-
});
|
|
5161
|
-
}
|
|
5162
|
-
continue;
|
|
5163
|
-
}
|
|
5164
|
-
const [entry] = group;
|
|
5165
|
-
if (entry) remoteFiles[entry.path] = entry;
|
|
5166
|
-
}
|
|
5167
|
-
return remoteFiles;
|
|
5168
|
-
}
|
|
5169
|
-
function validateRemoteEntry(root, entry) {
|
|
5170
|
-
validateDrivePath(entry.path);
|
|
5171
|
-
resolveInsideRoot(root, entry.path);
|
|
5172
|
-
}
|
|
5173
|
-
async function processPath(args) {
|
|
5174
|
-
const { root, state, api, path, action, remote, local, summary } = args;
|
|
5175
|
-
summary.paths.push({ path, action: action.type });
|
|
5176
|
-
let durableStateRequired = false;
|
|
5177
|
-
try {
|
|
5178
|
-
if (action.type === "upload_create" || action.type === "upload_update") {
|
|
5179
|
-
const localPath = resolveInsideRoot(root, path);
|
|
5180
|
-
const { body, digest: uploadDigest } = await readStableUploadBody(localPath, local);
|
|
5181
|
-
const uploaded = await api.uploadFile(state.library_id, path, body, uploadDigest, action.expectedEntryVersion);
|
|
5182
|
-
durableStateRequired = true;
|
|
5183
|
-
const nextState = cloneDriveState(state);
|
|
5184
|
-
nextState.entries[path] = stateEntryFromRemote(uploaded.entry, uploadDigest);
|
|
5185
|
-
delete nextState.conflicts[path];
|
|
5186
|
-
await commitDriveState(root, nextState);
|
|
5187
|
-
summary.uploaded += 1;
|
|
5188
|
-
return { state: nextState, stop: false };
|
|
5189
|
-
}
|
|
5190
|
-
if (action.type === "download") {
|
|
5191
|
-
if (!remote) throw new Error("remote entry missing for download");
|
|
5192
|
-
await assertLocalSafeForDownload(root, path, state.entries[path]);
|
|
5193
|
-
const digest = await downloadRemote(root, state.library_id, path, api, remote.content_sha256, state.entries[path], () => {
|
|
5194
|
-
durableStateRequired = true;
|
|
5195
|
-
});
|
|
5196
|
-
const nextState = cloneDriveState(state);
|
|
5197
|
-
nextState.entries[path] = stateEntryFromRemote(remote, digest);
|
|
5198
|
-
delete nextState.conflicts[path];
|
|
5199
|
-
await commitDriveState(root, nextState);
|
|
5200
|
-
summary.downloaded += 1;
|
|
5201
|
-
return { state: nextState, stop: false };
|
|
5202
|
-
}
|
|
5203
|
-
if (action.type === "delete_remote") {
|
|
5204
|
-
await assertLocalAbsentBeforeRemoteDelete(root, path);
|
|
5205
|
-
await api.deleteFile(state.library_id, path, action.expectedEntryVersion);
|
|
5206
|
-
durableStateRequired = true;
|
|
5207
|
-
await assertLocalAbsentBeforeRemoteDelete(root, path);
|
|
5208
|
-
const nextState = cloneDriveState(state);
|
|
5209
|
-
delete nextState.entries[path];
|
|
5210
|
-
delete nextState.conflicts[path];
|
|
5211
|
-
await commitDriveState(root, nextState);
|
|
5212
|
-
summary.deleted += 1;
|
|
5213
|
-
return { state: nextState, stop: false };
|
|
5214
|
-
}
|
|
5215
|
-
if (action.type === "delete_local") {
|
|
5216
|
-
await removeLocalIfStillBase(root, path, state.entries[path], () => {
|
|
5217
|
-
durableStateRequired = true;
|
|
5218
|
-
});
|
|
5219
|
-
const nextState = cloneDriveState(state);
|
|
5220
|
-
delete nextState.entries[path];
|
|
5221
|
-
delete nextState.conflicts[path];
|
|
5222
|
-
await commitDriveState(root, nextState);
|
|
5223
|
-
summary.deleted += 1;
|
|
5224
|
-
return { state: nextState, stop: false };
|
|
5225
|
-
}
|
|
5226
|
-
if (action.type === "state_only") {
|
|
5227
|
-
if (!remote) throw new Error("remote entry missing for state update");
|
|
5228
|
-
const nextState = cloneDriveState(state);
|
|
5229
|
-
nextState.entries[path] = stateEntryFromRemote(remote, local?.sha256 ?? remote.content_sha256);
|
|
5230
|
-
delete nextState.conflicts[path];
|
|
5231
|
-
await commitDriveState(root, nextState);
|
|
5232
|
-
summary.unchanged += 1;
|
|
5233
|
-
return { state: nextState, stop: false };
|
|
5234
|
-
}
|
|
5235
|
-
if (action.type === "remove_state") {
|
|
5236
|
-
const nextState = cloneDriveState(state);
|
|
5237
|
-
delete nextState.entries[path];
|
|
5238
|
-
delete nextState.conflicts[path];
|
|
5239
|
-
await commitDriveState(root, nextState);
|
|
5240
|
-
summary.unchanged += 1;
|
|
5241
|
-
return { state: nextState, stop: false };
|
|
5242
|
-
}
|
|
5243
|
-
if (action.type === "conflict") {
|
|
5244
|
-
if (action.reason === "local_and_remote_changed") {
|
|
5245
|
-
try {
|
|
5246
|
-
const mergedState = await tryResolveConflict({
|
|
5247
|
-
root,
|
|
5248
|
-
state,
|
|
5249
|
-
api,
|
|
5250
|
-
path,
|
|
5251
|
-
remote,
|
|
5252
|
-
local,
|
|
5253
|
-
onLocalMutation: () => {
|
|
5254
|
-
durableStateRequired = true;
|
|
5255
|
-
}
|
|
5256
|
-
});
|
|
5257
|
-
if (mergedState) {
|
|
5258
|
-
summary.merged += 1;
|
|
5259
|
-
summary.paths[summary.paths.length - 1] = { path, action: "merged" };
|
|
5260
|
-
return { state: mergedState, stop: false };
|
|
5261
|
-
}
|
|
5262
|
-
} catch (error) {
|
|
5263
|
-
if (!isLocalChangedDuringMerge(error)) {
|
|
5264
|
-
throw error;
|
|
5265
|
-
}
|
|
5266
|
-
const nextState2 = await recordTypedConflict(root, state, path, action.reason, remote, {
|
|
5267
|
-
type: "edit_edit",
|
|
5268
|
-
strategy: "record_only",
|
|
5269
|
-
reason: "local_changed_during_merge"
|
|
5270
|
-
});
|
|
5271
|
-
summary.conflicts += 1;
|
|
5272
|
-
return { state: nextState2, stop: false };
|
|
5273
|
-
}
|
|
5274
|
-
const conflictCopyState = await recordRemoteConflictCopy({
|
|
5275
|
-
root,
|
|
5276
|
-
state,
|
|
5277
|
-
api,
|
|
5278
|
-
path,
|
|
5279
|
-
reason: action.reason,
|
|
5280
|
-
type: "edit_edit",
|
|
5281
|
-
remote
|
|
5282
|
-
});
|
|
5283
|
-
if (conflictCopyState) {
|
|
5284
|
-
summary.conflicts += 1;
|
|
5285
|
-
return { state: conflictCopyState, stop: false };
|
|
5286
|
-
}
|
|
5287
|
-
}
|
|
5288
|
-
if (action.reason === "local_and_remote_without_base") {
|
|
5289
|
-
const conflictCopyState = await recordRemoteConflictCopy({
|
|
5290
|
-
root,
|
|
5291
|
-
state,
|
|
5292
|
-
api,
|
|
5293
|
-
path,
|
|
5294
|
-
reason: action.reason,
|
|
5295
|
-
type: "create_create",
|
|
5296
|
-
remote
|
|
5297
|
-
});
|
|
5298
|
-
if (conflictCopyState) {
|
|
5299
|
-
summary.conflicts += 1;
|
|
5300
|
-
return { state: conflictCopyState, stop: false };
|
|
5301
|
-
}
|
|
5302
|
-
}
|
|
5303
|
-
if (action.reason === "local_changed_remote_deleted") {
|
|
5304
|
-
const nextState2 = await recordTypedConflict(root, state, path, action.reason, remote, {
|
|
5305
|
-
type: "edit_delete",
|
|
5306
|
-
strategy: "record_only"
|
|
5307
|
-
});
|
|
5308
|
-
summary.conflicts += 1;
|
|
5309
|
-
return { state: nextState2, stop: false };
|
|
5310
|
-
}
|
|
5311
|
-
if (action.reason === "remote_changed_before_delete") {
|
|
5312
|
-
const conflictCopyState = await recordRemoteConflictCopy({
|
|
5313
|
-
root,
|
|
5314
|
-
state,
|
|
5315
|
-
api,
|
|
5316
|
-
path,
|
|
5317
|
-
reason: action.reason,
|
|
5318
|
-
type: "delete_edit",
|
|
5319
|
-
remote
|
|
5320
|
-
});
|
|
5321
|
-
if (conflictCopyState) {
|
|
5322
|
-
summary.conflicts += 1;
|
|
5323
|
-
return { state: conflictCopyState, stop: false };
|
|
5324
|
-
}
|
|
5325
|
-
}
|
|
5326
|
-
const nextState = await recordConflict(root, state, path, action.reason, remote);
|
|
5327
|
-
summary.conflicts += 1;
|
|
5328
|
-
return { state: nextState, stop: false };
|
|
5329
|
-
}
|
|
5330
|
-
summary.unchanged += 1;
|
|
5331
|
-
} catch (error) {
|
|
5332
|
-
if (isVersionConflict(error)) {
|
|
5333
|
-
try {
|
|
5334
|
-
const nextState = await recordConflict(root, state, path, "VERSION_CONFLICT", remote);
|
|
5335
|
-
summary.conflicts += 1;
|
|
5336
|
-
summary.paths[summary.paths.length - 1] = { path, action: "conflict" };
|
|
5337
|
-
return { state: nextState, stop: false };
|
|
5338
|
-
} catch (writeError) {
|
|
5339
|
-
await recordPathError(summary, void 0, path, writeError);
|
|
5340
|
-
return { state, stop: durableStateRequired };
|
|
5341
|
-
}
|
|
5342
|
-
}
|
|
5343
|
-
await recordPathError(summary, void 0, path, error);
|
|
5344
|
-
return { state, stop: durableStateRequired };
|
|
5345
|
-
}
|
|
5346
|
-
return { state, stop: false };
|
|
5347
|
-
}
|
|
5348
|
-
async function tryResolveConflict(args) {
|
|
5349
|
-
const { root, state, api, path, remote, local, onLocalMutation } = args;
|
|
5350
|
-
const entry = state.entries[path];
|
|
5351
|
-
const baseVersionId = entry?.current_version_id;
|
|
5352
|
-
const remoteVersionId = remote?.current_version_id;
|
|
5353
|
-
if (!entry || !remote || !local || baseVersionId === void 0 || remoteVersionId === void 0) {
|
|
5354
|
-
return void 0;
|
|
5355
|
-
}
|
|
5356
|
-
const localPath = resolveInsideRoot(root, path);
|
|
5357
|
-
let baseBytes;
|
|
5358
|
-
let remoteBytes;
|
|
5359
|
-
try {
|
|
5360
|
-
const [downloadedBaseBytes, downloadedRemoteBytes] = await Promise.all([
|
|
5361
|
-
downloadBytes(api, state.library_id, path, baseVersionId),
|
|
5362
|
-
downloadBytes(api, state.library_id, path, remoteVersionId)
|
|
5363
|
-
]);
|
|
5364
|
-
baseBytes = downloadedBaseBytes;
|
|
5365
|
-
remoteBytes = downloadedRemoteBytes;
|
|
5366
|
-
} catch (error) {
|
|
5367
|
-
if (isExpectedVersionDownloadMissing(error)) {
|
|
5368
|
-
return void 0;
|
|
5369
|
-
}
|
|
5370
|
-
throw error;
|
|
5371
|
-
}
|
|
5372
|
-
const localBytes = await readFile3(localPath);
|
|
5373
|
-
const baseText = classifyMergeText(path, baseBytes, void 0);
|
|
5374
|
-
const localText = classifyMergeText(path, localBytes, void 0);
|
|
5375
|
-
const remoteText = classifyMergeText(path, remoteBytes, void 0);
|
|
5376
|
-
if (!baseText.mergeable || !localText.mergeable || !remoteText.mergeable) {
|
|
5377
|
-
return void 0;
|
|
5378
|
-
}
|
|
5379
|
-
const merged = mergeText3(baseText.text, localText.text, remoteText.text);
|
|
5380
|
-
if (!merged.clean) {
|
|
5381
|
-
return void 0;
|
|
5382
|
-
}
|
|
5383
|
-
await assertLocalStillScanned(localPath, local);
|
|
5384
|
-
const mergedBytes = new TextEncoder().encode(merged.text);
|
|
5385
|
-
const mergedDigest = createHash2("sha256").update(mergedBytes).digest("hex");
|
|
5386
|
-
const install = await writeMergedLocalFile(root, path, mergedBytes, mergedDigest, local, onLocalMutation);
|
|
5387
|
-
let uploaded;
|
|
5388
|
-
try {
|
|
5389
|
-
uploaded = await api.uploadFile(state.library_id, path, mergedBytes, mergedDigest, remote.entry_version);
|
|
5390
|
-
} catch (error) {
|
|
5391
|
-
await install.restore();
|
|
5392
|
-
throw error;
|
|
5393
|
-
}
|
|
5394
|
-
await install.finalize();
|
|
5395
|
-
const nextState = cloneDriveState(state);
|
|
5396
|
-
nextState.entries[path] = stateEntryFromRemote(uploaded.entry, mergedDigest);
|
|
5397
|
-
delete nextState.conflicts[path];
|
|
5398
|
-
await commitDriveState(root, nextState);
|
|
5399
|
-
return nextState;
|
|
5400
|
-
}
|
|
5401
|
-
async function downloadBytes(api, libraryId, path, versionId) {
|
|
5402
|
-
const response = await api.downloadFile(libraryId, path, versionId);
|
|
5403
|
-
return new Uint8Array(await response.arrayBuffer());
|
|
5404
|
-
}
|
|
5405
|
-
async function recordRemoteConflictCopy(args) {
|
|
5406
|
-
const { root, state, api, path, reason, type, remote } = args;
|
|
5407
|
-
const entry = state.entries[path];
|
|
5408
|
-
const remoteVersionId = remote?.current_version_id;
|
|
5409
|
-
if (!remote || remoteVersionId === void 0) {
|
|
5410
|
-
return void 0;
|
|
5411
|
-
}
|
|
5412
|
-
if (await canReuseConflictCopy(root, state.conflicts[path], remoteVersionId)) {
|
|
5413
|
-
return state;
|
|
5414
|
-
}
|
|
5415
|
-
const remoteBytes = await downloadBytes(api, state.library_id, path, remoteVersionId);
|
|
5416
|
-
const copyPath = await writeConflictCopy(root, path, "remote", remoteVersionId, remoteBytes);
|
|
5417
|
-
const nextState = cloneDriveState(state);
|
|
5418
|
-
nextState.conflicts[path] = {
|
|
5419
|
-
detected_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5420
|
-
reason,
|
|
5421
|
-
type,
|
|
5422
|
-
strategy: "conflict_copy",
|
|
5423
|
-
base_version_id: entry?.current_version_id,
|
|
5424
|
-
remote_version_id: remoteVersionId,
|
|
5425
|
-
remote_entry_version: remote.entry_version,
|
|
5426
|
-
conflict_paths: [copyPath]
|
|
5427
|
-
};
|
|
5428
|
-
await commitDriveState(root, nextState);
|
|
5429
|
-
return nextState;
|
|
5430
|
-
}
|
|
5431
|
-
async function canReuseConflictCopy(root, conflict2, remoteVersionId) {
|
|
5432
|
-
if (conflict2?.strategy !== "conflict_copy" || conflict2.remote_version_id !== remoteVersionId || !Array.isArray(conflict2.conflict_paths) || conflict2.conflict_paths.length === 0) {
|
|
5433
|
-
return false;
|
|
5434
|
-
}
|
|
5435
|
-
for (const conflictPath of conflict2.conflict_paths) {
|
|
5436
|
-
try {
|
|
5437
|
-
validateDrivePath(conflictPath);
|
|
5438
|
-
if (!await localFileExists(resolveInsideRoot(root, conflictPath))) {
|
|
5439
|
-
return false;
|
|
5440
|
-
}
|
|
5441
|
-
} catch {
|
|
5442
|
-
return false;
|
|
5443
|
-
}
|
|
5444
|
-
}
|
|
5445
|
-
return true;
|
|
5446
|
-
}
|
|
5447
|
-
async function writeConflictCopy(root, path, side, versionId, bytes) {
|
|
5448
|
-
const baseCopyPath = conflictCopyPath(path, side, /* @__PURE__ */ new Date(), versionId);
|
|
5449
|
-
for (let suffix = 1; ; suffix += 1) {
|
|
5450
|
-
const candidate = conflictCopyPathWithSuffix(baseCopyPath, suffix);
|
|
5451
|
-
validateDrivePath(candidate);
|
|
5452
|
-
const target = resolveInsideRoot(root, candidate);
|
|
5453
|
-
await mkdir2(dirname(target), { recursive: true });
|
|
5454
|
-
for (; ; ) {
|
|
5455
|
-
const tmp = join4(dirname(target), `.${basename2(target)}.wspc-conflict-${randomUUID2()}.tmp`);
|
|
5456
|
-
let tmpWritten = false;
|
|
5457
|
-
try {
|
|
5458
|
-
await writeFile2(tmp, bytes, { flag: "wx" });
|
|
5459
|
-
tmpWritten = true;
|
|
5460
|
-
await installNoOverwrite(tmp, target);
|
|
5461
|
-
return candidate;
|
|
5462
|
-
} catch (error) {
|
|
5463
|
-
if (tmpWritten) {
|
|
5464
|
-
await rm2(tmp, { force: true }).catch(() => {
|
|
5465
|
-
});
|
|
5466
|
-
}
|
|
5467
|
-
if (isAlreadyExistsError(error)) {
|
|
5468
|
-
if (tmpWritten) break;
|
|
5469
|
-
continue;
|
|
5470
|
-
}
|
|
5471
|
-
throw error;
|
|
5472
|
-
}
|
|
5473
|
-
}
|
|
5474
|
-
}
|
|
5475
|
-
}
|
|
5476
|
-
function conflictCopyPathWithSuffix(path, suffix) {
|
|
5477
|
-
if (suffix === 1) {
|
|
5478
|
-
return path;
|
|
5479
|
-
}
|
|
5480
|
-
const parsed = pathPosix2.parse(path);
|
|
5481
|
-
const fileName = `${parsed.name}-${suffix}${parsed.ext}`;
|
|
5482
|
-
if (parsed.dir === "") {
|
|
5483
|
-
return fileName;
|
|
5484
|
-
}
|
|
5485
|
-
return pathPosix2.join(parsed.dir, fileName);
|
|
5486
|
-
}
|
|
5487
|
-
function isExpectedVersionDownloadMissing(error) {
|
|
5488
|
-
const structured = error;
|
|
5489
|
-
if (structured?.status === 404 || structured?.status === 410) return true;
|
|
5490
|
-
if (structured?.response?.status === 404 || structured?.response?.status === 410) return true;
|
|
5491
|
-
if (structured?.code === "VERSION_NOT_FOUND" || structured?.code === "NOT_FOUND") return true;
|
|
5492
|
-
return /\b(?:HTTP 40[410]|missing version|version not found|not found)\b/i.test(errorMessage(error));
|
|
5493
|
-
}
|
|
5494
|
-
async function assertLocalStillScanned(localPath, scanned) {
|
|
5495
|
-
const snapshot = await hashDriveFile(localPath).catch((error) => {
|
|
5496
|
-
if (isNotFoundError(error)) return void 0;
|
|
5497
|
-
throw error;
|
|
5498
|
-
});
|
|
5499
|
-
if (!snapshot || snapshot.sha256 !== scanned.sha256 || snapshot.sizeBytes !== scanned.size_bytes) {
|
|
5500
|
-
throw new Error("local file changed after scan");
|
|
5501
|
-
}
|
|
5502
|
-
}
|
|
5503
|
-
function recordUnresolvedConflicts(summary, state) {
|
|
5504
|
-
const newlyRecorded = new Set(summary.paths.filter((result) => result.action === "conflict").map((result) => result.path));
|
|
5505
|
-
const reportedPaths = new Set(summary.paths.map((result) => result.path));
|
|
5506
|
-
for (const path of Object.keys(state.conflicts).sort((left, right) => left.localeCompare(right))) {
|
|
5507
|
-
const conflictPaths = state.conflicts[path]?.conflict_paths;
|
|
5508
|
-
if (conflictPaths) {
|
|
5509
|
-
summary.conflict_paths.push(...conflictPaths);
|
|
5510
|
-
}
|
|
5511
|
-
if (!newlyRecorded.has(path)) {
|
|
5512
|
-
summary.conflicts += 1;
|
|
5513
|
-
}
|
|
5514
|
-
const existingResult = summary.paths.find((result) => result.path === path);
|
|
5515
|
-
if (existingResult?.action === "unchanged") {
|
|
5516
|
-
existingResult.action = "conflict";
|
|
5517
|
-
if (conflictPaths) existingResult.conflict_paths = conflictPaths;
|
|
5518
|
-
continue;
|
|
5519
|
-
}
|
|
5520
|
-
if (existingResult?.action === "conflict" && conflictPaths) {
|
|
5521
|
-
existingResult.conflict_paths = conflictPaths;
|
|
5522
|
-
}
|
|
5523
|
-
if (!reportedPaths.has(path)) {
|
|
5524
|
-
summary.paths.push({ path, action: "conflict", ...conflictPaths ? { conflict_paths: conflictPaths } : {} });
|
|
5525
|
-
}
|
|
5526
|
-
}
|
|
5527
|
-
}
|
|
5528
|
-
async function writeMergedLocalFile(root, path, bytes, digest, scanned, onLocalMutation) {
|
|
5529
|
-
const target = resolveInsideRoot(root, path);
|
|
5530
|
-
await mkdir2(dirname(target), { recursive: true });
|
|
5531
|
-
const tmp = join4(dirname(target), `.${basename2(target)}.wspc-merge-${randomUUID2()}.tmp`);
|
|
5532
|
-
try {
|
|
5533
|
-
await writeFile2(tmp, bytes, { flag: "wx" });
|
|
5534
|
-
return await installMergedLocalFile(root, path, tmp, scanned, digest, bytes.byteLength, onLocalMutation);
|
|
5535
|
-
} finally {
|
|
5536
|
-
await rm2(tmp, { force: true }).catch(() => {
|
|
5537
|
-
});
|
|
5538
|
-
}
|
|
5539
|
-
}
|
|
5540
|
-
async function installMergedLocalFile(root, path, tmp, scanned, mergedSha256, mergedSizeBytes, onLocalMutation) {
|
|
5541
|
-
const target = resolveInsideRoot(root, path);
|
|
5542
|
-
const backup = localMutationBackupPath(target);
|
|
5543
|
-
let backupIsScannedLocal = false;
|
|
5544
|
-
try {
|
|
5545
|
-
try {
|
|
5546
|
-
await rename2(target, backup);
|
|
5547
|
-
onLocalMutation();
|
|
5548
|
-
} catch (error) {
|
|
5549
|
-
if (isNotFoundError(error)) {
|
|
5550
|
-
throw new Error("local file changed after scan");
|
|
5551
|
-
}
|
|
5552
|
-
throw error;
|
|
5239
|
+
throw error;
|
|
5553
5240
|
}
|
|
5554
5241
|
const backupDigest = await hashDriveFile(backup);
|
|
5555
5242
|
if (!backupDigest || backupDigest.sha256 !== scanned.sha256 || backupDigest.sizeBytes !== scanned.size_bytes) {
|
|
@@ -5639,166 +5326,590 @@ async function downloadRemote(root, libraryId, path, api, expectedSha256, entry,
|
|
|
5639
5326
|
});
|
|
5640
5327
|
}
|
|
5641
5328
|
}
|
|
5642
|
-
async function installDownloadedFile(root, path, tmp, entry, onLocalMutation) {
|
|
5643
|
-
const target = resolveInsideRoot(root, path);
|
|
5644
|
-
const backup = localMutationBackupPath(target);
|
|
5645
|
-
const expectedSha256 = expectedLocalBaseSha256(entry);
|
|
5646
|
-
let backupIsExpectedBase = false;
|
|
5329
|
+
async function installDownloadedFile(root, path, tmp, entry, onLocalMutation) {
|
|
5330
|
+
const target = resolveInsideRoot(root, path);
|
|
5331
|
+
const backup = localMutationBackupPath(target);
|
|
5332
|
+
const expectedSha256 = expectedLocalBaseSha256(entry);
|
|
5333
|
+
let backupIsExpectedBase = false;
|
|
5334
|
+
try {
|
|
5335
|
+
try {
|
|
5336
|
+
await rename2(target, backup);
|
|
5337
|
+
onLocalMutation();
|
|
5338
|
+
} catch (error) {
|
|
5339
|
+
if (!isNotFoundError(error)) throw error;
|
|
5340
|
+
await installNoOverwrite(tmp, target, onLocalMutation);
|
|
5341
|
+
return;
|
|
5342
|
+
}
|
|
5343
|
+
const backupDigest = await hashDriveFile(backup);
|
|
5344
|
+
if (!backupDigest) {
|
|
5345
|
+
await restoreBackupWhenPossible(backup, target);
|
|
5346
|
+
throw new Error("local file changed before download");
|
|
5347
|
+
}
|
|
5348
|
+
if (!expectedSha256 || backupDigest.sha256 !== expectedSha256) {
|
|
5349
|
+
await restoreBackupWhenPossible(backup, target);
|
|
5350
|
+
throw new Error("local file changed before download");
|
|
5351
|
+
}
|
|
5352
|
+
backupIsExpectedBase = true;
|
|
5353
|
+
try {
|
|
5354
|
+
await installNoOverwrite(tmp, target, onLocalMutation);
|
|
5355
|
+
} catch (error) {
|
|
5356
|
+
const restored = await restoreBackupWhenPossible(backup, target);
|
|
5357
|
+
if (!restored && backupIsExpectedBase) {
|
|
5358
|
+
await unlink(backup).catch(() => {
|
|
5359
|
+
});
|
|
5360
|
+
}
|
|
5361
|
+
throw error;
|
|
5362
|
+
}
|
|
5363
|
+
await unlink(backup);
|
|
5364
|
+
} catch (error) {
|
|
5365
|
+
if (!backupIsExpectedBase) {
|
|
5366
|
+
await restoreBackupWhenPossible(backup, target);
|
|
5367
|
+
}
|
|
5368
|
+
throw error;
|
|
5369
|
+
}
|
|
5370
|
+
}
|
|
5371
|
+
async function removeLocalIfStillBase(root, path, entry, onLocalMutation) {
|
|
5372
|
+
const target = resolveInsideRoot(root, path);
|
|
5373
|
+
const backup = localMutationBackupPath(target);
|
|
5374
|
+
const expectedSha256 = expectedLocalBaseSha256(entry);
|
|
5375
|
+
if (!expectedSha256) {
|
|
5376
|
+
throw new Error("local file has no sync base");
|
|
5377
|
+
}
|
|
5378
|
+
let backupIsExpectedBase = false;
|
|
5379
|
+
try {
|
|
5380
|
+
try {
|
|
5381
|
+
await rename2(target, backup);
|
|
5382
|
+
onLocalMutation();
|
|
5383
|
+
} catch (error) {
|
|
5384
|
+
if (isNotFoundError(error)) {
|
|
5385
|
+
throw new Error("local file changed before delete");
|
|
5386
|
+
}
|
|
5387
|
+
throw error;
|
|
5388
|
+
}
|
|
5389
|
+
const backupDigest = await hashDriveFile(backup);
|
|
5390
|
+
if (!backupDigest || backupDigest.sha256 !== expectedSha256) {
|
|
5391
|
+
await restoreBackupWhenPossible(backup, target);
|
|
5392
|
+
throw new Error("local file changed before delete");
|
|
5393
|
+
}
|
|
5394
|
+
backupIsExpectedBase = true;
|
|
5395
|
+
if (await localFileExists(target)) {
|
|
5396
|
+
await unlink(backup).catch(() => {
|
|
5397
|
+
});
|
|
5398
|
+
throw new Error("local file reappeared during delete");
|
|
5399
|
+
}
|
|
5400
|
+
await unlink(backup);
|
|
5401
|
+
if (await localFileExists(target)) {
|
|
5402
|
+
throw new Error("local file reappeared during delete");
|
|
5403
|
+
}
|
|
5404
|
+
} catch (error) {
|
|
5405
|
+
if (!backupIsExpectedBase) {
|
|
5406
|
+
await restoreBackupWhenPossible(backup, target);
|
|
5407
|
+
}
|
|
5408
|
+
throw error;
|
|
5409
|
+
}
|
|
5410
|
+
}
|
|
5411
|
+
async function installNoOverwrite(source, target, onLinked) {
|
|
5412
|
+
await link(source, target);
|
|
5413
|
+
onLinked?.();
|
|
5414
|
+
await unlink(source);
|
|
5415
|
+
}
|
|
5416
|
+
async function restoreBackupWhenPossible(backup, target) {
|
|
5417
|
+
try {
|
|
5418
|
+
await installNoOverwrite(backup, target);
|
|
5419
|
+
return true;
|
|
5420
|
+
} catch (error) {
|
|
5421
|
+
if (isAlreadyExistsError(error)) return false;
|
|
5422
|
+
if (isNotFoundError(error)) return true;
|
|
5423
|
+
return false;
|
|
5424
|
+
}
|
|
5425
|
+
}
|
|
5426
|
+
async function localFileExists(path) {
|
|
5427
|
+
const digest = await hashDriveFile(path).catch((error) => {
|
|
5428
|
+
if (isNotFoundError(error)) return void 0;
|
|
5429
|
+
throw error;
|
|
5430
|
+
});
|
|
5431
|
+
return digest !== void 0;
|
|
5432
|
+
}
|
|
5433
|
+
function localMutationBackupPath(target) {
|
|
5434
|
+
return join4(dirname(target), `.${basename2(target)}.wspc-backup-${randomUUID2()}.tmp`);
|
|
5435
|
+
}
|
|
5436
|
+
function expectedLocalBaseSha256(entry) {
|
|
5437
|
+
return entry?.last_local_sha256 ?? entry?.content_sha256;
|
|
5438
|
+
}
|
|
5439
|
+
async function readStableUploadBody(localPath, scanned) {
|
|
5440
|
+
if (!scanned) {
|
|
5441
|
+
throw new Error("local file missing from scan");
|
|
5442
|
+
}
|
|
5443
|
+
const snapshot = await hashDriveFile(localPath).catch((error) => {
|
|
5444
|
+
if (isNotFoundError(error)) return void 0;
|
|
5445
|
+
throw error;
|
|
5446
|
+
});
|
|
5447
|
+
if (!snapshot || snapshot.sha256 !== scanned.sha256 || snapshot.sizeBytes !== scanned.size_bytes) {
|
|
5448
|
+
throw new Error("local file changed after scan");
|
|
5449
|
+
}
|
|
5450
|
+
const body = await readFile3(localPath).catch((error) => {
|
|
5451
|
+
if (isNotFoundError(error)) return void 0;
|
|
5452
|
+
throw error;
|
|
5453
|
+
});
|
|
5454
|
+
if (!body) {
|
|
5455
|
+
throw new Error("local file changed after scan");
|
|
5456
|
+
}
|
|
5457
|
+
const uploadBytes = new Uint8Array(body.byteLength);
|
|
5458
|
+
uploadBytes.set(body);
|
|
5459
|
+
const digest = createHash2("sha256").update(uploadBytes).digest("hex");
|
|
5460
|
+
if (digest !== scanned.sha256 || uploadBytes.byteLength !== scanned.size_bytes) {
|
|
5461
|
+
throw new Error("local file changed after scan");
|
|
5462
|
+
}
|
|
5463
|
+
return { body: uploadBytes.buffer, digest };
|
|
5464
|
+
}
|
|
5465
|
+
async function assertLocalSafeForDownload(root, path, entry) {
|
|
5466
|
+
const target = resolveInsideRoot(root, path);
|
|
5467
|
+
const digest = await hashDriveFile(target).catch((error) => {
|
|
5468
|
+
if (isNotFoundError(error)) return void 0;
|
|
5469
|
+
throw error;
|
|
5470
|
+
});
|
|
5471
|
+
if (!digest) return;
|
|
5472
|
+
if (!entry?.last_local_sha256) {
|
|
5473
|
+
throw new Error("local file appeared before download");
|
|
5474
|
+
}
|
|
5475
|
+
if (digest.sha256 !== entry.last_local_sha256) {
|
|
5476
|
+
throw new Error("local file changed before download");
|
|
5477
|
+
}
|
|
5478
|
+
}
|
|
5479
|
+
async function assertLocalAbsentBeforeRemoteDelete(root, path) {
|
|
5480
|
+
const digest = await hashDriveFile(resolveInsideRoot(root, path)).catch((error) => {
|
|
5481
|
+
if (isNotFoundError(error)) return void 0;
|
|
5482
|
+
throw error;
|
|
5483
|
+
});
|
|
5484
|
+
if (digest) {
|
|
5485
|
+
throw new Error("local file appeared before remote delete");
|
|
5486
|
+
}
|
|
5487
|
+
}
|
|
5488
|
+
function isNotFoundError(error) {
|
|
5489
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
5490
|
+
}
|
|
5491
|
+
function isAlreadyExistsError(error) {
|
|
5492
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
5493
|
+
}
|
|
5494
|
+
|
|
5495
|
+
// src/handwritten/commands/drive/sync.ts
|
|
5496
|
+
function emptySummary() {
|
|
5497
|
+
return {
|
|
5498
|
+
uploaded: 0,
|
|
5499
|
+
downloaded: 0,
|
|
5500
|
+
deleted: 0,
|
|
5501
|
+
unchanged: 0,
|
|
5502
|
+
merged: 0,
|
|
5503
|
+
conflicts: 0,
|
|
5504
|
+
errors: 0,
|
|
5505
|
+
conflict_paths: [],
|
|
5506
|
+
paths: []
|
|
5507
|
+
};
|
|
5508
|
+
}
|
|
5509
|
+
async function runDriveSyncOnce(root, api, clock = systemDriveClock) {
|
|
5510
|
+
return withDriveLock(root, async () => {
|
|
5511
|
+
let state = await readDriveState(root);
|
|
5512
|
+
const syncApi = api ?? await createDriveApi();
|
|
5513
|
+
const summary = emptySummary();
|
|
5514
|
+
const blockedPaths = /* @__PURE__ */ new Set();
|
|
5515
|
+
const localFiles = await scanDriveFiles(root, {
|
|
5516
|
+
onPathError: async (path, error) => {
|
|
5517
|
+
await recordPathError(summary, blockedPaths, path, error);
|
|
5518
|
+
}
|
|
5519
|
+
});
|
|
5520
|
+
const remoteFiles = await fetchRemoteManifest(root, state, syncApi, summary, blockedPaths);
|
|
5521
|
+
const paths = Array.from(
|
|
5522
|
+
/* @__PURE__ */ new Set([...Object.keys(localFiles), ...Object.keys(remoteFiles), ...Object.keys(state.entries)])
|
|
5523
|
+
).filter((path) => !blockedPaths.has(path)).sort((left, right) => left.localeCompare(right));
|
|
5524
|
+
for (const path of paths) {
|
|
5525
|
+
const remote = remoteFiles[path];
|
|
5526
|
+
const action = decideDriveAction(state.entries[path], localFiles[path], remote);
|
|
5527
|
+
const result = await processPath({ root, state, api: syncApi, path, action, remote, local: localFiles[path], summary, clock });
|
|
5528
|
+
state = result.state;
|
|
5529
|
+
if (result.stop) break;
|
|
5530
|
+
}
|
|
5531
|
+
recordUnresolvedConflicts(summary, state);
|
|
5532
|
+
return summary;
|
|
5533
|
+
});
|
|
5534
|
+
}
|
|
5535
|
+
function driveSyncCommand(api) {
|
|
5536
|
+
const sync = new Command71("sync").description("Drive sync commands");
|
|
5537
|
+
sync.command("once").description("Run one Drive sync pass").argument("[path]", "local folder path", ".").action(async (path) => {
|
|
5538
|
+
const summary = await runDriveSyncOnce(resolve3(path), api);
|
|
5539
|
+
render({ kind: "drive_sync_once", display: { shape: "object" } }, summary);
|
|
5540
|
+
if (summary.conflicts > 0 || summary.errors > 0) {
|
|
5541
|
+
process.exitCode = 1;
|
|
5542
|
+
}
|
|
5543
|
+
});
|
|
5544
|
+
return sync;
|
|
5545
|
+
}
|
|
5546
|
+
async function fetchRemoteManifest(root, state, api, summary, blockedPaths) {
|
|
5547
|
+
const entries = [];
|
|
5548
|
+
let cursor;
|
|
5549
|
+
do {
|
|
5550
|
+
const page = await api.getManifest(state.library_id, cursor);
|
|
5551
|
+
entries.push(...page.entries);
|
|
5552
|
+
cursor = page.next_cursor ?? void 0;
|
|
5553
|
+
} while (cursor !== void 0);
|
|
5554
|
+
const normalized = normalizeRemoteManifest(root, entries);
|
|
5555
|
+
for (const pathError of normalized.pathErrors) {
|
|
5556
|
+
await recordPathError(summary, blockedPaths, pathError.path, pathError.error, {
|
|
5557
|
+
appendPathResult: pathError.appendPathResult
|
|
5558
|
+
});
|
|
5559
|
+
}
|
|
5560
|
+
return normalized.remoteFiles;
|
|
5561
|
+
}
|
|
5562
|
+
async function processPath(args) {
|
|
5563
|
+
const { root, state, api, path, action, remote, local, summary, clock } = args;
|
|
5564
|
+
summary.paths.push({ path, action: action.type });
|
|
5565
|
+
let durableStateRequired = false;
|
|
5647
5566
|
try {
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5567
|
+
if (action.type === "upload_create" || action.type === "upload_update") {
|
|
5568
|
+
const localPath = resolveInsideRoot(root, path);
|
|
5569
|
+
const { body, digest: uploadDigest } = await readStableUploadBody(localPath, local);
|
|
5570
|
+
const uploaded = await api.uploadFile(state.library_id, path, body, uploadDigest, action.expectedEntryVersion);
|
|
5571
|
+
durableStateRequired = true;
|
|
5572
|
+
const nextState = cloneDriveState(state);
|
|
5573
|
+
nextState.entries[path] = stateEntryFromRemote(uploaded.entry, uploadDigest, clock);
|
|
5574
|
+
delete nextState.conflicts[path];
|
|
5575
|
+
await writeDriveState(root, nextState, clock);
|
|
5576
|
+
summary.uploaded += 1;
|
|
5577
|
+
return { state: nextState, stop: false };
|
|
5655
5578
|
}
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
await
|
|
5659
|
-
|
|
5579
|
+
if (action.type === "download") {
|
|
5580
|
+
if (!remote) throw new Error("remote entry missing for download");
|
|
5581
|
+
await assertLocalSafeForDownload(root, path, state.entries[path]);
|
|
5582
|
+
const digest = await downloadRemote(root, state.library_id, path, api, remote.content_sha256, state.entries[path], () => {
|
|
5583
|
+
durableStateRequired = true;
|
|
5584
|
+
});
|
|
5585
|
+
const nextState = cloneDriveState(state);
|
|
5586
|
+
nextState.entries[path] = stateEntryFromRemote(remote, digest, clock);
|
|
5587
|
+
delete nextState.conflicts[path];
|
|
5588
|
+
await writeDriveState(root, nextState, clock);
|
|
5589
|
+
summary.downloaded += 1;
|
|
5590
|
+
return { state: nextState, stop: false };
|
|
5660
5591
|
}
|
|
5661
|
-
if (
|
|
5662
|
-
await
|
|
5663
|
-
|
|
5592
|
+
if (action.type === "delete_remote") {
|
|
5593
|
+
await assertLocalAbsentBeforeRemoteDelete(root, path);
|
|
5594
|
+
await api.deleteFile(state.library_id, path, action.expectedEntryVersion);
|
|
5595
|
+
durableStateRequired = true;
|
|
5596
|
+
await assertLocalAbsentBeforeRemoteDelete(root, path);
|
|
5597
|
+
const nextState = cloneDriveState(state);
|
|
5598
|
+
delete nextState.entries[path];
|
|
5599
|
+
delete nextState.conflicts[path];
|
|
5600
|
+
await writeDriveState(root, nextState, clock);
|
|
5601
|
+
summary.deleted += 1;
|
|
5602
|
+
return { state: nextState, stop: false };
|
|
5664
5603
|
}
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
const
|
|
5670
|
-
|
|
5671
|
-
|
|
5604
|
+
if (action.type === "delete_local") {
|
|
5605
|
+
await removeLocalIfStillBase(root, path, state.entries[path], () => {
|
|
5606
|
+
durableStateRequired = true;
|
|
5607
|
+
});
|
|
5608
|
+
const nextState = cloneDriveState(state);
|
|
5609
|
+
delete nextState.entries[path];
|
|
5610
|
+
delete nextState.conflicts[path];
|
|
5611
|
+
await writeDriveState(root, nextState, clock);
|
|
5612
|
+
summary.deleted += 1;
|
|
5613
|
+
return { state: nextState, stop: false };
|
|
5614
|
+
}
|
|
5615
|
+
if (action.type === "state_only") {
|
|
5616
|
+
if (!remote) throw new Error("remote entry missing for state update");
|
|
5617
|
+
const nextState = cloneDriveState(state);
|
|
5618
|
+
nextState.entries[path] = stateEntryFromRemote(remote, local?.sha256 ?? remote.content_sha256, clock);
|
|
5619
|
+
delete nextState.conflicts[path];
|
|
5620
|
+
await writeDriveState(root, nextState, clock);
|
|
5621
|
+
summary.unchanged += 1;
|
|
5622
|
+
return { state: nextState, stop: false };
|
|
5623
|
+
}
|
|
5624
|
+
if (action.type === "remove_state") {
|
|
5625
|
+
const nextState = cloneDriveState(state);
|
|
5626
|
+
delete nextState.entries[path];
|
|
5627
|
+
delete nextState.conflicts[path];
|
|
5628
|
+
await writeDriveState(root, nextState, clock);
|
|
5629
|
+
summary.unchanged += 1;
|
|
5630
|
+
return { state: nextState, stop: false };
|
|
5631
|
+
}
|
|
5632
|
+
if (action.type === "conflict") {
|
|
5633
|
+
if (action.reason === "local_and_remote_changed") {
|
|
5634
|
+
try {
|
|
5635
|
+
const mergedState = await tryResolveConflict({
|
|
5636
|
+
root,
|
|
5637
|
+
state,
|
|
5638
|
+
api,
|
|
5639
|
+
path,
|
|
5640
|
+
remote,
|
|
5641
|
+
local,
|
|
5642
|
+
clock,
|
|
5643
|
+
onLocalMutation: () => {
|
|
5644
|
+
durableStateRequired = true;
|
|
5645
|
+
}
|
|
5646
|
+
});
|
|
5647
|
+
if (mergedState) {
|
|
5648
|
+
summary.merged += 1;
|
|
5649
|
+
summary.paths[summary.paths.length - 1] = { path, action: "merged" };
|
|
5650
|
+
return { state: mergedState, stop: false };
|
|
5651
|
+
}
|
|
5652
|
+
} catch (error) {
|
|
5653
|
+
if (!isLocalChangedDuringMerge(error)) {
|
|
5654
|
+
throw error;
|
|
5655
|
+
}
|
|
5656
|
+
const nextState2 = await recordTypedConflict(root, state, path, action.reason, remote, clock, {
|
|
5657
|
+
type: "edit_edit",
|
|
5658
|
+
strategy: "record_only",
|
|
5659
|
+
reason: "local_changed_during_merge"
|
|
5660
|
+
});
|
|
5661
|
+
summary.conflicts += 1;
|
|
5662
|
+
return { state: nextState2, stop: false };
|
|
5663
|
+
}
|
|
5664
|
+
const conflictCopyState = await recordRemoteConflictCopy({
|
|
5665
|
+
root,
|
|
5666
|
+
state,
|
|
5667
|
+
api,
|
|
5668
|
+
path,
|
|
5669
|
+
reason: action.reason,
|
|
5670
|
+
type: "edit_edit",
|
|
5671
|
+
remote,
|
|
5672
|
+
clock
|
|
5673
|
+
});
|
|
5674
|
+
if (conflictCopyState) {
|
|
5675
|
+
summary.conflicts += 1;
|
|
5676
|
+
return { state: conflictCopyState, stop: false };
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
5679
|
+
if (action.reason === "local_and_remote_without_base") {
|
|
5680
|
+
const conflictCopyState = await recordRemoteConflictCopy({
|
|
5681
|
+
root,
|
|
5682
|
+
state,
|
|
5683
|
+
api,
|
|
5684
|
+
path,
|
|
5685
|
+
reason: action.reason,
|
|
5686
|
+
type: "create_create",
|
|
5687
|
+
remote,
|
|
5688
|
+
clock
|
|
5672
5689
|
});
|
|
5690
|
+
if (conflictCopyState) {
|
|
5691
|
+
summary.conflicts += 1;
|
|
5692
|
+
return { state: conflictCopyState, stop: false };
|
|
5693
|
+
}
|
|
5673
5694
|
}
|
|
5674
|
-
|
|
5695
|
+
if (action.reason === "local_changed_remote_deleted") {
|
|
5696
|
+
const nextState2 = await recordTypedConflict(root, state, path, action.reason, remote, clock, {
|
|
5697
|
+
type: "edit_delete",
|
|
5698
|
+
strategy: "record_only"
|
|
5699
|
+
});
|
|
5700
|
+
summary.conflicts += 1;
|
|
5701
|
+
return { state: nextState2, stop: false };
|
|
5702
|
+
}
|
|
5703
|
+
if (action.reason === "remote_changed_before_delete") {
|
|
5704
|
+
const conflictCopyState = await recordRemoteConflictCopy({
|
|
5705
|
+
root,
|
|
5706
|
+
state,
|
|
5707
|
+
api,
|
|
5708
|
+
path,
|
|
5709
|
+
reason: action.reason,
|
|
5710
|
+
type: "delete_edit",
|
|
5711
|
+
remote,
|
|
5712
|
+
clock
|
|
5713
|
+
});
|
|
5714
|
+
if (conflictCopyState) {
|
|
5715
|
+
summary.conflicts += 1;
|
|
5716
|
+
return { state: conflictCopyState, stop: false };
|
|
5717
|
+
}
|
|
5718
|
+
}
|
|
5719
|
+
const nextState = await recordConflict(root, state, path, action.reason, remote, clock);
|
|
5720
|
+
summary.conflicts += 1;
|
|
5721
|
+
return { state: nextState, stop: false };
|
|
5675
5722
|
}
|
|
5676
|
-
|
|
5723
|
+
summary.unchanged += 1;
|
|
5677
5724
|
} catch (error) {
|
|
5678
|
-
if (
|
|
5679
|
-
|
|
5725
|
+
if (isVersionConflict(error)) {
|
|
5726
|
+
try {
|
|
5727
|
+
const nextState = await recordConflict(root, state, path, "VERSION_CONFLICT", remote, clock);
|
|
5728
|
+
summary.conflicts += 1;
|
|
5729
|
+
summary.paths[summary.paths.length - 1] = { path, action: "conflict" };
|
|
5730
|
+
return { state: nextState, stop: false };
|
|
5731
|
+
} catch (writeError) {
|
|
5732
|
+
await recordPathError(summary, void 0, path, writeError);
|
|
5733
|
+
return { state, stop: durableStateRequired };
|
|
5734
|
+
}
|
|
5680
5735
|
}
|
|
5681
|
-
|
|
5736
|
+
await recordPathError(summary, void 0, path, error);
|
|
5737
|
+
return { state, stop: durableStateRequired };
|
|
5682
5738
|
}
|
|
5739
|
+
return { state, stop: false };
|
|
5683
5740
|
}
|
|
5684
|
-
async function
|
|
5685
|
-
const
|
|
5686
|
-
const
|
|
5687
|
-
const
|
|
5688
|
-
|
|
5689
|
-
|
|
5741
|
+
async function tryResolveConflict(args) {
|
|
5742
|
+
const { root, state, api, path, remote, local, clock, onLocalMutation } = args;
|
|
5743
|
+
const entry = state.entries[path];
|
|
5744
|
+
const baseVersionId = entry?.current_version_id;
|
|
5745
|
+
const remoteVersionId = remote?.current_version_id;
|
|
5746
|
+
if (!entry || !remote || !local || baseVersionId === void 0 || remoteVersionId === void 0) {
|
|
5747
|
+
return void 0;
|
|
5690
5748
|
}
|
|
5691
|
-
|
|
5749
|
+
const localPath = resolveInsideRoot(root, path);
|
|
5750
|
+
let baseBytes;
|
|
5751
|
+
let remoteBytes;
|
|
5692
5752
|
try {
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
}
|
|
5700
|
-
throw error;
|
|
5701
|
-
}
|
|
5702
|
-
const backupDigest = await hashDriveFile(backup);
|
|
5703
|
-
if (!backupDigest || backupDigest.sha256 !== expectedSha256) {
|
|
5704
|
-
await restoreBackupWhenPossible(backup, target);
|
|
5705
|
-
throw new Error("local file changed before delete");
|
|
5706
|
-
}
|
|
5707
|
-
backupIsExpectedBase = true;
|
|
5708
|
-
if (await localFileExists(target)) {
|
|
5709
|
-
await unlink(backup).catch(() => {
|
|
5710
|
-
});
|
|
5711
|
-
throw new Error("local file reappeared during delete");
|
|
5712
|
-
}
|
|
5713
|
-
await unlink(backup);
|
|
5714
|
-
if (await localFileExists(target)) {
|
|
5715
|
-
throw new Error("local file reappeared during delete");
|
|
5716
|
-
}
|
|
5753
|
+
const [downloadedBaseBytes, downloadedRemoteBytes] = await Promise.all([
|
|
5754
|
+
downloadBytes(api, state.library_id, path, baseVersionId),
|
|
5755
|
+
downloadBytes(api, state.library_id, path, remoteVersionId)
|
|
5756
|
+
]);
|
|
5757
|
+
baseBytes = downloadedBaseBytes;
|
|
5758
|
+
remoteBytes = downloadedRemoteBytes;
|
|
5717
5759
|
} catch (error) {
|
|
5718
|
-
if (
|
|
5719
|
-
|
|
5760
|
+
if (isExpectedVersionDownloadMissing(error)) {
|
|
5761
|
+
return void 0;
|
|
5720
5762
|
}
|
|
5721
5763
|
throw error;
|
|
5722
5764
|
}
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5765
|
+
const localBytes = await readFile4(localPath);
|
|
5766
|
+
const baseText = classifyMergeText(path, baseBytes, void 0);
|
|
5767
|
+
const localText = classifyMergeText(path, localBytes, void 0);
|
|
5768
|
+
const remoteText = classifyMergeText(path, remoteBytes, void 0);
|
|
5769
|
+
if (!baseText.mergeable || !localText.mergeable || !remoteText.mergeable) {
|
|
5770
|
+
return void 0;
|
|
5771
|
+
}
|
|
5772
|
+
const merged = mergeText3(baseText.text, localText.text, remoteText.text);
|
|
5773
|
+
if (!merged.clean) {
|
|
5774
|
+
return void 0;
|
|
5775
|
+
}
|
|
5776
|
+
await assertLocalStillScanned(localPath, local);
|
|
5777
|
+
const mergedBytes = new TextEncoder().encode(merged.text);
|
|
5778
|
+
const mergedDigest = createHash3("sha256").update(mergedBytes).digest("hex");
|
|
5779
|
+
const install = await writeMergedLocalFile(root, path, mergedBytes, mergedDigest, local, onLocalMutation);
|
|
5780
|
+
let uploaded;
|
|
5730
5781
|
try {
|
|
5731
|
-
await
|
|
5732
|
-
return true;
|
|
5782
|
+
uploaded = await api.uploadFile(state.library_id, path, mergedBytes, mergedDigest, remote.entry_version);
|
|
5733
5783
|
} catch (error) {
|
|
5734
|
-
|
|
5735
|
-
if (isNotFoundError(error)) return true;
|
|
5736
|
-
return false;
|
|
5737
|
-
}
|
|
5738
|
-
}
|
|
5739
|
-
async function localFileExists(path) {
|
|
5740
|
-
const digest = await hashDriveFile(path).catch((error) => {
|
|
5741
|
-
if (isNotFoundError(error)) return void 0;
|
|
5784
|
+
await install.restore();
|
|
5742
5785
|
throw error;
|
|
5743
|
-
}
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5786
|
+
}
|
|
5787
|
+
await install.finalize();
|
|
5788
|
+
const nextState = cloneDriveState(state);
|
|
5789
|
+
nextState.entries[path] = stateEntryFromRemote(uploaded.entry, mergedDigest, clock);
|
|
5790
|
+
delete nextState.conflicts[path];
|
|
5791
|
+
await writeDriveState(root, nextState, clock);
|
|
5792
|
+
return nextState;
|
|
5748
5793
|
}
|
|
5749
|
-
function
|
|
5750
|
-
|
|
5794
|
+
async function downloadBytes(api, libraryId, path, versionId) {
|
|
5795
|
+
const response = await api.downloadFile(libraryId, path, versionId);
|
|
5796
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
5751
5797
|
}
|
|
5752
|
-
async function
|
|
5753
|
-
|
|
5754
|
-
|
|
5798
|
+
async function recordRemoteConflictCopy(args) {
|
|
5799
|
+
const { root, state, api, path, reason, type, remote, clock } = args;
|
|
5800
|
+
const entry = state.entries[path];
|
|
5801
|
+
const remoteVersionId = remote?.current_version_id;
|
|
5802
|
+
if (!remote || remoteVersionId === void 0) {
|
|
5803
|
+
return void 0;
|
|
5755
5804
|
}
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
throw error;
|
|
5759
|
-
});
|
|
5760
|
-
if (!snapshot || snapshot.sha256 !== scanned.sha256 || snapshot.sizeBytes !== scanned.size_bytes) {
|
|
5761
|
-
throw new Error("local file changed after scan");
|
|
5805
|
+
if (await canReuseConflictCopy(root, state.conflicts[path], remoteVersionId)) {
|
|
5806
|
+
return state;
|
|
5762
5807
|
}
|
|
5763
|
-
const
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5808
|
+
const remoteBytes = await downloadBytes(api, state.library_id, path, remoteVersionId);
|
|
5809
|
+
const copyPath = await writeConflictCopy(root, path, "remote", remoteVersionId, remoteBytes, clock);
|
|
5810
|
+
const nextState = cloneDriveState(state);
|
|
5811
|
+
nextState.conflicts[path] = {
|
|
5812
|
+
detected_at: driveIsoTimestamp(clock),
|
|
5813
|
+
reason,
|
|
5814
|
+
type,
|
|
5815
|
+
strategy: "conflict_copy",
|
|
5816
|
+
base_version_id: entry?.current_version_id,
|
|
5817
|
+
remote_version_id: remoteVersionId,
|
|
5818
|
+
remote_entry_version: remote.entry_version,
|
|
5819
|
+
conflict_paths: [copyPath]
|
|
5820
|
+
};
|
|
5821
|
+
await writeDriveState(root, nextState, clock);
|
|
5822
|
+
return nextState;
|
|
5823
|
+
}
|
|
5824
|
+
async function canReuseConflictCopy(root, conflict2, remoteVersionId) {
|
|
5825
|
+
if (conflict2?.strategy !== "conflict_copy" || conflict2.remote_version_id !== remoteVersionId || !Array.isArray(conflict2.conflict_paths) || conflict2.conflict_paths.length === 0) {
|
|
5826
|
+
return false;
|
|
5769
5827
|
}
|
|
5770
|
-
const
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5828
|
+
for (const conflictPath of conflict2.conflict_paths) {
|
|
5829
|
+
try {
|
|
5830
|
+
validateDrivePath(conflictPath);
|
|
5831
|
+
if (!await localFileExists(resolveInsideRoot(root, conflictPath))) {
|
|
5832
|
+
return false;
|
|
5833
|
+
}
|
|
5834
|
+
} catch {
|
|
5835
|
+
return false;
|
|
5836
|
+
}
|
|
5775
5837
|
}
|
|
5776
|
-
return
|
|
5838
|
+
return true;
|
|
5777
5839
|
}
|
|
5778
|
-
async function
|
|
5779
|
-
const
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5840
|
+
async function writeConflictCopy(root, path, side, versionId, bytes, clock) {
|
|
5841
|
+
const baseCopyPath = conflictCopyPath(path, side, driveConflictTimestamp(clock), versionId);
|
|
5842
|
+
for (let suffix = 1; ; suffix += 1) {
|
|
5843
|
+
const candidate = conflictCopyPathWithSuffix(baseCopyPath, suffix);
|
|
5844
|
+
validateDrivePath(candidate);
|
|
5845
|
+
const target = resolveInsideRoot(root, candidate);
|
|
5846
|
+
await mkdir3(dirname2(target), { recursive: true });
|
|
5847
|
+
for (; ; ) {
|
|
5848
|
+
const tmp = join5(dirname2(target), `.${basename3(target)}.wspc-conflict-${randomUUID3()}.tmp`);
|
|
5849
|
+
let tmpWritten = false;
|
|
5850
|
+
try {
|
|
5851
|
+
await writeFile3(tmp, bytes, { flag: "wx" });
|
|
5852
|
+
tmpWritten = true;
|
|
5853
|
+
await installNoOverwrite(tmp, target);
|
|
5854
|
+
return candidate;
|
|
5855
|
+
} catch (error) {
|
|
5856
|
+
if (tmpWritten) {
|
|
5857
|
+
await rm3(tmp, { force: true }).catch(() => {
|
|
5858
|
+
});
|
|
5859
|
+
}
|
|
5860
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") {
|
|
5861
|
+
if (tmpWritten) break;
|
|
5862
|
+
continue;
|
|
5863
|
+
}
|
|
5864
|
+
throw error;
|
|
5865
|
+
}
|
|
5866
|
+
}
|
|
5787
5867
|
}
|
|
5788
|
-
|
|
5789
|
-
|
|
5868
|
+
}
|
|
5869
|
+
function conflictCopyPathWithSuffix(path, suffix) {
|
|
5870
|
+
if (suffix === 1) {
|
|
5871
|
+
return path;
|
|
5872
|
+
}
|
|
5873
|
+
const parsed = pathPosix2.parse(path);
|
|
5874
|
+
const fileName = `${parsed.name}-${suffix}${parsed.ext}`;
|
|
5875
|
+
if (parsed.dir === "") {
|
|
5876
|
+
return fileName;
|
|
5790
5877
|
}
|
|
5878
|
+
return pathPosix2.join(parsed.dir, fileName);
|
|
5791
5879
|
}
|
|
5792
|
-
|
|
5793
|
-
const
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
5880
|
+
function isExpectedVersionDownloadMissing(error) {
|
|
5881
|
+
const structured = error;
|
|
5882
|
+
if (structured?.status === 404 || structured?.status === 410) return true;
|
|
5883
|
+
if (structured?.response?.status === 404 || structured?.response?.status === 410) return true;
|
|
5884
|
+
if (structured?.code === "VERSION_NOT_FOUND" || structured?.code === "NOT_FOUND") return true;
|
|
5885
|
+
return /\b(?:HTTP 40[410]|missing version|version not found|not found)\b/i.test(errorMessage(error));
|
|
5886
|
+
}
|
|
5887
|
+
function recordUnresolvedConflicts(summary, state) {
|
|
5888
|
+
const newlyRecorded = new Set(summary.paths.filter((result) => result.action === "conflict").map((result) => result.path));
|
|
5889
|
+
const reportedPaths = new Set(summary.paths.map((result) => result.path));
|
|
5890
|
+
for (const path of Object.keys(state.conflicts).sort((left, right) => left.localeCompare(right))) {
|
|
5891
|
+
const conflictPaths = state.conflicts[path]?.conflict_paths;
|
|
5892
|
+
if (conflictPaths) {
|
|
5893
|
+
summary.conflict_paths.push(...conflictPaths);
|
|
5894
|
+
}
|
|
5895
|
+
if (!newlyRecorded.has(path)) {
|
|
5896
|
+
summary.conflicts += 1;
|
|
5897
|
+
}
|
|
5898
|
+
const existingResult = summary.paths.find((result) => result.path === path);
|
|
5899
|
+
if (existingResult?.action === "unchanged") {
|
|
5900
|
+
existingResult.action = "conflict";
|
|
5901
|
+
if (conflictPaths) existingResult.conflict_paths = conflictPaths;
|
|
5902
|
+
continue;
|
|
5903
|
+
}
|
|
5904
|
+
if (existingResult?.action === "conflict" && conflictPaths) {
|
|
5905
|
+
existingResult.conflict_paths = conflictPaths;
|
|
5906
|
+
}
|
|
5907
|
+
if (!reportedPaths.has(path)) {
|
|
5908
|
+
summary.paths.push({ path, action: "conflict", ...conflictPaths ? { conflict_paths: conflictPaths } : {} });
|
|
5909
|
+
}
|
|
5799
5910
|
}
|
|
5800
5911
|
}
|
|
5801
|
-
function stateEntryFromRemote(remote, localSha256) {
|
|
5912
|
+
function stateEntryFromRemote(remote, localSha256, clock) {
|
|
5802
5913
|
return {
|
|
5803
5914
|
entry_id: remote.id,
|
|
5804
5915
|
entry_version: remote.entry_version,
|
|
@@ -5806,13 +5917,10 @@ function stateEntryFromRemote(remote, localSha256) {
|
|
|
5806
5917
|
content_sha256: remote.content_sha256,
|
|
5807
5918
|
size_bytes: remote.size_bytes,
|
|
5808
5919
|
last_local_sha256: localSha256,
|
|
5809
|
-
last_synced_at: (
|
|
5920
|
+
last_synced_at: driveIsoTimestamp(clock),
|
|
5810
5921
|
status: "synced"
|
|
5811
5922
|
};
|
|
5812
5923
|
}
|
|
5813
|
-
async function commitDriveState(root, nextState) {
|
|
5814
|
-
await writeDriveState(root, nextState);
|
|
5815
|
-
}
|
|
5816
5924
|
function cloneDriveState(state) {
|
|
5817
5925
|
return {
|
|
5818
5926
|
...state,
|
|
@@ -5820,21 +5928,21 @@ function cloneDriveState(state) {
|
|
|
5820
5928
|
conflicts: { ...state.conflicts }
|
|
5821
5929
|
};
|
|
5822
5930
|
}
|
|
5823
|
-
async function recordConflict(root, state, path, reason, remote) {
|
|
5931
|
+
async function recordConflict(root, state, path, reason, remote, clock) {
|
|
5824
5932
|
const nextState = cloneDriveState(state);
|
|
5825
|
-
nextState.conflicts[path] = conflict(reason, remote);
|
|
5826
|
-
await
|
|
5933
|
+
nextState.conflicts[path] = conflict(reason, remote, clock);
|
|
5934
|
+
await writeDriveState(root, nextState, clock);
|
|
5827
5935
|
return nextState;
|
|
5828
5936
|
}
|
|
5829
|
-
async function recordTypedConflict(root, state, path, reason, remote, metadata) {
|
|
5937
|
+
async function recordTypedConflict(root, state, path, reason, remote, clock, metadata) {
|
|
5830
5938
|
const nextState = cloneDriveState(state);
|
|
5831
5939
|
nextState.conflicts[path] = {
|
|
5832
|
-
...conflict(metadata.reason ?? reason, remote),
|
|
5940
|
+
...conflict(metadata.reason ?? reason, remote, clock),
|
|
5833
5941
|
type: metadata.type,
|
|
5834
5942
|
strategy: metadata.strategy,
|
|
5835
5943
|
base_version_id: state.entries[path]?.current_version_id
|
|
5836
5944
|
};
|
|
5837
|
-
await
|
|
5945
|
+
await writeDriveState(root, nextState, clock);
|
|
5838
5946
|
return nextState;
|
|
5839
5947
|
}
|
|
5840
5948
|
async function recordPathError(summary, blockedPaths, path, error, options = {}) {
|
|
@@ -5848,9 +5956,9 @@ async function recordPathError(summary, blockedPaths, path, error, options = {})
|
|
|
5848
5956
|
void errorMessage(error);
|
|
5849
5957
|
summary.errors += 1;
|
|
5850
5958
|
}
|
|
5851
|
-
function conflict(reason, remote) {
|
|
5959
|
+
function conflict(reason, remote, clock) {
|
|
5852
5960
|
return {
|
|
5853
|
-
detected_at: (
|
|
5961
|
+
detected_at: driveIsoTimestamp(clock),
|
|
5854
5962
|
reason,
|
|
5855
5963
|
remote_entry_version: remote?.entry_version,
|
|
5856
5964
|
remote_version_id: remote?.current_version_id
|
|
@@ -5876,22 +5984,268 @@ function containsVersionConflict(value) {
|
|
|
5876
5984
|
return false;
|
|
5877
5985
|
}
|
|
5878
5986
|
}
|
|
5879
|
-
function isNotFoundError(error) {
|
|
5880
|
-
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
5881
|
-
}
|
|
5882
|
-
function isAlreadyExistsError(error) {
|
|
5883
|
-
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
5884
|
-
}
|
|
5885
5987
|
|
|
5886
5988
|
// src/handwritten/commands/drive/watch.ts
|
|
5887
5989
|
import { Command as Command72 } from "commander";
|
|
5888
5990
|
import chokidar from "chokidar";
|
|
5889
5991
|
import { relative as relative2, resolve as resolve4 } from "path";
|
|
5992
|
+
|
|
5993
|
+
// src/handwritten/commands/drive/realtime.ts
|
|
5994
|
+
function createDriveRealtimeSource(args) {
|
|
5995
|
+
const connect = args.connect ?? nativeWebSocketConnector;
|
|
5996
|
+
const clock = args.clock ?? systemDriveClock;
|
|
5997
|
+
const scheduleTimeout = args.setTimeout ?? setTimeout;
|
|
5998
|
+
const cancelTimeout = args.clearTimeout ?? clearTimeout;
|
|
5999
|
+
let currentRealtime = { ...args.realtime };
|
|
6000
|
+
let handlers;
|
|
6001
|
+
let activeSocket;
|
|
6002
|
+
let reconnectTimer;
|
|
6003
|
+
let reconnectDelayMs = 1e3;
|
|
6004
|
+
let stopped = false;
|
|
6005
|
+
let authFailed = false;
|
|
6006
|
+
let connectionId = 0;
|
|
6007
|
+
function clearReconnectTimer() {
|
|
6008
|
+
if (reconnectTimer === void 0) return;
|
|
6009
|
+
cancelTimeout(reconnectTimer);
|
|
6010
|
+
reconnectTimer = void 0;
|
|
6011
|
+
}
|
|
6012
|
+
async function persist(next) {
|
|
6013
|
+
currentRealtime = next;
|
|
6014
|
+
await args.writeRealtimeState(next);
|
|
6015
|
+
}
|
|
6016
|
+
async function persistBestEffort(next) {
|
|
6017
|
+
try {
|
|
6018
|
+
await persist(next);
|
|
6019
|
+
} catch (error) {
|
|
6020
|
+
handlers?.onWarning?.(redactedRealtimeError(error));
|
|
6021
|
+
}
|
|
6022
|
+
}
|
|
6023
|
+
function connectNow() {
|
|
6024
|
+
if (stopped || authFailed) return;
|
|
6025
|
+
clearReconnectTimer();
|
|
6026
|
+
const id = ++connectionId;
|
|
6027
|
+
const url = buildDriveRealtimeUrl(args.baseUrl, args.libraryId, currentRealtime);
|
|
6028
|
+
activeSocket = connect(url, {
|
|
6029
|
+
open() {
|
|
6030
|
+
if (id !== connectionId || stopped || authFailed) return;
|
|
6031
|
+
reconnectDelayMs = 1e3;
|
|
6032
|
+
void persistBestEffort({ ...currentRealtime, last_connected_at: driveIsoTimestamp(clock) }).then(() => handlers?.onConnected());
|
|
6033
|
+
},
|
|
6034
|
+
message(data) {
|
|
6035
|
+
if (id !== connectionId || stopped || authFailed) return;
|
|
6036
|
+
void handleMessage(data).catch((error) => handlers?.onWarning?.(redactedRealtimeError(error)));
|
|
6037
|
+
},
|
|
6038
|
+
close(error) {
|
|
6039
|
+
closeConnection(id, error ?? "close");
|
|
6040
|
+
}
|
|
6041
|
+
}, args.headers === void 0 ? void 0 : { headers: args.headers });
|
|
6042
|
+
}
|
|
6043
|
+
function closeConnection(id, error) {
|
|
6044
|
+
if (id !== connectionId || stopped || authFailed) return;
|
|
6045
|
+
connectionId += 1;
|
|
6046
|
+
const socket = activeSocket;
|
|
6047
|
+
activeSocket = void 0;
|
|
6048
|
+
clearReconnectTimer();
|
|
6049
|
+
socket?.close();
|
|
6050
|
+
if (isRealtimeAuthError(error)) {
|
|
6051
|
+
authFailed = true;
|
|
6052
|
+
handlers?.onAuthFailed(redactedRealtimeError(error));
|
|
6053
|
+
return;
|
|
6054
|
+
}
|
|
6055
|
+
const delayMs = reconnectDelayMs;
|
|
6056
|
+
handlers?.onReconnect(delayMs, redactedRealtimeError(error));
|
|
6057
|
+
reconnectTimer = scheduleTimeout(() => {
|
|
6058
|
+
reconnectTimer = void 0;
|
|
6059
|
+
connectNow();
|
|
6060
|
+
}, delayMs);
|
|
6061
|
+
reconnectDelayMs = Math.min(delayMs * 2, 6e4);
|
|
6062
|
+
}
|
|
6063
|
+
async function handleMessage(data) {
|
|
6064
|
+
const message = parseDriveRealtimeMessage(data);
|
|
6065
|
+
if (message.type === "ready") {
|
|
6066
|
+
if (message.replayed > 0) {
|
|
6067
|
+
handlers?.onEvent(optionalString({ debounce_ms: 2e3, reason: "ready_replay" }, "cursor", message.cursor));
|
|
6068
|
+
}
|
|
6069
|
+
if (message.cursor !== void 0) {
|
|
6070
|
+
await persistBestEffort({ ...currentRealtime, last_cursor: message.cursor });
|
|
6071
|
+
}
|
|
6072
|
+
return;
|
|
6073
|
+
}
|
|
6074
|
+
if (message.type === "library_changed") {
|
|
6075
|
+
handlers?.onEvent(optionalString(optionalString({
|
|
6076
|
+
debounce_ms: 2e3,
|
|
6077
|
+
reason: "library_changed"
|
|
6078
|
+
}, "cursor", message.cursor), "path", message.path));
|
|
6079
|
+
if (message.cursor !== void 0) {
|
|
6080
|
+
await persistBestEffort({ ...currentRealtime, last_cursor: message.cursor, last_event_at: driveIsoTimestamp(clock) });
|
|
6081
|
+
}
|
|
6082
|
+
return;
|
|
6083
|
+
}
|
|
6084
|
+
if (message.type === "resync_required") {
|
|
6085
|
+
const reason = message.reason ?? "resync_required";
|
|
6086
|
+
handlers?.onEvent(optionalString({ immediate: true, reason }, "cursor", message.cursor));
|
|
6087
|
+
if (message.cursor !== void 0 || isInvalidCursorReason(reason)) {
|
|
6088
|
+
await persistBestEffort(resyncRealtimeState(currentRealtime, message.cursor, reason, driveIsoTimestamp(clock)));
|
|
6089
|
+
}
|
|
6090
|
+
return;
|
|
6091
|
+
}
|
|
6092
|
+
if (message.type === "error") {
|
|
6093
|
+
const error = message.message ?? message.code ?? "realtime error";
|
|
6094
|
+
if (isRealtimeAuthError(error) || isRealtimeAuthError(message.code)) {
|
|
6095
|
+
authFailed = true;
|
|
6096
|
+
connectionId += 1;
|
|
6097
|
+
clearReconnectTimer();
|
|
6098
|
+
handlers?.onAuthFailed(redactedRealtimeError(error));
|
|
6099
|
+
activeSocket?.close();
|
|
6100
|
+
activeSocket = void 0;
|
|
6101
|
+
return;
|
|
6102
|
+
}
|
|
6103
|
+
handlers?.onWarning?.(redactedRealtimeError(error));
|
|
6104
|
+
return;
|
|
6105
|
+
}
|
|
6106
|
+
handlers?.onWarning?.("unknown realtime message");
|
|
6107
|
+
}
|
|
6108
|
+
return {
|
|
6109
|
+
async start(nextHandlers) {
|
|
6110
|
+
handlers = nextHandlers;
|
|
6111
|
+
stopped = false;
|
|
6112
|
+
authFailed = false;
|
|
6113
|
+
connectNow();
|
|
6114
|
+
},
|
|
6115
|
+
async close() {
|
|
6116
|
+
stopped = true;
|
|
6117
|
+
clearReconnectTimer();
|
|
6118
|
+
activeSocket?.close();
|
|
6119
|
+
activeSocket = void 0;
|
|
6120
|
+
}
|
|
6121
|
+
};
|
|
6122
|
+
}
|
|
6123
|
+
function buildDriveRealtimeUrl(baseUrl, libraryId, realtime) {
|
|
6124
|
+
if (realtime.client_id.length === 0) {
|
|
6125
|
+
throw new Error("drive realtime client_id is required");
|
|
6126
|
+
}
|
|
6127
|
+
const url = new URL(baseUrl);
|
|
6128
|
+
url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
|
|
6129
|
+
url.pathname = `/drive/libraries/${encodeURIComponent(libraryId)}/realtime`;
|
|
6130
|
+
url.search = "";
|
|
6131
|
+
url.hash = "";
|
|
6132
|
+
if (realtime.last_cursor !== void 0) {
|
|
6133
|
+
url.searchParams.set("cursor", realtime.last_cursor);
|
|
6134
|
+
}
|
|
6135
|
+
url.searchParams.set("client_id", realtime.client_id);
|
|
6136
|
+
return url;
|
|
6137
|
+
}
|
|
6138
|
+
function parseDriveRealtimeMessage(raw) {
|
|
6139
|
+
let value;
|
|
6140
|
+
try {
|
|
6141
|
+
value = JSON.parse(raw);
|
|
6142
|
+
} catch {
|
|
6143
|
+
return { type: "unknown" };
|
|
6144
|
+
}
|
|
6145
|
+
if (!isRecord3(value)) {
|
|
6146
|
+
return { type: "unknown" };
|
|
6147
|
+
}
|
|
6148
|
+
const messageType = typeof value.type === "string" ? value.type : void 0;
|
|
6149
|
+
const cursor = typeof value.cursor === "string" ? value.cursor : void 0;
|
|
6150
|
+
if (messageType === "ready") {
|
|
6151
|
+
return optionalString({ type: "ready", replayed: typeof value.replayed === "number" ? value.replayed : 0 }, "cursor", cursor);
|
|
6152
|
+
}
|
|
6153
|
+
if (messageType === "library_changed") {
|
|
6154
|
+
return optionalString(optionalString({ type: "library_changed" }, "cursor", cursor), "path", value.path);
|
|
6155
|
+
}
|
|
6156
|
+
if (messageType === "resync_required") {
|
|
6157
|
+
return optionalString(optionalString({ type: "resync_required" }, "cursor", cursor), "reason", value.reason);
|
|
6158
|
+
}
|
|
6159
|
+
if (messageType === "error") {
|
|
6160
|
+
return optionalString(
|
|
6161
|
+
optionalString({ type: "error" }, "code", value.code),
|
|
6162
|
+
"message",
|
|
6163
|
+
typeof value.message === "string" ? redactedRealtimeError(value.message) : void 0
|
|
6164
|
+
);
|
|
6165
|
+
}
|
|
6166
|
+
return optionalString({ type: "unknown" }, "message_type", messageType);
|
|
6167
|
+
}
|
|
6168
|
+
function redactedRealtimeError(error) {
|
|
6169
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
6170
|
+
const status = text.match(/\bHTTP\s+(401|403|429|5\d\d)\b/i);
|
|
6171
|
+
if (status?.[1] !== void 0) {
|
|
6172
|
+
return `HTTP ${status[1]}`;
|
|
6173
|
+
}
|
|
6174
|
+
if (/\bauth|authorization\b/i.test(text)) {
|
|
6175
|
+
return "auth failed";
|
|
6176
|
+
}
|
|
6177
|
+
if (/\bnetwork|fetch|close\b/i.test(text)) {
|
|
6178
|
+
return "network error";
|
|
6179
|
+
}
|
|
6180
|
+
return "realtime error";
|
|
6181
|
+
}
|
|
6182
|
+
function isRecord3(value) {
|
|
6183
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6184
|
+
}
|
|
6185
|
+
function resyncRealtimeState(realtime, cursor, reason, lastEventAt) {
|
|
6186
|
+
if (isInvalidCursorReason(reason)) {
|
|
6187
|
+
const { last_cursor: _lastCursor, ...next } = realtime;
|
|
6188
|
+
return { ...next, last_event_at: lastEventAt };
|
|
6189
|
+
}
|
|
6190
|
+
return optionalString({ ...realtime, last_event_at: lastEventAt }, "last_cursor", cursor);
|
|
6191
|
+
}
|
|
6192
|
+
function isInvalidCursorReason(reason) {
|
|
6193
|
+
return /\bcursor[_ -]?(invalid|expired|gone|missing|not[_ -]?found)\b|\binvalid[_ -]?cursor\b/i.test(reason);
|
|
6194
|
+
}
|
|
6195
|
+
function isRealtimeAuthError(error) {
|
|
6196
|
+
return /\b(401|403|auth|authorization|unauthorized|forbidden)\b/i.test(String(error));
|
|
6197
|
+
}
|
|
6198
|
+
function optionalString(target, key, value) {
|
|
6199
|
+
if (typeof value !== "string") {
|
|
6200
|
+
return target;
|
|
6201
|
+
}
|
|
6202
|
+
return { ...target, [key]: value };
|
|
6203
|
+
}
|
|
6204
|
+
function nativeWebSocketConnector(url, handlers, init) {
|
|
6205
|
+
const WebSocketWithInit = WebSocket;
|
|
6206
|
+
const ws = new WebSocketWithInit(url.toString(), init);
|
|
6207
|
+
let closed = false;
|
|
6208
|
+
let pendingError;
|
|
6209
|
+
const closeOnce = (error) => {
|
|
6210
|
+
if (closed) return;
|
|
6211
|
+
closed = true;
|
|
6212
|
+
handlers.close(error);
|
|
6213
|
+
};
|
|
6214
|
+
ws.addEventListener("open", () => handlers.open());
|
|
6215
|
+
ws.addEventListener("message", (event) => handlers.message(String(event.data)));
|
|
6216
|
+
ws.addEventListener("close", (event) => closeOnce(webSocketCloseError(event) ?? pendingError));
|
|
6217
|
+
ws.addEventListener("error", (event) => {
|
|
6218
|
+
pendingError = webSocketError(event);
|
|
6219
|
+
if (isRealtimeAuthError(pendingError)) {
|
|
6220
|
+
closeOnce(pendingError);
|
|
6221
|
+
}
|
|
6222
|
+
});
|
|
6223
|
+
return { close: () => ws.close() };
|
|
6224
|
+
}
|
|
6225
|
+
function webSocketCloseError(event) {
|
|
6226
|
+
if (event.code === 1e3) return void 0;
|
|
6227
|
+
if (event.code === 4001 || event.code === 4401) return new Error("HTTP 401");
|
|
6228
|
+
if (event.code === 4003 || event.code === 4403) return new Error("HTTP 403");
|
|
6229
|
+
if (event.reason && isRealtimeAuthError(event.reason)) return new Error(event.reason);
|
|
6230
|
+
if (event.reason) return new Error(event.reason);
|
|
6231
|
+
return new Error(`WebSocket close ${event.code}`);
|
|
6232
|
+
}
|
|
6233
|
+
function webSocketError(event) {
|
|
6234
|
+
if (typeof ErrorEvent !== "undefined" && event instanceof ErrorEvent) {
|
|
6235
|
+
const detail = event.error instanceof Error ? event.error.message : event.message;
|
|
6236
|
+
if (detail) return new Error(detail);
|
|
6237
|
+
}
|
|
6238
|
+
return new Error("network error");
|
|
6239
|
+
}
|
|
6240
|
+
|
|
6241
|
+
// src/handwritten/commands/drive/watch.ts
|
|
5890
6242
|
async function runDriveWatch(root, options = {}) {
|
|
5891
6243
|
const runSync = options.runSync ?? runDriveSyncOnce;
|
|
5892
6244
|
const debounceMs = options.debounceMs ?? 500;
|
|
6245
|
+
const remoteDebounceMs = options.remoteDebounceMs ?? 2e3;
|
|
5893
6246
|
const emit = options.onEvent ?? ((event) => render({ kind: "drive_watch", display: { shape: "object" } }, event));
|
|
5894
6247
|
let debounceTimer;
|
|
6248
|
+
let debounceDeadlineMs;
|
|
5895
6249
|
let retryTimer;
|
|
5896
6250
|
let resolveRetryTimer;
|
|
5897
6251
|
let running = false;
|
|
@@ -5919,6 +6273,7 @@ async function runDriveWatch(root, options = {}) {
|
|
|
5919
6273
|
} catch (error) {
|
|
5920
6274
|
if (isAuthError(error) || isFatalWatchError(error) || !isRetryableWatchError(error)) throw error;
|
|
5921
6275
|
emit({ kind: "drive_watch_retry", delay_ms: backoffMs, error: errorMessage2(error) });
|
|
6276
|
+
if (stopped) return;
|
|
5922
6277
|
await waitForManagedTimer(backoffMs);
|
|
5923
6278
|
if (stopped) return;
|
|
5924
6279
|
backoffMs = Math.min(backoffMs * 2, 6e4);
|
|
@@ -5933,6 +6288,27 @@ async function runDriveWatch(root, options = {}) {
|
|
|
5933
6288
|
if (debounceTimer === void 0) return;
|
|
5934
6289
|
clearTimeout(debounceTimer);
|
|
5935
6290
|
debounceTimer = void 0;
|
|
6291
|
+
debounceDeadlineMs = void 0;
|
|
6292
|
+
}
|
|
6293
|
+
function scheduleSync(delayMs) {
|
|
6294
|
+
if (running) {
|
|
6295
|
+
rerunRequested = true;
|
|
6296
|
+
return;
|
|
6297
|
+
}
|
|
6298
|
+
if (delayMs <= 0) {
|
|
6299
|
+
clearDebounceTimer();
|
|
6300
|
+
requestSync().catch(stopWithError);
|
|
6301
|
+
return;
|
|
6302
|
+
}
|
|
6303
|
+
const deadlineMs = Date.now() + delayMs;
|
|
6304
|
+
if (debounceTimer !== void 0 && debounceDeadlineMs !== void 0 && debounceDeadlineMs <= deadlineMs) return;
|
|
6305
|
+
clearDebounceTimer();
|
|
6306
|
+
debounceDeadlineMs = deadlineMs;
|
|
6307
|
+
debounceTimer = setTimeout(() => {
|
|
6308
|
+
debounceTimer = void 0;
|
|
6309
|
+
debounceDeadlineMs = void 0;
|
|
6310
|
+
requestSync().catch(stopWithError);
|
|
6311
|
+
}, delayMs);
|
|
5936
6312
|
}
|
|
5937
6313
|
function clearRetryTimer() {
|
|
5938
6314
|
if (retryTimer === void 0) return;
|
|
@@ -5954,26 +6330,61 @@ async function runDriveWatch(root, options = {}) {
|
|
|
5954
6330
|
}
|
|
5955
6331
|
function stopWithError(error) {
|
|
5956
6332
|
stopError = error;
|
|
6333
|
+
stopped = true;
|
|
5957
6334
|
clearDebounceTimer();
|
|
5958
6335
|
clearRetryTimer();
|
|
5959
6336
|
stopWatch?.();
|
|
5960
6337
|
}
|
|
5961
|
-
|
|
5962
|
-
|
|
6338
|
+
let source;
|
|
6339
|
+
let realtimeSource;
|
|
5963
6340
|
try {
|
|
6341
|
+
let state = await (options.readState ?? readDriveState)(root);
|
|
6342
|
+
realtimeSource = options.once ? void 0 : options.realtimeSource;
|
|
6343
|
+
if (!options.once && realtimeSource === void 0) {
|
|
6344
|
+
state = await ensureDriveRealtimeState(root);
|
|
6345
|
+
const { baseUrl, headers } = await loadRealtimeAuthHeaders({
|
|
6346
|
+
verifyPath: `/drive/libraries/${encodeURIComponent(state.library_id)}`
|
|
6347
|
+
});
|
|
6348
|
+
const realtime = state.realtime;
|
|
6349
|
+
if (realtime === void 0) {
|
|
6350
|
+
throw new Error("drive realtime state is required");
|
|
6351
|
+
}
|
|
6352
|
+
realtimeSource = createDriveRealtimeSource({
|
|
6353
|
+
baseUrl,
|
|
6354
|
+
headers,
|
|
6355
|
+
libraryId: state.library_id,
|
|
6356
|
+
realtime,
|
|
6357
|
+
writeRealtimeState: (next) => writeDriveRealtimeState(root, next)
|
|
6358
|
+
});
|
|
6359
|
+
}
|
|
6360
|
+
source = options.source ?? createChokidarSource(root);
|
|
5964
6361
|
source.onChange((path) => {
|
|
5965
6362
|
if (isDriveInternalPath(root, path)) return;
|
|
5966
|
-
|
|
5967
|
-
rerunRequested = true;
|
|
5968
|
-
return;
|
|
5969
|
-
}
|
|
5970
|
-
clearDebounceTimer();
|
|
5971
|
-
debounceTimer = setTimeout(() => {
|
|
5972
|
-
debounceTimer = void 0;
|
|
5973
|
-
requestSync().catch(stopWithError);
|
|
5974
|
-
}, debounceMs);
|
|
6363
|
+
scheduleSync(debounceMs);
|
|
5975
6364
|
});
|
|
5976
6365
|
emit({ kind: "drive_watch_started", root, library_id: state.library_id });
|
|
6366
|
+
realtimeSource?.start({
|
|
6367
|
+
onConnected() {
|
|
6368
|
+
emit({ kind: "drive_realtime_connected", library_id: state.library_id });
|
|
6369
|
+
},
|
|
6370
|
+
onEvent(event) {
|
|
6371
|
+
emit(realtimeEvent(event));
|
|
6372
|
+
if (event.immediate) {
|
|
6373
|
+
scheduleSync(0);
|
|
6374
|
+
return;
|
|
6375
|
+
}
|
|
6376
|
+
scheduleSync(event.debounce_ms ?? remoteDebounceMs);
|
|
6377
|
+
},
|
|
6378
|
+
onReconnect(delayMs, error) {
|
|
6379
|
+
emit({ kind: "drive_realtime_reconnecting", delay_ms: delayMs, error });
|
|
6380
|
+
},
|
|
6381
|
+
onAuthFailed(error) {
|
|
6382
|
+
emit({ kind: "drive_realtime_auth_failed", error: error ?? "auth failed" });
|
|
6383
|
+
},
|
|
6384
|
+
onWarning(warning) {
|
|
6385
|
+
emit({ kind: "drive_realtime_warning", warning });
|
|
6386
|
+
}
|
|
6387
|
+
}).catch(stopWithError);
|
|
5977
6388
|
await requestSync();
|
|
5978
6389
|
if (options.once) return;
|
|
5979
6390
|
if (stopError !== void 0) throw stopError;
|
|
@@ -5987,7 +6398,7 @@ async function runDriveWatch(root, options = {}) {
|
|
|
5987
6398
|
clearDebounceTimer();
|
|
5988
6399
|
clearRetryTimer();
|
|
5989
6400
|
cleanupSignalListeners();
|
|
5990
|
-
await source
|
|
6401
|
+
await Promise.all([source?.close(), realtimeSource?.close()]);
|
|
5991
6402
|
}
|
|
5992
6403
|
}
|
|
5993
6404
|
function driveWatchCommand(options = {}) {
|
|
@@ -6029,6 +6440,15 @@ function isRetryableWatchError(error) {
|
|
|
6029
6440
|
function errorMessage2(error) {
|
|
6030
6441
|
return error instanceof Error ? error.message : String(error);
|
|
6031
6442
|
}
|
|
6443
|
+
function realtimeEvent(event) {
|
|
6444
|
+
return {
|
|
6445
|
+
kind: "drive_realtime_event",
|
|
6446
|
+
message: "remote update received; syncing",
|
|
6447
|
+
...event.path === void 0 ? {} : { path: event.path },
|
|
6448
|
+
...event.reason === void 0 ? {} : { reason: event.reason },
|
|
6449
|
+
...event.cursor === void 0 ? {} : { cursor: event.cursor }
|
|
6450
|
+
};
|
|
6451
|
+
}
|
|
6032
6452
|
function waitForStopSignal(onRegistered) {
|
|
6033
6453
|
return new Promise((resolveStop) => {
|
|
6034
6454
|
const stop = () => resolveStop();
|