@agent-native/scheduling 0.1.23 → 0.1.24

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 (40) hide show
  1. package/README.md +16 -0
  2. package/agent-native.package.json +154 -0
  3. package/dist/manifest.d.ts +5 -1
  4. package/dist/manifest.d.ts.map +1 -1
  5. package/dist/manifest.js +6 -1
  6. package/dist/manifest.js.map +1 -1
  7. package/dist/server/availability-engine.d.ts +20 -0
  8. package/dist/server/availability-engine.d.ts.map +1 -1
  9. package/dist/server/availability-engine.js +73 -23
  10. package/dist/server/availability-engine.js.map +1 -1
  11. package/dist/server/booking-service.d.ts.map +1 -1
  12. package/dist/server/booking-service.js +16 -5
  13. package/dist/server/booking-service.js.map +1 -1
  14. package/dist/server/bookings-repo.d.ts.map +1 -1
  15. package/dist/server/bookings-repo.js +50 -48
  16. package/dist/server/bookings-repo.js.map +1 -1
  17. package/dist/server/providers/index.d.ts +2 -0
  18. package/dist/server/providers/index.d.ts.map +1 -1
  19. package/dist/server/providers/index.js +1 -0
  20. package/dist/server/providers/index.js.map +1 -1
  21. package/dist/server/providers/teams.d.ts +22 -0
  22. package/dist/server/providers/teams.d.ts.map +1 -0
  23. package/dist/server/providers/teams.js +112 -0
  24. package/dist/server/providers/teams.js.map +1 -0
  25. package/docs/eject.md +11 -10
  26. package/docs/llms-full.txt +75 -14
  27. package/docs/llms.txt +15 -1
  28. package/docs/providers.md +30 -2
  29. package/docs/skills/integrations/SKILL.md +19 -1
  30. package/package.json +7 -6
  31. package/src/manifest.test.ts +19 -0
  32. package/src/manifest.ts +9 -7
  33. package/src/server/availability-engine.test.ts +344 -0
  34. package/src/server/availability-engine.ts +100 -22
  35. package/src/server/booking-service.spec.ts +557 -0
  36. package/src/server/booking-service.ts +17 -6
  37. package/src/server/bookings-repo.ts +54 -48
  38. package/src/server/providers/index.ts +2 -0
  39. package/src/server/providers/teams.test.ts +246 -0
  40. package/src/server/providers/teams.ts +183 -0
