@mulmoclaude/core 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/helps/error-recovery.md +110 -1
- package/dist/{calendarGrid-CaS9er8i.cjs → calendarGrid-BQYqOCnF.cjs} +8 -9
- package/dist/{calendarGrid-CaS9er8i.cjs.map → calendarGrid-BQYqOCnF.cjs.map} +1 -1
- package/dist/{calendarGrid-CrGxeCOG.js → calendarGrid-DivcNKkX.js} +8 -9
- package/dist/{calendarGrid-CrGxeCOG.js.map → calendarGrid-DivcNKkX.js.map} +1 -1
- package/dist/collection/core/project.d.ts +11 -0
- package/dist/collection/core/schemaZ.d.ts +4 -4
- package/dist/collection/index.cjs +5 -4
- package/dist/collection/index.js +5 -5
- package/dist/collection/registry/server/index.cjs +3 -3
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +3 -3
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/csvQuery.d.ts +7 -2
- package/dist/collection/server/csvStore.d.ts +2 -1
- package/dist/collection/server/index.cjs +3 -3
- package/dist/collection/server/index.js +3 -3
- package/dist/collection-watchers/index.cjs +10 -25
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +10 -25
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/{discovery-NRy3tyUA.cjs → discovery-CRDx6TS4.cjs} +88 -47
- package/dist/discovery-CRDx6TS4.cjs.map +1 -0
- package/dist/{discovery-B4CZvrXR.js → discovery-D6p4ITNR.js} +88 -47
- package/dist/discovery-D6p4ITNR.js.map +1 -0
- package/dist/{dist-pWpC-b04.cjs → dist-CsgSfWwR.cjs} +66 -1
- package/dist/{dist-pWpC-b04.cjs.map → dist-CsgSfWwR.cjs.map} +1 -1
- package/dist/{dist-Cwk0e12G.js → dist-D8zokgGo.js} +25 -2
- package/dist/{dist-Cwk0e12G.js.map → dist-D8zokgGo.js.map} +1 -1
- package/dist/feeds/index.cjs +2 -2
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/engine.d.ts +27 -0
- package/dist/feeds/server/index.cjs +97 -26
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.d.ts +1 -1
- package/dist/feeds/server/index.js +96 -27
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/google/calendar.d.ts +23 -9
- package/dist/google/calendarStateLock.d.ts +23 -0
- package/dist/google/collectionProjection.d.ts +0 -8
- package/dist/google/collectionPush.d.ts +13 -8
- package/dist/google/collectionSync.d.ts +80 -10
- package/dist/google/index.cjs +366 -98
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +6 -4
- package/dist/google/index.js +357 -99
- package/dist/google/index.js.map +1 -1
- package/dist/{ingestTypes-D1GQdG8e.cjs → ingestTypes-C7IJSqv4.cjs} +2 -2
- package/dist/{ingestTypes-D1GQdG8e.cjs.map → ingestTypes-C7IJSqv4.cjs.map} +1 -1
- package/dist/{ingestTypes-XReG-li7.js → ingestTypes-DmtDLRmt.js} +2 -2
- package/dist/{ingestTypes-XReG-li7.js.map → ingestTypes-DmtDLRmt.js.map} +1 -1
- package/dist/notifier/index.cjs +1 -1
- package/dist/notifier/index.js +1 -1
- package/dist/notifier/store.d.ts +4 -4
- package/dist/{notifier-BdA5qzhe.js → notifier-Dxh8mEFh.js} +39 -25
- package/dist/notifier-Dxh8mEFh.js.map +1 -0
- package/dist/{notifier-tMsAXyXp.cjs → notifier-Wc4M24Bi.cjs} +39 -25
- package/dist/notifier-Wc4M24Bi.cjs.map +1 -0
- package/dist/plugin-vue/index.cjs +4 -2
- package/dist/plugin-vue/index.cjs.map +1 -1
- package/dist/plugin-vue/index.js +4 -2
- package/dist/plugin-vue/index.js.map +1 -1
- package/dist/project-B0j_Vf_Q.js +33 -0
- package/dist/project-B0j_Vf_Q.js.map +1 -0
- package/dist/project-C1ep9pvo.cjs +44 -0
- package/dist/project-C1ep9pvo.cjs.map +1 -0
- package/dist/{promptSafety-FD8cQn3e.cjs → promptSafety-B52GvaJc.cjs} +99 -85
- package/dist/promptSafety-B52GvaJc.cjs.map +1 -0
- package/dist/{promptSafety-C--op_6a.js → promptSafety-DJgaDjM4.js} +99 -85
- package/dist/promptSafety-DJgaDjM4.js.map +1 -0
- package/dist/remote-host/index.cjs +2 -1
- package/dist/remote-host/index.d.ts +10 -0
- package/dist/remote-host/index.js +2 -2
- package/dist/remote-host/server/index.cjs +2 -2
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +2 -2
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/{remote-host-DkDVxNim.cjs → remote-host-CtQjagPt.cjs} +53 -1
- package/dist/remote-host-CtQjagPt.cjs.map +1 -0
- package/dist/remote-host-Dba4lF3Z.js +82 -0
- package/dist/remote-host-Dba4lF3Z.js.map +1 -0
- package/dist/remote-view/index.cjs +6 -4
- package/dist/remote-view/index.cjs.map +1 -1
- package/dist/remote-view/index.js +6 -4
- package/dist/remote-view/index.js.map +1 -1
- package/dist/scheduler/index.cjs +1 -1
- package/dist/scheduler/index.js +1 -1
- package/dist/{server-DdyIvFhz.js → server-D6EUf3BF.js} +46 -27
- package/dist/server-D6EUf3BF.js.map +1 -0
- package/dist/{server-7U-3DE2e.cjs → server-D7_oRbTJ.cjs} +46 -27
- package/dist/server-D7_oRbTJ.cjs.map +1 -0
- package/dist/utils/index.cjs +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/whisper/index.cjs +2 -6
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/whisper/index.js +2 -6
- package/dist/whisper/index.js.map +1 -1
- package/dist/wiki/index.cjs +2 -2
- package/dist/wiki/index.cjs.map +1 -1
- package/dist/wiki/index.js +2 -2
- package/dist/wiki/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/discovery-B4CZvrXR.js.map +0 -1
- package/dist/discovery-NRy3tyUA.cjs.map +0 -1
- package/dist/notifier-BdA5qzhe.js.map +0 -1
- package/dist/notifier-tMsAXyXp.cjs.map +0 -1
- package/dist/project-BWI5w_BT.cjs +0 -17
- package/dist/project-BWI5w_BT.cjs.map +0 -1
- package/dist/project-bU98ycsy.js +0 -12
- package/dist/project-bU98ycsy.js.map +0 -1
- package/dist/promptSafety-C--op_6a.js.map +0 -1
- package/dist/promptSafety-FD8cQn3e.cjs.map +0 -1
- package/dist/remote-host-D_BRFHcI.js +0 -36
- package/dist/remote-host-D_BRFHcI.js.map +0 -1
- package/dist/remote-host-DkDVxNim.cjs.map +0 -1
- package/dist/server-7U-3DE2e.cjs.map +0 -1
- package/dist/server-DdyIvFhz.js.map +0 -1
package/dist/google/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { t as errorMessage } from "../dist-
|
|
1
|
+
import { i as hasStringProp, t as errorMessage } from "../dist-D8zokgGo.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 { l as parseIsoDate, u as parseIsoDateTime } from "../calendarGrid-
|
|
6
|
-
import {
|
|
5
|
+
import { l as parseIsoDate, u as parseIsoDateTime } from "../calendarGrid-DivcNKkX.js";
|
|
6
|
+
import { t as mergeIntoExisting } from "../project-B0j_Vf_Q.js";
|
|
7
|
+
import { J as getWorkspaceRoot, d as storeFor, et as createForwardingLogger, n as discoverCollections, tt as createHostSlot } from "../discovery-D6p4ITNR.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";
|
|
@@ -588,6 +589,7 @@ var CALENDAR_LIST_PAGE_SIZE = 250;
|
|
|
588
589
|
var MAX_CALENDAR_LIST_PAGES = 40;
|
|
589
590
|
var EVENT_SYNC_PAGE_SIZE = 2500;
|
|
590
591
|
var MAX_EVENT_SYNC_PAGES = 200;
|
|
592
|
+
var CALENDAR_META_PAGE_SIZE = 1;
|
|
591
593
|
var HTTP_GONE = 410;
|
|
592
594
|
var EVENT_ABSENT_STATUSES = [404, HTTP_GONE];
|
|
593
595
|
/** An `If-Match` write whose etag no longer matches — someone else changed the
|
|
@@ -799,20 +801,34 @@ async function collectCalendarPages(fetchPage, maxPages = MAX_CALENDAR_LIST_PAGE
|
|
|
799
801
|
}
|
|
800
802
|
return calendars;
|
|
801
803
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
804
|
+
var toCalendarMeta = (value) => {
|
|
805
|
+
const record = asRecord(value);
|
|
806
|
+
return {
|
|
807
|
+
timeZone: stringField(record, "timeZone"),
|
|
808
|
+
accessRole: stringField(record, "accessRole")
|
|
809
|
+
};
|
|
810
|
+
};
|
|
811
|
+
/** One calendar's zone and the caller's role on it, read by id rather than
|
|
812
|
+
* looked up in the user's list.
|
|
807
813
|
*
|
|
808
814
|
* `calendarList` only holds calendars the user has ADDED; a calendar shared
|
|
809
|
-
* with them can be readable and writable by id without appearing there
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
|
|
814
|
-
|
|
815
|
+
* with them can be readable and writable by id without appearing there, and a
|
|
816
|
+
* push still needs its zone to send an offset-less `dateTime`.
|
|
817
|
+
*
|
|
818
|
+
* Deliberately `events.list` and NOT `calendars.get`: the latter takes only
|
|
819
|
+
* full-calendar scopes (`calendar`, `calendar.readonly`, `calendar.calendars*`)
|
|
820
|
+
* and this app asks for none of them, so it answers 403 for every account it
|
|
821
|
+
* links (#2735). The events list carries the same `timeZone` — plus the
|
|
822
|
+
* `accessRole` the calendar resource does not have — under `calendar.events`,
|
|
823
|
+
* which the push already holds because it writes with it. Only the envelope is
|
|
824
|
+
* read; the one event asked for is the smallest page Google will serve. */
|
|
825
|
+
async function getCalendarMeta(accessToken, calendarId) {
|
|
826
|
+
const params = new URLSearchParams({ maxResults: String(CALENDAR_META_PAGE_SIZE) });
|
|
827
|
+
return toCalendarMeta(await googleRequest(CALENDAR_API_LABEL, accessToken, `${eventsUrl(calendarId)}?${params.toString()}`));
|
|
815
828
|
}
|
|
829
|
+
/** The calendars the user has added/subscribed to (primary + secondary +
|
|
830
|
+
* shared), each with its id, name and colour, following pagination. Needs the
|
|
831
|
+
* calendar-list read scope (GOOGLE_CALENDARLIST_SCOPE). */
|
|
816
832
|
async function listCalendars(accessToken) {
|
|
817
833
|
return collectCalendarPages(async (pageToken) => {
|
|
818
834
|
const params = new URLSearchParams({ maxResults: String(CALENDAR_LIST_PAGE_SIZE) });
|
|
@@ -834,6 +850,159 @@ async function getCalendarColors(accessToken) {
|
|
|
834
850
|
};
|
|
835
851
|
}
|
|
836
852
|
//#endregion
|
|
853
|
+
//#region src/google/calendarStateLock.ts
|
|
854
|
+
var LOCK_MODE = 384;
|
|
855
|
+
/** How long a lock may sit untouched before another process may reclaim it.
|
|
856
|
+
*
|
|
857
|
+
* Sized against the HOLD time, which is one read plus one atomic write — a few
|
|
858
|
+
* milliseconds. Generous by three orders of magnitude so a slow disk never
|
|
859
|
+
* looks like a crash, while a host that died mid-write blocks the others for
|
|
860
|
+
* at most this long. */
|
|
861
|
+
var LOCK_STALE_MS = 1e4;
|
|
862
|
+
/** How long a caller waits before giving up and proceeding unlocked. */
|
|
863
|
+
var LOCK_WAIT_MS = 5e3;
|
|
864
|
+
var LOCK_RETRY_MS = 20;
|
|
865
|
+
var liveLockClock = {
|
|
866
|
+
now: () => Date.now(),
|
|
867
|
+
sleep: (delay_ms) => new Promise((resolve) => setTimeout(resolve, delay_ms))
|
|
868
|
+
};
|
|
869
|
+
var isEexist = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
|
|
870
|
+
/** The file's bytes, or null when it is not there. */
|
|
871
|
+
async function readLockRaw(lockPath) {
|
|
872
|
+
try {
|
|
873
|
+
return await readFile(lockPath, "utf-8");
|
|
874
|
+
} catch {
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/** Absent means unheld; UNREADABLE does not — see `stealIfStale`. */
|
|
879
|
+
function parseLock(raw) {
|
|
880
|
+
try {
|
|
881
|
+
const parsed = JSON.parse(raw);
|
|
882
|
+
return hasStringProp(parsed, "holder") ? { holder: parsed.holder } : null;
|
|
883
|
+
} catch {
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
async function readLock(lockPath) {
|
|
888
|
+
const raw = await readLockRaw(lockPath);
|
|
889
|
+
return raw === null ? null : parseLock(raw);
|
|
890
|
+
}
|
|
891
|
+
/** Take the lock, or answer false because someone else holds a live one. */
|
|
892
|
+
async function tryAcquire(lockPath, holder) {
|
|
893
|
+
try {
|
|
894
|
+
const handle = await open(lockPath, "wx", LOCK_MODE);
|
|
895
|
+
try {
|
|
896
|
+
await handle.writeFile(JSON.stringify({ holder }));
|
|
897
|
+
} finally {
|
|
898
|
+
await handle.close();
|
|
899
|
+
}
|
|
900
|
+
return true;
|
|
901
|
+
} catch (error) {
|
|
902
|
+
if (!isEexist(error)) throw error;
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
/** How long this lock has existed, per the filesystem that holds it.
|
|
907
|
+
*
|
|
908
|
+
* From mtime rather than anything the holder wrote down, and for every lock
|
|
909
|
+
* rather than only the unreadable ones. A timestamp written by one host and
|
|
910
|
+
* compared against another host's clock is two wall clocks pretending to be
|
|
911
|
+
* one: skew either way reclaims a live lock or immortalises a dead one (Codex
|
|
912
|
+
* review #2690). Every host reading this workspace sees the SAME mtime.
|
|
913
|
+
*
|
|
914
|
+
* This still assumes the reader's clock and the filesystem's are within a TTL
|
|
915
|
+
* of each other — true by construction on a local disk, and a sub-second NTP
|
|
916
|
+
* question on a network mount, against a window of ten seconds.
|
|
917
|
+
*
|
|
918
|
+
* Reading mtime also covers the moment `open("wx")` has created the file but
|
|
919
|
+
* its payload has not landed yet: "unreadable" is a state every healthy lock
|
|
920
|
+
* passes through, and one being born reads as newborn rather than dead.
|
|
921
|
+
*
|
|
922
|
+
* Null when the file is gone, which is not an age but an absence. */
|
|
923
|
+
async function lockAgeMs(lockPath, clock) {
|
|
924
|
+
const stamped = await stat(lockPath).catch(() => null);
|
|
925
|
+
return stamped === null ? null : clock.now() - stamped.mtimeMs;
|
|
926
|
+
}
|
|
927
|
+
/** Drop a lock that has sat untouched past the TTL, whatever it says.
|
|
928
|
+
*
|
|
929
|
+
* Readable or not, nothing is reclaimed until it is demonstrably stale. For a
|
|
930
|
+
* readable one the holder is re-read and compared before the unlink, so a
|
|
931
|
+
* holder that renewed in between is not evicted. That narrows the window
|
|
932
|
+
* rather than closing it — two processes can still both decide to clear the
|
|
933
|
+
* same corpse. The cost of losing that race is one lost update, which is what
|
|
934
|
+
* this file reduces rather than a new failure, and reaching it takes a host
|
|
935
|
+
* dying mid-write plus a full TTL of waiting. */
|
|
936
|
+
async function stealIfStale(lockPath, clock) {
|
|
937
|
+
const raw = await readLockRaw(lockPath);
|
|
938
|
+
if (raw === null) return;
|
|
939
|
+
const held = parseLock(raw);
|
|
940
|
+
const age_ms = await lockAgeMs(lockPath, clock);
|
|
941
|
+
if (age_ms === null || age_ms < LOCK_STALE_MS) return;
|
|
942
|
+
if (held === null) {
|
|
943
|
+
log.warn("google", "reclaiming an unreadable calendar state lock", {
|
|
944
|
+
lockPath,
|
|
945
|
+
age_ms
|
|
946
|
+
});
|
|
947
|
+
await unlink(lockPath).catch(() => void 0);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
if ((await readLock(lockPath))?.holder !== held.holder) return;
|
|
951
|
+
log.warn("google", "reclaiming a calendar state lock whose holder went quiet", {
|
|
952
|
+
lockPath,
|
|
953
|
+
age_ms
|
|
954
|
+
});
|
|
955
|
+
await unlink(lockPath).catch(() => void 0);
|
|
956
|
+
}
|
|
957
|
+
async function release(lockPath, holder) {
|
|
958
|
+
if ((await readLock(lockPath))?.holder !== holder) return;
|
|
959
|
+
await unlink(lockPath).catch(() => void 0);
|
|
960
|
+
}
|
|
961
|
+
/** Run `mutate` with no other process mutating the same file.
|
|
962
|
+
*
|
|
963
|
+
* Fails OPEN. A workspace that cannot create the lock file — a read-only
|
|
964
|
+
* mount, a missing directory, an exhausted disk — still syncs, the way it did
|
|
965
|
+
* before #2679; the lock removes a race, it is not a precondition for
|
|
966
|
+
* correctness. The same applies to waiting the timeout out: proceeding
|
|
967
|
+
* unlocked risks the lost update this exists to prevent, whereas refusing
|
|
968
|
+
* would stop the sync outright.
|
|
969
|
+
*
|
|
970
|
+
* Note what this cannot promise. `O_EXCL` is atomic on a local filesystem and
|
|
971
|
+
* on NFSv3+, but a workspace living in a consumer sync folder (Dropbox,
|
|
972
|
+
* iCloud, Drive) has no such guarantee — there the file is replicated after
|
|
973
|
+
* the fact and both hosts believe they hold it. */
|
|
974
|
+
async function withCalendarStateLock(lockPath, mutate, clock = liveLockClock) {
|
|
975
|
+
const holder = randomUUID();
|
|
976
|
+
const deadline = clock.now() + LOCK_WAIT_MS;
|
|
977
|
+
let holding = false;
|
|
978
|
+
try {
|
|
979
|
+
holding = await acquireBefore(lockPath, holder, deadline, clock);
|
|
980
|
+
if (!holding) log.warn("google", "proceeding without the calendar state lock — another host is holding it", { lockPath });
|
|
981
|
+
return await mutate();
|
|
982
|
+
} finally {
|
|
983
|
+
if (holding) await release(lockPath, holder);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/** Poll until the lock is ours or the deadline passes. */
|
|
987
|
+
async function acquireBefore(lockPath, holder, deadline, clock) {
|
|
988
|
+
for (;;) {
|
|
989
|
+
try {
|
|
990
|
+
if (await tryAcquire(lockPath, holder)) return true;
|
|
991
|
+
await stealIfStale(lockPath, clock);
|
|
992
|
+
} catch (error) {
|
|
993
|
+
log.warn("google", "could not take the calendar state lock — mutating without it", {
|
|
994
|
+
lockPath,
|
|
995
|
+
error: String(error)
|
|
996
|
+
});
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
if (clock.now() >= deadline) return false;
|
|
1000
|
+
await clock.sleep(LOCK_RETRY_MS);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/** `<state file>.lock`, so each state file is serialised on its own. */
|
|
1004
|
+
var stateLockPath = (statePath) => `${statePath}.lock`;
|
|
1005
|
+
//#endregion
|
|
837
1006
|
//#region src/google/calendarSyncStore.ts
|
|
838
1007
|
var SYNC_STATE_MODE = 384;
|
|
839
1008
|
/** `<workspace>/data/calendar/.sync-state.json` */
|
|
@@ -861,11 +1030,12 @@ var writeQueue$1 = { tail: Promise.resolve() };
|
|
|
861
1030
|
* nothing in this process can read between the two. A `null` state skips the
|
|
862
1031
|
* write; `result` is what the caller learns about the decision. */
|
|
863
1032
|
async function updateStateWith(decide, workspaceRoot) {
|
|
864
|
-
const
|
|
1033
|
+
const statePath = calendarSyncStatePath(workspaceRoot);
|
|
1034
|
+
const run = writeQueue$1.tail.then(async () => await withCalendarStateLock(stateLockPath(statePath), async () => {
|
|
865
1035
|
const { state, result } = decide(await readState$1(workspaceRoot));
|
|
866
|
-
if (state) await writeJsonAtomicWithMode(
|
|
1036
|
+
if (state) await writeJsonAtomicWithMode(statePath, state, SYNC_STATE_MODE);
|
|
867
1037
|
return result;
|
|
868
|
-
});
|
|
1038
|
+
}));
|
|
869
1039
|
writeQueue$1.tail = run.catch(() => void 0);
|
|
870
1040
|
return await run;
|
|
871
1041
|
}
|
|
@@ -985,10 +1155,11 @@ async function readState(workspaceRoot) {
|
|
|
985
1155
|
}
|
|
986
1156
|
var writeQueue = { tail: Promise.resolve() };
|
|
987
1157
|
async function updateState(mutate, workspaceRoot) {
|
|
988
|
-
const
|
|
1158
|
+
const statePath = calendarPushStatePath(workspaceRoot);
|
|
1159
|
+
const run = writeQueue.tail.then(async () => await withCalendarStateLock(stateLockPath(statePath), async () => {
|
|
989
1160
|
const state = await readState(workspaceRoot);
|
|
990
|
-
await writeJsonAtomicWithMode(
|
|
991
|
-
});
|
|
1161
|
+
await writeJsonAtomicWithMode(statePath, { events: mutate(state.events) }, PUSH_STATE_MODE);
|
|
1162
|
+
}));
|
|
992
1163
|
writeQueue.tail = run.catch(() => void 0);
|
|
993
1164
|
return await run;
|
|
994
1165
|
}
|
|
@@ -1193,19 +1364,6 @@ function toCollectionRecord(event, map, primaryKey, fields) {
|
|
|
1193
1364
|
[primaryKey]: event.id
|
|
1194
1365
|
};
|
|
1195
1366
|
}
|
|
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
1367
|
//#endregion
|
|
1210
1368
|
//#region src/google/pushPlan.ts
|
|
1211
1369
|
/** The event fields Google lets a caller write. `htmlLink` and `status` are
|
|
@@ -1220,7 +1378,7 @@ var PUSHABLE_SOURCE_FIELDS = [
|
|
|
1220
1378
|
"description",
|
|
1221
1379
|
"location"
|
|
1222
1380
|
];
|
|
1223
|
-
var isPushableSource = (source) => PUSHABLE_SOURCE_FIELDS.
|
|
1381
|
+
var isPushableSource = (source) => PUSHABLE_SOURCE_FIELDS.some((field) => field === source);
|
|
1224
1382
|
/** The subset of a schema's `map` a push can act on: collection field → event field. */
|
|
1225
1383
|
function pushableMap(map) {
|
|
1226
1384
|
return Object.fromEntries(Object.entries(map).filter((entry) => isPushableSource(entry[1])));
|
|
@@ -1342,21 +1500,28 @@ function locallyDeletedIds(shadow, presentIds) {
|
|
|
1342
1500
|
var WRITABLE_ACCESS_ROLES = ["owner", "writer"];
|
|
1343
1501
|
/** Whether to refuse the whole push before touching a single event.
|
|
1344
1502
|
*
|
|
1345
|
-
* Only on POSITIVE evidence of a non-writable role.
|
|
1346
|
-
* unknown and must fall through: hard-denying it
|
|
1347
|
-
* outright for a calendar the user can in fact write
|
|
1348
|
-
* Google's own 403 per record if the write really is
|
|
1503
|
+
* Only on POSITIVE evidence of a non-writable role. A calendar whose role
|
|
1504
|
+
* neither source reported is unknown and must fall through: hard-denying it
|
|
1505
|
+
* would block the feature outright for a calendar the user can in fact write
|
|
1506
|
+
* to. Those attempts surface Google's own 403 per record if the write really is
|
|
1507
|
+
* not allowed. */
|
|
1349
1508
|
var isDeniedAccessRole = (accessRole) => accessRole !== null && !WRITABLE_ACCESS_ROLES.includes(accessRole);
|
|
1509
|
+
/** A role Google left blank is UNKNOWN, not a denial. `isDeniedAccessRole`
|
|
1510
|
+
* refuses every role it does not recognise — `""` included — so passing an
|
|
1511
|
+
* unreported one straight through would refuse a calendar Google simply said
|
|
1512
|
+
* nothing about. */
|
|
1513
|
+
var reportedAccessRole = (accessRole) => accessRole || null;
|
|
1350
1514
|
/** Resolve the calendar a schema names, for its writability and its timezone.
|
|
1351
1515
|
*
|
|
1352
|
-
* The list is consulted first because
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
* equals the literal the schema declares.
|
|
1516
|
+
* The list is consulted first because it answers for every added calendar in
|
|
1517
|
+
* one call the push already makes. `"primary"` is matched on the `primary`
|
|
1518
|
+
* flag, not the id — the primary calendar's own id is the account's email
|
|
1519
|
+
* address, which never equals the literal the schema declares.
|
|
1357
1520
|
*
|
|
1358
|
-
* A calendar absent from the list is NOT denied:
|
|
1359
|
-
*
|
|
1521
|
+
* A calendar absent from the list is NOT denied: `calendarList` holds only the
|
|
1522
|
+
* calendars the user has added, so one shared with write access can be missing
|
|
1523
|
+
* from it (Codex review). It is asked about itself instead, which reports the
|
|
1524
|
+
* same zone AND the real role. A 404 there means the calendar is genuinely
|
|
1360
1525
|
* unreachable, and propagates as a `failed` outcome. */
|
|
1361
1526
|
async function liveCalendarMeta(accessToken, calendarId) {
|
|
1362
1527
|
const key = canonicalCalendarId(calendarId);
|
|
@@ -1366,10 +1531,13 @@ async function liveCalendarMeta(accessToken, calendarId) {
|
|
|
1366
1531
|
accessRole: listed.accessRole,
|
|
1367
1532
|
timeZone: listed.timeZone
|
|
1368
1533
|
};
|
|
1369
|
-
const direct = await
|
|
1370
|
-
log.info("google", "calendar is not in the user's list —
|
|
1534
|
+
const direct = await getCalendarMeta(accessToken, calendarId);
|
|
1535
|
+
log.info("google", "calendar is not in the user's list — role read from the calendar itself", {
|
|
1536
|
+
calendarId: key,
|
|
1537
|
+
accessRole: direct.accessRole
|
|
1538
|
+
});
|
|
1371
1539
|
return {
|
|
1372
|
-
accessRole:
|
|
1540
|
+
accessRole: reportedAccessRole(direct.accessRole),
|
|
1373
1541
|
timeZone: direct.timeZone
|
|
1374
1542
|
};
|
|
1375
1543
|
}
|
|
@@ -1483,15 +1651,23 @@ function buildPatch(ctx, record, changed, shadow) {
|
|
|
1483
1651
|
}
|
|
1484
1652
|
};
|
|
1485
1653
|
}
|
|
1654
|
+
/** Google has dropped the event this record mirrors.
|
|
1655
|
+
*
|
|
1656
|
+
* The advice used to be "sync first, then re-create it", which sent people
|
|
1657
|
+
* nowhere: Google never resends a cancellation, so a sync sees nothing to do —
|
|
1658
|
+
* and before #2688 the sync that DID see it deleted the record along with the
|
|
1659
|
+
* edit. Deliberately silent on whether Google will hand the id back; that is
|
|
1660
|
+
* unverified. */
|
|
1661
|
+
var goneFromGoogle = (eventId) => ({
|
|
1662
|
+
kind: "skipped",
|
|
1663
|
+
message: `${eventId}: Google no longer has this event, and your local edit was kept. Delete this record, or copy it into a new one.`
|
|
1664
|
+
});
|
|
1486
1665
|
async function updateFromRecord(ctx, eventId, record, changed, shadow) {
|
|
1487
1666
|
const fetched = await getCalendarEvent(ctx.accessToken, {
|
|
1488
1667
|
calendarId: ctx.calendarId,
|
|
1489
1668
|
eventId
|
|
1490
1669
|
});
|
|
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
|
-
};
|
|
1670
|
+
if (fetched === null || fetched.event.status === "cancelled") return goneFromGoogle(eventId);
|
|
1495
1671
|
if (conflictingFields(shadow, toShadowEvent(fetched.event), changed).length > 0) return { kind: "conflict" };
|
|
1496
1672
|
const built = buildPatch(ctx, record, changed, shadow);
|
|
1497
1673
|
if (!built.ok) return {
|
|
@@ -1546,9 +1722,9 @@ async function createOrAdopt(ctx, eventId, record) {
|
|
|
1546
1722
|
}
|
|
1547
1723
|
/** A 403 on a write is a permissions answer, but `googleApiError` appends the
|
|
1548
1724
|
* "is the API enabled for the Cloud project?" hint to every 403 — true for a
|
|
1549
|
-
* disabled API, misleading here. Reached when the up-front
|
|
1550
|
-
*
|
|
1551
|
-
*
|
|
1725
|
+
* disabled API, misleading here. Reached when the up-front gate let the push
|
|
1726
|
+
* through on an unknown role, which is exactly when the user needs the real
|
|
1727
|
+
* reason. */
|
|
1552
1728
|
function writeFailure(eventId, error) {
|
|
1553
1729
|
if (isGoogleApiError(error) && error.status === 403) return {
|
|
1554
1730
|
kind: "skipped",
|
|
@@ -1719,7 +1895,42 @@ function classifyDelete(eventId, kind) {
|
|
|
1719
1895
|
message: `delete ${eventId}: ${kind}`
|
|
1720
1896
|
};
|
|
1721
1897
|
}
|
|
1722
|
-
|
|
1898
|
+
/** Whether this record still says what the workspace last saw Google say.
|
|
1899
|
+
*
|
|
1900
|
+
* Compared against the baseline as it stood when this run STARTED READING, not
|
|
1901
|
+
* as it stands now: a full re-walk clears the baseline before writing a new one
|
|
1902
|
+
* (`restartFullSync`), so reading it live would answer "no baseline" for every
|
|
1903
|
+
* event and protect nothing exactly when the window is widest (#2684).
|
|
1904
|
+
*
|
|
1905
|
+
* A record with no baseline at all is NOT withheld — that is an event this
|
|
1906
|
+
* workspace has never held, so there is no local edit to lose. */
|
|
1907
|
+
function unsentEditGuard(schema, baseline) {
|
|
1908
|
+
const map = pushableMap(schema.googleCalendar?.map ?? {});
|
|
1909
|
+
return (existing, eventId) => {
|
|
1910
|
+
const shadow = baseline[eventId];
|
|
1911
|
+
if (shadow === void 0) return false;
|
|
1912
|
+
return locallyChangedFields(existing, baselineRecord(eventId, shadow, map, schema.primaryKey, schema.fields), map).length > 0;
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
/** What the pull may do to the record behind one event.
|
|
1916
|
+
*
|
|
1917
|
+
* The guard runs BEFORE the status is consulted, and that order is the whole
|
|
1918
|
+
* fix: "is there something local to lose here?" outranks "what did Google do
|
|
1919
|
+
* to it?". Asking about the status first is how a cancellation kept deleting
|
|
1920
|
+
* records that held an edit Google had never seen (#2688), long after the
|
|
1921
|
+
* same guard had been put in front of the overwrite (#2684). */
|
|
1922
|
+
function applyPlanFor(existing, event, hasUnsentEdit) {
|
|
1923
|
+
if (existing !== null && hasUnsentEdit(existing, event.id)) return "withhold";
|
|
1924
|
+
return event.status === "cancelled" ? "delete" : "write";
|
|
1925
|
+
}
|
|
1926
|
+
/** Lay Google's mapped values over whatever the record already holds. Split out
|
|
1927
|
+
* so `applyEvent` reads as read → decide → act rather than carrying the write
|
|
1928
|
+
* itself (CodeRabbit review #2689). */
|
|
1929
|
+
async function writeProjected(write, event, schema, existing) {
|
|
1930
|
+
const record = toCollectionRecord(event, schema.googleCalendar?.map ?? {}, schema.primaryKey, schema.fields);
|
|
1931
|
+
return classifyWrite(event.id, (await write(event.id, mergeIntoExisting(existing, record))).kind);
|
|
1932
|
+
}
|
|
1933
|
+
async function applyEvent(collection, event, workspaceRoot, hasUnsentEdit) {
|
|
1723
1934
|
const { schema } = collection;
|
|
1724
1935
|
try {
|
|
1725
1936
|
const store = storeFor(collection, { workspaceRoot });
|
|
@@ -1727,13 +1938,11 @@ async function applyEvent(collection, event, workspaceRoot) {
|
|
|
1727
1938
|
kind: "unwritable",
|
|
1728
1939
|
message: `collection '${collection.slug}' is read-only`
|
|
1729
1940
|
};
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
const written = await store.write(event.id, mergeIntoExisting(await store.read(event.id), record));
|
|
1736
|
-
return classifyWrite(event.id, written.kind);
|
|
1941
|
+
const existing = await store.read(event.id);
|
|
1942
|
+
const plan = applyPlanFor(existing, event, hasUnsentEdit);
|
|
1943
|
+
if (plan === "withhold") return { kind: "withheld" };
|
|
1944
|
+
if (plan === "delete") return classifyDelete(event.id, (await store.delete(event.id)).kind);
|
|
1945
|
+
return await writeProjected(store.write, event, schema, existing);
|
|
1737
1946
|
} catch (error) {
|
|
1738
1947
|
return {
|
|
1739
1948
|
kind: "error",
|
|
@@ -1784,15 +1993,15 @@ var NOTHING_UNPUSHED = /* @__PURE__ */ new Set();
|
|
|
1784
1993
|
* protection — would overwrite the very edits this exists to protect; the read
|
|
1785
1994
|
* that failed is no evidence that the pull's own writes would fail too, so they
|
|
1786
1995
|
* would land (CodeRabbit review #2666). */
|
|
1787
|
-
var PROTECTION_UNKNOWN = "could not work out which records to protect
|
|
1788
|
-
/** What ONE collection's pull must leave alone: only what ITS OWN push
|
|
1996
|
+
var PROTECTION_UNKNOWN = "could not work out which records to protect from the pull";
|
|
1997
|
+
/** What ONE collection's pull must leave alone: only what ITS OWN push did not
|
|
1789
1998
|
* send.
|
|
1790
1999
|
*
|
|
1791
|
-
* Scoped per collection because a calendar can back several of them, and
|
|
1792
|
-
*
|
|
1793
|
-
* group starved a collection that never even declares `autoPush`:
|
|
1794
|
-
*
|
|
1795
|
-
*
|
|
2000
|
+
* Scoped per collection because a calendar can back several of them, and one
|
|
2001
|
+
* collection's unsent edit says nothing about the others. Sharing one set across
|
|
2002
|
+
* the group starved a collection that never even declares `autoPush`: a
|
|
2003
|
+
* neighbour's conflict froze its records — and the sync token still advanced, so
|
|
2004
|
+
* Google never resent them (Codex review #2666). */
|
|
1796
2005
|
var unpushedFor = (unpushed, slug) => {
|
|
1797
2006
|
const protection = unpushed.get(slug);
|
|
1798
2007
|
return protection === void 0 ? NOTHING_UNPUSHED : protection;
|
|
@@ -1817,16 +2026,19 @@ var allUnpushed = (unpushed) => new Set([...unpushed.values()].flatMap((ids) =>
|
|
|
1817
2026
|
* feature exists to prevent: a calendar whose role degrades to reader refuses
|
|
1818
2027
|
* the whole push, yet the pull still runs — reading needs no write access — and
|
|
1819
2028
|
* 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).
|
|
2029
|
+
* next push could not even report the conflict (CodeRabbit review #2666). A
|
|
2030
|
+
* collection that never declares `autoPush` is in that same state on every
|
|
2031
|
+
* single run, which is how the loss reappeared without a failure in sight
|
|
2032
|
+
* (#2683).
|
|
1821
2033
|
*
|
|
1822
2034
|
* Protects the edited records rather than all of them, so an unchanged record
|
|
1823
2035
|
* keeps syncing normally. `null` when even that could not be worked out — the
|
|
1824
2036
|
* caller then refuses to pull the collection at all, because failing open here
|
|
1825
2037
|
* destroys exactly what this protects. */
|
|
1826
|
-
async function protectUnsentEdits(collection, workspaceRoot) {
|
|
2038
|
+
async function protectUnsentEdits(collection, workspaceRoot, deps) {
|
|
1827
2039
|
try {
|
|
1828
|
-
const edited = await
|
|
1829
|
-
if (edited.length > 0) log.warn("google", "protecting local edits
|
|
2040
|
+
const edited = await deps.unsentEdits(collection, workspaceRoot);
|
|
2041
|
+
if (edited.length > 0) log.warn("google", "protecting local edits that have not reached Google", {
|
|
1830
2042
|
slug: collection.slug,
|
|
1831
2043
|
edited
|
|
1832
2044
|
});
|
|
@@ -1839,8 +2051,18 @@ async function protectUnsentEdits(collection, workspaceRoot) {
|
|
|
1839
2051
|
return null;
|
|
1840
2052
|
}
|
|
1841
2053
|
}
|
|
1842
|
-
|
|
1843
|
-
|
|
2054
|
+
var livePullProtectionDeps = {
|
|
2055
|
+
pushNow: pushCollectionNow,
|
|
2056
|
+
unsentEdits: unsentLocalEdits
|
|
2057
|
+
};
|
|
2058
|
+
/** What ONE collection's pull must leave alone, pushing it first if it asked to
|
|
2059
|
+
* be pushed.
|
|
2060
|
+
*
|
|
2061
|
+
* A collection WITHOUT `autoPush` never pushes, so its local edits are unsent by
|
|
2062
|
+
* definition — the same state a failed push leaves behind, and it needs the same
|
|
2063
|
+
* protection. Pulling over them destroys the edit AND advances the baseline past
|
|
2064
|
+
* it, after which no conflict can be detected any more (#2683). "The push did not
|
|
2065
|
+
* run" is the condition that matters here; why it did not run is not.
|
|
1844
2066
|
*
|
|
1845
2067
|
* MUST run inside the calendar lock the caller already holds — hence
|
|
1846
2068
|
* `pushCollectionNow` rather than `pushCalendarForCollection`, which would take
|
|
@@ -1848,27 +2070,31 @@ async function protectUnsentEdits(collection, workspaceRoot) {
|
|
|
1848
2070
|
*
|
|
1849
2071
|
* A failed push must not stop the pull: the pull is what keeps the collection
|
|
1850
2072
|
* fresh, and a revoked write grant is no reason to freeze reading. */
|
|
1851
|
-
async function
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
const outcome = await
|
|
2073
|
+
async function pullProtectionFor(collection, workspaceRoot, deps = livePullProtectionDeps) {
|
|
2074
|
+
if (!collection.schema.googleCalendar?.autoPush) return await protectUnsentEdits(collection, workspaceRoot, deps);
|
|
2075
|
+
try {
|
|
2076
|
+
const outcome = await deps.pushNow(collection, workspaceRoot);
|
|
1855
2077
|
if (outcome.kind === "pushed") {
|
|
1856
2078
|
reportAutoPush(collection.slug, outcome.result);
|
|
1857
|
-
|
|
1858
|
-
continue;
|
|
2079
|
+
return new Set(outcome.result.unpushedIds);
|
|
1859
2080
|
}
|
|
1860
2081
|
log.warn("google", "auto push did not run", {
|
|
1861
2082
|
slug: collection.slug,
|
|
1862
2083
|
reason: outcome.kind
|
|
1863
2084
|
});
|
|
1864
|
-
unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
|
|
1865
2085
|
} catch (error) {
|
|
1866
2086
|
log.warn("google", "auto push failed — pulling anyway", {
|
|
1867
2087
|
slug: collection.slug,
|
|
1868
2088
|
error: String(error)
|
|
1869
2089
|
});
|
|
1870
|
-
unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
|
|
1871
2090
|
}
|
|
2091
|
+
return await protectUnsentEdits(collection, workspaceRoot, deps);
|
|
2092
|
+
}
|
|
2093
|
+
/** Push the `autoPush` collections in this group, and answer with what every
|
|
2094
|
+
* collection's pull must leave alone, keyed by collection. */
|
|
2095
|
+
async function pushAndProtect(collections, workspaceRoot, deps = livePullProtectionDeps) {
|
|
2096
|
+
const unpushed = /* @__PURE__ */ new Map();
|
|
2097
|
+
for (const collection of collections) unpushed.set(collection.slug, await pullProtectionFor(collection, workspaceRoot, deps));
|
|
1872
2098
|
return unpushed;
|
|
1873
2099
|
}
|
|
1874
2100
|
async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
@@ -1945,17 +2171,26 @@ async function releaseCalendarSyncClaim(calendarId, workspaceRoot) {
|
|
|
1945
2171
|
});
|
|
1946
2172
|
}
|
|
1947
2173
|
}
|
|
2174
|
+
/** Every event whose baseline must NOT advance: what the push could not send,
|
|
2175
|
+
* plus what the apply refused to overwrite.
|
|
2176
|
+
*
|
|
2177
|
+
* The two must agree exactly. The apply's refusals only became visible after it
|
|
2178
|
+
* ran, so they join the union here rather than at push time — leaving them out
|
|
2179
|
+
* would advance the baseline past a record the pull deliberately left holding a
|
|
2180
|
+
* local edit, which is the silent overwrite this whole path exists to stop. */
|
|
2181
|
+
var heldBack = (unpushed, results) => /* @__PURE__ */ new Set([...allUnpushed(unpushed), ...results.flatMap((result) => result.withheld)]);
|
|
1948
2182
|
async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
|
|
1949
|
-
const unpushed = await
|
|
2183
|
+
const unpushed = await pushAndProtect(collections, workspaceRoot);
|
|
2184
|
+
const baseline = await loadCalendarShadow(calendarId, workspaceRoot);
|
|
1950
2185
|
const accessToken = await getGoogleAccessToken();
|
|
1951
2186
|
const first = await syncCalendarEvents(accessToken, {
|
|
1952
2187
|
calendarId,
|
|
1953
2188
|
syncToken: await loadCalendarSyncToken(calendarId, workspaceRoot) ?? void 0
|
|
1954
2189
|
});
|
|
1955
2190
|
const result = first.fullResyncRequired ? await restartFullSync(accessToken, calendarId, workspaceRoot) : first;
|
|
1956
|
-
const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed);
|
|
2191
|
+
const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed, baseline);
|
|
1957
2192
|
if (windowFullyLanded(calendarId, results)) {
|
|
1958
|
-
await saveCalendarShadow(calendarId, shadowUpdates(result.events,
|
|
2193
|
+
await saveCalendarShadow(calendarId, shadowUpdates(result.events, heldBack(unpushed, results), baseline), workspaceRoot);
|
|
1959
2194
|
if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
|
|
1960
2195
|
}
|
|
1961
2196
|
return results;
|
|
@@ -1964,7 +2199,7 @@ async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
|
|
|
1964
2199
|
* one's own push protected. A collection whose protection could not be worked
|
|
1965
2200
|
* out is not pulled at all — it reports a retryable error instead, which holds
|
|
1966
2201
|
* the token and the baseline back for the whole group. */
|
|
1967
|
-
async function applyWindowToGroup(collections, events, workspaceRoot, unpushed) {
|
|
2202
|
+
async function applyWindowToGroup(collections, events, workspaceRoot, unpushed, baseline) {
|
|
1968
2203
|
const results = [];
|
|
1969
2204
|
for (const collection of collections) {
|
|
1970
2205
|
const protection = unpushedFor(unpushed, collection.slug);
|
|
@@ -1973,8 +2208,9 @@ async function applyWindowToGroup(collections, events, workspaceRoot, unpushed)
|
|
|
1973
2208
|
written: 0,
|
|
1974
2209
|
removed: 0,
|
|
1975
2210
|
unwritable: [],
|
|
2211
|
+
withheld: [],
|
|
1976
2212
|
errors: [PROTECTION_UNKNOWN]
|
|
1977
|
-
} : await applyEventsToCollection(collection, events, workspaceRoot, protection));
|
|
2213
|
+
} : await applyEventsToCollection(collection, events, workspaceRoot, protection, baseline));
|
|
1978
2214
|
}
|
|
1979
2215
|
return results;
|
|
1980
2216
|
}
|
|
@@ -1991,6 +2227,11 @@ function windowFullyLanded(calendarId, results) {
|
|
|
1991
2227
|
slug: entry.slug,
|
|
1992
2228
|
unwritable: entry.unwritable
|
|
1993
2229
|
}));
|
|
2230
|
+
results.filter((entry) => entry.withheld.length > 0).forEach((entry) => log.warn("google", "leaving calendar records alone — they hold edits Google has not seen", {
|
|
2231
|
+
calendarId,
|
|
2232
|
+
slug: entry.slug,
|
|
2233
|
+
withheld: entry.withheld
|
|
2234
|
+
}));
|
|
1994
2235
|
const failed = results.filter((entry) => entry.errors.length > 0);
|
|
1995
2236
|
failed.forEach((entry) => log.warn("google", "holding back calendar sync token after failed writes", {
|
|
1996
2237
|
calendarId,
|
|
@@ -2007,10 +2248,20 @@ function windowFullyLanded(calendarId, results) {
|
|
|
2007
2248
|
* keeps the local one would make the next push read a plain one-sided edit —
|
|
2008
2249
|
* no conflict to detect any more — and quietly overwrite Google. Held back, the
|
|
2009
2250
|
* baseline stays older than both sides, so the conflict keeps being reported
|
|
2010
|
-
* until someone resolves it (#2620).
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2251
|
+
* until someone resolves it (#2620).
|
|
2252
|
+
*
|
|
2253
|
+
* `held` carries what those events must KEEP. Omitting them is enough on an
|
|
2254
|
+
* incremental run, where the file is merged rather than replaced — but a full
|
|
2255
|
+
* re-walk CLEARS the baseline first (`restartFullSync`), and there omission
|
|
2256
|
+
* drops the entry for good. The next push would then read a conflicted record
|
|
2257
|
+
* as a brand-new create, hit Google's duplicate-id 409 and refuse it, instead
|
|
2258
|
+
* of reporting the conflict it actually is. Re-stating the pre-run value makes
|
|
2259
|
+
* a held-back event behave the same either way (observed during Claude review;
|
|
2260
|
+
* no bot flagged it). */
|
|
2261
|
+
function shadowUpdates(events, unpushed = /* @__PURE__ */ new Set(), held = {}) {
|
|
2262
|
+
const advanced = pullableEvents(events, unpushed).map((event) => [event.id, event.status === "cancelled" ? null : toShadowEvent(event)]);
|
|
2263
|
+
const kept = [...unpushed].flatMap((eventId) => held[eventId] === void 0 ? [] : [[eventId, held[eventId]]]);
|
|
2264
|
+
return Object.fromEntries([...kept, ...advanced]);
|
|
2014
2265
|
}
|
|
2015
2266
|
/** Save the window's token unless every collection that consumed it was deleted
|
|
2016
2267
|
* while the sync was in flight.
|
|
@@ -2043,13 +2294,19 @@ async function pathExists(absPath) {
|
|
|
2043
2294
|
async function anySyncedCollectionSurvives(collections, exists = pathExists) {
|
|
2044
2295
|
return (await Promise.all(collections.map((collection) => exists(collection.skillDir)))).some(Boolean);
|
|
2045
2296
|
}
|
|
2046
|
-
async function applyEventsToCollection(collection, events, workspaceRoot, unpushed) {
|
|
2047
|
-
const
|
|
2048
|
-
|
|
2297
|
+
async function applyEventsToCollection(collection, events, workspaceRoot, unpushed, baseline) {
|
|
2298
|
+
const hasUnsentEdit = unsentEditGuard(collection.schema, baseline);
|
|
2299
|
+
const attempts = [];
|
|
2300
|
+
for (const event of pullableEvents(events, unpushed)) attempts.push({
|
|
2301
|
+
eventId: event.id,
|
|
2302
|
+
outcome: await applyEvent(collection, event, workspaceRoot, hasUnsentEdit)
|
|
2303
|
+
});
|
|
2304
|
+
const outcomes = attempts.map((attempt) => attempt.outcome);
|
|
2049
2305
|
return {
|
|
2050
2306
|
slug: collection.slug,
|
|
2051
2307
|
written: outcomes.filter((outcome) => outcome.kind === "written").length,
|
|
2052
2308
|
removed: outcomes.filter((outcome) => outcome.kind === "removed").length,
|
|
2309
|
+
withheld: attempts.flatMap((attempt) => attempt.outcome.kind === "withheld" ? [attempt.eventId] : []),
|
|
2053
2310
|
unwritable: outcomes.flatMap((outcome) => outcome.kind === "unwritable" ? [outcome.message] : []),
|
|
2054
2311
|
errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
|
|
2055
2312
|
};
|
|
@@ -2152,6 +2409,7 @@ async function runCalendarGroups(groups, workspaceRoot, mayClaim = ALWAYS_CLAIM)
|
|
|
2152
2409
|
written: 0,
|
|
2153
2410
|
removed: 0,
|
|
2154
2411
|
unwritable: [],
|
|
2412
|
+
withheld: [],
|
|
2155
2413
|
errors: [String(error)]
|
|
2156
2414
|
})));
|
|
2157
2415
|
}
|
|
@@ -2437,6 +2695,6 @@ async function deleteDriveFile(accessToken, input) {
|
|
|
2437
2695
|
await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}`, { method: "DELETE" });
|
|
2438
2696
|
}
|
|
2439
2697
|
//#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,
|
|
2698
|
+
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, getCalendarColors, getCalendarEvent, getCalendarMeta, 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, reportedAccessRole, resolveEventSpan, saveCalendarShadow, saveCalendarSyncToken, saveGoogleTokens, shadowUpdates, stateLockPath, syncCalendarEvents, syncCalendarForCollection, syncCalendarGroup, syncDueCalendarCollections, syncNewCalendarCollections, toCalendarMeta, toCalendarSummary, toCollectionDateTime, toCollectionRecord, toDriveFileSummary, toEventSummary, toGoogleEventTime, toShadowEvent, toTaskListSummary, toTaskSummary, uncompleteTask, unlinkGoogle, unpushedFor, unsentEditGuard, unsentLocalEdits, unsyncedGroups, updateCalendarEvent, updateTask, waitForAuthCode, withCalendarLock, withCalendarStateLock, withKeyedLock, zoneSuffixOf };
|
|
2441
2699
|
|
|
2442
2700
|
//# sourceMappingURL=index.js.map
|