@mulmoclaude/core 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/assets/helps/error-recovery.md +98 -0
  2. package/dist/collection/core/project.d.ts +11 -0
  3. package/dist/collection/index.cjs +3 -2
  4. package/dist/collection/index.js +3 -3
  5. package/dist/collection/registry/server/index.cjs +3 -3
  6. package/dist/collection/registry/server/index.js +3 -3
  7. package/dist/collection/server/index.cjs +2 -2
  8. package/dist/collection/server/index.js +2 -2
  9. package/dist/collection-watchers/index.cjs +3 -3
  10. package/dist/collection-watchers/index.js +3 -3
  11. package/dist/{discovery-B4CZvrXR.js → discovery-D-ZJ6a6z.js} +39 -18
  12. package/dist/discovery-D-ZJ6a6z.js.map +1 -0
  13. package/dist/{discovery-NRy3tyUA.cjs → discovery-DAoMnBYM.cjs} +39 -18
  14. package/dist/discovery-DAoMnBYM.cjs.map +1 -0
  15. package/dist/{dist-pWpC-b04.cjs → dist-21pMsI1a.cjs} +45 -1
  16. package/dist/{dist-pWpC-b04.cjs.map → dist-21pMsI1a.cjs.map} +1 -1
  17. package/dist/{dist-Cwk0e12G.js → dist-DP1FTGnO.js} +16 -2
  18. package/dist/{dist-Cwk0e12G.js.map → dist-DP1FTGnO.js.map} +1 -1
  19. package/dist/feeds/server/engine.d.ts +27 -0
  20. package/dist/feeds/server/index.cjs +72 -7
  21. package/dist/feeds/server/index.cjs.map +1 -1
  22. package/dist/feeds/server/index.d.ts +1 -1
  23. package/dist/feeds/server/index.js +71 -8
  24. package/dist/feeds/server/index.js.map +1 -1
  25. package/dist/google/calendarStateLock.d.ts +23 -0
  26. package/dist/google/collectionProjection.d.ts +0 -8
  27. package/dist/google/collectionSync.d.ts +80 -10
  28. package/dist/google/index.cjs +310 -67
  29. package/dist/google/index.cjs.map +1 -1
  30. package/dist/google/index.d.ts +4 -2
  31. package/dist/google/index.js +304 -69
  32. package/dist/google/index.js.map +1 -1
  33. package/dist/notifier/index.cjs +1 -1
  34. package/dist/notifier/index.js +1 -1
  35. package/dist/notifier/store.d.ts +4 -4
  36. package/dist/{notifier-tMsAXyXp.cjs → notifier-C3IhYxqh.cjs} +39 -25
  37. package/dist/notifier-C3IhYxqh.cjs.map +1 -0
  38. package/dist/{notifier-BdA5qzhe.js → notifier-CMFlw74b.js} +39 -25
  39. package/dist/notifier-CMFlw74b.js.map +1 -0
  40. package/dist/project-B9NSyr6L.js +28 -0
  41. package/dist/project-B9NSyr6L.js.map +1 -0
  42. package/dist/project-CCtT_fQr.cjs +39 -0
  43. package/dist/project-CCtT_fQr.cjs.map +1 -0
  44. package/dist/remote-host/server/index.cjs +1 -1
  45. package/dist/remote-host/server/index.js +1 -1
  46. package/dist/remote-view/index.cjs +1 -1
  47. package/dist/remote-view/index.js +1 -1
  48. package/dist/scheduler/index.cjs +1 -1
  49. package/dist/scheduler/index.js +1 -1
  50. package/dist/{server-7U-3DE2e.cjs → server-B_b0bLSW.cjs} +3 -3
  51. package/dist/{server-7U-3DE2e.cjs.map → server-B_b0bLSW.cjs.map} +1 -1
  52. package/dist/{server-DdyIvFhz.js → server-Ci_A3chZ.js} +3 -3
  53. package/dist/{server-DdyIvFhz.js.map → server-Ci_A3chZ.js.map} +1 -1
  54. package/dist/utils/index.cjs +1 -1
  55. package/dist/utils/index.js +1 -1
  56. package/dist/whisper/index.cjs +1 -1
  57. package/dist/whisper/index.js +1 -1
  58. package/dist/wiki/index.cjs +1 -1
  59. package/dist/wiki/index.js +1 -1
  60. package/package.json +1 -1
  61. package/dist/discovery-B4CZvrXR.js.map +0 -1
  62. package/dist/discovery-NRy3tyUA.cjs.map +0 -1
  63. package/dist/notifier-BdA5qzhe.js.map +0 -1
  64. package/dist/notifier-tMsAXyXp.cjs.map +0 -1
  65. package/dist/project-BWI5w_BT.cjs +0 -17
  66. package/dist/project-BWI5w_BT.cjs.map +0 -1
  67. package/dist/project-bU98ycsy.js +0 -12
  68. package/dist/project-bU98ycsy.js.map +0 -1