@@ -0,0 +1,344 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import * as schema from "../schema/index.js";
4
+ import type { BusyInterval } from "../shared/index.js";
5
+ import { aggregateBusy } from "./availability-engine.js";
6
+ import { setSchedulingContext } from "./context.js";
7
+ import { registerCalendarProvider } from "./providers/registry.js";
8
+ import type { CalendarProvider } from "./providers/types.js";
9
+
10
+ const RANGE_START = new Date("2026-07-10T09:00:00.000Z");
11
+ const RANGE_END = new Date("2026-07-10T17:00:00.000Z");
12
+ const TEST_USER_EMAIL = "availability-test@example.com";
13
+
14
+ interface CredentialRow {
15
+ id: string;
16
+ type: string;
17
+ }
18
+
19
+ interface SelectedCalendarRow {
20
+ credentialId: string;
21
+ externalId: string;
22
+ }
23
+
24
+ interface FakeDbInput {
25
+ bookings?: Array<{
26
+ startTime: string;
27
+ endTime: string;
28
+ uid: string;
29
+ }>;
30
+ credentials?: CredentialRow[];
31
+ selected?: SelectedCalendarRow[];
32
+ /** Supports the pre-batching implementation during characterization. */
33
+ legacySelectedBatches?: SelectedCalendarRow[][];
34
+ }
35
+
36
+ function createFakeDb(input: FakeDbInput) {
37
+ const reads = {
38
+ bookings: 0,
39
+ credentials: 0,
40
+ selectedCalendars: 0,
41
+ };
42
+
43
+ const db = {
44
+ select(projection?: unknown) {
45
+ return {
46
+ from(table: unknown) {
47
+ return {
48
+ async where() {
49
+ if (table === schema.bookings) {
50
+ reads.bookings += 1;
51
+ return input.bookings ?? [];
52
+ }
53
+ if (table === schema.schedulingCredentials) {
54
+ reads.credentials += 1;
55
+ return input.credentials ?? [];
56
+ }
57
+ if (table === schema.selectedCalendars) {
58
+ reads.selectedCalendars += 1;
59
+ if (projection) return input.selected ?? [];
60
+ return (
61
+ input.legacySelectedBatches?.[reads.selectedCalendars - 1] ??
62
+ input.selected ??
63
+ []
64
+ );
65
+ }
66
+ throw new Error("Unexpected table in availability test");
67
+ },
68
+ };
69
+ },
70
+ };
71
+ },
72
+ };
73
+
74
+ setSchedulingContext({
75
+ getDb: () => db,
76
+ schema,
77
+ getCurrentUserEmail: () => TEST_USER_EMAIL,
78
+ });
79
+
80
+ return { reads };
81
+ }
82
+
83
+ function createCalendarProvider(
84
+ kind: string,
85
+ getBusy: CalendarProvider["getBusy"],
86
+ ): CalendarProvider {
87
+ return {
88
+ kind,
89
+ label: `Test provider ${kind}`,
90
+ startOAuth: vi.fn(async () => ({ authUrl: "https://example.test/oauth" })),
91
+ completeOAuth: vi.fn(async () => ({
92
+ externalEmail: "provider-test@example.com",
93
+ calendars: [],
94
+ })),
95
+ listCalendars: vi.fn(async () => []),
96
+ getBusy,
97
+ createEvent: vi.fn(async () => ({ externalId: "event-example" })),
98
+ updateEvent: vi.fn(async () => ({ iCalSequence: 1 })),
99
+ deleteEvent: vi.fn(async () => {}),
100
+ };
101
+ }
102
+
103
+ function interval(source: string, hour: number): BusyInterval {
104
+ return {
105
+ start: `2026-07-10T${String(hour).padStart(2, "0")}:00:00.000Z`,
106
+ end: `2026-07-10T${String(hour + 1).padStart(2, "0")}:00:00.000Z`,
107
+ source,
108
+ };
109
+ }
110
+
111
+ async function readBusy(): Promise<BusyInterval[]> {
112
+ return aggregateBusy({
113
+ userEmail: TEST_USER_EMAIL,
114
+ rangeStart: RANGE_START,
115
+ rangeEnd: RANGE_END,
116
+ });
117
+ }
118
+
119
+ describe("aggregateBusy", () => {
120
+ beforeEach(() => {
121
+ vi.restoreAllMocks();
122
+ });
123
+
124
+ it("groups selected calendars and preserves local and provider busy intervals", async () => {
125
+ const credentials = [
126
+ { id: "credential-a", type: "availability-group-a" },
127
+ { id: "credential-b", type: "availability-group-b" },
128
+ ];
129
+ const selected = [
130
+ { credentialId: "credential-a", externalId: "calendar-a-1" },
131
+ { credentialId: "credential-a", externalId: "calendar-a-2" },
132
+ { credentialId: "credential-b", externalId: "calendar-b-1" },
133
+ ];
134
+ createFakeDb({
135
+ bookings: [
136
+ {
137
+ startTime: "2026-07-10T10:00:00.000Z",
138
+ endTime: "2026-07-10T11:00:00.000Z",
139
+ uid: "booking-example",
140
+ },
141
+ ],
142
+ credentials,
143
+ selected,
144
+ legacySelectedBatches: [selected.slice(0, 2), selected.slice(2)],
145
+ });
146
+
147
+ const getBusyA = vi.fn(async () => [interval("provider-a", 12)]);
148
+ const getBusyB = vi.fn(async () => [interval("provider-b", 14)]);
149
+ registerCalendarProvider(
150
+ createCalendarProvider("availability-group-a", getBusyA),
151
+ );
152
+ registerCalendarProvider(
153
+ createCalendarProvider("availability-group-b", getBusyB),
154
+ );
155
+
156
+ const busy = await readBusy();
157
+
158
+ expect(busy.map((entry) => entry.source)).toEqual([
159
+ "booking:booking-example",
160
+ "provider-a",
161
+ "provider-b",
162
+ ]);
163
+ expect(getBusyA).toHaveBeenCalledWith({
164
+ credentialId: "credential-a",
165
+ calendarExternalIds: ["calendar-a-1", "calendar-a-2"],
166
+ start: RANGE_START,
167
+ end: RANGE_END,
168
+ });
169
+ expect(getBusyB).toHaveBeenCalledWith({
170
+ credentialId: "credential-b",
171
+ calendarExternalIds: ["calendar-b-1"],
172
+ start: RANGE_START,
173
+ end: RANGE_END,
174
+ });
175
+ });
176
+
177
+ it("reads bookings, credentials, and selected calendars once each", async () => {
178
+ const credentials = [
179
+ { id: "credential-query-a", type: "availability-query-a" },
180
+ { id: "credential-query-b", type: "availability-query-b" },
181
+ ];
182
+ const selected = [
183
+ { credentialId: "credential-query-a", externalId: "calendar-query-a" },
184
+ { credentialId: "credential-query-b", externalId: "calendar-query-b" },
185
+ ];
186
+ const { reads } = createFakeDb({
187
+ credentials,
188
+ selected,
189
+ legacySelectedBatches: [[selected[0]], [selected[1]]],
190
+ });
191
+ registerCalendarProvider(
192
+ createCalendarProvider(
193
+ "availability-query-a",
194
+ vi.fn(async () => []),
195
+ ),
196
+ );
197
+ registerCalendarProvider(
198
+ createCalendarProvider(
199
+ "availability-query-b",
200
+ vi.fn(async () => []),
201
+ ),
202
+ );
203
+
204
+ await readBusy();
205
+
206
+ expect(reads).toEqual({
207
+ bookings: 1,
208
+ credentials: 1,
209
+ selectedCalendars: 1,
210
+ });
211
+ });
212
+
213
+ it("runs provider reads concurrently with a maximum of four in flight", async () => {
214
+ const credentials = Array.from({ length: 6 }, (_, index) => ({
215
+ id: `credential-concurrency-${index}`,
216
+ type: `availability-concurrency-${index}`,
217
+ }));
218
+ const selected = credentials.map((credential, index) => ({
219
+ credentialId: credential.id,
220
+ externalId: `calendar-concurrency-${index}`,
221
+ }));
222
+ createFakeDb({
223
+ credentials,
224
+ selected,
225
+ legacySelectedBatches: selected.map((row) => [row]),
226
+ });
227
+
228
+ let active = 0;
229
+ let maximumActive = 0;
230
+ const started: string[] = [];
231
+ const gates = new Map<string, () => void>();
232
+ for (const credential of credentials) {
233
+ let release = () => {};
234
+ const gate = new Promise<void>((resolve) => {
235
+ release = resolve;
236
+ });
237
+ gates.set(credential.id, release);
238
+ registerCalendarProvider(
239
+ createCalendarProvider(credential.type, async ({ credentialId }) => {
240
+ started.push(credentialId);
241
+ active += 1;
242
+ maximumActive = Math.max(maximumActive, active);
243
+ await gate;
244
+ active -= 1;
245
+ const index = Number(credentialId.split("-").at(-1));
246
+ return [interval(`provider-concurrency-${index}`, 10 + index)];
247
+ }),
248
+ );
249
+ }
250
+
251
+ const busyPromise = readBusy();
252
+ await vi.waitFor(() => expect(started.length).toBeGreaterThan(0));
253
+ for (const release of Array.from(gates.values()).reverse()) release();
254
+ const busy = await busyPromise;
255
+
256
+ expect(maximumActive).toBeGreaterThan(1);
257
+ expect(maximumActive).toBeLessThanOrEqual(4);
258
+ expect(started).toHaveLength(6);
259
+ expect(busy.map((entry) => entry.source)).toEqual(
260
+ credentials.map((_, index) => `provider-concurrency-${index}`),
261
+ );
262
+ });
263
+
264
+ it("keeps successful provider intervals when another provider fails", async () => {
265
+ const credentials = [
266
+ { id: "credential-failure", type: "availability-failure" },
267
+ { id: "credential-success", type: "availability-success" },
268
+ ];
269
+ const selected = [
270
+ { credentialId: "credential-failure", externalId: "calendar-failure" },
271
+ { credentialId: "credential-success", externalId: "calendar-success" },
272
+ ];
273
+ createFakeDb({
274
+ credentials,
275
+ selected,
276
+ legacySelectedBatches: [[selected[0]], [selected[1]]],
277
+ });
278
+ registerCalendarProvider(
279
+ createCalendarProvider(
280
+ "availability-failure",
281
+ vi.fn(async () => {
282
+ throw new Error("Expected provider failure");
283
+ }),
284
+ ),
285
+ );
286
+ registerCalendarProvider(
287
+ createCalendarProvider(
288
+ "availability-success",
289
+ vi.fn(async () => [interval("provider-success", 13)]),
290
+ ),
291
+ );
292
+
293
+ await expect(readBusy()).resolves.toEqual([
294
+ interval("provider-success", 13),
295
+ ]);
296
+ });
297
+
298
+ it("skips credentials without selected calendars or a registered provider", async () => {
299
+ const credentials = [
300
+ { id: "credential-empty", type: "availability-empty" },
301
+ { id: "credential-unknown", type: "availability-unknown" },
302
+ ];
303
+ const selected = [
304
+ {
305
+ credentialId: "credential-unknown",
306
+ externalId: "calendar-unknown",
307
+ },
308
+ ];
309
+ createFakeDb({
310
+ credentials,
311
+ selected,
312
+ legacySelectedBatches: [[], selected],
313
+ });
314
+ const emptyProviderBusy = vi.fn(async () => []);
315
+ registerCalendarProvider(
316
+ createCalendarProvider("availability-empty", emptyProviderBusy),
317
+ );
318
+
319
+ await expect(readBusy()).resolves.toEqual([]);
320
+ expect(emptyProviderBusy).not.toHaveBeenCalled();
321
+ });
322
+
323
+ it("does not read selected calendars when there are no valid credentials", async () => {
324
+ const { reads } = createFakeDb({
325
+ bookings: [
326
+ {
327
+ startTime: "2026-07-10T10:00:00.000Z",
328
+ endTime: "2026-07-10T11:00:00.000Z",
329
+ uid: "booking-only-example",
330
+ },
331
+ ],
332
+ credentials: [],
333
+ });
334
+
335
+ await expect(readBusy()).resolves.toEqual([
336
+ {
337
+ start: "2026-07-10T10:00:00.000Z",
338
+ end: "2026-07-10T11:00:00.000Z",
339
+ source: "booking:booking-only-example",
340
+ },
341
+ ]);
342
+ expect(reads.selectedCalendars).toBe(0);
343
+ });
344
+ });
@@ -6,8 +6,10 @@
6
6
  * world: it fetches the schedule, merges busy times from the selected
