@mulmoclaude/core 1.11.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/calendarSyncDue.d.ts +10 -0
- package/dist/google/calendarSyncStore.d.ts +20 -1
- package/dist/google/collectionProjection.d.ts +0 -8
- package/dist/google/collectionSync.d.ts +104 -15
- package/dist/google/index.cjs +512 -89
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +6 -3
- package/dist/google/index.js +501 -91
- 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` */
|
|
@@ -845,34 +1001,129 @@ function calendarSyncStatePath(workspaceRoot) {
|
|
|
845
1001
|
return node_path.default.join(workspaceRoot ?? require_discovery.getWorkspaceRoot(), "data", "calendar", ".sync-state.json");
|
|
846
1002
|
}
|
|
847
1003
|
var calendarKey = canonicalCalendarId;
|
|
1004
|
+
/** Read one map out of whatever is on disk. Tolerant on purpose: a file written
|
|
1005
|
+
* before `lastSyncedAt` existed simply lacks the key, and a hand-edited one may
|
|
1006
|
+
* hold anything. A dropped entry costs a full re-walk or a duplicate run, never
|
|
1007
|
+
* data — throwing here would stop the sync entirely. */
|
|
1008
|
+
function stringRecord(value) {
|
|
1009
|
+
if (typeof value !== "object" || value === null) return {};
|
|
1010
|
+
return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string"));
|
|
1011
|
+
}
|
|
848
1012
|
async function readState$1(workspaceRoot) {
|
|
849
1013
|
const stored = await readJsonOrNull(calendarSyncStatePath(workspaceRoot));
|
|
850
|
-
return
|
|
1014
|
+
return {
|
|
1015
|
+
tokens: stringRecord(stored?.tokens),
|
|
1016
|
+
lastSyncedAt: stringRecord(stored?.lastSyncedAt)
|
|
1017
|
+
};
|
|
851
1018
|
}
|
|
852
1019
|
var writeQueue$1 = { tail: Promise.resolve() };
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1020
|
+
/** Decide from the stored state and write the result, both inside the queue, so
|
|
1021
|
+
* nothing in this process can read between the two. A `null` state skips the
|
|
1022
|
+
* write; `result` is what the caller learns about the decision. */
|
|
1023
|
+
async function updateStateWith(decide, workspaceRoot) {
|
|
1024
|
+
const statePath = calendarSyncStatePath(workspaceRoot);
|
|
1025
|
+
const run = writeQueue$1.tail.then(async () => await withCalendarStateLock(stateLockPath(statePath), async () => {
|
|
1026
|
+
const { state, result } = decide(await readState$1(workspaceRoot));
|
|
1027
|
+
if (state) await writeJsonAtomicWithMode(statePath, state, SYNC_STATE_MODE);
|
|
1028
|
+
return result;
|
|
1029
|
+
}));
|
|
858
1030
|
writeQueue$1.tail = run.catch(() => void 0);
|
|
859
1031
|
return await run;
|
|
860
1032
|
}
|
|
1033
|
+
async function updateState$1(mutate, workspaceRoot) {
|
|
1034
|
+
await updateStateWith((state) => ({
|
|
1035
|
+
state: mutate(state),
|
|
1036
|
+
result: void 0
|
|
1037
|
+
}), workspaceRoot);
|
|
1038
|
+
}
|
|
1039
|
+
var withoutKey = (record, dropped) => Object.fromEntries(Object.entries(record).filter(([key]) => key !== dropped));
|
|
861
1040
|
async function loadCalendarSyncToken(calendarId, workspaceRoot) {
|
|
862
1041
|
return (await readState$1(workspaceRoot)).tokens[calendarKey(calendarId)] ?? null;
|
|
863
1042
|
}
|
|
864
1043
|
async function saveCalendarSyncToken(calendarId, syncToken, workspaceRoot) {
|
|
865
1044
|
const stored = calendarKey(calendarId);
|
|
866
|
-
await updateState$1((
|
|
867
|
-
...
|
|
868
|
-
|
|
1045
|
+
await updateState$1((state) => ({
|
|
1046
|
+
...state,
|
|
1047
|
+
tokens: {
|
|
1048
|
+
...state.tokens,
|
|
1049
|
+
[stored]: syncToken
|
|
1050
|
+
}
|
|
869
1051
|
}), workspaceRoot);
|
|
870
1052
|
}
|
|
871
1053
|
/** Drop one calendar's token — used when Google answers 410, so the next run
|
|
872
|
-
* starts a clean full sync.
|
|
1054
|
+
* starts a clean full sync. Leaves the sync marker alone: "restart the walk"
|
|
1055
|
+
* says nothing about "nobody is running". */
|
|
873
1056
|
async function clearCalendarSyncToken(calendarId, workspaceRoot) {
|
|
874
1057
|
const dropped = calendarKey(calendarId);
|
|
875
|
-
await updateState$1((
|
|
1058
|
+
await updateState$1((state) => ({
|
|
1059
|
+
...state,
|
|
1060
|
+
tokens: withoutKey(state.tokens, dropped)
|
|
1061
|
+
}), workspaceRoot);
|
|
1062
|
+
}
|
|
1063
|
+
async function loadCalendarLastSyncedAt(calendarId, workspaceRoot) {
|
|
1064
|
+
return (await readState$1(workspaceRoot)).lastSyncedAt[calendarKey(calendarId)] ?? null;
|
|
1065
|
+
}
|
|
1066
|
+
/** Stamp when a sync of this calendar started, but only if `mayClaim` accepts
|
|
1067
|
+
* what the marker currently says. Answers whether THIS call is the one that
|
|
1068
|
+
* claimed it.
|
|
1069
|
+
*
|
|
1070
|
+
* The decision and the stamp share one pass through the queue on purpose.
|
|
1071
|
+
* Deciding from a snapshot read earlier — as a caller filtering a whole map of
|
|
1072
|
+
* calendars up front must — leaves both hosts believing the calendar is free,
|
|
1073
|
+
* which is the race this exists to close (Codex review #2680). What remains is
|
|
1074
|
+
* the read→write gap BETWEEN processes; closing that needs a real file lock
|
|
1075
|
+
* (#2679), and this stays a soft dedup until then.
|
|
1076
|
+
*
|
|
1077
|
+
* The time is passed in rather than read here so the rule stays testable
|
|
1078
|
+
* without freezing the clock. */
|
|
1079
|
+
async function claimCalendarSyncIfDue(calendarId, startedAt, mayClaim, workspaceRoot) {
|
|
1080
|
+
const stored = calendarKey(calendarId);
|
|
1081
|
+
return await updateStateWith((state) => {
|
|
1082
|
+
const claimed = mayClaim(state.lastSyncedAt[stored] ?? null);
|
|
1083
|
+
return {
|
|
1084
|
+
state: claimed ? {
|
|
1085
|
+
...state,
|
|
1086
|
+
lastSyncedAt: {
|
|
1087
|
+
...state.lastSyncedAt,
|
|
1088
|
+
[stored]: startedAt
|
|
1089
|
+
}
|
|
1090
|
+
} : null,
|
|
1091
|
+
result: claimed
|
|
1092
|
+
};
|
|
1093
|
+
}, workspaceRoot);
|
|
1094
|
+
}
|
|
1095
|
+
/** Drop one calendar's marker, so the next tick — on either host — may sync it
|
|
1096
|
+
* immediately instead of waiting the window out. */
|
|
1097
|
+
async function clearCalendarLastSyncedAt(calendarId, workspaceRoot) {
|
|
1098
|
+
const dropped = calendarKey(calendarId);
|
|
1099
|
+
await updateState$1((state) => ({
|
|
1100
|
+
...state,
|
|
1101
|
+
lastSyncedAt: withoutKey(state.lastSyncedAt, dropped)
|
|
1102
|
+
}), workspaceRoot);
|
|
1103
|
+
}
|
|
1104
|
+
//#endregion
|
|
1105
|
+
//#region src/google/calendarSyncDue.ts
|
|
1106
|
+
/** Slack between a tick and the marker it writes. Without it a lone host reads
|
|
1107
|
+
* its OWN marker as "not due yet" — elapsed lands a hair under the interval
|
|
1108
|
+
* every time — and syncs every other hour. */
|
|
1109
|
+
var TICK_JITTER_TOLERANCE_MS = 300 * 1e3;
|
|
1110
|
+
/** How long a fresh marker suppresses a sync: one interval, less the slack.
|
|
1111
|
+
* The slack is halved rather than subtracted flat for a very short configured
|
|
1112
|
+
* interval, where a fixed five minutes would swallow the window whole. */
|
|
1113
|
+
function calendarSyncDueWindowMs(intervalMs) {
|
|
1114
|
+
return intervalMs - Math.min(TICK_JITTER_TOLERANCE_MS, intervalMs / 2);
|
|
1115
|
+
}
|
|
1116
|
+
/** True iff no recent enough run stands in for this one.
|
|
1117
|
+
*
|
|
1118
|
+
* A marker that cannot be read as a time is treated as due: a stuck calendar is
|
|
1119
|
+
* worse than a duplicate run, which the sync is built to tolerate anyway
|
|
1120
|
+
* (writes are upserts by event id). */
|
|
1121
|
+
function isCalendarSyncDue(lastSyncedAt, windowMs, now = Date.now()) {
|
|
1122
|
+
if (!lastSyncedAt) return true;
|
|
1123
|
+
const elapsed = now - Date.parse(lastSyncedAt);
|
|
1124
|
+
if (!Number.isFinite(elapsed)) return true;
|
|
1125
|
+
if (elapsed < -windowMs) return true;
|
|
1126
|
+
return elapsed >= windowMs;
|
|
876
1127
|
}
|
|
877
1128
|
//#endregion
|
|
878
1129
|
//#region src/google/calendarPushState.ts
|
|
@@ -895,10 +1146,11 @@ async function readState(workspaceRoot) {
|
|
|
895
1146
|
}
|
|
896
1147
|
var writeQueue = { tail: Promise.resolve() };
|
|
897
1148
|
async function updateState(mutate, workspaceRoot) {
|
|
898
|
-
const
|
|
1149
|
+
const statePath = calendarPushStatePath(workspaceRoot);
|
|
1150
|
+
const run = writeQueue.tail.then(async () => await withCalendarStateLock(stateLockPath(statePath), async () => {
|
|
899
1151
|
const state = await readState(workspaceRoot);
|
|
900
|
-
await writeJsonAtomicWithMode(
|
|
901
|
-
});
|
|
1152
|
+
await writeJsonAtomicWithMode(statePath, { events: mutate(state.events) }, PUSH_STATE_MODE);
|
|
1153
|
+
}));
|
|
902
1154
|
writeQueue.tail = run.catch(() => void 0);
|
|
903
1155
|
return await run;
|
|
904
1156
|
}
|
|
@@ -1103,19 +1355,6 @@ function toCollectionRecord(event, map, primaryKey, fields) {
|
|
|
1103
1355
|
[primaryKey]: event.id
|
|
1104
1356
|
};
|
|
1105
1357
|
}
|
|
1106
|
-
/** Google's mapped values laid over whatever the record already holds.
|
|
1107
|
-
*
|
|
1108
|
-
* A record file is written whole (`writeItem`), so projecting alone would drop
|
|
1109
|
-
* every column the map does not name — a collection cannot then carry a local
|
|
1110
|
-
* note next to a mirrored event, because the next pull that touches that event
|
|
1111
|
-
* silently deletes it. The projection still wins on the fields it covers: those
|
|
1112
|
-
* are Google's to own. */
|
|
1113
|
-
function mergeIntoExisting(existing, projected) {
|
|
1114
|
-
return {
|
|
1115
|
-
...existing ?? {},
|
|
1116
|
-
...projected
|
|
1117
|
-
};
|
|
1118
|
-
}
|
|
1119
1358
|
//#endregion
|
|
1120
1359
|
//#region src/google/pushPlan.ts
|
|
1121
1360
|
/** The event fields Google lets a caller write. `htmlLink` and `status` are
|
|
@@ -1393,15 +1632,23 @@ function buildPatch(ctx, record, changed, shadow) {
|
|
|
1393
1632
|
}
|
|
1394
1633
|
};
|
|
1395
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
|
+
});
|
|
1396
1646
|
async function updateFromRecord(ctx, eventId, record, changed, shadow) {
|
|
1397
1647
|
const fetched = await getCalendarEvent(ctx.accessToken, {
|
|
1398
1648
|
calendarId: ctx.calendarId,
|
|
1399
1649
|
eventId
|
|
1400
1650
|
});
|
|
1401
|
-
if (fetched === null || fetched.event.status === "cancelled") return
|
|
1402
|
-
kind: "skipped",
|
|
1403
|
-
message: `${eventId}: the event no longer exists in Google — sync first, then re-create it`
|
|
1404
|
-
};
|
|
1651
|
+
if (fetched === null || fetched.event.status === "cancelled") return goneFromGoogle(eventId);
|
|
1405
1652
|
if (conflictingFields(shadow, toShadowEvent(fetched.event), changed).length > 0) return { kind: "conflict" };
|
|
1406
1653
|
const built = buildPatch(ctx, record, changed, shadow);
|
|
1407
1654
|
if (!built.ok) return {
|
|
@@ -1629,7 +1876,42 @@ function classifyDelete(eventId, kind) {
|
|
|
1629
1876
|
message: `delete ${eventId}: ${kind}`
|
|
1630
1877
|
};
|
|
1631
1878
|
}
|
|
1632
|
-
|
|
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) {
|
|
1633
1915
|
const { schema } = collection;
|
|
1634
1916
|
try {
|
|
1635
1917
|
const store = require_discovery.storeFor(collection, { workspaceRoot });
|
|
@@ -1637,13 +1919,11 @@ async function applyEvent(collection, event, workspaceRoot) {
|
|
|
1637
1919
|
kind: "unwritable",
|
|
1638
1920
|
message: `collection '${collection.slug}' is read-only`
|
|
1639
1921
|
};
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
const written = await store.write(event.id, mergeIntoExisting(await store.read(event.id), record));
|
|
1646
|
-
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);
|
|
1647
1927
|
} catch (error) {
|
|
1648
1928
|
return {
|
|
1649
1929
|
kind: "error",
|
|
@@ -1694,15 +1974,15 @@ var NOTHING_UNPUSHED = /* @__PURE__ */ new Set();
|
|
|
1694
1974
|
* protection — would overwrite the very edits this exists to protect; the read
|
|
1695
1975
|
* that failed is no evidence that the pull's own writes would fail too, so they
|
|
1696
1976
|
* would land (CodeRabbit review #2666). */
|
|
1697
|
-
var PROTECTION_UNKNOWN = "could not work out which records to protect
|
|
1698
|
-
/** 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
|
|
1699
1979
|
* send.
|
|
1700
1980
|
*
|
|
1701
|
-
* Scoped per collection because a calendar can back several of them, and
|
|
1702
|
-
*
|
|
1703
|
-
* group starved a collection that never even declares `autoPush`:
|
|
1704
|
-
*
|
|
1705
|
-
*
|
|
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). */
|
|
1706
1986
|
var unpushedFor = (unpushed, slug) => {
|
|
1707
1987
|
const protection = unpushed.get(slug);
|
|
1708
1988
|
return protection === void 0 ? NOTHING_UNPUSHED : protection;
|
|
@@ -1727,16 +2007,19 @@ var allUnpushed = (unpushed) => new Set([...unpushed.values()].flatMap((ids) =>
|
|
|
1727
2007
|
* feature exists to prevent: a calendar whose role degrades to reader refuses
|
|
1728
2008
|
* the whole push, yet the pull still runs — reading needs no write access — and
|
|
1729
2009
|
* overwrote every unsent local edit while advancing its baseline past it, so the
|
|
1730
|
-
* 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).
|
|
1731
2014
|
*
|
|
1732
2015
|
* Protects the edited records rather than all of them, so an unchanged record
|
|
1733
2016
|
* keeps syncing normally. `null` when even that could not be worked out — the
|
|
1734
2017
|
* caller then refuses to pull the collection at all, because failing open here
|
|
1735
2018
|
* destroys exactly what this protects. */
|
|
1736
|
-
async function protectUnsentEdits(collection, workspaceRoot) {
|
|
2019
|
+
async function protectUnsentEdits(collection, workspaceRoot, deps) {
|
|
1737
2020
|
try {
|
|
1738
|
-
const edited = await
|
|
1739
|
-
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", {
|
|
1740
2023
|
slug: collection.slug,
|
|
1741
2024
|
edited
|
|
1742
2025
|
});
|
|
@@ -1749,8 +2032,18 @@ async function protectUnsentEdits(collection, workspaceRoot) {
|
|
|
1749
2032
|
return null;
|
|
1750
2033
|
}
|
|
1751
2034
|
}
|
|
1752
|
-
|
|
1753
|
-
|
|
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.
|
|
1754
2047
|
*
|
|
1755
2048
|
* MUST run inside the calendar lock the caller already holds — hence
|
|
1756
2049
|
* `pushCollectionNow` rather than `pushCalendarForCollection`, which would take
|
|
@@ -1758,27 +2051,31 @@ async function protectUnsentEdits(collection, workspaceRoot) {
|
|
|
1758
2051
|
*
|
|
1759
2052
|
* A failed push must not stop the pull: the pull is what keeps the collection
|
|
1760
2053
|
* fresh, and a revoked write grant is no reason to freeze reading. */
|
|
1761
|
-
async function
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
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);
|
|
1765
2058
|
if (outcome.kind === "pushed") {
|
|
1766
2059
|
reportAutoPush(collection.slug, outcome.result);
|
|
1767
|
-
|
|
1768
|
-
continue;
|
|
2060
|
+
return new Set(outcome.result.unpushedIds);
|
|
1769
2061
|
}
|
|
1770
2062
|
log.warn("google", "auto push did not run", {
|
|
1771
2063
|
slug: collection.slug,
|
|
1772
2064
|
reason: outcome.kind
|
|
1773
2065
|
});
|
|
1774
|
-
unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
|
|
1775
2066
|
} catch (error) {
|
|
1776
2067
|
log.warn("google", "auto push failed — pulling anyway", {
|
|
1777
2068
|
slug: collection.slug,
|
|
1778
2069
|
error: String(error)
|
|
1779
2070
|
});
|
|
1780
|
-
unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
|
|
1781
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));
|
|
1782
2079
|
return unpushed;
|
|
1783
2080
|
}
|
|
1784
2081
|
async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
@@ -1786,6 +2083,7 @@ async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
|
1786
2083
|
await clearCalendarShadow(calendarId, workspaceRoot);
|
|
1787
2084
|
return await syncCalendarEvents(accessToken, { calendarId });
|
|
1788
2085
|
}
|
|
2086
|
+
var ALWAYS_CLAIM = () => true;
|
|
1789
2087
|
/** Sync ONE calendar and fan its events out to every collection bound to it.
|
|
1790
2088
|
*
|
|
1791
2089
|
* The fan-out is not an optimisation, it is correctness: the sync token is
|
|
@@ -1800,21 +2098,80 @@ async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
|
1800
2098
|
* load the SAME stored token and walk the same window. That is idempotent —
|
|
1801
2099
|
* writes are upserts by event id — but it is a wasted full walk. Queued, the
|
|
1802
2100
|
* second pass resumes from the token the first just stored and fetches only
|
|
1803
|
-
* what is genuinely new.
|
|
1804
|
-
|
|
1805
|
-
|
|
2101
|
+
* what is genuinely new. That queue is module state, so it orders the doors
|
|
2102
|
+
* into THIS process only; `claimThenSync` is what other hosts can see. */
|
|
2103
|
+
async function syncCalendarGroup(calendarId, collections, workspaceRoot, mayClaim = ALWAYS_CLAIM) {
|
|
2104
|
+
return await withCalendarLock(calendarId, () => claimThenSync(calendarId, collections, workspaceRoot, mayClaim));
|
|
2105
|
+
}
|
|
2106
|
+
/** Take the workspace-shared marker BEFORE the calendar is touched, sync only if
|
|
2107
|
+
* this run got it, and drop it again if the run could not happen at all.
|
|
2108
|
+
*
|
|
2109
|
+
* Claiming first is the whole point: hosts tick in the same minute (interval
|
|
2110
|
+
* schedules align to wall-clock boundaries), so a marker written on completion
|
|
2111
|
+
* would leave the entire run — minutes, for a first full walk — open for a
|
|
2112
|
+
* second host to start alongside this one and lose the push baseline it writes.
|
|
2113
|
+
*
|
|
2114
|
+
* Released only when the sync could not run at all. A run that finished with
|
|
2115
|
+
* retryable per-collection errors KEEPS the marker: it already held the token
|
|
2116
|
+
* and the baseline back, so it replays on the next tick either way, and
|
|
2117
|
+
* releasing would hand a permanently-failing calendar back to both hosts at
|
|
2118
|
+
* once — the very overlap this guards (observed during Claude review). */
|
|
2119
|
+
async function claimThenSync(calendarId, collections, workspaceRoot, mayClaim) {
|
|
2120
|
+
if (!await claimCalendarSync(calendarId, workspaceRoot, mayClaim)) {
|
|
2121
|
+
log.info("google", "skipping a calendar this workspace synced recently — another host may be on it", { calendarId });
|
|
2122
|
+
return [];
|
|
2123
|
+
}
|
|
2124
|
+
try {
|
|
2125
|
+
return await syncCalendarGroupNow(calendarId, collections, workspaceRoot);
|
|
2126
|
+
} catch (error) {
|
|
2127
|
+
await releaseCalendarSyncClaim(calendarId, workspaceRoot);
|
|
2128
|
+
throw error;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
/** Never throws, and fails OPEN: the marker guards against duplicate work, it is
|
|
2132
|
+
* not a precondition for syncing correctly. A workspace that cannot store it
|
|
2133
|
+
* syncs the way it did before #2678. */
|
|
2134
|
+
async function claimCalendarSync(calendarId, workspaceRoot, mayClaim) {
|
|
2135
|
+
try {
|
|
2136
|
+
return await claimCalendarSyncIfDue(calendarId, (/* @__PURE__ */ new Date()).toISOString(), mayClaim, workspaceRoot);
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
log.warn("google", "could not stamp the calendar sync marker — another host may sync this calendar in parallel", {
|
|
2139
|
+
calendarId,
|
|
2140
|
+
error: String(error)
|
|
2141
|
+
});
|
|
2142
|
+
return true;
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
async function releaseCalendarSyncClaim(calendarId, workspaceRoot) {
|
|
2146
|
+
try {
|
|
2147
|
+
await clearCalendarLastSyncedAt(calendarId, workspaceRoot);
|
|
2148
|
+
} catch (error) {
|
|
2149
|
+
log.warn("google", "could not release the calendar sync marker after a failed run", {
|
|
2150
|
+
calendarId,
|
|
2151
|
+
error: String(error)
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
1806
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)]);
|
|
1807
2163
|
async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
|
|
1808
|
-
const unpushed = await
|
|
2164
|
+
const unpushed = await pushAndProtect(collections, workspaceRoot);
|
|
2165
|
+
const baseline = await loadCalendarShadow(calendarId, workspaceRoot);
|
|
1809
2166
|
const accessToken = await getGoogleAccessToken();
|
|
1810
2167
|
const first = await syncCalendarEvents(accessToken, {
|
|
1811
2168
|
calendarId,
|
|
1812
2169
|
syncToken: await loadCalendarSyncToken(calendarId, workspaceRoot) ?? void 0
|
|
1813
2170
|
});
|
|
1814
2171
|
const result = first.fullResyncRequired ? await restartFullSync(accessToken, calendarId, workspaceRoot) : first;
|
|
1815
|
-
const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed);
|
|
2172
|
+
const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed, baseline);
|
|
1816
2173
|
if (windowFullyLanded(calendarId, results)) {
|
|
1817
|
-
await saveCalendarShadow(calendarId, shadowUpdates(result.events,
|
|
2174
|
+
await saveCalendarShadow(calendarId, shadowUpdates(result.events, heldBack(unpushed, results), baseline), workspaceRoot);
|
|
1818
2175
|
if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
|
|
1819
2176
|
}
|
|
1820
2177
|
return results;
|
|
@@ -1823,7 +2180,7 @@ async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
|
|
|
1823
2180
|
* one's own push protected. A collection whose protection could not be worked
|
|
1824
2181
|
* out is not pulled at all — it reports a retryable error instead, which holds
|
|
1825
2182
|
* the token and the baseline back for the whole group. */
|
|
1826
|
-
async function applyWindowToGroup(collections, events, workspaceRoot, unpushed) {
|
|
2183
|
+
async function applyWindowToGroup(collections, events, workspaceRoot, unpushed, baseline) {
|
|
1827
2184
|
const results = [];
|
|
1828
2185
|
for (const collection of collections) {
|
|
1829
2186
|
const protection = unpushedFor(unpushed, collection.slug);
|
|
@@ -1832,8 +2189,9 @@ async function applyWindowToGroup(collections, events, workspaceRoot, unpushed)
|
|
|
1832
2189
|
written: 0,
|
|
1833
2190
|
removed: 0,
|
|
1834
2191
|
unwritable: [],
|
|
2192
|
+
withheld: [],
|
|
1835
2193
|
errors: [PROTECTION_UNKNOWN]
|
|
1836
|
-
} : await applyEventsToCollection(collection, events, workspaceRoot, protection));
|
|
2194
|
+
} : await applyEventsToCollection(collection, events, workspaceRoot, protection, baseline));
|
|
1837
2195
|
}
|
|
1838
2196
|
return results;
|
|
1839
2197
|
}
|
|
@@ -1850,6 +2208,11 @@ function windowFullyLanded(calendarId, results) {
|
|
|
1850
2208
|
slug: entry.slug,
|
|
1851
2209
|
unwritable: entry.unwritable
|
|
1852
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
|
+
}));
|
|
1853
2216
|
const failed = results.filter((entry) => entry.errors.length > 0);
|
|
1854
2217
|
failed.forEach((entry) => log.warn("google", "holding back calendar sync token after failed writes", {
|
|
1855
2218
|
calendarId,
|
|
@@ -1866,10 +2229,20 @@ function windowFullyLanded(calendarId, results) {
|
|
|
1866
2229
|
* keeps the local one would make the next push read a plain one-sided edit —
|
|
1867
2230
|
* no conflict to detect any more — and quietly overwrite Google. Held back, the
|
|
1868
2231
|
* baseline stays older than both sides, so the conflict keeps being reported
|
|
1869
|
-
* until someone resolves it (#2620).
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
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]);
|
|
1873
2246
|
}
|
|
1874
2247
|
/** Save the window's token unless every collection that consumed it was deleted
|
|
1875
2248
|
* while the sync was in flight.
|
|
@@ -1902,13 +2275,19 @@ async function pathExists(absPath) {
|
|
|
1902
2275
|
async function anySyncedCollectionSurvives(collections, exists = pathExists) {
|
|
1903
2276
|
return (await Promise.all(collections.map((collection) => exists(collection.skillDir)))).some(Boolean);
|
|
1904
2277
|
}
|
|
1905
|
-
async function applyEventsToCollection(collection, events, workspaceRoot, unpushed) {
|
|
1906
|
-
const
|
|
1907
|
-
|
|
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);
|
|
1908
2286
|
return {
|
|
1909
2287
|
slug: collection.slug,
|
|
1910
2288
|
written: outcomes.filter((outcome) => outcome.kind === "written").length,
|
|
1911
2289
|
removed: outcomes.filter((outcome) => outcome.kind === "removed").length,
|
|
2290
|
+
withheld: attempts.flatMap((attempt) => attempt.outcome.kind === "withheld" ? [attempt.eventId] : []),
|
|
1912
2291
|
unwritable: outcomes.flatMap((outcome) => outcome.kind === "unwritable" ? [outcome.message] : []),
|
|
1913
2292
|
errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
|
|
1914
2293
|
};
|
|
@@ -1963,6 +2342,7 @@ async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
|
|
|
1963
2342
|
if (orphaned === null) return null;
|
|
1964
2343
|
await clearCalendarSyncToken(orphaned, workspaceRoot);
|
|
1965
2344
|
await clearCalendarShadow(orphaned, workspaceRoot);
|
|
2345
|
+
await clearCalendarLastSyncedAt(orphaned, workspaceRoot);
|
|
1966
2346
|
log.info("google", "cleared the sync token of a calendar no collection reads any more", { calendarId: orphaned });
|
|
1967
2347
|
return orphaned;
|
|
1968
2348
|
} catch (error) {
|
|
@@ -1979,19 +2359,27 @@ async function declaringGroups(workspaceRoot) {
|
|
|
1979
2359
|
* Authoring the collection before linking the account is an expected state,
|
|
1980
2360
|
* not a failure. Checking once here keeps it a quiet skip instead of an
|
|
1981
2361
|
* access-token throw per calendar, every hour, until the user links (#2188).
|
|
1982
|
-
* A user-triggered sync answers differently — it says so out loud.
|
|
2362
|
+
* A user-triggered sync answers differently — it says so out loud.
|
|
2363
|
+
*
|
|
2364
|
+
* Deliberately not phrased as a per-host state: the link lives in
|
|
2365
|
+
* `~/.config/mulmo`, which carries no app name, so every host on the machine
|
|
2366
|
+
* shares one answer. Reading it as "linked on this host only" is what made the
|
|
2367
|
+
* calendar look implicitly exclusive when it never was (#2678). */
|
|
1983
2368
|
async function backgroundSyncAllowed(groups) {
|
|
1984
2369
|
if (groups.size === 0) return false;
|
|
1985
2370
|
if (await isGoogleLinked()) return true;
|
|
1986
|
-
log.info("google", "skipping calendar sync — no Google account linked
|
|
2371
|
+
log.info("google", "skipping calendar sync — no Google account linked", { calendars: groups.size });
|
|
1987
2372
|
return false;
|
|
1988
2373
|
}
|
|
1989
2374
|
/** Run each group, isolating failures per calendar — one unreachable calendar
|
|
1990
|
-
* (or a revoked grant) must not stop the others.
|
|
1991
|
-
|
|
2375
|
+
* (or a revoked grant) must not stop the others. `mayClaim` is evaluated per
|
|
2376
|
+
* calendar as it comes up, never once for the whole map: an earlier calendar's
|
|
2377
|
+
* full walk takes minutes, and a decision made before it started says nothing
|
|
2378
|
+
* about who holds this one now. */
|
|
2379
|
+
async function runCalendarGroups(groups, workspaceRoot, mayClaim = ALWAYS_CLAIM) {
|
|
1992
2380
|
const results = [];
|
|
1993
2381
|
for (const [calendarId, collections] of groups) try {
|
|
1994
|
-
results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot));
|
|
2382
|
+
results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot, mayClaim));
|
|
1995
2383
|
} catch (error) {
|
|
1996
2384
|
log.warn("google", "calendar sync failed", {
|
|
1997
2385
|
calendarId,
|
|
@@ -2002,15 +2390,25 @@ async function runCalendarGroups(groups, workspaceRoot) {
|
|
|
2002
2390
|
written: 0,
|
|
2003
2391
|
removed: 0,
|
|
2004
2392
|
unwritable: [],
|
|
2393
|
+
withheld: [],
|
|
2005
2394
|
errors: [String(error)]
|
|
2006
2395
|
})));
|
|
2007
2396
|
}
|
|
2008
2397
|
return results;
|
|
2009
2398
|
}
|
|
2010
|
-
/** Sync every collection
|
|
2011
|
-
|
|
2399
|
+
/** Sync every collection whose calendar is due — no host in this workspace has
|
|
2400
|
+
* started one within `intervalMs` (#2678). Without that gate this walked every
|
|
2401
|
+
* declaring group on every tick, so a second host registering the same task
|
|
2402
|
+
* simply doubled the runs, concurrently.
|
|
2403
|
+
*
|
|
2404
|
+
* Dueness is not decided here, only described: the guard is handed down and
|
|
2405
|
+
* evaluated where the marker is written, so the answer cannot go stale between
|
|
2406
|
+
* deciding and claiming (Codex review #2680). */
|
|
2407
|
+
async function syncDueCalendarCollections(workspaceRoot, intervalMs = DEFAULT_SYNC_INTERVAL_MS) {
|
|
2012
2408
|
const groups = await declaringGroups(workspaceRoot);
|
|
2013
|
-
|
|
2409
|
+
if (!await backgroundSyncAllowed(groups)) return [];
|
|
2410
|
+
const windowMs = calendarSyncDueWindowMs(intervalMs);
|
|
2411
|
+
return await runCalendarGroups(groups, workspaceRoot, (lastSyncedAt) => isCalendarSyncDue(lastSyncedAt, windowMs));
|
|
2014
2412
|
}
|
|
2015
2413
|
/** The groups whose calendar has never synced. A missing token IS the "created
|
|
2016
2414
|
* since the last sync" signal — nothing else distinguishes a new collection
|
|
@@ -2056,10 +2454,21 @@ async function syncCalendarForCollection(slug, workspaceRoot, deps = liveManualS
|
|
|
2056
2454
|
results: await deps.runGroups(new Map(owning), workspaceRoot)
|
|
2057
2455
|
};
|
|
2058
2456
|
}
|
|
2457
|
+
/** The interval this task is scheduled on right now. A non-interval schedule
|
|
2458
|
+
* cannot be reached — the factory below builds an interval one — but the type
|
|
2459
|
+
* allows it, so it falls back rather than asserting. */
|
|
2460
|
+
function scheduledIntervalMs(task) {
|
|
2461
|
+
return task.schedule.type === _receptron_task_scheduler.SCHEDULE_TYPES.interval ? task.schedule.intervalMs : DEFAULT_SYNC_INTERVAL_MS;
|
|
2462
|
+
}
|
|
2059
2463
|
/** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
|
|
2060
|
-
* with a single line.
|
|
2464
|
+
* with a single line.
|
|
2465
|
+
*
|
|
2466
|
+
* `run` reads the interval back off the definition instead of closing over the
|
|
2467
|
+
* option: a host rewrites `schedule` from its own overrides file AFTER this
|
|
2468
|
+
* returns, and the due window has to follow it. Frozen at the default, a
|
|
2469
|
+
* shortened interval would tick often and skip nearly every tick. */
|
|
2061
2470
|
function googleCalendarSyncTaskDef(opts) {
|
|
2062
|
-
|
|
2471
|
+
const def = {
|
|
2063
2472
|
id: GOOGLE_CALENDAR_SYNC_TASK_ID,
|
|
2064
2473
|
name: "Google Calendar sync",
|
|
2065
2474
|
description: "Pulls changed Google Calendar events into any collection declaring `googleCalendar`, without invoking the LLM.",
|
|
@@ -2068,8 +2477,9 @@ function googleCalendarSyncTaskDef(opts) {
|
|
|
2068
2477
|
intervalMs: opts?.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS
|
|
2069
2478
|
},
|
|
2070
2479
|
missedRunPolicy: _receptron_task_scheduler.MISSED_RUN_POLICIES.runOnce,
|
|
2071
|
-
run: () => syncDueCalendarCollections(opts?.workspaceRoot ?? require_discovery.getWorkspaceRoot()).then(() => {})
|
|
2480
|
+
run: () => syncDueCalendarCollections(opts?.workspaceRoot ?? require_discovery.getWorkspaceRoot(), scheduledIntervalMs(def)).then(() => {})
|
|
2072
2481
|
};
|
|
2482
|
+
return def;
|
|
2073
2483
|
}
|
|
2074
2484
|
//#endregion
|
|
2075
2485
|
//#region src/google/tasks.ts
|
|
@@ -2284,6 +2694,7 @@ exports.PROTECTION_UNKNOWN = PROTECTION_UNKNOWN;
|
|
|
2284
2694
|
exports.PUSHABLE_SOURCE_FIELDS = PUSHABLE_SOURCE_FIELDS;
|
|
2285
2695
|
exports.allUnpushed = allUnpushed;
|
|
2286
2696
|
exports.anySyncedCollectionSurvives = anySyncedCollectionSurvives;
|
|
2697
|
+
exports.applyPlanFor = applyPlanFor;
|
|
2287
2698
|
exports.assertSafeMimeType = assertSafeMimeType;
|
|
2288
2699
|
exports.authorizeGoogle = authorizeGoogle;
|
|
2289
2700
|
exports.baselineRecord = baselineRecord;
|
|
@@ -2297,11 +2708,14 @@ exports.buildTaskPatch = buildTaskPatch;
|
|
|
2297
2708
|
exports.bySourceField = bySourceField;
|
|
2298
2709
|
exports.calendarApiError = calendarApiError;
|
|
2299
2710
|
exports.calendarPushStatePath = calendarPushStatePath;
|
|
2711
|
+
exports.calendarSyncDueWindowMs = calendarSyncDueWindowMs;
|
|
2300
2712
|
exports.calendarSyncStatePath = calendarSyncStatePath;
|
|
2301
2713
|
exports.canonicalCalendarId = canonicalCalendarId;
|
|
2302
2714
|
exports.canonicalTaskListId = canonicalTaskListId;
|
|
2715
|
+
exports.claimCalendarSyncIfDue = claimCalendarSyncIfDue;
|
|
2303
2716
|
exports.classifyDelete = classifyDelete;
|
|
2304
2717
|
exports.classifyWrite = classifyWrite;
|
|
2718
|
+
exports.clearCalendarLastSyncedAt = clearCalendarLastSyncedAt;
|
|
2305
2719
|
exports.clearCalendarShadow = clearCalendarShadow;
|
|
2306
2720
|
exports.clearCalendarSyncToken = clearCalendarSyncToken;
|
|
2307
2721
|
exports.clientSecretPresence = clientSecretPresence;
|
|
@@ -2331,6 +2745,8 @@ exports.googleConfigDir = googleConfigDir;
|
|
|
2331
2745
|
exports.googleSecretsDir = googleSecretsDir;
|
|
2332
2746
|
exports.googleTokenPath = googleTokenPath;
|
|
2333
2747
|
exports.groupByCalendar = groupByCalendar;
|
|
2748
|
+
exports.heldBack = heldBack;
|
|
2749
|
+
exports.isCalendarSyncDue = isCalendarSyncDue;
|
|
2334
2750
|
exports.isClientSettableEventId = isClientSettableEventId;
|
|
2335
2751
|
exports.isDeniedAccessRole = isDeniedAccessRole;
|
|
2336
2752
|
exports.isGoogleApiError = isGoogleApiError;
|
|
@@ -2343,6 +2759,8 @@ exports.listCalendars = listCalendars;
|
|
|
2343
2759
|
exports.listDriveFiles = listDriveFiles;
|
|
2344
2760
|
exports.listTaskLists = listTaskLists;
|
|
2345
2761
|
exports.listTasks = listTasks;
|
|
2762
|
+
exports.liveLockClock = liveLockClock;
|
|
2763
|
+
exports.loadCalendarLastSyncedAt = loadCalendarLastSyncedAt;
|
|
2346
2764
|
exports.loadCalendarShadow = loadCalendarShadow;
|
|
2347
2765
|
exports.loadCalendarSyncToken = loadCalendarSyncToken;
|
|
2348
2766
|
exports.loadClientSecret = loadClientSecret;
|
|
@@ -2352,12 +2770,14 @@ exports.locallyDeletedIds = locallyDeletedIds;
|
|
|
2352
2770
|
exports.locallyEditedIds = locallyEditedIds;
|
|
2353
2771
|
exports.mayAdoptExisting = mayAdoptExisting;
|
|
2354
2772
|
exports.mergeGoogleTokens = mergeGoogleTokens;
|
|
2355
|
-
exports.mergeIntoExisting = mergeIntoExisting;
|
|
2773
|
+
exports.mergeIntoExisting = require_project.mergeIntoExisting;
|
|
2356
2774
|
exports.mergeShadow = mergeShadow;
|
|
2357
2775
|
exports.orphanedCalendarId = orphanedCalendarId;
|
|
2358
2776
|
exports.pickBoundary = pickBoundary;
|
|
2359
2777
|
exports.planRecord = planRecord;
|
|
2778
|
+
exports.pullProtectionFor = pullProtectionFor;
|
|
2360
2779
|
exports.pullableEvents = pullableEvents;
|
|
2780
|
+
exports.pushAndProtect = pushAndProtect;
|
|
2361
2781
|
exports.pushCalendarForCollection = pushCalendarForCollection;
|
|
2362
2782
|
exports.pushCollectionNow = pushCollectionNow;
|
|
2363
2783
|
exports.pushableMap = pushableMap;
|
|
@@ -2368,6 +2788,7 @@ exports.saveCalendarShadow = saveCalendarShadow;
|
|
|
2368
2788
|
exports.saveCalendarSyncToken = saveCalendarSyncToken;
|
|
2369
2789
|
exports.saveGoogleTokens = saveGoogleTokens;
|
|
2370
2790
|
exports.shadowUpdates = shadowUpdates;
|
|
2791
|
+
exports.stateLockPath = stateLockPath;
|
|
2371
2792
|
exports.syncCalendarEvents = syncCalendarEvents;
|
|
2372
2793
|
exports.syncCalendarForCollection = syncCalendarForCollection;
|
|
2373
2794
|
exports.syncCalendarGroup = syncCalendarGroup;
|
|
@@ -2385,12 +2806,14 @@ exports.toTaskSummary = toTaskSummary;
|
|
|
2385
2806
|
exports.uncompleteTask = uncompleteTask;
|
|
2386
2807
|
exports.unlinkGoogle = unlinkGoogle;
|
|
2387
2808
|
exports.unpushedFor = unpushedFor;
|
|
2809
|
+
exports.unsentEditGuard = unsentEditGuard;
|
|
2388
2810
|
exports.unsentLocalEdits = unsentLocalEdits;
|
|
2389
2811
|
exports.unsyncedGroups = unsyncedGroups;
|
|
2390
2812
|
exports.updateCalendarEvent = updateCalendarEvent;
|
|
2391
2813
|
exports.updateTask = updateTask;
|
|
2392
2814
|
exports.waitForAuthCode = waitForAuthCode;
|
|
2393
2815
|
exports.withCalendarLock = withCalendarLock;
|
|
2816
|
+
exports.withCalendarStateLock = withCalendarStateLock;
|
|
2394
2817
|
exports.withKeyedLock = withKeyedLock;
|
|
2395
2818
|
exports.zoneSuffixOf = zoneSuffixOf;
|
|
2396
2819
|
|