@agent-native/scheduling 0.2.52 → 0.2.53-nightly-20261105110131

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 (30) hide show
  1. package/dist/server/availability-engine.d.ts +24 -0
  2. package/dist/server/availability-engine.d.ts.map +1 -1
  3. package/dist/server/availability-engine.js +73 -5
  4. package/dist/server/availability-engine.js.map +1 -1
  5. package/dist/server/providers/errors.d.ts +22 -0
  6. package/dist/server/providers/errors.d.ts.map +1 -0
  7. package/dist/server/providers/errors.js +53 -0
  8. package/dist/server/providers/errors.js.map +1 -0
  9. package/dist/server/providers/google-calendar.d.ts +0 -11
  10. package/dist/server/providers/google-calendar.d.ts.map +1 -1
  11. package/dist/server/providers/google-calendar.js +57 -3
  12. package/dist/server/providers/google-calendar.js.map +1 -1
  13. package/dist/server/providers/index.d.ts +1 -0
  14. package/dist/server/providers/index.d.ts.map +1 -1
  15. package/dist/server/providers/index.js +1 -0
  16. package/dist/server/providers/index.js.map +1 -1
  17. package/dist/server/providers/office365.d.ts.map +1 -1
  18. package/dist/server/providers/office365.js +87 -25
  19. package/dist/server/providers/office365.js.map +1 -1
  20. package/docs/llms-full.txt +29 -8
  21. package/docs/providers.md +10 -0
  22. package/docs/skills/integrations/SKILL.md +19 -8
  23. package/package.json +4 -4
  24. package/src/server/availability-engine.test.ts +459 -23
  25. package/src/server/availability-engine.ts +110 -10
  26. package/src/server/providers/calendar-busy.test.ts +417 -0
  27. package/src/server/providers/errors.ts +66 -0
  28. package/src/server/providers/google-calendar.ts +71 -4
  29. package/src/server/providers/index.ts +1 -0
  30. package/src/server/providers/office365.ts +111 -33
@@ -1,9 +1,19 @@
1
+ import { isActionContractError } from "@agent-native/core/action";
1
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
3
 
3
4
  import * as schema from "../schema/index.js";
4
- import type { BusyInterval } from "../shared/index.js";
5
- import { aggregateBusy } from "./availability-engine.js";
5
+ import type { BusyInterval, EventType, Schedule } from "../shared/index.js";
6
+ import {
7
+ aggregateBusy,
8
+ assertSlotAvailable,
9
+ CalendarAvailabilityUnavailableError,
10
+ getAvailableSlots,
11
+ } from "./availability-engine.js";
6
12
  import { setSchedulingContext } from "./context.js";
13
+ import {
14
+ CalendarCredentialInvalidError,
15
+ CalendarLookupRateLimitedError,
16
+ } from "./providers/errors.js";
7
17
  import { registerCalendarProvider } from "./providers/registry.js";
8
18
  import type { CalendarProvider } from "./providers/types.js";
9
19
 
@@ -11,9 +21,29 @@ const RANGE_START = new Date("2026-07-10T09:00:00.000Z");
11
21
  const RANGE_END = new Date("2026-07-10T17:00:00.000Z");
12
22
  const TEST_USER_EMAIL = "availability-test@example.com";
13
23
 
24
+ const WORKDAY_SCHEDULE: Schedule = {
25
+ id: "schedule-workday",
26
+ name: "Working hours",
27
+ timezone: "UTC",
28
+ ownerEmail: TEST_USER_EMAIL,
29
+ weeklyAvailability: [1, 2, 3, 4, 5].map((day) => ({
30
+ day,
31
+ intervals: [{ startTime: "09:00", endTime: "17:00" }],
32
+ })),
33
+ dateOverrides: [],
34
+ isDefault: true,
35
+ createdAt: "2026-01-01T00:00:00.000Z",
36
+ updatedAt: "2026-01-01T00:00:00.000Z",
37
+ };
38
+
39
+ vi.mock("./schedules-repo.js", () => ({
40
+ getScheduleById: vi.fn(async () => WORKDAY_SCHEDULE),
41
+ }));
42
+
14
43
  interface CredentialRow {
15
44
  id: string;
16
45
  type: string;
46
+ invalid?: boolean;
17
47
  }
18
48
 
