@mulmoclaude/core 1.10.0 → 1.11.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 (32) 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/collectionProjection.d.ts +18 -0
  20. package/dist/google/collectionPush.d.ts +62 -0
  21. package/dist/google/collectionSync.d.ts +51 -23
  22. package/dist/google/index.cjs +833 -567
  23. package/dist/google/index.cjs.map +1 -1
  24. package/dist/google/index.d.ts +4 -2
  25. package/dist/google/index.js +825 -568
  26. package/dist/google/index.js.map +1 -1
  27. package/dist/google/pushPlan.d.ts +1 -1
  28. package/dist/{server-l4TZ5Lbf.cjs → server-7U-3DE2e.cjs} +2 -2
  29. package/dist/{server-l4TZ5Lbf.cjs.map → server-7U-3DE2e.cjs.map} +1 -1
  30. package/dist/{server-BlIrtGKl.js → server-DdyIvFhz.js} +2 -2
  31. package/dist/{server-BlIrtGKl.js.map → server-DdyIvFhz.js.map} +1 -1
  32. package/package.json +4 -4
@@ -3,7 +3,7 @@ import { t as truncate } from "../text-CjDbP2O1.js";
3
3
  import { t as writeFileAtomic } from "../atomic-DPpdrJzO.js";
4
4
  import { fetchWithTimeout } from "../utils/fetch.js";
5
5
  import { l as parseIsoDate, u as parseIsoDateTime } from "../calendarGrid-CrGxeCOG.js";
6
- import { J as getWorkspaceRoot, d as storeFor, et as createForwardingLogger, n as discoverCollections, tt as createHostSlot } from "../discovery-_fjsluLx.js";
6
+ import { J as getWorkspaceRoot, d as storeFor, et as createForwardingLogger, n as discoverCollections, tt as createHostSlot } from "../discovery-B4CZvrXR.js";
7
7
  import { promises } from "node:fs";
8
8
  import path, { join } from "node:path";
9
9
  import { randomBytes } from "node:crypto";
@@ -621,7 +621,9 @@ var toEventSummary = (value) => {
621
621
  end: eventTime$1(record.end),
622
622
  htmlLink: stringField(record, "htmlLink"),
623
623
  status: stringField(record, "status"),
624
- colorId: stringField(record, "colorId")
624
+ colorId: stringField(record, "colorId"),
625
+ description: stringField(record, "description"),
626
+ location: stringField(record, "location")
625
627
  };
626
628
  };
