slotlock 0.3.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 +252 -0
- package/README.md +226 -17
- package/agent-server.d.ts +51 -8
- package/agent-server.js +478 -36
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +771 -30
- package/application-api.d.ts +22 -1
- package/application-api.js +412 -3
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +20 -1
- package/config.js +33 -0
- package/dashboard.d.ts +61 -5
- package/dashboard.js +995 -139
- package/ddl.d.ts +5 -5
- package/ddl.js +590 -5
- package/engine.d.ts +5 -0
- package/engine.js +7 -4
- package/event-feed.d.ts +178 -0
- package/event-feed.js +435 -0
- package/index.d.ts +8 -2
- package/index.js +6 -1
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +14 -2
- package/maintenance.js +35 -3
- 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/openapi.json +8346 -3400
- package/package.json +1 -1
- package/rate-limit.d.ts +10 -1
- package/rate-limit.js +25 -2
- package/rest-api.d.ts +22 -0
- package/rest-api.js +160 -15
- package/self-host.d.ts +26 -3
- package/self-host.js +316 -19
- package/slotlock-manifest.schema.json +1 -0
- package/slotlock.manifest.json +2 -1
- package/store.d.ts +317 -8
- package/store.js +1005 -89
- package/types.d.ts +87 -0
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
package/agent-server.js
CHANGED
|
@@ -6,7 +6,7 @@ import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlock
|
|
|
6
6
|
import { SLOTLOCK_EVENT_OUTPUT as EVENT } from './event-schema.js';
|
|
7
7
|
import { ignoreRejection } from './hooks.js';
|
|
8
8
|
import { slotlockPackageVersion } from './package-version.js';
|
|
9
|
-
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';
|
|
10
10
|
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, calendarEventRollingHorizon, } from './store.js';
|
|
11
11
|
import { canonicalRecurrenceRule, expandCalendarEventOccurrences, isBookableRecurrenceRule, } from './sync.js';
|
|
12
12
|
import { zonedOffsetMinutes, zonedWallTime } from './timezone.js';
|
|
@@ -60,9 +60,163 @@ export const SLOTLOCK_A2A_TASK_STATES = Object.freeze([
|
|
|
60
60
|
const DATE_TIME = z.string().datetime({ offset: true });
|
|
61
61
|
const RESOURCE_ID = z.string().trim().min(1).max(200);
|
|
62
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}$/);
|
|
63
65
|
const IDEMPOTENCY_KEY = z.string().trim().min(1).max(200);
|
|
64
66
|
const REVISION = z.number().int().positive();
|
|
65
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
|
+
}
|
|
66
220
|
/** The longest slot `slotlock_find_next_available` can look for: the whole search window. */
|
|
67
221
|
const MAX_SLOT_MINUTES = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60;
|
|
68
222
|
const MAX_EVENT_DURATION_MS = SLOTLOCK_MAX_EVENT_DURATION_DAYS * 24 * 60 * 60 * 1_000;
|
|
@@ -177,6 +331,12 @@ const OPERATION_SCOPES = Object.freeze({
|
|
|
177
331
|
slotlock_list_resources: 'read',
|
|
178
332
|
slotlock_get_free_busy: 'read',
|
|
179
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',
|
|
180
340
|
slotlock_create_event: 'write',
|
|
181
341
|
slotlock_get_event: 'read',
|
|
182
342
|
slotlock_list_events: 'read',
|
|
@@ -197,11 +357,25 @@ function principalScopesCover(principal, operation) {
|
|
|
197
357
|
export class SlotlockAgentOperationError extends Error {
|
|
198
358
|
code;
|
|
199
359
|
status;
|
|
200
|
-
|
|
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) {
|
|
201
367
|
super(code);
|
|
202
368
|
this.name = 'SlotlockAgentOperationError';
|
|
203
369
|
this.code = /^[a-z][a-z0-9_]{0,63}$/.test(code) ? code : 'operation_failed';
|
|
204
|
-
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));
|
|
205
379
|
}
|
|
206
380
|
}
|
|
207
381
|
const EXAMPLE_RESOURCE_ID = '0f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b';
|
|
@@ -224,6 +398,8 @@ const EVENT_CREATE_INPUT = z
|
|
|
224
398
|
reminders: z.array(REMINDER_INPUT).max(20).optional(),
|
|
225
399
|
idempotency_key: IDEMPOTENCY_KEY,
|
|
226
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(),
|
|
227
403
|
})
|
|
228
404
|
.strict()
|
|
229
405
|
.refine((value) => boundedEventSpan(value.starts_at, value.ends_at));
|
|
@@ -231,11 +407,12 @@ const OPERATION_DEFINITIONS = [
|
|
|
231
407
|
{
|
|
232
408
|
name: 'slotlock_list_resources',
|
|
233
409
|
title: 'List calendar resources',
|
|
234
|
-
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.',
|
|
235
411
|
input: z
|
|
236
412
|
.object({
|
|
237
413
|
cursor: z.string().max(500).optional(),
|
|
238
414
|
limit: z.number().int().min(1).max(100).default(50),
|
|
415
|
+
pool: POOL.optional(),
|
|
239
416
|
})
|
|
240
417
|
.strict(),
|
|
241
418
|
output: z
|
|
@@ -246,6 +423,7 @@ const OPERATION_DEFINITIONS = [
|
|
|
246
423
|
id: RESOURCE_ID,
|
|
247
424
|
external_ref: z.string().max(500).nullable(),
|
|
248
425
|
timezone: z.string().min(1).max(100),
|
|
426
|
+
pool: POOL.optional(),
|
|
249
427
|
})
|
|
250
428
|
.strict())
|
|
251
429
|
.max(100),
|
|
@@ -310,6 +488,40 @@ const OPERATION_DEFINITIONS = [
|
|
|
310
488
|
invoke: 'findNextAvailable',
|
|
311
489
|
example: { resource_ids: [EXAMPLE_RESOURCE_ID], ...EXAMPLE_WINDOW, duration_minutes: 120 },
|
|
312
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
|
+
},
|
|
313
525
|
{
|
|
314
526
|
name: 'slotlock_create_event',
|
|
315
527
|
title: 'Create resource event',
|
|
@@ -327,6 +539,86 @@ const OPERATION_DEFINITIONS = [
|
|
|
327
539
|
idempotency_key: 'create-handover-2027-03-02',
|
|
328
540
|
},
|
|
329
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
|
+
},
|
|
330
622
|
{
|
|
331
623
|
name: 'slotlock_get_event',
|
|
332
624
|
title: 'Get resource event',
|
|
@@ -440,8 +732,65 @@ const OPERATION_DEFINITIONS = [
|
|
|
440
732
|
idempotency_key: 'cancel-handover-2027-03-02',
|
|
441
733
|
},
|
|
442
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
|
+
},
|
|
443
792
|
];
|
|
444
|
-
const FORMATS_WITHOUT_PATTERN = new Set(['date-time', 'email']);
|
|
793
|
+
const FORMATS_WITHOUT_PATTERN = new Set(['date-time', 'email', 'uuid']);
|
|
445
794
|
function toJsonSchema(schema, io) {
|
|
446
795
|
// Zod's own converter: zod-to-json-schema reads only Zod 3 schemas (for a Zod 4 one it returns {})
|
|
447
796
|
// and its repository is archived. A type JSON Schema cannot express throws rather than vanishing.
|
|
@@ -450,7 +799,7 @@ function toJsonSchema(schema, io) {
|
|
|
450
799
|
io,
|
|
451
800
|
unrepresentable: 'throw',
|
|
452
801
|
override: ({ jsonSchema }) => {
|
|
453
|
-
// 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
|
|
454
803
|
// characters, on 60 fields): the format already says it, and every agent reads the list.
|
|
455
804
|
if (FORMATS_WITHOUT_PATTERN.has(String(jsonSchema.format)))
|
|
456
805
|
delete jsonSchema.pattern;
|
|
@@ -539,18 +888,32 @@ export function slotlockAgentTools() {
|
|
|
539
888
|
idempotentHint: operation.risk.idempotent,
|
|
540
889
|
openWorldHint: false,
|
|
541
890
|
},
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
+
},
|
|
552
902
|
}));
|
|
553
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
|
+
}
|
|
554
917
|
function json(status, body, extraHeaders = {}) {
|
|
555
918
|
return new Response(JSON.stringify(body), {
|
|
556
919
|
status,
|
|
@@ -810,7 +1173,11 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
810
1173
|
if (halt)
|
|
811
1174
|
return { ok: false, status: halt.status, code: halt.code };
|
|
812
1175
|
}
|
|
813
|
-
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];
|
|
814
1181
|
const context = {
|
|
815
1182
|
principal,
|
|
816
1183
|
operation: operation.name,
|
|
@@ -818,7 +1185,7 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
818
1185
|
};
|
|
819
1186
|
if (args.trace)
|
|
820
1187
|
context.trace = args.trace;
|
|
821
|
-
const data = await method.call(
|
|
1188
|
+
const data = await method.call(backend, context, parsed.data);
|
|
822
1189
|
if (!operation.output.safeParse(data).success) {
|
|
823
1190
|
// Neither the result nor the schema's complaint about it: either may hold tenant data.
|
|
824
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);
|
|
@@ -828,7 +1195,12 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
828
1195
|
}
|
|
829
1196
|
catch (error) {
|
|
830
1197
|
if (error instanceof SlotlockAgentOperationError) {
|
|
831
|
-
return {
|
|
1198
|
+
return {
|
|
1199
|
+
ok: false,
|
|
1200
|
+
status: error.status,
|
|
1201
|
+
code: error.code,
|
|
1202
|
+
...(error.details ? { details: error.details } : {}),
|
|
1203
|
+
};
|
|
832
1204
|
}
|
|
833
1205
|
reportUnexpected(args.options.onError, error, resolvedOperation);
|
|
834
1206
|
return { ok: false, status: 500, code: 'internal_error' };
|
|
@@ -1141,13 +1513,15 @@ function unref(timer) {
|
|
|
1141
1513
|
/** Natural-language guidance `server/discover` gives the model on how to use these tools well. */
|
|
1142
1514
|
const SLOTLOCK_MCP_INSTRUCTIONS = [
|
|
1143
1515
|
'Slotlock is a tenant-isolated resource calendar (vehicles, rooms, equipment).',
|
|
1144
|
-
'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".',
|
|
1145
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.',
|
|
1146
1518
|
'Times are RFC 3339 with an offset; intervals are half-open [start, end).',
|
|
1147
1519
|
'A failed call returns {"error":{"code":"…"}}; read the code before retrying (overlap means the slot is taken).',
|
|
1148
1520
|
].join(' ');
|
|
1149
1521
|
const WRITE_OPERATIONS = new Set([
|
|
1150
1522
|
'slotlock_create_event',
|
|
1523
|
+
'slotlock_book_together',
|
|
1524
|
+
'slotlock_book_pool',
|
|
1151
1525
|
'slotlock_update_event',
|
|
1152
1526
|
'slotlock_delete_event',
|
|
1153
1527
|
]);
|
|
@@ -1180,7 +1554,15 @@ function validateConfirmation(value) {
|
|
|
1180
1554
|
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 30 || ttlSeconds > 3_600) {
|
|
1181
1555
|
throw invalid('ttlSeconds');
|
|
1182
1556
|
}
|
|
1183
|
-
|
|
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
|
+
};
|
|
1184
1566
|
}
|
|
1185
1567
|
/** An empty `elicitation` object declares form mode (2026-07-28 client/elicitation §Capabilities). */
|
|
1186
1568
|
function clientSupportsFormElicitation(capabilities) {
|
|
@@ -1537,6 +1919,23 @@ function confirmationLookup(options, context) {
|
|
|
1537
1919
|
* part has its own bound, so nothing is cut from the end.
|
|
1538
1920
|
*/
|
|
1539
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
|
+
}
|
|
1540
1939
|
if (operation === 'slotlock_create_event') {
|
|
1541
1940
|
const resource = await lookup.resource(input.resource_id);
|
|
1542
1941
|
const zone = promptZone([
|
|
@@ -1946,6 +2345,17 @@ export function createSlotlockAgentServer(options) {
|
|
|
1946
2345
|
operation,
|
|
1947
2346
|
argsDigest: gate.argsDigest,
|
|
1948
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
|
+
}
|
|
1949
2359
|
// Before the call it decides, as on every other protocol.
|
|
1950
2360
|
emit({ type: 'confirmation', operation, outcome: CONFIRMATION_OUTCOME[gate.decision] });
|
|
1951
2361
|
return gate.decision === 'accepted' ? null : CONFIRMATION_HALT[gate.decision];
|
|
@@ -2003,7 +2413,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2003
2413
|
if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
|
|
2004
2414
|
return modernError(500, id, -32603, 'Internal error');
|
|
2005
2415
|
}
|
|
2006
|
-
return modernResult(id, slotlockMcpToolResult(outcome
|
|
2416
|
+
return modernResult(id, slotlockMcpToolResult(outcome));
|
|
2007
2417
|
}
|
|
2008
2418
|
/** A backend failure behind a resources/* or subscriptions/* request, as its JSON-RPC error. */
|
|
2009
2419
|
const modernFailure = (id, outcome) => {
|
|
@@ -2434,7 +2844,12 @@ export function createSlotlockAgentServer(options) {
|
|
|
2434
2844
|
}
|
|
2435
2845
|
catch (error) {
|
|
2436
2846
|
if (error instanceof SlotlockAgentOperationError) {
|
|
2437
|
-
return {
|
|
2847
|
+
return {
|
|
2848
|
+
ok: false,
|
|
2849
|
+
status: error.status,
|
|
2850
|
+
code: error.code,
|
|
2851
|
+
...(error.details ? { details: error.details } : {}),
|
|
2852
|
+
};
|
|
2438
2853
|
}
|
|
2439
2854
|
reportUnexpected(options.onError, error, definition.name);
|
|
2440
2855
|
return { ok: false, status: 500, code: 'internal_error' };
|
|
@@ -2446,7 +2861,12 @@ export function createSlotlockAgentServer(options) {
|
|
|
2446
2861
|
* for, so a guarded write answers 428 `confirmation_required`.
|
|
2447
2862
|
*/
|
|
2448
2863
|
async function restCall(request, path, url) {
|
|
2449
|
-
|
|
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
|
+
});
|
|
2450
2870
|
if (path === '/openapi.json') {
|
|
2451
2871
|
return request.method === 'GET'
|
|
2452
2872
|
? json(200, openApi)
|
|
@@ -2521,20 +2941,27 @@ export function createSlotlockAgentServer(options) {
|
|
|
2521
2941
|
if (Object.keys(params).some((name) => Object.hasOwn(given, name))) {
|
|
2522
2942
|
return refuseCall(400, 'invalid_arguments');
|
|
2523
2943
|
}
|
|
2524
|
-
//
|
|
2525
|
-
// clients put it, as well as its
|
|
2526
|
-
//
|
|
2527
|
-
//
|
|
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);
|
|
2528
2949
|
const idempotencyHeader = request.headers.get('idempotency-key');
|
|
2529
|
-
if (idempotencyHeader !== null
|
|
2530
|
-
|
|
2531
|
-
if (!Object.hasOwn(fields, 'idempotency_key')) {
|
|
2950
|
+
if (idempotencyHeader !== null) {
|
|
2951
|
+
if (!Object.hasOwn(inputSchema?.properties ?? {}, 'idempotency_key')) {
|
|
2532
2952
|
return refuseCall(400, 'idempotency_key_not_supported');
|
|
2533
2953
|
}
|
|
2534
|
-
|
|
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) {
|
|
2535
2958
|
return refuseCall(400, 'invalid_arguments');
|
|
2536
2959
|
}
|
|
2537
|
-
given = { ...given, idempotency_key:
|
|
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');
|
|
2538
2965
|
}
|
|
2539
2966
|
const outcome = isSlotlockApplicationOperation(route.operation)
|
|
2540
2967
|
? await invokeApplicationOperation(slotlockApplicationOperation(route.operation), principal, { ...given, ...params }, request, trace)
|
|
@@ -2550,7 +2977,16 @@ export function createSlotlockAgentServer(options) {
|
|
|
2550
2977
|
report(status);
|
|
2551
2978
|
if (outcome.ok)
|
|
2552
2979
|
return json(200, outcome.data);
|
|
2553
|
-
|
|
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
|
+
});
|
|
2554
2990
|
}
|
|
2555
2991
|
return {
|
|
2556
2992
|
manifest,
|
|
@@ -2596,6 +3032,9 @@ export function createSlotlockAgentServer(options) {
|
|
|
2596
3032
|
return json(health.ready ? 200 : 503, {
|
|
2597
3033
|
status: health.ready ? 'ready' : 'not_ready',
|
|
2598
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,
|
|
2599
3038
|
checks: [...health.checks]
|
|
2600
3039
|
.slice(0, 100)
|
|
2601
3040
|
.map((check) => (/^[a-z][a-z0-9_-]{0,63}$/.test(check) ? check : 'invalid')),
|
|
@@ -2605,6 +3044,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2605
3044
|
return json(503, {
|
|
2606
3045
|
status: 'not_ready',
|
|
2607
3046
|
version: SLOTLOCK_AGENT_SERVER_VERSION,
|
|
3047
|
+
tzdata: process.versions.tz ?? null,
|
|
2608
3048
|
checks: [],
|
|
2609
3049
|
});
|
|
2610
3050
|
}
|
|
@@ -2782,7 +3222,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2782
3222
|
if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
|
|
2783
3223
|
return json(500, rpcError(id, -32603, 'Internal error'));
|
|
2784
3224
|
}
|
|
2785
|
-
return json(200, rpcResult(id, slotlockMcpToolResult(outcome
|
|
3225
|
+
return json(200, rpcResult(id, slotlockMcpToolResult(outcome)));
|
|
2786
3226
|
}
|
|
2787
3227
|
catch {
|
|
2788
3228
|
return json(500, rpcError(id, -32603, 'Internal error'));
|
|
@@ -2904,7 +3344,9 @@ export function createSlotlockAgentServer(options) {
|
|
|
2904
3344
|
role: 'ROLE_AGENT',
|
|
2905
3345
|
parts: [
|
|
2906
3346
|
{
|
|
2907
|
-
data: outcome.ok
|
|
3347
|
+
data: outcome.ok
|
|
3348
|
+
? outcome.data
|
|
3349
|
+
: { error: { ...outcome.details, code: outcome.code } },
|
|
2908
3350
|
mediaType: 'application/json',
|
|
2909
3351
|
},
|
|
2910
3352
|
],
|