@agent-native/core 0.100.0 → 0.100.2
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +14 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +4 -0
- package/corpus/core/src/client/composer/PromptComposer.tsx +4 -0
- package/corpus/core/src/client/composer/TiptapComposer.tsx +30 -3
- package/corpus/core/src/integrations/webhook-handler.ts +4 -2
- package/corpus/core/src/secrets/crypto.ts +98 -47
- package/corpus/core/src/secrets/index.ts +2 -1
- package/corpus/core/src/secrets/schema.ts +3 -0
- package/corpus/core/src/secrets/storage.ts +164 -31
- package/corpus/templates/assets/app/components/layout/Layout.tsx +4 -0
- package/corpus/templates/assets/app/hooks/use-navigation-state.ts +3 -1
- package/corpus/templates/assets/changelog/2026-07-13-assets-keeps-the-right-chat-sidebar-closed-when-the-full-pag.md +6 -0
- package/corpus/templates/calendar/AGENTS.md +9 -0
- package/corpus/templates/calendar/actions/list-events.ts +659 -44
- package/corpus/templates/calendar/changelog/2026-07-13-calendar-inventory-reads-report-source-coverage.md +6 -0
- package/corpus/templates/calendar/server/lib/calendar-connector-catalog.ts +8 -0
- package/corpus/templates/calendar/server/lib/google-calendar.ts +173 -51
- package/corpus/templates/calendar/server/lib/ical-fetcher.ts +13 -2
- package/corpus/templates/calendar/server/plugins/agent-chat.ts +5 -3
- package/corpus/templates/calendar/shared/api.ts +2 -0
- package/corpus/templates/mail/AGENTS.md +12 -0
- package/corpus/templates/mail/actions/list-emails.ts +486 -4
- package/corpus/templates/mail/changelog/2026-07-13-coverage-aware-connected-inbox-inventory.md +6 -0
- package/corpus/templates/mail/server/db/schema.ts +17 -0
- package/corpus/templates/mail/server/lib/google-auth.ts +33 -5
- package/corpus/templates/mail/server/lib/inventory-cursor.ts +406 -0
- package/corpus/templates/mail/server/lib/list-inbox-emails.ts +21 -2
- package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -0
- package/corpus/templates/mail/server/plugins/agent-chat.ts +4 -9
- package/corpus/templates/mail/server/plugins/db.ts +20 -0
- package/dist/client/AssistantChat.d.ts +2 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +2 -2
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/composer/PromptComposer.d.ts +2 -0
- package/dist/client/composer/PromptComposer.d.ts.map +1 -1
- package/dist/client/composer/PromptComposer.js +2 -2
- package/dist/client/composer/PromptComposer.js.map +1 -1
- package/dist/client/composer/TiptapComposer.d.ts +3 -1
- package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/client/composer/TiptapComposer.js +20 -5
- package/dist/client/composer/TiptapComposer.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/integrations/webhook-handler.d.ts.map +1 -1
- package/dist/integrations/webhook-handler.js +3 -2
- package/dist/integrations/webhook-handler.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/secrets/crypto.d.ts +35 -13
- package/dist/secrets/crypto.d.ts.map +1 -1
- package/dist/secrets/crypto.js +78 -41
- package/dist/secrets/crypto.js.map +1 -1
- package/dist/secrets/index.d.ts.map +1 -1
- package/dist/secrets/index.js +2 -1
- package/dist/secrets/index.js.map +1 -1
- package/dist/secrets/schema.d.ts +20 -1
- package/dist/secrets/schema.d.ts.map +1 -1
- package/dist/secrets/schema.js +3 -0
- package/dist/secrets/schema.js.map +1 -1
- package/dist/secrets/storage.d.ts +7 -7
- package/dist/secrets/storage.d.ts.map +1 -1
- package/dist/secrets/storage.js +121 -30
- package/dist/secrets/storage.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- 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 =
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
343
|
-
|
|
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 =
|
|
358
|
-
|
|
359
|
-
|
|
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("
|
|
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
|
-
|
|
408
|
-
|
|
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(
|