@mulmoclaude/core 1.12.0 → 1.14.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 +110 -1
- package/dist/{calendarGrid-CaS9er8i.cjs → calendarGrid-BQYqOCnF.cjs} +8 -9
- package/dist/{calendarGrid-CaS9er8i.cjs.map → calendarGrid-BQYqOCnF.cjs.map} +1 -1
- package/dist/{calendarGrid-CrGxeCOG.js → calendarGrid-DivcNKkX.js} +8 -9
- package/dist/{calendarGrid-CrGxeCOG.js.map → calendarGrid-DivcNKkX.js.map} +1 -1
- package/dist/collection/core/project.d.ts +11 -0
- package/dist/collection/core/schemaZ.d.ts +4 -4
- package/dist/collection/index.cjs +5 -4
- package/dist/collection/index.js +5 -5
- package/dist/collection/registry/server/index.cjs +3 -3
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +3 -3
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/csvQuery.d.ts +7 -2
- package/dist/collection/server/csvStore.d.ts +2 -1
- package/dist/collection/server/index.cjs +3 -3
- package/dist/collection/server/index.js +3 -3
- package/dist/collection-watchers/index.cjs +10 -25
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +10 -25
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/{discovery-NRy3tyUA.cjs → discovery-CRDx6TS4.cjs} +88 -47
- package/dist/discovery-CRDx6TS4.cjs.map +1 -0
- package/dist/{discovery-B4CZvrXR.js → discovery-D6p4ITNR.js} +88 -47
- package/dist/discovery-D6p4ITNR.js.map +1 -0
- package/dist/{dist-pWpC-b04.cjs → dist-CsgSfWwR.cjs} +66 -1
- package/dist/{dist-pWpC-b04.cjs.map → dist-CsgSfWwR.cjs.map} +1 -1
- package/dist/{dist-Cwk0e12G.js → dist-D8zokgGo.js} +25 -2
- package/dist/{dist-Cwk0e12G.js.map → dist-D8zokgGo.js.map} +1 -1
- package/dist/feeds/index.cjs +2 -2
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/engine.d.ts +27 -0
- package/dist/feeds/server/index.cjs +97 -26
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.d.ts +1 -1
- package/dist/feeds/server/index.js +96 -27
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/google/calendar.d.ts +23 -9
- package/dist/google/calendarStateLock.d.ts +23 -0
- package/dist/google/collectionProjection.d.ts +0 -8
- package/dist/google/collectionPush.d.ts +13 -8
- package/dist/google/collectionSync.d.ts +80 -10
- package/dist/google/index.cjs +366 -98
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +6 -4
- package/dist/google/index.js +357 -99
- package/dist/google/index.js.map +1 -1
- package/dist/{ingestTypes-D1GQdG8e.cjs → ingestTypes-C7IJSqv4.cjs} +2 -2
- package/dist/{ingestTypes-D1GQdG8e.cjs.map → ingestTypes-C7IJSqv4.cjs.map} +1 -1
- package/dist/{ingestTypes-XReG-li7.js → ingestTypes-DmtDLRmt.js} +2 -2
- package/dist/{ingestTypes-XReG-li7.js.map → ingestTypes-DmtDLRmt.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-BdA5qzhe.js → notifier-Dxh8mEFh.js} +39 -25
- package/dist/notifier-Dxh8mEFh.js.map +1 -0
- package/dist/{notifier-tMsAXyXp.cjs → notifier-Wc4M24Bi.cjs} +39 -25
- package/dist/notifier-Wc4M24Bi.cjs.map +1 -0
- package/dist/plugin-vue/index.cjs +4 -2
- package/dist/plugin-vue/index.cjs.map +1 -1
- package/dist/plugin-vue/index.js +4 -2
- package/dist/plugin-vue/index.js.map +1 -1
- package/dist/project-B0j_Vf_Q.js +33 -0
- package/dist/project-B0j_Vf_Q.js.map +1 -0
- package/dist/project-C1ep9pvo.cjs +44 -0
- package/dist/project-C1ep9pvo.cjs.map +1 -0
- package/dist/{promptSafety-FD8cQn3e.cjs → promptSafety-B52GvaJc.cjs} +99 -85
- package/dist/promptSafety-B52GvaJc.cjs.map +1 -0
- package/dist/{promptSafety-C--op_6a.js → promptSafety-DJgaDjM4.js} +99 -85
- package/dist/promptSafety-DJgaDjM4.js.map +1 -0
- package/dist/remote-host/index.cjs +2 -1
- package/dist/remote-host/index.d.ts +10 -0
- package/dist/remote-host/index.js +2 -2
- package/dist/remote-host/server/index.cjs +2 -2
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +2 -2
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/{remote-host-DkDVxNim.cjs → remote-host-CtQjagPt.cjs} +53 -1
- package/dist/remote-host-CtQjagPt.cjs.map +1 -0
- package/dist/remote-host-Dba4lF3Z.js +82 -0
- package/dist/remote-host-Dba4lF3Z.js.map +1 -0
- package/dist/remote-view/index.cjs +6 -4
- package/dist/remote-view/index.cjs.map +1 -1
- package/dist/remote-view/index.js +6 -4
- package/dist/remote-view/index.js.map +1 -1
- package/dist/scheduler/index.cjs +1 -1
- package/dist/scheduler/index.js +1 -1
- package/dist/{server-DdyIvFhz.js → server-D6EUf3BF.js} +46 -27
- package/dist/server-D6EUf3BF.js.map +1 -0
- package/dist/{server-7U-3DE2e.cjs → server-D7_oRbTJ.cjs} +46 -27
- package/dist/server-D7_oRbTJ.cjs.map +1 -0
- package/dist/utils/index.cjs +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/whisper/index.cjs +2 -6
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/whisper/index.js +2 -6
- package/dist/whisper/index.js.map +1 -1
- package/dist/wiki/index.cjs +2 -2
- package/dist/wiki/index.cjs.map +1 -1
- package/dist/wiki/index.js +2 -2
- package/dist/wiki/index.js.map +1 -1
- package/package.json +2 -2
- 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/promptSafety-C--op_6a.js.map +0 -1
- package/dist/promptSafety-FD8cQn3e.cjs.map +0 -1
- package/dist/remote-host-D_BRFHcI.js +0 -36
- package/dist/remote-host-D_BRFHcI.js.map +0 -1
- package/dist/remote-host-DkDVxNim.cjs.map +0 -1
- package/dist/server-7U-3DE2e.cjs.map +0 -1
- package/dist/server-DdyIvFhz.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-CsgSfWwR.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
|
-
const require_calendarGrid = require("../calendarGrid-
|
|
8
|
-
const
|
|
7
|
+
const require_calendarGrid = require("../calendarGrid-BQYqOCnF.cjs");
|
|
8
|
+
const require_project = require("../project-C1ep9pvo.cjs");
|
|
9
|
+
const require_discovery = require("../discovery-CRDx6TS4.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);
|
|
@@ -592,6 +593,7 @@ var CALENDAR_LIST_PAGE_SIZE = 250;
|
|
|
592
593
|
var MAX_CALENDAR_LIST_PAGES = 40;
|
|
593
594
|
var EVENT_SYNC_PAGE_SIZE = 2500;
|
|
594
595
|
var MAX_EVENT_SYNC_PAGES = 200;
|
|
596
|
+
var CALENDAR_META_PAGE_SIZE = 1;
|
|
595
597
|
var HTTP_GONE = 410;
|
|
596
598
|
var EVENT_ABSENT_STATUSES = [404, HTTP_GONE];
|
|
597
599
|
/** An `If-Match` write whose etag no longer matches — someone else changed the
|
|
@@ -803,20 +805,34 @@ async function collectCalendarPages(fetchPage, maxPages = MAX_CALENDAR_LIST_PAGE
|
|
|
803
805
|
}
|
|
804
806
|
return calendars;
|
|
805
807
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
808
|
+
var toCalendarMeta = (value) => {
|
|
809
|
+
const record = asRecord(value);
|
|
810
|
+
return {
|
|
811
|
+
timeZone: stringField(record, "timeZone"),
|
|
812
|
+
accessRole: stringField(record, "accessRole")
|
|
813
|
+
};
|
|
814
|
+
};
|
|
815
|
+
/** One calendar's zone and the caller's role on it, read by id rather than
|
|
816
|
+
* looked up in the user's list.
|
|
811
817
|
*
|
|
812
818
|
* `calendarList` only holds calendars the user has ADDED; a calendar shared
|
|
813
|
-
* with them can be readable and writable by id without appearing there
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
|
|
818
|
-
|
|
819
|
+
* with them can be readable and writable by id without appearing there, and a
|
|
820
|
+
* push still needs its zone to send an offset-less `dateTime`.
|
|
821
|
+
*
|
|
822
|
+
* Deliberately `events.list` and NOT `calendars.get`: the latter takes only
|
|
823
|
+
* full-calendar scopes (`calendar`, `calendar.readonly`, `calendar.calendars*`)
|
|
824
|
+
* and this app asks for none of them, so it answers 403 for every account it
|
|
825
|
+
* links (#2735). The events list carries the same `timeZone` — plus the
|
|
826
|
+
* `accessRole` the calendar resource does not have — under `calendar.events`,
|
|
827
|
+
* which the push already holds because it writes with it. Only the envelope is
|
|
828
|
+
* read; the one event asked for is the smallest page Google will serve. */
|
|
829
|
+
async function getCalendarMeta(accessToken, calendarId) {
|
|
830
|
+
const params = new URLSearchParams({ maxResults: String(CALENDAR_META_PAGE_SIZE) });
|
|
831
|
+
return toCalendarMeta(await googleRequest(CALENDAR_API_LABEL, accessToken, `${eventsUrl(calendarId)}?${params.toString()}`));
|
|
819
832
|
}
|
|
833
|
+
/** The calendars the user has added/subscribed to (primary + secondary +
|
|
834
|
+
* shared), each with its id, name and colour, following pagination. Needs the
|
|
835
|
+
* calendar-list read scope (GOOGLE_CALENDARLIST_SCOPE). */
|
|
820
836
|
async function listCalendars(accessToken) {
|
|
821
837
|
return collectCalendarPages(async (pageToken) => {
|
|
822
838
|
const params = new URLSearchParams({ maxResults: String(CALENDAR_LIST_PAGE_SIZE) });
|
|
@@ -838,6 +854,159 @@ async function getCalendarColors(accessToken) {
|
|
|
838
854
|
};
|
|
839
855
|
}
|
|
840
856
|
//#endregion
|
|
857
|
+
//#region src/google/calendarStateLock.ts
|
|
858
|
+
var LOCK_MODE = 384;
|
|
859
|
+
/** How long a lock may sit untouched before another process may reclaim it.
|
|
860
|
+
*
|
|
861
|
+
* Sized against the HOLD time, which is one read plus one atomic write — a few
|
|
862
|
+
* milliseconds. Generous by three orders of magnitude so a slow disk never
|
|
863
|
+
* looks like a crash, while a host that died mid-write blocks the others for
|
|
864
|
+
* at most this long. */
|
|
865
|
+
var LOCK_STALE_MS = 1e4;
|
|
866
|
+
/** How long a caller waits before giving up and proceeding unlocked. */
|
|
867
|
+
var LOCK_WAIT_MS = 5e3;
|
|
868
|
+
var LOCK_RETRY_MS = 20;
|
|
869
|
+
var liveLockClock = {
|
|
870
|
+
now: () => Date.now(),
|
|
871
|
+
sleep: (delay_ms) => new Promise((resolve) => setTimeout(resolve, delay_ms))
|
|
872
|
+
};
|
|
873
|
+
var isEexist = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
|
|
874
|
+
/** The file's bytes, or null when it is not there. */
|
|
875
|
+
async function readLockRaw(lockPath) {
|
|
876
|
+
try {
|
|
877
|
+
return await (0, node_fs_promises.readFile)(lockPath, "utf-8");
|
|
878
|
+
} catch {
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/** Absent means unheld; UNREADABLE does not — see `stealIfStale`. */
|
|
883
|
+
function parseLock(raw) {
|
|
884
|
+
try {
|
|
885
|
+
const parsed = JSON.parse(raw);
|
|
886
|
+
return require_dist.hasStringProp(parsed, "holder") ? { holder: parsed.holder } : null;
|
|
887
|
+
} catch {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
async function readLock(lockPath) {
|
|
892
|
+
const raw = await readLockRaw(lockPath);
|
|
893
|
+
return raw === null ? null : parseLock(raw);
|
|
894
|
+
}
|
|
895
|
+
/** Take the lock, or answer false because someone else holds a live one. */
|
|
896
|
+
async function tryAcquire(lockPath, holder) {
|
|
897
|
+
try {
|
|
898
|
+
const handle = await (0, node_fs_promises.open)(lockPath, "wx", LOCK_MODE);
|
|
899
|
+
try {
|
|
900
|
+
await handle.writeFile(JSON.stringify({ holder }));
|
|
901
|
+
} finally {
|
|
902
|
+
await handle.close();
|
|
903
|
+
}
|
|
904
|
+
return true;
|
|
905
|
+
} catch (error) {
|
|
906
|
+
if (!isEexist(error)) throw error;
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
/** How long this lock has existed, per the filesystem that holds it.
|
|
911
|
+
*
|
|
912
|
+
* From mtime rather than anything the holder wrote down, and for every lock
|
|
913
|
+
* rather than only the unreadable ones. A timestamp written by one host and
|
|
914
|
+
* compared against another host's clock is two wall clocks pretending to be
|
|
915
|
+
* one: skew either way reclaims a live lock or immortalises a dead one (Codex
|
|
916
|
+
* review #2690). Every host reading this workspace sees the SAME mtime.
|
|
917
|
+
*
|
|
918
|
+
* This still assumes the reader's clock and the filesystem's are within a TTL
|
|
919
|
+
* of each other — true by construction on a local disk, and a sub-second NTP
|
|
920
|
+
* question on a network mount, against a window of ten seconds.
|
|
921
|
+
*
|
|
922
|
+
* Reading mtime also covers the moment `open("wx")` has created the file but
|
|
923
|
+
* its payload has not landed yet: "unreadable" is a state every healthy lock
|
|
924
|
+
* passes through, and one being born reads as newborn rather than dead.
|
|
925
|
+
*
|
|
926
|
+
* Null when the file is gone, which is not an age but an absence. */
|
|
927
|
+
async function lockAgeMs(lockPath, clock) {
|
|
928
|
+
const stamped = await (0, node_fs_promises.stat)(lockPath).catch(() => null);
|
|
929
|
+
return stamped === null ? null : clock.now() - stamped.mtimeMs;
|
|
930
|
+
}
|
|
931
|
+
/** Drop a lock that has sat untouched past the TTL, whatever it says.
|
|
932
|
+
*
|
|
933
|
+
* Readable or not, nothing is reclaimed until it is demonstrably stale. For a
|
|
934
|
+
* readable one the holder is re-read and compared before the unlink, so a
|
|
935
|
+
* holder that renewed in between is not evicted. That narrows the window
|
|
936
|
+
* rather than closing it — two processes can still both decide to clear the
|
|
937
|
+
* same corpse. The cost of losing that race is one lost update, which is what
|
|
938
|
+
* this file reduces rather than a new failure, and reaching it takes a host
|
|
939
|
+
* dying mid-write plus a full TTL of waiting. */
|
|
940
|
+
async function stealIfStale(lockPath, clock) {
|
|
941
|
+
const raw = await readLockRaw(lockPath);
|
|
942
|
+
if (raw === null) return;
|
|
943
|
+
const held = parseLock(raw);
|
|
944
|
+
const age_ms = await lockAgeMs(lockPath, clock);
|
|
945
|
+
if (age_ms === null || age_ms < LOCK_STALE_MS) return;
|
|
946
|
+
if (held === null) {
|
|
947
|
+
log.warn("google", "reclaiming an unreadable calendar state lock", {
|
|
948
|
+
lockPath,
|
|
949
|
+
age_ms
|
|
950
|
+
});
|
|
951
|
+
await (0, node_fs_promises.unlink)(lockPath).catch(() => void 0);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if ((await readLock(lockPath))?.holder !== held.holder) return;
|
|
955
|
+
log.warn("google", "reclaiming a calendar state lock whose holder went quiet", {
|
|
956
|
+
lockPath,
|
|
957
|
+
age_ms
|
|
958
|
+
});
|
|
959
|
+
await (0, node_fs_promises.unlink)(lockPath).catch(() => void 0);
|
|
960
|
+
}
|
|
961
|
+
async function release(lockPath, holder) {
|
|
962
|
+
if ((await readLock(lockPath))?.holder !== holder) return;
|
|
963
|
+
await (0, node_fs_promises.unlink)(lockPath).catch(() => void 0);
|
|
964
|
+
}
|
|
965
|
+
/** Run `mutate` with no other process mutating the same file.
|
|
966
|
+
*
|
|
967
|
+
* Fails OPEN. A workspace that cannot create the lock file — a read-only
|
|
968
|
+
* mount, a missing directory, an exhausted disk — still syncs, the way it did
|
|
969
|
+
* before #2679; the lock removes a race, it is not a precondition for
|
|
970
|
+
* correctness. The same applies to waiting the timeout out: proceeding
|
|
971
|
+
* unlocked risks the lost update this exists to prevent, whereas refusing
|
|
972
|
+
* would stop the sync outright.
|
|
973
|
+
*
|
|
974
|
+
* Note what this cannot promise. `O_EXCL` is atomic on a local filesystem and
|
|
975
|
+
* on NFSv3+, but a workspace living in a consumer sync folder (Dropbox,
|
|
976
|
+
* iCloud, Drive) has no such guarantee — there the file is replicated after
|
|
977
|
+
* the fact and both hosts believe they hold it. */
|
|
978
|
+
async function withCalendarStateLock(lockPath, mutate, clock = liveLockClock) {
|
|
979
|
+
const holder = (0, node_crypto.randomUUID)();
|
|
980
|
+
const deadline = clock.now() + LOCK_WAIT_MS;
|
|
981
|
+
let holding = false;
|
|
982
|
+
try {
|
|
983
|
+
holding = await acquireBefore(lockPath, holder, deadline, clock);
|
|
984
|
+
if (!holding) log.warn("google", "proceeding without the calendar state lock — another host is holding it", { lockPath });
|
|
985
|
+
return await mutate();
|
|
986
|
+
} finally {
|
|
987
|
+
if (holding) await release(lockPath, holder);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
/** Poll until the lock is ours or the deadline passes. */
|
|
991
|
+
async function acquireBefore(lockPath, holder, deadline, clock) {
|
|
992
|
+
for (;;) {
|
|
993
|
+
try {
|
|
994
|
+
if (await tryAcquire(lockPath, holder)) return true;
|
|
995
|
+
await stealIfStale(lockPath, clock);
|
|
996
|
+
} catch (error) {
|
|
997
|
+
log.warn("google", "could not take the calendar state lock — mutating without it", {
|
|
998
|
+
lockPath,
|
|
999
|
+
error: String(error)
|
|
1000
|
+
});
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
if (clock.now() >= deadline) return false;
|
|
1004
|
+
await clock.sleep(LOCK_RETRY_MS);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
/** `<state file>.lock`, so each state file is serialised on its own. */
|
|
1008
|
+
var stateLockPath = (statePath) => `${statePath}.lock`;
|
|
1009
|
+
//#endregion
|
|
841
1010
|
//#region src/google/calendarSyncStore.ts
|
|
842
1011
|
var SYNC_STATE_MODE = 384;
|
|
843
1012
|
/** `<workspace>/data/calendar/.sync-state.json` */
|
|
@@ -865,11 +1034,12 @@ var writeQueue$1 = { tail: Promise.resolve() };
|
|
|
865
1034
|
* nothing in this process can read between the two. A `null` state skips the
|
|
866
1035
|
* write; `result` is what the caller learns about the decision. */
|
|
867
1036
|
async function updateStateWith(decide, workspaceRoot) {
|
|
868
|
-
const
|
|
1037
|
+
const statePath = calendarSyncStatePath(workspaceRoot);
|
|
1038
|
+
const run = writeQueue$1.tail.then(async () => await withCalendarStateLock(stateLockPath(statePath), async () => {
|
|
869
1039
|
const { state, result } = decide(await readState$1(workspaceRoot));
|
|
870
|
-
if (state) await writeJsonAtomicWithMode(
|
|
1040
|
+
if (state) await writeJsonAtomicWithMode(statePath, state, SYNC_STATE_MODE);
|
|
871
1041
|
return result;
|
|
872
|
-
});
|
|
1042
|
+
}));
|
|
873
1043
|
writeQueue$1.tail = run.catch(() => void 0);
|
|
874
1044
|
return await run;
|
|
875
1045
|
}
|
|
@@ -989,10 +1159,11 @@ async function readState(workspaceRoot) {
|
|
|
989
1159
|
}
|
|
990
1160
|
var writeQueue = { tail: Promise.resolve() };
|
|
991
1161
|
async function updateState(mutate, workspaceRoot) {
|
|
992
|
-
const
|
|
1162
|
+
const statePath = calendarPushStatePath(workspaceRoot);
|
|
1163
|
+
const run = writeQueue.tail.then(async () => await withCalendarStateLock(stateLockPath(statePath), async () => {
|
|
993
1164
|
const state = await readState(workspaceRoot);
|
|
994
|
-
await writeJsonAtomicWithMode(
|
|
995
|
-
});
|
|
1165
|
+
await writeJsonAtomicWithMode(statePath, { events: mutate(state.events) }, PUSH_STATE_MODE);
|
|
1166
|
+
}));
|
|
996
1167
|
writeQueue.tail = run.catch(() => void 0);
|
|
997
1168
|
return await run;
|
|
998
1169
|
}
|
|
@@ -1197,19 +1368,6 @@ function toCollectionRecord(event, map, primaryKey, fields) {
|
|
|
1197
1368
|
[primaryKey]: event.id
|
|
1198
1369
|
};
|
|
1199
1370
|
}
|
|
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
1371
|
//#endregion
|
|
1214
1372
|
//#region src/google/pushPlan.ts
|
|
1215
1373
|
/** The event fields Google lets a caller write. `htmlLink` and `status` are
|
|
@@ -1224,7 +1382,7 @@ var PUSHABLE_SOURCE_FIELDS = [
|
|
|
1224
1382
|
"description",
|
|
1225
1383
|
"location"
|
|
1226
1384
|
];
|
|
1227
|
-
var isPushableSource = (source) => PUSHABLE_SOURCE_FIELDS.
|
|
1385
|
+
var isPushableSource = (source) => PUSHABLE_SOURCE_FIELDS.some((field) => field === source);
|
|
1228
1386
|
/** The subset of a schema's `map` a push can act on: collection field → event field. */
|
|
1229
1387
|
function pushableMap(map) {
|
|
1230
1388
|
return Object.fromEntries(Object.entries(map).filter((entry) => isPushableSource(entry[1])));
|
|
@@ -1346,21 +1504,28 @@ function locallyDeletedIds(shadow, presentIds) {
|
|
|
1346
1504
|
var WRITABLE_ACCESS_ROLES = ["owner", "writer"];
|
|
1347
1505
|
/** Whether to refuse the whole push before touching a single event.
|
|
1348
1506
|
*
|
|
1349
|
-
* Only on POSITIVE evidence of a non-writable role.
|
|
1350
|
-
* unknown and must fall through: hard-denying it
|
|
1351
|
-
* outright for a calendar the user can in fact write
|
|
1352
|
-
* Google's own 403 per record if the write really is
|
|
1507
|
+
* Only on POSITIVE evidence of a non-writable role. A calendar whose role
|
|
1508
|
+
* neither source reported is unknown and must fall through: hard-denying it
|
|
1509
|
+
* would block the feature outright for a calendar the user can in fact write
|
|
1510
|
+
* to. Those attempts surface Google's own 403 per record if the write really is
|
|
1511
|
+
* not allowed. */
|
|
1353
1512
|
var isDeniedAccessRole = (accessRole) => accessRole !== null && !WRITABLE_ACCESS_ROLES.includes(accessRole);
|
|
1513
|
+
/** A role Google left blank is UNKNOWN, not a denial. `isDeniedAccessRole`
|
|
1514
|
+
* refuses every role it does not recognise — `""` included — so passing an
|
|
1515
|
+
* unreported one straight through would refuse a calendar Google simply said
|
|
1516
|
+
* nothing about. */
|
|
1517
|
+
var reportedAccessRole = (accessRole) => accessRole || null;
|
|
1354
1518
|
/** Resolve the calendar a schema names, for its writability and its timezone.
|
|
1355
1519
|
*
|
|
1356
|
-
* The list is consulted first because
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1360
|
-
* equals the literal the schema declares.
|
|
1520
|
+
* The list is consulted first because it answers for every added calendar in
|
|
1521
|
+
* one call the push already makes. `"primary"` is matched on the `primary`
|
|
1522
|
+
* flag, not the id — the primary calendar's own id is the account's email
|
|
1523
|
+
* address, which never equals the literal the schema declares.
|
|
1361
1524
|
*
|
|
1362
|
-
* A calendar absent from the list is NOT denied:
|
|
1363
|
-
*
|
|
1525
|
+
* A calendar absent from the list is NOT denied: `calendarList` holds only the
|
|
1526
|
+
* calendars the user has added, so one shared with write access can be missing
|
|
1527
|
+
* from it (Codex review). It is asked about itself instead, which reports the
|
|
1528
|
+
* same zone AND the real role. A 404 there means the calendar is genuinely
|
|
1364
1529
|
* unreachable, and propagates as a `failed` outcome. */
|
|
1365
1530
|
async function liveCalendarMeta(accessToken, calendarId) {
|
|
1366
1531
|
const key = canonicalCalendarId(calendarId);
|
|
@@ -1370,10 +1535,13 @@ async function liveCalendarMeta(accessToken, calendarId) {
|
|
|
1370
1535
|
accessRole: listed.accessRole,
|
|
1371
1536
|
timeZone: listed.timeZone
|
|
1372
1537
|
};
|
|
1373
|
-
const direct = await
|
|
1374
|
-
log.info("google", "calendar is not in the user's list —
|
|
1538
|
+
const direct = await getCalendarMeta(accessToken, calendarId);
|
|
1539
|
+
log.info("google", "calendar is not in the user's list — role read from the calendar itself", {
|
|
1540
|
+
calendarId: key,
|
|
1541
|
+
accessRole: direct.accessRole
|
|
1542
|
+
});
|
|
1375
1543
|
return {
|
|
1376
|
-
accessRole:
|
|
1544
|
+
accessRole: reportedAccessRole(direct.accessRole),
|
|
1377
1545
|
timeZone: direct.timeZone
|
|
1378
1546
|
};
|
|
1379
1547
|
}
|
|
@@ -1487,15 +1655,23 @@ function buildPatch(ctx, record, changed, shadow) {
|
|
|
1487
1655
|
}
|
|
1488
1656
|
};
|
|
1489
1657
|
}
|
|
1658
|
+
/** Google has dropped the event this record mirrors.
|
|
1659
|
+
*
|
|
1660
|
+
* The advice used to be "sync first, then re-create it", which sent people
|
|
1661
|
+
* nowhere: Google never resends a cancellation, so a sync sees nothing to do —
|
|
1662
|
+
* and before #2688 the sync that DID see it deleted the record along with the
|
|
1663
|
+
* edit. Deliberately silent on whether Google will hand the id back; that is
|
|
1664
|
+
* unverified. */
|
|
1665
|
+
var goneFromGoogle = (eventId) => ({
|
|
1666
|
+
kind: "skipped",
|
|
1667
|
+
message: `${eventId}: Google no longer has this event, and your local edit was kept. Delete this record, or copy it into a new one.`
|
|
1668
|
+
});
|
|
1490
1669
|
async function updateFromRecord(ctx, eventId, record, changed, shadow) {
|
|
1491
1670
|
const fetched = await getCalendarEvent(ctx.accessToken, {
|
|
1492
1671
|
calendarId: ctx.calendarId,
|
|
1493
1672
|
eventId
|
|
1494
1673
|
});
|
|
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
|
-
};
|
|
1674
|
+
if (fetched === null || fetched.event.status === "cancelled") return goneFromGoogle(eventId);
|
|
1499
1675
|
if (conflictingFields(shadow, toShadowEvent(fetched.event), changed).length > 0) return { kind: "conflict" };
|
|
1500
1676
|
const built = buildPatch(ctx, record, changed, shadow);
|
|
1501
1677
|
if (!built.ok) return {
|
|
@@ -1550,9 +1726,9 @@ async function createOrAdopt(ctx, eventId, record) {
|
|
|
1550
1726
|
}
|
|
1551
1727
|
/** A 403 on a write is a permissions answer, but `googleApiError` appends the
|
|
1552
1728
|
* "is the API enabled for the Cloud project?" hint to every 403 — true for a
|
|
1553
|
-
* disabled API, misleading here. Reached when the up-front
|
|
1554
|
-
*
|
|
1555
|
-
*
|
|
1729
|
+
* disabled API, misleading here. Reached when the up-front gate let the push
|
|
1730
|
+
* through on an unknown role, which is exactly when the user needs the real
|
|
1731
|
+
* reason. */
|
|
1556
1732
|
function writeFailure(eventId, error) {
|
|
1557
1733
|
if (isGoogleApiError(error) && error.status === 403) return {
|
|
1558
1734
|
kind: "skipped",
|
|
@@ -1723,7 +1899,42 @@ function classifyDelete(eventId, kind) {
|
|
|
1723
1899
|
message: `delete ${eventId}: ${kind}`
|
|
1724
1900
|
};
|
|
1725
1901
|
}
|
|
1726
|
-
|
|
1902
|
+
/** Whether this record still says what the workspace last saw Google say.
|
|
1903
|
+
*
|
|
1904
|
+
* Compared against the baseline as it stood when this run STARTED READING, not
|
|
1905
|
+
* as it stands now: a full re-walk clears the baseline before writing a new one
|
|
1906
|
+
* (`restartFullSync`), so reading it live would answer "no baseline" for every
|
|
1907
|
+
* event and protect nothing exactly when the window is widest (#2684).
|
|
1908
|
+
*
|
|
1909
|
+
* A record with no baseline at all is NOT withheld — that is an event this
|
|
1910
|
+
* workspace has never held, so there is no local edit to lose. */
|
|
1911
|
+
function unsentEditGuard(schema, baseline) {
|
|
1912
|
+
const map = pushableMap(schema.googleCalendar?.map ?? {});
|
|
1913
|
+
return (existing, eventId) => {
|
|
1914
|
+
const shadow = baseline[eventId];
|
|
1915
|
+
if (shadow === void 0) return false;
|
|
1916
|
+
return locallyChangedFields(existing, baselineRecord(eventId, shadow, map, schema.primaryKey, schema.fields), map).length > 0;
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
/** What the pull may do to the record behind one event.
|
|
1920
|
+
*
|
|
1921
|
+
* The guard runs BEFORE the status is consulted, and that order is the whole
|
|
1922
|
+
* fix: "is there something local to lose here?" outranks "what did Google do
|
|
1923
|
+
* to it?". Asking about the status first is how a cancellation kept deleting
|
|
1924
|
+
* records that held an edit Google had never seen (#2688), long after the
|
|
1925
|
+
* same guard had been put in front of the overwrite (#2684). */
|
|
1926
|
+
function applyPlanFor(existing, event, hasUnsentEdit) {
|
|
1927
|
+
if (existing !== null && hasUnsentEdit(existing, event.id)) return "withhold";
|
|
1928
|
+
return event.status === "cancelled" ? "delete" : "write";
|
|
1929
|
+
}
|
|
1930
|
+
/** Lay Google's mapped values over whatever the record already holds. Split out
|
|
1931
|
+
* so `applyEvent` reads as read → decide → act rather than carrying the write
|
|
1932
|
+
* itself (CodeRabbit review #2689). */
|
|
1933
|
+
async function writeProjected(write, event, schema, existing) {
|
|
1934
|
+
const record = toCollectionRecord(event, schema.googleCalendar?.map ?? {}, schema.primaryKey, schema.fields);
|
|
1935
|
+
return classifyWrite(event.id, (await write(event.id, require_project.mergeIntoExisting(existing, record))).kind);
|
|
1936
|
+
}
|
|
1937
|
+
async function applyEvent(collection, event, workspaceRoot, hasUnsentEdit) {
|
|
1727
1938
|
const { schema } = collection;
|
|
1728
1939
|
try {
|
|
1729
1940
|
const store = require_discovery.storeFor(collection, { workspaceRoot });
|
|
@@ -1731,13 +1942,11 @@ async function applyEvent(collection, event, workspaceRoot) {
|
|
|
1731
1942
|
kind: "unwritable",
|
|
1732
1943
|
message: `collection '${collection.slug}' is read-only`
|
|
1733
1944
|
};
|
|
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);
|
|
1945
|
+
const existing = await store.read(event.id);
|
|
1946
|
+
const plan = applyPlanFor(existing, event, hasUnsentEdit);
|
|
1947
|
+
if (plan === "withhold") return { kind: "withheld" };
|
|
1948
|
+
if (plan === "delete") return classifyDelete(event.id, (await store.delete(event.id)).kind);
|
|
1949
|
+
return await writeProjected(store.write, event, schema, existing);
|
|
1741
1950
|
} catch (error) {
|
|
1742
1951
|
return {
|
|
1743
1952
|
kind: "error",
|
|
@@ -1788,15 +1997,15 @@ var NOTHING_UNPUSHED = /* @__PURE__ */ new Set();
|
|
|
1788
1997
|
* protection — would overwrite the very edits this exists to protect; the read
|
|
1789
1998
|
* that failed is no evidence that the pull's own writes would fail too, so they
|
|
1790
1999
|
* 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
|
|
2000
|
+
var PROTECTION_UNKNOWN = "could not work out which records to protect from the pull";
|
|
2001
|
+
/** What ONE collection's pull must leave alone: only what ITS OWN push did not
|
|
1793
2002
|
* send.
|
|
1794
2003
|
*
|
|
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
|
-
*
|
|
2004
|
+
* Scoped per collection because a calendar can back several of them, and one
|
|
2005
|
+
* collection's unsent edit says nothing about the others. Sharing one set across
|
|
2006
|
+
* the group starved a collection that never even declares `autoPush`: a
|
|
2007
|
+
* neighbour's conflict froze its records — and the sync token still advanced, so
|
|
2008
|
+
* Google never resent them (Codex review #2666). */
|
|
1800
2009
|
var unpushedFor = (unpushed, slug) => {
|
|
1801
2010
|
const protection = unpushed.get(slug);
|
|
1802
2011
|
return protection === void 0 ? NOTHING_UNPUSHED : protection;
|
|
@@ -1821,16 +2030,19 @@ var allUnpushed = (unpushed) => new Set([...unpushed.values()].flatMap((ids) =>
|
|
|
1821
2030
|
* feature exists to prevent: a calendar whose role degrades to reader refuses
|
|
1822
2031
|
* the whole push, yet the pull still runs — reading needs no write access — and
|
|
1823
2032
|
* 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).
|
|
2033
|
+
* next push could not even report the conflict (CodeRabbit review #2666). A
|
|
2034
|
+
* collection that never declares `autoPush` is in that same state on every
|
|
2035
|
+
* single run, which is how the loss reappeared without a failure in sight
|
|
2036
|
+
* (#2683).
|
|
1825
2037
|
*
|
|
1826
2038
|
* Protects the edited records rather than all of them, so an unchanged record
|
|
1827
2039
|
* keeps syncing normally. `null` when even that could not be worked out — the
|
|
1828
2040
|
* caller then refuses to pull the collection at all, because failing open here
|
|
1829
2041
|
* destroys exactly what this protects. */
|
|
1830
|
-
async function protectUnsentEdits(collection, workspaceRoot) {
|
|
2042
|
+
async function protectUnsentEdits(collection, workspaceRoot, deps) {
|
|
1831
2043
|
try {
|
|
1832
|
-
const edited = await
|
|
1833
|
-
if (edited.length > 0) log.warn("google", "protecting local edits
|
|
2044
|
+
const edited = await deps.unsentEdits(collection, workspaceRoot);
|
|
2045
|
+
if (edited.length > 0) log.warn("google", "protecting local edits that have not reached Google", {
|
|
1834
2046
|
slug: collection.slug,
|
|
1835
2047
|
edited
|
|
1836
2048
|
});
|
|
@@ -1843,8 +2055,18 @@ async function protectUnsentEdits(collection, workspaceRoot) {
|
|
|
1843
2055
|
return null;
|
|
1844
2056
|
}
|
|
1845
2057
|
}
|
|
1846
|
-
|
|
1847
|
-
|
|
2058
|
+
var livePullProtectionDeps = {
|
|
2059
|
+
pushNow: pushCollectionNow,
|
|
2060
|
+
unsentEdits: unsentLocalEdits
|
|
2061
|
+
};
|
|
2062
|
+
/** What ONE collection's pull must leave alone, pushing it first if it asked to
|
|
2063
|
+
* be pushed.
|
|
2064
|
+
*
|
|
2065
|
+
* A collection WITHOUT `autoPush` never pushes, so its local edits are unsent by
|
|
2066
|
+
* definition — the same state a failed push leaves behind, and it needs the same
|
|
2067
|
+
* protection. Pulling over them destroys the edit AND advances the baseline past
|
|
2068
|
+
* it, after which no conflict can be detected any more (#2683). "The push did not
|
|
2069
|
+
* run" is the condition that matters here; why it did not run is not.
|
|
1848
2070
|
*
|
|
1849
2071
|
* MUST run inside the calendar lock the caller already holds — hence
|
|
1850
2072
|
* `pushCollectionNow` rather than `pushCalendarForCollection`, which would take
|
|
@@ -1852,27 +2074,31 @@ async function protectUnsentEdits(collection, workspaceRoot) {
|
|
|
1852
2074
|
*
|
|
1853
2075
|
* A failed push must not stop the pull: the pull is what keeps the collection
|
|
1854
2076
|
* fresh, and a revoked write grant is no reason to freeze reading. */
|
|
1855
|
-
async function
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
const outcome = await
|
|
2077
|
+
async function pullProtectionFor(collection, workspaceRoot, deps = livePullProtectionDeps) {
|
|
2078
|
+
if (!collection.schema.googleCalendar?.autoPush) return await protectUnsentEdits(collection, workspaceRoot, deps);
|
|
2079
|
+
try {
|
|
2080
|
+
const outcome = await deps.pushNow(collection, workspaceRoot);
|
|
1859
2081
|
if (outcome.kind === "pushed") {
|
|
1860
2082
|
reportAutoPush(collection.slug, outcome.result);
|
|
1861
|
-
|
|
1862
|
-
continue;
|
|
2083
|
+
return new Set(outcome.result.unpushedIds);
|
|
1863
2084
|
}
|
|
1864
2085
|
log.warn("google", "auto push did not run", {
|
|
1865
2086
|
slug: collection.slug,
|
|
1866
2087
|
reason: outcome.kind
|
|
1867
2088
|
});
|
|
1868
|
-
unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
|
|
1869
2089
|
} catch (error) {
|
|
1870
2090
|
log.warn("google", "auto push failed — pulling anyway", {
|
|
1871
2091
|
slug: collection.slug,
|
|
1872
2092
|
error: String(error)
|
|
1873
2093
|
});
|
|
1874
|
-
unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
|
|
1875
2094
|
}
|
|
2095
|
+
return await protectUnsentEdits(collection, workspaceRoot, deps);
|
|
2096
|
+
}
|
|
2097
|
+
/** Push the `autoPush` collections in this group, and answer with what every
|
|
2098
|
+
* collection's pull must leave alone, keyed by collection. */
|
|
2099
|
+
async function pushAndProtect(collections, workspaceRoot, deps = livePullProtectionDeps) {
|
|
2100
|
+
const unpushed = /* @__PURE__ */ new Map();
|
|
2101
|
+
for (const collection of collections) unpushed.set(collection.slug, await pullProtectionFor(collection, workspaceRoot, deps));
|
|
1876
2102
|
return unpushed;
|
|
1877
2103
|
}
|
|
1878
2104
|
async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
@@ -1949,17 +2175,26 @@ async function releaseCalendarSyncClaim(calendarId, workspaceRoot) {
|
|
|
1949
2175
|
});
|
|
1950
2176
|
}
|
|
1951
2177
|
}
|
|
2178
|
+
/** Every event whose baseline must NOT advance: what the push could not send,
|
|
2179
|
+
* plus what the apply refused to overwrite.
|
|
2180
|
+
*
|
|
2181
|
+
* The two must agree exactly. The apply's refusals only became visible after it
|
|
2182
|
+
* ran, so they join the union here rather than at push time — leaving them out
|
|
2183
|
+
* would advance the baseline past a record the pull deliberately left holding a
|
|
2184
|
+
* local edit, which is the silent overwrite this whole path exists to stop. */
|
|
2185
|
+
var heldBack = (unpushed, results) => /* @__PURE__ */ new Set([...allUnpushed(unpushed), ...results.flatMap((result) => result.withheld)]);
|
|
1952
2186
|
async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
|
|
1953
|
-
const unpushed = await
|
|
2187
|
+
const unpushed = await pushAndProtect(collections, workspaceRoot);
|
|
2188
|
+
const baseline = await loadCalendarShadow(calendarId, workspaceRoot);
|
|
1954
2189
|
const accessToken = await getGoogleAccessToken();
|
|
1955
2190
|
const first = await syncCalendarEvents(accessToken, {
|
|
1956
2191
|
calendarId,
|
|
1957
2192
|
syncToken: await loadCalendarSyncToken(calendarId, workspaceRoot) ?? void 0
|
|
1958
2193
|
});
|
|
1959
2194
|
const result = first.fullResyncRequired ? await restartFullSync(accessToken, calendarId, workspaceRoot) : first;
|
|
1960
|
-
const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed);
|
|
2195
|
+
const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed, baseline);
|
|
1961
2196
|
if (windowFullyLanded(calendarId, results)) {
|
|
1962
|
-
await saveCalendarShadow(calendarId, shadowUpdates(result.events,
|
|
2197
|
+
await saveCalendarShadow(calendarId, shadowUpdates(result.events, heldBack(unpushed, results), baseline), workspaceRoot);
|
|
1963
2198
|
if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
|
|
1964
2199
|
}
|
|
1965
2200
|
return results;
|
|
@@ -1968,7 +2203,7 @@ async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
|
|
|
1968
2203
|
* one's own push protected. A collection whose protection could not be worked
|
|
1969
2204
|
* out is not pulled at all — it reports a retryable error instead, which holds
|
|
1970
2205
|
* the token and the baseline back for the whole group. */
|
|
1971
|
-
async function applyWindowToGroup(collections, events, workspaceRoot, unpushed) {
|
|
2206
|
+
async function applyWindowToGroup(collections, events, workspaceRoot, unpushed, baseline) {
|
|
1972
2207
|
const results = [];
|
|
1973
2208
|
for (const collection of collections) {
|
|
1974
2209
|
const protection = unpushedFor(unpushed, collection.slug);
|
|
@@ -1977,8 +2212,9 @@ async function applyWindowToGroup(collections, events, workspaceRoot, unpushed)
|
|
|
1977
2212
|
written: 0,
|
|
1978
2213
|
removed: 0,
|
|
1979
2214
|
unwritable: [],
|
|
2215
|
+
withheld: [],
|
|
1980
2216
|
errors: [PROTECTION_UNKNOWN]
|
|
1981
|
-
} : await applyEventsToCollection(collection, events, workspaceRoot, protection));
|
|
2217
|
+
} : await applyEventsToCollection(collection, events, workspaceRoot, protection, baseline));
|
|
1982
2218
|
}
|
|
1983
2219
|
return results;
|
|
1984
2220
|
}
|
|
@@ -1995,6 +2231,11 @@ function windowFullyLanded(calendarId, results) {
|
|
|
1995
2231
|
slug: entry.slug,
|
|
1996
2232
|
unwritable: entry.unwritable
|
|
1997
2233
|
}));
|
|
2234
|
+
results.filter((entry) => entry.withheld.length > 0).forEach((entry) => log.warn("google", "leaving calendar records alone — they hold edits Google has not seen", {
|
|
2235
|
+
calendarId,
|
|
2236
|
+
slug: entry.slug,
|
|
2237
|
+
withheld: entry.withheld
|
|
2238
|
+
}));
|
|
1998
2239
|
const failed = results.filter((entry) => entry.errors.length > 0);
|
|
1999
2240
|
failed.forEach((entry) => log.warn("google", "holding back calendar sync token after failed writes", {
|
|
2000
2241
|
calendarId,
|
|
@@ -2011,10 +2252,20 @@ function windowFullyLanded(calendarId, results) {
|
|
|
2011
2252
|
* keeps the local one would make the next push read a plain one-sided edit —
|
|
2012
2253
|
* no conflict to detect any more — and quietly overwrite Google. Held back, the
|
|
2013
2254
|
* baseline stays older than both sides, so the conflict keeps being reported
|
|
2014
|
-
* until someone resolves it (#2620).
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2255
|
+
* until someone resolves it (#2620).
|
|
2256
|
+
*
|
|
2257
|
+
* `held` carries what those events must KEEP. Omitting them is enough on an
|
|
2258
|
+
* incremental run, where the file is merged rather than replaced — but a full
|
|
2259
|
+
* re-walk CLEARS the baseline first (`restartFullSync`), and there omission
|
|
2260
|
+
* drops the entry for good. The next push would then read a conflicted record
|
|
2261
|
+
* as a brand-new create, hit Google's duplicate-id 409 and refuse it, instead
|
|
2262
|
+
* of reporting the conflict it actually is. Re-stating the pre-run value makes
|
|
2263
|
+
* a held-back event behave the same either way (observed during Claude review;
|
|
2264
|
+
* no bot flagged it). */
|
|
2265
|
+
function shadowUpdates(events, unpushed = /* @__PURE__ */ new Set(), held = {}) {
|
|
2266
|
+
const advanced = pullableEvents(events, unpushed).map((event) => [event.id, event.status === "cancelled" ? null : toShadowEvent(event)]);
|
|
2267
|
+
const kept = [...unpushed].flatMap((eventId) => held[eventId] === void 0 ? [] : [[eventId, held[eventId]]]);
|
|
2268
|
+
return Object.fromEntries([...kept, ...advanced]);
|
|
2018
2269
|
}
|
|
2019
2270
|
/** Save the window's token unless every collection that consumed it was deleted
|
|
2020
2271
|
* while the sync was in flight.
|
|
@@ -2047,13 +2298,19 @@ async function pathExists(absPath) {
|
|
|
2047
2298
|
async function anySyncedCollectionSurvives(collections, exists = pathExists) {
|
|
2048
2299
|
return (await Promise.all(collections.map((collection) => exists(collection.skillDir)))).some(Boolean);
|
|
2049
2300
|
}
|
|
2050
|
-
async function applyEventsToCollection(collection, events, workspaceRoot, unpushed) {
|
|
2051
|
-
const
|
|
2052
|
-
|
|
2301
|
+
async function applyEventsToCollection(collection, events, workspaceRoot, unpushed, baseline) {
|
|
2302
|
+
const hasUnsentEdit = unsentEditGuard(collection.schema, baseline);
|
|
2303
|
+
const attempts = [];
|
|
2304
|
+
for (const event of pullableEvents(events, unpushed)) attempts.push({
|
|
2305
|
+
eventId: event.id,
|
|
2306
|
+
outcome: await applyEvent(collection, event, workspaceRoot, hasUnsentEdit)
|
|
2307
|
+
});
|
|
2308
|
+
const outcomes = attempts.map((attempt) => attempt.outcome);
|
|
2053
2309
|
return {
|
|
2054
2310
|
slug: collection.slug,
|
|
2055
2311
|
written: outcomes.filter((outcome) => outcome.kind === "written").length,
|
|
2056
2312
|
removed: outcomes.filter((outcome) => outcome.kind === "removed").length,
|
|
2313
|
+
withheld: attempts.flatMap((attempt) => attempt.outcome.kind === "withheld" ? [attempt.eventId] : []),
|
|
2057
2314
|
unwritable: outcomes.flatMap((outcome) => outcome.kind === "unwritable" ? [outcome.message] : []),
|
|
2058
2315
|
errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
|
|
2059
2316
|
};
|
|
@@ -2156,6 +2413,7 @@ async function runCalendarGroups(groups, workspaceRoot, mayClaim = ALWAYS_CLAIM)
|
|
|
2156
2413
|
written: 0,
|
|
2157
2414
|
removed: 0,
|
|
2158
2415
|
unwritable: [],
|
|
2416
|
+
withheld: [],
|
|
2159
2417
|
errors: [String(error)]
|
|
2160
2418
|
})));
|
|
2161
2419
|
}
|
|
@@ -2459,6 +2717,7 @@ exports.PROTECTION_UNKNOWN = PROTECTION_UNKNOWN;
|
|
|
2459
2717
|
exports.PUSHABLE_SOURCE_FIELDS = PUSHABLE_SOURCE_FIELDS;
|
|
2460
2718
|
exports.allUnpushed = allUnpushed;
|
|
2461
2719
|
exports.anySyncedCollectionSurvives = anySyncedCollectionSurvives;
|
|
2720
|
+
exports.applyPlanFor = applyPlanFor;
|
|
2462
2721
|
exports.assertSafeMimeType = assertSafeMimeType;
|
|
2463
2722
|
exports.authorizeGoogle = authorizeGoogle;
|
|
2464
2723
|
exports.baselineRecord = baselineRecord;
|
|
@@ -2498,9 +2757,9 @@ exports.deleteGoogleTokens = deleteGoogleTokens;
|
|
|
2498
2757
|
exports.deleteTask = deleteTask;
|
|
2499
2758
|
exports.fieldText = fieldText;
|
|
2500
2759
|
exports.findClientSecretPath = findClientSecretPath;
|
|
2501
|
-
exports.getCalendar = getCalendar;
|
|
2502
2760
|
exports.getCalendarColors = getCalendarColors;
|
|
2503
2761
|
exports.getCalendarEvent = getCalendarEvent;
|
|
2762
|
+
exports.getCalendarMeta = getCalendarMeta;
|
|
2504
2763
|
exports.getGoogleAccessToken = getGoogleAccessToken;
|
|
2505
2764
|
exports.googleApiError = googleApiError;
|
|
2506
2765
|
exports.googleAuthFlow = googleAuthFlow;
|
|
@@ -2509,6 +2768,7 @@ exports.googleConfigDir = googleConfigDir;
|
|
|
2509
2768
|
exports.googleSecretsDir = googleSecretsDir;
|
|
2510
2769
|
exports.googleTokenPath = googleTokenPath;
|
|
2511
2770
|
exports.groupByCalendar = groupByCalendar;
|
|
2771
|
+
exports.heldBack = heldBack;
|
|
2512
2772
|
exports.isCalendarSyncDue = isCalendarSyncDue;
|
|
2513
2773
|
exports.isClientSettableEventId = isClientSettableEventId;
|
|
2514
2774
|
exports.isDeniedAccessRole = isDeniedAccessRole;
|
|
@@ -2522,6 +2782,7 @@ exports.listCalendars = listCalendars;
|
|
|
2522
2782
|
exports.listDriveFiles = listDriveFiles;
|
|
2523
2783
|
exports.listTaskLists = listTaskLists;
|
|
2524
2784
|
exports.listTasks = listTasks;
|
|
2785
|
+
exports.liveLockClock = liveLockClock;
|
|
2525
2786
|
exports.loadCalendarLastSyncedAt = loadCalendarLastSyncedAt;
|
|
2526
2787
|
exports.loadCalendarShadow = loadCalendarShadow;
|
|
2527
2788
|
exports.loadCalendarSyncToken = loadCalendarSyncToken;
|
|
@@ -2532,27 +2793,32 @@ exports.locallyDeletedIds = locallyDeletedIds;
|
|
|
2532
2793
|
exports.locallyEditedIds = locallyEditedIds;
|
|
2533
2794
|
exports.mayAdoptExisting = mayAdoptExisting;
|
|
2534
2795
|
exports.mergeGoogleTokens = mergeGoogleTokens;
|
|
2535
|
-
exports.mergeIntoExisting = mergeIntoExisting;
|
|
2796
|
+
exports.mergeIntoExisting = require_project.mergeIntoExisting;
|
|
2536
2797
|
exports.mergeShadow = mergeShadow;
|
|
2537
2798
|
exports.orphanedCalendarId = orphanedCalendarId;
|
|
2538
2799
|
exports.pickBoundary = pickBoundary;
|
|
2539
2800
|
exports.planRecord = planRecord;
|
|
2801
|
+
exports.pullProtectionFor = pullProtectionFor;
|
|
2540
2802
|
exports.pullableEvents = pullableEvents;
|
|
2803
|
+
exports.pushAndProtect = pushAndProtect;
|
|
2541
2804
|
exports.pushCalendarForCollection = pushCalendarForCollection;
|
|
2542
2805
|
exports.pushCollectionNow = pushCollectionNow;
|
|
2543
2806
|
exports.pushableMap = pushableMap;
|
|
2544
2807
|
exports.readDriveFile = readDriveFile;
|
|
2545
2808
|
exports.releaseOrphanedCalendarToken = releaseOrphanedCalendarToken;
|
|
2809
|
+
exports.reportedAccessRole = reportedAccessRole;
|
|
2546
2810
|
exports.resolveEventSpan = resolveEventSpan;
|
|
2547
2811
|
exports.saveCalendarShadow = saveCalendarShadow;
|
|
2548
2812
|
exports.saveCalendarSyncToken = saveCalendarSyncToken;
|
|
2549
2813
|
exports.saveGoogleTokens = saveGoogleTokens;
|
|
2550
2814
|
exports.shadowUpdates = shadowUpdates;
|
|
2815
|
+
exports.stateLockPath = stateLockPath;
|
|
2551
2816
|
exports.syncCalendarEvents = syncCalendarEvents;
|
|
2552
2817
|
exports.syncCalendarForCollection = syncCalendarForCollection;
|
|
2553
2818
|
exports.syncCalendarGroup = syncCalendarGroup;
|
|
2554
2819
|
exports.syncDueCalendarCollections = syncDueCalendarCollections;
|
|
2555
2820
|
exports.syncNewCalendarCollections = syncNewCalendarCollections;
|
|
2821
|
+
exports.toCalendarMeta = toCalendarMeta;
|
|
2556
2822
|
exports.toCalendarSummary = toCalendarSummary;
|
|
2557
2823
|
exports.toCollectionDateTime = toCollectionDateTime;
|
|
2558
2824
|
exports.toCollectionRecord = toCollectionRecord;
|
|
@@ -2565,12 +2831,14 @@ exports.toTaskSummary = toTaskSummary;
|
|
|
2565
2831
|
exports.uncompleteTask = uncompleteTask;
|
|
2566
2832
|
exports.unlinkGoogle = unlinkGoogle;
|
|
2567
2833
|
exports.unpushedFor = unpushedFor;
|
|
2834
|
+
exports.unsentEditGuard = unsentEditGuard;
|
|
2568
2835
|
exports.unsentLocalEdits = unsentLocalEdits;
|
|
2569
2836
|
exports.unsyncedGroups = unsyncedGroups;
|
|
2570
2837
|
exports.updateCalendarEvent = updateCalendarEvent;
|
|
2571
2838
|
exports.updateTask = updateTask;
|
|
2572
2839
|
exports.waitForAuthCode = waitForAuthCode;
|
|
2573
2840
|
exports.withCalendarLock = withCalendarLock;
|
|
2841
|
+
exports.withCalendarStateLock = withCalendarStateLock;
|
|
2574
2842
|
exports.withKeyedLock = withKeyedLock;
|
|
2575
2843
|
exports.zoneSuffixOf = zoneSuffixOf;
|
|
2576
2844
|
|