@mulmoclaude/core 1.10.0 → 1.12.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.
Files changed (34) hide show
  1. package/assets/helps/error-recovery.md +85 -0
  2. package/assets/helps/google-calendar-collection.md +41 -18
  3. package/dist/collection/core/schemaZ.d.ts +10 -1
  4. package/dist/collection/registry/server/index.cjs +2 -2
  5. package/dist/collection/registry/server/index.js +2 -2
  6. package/dist/collection/server/index.cjs +2 -2
  7. package/dist/collection/server/index.js +2 -2
  8. package/dist/collection-watchers/index.cjs +2 -2
  9. package/dist/collection-watchers/index.js +2 -2
  10. package/dist/{discovery-_fjsluLx.js → discovery-B4CZvrXR.js} +11 -3
  11. package/dist/{discovery-_fjsluLx.js.map → discovery-B4CZvrXR.js.map} +1 -1
  12. package/dist/{discovery-DYcR83qx.cjs → discovery-NRy3tyUA.cjs} +11 -3
  13. package/dist/{discovery-DYcR83qx.cjs.map → discovery-NRy3tyUA.cjs.map} +1 -1
  14. package/dist/feeds/server/index.cjs +2 -2
  15. package/dist/feeds/server/index.js +2 -2
  16. package/dist/google/calendar.d.ts +9 -0
  17. package/dist/google/calendarLock.d.ts +16 -0
  18. package/dist/google/calendarPushState.d.ts +1 -1
  19. package/dist/google/calendarSyncDue.d.ts +10 -0
  20. package/dist/google/calendarSyncStore.d.ts +20 -1
  21. package/dist/google/collectionProjection.d.ts +18 -0
  22. package/dist/google/collectionPush.d.ts +62 -0
  23. package/dist/google/collectionSync.d.ts +75 -28
  24. package/dist/google/index.cjs +1217 -771
  25. package/dist/google/index.cjs.map +1 -1
  26. package/dist/google/index.d.ts +6 -3
  27. package/dist/google/index.js +1204 -772
  28. package/dist/google/index.js.map +1 -1
  29. package/dist/google/pushPlan.d.ts +1 -1
  30. package/dist/{server-l4TZ5Lbf.cjs → server-7U-3DE2e.cjs} +2 -2
  31. package/dist/{server-l4TZ5Lbf.cjs.map → server-7U-3DE2e.cjs.map} +1 -1
  32. package/dist/{server-BlIrtGKl.js → server-DdyIvFhz.js} +2 -2
  33. package/dist/{server-BlIrtGKl.js.map → server-DdyIvFhz.js.map} +1 -1
  34. package/package.json +4 -4
@@ -5,7 +5,7 @@ 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 require_discovery = require("../discovery-DYcR83qx.cjs");
8
+ const require_discovery = require("../discovery-NRy3tyUA.cjs");
9
9
  let node_fs = require("node:fs");
10
10
  let node_path = require("node:path");
11
11
  node_path = require_rolldown_runtime.__toESM(node_path, 1);
@@ -625,7 +625,9 @@ var toEventSummary = (value) => {
625
625
  end: eventTime$1(record.end),
626
626
  htmlLink: stringField(record, "htmlLink"),
627
627
  status: stringField(record, "status"),
628
- colorId: stringField(record, "colorId")
628
+ colorId: stringField(record, "colorId"),
629
+ description: stringField(record, "description"),
630
+ location: stringField(record, "location")
629
631
  };
630
632
  };
