@mulmoclaude/core 1.12.0 → 1.13.0
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/assets/helps/error-recovery.md +98 -0
- package/dist/collection/core/project.d.ts +11 -0
- package/dist/collection/index.cjs +3 -2
- package/dist/collection/index.js +3 -3
- package/dist/collection/registry/server/index.cjs +3 -3
- package/dist/collection/registry/server/index.js +3 -3
- package/dist/collection/server/index.cjs +2 -2
- package/dist/collection/server/index.js +2 -2
- package/dist/collection-watchers/index.cjs +3 -3
- package/dist/collection-watchers/index.js +3 -3
- package/dist/{discovery-B4CZvrXR.js → discovery-D-ZJ6a6z.js} +39 -18
- package/dist/discovery-D-ZJ6a6z.js.map +1 -0
- package/dist/{discovery-NRy3tyUA.cjs → discovery-DAoMnBYM.cjs} +39 -18
- package/dist/discovery-DAoMnBYM.cjs.map +1 -0
- package/dist/{dist-pWpC-b04.cjs → dist-21pMsI1a.cjs} +45 -1
- package/dist/{dist-pWpC-b04.cjs.map → dist-21pMsI1a.cjs.map} +1 -1
- package/dist/{dist-Cwk0e12G.js → dist-DP1FTGnO.js} +16 -2
- package/dist/{dist-Cwk0e12G.js.map → dist-DP1FTGnO.js.map} +1 -1
- package/dist/feeds/server/engine.d.ts +27 -0
- package/dist/feeds/server/index.cjs +72 -7
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.d.ts +1 -1
- package/dist/feeds/server/index.js +71 -8
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/google/calendarStateLock.d.ts +23 -0
- package/dist/google/collectionProjection.d.ts +0 -8
- package/dist/google/collectionSync.d.ts +80 -10
- package/dist/google/index.cjs +310 -67
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +4 -2
- package/dist/google/index.js +304 -69
- package/dist/google/index.js.map +1 -1
- package/dist/notifier/index.cjs +1 -1
- package/dist/notifier/index.js +1 -1
- package/dist/notifier/store.d.ts +4 -4
- package/dist/{notifier-tMsAXyXp.cjs → notifier-C3IhYxqh.cjs} +39 -25
- package/dist/notifier-C3IhYxqh.cjs.map +1 -0
- package/dist/{notifier-BdA5qzhe.js → notifier-CMFlw74b.js} +39 -25
- package/dist/notifier-CMFlw74b.js.map +1 -0
- package/dist/project-B9NSyr6L.js +28 -0
- package/dist/project-B9NSyr6L.js.map +1 -0
- package/dist/project-CCtT_fQr.cjs +39 -0
- package/dist/project-CCtT_fQr.cjs.map +1 -0
- package/dist/remote-host/server/index.cjs +1 -1
- package/dist/remote-host/server/index.js +1 -1
- package/dist/remote-view/index.cjs +1 -1
- package/dist/remote-view/index.js +1 -1
- package/dist/scheduler/index.cjs +1 -1
- package/dist/scheduler/index.js +1 -1
- package/dist/{server-7U-3DE2e.cjs → server-B_b0bLSW.cjs} +3 -3
- package/dist/{server-7U-3DE2e.cjs.map → server-B_b0bLSW.cjs.map} +1 -1
- package/dist/{server-DdyIvFhz.js → server-Ci_A3chZ.js} +3 -3
- package/dist/{server-DdyIvFhz.js.map → server-Ci_A3chZ.js.map} +1 -1
- package/dist/utils/index.cjs +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/whisper/index.cjs +1 -1
- package/dist/whisper/index.js +1 -1
- package/dist/wiki/index.cjs +1 -1
- package/dist/wiki/index.js +1 -1
- package/package.json +1 -1
- package/dist/discovery-B4CZvrXR.js.map +0 -1
- package/dist/discovery-NRy3tyUA.cjs.map +0 -1
- package/dist/notifier-BdA5qzhe.js.map +0 -1
- package/dist/notifier-tMsAXyXp.cjs.map +0 -1
- package/dist/project-BWI5w_BT.cjs +0 -17
- package/dist/project-BWI5w_BT.cjs.map +0 -1
- package/dist/project-bU98ycsy.js +0 -12
- package/dist/project-bU98ycsy.js.map +0 -1
package/dist/google/index.cjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_rolldown_runtime = require("../rolldown-runtime-D6vf50IK.cjs");
|
|
3
|
-
const require_dist = require("../dist-
|
|
3
|
+
const require_dist = require("../dist-21pMsI1a.cjs");
|
|
4
4
|
const require_text = require("../text-CaAh9ZSO.cjs");
|
|
5
5
|
const require_atomic = require("../atomic-C_7YpMiM.cjs");
|
|
6
6
|
const require_utils_fetch = require("../utils/fetch.cjs");
|
|
7
7
|
const require_calendarGrid = require("../calendarGrid-CaS9er8i.cjs");
|
|
8
|
-
const
|
|
8
|
+
const require_project = require("../project-CCtT_fQr.cjs");
|
|
9
|
+
const require_discovery = require("../discovery-DAoMnBYM.cjs");
|
|
9
10
|
let node_fs = require("node:fs");
|
|
10
11
|
let node_path = require("node:path");
|
|
11
12
|
node_path = require_rolldown_runtime.__toESM(node_path, 1);
|
|
@@ -838,6 +839,161 @@ async function getCalendarColors(accessToken) {
|
|
|
838
839
|
};
|
|
839
840
|
}
|
|
840
841
|
//#endregion
|
|
842
|
+
//#region src/google/calendarStateLock.ts
|
|
843
|
+
var LOCK_MODE = 384;
|
|
844
|
+
/** How long a lock may sit untouched before another process may reclaim it.
|
|
845
|
+
*
|
|
846
|
+
* Sized against the HOLD time, which is one read plus one atomic write — a few
|
|
847
|
+
* milliseconds. Generous by three orders of magnitude so a slow disk never
|
|
848
|
+
* looks like a crash, while a host that died mid-write blocks the others for
|
|
849
|
+
* at most this long. */
|
|
850
|
+
var LOCK_STALE_MS = 1e4;
|
|
851
|
+
/** How long a caller waits before giving up and proceeding unlocked. */
|
|
852
|
+
var LOCK_WAIT_MS = 5e3;
|
|
853
|
+
var LOCK_RETRY_MS = 20;
|
|
854
|
+
var liveLockClock = {
|
|
855
|
+
now: () => Date.now(),
|
|
856
|
+
sleep: (delay_ms) => new Promise((resolve) => setTimeout(resolve, delay_ms))
|
|
857
|
+
};
|
|
858
|
+
var isEexist = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
|
|
859
|
+
/** The file's bytes, or null when it is not there. */
|
|
860
|
+
async function readLockRaw(lockPath) {
|
|
861
|
+
try {
|
|
862
|
+
return await (0, node_fs_promises.readFile)(lockPath, "utf-8");
|
|
863
|
+
} catch {
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
/** Absent means unheld; UNREADABLE does not — see `stealIfStale`. */
|
|
868
|
+
function parseLock(raw) {
|
|
869
|
+
try {
|
|
870
|
+
const parsed = JSON.parse(raw);
|
|
871
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
872
|
+
const { holder } = parsed;
|
|
873
|
+
return typeof holder === "string" ? { holder } : null;
|
|
874
|
+
} catch {
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
async function readLock(lockPath) {
|
|
879
|
+
const raw = await readLockRaw(lockPath);
|
|
880
|
+
return raw === null ? null : parseLock(raw);
|
|
881
|
+
}
|
|
882
|
+
/** Take the lock, or answer false because someone else holds a live one. */
|
|
883
|
+
async function tryAcquire(lockPath, holder) {
|
|
884
|
+
try {
|
|
885
|
+
const handle = await (0, node_fs_promises.open)(lockPath, "wx", LOCK_MODE);
|
|
886
|
+
try {
|
|
887
|
+
await handle.writeFile(JSON.stringify({ holder }));
|
|
888
|
+
} finally {
|
|
889
|
+
await handle.close();
|
|
890
|
+
}
|
|
891
|
+
return true;
|
|
892
|
+
} catch (error) {
|
|
893
|
+
if (!isEexist(error)) throw error;
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/** How long this lock has existed, per the filesystem that holds it.
|
|
898
|
+
*
|
|
899
|
+
* From mtime rather than anything the holder wrote down, and for every lock
|
|
900
|
+
* rather than only the unreadable ones. A timestamp written by one host and
|
|
901
|
+
* compared against another host's clock is two wall clocks pretending to be
|
|
902
|
+
* one: skew either way reclaims a live lock or immortalises a dead one (Codex
|
|
903
|
+
* review #2690). Every host reading this workspace sees the SAME mtime.
|
|
904
|
+
*
|
|
905
|
+
* This still assumes the reader's clock and the filesystem's are within a TTL
|
|
906
|
+
* of each other — true by construction on a local disk, and a sub-second NTP
|
|
907
|
+
* question on a network mount, against a window of ten seconds.
|
|
908
|
+
*
|
|
909
|
+
* Reading mtime also covers the moment `open("wx")` has created the file but
|
|
910
|
+
* its payload has not landed yet: "unreadable" is a state every healthy lock
|
|
911
|
+
* passes through, and one being born reads as newborn rather than dead.
|
|
912
|
+
*
|
|
913
|
+
* Null when the file is gone, which is not an age but an absence. */
|
|
914
|
+
async function lockAgeMs(lockPath, clock) {
|
|
915
|
+
const stamped = await (0, node_fs_promises.stat)(lockPath).catch(() => null);
|
|
916
|
+
return stamped === null ? null : clock.now() - stamped.mtimeMs;
|
|
917
|
+
}
|
|
918
|
+
/** Drop a lock that has sat untouched past the TTL, whatever it says.
|
|
919
|
+
*
|
|
920
|
+
* Readable or not, nothing is reclaimed until it is demonstrably stale. For a
|
|
921
|
+
* readable one the holder is re-read and compared before the unlink, so a
|
|
922
|
+
* holder that renewed in between is not evicted. That narrows the window
|
|
923
|
+
* rather than closing it — two processes can still both decide to clear the
|
|
924
|
+
* same corpse. The cost of losing that race is one lost update, which is what
|
|
925
|
+
* this file reduces rather than a new failure, and reaching it takes a host
|
|
926
|
+
* dying mid-write plus a full TTL of waiting. */
|
|
927
|
+
async function stealIfStale(lockPath, clock) {
|
|
928
|
+
const raw = await readLockRaw(lockPath);
|
|
929
|
+
if (raw === null) return;
|
|
930
|
+
const held = parseLock(raw);
|
|
931
|
+
const age_ms = await lockAgeMs(lockPath, clock);
|
|
932
|
+
if (age_ms === null || age_ms < LOCK_STALE_MS) return;
|
|
933
|
+
if (held === null) {
|
|
934
|
+
log.warn("google", "reclaiming an unreadable calendar state lock", {
|
|
935
|
+
lockPath,
|
|
936
|
+
age_ms
|
|
937
|
+
});
|
|
938
|
+
await (0, node_fs_promises.unlink)(lockPath).catch(() => void 0);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if ((await readLock(lockPath))?.holder !== held.holder) return;
|
|
942
|
+
log.warn("google", "reclaiming a calendar state lock whose holder went quiet", {
|
|
943
|
+
lockPath,
|
|
944
|
+
age_ms
|
|
945
|
+
});
|
|
946
|
+
await (0, node_fs_promises.unlink)(lockPath).catch(() => void 0);
|
|
947
|
+
}
|
|
948
|
+
async function release(lockPath, holder) {
|
|
949
|
+
if ((await readLock(lockPath))?.holder !== holder) return;
|
|
950
|
+
await (0, node_fs_promises.unlink)(lockPath).catch(() => void 0);
|
|
951
|
+
}
|
|
952
|
+
/** Run `mutate` with no other process mutating the same file.
|
|
953
|
+
*
|
|
954
|
+
* Fails OPEN. A workspace that cannot create the lock file — a read-only
|
|
955
|
+
* mount, a missing directory, an exhausted disk — still syncs, the way it did
|
|
956
|
+
* before #2679; the lock removes a race, it is not a precondition for
|
|
957
|
+
* correctness. The same applies to waiting the timeout out: proceeding
|
|
958
|
+
* unlocked risks the lost update this exists to prevent, whereas refusing
|
|
959
|
+
* would stop the sync outright.
|
|
960
|
+
*
|
|
961
|
+
* Note what this cannot promise. `O_EXCL` is atomic on a local filesystem and
|
|
962
|
+
* on NFSv3+, but a workspace living in a consumer sync folder (Dropbox,
|
|
963
|
+
* iCloud, Drive) has no such guarantee — there the file is replicated after
|
|
964
|
+
* the fact and both hosts believe they hold it. */
|
|
965
|
+
async function withCalendarStateLock(lockPath, mutate, clock = liveLockClock) {
|
|
966
|
+
const holder = (0, node_crypto.randomUUID)();
|
|
967
|
+
const deadline = clock.now() + LOCK_WAIT_MS;
|
|
968
|
+
let holding = false;
|
|
969
|
+
try {
|
|
970
|
+
holding = await acquireBefore(lockPath, holder, deadline, clock);
|
|
971
|
+
if (!holding) log.warn("google", "proceeding without the calendar state lock — another host is holding it", { lockPath });
|
|
972
|
+
return await mutate();
|
|
973
|
+
} finally {
|
|
974
|
+
if (holding) await release(lockPath, holder);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/** Poll until the lock is ours or the deadline passes. */
|
|
978
|
+
async function acquireBefore(lockPath, holder, deadline, clock) {
|
|
979
|
+
for (;;) {
|
|
980
|
+
try {
|
|
981
|
+
if (await tryAcquire(lockPath, holder)) return true;
|
|
982
|
+
await stealIfStale(lockPath, clock);
|
|
983
|
+
} catch (error) {
|
|
984
|
+
log.warn("google", "could not take the calendar state lock — mutating without it", {
|
|
985
|
+
lockPath,
|
|
986
|
+
error: String(error)
|
|
987
|
+
});
|
|
988
|
+
return false;
|
|
989
|
+
}
|
|
990
|
+
if (clock.now() >= deadline) return false;
|
|
991
|
+
await clock.sleep(LOCK_RETRY_MS);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/** `<state file>.lock`, so each state file is serialised on its own. */
|
|
995
|
+
var stateLockPath = (statePath) => `${statePath}.lock`;
|
|
996
|
+
//#endregion
|
|
841
997
|
//#region src/google/calendarSyncStore.ts
|
|
842
998
|
var SYNC_STATE_MODE = 384;
|
|
843
999
|
/** `<workspace>/data/calendar/.sync-state.json` */
|
|
@@ -865,11 +1021,12 @@ var writeQueue$1 = { tail: Promise.resolve() };
|
|
|
865
1021
|
* nothing in this process can read between the two. A `null` state skips the
|
|
866
1022
|
* write; `result` is what the caller learns about the decision. */
|
|
867
1023
|
async function updateStateWith(decide, workspaceRoot) {
|
|
868
|
-
const
|
|
1024
|
+
const statePath = calendarSyncStatePath(workspaceRoot);
|
|
1025
|
+
const run = writeQueue$1.tail.then(async () => await withCalendarStateLock(stateLockPath(statePath), async () => {
|
|
869
1026
|
const { state, result } = decide(await readState$1(workspaceRoot));
|
|
870
|
-
if (state) await writeJsonAtomicWithMode(
|
|
1027
|
+
if (state) await writeJsonAtomicWithMode(statePath, state, SYNC_STATE_MODE);
|
|
871
1028
|
return result;
|
|
872
|
-
});
|
|
1029
|
+
}));
|
|
873
1030
|
writeQueue$1.tail = run.catch(() => void 0);
|
|
874
1031
|
return await run;
|
|
875
1032
|
}
|
|
@@ -989,10 +1146,11 @@ async function readState(workspaceRoot) {
|
|
|
989
1146
|
}
|
|
990
1147
|
var writeQueue = { tail: Promise.resolve() };
|
|
991
1148
|
async function updateState(mutate, workspaceRoot) {
|
|
992
|
-
const
|
|
1149
|
+
const statePath = calendarPushStatePath(workspaceRoot);
|
|
1150
|
+
const run = writeQueue.tail.then(async () => await withCalendarStateLock(stateLockPath(statePath), async () => {
|
|
993
1151
|
const state = await readState(workspaceRoot);
|
|
994
|
-
await writeJsonAtomicWithMode(
|
|
995
|
-
});
|
|
1152
|
+
await writeJsonAtomicWithMode(statePath, { events: mutate(state.events) }, PUSH_STATE_MODE);
|
|
1153
|
+
}));
|
|
996
1154
|
writeQueue.tail = run.catch(() => void 0);
|
|
997
1155
|
return await run;
|
|
998
1156
|
}
|
|
@@ -1197,19 +1355,6 @@ function toCollectionRecord(event, map, primaryKey, fields) {
|
|
|
1197
1355
|
[primaryKey]: event.id
|
|
1198
1356
|
};
|
|
1199
1357
|
}
|
|
1200
|
-
/** Google's mapped values laid over whatever the record already holds.
|
|
1201
|
-
*
|
|
1202
|
-
* A record file is written whole (`writeItem`), so projecting alone would drop
|
|
1203
|
-
* every column the map does not name — a collection cannot then carry a local
|
|
1204
|
-
* note next to a mirrored event, because the next pull that touches that event
|
|
1205
|
-
* silently deletes it. The projection still wins on the fields it covers: those
|
|
1206
|
-
* are Google's to own. */
|
|
1207
|
-
function mergeIntoExisting(existing, projected) {
|
|
1208
|
-
return {
|
|
1209
|
-
...existing ?? {},
|
|
1210
|
-
...projected
|
|
1211
|
-
};
|
|
1212
|
-
}
|
|
1213
1358
|
//#endregion
|
|
1214
1359
|
//#region src/google/pushPlan.ts
|
|
1215
1360
|
/** The event fields Google lets a caller write. `htmlLink` and `status` are
|
|
@@ -1487,15 +1632,23 @@ function buildPatch(ctx, record, changed, shadow) {
|
|
|
1487
1632
|
}
|
|
1488
1633
|
};
|
|
1489
1634
|
}
|
|
1635
|
+
/** Google has dropped the event this record mirrors.
|
|
1636
|
+
*
|
|
1637
|
+
* The advice used to be "sync first, then re-create it", which sent people
|
|
1638
|
+
* nowhere: Google never resends a cancellation, so a sync sees nothing to do —
|
|
1639
|
+
* and before #2688 the sync that DID see it deleted the record along with the
|
|
1640
|
+
* edit. Deliberately silent on whether Google will hand the id back; that is
|
|
1641
|
+
* unverified. */
|
|
1642
|
+
var goneFromGoogle = (eventId) => ({
|
|
1643
|
+
kind: "skipped",
|
|
1644
|
+
message: `${eventId}: Google no longer has this event, and your local edit was kept. Delete this record, or copy it into a new one.`
|
|
1645
|
+
});
|
|
1490
1646
|
async function updateFromRecord(ctx, eventId, record, changed, shadow) {
|
|
1491
1647
|
const fetched = await getCalendarEvent(ctx.accessToken, {
|
|
1492
1648
|
calendarId: ctx.calendarId,
|
|
1493
1649
|
eventId
|
|
1494
1650
|
});
|
|
1495
|
-
if (fetched === null || fetched.event.status === "cancelled") return
|
|
1496
|
-
kind: "skipped",
|
|
1497
|
-
message: `${eventId}: the event no longer exists in Google — sync first, then re-create it`
|
|
1498
|
-
};
|
|
1651
|
+
if (fetched === null || fetched.event.status === "cancelled") return goneFromGoogle(eventId);
|
|
1499
1652
|
if (conflictingFields(shadow, toShadowEvent(fetched.event), changed).length > 0) return { kind: "conflict" };
|
|
1500
1653
|
const built = buildPatch(ctx, record, changed, shadow);
|
|
1501
1654
|
if (!built.ok) return {
|
|
@@ -1723,7 +1876,42 @@ function classifyDelete(eventId, kind) {
|
|
|
1723
1876
|
message: `delete ${eventId}: ${kind}`
|
|
1724
1877
|
};
|
|
1725
1878
|
}
|
|
1726
|
-
|
|
1879
|
+
/** Whether this record still says what the workspace last saw Google say.
|
|
1880
|
+
*
|
|
1881
|
+
* Compared against the baseline as it stood when this run STARTED READING, not
|
|
1882
|
+
* as it stands now: a full re-walk clears the baseline before writing a new one
|
|
1883
|
+
* (`restartFullSync`), so reading it live would answer "no baseline" for every
|
|
1884
|
+
* event and protect nothing exactly when the window is widest (#2684).
|
|
1885
|
+
*
|
|
1886
|
+
* A record with no baseline at all is NOT withheld — that is an event this
|
|
1887
|
+
* workspace has never held, so there is no local edit to lose. */
|
|
1888
|
+
function unsentEditGuard(schema, baseline) {
|
|
1889
|
+
const map = pushableMap(schema.googleCalendar?.map ?? {});
|
|
1890
|
+
return (existing, eventId) => {
|
|
1891
|
+
const shadow = baseline[eventId];
|
|
1892
|
+
if (shadow === void 0) return false;
|
|
1893
|
+
return locallyChangedFields(existing, baselineRecord(eventId, shadow, map, schema.primaryKey, schema.fields), map).length > 0;
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
/** What the pull may do to the record behind one event.
|
|
1897
|
+
*
|
|
1898
|
+
* The guard runs BEFORE the status is consulted, and that order is the whole
|
|
1899
|
+
* fix: "is there something local to lose here?" outranks "what did Google do
|
|
1900
|
+
* to it?". Asking about the status first is how a cancellation kept deleting
|
|
1901
|
+
* records that held an edit Google had never seen (#2688), long after the
|
|
1902
|
+
* same guard had been put in front of the overwrite (#2684). */
|
|
1903
|
+
function applyPlanFor(existing, event, hasUnsentEdit) {
|
|
1904
|
+
if (existing !== null && hasUnsentEdit(existing, event.id)) return "withhold";
|
|
1905
|
+
return event.status === "cancelled" ? "delete" : "write";
|
|
1906
|
+
}
|
|
1907
|
+
/** Lay Google's mapped values over whatever the record already holds. Split out
|
|
1908
|
+
* so `applyEvent` reads as read → decide → act rather than carrying the write
|
|
1909
|
+
* itself (CodeRabbit review #2689). */
|
|
1910
|
+
async function writeProjected(write, event, schema, existing) {
|
|
1911
|
+
const record = toCollectionRecord(event, schema.googleCalendar?.map ?? {}, schema.primaryKey, schema.fields);
|
|
1912
|
+
return classifyWrite(event.id, (await write(event.id, require_project.mergeIntoExisting(existing, record))).kind);
|
|
1913
|
+
}
|
|
1914
|
+
async function applyEvent(collection, event, workspaceRoot, hasUnsentEdit) {
|
|
1727
1915
|
const { schema } = collection;
|
|
1728
1916
|
try {
|
|
1729
1917
|
const store = require_discovery.storeFor(collection, { workspaceRoot });
|
|
@@ -1731,13 +1919,11 @@ async function applyEvent(collection, event, workspaceRoot) {
|
|
|
1731
1919
|
kind: "unwritable",
|
|
1732
1920
|
message: `collection '${collection.slug}' is read-only`
|
|
1733
1921
|
};
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
const written = await store.write(event.id, mergeIntoExisting(await store.read(event.id), record));
|
|
1740
|
-
return classifyWrite(event.id, written.kind);
|
|
1922
|
+
const existing = await store.read(event.id);
|
|
1923
|
+
const plan = applyPlanFor(existing, event, hasUnsentEdit);
|
|
1924
|
+
if (plan === "withhold") return { kind: "withheld" };
|
|
1925
|
+
if (plan === "delete") return classifyDelete(event.id, (await store.delete(event.id)).kind);
|
|
1926
|
+
return await writeProjected(store.write, event, schema, existing);
|
|
1741
1927
|
} catch (error) {
|
|
1742
1928
|
return {
|
|
1743
1929
|
kind: "error",
|
|
@@ -1788,15 +1974,15 @@ var NOTHING_UNPUSHED = /* @__PURE__ */ new Set();
|
|
|
1788
1974
|
* protection — would overwrite the very edits this exists to protect; the read
|
|
1789
1975
|
* that failed is no evidence that the pull's own writes would fail too, so they
|
|
1790
1976
|
* would land (CodeRabbit review #2666). */
|
|
1791
|
-
var PROTECTION_UNKNOWN = "could not work out which records to protect
|
|
1792
|
-
/** What ONE collection's pull must leave alone: only what ITS OWN push
|
|
1977
|
+
var PROTECTION_UNKNOWN = "could not work out which records to protect from the pull";
|
|
1978
|
+
/** What ONE collection's pull must leave alone: only what ITS OWN push did not
|
|
1793
1979
|
* send.
|
|
1794
1980
|
*
|
|
1795
|
-
* Scoped per collection because a calendar can back several of them, and
|
|
1796
|
-
*
|
|
1797
|
-
* group starved a collection that never even declares `autoPush`:
|
|
1798
|
-
*
|
|
1799
|
-
*
|
|
1981
|
+
* Scoped per collection because a calendar can back several of them, and one
|
|
1982
|
+
* collection's unsent edit says nothing about the others. Sharing one set across
|
|
1983
|
+
* the group starved a collection that never even declares `autoPush`: a
|
|
1984
|
+
* neighbour's conflict froze its records — and the sync token still advanced, so
|
|
1985
|
+
* Google never resent them (Codex review #2666). */
|
|
1800
1986
|
var unpushedFor = (unpushed, slug) => {
|
|
1801
1987
|
const protection = unpushed.get(slug);
|
|
1802
1988
|
return protection === void 0 ? NOTHING_UNPUSHED : protection;
|
|
@@ -1821,16 +2007,19 @@ var allUnpushed = (unpushed) => new Set([...unpushed.values()].flatMap((ids) =>
|
|
|
1821
2007
|
* feature exists to prevent: a calendar whose role degrades to reader refuses
|
|
1822
2008
|
* the whole push, yet the pull still runs — reading needs no write access — and
|
|
1823
2009
|
* overwrote every unsent local edit while advancing its baseline past it, so the
|
|
1824
|
-
* next push could not even report the conflict (CodeRabbit review #2666).
|
|
2010
|
+
* next push could not even report the conflict (CodeRabbit review #2666). A
|
|
2011
|
+
* collection that never declares `autoPush` is in that same state on every
|
|
2012
|
+
* single run, which is how the loss reappeared without a failure in sight
|
|
2013
|
+
* (#2683).
|
|
1825
2014
|
*
|
|
1826
2015
|
* Protects the edited records rather than all of them, so an unchanged record
|
|
1827
2016
|
* keeps syncing normally. `null` when even that could not be worked out — the
|
|
1828
2017
|
* caller then refuses to pull the collection at all, because failing open here
|
|
1829
2018
|
* destroys exactly what this protects. */
|
|
1830
|
-
async function protectUnsentEdits(collection, workspaceRoot) {
|
|
2019
|
+
async function protectUnsentEdits(collection, workspaceRoot, deps) {
|
|
1831
2020
|
try {
|
|
1832
|
-
const edited = await
|
|
1833
|
-
if (edited.length > 0) log.warn("google", "protecting local edits
|
|
2021
|
+
const edited = await deps.unsentEdits(collection, workspaceRoot);
|
|
2022
|
+
if (edited.length > 0) log.warn("google", "protecting local edits that have not reached Google", {
|
|
1834
2023
|
slug: collection.slug,
|
|
1835
2024
|
edited
|
|
1836
2025
|
});
|
|
@@ -1843,8 +2032,18 @@ async function protectUnsentEdits(collection, workspaceRoot) {
|
|
|
1843
2032
|
return null;
|
|
1844
2033
|
}
|
|
1845
2034
|
}
|
|
1846
|
-
|
|
1847
|
-
|
|
2035
|
+
var livePullProtectionDeps = {
|
|
2036
|
+
pushNow: pushCollectionNow,
|
|
2037
|
+
unsentEdits: unsentLocalEdits
|
|
2038
|
+
};
|
|
2039
|
+
/** What ONE collection's pull must leave alone, pushing it first if it asked to
|
|
2040
|
+
* be pushed.
|
|
2041
|
+
*
|
|
2042
|
+
* A collection WITHOUT `autoPush` never pushes, so its local edits are unsent by
|
|
2043
|
+
* definition — the same state a failed push leaves behind, and it needs the same
|
|
2044
|
+
* protection. Pulling over them destroys the edit AND advances the baseline past
|
|
2045
|
+
* it, after which no conflict can be detected any more (#2683). "The push did not
|
|
2046
|
+
* run" is the condition that matters here; why it did not run is not.
|
|
1848
2047
|
*
|
|
1849
2048
|
* MUST run inside the calendar lock the caller already holds — hence
|
|
1850
2049
|
* `pushCollectionNow` rather than `pushCalendarForCollection`, which would take
|
|
@@ -1852,27 +2051,31 @@ async function protectUnsentEdits(collection, workspaceRoot) {
|
|
|
1852
2051
|
*
|
|
1853
2052
|
* A failed push must not stop the pull: the pull is what keeps the collection
|
|
1854
2053
|
* fresh, and a revoked write grant is no reason to freeze reading. */
|
|
1855
|
-
async function
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
const outcome = await
|
|
2054
|
+
async function pullProtectionFor(collection, workspaceRoot, deps = livePullProtectionDeps) {
|
|
2055
|
+
if (!collection.schema.googleCalendar?.autoPush) return await protectUnsentEdits(collection, workspaceRoot, deps);
|
|
2056
|
+
try {
|
|
2057
|
+
const outcome = await deps.pushNow(collection, workspaceRoot);
|
|
1859
2058
|
if (outcome.kind === "pushed") {
|
|
1860
2059
|
reportAutoPush(collection.slug, outcome.result);
|
|
1861
|
-
|
|
1862
|
-
continue;
|
|
2060
|
+
return new Set(outcome.result.unpushedIds);
|
|
1863
2061
|
}
|
|
1864
2062
|
log.warn("google", "auto push did not run", {
|
|
1865
2063
|
slug: collection.slug,
|
|
1866
2064
|
reason: outcome.kind
|
|
1867
2065
|
});
|
|
1868
|
-
unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
|
|
1869
2066
|
} catch (error) {
|
|
1870
2067
|
log.warn("google", "auto push failed — pulling anyway", {
|
|
1871
2068
|
slug: collection.slug,
|
|
1872
2069
|
error: String(error)
|
|
1873
2070
|
});
|
|
1874
|
-
unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
|
|
1875
2071
|
}
|
|
2072
|
+
return await protectUnsentEdits(collection, workspaceRoot, deps);
|
|
2073
|
+
}
|
|
2074
|
+
/** Push the `autoPush` collections in this group, and answer with what every
|
|
2075
|
+
* collection's pull must leave alone, keyed by collection. */
|
|
2076
|
+
async function pushAndProtect(collections, workspaceRoot, deps = livePullProtectionDeps) {
|
|
2077
|
+
const unpushed = /* @__PURE__ */ new Map();
|
|
2078
|
+
for (const collection of collections) unpushed.set(collection.slug, await pullProtectionFor(collection, workspaceRoot, deps));
|
|
1876
2079
|
return unpushed;
|
|
1877
2080
|
}
|
|
1878
2081
|
async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
@@ -1949,17 +2152,26 @@ async function releaseCalendarSyncClaim(calendarId, workspaceRoot) {
|
|
|
1949
2152
|
});
|
|
1950
2153
|
}
|
|
1951
2154
|
}
|
|
2155
|
+
/** Every event whose baseline must NOT advance: what the push could not send,
|
|
2156
|
+
* plus what the apply refused to overwrite.
|
|
2157
|
+
*
|
|
2158
|
+
* The two must agree exactly. The apply's refusals only became visible after it
|
|
2159
|
+
* ran, so they join the union here rather than at push time — leaving them out
|
|
2160
|
+
* would advance the baseline past a record the pull deliberately left holding a
|
|
2161
|
+
* local edit, which is the silent overwrite this whole path exists to stop. */
|
|
2162
|
+
var heldBack = (unpushed, results) => /* @__PURE__ */ new Set([...allUnpushed(unpushed), ...results.flatMap((result) => result.withheld)]);
|
|
1952
2163
|
async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
|
|
1953
|
-
const unpushed = await
|
|
2164
|
+
const unpushed = await pushAndProtect(collections, workspaceRoot);
|
|
2165
|
+
const baseline = await loadCalendarShadow(calendarId, workspaceRoot);
|
|
1954
2166
|
const accessToken = await getGoogleAccessToken();
|
|
1955
2167
|
const first = await syncCalendarEvents(accessToken, {
|
|
1956
2168
|
calendarId,
|
|
1957
2169
|
syncToken: await loadCalendarSyncToken(calendarId, workspaceRoot) ?? void 0
|
|
1958
2170
|
});
|
|
1959
2171
|
const result = first.fullResyncRequired ? await restartFullSync(accessToken, calendarId, workspaceRoot) : first;
|
|
1960
|
-
const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed);
|
|
2172
|
+
const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed, baseline);
|
|
1961
2173
|
if (windowFullyLanded(calendarId, results)) {
|
|
1962
|
-
await saveCalendarShadow(calendarId, shadowUpdates(result.events,
|
|
2174
|
+
await saveCalendarShadow(calendarId, shadowUpdates(result.events, heldBack(unpushed, results), baseline), workspaceRoot);
|
|
1963
2175
|
if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
|
|
1964
2176
|
}
|
|
1965
2177
|
return results;
|
|
@@ -1968,7 +2180,7 @@ async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
|
|
|
1968
2180
|
* one's own push protected. A collection whose protection could not be worked
|
|
1969
2181
|
* out is not pulled at all — it reports a retryable error instead, which holds
|
|
1970
2182
|
* the token and the baseline back for the whole group. */
|
|
1971
|
-
async function applyWindowToGroup(collections, events, workspaceRoot, unpushed) {
|
|
2183
|
+
async function applyWindowToGroup(collections, events, workspaceRoot, unpushed, baseline) {
|
|
1972
2184
|
const results = [];
|
|
1973
2185
|
for (const collection of collections) {
|
|
1974
2186
|
const protection = unpushedFor(unpushed, collection.slug);
|
|
@@ -1977,8 +2189,9 @@ async function applyWindowToGroup(collections, events, workspaceRoot, unpushed)
|
|
|
1977
2189
|
written: 0,
|
|
1978
2190
|
removed: 0,
|
|
1979
2191
|
unwritable: [],
|
|
2192
|
+
withheld: [],
|
|
1980
2193
|
errors: [PROTECTION_UNKNOWN]
|
|
1981
|
-
} : await applyEventsToCollection(collection, events, workspaceRoot, protection));
|
|
2194
|
+
} : await applyEventsToCollection(collection, events, workspaceRoot, protection, baseline));
|
|
1982
2195
|
}
|
|
1983
2196
|
return results;
|
|
1984
2197
|
}
|
|
@@ -1995,6 +2208,11 @@ function windowFullyLanded(calendarId, results) {
|
|
|
1995
2208
|
slug: entry.slug,
|
|
1996
2209
|
unwritable: entry.unwritable
|
|
1997
2210
|
}));
|
|
2211
|
+
results.filter((entry) => entry.withheld.length > 0).forEach((entry) => log.warn("google", "leaving calendar records alone — they hold edits Google has not seen", {
|
|
2212
|
+
calendarId,
|
|
2213
|
+
slug: entry.slug,
|
|
2214
|
+
withheld: entry.withheld
|
|
2215
|
+
}));
|
|
1998
2216
|
const failed = results.filter((entry) => entry.errors.length > 0);
|
|
1999
2217
|
failed.forEach((entry) => log.warn("google", "holding back calendar sync token after failed writes", {
|
|
2000
2218
|
calendarId,
|
|
@@ -2011,10 +2229,20 @@ function windowFullyLanded(calendarId, results) {
|
|
|
2011
2229
|
* keeps the local one would make the next push read a plain one-sided edit —
|
|
2012
2230
|
* no conflict to detect any more — and quietly overwrite Google. Held back, the
|
|
2013
2231
|
* baseline stays older than both sides, so the conflict keeps being reported
|
|
2014
|
-
* until someone resolves it (#2620).
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2232
|
+
* until someone resolves it (#2620).
|
|
2233
|
+
*
|
|
2234
|
+
* `held` carries what those events must KEEP. Omitting them is enough on an
|
|
2235
|
+
* incremental run, where the file is merged rather than replaced — but a full
|
|
2236
|
+
* re-walk CLEARS the baseline first (`restartFullSync`), and there omission
|
|
2237
|
+
* drops the entry for good. The next push would then read a conflicted record
|
|
2238
|
+
* as a brand-new create, hit Google's duplicate-id 409 and refuse it, instead
|
|
2239
|
+
* of reporting the conflict it actually is. Re-stating the pre-run value makes
|
|
2240
|
+
* a held-back event behave the same either way (observed during Claude review;
|
|
2241
|
+
* no bot flagged it). */
|
|
2242
|
+
function shadowUpdates(events, unpushed = /* @__PURE__ */ new Set(), held = {}) {
|
|
2243
|
+
const advanced = pullableEvents(events, unpushed).map((event) => [event.id, event.status === "cancelled" ? null : toShadowEvent(event)]);
|
|
2244
|
+
const kept = [...unpushed].flatMap((eventId) => held[eventId] === void 0 ? [] : [[eventId, held[eventId]]]);
|
|
2245
|
+
return Object.fromEntries([...kept, ...advanced]);
|
|
2018
2246
|
}
|
|
2019
2247
|
/** Save the window's token unless every collection that consumed it was deleted
|
|
2020
2248
|
* while the sync was in flight.
|
|
@@ -2047,13 +2275,19 @@ async function pathExists(absPath) {
|
|
|
2047
2275
|
async function anySyncedCollectionSurvives(collections, exists = pathExists) {
|
|
2048
2276
|
return (await Promise.all(collections.map((collection) => exists(collection.skillDir)))).some(Boolean);
|
|
2049
2277
|
}
|
|
2050
|
-
async function applyEventsToCollection(collection, events, workspaceRoot, unpushed) {
|
|
2051
|
-
const
|
|
2052
|
-
|
|
2278
|
+
async function applyEventsToCollection(collection, events, workspaceRoot, unpushed, baseline) {
|
|
2279
|
+
const hasUnsentEdit = unsentEditGuard(collection.schema, baseline);
|
|
2280
|
+
const attempts = [];
|
|
2281
|
+
for (const event of pullableEvents(events, unpushed)) attempts.push({
|
|
2282
|
+
eventId: event.id,
|
|
2283
|
+
outcome: await applyEvent(collection, event, workspaceRoot, hasUnsentEdit)
|
|
2284
|
+
});
|
|
2285
|
+
const outcomes = attempts.map((attempt) => attempt.outcome);
|
|
2053
2286
|
return {
|
|
2054
2287
|
slug: collection.slug,
|
|
2055
2288
|
written: outcomes.filter((outcome) => outcome.kind === "written").length,
|
|
2056
2289
|
removed: outcomes.filter((outcome) => outcome.kind === "removed").length,
|
|
2290
|
+
withheld: attempts.flatMap((attempt) => attempt.outcome.kind === "withheld" ? [attempt.eventId] : []),
|
|
2057
2291
|
unwritable: outcomes.flatMap((outcome) => outcome.kind === "unwritable" ? [outcome.message] : []),
|
|
2058
2292
|
errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
|
|
2059
2293
|
};
|
|
@@ -2156,6 +2390,7 @@ async function runCalendarGroups(groups, workspaceRoot, mayClaim = ALWAYS_CLAIM)
|
|
|
2156
2390
|
written: 0,
|
|
2157
2391
|
removed: 0,
|
|
2158
2392
|
unwritable: [],
|
|
2393
|
+
withheld: [],
|
|
2159
2394
|
errors: [String(error)]
|
|
2160
2395
|
})));
|
|
2161
2396
|
}
|
|
@@ -2459,6 +2694,7 @@ exports.PROTECTION_UNKNOWN = PROTECTION_UNKNOWN;
|
|
|
2459
2694
|
exports.PUSHABLE_SOURCE_FIELDS = PUSHABLE_SOURCE_FIELDS;
|
|
2460
2695
|
exports.allUnpushed = allUnpushed;
|
|
2461
2696
|
exports.anySyncedCollectionSurvives = anySyncedCollectionSurvives;
|
|
2697
|
+
exports.applyPlanFor = applyPlanFor;
|
|
2462
2698
|
exports.assertSafeMimeType = assertSafeMimeType;
|
|
2463
2699
|
exports.authorizeGoogle = authorizeGoogle;
|
|
2464
2700
|
exports.baselineRecord = baselineRecord;
|
|
@@ -2509,6 +2745,7 @@ exports.googleConfigDir = googleConfigDir;
|
|
|
2509
2745
|
exports.googleSecretsDir = googleSecretsDir;
|
|
2510
2746
|
exports.googleTokenPath = googleTokenPath;
|
|
2511
2747
|
exports.groupByCalendar = groupByCalendar;
|
|
2748
|
+
exports.heldBack = heldBack;
|
|
2512
2749
|
exports.isCalendarSyncDue = isCalendarSyncDue;
|
|
2513
2750
|
exports.isClientSettableEventId = isClientSettableEventId;
|
|
2514
2751
|
exports.isDeniedAccessRole = isDeniedAccessRole;
|
|
@@ -2522,6 +2759,7 @@ exports.listCalendars = listCalendars;
|
|
|
2522
2759
|
exports.listDriveFiles = listDriveFiles;
|
|
2523
2760
|
exports.listTaskLists = listTaskLists;
|
|
2524
2761
|
exports.listTasks = listTasks;
|
|
2762
|
+
exports.liveLockClock = liveLockClock;
|
|
2525
2763
|
exports.loadCalendarLastSyncedAt = loadCalendarLastSyncedAt;
|
|
2526
2764
|
exports.loadCalendarShadow = loadCalendarShadow;
|
|
2527
2765
|
exports.loadCalendarSyncToken = loadCalendarSyncToken;
|
|
@@ -2532,12 +2770,14 @@ exports.locallyDeletedIds = locallyDeletedIds;
|
|
|
2532
2770
|
exports.locallyEditedIds = locallyEditedIds;
|
|
2533
2771
|
exports.mayAdoptExisting = mayAdoptExisting;
|
|
2534
2772
|
exports.mergeGoogleTokens = mergeGoogleTokens;
|
|
2535
|
-
exports.mergeIntoExisting = mergeIntoExisting;
|
|
2773
|
+
exports.mergeIntoExisting = require_project.mergeIntoExisting;
|
|
2536
2774
|
exports.mergeShadow = mergeShadow;
|
|
2537
2775
|
exports.orphanedCalendarId = orphanedCalendarId;
|
|
2538
2776
|
exports.pickBoundary = pickBoundary;
|
|
2539
2777
|
exports.planRecord = planRecord;
|
|
2778
|
+
exports.pullProtectionFor = pullProtectionFor;
|
|
2540
2779
|
exports.pullableEvents = pullableEvents;
|
|
2780
|
+
exports.pushAndProtect = pushAndProtect;
|
|
2541
2781
|
exports.pushCalendarForCollection = pushCalendarForCollection;
|
|
2542
2782
|
exports.pushCollectionNow = pushCollectionNow;
|
|
2543
2783
|
exports.pushableMap = pushableMap;
|
|
@@ -2548,6 +2788,7 @@ exports.saveCalendarShadow = saveCalendarShadow;
|
|
|
2548
2788
|
exports.saveCalendarSyncToken = saveCalendarSyncToken;
|
|
2549
2789
|
exports.saveGoogleTokens = saveGoogleTokens;
|
|
2550
2790
|
exports.shadowUpdates = shadowUpdates;
|
|
2791
|
+
exports.stateLockPath = stateLockPath;
|
|
2551
2792
|
exports.syncCalendarEvents = syncCalendarEvents;
|
|
2552
2793
|
exports.syncCalendarForCollection = syncCalendarForCollection;
|
|
2553
2794
|
exports.syncCalendarGroup = syncCalendarGroup;
|
|
@@ -2565,12 +2806,14 @@ exports.toTaskSummary = toTaskSummary;
|
|
|
2565
2806
|
exports.uncompleteTask = uncompleteTask;
|
|
2566
2807
|
exports.unlinkGoogle = unlinkGoogle;
|
|
2567
2808
|
exports.unpushedFor = unpushedFor;
|
|
2809
|
+
exports.unsentEditGuard = unsentEditGuard;
|
|
2568
2810
|
exports.unsentLocalEdits = unsentLocalEdits;
|
|
2569
2811
|
exports.unsyncedGroups = unsyncedGroups;
|
|
2570
2812
|
exports.updateCalendarEvent = updateCalendarEvent;
|
|
2571
2813
|
exports.updateTask = updateTask;
|
|
2572
2814
|
exports.waitForAuthCode = waitForAuthCode;
|
|
2573
2815
|
exports.withCalendarLock = withCalendarLock;
|
|
2816
|
+
exports.withCalendarStateLock = withCalendarStateLock;
|
|
2574
2817
|
exports.withKeyedLock = withKeyedLock;
|
|
2575
2818
|
exports.zoneSuffixOf = zoneSuffixOf;
|
|
2576
2819
|
|