@mulmoclaude/core 1.5.0 → 1.7.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 (69) hide show
  1. package/assets/helps/bug-report-faq.md +130 -0
  2. package/assets/helps/error-recovery.md +134 -1
  3. package/assets/helps/google-calendar-collection.md +57 -3
  4. package/assets/helps/google.md +3 -2
  5. package/assets/helps/index.md +2 -1
  6. package/dist/calendarGrid-CaS9er8i.cjs +576 -0
  7. package/dist/calendarGrid-CaS9er8i.cjs.map +1 -0
  8. package/dist/calendarGrid-CrGxeCOG.js +397 -0
  9. package/dist/calendarGrid-CrGxeCOG.js.map +1 -0
  10. package/dist/collection/index.cjs +44 -44
  11. package/dist/collection/index.cjs.map +1 -1
  12. package/dist/collection/index.js +2 -2
  13. package/dist/collection/registry/server/index.cjs +2 -2
  14. package/dist/collection/registry/server/index.js +2 -2
  15. package/dist/collection/server/index.cjs +4 -4
  16. package/dist/collection/server/index.js +3 -3
  17. package/dist/collection-watchers/index.cjs +5 -5
  18. package/dist/collection-watchers/index.cjs.map +1 -1
  19. package/dist/collection-watchers/index.js +4 -4
  20. package/dist/{discovery--kRUWed0.cjs → discovery-DYcR83qx.cjs} +64 -53
  21. package/dist/discovery-DYcR83qx.cjs.map +1 -0
  22. package/dist/{discovery-CKR4TOX3.js → discovery-_fjsluLx.js} +43 -32
  23. package/dist/discovery-_fjsluLx.js.map +1 -0
  24. package/dist/feeds/index.cjs +4 -4
  25. package/dist/feeds/index.js +2 -2
  26. package/dist/feeds/server/index.cjs +6 -6
  27. package/dist/feeds/server/index.js +4 -4
  28. package/dist/google/apiClient.d.ts +6 -0
  29. package/dist/google/calendar.d.ts +79 -8
  30. package/dist/google/calendarPushState.d.ts +17 -0
  31. package/dist/google/collectionPush.d.ts +64 -0
  32. package/dist/google/collectionSync.d.ts +10 -0
  33. package/dist/google/index.cjs +712 -36
  34. package/dist/google/index.cjs.map +1 -1
  35. package/dist/google/index.d.ts +7 -3
  36. package/dist/google/index.js +683 -37
  37. package/dist/google/index.js.map +1 -1
  38. package/dist/google/pushDateTime.d.ts +10 -0
  39. package/dist/google/pushPlan.d.ts +73 -0
  40. package/dist/{ingestTypes-xX8VpQqh.cjs → ingestTypes-D1GQdG8e.cjs} +3 -3
  41. package/dist/{ingestTypes-xX8VpQqh.cjs.map → ingestTypes-D1GQdG8e.cjs.map} +1 -1
  42. package/dist/{ingestTypes-L59cIGgX.js → ingestTypes-XReG-li7.js} +2 -2
  43. package/dist/{ingestTypes-L59cIGgX.js.map → ingestTypes-XReG-li7.js.map} +1 -1
  44. package/dist/{promptSafety-CQ5Un4wi.js → promptSafety-C--op_6a.js} +3 -269
  45. package/dist/promptSafety-C--op_6a.js.map +1 -0
  46. package/dist/{promptSafety-DfZYNtjK.cjs → promptSafety-FD8cQn3e.cjs} +8 -358
  47. package/dist/promptSafety-FD8cQn3e.cjs.map +1 -0
  48. package/dist/remote-host/index.cjs +8 -0
  49. package/dist/remote-host/index.cjs.map +1 -1
  50. package/dist/remote-host/index.d.ts +21 -0
  51. package/dist/remote-host/index.js +8 -1
  52. package/dist/remote-host/index.js.map +1 -1
  53. package/dist/{server-D_6FLygK.js → server-BlIrtGKl.js} +4 -4
  54. package/dist/{server-D_6FLygK.js.map → server-BlIrtGKl.js.map} +1 -1
  55. package/dist/{server-gEOeV_Nv.cjs → server-l4TZ5Lbf.cjs} +21 -21
  56. package/dist/{server-gEOeV_Nv.cjs.map → server-l4TZ5Lbf.cjs.map} +1 -1
  57. package/dist/workspace-setup/faq-entries.d.ts +24 -0
  58. package/dist/workspace-setup/index.d.ts +1 -0
  59. package/dist/workspace-setup/index.js +50 -1
  60. package/dist/workspace-setup/index.js.map +1 -1
  61. package/package.json +1 -1
  62. package/dist/discovery--kRUWed0.cjs.map +0 -1
  63. package/dist/discovery-CKR4TOX3.js.map +0 -1
  64. package/dist/ids-BnTh3Hl0.cjs +0 -226
  65. package/dist/ids-BnTh3Hl0.cjs.map +0 -1
  66. package/dist/ids-s4GfmE93.js +0 -131
  67. package/dist/ids-s4GfmE93.js.map +0 -1
  68. package/dist/promptSafety-CQ5Un4wi.js.map +0 -1
  69. package/dist/promptSafety-DfZYNtjK.cjs.map +0 -1
@@ -2,7 +2,8 @@ import { t as errorMessage } from "../dist-Cwk0e12G.js";
2
2
  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
- import { J as getWorkspaceRoot, d as storeFor, et as createForwardingLogger, n as discoverCollections, tt as createHostSlot } from "../discovery-CKR4TOX3.js";
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
7
  import { promises } from "node:fs";
7
8
  import path, { join } from "node:path";
8
9
  import { randomBytes } from "node:crypto";
@@ -20,7 +21,7 @@ var log = createForwardingLogger(() => hostSlot.peek());
20
21
  //#endregion
21
22
  //#region src/google/datetime.ts