19
49
  interface SelectedCalendarRow {
@@ -260,7 +290,7 @@ describe("aggregateBusy", () => {
260
290
  );
261
291
  });
262
292
 
263
- it("keeps successful provider intervals when another provider fails", async () => {
293
+ it("fails naming the unreadable calendar even when another provider succeeds", async () => {
264
294
  const credentials = [
265
295
  { id: "credential-failure", type: "availability-failure" },
266
296
  { id: "credential-success", type: "availability-success" },
@@ -282,33 +312,39 @@ describe("aggregateBusy", () => {
282
312
  }),
283
313
  ),
284
314
  );
315
+ const successBusy = vi.fn(async () => [interval("provider-success", 13)]);
285
316
  registerCalendarProvider(
286
- createCalendarProvider(
287
- "availability-success",
288
- vi.fn(async () => [interval("provider-success", 13)]),
289
- ),
317
+ createCalendarProvider("availability-success", successBusy),
290
318
  );
291
319
 
292
- await expect(readBusy()).resolves.toEqual([
293
- interval("provider-success", 13),
294
- ]);
295
- });
320
+ const error = await readBusy().then(
321
+ () => null,
322
+ (thrown: unknown) => thrown,
323
+ );
296
324
 
297
- it("skips credentials without selected calendars or a registered provider", async () => {
298
- const credentials = [
299
- { id: "credential-empty", type: "availability-empty" },
300
- { id: "credential-unknown", type: "availability-unknown" },
301
- ];
302
- const selected = [
325
+ expect(error).toBeInstanceOf(CalendarAvailabilityUnavailableError);
326
+ const unavailable = error as CalendarAvailabilityUnavailableError;
327
+ expect(unavailable.message).toContain(
328
+ "Couldn't check Test provider availability-failure",
329
+ );
330
+ expect(unavailable.failures).toEqual([
303
331
  {
304
- credentialId: "credential-unknown",
305
- externalId: "calendar-unknown",
332
+ credentialId: "credential-failure",
333
+ provider: "availability-failure",
334
+ providerLabel: "Test provider availability-failure",
335
+ calendarExternalIds: ["calendar-failure"],
336
+ reason: "lookup_failed",
337
+ cause: "Expected provider failure",
306
338
  },
307
- ];
339
+ ]);
340
+ expect(successBusy).toHaveBeenCalledTimes(1);
341
+ });
342
+
343
+ it("skips credentials without selected calendars", async () => {
308
344
  createFakeDb({
309
- credentials,
310
- selected,
311
- legacySelectedBatches: [[], selected],
345
+ credentials: [{ id: "credential-empty", type: "availability-empty" }],
346
+ selected: [],
347
+ legacySelectedBatches: [[]],
312
348
  });
313
349
  const emptyProviderBusy = vi.fn(async () => []);
314
350
  registerCalendarProvider(
@@ -319,6 +355,259 @@ describe("aggregateBusy", () => {
319
355
  expect(emptyProviderBusy).not.toHaveBeenCalled();
320
356
  });
321
357
 
358
+ it("fails when a selected calendar's provider is not registered", async () => {
359
+ createFakeDb({
360
+ credentials: [{ id: "credential-unknown", type: "availability-unknown" }],
361
+ selected: [
362
+ { credentialId: "credential-unknown", externalId: "calendar-unknown" },
363
+ ],
364
+ });
365
+
366
+ const error = await readBusy().then(
367
+ () => null,
368
+ (thrown: unknown) => thrown,
369
+ );
370
+
371
+ expect(error).toBeInstanceOf(CalendarAvailabilityUnavailableError);
372
+ expect((error as CalendarAvailabilityUnavailableError).failures).toEqual([
373
+ expect.objectContaining({
374
+ credentialId: "credential-unknown",
375
+ provider: "availability-unknown",
376
+ reason: "provider_not_registered",
377
+ }),
378
+ ]);
379
+ });
380
+
381
+ it("fails when a selected calendar's credential was marked invalid", async () => {
382
+ const invalidBusy = vi.fn(async () => []);
383
+ registerCalendarProvider(
384
+ createCalendarProvider("availability-invalid", invalidBusy),
385
+ );
386
+ createFakeDb({
387
+ credentials: [
388
+ {
389
+ id: "credential-invalid",
390
+ type: "availability-invalid",
391
+ invalid: true,
392
+ },
393
+ ],
394
+ selected: [
395
+ { credentialId: "credential-invalid", externalId: "calendar-invalid" },
396
+ ],
397
+ });
398
+
399
+ const error = await readBusy().then(
400
+ () => null,
401
+ (thrown: unknown) => thrown,
402
+ );
403
+
404
+ expect(error).toBeInstanceOf(CalendarAvailabilityUnavailableError);
405
+ expect((error as CalendarAvailabilityUnavailableError).failures).toEqual([
406
+ expect.objectContaining({
407
+ credentialId: "credential-invalid",
408
+ reason: "credential_invalid",
409
+ }),
410
+ ]);
411
+ expect(invalidBusy).not.toHaveBeenCalled();
412
+ });
413
+
414
+ it("reports a throttled lookup as a retryable lookup_failed with its Retry-After", async () => {
415
+ registerCalendarProvider(
416
+ createCalendarProvider(
417
+ "availability-throttled",
418
+ vi.fn(async () => {
419
+ throw new CalendarLookupRateLimitedError(
420
+ "Test provider rate limited (403 rateLimitExceeded)",
421
+ 45,
422
+ );
423
+ }),
424
+ ),
425
+ );
426
+ createFakeDb({
427
+ credentials: [
428
+ { id: "credential-throttled", type: "availability-throttled" },
429
+ ],
430
+ selected: [
431
+ {
432
+ credentialId: "credential-throttled",
433
+ externalId: "calendar-throttled",
434
+ },
435
+ ],
436
+ });
437
+
438
+ const error = await readBusy().then(
439
+ () => null,
440
+ (thrown: unknown) => thrown,
441
+ );
442
+
443
+ expect(error).toBeInstanceOf(CalendarAvailabilityUnavailableError);
444
+ expect((error as CalendarAvailabilityUnavailableError).details).toEqual({
445
+ calendars: [
446
+ {
447
+ provider: "availability-throttled",
448
+ providerLabel: "Test provider availability-throttled",
449
+ reason: "lookup_failed",
450
+ retryAfterSeconds: 45,
451
+ },
452
+ ],
453
+ retryAfterSeconds: 45,
454
+ });
455
+ // A 429 with details.retryAfterSeconds is what the action route turns
456
+ // into a Retry-After header.
457
+ expect((error as CalendarAvailabilityUnavailableError).statusCode).toBe(
458
+ 429,
459
+ );
460
+ });
461
+
462
+ it("stays a 503 when a throttled calendar fails alongside another failure", async () => {
463
+ registerCalendarProvider(
464
+ createCalendarProvider(
465
+ "availability-throttled-mixed",
466
+ vi.fn(async () => {
467
+ throw new CalendarLookupRateLimitedError("Rate limited", 45);
468
+ }),
469
+ ),
470
+ );
471
+ registerCalendarProvider(
472
+ createCalendarProvider(
473
+ "availability-broken-mixed",
474
+ vi.fn(async () => {
475
+ throw new Error("Provider outage");
476
+ }),
477
+ ),
478
+ );
479
+ createFakeDb({
480
+ credentials: [
481
+ {
482
+ id: "credential-throttled-mixed",
483
+ type: "availability-throttled-mixed",
484
+ },
485
+ { id: "credential-broken-mixed", type: "availability-broken-mixed" },
486
+ ],
487
+ selected: [
488
+ {
489
+ credentialId: "credential-throttled-mixed",
490
+ externalId: "calendar-throttled-mixed",
491
+ },
492
+ {
493
+ credentialId: "credential-broken-mixed",
494
+ externalId: "calendar-broken-mixed",
495
+ },
496
+ ],
497
+ });
498
+
499
+ const error = await readBusy().then(
500
+ () => null,
501
+ (thrown: unknown) => thrown,
502
+ );
503
+
504
+ expect(error).toBeInstanceOf(CalendarAvailabilityUnavailableError);
505
+ expect((error as CalendarAvailabilityUnavailableError).statusCode).toBe(
506
+ 503,
507
+ );
508
+ expect(
509
+ (error as CalendarAvailabilityUnavailableError).details
510
+ ?.retryAfterSeconds,
511
+ ).toBe(45);
512
+ });
513
+
514
+ it("is a 429 without a delay when a throttle gives no Retry-After", async () => {
515
+ registerCalendarProvider(
516
+ createCalendarProvider(
517
+ "availability-throttled-no-delay",
518
+ vi.fn(async () => {
519
+ throw new CalendarLookupRateLimitedError("Rate limited");
520
+ }),
521
+ ),
522
+ );
523
+ createFakeDb({
524
+ credentials: [
525
+ {
526
+ id: "credential-throttled-no-delay",
527
+ type: "availability-throttled-no-delay",
528
+ },
529
+ ],
530
+ selected: [
531
+ {
532
+ credentialId: "credential-throttled-no-delay",
533
+ externalId: "calendar-throttled-no-delay",
534
+ },
535
+ ],
536
+ });
537
+
538
+ const error = await readBusy().then(
539
+ () => null,
540
+ (thrown: unknown) => thrown,
541
+ );
542
+
543
+ expect(error).toBeInstanceOf(CalendarAvailabilityUnavailableError);
544
+ const unavailable = error as CalendarAvailabilityUnavailableError;
545
+ expect(unavailable.statusCode).toBe(429);
546
+ expect(unavailable.details).toEqual({
547
+ calendars: [
548
+ {
549
+ provider: "availability-throttled-no-delay",
550
+ providerLabel: "Test provider availability-throttled-no-delay",
551
+ reason: "lookup_failed",
552
+ },
553
+ ],
554
+ });
555
+ });
556
+
557
+ it("reports credential_invalid when the provider rejects the credential on this lookup", async () => {
558
+ registerCalendarProvider(
559
+ createCalendarProvider(
560
+ "availability-unauthorized",
561
+ vi.fn(async () => {
562
+ throw new CalendarCredentialInvalidError(
563
+ "Test provider credential invalid (401)",
564
+ );
565
+ }),
566
+ ),
567
+ );
568
+ createFakeDb({
569
+ credentials: [
570
+ { id: "credential-unauthorized", type: "availability-unauthorized" },
571
+ ],
572
+ selected: [
573
+ {
574
+ credentialId: "credential-unauthorized",
575
+ externalId: "calendar-unauthorized",
576
+ },
577
+ ],
578
+ });
579
+
580
+ const error = await readBusy().then(
581
+ () => null,
582
+ (thrown: unknown) => thrown,
583
+ );
584
+
585
+ expect(error).toBeInstanceOf(CalendarAvailabilityUnavailableError);
586
+ expect((error as CalendarAvailabilityUnavailableError).details).toEqual({
587
+ calendars: [
588
+ expect.objectContaining({
589
+ provider: "availability-unauthorized",
590
+ reason: "credential_invalid",
591
+ }),
592
+ ],
593
+ });
594
+ });
595
+
596
+ it("ignores an invalid credential that has no selected calendars", async () => {
597
+ createFakeDb({
598
+ credentials: [
599
+ {
600
+ id: "credential-invalid-unselected",
601
+ type: "availability-invalid-unselected",
602
+ invalid: true,
603
+ },
604
+ ],
605
+ selected: [],
606
+ });
607
+
608
+ await expect(readBusy()).resolves.toEqual([]);
609
+ });
610
+
322
611
  it("does not read selected calendars when there are no valid credentials", async () => {
323
612
  const { reads } = createFakeDb({
324
613
  bookings: [
@@ -341,3 +630,150 @@ describe("aggregateBusy", () => {
341
630
  expect(reads.selectedCalendars).toBe(0);
342
631
  });
343
632
  });
633
+
634
+ const SLOT_DAY_START = new Date("2030-01-07T00:00:00.000Z");
635
+ const SLOT_DAY_END = new Date("2030-01-08T00:00:00.000Z");
636
+
637
+ const EVENT_TYPE: EventType = {
638
+ id: "event-type-availability",
639
+ title: "Intro call",
640
+ slug: "intro-call",
641
+ length: 30,
642
+ hidden: false,
643
+ position: 0,
644
+ schedulingType: "individual",
645
+ ownerEmail: TEST_USER_EMAIL,
646
+ scheduleId: WORKDAY_SCHEDULE.id,
647
+ locations: [],
648
+ customFields: [],
649
+ minimumBookingNotice: 0,
650
+ beforeEventBuffer: 0,
651
+ afterEventBuffer: 0,
652
+ slotInterval: null,
653
+ periodType: "unlimited",
654
+ requiresConfirmation: false,
655
+ disableGuests: false,
656
+ hideCalendarNotes: false,
657
+ lockTimeZoneToggle: false,
658
+ createdAt: "2026-01-01T00:00:00.000Z",
659
+ updatedAt: "2026-01-01T00:00:00.000Z",
660
+ };
661
+
662
+ function readSlots() {
663
+ return getAvailableSlots({
664
+ eventType: EVENT_TYPE,
665
+ forUserEmail: TEST_USER_EMAIL,
666
+ rangeStart: SLOT_DAY_START,
667
+ rangeEnd: SLOT_DAY_END,
668
+ now: new Date("2029-12-01T00:00:00.000Z"),
669
+ });
670
+ }
671
+
672
+ function selectedCalendarDb(kind: string) {
673
+ createFakeDb({
674
+ credentials: [{ id: `credential-${kind}`, type: kind }],
675
+ selected: [
676
+ { credentialId: `credential-${kind}`, externalId: `calendar-${kind}` },
677
+ ],
678
+ });
679
+ }
680
+
681
+ describe("getAvailableSlots with selected calendars", () => {
682
+ it("offers the whole working day when no calendar is connected", async () => {
683
+ createFakeDb({ credentials: [] });
684
+
685
+ await expect(readSlots()).resolves.toHaveLength(16);
686
+ });
687
+
688
+ it("offers nothing when a selected calendar is busy all day", async () => {
689
+ const getBusy = vi.fn(async () => [
690
+ {
691
+ start: "2030-01-07T09:00:00.000Z",
692
+ end: "2030-01-07T17:00:00.000Z",
693
+ source: "calendar-slots-busy",
694
+ },
695
+ ]);
696
+ registerCalendarProvider(createCalendarProvider("slots-busy", getBusy));
697
+ selectedCalendarDb("slots-busy");
698
+
699
+ await expect(readSlots()).resolves.toHaveLength(0);
700
+ expect(getBusy).toHaveBeenCalledTimes(1);
701
+ });
702
+
703
+ it("fails with a typed error instead of offering free slots when the busy lookup throws", async () => {
704
+ const getBusy = vi.fn(async () => {
705
+ throw new Error("Injected calendar outage");
706
+ });
707
+ registerCalendarProvider(createCalendarProvider("slots-throws", getBusy));
708
+ selectedCalendarDb("slots-throws");
709
+
710
+ const result = await readSlots().then(
711
+ (slots) => ({ slots }),
712
+ (error: unknown) => ({ error }),
713
+ );
714
+
715
+ expect(getBusy).toHaveBeenCalledTimes(1);
716
+ expect(result).not.toHaveProperty("slots");
717
+ const error = (result as { error: unknown }).error;
718
+ expect(error).toBeInstanceOf(CalendarAvailabilityUnavailableError);
719
+ expect(isActionContractError(error)).toBe(true);
720
+ const unavailable = error as CalendarAvailabilityUnavailableError;
721
+ expect(unavailable.errorCode).toBe("calendar_availability_unavailable");
722
+ expect(unavailable.statusCode).toBe(503);
723
+ expect(unavailable.message).toContain(
724
+ "Couldn't check Test provider slots-throws",
725
+ );
726
+ expect(unavailable.details).toEqual({
727
+ calendars: [
728
+ {
729
+ provider: "slots-throws",
730
+ providerLabel: "Test provider slots-throws",
731
+ reason: "lookup_failed",
732
+ },
733
+ ],
734
+ });
735
+ expect(JSON.stringify(unavailable.details)).not.toContain(
736
+ "Injected calendar outage",
737
+ );
738
+ });
739
+
740
+ it("keeps offering slots for a credential whose calendars are all unselected", async () => {
741
+ const getBusy = vi.fn(async () => {
742
+ throw new Error("Unselected calendars are never read");
743
+ });
744
+ registerCalendarProvider(
745
+ createCalendarProvider("slots-unselected", getBusy),
746
+ );
747
+ createFakeDb({
748
+ credentials: [
749
+ { id: "credential-slots-unselected", type: "slots-unselected" },
750
+ ],
751
+ selected: [],
752
+ });
753
+
754
+ await expect(readSlots()).resolves.toHaveLength(16);
755
+ expect(getBusy).not.toHaveBeenCalled();
756
+ });
757
+ });
758
+
759
+ describe("assertSlotAvailable with selected calendars", () => {
760
+ it("refuses the booking when a selected calendar can't be read", async () => {
761
+ registerCalendarProvider(
762
+ createCalendarProvider(
763
+ "booking-throws",
764
+ vi.fn(async () => {
765
+ throw new Error("Injected calendar outage");
766
+ }),
767
+ ),
768
+ );
769
+ selectedCalendarDb("booking-throws");
770
+
771
+ await expect(
772
+ assertSlotAvailable({
773
+ hostEmail: TEST_USER_EMAIL,
774
+ startTime: "2030-01-07T09:00:00.000Z",
775
+ endTime: "2030-01-07T09:30:00.000Z",
776
+ }),
777
+ ).rejects.toBeInstanceOf(CalendarAvailabilityUnavailableError);
778
+ });
779
+ });
@@ -1,3 +1,4 @@
1
+ import { ActionContractError } from "@agent-native/core/action";
1
2
  import { eq, gte, lt, and, inArray } from "drizzle-orm";
2
3
 
3
4
  import { expandSlotForConflictCheck } from "../core/buffers.js";
@@ -12,6 +13,10 @@ import type {
12
13
  Slot,
13
14
  } from "../shared/index.js";
14
15
  import { getSchedulingContext } from "./context.js";
16
+ import {
17
+ isCalendarCredentialInvalidError,
18
+ isCalendarLookupRateLimitedError,
19
+ } from "./providers/errors.js";
15
20
  import { getCalendarProvider } from "./providers/registry.js";
16
21
  import { getScheduleById } from "./schedules-repo.js";
17
22
 
@@ -25,6 +30,66 @@ export class SlotConflictError extends Error {
25
30
  }
26
31
  }
27
32
 
33
+ export type CalendarBusyFailureReason =
34
+ | "lookup_failed"
35
+ | "provider_not_registered"
36
+ | "credential_invalid";
37
+
38
+ export interface CalendarBusyFailure {
39
+ credentialId: string;
40
+ provider: string;
41
+ providerLabel: string;
42
+ calendarExternalIds: string[];
43
+ reason: CalendarBusyFailureReason;
44
+ cause?: string;
45
+ /** Set when the provider throttled the lookup (rate or quota limit). */
46
+ throttled?: true;
47
+ /** Set when a throttling provider said when to retry. */
48
+ retryAfterSeconds?: number;
49
+ }
50
+
51
+ /**
52
+ * A selected calendar could not be read, so its busy times are unknown.
53
+ * `details` is echoed to callers (including public booking pages), so it
54
+ * carries only provider names and reasons; the raw provider error stays in
55
+ * `failures` on the server.
56
+ */
57
+ export class CalendarAvailabilityUnavailableError extends ActionContractError {
58
+ readonly failures: CalendarBusyFailure[];
59
+
60
+ constructor(failures: CalendarBusyFailure[]) {
61
+ const labels = Array.from(new Set(failures.map((f) => f.providerLabel)));
62
+ const retryAfters = failures.flatMap((f) =>
63
+ f.retryAfterSeconds === undefined ? [] : [f.retryAfterSeconds],
64
+ );
65
+ super(
66
+ `Couldn't check ${labels.join(", ")}, so availability can't be confirmed. Try again shortly.`,
67
+ {
68
+ errorCode: "calendar_availability_unavailable",
69
+ // Purely throttled lookups are a 429; the action route sends
70
+ // Retry-After from details.retryAfterSeconds only on a 429. Any other
71
+ // failure in the mix makes it a 503.
72
+ statusCode: failures.every((f) => f.throttled) ? 429 : 503,
73
+ details: {
74
+ calendars: failures.map((f) => ({
75
+ provider: f.provider,
76
+ providerLabel: f.providerLabel,
77
+ reason: f.reason,
78
+ ...(f.retryAfterSeconds === undefined
79
+ ? {}
80
+ : { retryAfterSeconds: f.retryAfterSeconds }),
81
+ })),
82
+ ...(retryAfters.length === 0
83
+ ? {}
84
+ : { retryAfterSeconds: Math.max(...retryAfters) }),
85
+ },
86
+ },
87
+ );
88
+ this.name = "CalendarAvailabilityUnavailableError";
89
+ this.failures = failures;
90
+ }
91
+ }
92
+
28
93
  export interface AssertSlotAvailableInput {
29
94
  hostEmail: string;
30
95
  startTime: string;
@@ -153,15 +218,12 @@ export async function aggregateBusy(input: {
153
218
  });
154
219
  }
155
220
 
221
+ // Invalid credentials are read too: one whose calendars are still selected
222
+ // is a calendar we can't check, not one that has no events.
156
223
  const creds = await db
157
224
  .select()
158
225
  .from(schema.schedulingCredentials)
159
- .where(
160
- and(
161
- eq(schema.schedulingCredentials.userEmail, input.userEmail),
162
- eq(schema.schedulingCredentials.invalid, false),
163
- ),
164
- );
226
+ .where(eq(schema.schedulingCredentials.userEmail, input.userEmail));
165
227
  if (creds.length === 0) return busy;
166
228
 
167
229
  const selected = await db
@@ -188,6 +250,9 @@ export async function aggregateBusy(input: {
188
250
  { length: creds.length },
189
251
  () => [],
190
252
  );
253
+ const failures: (CalendarBusyFailure | undefined)[] = Array.from({
254
+ length: creds.length,
255
+ });
191
256
  let nextCredentialIndex = 0;
192
257
  async function worker(): Promise<void> {
193
258
  while (nextCredentialIndex < creds.length) {
@@ -196,7 +261,25 @@ export async function aggregateBusy(input: {
196
261
  const externalIds = externalIdsByCredential.get(cred.id);
197
262
  if (!externalIds?.length) continue;
198
263
  const provider = getCalendarProvider(cred.type);
199
- if (!provider) continue;
264
+ const failure = (
265
+ reason: CalendarBusyFailureReason,
266
+ cause?: string,
267
+ ): CalendarBusyFailure => ({
268
+ credentialId: cred.id,
269
+ provider: cred.type,
270
+ providerLabel: provider?.label ?? cred.type,
271
+ calendarExternalIds: externalIds,
272
+ reason,
273
+ ...(cause === undefined ? {} : { cause }),
274
+ });
275
+ if (!provider) {
276
+ failures[credentialIndex] = failure("provider_not_registered");
277
+ continue;
278
+ }
279
+ if (cred.invalid) {
280
+ failures[credentialIndex] = failure("credential_invalid");
281
+ continue;
282
+ }
200
283
  try {
201
284
  providerBusy[credentialIndex] = await provider.getBusy({
202
285
  credentialId: cred.id,
@@ -204,14 +287,31 @@ export async function aggregateBusy(input: {
204
287
  start: input.rangeStart,
205
288
  end: input.rangeEnd,
206
289
  });
207
- } catch {
208
- // Silently degrade — booking UI shows "couldn't verify availability" banner
209
- // Consumer logs via their own error handler
290
+ } catch (error) {
291
+ const lookupFailure = failure(
292
+ isCalendarCredentialInvalidError(error)
293
+ ? "credential_invalid"
294
+ : "lookup_failed",
295
+ error instanceof Error ? error.message : String(error),
296
+ );
297
+ if (isCalendarLookupRateLimitedError(error)) {
298
+ lookupFailure.throttled = true;
299
+ if (error.retryAfterSeconds !== undefined) {
300
+ lookupFailure.retryAfterSeconds = error.retryAfterSeconds;
301
+ }
302
+ }
303
+ failures[credentialIndex] = lookupFailure;
210
304
  }
211
305
  }
212
306
  }
213
307
  const workerCount = Math.min(MAX_CALENDAR_BUSY_CONCURRENCY, creds.length);
214
308
  await Promise.all(Array.from({ length: workerCount }, () => worker()));
309
+ const unreadable = failures.filter(
310
+ (f): f is CalendarBusyFailure => f !== undefined,
311
+ );
312
+ if (unreadable.length > 0) {
313
+ throw new CalendarAvailabilityUnavailableError(unreadable);
314
+ }
215
315
  busy.push(...providerBusy.flat());
216
316
 
217
317
  return busy;