7
7
  * calendars + existing bookings, and applies booking limits.
8
8
  */
9
- import { eq, gte, lt, and } from "drizzle-orm";
9
+ import { eq, gte, lt, and, inArray } from "drizzle-orm";
10
10
 
11
+ import { expandSlotForConflictCheck } from "../core/buffers.js";
12
+ import { hasConflict } from "../core/conflicts.js";
11
13
  import type { BookingCounts } from "../core/limits.js";
12
14
  import { bucketKeysForSlot } from "../core/limits.js";
13
15
  import { computeAvailableSlots } from "../core/slots.js";
@@ -21,6 +23,54 @@ import { getSchedulingContext } from "./context.js";
21
23
  import { getCalendarProvider } from "./providers/registry.js";
22
24
  import { getScheduleById } from "./schedules-repo.js";
23
25
 
26
+ const MAX_CALENDAR_BUSY_CONCURRENCY = 4;
27
+
28
+ export class SlotConflictError extends Error {
29
+ statusCode = 409;
30
+ constructor(message = "This time slot is no longer available") {
31
+ super(message);
32
+ this.name = "SlotConflictError";
33
+ }
34
+ }
35
+
36
+ export interface AssertSlotAvailableInput {
37
+ hostEmail: string;
38
+ startTime: string;
39
+ endTime: string;
40
+ beforeEventBuffer?: number;
41
+ afterEventBuffer?: number;
42
+ /** Booking uid to ignore when checking conflicts (e.g. the one being rescheduled). */
43
+ excludeBookingUid?: string;
44
+ }
45
+
46
+ /**
47
+ * Re-validate a single requested interval right before it's written —
48
+ * cheap by design: aggregates busy only over the buffer-expanded window
49
+ * instead of scanning a full day like `getAvailableSlots`. Throws
50
+ * `SlotConflictError` if the window collides with existing busy time.
51
+ */
52
+ export async function assertSlotAvailable(
53
+ input: AssertSlotAvailableInput,
54
+ ): Promise<void> {
55
+ const expanded = expandSlotForConflictCheck(
56
+ new Date(input.startTime),
57
+ new Date(input.endTime),
58
+ input.beforeEventBuffer ?? 0,
59
+ input.afterEventBuffer ?? 0,
60
+ );
61
+ const busy = await aggregateBusy({
62
+ userEmail: input.hostEmail,
63
+ rangeStart: expanded.start,
64
+ rangeEnd: expanded.end,
65
+ });
66
+ const relevantBusy = input.excludeBookingUid
67
+ ? busy.filter((b) => b.source !== `booking:${input.excludeBookingUid}`)
68
+ : busy;
69
+ if (hasConflict(expanded, relevantBusy)) {
70
+ throw new SlotConflictError();
71
+ }
72
+ }
73
+
24
74
  export interface GetSlotsInput {
25
75
  eventType: EventType;
26
76
  /** The user we're finding availability for — their schedule + calendars. */
@@ -134,29 +184,57 @@ export async function aggregateBusy(input: {
134
184
  eq(schema.schedulingCredentials.invalid, false),
135
185
  ),
136
186
  );
137
- for (const cred of creds) {
138
- const selected = await db
139
- .select()
140
- .from(schema.selectedCalendars)
141
- .where(eq(schema.selectedCalendars.credentialId, cred.id));
142
- if (selected.length === 0) continue;
143
- const provider = getCalendarProvider(cred.type);
144
- if (!provider) continue;
145
- try {
146
- const result = await provider.getBusy({
147
- credentialId: cred.id,
148
- calendarExternalIds: selected.map(
149
- (s: { externalId: string }) => s.externalId,
150
- ),
151
- start: input.rangeStart,
152
- end: input.rangeEnd,
153
- });
154
- busy.push(...result);
155
- } catch {
156
- // Silently degrade — booking UI shows "couldn't verify availability" banner
157
- // Consumer logs via their own error handler
187
+ if (creds.length === 0) return busy;
188
+
189
+ const selected = await db
190
+ .select({
191
+ credentialId: schema.selectedCalendars.credentialId,
192
+ externalId: schema.selectedCalendars.externalId,
193
+ })
194
+ .from(schema.selectedCalendars)
195
+ .where(
196
+ inArray(
197
+ schema.selectedCalendars.credentialId,
198
+ creds.map((cred: { id: string }) => cred.id),
199
+ ),
200
+ );
201
+ const externalIdsByCredential = new Map<string, string[]>();
202
+ for (const calendar of selected) {
203
+ const externalIds =
204
+ externalIdsByCredential.get(calendar.credentialId) ?? [];
205
+ externalIds.push(calendar.externalId);
206
+ externalIdsByCredential.set(calendar.credentialId, externalIds);
207
+ }
208
+
209
+ const providerBusy: BusyInterval[][] = Array.from(
210
+ { length: creds.length },
211
+ () => [],
212
+ );
213
+ let nextCredentialIndex = 0;
214
+ async function worker(): Promise<void> {
215
+ while (nextCredentialIndex < creds.length) {
216
+ const credentialIndex = nextCredentialIndex++;
217
+ const cred = creds[credentialIndex];
218
+ const externalIds = externalIdsByCredential.get(cred.id);
219
+ if (!externalIds?.length) continue;
220
+ const provider = getCalendarProvider(cred.type);
221
+ if (!provider) continue;
222
+ try {
223
+ providerBusy[credentialIndex] = await provider.getBusy({
224
+ credentialId: cred.id,
225
+ calendarExternalIds: externalIds,
226
+ start: input.rangeStart,
227
+ end: input.rangeEnd,
228
+ });
229
+ } catch {
230
+ // Silently degrade — booking UI shows "couldn't verify availability" banner
231
+ // Consumer logs via their own error handler
232
+ }
158
233
  }
159
234
  }
235
+ const workerCount = Math.min(MAX_CALENDAR_BUSY_CONCURRENCY, creds.length);
236
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
237
+ busy.push(...providerBusy.flat());
160
238
 
161
239
  return busy;
162
240
  }