22
23
  var ISO_DATE_TIME_WITH_OFFSET_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:Z|[+-](\d{2}):(\d{2}))$/;
23
- var FRACTIONAL_SECONDS_RE$1 = /\.\d+(?=Z|[+-])/;
24
+ var FRACTIONAL_SECONDS_RE$2 = /\.\d+(?=Z|[+-])/;
24
25
  var MAX_HOUR = 23;
25
26
  var MAX_MINUTE = 59;
26
27
  var MAX_SECOND = 59;
@@ -29,7 +30,7 @@ var isRealCalendarDate = (year, month, day) => {
29
30
  return roundTrip.getUTCFullYear() === year && roundTrip.getUTCMonth() === month - 1 && roundTrip.getUTCDate() === day;
30
31
  };
31
32
  var isIsoDateTimeWithOffset = (value) => {
32
- const match = ISO_DATE_TIME_WITH_OFFSET_RE.exec(value.replace(FRACTIONAL_SECONDS_RE$1, ""));
33
+ const match = ISO_DATE_TIME_WITH_OFFSET_RE.exec(value.replace(FRACTIONAL_SECONDS_RE$2, ""));
33
34
  if (!match) return false;
34
35
  const [year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0] = match.slice(1, 7).map(Number);
35
36
  const timeInRange = hour <= MAX_HOUR && minute <= MAX_MINUTE && second <= MAX_SECOND;
@@ -551,14 +552,15 @@ var isGoogleApiError = (value) => value instanceof GoogleApiError;
551
552
  /** 403 usually means the API is not enabled for the user's Cloud project —
552
553
  * name the API so the agent's recovery guidance can be specific. */
553
554
  var googleApiError = (apiLabel, status, body) => {
554
- return new GoogleApiError(status, `${apiLabel}: HTTP ${status}${status === HTTP_FORBIDDEN ? ` (is the ${apiLabel} enabled for the Cloud project?)` : ""}${body ? ` — ${truncate(body, ERROR_BODY_MAX_CHARS)}` : ""}`);
555
+ return new GoogleApiError(status, `${apiLabel}: HTTP ${status}${status === 403 ? ` (is the ${apiLabel} enabled for the Cloud project?)` : ""}${body ? ` — ${truncate(body, ERROR_BODY_MAX_CHARS)}` : ""}`);
555
556
  };
556
557
  async function googleRequest(apiLabel, accessToken, url, init = {}) {
557
- const { contentType = "application/json", expectText = false, ...rest } = init;
558
+ const { contentType = "application/json", expectText = false, extraHeaders, ...rest } = init;
558
559
  const response = await fetchWithTimeout(url, {
559
560
  ...rest,
560
561
  timeoutMs: GOOGLE_API_TIMEOUT_MS,
561
562
  headers: {
563
+ ...extraHeaders,
562
564
  Authorization: `Bearer ${accessToken}`,
563
565
  "Content-Type": contentType
564
566
  }
@@ -587,6 +589,16 @@ var MAX_CALENDAR_LIST_PAGES = 40;
587
589
  var EVENT_SYNC_PAGE_SIZE = 2500;
588
590
  var MAX_EVENT_SYNC_PAGES = 200;
589
591
  var HTTP_GONE = 410;
592
+ var EVENT_ABSENT_STATUSES = [404, HTTP_GONE];
593
+ /** An `If-Match` write whose etag no longer matches — someone else changed the
594
+ * event since it was read. */
595
+ var HTTP_PRECONDITION_FAILED = 412;
596
+ /** An `insert` whose caller-supplied event id is already taken. */
597
+ var HTTP_CONFLICT = 409;
598
+ /** Google's status for an event that was deleted; a sync window reports it so
599
+ * consumers can remove their copy. Single-sourced — the pull and the push both
600
+ * branch on it. */
601
+ var CANCELLED_EVENT_STATUS = "cancelled";
590
602
  /** Resolve a declared calendarId to the one the API and the sync-token store
591
603
  * both address. `||` (not `??`) so an empty string also falls back instead of
592
604
  * building a malformed `/calendars//events` URL. Single-sourced because an
@@ -594,7 +606,7 @@ var HTTP_GONE = 410;
594
606
  * apart while they share a sync token silently loses events (#2184). */
595
607
  var canonicalCalendarId = (calendarId) => calendarId || DEFAULT_CALENDAR_ID;
596
608
  var eventsUrl = (calendarId) => `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(canonicalCalendarId(calendarId))}/events`;
597
- var eventTime = (value) => {
609
+ var eventTime$1 = (value) => {
598
610
  if (!isRecord(value)) return "";
599
611
  if (typeof value.dateTime === "string") return value.dateTime;
600
612
  if (typeof value.date === "string") return value.date;
@@ -605,8 +617,8 @@ var toEventSummary = (value) => {
605
617
  return {
606
618
  id: stringField(record, "id"),
607
619
  summary: stringField(record, "summary"),
608
- start: eventTime(record.start),
609
- end: eventTime(record.end),
620
+ start: eventTime$1(record.start),
621
+ end: eventTime$1(record.end),
610
622
  htmlLink: stringField(record, "htmlLink"),
611
623
  status: stringField(record, "status"),
612
624
  colorId: stringField(record, "colorId")
@@ -622,7 +634,8 @@ var toCalendarSummary = (value) => {
622
634
  accessRole: stringField(record, "accessRole"),
623
635
  backgroundColor: stringField(record, "backgroundColor"),
624
636
  foregroundColor: stringField(record, "foregroundColor"),
625
- colorId: stringField(record, "colorId")
637
+ colorId: stringField(record, "colorId"),
638
+ timeZone: stringField(record, "timeZone")
626
639
  };
627
640
  };
628
641
  var toColorMap = (value) => {
@@ -638,13 +651,21 @@ var toColorMap = (value) => {
638
651
  /** Kept as a named export for the existing unit tests / callers; the shared
639
652
  * helper now carries the wording. */
640
653
  var calendarApiError = (status, body) => googleApiError(CALENDAR_API_LABEL, status, body);
654
+ /** Normalise either spelling of a span into the pair the API body carries. */
655
+ var resolveEventSpan = (span) => "start" in span ? {
656
+ start: span.start,
657
+ end: span.end
658
+ } : {
659
+ start: { dateTime: span.startDateTime },
660
+ end: { dateTime: span.endDateTime }
661
+ };
641
662
  async function createCalendarEvent(accessToken, input) {
642
663
  const body = {
643
664
  summary: input.summary,
644
665
  description: input.description,
645
- start: { dateTime: input.startDateTime },
646
- end: { dateTime: input.endDateTime },
647
- ...input.colorId ? { colorId: input.colorId } : {}
666
+ ...resolveEventSpan(input),
667
+ ...input.colorId ? { colorId: input.colorId } : {},
668
+ ...input.eventId ? { id: input.eventId } : {}
648
669
  };
649
670
  return toEventSummary(await googleRequest(CALENDAR_API_LABEL, accessToken, eventsUrl(input.calendarId), {
650
671
  method: "POST",
@@ -657,11 +678,17 @@ var eventUrl = (calendarId, eventId) => `${eventsUrl(calendarId)}/${encodeURICom
657
678
  * `undefined` means "leave as is" and `""` means "clear it", so the two cannot
658
679
  * be collapsed: dropping `description: ""` would silently ignore a request to
659
680
  * empty the body text. Pure so the distinction is testable without network. */
681
+ /** One end of the patch: the structured value if given, else the flat one
682
+ * wrapped, else absent (= leave it alone). */
683
+ var eventEnd = (key, structured, flat) => {
684
+ if (structured !== void 0) return { [key]: structured };
685
+ return flat !== void 0 ? { [key]: { dateTime: flat } } : {};
686
+ };
660
687
  var buildEventPatch = (input) => ({
661
688
  ...input.summary !== void 0 ? { summary: input.summary } : {},
662
689
  ...input.description !== void 0 ? { description: input.description } : {},
663
- ...input.startDateTime !== void 0 ? { start: { dateTime: input.startDateTime } } : {},
664
- ...input.endDateTime !== void 0 ? { end: { dateTime: input.endDateTime } } : {},
690
+ ...eventEnd("start", input.start, input.startDateTime),
691
+ ...eventEnd("end", input.end, input.endDateTime),
665
692
  ...input.colorId ? { colorId: input.colorId } : {}
666
693
  });
667
694
  /** Edit an existing event in place. PATCH, not PUT — a PUT would need the whole
@@ -670,9 +697,28 @@ var buildEventPatch = (input) => ({
670
697
  async function updateCalendarEvent(accessToken, input) {
671
698
  return toEventSummary(await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId), {
672
699
  method: "PATCH",
673
- body: JSON.stringify(buildEventPatch(input))
700
+ body: JSON.stringify(buildEventPatch(input)),
701
+ ...input.ifMatch ? { extraHeaders: { "If-Match": input.ifMatch } } : {}
674
702
  }));
675
703
  }
704
+ /** Read ONE event, or null when it is gone (404 / 410).
705
+ *
706
+ * A push needs Google's current value per changed record to tell a local-only
707
+ * edit from a both-sides conflict. Deliberately not `syncCalendarEvents`: that
708
+ * consumes the calendar's sync token, and the token's window is served once —
709
+ * spending it here would make the next pull miss everything in it. */
710
+ async function getCalendarEvent(accessToken, input) {
711
+ try {
712
+ const fetched = await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId));
713
+ return {
714
+ event: toEventSummary(fetched),
715
+ etag: stringField(asRecord(fetched), "etag")
716
+ };
717
+ } catch (error) {
718
+ if (isGoogleApiError(error) && EVENT_ABSENT_STATUSES.includes(error.status)) return null;
719
+ throw error;
720
+ }
721
+ }
676
722
  /** Remove an event. Google answers 204 with no body, so there is nothing to
677
723
  * return; a second delete of the same id answers 410, which surfaces as a
678
724
  * GoogleApiError rather than being swallowed. */
@@ -752,6 +798,17 @@ async function collectCalendarPages(fetchPage, maxPages = MAX_CALENDAR_LIST_PAGE
752
798
  /** The calendars the user has added/subscribed to (primary + secondary +
753
799
  * shared), each with its id, name and colour, following pagination. Needs the
754
800
  * calendar-list read scope (GOOGLE_CALENDARLIST_SCOPE). */
801
+ /** One calendar's own resource, addressed by id rather than looked up in the
802
+ * user's list.
803
+ *
804
+ * `calendarList` only holds calendars the user has ADDED; a calendar shared
805
+ * with them can be readable and writable by id without appearing there. This is
806
+ * how a push learns such a calendar's `timeZone`. Note the resource carries no
807
+ * `accessRole` — that is a calendarList property — so reachability here says
808
+ * nothing about writability. */
809
+ async function getCalendar(accessToken, calendarId) {
810
+ return toCalendarSummary(await googleRequest(CALENDAR_API_LABEL, accessToken, `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(canonicalCalendarId(calendarId))}`));
811
+ }
755
812
  async function listCalendars(accessToken) {
756
813
  return collectCalendarPages(async (pageToken) => {
757
814
  const params = new URLSearchParams({ maxResults: String(CALENDAR_LIST_PAGE_SIZE) });
@@ -780,25 +837,25 @@ function calendarSyncStatePath(workspaceRoot) {
780
837
  return path.join(workspaceRoot ?? getWorkspaceRoot(), "data", "calendar", ".sync-state.json");
781
838
  }
782
839
  var calendarKey = canonicalCalendarId;
783
- async function readState(workspaceRoot) {
840
+ async function readState$1(workspaceRoot) {
784
841
  const stored = await readJsonOrNull(calendarSyncStatePath(workspaceRoot));
785
842
  return stored?.tokens ? stored : { tokens: {} };
786
843
  }
787
- var writeQueue = { tail: Promise.resolve() };
788
- async function updateState(mutate, workspaceRoot) {
789
- const run = writeQueue.tail.then(async () => {
790
- const state = await readState(workspaceRoot);
844
+ var writeQueue$1 = { tail: Promise.resolve() };
845
+ async function updateState$1(mutate, workspaceRoot) {
846
+ const run = writeQueue$1.tail.then(async () => {
847
+ const state = await readState$1(workspaceRoot);
791
848
  await writeJsonAtomicWithMode(calendarSyncStatePath(workspaceRoot), { tokens: mutate(state.tokens) }, SYNC_STATE_MODE);
792
849
  });
793
- writeQueue.tail = run.catch(() => void 0);
850
+ writeQueue$1.tail = run.catch(() => void 0);
794
851
  return await run;
795
852
  }
796
853
  async function loadCalendarSyncToken(calendarId, workspaceRoot) {
797
- return (await readState(workspaceRoot)).tokens[calendarKey(calendarId)] ?? null;
854
+ return (await readState$1(workspaceRoot)).tokens[calendarKey(calendarId)] ?? null;
798
855
  }
799
856
  async function saveCalendarSyncToken(calendarId, syncToken, workspaceRoot) {
800
857
  const stored = calendarKey(calendarId);
801
- await updateState((tokens) => ({
858
+ await updateState$1((tokens) => ({
802
859
  ...tokens,
803
860
  [stored]: syncToken
804
861
  }), workspaceRoot);
@@ -807,7 +864,60 @@ async function saveCalendarSyncToken(calendarId, syncToken, workspaceRoot) {
807
864
  * starts a clean full sync. */
808
865
  async function clearCalendarSyncToken(calendarId, workspaceRoot) {
809
866
  const dropped = calendarKey(calendarId);
810
- await updateState((tokens) => Object.fromEntries(Object.entries(tokens).filter(([key]) => key !== dropped)), workspaceRoot);
867
+ await updateState$1((tokens) => Object.fromEntries(Object.entries(tokens).filter(([key]) => key !== dropped)), workspaceRoot);
868
+ }
869
+ //#endregion
870
+ //#region src/google/calendarPushState.ts
871
+ var PUSH_STATE_MODE = 384;
872
+ /** `<workspace>/data/calendar/.push-state.json` */
873
+ function calendarPushStatePath(workspaceRoot) {
874
+ return path.join(workspaceRoot ?? getWorkspaceRoot(), "data", "calendar", ".push-state.json");
875
+ }
876
+ var toShadowEvent = (event) => ({
877
+ summary: event.summary,
878
+ start: event.start,
879
+ end: event.end,
880
+ colorId: event.colorId
881
+ });
882
+ async function readState(workspaceRoot) {
883
+ const stored = await readJsonOrNull(calendarPushStatePath(workspaceRoot));
884
+ return stored?.events ? stored : { events: {} };
885
+ }
886
+ var writeQueue = { tail: Promise.resolve() };
887
+ async function updateState(mutate, workspaceRoot) {
888
+ const run = writeQueue.tail.then(async () => {
889
+ const state = await readState(workspaceRoot);
890
+ await writeJsonAtomicWithMode(calendarPushStatePath(workspaceRoot), { events: mutate(state.events) }, PUSH_STATE_MODE);
891
+ });
892
+ writeQueue.tail = run.catch(() => void 0);
893
+ return await run;
894
+ }
895
+ async function loadCalendarShadow(calendarId, workspaceRoot) {
896
+ return (await readState(workspaceRoot)).events[canonicalCalendarId(calendarId)] ?? {};
897
+ }
898
+ /** Apply a batch to one calendar's baseline. `null` removes an entry (the event
899
+ * was cancelled), so a cancel-then-recreate cannot resume from a stale one.
900
+ * Merged rather than replaced: a sync window describes only what changed. */
901
+ function mergeShadow(current, updates) {
902
+ const removed = new Set(Object.entries(updates).filter(([, entry]) => entry === null).map(([eventId]) => eventId));
903
+ const kept = Object.entries(current).filter(([eventId]) => !removed.has(eventId));
904
+ const written = Object.entries(updates).filter((entry) => entry[1] !== null);
905
+ return Object.fromEntries([...kept, ...written]);
906
+ }
907
+ async function saveCalendarShadow(calendarId, updates, workspaceRoot) {
908
+ if (Object.keys(updates).length === 0) return;
909
+ const key = canonicalCalendarId(calendarId);
910
+ await updateState((events) => ({
911
+ ...events,
912
+ [key]: mergeShadow(events[key] ?? {}, updates)
913
+ }), workspaceRoot);
914
+ }
915
+ /** Drop one calendar's whole baseline. Called wherever the sync token is
916
+ * cleared: a baseline outliving its token would let a push diff against events
917
+ * the workspace no longer holds. */
918
+ async function clearCalendarShadow(calendarId, workspaceRoot) {
919
+ const dropped = canonicalCalendarId(calendarId);
920
+ await updateState((events) => Object.fromEntries(Object.entries(events).filter(([key]) => key !== dropped)), workspaceRoot);
811
921
  }
812
922
  //#endregion
813
923
  //#region src/google/collectionDateTime.ts
@@ -815,9 +925,9 @@ async function clearCalendarSyncToken(calendarId, workspaceRoot) {
815
925
  var ALL_DAY_CLOCK = "00:00";
816
926
  var DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
817
927
  /** RFC3339 zone designator: `Z`, `+09:00`, `-0500`. */
818
- var ZONE_SUFFIX_RE = /(?:Z|[+-]\d{2}:?\d{2})$/;
928
+ var ZONE_SUFFIX_RE$1 = /(?:Z|[+-]\d{2}:?\d{2})$/;
819
929
  /** Fractional seconds have no place in the collection datetime shape. */
820
- var FRACTIONAL_SECONDS_RE = /\.\d+$/;
930
+ var FRACTIONAL_SECONDS_RE$1 = /\.\d+$/;
821
931
  /** Normalise one Google Calendar time value for storage in a `datetime` field.
822
932
  * Anything that isn't a recognisable Google shape (empty string, non-string,
823
933
  * free text) is returned untouched — the record lint should report it rather
@@ -827,13 +937,12 @@ function toCollectionDateTime(value) {
827
937
  const trimmed = value.trim();
828
938
  if (DATE_ONLY_RE.test(trimmed)) return `${trimmed}T${ALL_DAY_CLOCK}`;
829
939
  if (!trimmed.includes("T")) return value;
830
- return trimmed.replace(ZONE_SUFFIX_RE, "").replace(FRACTIONAL_SECONDS_RE, "");
940
+ return trimmed.replace(ZONE_SUFFIX_RE$1, "").replace(FRACTIONAL_SECONDS_RE$1, "");
831
941
  }
832
942
  //#endregion
833
943
  //#region src/google/collectionSync.ts
834
944
  var GOOGLE_CALENDAR_SYNC_TASK_ID = "system:google-calendar-sync";
835
945
  var DEFAULT_SYNC_INTERVAL_MS = 3600 * 1e3;
836
- var CANCELLED_STATUS = "cancelled";
837
946
  var DATETIME_FIELD_TYPE = "datetime";
838
947
  /** Own-property lookup, mirroring what the record lint reads: a spec reachable
839
948
  * only through the prototype chain is not a DECLARED field, so it must not
@@ -889,7 +998,7 @@ async function applyEvent(collection, event, workspaceRoot) {
889
998
  kind: "unwritable",
890
999
  message: `collection '${collection.slug}' is read-only`
891
1000
  };
892
- if (event.status === CANCELLED_STATUS) {
1001
+ if (event.status === "cancelled") {
893
1002
  const deleted = await store.delete(event.id);
894
1003
  return classifyDelete(event.id, deleted.kind);
895
1004
  }
@@ -905,6 +1014,7 @@ async function applyEvent(collection, event, workspaceRoot) {
905
1014
  }
906
1015
  async function restartFullSync(accessToken, calendarId, workspaceRoot) {
907
1016
  await clearCalendarSyncToken(calendarId, workspaceRoot);
1017
+ await clearCalendarShadow(calendarId, workspaceRoot);
908
1018
  return await syncCalendarEvents(accessToken, { calendarId });
909
1019
  }
910
1020
  /** Serialise `run` against whatever is already running for `key`.
@@ -942,8 +1052,16 @@ var calendarLocks = /* @__PURE__ */ new Map();
942
1052
  * writes are upserts by event id — but it is a wasted full walk. Queued, the
943
1053
  * second pass resumes from the token the first just stored and fetches only
944
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
+ }
945
1063
  async function syncCalendarGroup(calendarId, collections, workspaceRoot) {
946
- return await withKeyedLock(calendarLocks, canonicalCalendarId(calendarId), () => syncCalendarGroupNow(calendarId, collections, workspaceRoot));
1064
+ return await withCalendarLock(calendarId, () => syncCalendarGroupNow(calendarId, collections, workspaceRoot));
947
1065
  }
948
1066
  async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
949
1067
  const accessToken = await getGoogleAccessToken();
@@ -967,9 +1085,15 @@ async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
967
1085
  });
968
1086
  return results;
969
1087
  }
1088
+ await saveCalendarShadow(calendarId, shadowUpdates(result.events), workspaceRoot);
970
1089
  if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
971
1090
  return results;
972
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
+ }
973
1097
  /** Save the window's token unless every collection that consumed it was deleted
974
1098
  * while the sync was in flight.
975
1099
  *
@@ -1061,6 +1185,7 @@ async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
1061
1185
  const orphaned = orphanedCalendarId(deleted, (await discoverCollections({ workspaceRoot })).map((collection) => collection.schema));
1062
1186
  if (orphaned === null) return null;
1063
1187
  await clearCalendarSyncToken(orphaned, workspaceRoot);
1188
+ await clearCalendarShadow(orphaned, workspaceRoot);
1064
1189
  log.info("google", "cleared the sync token of a calendar no collection reads any more", { calendarId: orphaned });
1065
1190
  return orphaned;
1066
1191
  } catch (error) {
@@ -1170,6 +1295,522 @@ function googleCalendarSyncTaskDef(opts) {
1170
1295
  };
1171
1296
  }
1172
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.
1446
+ *
1447
+ * A value that carries a zone offset (a `start` mapped onto a `string` field) is
1448
+ * left alone: it is Google's own text on both sides already. */
1449
+ var comparableText = (value) => {
1450
+ const text = fieldText(value);
1451
+ return SECONDLESS_DATETIME_RE.test(text) ? `${text}${WHOLE_MINUTE}` : text;
1452
+ };
1453
+ /** A baseline shaped as the event it came from, so the comparison can run
1454
+ * through the very projection that wrote the record. `htmlLink`/`status` are
1455
+ * never pushable, so their absence from the baseline cannot matter. */
1456
+ var asEventSummary = (eventId, shadow) => ({
1457
+ id: eventId,
1458
+ htmlLink: "",
1459
+ status: "",
1460
+ ...shadow
1461
+ });
1462
+ /** What the record WOULD hold if nobody had edited it since the baseline.
1463
+ *
1464
+ * Deliberately `toCollectionRecord` — the same function the pull writes with —
1465
+ * so "unchanged" here means exactly "the pull would produce this". A private
1466
+ * re-implementation would drift from it and report phantom edits. */
1467
+ function baselineRecord(eventId, shadow, map, primaryKey, fields) {
1468
+ return toCollectionRecord(asEventSummary(eventId, shadow), map, primaryKey, fields);
1469
+ }
1470
+ /** The mapped fields whose local value no longer matches the baseline. */
1471
+ function locallyChangedFields(record, baseline, map) {
1472
+ return Object.entries(map).filter(([field]) => comparableText(record[field]) !== comparableText(baseline[field])).map(([, source]) => source);
1473
+ }
1474
+ /** The fields Google changed since the baseline, restricted to the ones the
1475
+ * local edit touches.
1476
+ *
1477
+ * Field-level on purpose: Google moving an event the user retitled locally is
1478
+ * not a conflict, because the patch only carries the title. Comparing whole
1479
+ * events would refuse those pushes for no reason. */
1480
+ function conflictingFields(shadow, current, changed) {
1481
+ return changed.filter((source) => comparableText(shadow[source]) !== comparableText(current[source]));
1482
+ }
1483
+ /** Classify one record against its baseline, before Google is consulted.
1484
+ *
1485
+ * No baseline means the record never came from a sync, so it is local-only and
1486
+ * has to be created. Google is queried ONLY for a `changed` record, which is
1487
+ * what keeps a push over a large collection cheap. */
1488
+ function planRecord(eventId, record, shadow, map, primaryKey, fields) {
1489
+ if (shadow === void 0) return { kind: "create" };
1490
+ const changed = locallyChangedFields(record, baselineRecord(eventId, shadow, map, primaryKey, fields), map);
1491
+ return changed.length === 0 ? { kind: "unchanged" } : {
1492
+ kind: "changed",
1493
+ fields: changed
1494
+ };
1495
+ }
1496
+ /** Whether an event that already holds this record's id may be adopted as its
1497
+ * baseline.
1498
+ *
1499
+ * A 409 from `insert` usually means a previous push created the event but its
1500
+ * baseline never landed. It can ALSO mean an unrelated event happens to hold
1501
+ * this id, and adopting that would bind the record to a stranger's event — so
1502
+ * adoption needs evidence the two are the same logical event, not just the same
1503
+ * id (Codex review).
1504
+ *
1505
+ * The evidence is that the remote event ALREADY equals what this record would
1506
+ * write. Adoption then records a baseline and writes nothing, so it cannot
1507
+ * modify whatever that event is. Anything else is refused. */
1508
+ function mayAdoptExisting(eventId, record, existing, map, primaryKey, fields) {
1509
+ return locallyChangedFields(record, baselineRecord(eventId, existing, map, primaryKey, fields), map).length === 0;
1510
+ }
1511
+ /** Record ids the baseline knows but the collection no longer holds — deleted
1512
+ * locally. v1 reports the count and pushes nothing: `calendarDeleteEvent`
1513
+ * removes the event for every attendee and cannot be undone. */
1514
+ function locallyDeletedIds(shadow, presentIds) {
1515
+ const present = new Set(presentIds);
1516
+ return Object.keys(shadow).filter((eventId) => !present.has(eventId));
1517
+ }
1518
+ //#endregion
1519
+ //#region src/google/collectionPush.ts
1520
+ /** Access roles that may write events. `reader` / `freeBusyReader` cannot, and a
1521
+ * push under them fails per event with an opaque 403 — checked once up front so
1522
+ * the user is told the real reason. */
1523
+ var WRITABLE_ACCESS_ROLES = ["owner", "writer"];
1524
+ /** Whether to refuse the whole push before touching a single event.
1525
+ *
1526
+ * Only on POSITIVE evidence of a non-writable role. An unlisted calendar is
1527
+ * unknown and must fall through: hard-denying it would block the feature
1528
+ * outright for a calendar the user can in fact write to. Those attempts surface
1529
+ * Google's own 403 per record if the write really is not allowed. */
1530
+ var isDeniedAccessRole = (accessRole) => accessRole !== null && !WRITABLE_ACCESS_ROLES.includes(accessRole);
1531
+ /** Resolve the calendar a schema names, for its writability and its timezone.
1532
+ *
1533
+ * The list is consulted first because only it reports `accessRole`, which is
1534
+ * what lets a read-only calendar be refused with the real reason instead of an
1535
+ * opaque per-event 403. `"primary"` is matched on the `primary` flag, not the
1536
+ * id — the primary calendar's own id is the account's email address, which never
1537
+ * equals the literal the schema declares.
1538
+ *
1539
+ * A calendar absent from the list is NOT denied: it is fetched by id for its
1540
+ * timezone and its role left unknown. A 404 there means the calendar really is
1541
+ * unreachable, and propagates as a `failed` outcome. */
1542
+ async function liveCalendarMeta(accessToken, calendarId) {
1543
+ const key = canonicalCalendarId(calendarId);
1544
+ const calendars = await listCalendars(accessToken);
1545
+ const listed = key === "primary" ? calendars.find((calendar) => calendar.primary) : calendars.find((calendar) => calendar.id === key);
1546
+ if (listed) return {
1547
+ accessRole: listed.accessRole,
1548
+ timeZone: listed.timeZone
1549
+ };
1550
+ const direct = await getCalendar(accessToken, calendarId);
1551
+ log.info("google", "calendar is not in the user's list — pushing without an up-front role check", { calendarId: key });
1552
+ return {
1553
+ accessRole: null,
1554
+ timeZone: direct.timeZone
1555
+ };
1556
+ }
1557
+ async function findCalendarCollection(slug, workspaceRoot) {
1558
+ return (await discoverCollections({ workspaceRoot })).find((collection) => collection.slug === slug && collection.schema.googleCalendar) ?? null;
1559
+ }
1560
+ var liveDeps = {
1561
+ findCollection: findCalendarCollection,
1562
+ isLinked: async () => Boolean((await loadGoogleTokens())?.refresh_token),
1563
+ accessToken: getGoogleAccessToken,
1564
+ calendarMeta: liveCalendarMeta
1565
+ };
1566
+ var localValue = (ctx, record, source) => {
1567
+ const field = ctx.bySource[source];
1568
+ return field === void 0 ? void 0 : record[field];
1569
+ };
1570
+ /** One end of the span, rebuilt from the stored clock plus whatever the baseline
1571
+ * remembers about the original (zone, all-day). */
1572
+ function eventTime(ctx, record, source, previous) {
1573
+ const value = localValue(ctx, record, source);
1574
+ if (value === void 0) return {
1575
+ ok: false,
1576
+ reason: `no \`${source}\` field is mapped`
1577
+ };
1578
+ const time = toGoogleEventTime(value, previous, ctx.timeZone);
1579
+ if (time === null) return {
1580
+ ok: false,
1581
+ reason: `\`${source}\` is not a date-time Google accepts (got ${JSON.stringify(value)})`
1582
+ };
1583
+ if ("dateTime" in time && time.timeZone === "") return {
1584
+ ok: false,
1585
+ reason: `the calendar reports no timezone, so \`${source}\` cannot be sent without one`
1586
+ };
1587
+ return {
1588
+ ok: true,
1589
+ time
1590
+ };
1591
+ }
1592
+ async function createFromRecord(ctx, eventId, record) {
1593
+ if (!isClientSettableEventId(eventId)) return {
1594
+ kind: "skipped",
1595
+ message: `${eventId}: the record id cannot be used as a Google event id (needs 5-1024 characters from 0-9a-v)`
1596
+ };
1597
+ const start = eventTime(ctx, record, "start", void 0);
1598
+ const end = eventTime(ctx, record, "end", void 0);
1599
+ if (!start.ok) return {
1600
+ kind: "skipped",
1601
+ message: `${eventId}: ${start.reason}`
1602
+ };
1603
+ if (!end.ok) return {
1604
+ kind: "skipped",
1605
+ message: `${eventId}: ${end.reason}`
1606
+ };
1607
+ const colorId = fieldText(localValue(ctx, record, "colorId"));
1608
+ return {
1609
+ kind: "created",
1610
+ event: await createCalendarEvent(ctx.accessToken, {
1611
+ eventId,
1612
+ calendarId: ctx.calendarId,
1613
+ summary: fieldText(localValue(ctx, record, "summary")),
1614
+ start: start.time,
1615
+ end: end.time,
1616
+ ...colorId ? { colorId } : {}
1617
+ })
1618
+ };
1619
+ }
1620
+ /** Only the fields the local edit touched — PATCH semantics, so an untouched
1621
+ * attendee list / recurrence rule is left alone. */
1622
+ function buildPatch(ctx, record, changed, shadow) {
1623
+ const resolved = ["start", "end"].filter((source) => changed.includes(source)).map((source) => ({
1624
+ source,
1625
+ result: eventTime(ctx, record, source, shadow[source])
1626
+ }));
1627
+ const [failure] = resolved.flatMap((entry) => entry.result.ok ? [] : [entry.result.reason]);
1628
+ if (failure !== void 0) return {
1629
+ ok: false,
1630
+ reason: failure
1631
+ };
1632
+ const colorId = fieldText(localValue(ctx, record, "colorId"));
1633
+ if (changed.includes("colorId") && colorId === "") return {
1634
+ ok: false,
1635
+ reason: "Google cannot clear an event colour once set"
1636
+ };
1637
+ return {
1638
+ ok: true,
1639
+ patch: {
1640
+ ...changed.includes("summary") ? { summary: fieldText(localValue(ctx, record, "summary")) } : {},
1641
+ ...changed.includes("colorId") ? { colorId } : {},
1642
+ ...Object.fromEntries(resolved.flatMap((entry) => entry.result.ok ? [[entry.source, entry.result.time]] : []))
1643
+ }
1644
+ };
1645
+ }
1646
+ async function updateFromRecord(ctx, eventId, record, changed, shadow) {
1647
+ const fetched = await getCalendarEvent(ctx.accessToken, {
1648
+ calendarId: ctx.calendarId,
1649
+ eventId
1650
+ });
1651
+ if (fetched === null || fetched.event.status === "cancelled") return {
1652
+ kind: "skipped",
1653
+ message: `${eventId}: the event no longer exists in Google — sync first, then re-create it`
1654
+ };
1655
+ if (conflictingFields(shadow, toShadowEvent(fetched.event), changed).length > 0) return { kind: "conflict" };
1656
+ const built = buildPatch(ctx, record, changed, shadow);
1657
+ if (!built.ok) return {
1658
+ kind: "skipped",
1659
+ message: `${eventId}: ${built.reason}`
1660
+ };
1661
+ try {
1662
+ return {
1663
+ kind: "updated",
1664
+ event: await updateCalendarEvent(ctx.accessToken, {
1665
+ eventId,
1666
+ calendarId: ctx.calendarId,
1667
+ ifMatch: fetched.etag,
1668
+ ...built.patch
1669
+ })
1670
+ };
1671
+ } catch (error) {
1672
+ if (isGoogleApiError(error) && error.status === 412) return { kind: "conflict" };
1673
+ throw error;
1674
+ }
1675
+ }
1676
+ /** Create, recovering from the one 409 that is provably safe to recover from.
1677
+ *
1678
+ * A 409 means the id is taken, which is USUALLY a previous push whose baseline
1679
+ * never landed. It can also be an unrelated event that happens to hold this id,
1680
+ * so the recovery never writes: it adopts the baseline only when the remote
1681
+ * event already equals what this record would write, and otherwise reports the
1682
+ * fix. Patching whatever answered the 409 would let a push modify a stranger's
1683
+ * event (Codex review).
1684
+ *
1685
+ * Refusal is not a dead end — a Sync writes both the record and its baseline,
1686
+ * after which the record pushes normally. */
1687
+ async function createOrAdopt(ctx, eventId, record) {
1688
+ try {
1689
+ return await createFromRecord(ctx, eventId, record);
1690
+ } catch (error) {
1691
+ if (!isGoogleApiError(error) || error.status !== 409) throw error;
1692
+ const fetched = await getCalendarEvent(ctx.accessToken, {
1693
+ calendarId: ctx.calendarId,
1694
+ eventId
1695
+ });
1696
+ if (fetched === null) throw error;
1697
+ if (mayAdoptExisting(eventId, record, toShadowEvent(fetched.event), ctx.map, ctx.primaryKey, ctx.fields)) return {
1698
+ kind: "unchanged",
1699
+ event: fetched.event
1700
+ };
1701
+ return {
1702
+ kind: "skipped",
1703
+ message: `${eventId}: an event with this id already exists in Google and differs from this record — press Sync to adopt it, then push again`
1704
+ };
1705
+ }
1706
+ }
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) {
1723
+ 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);
1731
+ }
1732
+ }
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)]));
1739
+ }
1740
+ function tally(slug, outcomes, localDeletes) {
1741
+ const count = (kind) => outcomes.filter((outcome) => outcome.kind === kind).length;
1742
+ 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] : []),
1749
+ errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
1750
+ };
1751
+ }
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);
1779
+ }
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
+ };
1789
+ }
1790
+ /** Push ONE collection's records to the calendar it declares.
1791
+ *
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) {
1797
+ 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));
1802
+ } catch (error) {
1803
+ log.warn("google", "calendar push could not start", {
1804
+ slug,
1805
+ error: String(error)
1806
+ });
1807
+ return {
1808
+ kind: "failed",
1809
+ message: String(error)
1810
+ };
1811
+ }
1812
+ }
1813
+ //#endregion
1173
1814
  //#region src/google/tasks.ts
1174
1815
  var TASKS_BASE_URL = "https://tasks.googleapis.com/tasks/v1";
1175
1816
  var TASKS_API_LABEL = "Google Tasks API";
@@ -1237,12 +1878,20 @@ async function updateTask(accessToken, input) {
1237
1878
  body: JSON.stringify(buildTaskPatch(input))
1238
1879
  }));
1239
1880
  }
1240
- async function completeTask(accessToken, input) {
1881
+ /** Shared body of the two status transitions. PATCH keeps the rest of the task
1882
+ * intact — a PUT would need the full body and would silently drop fields the
1883
+ * caller never read. Private on purpose: the exported `completeTask` /
1884
+ * `uncompleteTask` names are the API, so no caller can invent a third target
1885
+ * state by passing an arbitrary status through. */
1886
+ async function patchTaskStatus(accessToken, input, status) {
1241
1887
  return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
1242
1888
  method: "PATCH",
1243
- body: JSON.stringify({ status: TASK_STATUS_COMPLETED })
1889
+ body: JSON.stringify({ status })
1244
1890
  }));