627
629
  var toCalendarSummary = (value) => {
@@ -663,6 +665,7 @@ async function createCalendarEvent(accessToken, input) {
663
665
  const body = {
664
666
  summary: input.summary,
665
667
  description: input.description,
668
+ location: input.location,
666
669
  ...resolveEventSpan(input),
667
670
  ...input.colorId ? { colorId: input.colorId } : {},
668
671
  ...input.eventId ? { id: input.eventId } : {}
@@ -687,6 +690,7 @@ var eventEnd = (key, structured, flat) => {
687
690
  var buildEventPatch = (input) => ({
688
691
  ...input.summary !== void 0 ? { summary: input.summary } : {},
689
692
  ...input.description !== void 0 ? { description: input.description } : {},
693
+ ...input.location !== void 0 ? { location: input.location } : {},
690
694
  ...eventEnd("start", input.start, input.startDateTime),
691
695
  ...eventEnd("end", input.end, input.endDateTime),
692
696
  ...input.colorId ? { colorId: input.colorId } : {}
@@ -877,7 +881,9 @@ var toShadowEvent = (event) => ({
877
881
  summary: event.summary,
878
882
  start: event.start,
879
883
  end: event.end,
880
- colorId: event.colorId
884
+ colorId: event.colorId,
885
+ description: event.description,
886
+ location: event.location
881
887
  });
882
888
  async function readState(workspaceRoot) {
883
889
  const stored = await readJsonOrNull(calendarPushStatePath(workspaceRoot));
@@ -920,14 +926,142 @@ async function clearCalendarShadow(calendarId, workspaceRoot) {
920
926
  await updateState((events) => Object.fromEntries(Object.entries(events).filter(([key]) => key !== dropped)), workspaceRoot);
921
927
  }
922
928
  //#endregion
929
+ //#region src/google/calendarLock.ts
930
+ /** Serialise `run` against whatever is already running for `key`.
931
+ *
932
+ * `locks` is passed in so the queuing rule is testable without module state;
933
+ * the key is dropped once nothing is queued behind it, so the map cannot grow
934
+ * an entry per calendar forever. A failed predecessor still releases the
935
+ * queue — `then(run, run)`. */
936
+ async function withKeyedLock(locks, key, run) {
937
+ const result = (locks.get(key) ?? Promise.resolve()).then(run, run);
938
+ const tail = result.then(() => void 0, () => void 0);
939
+ locks.set(key, tail);
940
+ try {
941
+ return await result;
942
+ } finally {
943
+ if (locks.get(key) === tail) locks.delete(key);
944
+ }
945
+ }
946
+ /** In-flight work per canonical calendar id. Module state on purpose: the
947
+ * scheduler, the create trigger and the Refresh button are three doors into
948
+ * the same calendar (CodeRabbit review #2566). */
949
+ var calendarLocks = /* @__PURE__ */ new Map();
950
+ /** Serialise anything that touches ONE calendar's sync state.
951
+ *
952
+ * Shared with the push path (#2598), not only the pull doors: a push that
953
+ * overtakes an in-flight pull would record a baseline for events the pull is
954
+ * still writing, so the next push would diff against a future it never saw.
955
+ *
956
+ * NOT reentrant — a holder must call the lock-free form of whatever it drives,
957
+ * or it waits on itself forever. */
958
+ async function withCalendarLock(calendarId, run) {
959
+ return await withKeyedLock(calendarLocks, canonicalCalendarId(calendarId), run);
960
+ }
961
+ //#endregion
962
+ //#region src/google/pushDateTime.ts
963
+ /** RFC3339 zone designator: `Z`, `+09:00`, `-0500`. */
964
+ var ZONE_SUFFIX_RE$1 = /(?:Z|[+-]\d{2}:?\d{2})$/;
965
+ var FRACTIONAL_SECONDS_RE$1 = /\.\d+$/;
966
+ var TWO_DIGITS_RE = /^\d{2}$/;
967
+ var CLOCK_SEPARATOR = ":";
968
+ var DATE_TIME_SEPARATOR = "T";
969
+ var MIN_CLOCK_SEGMENTS = 2;
970
+ var MAX_CLOCK_SEGMENTS = 3;
971
+ var WHOLE_MINUTE_SECONDS = "00";
972
+ var MAX_SECONDS = 59;
973
+ /** Whether `value` is a real calendar day (`2026-02-30` is not).
974
+ *
975
+ * Delegated to the parser the record lint, the calendar grid and the day view
976
+ * already share, so the push agrees with the rest of the app on what a valid
977
+ * stored date IS — and rejects an impossible one here instead of turning it
978
+ * into an opaque Google 400. */
979
+ var isCalendarDate = (value) => parseIsoDate(value) !== null;
980
+ /** A value Google already accepts verbatim, as the event time to send, or null.
981
+ *
982
+ * Reached when the schema maps `start` onto a `string` field: `projectValue`
983
+ * normalises only `datetime` fields, so that column holds Google's own text,
984
+ * zone and all. The civil part is still validated — matching a trailing `Z` is
985
+ * not evidence that the rest is a date-time. */
986
+ function rawGoogleTime(value) {
987
+ if (isCalendarDate(value)) return { date: value };
988
+ const zone = value.match(ZONE_SUFFIX_RE$1)?.[0];
989
+ if (zone === void 0) return null;
990
+ return parseIsoDateTime(value.slice(0, value.length - zone.length).replace(FRACTIONAL_SECONDS_RE$1, "")) === null ? null : { dateTime: value };
991
+ }
992
+ /** Google's own all-day `end` is EXCLUSIVE — a single day on the 12th ends on
993
+ * the 13th. The pull stores that as `2026-05-13T00:00` and this takes the date
994
+ * part straight back, so the exclusive end round-trips untouched. Looks
995
+ * off-by-one; adjusting it would shorten every all-day event by a day. */
996
+ function allDayFrom(datePart) {
997
+ return { date: datePart };
998
+ }
999
+ /** Split `HH:MM[:SS]` into RFC3339's always-three segments, or null.
1000
+ *
1001
+ * Split rather than one regex: expressing the optional seconds as `(?::(\d{2}))?`
1002
+ * nests a quantifier inside a quantified group, which the ReDoS lint rejects. */
1003
+ function clockSegments(clockPart) {
1004
+ const segments = clockPart.split(CLOCK_SEPARATOR);
1005
+ if (segments.length < MIN_CLOCK_SEGMENTS || segments.length > MAX_CLOCK_SEGMENTS) return null;
1006
+ if (!segments.every((segment) => TWO_DIGITS_RE.test(segment))) return null;
1007
+ const [hours, minutes, seconds] = segments;
1008
+ if (seconds !== void 0 && Number(seconds) > MAX_SECONDS) return null;
1009
+ return [
1010
+ hours,
1011
+ minutes,
1012
+ seconds ?? WHOLE_MINUTE_SECONDS
1013
+ ].join(CLOCK_SEPARATOR);
1014
+ }
1015
+ /** `YYYY-MM-DDTHH:MM[:SS]` → its date and its RFC3339 clock, or null.
1016
+ *
1017
+ * Validity comes from the shared strict parser, so an out-of-range clock or an
1018
+ * impossible day is refused here rather than sent to Google. */
1019
+ function parseStoredDateTime(value) {
1020
+ if (parseIsoDateTime(value) === null) return null;
1021
+ const [datePart, clockPart, ...extra] = value.split(DATE_TIME_SEPARATOR);
1022
+ if (datePart === void 0 || clockPart === void 0 || extra.length > 0) return null;
1023
+ const clock = clockSegments(clockPart);
1024
+ return clock === null ? null : {
1025
+ datePart,
1026
+ clock
1027
+ };
1028
+ }
1029
+ /** The zone designator to re-attach, or null when `previous` carries none. */
1030
+ function zoneSuffixOf(previous) {
1031
+ if (previous === void 0) return null;
1032
+ return previous.match(ZONE_SUFFIX_RE$1)?.[0] ?? null;
1033
+ }
1034
+ /** Build the event time to send for one field.
1035
+ *
1036
+ * `previous` is the value Google last reported for this event, absent for a
1037
+ * locally-created record. Returns null when `local` is neither a stored
1038
+ * datetime nor a raw Google value — the caller reports that record rather than
1039
+ * letting this invent a time Google would silently accept. */
1040
+ function toGoogleEventTime(local, previous, calendarTimeZone) {
1041
+ if (typeof local !== "string") return null;
1042
+ const trimmed = local.trim();
1043
+ if (trimmed.length === 0) return null;
1044
+ const raw = rawGoogleTime(trimmed);
1045
+ if (raw !== null) return raw;
1046
+ const parts = parseStoredDateTime(trimmed);
1047
+ if (parts === null) return null;
1048
+ if (previous !== void 0 && isCalendarDate(previous)) return allDayFrom(parts.datePart);
1049
+ const zone = zoneSuffixOf(previous);
1050
+ const dateTime = `${parts.datePart}${DATE_TIME_SEPARATOR}${parts.clock}`;
1051
+ return zone === null ? {
1052
+ dateTime,
1053
+ timeZone: calendarTimeZone
1054
+ } : { dateTime: `${dateTime}${zone}` };
1055
+ }
1056
+ //#endregion
923
1057
  //#region src/google/collectionDateTime.ts
924
1058
  /** All-day events carry no clock; midnight is where the day view places them. */
925
1059
  var ALL_DAY_CLOCK = "00:00";
926
1060
  var DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
927
1061
  /** RFC3339 zone designator: `Z`, `+09:00`, `-0500`. */
928
- var ZONE_SUFFIX_RE$1 = /(?:Z|[+-]\d{2}:?\d{2})$/;
1062
+ var ZONE_SUFFIX_RE = /(?:Z|[+-]\d{2}:?\d{2})$/;
929
1063
  /** Fractional seconds have no place in the collection datetime shape. */
930
- var FRACTIONAL_SECONDS_RE$1 = /\.\d+$/;
1064
+ var FRACTIONAL_SECONDS_RE = /\.\d+$/;
931
1065
  /** Normalise one Google Calendar time value for storage in a `datetime` field.
932
1066
  * Anything that isn't a recognisable Google shape (empty string, non-string,
933
1067
  * free text) is returned untouched — the record lint should report it rather
@@ -937,12 +1071,10 @@ function toCollectionDateTime(value) {
937
1071
  const trimmed = value.trim();
938
1072
  if (DATE_ONLY_RE.test(trimmed)) return `${trimmed}T${ALL_DAY_CLOCK}`;
939
1073
  if (!trimmed.includes("T")) return value;
940
- return trimmed.replace(ZONE_SUFFIX_RE$1, "").replace(FRACTIONAL_SECONDS_RE$1, "");
1074
+ return trimmed.replace(ZONE_SUFFIX_RE, "").replace(FRACTIONAL_SECONDS_RE, "");
941
1075
  }
942
1076
  //#endregion
943
- //#region src/google/collectionSync.ts
944
- var GOOGLE_CALENDAR_SYNC_TASK_ID = "system:google-calendar-sync";
945
- var DEFAULT_SYNC_INTERVAL_MS = 3600 * 1e3;
1077
+ //#region src/google/collectionProjection.ts
946
1078
  var DATETIME_FIELD_TYPE = "datetime";
947
1079
  /** Own-property lookup, mirroring what the record lint reads: a spec reachable
948
1080
  * only through the prototype chain is not a DECLARED field, so it must not
@@ -967,482 +1099,75 @@ function toCollectionRecord(event, map, primaryKey, fields) {
967
1099
  [primaryKey]: event.id
968
1100
  };
969
1101
  }
970
- function classifyWrite(eventId, kind) {
971
- if (kind === "ok") return { kind: "written" };
972
- if (kind === "invalid-id") return {
973
- kind: "unwritable",
974
- message: `write ${eventId}: invalid-id`
975
- };
976
- return {
977
- kind: "error",
978
- message: `write ${eventId}: ${kind}`
979
- };
980
- }
981
- function classifyDelete(eventId, kind) {
982
- if (kind === "ok") return { kind: "removed" };
983
- if (kind === "not-found") return { kind: "skipped" };
984
- if (kind === "invalid-id") return {
985
- kind: "unwritable",
986
- message: `delete ${eventId}: invalid-id`
987
- };
1102
+ /** Google's mapped values laid over whatever the record already holds.
1103
+ *
1104
+ * A record file is written whole (`writeItem`), so projecting alone would drop
1105
+ * every column the map does not name — a collection cannot then carry a local
1106
+ * note next to a mirrored event, because the next pull that touches that event
1107
+ * silently deletes it. The projection still wins on the fields it covers: those
1108
+ * are Google's to own. */
1109
+ function mergeIntoExisting(existing, projected) {
988
1110
  return {
989
- kind: "error",
990
- message: `delete ${eventId}: ${kind}`
1111
+ ...existing ?? {},
1112
+ ...projected
991
1113
  };
992
1114
  }
993
- async function applyEvent(collection, event, workspaceRoot) {
994
- const { schema } = collection;
995
- try {
996
- const store = storeFor(collection, { workspaceRoot });
997
- if (!store.write || !store.delete) return {
998
- kind: "unwritable",
999
- message: `collection '${collection.slug}' is read-only`
1000
- };
1001
- if (event.status === "cancelled") {
1002
- const deleted = await store.delete(event.id);
1003
- return classifyDelete(event.id, deleted.kind);
1004
- }
1005
- const record = toCollectionRecord(event, schema.googleCalendar?.map ?? {}, schema.primaryKey, schema.fields);
1006
- const written = await store.write(event.id, record);
1007
- return classifyWrite(event.id, written.kind);
1008
- } catch (error) {
1009
- return {
1010
- kind: "error",
1011
- message: `apply ${event.id}: ${String(error)}`
1012
- };
1013
- }
1115
+ //#endregion
1116
+ //#region src/google/pushPlan.ts
1117
+ /** The event fields Google lets a caller write. `htmlLink` and `status` are
1118
+ * read-only, so a record column mapped to either is ignored here rather than
1119
+ * rejected — the mapping was authored for the pull, and a push has no business
1120
+ * invalidating it. */
1121
+ var PUSHABLE_SOURCE_FIELDS = [
1122
+ "summary",
1123
+ "start",
1124
+ "end",
1125
+ "colorId",
1126
+ "description",
1127
+ "location"
1128
+ ];
1129
+ var isPushableSource = (source) => PUSHABLE_SOURCE_FIELDS.includes(source);
1130
+ /** The subset of a schema's `map` a push can act on: collection field → event field. */
1131
+ function pushableMap(map) {
1132
+ return Object.fromEntries(Object.entries(map).filter((entry) => isPushableSource(entry[1])));
1014
1133
  }
1015
- async function restartFullSync(accessToken, calendarId, workspaceRoot) {
1016
- await clearCalendarSyncToken(calendarId, workspaceRoot);
1017
- await clearCalendarShadow(calendarId, workspaceRoot);
1018
- return await syncCalendarEvents(accessToken, { calendarId });
1134
+ /** Event field → the record column feeding it. The schema authors the map the
1135
+ * other way round (for the pull); a push looks up by event field. Two columns
1136
+ * mapped to one event field is already ambiguous for the pull — last wins. */
1137
+ function bySourceField(map) {
1138
+ return Object.fromEntries(Object.entries(map).map(([field, source]) => [source, field]));
1019
1139
  }
1020
- /** Serialise `run` against whatever is already running for `key`.
1140
+ /** Google accepts a caller-chosen event id of 5-1024 base32hex characters. A
1141
+ * record id outside that (a semantic id like `team-standup`) cannot be used,
1142
+ * and v1 reports it instead of re-keying the record — see the plan. */
1143
+ var CLIENT_EVENT_ID_RE = /^[0-9a-v]{5,1024}$/;
1144
+ var isClientSettableEventId = (eventId) => CLIENT_EVENT_ID_RE.test(eventId);
1145
+ /** A field's value as comparable/sendable text.
1021
1146
  *
1022
- * `locks` is passed in so the queuing rule is testable without module state;
1023
- * the key is dropped once nothing is queued behind it, so the map cannot grow
1024
- * an entry per calendar forever. A failed predecessor still releases the
1025
- * queue — `then(run, run)`. */
1026
- async function withKeyedLock(locks, key, run) {
1027
- const result = (locks.get(key) ?? Promise.resolve()).then(run, run);
1028
- const tail = result.then(() => void 0, () => void 0);
1029
- locks.set(key, tail);
1030
- try {
1031
- return await result;
1032
- } finally {
1033
- if (locks.get(key) === tail) locks.delete(key);
1034
- }
1035
- }
1036
- /** In-flight sync per canonical calendar id. Module state on purpose: the
1037
- * scheduler, the create trigger and the Refresh button are three doors into
1038
- * the same calendar (CodeRabbit review #2566). */
1039
- var calendarLocks = /* @__PURE__ */ new Map();
1040
- /** Sync ONE calendar and fan its events out to every collection bound to it.
1147
+ * Absent, null and `""` all collapse to `""`: Google reports an inherited colour
1148
+ * as `""` while the record file may omit the key entirely, and treating those as
1149
+ * different would report a local edit on every single push.
1041
1150
  *
1042
- * The fan-out is not an optimisation, it is correctness: the sync token is
1043
- * keyed by `calendarId`, so syncing collection-by-collection would let the
1044
- * first collection advance the shared token and leave every later collection
1045
- * on the same calendar reading an already-consumed window — silently missing
1046
- * those events forever. Fetch once, apply to all, then advance the token.
1047
- * (Codex + CodeRabbit review on #2184.)
1151
+ * A non-primitive is JSON-encoded rather than left to `String`, whose
1152
+ * `[object Object]` would make two DIFFERENT objects compare equal — a
1153
+ * silently-skipped edit. */
1154
+ var fieldText = (value) => {
1155
+ if (value === void 0 || value === null) return "";
1156
+ if (typeof value === "string") return value;
1157
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
1158
+ return JSON.stringify(value) ?? "";
1159
+ };
1160
+ /** A stored datetime with no seconds — what the collection's own date-time input
1161
+ * produces. Google's values always carry seconds, so the pull writes them and a
1162
+ * hand-typed value does not. */
1163
+ var SECONDLESS_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
1164
+ var WHOLE_MINUTE = ":00";
1165
+ /** Field text with `…T09:30` and `…T09:30:00` collapsed to one spelling.
1048
1166
  *
1049
- * Queued per calendar for the same reason the fan-out exists: two passes over
1050
- * one calendar (a Refresh click landing during the scheduled run) would each
1051
- * load the SAME stored token and walk the same window. That is idempotent —
1052
- * writes are upserts by event id — but it is a wasted full walk. Queued, the
1053
- * second pass resumes from the token the first just stored and fetches only
1054
- * what is genuinely new. */
1055
- /** Serialise anything that touches ONE calendar's sync state.
1056
- *
1057
- * Shared with the push path (#2598), not only the pull doors: a push that
1058
- * overtakes an in-flight pull would record a baseline for events the pull is
1059
- * still writing, so the next push would diff against a future it never saw. */
1060
- async function withCalendarLock(calendarId, run) {
1061
- return await withKeyedLock(calendarLocks, canonicalCalendarId(calendarId), run);
1062
- }
1063
- async function syncCalendarGroup(calendarId, collections, workspaceRoot) {
1064
- return await withCalendarLock(calendarId, () => syncCalendarGroupNow(calendarId, collections, workspaceRoot));
1065
- }
1066
- async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
1067
- const accessToken = await getGoogleAccessToken();
1068
- const first = await syncCalendarEvents(accessToken, {
1069
- calendarId,
1070
- syncToken: await loadCalendarSyncToken(calendarId, workspaceRoot) ?? void 0
1071
- });
1072
- const result = first.fullResyncRequired ? await restartFullSync(accessToken, calendarId, workspaceRoot) : first;
1073
- const results = [];
1074
- for (const collection of collections) results.push(await applyEventsToCollection(collection, result.events, workspaceRoot));
1075
- const unwritable = results.flatMap((entry) => entry.unwritable);
1076
- if (unwritable.length > 0) log.warn("google", "skipping calendar events that can never be stored", {
1077
- calendarId,
1078
- unwritable
1079
- });
1080
- const failed = results.flatMap((entry) => entry.errors);
1081
- if (failed.length > 0) {
1082
- log.warn("google", "holding back calendar sync token after failed writes", {
1083
- calendarId,
1084
- failed: failed.length
1085
- });
1086
- return results;
1087
- }
1088
- await saveCalendarShadow(calendarId, shadowUpdates(result.events), workspaceRoot);
1089
- if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
1090
- return results;
1091
- }
1092
- /** The baseline this window establishes: what Google now says per event, and
1093
- * `null` for a cancelled one so a recreate cannot resume from a dead baseline. */
1094
- function shadowUpdates(events) {
1095
- return Object.fromEntries(events.map((event) => [event.id, event.status === "cancelled" ? null : toShadowEvent(event)]));
1096
- }
1097
- /** Save the window's token unless every collection that consumed it was deleted
1098
- * while the sync was in flight.
1099
- *
1100
- * The sync opens with a `discoverCollections()` snapshot, so a delete landing
1101
- * mid-run has already cleared this calendar's token by the time we get here
1102
- * (`releaseOrphanedCalendarToken`). Saving anyway would resurrect exactly the
1103
- * orphan the delete removed, and the next collection on this calendar would
1104
- * resume from a token describing records it never received (#2428).
1105
- *
1106
- * One survivor is enough: it still needs the incremental position. */
1107
- async function advanceToken(calendarId, nextSyncToken, collections, workspaceRoot) {
1108
- if (!await anySyncedCollectionSurvives(collections)) {
1109
- log.info("google", "not advancing the sync token — every collection on this calendar was deleted mid-sync", { calendarId });
1110
- return;
1111
- }
1112
- await saveCalendarSyncToken(calendarId, nextSyncToken, workspaceRoot);
1113
- }
1114
- async function pathExists(absPath) {
1115
- try {
1116
- await stat(absPath);
1117
- return true;
1118
- } catch {
1119
- return false;
1120
- }
1121
- }
1122
- /** Liveness of the collections a sync just wrote to, checked against the skill
1123
- * dir `deleteCollection` removes. `exists` is injected so the rule is testable
1124
- * without a filesystem. An empty group has no survivor by definition. */
1125
- async function anySyncedCollectionSurvives(collections, exists = pathExists) {
1126
- return (await Promise.all(collections.map((collection) => exists(collection.skillDir)))).some(Boolean);
1127
- }
1128
- async function applyEventsToCollection(collection, events, workspaceRoot) {
1129
- const outcomes = [];
1130
- for (const event of events) outcomes.push(await applyEvent(collection, event, workspaceRoot));
1131
- return {
1132
- slug: collection.slug,
1133
- written: outcomes.filter((outcome) => outcome.kind === "written").length,
1134
- removed: outcomes.filter((outcome) => outcome.kind === "removed").length,
1135
- unwritable: outcomes.flatMap((outcome) => outcome.kind === "unwritable" ? [outcome.message] : []),
1136
- errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
1137
- };
1138
- }
1139
- /** A refresh token is what lets the scheduler run unattended; without one
1140
- * there is nothing to sync with. */
1141
- async function isGoogleLinked() {
1142
- return Boolean((await loadGoogleTokens())?.refresh_token);
1143
- }
1144
- /** Group the declaring collections by the calendar they read, so each calendar
1145
- * is fetched exactly once.
1146
- *
1147
- * Keyed by the CANONICAL id, not the declared one: an omitted `calendarId` and
1148
- * an explicit `"primary"` address the same calendar and therefore share one
1149
- * sync token, so grouping them apart would let one group advance the token out
1150
- * from under the other — the very loss this grouping exists to prevent
1151
- * (Codex review #2184). */
1152
- function groupByCalendar(collections) {
1153
- const groups = /* @__PURE__ */ new Map();
1154
- for (const collection of collections) {
1155
- const key = canonicalCalendarId(collection.schema.googleCalendar?.calendarId);
1156
- groups.set(key, [...groups.get(key) ?? [], collection]);
1157
- }
1158
- return groups;
1159
- }
1160
- /** The canonical calendar whose sync token nothing needs any more, or null.
1161
- *
1162
- * Sync tokens are keyed by calendar, NOT by collection, so a deleted
1163
- * collection's token outlives it. Recreating a collection on the same calendar
1164
- * then resumes from that token and receives only the delta — the new
1165
- * collection never gets the history (#2428).
1166
- *
1167
- * Returns null while ANY remaining collection still reads that calendar:
1168
- * clearing a live calendar's token costs a full re-walk on the next sync. The
1169
- * comparison is on the CANONICAL id for the same reason `groupByCalendar` is —
1170
- * an omitted `calendarId` and an explicit `"primary"` are one calendar. */
1171
- function orphanedCalendarId(deleted, remaining) {
1172
- if (!deleted.googleCalendar) return null;
1173
- const key = canonicalCalendarId(deleted.googleCalendar.calendarId);
1174
- return remaining.some((other) => other.googleCalendar && canonicalCalendarId(other.googleCalendar.calendarId) === key) ? null : key;
1175
- }
1176
- /** Drop the sync token of a just-deleted collection's calendar, unless another
1177
- * collection still reads it. Call AFTER the delete lands — the check reads the
1178
- * collections that survive it.
1179
- *
1180
- * Returns the cleared calendar id, or null when nothing was cleared. Never
1181
- * throws: a failed cleanup must not fail the delete it follows. */
1182
- async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
1183
- try {
1184
- if (!deleted.googleCalendar) return null;
1185
- const orphaned = orphanedCalendarId(deleted, (await discoverCollections({ workspaceRoot })).map((collection) => collection.schema));
1186
- if (orphaned === null) return null;
1187
- await clearCalendarSyncToken(orphaned, workspaceRoot);
1188
- await clearCalendarShadow(orphaned, workspaceRoot);
1189
- log.info("google", "cleared the sync token of a calendar no collection reads any more", { calendarId: orphaned });
1190
- return orphaned;
1191
- } catch (error) {
1192
- log.warn("google", "could not release the deleted collection's calendar sync token", { error: String(error) });
1193
- return null;
1194
- }
1195
- }
1196
- /** Every declaring collection, grouped by the calendar it reads. */
1197
- async function declaringGroups(workspaceRoot) {
1198
- return groupByCalendar((await discoverCollections({ workspaceRoot })).filter((collection) => collection.schema.googleCalendar));
1199
- }
1200
- /** Whether a background sync of these groups may run at all.
1201
- *
1202
- * Authoring the collection before linking the account is an expected state,
1203
- * not a failure. Checking once here keeps it a quiet skip instead of an
1204
- * access-token throw per calendar, every hour, until the user links (#2188).
1205
- * A user-triggered sync answers differently — it says so out loud. */
1206
- async function backgroundSyncAllowed(groups) {
1207
- if (groups.size === 0) return false;
1208
- if (await isGoogleLinked()) return true;
1209
- log.info("google", "skipping calendar sync — no Google account linked on this host", { calendars: groups.size });
1210
- return false;
1211
- }
1212
- /** Run each group, isolating failures per calendar — one unreachable calendar
1213
- * (or a revoked grant) must not stop the others. */
1214
- async function runCalendarGroups(groups, workspaceRoot) {
1215
- const results = [];
1216
- for (const [calendarId, collections] of groups) try {
1217
- results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot));
1218
- } catch (error) {
1219
- log.warn("google", "calendar sync failed", {
1220
- calendarId,
1221
- error: String(error)
1222
- });
1223
- results.push(...collections.map((collection) => ({
1224
- slug: collection.slug,
1225
- written: 0,
1226
- removed: 0,
1227
- unwritable: [],
1228
- errors: [String(error)]
1229
- })));
1230
- }
1231
- return results;
1232
- }
1233
- /** Sync every collection that declares `googleCalendar`. */
1234
- async function syncDueCalendarCollections(workspaceRoot) {
1235
- const groups = await declaringGroups(workspaceRoot);
1236
- return await backgroundSyncAllowed(groups) ? await runCalendarGroups(groups, workspaceRoot) : [];
1237
- }
1238
- /** The groups whose calendar has never synced. A missing token IS the "created
1239
- * since the last sync" signal — nothing else distinguishes a new collection
1240
- * from an edited one on the write path this feeds (#2427).
1241
- *
1242
- * Self-silencing by construction: the first sync stores a token, so a calendar
1243
- * matches at most once. `loadToken` is injected so the rule is testable without
1244
- * a workspace on disk. */
1245
- async function unsyncedGroups(groups, loadToken) {
1246
- const checked = await Promise.all([...groups].map(async (entry) => await loadToken(entry[0]) === null ? entry : null));
1247
- return new Map(checked.filter((entry) => entry !== null));
1248
- }
1249
- /** Sync only the calendars that have never synced — the first sync for a
1250
- * just-created collection, which otherwise stays empty until the hourly
1251
- * scheduler run (#2427). Cheap and safe to call on every config write. */
1252
- async function syncNewCalendarCollections(workspaceRoot) {
1253
- const pending = await unsyncedGroups(await declaringGroups(workspaceRoot), (calendarId) => loadCalendarSyncToken(calendarId, workspaceRoot));
1254
- if (!await backgroundSyncAllowed(pending)) return [];
1255
- log.info("google", "running the first sync for newly declared calendars", { calendars: [...pending.keys()] });
1256
- return await runCalendarGroups(pending, workspaceRoot);
1257
- }
1258
- var liveManualSyncDeps = {
1259
- loadGroups: declaringGroups,
1260
- isLinked: isGoogleLinked,
1261
- runGroups: runCalendarGroups
1262
- };
1263
- /** Sync the calendar ONE collection reads, on demand (the Refresh button).
1264
- *
1265
- * Deliberately syncs the whole group, not just `slug`: the sync token is keyed
1266
- * by calendar, so consuming a window for one collection would leave the others
1267
- * on that calendar reading an already-consumed one. Returns every result of the
1268
- * group so the caller can report the requested slug's own counts.
1269
- *
1270
- * "Does this collection sync at all" is answered BEFORE "is Google linked":
1271
- * telling someone to link their account for a collection that never declared a
1272
- * calendar sends them fixing the wrong thing. */
1273
- async function syncCalendarForCollection(slug, workspaceRoot, deps = liveManualSyncDeps) {
1274
- const owning = [...await deps.loadGroups(workspaceRoot)].filter(([, collections]) => collections.some((collection) => collection.slug === slug));
1275
- if (owning.length === 0) return { kind: "not-a-calendar" };
1276
- if (!await deps.isLinked()) return { kind: "not-linked" };
1277
- return {
1278
- kind: "synced",
1279
- results: await deps.runGroups(new Map(owning), workspaceRoot)
1280
- };
1281
- }
1282
- /** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
1283
- * with a single line. */
1284
- function googleCalendarSyncTaskDef(opts) {
1285
- return {
1286
- id: GOOGLE_CALENDAR_SYNC_TASK_ID,
1287
- name: "Google Calendar sync",
1288
- description: "Pulls changed Google Calendar events into any collection declaring `googleCalendar`, without invoking the LLM.",
1289
- schedule: {
1290
- type: SCHEDULE_TYPES.interval,
1291
- intervalMs: opts?.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS
1292
- },
1293
- missedRunPolicy: MISSED_RUN_POLICIES.runOnce,
1294
- run: () => syncDueCalendarCollections(opts?.workspaceRoot ?? getWorkspaceRoot()).then(() => {})
1295
- };
1296
- }
1297
- //#endregion
1298
- //#region src/google/pushDateTime.ts
1299
- /** RFC3339 zone designator: `Z`, `+09:00`, `-0500`. */
1300
- var ZONE_SUFFIX_RE = /(?:Z|[+-]\d{2}:?\d{2})$/;
1301
- var FRACTIONAL_SECONDS_RE = /\.\d+$/;
1302
- var TWO_DIGITS_RE = /^\d{2}$/;
1303
- var CLOCK_SEPARATOR = ":";
1304
- var DATE_TIME_SEPARATOR = "T";
1305
- var MIN_CLOCK_SEGMENTS = 2;
1306
- var MAX_CLOCK_SEGMENTS = 3;
1307
- var WHOLE_MINUTE_SECONDS = "00";
1308
- var MAX_SECONDS = 59;
1309
- /** Whether `value` is a real calendar day (`2026-02-30` is not).
1310
- *
1311
- * Delegated to the parser the record lint, the calendar grid and the day view
1312
- * already share, so the push agrees with the rest of the app on what a valid
1313
- * stored date IS — and rejects an impossible one here instead of turning it
1314
- * into an opaque Google 400. */
1315
- var isCalendarDate = (value) => parseIsoDate(value) !== null;
1316
- /** A value Google already accepts verbatim, as the event time to send, or null.
1317
- *
1318
- * Reached when the schema maps `start` onto a `string` field: `projectValue`
1319
- * normalises only `datetime` fields, so that column holds Google's own text,
1320
- * zone and all. The civil part is still validated — matching a trailing `Z` is
1321
- * not evidence that the rest is a date-time. */
1322
- function rawGoogleTime(value) {
1323
- if (isCalendarDate(value)) return { date: value };
1324
- const zone = value.match(ZONE_SUFFIX_RE)?.[0];
1325
- if (zone === void 0) return null;
1326
- return parseIsoDateTime(value.slice(0, value.length - zone.length).replace(FRACTIONAL_SECONDS_RE, "")) === null ? null : { dateTime: value };
1327
- }
1328
- /** Google's own all-day `end` is EXCLUSIVE — a single day on the 12th ends on
1329
- * the 13th. The pull stores that as `2026-05-13T00:00` and this takes the date
1330
- * part straight back, so the exclusive end round-trips untouched. Looks
1331
- * off-by-one; adjusting it would shorten every all-day event by a day. */
1332
- function allDayFrom(datePart) {
1333
- return { date: datePart };
1334
- }
1335
- /** Split `HH:MM[:SS]` into RFC3339's always-three segments, or null.
1336
- *
1337
- * Split rather than one regex: expressing the optional seconds as `(?::(\d{2}))?`
1338
- * nests a quantifier inside a quantified group, which the ReDoS lint rejects. */
1339
- function clockSegments(clockPart) {
1340
- const segments = clockPart.split(CLOCK_SEPARATOR);
1341
- if (segments.length < MIN_CLOCK_SEGMENTS || segments.length > MAX_CLOCK_SEGMENTS) return null;
1342
- if (!segments.every((segment) => TWO_DIGITS_RE.test(segment))) return null;
1343
- const [hours, minutes, seconds] = segments;
1344
- if (seconds !== void 0 && Number(seconds) > MAX_SECONDS) return null;
1345
- return [
1346
- hours,
1347
- minutes,
1348
- seconds ?? WHOLE_MINUTE_SECONDS
1349
- ].join(CLOCK_SEPARATOR);
1350
- }
1351
- /** `YYYY-MM-DDTHH:MM[:SS]` → its date and its RFC3339 clock, or null.
1352
- *
1353
- * Validity comes from the shared strict parser, so an out-of-range clock or an
1354
- * impossible day is refused here rather than sent to Google. */
1355
- function parseStoredDateTime(value) {
1356
- if (parseIsoDateTime(value) === null) return null;
1357
- const [datePart, clockPart, ...extra] = value.split(DATE_TIME_SEPARATOR);
1358
- if (datePart === void 0 || clockPart === void 0 || extra.length > 0) return null;
1359
- const clock = clockSegments(clockPart);
1360
- return clock === null ? null : {
1361
- datePart,
1362
- clock
1363
- };
1364
- }
1365
- /** The zone designator to re-attach, or null when `previous` carries none. */
1366
- function zoneSuffixOf(previous) {
1367
- if (previous === void 0) return null;
1368
- return previous.match(ZONE_SUFFIX_RE)?.[0] ?? null;
1369
- }
1370
- /** Build the event time to send for one field.
1371
- *
1372
- * `previous` is the value Google last reported for this event, absent for a
1373
- * locally-created record. Returns null when `local` is neither a stored
1374
- * datetime nor a raw Google value — the caller reports that record rather than
1375
- * letting this invent a time Google would silently accept. */
1376
- function toGoogleEventTime(local, previous, calendarTimeZone) {
1377
- if (typeof local !== "string") return null;
1378
- const trimmed = local.trim();
1379
- if (trimmed.length === 0) return null;
1380
- const raw = rawGoogleTime(trimmed);
1381
- if (raw !== null) return raw;
1382
- const parts = parseStoredDateTime(trimmed);
1383
- if (parts === null) return null;
1384
- if (previous !== void 0 && isCalendarDate(previous)) return allDayFrom(parts.datePart);
1385
- const zone = zoneSuffixOf(previous);
1386
- const dateTime = `${parts.datePart}${DATE_TIME_SEPARATOR}${parts.clock}`;
1387
- return zone === null ? {
1388
- dateTime,
1389
- timeZone: calendarTimeZone
1390
- } : { dateTime: `${dateTime}${zone}` };
1391
- }
1392
- //#endregion
1393
- //#region src/google/pushPlan.ts
1394
- /** The event fields Google lets a caller write. `htmlLink` and `status` are
1395
- * read-only, so a record column mapped to either is ignored here rather than
1396
- * rejected — the mapping was authored for the pull, and a push has no business
1397
- * invalidating it. */
1398
- var PUSHABLE_SOURCE_FIELDS = [
1399
- "summary",
1400
- "start",
1401
- "end",
1402
- "colorId"
1403
- ];
1404
- var isPushableSource = (source) => PUSHABLE_SOURCE_FIELDS.includes(source);
1405
- /** The subset of a schema's `map` a push can act on: collection field → event field. */
1406
- function pushableMap(map) {
1407
- return Object.fromEntries(Object.entries(map).filter((entry) => isPushableSource(entry[1])));
1408
- }
1409
- /** Event field → the record column feeding it. The schema authors the map the
1410
- * other way round (for the pull); a push looks up by event field. Two columns
1411
- * mapped to one event field is already ambiguous for the pull — last wins. */
1412
- function bySourceField(map) {
1413
- return Object.fromEntries(Object.entries(map).map(([field, source]) => [source, field]));
1414
- }
1415
- /** Google accepts a caller-chosen event id of 5-1024 base32hex characters. A
1416
- * record id outside that (a semantic id like `team-standup`) cannot be used,
1417
- * and v1 reports it instead of re-keying the record — see the plan. */
1418
- var CLIENT_EVENT_ID_RE = /^[0-9a-v]{5,1024}$/;
1419
- var isClientSettableEventId = (eventId) => CLIENT_EVENT_ID_RE.test(eventId);
1420
- /** A field's value as comparable/sendable text.
1421
- *
1422
- * Absent, null and `""` all collapse to `""`: Google reports an inherited colour
1423
- * as `""` while the record file may omit the key entirely, and treating those as
1424
- * different would report a local edit on every single push.
1425
- *
1426
- * A non-primitive is JSON-encoded rather than left to `String`, whose
1427
- * `[object Object]` would make two DIFFERENT objects compare equal — a
1428
- * silently-skipped edit. */
1429
- var fieldText = (value) => {
1430
- if (value === void 0 || value === null) return "";
1431
- if (typeof value === "string") return value;
1432
- if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
1433
- return JSON.stringify(value) ?? "";
1434
- };
1435
- /** A stored datetime with no seconds — what the collection's own date-time input
1436
- * produces. Google's values always carry seconds, so the pull writes them and a
1437
- * hand-typed value does not. */
1438
- var SECONDLESS_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
1439
- var WHOLE_MINUTE = ":00";
1440
- /** Field text with `…T09:30` and `…T09:30:00` collapsed to one spelling.
1441
- *
1442
- * Without this, a record the user typed a time into never stops looking edited:
1443
- * the push writes it, Google answers with seconds, the baseline projection keeps
1444
- * them, and the seconds-less record then differs from its own baseline on every
1445
- * later push — re-PATCHing the same event on every click, forever.
1167
+ * Without this, a record the user typed a time into never stops looking edited:
1168
+ * the push writes it, Google answers with seconds, the baseline projection keeps
1169
+ * them, and the seconds-less record then differs from its own baseline on every
1170
+ * later push — re-PATCHing the same event on every click, forever.
1446
1171
  *
1447
1172
  * A value that carries a zone offset (a `start` mapped onto a `string` field) is
1448
1173
  * left alone: it is Google's own text on both sides already. */
@@ -1589,6 +1314,16 @@ function eventTime(ctx, record, source, previous) {
1589
1314
  time
1590
1315
  };
1591
1316
  }
1317
+ /** `description` / `location` for a create — omitted when empty, since there is
1318
+ * nothing to state about an event that carries neither. */
1319
+ var optionalText = (ctx, record) => {
1320
+ const description = fieldText(localValue(ctx, record, "description"));
1321
+ const location = fieldText(localValue(ctx, record, "location"));
1322
+ return {
1323
+ ...description ? { description } : {},
1324
+ ...location ? { location } : {}
1325
+ };
1326
+ };
1592
1327
  async function createFromRecord(ctx, eventId, record) {
1593
1328
  if (!isClientSettableEventId(eventId)) return {
1594
1329
  kind: "skipped",
@@ -1613,10 +1348,21 @@ async function createFromRecord(ctx, eventId, record) {
1613
1348
  summary: fieldText(localValue(ctx, record, "summary")),
1614
1349
  start: start.time,
1615
1350
  end: end.time,
1351
+ ...optionalText(ctx, record),
1616
1352
  ...colorId ? { colorId } : {}
1617
1353
  })
1618
1354
  };
1619
1355
  }
1356
+ /** The free-text fields of a patch, each only when the local edit touched it.
1357
+ * Unlike `colorId`, `""` is a real value here — Google clears the field. */
1358
+ var textPatch = (ctx, record, changed) => {
1359
+ const touched = [
1360
+ "summary",
1361
+ "description",
1362
+ "location"
1363
+ ].filter((source) => changed.includes(source));
1364
+ return Object.fromEntries(touched.map((source) => [source, fieldText(localValue(ctx, record, source))]));
1365
+ };
1620
1366
  /** Only the fields the local edit touched — PATCH semantics, so an untouched
1621
1367
  * attendee list / recurrence rule is left alone. */
1622
1368
  function buildPatch(ctx, record, changed, shadow) {
@@ -1637,7 +1383,7 @@ function buildPatch(ctx, record, changed, shadow) {
1637
1383
  return {
1638
1384
  ok: true,
1639
1385
  patch: {
1640
- ...changed.includes("summary") ? { summary: fieldText(localValue(ctx, record, "summary")) } : {},
1386
+ ...textPatch(ctx, record, changed),
1641
1387
  ...changed.includes("colorId") ? { colorId } : {},
1642
1388
  ...Object.fromEntries(resolved.flatMap((entry) => entry.result.ok ? [[entry.source, entry.result.time]] : []))
1643
1389
  }
@@ -1704,111 +1450,622 @@ async function createOrAdopt(ctx, eventId, record) {
1704
1450
  };
1705
1451
  }
1706
1452
  }
1707
- /** A 403 on a write is a permissions answer, but `googleApiError` appends the
1708
- * "is the API enabled for the Cloud project?" hint to every 403 — true for a
1709
- * disabled API, misleading here. Reached when the up-front role check could not
1710
- * run because the calendar is not in the user's list, which is exactly when the
1711
- * user needs the real reason. */
1712
- function writeFailure(eventId, error) {
1713
- if (isGoogleApiError(error) && error.status === 403) return {
1714
- kind: "skipped",
1715
- message: `${eventId}: Google refused the write — you may not have permission to change events on this calendar`
1716
- };
1717
- return {
1718
- kind: "error",
1719
- message: `${eventId}: ${String(error)}`
1720
- };
1721
- }
1722
- async function pushRecord(ctx, eventId, record) {
1453
+ /** A 403 on a write is a permissions answer, but `googleApiError` appends the
1454
+ * "is the API enabled for the Cloud project?" hint to every 403 — true for a
1455
+ * disabled API, misleading here. Reached when the up-front role check could not
1456
+ * run because the calendar is not in the user's list, which is exactly when the
1457
+ * user needs the real reason. */
1458
+ function writeFailure(eventId, error) {
1459
+ if (isGoogleApiError(error) && error.status === 403) return {
1460
+ kind: "skipped",
1461
+ message: `${eventId}: Google refused the write — you may not have permission to change events on this calendar`
1462
+ };
1463
+ return {
1464
+ kind: "error",
1465
+ message: `${eventId}: ${String(error)}`
1466
+ };
1467
+ }
1468
+ async function pushRecord(ctx, eventId, record) {
1469
+ try {
1470
+ const shadow = ctx.shadow[eventId];
1471
+ const plan = planRecord(eventId, record, shadow, ctx.map, ctx.primaryKey, ctx.fields);
1472
+ if (plan.kind === "unchanged") return { kind: "unchanged" };
1473
+ if (plan.kind === "create" || shadow === void 0) return await createOrAdopt(ctx, eventId, record);
1474
+ return await updateFromRecord(ctx, eventId, record, plan.fields, shadow);
1475
+ } catch (error) {
1476
+ return writeFailure(eventId, error);
1477
+ }
1478
+ }
1479
+ /** The baseline an outcome establishes, so the next push sees the record as
1480
+ * unchanged instead of pushing it again. Empty for anything that wrote nothing
1481
+ * and had no baseline to repair. */
1482
+ function pushedShadow(outcomes) {
1483
+ const written = outcomes.flatMap((outcome) => "event" in outcome && outcome.event !== void 0 ? [outcome.event] : []);
1484
+ return Object.fromEntries(written.map((event) => [event.id, toShadowEvent(event)]));
1485
+ }
1486
+ /** See `unpushedIds` on the result for why `skipped` is not in this set. */
1487
+ var UNPUSHED_KINDS = ["conflict", "error"];
1488
+ /** Whether this outcome leaves a local edit that only exists locally, so the
1489
+ * pull must not overwrite the record and the baseline must not advance. */
1490
+ var isUnpushed = (kind) => UNPUSHED_KINDS.includes(kind);
1491
+ function tally(slug, attempts, localDeletes) {
1492
+ const outcomes = attempts.map((attempt) => attempt.outcome);
1493
+ const count = (kind) => outcomes.filter((outcome) => outcome.kind === kind).length;
1494
+ return {
1495
+ slug,
1496
+ created: count("created"),
1497
+ updated: count("updated"),
1498
+ conflicts: count("conflict"),
1499
+ localDeletes,
1500
+ skipped: outcomes.flatMap((outcome) => outcome.kind === "skipped" ? [outcome.message] : []),
1501
+ errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : []),
1502
+ unpushedIds: attempts.filter((attempt) => isUnpushed(attempt.outcome.kind)).map((attempt) => attempt.eventId)
1503
+ };
1504
+ }
1505
+ /** Record ids whose local value no longer matches the baseline — edits that have
1506
+ * not reached Google.
1507
+ *
1508
+ * Pure, and deliberately built from `planRecord`, so it answers with exactly the
1509
+ * records a push WOULD have acted on. A private re-implementation would drift
1510
+ * from the push and protect the wrong set. */
1511
+ function locallyEditedIds(records, shadow, map, primaryKey, fields) {
1512
+ return records.map((record) => ({
1513
+ eventId: fieldText(record[primaryKey]),
1514
+ record
1515
+ })).filter(({ eventId, record }) => planRecord(eventId, record, shadow[eventId], map, primaryKey, fields).kind !== "unchanged").map(({ eventId }) => eventId);
1516
+ }
1517
+ /** The same set, read from the workspace.
1518
+ *
1519
+ * Needed when a push cannot run AT ALL — a calendar whose role degraded to
1520
+ * reader, a revoked grant, an IO failure. The pull still runs, since reading
1521
+ * needs no write access, and would overwrite exactly these records while
1522
+ * advancing their baseline past the edit (CodeRabbit review #2666). Computed
1523
+ * from the stored baseline alone, so it works precisely when Google is the
1524
+ * thing that is unreachable. */
1525
+ async function unsentLocalEdits(collection, workspaceRoot) {
1526
+ const { schema } = collection;
1527
+ const shadow = await loadCalendarShadow(schema.googleCalendar?.calendarId, workspaceRoot);
1528
+ return locallyEditedIds(await storeFor(collection, { workspaceRoot }).list(), shadow, pushableMap(schema.googleCalendar?.map ?? {}), schema.primaryKey, schema.fields);
1529
+ }
1530
+ /** Push one collection WITHOUT taking the calendar lock.
1531
+ *
1532
+ * The caller must already hold it. The scheduled push→pull cycle
1533
+ * (`collectionSync.ts`) runs inside the lock it took for the pull, and the lock
1534
+ * is not reentrant — going through `pushCalendarForCollection` there would make
1535
+ * the sync wait on itself forever. */
1536
+ async function pushCollectionNow(collection, workspaceRoot, deps = liveDeps) {
1537
+ const { schema, slug } = collection;
1538
+ const calendarId = schema.googleCalendar?.calendarId;
1539
+ const accessToken = await deps.accessToken();
1540
+ const meta = await deps.calendarMeta(accessToken, calendarId);
1541
+ if (isDeniedAccessRole(meta.accessRole)) return {
1542
+ kind: "read-only",
1543
+ accessRole: meta.accessRole ?? ""
1544
+ };
1545
+ const map = pushableMap(schema.googleCalendar?.map ?? {});
1546
+ const shadow = await loadCalendarShadow(calendarId, workspaceRoot);
1547
+ const records = await storeFor(collection, { workspaceRoot }).list();
1548
+ const ctx = {
1549
+ accessToken,
1550
+ calendarId,
1551
+ timeZone: meta.timeZone,
1552
+ map,
1553
+ bySource: bySourceField(map),
1554
+ primaryKey: schema.primaryKey,
1555
+ fields: schema.fields,
1556
+ shadow
1557
+ };
1558
+ const attempts = [];
1559
+ for (const record of records) {
1560
+ const eventId = fieldText(record[schema.primaryKey]);
1561
+ const outcome = await pushRecord(ctx, eventId, record);
1562
+ attempts.push({
1563
+ eventId,
1564
+ outcome
1565
+ });
1566
+ await saveCalendarShadow(calendarId, pushedShadow([outcome]), workspaceRoot);
1567
+ }
1568
+ const result = tally(slug, attempts, locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))).length);
1569
+ if (result.errors.length > 0) log.warn("google", "calendar push finished with errors", {
1570
+ slug,
1571
+ errors: result.errors.length
1572
+ });
1573
+ return {
1574
+ kind: "pushed",
1575
+ result
1576
+ };
1577
+ }
1578
+ /** Push ONE collection's records to the calendar it declares.
1579
+ *
1580
+ * "Does this collection sync at all" is answered before "is Google linked", for
1581
+ * the reason `syncCalendarForCollection` gives: telling someone to link their
1582
+ * account for a collection that never declared a calendar sends them fixing the
1583
+ * wrong thing. */
1584
+ async function pushCalendarForCollection(slug, workspaceRoot, deps = liveDeps) {
1585
+ try {
1586
+ const collection = await deps.findCollection(slug, workspaceRoot);
1587
+ if (collection === null) return { kind: "not-a-calendar" };
1588
+ if (!await deps.isLinked()) return { kind: "not-linked" };
1589
+ return await withCalendarLock(collection.schema.googleCalendar?.calendarId, () => pushCollectionNow(collection, workspaceRoot, deps));
1590
+ } catch (error) {
1591
+ log.warn("google", "calendar push could not start", {
1592
+ slug,
1593
+ error: String(error)
1594
+ });
1595
+ return {
1596
+ kind: "failed",
1597
+ message: String(error)
1598
+ };
1599
+ }
1600
+ }
1601
+ //#endregion
1602
+ //#region src/google/collectionSync.ts
1603
+ var GOOGLE_CALENDAR_SYNC_TASK_ID = "system:google-calendar-sync";
1604
+ var DEFAULT_SYNC_INTERVAL_MS = 3600 * 1e3;
1605
+ function classifyWrite(eventId, kind) {
1606
+ if (kind === "ok") return { kind: "written" };
1607
+ if (kind === "invalid-id") return {
1608
+ kind: "unwritable",
1609
+ message: `write ${eventId}: invalid-id`
1610
+ };
1611
+ return {
1612
+ kind: "error",
1613
+ message: `write ${eventId}: ${kind}`
1614
+ };
1615
+ }
1616
+ function classifyDelete(eventId, kind) {
1617
+ if (kind === "ok") return { kind: "removed" };
1618
+ if (kind === "not-found") return { kind: "skipped" };
1619
+ if (kind === "invalid-id") return {
1620
+ kind: "unwritable",
1621
+ message: `delete ${eventId}: invalid-id`
1622
+ };
1623
+ return {
1624
+ kind: "error",
1625
+ message: `delete ${eventId}: ${kind}`
1626
+ };
1627
+ }
1628
+ async function applyEvent(collection, event, workspaceRoot) {
1629
+ const { schema } = collection;
1630
+ try {
1631
+ const store = storeFor(collection, { workspaceRoot });
1632
+ if (!store.write || !store.delete) return {
1633
+ kind: "unwritable",
1634
+ message: `collection '${collection.slug}' is read-only`
1635
+ };
1636
+ if (event.status === "cancelled") {
1637
+ const deleted = await store.delete(event.id);
1638
+ return classifyDelete(event.id, deleted.kind);
1639
+ }
1640
+ const record = toCollectionRecord(event, schema.googleCalendar?.map ?? {}, schema.primaryKey, schema.fields);
1641
+ const written = await store.write(event.id, mergeIntoExisting(await store.read(event.id), record));
1642
+ return classifyWrite(event.id, written.kind);
1643
+ } catch (error) {
1644
+ return {
1645
+ kind: "error",
1646
+ message: `apply ${event.id}: ${String(error)}`
1647
+ };
1648
+ }
1649
+ }
1650
+ /** The events of a window a pull may act on.
1651
+ *
1652
+ * A record the push just refused to send is edited on BOTH sides. Writing
1653
+ * Google's value over it destroys the local edit the push declined to resolve,
1654
+ * so the local one stands. Single-sourced because the record write and the
1655
+ * baseline write must agree exactly on which events they skip — disagreeing is
1656
+ * what would silently overwrite Google on the next push (#2620). */
1657
+ function pullableEvents(events, unpushed) {
1658
+ return events.filter((event) => !unpushed.has(event.id));
1659
+ }
1660
+ /** Report what an automatic push did, since nobody is watching a scheduled run.
1661
+ * A conflict or an error means that record now diverges from Google until
1662
+ * someone resolves it, which must not be silent. */
1663
+ function reportAutoPush(slug, result) {
1664
+ const { created, updated, conflicts, skipped, errors, unpushedIds } = result;
1665
+ if (created + updated > 0) log.info("google", "auto-pushed local calendar edits", {
1666
+ slug,
1667
+ created,
1668
+ updated
1669
+ });
1670
+ if (unpushedIds.length > 0) log.warn("google", "records the auto push could not send — the pull will leave them alone", {
1671
+ slug,
1672
+ conflicts,
1673
+ unpushedIds
1674
+ });
1675
+ if (skipped.length > 0) log.warn("google", "records the auto push skipped", {
1676
+ slug,
1677
+ skipped
1678
+ });
1679
+ if (errors.length > 0) log.warn("google", "auto push errors", {
1680
+ slug,
1681
+ errors
1682
+ });
1683
+ }
1684
+ var NOTHING_UNPUSHED = /* @__PURE__ */ new Set();
1685
+ /** A collection whose protection is unknown must not be pulled this run.
1686
+ *
1687
+ * Reported through the retryable `errors` channel rather than as a special case:
1688
+ * that already holds the sync token AND skips the baseline save, so the window
1689
+ * simply replays next run with nothing lost. Failing OPEN here — pulling with no
1690
+ * protection — would overwrite the very edits this exists to protect; the read
1691
+ * that failed is no evidence that the pull's own writes would fail too, so they
1692
+ * would land (CodeRabbit review #2666). */
1693
+ var PROTECTION_UNKNOWN = "could not work out which records to protect after a failed push";
1694
+ /** What ONE collection's pull must leave alone: only what ITS OWN push failed to
1695
+ * send.
1696
+ *
1697
+ * Scoped per collection because a calendar can back several of them, and a
1698
+ * conflict in one says nothing about the others. Sharing one set across the
1699
+ * group starved a collection that never even declares `autoPush`: it cannot
1700
+ * conflict, yet a neighbour's conflict froze its records — and the sync token
1701
+ * still advanced, so Google never resent them (Codex review #2666). */
1702
+ var unpushedFor = (unpushed, slug) => {
1703
+ const protection = unpushed.get(slug);
1704
+ return protection === void 0 ? NOTHING_UNPUSHED : protection;
1705
+ };
1706
+ /** What the calendar's BASELINE must leave alone: the union over every
1707
+ * collection.
1708
+ *
1709
+ * Deliberately not per collection, unlike the records above. `.push-state.json`
1710
+ * holds ONE baseline per calendar, shared by every collection on it, so there is
1711
+ * no per-collection baseline to hold back. Advancing it while any collection
1712
+ * still has an unresolved conflict is the failure that silently overwrites
1713
+ * Google on the next push, and holding it back only ever means "keep reporting
1714
+ * the conflict" — so the union is the safe side of an asymmetry the shared
1715
+ * storage forces.
1716
+ *
1717
+ * A `null` (unknown) entry contributes nothing, because that collection reports
1718
+ * a retryable error instead — which stops the baseline being saved at all. */
1719
+ var allUnpushed = (unpushed) => new Set([...unpushed.values()].flatMap((ids) => ids === null ? [] : [...ids]));
1720
+ /** What a collection's pull must protect when its push did not run AT ALL.
1721
+ *
1722
+ * Registering nothing here was a silent data loss of exactly the kind this
1723
+ * feature exists to prevent: a calendar whose role degrades to reader refuses
1724
+ * the whole push, yet the pull still runs — reading needs no write access — and
1725
+ * overwrote every unsent local edit while advancing its baseline past it, so the
1726
+ * next push could not even report the conflict (CodeRabbit review #2666).
1727
+ *
1728
+ * Protects the edited records rather than all of them, so an unchanged record
1729
+ * keeps syncing normally. `null` when even that could not be worked out — the
1730
+ * caller then refuses to pull the collection at all, because failing open here
1731
+ * destroys exactly what this protects. */
1732
+ async function protectUnsentEdits(collection, workspaceRoot) {
1733
+ try {
1734
+ const edited = await unsentLocalEdits(collection, workspaceRoot);
1735
+ if (edited.length > 0) log.warn("google", "protecting local edits a failed push could not send", {
1736
+ slug: collection.slug,
1737
+ edited
1738
+ });
1739
+ return new Set(edited);
1740
+ } catch (error) {
1741
+ log.warn("google", PROTECTION_UNKNOWN, {
1742
+ slug: collection.slug,
1743
+ error: String(error)
1744
+ });
1745
+ return null;
1746
+ }
1747
+ }
1748
+ /** Push every `autoPush` collection in this group, and answer with the records
1749
+ * whose local edit did not reach Google, keyed by collection.
1750
+ *
1751
+ * MUST run inside the calendar lock the caller already holds — hence
1752
+ * `pushCollectionNow` rather than `pushCalendarForCollection`, which would take
1753
+ * the same non-reentrant lock and wait on itself forever.
1754
+ *
1755
+ * A failed push must not stop the pull: the pull is what keeps the collection
1756
+ * fresh, and a revoked write grant is no reason to freeze reading. */
1757
+ async function pushAutoCollections(collections, workspaceRoot) {
1758
+ const unpushed = /* @__PURE__ */ new Map();
1759
+ for (const collection of collections.filter((entry) => entry.schema.googleCalendar?.autoPush)) try {
1760
+ const outcome = await pushCollectionNow(collection, workspaceRoot);
1761
+ if (outcome.kind === "pushed") {
1762
+ reportAutoPush(collection.slug, outcome.result);
1763
+ unpushed.set(collection.slug, new Set(outcome.result.unpushedIds));
1764
+ continue;
1765
+ }
1766
+ log.warn("google", "auto push did not run", {
1767
+ slug: collection.slug,
1768
+ reason: outcome.kind
1769
+ });
1770
+ unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
1771
+ } catch (error) {
1772
+ log.warn("google", "auto push failed — pulling anyway", {
1773
+ slug: collection.slug,
1774
+ error: String(error)
1775
+ });
1776
+ unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
1777
+ }
1778
+ return unpushed;
1779
+ }
1780
+ async function restartFullSync(accessToken, calendarId, workspaceRoot) {
1781
+ await clearCalendarSyncToken(calendarId, workspaceRoot);
1782
+ await clearCalendarShadow(calendarId, workspaceRoot);
1783
+ return await syncCalendarEvents(accessToken, { calendarId });
1784
+ }
1785
+ /** Sync ONE calendar and fan its events out to every collection bound to it.
1786
+ *
1787
+ * The fan-out is not an optimisation, it is correctness: the sync token is
1788
+ * keyed by `calendarId`, so syncing collection-by-collection would let the
1789
+ * first collection advance the shared token and leave every later collection
1790
+ * on the same calendar reading an already-consumed window — silently missing
1791
+ * those events forever. Fetch once, apply to all, then advance the token.
1792
+ * (Codex + CodeRabbit review on #2184.)
1793
+ *
1794
+ * Queued per calendar for the same reason the fan-out exists: two passes over
1795
+ * one calendar (a Refresh click landing during the scheduled run) would each
1796
+ * load the SAME stored token and walk the same window. That is idempotent —
1797
+ * writes are upserts by event id — but it is a wasted full walk. Queued, the
1798
+ * second pass resumes from the token the first just stored and fetches only
1799
+ * what is genuinely new. */
1800
+ async function syncCalendarGroup(calendarId, collections, workspaceRoot) {
1801
+ return await withCalendarLock(calendarId, () => syncCalendarGroupNow(calendarId, collections, workspaceRoot));
1802
+ }
1803
+ async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
1804
+ const unpushed = await pushAutoCollections(collections, workspaceRoot);
1805
+ const accessToken = await getGoogleAccessToken();
1806
+ const first = await syncCalendarEvents(accessToken, {
1807
+ calendarId,
1808
+ syncToken: await loadCalendarSyncToken(calendarId, workspaceRoot) ?? void 0
1809
+ });
1810
+ const result = first.fullResyncRequired ? await restartFullSync(accessToken, calendarId, workspaceRoot) : first;
1811
+ const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed);
1812
+ if (windowFullyLanded(calendarId, results)) {
1813
+ await saveCalendarShadow(calendarId, shadowUpdates(result.events, allUnpushed(unpushed)), workspaceRoot);
1814
+ if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
1815
+ }
1816
+ return results;
1817
+ }
1818
+ /** Apply one window to every collection on the calendar, honouring what each
1819
+ * one's own push protected. A collection whose protection could not be worked
1820
+ * out is not pulled at all — it reports a retryable error instead, which holds
1821
+ * the token and the baseline back for the whole group. */
1822
+ async function applyWindowToGroup(collections, events, workspaceRoot, unpushed) {
1823
+ const results = [];
1824
+ for (const collection of collections) {
1825
+ const protection = unpushedFor(unpushed, collection.slug);
1826
+ results.push(protection === null ? {
1827
+ slug: collection.slug,
1828
+ written: 0,
1829
+ removed: 0,
1830
+ unwritable: [],
1831
+ errors: [PROTECTION_UNKNOWN]
1832
+ } : await applyEventsToCollection(collection, events, workspaceRoot, protection));
1833
+ }
1834
+ return results;
1835
+ }
1836
+ /** Whether the token and baseline may advance past this window.
1837
+ *
1838
+ * Google never resends a window, so advancing past a failed write would lose
1839
+ * those events for good; holding the token back just replays them next run
1840
+ * (writes are idempotent). An `unwritable` event can never succeed, so it does
1841
+ * NOT hold the token — but it is logged loudly, since it will silently never
1842
+ * appear in the collection. */
1843
+ function windowFullyLanded(calendarId, results) {
1844
+ results.filter((entry) => entry.unwritable.length > 0).forEach((entry) => log.warn("google", "skipping calendar events that can never be stored", {
1845
+ calendarId,
1846
+ slug: entry.slug,
1847
+ unwritable: entry.unwritable
1848
+ }));
1849
+ const failed = results.filter((entry) => entry.errors.length > 0);
1850
+ failed.forEach((entry) => log.warn("google", "holding back calendar sync token after failed writes", {
1851
+ calendarId,
1852
+ slug: entry.slug,
1853
+ errors: entry.errors
1854
+ }));
1855
+ return failed.length === 0;
1856
+ }
1857
+ /** The baseline this window establishes: what Google now says per event, and
1858
+ * `null` for a cancelled one so a recreate cannot resume from a dead baseline.
1859
+ *
1860
+ * An event whose record the push could not send is left OUT, and that omission
1861
+ * is load-bearing. Advancing its baseline to Google's new value while the record
1862
+ * keeps the local one would make the next push read a plain one-sided edit —
1863
+ * no conflict to detect any more — and quietly overwrite Google. Held back, the
1864
+ * baseline stays older than both sides, so the conflict keeps being reported
1865
+ * until someone resolves it (#2620). */
1866
+ function shadowUpdates(events, unpushed = /* @__PURE__ */ new Set()) {
1867
+ const carried = pullableEvents(events, unpushed);
1868
+ return Object.fromEntries(carried.map((event) => [event.id, event.status === "cancelled" ? null : toShadowEvent(event)]));
1869
+ }
1870
+ /** Save the window's token unless every collection that consumed it was deleted
1871
+ * while the sync was in flight.
1872
+ *
1873
+ * The sync opens with a `discoverCollections()` snapshot, so a delete landing
1874
+ * mid-run has already cleared this calendar's token by the time we get here
1875
+ * (`releaseOrphanedCalendarToken`). Saving anyway would resurrect exactly the
1876
+ * orphan the delete removed, and the next collection on this calendar would
1877
+ * resume from a token describing records it never received (#2428).
1878
+ *
1879
+ * One survivor is enough: it still needs the incremental position. */
1880
+ async function advanceToken(calendarId, nextSyncToken, collections, workspaceRoot) {
1881
+ if (!await anySyncedCollectionSurvives(collections)) {
1882
+ log.info("google", "not advancing the sync token — every collection on this calendar was deleted mid-sync", { calendarId });
1883
+ return;
1884
+ }
1885
+ await saveCalendarSyncToken(calendarId, nextSyncToken, workspaceRoot);
1886
+ }
1887
+ async function pathExists(absPath) {
1723
1888
  try {
1724
- const shadow = ctx.shadow[eventId];
1725
- const plan = planRecord(eventId, record, shadow, ctx.map, ctx.primaryKey, ctx.fields);
1726
- if (plan.kind === "unchanged") return { kind: "unchanged" };
1727
- if (plan.kind === "create" || shadow === void 0) return await createOrAdopt(ctx, eventId, record);
1728
- return await updateFromRecord(ctx, eventId, record, plan.fields, shadow);
1729
- } catch (error) {
1730
- return writeFailure(eventId, error);
1889
+ await stat(absPath);
1890
+ return true;
1891
+ } catch {
1892
+ return false;
1731
1893
  }
1732
1894
  }