@@ -10,13 +10,15 @@ export { googleApiError, isGoogleApiError, GoogleApiError, DEFAULT_LIST_MAX_RESU
10
10
  export { buildEventPatch, calendarApiError, collectCalendarPages, createCalendarEvent, deleteCalendarEvent, getCalendarColors, canonicalCalendarId, listCalendarEvents, listCalendars, syncCalendarEvents, toCalendarSummary, toEventSummary, updateCalendarEvent, getCalendar, getCalendarEvent, resolveEventSpan, CANCELLED_EVENT_STATUS, HTTP_CONFLICT, HTTP_PRECONDITION_FAILED, type CalendarColorEntry, type CalendarColors, type CalendarEventInput, type CalendarEventSpan, type CalendarEventSummary, type CalendarEventTime, type CalendarListPage, type CalendarSummary, type CalendarSyncResult, type DeleteCalendarEventInput, type FetchedCalendarEvent, type ListEventsInput, type SyncEventsInput, type UpdateCalendarEventInput, } from './calendar.js';
11
11
  export { calendarSyncStatePath, claimCalendarSyncIfDue, clearCalendarLastSyncedAt, clearCalendarSyncToken, loadCalendarLastSyncedAt, loadCalendarSyncToken, saveCalendarSyncToken, } from './calendarSyncStore.js';
12
12
  export { calendarSyncDueWindowMs, isCalendarSyncDue } from './calendarSyncDue.js';
13
+ export { stateLockPath, withCalendarStateLock, liveLockClock, type LockClock } from './calendarStateLock.js';
13
14
  export { calendarPushStatePath, clearCalendarShadow, loadCalendarShadow, mergeShadow, saveCalendarShadow, toShadowEvent, type ShadowEvent, } from './calendarPushState.js';
14
15
  export { isDeniedAccessRole, isUnpushed, locallyEditedIds, pushCalendarForCollection, pushCollectionNow, unsentLocalEdits, type PushOutcomeKind, type CalendarCollectionPushResult, type CalendarPushDeps, type CalendarPushOutcome, type CalendarWriteTarget, } from './collectionPush.js';
15
16
  export { toGoogleEventTime, zoneSuffixOf } from './pushDateTime.js';
16
17
  export { baselineRecord, bySourceField, conflictingFields, fieldText, isClientSettableEventId, locallyChangedFields, locallyDeletedIds, mayAdoptExisting, planRecord, pushableMap, PUSHABLE_SOURCE_FIELDS, type PushableSourceField, type RecordPlan, } from './pushPlan.js';
17
18
  export { toCollectionDateTime } from './collectionDateTime.js';
18
19
  export { withCalendarLock, withKeyedLock } from './calendarLock.js';
19
- export { mergeIntoExisting, toCollectionRecord, type GoogleCalendarSourceField } from './collectionProjection.js';
20
- export { googleCalendarSyncTaskDef, classifyDelete, classifyWrite, anySyncedCollectionSurvives, groupByCalendar, allUnpushed, orphanedCalendarId, pullableEvents, releaseOrphanedCalendarToken, unpushedFor, PROTECTION_UNKNOWN, type ClaimGuard, type UnpushedBySlug, syncCalendarForCollection, syncCalendarGroup, syncDueCalendarCollections, syncNewCalendarCollections, shadowUpdates, unsyncedGroups, GOOGLE_CALENDAR_SYNC_TASK_ID, type CalendarCollectionSyncResult, type CalendarDeclaring, type ManualCalendarSyncDeps, type ManualCalendarSyncOutcome, } from './collectionSync.js';
20
+ export { toCollectionRecord, type GoogleCalendarSourceField } from './collectionProjection.js';
21
+ export { mergeIntoExisting } from '../collection/core/project.js';
22
+ export { googleCalendarSyncTaskDef, classifyDelete, classifyWrite, anySyncedCollectionSurvives, applyPlanFor, groupByCalendar, allUnpushed, unsentEditGuard, heldBack, orphanedCalendarId, pullableEvents, pullProtectionFor, pushAndProtect, releaseOrphanedCalendarToken, unpushedFor, PROTECTION_UNKNOWN, type ClaimGuard, type PullProtectionDeps, type UnpushedBySlug, syncCalendarForCollection, syncCalendarGroup, syncDueCalendarCollections, syncNewCalendarCollections, shadowUpdates, unsyncedGroups, GOOGLE_CALENDAR_SYNC_TASK_ID, type CalendarCollectionSyncResult, type CalendarDeclaring, type ManualCalendarSyncDeps, type ManualCalendarSyncOutcome, } from './collectionSync.js';
21
23
  export { buildTaskPatch, canonicalTaskListId, completeTask, createTask, deleteTask, listTaskLists, listTasks, toTaskListSummary, toTaskSummary, uncompleteTask, updateTask, type CompleteTaskInput, type CreateTaskInput, type ListTasksInput, type TaskListSummary, type TaskSummary, type UpdateTaskInput, } from './tasks.js';
22
24
  export { assertSafeMimeType, buildMultipartBody, createDriveFile, deleteDriveFile, isTextMimeType, listDriveFiles, pickBoundary, readDriveFile, toDriveFileSummary, type CreateDriveFileInput, type DriveFileSummary, type ListDriveFilesInput, type ReadDriveFileInput, } from './driveFile.js';
@@ -1,13 +1,14 @@
1
- import { t as errorMessage } from "../dist-Cwk0e12G.js";
1
+ import { t as errorMessage } from "../dist-DP1FTGnO.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
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-B4CZvrXR.js";
6
+ import { t as mergeIntoExisting } from "../project-B9NSyr6L.js";
7
+ import { J as getWorkspaceRoot, d as storeFor, et as createForwardingLogger, n as discoverCollections, tt as createHostSlot } from "../discovery-D-ZJ6a6z.js";
7
8
  import { promises } from "node:fs";
8
9
  import path, { join } from "node:path";
9
- import { randomBytes } from "node:crypto";
10
- import { constants, copyFile, mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
10
+ import { randomBytes, randomUUID } from "node:crypto";
11
+ import { constants, copyFile, mkdir, open, readFile, readdir, rm, stat, unlink } from "node:fs/promises";
11
12
  import { homedir } from "node:os";
12
13
  import { MISSED_RUN_POLICIES, SCHEDULE_TYPES } from "@receptron/task-scheduler";
13
14
  import http from "node:http";
@@ -834,6 +835,161 @@ async function getCalendarColors(accessToken) {
834
835
  };
835
836
  }
836
837
  //#endregion
838
+ //#region src/google/calendarStateLock.ts
839
+ var LOCK_MODE = 384;
840
+ /** How long a lock may sit untouched before another process may reclaim it.
841
+ *
842
+ * Sized against the HOLD time, which is one read plus one atomic write — a few
843
+ * milliseconds. Generous by three orders of magnitude so a slow disk never
844
+ * looks like a crash, while a host that died mid-write blocks the others for
845
+ * at most this long. */
846
+ var LOCK_STALE_MS = 1e4;
847
+ /** How long a caller waits before giving up and proceeding unlocked. */
848
+ var LOCK_WAIT_MS = 5e3;
849
+ var LOCK_RETRY_MS = 20;
850
+ var liveLockClock = {
851
+ now: () => Date.now(),
852
+ sleep: (delay_ms) => new Promise((resolve) => setTimeout(resolve, delay_ms))
853
+ };
854
+ var isEexist = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
855
+ /** The file's bytes, or null when it is not there. */
856
+ async function readLockRaw(lockPath) {
857
+ try {
858
+ return await readFile(lockPath, "utf-8");
859
+ } catch {
860
+ return null;
861
+ }
862
+ }
863
+ /** Absent means unheld; UNREADABLE does not — see `stealIfStale`. */
864
+ function parseLock(raw) {
865
+ try {
866
+ const parsed = JSON.parse(raw);
867
+ if (typeof parsed !== "object" || parsed === null) return null;
868
+ const { holder } = parsed;
869
+ return typeof holder === "string" ? { holder } : null;
870
+ } catch {
871
+ return null;
872
+ }
873
+ }
874
+ async function readLock(lockPath) {
875
+ const raw = await readLockRaw(lockPath);
876
+ return raw === null ? null : parseLock(raw);
877
+ }
878
+ /** Take the lock, or answer false because someone else holds a live one. */
879
+ async function tryAcquire(lockPath, holder) {
880
+ try {
881
+ const handle = await open(lockPath, "wx", LOCK_MODE);
882
+ try {
883
+ await handle.writeFile(JSON.stringify({ holder }));
884
+ } finally {
885
+ await handle.close();
886
+ }
887
+ return true;
888
+ } catch (error) {
889
+ if (!isEexist(error)) throw error;
890
+ return false;
891
+ }
892
+ }
893
+ /** How long this lock has existed, per the filesystem that holds it.
894
+ *
895
+ * From mtime rather than anything the holder wrote down, and for every lock
896
+ * rather than only the unreadable ones. A timestamp written by one host and
897
+ * compared against another host's clock is two wall clocks pretending to be
898
+ * one: skew either way reclaims a live lock or immortalises a dead one (Codex
899
+ * review #2690). Every host reading this workspace sees the SAME mtime.
900
+ *
901
+ * This still assumes the reader's clock and the filesystem's are within a TTL
902
+ * of each other — true by construction on a local disk, and a sub-second NTP
903
+ * question on a network mount, against a window of ten seconds.
904
+ *
905
+ * Reading mtime also covers the moment `open("wx")` has created the file but
906
+ * its payload has not landed yet: "unreadable" is a state every healthy lock
907
+ * passes through, and one being born reads as newborn rather than dead.
908
+ *
909
+ * Null when the file is gone, which is not an age but an absence. */
910
+ async function lockAgeMs(lockPath, clock) {
911
+ const stamped = await stat(lockPath).catch(() => null);
912
+ return stamped === null ? null : clock.now() - stamped.mtimeMs;
913
+ }
914
+ /** Drop a lock that has sat untouched past the TTL, whatever it says.
915
+ *
916
+ * Readable or not, nothing is reclaimed until it is demonstrably stale. For a
917
+ * readable one the holder is re-read and compared before the unlink, so a
918
+ * holder that renewed in between is not evicted. That narrows the window
919
+ * rather than closing it — two processes can still both decide to clear the
920
+ * same corpse. The cost of losing that race is one lost update, which is what
921
+ * this file reduces rather than a new failure, and reaching it takes a host
922
+ * dying mid-write plus a full TTL of waiting. */
923
+ async function stealIfStale(lockPath, clock) {
924
+ const raw = await readLockRaw(lockPath);
925
+ if (raw === null) return;
926
+ const held = parseLock(raw);
927
+ const age_ms = await lockAgeMs(lockPath, clock);
928
+ if (age_ms === null || age_ms < LOCK_STALE_MS) return;
929
+ if (held === null) {
930
+ log.warn("google", "reclaiming an unreadable calendar state lock", {
931
+ lockPath,
932
+ age_ms
933
+ });
934
+ await unlink(lockPath).catch(() => void 0);
935
+ return;
936
+ }
937
+ if ((await readLock(lockPath))?.holder !== held.holder) return;
938
+ log.warn("google", "reclaiming a calendar state lock whose holder went quiet", {
939
+ lockPath,
940
+ age_ms
941
+ });
942
+ await unlink(lockPath).catch(() => void 0);
943
+ }
944
+ async function release(lockPath, holder) {
945
+ if ((await readLock(lockPath))?.holder !== holder) return;
946
+ await unlink(lockPath).catch(() => void 0);
947
+ }
948
+ /** Run `mutate` with no other process mutating the same file.
949
+ *
950
+ * Fails OPEN. A workspace that cannot create the lock file — a read-only
951
+ * mount, a missing directory, an exhausted disk — still syncs, the way it did
952
+ * before #2679; the lock removes a race, it is not a precondition for
953
+ * correctness. The same applies to waiting the timeout out: proceeding
954
+ * unlocked risks the lost update this exists to prevent, whereas refusing
955
+ * would stop the sync outright.
956
+ *
957
+ * Note what this cannot promise. `O_EXCL` is atomic on a local filesystem and
958
+ * on NFSv3+, but a workspace living in a consumer sync folder (Dropbox,
959
+ * iCloud, Drive) has no such guarantee — there the file is replicated after
960
+ * the fact and both hosts believe they hold it. */
961
+ async function withCalendarStateLock(lockPath, mutate, clock = liveLockClock) {
962
+ const holder = randomUUID();
963
+ const deadline = clock.now() + LOCK_WAIT_MS;
964
+ let holding = false;
965
+ try {
966
+ holding = await acquireBefore(lockPath, holder, deadline, clock);
967
+ if (!holding) log.warn("google", "proceeding without the calendar state lock — another host is holding it", { lockPath });
968
+ return await mutate();
969
+ } finally {
970
+ if (holding) await release(lockPath, holder);
971
+ }
972
+ }
973
+ /** Poll until the lock is ours or the deadline passes. */
974
+ async function acquireBefore(lockPath, holder, deadline, clock) {
975
+ for (;;) {
976
+ try {
977
+ if (await tryAcquire(lockPath, holder)) return true;
978
+ await stealIfStale(lockPath, clock);
979
+ } catch (error) {
980
+ log.warn("google", "could not take the calendar state lock — mutating without it", {
981
+ lockPath,
982
+ error: String(error)
983
+ });
984
+ return false;
985
+ }
986
+ if (clock.now() >= deadline) return false;
987
+ await clock.sleep(LOCK_RETRY_MS);
988
+ }
989
+ }
990
+ /** `<state file>.lock`, so each state file is serialised on its own. */
991
+ var stateLockPath = (statePath) => `${statePath}.lock`;
992
+ //#endregion
837
993
  //#region src/google/calendarSyncStore.ts
838
994
  var SYNC_STATE_MODE = 384;
839
995
  /** `<workspace>/data/calendar/.sync-state.json` */
@@ -861,11 +1017,12 @@ var writeQueue$1 = { tail: Promise.resolve() };
861
1017
  * nothing in this process can read between the two. A `null` state skips the
862
1018
  * write; `result` is what the caller learns about the decision. */
863
1019
  async function updateStateWith(decide, workspaceRoot) {
864
- const run = writeQueue$1.tail.then(async () => {
1020
+ const statePath = calendarSyncStatePath(workspaceRoot);
1021
+ const run = writeQueue$1.tail.then(async () => await withCalendarStateLock(stateLockPath(statePath), async () => {
865
1022
  const { state, result } = decide(await readState$1(workspaceRoot));
866
- if (state) await writeJsonAtomicWithMode(calendarSyncStatePath(workspaceRoot), state, SYNC_STATE_MODE);
1023
+ if (state) await writeJsonAtomicWithMode(statePath, state, SYNC_STATE_MODE);
867
1024
  return result;
868
- });
1025
+ }));
869
1026
  writeQueue$1.tail = run.catch(() => void 0);
870
1027
  return await run;
871
1028
  }
@@ -985,10 +1142,11 @@ async function readState(workspaceRoot) {
985
1142
  }
986
1143
  var writeQueue = { tail: Promise.resolve() };
987
1144
  async function updateState(mutate, workspaceRoot) {
988
- const run = writeQueue.tail.then(async () => {
1145
+ const statePath = calendarPushStatePath(workspaceRoot);
1146
+ const run = writeQueue.tail.then(async () => await withCalendarStateLock(stateLockPath(statePath), async () => {
989
1147
  const state = await readState(workspaceRoot);
990
- await writeJsonAtomicWithMode(calendarPushStatePath(workspaceRoot), { events: mutate(state.events) }, PUSH_STATE_MODE);
991
- });
1148
+ await writeJsonAtomicWithMode(statePath, { events: mutate(state.events) }, PUSH_STATE_MODE);
1149
+ }));
992
1150
  writeQueue.tail = run.catch(() => void 0);
993
1151
  return await run;
994
1152
  }
@@ -1193,19 +1351,6 @@ function toCollectionRecord(event, map, primaryKey, fields) {
1193
1351
  [primaryKey]: event.id
1194
1352
  };
1195
1353
  }