1245
1891
  }
1892
+ async function completeTask(accessToken, input) {
1893
+ return patchTaskStatus(accessToken, input, TASK_STATUS_COMPLETED);
1894
+ }
1246
1895
  /** Send a completed task back to the to-do list.
1247
1896
  *
1248
1897
  * Its own function rather than a flag on `completeTask`, and deliberately not
@@ -1257,10 +1906,7 @@ async function completeTask(accessToken, input) {
1257
1906
  * reopened task ever displays a completion date in Google's own UI, this is
1258
1907
  * the place to add `completed: null` to the patch. */
1259
1908
  async function uncompleteTask(accessToken, input) {
1260
- return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
1261
- method: "PATCH",
1262
- body: JSON.stringify({ status: TASK_STATUS_NEEDS_ACTION })
1263
- }));
1909
+ return patchTaskStatus(accessToken, input, TASK_STATUS_NEEDS_ACTION);
1264
1910
  }
1265
1911
  async function deleteTask(accessToken, input) {
1266
1912
  await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), { method: "DELETE" });
@@ -1359,6 +2005,6 @@ async function deleteDriveFile(accessToken, input) {
1359
2005
  await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}`, { method: "DELETE" });
1360
2006
  }
1361
2007
  //#endregion
1362
- export { 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, MAX_LIST_RESULTS, anySyncedCollectionSurvives, assertSafeMimeType, authorizeGoogle, brokerBaseUrl, brokerExchange, brokerRefresh, brokerStart, buildEventPatch, buildMultipartBody, buildTaskPatch, calendarApiError, calendarSyncStatePath, canonicalCalendarId, canonicalTaskListId, classifyDelete, classifyWrite, clearCalendarSyncToken, clientSecretPresence, collectCalendarPages, commitLinkedTokens, completeTask, configureGoogleHost, createCalendarEvent, createDriveFile, createGoogleAuthFlow, createTask, deleteCalendarEvent, deleteDriveFile, deleteGoogleTokens, deleteTask, findClientSecretPath, getCalendarColors, getGoogleAccessToken, googleApiError, googleAuthFlow, googleCalendarSyncTaskDef, googleConfigDir, googleSecretsDir, googleTokenPath, groupByCalendar, isGoogleApiError, isIsoDateTimeWithOffset, isTextMimeType, legacyGoogleTokenPath, listCalendarEvents, listCalendars, listDriveFiles, listTaskLists, listTasks, loadCalendarSyncToken, loadClientSecret, loadGoogleTokens, mergeGoogleTokens, orphanedCalendarId, pickBoundary, readDriveFile, releaseOrphanedCalendarToken, saveCalendarSyncToken, saveGoogleTokens, syncCalendarEvents, syncCalendarForCollection, syncCalendarGroup, syncDueCalendarCollections, syncNewCalendarCollections, toCalendarSummary, toCollectionDateTime, toCollectionRecord, toDriveFileSummary, toEventSummary, toTaskListSummary, toTaskSummary, uncompleteTask, unlinkGoogle, unsyncedGroups, updateCalendarEvent, updateTask, waitForAuthCode, withKeyedLock };
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 };
1363
2009
 
1364
2010
  //# sourceMappingURL=index.js.map