@agent-native/core 0.100.0 → 0.100.1

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 (26) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/templates/calendar/AGENTS.md +9 -0
  5. package/corpus/templates/calendar/actions/list-events.ts +659 -44
  6. package/corpus/templates/calendar/changelog/2026-07-13-calendar-inventory-reads-report-source-coverage.md +6 -0
  7. package/corpus/templates/calendar/server/lib/calendar-connector-catalog.ts +8 -0
  8. package/corpus/templates/calendar/server/lib/google-calendar.ts +173 -51
  9. package/corpus/templates/calendar/server/lib/ical-fetcher.ts +13 -2
  10. package/corpus/templates/calendar/server/plugins/agent-chat.ts +5 -3
  11. package/corpus/templates/calendar/shared/api.ts +2 -0
  12. package/corpus/templates/mail/AGENTS.md +12 -0
  13. package/corpus/templates/mail/actions/list-emails.ts +486 -4
  14. package/corpus/templates/mail/changelog/2026-07-13-coverage-aware-connected-inbox-inventory.md +6 -0
  15. package/corpus/templates/mail/server/db/schema.ts +17 -0
  16. package/corpus/templates/mail/server/lib/google-auth.ts +33 -5
  17. package/corpus/templates/mail/server/lib/inventory-cursor.ts +406 -0
  18. package/corpus/templates/mail/server/lib/list-inbox-emails.ts +21 -2
  19. package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -0
  20. package/corpus/templates/mail/server/plugins/agent-chat.ts +4 -9
  21. package/corpus/templates/mail/server/plugins/db.ts +20 -0
  22. package/dist/collab/struct-routes.d.ts +1 -1
  23. package/dist/notifications/routes.d.ts +3 -3
  24. package/dist/resources/handlers.d.ts +2 -2
  25. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  26. package/package.json +1 -1
@@ -1,7 +1,11 @@
1
+ import { createHash } from "node:crypto";
2
+
1
3
  import { defineAction } from "@agent-native/core";
2
4
  import {
3
5
  getRequestTimezone,
4
6
  getRequestUserEmail,
7
+ signShortLivedToken,
8
+ verifyShortLivedToken,
5
9
  } from "@agent-native/core/server";
6
10
  import { getUserSetting } from "@agent-native/core/settings";
7
11
  import { accessFilter } from "@agent-native/core/sharing";
@@ -44,6 +48,7 @@ async function fetchICalEventsCached(
44
48
  cal.color,
45
49
  from,
46
50
  to,
51
+ { throwOnError: true },
47
52
  );
48
53
  icalCache.set(cacheKey, { events, fetchedAt: Date.now() });
49
54
  return events;
@@ -60,14 +65,285 @@ interface ListCalendarEventsArgs {
60
65
  from?: string;
61
66
  to?: string;
62
67
  query?: string;
63
- overlayEmails?: string;
68
+ overlayEmails?: string | string[];
69
+ accountEmails?: string[];
70
+ sources?: CalendarInventorySource[];
71
+ providerPageSize?: number;
72
+ }
73
+
74
+ interface ListCalendarEventsOptions {
75
+ ownedAccounts?: string[];
76
+ range?: CalendarEventRange;
64
77
  }
65
78
 