631
633
  var toCalendarSummary = (value) => {
@@ -667,6 +669,7 @@ async function createCalendarEvent(accessToken, input) {
667
669
  const body = {
668
670
  summary: input.summary,
669
671
  description: input.description,
672
+ location: input.location,
670
673
  ...resolveEventSpan(input),
671
674
  ...input.colorId ? { colorId: input.colorId } : {},
672
675
  ...input.eventId ? { id: input.eventId } : {}
@@ -691,6 +694,7 @@ var eventEnd = (key, structured, flat) => {
691
694
  var buildEventPatch = (input) => ({
692
695
  ...input.summary !== void 0 ? { summary: input.summary } : {},
693
696
  ...input.description !== void 0 ? { description: input.description } : {},
697
+ ...input.location !== void 0 ? { location: input.location } : {},
694
698
  ...eventEnd("start", input.start, input.startDateTime),
695
699
  ...eventEnd("end", input.end, input.endDateTime),
696
700
  ...input.colorId ? { colorId: input.colorId } : {}
@@ -841,34 +845,128 @@ function calendarSyncStatePath(workspaceRoot) {
841
845
  return node_path.default.join(workspaceRoot ?? require_discovery.getWorkspaceRoot(), "data", "calendar", ".sync-state.json");
842
846
  }
843
847
  var calendarKey = canonicalCalendarId;
848
+ /** Read one map out of whatever is on disk. Tolerant on purpose: a file written
849
+ * before `lastSyncedAt` existed simply lacks the key, and a hand-edited one may
850
+ * hold anything. A dropped entry costs a full re-walk or a duplicate run, never
851
+ * data — throwing here would stop the sync entirely. */
852
+ function stringRecord(value) {
853
+ if (typeof value !== "object" || value === null) return {};
854
+ return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string"));
855
+ }
844
856
  async function readState$1(workspaceRoot) {
845
857
  const stored = await readJsonOrNull(calendarSyncStatePath(workspaceRoot));
846
- return stored?.tokens ? stored : { tokens: {} };
858
+ return {
859
+ tokens: stringRecord(stored?.tokens),
860
+ lastSyncedAt: stringRecord(stored?.lastSyncedAt)
861
+ };
847
862
  }
848
863
  var writeQueue$1 = { tail: Promise.resolve() };
849
- async function updateState$1(mutate, workspaceRoot) {
864
+ /** Decide from the stored state and write the result, both inside the queue, so
865
+ * nothing in this process can read between the two. A `null` state skips the
866
+ * write; `result` is what the caller learns about the decision. */
867
+ async function updateStateWith(decide, workspaceRoot) {
850
868
  const run = writeQueue$1.tail.then(async () => {
851
- const state = await readState$1(workspaceRoot);
852
- await writeJsonAtomicWithMode(calendarSyncStatePath(workspaceRoot), { tokens: mutate(state.tokens) }, SYNC_STATE_MODE);
869
+ const { state, result } = decide(await readState$1(workspaceRoot));
870
+ if (state) await writeJsonAtomicWithMode(calendarSyncStatePath(workspaceRoot), state, SYNC_STATE_MODE);
871
+ return result;
853
872
  });
854
873
  writeQueue$1.tail = run.catch(() => void 0);
855
874
  return await run;
856
875
  }
876
+ async function updateState$1(mutate, workspaceRoot) {
877
+ await updateStateWith((state) => ({
878
+ state: mutate(state),
879
+ result: void 0
880
+ }), workspaceRoot);
881
+ }
882
+ var withoutKey = (record, dropped) => Object.fromEntries(Object.entries(record).filter(([key]) => key !== dropped));
857
883
  async function loadCalendarSyncToken(calendarId, workspaceRoot) {
858
884
  return (await readState$1(workspaceRoot)).tokens[calendarKey(calendarId)] ?? null;
859
885
  }
860
886
  async function saveCalendarSyncToken(calendarId, syncToken, workspaceRoot) {
861
887
  const stored = calendarKey(calendarId);
862
- await updateState$1((tokens) => ({
863
- ...tokens,
864
- [stored]: syncToken
888
+ await updateState$1((state) => ({
889
+ ...state,
890
+ tokens: {
891
+ ...state.tokens,
892
+ [stored]: syncToken
893
+ }
865
894
  }), workspaceRoot);
866
895
  }
867
896
  /** Drop one calendar's token — used when Google answers 410, so the next run
868
- * starts a clean full sync. */
897
+ * starts a clean full sync. Leaves the sync marker alone: "restart the walk"
898
+ * says nothing about "nobody is running". */
869
899
  async function clearCalendarSyncToken(calendarId, workspaceRoot) {
870
900
  const dropped = calendarKey(calendarId);
871
- await updateState$1((tokens) => Object.fromEntries(Object.entries(tokens).filter(([key]) => key !== dropped)), workspaceRoot);
901
+ await updateState$1((state) => ({
902
+ ...state,
903
+ tokens: withoutKey(state.tokens, dropped)
904
+ }), workspaceRoot);
905
+ }
906
+ async function loadCalendarLastSyncedAt(calendarId, workspaceRoot) {
907
+ return (await readState$1(workspaceRoot)).lastSyncedAt[calendarKey(calendarId)] ?? null;
908
+ }
909
+ /** Stamp when a sync of this calendar started, but only if `mayClaim` accepts
910
+ * what the marker currently says. Answers whether THIS call is the one that
911
+ * claimed it.
912
+ *
913
+ * The decision and the stamp share one pass through the queue on purpose.
914
+ * Deciding from a snapshot read earlier — as a caller filtering a whole map of
915
+ * calendars up front must — leaves both hosts believing the calendar is free,
916
+ * which is the race this exists to close (Codex review #2680). What remains is
917
+ * the read→write gap BETWEEN processes; closing that needs a real file lock
918
+ * (#2679), and this stays a soft dedup until then.
919
+ *
920
+ * The time is passed in rather than read here so the rule stays testable
921
+ * without freezing the clock. */
922
+ async function claimCalendarSyncIfDue(calendarId, startedAt, mayClaim, workspaceRoot) {
923
+ const stored = calendarKey(calendarId);
924
+ return await updateStateWith((state) => {
925
+ const claimed = mayClaim(state.lastSyncedAt[stored] ?? null);
926
+ return {
927
+ state: claimed ? {
928
+ ...state,
929
+ lastSyncedAt: {
930
+ ...state.lastSyncedAt,
931
+ [stored]: startedAt
932
+ }
933
+ } : null,
934
+ result: claimed
935
+ };
936
+ }, workspaceRoot);
937
+ }
938
+ /** Drop one calendar's marker, so the next tick — on either host — may sync it
939
+ * immediately instead of waiting the window out. */
940
+ async function clearCalendarLastSyncedAt(calendarId, workspaceRoot) {
941
+ const dropped = calendarKey(calendarId);
942
+ await updateState$1((state) => ({
943
+ ...state,
944
+ lastSyncedAt: withoutKey(state.lastSyncedAt, dropped)
945
+ }), workspaceRoot);
946
+ }
947
+ //#endregion
948
+ //#region src/google/calendarSyncDue.ts
949
+ /** Slack between a tick and the marker it writes. Without it a lone host reads
950
+ * its OWN marker as "not due yet" — elapsed lands a hair under the interval
951
+ * every time — and syncs every other hour. */
952
+ var TICK_JITTER_TOLERANCE_MS = 300 * 1e3;
953
+ /** How long a fresh marker suppresses a sync: one interval, less the slack.
954
+ * The slack is halved rather than subtracted flat for a very short configured
955
+ * interval, where a fixed five minutes would swallow the window whole. */
956
+ function calendarSyncDueWindowMs(intervalMs) {
957
+ return intervalMs - Math.min(TICK_JITTER_TOLERANCE_MS, intervalMs / 2);
958
+ }
959
+ /** True iff no recent enough run stands in for this one.
960
+ *
961
+ * A marker that cannot be read as a time is treated as due: a stuck calendar is
962
+ * worse than a duplicate run, which the sync is built to tolerate anyway
963
+ * (writes are upserts by event id). */
964
+ function isCalendarSyncDue(lastSyncedAt, windowMs, now = Date.now()) {
965
+ if (!lastSyncedAt) return true;
966
+ const elapsed = now - Date.parse(lastSyncedAt);
967
+ if (!Number.isFinite(elapsed)) return true;
968
+ if (elapsed < -windowMs) return true;
969
+ return elapsed >= windowMs;
872
970
  }
873
971
  //#endregion
874
972
  //#region src/google/calendarPushState.ts
@@ -881,7 +979,9 @@ var toShadowEvent = (event) => ({
881
979
  summary: event.summary,
882
980
  start: event.start,
883
981
  end: event.end,
884
- colorId: event.colorId
982
+ colorId: event.colorId,
983
+ description: event.description,
984
+ location: event.location
885
985
  });
886
986
  async function readState(workspaceRoot) {
887
987
  const stored = await readJsonOrNull(calendarPushStatePath(workspaceRoot));
@@ -924,14 +1024,142 @@ async function clearCalendarShadow(calendarId, workspaceRoot) {
924
1024
  await updateState((events) => Object.fromEntries(Object.entries(events).filter(([key]) => key !== dropped)), workspaceRoot);
925
1025
  }
926
1026
  //#endregion
1027
+ //#region src/google/calendarLock.ts
1028
+ /** Serialise `run` against whatever is already running for `key`.
1029
+ *
1030
+ * `locks` is passed in so the queuing rule is testable without module state;
1031
+ * the key is dropped once nothing is queued behind it, so the map cannot grow
1032
+ * an entry per calendar forever. A failed predecessor still releases the
1033
+ * queue — `then(run, run)`. */
1034
+ async function withKeyedLock(locks, key, run) {
1035
+ const result = (locks.get(key) ?? Promise.resolve()).then(run, run);
1036
+ const tail = result.then(() => void 0, () => void 0);
1037
+ locks.set(key, tail);
1038
+ try {
1039
+ return await result;
1040
+ } finally {
1041
+ if (locks.get(key) === tail) locks.delete(key);
1042
+ }
1043
+ }
1044
+ /** In-flight work per canonical calendar id. Module state on purpose: the
1045
+ * scheduler, the create trigger and the Refresh button are three doors into
1046
+ * the same calendar (CodeRabbit review #2566). */
1047
+ var calendarLocks = /* @__PURE__ */ new Map();
1048
+ /** Serialise anything that touches ONE calendar's sync state.
1049
+ *
1050
+ * Shared with the push path (#2598), not only the pull doors: a push that
1051
+ * overtakes an in-flight pull would record a baseline for events the pull is
1052
+ * still writing, so the next push would diff against a future it never saw.
1053
+ *
1054
+ * NOT reentrant — a holder must call the lock-free form of whatever it drives,
1055
+ * or it waits on itself forever. */
1056
+ async function withCalendarLock(calendarId, run) {
1057
+ return await withKeyedLock(calendarLocks, canonicalCalendarId(calendarId), run);
1058
+ }
1059
+ //#endregion
1060
+ //#region src/google/pushDateTime.ts
1061
+ /** RFC3339 zone designator: `Z`, `+09:00`, `-0500`. */
1062
+ var ZONE_SUFFIX_RE$1 = /(?:Z|[+-]\d{2}:?\d{2})$/;
1063
+ var FRACTIONAL_SECONDS_RE$1 = /\.\d+$/;
1064
+ var TWO_DIGITS_RE = /^\d{2}$/;
1065
+ var CLOCK_SEPARATOR = ":";
1066
+ var DATE_TIME_SEPARATOR = "T";
1067
+ var MIN_CLOCK_SEGMENTS = 2;
1068
+ var MAX_CLOCK_SEGMENTS = 3;
1069
+ var WHOLE_MINUTE_SECONDS = "00";
1070
+ var MAX_SECONDS = 59;
1071
+ /** Whether `value` is a real calendar day (`2026-02-30` is not).
1072
+ *
1073
+ * Delegated to the parser the record lint, the calendar grid and the day view
1074
+ * already share, so the push agrees with the rest of the app on what a valid
1075
+ * stored date IS — and rejects an impossible one here instead of turning it
1076
+ * into an opaque Google 400. */
1077
+ var isCalendarDate = (value) => require_calendarGrid.parseIsoDate(value) !== null;
1078
+ /** A value Google already accepts verbatim, as the event time to send, or null.
1079
+ *
1080
+ * Reached when the schema maps `start` onto a `string` field: `projectValue`
1081
+ * normalises only `datetime` fields, so that column holds Google's own text,
1082
+ * zone and all. The civil part is still validated — matching a trailing `Z` is
1083
+ * not evidence that the rest is a date-time. */
1084
+ function rawGoogleTime(value) {
1085
+ if (isCalendarDate(value)) return { date: value };
1086
+ const zone = value.match(ZONE_SUFFIX_RE$1)?.[0];
1087
+ if (zone === void 0) return null;
1088
+ return require_calendarGrid.parseIsoDateTime(value.slice(0, value.length - zone.length).replace(FRACTIONAL_SECONDS_RE$1, "")) === null ? null : { dateTime: value };
1089
+ }
1090
+ /** Google's own all-day `end` is EXCLUSIVE — a single day on the 12th ends on
1091
+ * the 13th. The pull stores that as `2026-05-13T00:00` and this takes the date
1092
+ * part straight back, so the exclusive end round-trips untouched. Looks
1093
+ * off-by-one; adjusting it would shorten every all-day event by a day. */
1094
+ function allDayFrom(datePart) {
1095
+ return { date: datePart };
1096
+ }
1097
+ /** Split `HH:MM[:SS]` into RFC3339's always-three segments, or null.
1098
+ *
1099
+ * Split rather than one regex: expressing the optional seconds as `(?::(\d{2}))?`
1100
+ * nests a quantifier inside a quantified group, which the ReDoS lint rejects. */
1101
+ function clockSegments(clockPart) {
1102
+ const segments = clockPart.split(CLOCK_SEPARATOR);
1103
+ if (segments.length < MIN_CLOCK_SEGMENTS || segments.length > MAX_CLOCK_SEGMENTS) return null;
1104
+ if (!segments.every((segment) => TWO_DIGITS_RE.test(segment))) return null;
1105
+ const [hours, minutes, seconds] = segments;
1106
+ if (seconds !== void 0 && Number(seconds) > MAX_SECONDS) return null;
1107
+ return [
1108
+ hours,
1109
+ minutes,
1110
+ seconds ?? WHOLE_MINUTE_SECONDS
1111
+ ].join(CLOCK_SEPARATOR);
1112
+ }
1113
+ /** `YYYY-MM-DDTHH:MM[:SS]` → its date and its RFC3339 clock, or null.
1114
+ *
1115
+ * Validity comes from the shared strict parser, so an out-of-range clock or an
1116
+ * impossible day is refused here rather than sent to Google. */
1117
+ function parseStoredDateTime(value) {
1118
+ if (require_calendarGrid.parseIsoDateTime(value) === null) return null;
1119
+ const [datePart, clockPart, ...extra] = value.split(DATE_TIME_SEPARATOR);
1120
+ if (datePart === void 0 || clockPart === void 0 || extra.length > 0) return null;
1121
+ const clock = clockSegments(clockPart);
1122
+ return clock === null ? null : {
1123
+ datePart,
1124
+ clock
1125
+ };
1126
+ }
1127
+ /** The zone designator to re-attach, or null when `previous` carries none. */
1128
+ function zoneSuffixOf(previous) {
1129
+ if (previous === void 0) return null;
1130
+ return previous.match(ZONE_SUFFIX_RE$1)?.[0] ?? null;
1131
+ }
1132
+ /** Build the event time to send for one field.
1133
+ *
1134
+ * `previous` is the value Google last reported for this event, absent for a
1135
+ * locally-created record. Returns null when `local` is neither a stored
1136
+ * datetime nor a raw Google value — the caller reports that record rather than
1137
+ * letting this invent a time Google would silently accept. */
1138
+ function toGoogleEventTime(local, previous, calendarTimeZone) {
1139
+ if (typeof local !== "string") return null;
1140
+ const trimmed = local.trim();
1141
+ if (trimmed.length === 0) return null;
1142
+ const raw = rawGoogleTime(trimmed);
1143
+ if (raw !== null) return raw;
1144
+ const parts = parseStoredDateTime(trimmed);
1145
+ if (parts === null) return null;
1146
+ if (previous !== void 0 && isCalendarDate(previous)) return allDayFrom(parts.datePart);
1147
+ const zone = zoneSuffixOf(previous);
1148
+ const dateTime = `${parts.datePart}${DATE_TIME_SEPARATOR}${parts.clock}`;
1149
+ return zone === null ? {
1150
+ dateTime,
1151
+ timeZone: calendarTimeZone
1152
+ } : { dateTime: `${dateTime}${zone}` };
1153
+ }
1154
+ //#endregion
927
1155
  //#region src/google/collectionDateTime.ts
928
1156
  /** All-day events carry no clock; midnight is where the day view places them. */
929
1157
  var ALL_DAY_CLOCK = "00:00";
930
1158
  var DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
931
1159
  /** RFC3339 zone designator: `Z`, `+09:00`, `-0500`. */
932
- var ZONE_SUFFIX_RE$1 = /(?:Z|[+-]\d{2}:?\d{2})$/;
1160
+ var ZONE_SUFFIX_RE = /(?:Z|[+-]\d{2}:?\d{2})$/;
933
1161
  /** Fractional seconds have no place in the collection datetime shape. */
934
- var FRACTIONAL_SECONDS_RE$1 = /\.\d+$/;
1162
+ var FRACTIONAL_SECONDS_RE = /\.\d+$/;
935
1163
  /** Normalise one Google Calendar time value for storage in a `datetime` field.
936
1164
  * Anything that isn't a recognisable Google shape (empty string, non-string,
937
1165
  * free text) is returned untouched — the record lint should report it rather
@@ -941,12 +1169,10 @@ function toCollectionDateTime(value) {
941
1169
  const trimmed = value.trim();
942
1170
  if (DATE_ONLY_RE.test(trimmed)) return `${trimmed}T${ALL_DAY_CLOCK}`;
943
1171
  if (!trimmed.includes("T")) return value;
944
- return trimmed.replace(ZONE_SUFFIX_RE$1, "").replace(FRACTIONAL_SECONDS_RE$1, "");
1172
+ return trimmed.replace(ZONE_SUFFIX_RE, "").replace(FRACTIONAL_SECONDS_RE, "");
945
1173
  }
946
1174
  //#endregion
947
- //#region src/google/collectionSync.ts
948
- var GOOGLE_CALENDAR_SYNC_TASK_ID = "system:google-calendar-sync";
949
- var DEFAULT_SYNC_INTERVAL_MS = 3600 * 1e3;
1175
+ //#region src/google/collectionProjection.ts
950
1176
  var DATETIME_FIELD_TYPE = "datetime";
951
1177
  /** Own-property lookup, mirroring what the record lint reads: a spec reachable
952
1178
  * only through the prototype chain is not a DECLARED field, so it must not
@@ -971,848 +1197,1054 @@ function toCollectionRecord(event, map, primaryKey, fields) {
971
1197
  [primaryKey]: event.id
972
1198
  };
973
1199
  }
974
- function classifyWrite(eventId, kind) {
975
- if (kind === "ok") return { kind: "written" };
976
- if (kind === "invalid-id") return {
977
- kind: "unwritable",
978
- message: `write ${eventId}: invalid-id`
979
- };
980
- return {
981
- kind: "error",
982
- message: `write ${eventId}: ${kind}`
983
- };
984
- }
985
- function classifyDelete(eventId, kind) {
986
- if (kind === "ok") return { kind: "removed" };
987
- if (kind === "not-found") return { kind: "skipped" };
988
- if (kind === "invalid-id") return {
989
- kind: "unwritable",
990
- message: `delete ${eventId}: invalid-id`
991
- };
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) {
992
1208
  return {
993
- kind: "error",
994
- message: `delete ${eventId}: ${kind}`
1209
+ ...existing ?? {},
1210
+ ...projected
995
1211
  };
996
1212
  }
997
- async function applyEvent(collection, event, workspaceRoot) {
998
- const { schema } = collection;
999
- try {
1000
- const store = require_discovery.storeFor(collection, { workspaceRoot });
1001
- if (!store.write || !store.delete) return {
1002
- kind: "unwritable",
1003
- message: `collection '${collection.slug}' is read-only`
1004
- };
1005
- if (event.status === "cancelled") {
1006
- const deleted = await store.delete(event.id);
1007
- return classifyDelete(event.id, deleted.kind);
1008
- }
1009
- const record = toCollectionRecord(event, schema.googleCalendar?.map ?? {}, schema.primaryKey, schema.fields);
1010
- const written = await store.write(event.id, record);
1011
- return classifyWrite(event.id, written.kind);
1012
- } catch (error) {
1013
- return {
1014
- kind: "error",
1015
- message: `apply ${event.id}: ${String(error)}`
1016
- };
1017
- }
1018
- }
1019
- async function restartFullSync(accessToken, calendarId, workspaceRoot) {
1020
- await clearCalendarSyncToken(calendarId, workspaceRoot);
1021
- await clearCalendarShadow(calendarId, workspaceRoot);
1022
- return await syncCalendarEvents(accessToken, { calendarId });
1213
+ //#endregion
1214
+ //#region src/google/pushPlan.ts
1215
+ /** The event fields Google lets a caller write. `htmlLink` and `status` are
1216
+ * read-only, so a record column mapped to either is ignored here rather than
1217
+ * rejected — the mapping was authored for the pull, and a push has no business
1218
+ * invalidating it. */
1219
+ var PUSHABLE_SOURCE_FIELDS = [
1220
+ "summary",
1221
+ "start",
1222
+ "end",
1223
+ "colorId",
1224
+ "description",
1225
+ "location"
1226
+ ];
1227
+ var isPushableSource = (source) => PUSHABLE_SOURCE_FIELDS.includes(source);
1228
+ /** The subset of a schema's `map` a push can act on: collection field → event field. */
1229
+ function pushableMap(map) {
1230
+ return Object.fromEntries(Object.entries(map).filter((entry) => isPushableSource(entry[1])));
1023
1231
  }
1024
- /** Serialise `run` against whatever is already running for `key`.
1025
- *
1026
- * `locks` is passed in so the queuing rule is testable without module state;
1027
- * the key is dropped once nothing is queued behind it, so the map cannot grow
1028
- * an entry per calendar forever. A failed predecessor still releases the
1029
- * queue — `then(run, run)`. */
1030
- async function withKeyedLock(locks, key, run) {
1031
- const result = (locks.get(key) ?? Promise.resolve()).then(run, run);
1032
- const tail = result.then(() => void 0, () => void 0);
1033
- locks.set(key, tail);
1034
- try {
1035
- return await result;
1036
- } finally {
1037
- if (locks.get(key) === tail) locks.delete(key);
1038
- }
1232
+ /** Event field → the record column feeding it. The schema authors the map the
1233
+ * other way round (for the pull); a push looks up by event field. Two columns
1234
+ * mapped to one event field is already ambiguous for the pull — last wins. */
1235
+ function bySourceField(map) {
1236
+ return Object.fromEntries(Object.entries(map).map(([field, source]) => [source, field]));
1039
1237
  }
1040
- /** In-flight sync per canonical calendar id. Module state on purpose: the
1041
- * scheduler, the create trigger and the Refresh button are three doors into
1042
- * the same calendar (CodeRabbit review #2566). */
1043
- var calendarLocks = /* @__PURE__ */ new Map();
1044
- /** Sync ONE calendar and fan its events out to every collection bound to it.
1238
+ /** Google accepts a caller-chosen event id of 5-1024 base32hex characters. A
1239
+ * record id outside that (a semantic id like `team-standup`) cannot be used,
1240
+ * and v1 reports it instead of re-keying the record — see the plan. */
1241
+ var CLIENT_EVENT_ID_RE = /^[0-9a-v]{5,1024}$/;
1242
+ var isClientSettableEventId = (eventId) => CLIENT_EVENT_ID_RE.test(eventId);
1243
+ /** A field's value as comparable/sendable text.
1045
1244
  *
1046
- * The fan-out is not an optimisation, it is correctness: the sync token is
1047
- * keyed by `calendarId`, so syncing collection-by-collection would let the
1048
- * first collection advance the shared token and leave every later collection
1049
- * on the same calendar reading an already-consumed window — silently missing
1050
- * those events forever. Fetch once, apply to all, then advance the token.
1051
- * (Codex + CodeRabbit review on #2184.)
1245
+ * Absent, null and `""` all collapse to `""`: Google reports an inherited colour
1246
+ * as `""` while the record file may omit the key entirely, and treating those as
1247
+ * different would report a local edit on every single push.
1052
1248
  *
1053
- * Queued per calendar for the same reason the fan-out exists: two passes over
1054
- * one calendar (a Refresh click landing during the scheduled run) would each
1055
- * load the SAME stored token and walk the same window. That is idempotent —
1056
- * writes are upserts by event id — but it is a wasted full walk. Queued, the
1057
- * second pass resumes from the token the first just stored and fetches only
1058
- * what is genuinely new. */
1059
- /** Serialise anything that touches ONE calendar's sync state.
1249
+ * A non-primitive is JSON-encoded rather than left to `String`, whose
1250
+ * `[object Object]` would make two DIFFERENT objects compare equal — a
1251
+ * silently-skipped edit. */
1252
+ var fieldText = (value) => {
1253
+ if (value === void 0 || value === null) return "";
1254
+ if (typeof value === "string") return value;
1255
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
1256
+ return JSON.stringify(value) ?? "";
1257
+ };
1258
+ /** A stored datetime with no seconds — what the collection's own date-time input
1259
+ * produces. Google's values always carry seconds, so the pull writes them and a
1260
+ * hand-typed value does not. */
1261
+ var SECONDLESS_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
1262
+ var WHOLE_MINUTE = ":00";
1263
+ /** Field text with `…T09:30` and `…T09:30:00` collapsed to one spelling.
1060
1264
  *
1061
- * Shared with the push path (#2598), not only the pull doors: a push that
1062
- * overtakes an in-flight pull would record a baseline for events the pull is
1063
- * still writing, so the next push would diff against a future it never saw. */
1064
- async function withCalendarLock(calendarId, run) {
1065
- return await withKeyedLock(calendarLocks, canonicalCalendarId(calendarId), run);
1265
+ * Without this, a record the user typed a time into never stops looking edited:
1266
+ * the push writes it, Google answers with seconds, the baseline projection keeps
1267
+ * them, and the seconds-less record then differs from its own baseline on every
1268
+ * later push — re-PATCHing the same event on every click, forever.
1269
+ *
1270
+ * A value that carries a zone offset (a `start` mapped onto a `string` field) is
1271
+ * left alone: it is Google's own text on both sides already. */
1272
+ var comparableText = (value) => {
1273
+ const text = fieldText(value);
1274
+ return SECONDLESS_DATETIME_RE.test(text) ? `${text}${WHOLE_MINUTE}` : text;
1275
+ };
1276
+ /** A baseline shaped as the event it came from, so the comparison can run
1277
+ * through the very projection that wrote the record. `htmlLink`/`status` are
1278
+ * never pushable, so their absence from the baseline cannot matter. */
1279
+ var asEventSummary = (eventId, shadow) => ({
1280
+ id: eventId,
1281
+ htmlLink: "",
1282
+ status: "",
1283
+ ...shadow
1284
+ });
1285
+ /** What the record WOULD hold if nobody had edited it since the baseline.
1286
+ *
1287
+ * Deliberately `toCollectionRecord` — the same function the pull writes with —
1288
+ * so "unchanged" here means exactly "the pull would produce this". A private
1289
+ * re-implementation would drift from it and report phantom edits. */
1290
+ function baselineRecord(eventId, shadow, map, primaryKey, fields) {
1291
+ return toCollectionRecord(asEventSummary(eventId, shadow), map, primaryKey, fields);
1066
1292
  }
1067
- async function syncCalendarGroup(calendarId, collections, workspaceRoot) {
1068
- return await withCalendarLock(calendarId, () => syncCalendarGroupNow(calendarId, collections, workspaceRoot));
1293
+ /** The mapped fields whose local value no longer matches the baseline. */
1294
+ function locallyChangedFields(record, baseline, map) {
1295
+ return Object.entries(map).filter(([field]) => comparableText(record[field]) !== comparableText(baseline[field])).map(([, source]) => source);
1069
1296
  }
1070
- async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
1071
- const accessToken = await getGoogleAccessToken();
1072
- const first = await syncCalendarEvents(accessToken, {
1073
- calendarId,
1074
- syncToken: await loadCalendarSyncToken(calendarId, workspaceRoot) ?? void 0
1075
- });
1076
- const result = first.fullResyncRequired ? await restartFullSync(accessToken, calendarId, workspaceRoot) : first;
1077
- const results = [];
1078
- for (const collection of collections) results.push(await applyEventsToCollection(collection, result.events, workspaceRoot));
1079
- const unwritable = results.flatMap((entry) => entry.unwritable);
1080
- if (unwritable.length > 0) log.warn("google", "skipping calendar events that can never be stored", {
1081
- calendarId,
1082
- unwritable
1083
- });
1084
- const failed = results.flatMap((entry) => entry.errors);
1085
- if (failed.length > 0) {
1086
- log.warn("google", "holding back calendar sync token after failed writes", {
1087
- calendarId,
1088
- failed: failed.length
1089
- });
1090
- return results;
1091
- }
1092
- await saveCalendarShadow(calendarId, shadowUpdates(result.events), workspaceRoot);
1093
- if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
1094
- return results;
1297
+ /** The fields Google changed since the baseline, restricted to the ones the
1298
+ * local edit touches.
1299
+ *
1300
+ * Field-level on purpose: Google moving an event the user retitled locally is
1301
+ * not a conflict, because the patch only carries the title. Comparing whole
1302
+ * events would refuse those pushes for no reason. */
1303
+ function conflictingFields(shadow, current, changed) {
1304
+ return changed.filter((source) => comparableText(shadow[source]) !== comparableText(current[source]));
1095
1305
  }
1096
- /** The baseline this window establishes: what Google now says per event, and
1097
- * `null` for a cancelled one so a recreate cannot resume from a dead baseline. */
1098
- function shadowUpdates(events) {
1099
- return Object.fromEntries(events.map((event) => [event.id, event.status === "cancelled" ? null : toShadowEvent(event)]));
1306
+ /** Classify one record against its baseline, before Google is consulted.
1307
+ *
1308
+ * No baseline means the record never came from a sync, so it is local-only and
1309
+ * has to be created. Google is queried ONLY for a `changed` record, which is
1310
+ * what keeps a push over a large collection cheap. */
1311
+ function planRecord(eventId, record, shadow, map, primaryKey, fields) {
1312
+ if (shadow === void 0) return { kind: "create" };
1313
+ const changed = locallyChangedFields(record, baselineRecord(eventId, shadow, map, primaryKey, fields), map);
1314
+ return changed.length === 0 ? { kind: "unchanged" } : {
1315
+ kind: "changed",
1316
+ fields: changed
1317
+ };
1100
1318
  }
1101
- /** Save the window's token unless every collection that consumed it was deleted
1102
- * while the sync was in flight.
1319
+ /** Whether an event that already holds this record's id may be adopted as its
1320
+ * baseline.
1103
1321
  *
1104
- * The sync opens with a `discoverCollections()` snapshot, so a delete landing
1105
- * mid-run has already cleared this calendar's token by the time we get here
1106
- * (`releaseOrphanedCalendarToken`). Saving anyway would resurrect exactly the
1107
- * orphan the delete removed, and the next collection on this calendar would
1108
- * resume from a token describing records it never received (#2428).
1322
+ * A 409 from `insert` usually means a previous push created the event but its
1323
+ * baseline never landed. It can ALSO mean an unrelated event happens to hold
1324
+ * this id, and adopting that would bind the record to a stranger's event — so
1325
+ * adoption needs evidence the two are the same logical event, not just the same
1326
+ * id (Codex review).
1109
1327
  *
1110
- * One survivor is enough: it still needs the incremental position. */
1111
- async function advanceToken(calendarId, nextSyncToken, collections, workspaceRoot) {
1112
- if (!await anySyncedCollectionSurvives(collections)) {
1113
- log.info("google", "not advancing the sync token — every collection on this calendar was deleted mid-sync", { calendarId });
1114
- return;
1115
- }
1116
- await saveCalendarSyncToken(calendarId, nextSyncToken, workspaceRoot);
1117
- }
1118
- async function pathExists(absPath) {
1119
- try {
1120
- await (0, node_fs_promises.stat)(absPath);
1121
- return true;
1122
- } catch {
1123
- return false;
1124
- }
1328
+ * The evidence is that the remote event ALREADY equals what this record would
1329
+ * write. Adoption then records a baseline and writes nothing, so it cannot
1330
+ * modify whatever that event is. Anything else is refused. */
1331
+ function mayAdoptExisting(eventId, record, existing, map, primaryKey, fields) {
1332
+ return locallyChangedFields(record, baselineRecord(eventId, existing, map, primaryKey, fields), map).length === 0;
1125
1333
  }
1126
- /** Liveness of the collections a sync just wrote to, checked against the skill
1127
- * dir `deleteCollection` removes. `exists` is injected so the rule is testable
1128
- * without a filesystem. An empty group has no survivor by definition. */
1129
- async function anySyncedCollectionSurvives(collections, exists = pathExists) {
1130
- return (await Promise.all(collections.map((collection) => exists(collection.skillDir)))).some(Boolean);
1334
+ /** Record ids the baseline knows but the collection no longer holds — deleted
1335
+ * locally. v1 reports the count and pushes nothing: `calendarDeleteEvent`
1336
+ * removes the event for every attendee and cannot be undone. */
1337
+ function locallyDeletedIds(shadow, presentIds) {
1338
+ const present = new Set(presentIds);
1339
+ return Object.keys(shadow).filter((eventId) => !present.has(eventId));
1131
1340
  }
1132
- async function applyEventsToCollection(collection, events, workspaceRoot) {
1133
- const outcomes = [];
1134
- for (const event of events) outcomes.push(await applyEvent(collection, event, workspaceRoot));
1341
+ //#endregion
1342
+ //#region src/google/collectionPush.ts
1343
+ /** Access roles that may write events. `reader` / `freeBusyReader` cannot, and a
1344
+ * push under them fails per event with an opaque 403 — checked once up front so
1345
+ * the user is told the real reason. */
1346
+ var WRITABLE_ACCESS_ROLES = ["owner", "writer"];
1347
+ /** Whether to refuse the whole push before touching a single event.
1348
+ *
1349
+ * Only on POSITIVE evidence of a non-writable role. An unlisted calendar is
1350
+ * unknown and must fall through: hard-denying it would block the feature
1351
+ * outright for a calendar the user can in fact write to. Those attempts surface
1352
+ * Google's own 403 per record if the write really is not allowed. */
1353
+ var isDeniedAccessRole = (accessRole) => accessRole !== null && !WRITABLE_ACCESS_ROLES.includes(accessRole);
1354
+ /** Resolve the calendar a schema names, for its writability and its timezone.
1355
+ *
1356
+ * The list is consulted first because only it reports `accessRole`, which is
1357
+ * what lets a read-only calendar be refused with the real reason instead of an
1358
+ * opaque per-event 403. `"primary"` is matched on the `primary` flag, not the
1359
+ * id — the primary calendar's own id is the account's email address, which never
1360
+ * equals the literal the schema declares.
1361
+ *
1362
+ * A calendar absent from the list is NOT denied: it is fetched by id for its
1363
+ * timezone and its role left unknown. A 404 there means the calendar really is
1364
+ * unreachable, and propagates as a `failed` outcome. */
1365
+ async function liveCalendarMeta(accessToken, calendarId) {
1366
+ const key = canonicalCalendarId(calendarId);
1367
+ const calendars = await listCalendars(accessToken);
1368
+ const listed = key === "primary" ? calendars.find((calendar) => calendar.primary) : calendars.find((calendar) => calendar.id === key);
1369
+ if (listed) return {
1370
+ accessRole: listed.accessRole,
1371
+ timeZone: listed.timeZone
1372
+ };
1373
+ const direct = await getCalendar(accessToken, calendarId);
1374
+ log.info("google", "calendar is not in the user's list — pushing without an up-front role check", { calendarId: key });
1135
1375
  return {
1136
- slug: collection.slug,
1137
- written: outcomes.filter((outcome) => outcome.kind === "written").length,
1138
- removed: outcomes.filter((outcome) => outcome.kind === "removed").length,
1139
- unwritable: outcomes.flatMap((outcome) => outcome.kind === "unwritable" ? [outcome.message] : []),
1140
- errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
1376
+ accessRole: null,
1377
+ timeZone: direct.timeZone
1141
1378
  };
1142
1379
  }
1143
- /** A refresh token is what lets the scheduler run unattended; without one
1144
- * there is nothing to sync with. */
1145
- async function isGoogleLinked() {
1146
- return Boolean((await loadGoogleTokens())?.refresh_token);
1380
+ async function findCalendarCollection(slug, workspaceRoot) {
1381
+ return (await require_discovery.discoverCollections({ workspaceRoot })).find((collection) => collection.slug === slug && collection.schema.googleCalendar) ?? null;
1147
1382
  }
1148
- /** Group the declaring collections by the calendar they read, so each calendar
1149
- * is fetched exactly once.
1150
- *
1151
- * Keyed by the CANONICAL id, not the declared one: an omitted `calendarId` and
1152
- * an explicit `"primary"` address the same calendar and therefore share one
1153
- * sync token, so grouping them apart would let one group advance the token out
1154
- * from under the other — the very loss this grouping exists to prevent
1155
- * (Codex review #2184). */
1156
- function groupByCalendar(collections) {
1157
- const groups = /* @__PURE__ */ new Map();
1158
- for (const collection of collections) {
1159
- const key = canonicalCalendarId(collection.schema.googleCalendar?.calendarId);
1160
- groups.set(key, [...groups.get(key) ?? [], collection]);
1161
- }
1162
- return groups;
1383
+ var liveDeps = {
1384
+ findCollection: findCalendarCollection,
1385
+ isLinked: async () => Boolean((await loadGoogleTokens())?.refresh_token),
1386
+ accessToken: getGoogleAccessToken,
1387
+ calendarMeta: liveCalendarMeta
1388
+ };
1389
+ var localValue = (ctx, record, source) => {
1390
+ const field = ctx.bySource[source];
1391
+ return field === void 0 ? void 0 : record[field];
1392
+ };
1393
+ /** One end of the span, rebuilt from the stored clock plus whatever the baseline
1394
+ * remembers about the original (zone, all-day). */
1395
+ function eventTime(ctx, record, source, previous) {
1396
+ const value = localValue(ctx, record, source);
1397
+ if (value === void 0) return {
1398
+ ok: false,
1399
+ reason: `no \`${source}\` field is mapped`
1400
+ };
1401
+ const time = toGoogleEventTime(value, previous, ctx.timeZone);
1402
+ if (time === null) return {
1403
+ ok: false,
1404
+ reason: `\`${source}\` is not a date-time Google accepts (got ${JSON.stringify(value)})`
1405
+ };
1406
+ if ("dateTime" in time && time.timeZone === "") return {
1407
+ ok: false,
1408
+ reason: `the calendar reports no timezone, so \`${source}\` cannot be sent without one`
1409
+ };
1410
+ return {
1411
+ ok: true,
1412
+ time
1413
+ };
1163
1414
  }
1164
- /** The canonical calendar whose sync token nothing needs any more, or null.
1165
- *
1166
- * Sync tokens are keyed by calendar, NOT by collection, so a deleted
1167
- * collection's token outlives it. Recreating a collection on the same calendar
1168
- * then resumes from that token and receives only the delta — the new
1169
- * collection never gets the history (#2428).
1170
- *
1171
- * Returns null while ANY remaining collection still reads that calendar:
1172
- * clearing a live calendar's token costs a full re-walk on the next sync. The
1173
- * comparison is on the CANONICAL id for the same reason `groupByCalendar` is —
1174
- * an omitted `calendarId` and an explicit `"primary"` are one calendar. */
1175
- function orphanedCalendarId(deleted, remaining) {
1176
- if (!deleted.googleCalendar) return null;
1177
- const key = canonicalCalendarId(deleted.googleCalendar.calendarId);
1178
- return remaining.some((other) => other.googleCalendar && canonicalCalendarId(other.googleCalendar.calendarId) === key) ? null : key;
1415
+ /** `description` / `location` for a create — omitted when empty, since there is
1416
+ * nothing to state about an event that carries neither. */
1417
+ var optionalText = (ctx, record) => {
1418
+ const description = fieldText(localValue(ctx, record, "description"));
1419
+ const location = fieldText(localValue(ctx, record, "location"));
1420
+ return {
1421
+ ...description ? { description } : {},
1422
+ ...location ? { location } : {}
1423
+ };
1424
+ };
1425
+ async function createFromRecord(ctx, eventId, record) {
1426
+ if (!isClientSettableEventId(eventId)) return {
1427
+ kind: "skipped",
1428
+ message: `${eventId}: the record id cannot be used as a Google event id (needs 5-1024 characters from 0-9a-v)`
1429
+ };
1430
+ const start = eventTime(ctx, record, "start", void 0);
1431
+ const end = eventTime(ctx, record, "end", void 0);
1432
+ if (!start.ok) return {
1433
+ kind: "skipped",
1434
+ message: `${eventId}: ${start.reason}`
1435
+ };
1436
+ if (!end.ok) return {
1437
+ kind: "skipped",
1438
+ message: `${eventId}: ${end.reason}`
1439
+ };
1440
+ const colorId = fieldText(localValue(ctx, record, "colorId"));
1441
+ return {
1442
+ kind: "created",
1443
+ event: await createCalendarEvent(ctx.accessToken, {
1444
+ eventId,
1445
+ calendarId: ctx.calendarId,
1446
+ summary: fieldText(localValue(ctx, record, "summary")),
1447
+ start: start.time,
1448
+ end: end.time,
1449
+ ...optionalText(ctx, record),
1450
+ ...colorId ? { colorId } : {}
1451
+ })
1452
+ };
1179
1453
  }
1180
- /** Drop the sync token of a just-deleted collection's calendar, unless another
1181
- * collection still reads it. Call AFTER the delete lands — the check reads the
1182
- * collections that survive it.
1183
- *
1184
- * Returns the cleared calendar id, or null when nothing was cleared. Never
1185
- * throws: a failed cleanup must not fail the delete it follows. */
1186
- async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
1454
+ /** The free-text fields of a patch, each only when the local edit touched it.
1455
+ * Unlike `colorId`, `""` is a real value here — Google clears the field. */
1456
+ var textPatch = (ctx, record, changed) => {
1457
+ const touched = [
1458
+ "summary",
1459
+ "description",
1460
+ "location"
1461
+ ].filter((source) => changed.includes(source));
1462
+ return Object.fromEntries(touched.map((source) => [source, fieldText(localValue(ctx, record, source))]));
1463
+ };
1464
+ /** Only the fields the local edit touched — PATCH semantics, so an untouched
1465
+ * attendee list / recurrence rule is left alone. */
1466
+ function buildPatch(ctx, record, changed, shadow) {
1467
+ const resolved = ["start", "end"].filter((source) => changed.includes(source)).map((source) => ({
1468
+ source,
1469
+ result: eventTime(ctx, record, source, shadow[source])
1470
+ }));
1471
+ const [failure] = resolved.flatMap((entry) => entry.result.ok ? [] : [entry.result.reason]);
1472
+ if (failure !== void 0) return {
1473
+ ok: false,
1474
+ reason: failure
1475
+ };
1476
+ const colorId = fieldText(localValue(ctx, record, "colorId"));
1477
+ if (changed.includes("colorId") && colorId === "") return {
1478
+ ok: false,
1479
+ reason: "Google cannot clear an event colour once set"
1480
+ };
1481
+ return {
1482
+ ok: true,
1483
+ patch: {
1484
+ ...textPatch(ctx, record, changed),
1485
+ ...changed.includes("colorId") ? { colorId } : {},
1486
+ ...Object.fromEntries(resolved.flatMap((entry) => entry.result.ok ? [[entry.source, entry.result.time]] : []))
1487
+ }
1488
+ };
1489
+ }
1490
+ async function updateFromRecord(ctx, eventId, record, changed, shadow) {
1491
+ const fetched = await getCalendarEvent(ctx.accessToken, {
1492
+ calendarId: ctx.calendarId,
1493
+ eventId
1494
+ });
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
+ };
1499
+ if (conflictingFields(shadow, toShadowEvent(fetched.event), changed).length > 0) return { kind: "conflict" };
1500
+ const built = buildPatch(ctx, record, changed, shadow);
1501
+ if (!built.ok) return {
1502
+ kind: "skipped",
1503
+ message: `${eventId}: ${built.reason}`
1504
+ };
1187
1505
  try {
1188
- if (!deleted.googleCalendar) return null;
1189
- const orphaned = orphanedCalendarId(deleted, (await require_discovery.discoverCollections({ workspaceRoot })).map((collection) => collection.schema));
1190
- if (orphaned === null) return null;
1191
- await clearCalendarSyncToken(orphaned, workspaceRoot);
1192
- await clearCalendarShadow(orphaned, workspaceRoot);
1193
- log.info("google", "cleared the sync token of a calendar no collection reads any more", { calendarId: orphaned });
1194
- return orphaned;
1506
+ return {
1507
+ kind: "updated",
1508
+ event: await updateCalendarEvent(ctx.accessToken, {
1509
+ eventId,
1510
+ calendarId: ctx.calendarId,
1511
+ ifMatch: fetched.etag,
1512
+ ...built.patch
1513
+ })
1514
+ };
1195
1515
  } catch (error) {
1196
- log.warn("google", "could not release the deleted collection's calendar sync token", { error: String(error) });
1197
- return null;
1516
+ if (isGoogleApiError(error) && error.status === 412) return { kind: "conflict" };
1517
+ throw error;
1198
1518
  }
1199
1519
  }
1200
- /** Every declaring collection, grouped by the calendar it reads. */
1201
- async function declaringGroups(workspaceRoot) {
1202
- return groupByCalendar((await require_discovery.discoverCollections({ workspaceRoot })).filter((collection) => collection.schema.googleCalendar));
1203
- }
1204
- /** Whether a background sync of these groups may run at all.
1520
+ /** Create, recovering from the one 409 that is provably safe to recover from.
1205
1521
  *
1206
- * Authoring the collection before linking the account is an expected state,
1207
- * not a failure. Checking once here keeps it a quiet skip instead of an
1208
- * access-token throw per calendar, every hour, until the user links (#2188).
1209
- * A user-triggered sync answers differently — it says so out loud. */
1210
- async function backgroundSyncAllowed(groups) {
1211
- if (groups.size === 0) return false;
1212
- if (await isGoogleLinked()) return true;
1213
- log.info("google", "skipping calendar sync — no Google account linked on this host", { calendars: groups.size });
1214
- return false;
1215
- }
1216
- /** Run each group, isolating failures per calendar — one unreachable calendar
1217
- * (or a revoked grant) must not stop the others. */
1218
- async function runCalendarGroups(groups, workspaceRoot) {
1219
- const results = [];
1220
- for (const [calendarId, collections] of groups) try {
1221
- results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot));
1522
+ * A 409 means the id is taken, which is USUALLY a previous push whose baseline
1523
+ * never landed. It can also be an unrelated event that happens to hold this id,
1524
+ * so the recovery never writes: it adopts the baseline only when the remote
1525
+ * event already equals what this record would write, and otherwise reports the
1526
+ * fix. Patching whatever answered the 409 would let a push modify a stranger's
1527
+ * event (Codex review).
1528
+ *
1529
+ * Refusal is not a dead end — a Sync writes both the record and its baseline,
1530
+ * after which the record pushes normally. */
1531
+ async function createOrAdopt(ctx, eventId, record) {
1532
+ try {
1533
+ return await createFromRecord(ctx, eventId, record);
1222
1534
  } catch (error) {
1223
- log.warn("google", "calendar sync failed", {
1224
- calendarId,
1225
- error: String(error)
1535
+ if (!isGoogleApiError(error) || error.status !== 409) throw error;
1536
+ const fetched = await getCalendarEvent(ctx.accessToken, {
1537
+ calendarId: ctx.calendarId,
1538
+ eventId
1226
1539
  });
1227
- results.push(...collections.map((collection) => ({
1228
- slug: collection.slug,
1229
- written: 0,
1230
- removed: 0,
1231
- unwritable: [],
1232
- errors: [String(error)]
1233
- })));
1540
+ if (fetched === null) throw error;
1541
+ if (mayAdoptExisting(eventId, record, toShadowEvent(fetched.event), ctx.map, ctx.primaryKey, ctx.fields)) return {
1542
+ kind: "unchanged",
1543
+ event: fetched.event
1544
+ };
1545
+ return {
1546
+ kind: "skipped",
1547
+ message: `${eventId}: an event with this id already exists in Google and differs from this record — press Sync to adopt it, then push again`
1548
+ };
1234
1549
  }
1235
- return results;
1236
- }
1237
- /** Sync every collection that declares `googleCalendar`. */
1238
- async function syncDueCalendarCollections(workspaceRoot) {
1239
- const groups = await declaringGroups(workspaceRoot);
1240
- return await backgroundSyncAllowed(groups) ? await runCalendarGroups(groups, workspaceRoot) : [];
1241
- }
1242
- /** The groups whose calendar has never synced. A missing token IS the "created
1243
- * since the last sync" signal — nothing else distinguishes a new collection
1244
- * from an edited one on the write path this feeds (#2427).
1245
- *
1246
- * Self-silencing by construction: the first sync stores a token, so a calendar
1247
- * matches at most once. `loadToken` is injected so the rule is testable without
1248
- * a workspace on disk. */
1249
- async function unsyncedGroups(groups, loadToken) {
1250
- const checked = await Promise.all([...groups].map(async (entry) => await loadToken(entry[0]) === null ? entry : null));
1251
- return new Map(checked.filter((entry) => entry !== null));
1252
- }
1253
- /** Sync only the calendars that have never synced — the first sync for a
1254
- * just-created collection, which otherwise stays empty until the hourly
1255
- * scheduler run (#2427). Cheap and safe to call on every config write. */
1256
- async function syncNewCalendarCollections(workspaceRoot) {
1257
- const pending = await unsyncedGroups(await declaringGroups(workspaceRoot), (calendarId) => loadCalendarSyncToken(calendarId, workspaceRoot));
1258
- if (!await backgroundSyncAllowed(pending)) return [];
1259
- log.info("google", "running the first sync for newly declared calendars", { calendars: [...pending.keys()] });
1260
- return await runCalendarGroups(pending, workspaceRoot);
1261
1550
  }
1262
- var liveManualSyncDeps = {
1263
- loadGroups: declaringGroups,
1264
- isLinked: isGoogleLinked,
1265
- runGroups: runCalendarGroups
1266
- };
1267
- /** Sync the calendar ONE collection reads, on demand (the Refresh button).
1268
- *
1269
- * Deliberately syncs the whole group, not just `slug`: the sync token is keyed
1270
- * by calendar, so consuming a window for one collection would leave the others
1271
- * on that calendar reading an already-consumed one. Returns every result of the
1272
- * group so the caller can report the requested slug's own counts.
1273
- *
1274
- * "Does this collection sync at all" is answered BEFORE "is Google linked":
1275
- * telling someone to link their account for a collection that never declared a
1276
- * calendar sends them fixing the wrong thing. */
1277
- async function syncCalendarForCollection(slug, workspaceRoot, deps = liveManualSyncDeps) {
1278
- const owning = [...await deps.loadGroups(workspaceRoot)].filter(([, collections]) => collections.some((collection) => collection.slug === slug));
1279
- if (owning.length === 0) return { kind: "not-a-calendar" };
1280
- if (!await deps.isLinked()) return { kind: "not-linked" };
1281
- return {
1282
- kind: "synced",
1283
- results: await deps.runGroups(new Map(owning), workspaceRoot)
1551
+ /** A 403 on a write is a permissions answer, but `googleApiError` appends the
1552
+ * "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 role check could not
1554
+ * run because the calendar is not in the user's list, which is exactly when the
1555
+ * user needs the real reason. */
1556
+ function writeFailure(eventId, error) {
1557
+ if (isGoogleApiError(error) && error.status === 403) return {
1558
+ kind: "skipped",
1559
+ message: `${eventId}: Google refused the write — you may not have permission to change events on this calendar`
1284
1560
  };
1285
- }
1286
- /** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
1287
- * with a single line. */
1288
- function googleCalendarSyncTaskDef(opts) {
1289
1561
  return {
1290
- id: GOOGLE_CALENDAR_SYNC_TASK_ID,
1291
- name: "Google Calendar sync",
1292
- description: "Pulls changed Google Calendar events into any collection declaring `googleCalendar`, without invoking the LLM.",
1293
- schedule: {
1294
- type: _receptron_task_scheduler.SCHEDULE_TYPES.interval,
1295
- intervalMs: opts?.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS
1296
- },
1297
- missedRunPolicy: _receptron_task_scheduler.MISSED_RUN_POLICIES.runOnce,
1298
- run: () => syncDueCalendarCollections(opts?.workspaceRoot ?? require_discovery.getWorkspaceRoot()).then(() => {})
1562
+ kind: "error",
1563
+ message: `${eventId}: ${String(error)}`
1299
1564
  };
1300
1565
  }
1301
- //#endregion
1302
- //#region src/google/pushDateTime.ts
1303
- /** RFC3339 zone designator: `Z`, `+09:00`, `-0500`. */
1304
- var ZONE_SUFFIX_RE = /(?:Z|[+-]\d{2}:?\d{2})$/;
1305
- var FRACTIONAL_SECONDS_RE = /\.\d+$/;
1306
- var TWO_DIGITS_RE = /^\d{2}$/;
1307
- var CLOCK_SEPARATOR = ":";
1308
- var DATE_TIME_SEPARATOR = "T";
1309
- var MIN_CLOCK_SEGMENTS = 2;
1310
- var MAX_CLOCK_SEGMENTS = 3;
1311
- var WHOLE_MINUTE_SECONDS = "00";
1312
- var MAX_SECONDS = 59;
1313
- /** Whether `value` is a real calendar day (`2026-02-30` is not).
1314
- *
1315
- * Delegated to the parser the record lint, the calendar grid and the day view
1316
- * already share, so the push agrees with the rest of the app on what a valid
1317
- * stored date IS — and rejects an impossible one here instead of turning it
1318
- * into an opaque Google 400. */
1319
- var isCalendarDate = (value) => require_calendarGrid.parseIsoDate(value) !== null;
1320
- /** A value Google already accepts verbatim, as the event time to send, or null.
1321
- *
1322
- * Reached when the schema maps `start` onto a `string` field: `projectValue`
1323
- * normalises only `datetime` fields, so that column holds Google's own text,
1324
- * zone and all. The civil part is still validated — matching a trailing `Z` is
1325
- * not evidence that the rest is a date-time. */
1326
- function rawGoogleTime(value) {
1327
- if (isCalendarDate(value)) return { date: value };
1328
- const zone = value.match(ZONE_SUFFIX_RE)?.[0];
1329
- if (zone === void 0) return null;
1330
- return require_calendarGrid.parseIsoDateTime(value.slice(0, value.length - zone.length).replace(FRACTIONAL_SECONDS_RE, "")) === null ? null : { dateTime: value };
1566
+ async function pushRecord(ctx, eventId, record) {
1567
+ try {
1568
+ const shadow = ctx.shadow[eventId];
1569
+ const plan = planRecord(eventId, record, shadow, ctx.map, ctx.primaryKey, ctx.fields);
1570
+ if (plan.kind === "unchanged") return { kind: "unchanged" };
1571
+ if (plan.kind === "create" || shadow === void 0) return await createOrAdopt(ctx, eventId, record);
1572
+ return await updateFromRecord(ctx, eventId, record, plan.fields, shadow);
1573
+ } catch (error) {
1574
+ return writeFailure(eventId, error);
1575
+ }
1331
1576
  }
1332
- /** Google's own all-day `end` is EXCLUSIVE — a single day on the 12th ends on
1333
- * the 13th. The pull stores that as `2026-05-13T00:00` and this takes the date
1334
- * part straight back, so the exclusive end round-trips untouched. Looks
1335
- * off-by-one; adjusting it would shorten every all-day event by a day. */
1336
- function allDayFrom(datePart) {
1337
- return { date: datePart };
1577
+ /** The baseline an outcome establishes, so the next push sees the record as
1578
+ * unchanged instead of pushing it again. Empty for anything that wrote nothing
1579
+ * and had no baseline to repair. */
1580
+ function pushedShadow(outcomes) {
1581
+ const written = outcomes.flatMap((outcome) => "event" in outcome && outcome.event !== void 0 ? [outcome.event] : []);
1582
+ return Object.fromEntries(written.map((event) => [event.id, toShadowEvent(event)]));
1338
1583
  }
1339
- /** Split `HH:MM[:SS]` into RFC3339's always-three segments, or null.
1340
- *
1341
- * Split rather than one regex: expressing the optional seconds as `(?::(\d{2}))?`
1342
- * nests a quantifier inside a quantified group, which the ReDoS lint rejects. */
1343
- function clockSegments(clockPart) {
1344
- const segments = clockPart.split(CLOCK_SEPARATOR);
1345
- if (segments.length < MIN_CLOCK_SEGMENTS || segments.length > MAX_CLOCK_SEGMENTS) return null;
1346
- if (!segments.every((segment) => TWO_DIGITS_RE.test(segment))) return null;
1347
- const [hours, minutes, seconds] = segments;
1348
- if (seconds !== void 0 && Number(seconds) > MAX_SECONDS) return null;
1349
- return [
1350
- hours,
1351
- minutes,
1352
- seconds ?? WHOLE_MINUTE_SECONDS
1353
- ].join(CLOCK_SEPARATOR);
1584
+ /** See `unpushedIds` on the result for why `skipped` is not in this set. */
1585
+ var UNPUSHED_KINDS = ["conflict", "error"];
1586
+ /** Whether this outcome leaves a local edit that only exists locally, so the
1587
+ * pull must not overwrite the record and the baseline must not advance. */
1588
+ var isUnpushed = (kind) => UNPUSHED_KINDS.includes(kind);
1589
+ function tally(slug, attempts, localDeletes) {
1590
+ const outcomes = attempts.map((attempt) => attempt.outcome);
1591
+ const count = (kind) => outcomes.filter((outcome) => outcome.kind === kind).length;
1592
+ return {
1593
+ slug,
1594
+ created: count("created"),
1595
+ updated: count("updated"),
1596
+ conflicts: count("conflict"),
1597
+ localDeletes,
1598
+ skipped: outcomes.flatMap((outcome) => outcome.kind === "skipped" ? [outcome.message] : []),
1599
+ errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : []),
1600
+ unpushedIds: attempts.filter((attempt) => isUnpushed(attempt.outcome.kind)).map((attempt) => attempt.eventId)
1601
+ };
1354
1602
  }
1355
- /** `YYYY-MM-DDTHH:MM[:SS]` → its date and its RFC3339 clock, or null.
1603
+ /** Record ids whose local value no longer matches the baseline — edits that have
1604
+ * not reached Google.
1356
1605
  *
1357
- * Validity comes from the shared strict parser, so an out-of-range clock or an
1358
- * impossible day is refused here rather than sent to Google. */
1359
- function parseStoredDateTime(value) {
1360
- if (require_calendarGrid.parseIsoDateTime(value) === null) return null;
1361
- const [datePart, clockPart, ...extra] = value.split(DATE_TIME_SEPARATOR);
1362
- if (datePart === void 0 || clockPart === void 0 || extra.length > 0) return null;
1363
- const clock = clockSegments(clockPart);
1364
- return clock === null ? null : {
1365
- datePart,
1366
- clock
1367
- };
1606
+ * Pure, and deliberately built from `planRecord`, so it answers with exactly the
1607
+ * records a push WOULD have acted on. A private re-implementation would drift
1608
+ * from the push and protect the wrong set. */
1609
+ function locallyEditedIds(records, shadow, map, primaryKey, fields) {
1610
+ return records.map((record) => ({
1611
+ eventId: fieldText(record[primaryKey]),
1612
+ record
1613
+ })).filter(({ eventId, record }) => planRecord(eventId, record, shadow[eventId], map, primaryKey, fields).kind !== "unchanged").map(({ eventId }) => eventId);
1614
+ }
1615
+ /** The same set, read from the workspace.
1616
+ *
1617
+ * Needed when a push cannot run AT ALL — a calendar whose role degraded to
1618
+ * reader, a revoked grant, an IO failure. The pull still runs, since reading
1619
+ * needs no write access, and would overwrite exactly these records while
1620
+ * advancing their baseline past the edit (CodeRabbit review #2666). Computed
1621
+ * from the stored baseline alone, so it works precisely when Google is the
1622
+ * thing that is unreachable. */
1623
+ async function unsentLocalEdits(collection, workspaceRoot) {
1624
+ const { schema } = collection;
1625
+ const shadow = await loadCalendarShadow(schema.googleCalendar?.calendarId, workspaceRoot);
1626
+ return locallyEditedIds(await require_discovery.storeFor(collection, { workspaceRoot }).list(), shadow, pushableMap(schema.googleCalendar?.map ?? {}), schema.primaryKey, schema.fields);
1368
1627
  }
1369
- /** The zone designator to re-attach, or null when `previous` carries none. */
1370
- function zoneSuffixOf(previous) {
1371
- if (previous === void 0) return null;
1372
- return previous.match(ZONE_SUFFIX_RE)?.[0] ?? null;
1628
+ /** Push one collection WITHOUT taking the calendar lock.
1629
+ *
1630
+ * The caller must already hold it. The scheduled push→pull cycle
1631
+ * (`collectionSync.ts`) runs inside the lock it took for the pull, and the lock
1632
+ * is not reentrant — going through `pushCalendarForCollection` there would make
1633
+ * the sync wait on itself forever. */
1634
+ async function pushCollectionNow(collection, workspaceRoot, deps = liveDeps) {
1635
+ const { schema, slug } = collection;
1636
+ const calendarId = schema.googleCalendar?.calendarId;
1637
+ const accessToken = await deps.accessToken();
1638
+ const meta = await deps.calendarMeta(accessToken, calendarId);
1639
+ if (isDeniedAccessRole(meta.accessRole)) return {
1640
+ kind: "read-only",
1641
+ accessRole: meta.accessRole ?? ""
1642
+ };
1643
+ const map = pushableMap(schema.googleCalendar?.map ?? {});
1644
+ const shadow = await loadCalendarShadow(calendarId, workspaceRoot);
1645
+ const records = await require_discovery.storeFor(collection, { workspaceRoot }).list();
1646
+ const ctx = {
1647
+ accessToken,
1648
+ calendarId,
1649
+ timeZone: meta.timeZone,
1650
+ map,
1651
+ bySource: bySourceField(map),
1652
+ primaryKey: schema.primaryKey,
1653
+ fields: schema.fields,
1654
+ shadow
1655
+ };
1656
+ const attempts = [];
1657
+ for (const record of records) {
1658
+ const eventId = fieldText(record[schema.primaryKey]);
1659
+ const outcome = await pushRecord(ctx, eventId, record);
1660
+ attempts.push({
1661
+ eventId,
1662
+ outcome
1663
+ });
1664
+ await saveCalendarShadow(calendarId, pushedShadow([outcome]), workspaceRoot);
1665
+ }
1666
+ const result = tally(slug, attempts, locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))).length);
1667
+ if (result.errors.length > 0) log.warn("google", "calendar push finished with errors", {
1668
+ slug,
1669
+ errors: result.errors.length
1670
+ });
1671
+ return {
1672
+ kind: "pushed",
1673
+ result
1674
+ };
1373
1675
  }
1374
- /** Build the event time to send for one field.
1676
+ /** Push ONE collection's records to the calendar it declares.
1375
1677
  *
1376
- * `previous` is the value Google last reported for this event, absent for a
1377
- * locally-created record. Returns null when `local` is neither a stored
1378
- * datetime nor a raw Google value — the caller reports that record rather than
1379
- * letting this invent a time Google would silently accept. */
1380
- function toGoogleEventTime(local, previous, calendarTimeZone) {
1381
- if (typeof local !== "string") return null;
1382
- const trimmed = local.trim();
1383
- if (trimmed.length === 0) return null;
1384
- const raw = rawGoogleTime(trimmed);
1385
- if (raw !== null) return raw;
1386
- const parts = parseStoredDateTime(trimmed);
1387
- if (parts === null) return null;
1388
- if (previous !== void 0 && isCalendarDate(previous)) return allDayFrom(parts.datePart);
1389
- const zone = zoneSuffixOf(previous);
1390
- const dateTime = `${parts.datePart}${DATE_TIME_SEPARATOR}${parts.clock}`;
1391
- return zone === null ? {
1392
- dateTime,
1393
- timeZone: calendarTimeZone
1394
- } : { dateTime: `${dateTime}${zone}` };
1678
+ * "Does this collection sync at all" is answered before "is Google linked", for
1679
+ * the reason `syncCalendarForCollection` gives: telling someone to link their
1680
+ * account for a collection that never declared a calendar sends them fixing the
1681
+ * wrong thing. */
1682
+ async function pushCalendarForCollection(slug, workspaceRoot, deps = liveDeps) {
1683
+ try {
1684
+ const collection = await deps.findCollection(slug, workspaceRoot);
1685
+ if (collection === null) return { kind: "not-a-calendar" };
1686
+ if (!await deps.isLinked()) return { kind: "not-linked" };
1687
+ return await withCalendarLock(collection.schema.googleCalendar?.calendarId, () => pushCollectionNow(collection, workspaceRoot, deps));
1688
+ } catch (error) {
1689
+ log.warn("google", "calendar push could not start", {
1690
+ slug,
1691
+ error: String(error)
1692
+ });
1693
+ return {
1694
+ kind: "failed",
1695
+ message: String(error)
1696
+ };
1697
+ }
1395
1698
  }
1396
1699
  //#endregion
1397
- //#region src/google/pushPlan.ts
1398
- /** The event fields Google lets a caller write. `htmlLink` and `status` are
1399
- * read-only, so a record column mapped to either is ignored here rather than
1400
- * rejected — the mapping was authored for the pull, and a push has no business
1401
- * invalidating it. */
1402
- var PUSHABLE_SOURCE_FIELDS = [
1403
- "summary",
1404
- "start",
1405
- "end",
1406
- "colorId"
1407
- ];
1408
- var isPushableSource = (source) => PUSHABLE_SOURCE_FIELDS.includes(source);
1409
- /** The subset of a schema's `map` a push can act on: collection field → event field. */
1410
- function pushableMap(map) {
1411
- return Object.fromEntries(Object.entries(map).filter((entry) => isPushableSource(entry[1])));
1700
+ //#region src/google/collectionSync.ts
1701
+ var GOOGLE_CALENDAR_SYNC_TASK_ID = "system:google-calendar-sync";
1702
+ var DEFAULT_SYNC_INTERVAL_MS = 3600 * 1e3;
1703
+ function classifyWrite(eventId, kind) {
1704
+ if (kind === "ok") return { kind: "written" };
1705
+ if (kind === "invalid-id") return {
1706
+ kind: "unwritable",
1707
+ message: `write ${eventId}: invalid-id`
1708
+ };
1709
+ return {
1710
+ kind: "error",
1711
+ message: `write ${eventId}: ${kind}`
1712
+ };
1412
1713
  }
1413
- /** Event field → the record column feeding it. The schema authors the map the
1414
- * other way round (for the pull); a push looks up by event field. Two columns
1415
- * mapped to one event field is already ambiguous for the pull — last wins. */
1416
- function bySourceField(map) {
1417
- return Object.fromEntries(Object.entries(map).map(([field, source]) => [source, field]));
1714
+ function classifyDelete(eventId, kind) {
1715
+ if (kind === "ok") return { kind: "removed" };
1716
+ if (kind === "not-found") return { kind: "skipped" };
1717
+ if (kind === "invalid-id") return {
1718
+ kind: "unwritable",
1719
+ message: `delete ${eventId}: invalid-id`
1720
+ };
1721
+ return {
1722
+ kind: "error",
1723
+ message: `delete ${eventId}: ${kind}`
1724
+ };
1418
1725
  }
1419
- /** Google accepts a caller-chosen event id of 5-1024 base32hex characters. A
1420
- * record id outside that (a semantic id like `team-standup`) cannot be used,
1421
- * and v1 reports it instead of re-keying the record — see the plan. */
1422
- var CLIENT_EVENT_ID_RE = /^[0-9a-v]{5,1024}$/;
1423
- var isClientSettableEventId = (eventId) => CLIENT_EVENT_ID_RE.test(eventId);
1424
- /** A field's value as comparable/sendable text.
1726
+ async function applyEvent(collection, event, workspaceRoot) {
1727
+ const { schema } = collection;
1728
+ try {
1729
+ const store = require_discovery.storeFor(collection, { workspaceRoot });
1730
+ if (!store.write || !store.delete) return {
1731
+ kind: "unwritable",
1732
+ message: `collection '${collection.slug}' is read-only`
1733
+ };
1734
+ if (event.status === "cancelled") {
1735
+ const deleted = await store.delete(event.id);
1736
+ return classifyDelete(event.id, deleted.kind);
1737
+ }
1738
+ const record = toCollectionRecord(event, schema.googleCalendar?.map ?? {}, schema.primaryKey, schema.fields);
1739
+ const written = await store.write(event.id, mergeIntoExisting(await store.read(event.id), record));
1740
+ return classifyWrite(event.id, written.kind);
1741
+ } catch (error) {
1742
+ return {
1743
+ kind: "error",
1744
+ message: `apply ${event.id}: ${String(error)}`
1745
+ };
1746
+ }
1747
+ }
1748
+ /** The events of a window a pull may act on.
1425
1749
  *
1426
- * Absent, null and `""` all collapse to `""`: Google reports an inherited colour
1427
- * as `""` while the record file may omit the key entirely, and treating those as
1428
- * different would report a local edit on every single push.
1750
+ * A record the push just refused to send is edited on BOTH sides. Writing
1751
+ * Google's value over it destroys the local edit the push declined to resolve,
1752
+ * so the local one stands. Single-sourced because the record write and the
1753
+ * baseline write must agree exactly on which events they skip — disagreeing is
1754
+ * what would silently overwrite Google on the next push (#2620). */
1755
+ function pullableEvents(events, unpushed) {
1756
+ return events.filter((event) => !unpushed.has(event.id));
1757
+ }
1758
+ /** Report what an automatic push did, since nobody is watching a scheduled run.
1759
+ * A conflict or an error means that record now diverges from Google until
1760
+ * someone resolves it, which must not be silent. */
1761
+ function reportAutoPush(slug, result) {
1762
+ const { created, updated, conflicts, skipped, errors, unpushedIds } = result;
1763
+ if (created + updated > 0) log.info("google", "auto-pushed local calendar edits", {
1764
+ slug,
1765
+ created,
1766
+ updated
1767
+ });
1768
+ if (unpushedIds.length > 0) log.warn("google", "records the auto push could not send — the pull will leave them alone", {
1769
+ slug,
1770
+ conflicts,
1771
+ unpushedIds
1772
+ });
1773
+ if (skipped.length > 0) log.warn("google", "records the auto push skipped", {
1774
+ slug,
1775
+ skipped
1776
+ });
1777
+ if (errors.length > 0) log.warn("google", "auto push errors", {
1778
+ slug,
1779
+ errors
1780
+ });
1781
+ }
1782
+ var NOTHING_UNPUSHED = /* @__PURE__ */ new Set();
1783
+ /** A collection whose protection is unknown must not be pulled this run.
1429
1784
  *
1430
- * A non-primitive is JSON-encoded rather than left to `String`, whose
1431
- * `[object Object]` would make two DIFFERENT objects compare equal — a
1432
- * silently-skipped edit. */
1433
- var fieldText = (value) => {
1434
- if (value === void 0 || value === null) return "";
1435
- if (typeof value === "string") return value;
1436
- if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
1437
- return JSON.stringify(value) ?? "";
1785
+ * Reported through the retryable `errors` channel rather than as a special case:
1786
+ * that already holds the sync token AND skips the baseline save, so the window
1787
+ * simply replays next run with nothing lost. Failing OPEN here — pulling with no
1788
+ * protection — would overwrite the very edits this exists to protect; the read
1789
+ * that failed is no evidence that the pull's own writes would fail too, so they
1790
+ * would land (CodeRabbit review #2666). */
1791
+ var PROTECTION_UNKNOWN = "could not work out which records to protect after a failed push";
1792
+ /** What ONE collection's pull must leave alone: only what ITS OWN push failed to
1793
+ * send.
1794
+ *
1795
+ * Scoped per collection because a calendar can back several of them, and a
1796
+ * conflict in one says nothing about the others. Sharing one set across the
1797
+ * group starved a collection that never even declares `autoPush`: it cannot
1798
+ * conflict, yet a neighbour's conflict froze its records — and the sync token
1799
+ * still advanced, so Google never resent them (Codex review #2666). */
1800
+ var unpushedFor = (unpushed, slug) => {
1801
+ const protection = unpushed.get(slug);
1802
+ return protection === void 0 ? NOTHING_UNPUSHED : protection;
1438
1803
  };
1439
- /** A stored datetime with no seconds — what the collection's own date-time input
1440
- * produces. Google's values always carry seconds, so the pull writes them and a
1441
- * hand-typed value does not. */
1442
- var SECONDLESS_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
1443
- var WHOLE_MINUTE = ":00";
1444
- /** Field text with `…T09:30` and `…T09:30:00` collapsed to one spelling.
1804
+ /** What the calendar's BASELINE must leave alone: the union over every
1805
+ * collection.
1445
1806
  *
1446
- * Without this, a record the user typed a time into never stops looking edited:
1447
- * the push writes it, Google answers with seconds, the baseline projection keeps
1448
- * them, and the seconds-less record then differs from its own baseline on every
1449
- * later push — re-PATCHing the same event on every click, forever.
1807
+ * Deliberately not per collection, unlike the records above. `.push-state.json`
1808
+ * holds ONE baseline per calendar, shared by every collection on it, so there is
1809
+ * no per-collection baseline to hold back. Advancing it while any collection
1810
+ * still has an unresolved conflict is the failure that silently overwrites
1811
+ * Google on the next push, and holding it back only ever means "keep reporting
1812
+ * the conflict" — so the union is the safe side of an asymmetry the shared
1813
+ * storage forces.
1450
1814
  *
1451
- * A value that carries a zone offset (a `start` mapped onto a `string` field) is
1452
- * left alone: it is Google's own text on both sides already. */
1453
- var comparableText = (value) => {
1454
- const text = fieldText(value);
1455
- return SECONDLESS_DATETIME_RE.test(text) ? `${text}${WHOLE_MINUTE}` : text;
1456
- };
1457
- /** A baseline shaped as the event it came from, so the comparison can run
1458
- * through the very projection that wrote the record. `htmlLink`/`status` are
1459
- * never pushable, so their absence from the baseline cannot matter. */
1460
- var asEventSummary = (eventId, shadow) => ({
1461
- id: eventId,
1462
- htmlLink: "",
1463
- status: "",
1464
- ...shadow
1465
- });
1466
- /** What the record WOULD hold if nobody had edited it since the baseline.
1815
+ * A `null` (unknown) entry contributes nothing, because that collection reports
1816
+ * a retryable error instead — which stops the baseline being saved at all. */
1817
+ var allUnpushed = (unpushed) => new Set([...unpushed.values()].flatMap((ids) => ids === null ? [] : [...ids]));
1818
+ /** What a collection's pull must protect when its push did not run AT ALL.
1467
1819
  *
1468
- * Deliberately `toCollectionRecord` — the same function the pull writes with —
1469
- * so "unchanged" here means exactly "the pull would produce this". A private
1470
- * re-implementation would drift from it and report phantom edits. */
1471
- function baselineRecord(eventId, shadow, map, primaryKey, fields) {
1472
- return toCollectionRecord(asEventSummary(eventId, shadow), map, primaryKey, fields);
1473
- }
1474
- /** The mapped fields whose local value no longer matches the baseline. */
1475
- function locallyChangedFields(record, baseline, map) {
1476
- return Object.entries(map).filter(([field]) => comparableText(record[field]) !== comparableText(baseline[field])).map(([, source]) => source);
1820
+ * Registering nothing here was a silent data loss of exactly the kind this
1821
+ * feature exists to prevent: a calendar whose role degrades to reader refuses
1822
+ * the whole push, yet the pull still runs — reading needs no write access — and
1823
+ * 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).
1825
+ *
1826
+ * Protects the edited records rather than all of them, so an unchanged record
1827
+ * keeps syncing normally. `null` when even that could not be worked out — the
1828
+ * caller then refuses to pull the collection at all, because failing open here
1829
+ * destroys exactly what this protects. */
1830
+ async function protectUnsentEdits(collection, workspaceRoot) {
1831
+ try {
1832
+ const edited = await unsentLocalEdits(collection, workspaceRoot);
1833
+ if (edited.length > 0) log.warn("google", "protecting local edits a failed push could not send", {
1834
+ slug: collection.slug,
1835
+ edited
1836
+ });
1837
+ return new Set(edited);
1838
+ } catch (error) {
1839
+ log.warn("google", PROTECTION_UNKNOWN, {
1840
+ slug: collection.slug,
1841
+ error: String(error)
1842
+ });
1843
+ return null;
1844
+ }
1477
1845
  }
1478
- /** The fields Google changed since the baseline, restricted to the ones the
1479
- * local edit touches.
1846
+ /** Push every `autoPush` collection in this group, and answer with the records
1847
+ * whose local edit did not reach Google, keyed by collection.
1480
1848
  *
1481
- * Field-level on purpose: Google moving an event the user retitled locally is
1482
- * not a conflict, because the patch only carries the title. Comparing whole
1483
- * events would refuse those pushes for no reason. */
1484
- function conflictingFields(shadow, current, changed) {
1485
- return changed.filter((source) => comparableText(shadow[source]) !== comparableText(current[source]));
1849
+ * MUST run inside the calendar lock the caller already holds — hence
1850
+ * `pushCollectionNow` rather than `pushCalendarForCollection`, which would take
1851
+ * the same non-reentrant lock and wait on itself forever.
1852
+ *
1853
+ * A failed push must not stop the pull: the pull is what keeps the collection
1854
+ * fresh, and a revoked write grant is no reason to freeze reading. */
1855
+ async function pushAutoCollections(collections, workspaceRoot) {
1856
+ const unpushed = /* @__PURE__ */ new Map();
1857
+ for (const collection of collections.filter((entry) => entry.schema.googleCalendar?.autoPush)) try {
1858
+ const outcome = await pushCollectionNow(collection, workspaceRoot);
1859
+ if (outcome.kind === "pushed") {
1860
+ reportAutoPush(collection.slug, outcome.result);
1861
+ unpushed.set(collection.slug, new Set(outcome.result.unpushedIds));
1862
+ continue;
1863
+ }
1864
+ log.warn("google", "auto push did not run", {
1865
+ slug: collection.slug,
1866
+ reason: outcome.kind
1867
+ });
1868
+ unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
1869
+ } catch (error) {
1870
+ log.warn("google", "auto push failed — pulling anyway", {
1871
+ slug: collection.slug,
1872
+ error: String(error)
1873
+ });
1874
+ unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
1875
+ }
1876
+ return unpushed;
1877
+ }
1878
+ async function restartFullSync(accessToken, calendarId, workspaceRoot) {
1879
+ await clearCalendarSyncToken(calendarId, workspaceRoot);
1880
+ await clearCalendarShadow(calendarId, workspaceRoot);
1881
+ return await syncCalendarEvents(accessToken, { calendarId });
1486
1882
  }
1487
- /** Classify one record against its baseline, before Google is consulted.
1883
+ var ALWAYS_CLAIM = () => true;
1884
+ /** Sync ONE calendar and fan its events out to every collection bound to it.
1488
1885
  *
1489
- * No baseline means the record never came from a sync, so it is local-only and
1490
- * has to be created. Google is queried ONLY for a `changed` record, which is
1491
- * what keeps a push over a large collection cheap. */
1492
- function planRecord(eventId, record, shadow, map, primaryKey, fields) {
1493
- if (shadow === void 0) return { kind: "create" };
1494
- const changed = locallyChangedFields(record, baselineRecord(eventId, shadow, map, primaryKey, fields), map);
1495
- return changed.length === 0 ? { kind: "unchanged" } : {
1496
- kind: "changed",
1497
- fields: changed
1498
- };
1886
+ * The fan-out is not an optimisation, it is correctness: the sync token is
1887
+ * keyed by `calendarId`, so syncing collection-by-collection would let the
1888
+ * first collection advance the shared token and leave every later collection
1889
+ * on the same calendar reading an already-consumed window — silently missing
1890
+ * those events forever. Fetch once, apply to all, then advance the token.
1891
+ * (Codex + CodeRabbit review on #2184.)
1892
+ *
1893
+ * Queued per calendar for the same reason the fan-out exists: two passes over
1894
+ * one calendar (a Refresh click landing during the scheduled run) would each
1895
+ * load the SAME stored token and walk the same window. That is idempotent —
1896
+ * writes are upserts by event id — but it is a wasted full walk. Queued, the
1897
+ * second pass resumes from the token the first just stored and fetches only
1898
+ * what is genuinely new. That queue is module state, so it orders the doors
1899
+ * into THIS process only; `claimThenSync` is what other hosts can see. */
1900
+ async function syncCalendarGroup(calendarId, collections, workspaceRoot, mayClaim = ALWAYS_CLAIM) {
1901
+ return await withCalendarLock(calendarId, () => claimThenSync(calendarId, collections, workspaceRoot, mayClaim));
1499
1902
  }
1500
- /** Whether an event that already holds this record's id may be adopted as its
1501
- * baseline.
1903
+ /** Take the workspace-shared marker BEFORE the calendar is touched, sync only if
1904
+ * this run got it, and drop it again if the run could not happen at all.
1502
1905
  *
1503
- * A 409 from `insert` usually means a previous push created the event but its
1504
- * baseline never landed. It can ALSO mean an unrelated event happens to hold
1505
- * this id, and adopting that would bind the record to a stranger's event — so
1506
- * adoption needs evidence the two are the same logical event, not just the same
1507
- * id (Codex review).
1906
+ * Claiming first is the whole point: hosts tick in the same minute (interval
1907
+ * schedules align to wall-clock boundaries), so a marker written on completion
1908
+ * would leave the entire run — minutes, for a first full walk — open for a
1909
+ * second host to start alongside this one and lose the push baseline it writes.
1508
1910
  *
1509
- * The evidence is that the remote event ALREADY equals what this record would
1510
- * write. Adoption then records a baseline and writes nothing, so it cannot
1511
- * modify whatever that event is. Anything else is refused. */
1512
- function mayAdoptExisting(eventId, record, existing, map, primaryKey, fields) {
1513
- return locallyChangedFields(record, baselineRecord(eventId, existing, map, primaryKey, fields), map).length === 0;
1911
+ * Released only when the sync could not run at all. A run that finished with
1912
+ * retryable per-collection errors KEEPS the marker: it already held the token
1913
+ * and the baseline back, so it replays on the next tick either way, and
1914
+ * releasing would hand a permanently-failing calendar back to both hosts at
1915
+ * once — the very overlap this guards (observed during Claude review). */
1916
+ async function claimThenSync(calendarId, collections, workspaceRoot, mayClaim) {
1917
+ if (!await claimCalendarSync(calendarId, workspaceRoot, mayClaim)) {
1918
+ log.info("google", "skipping a calendar this workspace synced recently — another host may be on it", { calendarId });
1919
+ return [];
1920
+ }
1921
+ try {
1922
+ return await syncCalendarGroupNow(calendarId, collections, workspaceRoot);
1923
+ } catch (error) {
1924
+ await releaseCalendarSyncClaim(calendarId, workspaceRoot);
1925
+ throw error;
1926
+ }
1514
1927
  }
1515
- /** Record ids the baseline knows but the collection no longer holds — deleted
1516
- * locally. v1 reports the count and pushes nothing: `calendarDeleteEvent`
1517
- * removes the event for every attendee and cannot be undone. */
1518
- function locallyDeletedIds(shadow, presentIds) {
1519
- const present = new Set(presentIds);
1520
- return Object.keys(shadow).filter((eventId) => !present.has(eventId));
1928
+ /** Never throws, and fails OPEN: the marker guards against duplicate work, it is
1929
+ * not a precondition for syncing correctly. A workspace that cannot store it
1930
+ * syncs the way it did before #2678. */
1931
+ async function claimCalendarSync(calendarId, workspaceRoot, mayClaim) {
1932
+ try {
1933
+ return await claimCalendarSyncIfDue(calendarId, (/* @__PURE__ */ new Date()).toISOString(), mayClaim, workspaceRoot);
1934
+ } catch (error) {
1935
+ log.warn("google", "could not stamp the calendar sync marker — another host may sync this calendar in parallel", {
1936
+ calendarId,
1937
+ error: String(error)
1938
+ });
1939
+ return true;
1940
+ }
1521
1941
  }
1522
- //#endregion
1523
- //#region src/google/collectionPush.ts
1524
- /** Access roles that may write events. `reader` / `freeBusyReader` cannot, and a
1525
- * push under them fails per event with an opaque 403 — checked once up front so
1526
- * the user is told the real reason. */
1527
- var WRITABLE_ACCESS_ROLES = ["owner", "writer"];
1528
- /** Whether to refuse the whole push before touching a single event.
1942
+ async function releaseCalendarSyncClaim(calendarId, workspaceRoot) {
1943
+ try {
1944
+ await clearCalendarLastSyncedAt(calendarId, workspaceRoot);
1945
+ } catch (error) {
1946
+ log.warn("google", "could not release the calendar sync marker after a failed run", {
1947
+ calendarId,
1948
+ error: String(error)
1949
+ });
1950
+ }
1951
+ }
1952
+ async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
1953
+ const unpushed = await pushAutoCollections(collections, workspaceRoot);
1954
+ const accessToken = await getGoogleAccessToken();
1955
+ const first = await syncCalendarEvents(accessToken, {
1956
+ calendarId,
1957
+ syncToken: await loadCalendarSyncToken(calendarId, workspaceRoot) ?? void 0
1958
+ });
1959
+ const result = first.fullResyncRequired ? await restartFullSync(accessToken, calendarId, workspaceRoot) : first;
1960
+ const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed);
1961
+ if (windowFullyLanded(calendarId, results)) {
1962
+ await saveCalendarShadow(calendarId, shadowUpdates(result.events, allUnpushed(unpushed)), workspaceRoot);
1963
+ if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
1964
+ }
1965
+ return results;
1966
+ }
1967
+ /** Apply one window to every collection on the calendar, honouring what each
1968
+ * one's own push protected. A collection whose protection could not be worked
1969
+ * out is not pulled at all — it reports a retryable error instead, which holds
1970
+ * the token and the baseline back for the whole group. */
1971
+ async function applyWindowToGroup(collections, events, workspaceRoot, unpushed) {
1972
+ const results = [];
1973
+ for (const collection of collections) {
1974
+ const protection = unpushedFor(unpushed, collection.slug);
1975
+ results.push(protection === null ? {
1976
+ slug: collection.slug,
1977
+ written: 0,
1978
+ removed: 0,
1979
+ unwritable: [],
1980
+ errors: [PROTECTION_UNKNOWN]
1981
+ } : await applyEventsToCollection(collection, events, workspaceRoot, protection));
1982
+ }
1983
+ return results;
1984
+ }
1985
+ /** Whether the token and baseline may advance past this window.
1529
1986
  *
1530
- * Only on POSITIVE evidence of a non-writable role. An unlisted calendar is
1531
- * unknown and must fall through: hard-denying it would block the feature
1532
- * outright for a calendar the user can in fact write to. Those attempts surface
1533
- * Google's own 403 per record if the write really is not allowed. */
1534
- var isDeniedAccessRole = (accessRole) => accessRole !== null && !WRITABLE_ACCESS_ROLES.includes(accessRole);
1535
- /** Resolve the calendar a schema names, for its writability and its timezone.
1987
+ * Google never resends a window, so advancing past a failed write would lose
1988
+ * those events for good; holding the token back just replays them next run
1989
+ * (writes are idempotent). An `unwritable` event can never succeed, so it does
1990
+ * NOT hold the token — but it is logged loudly, since it will silently never
1991
+ * appear in the collection. */
1992
+ function windowFullyLanded(calendarId, results) {
1993
+ results.filter((entry) => entry.unwritable.length > 0).forEach((entry) => log.warn("google", "skipping calendar events that can never be stored", {
1994
+ calendarId,
1995
+ slug: entry.slug,
1996
+ unwritable: entry.unwritable
1997
+ }));
1998
+ const failed = results.filter((entry) => entry.errors.length > 0);
1999
+ failed.forEach((entry) => log.warn("google", "holding back calendar sync token after failed writes", {
2000
+ calendarId,
2001
+ slug: entry.slug,
2002
+ errors: entry.errors
2003
+ }));
2004
+ return failed.length === 0;
2005
+ }
2006
+ /** The baseline this window establishes: what Google now says per event, and
2007
+ * `null` for a cancelled one so a recreate cannot resume from a dead baseline.
1536
2008
  *
1537
- * The list is consulted first because only it reports `accessRole`, which is
1538
- * what lets a read-only calendar be refused with the real reason instead of an
1539
- * opaque per-event 403. `"primary"` is matched on the `primary` flag, not the
1540
- * id — the primary calendar's own id is the account's email address, which never
1541
- * equals the literal the schema declares.
2009
+ * An event whose record the push could not send is left OUT, and that omission
2010
+ * is load-bearing. Advancing its baseline to Google's new value while the record
2011
+ * keeps the local one would make the next push read a plain one-sided edit —
2012
+ * no conflict to detect any more — and quietly overwrite Google. Held back, the
2013
+ * baseline stays older than both sides, so the conflict keeps being reported
2014
+ * until someone resolves it (#2620). */
2015
+ function shadowUpdates(events, unpushed = /* @__PURE__ */ new Set()) {
2016
+ const carried = pullableEvents(events, unpushed);
2017
+ return Object.fromEntries(carried.map((event) => [event.id, event.status === "cancelled" ? null : toShadowEvent(event)]));
2018
+ }
2019
+ /** Save the window's token unless every collection that consumed it was deleted
2020
+ * while the sync was in flight.
1542
2021
  *
1543
- * A calendar absent from the list is NOT denied: it is fetched by id for its
1544
- * timezone and its role left unknown. A 404 there means the calendar really is
1545
- * unreachable, and propagates as a `failed` outcome. */
1546
- async function liveCalendarMeta(accessToken, calendarId) {
1547
- const key = canonicalCalendarId(calendarId);
1548
- const calendars = await listCalendars(accessToken);
1549
- const listed = key === "primary" ? calendars.find((calendar) => calendar.primary) : calendars.find((calendar) => calendar.id === key);
1550
- if (listed) return {
1551
- accessRole: listed.accessRole,
1552
- timeZone: listed.timeZone
1553
- };
1554
- const direct = await getCalendar(accessToken, calendarId);
1555
- log.info("google", "calendar is not in the user's list — pushing without an up-front role check", { calendarId: key });
1556
- return {
1557
- accessRole: null,
1558
- timeZone: direct.timeZone
1559
- };
2022
+ * The sync opens with a `discoverCollections()` snapshot, so a delete landing
2023
+ * mid-run has already cleared this calendar's token by the time we get here
2024
+ * (`releaseOrphanedCalendarToken`). Saving anyway would resurrect exactly the
2025
+ * orphan the delete removed, and the next collection on this calendar would
2026
+ * resume from a token describing records it never received (#2428).
2027
+ *
2028
+ * One survivor is enough: it still needs the incremental position. */
2029
+ async function advanceToken(calendarId, nextSyncToken, collections, workspaceRoot) {
2030
+ if (!await anySyncedCollectionSurvives(collections)) {
2031
+ log.info("google", "not advancing the sync token — every collection on this calendar was deleted mid-sync", { calendarId });
2032
+ return;
2033
+ }
2034
+ await saveCalendarSyncToken(calendarId, nextSyncToken, workspaceRoot);
1560
2035
  }
1561
- async function findCalendarCollection(slug, workspaceRoot) {
1562
- return (await require_discovery.discoverCollections({ workspaceRoot })).find((collection) => collection.slug === slug && collection.schema.googleCalendar) ?? null;
2036
+ async function pathExists(absPath) {
2037
+ try {
2038
+ await (0, node_fs_promises.stat)(absPath);
2039
+ return true;
2040
+ } catch {
2041
+ return false;
2042
+ }
1563
2043
  }
1564
- var liveDeps = {
1565
- findCollection: findCalendarCollection,
1566
- isLinked: async () => Boolean((await loadGoogleTokens())?.refresh_token),
1567
- accessToken: getGoogleAccessToken,
1568
- calendarMeta: liveCalendarMeta
1569
- };
1570
- var localValue = (ctx, record, source) => {
1571
- const field = ctx.bySource[source];
1572
- return field === void 0 ? void 0 : record[field];
1573
- };
1574
- /** One end of the span, rebuilt from the stored clock plus whatever the baseline
1575
- * remembers about the original (zone, all-day). */
1576
- function eventTime(ctx, record, source, previous) {
1577
- const value = localValue(ctx, record, source);
1578
- if (value === void 0) return {
1579
- ok: false,
1580
- reason: `no \`${source}\` field is mapped`
1581
- };
1582
- const time = toGoogleEventTime(value, previous, ctx.timeZone);
1583
- if (time === null) return {
1584
- ok: false,
1585
- reason: `\`${source}\` is not a date-time Google accepts (got ${JSON.stringify(value)})`
1586
- };
1587
- if ("dateTime" in time && time.timeZone === "") return {
1588
- ok: false,
1589
- reason: `the calendar reports no timezone, so \`${source}\` cannot be sent without one`
1590
- };
1591
- return {
1592
- ok: true,
1593
- time
1594
- };
2044
+ /** Liveness of the collections a sync just wrote to, checked against the skill
2045
+ * dir `deleteCollection` removes. `exists` is injected so the rule is testable
2046
+ * without a filesystem. An empty group has no survivor by definition. */
2047
+ async function anySyncedCollectionSurvives(collections, exists = pathExists) {
2048
+ return (await Promise.all(collections.map((collection) => exists(collection.skillDir)))).some(Boolean);
1595
2049
  }
1596
- async function createFromRecord(ctx, eventId, record) {
1597
- if (!isClientSettableEventId(eventId)) return {
1598
- kind: "skipped",
1599
- message: `${eventId}: the record id cannot be used as a Google event id (needs 5-1024 characters from 0-9a-v)`
1600
- };
1601
- const start = eventTime(ctx, record, "start", void 0);
1602
- const end = eventTime(ctx, record, "end", void 0);
1603
- if (!start.ok) return {
1604
- kind: "skipped",
1605
- message: `${eventId}: ${start.reason}`
1606
- };
1607
- if (!end.ok) return {
1608
- kind: "skipped",
1609
- message: `${eventId}: ${end.reason}`
1610
- };
1611
- const colorId = fieldText(localValue(ctx, record, "colorId"));
2050
+ async function applyEventsToCollection(collection, events, workspaceRoot, unpushed) {
2051
+ const outcomes = [];
2052
+ for (const event of pullableEvents(events, unpushed)) outcomes.push(await applyEvent(collection, event, workspaceRoot));
1612
2053
  return {
1613
- kind: "created",
1614
- event: await createCalendarEvent(ctx.accessToken, {
1615
- eventId,
1616
- calendarId: ctx.calendarId,
1617
- summary: fieldText(localValue(ctx, record, "summary")),
1618
- start: start.time,
1619
- end: end.time,
1620
- ...colorId ? { colorId } : {}
1621
- })
2054
+ slug: collection.slug,
2055
+ written: outcomes.filter((outcome) => outcome.kind === "written").length,
2056
+ removed: outcomes.filter((outcome) => outcome.kind === "removed").length,
2057
+ unwritable: outcomes.flatMap((outcome) => outcome.kind === "unwritable" ? [outcome.message] : []),
2058
+ errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
1622
2059
  };
1623
2060
  }
1624
- /** Only the fields the local edit touched — PATCH semantics, so an untouched
1625
- * attendee list / recurrence rule is left alone. */
1626
- function buildPatch(ctx, record, changed, shadow) {
1627
- const resolved = ["start", "end"].filter((source) => changed.includes(source)).map((source) => ({
1628
- source,
1629
- result: eventTime(ctx, record, source, shadow[source])
1630
- }));
1631
- const [failure] = resolved.flatMap((entry) => entry.result.ok ? [] : [entry.result.reason]);
1632
- if (failure !== void 0) return {
1633
- ok: false,
1634
- reason: failure
1635
- };
1636
- const colorId = fieldText(localValue(ctx, record, "colorId"));
1637
- if (changed.includes("colorId") && colorId === "") return {
1638
- ok: false,
1639
- reason: "Google cannot clear an event colour once set"
1640
- };
1641
- return {
1642
- ok: true,
1643
- patch: {
1644
- ...changed.includes("summary") ? { summary: fieldText(localValue(ctx, record, "summary")) } : {},
1645
- ...changed.includes("colorId") ? { colorId } : {},
1646
- ...Object.fromEntries(resolved.flatMap((entry) => entry.result.ok ? [[entry.source, entry.result.time]] : []))
1647
- }
1648
- };
2061
+ /** A refresh token is what lets the scheduler run unattended; without one
2062
+ * there is nothing to sync with. */
2063
+ async function isGoogleLinked() {
2064
+ return Boolean((await loadGoogleTokens())?.refresh_token);
2065
+ }
2066
+ /** Group the declaring collections by the calendar they read, so each calendar
2067
+ * is fetched exactly once.
2068
+ *
2069
+ * Keyed by the CANONICAL id, not the declared one: an omitted `calendarId` and
2070
+ * an explicit `"primary"` address the same calendar and therefore share one
2071
+ * sync token, so grouping them apart would let one group advance the token out
2072
+ * from under the other — the very loss this grouping exists to prevent
2073
+ * (Codex review #2184). */
2074
+ function groupByCalendar(collections) {
2075
+ const groups = /* @__PURE__ */ new Map();
2076
+ for (const collection of collections) {
2077
+ const key = canonicalCalendarId(collection.schema.googleCalendar?.calendarId);
2078
+ groups.set(key, [...groups.get(key) ?? [], collection]);
2079
+ }
2080
+ return groups;
1649
2081
  }
1650
- async function updateFromRecord(ctx, eventId, record, changed, shadow) {
1651
- const fetched = await getCalendarEvent(ctx.accessToken, {
1652
- calendarId: ctx.calendarId,
1653
- eventId
1654
- });
1655
- if (fetched === null || fetched.event.status === "cancelled") return {
1656
- kind: "skipped",
1657
- message: `${eventId}: the event no longer exists in Google — sync first, then re-create it`
1658
- };
1659
- if (conflictingFields(shadow, toShadowEvent(fetched.event), changed).length > 0) return { kind: "conflict" };
1660
- const built = buildPatch(ctx, record, changed, shadow);
1661
- if (!built.ok) return {
1662
- kind: "skipped",
1663
- message: `${eventId}: ${built.reason}`
1664
- };
2082
+ /** The canonical calendar whose sync token nothing needs any more, or null.
2083
+ *
2084
+ * Sync tokens are keyed by calendar, NOT by collection, so a deleted
2085
+ * collection's token outlives it. Recreating a collection on the same calendar
2086
+ * then resumes from that token and receives only the delta — the new
2087
+ * collection never gets the history (#2428).
2088
+ *
2089
+ * Returns null while ANY remaining collection still reads that calendar:
2090
+ * clearing a live calendar's token costs a full re-walk on the next sync. The
2091
+ * comparison is on the CANONICAL id for the same reason `groupByCalendar` is —
2092
+ * an omitted `calendarId` and an explicit `"primary"` are one calendar. */
2093
+ function orphanedCalendarId(deleted, remaining) {
2094
+ if (!deleted.googleCalendar) return null;
2095
+ const key = canonicalCalendarId(deleted.googleCalendar.calendarId);
2096
+ return remaining.some((other) => other.googleCalendar && canonicalCalendarId(other.googleCalendar.calendarId) === key) ? null : key;
2097
+ }
2098
+ /** Drop the sync token of a just-deleted collection's calendar, unless another
2099
+ * collection still reads it. Call AFTER the delete lands — the check reads the
2100
+ * collections that survive it.
2101
+ *
2102
+ * Returns the cleared calendar id, or null when nothing was cleared. Never
2103
+ * throws: a failed cleanup must not fail the delete it follows. */
2104
+ async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
1665
2105
  try {
1666
- return {
1667
- kind: "updated",
1668
- event: await updateCalendarEvent(ctx.accessToken, {
1669
- eventId,
1670
- calendarId: ctx.calendarId,
1671
- ifMatch: fetched.etag,
1672
- ...built.patch
1673
- })
1674
- };
2106
+ if (!deleted.googleCalendar) return null;
2107
+ const orphaned = orphanedCalendarId(deleted, (await require_discovery.discoverCollections({ workspaceRoot })).map((collection) => collection.schema));
2108
+ if (orphaned === null) return null;
2109
+ await clearCalendarSyncToken(orphaned, workspaceRoot);
2110
+ await clearCalendarShadow(orphaned, workspaceRoot);
2111
+ await clearCalendarLastSyncedAt(orphaned, workspaceRoot);
2112
+ log.info("google", "cleared the sync token of a calendar no collection reads any more", { calendarId: orphaned });
2113
+ return orphaned;
1675
2114
  } catch (error) {
1676
- if (isGoogleApiError(error) && error.status === 412) return { kind: "conflict" };
1677
- throw error;
2115
+ log.warn("google", "could not release the deleted collection's calendar sync token", { error: String(error) });
2116
+ return null;
1678
2117
  }
1679
2118
  }
1680
- /** Create, recovering from the one 409 that is provably safe to recover from.
2119
+ /** Every declaring collection, grouped by the calendar it reads. */
2120
+ async function declaringGroups(workspaceRoot) {
2121
+ return groupByCalendar((await require_discovery.discoverCollections({ workspaceRoot })).filter((collection) => collection.schema.googleCalendar));
2122
+ }
2123
+ /** Whether a background sync of these groups may run at all.
1681
2124
  *
1682
- * A 409 means the id is taken, which is USUALLY a previous push whose baseline
1683
- * never landed. It can also be an unrelated event that happens to hold this id,
1684
- * so the recovery never writes: it adopts the baseline only when the remote
1685
- * event already equals what this record would write, and otherwise reports the
1686
- * fix. Patching whatever answered the 409 would let a push modify a stranger's
1687
- * event (Codex review).
2125
+ * Authoring the collection before linking the account is an expected state,
2126
+ * not a failure. Checking once here keeps it a quiet skip instead of an
2127
+ * access-token throw per calendar, every hour, until the user links (#2188).
2128
+ * A user-triggered sync answers differently — it says so out loud.
1688
2129
  *
1689
- * Refusal is not a dead end — a Sync writes both the record and its baseline,
1690
- * after which the record pushes normally. */
1691
- async function createOrAdopt(ctx, eventId, record) {
1692
- try {
1693
- return await createFromRecord(ctx, eventId, record);
2130
+ * Deliberately not phrased as a per-host state: the link lives in
2131
+ * `~/.config/mulmo`, which carries no app name, so every host on the machine
2132
+ * shares one answer. Reading it as "linked on this host only" is what made the
2133
+ * calendar look implicitly exclusive when it never was (#2678). */
2134
+ async function backgroundSyncAllowed(groups) {
2135
+ if (groups.size === 0) return false;
2136
+ if (await isGoogleLinked()) return true;
2137
+ log.info("google", "skipping calendar sync — no Google account linked", { calendars: groups.size });
2138
+ return false;
2139
+ }
2140
+ /** Run each group, isolating failures per calendar — one unreachable calendar
2141
+ * (or a revoked grant) must not stop the others. `mayClaim` is evaluated per
2142
+ * calendar as it comes up, never once for the whole map: an earlier calendar's
2143
+ * full walk takes minutes, and a decision made before it started says nothing
2144
+ * about who holds this one now. */
2145
+ async function runCalendarGroups(groups, workspaceRoot, mayClaim = ALWAYS_CLAIM) {
2146
+ const results = [];
2147
+ for (const [calendarId, collections] of groups) try {
2148
+ results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot, mayClaim));
1694
2149
  } catch (error) {
1695
- if (!isGoogleApiError(error) || error.status !== 409) throw error;
1696
- const fetched = await getCalendarEvent(ctx.accessToken, {
1697
- calendarId: ctx.calendarId,
1698
- eventId
2150
+ log.warn("google", "calendar sync failed", {
2151
+ calendarId,
2152
+ error: String(error)
1699
2153
  });
1700
- if (fetched === null) throw error;
1701
- if (mayAdoptExisting(eventId, record, toShadowEvent(fetched.event), ctx.map, ctx.primaryKey, ctx.fields)) return {
1702
- kind: "unchanged",
1703
- event: fetched.event
1704
- };
1705
- return {
1706
- kind: "skipped",
1707
- message: `${eventId}: an event with this id already exists in Google and differs from this record — press Sync to adopt it, then push again`
1708
- };
2154
+ results.push(...collections.map((collection) => ({
2155
+ slug: collection.slug,
2156
+ written: 0,
2157
+ removed: 0,
2158
+ unwritable: [],
2159
+ errors: [String(error)]
2160
+ })));
1709
2161
  }
2162
+ return results;
1710
2163
  }
1711
- /** A 403 on a write is a permissions answer, but `googleApiError` appends the
1712
- * "is the API enabled for the Cloud project?" hint to every 403 — true for a
1713
- * disabled API, misleading here. Reached when the up-front role check could not
1714
- * run because the calendar is not in the user's list, which is exactly when the
1715
- * user needs the real reason. */
1716
- function writeFailure(eventId, error) {
1717
- if (isGoogleApiError(error) && error.status === 403) return {
1718
- kind: "skipped",
1719
- message: `${eventId}: Google refused the write — you may not have permission to change events on this calendar`
1720
- };
1721
- return {
1722
- kind: "error",
1723
- message: `${eventId}: ${String(error)}`
1724
- };
2164
+ /** Sync every collection whose calendar is due — no host in this workspace has
2165
+ * started one within `intervalMs` (#2678). Without that gate this walked every
2166
+ * declaring group on every tick, so a second host registering the same task
2167
+ * simply doubled the runs, concurrently.
2168
+ *
2169
+ * Dueness is not decided here, only described: the guard is handed down and
2170
+ * evaluated where the marker is written, so the answer cannot go stale between
2171
+ * deciding and claiming (Codex review #2680). */
2172
+ async function syncDueCalendarCollections(workspaceRoot, intervalMs = DEFAULT_SYNC_INTERVAL_MS) {
2173
+ const groups = await declaringGroups(workspaceRoot);
2174
+ if (!await backgroundSyncAllowed(groups)) return [];
2175
+ const windowMs = calendarSyncDueWindowMs(intervalMs);
2176
+ return await runCalendarGroups(groups, workspaceRoot, (lastSyncedAt) => isCalendarSyncDue(lastSyncedAt, windowMs));
1725
2177
  }
1726
- async function pushRecord(ctx, eventId, record) {
1727
- try {
1728
- const shadow = ctx.shadow[eventId];
1729
- const plan = planRecord(eventId, record, shadow, ctx.map, ctx.primaryKey, ctx.fields);
1730
- if (plan.kind === "unchanged") return { kind: "unchanged" };
1731
- if (plan.kind === "create" || shadow === void 0) return await createOrAdopt(ctx, eventId, record);
1732
- return await updateFromRecord(ctx, eventId, record, plan.fields, shadow);
1733
- } catch (error) {
1734
- return writeFailure(eventId, error);
1735
- }
2178
+ /** The groups whose calendar has never synced. A missing token IS the "created
2179
+ * since the last sync" signal — nothing else distinguishes a new collection
2180
+ * from an edited one on the write path this feeds (#2427).
2181
+ *
2182
+ * Self-silencing by construction: the first sync stores a token, so a calendar
2183
+ * matches at most once. `loadToken` is injected so the rule is testable without
2184
+ * a workspace on disk. */
2185
+ async function unsyncedGroups(groups, loadToken) {
2186
+ const checked = await Promise.all([...groups].map(async (entry) => await loadToken(entry[0]) === null ? entry : null));
2187
+ return new Map(checked.filter((entry) => entry !== null));
1736
2188
  }
1737
- /** The baseline an outcome establishes, so the next push sees the record as
1738
- * unchanged instead of pushing it again. Empty for anything that wrote nothing
1739
- * and had no baseline to repair. */
1740
- function pushedShadow(outcomes) {
1741
- const written = outcomes.flatMap((outcome) => "event" in outcome && outcome.event !== void 0 ? [outcome.event] : []);
1742
- return Object.fromEntries(written.map((event) => [event.id, toShadowEvent(event)]));
2189
+ /** Sync only the calendars that have never synced — the first sync for a
2190
+ * just-created collection, which otherwise stays empty until the hourly
2191
+ * scheduler run (#2427). Cheap and safe to call on every config write. */
2192
+ async function syncNewCalendarCollections(workspaceRoot) {
2193
+ const pending = await unsyncedGroups(await declaringGroups(workspaceRoot), (calendarId) => loadCalendarSyncToken(calendarId, workspaceRoot));
2194
+ if (!await backgroundSyncAllowed(pending)) return [];
2195
+ log.info("google", "running the first sync for newly declared calendars", { calendars: [...pending.keys()] });
2196
+ return await runCalendarGroups(pending, workspaceRoot);
1743
2197
  }
1744
- function tally(slug, outcomes, localDeletes) {
1745
- const count = (kind) => outcomes.filter((outcome) => outcome.kind === kind).length;
2198
+ var liveManualSyncDeps = {
2199
+ loadGroups: declaringGroups,
2200
+ isLinked: isGoogleLinked,
2201
+ runGroups: runCalendarGroups
2202
+ };
2203
+ /** Sync the calendar ONE collection reads, on demand (the Refresh button).
2204
+ *
2205
+ * Deliberately syncs the whole group, not just `slug`: the sync token is keyed
2206
+ * by calendar, so consuming a window for one collection would leave the others
2207
+ * on that calendar reading an already-consumed one. Returns every result of the
2208
+ * group so the caller can report the requested slug's own counts.
2209
+ *
2210
+ * "Does this collection sync at all" is answered BEFORE "is Google linked":
2211
+ * telling someone to link their account for a collection that never declared a
2212
+ * calendar sends them fixing the wrong thing. */
2213
+ async function syncCalendarForCollection(slug, workspaceRoot, deps = liveManualSyncDeps) {
2214
+ const owning = [...await deps.loadGroups(workspaceRoot)].filter(([, collections]) => collections.some((collection) => collection.slug === slug));
2215
+ if (owning.length === 0) return { kind: "not-a-calendar" };
2216
+ if (!await deps.isLinked()) return { kind: "not-linked" };
1746
2217
  return {
1747
- slug,
1748
- created: count("created"),
1749
- updated: count("updated"),
1750
- conflicts: count("conflict"),
1751
- localDeletes,
1752
- skipped: outcomes.flatMap((outcome) => outcome.kind === "skipped" ? [outcome.message] : []),
1753
- errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
2218
+ kind: "synced",
2219
+ results: await deps.runGroups(new Map(owning), workspaceRoot)
1754
2220
  };
1755
2221
  }
1756
- async function pushNow(collection, workspaceRoot, deps) {
1757
- const { schema, slug } = collection;
1758
- const calendarId = schema.googleCalendar?.calendarId;
1759
- const accessToken = await deps.accessToken();
1760
- const meta = await deps.calendarMeta(accessToken, calendarId);
1761
- if (isDeniedAccessRole(meta.accessRole)) return {
1762
- kind: "read-only",
1763
- accessRole: meta.accessRole ?? ""
1764
- };
1765
- const map = pushableMap(schema.googleCalendar?.map ?? {});
1766
- const shadow = await loadCalendarShadow(calendarId, workspaceRoot);
1767
- const records = await require_discovery.storeFor(collection, { workspaceRoot }).list();
1768
- const ctx = {
1769
- accessToken,
1770
- calendarId,
1771
- timeZone: meta.timeZone,
1772
- map,
1773
- bySource: bySourceField(map),
1774
- primaryKey: schema.primaryKey,
1775
- fields: schema.fields,
1776
- shadow
1777
- };
1778
- const outcomes = [];
1779
- for (const record of records) {
1780
- const outcome = await pushRecord(ctx, fieldText(record[schema.primaryKey]), record);
1781
- outcomes.push(outcome);
1782
- await saveCalendarShadow(calendarId, pushedShadow([outcome]), workspaceRoot);
1783
- }
1784
- const result = tally(slug, outcomes, locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))).length);
1785
- if (result.errors.length > 0) log.warn("google", "calendar push finished with errors", {
1786
- slug,
1787
- errors: result.errors.length
1788
- });
1789
- return {
1790
- kind: "pushed",
1791
- result
1792
- };
2222
+ /** The interval this task is scheduled on right now. A non-interval schedule
2223
+ * cannot be reached — the factory below builds an interval one — but the type
2224
+ * allows it, so it falls back rather than asserting. */
2225
+ function scheduledIntervalMs(task) {
2226
+ return task.schedule.type === _receptron_task_scheduler.SCHEDULE_TYPES.interval ? task.schedule.intervalMs : DEFAULT_SYNC_INTERVAL_MS;
1793
2227
  }
1794
- /** Push ONE collection's records to the calendar it declares.
2228
+ /** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
2229
+ * with a single line.
1795
2230
  *
1796
- * "Does this collection sync at all" is answered before "is Google linked", for
1797
- * the reason `syncCalendarForCollection` gives: telling someone to link their
1798
- * account for a collection that never declared a calendar sends them fixing the
1799
- * wrong thing. */
1800
- async function pushCalendarForCollection(slug, workspaceRoot, deps = liveDeps) {
1801
- try {
1802
- const collection = await deps.findCollection(slug, workspaceRoot);
1803
- if (collection === null) return { kind: "not-a-calendar" };
1804
- if (!await deps.isLinked()) return { kind: "not-linked" };
1805
- return await withCalendarLock(collection.schema.googleCalendar?.calendarId, () => pushNow(collection, workspaceRoot, deps));
1806
- } catch (error) {
1807
- log.warn("google", "calendar push could not start", {
1808
- slug,
1809
- error: String(error)
1810
- });
1811
- return {
1812
- kind: "failed",
1813
- message: String(error)
1814
- };
1815
- }
2231
+ * `run` reads the interval back off the definition instead of closing over the
2232
+ * option: a host rewrites `schedule` from its own overrides file AFTER this
2233
+ * returns, and the due window has to follow it. Frozen at the default, a
2234
+ * shortened interval would tick often and skip nearly every tick. */
2235
+ function googleCalendarSyncTaskDef(opts) {
2236
+ const def = {
2237
+ id: GOOGLE_CALENDAR_SYNC_TASK_ID,
2238
+ name: "Google Calendar sync",
2239
+ description: "Pulls changed Google Calendar events into any collection declaring `googleCalendar`, without invoking the LLM.",
2240
+ schedule: {
2241
+ type: _receptron_task_scheduler.SCHEDULE_TYPES.interval,
2242
+ intervalMs: opts?.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS
2243
+ },
2244
+ missedRunPolicy: _receptron_task_scheduler.MISSED_RUN_POLICIES.runOnce,
2245
+ run: () => syncDueCalendarCollections(opts?.workspaceRoot ?? require_discovery.getWorkspaceRoot(), scheduledIntervalMs(def)).then(() => {})
2246
+ };
2247
+ return def;
1816
2248
  }
1817
2249
  //#endregion
1818
2250
  //#region src/google/tasks.ts
@@ -2023,7 +2455,9 @@ exports.HTTP_CONFLICT = HTTP_CONFLICT;
2023
2455
  exports.HTTP_FORBIDDEN = HTTP_FORBIDDEN;
2024
2456
  exports.HTTP_PRECONDITION_FAILED = HTTP_PRECONDITION_FAILED;
2025
2457
  exports.MAX_LIST_RESULTS = MAX_LIST_RESULTS;
2458
+ exports.PROTECTION_UNKNOWN = PROTECTION_UNKNOWN;
2026
2459
  exports.PUSHABLE_SOURCE_FIELDS = PUSHABLE_SOURCE_FIELDS;
2460
+ exports.allUnpushed = allUnpushed;
2027
2461
  exports.anySyncedCollectionSurvives = anySyncedCollectionSurvives;
2028
2462
  exports.assertSafeMimeType = assertSafeMimeType;
2029
2463
  exports.authorizeGoogle = authorizeGoogle;
@@ -2038,11 +2472,14 @@ exports.buildTaskPatch = buildTaskPatch;
2038
2472
  exports.bySourceField = bySourceField;
2039
2473
  exports.calendarApiError = calendarApiError;
2040
2474
  exports.calendarPushStatePath = calendarPushStatePath;
2475
+ exports.calendarSyncDueWindowMs = calendarSyncDueWindowMs;
2041
2476
  exports.calendarSyncStatePath = calendarSyncStatePath;
2042
2477
  exports.canonicalCalendarId = canonicalCalendarId;
2043
2478
  exports.canonicalTaskListId = canonicalTaskListId;
2479
+ exports.claimCalendarSyncIfDue = claimCalendarSyncIfDue;
2044
2480
  exports.classifyDelete = classifyDelete;
2045
2481
  exports.classifyWrite = classifyWrite;
2482
+ exports.clearCalendarLastSyncedAt = clearCalendarLastSyncedAt;
2046
2483
  exports.clearCalendarShadow = clearCalendarShadow;
2047
2484
  exports.clearCalendarSyncToken = clearCalendarSyncToken;
2048
2485
  exports.clientSecretPresence = clientSecretPresence;
@@ -2072,30 +2509,37 @@ exports.googleConfigDir = googleConfigDir;
2072
2509
  exports.googleSecretsDir = googleSecretsDir;
2073
2510
  exports.googleTokenPath = googleTokenPath;
2074
2511
  exports.groupByCalendar = groupByCalendar;
2512
+ exports.isCalendarSyncDue = isCalendarSyncDue;
2075
2513
  exports.isClientSettableEventId = isClientSettableEventId;
2076
2514
  exports.isDeniedAccessRole = isDeniedAccessRole;
2077
2515
  exports.isGoogleApiError = isGoogleApiError;
2078
2516
  exports.isIsoDateTimeWithOffset = isIsoDateTimeWithOffset;
2079
2517
  exports.isTextMimeType = isTextMimeType;
2518
+ exports.isUnpushed = isUnpushed;
2080
2519
  exports.legacyGoogleTokenPath = legacyGoogleTokenPath;
2081
2520
  exports.listCalendarEvents = listCalendarEvents;
2082
2521
  exports.listCalendars = listCalendars;
2083
2522
  exports.listDriveFiles = listDriveFiles;
2084
2523
  exports.listTaskLists = listTaskLists;
2085
2524
  exports.listTasks = listTasks;
2525
+ exports.loadCalendarLastSyncedAt = loadCalendarLastSyncedAt;
2086
2526
  exports.loadCalendarShadow = loadCalendarShadow;
2087
2527
  exports.loadCalendarSyncToken = loadCalendarSyncToken;
2088
2528
  exports.loadClientSecret = loadClientSecret;
2089
2529
  exports.loadGoogleTokens = loadGoogleTokens;
2090
2530
  exports.locallyChangedFields = locallyChangedFields;
2091
2531
  exports.locallyDeletedIds = locallyDeletedIds;
2532
+ exports.locallyEditedIds = locallyEditedIds;
2092
2533
  exports.mayAdoptExisting = mayAdoptExisting;
2093
2534
  exports.mergeGoogleTokens = mergeGoogleTokens;
2535
+ exports.mergeIntoExisting = mergeIntoExisting;
2094
2536
  exports.mergeShadow = mergeShadow;
2095
2537
  exports.orphanedCalendarId = orphanedCalendarId;
2096
2538
  exports.pickBoundary = pickBoundary;
2097
2539
  exports.planRecord = planRecord;
2540
+ exports.pullableEvents = pullableEvents;
2098
2541
  exports.pushCalendarForCollection = pushCalendarForCollection;
2542
+ exports.pushCollectionNow = pushCollectionNow;
2099
2543
  exports.pushableMap = pushableMap;
2100
2544
  exports.readDriveFile = readDriveFile;
2101
2545
  exports.releaseOrphanedCalendarToken = releaseOrphanedCalendarToken;
@@ -2120,6 +2564,8 @@ exports.toTaskListSummary = toTaskListSummary;
2120
2564
  exports.toTaskSummary = toTaskSummary;
2121
2565
  exports.uncompleteTask = uncompleteTask;
2122
2566
  exports.unlinkGoogle = unlinkGoogle;
2567
+ exports.unpushedFor = unpushedFor;
2568
+ exports.unsentLocalEdits = unsentLocalEdits;
2123
2569
  exports.unsyncedGroups = unsyncedGroups;
2124
2570
  exports.updateCalendarEvent = updateCalendarEvent;
2125
2571
  exports.updateTask = updateTask;