@mulmoclaude/core 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/google/calendarSyncDue.d.ts +10 -0
- package/dist/google/calendarSyncStore.d.ts +20 -1
- package/dist/google/collectionSync.d.ts +24 -5
- package/dist/google/index.cjs +203 -23
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +3 -2
- package/dist/google/index.js +199 -24
- package/dist/google/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** How long a fresh marker suppresses a sync: one interval, less the slack.
|
|
2
|
+
* The slack is halved rather than subtracted flat for a very short configured
|
|
3
|
+
* interval, where a fixed five minutes would swallow the window whole. */
|
|
4
|
+
export declare function calendarSyncDueWindowMs(intervalMs: number): number;
|
|
5
|
+
/** True iff no recent enough run stands in for this one.
|
|
6
|
+
*
|
|
7
|
+
* A marker that cannot be read as a time is treated as due: a stuck calendar is
|
|
8
|
+
* worse than a duplicate run, which the sync is built to tolerate anyway
|
|
9
|
+
* (writes are upserts by event id). */
|
|
10
|
+
export declare function isCalendarSyncDue(lastSyncedAt: string | null, windowMs: number, now?: number): boolean;
|
|
@@ -3,5 +3,24 @@ export declare function calendarSyncStatePath(workspaceRoot?: string): string;
|
|
|
3
3
|
export declare function loadCalendarSyncToken(calendarId?: string, workspaceRoot?: string): Promise<string | null>;
|
|
4
4
|
export declare function saveCalendarSyncToken(calendarId: string | undefined, syncToken: string, workspaceRoot?: string): Promise<void>;
|
|
5
5
|
/** Drop one calendar's token — used when Google answers 410, so the next run
|
|
6
|
-
* starts a clean full sync.
|
|
6
|
+
* starts a clean full sync. Leaves the sync marker alone: "restart the walk"
|
|
7
|
+
* says nothing about "nobody is running". */
|
|
7
8
|
export declare function clearCalendarSyncToken(calendarId?: string, workspaceRoot?: string): Promise<void>;
|
|
9
|
+
export declare function loadCalendarLastSyncedAt(calendarId?: string, workspaceRoot?: string): Promise<string | null>;
|
|
10
|
+
/** Stamp when a sync of this calendar started, but only if `mayClaim` accepts
|
|
11
|
+
* what the marker currently says. Answers whether THIS call is the one that
|
|
12
|
+
* claimed it.
|
|
13
|
+
*
|
|
14
|
+
* The decision and the stamp share one pass through the queue on purpose.
|
|
15
|
+
* Deciding from a snapshot read earlier — as a caller filtering a whole map of
|
|
16
|
+
* calendars up front must — leaves both hosts believing the calendar is free,
|
|
17
|
+
* which is the race this exists to close (Codex review #2680). What remains is
|
|
18
|
+
* the read→write gap BETWEEN processes; closing that needs a real file lock
|
|
19
|
+
* (#2679), and this stays a soft dedup until then.
|
|
20
|
+
*
|
|
21
|
+
* The time is passed in rather than read here so the rule stays testable
|
|
22
|
+
* without freezing the clock. */
|
|
23
|
+
export declare function claimCalendarSyncIfDue(calendarId: string | undefined, startedAt: string, mayClaim: (lastSyncedAt: string | null) => boolean, workspaceRoot?: string): Promise<boolean>;
|
|
24
|
+
/** Drop one calendar's marker, so the next tick — on either host — may sync it
|
|
25
|
+
* immediately instead of waiting the window out. */
|
|
26
|
+
export declare function clearCalendarLastSyncedAt(calendarId?: string, workspaceRoot?: string): Promise<void>;
|
|
@@ -74,6 +74,12 @@ export declare const unpushedFor: (unpushed: UnpushedBySlug, slug: string) => Re
|
|
|
74
74
|
* A `null` (unknown) entry contributes nothing, because that collection reports
|
|
75
75
|
* a retryable error instead — which stops the baseline being saved at all. */
|
|
76
76
|
export declare const allUnpushed: (unpushed: UnpushedBySlug) => ReadonlySet<string>;
|
|
77
|
+
/** Whether a run may take the calendar, given what the shared marker says.
|
|
78
|
+
*
|
|
79
|
+
* The scheduled door defers to it; every user-facing door claims regardless,
|
|
80
|
+
* because a Refresh click that silently returns nothing reads as an empty
|
|
81
|
+
* calendar, not as "another host has this". */
|
|
82
|
+
export type ClaimGuard = (lastSyncedAt: string | null) => boolean;
|
|
77
83
|
/** Sync ONE calendar and fan its events out to every collection bound to it.
|
|
78
84
|
*
|
|
79
85
|
* The fan-out is not an optimisation, it is correctness: the sync token is
|
|
@@ -88,8 +94,9 @@ export declare const allUnpushed: (unpushed: UnpushedBySlug) => ReadonlySet<stri
|
|
|
88
94
|
* load the SAME stored token and walk the same window. That is idempotent —
|
|
89
95
|
* writes are upserts by event id — but it is a wasted full walk. Queued, the
|
|
90
96
|
* second pass resumes from the token the first just stored and fetches only
|
|
91
|
-
* what is genuinely new.
|
|
92
|
-
|
|
97
|
+
* what is genuinely new. That queue is module state, so it orders the doors
|
|
98
|
+
* into THIS process only; `claimThenSync` is what other hosts can see. */
|
|
99
|
+
export declare function syncCalendarGroup(calendarId: string | undefined, collections: readonly LoadedCollection[], workspaceRoot: string, mayClaim?: ClaimGuard): Promise<CalendarCollectionSyncResult[]>;
|
|
93
100
|
/** The baseline this window establishes: what Google now says per event, and
|
|
94
101
|
* `null` for a cancelled one so a recreate cannot resume from a dead baseline.
|
|
95
102
|
*
|
|
@@ -139,8 +146,15 @@ export declare function orphanedCalendarId(deleted: CalendarDeclaring, remaining
|
|
|
139
146
|
* Returns the cleared calendar id, or null when nothing was cleared. Never
|
|
140
147
|
* throws: a failed cleanup must not fail the delete it follows. */
|
|
141
148
|
export declare function releaseOrphanedCalendarToken(deleted: CalendarDeclaring, workspaceRoot: string): Promise<string | null>;
|
|
142
|
-
/** Sync every collection
|
|
143
|
-
|
|
149
|
+
/** Sync every collection whose calendar is due — no host in this workspace has
|
|
150
|
+
* started one within `intervalMs` (#2678). Without that gate this walked every
|
|
151
|
+
* declaring group on every tick, so a second host registering the same task
|
|
152
|
+
* simply doubled the runs, concurrently.
|
|
153
|
+
*
|
|
154
|
+
* Dueness is not decided here, only described: the guard is handed down and
|
|
155
|
+
* evaluated where the marker is written, so the answer cannot go stale between
|
|
156
|
+
* deciding and claiming (Codex review #2680). */
|
|
157
|
+
export declare function syncDueCalendarCollections(workspaceRoot: string, intervalMs?: number): Promise<CalendarCollectionSyncResult[]>;
|
|
144
158
|
/** The groups whose calendar has never synced. A missing token IS the "created
|
|
145
159
|
* since the last sync" signal — nothing else distinguishes a new collection
|
|
146
160
|
* from an edited one on the write path this feeds (#2427).
|
|
@@ -184,7 +198,12 @@ export interface ManualCalendarSyncDeps {
|
|
|
184
198
|
* calendar sends them fixing the wrong thing. */
|
|
185
199
|
export declare function syncCalendarForCollection(slug: string, workspaceRoot: string, deps?: ManualCalendarSyncDeps): Promise<ManualCalendarSyncOutcome>;
|
|
186
200
|
/** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
|
|
187
|
-
* with a single line.
|
|
201
|
+
* with a single line.
|
|
202
|
+
*
|
|
203
|
+
* `run` reads the interval back off the definition instead of closing over the
|
|
204
|
+
* option: a host rewrites `schedule` from its own overrides file AFTER this
|
|
205
|
+
* returns, and the due window has to follow it. Frozen at the default, a
|
|
206
|
+
* shortened interval would tick often and skip nearly every tick. */
|
|
188
207
|
export declare function googleCalendarSyncTaskDef(opts?: {
|
|
189
208
|
workspaceRoot?: string;
|
|
190
209
|
intervalMs?: number;
|
package/dist/google/index.cjs
CHANGED
|
@@ -845,34 +845,128 @@ function calendarSyncStatePath(workspaceRoot) {
|
|
|
845
845
|
return node_path.default.join(workspaceRoot ?? require_discovery.getWorkspaceRoot(), "data", "calendar", ".sync-state.json");
|
|
846
846
|
}
|
|
847
847
|
var calendarKey = canonicalCalendarId;
|
|
848
|
+
/** Read one map out of whatever is on disk. Tolerant on purpose: a file written
|
|
849
|
+
* before `lastSyncedAt` existed simply lacks the key, and a hand-edited one may
|
|
850
|
+
* hold anything. A dropped entry costs a full re-walk or a duplicate run, never
|
|
851
|
+
* data — throwing here would stop the sync entirely. */
|
|
852
|
+
function stringRecord(value) {
|
|
853
|
+
if (typeof value !== "object" || value === null) return {};
|
|
854
|
+
return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string"));
|
|
855
|
+
}
|
|
848
856
|
async function readState$1(workspaceRoot) {
|
|
849
857
|
const stored = await readJsonOrNull(calendarSyncStatePath(workspaceRoot));
|
|
850
|
-
return
|
|
858
|
+
return {
|
|
859
|
+
tokens: stringRecord(stored?.tokens),
|
|
860
|
+
lastSyncedAt: stringRecord(stored?.lastSyncedAt)
|
|
861
|
+
};
|
|
851
862
|
}
|
|
852
863
|
var writeQueue$1 = { tail: Promise.resolve() };
|
|
853
|
-
|
|
864
|
+
/** Decide from the stored state and write the result, both inside the queue, so
|
|
865
|
+
* nothing in this process can read between the two. A `null` state skips the
|
|
866
|
+
* write; `result` is what the caller learns about the decision. */
|
|
867
|
+
async function updateStateWith(decide, workspaceRoot) {
|
|
854
868
|
const run = writeQueue$1.tail.then(async () => {
|
|
855
|
-
const state = await readState$1(workspaceRoot);
|
|
856
|
-
await writeJsonAtomicWithMode(calendarSyncStatePath(workspaceRoot),
|
|
869
|
+
const { state, result } = decide(await readState$1(workspaceRoot));
|
|
870
|
+
if (state) await writeJsonAtomicWithMode(calendarSyncStatePath(workspaceRoot), state, SYNC_STATE_MODE);
|
|
871
|
+
return result;
|
|
857
872
|
});
|
|
858
873
|
writeQueue$1.tail = run.catch(() => void 0);
|
|
859
874
|
return await run;
|
|
860
875
|
}
|
|
876
|
+
async function updateState$1(mutate, workspaceRoot) {
|
|
877
|
+
await updateStateWith((state) => ({
|
|
878
|
+
state: mutate(state),
|
|
879
|
+
result: void 0
|
|
880
|
+
}), workspaceRoot);
|
|
881
|
+
}
|
|
882
|
+
var withoutKey = (record, dropped) => Object.fromEntries(Object.entries(record).filter(([key]) => key !== dropped));
|
|
861
883
|
async function loadCalendarSyncToken(calendarId, workspaceRoot) {
|
|
862
884
|
return (await readState$1(workspaceRoot)).tokens[calendarKey(calendarId)] ?? null;
|
|
863
885
|
}
|
|
864
886
|
async function saveCalendarSyncToken(calendarId, syncToken, workspaceRoot) {
|
|
865
887
|
const stored = calendarKey(calendarId);
|
|
866
|
-
await updateState$1((
|
|
867
|
-
...
|
|
868
|
-
|
|
888
|
+
await updateState$1((state) => ({
|
|
889
|
+
...state,
|
|
890
|
+
tokens: {
|
|
891
|
+
...state.tokens,
|
|
892
|
+
[stored]: syncToken
|
|
893
|
+
}
|
|
869
894
|
}), workspaceRoot);
|
|
870
895
|
}
|
|
871
896
|
/** Drop one calendar's token — used when Google answers 410, so the next run
|
|
872
|
-
* starts a clean full sync.
|
|
897
|
+
* starts a clean full sync. Leaves the sync marker alone: "restart the walk"
|
|
898
|
+
* says nothing about "nobody is running". */
|
|
873
899
|
async function clearCalendarSyncToken(calendarId, workspaceRoot) {
|
|
874
900
|
const dropped = calendarKey(calendarId);
|
|
875
|
-
await updateState$1((
|
|
901
|
+
await updateState$1((state) => ({
|
|
902
|
+
...state,
|
|
903
|
+
tokens: withoutKey(state.tokens, dropped)
|
|
904
|
+
}), workspaceRoot);
|
|
905
|
+
}
|
|
906
|
+
async function loadCalendarLastSyncedAt(calendarId, workspaceRoot) {
|
|
907
|
+
return (await readState$1(workspaceRoot)).lastSyncedAt[calendarKey(calendarId)] ?? null;
|
|
908
|
+
}
|
|
909
|
+
/** Stamp when a sync of this calendar started, but only if `mayClaim` accepts
|
|
910
|
+
* what the marker currently says. Answers whether THIS call is the one that
|
|
911
|
+
* claimed it.
|
|
912
|
+
*
|
|
913
|
+
* The decision and the stamp share one pass through the queue on purpose.
|
|
914
|
+
* Deciding from a snapshot read earlier — as a caller filtering a whole map of
|
|
915
|
+
* calendars up front must — leaves both hosts believing the calendar is free,
|
|
916
|
+
* which is the race this exists to close (Codex review #2680). What remains is
|
|
917
|
+
* the read→write gap BETWEEN processes; closing that needs a real file lock
|
|
918
|
+
* (#2679), and this stays a soft dedup until then.
|
|
919
|
+
*
|
|
920
|
+
* The time is passed in rather than read here so the rule stays testable
|
|
921
|
+
* without freezing the clock. */
|
|
922
|
+
async function claimCalendarSyncIfDue(calendarId, startedAt, mayClaim, workspaceRoot) {
|
|
923
|
+
const stored = calendarKey(calendarId);
|
|
924
|
+
return await updateStateWith((state) => {
|
|
925
|
+
const claimed = mayClaim(state.lastSyncedAt[stored] ?? null);
|
|
926
|
+
return {
|
|
927
|
+
state: claimed ? {
|
|
928
|
+
...state,
|
|
929
|
+
lastSyncedAt: {
|
|
930
|
+
...state.lastSyncedAt,
|
|
931
|
+
[stored]: startedAt
|
|
932
|
+
}
|
|
933
|
+
} : null,
|
|
934
|
+
result: claimed
|
|
935
|
+
};
|
|
936
|
+
}, workspaceRoot);
|
|
937
|
+
}
|
|
938
|
+
/** Drop one calendar's marker, so the next tick — on either host — may sync it
|
|
939
|
+
* immediately instead of waiting the window out. */
|
|
940
|
+
async function clearCalendarLastSyncedAt(calendarId, workspaceRoot) {
|
|
941
|
+
const dropped = calendarKey(calendarId);
|
|
942
|
+
await updateState$1((state) => ({
|
|
943
|
+
...state,
|
|
944
|
+
lastSyncedAt: withoutKey(state.lastSyncedAt, dropped)
|
|
945
|
+
}), workspaceRoot);
|
|
946
|
+
}
|
|
947
|
+
//#endregion
|
|
948
|
+
//#region src/google/calendarSyncDue.ts
|
|
949
|
+
/** Slack between a tick and the marker it writes. Without it a lone host reads
|
|
950
|
+
* its OWN marker as "not due yet" — elapsed lands a hair under the interval
|
|
951
|
+
* every time — and syncs every other hour. */
|
|
952
|
+
var TICK_JITTER_TOLERANCE_MS = 300 * 1e3;
|
|
953
|
+
/** How long a fresh marker suppresses a sync: one interval, less the slack.
|
|
954
|
+
* The slack is halved rather than subtracted flat for a very short configured
|
|
955
|
+
* interval, where a fixed five minutes would swallow the window whole. */
|
|
956
|
+
function calendarSyncDueWindowMs(intervalMs) {
|
|
957
|
+
return intervalMs - Math.min(TICK_JITTER_TOLERANCE_MS, intervalMs / 2);
|
|
958
|
+
}
|
|
959
|
+
/** True iff no recent enough run stands in for this one.
|
|
960
|
+
*
|
|
961
|
+
* A marker that cannot be read as a time is treated as due: a stuck calendar is
|
|
962
|
+
* worse than a duplicate run, which the sync is built to tolerate anyway
|
|
963
|
+
* (writes are upserts by event id). */
|
|
964
|
+
function isCalendarSyncDue(lastSyncedAt, windowMs, now = Date.now()) {
|
|
965
|
+
if (!lastSyncedAt) return true;
|
|
966
|
+
const elapsed = now - Date.parse(lastSyncedAt);
|
|
967
|
+
if (!Number.isFinite(elapsed)) return true;
|
|
968
|
+
if (elapsed < -windowMs) return true;
|
|
969
|
+
return elapsed >= windowMs;
|
|
876
970
|
}
|
|
877
971
|
//#endregion
|
|
878
972
|
//#region src/google/calendarPushState.ts
|
|
@@ -1786,6 +1880,7 @@ async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
|
1786
1880
|
await clearCalendarShadow(calendarId, workspaceRoot);
|
|
1787
1881
|
return await syncCalendarEvents(accessToken, { calendarId });
|
|
1788
1882
|
}
|
|
1883
|
+
var ALWAYS_CLAIM = () => true;
|
|
1789
1884
|
/** Sync ONE calendar and fan its events out to every collection bound to it.
|
|
1790
1885
|
*
|
|
1791
1886
|
* The fan-out is not an optimisation, it is correctness: the sync token is
|
|
@@ -1800,9 +1895,59 @@ async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
|
1800
1895
|
* load the SAME stored token and walk the same window. That is idempotent —
|
|
1801
1896
|
* writes are upserts by event id — but it is a wasted full walk. Queued, the
|
|
1802
1897
|
* second pass resumes from the token the first just stored and fetches only
|
|
1803
|
-
* what is genuinely new.
|
|
1804
|
-
|
|
1805
|
-
|
|
1898
|
+
* what is genuinely new. That queue is module state, so it orders the doors
|
|
1899
|
+
* into THIS process only; `claimThenSync` is what other hosts can see. */
|
|
1900
|
+
async function syncCalendarGroup(calendarId, collections, workspaceRoot, mayClaim = ALWAYS_CLAIM) {
|
|
1901
|
+
return await withCalendarLock(calendarId, () => claimThenSync(calendarId, collections, workspaceRoot, mayClaim));
|
|
1902
|
+
}
|
|
1903
|
+
/** Take the workspace-shared marker BEFORE the calendar is touched, sync only if
|
|
1904
|
+
* this run got it, and drop it again if the run could not happen at all.
|
|
1905
|
+
*
|
|
1906
|
+
* Claiming first is the whole point: hosts tick in the same minute (interval
|
|
1907
|
+
* schedules align to wall-clock boundaries), so a marker written on completion
|
|
1908
|
+
* would leave the entire run — minutes, for a first full walk — open for a
|
|
1909
|
+
* second host to start alongside this one and lose the push baseline it writes.
|
|
1910
|
+
*
|
|
1911
|
+
* Released only when the sync could not run at all. A run that finished with
|
|
1912
|
+
* retryable per-collection errors KEEPS the marker: it already held the token
|
|
1913
|
+
* and the baseline back, so it replays on the next tick either way, and
|
|
1914
|
+
* releasing would hand a permanently-failing calendar back to both hosts at
|
|
1915
|
+
* once — the very overlap this guards (observed during Claude review). */
|
|
1916
|
+
async function claimThenSync(calendarId, collections, workspaceRoot, mayClaim) {
|
|
1917
|
+
if (!await claimCalendarSync(calendarId, workspaceRoot, mayClaim)) {
|
|
1918
|
+
log.info("google", "skipping a calendar this workspace synced recently — another host may be on it", { calendarId });
|
|
1919
|
+
return [];
|
|
1920
|
+
}
|
|
1921
|
+
try {
|
|
1922
|
+
return await syncCalendarGroupNow(calendarId, collections, workspaceRoot);
|
|
1923
|
+
} catch (error) {
|
|
1924
|
+
await releaseCalendarSyncClaim(calendarId, workspaceRoot);
|
|
1925
|
+
throw error;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
/** Never throws, and fails OPEN: the marker guards against duplicate work, it is
|
|
1929
|
+
* not a precondition for syncing correctly. A workspace that cannot store it
|
|
1930
|
+
* syncs the way it did before #2678. */
|
|
1931
|
+
async function claimCalendarSync(calendarId, workspaceRoot, mayClaim) {
|
|
1932
|
+
try {
|
|
1933
|
+
return await claimCalendarSyncIfDue(calendarId, (/* @__PURE__ */ new Date()).toISOString(), mayClaim, workspaceRoot);
|
|
1934
|
+
} catch (error) {
|
|
1935
|
+
log.warn("google", "could not stamp the calendar sync marker — another host may sync this calendar in parallel", {
|
|
1936
|
+
calendarId,
|
|
1937
|
+
error: String(error)
|
|
1938
|
+
});
|
|
1939
|
+
return true;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
async function releaseCalendarSyncClaim(calendarId, workspaceRoot) {
|
|
1943
|
+
try {
|
|
1944
|
+
await clearCalendarLastSyncedAt(calendarId, workspaceRoot);
|
|
1945
|
+
} catch (error) {
|
|
1946
|
+
log.warn("google", "could not release the calendar sync marker after a failed run", {
|
|
1947
|
+
calendarId,
|
|
1948
|
+
error: String(error)
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1806
1951
|
}
|
|
1807
1952
|
async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
|
|
1808
1953
|
const unpushed = await pushAutoCollections(collections, workspaceRoot);
|
|
@@ -1963,6 +2108,7 @@ async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
|
|
|
1963
2108
|
if (orphaned === null) return null;
|
|
1964
2109
|
await clearCalendarSyncToken(orphaned, workspaceRoot);
|
|
1965
2110
|
await clearCalendarShadow(orphaned, workspaceRoot);
|
|
2111
|
+
await clearCalendarLastSyncedAt(orphaned, workspaceRoot);
|
|
1966
2112
|
log.info("google", "cleared the sync token of a calendar no collection reads any more", { calendarId: orphaned });
|
|
1967
2113
|
return orphaned;
|
|
1968
2114
|
} catch (error) {
|
|
@@ -1979,19 +2125,27 @@ async function declaringGroups(workspaceRoot) {
|
|
|
1979
2125
|
* Authoring the collection before linking the account is an expected state,
|
|
1980
2126
|
* not a failure. Checking once here keeps it a quiet skip instead of an
|
|
1981
2127
|
* access-token throw per calendar, every hour, until the user links (#2188).
|
|
1982
|
-
* A user-triggered sync answers differently — it says so out loud.
|
|
2128
|
+
* A user-triggered sync answers differently — it says so out loud.
|
|
2129
|
+
*
|
|
2130
|
+
* Deliberately not phrased as a per-host state: the link lives in
|
|
2131
|
+
* `~/.config/mulmo`, which carries no app name, so every host on the machine
|
|
2132
|
+
* shares one answer. Reading it as "linked on this host only" is what made the
|
|
2133
|
+
* calendar look implicitly exclusive when it never was (#2678). */
|
|
1983
2134
|
async function backgroundSyncAllowed(groups) {
|
|
1984
2135
|
if (groups.size === 0) return false;
|
|
1985
2136
|
if (await isGoogleLinked()) return true;
|
|
1986
|
-
log.info("google", "skipping calendar sync — no Google account linked
|
|
2137
|
+
log.info("google", "skipping calendar sync — no Google account linked", { calendars: groups.size });
|
|
1987
2138
|
return false;
|
|
1988
2139
|
}
|
|
1989
2140
|
/** Run each group, isolating failures per calendar — one unreachable calendar
|
|
1990
|
-
* (or a revoked grant) must not stop the others.
|
|
1991
|
-
|
|
2141
|
+
* (or a revoked grant) must not stop the others. `mayClaim` is evaluated per
|
|
2142
|
+
* calendar as it comes up, never once for the whole map: an earlier calendar's
|
|
2143
|
+
* full walk takes minutes, and a decision made before it started says nothing
|
|
2144
|
+
* about who holds this one now. */
|
|
2145
|
+
async function runCalendarGroups(groups, workspaceRoot, mayClaim = ALWAYS_CLAIM) {
|
|
1992
2146
|
const results = [];
|
|
1993
2147
|
for (const [calendarId, collections] of groups) try {
|
|
1994
|
-
results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot));
|
|
2148
|
+
results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot, mayClaim));
|
|
1995
2149
|
} catch (error) {
|
|
1996
2150
|
log.warn("google", "calendar sync failed", {
|
|
1997
2151
|
calendarId,
|
|
@@ -2007,10 +2161,19 @@ async function runCalendarGroups(groups, workspaceRoot) {
|
|
|
2007
2161
|
}
|
|
2008
2162
|
return results;
|
|
2009
2163
|
}
|
|
2010
|
-
/** Sync every collection
|
|
2011
|
-
|
|
2164
|
+
/** Sync every collection whose calendar is due — no host in this workspace has
|
|
2165
|
+
* started one within `intervalMs` (#2678). Without that gate this walked every
|
|
2166
|
+
* declaring group on every tick, so a second host registering the same task
|
|
2167
|
+
* simply doubled the runs, concurrently.
|
|
2168
|
+
*
|
|
2169
|
+
* Dueness is not decided here, only described: the guard is handed down and
|
|
2170
|
+
* evaluated where the marker is written, so the answer cannot go stale between
|
|
2171
|
+
* deciding and claiming (Codex review #2680). */
|
|
2172
|
+
async function syncDueCalendarCollections(workspaceRoot, intervalMs = DEFAULT_SYNC_INTERVAL_MS) {
|
|
2012
2173
|
const groups = await declaringGroups(workspaceRoot);
|
|
2013
|
-
|
|
2174
|
+
if (!await backgroundSyncAllowed(groups)) return [];
|
|
2175
|
+
const windowMs = calendarSyncDueWindowMs(intervalMs);
|
|
2176
|
+
return await runCalendarGroups(groups, workspaceRoot, (lastSyncedAt) => isCalendarSyncDue(lastSyncedAt, windowMs));
|
|
2014
2177
|
}
|
|
2015
2178
|
/** The groups whose calendar has never synced. A missing token IS the "created
|
|
2016
2179
|
* since the last sync" signal — nothing else distinguishes a new collection
|
|
@@ -2056,10 +2219,21 @@ async function syncCalendarForCollection(slug, workspaceRoot, deps = liveManualS
|
|
|
2056
2219
|
results: await deps.runGroups(new Map(owning), workspaceRoot)
|
|
2057
2220
|
};
|
|
2058
2221
|
}
|
|
2222
|
+
/** The interval this task is scheduled on right now. A non-interval schedule
|
|
2223
|
+
* cannot be reached — the factory below builds an interval one — but the type
|
|
2224
|
+
* allows it, so it falls back rather than asserting. */
|
|
2225
|
+
function scheduledIntervalMs(task) {
|
|
2226
|
+
return task.schedule.type === _receptron_task_scheduler.SCHEDULE_TYPES.interval ? task.schedule.intervalMs : DEFAULT_SYNC_INTERVAL_MS;
|
|
2227
|
+
}
|
|
2059
2228
|
/** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
|
|
2060
|
-
* with a single line.
|
|
2229
|
+
* with a single line.
|
|
2230
|
+
*
|
|
2231
|
+
* `run` reads the interval back off the definition instead of closing over the
|
|
2232
|
+
* option: a host rewrites `schedule` from its own overrides file AFTER this
|
|
2233
|
+
* returns, and the due window has to follow it. Frozen at the default, a
|
|
2234
|
+
* shortened interval would tick often and skip nearly every tick. */
|
|
2061
2235
|
function googleCalendarSyncTaskDef(opts) {
|
|
2062
|
-
|
|
2236
|
+
const def = {
|
|
2063
2237
|
id: GOOGLE_CALENDAR_SYNC_TASK_ID,
|
|
2064
2238
|
name: "Google Calendar sync",
|
|
2065
2239
|
description: "Pulls changed Google Calendar events into any collection declaring `googleCalendar`, without invoking the LLM.",
|
|
@@ -2068,8 +2242,9 @@ function googleCalendarSyncTaskDef(opts) {
|
|
|
2068
2242
|
intervalMs: opts?.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS
|
|
2069
2243
|
},
|
|
2070
2244
|
missedRunPolicy: _receptron_task_scheduler.MISSED_RUN_POLICIES.runOnce,
|
|
2071
|
-
run: () => syncDueCalendarCollections(opts?.workspaceRoot ?? require_discovery.getWorkspaceRoot()).then(() => {})
|
|
2245
|
+
run: () => syncDueCalendarCollections(opts?.workspaceRoot ?? require_discovery.getWorkspaceRoot(), scheduledIntervalMs(def)).then(() => {})
|
|
2072
2246
|
};
|
|
2247
|
+
return def;
|
|
2073
2248
|
}
|
|
2074
2249
|
//#endregion
|
|
2075
2250
|
//#region src/google/tasks.ts
|
|
@@ -2297,11 +2472,14 @@ exports.buildTaskPatch = buildTaskPatch;
|
|
|
2297
2472
|
exports.bySourceField = bySourceField;
|
|
2298
2473
|
exports.calendarApiError = calendarApiError;
|
|
2299
2474
|
exports.calendarPushStatePath = calendarPushStatePath;
|
|
2475
|
+
exports.calendarSyncDueWindowMs = calendarSyncDueWindowMs;
|
|
2300
2476
|
exports.calendarSyncStatePath = calendarSyncStatePath;
|
|
2301
2477
|
exports.canonicalCalendarId = canonicalCalendarId;
|
|
2302
2478
|
exports.canonicalTaskListId = canonicalTaskListId;
|
|
2479
|
+
exports.claimCalendarSyncIfDue = claimCalendarSyncIfDue;
|
|
2303
2480
|
exports.classifyDelete = classifyDelete;
|
|
2304
2481
|
exports.classifyWrite = classifyWrite;
|
|
2482
|
+
exports.clearCalendarLastSyncedAt = clearCalendarLastSyncedAt;
|
|
2305
2483
|
exports.clearCalendarShadow = clearCalendarShadow;
|
|
2306
2484
|
exports.clearCalendarSyncToken = clearCalendarSyncToken;
|
|
2307
2485
|
exports.clientSecretPresence = clientSecretPresence;
|
|
@@ -2331,6 +2509,7 @@ exports.googleConfigDir = googleConfigDir;
|
|
|
2331
2509
|
exports.googleSecretsDir = googleSecretsDir;
|
|
2332
2510
|
exports.googleTokenPath = googleTokenPath;
|
|
2333
2511
|
exports.groupByCalendar = groupByCalendar;
|
|
2512
|
+
exports.isCalendarSyncDue = isCalendarSyncDue;
|
|
2334
2513
|
exports.isClientSettableEventId = isClientSettableEventId;
|
|
2335
2514
|
exports.isDeniedAccessRole = isDeniedAccessRole;
|
|
2336
2515
|
exports.isGoogleApiError = isGoogleApiError;
|
|
@@ -2343,6 +2522,7 @@ exports.listCalendars = listCalendars;
|
|
|
2343
2522
|
exports.listDriveFiles = listDriveFiles;
|
|
2344
2523
|
exports.listTaskLists = listTaskLists;
|
|
2345
2524
|
exports.listTasks = listTasks;
|
|
2525
|
+
exports.loadCalendarLastSyncedAt = loadCalendarLastSyncedAt;
|
|
2346
2526
|
exports.loadCalendarShadow = loadCalendarShadow;
|
|
2347
2527
|
exports.loadCalendarSyncToken = loadCalendarSyncToken;
|
|
2348
2528
|
exports.loadClientSecret = loadClientSecret;
|