79
+ type CalendarInventorySource = "google" | "bookings" | "ics" | "overlays";
80
+
66
81
  interface CalendarEventsResult {
67
82
  events: CalendarEvent[];
68
83
  errors: Array<{ email: string; error: string }>;
69
84
  googleConnected: boolean;
70
85
  range: CalendarEventRange;
86
+ icalErrors: Array<{ id: string; name: string; error: string }>;
87
+ icalSources: Array<{
88
+ id: string;
89
+ name: string;
90
+ status: "ok" | "error";
91
+ error?: string;
92
+ }>;
93
+ overlaySources: Array<{
94
+ email: string;
95
+ status: "ok" | "error";
96
+ error?: string;
97
+ }>;
98
+ requestedAccounts: string[] | null;
99
+ resolvedAccounts: string[];
100
+ queriedAccounts: string[];
101
+ sources: CalendarInventorySource[];
102
+ }
103
+
104
+ export interface CalendarInventoryItem {
105
+ key: string;
106
+ id: string;
107
+ title: string;
108
+ start: string;
109
+ end: string;
110
+ allDay: boolean;
111
+ eventType?: CalendarEvent["eventType"];
112
+ status?: CalendarEvent["status"];
113
+ transparency?: CalendarEvent["transparency"];
114
+ source: "google" | "booking" | "ics" | "overlay";
115
+ sourceId?: string;
116
+ accountEmail?: string;
117
+ overlayEmail?: string;
118
+ organizer?: { email?: string; displayName?: string; self?: boolean };
119
+ selfResponseStatus?: CalendarEvent["responseStatus"];
120
+ attendeeCount: number;
121
+ attendeeStatusCounts: Record<string, number>;
122
+ attendees: Array<{
123
+ email?: string;
124
+ displayName?: string;
125
+ responseStatus?: string;
126
+ optional?: boolean;
127
+ self?: boolean;
128
+ organizer?: boolean;
129
+ }>;
130
+ attendeesComplete: boolean;
131
+ }
132
+
133
+ interface InventoryCursor {
134
+ owner: string;
135
+ query: string;
136
+ start: string;
137
+ key: string;
138
+ }
139
+
140
+ const INVENTORY_VERSION = 1;
141
+ const INVENTORY_CURSOR_PREFIX = "calendar-inventory:";
142
+ const INVENTORY_PAGE_SIZE = 100;
143
+ const INVENTORY_MAX_PAGE_SIZE = 250;
144
+ const INVENTORY_ITEM_BUDGET_BYTES = 12_000;
145
+ const INVENTORY_STRING_LIMIT = 240;
146
+ const INVENTORY_ATTENDEE_PREVIEW_LIMIT = 8;
147
+
148
+ function cap(
149
+ value: string | undefined,
150
+ limit = INVENTORY_STRING_LIMIT,
151
+ ): string | undefined {
152
+ if (!value) return undefined;
153
+ return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
154
+ }
155
+
156
+ function sanitizeError(message: unknown, fallback: string) {
157
+ return (
158
+ cap(
159
+ String(message ?? fallback)
160
+ .replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
161
+ .replace(
162
+ /\b(access_token|refresh_token|id_token|token)=([^\s&]+)/gi,
163
+ "$1=[redacted]",
164
+ ),
165
+ ) ?? fallback
166
+ );
167
+ }
168
+
169
+ function sourceCoverageError(message: string, fallback: string) {
170
+ const bounded = sanitizeError(message, fallback);
171
+ const notConnected = /not connected|reconnect|authentication/i.test(bounded);
172
+ return {
173
+ code: notConnected ? "NOT_CONNECTED" : "SOURCE_READ_FAILED",
174
+ message: bounded,
175
+ retryable: !notConnected,
176
+ };
177
+ }
178
+
179
+ function normalizedEmails(values: string[] | undefined): string[] {
180
+ return Array.from(
181
+ new Set(
182
+ (values ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean),
183
+ ),
184
+ ).sort();
185
+ }
186
+
187
+ function mergeAccountErrors(
188
+ ...groups: Array<Array<{ email: string; error: string }>>
189
+ ): Array<{ email: string; error: string }> {
190
+ return Array.from(
191
+ new Map(
192
+ groups
193
+ .flat()
194
+ .map((entry) => [
195
+ `${entry.email.trim().toLowerCase()}\0${entry.error}`,
196
+ entry,
197
+ ]),
198
+ ).values(),
199
+ );
200
+ }
201
+
202
+ function normalizedOverlayEmails(value: string | string[] | undefined) {
203
+ return normalizedEmails(
204
+ Array.isArray(value)
205
+ ? value
206
+ : value?.split(",").map((email) => email.trim()),
207
+ );
208
+ }
209
+
210
+ function resolveInventorySources(
211
+ sources: CalendarInventorySource[] | undefined,
212
+ ): CalendarInventorySource[] {
213
+ return Array.from(
214
+ new Set<CalendarInventorySource>(
215
+ sources ?? ["google", "bookings", "ics", "overlays"],
216
+ ),
217
+ );
218
+ }
219
+
220
+ function inventoryQueryKey(args: {
221
+ from: string;
222
+ to: string;
223
+ query?: string;
224
+ accountEmails: string[];
225
+ overlayEmails?: string | string[];
226
+ sources: CalendarInventorySource[];
227
+ }): string {
228
+ const canonical = JSON.stringify({
229
+ v: INVENTORY_VERSION,
230
+ from: args.from,
231
+ to: args.to,
232
+ query: args.query?.trim().toLowerCase() || "",
233
+ accountEmails: normalizedEmails(args.accountEmails),
234
+ overlayEmails: normalizedOverlayEmails(args.overlayEmails),
235
+ sources: [...args.sources].sort(),
236
+ });
237
+ return createHash("sha256").update(canonical).digest("base64url");
238
+ }
239
+
240
+ function encodeInventoryCursor(cursor: InventoryCursor): string {
241
+ const resourceId = `${INVENTORY_CURSOR_PREFIX}${Buffer.from(JSON.stringify(cursor)).toString("base64url")}`;
242
+ return signShortLivedToken({ resourceId, ttlSeconds: 600 });
243
+ }
244
+
245
+ function decodeInventoryCursor(
246
+ token: string,
247
+ owner: string,
248
+ query: string,
249
+ ): InventoryCursor {
250
+ const [payload] = token.split(".", 1);
251
+ if (!payload) throw new Error("Invalid inventory cursor");
252
+ let resourceId: unknown;
253
+ try {
254
+ resourceId = JSON.parse(
255
+ Buffer.from(payload, "base64url").toString("utf8"),
256
+ ).resourceId;
257
+ } catch {
258
+ throw new Error("Invalid inventory cursor");
259
+ }
260
+ if (
261
+ typeof resourceId !== "string" ||
262
+ !resourceId.startsWith(INVENTORY_CURSOR_PREFIX)
263
+ )
264
+ throw new Error("Invalid inventory cursor");
265
+ if (!verifyShortLivedToken(token, resourceId).ok)
266
+ throw new Error("Expired or invalid inventory cursor");
267
+ let cursor: InventoryCursor;
268
+ try {
269
+ cursor = JSON.parse(
270
+ Buffer.from(
271
+ resourceId.slice(INVENTORY_CURSOR_PREFIX.length),
272
+ "base64url",
273
+ ).toString("utf8"),
274
+ );
275
+ } catch {
276
+ throw new Error("Invalid inventory cursor");
277
+ }
278
+ if (
279
+ cursor.owner !== owner ||
280
+ cursor.query !== query ||
281
+ !cursor.start ||
282
+ !cursor.key
283
+ )
284
+ throw new Error("Inventory cursor does not match this query");
285
+ return cursor;
286
+ }
287
+
288
+ function compactInventoryEvent(event: CalendarEvent): CalendarInventoryItem {
289
+ const attendees = event.attendees ?? [];
290
+ const attendeeStatusCounts = attendees.reduce<Record<string, number>>(
291
+ (counts, attendee) => {
292
+ const status = attendee.responseStatus ?? "unknown";
293
+ counts[status] = (counts[status] ?? 0) + 1;
294
+ return counts;
295
+ },
296
+ {},
297
+ );
298
+ const key = [
299
+ event.source,
300
+ event.accountEmail ?? event.overlayEmail ?? "local",
301
+ event.googleEventId ?? event.id,
302
+ event.start,
303
+ ].join(":");
304
+ const source = event.overlayEmail
305
+ ? "overlay"
306
+ : event.source === "local"
307
+ ? "booking"
308
+ : event.source === "ical"
309
+ ? "ics"
310
+ : event.source;
311
+ return {
312
+ key,
313
+ id: event.googleEventId ?? event.id,
314
+ title: cap(event.title) ?? "Untitled",
315
+ start: event.start,
316
+ end: event.end,
317
+ allDay: event.allDay,
318
+ eventType: event.eventType,
319
+ status: event.status,
320
+ transparency: event.transparency,
321
+ source,
322
+ sourceId: event.sourceId,
323
+ accountEmail: event.accountEmail,
324
+ overlayEmail: event.overlayEmail,
325
+ organizer: event.organizer
326
+ ? {
327
+ email: cap(event.organizer.email, 320) ?? "",
328
+ displayName: cap(event.organizer.displayName),
329
+ self: event.organizer.self,
330
+ }
331
+ : undefined,
332
+ selfResponseStatus: event.responseStatus,
333
+ attendeeCount: attendees.length,
334
+ attendeeStatusCounts,
335
+ attendees: attendees
336
+ .slice(0, INVENTORY_ATTENDEE_PREVIEW_LIMIT)
337
+ .map((attendee) => ({
338
+ email: cap(attendee.email, 320) ?? "",
339
+ displayName: cap(attendee.displayName),
340
+ responseStatus: attendee.responseStatus,
341
+ optional: attendee.optional,
342
+ self: attendee.self,
343
+ organizer: attendee.organizer,
344
+ })),
345
+ attendeesComplete: attendees.length <= INVENTORY_ATTENDEE_PREVIEW_LIMIT,
346
+ };
71
347
  }
