slotlock 0.2.0 → 0.5.0
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/CHANGELOG.md +582 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +498 -65
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +62 -10
- package/agent-server.js +577 -105
- package/agent-store-backend.d.ts +12 -0
- package/agent-store-backend.js +1115 -55
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +35 -1
- package/application-api.js +683 -12
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +112 -0
- package/config.js +225 -0
- package/dashboard.d.ts +67 -5
- package/dashboard.js +1012 -140
- package/ddl.d.ts +45 -3
- package/ddl.js +1124 -13
- package/engine.d.ts +5 -0
- package/engine.js +23 -6
- package/event-feed.d.ts +178 -0
- package/event-feed.js +435 -0
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +12 -6
- package/index.js +9 -4
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +27 -5
- package/maintenance.js +92 -28
- package/mcp-app.d.ts +7 -1
- package/mcp-app.js +259 -40
- package/metrics.d.ts +24 -0
- package/metrics.js +167 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- package/openapi.json +10970 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +80 -0
- package/rate-limit.js +741 -0
- package/rest-api.d.ts +22 -0
- package/rest-api.js +166 -11
- package/rules.js +6 -3
- package/self-host.d.ts +45 -8
- package/self-host.js +616 -46
- package/slotlock-manifest.schema.json +7 -1
- package/slotlock.manifest.json +8 -2
- package/store.d.ts +647 -28
- package/store.js +2982 -327
- package/sync.d.ts +43 -2
- package/sync.js +1515 -323
- package/timezone.d.ts +58 -3
- package/timezone.js +249 -20
- package/types.d.ts +300 -9
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
package/agent-server.js
CHANGED
|
@@ -3,9 +3,10 @@ import { z } from 'zod';
|
|
|
3
3
|
import { SLOTLOCK_MCP_APPS_EXTENSION, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, clientSupportsSlotlockMcpApp, } from './mcp-app.js';
|
|
4
4
|
import { MCP_META_PROTOCOL_VERSION, MCP_META_SERVER_INFO, MCP_META_SUBSCRIPTION_ID, MCP_MISSING_REQUIRED_CLIENT_CAPABILITY, canonicalJson, checkModernMcpRequest, isRecord, openMcpEventStream, openRequestState, principalTag, sealRequestState, sha256Hex, stateSecrets, traceContextFrom, } from './mcp-modern.js';
|
|
5
5
|
import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, slotlockApplicationOperation, } from './application-api.js';
|
|
6
|
+
import { SLOTLOCK_EVENT_OUTPUT as EVENT } from './event-schema.js';
|
|
6
7
|
import { ignoreRejection } from './hooks.js';
|
|
7
8
|
import { slotlockPackageVersion } from './package-version.js';
|
|
8
|
-
import { SLOTLOCK_APPLICATION_REST_ROUTES, SLOTLOCK_REST_ROUTES, matchSlotlockRestRoute, restInputFromBody, restQueryInput, slotlockOpenApiDocument, } from './rest-api.js';
|
|
9
|
+
import { SLOTLOCK_APPLICATION_REST_ROUTES, SLOTLOCK_REST_ROUTES, matchSlotlockRestRoute, parseIdempotencyKeyHeader, SLOTLOCK_PROBLEM_DOCS, slotlockProblem, restInputFromBody, restQueryInput, slotlockOpenApiDocument, } from './rest-api.js';
|
|
9
10
|
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, calendarEventRollingHorizon, } from './store.js';
|
|
10
11
|
import { canonicalRecurrenceRule, expandCalendarEventOccurrences, isBookableRecurrenceRule, } from './sync.js';
|
|
11
12
|
import { zonedOffsetMinutes, zonedWallTime } from './timezone.js';
|
|
@@ -59,17 +60,163 @@ export const SLOTLOCK_A2A_TASK_STATES = Object.freeze([
|
|
|
59
60
|
const DATE_TIME = z.string().datetime({ offset: true });
|
|
60
61
|
const RESOURCE_ID = z.string().trim().min(1).max(200);
|
|
61
62
|
const EVENT_ID = z.string().trim().min(1).max(500);
|
|
63
|
+
const HOLD_ID = z.string().uuid();
|
|
64
|
+
const POOL = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/);
|
|
62
65
|
const IDEMPOTENCY_KEY = z.string().trim().min(1).max(200);
|
|
63
66
|
const REVISION = z.number().int().positive();
|
|
64
|
-
// Output bounds mirror the trusted store's byte ceilings. A value admitted by a UTF-8 byte
|
|
65
|
-
// ceiling can never exceed that many UTF-16 code units, so every valid stored/provider event is
|
|
66
|
-
// serializable without truncating recurrence semantics or calendar identities.
|
|
67
|
-
const TRUSTED_SUMMARY_OUTPUT_MAX = 4_096;
|
|
68
|
-
const TRUSTED_TEXT_OUTPUT_MAX = 16_384;
|
|
69
|
-
const TRUSTED_PERSON_NAME_OUTPUT_MAX = 1_024;
|
|
70
|
-
const TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX = 1_000;
|
|
71
|
-
const TRUSTED_REMINDER_MINUTES_OUTPUT_MAX = 366 * 24 * 60;
|
|
72
67
|
const MAX_AGENT_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60 * 60 * 1_000;
|
|
68
|
+
/** How many slots `slotlock_find_available_slots` lists at most, and over how many resources. */
|
|
69
|
+
const MAX_LISTED_SLOTS = 50;
|
|
70
|
+
const MAX_SLOT_RESOURCES = 20;
|
|
71
|
+
/** `slotlock_hold_slots`, from a backend that holds; one without it answers `holds_unavailable`. */
|
|
72
|
+
function holdSlots(backend, context, input) {
|
|
73
|
+
if (!backend.holdSlots)
|
|
74
|
+
throw new SlotlockAgentOperationError('holds_unavailable', 409);
|
|
75
|
+
return backend.holdSlots(context, input);
|
|
76
|
+
}
|
|
77
|
+
/** `slotlock_book_pool`, from a backend that books pools, as `holdSlots`. */
|
|
78
|
+
function bookPool(backend, context, input) {
|
|
79
|
+
if (!backend.bookPool)
|
|
80
|
+
throw new SlotlockAgentOperationError('pools_unavailable', 409);
|
|
81
|
+
return backend.bookPool(context, input);
|
|
82
|
+
}
|
|
83
|
+
/** `slotlock_get_pool_availability`, from a backend that knows pools, as `holdSlots`. */
|
|
84
|
+
function getPoolAvailability(backend, context, input) {
|
|
85
|
+
if (!backend.getPoolAvailability)
|
|
86
|
+
throw new SlotlockAgentOperationError('pools_unavailable', 409);
|
|
87
|
+
return backend.getPoolAvailability(context, input);
|
|
88
|
+
}
|
|
89
|
+
/** `slotlock_book_together`, from a backend that books bundles, as `holdSlots`. */
|
|
90
|
+
function bookTogether(backend, context, input) {
|
|
91
|
+
if (!backend.bookTogether)
|
|
92
|
+
throw new SlotlockAgentOperationError('bundles_unavailable', 409);
|
|
93
|
+
return backend.bookTogether(context, input);
|
|
94
|
+
}
|
|
95
|
+
/** `slotlock_release_slots`, as `holdSlots`. */
|
|
96
|
+
function releaseSlots(backend, context, input) {
|
|
97
|
+
if (!backend.releaseSlots)
|
|
98
|
+
throw new SlotlockAgentOperationError('holds_unavailable', 409);
|
|
99
|
+
return backend.releaseSlots(context, input);
|
|
100
|
+
}
|
|
101
|
+
/** Next-available searches one collective search may make, across its resources. */
|
|
102
|
+
const MAX_TOGETHER_SEARCHES = 500;
|
|
103
|
+
/**
|
|
104
|
+
* Times when at least `needed` of the resources are free together for the duration, earliest
|
|
105
|
+
* first. Each resource's next certain start at or after a candidate time comes from the backend's
|
|
106
|
+
* own search (hours, booking rules, coverage, as a single answer judges them); the candidate is
|
|
107
|
+
* the `needed`-th earliest of those starts, and it is a slot when that many start there. Otherwise
|
|
108
|
+
* the search moves on to it, where the resources that start earlier are asked again. A resource's
|
|
109
|
+
* start stays valid until the candidate passes it, so most are asked once per slot at most.
|
|
110
|
+
*/
|
|
111
|
+
async function freeTogether(resourceIds, needed, next, options) {
|
|
112
|
+
if (needed > resourceIds.length)
|
|
113
|
+
throw new SlotlockAgentOperationError('invalid_arguments', 400);
|
|
114
|
+
const known = new Map();
|
|
115
|
+
const slots = [];
|
|
116
|
+
let from = options.window.start.getTime();
|
|
117
|
+
let searches = 0;
|
|
118
|
+
while (slots.length < options.limit && from + options.durationMs <= options.window.end.getTime()) {
|
|
119
|
+
for (const resourceId of resourceIds) {
|
|
120
|
+
const start = known.get(resourceId);
|
|
121
|
+
if (start === null || (start !== undefined && start >= from))
|
|
122
|
+
continue;
|
|
123
|
+
if (++searches > MAX_TOGETHER_SEARCHES)
|
|
124
|
+
return slots;
|
|
125
|
+
const found = await next(resourceId, new Date(from).toISOString());
|
|
126
|
+
if (found === 'uncertain')
|
|
127
|
+
return 'uncertain';
|
|
128
|
+
known.set(resourceId, found ? Date.parse(found.start) : null);
|
|
129
|
+
}
|
|
130
|
+
const starts = resourceIds
|
|
131
|
+
.map((resourceId) => known.get(resourceId))
|
|
132
|
+
.filter((start) => typeof start === 'number')
|
|
133
|
+
.sort((a, b) => a - b);
|
|
134
|
+
const candidate = starts[needed - 1];
|
|
135
|
+
if (candidate === undefined)
|
|
136
|
+
return slots;
|
|
137
|
+
const free = resourceIds.filter((resourceId) => known.get(resourceId) === candidate);
|
|
138
|
+
if (free.length >= needed) {
|
|
139
|
+
slots.push({
|
|
140
|
+
resource_id: free[0],
|
|
141
|
+
start: new Date(candidate).toISOString(),
|
|
142
|
+
end: new Date(candidate + options.durationMs).toISOString(),
|
|
143
|
+
resource_ids: free,
|
|
144
|
+
});
|
|
145
|
+
from = candidate + options.durationMs;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
from = candidate;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return slots;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Slots for `slotlock_find_available_slots`, from the backend's own next-available search, so each
|
|
155
|
+
* one is checked against busy time, the resource's hours and coverage exactly as a single answer
|
|
156
|
+
* is. Each resource's earliest slot first, then the next one after whichever slot was listed: at
|
|
157
|
+
* most one search per resource plus one per listed slot. Like a single answer, the list is certain
|
|
158
|
+
* only when every resource's coverage is, and empty otherwise.
|
|
159
|
+
*/
|
|
160
|
+
async function findAvailableSlots(backend, context, input) {
|
|
161
|
+
const end = input.end;
|
|
162
|
+
const durationMs = input.duration_minutes * 60_000;
|
|
163
|
+
const limit = input.limit;
|
|
164
|
+
const window = { start: new Date(input.start), end: new Date(end) };
|
|
165
|
+
const coverage = (certain) => ({
|
|
166
|
+
start: window.start.toISOString(),
|
|
167
|
+
end: window.end.toISOString(),
|
|
168
|
+
certainty: certain ? 'certain' : 'uncertain',
|
|
169
|
+
reason: certain ? null : 'coverage_incomplete',
|
|
170
|
+
});
|
|
171
|
+
const next = async (resourceId, from) => {
|
|
172
|
+
const found = await backend.findNextAvailable(context, {
|
|
173
|
+
resource_ids: [resourceId],
|
|
174
|
+
start: from,
|
|
175
|
+
end,
|
|
176
|
+
duration_minutes: input.duration_minutes,
|
|
177
|
+
});
|
|
178
|
+
if (found.coverage?.certainty !== 'certain') {
|
|
179
|
+
return 'uncertain';
|
|
180
|
+
}
|
|
181
|
+
return typeof found.start === 'string' && typeof found.end === 'string'
|
|
182
|
+
? { resource_id: resourceId, start: found.start, end: found.end }
|
|
183
|
+
: null;
|
|
184
|
+
};
|
|
185
|
+
const resourceIds = [...new Set(input.resource_ids)];
|
|
186
|
+
if (typeof input.require_free === 'number') {
|
|
187
|
+
const together = await freeTogether(resourceIds, input.require_free, next, {
|
|
188
|
+
window,
|
|
189
|
+
durationMs,
|
|
190
|
+
limit,
|
|
191
|
+
});
|
|
192
|
+
return together === 'uncertain'
|
|
193
|
+
? { slots: [], coverage: coverage(false) }
|
|
194
|
+
: { slots: together, coverage: coverage(true) };
|
|
195
|
+
}
|
|
196
|
+
const earliest = (a, b) => Date.parse(a.start) - Date.parse(b.start) || a.resource_id.localeCompare(b.resource_id);
|
|
197
|
+
const heads = [];
|
|
198
|
+
for (const resourceId of resourceIds) {
|
|
199
|
+
const head = await next(resourceId, window.start.toISOString());
|
|
200
|
+
if (head === 'uncertain')
|
|
201
|
+
return { slots: [], coverage: coverage(false) };
|
|
202
|
+
if (head)
|
|
203
|
+
heads.push(head);
|
|
204
|
+
}
|
|
205
|
+
const slots = [];
|
|
206
|
+
while (slots.length < limit && heads.length > 0) {
|
|
207
|
+
heads.sort(earliest);
|
|
208
|
+
const listed = heads.shift();
|
|
209
|
+
slots.push(listed);
|
|
210
|
+
if (slots.length === limit || window.end.getTime() - Date.parse(listed.end) < durationMs)
|
|
211
|
+
continue;
|
|
212
|
+
const after = await next(listed.resource_id, listed.end);
|
|
213
|
+
if (after === 'uncertain')
|
|
214
|
+
return { slots: [], coverage: coverage(false) };
|
|
215
|
+
if (after)
|
|
216
|
+
heads.push(after);
|
|
217
|
+
}
|
|
218
|
+
return { slots, coverage: coverage(true) };
|
|
219
|
+
}
|
|
73
220
|
/** The longest slot `slotlock_find_next_available` can look for: the whole search window. */
|
|
74
221
|
const MAX_SLOT_MINUTES = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60;
|
|
75
222
|
const MAX_EVENT_DURATION_MS = SLOTLOCK_MAX_EVENT_DURATION_DAYS * 24 * 60 * 60 * 1_000;
|
|
@@ -95,41 +242,6 @@ const COVERAGE = z
|
|
|
95
242
|
reason: z.string().max(100).nullable(),
|
|
96
243
|
})
|
|
97
244
|
.strict();
|
|
98
|
-
const ORGANIZER = z
|
|
99
|
-
.object({
|
|
100
|
-
address: z.string().email().max(320),
|
|
101
|
-
name: z.string().trim().max(TRUSTED_PERSON_NAME_OUTPUT_MAX).nullable(),
|
|
102
|
-
})
|
|
103
|
-
.strict();
|
|
104
|
-
const ATTENDEE = z
|
|
105
|
-
.object({
|
|
106
|
-
address: z.string().email().max(320),
|
|
107
|
-
name: z.string().trim().max(TRUSTED_PERSON_NAME_OUTPUT_MAX).nullable(),
|
|
108
|
-
role: z.enum(['chair', 'required', 'optional', 'non_participant']),
|
|
109
|
-
participation_status: z.enum([
|
|
110
|
-
'needs_action',
|
|
111
|
-
'accepted',
|
|
112
|
-
'declined',
|
|
113
|
-
'tentative',
|
|
114
|
-
'delegated',
|
|
115
|
-
]),
|
|
116
|
-
rsvp: z.boolean(),
|
|
117
|
-
})
|
|
118
|
-
.strict();
|
|
119
|
-
const REMINDER = z
|
|
120
|
-
.object({
|
|
121
|
-
minutes_before: z.number().int().min(0).max(TRUSTED_REMINDER_MINUTES_OUTPUT_MAX),
|
|
122
|
-
channel: z.enum(['display', 'email']),
|
|
123
|
-
})
|
|
124
|
-
.strict();
|
|
125
|
-
const RECURRENCE_EXCEPTION = z
|
|
126
|
-
.object({
|
|
127
|
-
recurrence_id: DATE_TIME,
|
|
128
|
-
cancelled: z.boolean(),
|
|
129
|
-
starts_at: DATE_TIME.nullable(),
|
|
130
|
-
ends_at: DATE_TIME.nullable(),
|
|
131
|
-
})
|
|
132
|
-
.strict();
|
|
133
245
|
const ORGANIZER_INPUT = z
|
|
134
246
|
.object({
|
|
135
247
|
address: z.string().email().max(320),
|
|
@@ -187,30 +299,6 @@ const MCP_INITIALIZE_PARAMS = z
|
|
|
187
299
|
_meta: z.record(z.string(), z.unknown()).optional(),
|
|
188
300
|
})
|
|
189
301
|
.passthrough();
|
|
190
|
-
const EVENT = z
|
|
191
|
-
.object({
|
|
192
|
-
id: EVENT_ID,
|
|
193
|
-
resource_id: RESOURCE_ID,
|
|
194
|
-
starts_at: DATE_TIME,
|
|
195
|
-
ends_at: DATE_TIME,
|
|
196
|
-
timezone: z.string().min(1).max(100),
|
|
197
|
-
title: z.string().max(TRUSTED_SUMMARY_OUTPUT_MAX).nullable(),
|
|
198
|
-
description: z.string().max(TRUSTED_TEXT_OUTPUT_MAX).nullable(),
|
|
199
|
-
location: z.string().max(TRUSTED_TEXT_OUTPUT_MAX).nullable(),
|
|
200
|
-
organizer: ORGANIZER.nullable(),
|
|
201
|
-
attendees: z.array(ATTENDEE).max(100),
|
|
202
|
-
reminders: z.array(REMINDER).max(20),
|
|
203
|
-
status: z.enum(['tentative', 'confirmed', 'cancelled']),
|
|
204
|
-
transparency: z.enum(['opaque', 'transparent']),
|
|
205
|
-
sequence: z.number().int().nonnegative(),
|
|
206
|
-
revision: REVISION,
|
|
207
|
-
recurrence_rule: z.string().max(TRUSTED_SUMMARY_OUTPUT_MAX).nullable(),
|
|
208
|
-
recurrence_exceptions: z
|
|
209
|
-
.array(RECURRENCE_EXCEPTION)
|
|
210
|
-
.max(TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX),
|
|
211
|
-
recurrence_id: DATE_TIME.nullable(),
|
|
212
|
-
})
|
|
213
|
-
.strict();
|
|
214
302
|
/**
|
|
215
303
|
* Names earlier builds published, which every entry point still accepts and dispatches as the same
|
|
216
304
|
* operation; nothing advertises them. The dotted names came first, before tool names had to be
|
|
@@ -243,6 +331,12 @@ const OPERATION_SCOPES = Object.freeze({
|
|
|
243
331
|
slotlock_list_resources: 'read',
|
|
244
332
|
slotlock_get_free_busy: 'read',
|
|
245
333
|
slotlock_find_next_available: 'read',
|
|
334
|
+
slotlock_find_available_slots: 'read',
|
|
335
|
+
slotlock_hold_slots: 'write',
|
|
336
|
+
slotlock_book_together: 'write',
|
|
337
|
+
slotlock_book_pool: 'write',
|
|
338
|
+
slotlock_get_pool_availability: 'read',
|
|
339
|
+
slotlock_release_slots: 'write',
|
|
246
340
|
slotlock_create_event: 'write',
|
|
247
341
|
slotlock_get_event: 'read',
|
|
248
342
|
slotlock_list_events: 'read',
|
|
@@ -263,11 +357,25 @@ function principalScopesCover(principal, operation) {
|
|
|
263
357
|
export class SlotlockAgentOperationError extends Error {
|
|
264
358
|
code;
|
|
265
359
|
status;
|
|
266
|
-
|
|
360
|
+
/**
|
|
361
|
+
* What the refusal is about, beside its code (the resource a bundle was refused on, say): at most
|
|
362
|
+
* five snake_case names with text values of up to 200 characters; anything else is dropped. Every
|
|
363
|
+
* protocol carries them next to `code`.
|
|
364
|
+
*/
|
|
365
|
+
details;
|
|
366
|
+
constructor(code, status, details) {
|
|
267
367
|
super(code);
|
|
268
368
|
this.name = 'SlotlockAgentOperationError';
|
|
269
369
|
this.code = /^[a-z][a-z0-9_]{0,63}$/.test(code) ? code : 'operation_failed';
|
|
270
|
-
this.status = [400, 403, 404, 409, 429, 503].includes(status) ? status : 500;
|
|
370
|
+
this.status = [400, 403, 404, 409, 410, 429, 503].includes(status) ? status : 500;
|
|
371
|
+
const kept = Object.entries(details ?? {})
|
|
372
|
+
.filter(([name, value]) => /^[a-z][a-z0-9_]{0,39}$/.test(name) &&
|
|
373
|
+
name !== 'code' &&
|
|
374
|
+
typeof value === 'string' &&
|
|
375
|
+
value.length <= 200)
|
|
376
|
+
.slice(0, 5);
|
|
377
|
+
if (kept.length > 0)
|
|
378
|
+
this.details = Object.freeze(Object.fromEntries(kept));
|
|
271
379
|
}
|
|
272
380
|
}
|
|
273
381
|
const EXAMPLE_RESOURCE_ID = '0f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b';
|
|
@@ -290,6 +398,8 @@ const EVENT_CREATE_INPUT = z
|
|
|
290
398
|
reminders: z.array(REMINDER_INPUT).max(20).optional(),
|
|
291
399
|
idempotency_key: IDEMPOTENCY_KEY,
|
|
292
400
|
recurrence_exceptions: z.array(RECURRENCE_EXCEPTION_INPUT).max(500).optional(),
|
|
401
|
+
/** Book the time one of your holds keeps (slotlock_hold_slots); its alternatives are released. */
|
|
402
|
+
hold_id: HOLD_ID.optional(),
|
|
293
403
|
})
|
|
294
404
|
.strict()
|
|
295
405
|
.refine((value) => boundedEventSpan(value.starts_at, value.ends_at));
|
|
@@ -297,11 +407,12 @@ const OPERATION_DEFINITIONS = [
|
|
|
297
407
|
{
|
|
298
408
|
name: 'slotlock_list_resources',
|
|
299
409
|
title: 'List calendar resources',
|
|
300
|
-
description: 'List resources visible to the authenticated tenant principal.',
|
|
410
|
+
description: 'List resources visible to the authenticated tenant principal, with the pool each belongs to (a vehicle class, a room type). With pool, only that pool\'s members, in the order slotlock_book_pool tries them.',
|
|
301
411
|
input: z
|
|
302
412
|
.object({
|
|
303
413
|
cursor: z.string().max(500).optional(),
|
|
304
414
|
limit: z.number().int().min(1).max(100).default(50),
|
|
415
|
+
pool: POOL.optional(),
|
|
305
416
|
})
|
|
306
417
|
.strict(),
|
|
307
418
|
output: z
|
|
@@ -312,6 +423,7 @@ const OPERATION_DEFINITIONS = [
|
|
|
312
423
|
id: RESOURCE_ID,
|
|
313
424
|
external_ref: z.string().max(500).nullable(),
|
|
314
425
|
timezone: z.string().min(1).max(100),
|
|
426
|
+
pool: POOL.optional(),
|
|
315
427
|
})
|
|
316
428
|
.strict())
|
|
317
429
|
.max(100),
|
|
@@ -376,6 +488,40 @@ const OPERATION_DEFINITIONS = [
|
|
|
376
488
|
invoke: 'findNextAvailable',
|
|
377
489
|
example: { resource_ids: [EXAMPLE_RESOURCE_ID], ...EXAMPLE_WINDOW, duration_minutes: 120 },
|
|
378
490
|
},
|
|
491
|
+
{
|
|
492
|
+
name: 'slotlock_find_available_slots',
|
|
493
|
+
title: 'Find available slots',
|
|
494
|
+
description: 'List up to `limit` certain slots of the requested duration, earliest first across the resources, each one free of busy time and inside the resource\'s hours, to offer a person a choice. With require_free, only times when at least that many of the resources are free together, each slot naming them in resource_ids: every resource for a booking that needs them all (book it with slotlock_book_together), or any n of them. No slot is listed unless every resource\'s coverage is certain.',
|
|
495
|
+
input: z
|
|
496
|
+
.object({
|
|
497
|
+
resource_ids: z.array(RESOURCE_ID).min(1).max(MAX_SLOT_RESOURCES),
|
|
498
|
+
start: DATE_TIME,
|
|
499
|
+
end: DATE_TIME,
|
|
500
|
+
duration_minutes: z.number().int().min(1).max(MAX_SLOT_MINUTES),
|
|
501
|
+
limit: z.number().int().min(1).max(MAX_LISTED_SLOTS).default(10),
|
|
502
|
+
require_free: z.number().int().min(1).max(MAX_SLOT_RESOURCES).optional(),
|
|
503
|
+
})
|
|
504
|
+
.strict()
|
|
505
|
+
.refine(boundedAgentWindow),
|
|
506
|
+
output: z
|
|
507
|
+
.object({
|
|
508
|
+
slots: z
|
|
509
|
+
.array(z
|
|
510
|
+
.object({
|
|
511
|
+
resource_id: RESOURCE_ID,
|
|
512
|
+
start: DATE_TIME,
|
|
513
|
+
end: DATE_TIME,
|
|
514
|
+
resource_ids: z.array(RESOURCE_ID).min(1).max(MAX_SLOT_RESOURCES).optional(),
|
|
515
|
+
})
|
|
516
|
+
.strict())
|
|
517
|
+
.max(MAX_LISTED_SLOTS),
|
|
518
|
+
coverage: COVERAGE,
|
|
519
|
+
})
|
|
520
|
+
.strict(),
|
|
521
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
522
|
+
invoke: findAvailableSlots,
|
|
523
|
+
example: { resource_ids: [EXAMPLE_RESOURCE_ID], ...EXAMPLE_WINDOW, duration_minutes: 60, limit: 10 },
|
|
524
|
+
},
|
|
379
525
|
{
|
|
380
526
|
name: 'slotlock_create_event',
|
|
381
527
|
title: 'Create resource event',
|
|
@@ -393,6 +539,86 @@ const OPERATION_DEFINITIONS = [
|
|
|
393
539
|
idempotency_key: 'create-handover-2027-03-02',
|
|
394
540
|
},
|
|
395
541
|
},
|
|
542
|
+
{
|
|
543
|
+
name: 'slotlock_book_pool',
|
|
544
|
+
title: 'Book from a pool',
|
|
545
|
+
description: 'Book any one member of a pool (a vehicle class, a room type) for a time: members are tried in a fixed order and the first that is free and allows the booking is booked, in one transaction, so a class sells out exactly at its number of members. The event names the member booked. 409 pool_full when none is free; 404 pool_not_found when the pool has no member you may book. A retry with the same idempotency_key answers the same event.',
|
|
546
|
+
input: z
|
|
547
|
+
.object({
|
|
548
|
+
pool: POOL,
|
|
549
|
+
starts_at: DATE_TIME,
|
|
550
|
+
ends_at: DATE_TIME,
|
|
551
|
+
timezone: z.string().min(1).max(100),
|
|
552
|
+
title: z.string().trim().min(1).max(500).optional(),
|
|
553
|
+
idempotency_key: IDEMPOTENCY_KEY,
|
|
554
|
+
})
|
|
555
|
+
.strict()
|
|
556
|
+
.refine((value) => boundedEventSpan(value.starts_at, value.ends_at)),
|
|
557
|
+
output: z.object({ event: EVENT, replayed: z.boolean() }).strict(),
|
|
558
|
+
risk: { readOnly: false, destructive: false, idempotent: true },
|
|
559
|
+
invoke: bookPool,
|
|
560
|
+
example: {
|
|
561
|
+
pool: 'van',
|
|
562
|
+
starts_at: '2027-03-02T09:00:00Z',
|
|
563
|
+
ends_at: '2027-03-03T09:00:00Z',
|
|
564
|
+
timezone: 'Europe/London',
|
|
565
|
+
title: 'Van hire',
|
|
566
|
+
idempotency_key: 'van-hire-2027-03-02',
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
name: 'slotlock_get_pool_availability',
|
|
571
|
+
title: 'Get pool availability',
|
|
572
|
+
description: 'How many members of a pool (a vehicle class, a room type) are free across a window, each only where its booking rules let a booking start: the window split where that number changes, each part with how many are free, and coverage certainty. Book one with slotlock_book_pool. 404 pool_not_found when the pool has no member you may book.',
|
|
573
|
+
input: z
|
|
574
|
+
.object({ pool: POOL, start: DATE_TIME, end: DATE_TIME })
|
|
575
|
+
.strict()
|
|
576
|
+
.refine(boundedAgentWindow),
|
|
577
|
+
output: z
|
|
578
|
+
.object({
|
|
579
|
+
pool: POOL,
|
|
580
|
+
members: z.number().int().min(1).max(100),
|
|
581
|
+
intervals: z
|
|
582
|
+
.array(z
|
|
583
|
+
.object({ start: DATE_TIME, end: DATE_TIME, free: z.number().int().min(0).max(100) })
|
|
584
|
+
.strict())
|
|
585
|
+
.max(20_001),
|
|
586
|
+
coverage: COVERAGE,
|
|
587
|
+
})
|
|
588
|
+
.strict(),
|
|
589
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
590
|
+
invoke: getPoolAvailability,
|
|
591
|
+
example: { pool: 'van', ...EXAMPLE_WINDOW },
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
name: 'slotlock_book_together',
|
|
595
|
+
title: 'Book resources together',
|
|
596
|
+
description: 'Book the same time on 2-20 resources at once, all or none: a van with its driver, a room with its projector. If any of them is taken, nothing is booked, and the refusal names that resource_id. Find such a time with slotlock_find_available_slots and require_free. A retry with the same idempotency_key answers the same events.',
|
|
597
|
+
input: z
|
|
598
|
+
.object({
|
|
599
|
+
resource_ids: z.array(RESOURCE_ID).min(2).max(MAX_SLOT_RESOURCES),
|
|
600
|
+
starts_at: DATE_TIME,
|
|
601
|
+
ends_at: DATE_TIME,
|
|
602
|
+
timezone: z.string().min(1).max(100),
|
|
603
|
+
title: z.string().trim().min(1).max(500).optional(),
|
|
604
|
+
idempotency_key: IDEMPOTENCY_KEY,
|
|
605
|
+
})
|
|
606
|
+
.strict()
|
|
607
|
+
.refine((value) => boundedEventSpan(value.starts_at, value.ends_at)),
|
|
608
|
+
output: z
|
|
609
|
+
.object({ events: z.array(EVENT).min(2).max(MAX_SLOT_RESOURCES), replayed: z.boolean() })
|
|
610
|
+
.strict(),
|
|
611
|
+
risk: { readOnly: false, destructive: false, idempotent: true },
|
|
612
|
+
invoke: bookTogether,
|
|
613
|
+
example: {
|
|
614
|
+
resource_ids: [EXAMPLE_RESOURCE_ID, '1f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b'],
|
|
615
|
+
starts_at: '2027-03-02T09:00:00Z',
|
|
616
|
+
ends_at: '2027-03-02T10:00:00Z',
|
|
617
|
+
timezone: 'Europe/London',
|
|
618
|
+
title: 'Handover with driver',
|
|
619
|
+
idempotency_key: 'bundle-handover-2027-03-02',
|
|
620
|
+
},
|
|
621
|
+
},
|
|
396
622
|
{
|
|
397
623
|
name: 'slotlock_get_event',
|
|
398
624
|
title: 'Get resource event',
|
|
@@ -427,7 +653,7 @@ const OPERATION_DEFINITIONS = [
|
|
|
427
653
|
{
|
|
428
654
|
name: 'slotlock_update_event',
|
|
429
655
|
title: 'Update resource event',
|
|
430
|
-
description: 'Apply an expected-revision event patch and rematerialize occupancy atomically.',
|
|
656
|
+
description: 'Apply an expected-revision event patch and rematerialize occupancy atomically. With expected_resource_id, the resource you read the event on, an event since moved elsewhere is not found.',
|
|
431
657
|
input: z
|
|
432
658
|
.object({
|
|
433
659
|
event_id: EVENT_ID,
|
|
@@ -447,6 +673,8 @@ const OPERATION_DEFINITIONS = [
|
|
|
447
673
|
recurrence_rule: RECURRENCE_RULE.nullable().optional(),
|
|
448
674
|
recurrence_exceptions: z.array(RECURRENCE_EXCEPTION_INPUT).max(500).optional(),
|
|
449
675
|
idempotency_key: IDEMPOTENCY_KEY,
|
|
676
|
+
// Last, so the generated clients that take optional arguments by position keep their order.
|
|
677
|
+
expected_resource_id: RESOURCE_ID.optional(),
|
|
450
678
|
})
|
|
451
679
|
.strict()
|
|
452
680
|
.refine((value) => value.starts_at !== undefined ||
|
|
@@ -504,8 +732,65 @@ const OPERATION_DEFINITIONS = [
|
|
|
504
732
|
idempotency_key: 'cancel-handover-2027-03-02',
|
|
505
733
|
},
|
|
506
734
|
},
|
|
735
|
+
{
|
|
736
|
+
name: 'slotlock_hold_slots',
|
|
737
|
+
title: 'Hold candidate slots',
|
|
738
|
+
description: 'Hold up to five candidate slots for a short time (30-900 seconds) while a person chooses, as alternatives in one group: each free one is held, the others are listed with why not. Book one by passing its hold_id to slotlock_create_event, which releases the rest; unbooked holds expire on their own. A hold is yours alone, and you may have 20 live at once.',
|
|
739
|
+
input: z
|
|
740
|
+
.object({
|
|
741
|
+
slots: z
|
|
742
|
+
.array(z
|
|
743
|
+
.object({ resource_id: RESOURCE_ID, start: DATE_TIME, end: DATE_TIME })
|
|
744
|
+
.strict()
|
|
745
|
+
.refine((value) => boundedEventSpan(value.start, value.end)))
|
|
746
|
+
.min(1)
|
|
747
|
+
.max(5),
|
|
748
|
+
ttl_seconds: z.number().int().min(30).max(900).default(300),
|
|
749
|
+
idempotency_key: IDEMPOTENCY_KEY,
|
|
750
|
+
})
|
|
751
|
+
.strict(),
|
|
752
|
+
output: z
|
|
753
|
+
.object({
|
|
754
|
+
group_id: HOLD_ID,
|
|
755
|
+
holds: z
|
|
756
|
+
.array(z
|
|
757
|
+
.object({
|
|
758
|
+
hold_id: HOLD_ID,
|
|
759
|
+
resource_id: RESOURCE_ID,
|
|
760
|
+
start: DATE_TIME,
|
|
761
|
+
end: DATE_TIME,
|
|
762
|
+
expires_at: DATE_TIME,
|
|
763
|
+
})
|
|
764
|
+
.strict())
|
|
765
|
+
.max(5),
|
|
766
|
+
refused: z
|
|
767
|
+
.array(z.object({ index: z.number().int().min(0).max(4), code: z.string().max(64) }).strict())
|
|
768
|
+
.max(5),
|
|
769
|
+
})
|
|
770
|
+
.strict(),
|
|
771
|
+
risk: { readOnly: false, destructive: false, idempotent: true },
|
|
772
|
+
invoke: holdSlots,
|
|
773
|
+
example: {
|
|
774
|
+
slots: [
|
|
775
|
+
{ resource_id: EXAMPLE_RESOURCE_ID, start: '2027-03-02T09:00:00Z', end: '2027-03-02T10:00:00Z' },
|
|
776
|
+
{ resource_id: EXAMPLE_RESOURCE_ID, start: '2027-03-02T14:00:00Z', end: '2027-03-02T15:00:00Z' },
|
|
777
|
+
],
|
|
778
|
+
ttl_seconds: 300,
|
|
779
|
+
idempotency_key: 'hold-handover-2027-03-02',
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
name: 'slotlock_release_slots',
|
|
784
|
+
title: 'Release held slots',
|
|
785
|
+
description: 'Release one of your holds, and the rest of its group, at once instead of letting them expire. `released` is false when it was already gone.',
|
|
786
|
+
input: z.object({ hold_id: HOLD_ID }).strict(),
|
|
787
|
+
output: z.object({ released: z.boolean() }).strict(),
|
|
788
|
+
risk: { readOnly: false, destructive: false, idempotent: true },
|
|
789
|
+
invoke: releaseSlots,
|
|
790
|
+
example: { hold_id: '7d3f8c2a-4b1e-4f6a-9c2d-1e5b7a9f3c4d' },
|
|
791
|
+
},
|
|
507
792
|
];
|
|
508
|
-
const FORMATS_WITHOUT_PATTERN = new Set(['date-time', 'email']);
|
|
793
|
+
const FORMATS_WITHOUT_PATTERN = new Set(['date-time', 'email', 'uuid']);
|
|
509
794
|
function toJsonSchema(schema, io) {
|
|
510
795
|
// Zod's own converter: zod-to-json-schema reads only Zod 3 schemas (for a Zod 4 one it returns {})
|
|
511
796
|
// and its repository is archived. A type JSON Schema cannot express throws rather than vanishing.
|
|
@@ -514,7 +799,7 @@ function toJsonSchema(schema, io) {
|
|
|
514
799
|
io,
|
|
515
800
|
unrepresentable: 'throw',
|
|
516
801
|
override: ({ jsonSchema }) => {
|
|
517
|
-
// Zod 4.6 writes its validation regex beside `format` for dates and
|
|
802
|
+
// Zod 4.6 writes its validation regex beside `format` for dates, emails and UUIDs (288 and 102
|
|
518
803
|
// characters, on 60 fields): the format already says it, and every agent reads the list.
|
|
519
804
|
if (FORMATS_WITHOUT_PATTERN.has(String(jsonSchema.format)))
|
|
520
805
|
delete jsonSchema.pattern;
|
|
@@ -603,18 +888,32 @@ export function slotlockAgentTools() {
|
|
|
603
888
|
idempotentHint: operation.risk.idempotent,
|
|
604
889
|
openWorldHint: false,
|
|
605
890
|
},
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
891
|
+
// Reads render in the calendar view, which may call them again. A write is the model's to make
|
|
892
|
+
// through the normal path, where a person can be asked to confirm it: never a view's, which
|
|
893
|
+
// MCP Apps would otherwise allow (visibility defaults to model and app).
|
|
894
|
+
// Held slots render there too, to pick from, but only the model holds.
|
|
895
|
+
_meta: {
|
|
896
|
+
ui: operation.risk.readOnly
|
|
897
|
+
? { resourceUri: SLOTLOCK_MCP_APP_RESOURCE_URI, visibility: ['model', 'app'] }
|
|
898
|
+
: operation.name === 'slotlock_hold_slots'
|
|
899
|
+
? { resourceUri: SLOTLOCK_MCP_APP_RESOURCE_URI, visibility: ['model'] }
|
|
900
|
+
: { visibility: ['model'] },
|
|
901
|
+
},
|
|
616
902
|
}));
|
|
617
903
|
}
|
|
904
|
+
/** The resource and interval a refused write asked for, as an overlap's problem details name them. */
|
|
905
|
+
function refusedInterval(input) {
|
|
906
|
+
const named = {};
|
|
907
|
+
const fields = [
|
|
908
|
+
['resource_id', input.resource_id],
|
|
909
|
+
['start', input.starts_at ?? input.start],
|
|
910
|
+
['end', input.ends_at ?? input.end],
|
|
911
|
+
];
|
|
912
|
+
for (const [name, value] of fields)
|
|
913
|
+
if (typeof value === 'string')
|
|
914
|
+
named[name] = value;
|
|
915
|
+
return named;
|
|
916
|
+
}
|
|
618
917
|
function json(status, body, extraHeaders = {}) {
|
|
619
918
|
return new Response(JSON.stringify(body), {
|
|
620
919
|
status,
|
|
@@ -874,7 +1173,11 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
874
1173
|
if (halt)
|
|
875
1174
|
return { ok: false, status: halt.status, code: halt.code };
|
|
876
1175
|
}
|
|
877
|
-
const
|
|
1176
|
+
const invoke = operation.invoke;
|
|
1177
|
+
const backend = args.options.backend;
|
|
1178
|
+
const method = typeof invoke === 'function'
|
|
1179
|
+
? (context, input) => invoke(backend, context, input)
|
|
1180
|
+
: backend[invoke];
|
|
878
1181
|
const context = {
|
|
879
1182
|
principal,
|
|
880
1183
|
operation: operation.name,
|
|
@@ -882,7 +1185,7 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
882
1185
|
};
|
|
883
1186
|
if (args.trace)
|
|
884
1187
|
context.trace = args.trace;
|
|
885
|
-
const data = await method.call(
|
|
1188
|
+
const data = await method.call(backend, context, parsed.data);
|
|
886
1189
|
if (!operation.output.safeParse(data).success) {
|
|
887
1190
|
// Neither the result nor the schema's complaint about it: either may hold tenant data.
|
|
888
1191
|
reportUnexpected(args.options.onError, Object.assign(new Error(`The backend's ${operation.name} result does not match the operation's output schema`), { code: 'invalid_backend_result' }), resolvedOperation);
|
|
@@ -892,7 +1195,12 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
892
1195
|
}
|
|
893
1196
|
catch (error) {
|
|
894
1197
|
if (error instanceof SlotlockAgentOperationError) {
|
|
895
|
-
return {
|
|
1198
|
+
return {
|
|
1199
|
+
ok: false,
|
|
1200
|
+
status: error.status,
|
|
1201
|
+
code: error.code,
|
|
1202
|
+
...(error.details ? { details: error.details } : {}),
|
|
1203
|
+
};
|
|
896
1204
|
}
|
|
897
1205
|
reportUnexpected(args.options.onError, error, resolvedOperation);
|
|
898
1206
|
return { ok: false, status: 500, code: 'internal_error' };
|
|
@@ -1152,12 +1460,14 @@ function validateSubscriptions(value) {
|
|
|
1152
1460
|
}
|
|
1153
1461
|
return setting;
|
|
1154
1462
|
};
|
|
1463
|
+
const maxTotal = bounded('maxTotal', 1, 10_000, 256);
|
|
1155
1464
|
return {
|
|
1156
1465
|
pollIntervalMs: bounded('pollIntervalMs', 1_000, 300_000, 10_000),
|
|
1157
1466
|
maxDurationMs: bounded('maxDurationMs', 60_000, 86_400_000, 900_000),
|
|
1158
1467
|
keepAliveMs: bounded('keepAliveMs', 1_000, 60_000, 15_000),
|
|
1159
1468
|
maxPerPrincipal: bounded('maxPerPrincipal', 1, 64, 4),
|
|
1160
|
-
|
|
1469
|
+
maxPerTenant: bounded('maxPerTenant', 1, 10_000, maxTotal),
|
|
1470
|
+
maxTotal,
|
|
1161
1471
|
};
|
|
1162
1472
|
}
|
|
1163
1473
|
/**
|
|
@@ -1203,13 +1513,15 @@ function unref(timer) {
|
|
|
1203
1513
|
/** Natural-language guidance `server/discover` gives the model on how to use these tools well. */
|
|
1204
1514
|
const SLOTLOCK_MCP_INSTRUCTIONS = [
|
|
1205
1515
|
'Slotlock is a tenant-isolated resource calendar (vehicles, rooms, equipment).',
|
|
1206
|
-
'Check availability before booking: slotlock_get_free_busy returns busy intervals and slotlock_find_next_available the earliest free slot of a given length; an answer is certain only when coverage.certainty is "certain".',
|
|
1516
|
+
'Check availability before booking: slotlock_get_free_busy returns busy intervals and slotlock_find_next_available the earliest free slot of a given length, and slotlock_find_available_slots up to 50 of them to offer a person a choice; an answer is certain only when coverage.certainty is "certain".',
|
|
1207
1517
|
'slotlock_create_event needs an idempotency_key: retry with the same key and arguments, never a new key for the same booking. slotlock_update_event and slotlock_delete_event need the event revision you last read as expected_revision.',
|
|
1208
1518
|
'Times are RFC 3339 with an offset; intervals are half-open [start, end).',
|
|
1209
1519
|
'A failed call returns {"error":{"code":"…"}}; read the code before retrying (overlap means the slot is taken).',
|
|
1210
1520
|
].join(' ');
|
|
1211
1521
|
const WRITE_OPERATIONS = new Set([
|
|
1212
1522
|
'slotlock_create_event',
|
|
1523
|
+
'slotlock_book_together',
|
|
1524
|
+
'slotlock_book_pool',
|
|
1213
1525
|
'slotlock_update_event',
|
|
1214
1526
|
'slotlock_delete_event',
|
|
1215
1527
|
]);
|
|
@@ -1242,7 +1554,15 @@ function validateConfirmation(value) {
|
|
|
1242
1554
|
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 30 || ttlSeconds > 3_600) {
|
|
1243
1555
|
throw invalid('ttlSeconds');
|
|
1244
1556
|
}
|
|
1245
|
-
|
|
1557
|
+
if (value.consumeAcceptance !== undefined && typeof value.consumeAcceptance !== 'function') {
|
|
1558
|
+
throw invalid('consumeAcceptance');
|
|
1559
|
+
}
|
|
1560
|
+
return {
|
|
1561
|
+
operations: new Set(value.operations),
|
|
1562
|
+
keys,
|
|
1563
|
+
ttlMs: ttlSeconds * 1_000,
|
|
1564
|
+
...(value.consumeAcceptance ? { consumeAcceptance: value.consumeAcceptance } : {}),
|
|
1565
|
+
};
|
|
1246
1566
|
}
|
|
1247
1567
|
/** An empty `elicitation` object declares form mode (2026-07-28 client/elicitation §Capabilities). */
|
|
1248
1568
|
function clientSupportsFormElicitation(capabilities) {
|
|
@@ -1451,10 +1771,14 @@ function promptSeries(series, zone) {
|
|
|
1451
1771
|
const booked = expandCalendarEventOccurrences(content, horizon);
|
|
1452
1772
|
beyond = promptSeriesEnd(series.rule, booked.length, horizon.end);
|
|
1453
1773
|
const length = end.getTime() - start.getTime();
|
|
1774
|
+
// Moved: by an exception, to another time or length. One the clocks change inside, or that
|
|
1775
|
+
// the store ends where the next starts, is where its rule puts it.
|
|
1776
|
+
const excepted = new Set(exceptions.flatMap((exception) => exception.cancelled !== true && exception.start ? [exception.recurrenceId.getTime()] : []));
|
|
1454
1777
|
moved = promptMoved(booked.flatMap((occurrence) => {
|
|
1455
1778
|
const from = new Date(occurrence.recurrenceId);
|
|
1456
|
-
const shifted =
|
|
1457
|
-
occurrence.
|
|
1779
|
+
const shifted = excepted.has(from.getTime()) &&
|
|
1780
|
+
(occurrence.start.getTime() !== from.getTime() ||
|
|
1781
|
+
occurrence.end.getTime() - occurrence.start.getTime() !== length);
|
|
1458
1782
|
return shifted && Number.isFinite(from.getTime())
|
|
1459
1783
|
? [{ from, start: occurrence.start, end: occurrence.end }]
|
|
1460
1784
|
: [];
|
|
@@ -1595,6 +1919,23 @@ function confirmationLookup(options, context) {
|
|
|
1595
1919
|
* part has its own bound, so nothing is cut from the end.
|
|
1596
1920
|
*/
|
|
1597
1921
|
async function confirmationMessage(operation, input, lookup) {
|
|
1922
|
+
if (operation === 'slotlock_book_pool') {
|
|
1923
|
+
const zone = promptZone([[input.timezone, false]]);
|
|
1924
|
+
const title = typeof input.title === 'string' && input.title.length > 0 ? `: ${promptQuoted(input.title)}` : '';
|
|
1925
|
+
return `Book any one of pool ${promptText(input.pool, 100)} for ${promptRange(promptInstantOf(input.starts_at), promptInstantOf(input.ends_at), zone)}${title}.`;
|
|
1926
|
+
}
|
|
1927
|
+
if (operation === 'slotlock_book_together') {
|
|
1928
|
+
const ids = Array.isArray(input.resource_ids) ? input.resource_ids : [];
|
|
1929
|
+
const resources = await Promise.all(ids.map((id) => lookup.resource(id)));
|
|
1930
|
+
const zone = promptZone([
|
|
1931
|
+
...resources.map((resource) => [resource.timezone, true]),
|
|
1932
|
+
[input.timezone, false],
|
|
1933
|
+
]);
|
|
1934
|
+
const labels = resources.map((resource) => resource.label);
|
|
1935
|
+
const all = labels.length > 1 ? `${labels.slice(0, -1).join(', ')} and ${labels.at(-1)}` : (labels[0] ?? '');
|
|
1936
|
+
const title = typeof input.title === 'string' && input.title.length > 0 ? `: ${promptQuoted(input.title)}` : '';
|
|
1937
|
+
return `Book ${all} together, all or none, for ${promptRange(promptInstantOf(input.starts_at), promptInstantOf(input.ends_at), zone)}${title}.`;
|
|
1938
|
+
}
|
|
1598
1939
|
if (operation === 'slotlock_create_event') {
|
|
1599
1940
|
const resource = await lookup.resource(input.resource_id);
|
|
1600
1941
|
const zone = promptZone([
|
|
@@ -1673,8 +2014,18 @@ async function confirmationMessage(operation, input, lookup) {
|
|
|
1673
2014
|
changes.push('no longer repeats');
|
|
1674
2015
|
}
|
|
1675
2016
|
else if (typeof rule === 'string' && reshaped && seriesStart && seriesEnd) {
|
|
1676
|
-
const seriesZone = input.timezone ?? event?.timezone
|
|
1677
|
-
|
|
2017
|
+
const seriesZone = input.timezone ?? event?.timezone;
|
|
2018
|
+
if (seriesZone === undefined) {
|
|
2019
|
+
// An unread event and no new zone: the store expands in the event's own, which the question
|
|
2020
|
+
// cannot know, so it states no dates.
|
|
2021
|
+
changes.push(`repeats ${promptRule(rule)}, on dates that follow the event's existing time zone`);
|
|
2022
|
+
if (input.recurrence_exceptions !== undefined) {
|
|
2023
|
+
changes.push(`exceptions to ${promptRequestedExceptions(input.recurrence_exceptions, zone)}`);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
else {
|
|
2027
|
+
changes.push(`so it books ${promptSeries({ start: seriesStart, end: seriesEnd, timezone: seriesZone, rule, exceptions }, zone)}`);
|
|
2028
|
+
}
|
|
1678
2029
|
}
|
|
1679
2030
|
else {
|
|
1680
2031
|
if (typeof input.recurrence_rule === 'string') {
|
|
@@ -1771,6 +2122,22 @@ function a2aError(id, code, message, info) {
|
|
|
1771
2122
|
},
|
|
1772
2123
|
});
|
|
1773
2124
|
}
|
|
2125
|
+
/**
|
|
2126
|
+
* The request's principal, or the error when `authenticate` throws a 503
|
|
2127
|
+
* `SlotlockAgentOperationError`, as createSlotlockApiKeyAuthenticator does when its lookup queue is
|
|
2128
|
+
* full: no answer about the credential, so the client is told to retry rather than that it is
|
|
2129
|
+
* anonymous.
|
|
2130
|
+
*/
|
|
2131
|
+
async function principalOrBusy(authenticate, request) {
|
|
2132
|
+
try {
|
|
2133
|
+
return await authenticate(request);
|
|
2134
|
+
}
|
|
2135
|
+
catch (error) {
|
|
2136
|
+
if (error instanceof SlotlockAgentOperationError && error.status === 503)
|
|
2137
|
+
return error;
|
|
2138
|
+
throw error;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
1774
2141
|
/** Map a request path onto the server's base path; `null` when it lies outside it. */
|
|
1775
2142
|
function routePath(pathname, basePath) {
|
|
1776
2143
|
if (basePath === '')
|
|
@@ -1978,6 +2345,17 @@ export function createSlotlockAgentServer(options) {
|
|
|
1978
2345
|
operation,
|
|
1979
2346
|
argsDigest: gate.argsDigest,
|
|
1980
2347
|
});
|
|
2348
|
+
if (gate.decision === 'accepted' && confirmation.consumeAcceptance) {
|
|
2349
|
+
// Once only: the same accepted state sent again is refused, on any server.
|
|
2350
|
+
const state = openRequestState(confirmation.keys, params.requestState)?.payload;
|
|
2351
|
+
const nonce = state?.nonce;
|
|
2352
|
+
if (typeof nonce !== 'string' || typeof state?.exp !== 'number') {
|
|
2353
|
+
gate.decision = 'invalid';
|
|
2354
|
+
}
|
|
2355
|
+
else if (!(await confirmation.consumeAcceptance({ nonce, expiresAt: new Date(state.exp) }))) {
|
|
2356
|
+
gate.decision = 'invalid';
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
1981
2359
|
// Before the call it decides, as on every other protocol.
|
|
1982
2360
|
emit({ type: 'confirmation', operation, outcome: CONFIRMATION_OUTCOME[gate.decision] });
|
|
1983
2361
|
return gate.decision === 'accepted' ? null : CONFIRMATION_HALT[gate.decision];
|
|
@@ -2035,7 +2413,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2035
2413
|
if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
|
|
2036
2414
|
return modernError(500, id, -32603, 'Internal error');
|
|
2037
2415
|
}
|
|
2038
|
-
return modernResult(id, slotlockMcpToolResult(outcome
|
|
2416
|
+
return modernResult(id, slotlockMcpToolResult(outcome));
|
|
2039
2417
|
}
|
|
2040
2418
|
/** A backend failure behind a resources/* or subscriptions/* request, as its JSON-RPC error. */
|
|
2041
2419
|
const modernFailure = (id, outcome) => {
|
|
@@ -2185,15 +2563,48 @@ export function createSlotlockAgentServer(options) {
|
|
|
2185
2563
|
return modernError(503, id, -32603, 'Server is shutting down');
|
|
2186
2564
|
const principalKey = `${principal.tenantRef}\0${principal.subject}`;
|
|
2187
2565
|
let held = 0;
|
|
2188
|
-
|
|
2566
|
+
let heldByTenant = 0;
|
|
2567
|
+
for (const open of openSubscriptions) {
|
|
2189
2568
|
if (open.principalKey === principalKey)
|
|
2190
2569
|
held += 1;
|
|
2570
|
+
if (open.tenantRef === principal.tenantRef)
|
|
2571
|
+
heldByTenant += 1;
|
|
2572
|
+
}
|
|
2573
|
+
if (openSubscriptions.size >= subscriptionPolicy.maxTotal &&
|
|
2574
|
+
held < subscriptionPolicy.maxPerPrincipal &&
|
|
2575
|
+
heldByTenant < subscriptionPolicy.maxPerTenant) {
|
|
2576
|
+
// A full server is shared out: a tenant holding fewer than the one holding most, less one,
|
|
2577
|
+
// ends that tenant's oldest stream, so many tenants within their share cannot keep another
|
|
2578
|
+
// from holding any. A slot still opening has nothing to end yet.
|
|
2579
|
+
const streamsOf = new Map();
|
|
2580
|
+
for (const open of openSubscriptions) {
|
|
2581
|
+
if (!open.streaming)
|
|
2582
|
+
continue;
|
|
2583
|
+
const streams = streamsOf.get(open.tenantRef);
|
|
2584
|
+
if (streams)
|
|
2585
|
+
streams.push(open);
|
|
2586
|
+
else
|
|
2587
|
+
streamsOf.set(open.tenantRef, [open]);
|
|
2588
|
+
}
|
|
2589
|
+
let most = [];
|
|
2590
|
+
for (const streams of streamsOf.values())
|
|
2591
|
+
if (streams.length > most.length)
|
|
2592
|
+
most = streams;
|
|
2593
|
+
if (most.length > heldByTenant + 1)
|
|
2594
|
+
most[0]?.end('preempted');
|
|
2595
|
+
}
|
|
2191
2596
|
if (openSubscriptions.size >= subscriptionPolicy.maxTotal ||
|
|
2192
|
-
held >= subscriptionPolicy.maxPerPrincipal
|
|
2597
|
+
held >= subscriptionPolicy.maxPerPrincipal ||
|
|
2598
|
+
heldByTenant >= subscriptionPolicy.maxPerTenant) {
|
|
2193
2599
|
return modernError(429, id, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, 'Too many subscriptions');
|
|
2194
2600
|
}
|
|
2195
2601
|
// The slot is held while access is checked, so concurrent opens cannot overshoot the caps.
|
|
2196
|
-
const slot = {
|
|
2602
|
+
const slot = {
|
|
2603
|
+
principalKey,
|
|
2604
|
+
tenantRef: principal.tenantRef,
|
|
2605
|
+
streaming: false,
|
|
2606
|
+
end: () => { },
|
|
2607
|
+
};
|
|
2197
2608
|
openSubscriptions.add(slot);
|
|
2198
2609
|
let streaming = false;
|
|
2199
2610
|
try {
|
|
@@ -2257,6 +2668,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2257
2668
|
});
|
|
2258
2669
|
}
|
|
2259
2670
|
slot.end = end;
|
|
2671
|
+
slot.streaming = true;
|
|
2260
2672
|
stream.send({
|
|
2261
2673
|
jsonrpc: '2.0',
|
|
2262
2674
|
method: 'notifications/subscriptions/acknowledged',
|
|
@@ -2432,7 +2844,12 @@ export function createSlotlockAgentServer(options) {
|
|
|
2432
2844
|
}
|
|
2433
2845
|
catch (error) {
|
|
2434
2846
|
if (error instanceof SlotlockAgentOperationError) {
|
|
2435
|
-
return {
|
|
2847
|
+
return {
|
|
2848
|
+
ok: false,
|
|
2849
|
+
status: error.status,
|
|
2850
|
+
code: error.code,
|
|
2851
|
+
...(error.details ? { details: error.details } : {}),
|
|
2852
|
+
};
|
|
2436
2853
|
}
|
|
2437
2854
|
reportUnexpected(options.onError, error, definition.name);
|
|
2438
2855
|
return { ok: false, status: 500, code: 'internal_error' };
|
|
@@ -2444,7 +2861,12 @@ export function createSlotlockAgentServer(options) {
|
|
|
2444
2861
|
* for, so a guarded write answers 428 `confirmation_required`.
|
|
2445
2862
|
*/
|
|
2446
2863
|
async function restCall(request, path, url) {
|
|
2447
|
-
|
|
2864
|
+
// Every refusal is RFC 9457 problem details, still carrying `error.code`.
|
|
2865
|
+
const refuse = (status, code, headers = {}, extensions = {}) => json(status, slotlockProblem(status, code, extensions), {
|
|
2866
|
+
'Content-Type': 'application/problem+json',
|
|
2867
|
+
Link: `<${SLOTLOCK_PROBLEM_DOCS}>; rel="describedby"; type="text/html"`,
|
|
2868
|
+
...headers,
|
|
2869
|
+
});
|
|
2448
2870
|
if (path === '/openapi.json') {
|
|
2449
2871
|
return request.method === 'GET'
|
|
2450
2872
|
? json(200, openApi)
|
|
@@ -2463,7 +2885,10 @@ export function createSlotlockAgentServer(options) {
|
|
|
2463
2885
|
return refuse(403, 'origin_not_allowed');
|
|
2464
2886
|
}
|
|
2465
2887
|
}
|
|
2466
|
-
const principal = await options.authenticate
|
|
2888
|
+
const principal = await principalOrBusy(options.authenticate, request);
|
|
2889
|
+
if (principal instanceof SlotlockAgentOperationError) {
|
|
2890
|
+
return refuse(503, principal.code, { 'Retry-After': '1' });
|
|
2891
|
+
}
|
|
2467
2892
|
if (!principal ||
|
|
2468
2893
|
!isCanonicalPrincipalIdentity(principal.subject) ||
|
|
2469
2894
|
!isCanonicalPrincipalIdentity(principal.tenantRef)) {
|
|
@@ -2516,6 +2941,28 @@ export function createSlotlockAgentServer(options) {
|
|
|
2516
2941
|
if (Object.keys(params).some((name) => Object.hasOwn(given, name))) {
|
|
2517
2942
|
return refuseCall(400, 'invalid_arguments');
|
|
2518
2943
|
}
|
|
2944
|
+
// A write's retry identity may come as the Idempotency-Key header (a Structured Field String,
|
|
2945
|
+
// draft-ietf-httpapi-idempotency-key-header-08), where HTTP clients put it, as well as its
|
|
2946
|
+
// `idempotency_key` argument. On an operation that keeps none it is refused rather than
|
|
2947
|
+
// ignored: a key that was dropped would promise a replay it never gives.
|
|
2948
|
+
const inputSchema = restInputSchemas.get(route.operation);
|
|
2949
|
+
const idempotencyHeader = request.headers.get('idempotency-key');
|
|
2950
|
+
if (idempotencyHeader !== null) {
|
|
2951
|
+
if (!Object.hasOwn(inputSchema?.properties ?? {}, 'idempotency_key')) {
|
|
2952
|
+
return refuseCall(400, 'idempotency_key_not_supported');
|
|
2953
|
+
}
|
|
2954
|
+
const key = parseIdempotencyKeyHeader(idempotencyHeader);
|
|
2955
|
+
if (key === null)
|
|
2956
|
+
return refuseCall(400, 'invalid_idempotency_key');
|
|
2957
|
+
if (Object.hasOwn(given, 'idempotency_key') && given.idempotency_key !== key) {
|
|
2958
|
+
return refuseCall(400, 'invalid_arguments');
|
|
2959
|
+
}
|
|
2960
|
+
given = { ...given, idempotency_key: key };
|
|
2961
|
+
}
|
|
2962
|
+
else if (inputSchema?.required?.includes('idempotency_key') &&
|
|
2963
|
+
!Object.hasOwn(given, 'idempotency_key')) {
|
|
2964
|
+
return refuseCall(400, 'idempotency_key_required');
|
|
2965
|
+
}
|
|
2519
2966
|
const outcome = isSlotlockApplicationOperation(route.operation)
|
|
2520
2967
|
? await invokeApplicationOperation(slotlockApplicationOperation(route.operation), principal, { ...given, ...params }, request, trace)
|
|
2521
2968
|
: await invokeSlotlockAgentOperation({
|
|
@@ -2530,7 +2977,16 @@ export function createSlotlockAgentServer(options) {
|
|
|
2530
2977
|
report(status);
|
|
2531
2978
|
if (outcome.ok)
|
|
2532
2979
|
return json(200, outcome.data);
|
|
2533
|
-
|
|
2980
|
+
if (internal)
|
|
2981
|
+
return refuse(status, 'internal_error');
|
|
2982
|
+
// The same key with another request is 422 (the Idempotency-Key draft); on MCP and A2A, where
|
|
2983
|
+
// no status is seen, it is the same code.
|
|
2984
|
+
if (outcome.code === 'idempotency_conflict')
|
|
2985
|
+
return refuse(422, outcome.code);
|
|
2986
|
+
return refuse(status, outcome.code, {}, {
|
|
2987
|
+
...(outcome.code === 'overlap' ? refusedInterval(given) : {}),
|
|
2988
|
+
...outcome.details,
|
|
2989
|
+
});
|
|
2534
2990
|
}
|
|
2535
2991
|
return {
|
|
2536
2992
|
manifest,
|
|
@@ -2576,6 +3032,9 @@ export function createSlotlockAgentServer(options) {
|
|
|
2576
3032
|
return json(health.ready ? 200 : 503, {
|
|
2577
3033
|
status: health.ready ? 'ready' : 'not_ready',
|
|
2578
3034
|
version: SLOTLOCK_AGENT_SERVER_VERSION,
|
|
3035
|
+
// The time zone rules this process resolves zones with: when they change, maintenance
|
|
3036
|
+
// finds the bookings whose wall time moved (store.followZoneRules).
|
|
3037
|
+
tzdata: process.versions.tz ?? null,
|
|
2579
3038
|
checks: [...health.checks]
|
|
2580
3039
|
.slice(0, 100)
|
|
2581
3040
|
.map((check) => (/^[a-z][a-z0-9_-]{0,63}$/.test(check) ? check : 'invalid')),
|
|
@@ -2585,6 +3044,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2585
3044
|
return json(503, {
|
|
2586
3045
|
status: 'not_ready',
|
|
2587
3046
|
version: SLOTLOCK_AGENT_SERVER_VERSION,
|
|
3047
|
+
tzdata: process.versions.tz ?? null,
|
|
2588
3048
|
checks: [],
|
|
2589
3049
|
});
|
|
2590
3050
|
}
|
|
@@ -2607,7 +3067,17 @@ export function createSlotlockAgentServer(options) {
|
|
|
2607
3067
|
return json(403, rpcError(null, -32600, 'Origin not allowed'));
|
|
2608
3068
|
}
|
|
2609
3069
|
}
|
|
2610
|
-
const principal = await options.authenticate
|
|
3070
|
+
const principal = await principalOrBusy(options.authenticate, request);
|
|
3071
|
+
if (principal instanceof SlotlockAgentOperationError) {
|
|
3072
|
+
// As `slotlock serve` answers it: A2A names the reason in an ErrorInfo, as for a rate limit.
|
|
3073
|
+
const error = { code: -32603, message: 'Server is busy' };
|
|
3074
|
+
const info = {
|
|
3075
|
+
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
|
3076
|
+
reason: 'SERVER_BUSY',
|
|
3077
|
+
domain: 'slotlock',
|
|
3078
|
+
};
|
|
3079
|
+
return json(503, { jsonrpc: '2.0', id: null, error: path === '/a2a' ? { ...error, data: [info] } : error }, { 'Retry-After': '1' });
|
|
3080
|
+
}
|
|
2611
3081
|
if (!principal ||
|
|
2612
3082
|
!isCanonicalPrincipalIdentity(principal.subject) ||
|
|
2613
3083
|
!isCanonicalPrincipalIdentity(principal.tenantRef)) {
|
|
@@ -2752,7 +3222,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2752
3222
|
if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
|
|
2753
3223
|
return json(500, rpcError(id, -32603, 'Internal error'));
|
|
2754
3224
|
}
|
|
2755
|
-
return json(200, rpcResult(id, slotlockMcpToolResult(outcome
|
|
3225
|
+
return json(200, rpcResult(id, slotlockMcpToolResult(outcome)));
|
|
2756
3226
|
}
|
|
2757
3227
|
catch {
|
|
2758
3228
|
return json(500, rpcError(id, -32603, 'Internal error'));
|
|
@@ -2874,7 +3344,9 @@ export function createSlotlockAgentServer(options) {
|
|
|
2874
3344
|
role: 'ROLE_AGENT',
|
|
2875
3345
|
parts: [
|
|
2876
3346
|
{
|
|
2877
|
-
data: outcome.ok
|
|
3347
|
+
data: outcome.ok
|
|
3348
|
+
? outcome.data
|
|
3349
|
+
: { error: { ...outcome.details, code: outcome.code } },
|
|
2878
3350
|
mediaType: 'application/json',
|
|
2879
3351
|
},
|
|
2880
3352
|
],
|