@agent-native/scheduling 0.2.52 → 0.2.53-nightly-20261105200106
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/server/availability-engine.d.ts +24 -0
- package/dist/server/availability-engine.d.ts.map +1 -1
- package/dist/server/availability-engine.js +73 -5
- package/dist/server/availability-engine.js.map +1 -1
- package/dist/server/providers/errors.d.ts +22 -0
- package/dist/server/providers/errors.d.ts.map +1 -0
- package/dist/server/providers/errors.js +53 -0
- package/dist/server/providers/errors.js.map +1 -0
- package/dist/server/providers/google-calendar.d.ts +0 -11
- package/dist/server/providers/google-calendar.d.ts.map +1 -1
- package/dist/server/providers/google-calendar.js +57 -3
- package/dist/server/providers/google-calendar.js.map +1 -1
- package/dist/server/providers/index.d.ts +1 -0
- package/dist/server/providers/index.d.ts.map +1 -1
- package/dist/server/providers/index.js +1 -0
- package/dist/server/providers/index.js.map +1 -1
- package/dist/server/providers/office365.d.ts.map +1 -1
- package/dist/server/providers/office365.js +87 -25
- package/dist/server/providers/office365.js.map +1 -1
- package/docs/llms-full.txt +29 -8
- package/docs/providers.md +10 -0
- package/docs/skills/integrations/SKILL.md +19 -8
- package/package.json +4 -4
- package/src/server/availability-engine.test.ts +459 -23
- package/src/server/availability-engine.ts +110 -10
- package/src/server/providers/calendar-busy.test.ts +417 -0
- package/src/server/providers/errors.ts +66 -0
- package/src/server/providers/google-calendar.ts +71 -4
- package/src/server/providers/index.ts +1 -0
- 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 {
|
|
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("
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
320
|
+
const error = await readBusy().then(
|
|
321
|
+
() => null,
|
|
322
|
+
(thrown: unknown) => thrown,
|
|
323
|
+
);
|
|
296
324
|
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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-
|
|
305
|
-
|
|
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: [[]
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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;
|