72
348
 
73
349
  function normalizeTimezone(timezone?: string): string {
@@ -298,66 +574,167 @@ function shouldShowLocalBookingEvent({
298
574
 
299
575
  export async function listCalendarEvents(
300
576
  args: ListCalendarEventsArgs = {},
577
+ options: ListCalendarEventsOptions = {},
301
578
  ): Promise<CalendarEventsResult> {
302
579
  const email = getRequestUserEmail();
303
580
  if (!email) throw new Error("no authenticated user");
304
- const range = resolveCalendarEventRange({
305
- from: args.from,
306
- to: args.to,
307
- });
581
+ const range =
582
+ options.range ??
583
+ resolveCalendarEventRange({
584
+ from: args.from,
585
+ to: args.to,
586
+ });
587
+
588
+ const sources = resolveInventorySources(args.sources);
589
+ const includeGoogle = sources.includes("google");
590
+ const includeOverlays = sources.includes("overlays");
308
591
 
309
- // Fetch Google Calendar events
592
+ // Resolve owned accounts before any token refresh or provider call.
310
593
  let googleEvents: CalendarEvent[] = [];
311
594
  let errors: Array<{ email: string; error: string }> = [];
312
- const connected = await googleCalendar.isConnected(email);
313
- if (connected) {
314
- const result = await googleCalendar.listEvents(range.from, range.to, email);
315
- googleEvents = result.events;
316
- errors = result.errors;
317
-
318
- if (args.overlayEmails) {
319
- const overlayEmails = args.overlayEmails
320
- .split(",")
321
- .filter(Boolean)
322
- .slice(0, 10);
323
- if (overlayEmails.length > 0) {
324
- const { events: overlayEvents } =
325
- await googleCalendar.listOverlayEvents(
326
- range.from,
327
- range.to,
328
- overlayEmails,
329
- email,
330
- );
331
- googleEvents = [...googleEvents, ...overlayEvents];
332
- }
595
+ let overlaySources: Array<{
596
+ email: string;
597
+ status: "ok" | "error";
598
+ error?: string;
599
+ }> = [];
600
+ const normalizedRequestedAccounts = normalizedEmails(args.accountEmails);
601
+ const requestedAccounts = args.accountEmails
602
+ ? normalizedRequestedAccounts
603
+ : null;
604
+ let resolvedAccounts: string[] = [];
605
+ // Resolve/validate ownership before `isConnected` or token refreshes. A
606
+ // rejected filter is therefore atomic even when every token is expired.
607
+ const [ownedAccounts, connected] = await Promise.all([
608
+ options.ownedAccounts ?? googleCalendar.getOwnedAccountEmails(email),
609
+ googleCalendar.isConnected(email),
610
+ ]);
611
+ if (normalizedRequestedAccounts.length > 0) {
612
+ const unowned = normalizedRequestedAccounts.filter(
613
+ (account) =>
614
+ !ownedAccounts.some((owned) => owned.trim().toLowerCase() === account),
615
+ );
616
+ if (unowned.length > 0) {
617
+ throw new Error(
618
+ `Google Calendar account not connected for this user: ${unowned.join(", ")}`,
619
+ );
333
620
  }
621
+ resolvedAccounts = ownedAccounts.filter((account) =>
622
+ normalizedRequestedAccounts.includes(account.trim().toLowerCase()),
623
+ );
624
+ } else {
625
+ resolvedAccounts = ownedAccounts;
626
+ }
627
+ const requestedOverlayEmails = normalizedOverlayEmails(
628
+ args.overlayEmails,
629
+ ).slice(0, 10);
630
+ if (includeOverlays && requestedOverlayEmails.length > 0 && !connected) {
631
+ overlaySources = requestedOverlayEmails.map((overlayEmail) => ({
632
+ email: overlayEmail,
633
+ status: "error",
634
+ error: "Google Calendar is not connected",
635
+ }));
334
636
  }
637
+ // Once account ownership is validated, independent providers and local SQL
638
+ // can run together. The slowest source should set latency, not their sum.
639
+ const googleRead =
640
+ connected && includeGoogle
641
+ ? googleCalendar.listEvents(range.from, range.to, email, {
642
+ accountEmails: args.accountEmails,
643
+ maxResults: args.providerPageSize,
644
+ })
645
+ : Promise.resolve({ events: [], errors: [] });
646
+ const overlayRead =
647
+ connected && includeOverlays && requestedOverlayEmails.length > 0
648
+ ? googleCalendar.listOverlayEvents(
649
+ range.from,
650
+ range.to,
651
+ requestedOverlayEmails,
652
+ email,
653
+ { accountEmails: args.accountEmails },
654
+ )
655
+ : Promise.resolve({ events: [], errors: [], accountErrors: [] });
656
+ const icalRead = sources.includes("ics")
657
+ ? Promise.resolve(getUserSetting(email, "external-calendars")).then(
658
+ async (setting) => {
659
+ const calendars =
660
+ (setting as unknown as ExternalCalendar[] | null) ?? [];
661
+ return {
662
+ calendars,
663
+ results: await Promise.allSettled(
664
+ calendars.map((calendar) =>
665
+ fetchICalEventsCached(calendar, range.from, range.to),
666
+ ),
667
+ ),
668
+ };
669
+ },
670
+ )
671
+ : Promise.resolve({ calendars: [], results: [] });
672
+ const bookingRead = sources.includes("bookings")
673
+ ? listLocalBookingEvents(range.from, range.to)
674
+ : Promise.resolve([]);
335
675
 
336
- // Fetch external ICS calendar feeds concurrently
337
- const externalCalendars =
338
- ((await getUserSetting(email, "external-calendars")) as unknown as
339
- | ExternalCalendar[]
340
- | null) ?? [];
676
+ const [googleResult, overlayResult, icalResult, rawBookingEvents] =
677
+ await Promise.all([googleRead, overlayRead, icalRead, bookingRead]);
678
+ googleEvents = [...googleResult.events, ...overlayResult.events];
679
+ errors = mergeAccountErrors(googleResult.errors, overlayResult.accountErrors);
680
+ if (connected && includeOverlays && requestedOverlayEmails.length > 0) {
681
+ overlaySources = requestedOverlayEmails.map((overlayEmail) => {
682
+ const error = overlayResult.errors.find(
683
+ (entry) => entry.email.toLowerCase() === overlayEmail.toLowerCase(),
684
+ );
685
+ return error
686
+ ? {
687
+ email: overlayEmail,
688
+ status: "error" as const,
689
+ error: error.error,
690
+ }
691
+ : { email: overlayEmail, status: "ok" as const };
692
+ });
693
+ }
341
694
 
342
- const icalResults = await Promise.allSettled(
343
- externalCalendars.map((cal) =>
344
- fetchICalEventsCached(cal, range.from, range.to),
345
- ),
346
- );
695
+ const externalCalendars = icalResult.calendars;
696
+ const icalResults = icalResult.results;
347
697
 
348
698
  const icalEvents: CalendarEvent[] = icalResults.flatMap((r) =>
349
699
  r.status === "fulfilled" ? r.value : [],
350
700
  );
701
+ const icalErrors = icalResults.flatMap((result, index) =>
702
+ result.status === "rejected"
703
+ ? [
704
+ {
705
+ id: externalCalendars[index]!.id,
706
+ name: externalCalendars[index]!.name,
707
+ error:
708
+ result.reason instanceof Error
709
+ ? result.reason.message
710
+ : "Unable to load ICS feed",
711
+ },
712
+ ]
713
+ : [],
714
+ );
715
+ const icalSources = externalCalendars.map((calendar, index) => {
716
+ const result = icalResults[index]!;
717
+ return result.status === "fulfilled"
718
+ ? { id: calendar.id, name: calendar.name, status: "ok" as const }
719
+ : {
720
+ id: calendar.id,
721
+ name: calendar.name,
722
+ status: "error" as const,
723
+ error:
724
+ result.reason instanceof Error
725
+ ? result.reason.message
726
+ : "Unable to load ICS feed",
727
+ };
728
+ });
351
729
 
352
730
  const googleEventIds = new Set(
353
731
  googleEvents
354
732
  .map((event) => event.googleEventId)
355
733
  .filter((id): id is string => Boolean(id)),
356
734
  );
357
- const googleReadAuthoritative = connected && errors.length === 0;
358
- const bookingEvents = (
359
- await listLocalBookingEvents(range.from, range.to)
360
- ).filter((event) =>
735
+ const googleReadAuthoritative =
736
+ includeGoogle && connected && errors.length === 0;
737
+ const bookingEvents = rawBookingEvents.filter((event) =>
361
738
  shouldShowLocalBookingEvent({
362
739
  event,
363
740
  googleEventIds,
@@ -385,6 +762,16 @@ export async function listCalendarEvents(
385
762
  errors,
386
763
  googleConnected: connected,
387
764
  range,
765
+ icalErrors,
766
+ icalSources,
767
+ overlaySources,
768
+ requestedAccounts,
769
+ resolvedAccounts,
770
+ queriedAccounts:
771
+ includeGoogle || (includeOverlays && requestedOverlayEmails.length > 0)
772
+ ? resolvedAccounts
773
+ : [],
774
+ sources,
388
775
  };
389
776
  }
390
777
 
@@ -396,16 +783,244 @@ export default defineAction({
396
783
  to: z.string().optional().describe("End date (ISO string)"),
397
784
  query: z
398
785
  .string()
786
+ .max(500)
399
787
  .optional()
400
788
  .describe("Case-insensitive title/attendee/organizer search term"),
401
789
  overlayEmails: z
790
+ .union([z.string().max(3_200), z.array(z.string().email()).max(10)])
791
+ .optional()
792
+ .describe(
793
+ "Overlay calendar emails (an array, or legacy comma-separated string)",
794
+ ),
795
+ accountEmails: z
796
+ .array(z.string().email())
797
+ .min(1)
798
+ .max(20)
799
+ .optional()
800
+ .describe(
801
+ "Connected Google accounts to read; omitted reads every connected account",
802
+ ),
803
+ sources: z
804
+ .array(z.enum(["google", "bookings", "ics", "overlays"]))
805
+ .max(4)
806
+ .optional()
807
+ .describe("Calendar sources to query; omitted reads every source"),
808
+ format: z
809
+ .enum(["legacy", "inventory"])
810
+ .optional()
811
+ .describe("Use inventory for compact, coverage-aware external reads"),
812
+ cursor: z
402
813
  .string()
814
+ .max(4096)
403
815
  .optional()
404
- .describe("Comma-separated emails for overlay calendar view"),
816
+ .describe("Opaque cursor from an inventory response"),
817
+ pageSize: z.coerce
818
+ .number()
819
+ .int()
820
+ .min(1)
821
+ .max(INVENTORY_MAX_PAGE_SIZE)
822
+ .optional(),
405
823
  }),
406
824
  http: { method: "GET" },
407
- run: async (args) => {
408
- const result = await listCalendarEvents(args);
825
+ readOnly: true,
826
+ publicAgent: { expose: true, readOnly: true, requiresAuth: true },
827
+ run: async (args, ctx) => {
828
+ const inventory =
829
+ args.format === "inventory" || (ctx?.caller === "mcp" && !args.format);
830
+ const owner = inventory ? getRequestUserEmail() : undefined;
831
+ if (inventory && !owner) throw new Error("no authenticated user");
832
+
833
+ // Reject invalid, expired, owner-bound, and query-bound cursors before any
834
+ // provider call. Omitted account filters require the cheap owned-account
835
+ // lookup to reproduce the exact query key, but token refreshes and calendar
836
+ // reads remain behind this gate.
837
+ let preparedCursor: InventoryCursor | undefined;
838
+ let preparedQuery: string | undefined;
839
+ let preparedRange: CalendarEventRange | undefined;
840
+ let preparedOwnedAccounts: string[] | undefined;
841
+ if (inventory && args.cursor) {
842
+ preparedRange = resolveCalendarEventRange({
843
+ from: args.from,
844
+ to: args.to,
845
+ });
846
+ preparedOwnedAccounts = args.accountEmails
847
+ ? undefined
848
+ : await googleCalendar.getOwnedAccountEmails(owner!);
849
+ preparedQuery = inventoryQueryKey({
850
+ from: preparedRange.from,
851
+ to: preparedRange.to,
852
+ query: args.query,
853
+ accountEmails: args.accountEmails ?? preparedOwnedAccounts ?? [],
854
+ overlayEmails: args.overlayEmails,
855
+ sources: resolveInventorySources(args.sources),
856
+ });
857
+ preparedCursor = decodeInventoryCursor(
858
+ args.cursor,
859
+ owner!,
860
+ preparedQuery,
861
+ );
862
+ }
863
+
864
+ const result = await listCalendarEvents(
865
+ {
866
+ ...args,
867
+ },
868
+ {
869
+ ownedAccounts: preparedOwnedAccounts,
870
+ range: preparedRange,
871
+ },
872
+ );
873
+
874
+ if (inventory) {
875
+ const query =
876
+ preparedQuery ??
877
+ inventoryQueryKey({
878
+ from: result.range.from,
879
+ to: result.range.to,
880
+ query: args.query,
881
+ accountEmails: result.requestedAccounts ?? result.resolvedAccounts,
882
+ overlayEmails: args.overlayEmails,
883
+ sources: result.sources,
884
+ });
885
+ const compact = result.events.map(compactInventoryEvent);
886
+ // Provider ids are only unique within an account. Prefer the owned Google
887
+ // occurrence to a duplicate local booking; otherwise keep first after a
888
+ // stable source/key sort.
889
+ const unique = Array.from(
890
+ new Map(
891
+ compact
892
+ .sort(
893
+ (a, b) =>
894
+ a.start.localeCompare(b.start) || a.key.localeCompare(b.key),
895
+ )
896
+ .map((item) => [item.key, item]),
897
+ ).values(),
898
+ );
899
+ const cursor = preparedCursor;
900
+ const afterCursor = cursor
901
+ ? unique.filter(
902
+ (item) =>
903
+ item.start > cursor.start ||
904
+ (item.start === cursor.start && item.key > cursor.key),
905
+ )
906
+ : unique;
907
+ const pageSize = args.pageSize ?? INVENTORY_PAGE_SIZE;
908
+ const items: CalendarInventoryItem[] = [];
909
+ for (const item of afterCursor) {
910
+ if (items.length >= pageSize) break;
911
+ const nextSize = Buffer.byteLength(
912
+ JSON.stringify([...items, item]),
913
+ "utf8",
914
+ );
915
+ if (items.length > 0 && nextSize > INVENTORY_ITEM_BUDGET_BYTES) break;
916
+ items.push(item);
917
+ }
918
+ const last = items[items.length - 1];
919
+ const hasMore = afterCursor.length > items.length;
920
+ const nextCursor =
921
+ hasMore && last
922
+ ? encodeInventoryCursor({
923
+ owner: owner!,
924
+ query,
925
+ start: last.start,
926
+ key: last.key,
927
+ })
928
+ : undefined;
929
+ const accounts = result.queriedAccounts.map((accountEmail) => {
930
+ const error = result.errors.find(
931
+ (entry) =>
932
+ entry.email.trim().toLowerCase() ===
933
+ accountEmail.trim().toLowerCase(),
934
+ );
935
+ return {
936
+ accountEmail,
937
+ status: error ? ("error" as const) : ("ok" as const),
938
+ count: compact.filter(
939
+ (item) => item.accountEmail?.trim().toLowerCase() === accountEmail,
940
+ ).length,
941
+ exhausted: !error,
942
+ ...(error
943
+ ? {
944
+ error: {
945
+ code: "PROVIDER_READ_FAILED",
946
+ message: sanitizeError(error.error, "Calendar read failed"),
947
+ retryable: true,
948
+ },
949
+ }
950
+ : {}),
951
+ };
952
+ });
953
+ const sourceCoverage = [
954
+ ...(result.sources.includes("google") && !result.googleConnected
955
+ ? [
956
+ {
957
+ source: "google" as const,
958
+ id: "owned-accounts",
959
+ status: "error" as const,
960
+ error: {
961
+ code: "NOT_CONNECTED",
962
+ message: "Google Calendar is not connected",
963
+ retryable: false,
964
+ },
965
+ },
966
+ ]
967
+ : []),
968
+ ...result.icalSources.map((feed) => ({
969
+ source: "ics" as const,
970
+ id: feed.id,
971
+ status: feed.status,
972
+ ...(feed.error
973
+ ? {
974
+ error: {
975
+ ...sourceCoverageError(feed.error, "ICS read failed"),
976
+ },
977
+ }
978
+ : {}),
979
+ })),
980
+ ...result.overlaySources.map((overlay) => ({
981
+ source: "overlay" as const,
982
+ id: overlay.email,
983
+ status: overlay.status,
984
+ ...(overlay.error
985
+ ? {
986
+ error: {
987
+ ...sourceCoverageError(overlay.error, "Overlay read failed"),
988
+ },
989
+ }
990
+ : {}),
991
+ })),
992
+ ...(result.sources.includes("bookings")
993
+ ? [
994
+ {
995
+ source: "booking" as const,
996
+ id: "bookings",
997
+ status: "ok" as const,
998
+ },
999
+ ]
1000
+ : []),
1001
+ ];
1002
+ const coverageComplete =
1003
+ accounts.every((account) => account.status === "ok") &&
1004
+ sourceCoverage.every((entry) => entry.status === "ok");
1005
+ return {
1006
+ version: INVENTORY_VERSION,
1007
+ query: {
1008
+ ...result.range,
1009
+ ...(args.query ? { text: args.query } : {}),
1010
+ sources: result.sources,
1011
+ overlayEmails: normalizedOverlayEmails(args.overlayEmails),
1012
+ },
1013
+ requestedAccounts: result.requestedAccounts,
1014
+ resolvedAccounts: result.resolvedAccounts,
1015
+ queriedAccounts: result.queriedAccounts,
1016
+ accounts,
1017
+ sourceCoverage,
1018
+ coverageComplete,
1019
+ complete: !hasMore && coverageComplete,
1020
+ items,
1021
+ page: { returned: items.length, nextCursor, hasMore },
1022
+ };
1023
+ }
409
1024
 
410
1025
  if (result.events.length === 0 && result.errors.length > 0) {
411
1026
  throw new Error(