1733
- /** The baseline an outcome establishes, so the next push sees the record as
1734
- * unchanged instead of pushing it again. Empty for anything that wrote nothing
1735
- * and had no baseline to repair. */
1736
- function pushedShadow(outcomes) {
1737
- const written = outcomes.flatMap((outcome) => "event" in outcome && outcome.event !== void 0 ? [outcome.event] : []);
1738
- return Object.fromEntries(written.map((event) => [event.id, toShadowEvent(event)]));
1895
+ /** Liveness of the collections a sync just wrote to, checked against the skill
1896
+ * dir `deleteCollection` removes. `exists` is injected so the rule is testable
1897
+ * without a filesystem. An empty group has no survivor by definition. */
1898
+ async function anySyncedCollectionSurvives(collections, exists = pathExists) {
1899
+ return (await Promise.all(collections.map((collection) => exists(collection.skillDir)))).some(Boolean);
1739
1900
  }
1740
- function tally(slug, outcomes, localDeletes) {
1741
- const count = (kind) => outcomes.filter((outcome) => outcome.kind === kind).length;
1901
+ async function applyEventsToCollection(collection, events, workspaceRoot, unpushed) {
1902
+ const outcomes = [];
1903
+ for (const event of pullableEvents(events, unpushed)) outcomes.push(await applyEvent(collection, event, workspaceRoot));
1742
1904
  return {
1743
- slug,
1744
- created: count("created"),
1745
- updated: count("updated"),
1746
- conflicts: count("conflict"),
1747
- localDeletes,
1748
- skipped: outcomes.flatMap((outcome) => outcome.kind === "skipped" ? [outcome.message] : []),
1905
+ slug: collection.slug,
1906
+ written: outcomes.filter((outcome) => outcome.kind === "written").length,
1907
+ removed: outcomes.filter((outcome) => outcome.kind === "removed").length,
1908
+ unwritable: outcomes.flatMap((outcome) => outcome.kind === "unwritable" ? [outcome.message] : []),
1749
1909
  errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
1750
1910
  };
1751
1911
  }
1752
- async function pushNow(collection, workspaceRoot, deps) {
1753
- const { schema, slug } = collection;
1754
- const calendarId = schema.googleCalendar?.calendarId;
1755
- const accessToken = await deps.accessToken();
1756
- const meta = await deps.calendarMeta(accessToken, calendarId);
1757
- if (isDeniedAccessRole(meta.accessRole)) return {
1758
- kind: "read-only",
1759
- accessRole: meta.accessRole ?? ""
1760
- };
1761
- const map = pushableMap(schema.googleCalendar?.map ?? {});
1762
- const shadow = await loadCalendarShadow(calendarId, workspaceRoot);
1763
- const records = await storeFor(collection, { workspaceRoot }).list();
1764
- const ctx = {
1765
- accessToken,
1766
- calendarId,
1767
- timeZone: meta.timeZone,
1768
- map,
1769
- bySource: bySourceField(map),
1770
- primaryKey: schema.primaryKey,
1771
- fields: schema.fields,
1772
- shadow
1773
- };
1774
- const outcomes = [];
1775
- for (const record of records) {
1776
- const outcome = await pushRecord(ctx, fieldText(record[schema.primaryKey]), record);
1777
- outcomes.push(outcome);
1778
- await saveCalendarShadow(calendarId, pushedShadow([outcome]), workspaceRoot);
1912
+ /** A refresh token is what lets the scheduler run unattended; without one
1913
+ * there is nothing to sync with. */
1914
+ async function isGoogleLinked() {
1915
+ return Boolean((await loadGoogleTokens())?.refresh_token);
1916
+ }
1917
+ /** Group the declaring collections by the calendar they read, so each calendar
1918
+ * is fetched exactly once.
1919
+ *
1920
+ * Keyed by the CANONICAL id, not the declared one: an omitted `calendarId` and
1921
+ * an explicit `"primary"` address the same calendar and therefore share one
1922
+ * sync token, so grouping them apart would let one group advance the token out
1923
+ * from under the other — the very loss this grouping exists to prevent
1924
+ * (Codex review #2184). */
1925
+ function groupByCalendar(collections) {
1926
+ const groups = /* @__PURE__ */ new Map();
1927
+ for (const collection of collections) {
1928
+ const key = canonicalCalendarId(collection.schema.googleCalendar?.calendarId);
1929
+ groups.set(key, [...groups.get(key) ?? [], collection]);
1779
1930
  }
1780
- const result = tally(slug, outcomes, locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))).length);
1781
- if (result.errors.length > 0) log.warn("google", "calendar push finished with errors", {
1782
- slug,
1783
- errors: result.errors.length
1784
- });
1785
- return {
1786
- kind: "pushed",
1787
- result
1788
- };
1931
+ return groups;
1789
1932
  }
1790
- /** Push ONE collection's records to the calendar it declares.
1933
+ /** The canonical calendar whose sync token nothing needs any more, or null.
1791
1934
  *
1792
- * "Does this collection sync at all" is answered before "is Google linked", for
1793
- * the reason `syncCalendarForCollection` gives: telling someone to link their
1794
- * account for a collection that never declared a calendar sends them fixing the
1795
- * wrong thing. */
1796
- async function pushCalendarForCollection(slug, workspaceRoot, deps = liveDeps) {
1935
+ * Sync tokens are keyed by calendar, NOT by collection, so a deleted
1936
+ * collection's token outlives it. Recreating a collection on the same calendar
1937
+ * then resumes from that token and receives only the delta — the new
1938
+ * collection never gets the history (#2428).
1939
+ *
1940
+ * Returns null while ANY remaining collection still reads that calendar:
1941
+ * clearing a live calendar's token costs a full re-walk on the next sync. The
1942
+ * comparison is on the CANONICAL id for the same reason `groupByCalendar` is —
1943
+ * an omitted `calendarId` and an explicit `"primary"` are one calendar. */
1944
+ function orphanedCalendarId(deleted, remaining) {
1945
+ if (!deleted.googleCalendar) return null;
1946
+ const key = canonicalCalendarId(deleted.googleCalendar.calendarId);
1947
+ return remaining.some((other) => other.googleCalendar && canonicalCalendarId(other.googleCalendar.calendarId) === key) ? null : key;
1948
+ }
1949
+ /** Drop the sync token of a just-deleted collection's calendar, unless another
1950
+ * collection still reads it. Call AFTER the delete lands — the check reads the
1951
+ * collections that survive it.
1952
+ *
1953
+ * Returns the cleared calendar id, or null when nothing was cleared. Never
1954
+ * throws: a failed cleanup must not fail the delete it follows. */
1955
+ async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
1797
1956
  try {
1798
- const collection = await deps.findCollection(slug, workspaceRoot);
1799
- if (collection === null) return { kind: "not-a-calendar" };
1800
- if (!await deps.isLinked()) return { kind: "not-linked" };
1801
- return await withCalendarLock(collection.schema.googleCalendar?.calendarId, () => pushNow(collection, workspaceRoot, deps));
1957
+ if (!deleted.googleCalendar) return null;
1958
+ const orphaned = orphanedCalendarId(deleted, (await discoverCollections({ workspaceRoot })).map((collection) => collection.schema));
1959
+ if (orphaned === null) return null;
1960
+ await clearCalendarSyncToken(orphaned, workspaceRoot);
1961
+ await clearCalendarShadow(orphaned, workspaceRoot);
1962
+ log.info("google", "cleared the sync token of a calendar no collection reads any more", { calendarId: orphaned });
1963
+ return orphaned;
1802
1964
  } catch (error) {
1803
- log.warn("google", "calendar push could not start", {
1804
- slug,
1965
+ log.warn("google", "could not release the deleted collection's calendar sync token", { error: String(error) });
1966
+ return null;
1967
+ }
1968
+ }
1969
+ /** Every declaring collection, grouped by the calendar it reads. */
1970
+ async function declaringGroups(workspaceRoot) {
1971
+ return groupByCalendar((await discoverCollections({ workspaceRoot })).filter((collection) => collection.schema.googleCalendar));
1972
+ }
1973
+ /** Whether a background sync of these groups may run at all.
1974
+ *
1975
+ * Authoring the collection before linking the account is an expected state,
1976
+ * not a failure. Checking once here keeps it a quiet skip instead of an
1977
+ * access-token throw per calendar, every hour, until the user links (#2188).
1978
+ * A user-triggered sync answers differently — it says so out loud. */
1979
+ async function backgroundSyncAllowed(groups) {
1980
+ if (groups.size === 0) return false;
1981
+ if (await isGoogleLinked()) return true;
1982
+ log.info("google", "skipping calendar sync — no Google account linked on this host", { calendars: groups.size });
1983
+ return false;
1984
+ }
1985
+ /** Run each group, isolating failures per calendar — one unreachable calendar
1986
+ * (or a revoked grant) must not stop the others. */
1987
+ async function runCalendarGroups(groups, workspaceRoot) {
1988
+ const results = [];
1989
+ for (const [calendarId, collections] of groups) try {
1990
+ results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot));
1991
+ } catch (error) {
1992
+ log.warn("google", "calendar sync failed", {
1993
+ calendarId,
1805
1994
  error: String(error)
1806
1995
  });
1807
- return {
1808
- kind: "failed",
1809
- message: String(error)
1810
- };
1996
+ results.push(...collections.map((collection) => ({
1997
+ slug: collection.slug,
1998
+ written: 0,
1999
+ removed: 0,
2000
+ unwritable: [],
2001
+ errors: [String(error)]
2002
+ })));
1811
2003
  }
2004
+ return results;
2005
+ }
2006
+ /** Sync every collection that declares `googleCalendar`. */
2007
+ async function syncDueCalendarCollections(workspaceRoot) {
2008
+ const groups = await declaringGroups(workspaceRoot);
2009
+ return await backgroundSyncAllowed(groups) ? await runCalendarGroups(groups, workspaceRoot) : [];
2010
+ }
2011
+ /** The groups whose calendar has never synced. A missing token IS the "created
2012
+ * since the last sync" signal — nothing else distinguishes a new collection
2013
+ * from an edited one on the write path this feeds (#2427).
2014
+ *
2015
+ * Self-silencing by construction: the first sync stores a token, so a calendar
2016
+ * matches at most once. `loadToken` is injected so the rule is testable without
2017
+ * a workspace on disk. */
2018
+ async function unsyncedGroups(groups, loadToken) {
2019
+ const checked = await Promise.all([...groups].map(async (entry) => await loadToken(entry[0]) === null ? entry : null));
2020
+ return new Map(checked.filter((entry) => entry !== null));
2021
+ }
2022
+ /** Sync only the calendars that have never synced — the first sync for a
2023
+ * just-created collection, which otherwise stays empty until the hourly
2024
+ * scheduler run (#2427). Cheap and safe to call on every config write. */
2025
+ async function syncNewCalendarCollections(workspaceRoot) {
2026
+ const pending = await unsyncedGroups(await declaringGroups(workspaceRoot), (calendarId) => loadCalendarSyncToken(calendarId, workspaceRoot));
2027
+ if (!await backgroundSyncAllowed(pending)) return [];
2028
+ log.info("google", "running the first sync for newly declared calendars", { calendars: [...pending.keys()] });
2029
+ return await runCalendarGroups(pending, workspaceRoot);
2030
+ }
2031
+ var liveManualSyncDeps = {
2032
+ loadGroups: declaringGroups,
2033
+ isLinked: isGoogleLinked,
2034
+ runGroups: runCalendarGroups
2035
+ };
2036
+ /** Sync the calendar ONE collection reads, on demand (the Refresh button).
2037
+ *
2038
+ * Deliberately syncs the whole group, not just `slug`: the sync token is keyed
2039
+ * by calendar, so consuming a window for one collection would leave the others
2040
+ * on that calendar reading an already-consumed one. Returns every result of the
2041
+ * group so the caller can report the requested slug's own counts.
2042
+ *
2043
+ * "Does this collection sync at all" is answered BEFORE "is Google linked":
2044
+ * telling someone to link their account for a collection that never declared a
2045
+ * calendar sends them fixing the wrong thing. */
2046
+ async function syncCalendarForCollection(slug, workspaceRoot, deps = liveManualSyncDeps) {
2047
+ const owning = [...await deps.loadGroups(workspaceRoot)].filter(([, collections]) => collections.some((collection) => collection.slug === slug));
2048
+ if (owning.length === 0) return { kind: "not-a-calendar" };
2049
+ if (!await deps.isLinked()) return { kind: "not-linked" };
2050
+ return {
2051
+ kind: "synced",
2052
+ results: await deps.runGroups(new Map(owning), workspaceRoot)
2053
+ };
2054
+ }
2055
+ /** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
2056
+ * with a single line. */
2057
+ function googleCalendarSyncTaskDef(opts) {
2058
+ return {
2059
+ id: GOOGLE_CALENDAR_SYNC_TASK_ID,
2060
+ name: "Google Calendar sync",
2061
+ description: "Pulls changed Google Calendar events into any collection declaring `googleCalendar`, without invoking the LLM.",
2062
+ schedule: {
2063
+ type: SCHEDULE_TYPES.interval,
2064
+ intervalMs: opts?.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS
2065
+ },
2066
+ missedRunPolicy: MISSED_RUN_POLICIES.runOnce,
2067
+ run: () => syncDueCalendarCollections(opts?.workspaceRoot ?? getWorkspaceRoot()).then(() => {})
2068
+ };
1812
2069
  }
1813
2070
  //#endregion
1814
2071
  //#region src/google/tasks.ts
@@ -2005,6 +2262,6 @@ async function deleteDriveFile(accessToken, input) {
2005
2262
  await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}`, { method: "DELETE" });
2006
2263
  }
2007
2264
  //#endregion
2008
- export { CANCELLED_EVENT_STATUS, DEFAULT_LIST_MAX_RESULTS, GOOGLE_AUTH_CANCELLED, GOOGLE_CALENDARLIST_SCOPE, GOOGLE_CALENDAR_SCOPE, GOOGLE_CALENDAR_SYNC_TASK_ID, GOOGLE_DRIVE_FILE_SCOPE, GOOGLE_SCOPES, GOOGLE_TASKS_SCOPE, GoogleApiError, HTTP_CONFLICT, HTTP_FORBIDDEN, HTTP_PRECONDITION_FAILED, MAX_LIST_RESULTS, PUSHABLE_SOURCE_FIELDS, anySyncedCollectionSurvives, assertSafeMimeType, authorizeGoogle, baselineRecord, brokerBaseUrl, brokerExchange, brokerRefresh, brokerStart, buildEventPatch, buildMultipartBody, buildTaskPatch, bySourceField, calendarApiError, calendarPushStatePath, calendarSyncStatePath, canonicalCalendarId, canonicalTaskListId, classifyDelete, classifyWrite, clearCalendarShadow, clearCalendarSyncToken, clientSecretPresence, collectCalendarPages, commitLinkedTokens, completeTask, configureGoogleHost, conflictingFields, createCalendarEvent, createDriveFile, createGoogleAuthFlow, createTask, deleteCalendarEvent, deleteDriveFile, deleteGoogleTokens, deleteTask, fieldText, findClientSecretPath, getCalendar, getCalendarColors, getCalendarEvent, getGoogleAccessToken, googleApiError, googleAuthFlow, googleCalendarSyncTaskDef, googleConfigDir, googleSecretsDir, googleTokenPath, groupByCalendar, isClientSettableEventId, isDeniedAccessRole, isGoogleApiError, isIsoDateTimeWithOffset, isTextMimeType, legacyGoogleTokenPath, listCalendarEvents, listCalendars, listDriveFiles, listTaskLists, listTasks, loadCalendarShadow, loadCalendarSyncToken, loadClientSecret, loadGoogleTokens, locallyChangedFields, locallyDeletedIds, mayAdoptExisting, mergeGoogleTokens, mergeShadow, orphanedCalendarId, pickBoundary, planRecord, pushCalendarForCollection, pushableMap, readDriveFile, releaseOrphanedCalendarToken, resolveEventSpan, saveCalendarShadow, saveCalendarSyncToken, saveGoogleTokens, shadowUpdates, syncCalendarEvents, syncCalendarForCollection, syncCalendarGroup, syncDueCalendarCollections, syncNewCalendarCollections, toCalendarSummary, toCollectionDateTime, toCollectionRecord, toDriveFileSummary, toEventSummary, toGoogleEventTime, toShadowEvent, toTaskListSummary, toTaskSummary, uncompleteTask, unlinkGoogle, unsyncedGroups, updateCalendarEvent, updateTask, waitForAuthCode, withCalendarLock, withKeyedLock, zoneSuffixOf };
2265
+ export { CANCELLED_EVENT_STATUS, DEFAULT_LIST_MAX_RESULTS, GOOGLE_AUTH_CANCELLED, GOOGLE_CALENDARLIST_SCOPE, GOOGLE_CALENDAR_SCOPE, GOOGLE_CALENDAR_SYNC_TASK_ID, GOOGLE_DRIVE_FILE_SCOPE, GOOGLE_SCOPES, GOOGLE_TASKS_SCOPE, GoogleApiError, HTTP_CONFLICT, HTTP_FORBIDDEN, HTTP_PRECONDITION_FAILED, MAX_LIST_RESULTS, PROTECTION_UNKNOWN, PUSHABLE_SOURCE_FIELDS, allUnpushed, anySyncedCollectionSurvives, assertSafeMimeType, authorizeGoogle, baselineRecord, brokerBaseUrl, brokerExchange, brokerRefresh, brokerStart, buildEventPatch, buildMultipartBody, buildTaskPatch, bySourceField, calendarApiError, calendarPushStatePath, calendarSyncStatePath, canonicalCalendarId, canonicalTaskListId, classifyDelete, classifyWrite, clearCalendarShadow, clearCalendarSyncToken, clientSecretPresence, collectCalendarPages, commitLinkedTokens, completeTask, configureGoogleHost, conflictingFields, createCalendarEvent, createDriveFile, createGoogleAuthFlow, createTask, deleteCalendarEvent, deleteDriveFile, deleteGoogleTokens, deleteTask, fieldText, findClientSecretPath, getCalendar, getCalendarColors, getCalendarEvent, getGoogleAccessToken, googleApiError, googleAuthFlow, googleCalendarSyncTaskDef, googleConfigDir, googleSecretsDir, googleTokenPath, groupByCalendar, isClientSettableEventId, isDeniedAccessRole, isGoogleApiError, isIsoDateTimeWithOffset, isTextMimeType, isUnpushed, legacyGoogleTokenPath, listCalendarEvents, listCalendars, listDriveFiles, listTaskLists, listTasks, loadCalendarShadow, loadCalendarSyncToken, loadClientSecret, loadGoogleTokens, locallyChangedFields, locallyDeletedIds, locallyEditedIds, mayAdoptExisting, mergeGoogleTokens, mergeIntoExisting, mergeShadow, orphanedCalendarId, pickBoundary, planRecord, pullableEvents, pushCalendarForCollection, pushCollectionNow, pushableMap, readDriveFile, releaseOrphanedCalendarToken, resolveEventSpan, saveCalendarShadow, saveCalendarSyncToken, saveGoogleTokens, shadowUpdates, syncCalendarEvents, syncCalendarForCollection, syncCalendarGroup, syncDueCalendarCollections, syncNewCalendarCollections, toCalendarSummary, toCollectionDateTime, toCollectionRecord, toDriveFileSummary, toEventSummary, toGoogleEventTime, toShadowEvent, toTaskListSummary, toTaskSummary, uncompleteTask, unlinkGoogle, unpushedFor, unsentLocalEdits, unsyncedGroups, updateCalendarEvent, updateTask, waitForAuthCode, withCalendarLock, withKeyedLock, zoneSuffixOf };
2009
2266
 
2010
2267
  //# sourceMappingURL=index.js.map