1196
- /** Google's mapped values laid over whatever the record already holds.
1197
- *
1198
- * A record file is written whole (`writeItem`), so projecting alone would drop
1199
- * every column the map does not name — a collection cannot then carry a local
1200
- * note next to a mirrored event, because the next pull that touches that event
1201
- * silently deletes it. The projection still wins on the fields it covers: those
1202
- * are Google's to own. */
1203
- function mergeIntoExisting(existing, projected) {
1204
- return {
1205
- ...existing ?? {},
1206
- ...projected
1207
- };
1208
- }
1209
1354
  //#endregion
1210
1355
  //#region src/google/pushPlan.ts
1211
1356
  /** The event fields Google lets a caller write. `htmlLink` and `status` are
@@ -1483,15 +1628,23 @@ function buildPatch(ctx, record, changed, shadow) {
1483
1628
  }
1484
1629
  };
1485
1630
  }
1631
+ /** Google has dropped the event this record mirrors.
1632
+ *
1633
+ * The advice used to be "sync first, then re-create it", which sent people
1634
+ * nowhere: Google never resends a cancellation, so a sync sees nothing to do —
1635
+ * and before #2688 the sync that DID see it deleted the record along with the
1636
+ * edit. Deliberately silent on whether Google will hand the id back; that is
1637
+ * unverified. */
1638
+ var goneFromGoogle = (eventId) => ({
1639
+ kind: "skipped",
1640
+ message: `${eventId}: Google no longer has this event, and your local edit was kept. Delete this record, or copy it into a new one.`
1641
+ });
1486
1642
  async function updateFromRecord(ctx, eventId, record, changed, shadow) {
1487
1643
  const fetched = await getCalendarEvent(ctx.accessToken, {
1488
1644
  calendarId: ctx.calendarId,
1489
1645
  eventId
1490
1646
  });
1491
- if (fetched === null || fetched.event.status === "cancelled") return {
1492
- kind: "skipped",
1493
- message: `${eventId}: the event no longer exists in Google — sync first, then re-create it`
1494
- };
1647
+ if (fetched === null || fetched.event.status === "cancelled") return goneFromGoogle(eventId);
1495
1648
  if (conflictingFields(shadow, toShadowEvent(fetched.event), changed).length > 0) return { kind: "conflict" };
1496
1649
  const built = buildPatch(ctx, record, changed, shadow);
1497
1650
  if (!built.ok) return {
@@ -1719,7 +1872,42 @@ function classifyDelete(eventId, kind) {
1719
1872
  message: `delete ${eventId}: ${kind}`
1720
1873
  };
1721
1874
  }
1722
- async function applyEvent(collection, event, workspaceRoot) {
1875
+ /** Whether this record still says what the workspace last saw Google say.
1876
+ *
1877
+ * Compared against the baseline as it stood when this run STARTED READING, not
1878
+ * as it stands now: a full re-walk clears the baseline before writing a new one
1879
+ * (`restartFullSync`), so reading it live would answer "no baseline" for every
1880
+ * event and protect nothing exactly when the window is widest (#2684).
1881
+ *
1882
+ * A record with no baseline at all is NOT withheld — that is an event this
1883
+ * workspace has never held, so there is no local edit to lose. */
1884
+ function unsentEditGuard(schema, baseline) {
1885
+ const map = pushableMap(schema.googleCalendar?.map ?? {});
1886
+ return (existing, eventId) => {
1887
+ const shadow = baseline[eventId];
1888
+ if (shadow === void 0) return false;
1889
+ return locallyChangedFields(existing, baselineRecord(eventId, shadow, map, schema.primaryKey, schema.fields), map).length > 0;
1890
+ };
1891
+ }
1892
+ /** What the pull may do to the record behind one event.
1893
+ *
1894
+ * The guard runs BEFORE the status is consulted, and that order is the whole
1895
+ * fix: "is there something local to lose here?" outranks "what did Google do
1896
+ * to it?". Asking about the status first is how a cancellation kept deleting
1897
+ * records that held an edit Google had never seen (#2688), long after the
1898
+ * same guard had been put in front of the overwrite (#2684). */
1899
+ function applyPlanFor(existing, event, hasUnsentEdit) {
1900
+ if (existing !== null && hasUnsentEdit(existing, event.id)) return "withhold";
1901
+ return event.status === "cancelled" ? "delete" : "write";
1902
+ }
1903
+ /** Lay Google's mapped values over whatever the record already holds. Split out
1904
+ * so `applyEvent` reads as read → decide → act rather than carrying the write
1905
+ * itself (CodeRabbit review #2689). */
1906
+ async function writeProjected(write, event, schema, existing) {
1907
+ const record = toCollectionRecord(event, schema.googleCalendar?.map ?? {}, schema.primaryKey, schema.fields);
1908
+ return classifyWrite(event.id, (await write(event.id, mergeIntoExisting(existing, record))).kind);
1909
+ }
1910
+ async function applyEvent(collection, event, workspaceRoot, hasUnsentEdit) {
1723
1911
  const { schema } = collection;
1724
1912
  try {
1725
1913
  const store = storeFor(collection, { workspaceRoot });
@@ -1727,13 +1915,11 @@ async function applyEvent(collection, event, workspaceRoot) {
1727
1915
  kind: "unwritable",
1728
1916
  message: `collection '${collection.slug}' is read-only`
1729
1917
  };
1730
- if (event.status === "cancelled") {
1731
- const deleted = await store.delete(event.id);
1732
- return classifyDelete(event.id, deleted.kind);
1733
- }
1734
- const record = toCollectionRecord(event, schema.googleCalendar?.map ?? {}, schema.primaryKey, schema.fields);
1735
- const written = await store.write(event.id, mergeIntoExisting(await store.read(event.id), record));
1736
- return classifyWrite(event.id, written.kind);
1918
+ const existing = await store.read(event.id);
1919
+ const plan = applyPlanFor(existing, event, hasUnsentEdit);
1920
+ if (plan === "withhold") return { kind: "withheld" };
1921
+ if (plan === "delete") return classifyDelete(event.id, (await store.delete(event.id)).kind);
1922
+ return await writeProjected(store.write, event, schema, existing);
1737
1923
  } catch (error) {
1738
1924
  return {
1739
1925
  kind: "error",
@@ -1784,15 +1970,15 @@ var NOTHING_UNPUSHED = /* @__PURE__ */ new Set();
1784
1970
  * protection — would overwrite the very edits this exists to protect; the read
1785
1971
  * that failed is no evidence that the pull's own writes would fail too, so they
1786
1972
  * would land (CodeRabbit review #2666). */
1787
- var PROTECTION_UNKNOWN = "could not work out which records to protect after a failed push";
1788
- /** What ONE collection's pull must leave alone: only what ITS OWN push failed to
1973
+ var PROTECTION_UNKNOWN = "could not work out which records to protect from the pull";
1974
+ /** What ONE collection's pull must leave alone: only what ITS OWN push did not
1789
1975
  * send.
1790
1976
  *
1791
- * Scoped per collection because a calendar can back several of them, and a
1792
- * conflict in one says nothing about the others. Sharing one set across the
1793
- * group starved a collection that never even declares `autoPush`: it cannot
1794
- * conflict, yet a neighbour's conflict froze its records — and the sync token
1795
- * still advanced, so Google never resent them (Codex review #2666). */
1977
+ * Scoped per collection because a calendar can back several of them, and one
1978
+ * collection's unsent edit says nothing about the others. Sharing one set across
1979
+ * the group starved a collection that never even declares `autoPush`: a
1980
+ * neighbour's conflict froze its records — and the sync token still advanced, so
1981
+ * Google never resent them (Codex review #2666). */
1796
1982
  var unpushedFor = (unpushed, slug) => {
1797
1983
  const protection = unpushed.get(slug);
1798
1984
  return protection === void 0 ? NOTHING_UNPUSHED : protection;
@@ -1817,16 +2003,19 @@ var allUnpushed = (unpushed) => new Set([...unpushed.values()].flatMap((ids) =>
1817
2003
  * feature exists to prevent: a calendar whose role degrades to reader refuses
1818
2004
  * the whole push, yet the pull still runs — reading needs no write access — and
1819
2005
  * overwrote every unsent local edit while advancing its baseline past it, so the
1820
- * next push could not even report the conflict (CodeRabbit review #2666).
2006
+ * next push could not even report the conflict (CodeRabbit review #2666). A
2007
+ * collection that never declares `autoPush` is in that same state on every
2008
+ * single run, which is how the loss reappeared without a failure in sight
2009
+ * (#2683).
1821
2010
  *
1822
2011
  * Protects the edited records rather than all of them, so an unchanged record
1823
2012
  * keeps syncing normally. `null` when even that could not be worked out — the
1824
2013
  * caller then refuses to pull the collection at all, because failing open here
1825
2014
  * destroys exactly what this protects. */
1826
- async function protectUnsentEdits(collection, workspaceRoot) {
2015
+ async function protectUnsentEdits(collection, workspaceRoot, deps) {
1827
2016
  try {
1828
- const edited = await unsentLocalEdits(collection, workspaceRoot);
1829
- if (edited.length > 0) log.warn("google", "protecting local edits a failed push could not send", {
2017
+ const edited = await deps.unsentEdits(collection, workspaceRoot);
2018
+ if (edited.length > 0) log.warn("google", "protecting local edits that have not reached Google", {
1830
2019
  slug: collection.slug,
1831
2020
  edited
1832
2021
  });
@@ -1839,8 +2028,18 @@ async function protectUnsentEdits(collection, workspaceRoot) {
1839
2028
  return null;
1840
2029
  }
1841
2030
  }
1842
- /** Push every `autoPush` collection in this group, and answer with the records
1843
- * whose local edit did not reach Google, keyed by collection.
2031
+ var livePullProtectionDeps = {
2032
+ pushNow: pushCollectionNow,
2033
+ unsentEdits: unsentLocalEdits
2034
+ };
2035
+ /** What ONE collection's pull must leave alone, pushing it first if it asked to
2036
+ * be pushed.
2037
+ *
2038
+ * A collection WITHOUT `autoPush` never pushes, so its local edits are unsent by
2039
+ * definition — the same state a failed push leaves behind, and it needs the same
2040
+ * protection. Pulling over them destroys the edit AND advances the baseline past
2041
+ * it, after which no conflict can be detected any more (#2683). "The push did not
2042
+ * run" is the condition that matters here; why it did not run is not.
1844
2043
  *
1845
2044
  * MUST run inside the calendar lock the caller already holds — hence
1846
2045
  * `pushCollectionNow` rather than `pushCalendarForCollection`, which would take
@@ -1848,27 +2047,31 @@ async function protectUnsentEdits(collection, workspaceRoot) {
1848
2047
  *
1849
2048
  * A failed push must not stop the pull: the pull is what keeps the collection
1850
2049
  * fresh, and a revoked write grant is no reason to freeze reading. */
1851
- async function pushAutoCollections(collections, workspaceRoot) {
1852
- const unpushed = /* @__PURE__ */ new Map();
1853
- for (const collection of collections.filter((entry) => entry.schema.googleCalendar?.autoPush)) try {
1854
- const outcome = await pushCollectionNow(collection, workspaceRoot);
2050
+ async function pullProtectionFor(collection, workspaceRoot, deps = livePullProtectionDeps) {
2051
+ if (!collection.schema.googleCalendar?.autoPush) return await protectUnsentEdits(collection, workspaceRoot, deps);
2052
+ try {
2053
+ const outcome = await deps.pushNow(collection, workspaceRoot);
1855
2054
  if (outcome.kind === "pushed") {
1856
2055
  reportAutoPush(collection.slug, outcome.result);
1857
- unpushed.set(collection.slug, new Set(outcome.result.unpushedIds));
1858
- continue;
2056
+ return new Set(outcome.result.unpushedIds);
1859
2057
  }
1860
2058
  log.warn("google", "auto push did not run", {
1861
2059
  slug: collection.slug,
1862
2060
  reason: outcome.kind
1863
2061
  });
1864
- unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
1865
2062
  } catch (error) {
1866
2063
  log.warn("google", "auto push failed — pulling anyway", {
1867
2064
  slug: collection.slug,
1868
2065
  error: String(error)
1869
2066
  });
1870
- unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
1871
2067
  }
2068
+ return await protectUnsentEdits(collection, workspaceRoot, deps);
2069
+ }
2070
+ /** Push the `autoPush` collections in this group, and answer with what every
2071
+ * collection's pull must leave alone, keyed by collection. */
2072
+ async function pushAndProtect(collections, workspaceRoot, deps = livePullProtectionDeps) {
2073
+ const unpushed = /* @__PURE__ */ new Map();
2074
+ for (const collection of collections) unpushed.set(collection.slug, await pullProtectionFor(collection, workspaceRoot, deps));
1872
2075
  return unpushed;
1873
2076
  }
1874
2077
  async function restartFullSync(accessToken, calendarId, workspaceRoot) {
@@ -1945,17 +2148,26 @@ async function releaseCalendarSyncClaim(calendarId, workspaceRoot) {
1945
2148
  });
1946
2149
  }
1947
2150
  }
2151
+ /** Every event whose baseline must NOT advance: what the push could not send,
2152
+ * plus what the apply refused to overwrite.
2153
+ *
2154
+ * The two must agree exactly. The apply's refusals only became visible after it
2155
+ * ran, so they join the union here rather than at push time — leaving them out
2156
+ * would advance the baseline past a record the pull deliberately left holding a
2157
+ * local edit, which is the silent overwrite this whole path exists to stop. */
2158
+ var heldBack = (unpushed, results) => /* @__PURE__ */ new Set([...allUnpushed(unpushed), ...results.flatMap((result) => result.withheld)]);
1948
2159
  async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
1949
- const unpushed = await pushAutoCollections(collections, workspaceRoot);
2160
+ const unpushed = await pushAndProtect(collections, workspaceRoot);
2161
+ const baseline = await loadCalendarShadow(calendarId, workspaceRoot);
1950
2162
  const accessToken = await getGoogleAccessToken();
1951
2163
  const first = await syncCalendarEvents(accessToken, {
1952
2164
  calendarId,
1953
2165
  syncToken: await loadCalendarSyncToken(calendarId, workspaceRoot) ?? void 0
1954
2166
  });
1955
2167
  const result = first.fullResyncRequired ? await restartFullSync(accessToken, calendarId, workspaceRoot) : first;
1956
- const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed);
2168
+ const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed, baseline);
1957
2169
  if (windowFullyLanded(calendarId, results)) {
1958
- await saveCalendarShadow(calendarId, shadowUpdates(result.events, allUnpushed(unpushed)), workspaceRoot);
2170
+ await saveCalendarShadow(calendarId, shadowUpdates(result.events, heldBack(unpushed, results), baseline), workspaceRoot);
1959
2171
  if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
1960
2172
  }
1961
2173
  return results;
@@ -1964,7 +2176,7 @@ async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
1964
2176
  * one's own push protected. A collection whose protection could not be worked
1965
2177
  * out is not pulled at all — it reports a retryable error instead, which holds
1966
2178
  * the token and the baseline back for the whole group. */
1967
- async function applyWindowToGroup(collections, events, workspaceRoot, unpushed) {
2179
+ async function applyWindowToGroup(collections, events, workspaceRoot, unpushed, baseline) {
1968
2180
  const results = [];
1969
2181
  for (const collection of collections) {
1970
2182
  const protection = unpushedFor(unpushed, collection.slug);
@@ -1973,8 +2185,9 @@ async function applyWindowToGroup(collections, events, workspaceRoot, unpushed)
1973
2185
  written: 0,
1974
2186
  removed: 0,
1975
2187
  unwritable: [],
2188
+ withheld: [],
1976
2189
  errors: [PROTECTION_UNKNOWN]
1977
- } : await applyEventsToCollection(collection, events, workspaceRoot, protection));
2190
+ } : await applyEventsToCollection(collection, events, workspaceRoot, protection, baseline));
1978
2191
  }
1979
2192
  return results;
1980
2193
  }
@@ -1991,6 +2204,11 @@ function windowFullyLanded(calendarId, results) {
1991
2204
  slug: entry.slug,
1992
2205
  unwritable: entry.unwritable
1993
2206
  }));
2207
+ results.filter((entry) => entry.withheld.length > 0).forEach((entry) => log.warn("google", "leaving calendar records alone — they hold edits Google has not seen", {
2208
+ calendarId,
2209
+ slug: entry.slug,
2210
+ withheld: entry.withheld
2211
+ }));
1994
2212
  const failed = results.filter((entry) => entry.errors.length > 0);
1995
2213
  failed.forEach((entry) => log.warn("google", "holding back calendar sync token after failed writes", {
1996
2214
  calendarId,
@@ -2007,10 +2225,20 @@ function windowFullyLanded(calendarId, results) {
2007
2225
  * keeps the local one would make the next push read a plain one-sided edit —
2008
2226
  * no conflict to detect any more — and quietly overwrite Google. Held back, the
2009
2227
  * baseline stays older than both sides, so the conflict keeps being reported
2010
- * until someone resolves it (#2620). */
2011
- function shadowUpdates(events, unpushed = /* @__PURE__ */ new Set()) {
2012
- const carried = pullableEvents(events, unpushed);
2013
- return Object.fromEntries(carried.map((event) => [event.id, event.status === "cancelled" ? null : toShadowEvent(event)]));
2228
+ * until someone resolves it (#2620).
2229
+ *
2230
+ * `held` carries what those events must KEEP. Omitting them is enough on an
2231
+ * incremental run, where the file is merged rather than replaced — but a full
2232
+ * re-walk CLEARS the baseline first (`restartFullSync`), and there omission
2233
+ * drops the entry for good. The next push would then read a conflicted record
2234
+ * as a brand-new create, hit Google's duplicate-id 409 and refuse it, instead
2235
+ * of reporting the conflict it actually is. Re-stating the pre-run value makes
2236
+ * a held-back event behave the same either way (observed during Claude review;
2237
+ * no bot flagged it). */
2238
+ function shadowUpdates(events, unpushed = /* @__PURE__ */ new Set(), held = {}) {
2239
+ const advanced = pullableEvents(events, unpushed).map((event) => [event.id, event.status === "cancelled" ? null : toShadowEvent(event)]);
2240
+ const kept = [...unpushed].flatMap((eventId) => held[eventId] === void 0 ? [] : [[eventId, held[eventId]]]);
2241
+ return Object.fromEntries([...kept, ...advanced]);
2014
2242
  }
2015
2243
  /** Save the window's token unless every collection that consumed it was deleted
2016
2244
  * while the sync was in flight.
@@ -2043,13 +2271,19 @@ async function pathExists(absPath) {
2043
2271
  async function anySyncedCollectionSurvives(collections, exists = pathExists) {
2044
2272
  return (await Promise.all(collections.map((collection) => exists(collection.skillDir)))).some(Boolean);
2045
2273
  }
2046
- async function applyEventsToCollection(collection, events, workspaceRoot, unpushed) {
2047
- const outcomes = [];
2048
- for (const event of pullableEvents(events, unpushed)) outcomes.push(await applyEvent(collection, event, workspaceRoot));
2274
+ async function applyEventsToCollection(collection, events, workspaceRoot, unpushed, baseline) {
2275
+ const hasUnsentEdit = unsentEditGuard(collection.schema, baseline);
2276
+ const attempts = [];
2277
+ for (const event of pullableEvents(events, unpushed)) attempts.push({
2278
+ eventId: event.id,
2279
+ outcome: await applyEvent(collection, event, workspaceRoot, hasUnsentEdit)
2280
+ });
2281
+ const outcomes = attempts.map((attempt) => attempt.outcome);
2049
2282
  return {
2050
2283
  slug: collection.slug,
2051
2284
  written: outcomes.filter((outcome) => outcome.kind === "written").length,
2052
2285
  removed: outcomes.filter((outcome) => outcome.kind === "removed").length,
2286
+ withheld: attempts.flatMap((attempt) => attempt.outcome.kind === "withheld" ? [attempt.eventId] : []),
2053
2287
  unwritable: outcomes.flatMap((outcome) => outcome.kind === "unwritable" ? [outcome.message] : []),
2054
2288
  errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
2055
2289
  };
@@ -2152,6 +2386,7 @@ async function runCalendarGroups(groups, workspaceRoot, mayClaim = ALWAYS_CLAIM)
2152
2386
  written: 0,
2153
2387
  removed: 0,
2154
2388
  unwritable: [],
2389
+ withheld: [],
2155
2390
  errors: [String(error)]
2156
2391
  })));
2157
2392
  }
@@ -2437,6 +2672,6 @@ async function deleteDriveFile(accessToken, input) {
2437
2672
  await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}`, { method: "DELETE" });
2438
2673
  }
2439
2674
  //#endregion
2440
- 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, calendarSyncDueWindowMs, calendarSyncStatePath, canonicalCalendarId, canonicalTaskListId, claimCalendarSyncIfDue, classifyDelete, classifyWrite, clearCalendarLastSyncedAt, 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, isCalendarSyncDue, isClientSettableEventId, isDeniedAccessRole, isGoogleApiError, isIsoDateTimeWithOffset, isTextMimeType, isUnpushed, legacyGoogleTokenPath, listCalendarEvents, listCalendars, listDriveFiles, listTaskLists, listTasks, loadCalendarLastSyncedAt, 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 };
2675
+ 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, applyPlanFor, assertSafeMimeType, authorizeGoogle, baselineRecord, brokerBaseUrl, brokerExchange, brokerRefresh, brokerStart, buildEventPatch, buildMultipartBody, buildTaskPatch, bySourceField, calendarApiError, calendarPushStatePath, calendarSyncDueWindowMs, calendarSyncStatePath, canonicalCalendarId, canonicalTaskListId, claimCalendarSyncIfDue, classifyDelete, classifyWrite, clearCalendarLastSyncedAt, 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, heldBack, isCalendarSyncDue, isClientSettableEventId, isDeniedAccessRole, isGoogleApiError, isIsoDateTimeWithOffset, isTextMimeType, isUnpushed, legacyGoogleTokenPath, listCalendarEvents, listCalendars, listDriveFiles, listTaskLists, listTasks, liveLockClock, loadCalendarLastSyncedAt, loadCalendarShadow, loadCalendarSyncToken, loadClientSecret, loadGoogleTokens, locallyChangedFields, locallyDeletedIds, locallyEditedIds, mayAdoptExisting, mergeGoogleTokens, mergeIntoExisting, mergeShadow, orphanedCalendarId, pickBoundary, planRecord, pullProtectionFor, pullableEvents, pushAndProtect, pushCalendarForCollection, pushCollectionNow, pushableMap, readDriveFile, releaseOrphanedCalendarToken, resolveEventSpan, saveCalendarShadow, saveCalendarSyncToken, saveGoogleTokens, shadowUpdates, stateLockPath, syncCalendarEvents, syncCalendarForCollection, syncCalendarGroup, syncDueCalendarCollections, syncNewCalendarCollections, toCalendarSummary, toCollectionDateTime, toCollectionRecord, toDriveFileSummary, toEventSummary, toGoogleEventTime, toShadowEvent, toTaskListSummary, toTaskSummary, uncompleteTask, unlinkGoogle, unpushedFor, unsentEditGuard, unsentLocalEdits, unsyncedGroups, updateCalendarEvent, updateTask, waitForAuthCode, withCalendarLock, withCalendarStateLock, withKeyedLock, zoneSuffixOf };
2441
2676
 
2442
2677
  //# sourceMappingURL=index.js.map