slotlock 0.1.1 → 0.3.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/CONTRIBUTING.md +55 -19
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +403 -103
  7. package/SECURITY.md +70 -5
  8. package/SPEC.md +161 -21
  9. package/agent-server.d.ts +76 -10
  10. package/agent-server.js +381 -146
  11. package/agent-store-backend.d.ts +22 -0
  12. package/agent-store-backend.js +598 -24
  13. package/api-keys.d.ts +22 -7
  14. package/api-keys.js +38 -8
  15. package/application-api.d.ts +67 -0
  16. package/application-api.js +552 -0
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +10 -1
  20. package/dashboard.js +23 -5
  21. package/ddl.d.ts +47 -4
  22. package/ddl.js +679 -25
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/hooks.d.ts +7 -0
  27. package/hooks.js +13 -0
  28. package/ical-worker-thread.js +4 -2
  29. package/ical-worker.d.ts +8 -5
  30. package/ical-worker.js +9 -2
  31. package/index.d.ts +12 -8
  32. package/index.js +7 -5
  33. package/maintenance.d.ts +67 -0
  34. package/maintenance.js +213 -0
  35. package/node-server.d.ts +9 -1
  36. package/node-server.js +127 -6
  37. package/openapi.json +6024 -0
  38. package/package.json +3 -2
  39. package/rate-limit.d.ts +71 -0
  40. package/rate-limit.js +718 -0
  41. package/rest-api.d.ts +33 -18
  42. package/rest-api.js +141 -63
  43. package/rules.d.ts +19 -0
  44. package/rules.js +38 -7
  45. package/self-host.d.ts +26 -9
  46. package/self-host.js +427 -47
  47. package/slotlock-manifest.schema.json +6 -1
  48. package/slotlock.manifest.json +7 -2
  49. package/store.d.ts +359 -27
  50. package/store.js +2077 -296
  51. package/sync.d.ts +43 -2
  52. package/sync.js +1950 -277
  53. package/timezone.d.ts +77 -3
  54. package/timezone.js +265 -21
  55. package/types.d.ts +217 -10
  56. package/weekly-hours.js +2 -2
package/agent-server.js CHANGED
@@ -2,8 +2,11 @@ import { createHash, randomBytes } from 'node:crypto';
2
2
  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
+ import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, slotlockApplicationOperation, } from './application-api.js';
6
+ import { SLOTLOCK_EVENT_OUTPUT as EVENT } from './event-schema.js';
7
+ import { ignoreRejection } from './hooks.js';
5
8
  import { slotlockPackageVersion } from './package-version.js';
6
- import { matchSlotlockRestRoute, restInputFromBody, restQueryInput, slotlockOpenApiDocument, } from './rest-api.js';
9
+ import { SLOTLOCK_APPLICATION_REST_ROUTES, SLOTLOCK_REST_ROUTES, matchSlotlockRestRoute, restInputFromBody, restQueryInput, slotlockOpenApiDocument, } from './rest-api.js';
7
10
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, calendarEventRollingHorizon, } from './store.js';
8
11
  import { canonicalRecurrenceRule, expandCalendarEventOccurrences, isBookableRecurrenceRule, } from './sync.js';
9
12
  import { zonedOffsetMinutes, zonedWallTime } from './timezone.js';
@@ -59,14 +62,6 @@ const RESOURCE_ID = z.string().trim().min(1).max(200);
59
62
  const EVENT_ID = z.string().trim().min(1).max(500);
60
63
  const IDEMPOTENCY_KEY = z.string().trim().min(1).max(200);
61
64
  const REVISION = z.number().int().positive();
62
- // Output bounds mirror the trusted store's byte ceilings. A value admitted by a UTF-8 byte
63
- // ceiling can never exceed that many UTF-16 code units, so every valid stored/provider event is
64
- // serializable without truncating recurrence semantics or calendar identities.
65
- const TRUSTED_SUMMARY_OUTPUT_MAX = 4_096;
66
- const TRUSTED_TEXT_OUTPUT_MAX = 16_384;
67
- const TRUSTED_PERSON_NAME_OUTPUT_MAX = 1_024;
68
- const TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX = 1_000;
69
- const TRUSTED_REMINDER_MINUTES_OUTPUT_MAX = 366 * 24 * 60;
70
65
  const MAX_AGENT_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60 * 60 * 1_000;
71
66
  /** The longest slot `slotlock_find_next_available` can look for: the whole search window. */
72
67
  const MAX_SLOT_MINUTES = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60;
@@ -93,41 +88,6 @@ const COVERAGE = z
93
88
  reason: z.string().max(100).nullable(),
94
89
  })
95
90
  .strict();
96
- const ORGANIZER = z
97
- .object({
98
- address: z.string().email().max(320),
99
- name: z.string().trim().max(TRUSTED_PERSON_NAME_OUTPUT_MAX).nullable(),
100
- })
101
- .strict();
102
- const ATTENDEE = z
103
- .object({
104
- address: z.string().email().max(320),
105
- name: z.string().trim().max(TRUSTED_PERSON_NAME_OUTPUT_MAX).nullable(),
106
- role: z.enum(['chair', 'required', 'optional', 'non_participant']),
107
- participation_status: z.enum([
108
- 'needs_action',
109
- 'accepted',
110
- 'declined',
111
- 'tentative',
112
- 'delegated',
113
- ]),
114
- rsvp: z.boolean(),
115
- })
116
- .strict();
117
- const REMINDER = z
118
- .object({
119
- minutes_before: z.number().int().min(0).max(TRUSTED_REMINDER_MINUTES_OUTPUT_MAX),
120
- channel: z.enum(['display', 'email']),
121
- })
122
- .strict();
123
- const RECURRENCE_EXCEPTION = z
124
- .object({
125
- recurrence_id: DATE_TIME,
126
- cancelled: z.boolean(),
127
- starts_at: DATE_TIME.nullable(),
128
- ends_at: DATE_TIME.nullable(),
129
- })
130
- .strict();
131
91
  const ORGANIZER_INPUT = z
132
92
  .object({
133
93
  address: z.string().email().max(320),
@@ -185,30 +145,6 @@ const MCP_INITIALIZE_PARAMS = z
185
145
  _meta: z.record(z.string(), z.unknown()).optional(),
186
146
  })
187
147
  .passthrough();
188
- const EVENT = z
189
- .object({
190
- id: EVENT_ID,
191
- resource_id: RESOURCE_ID,
192
- starts_at: DATE_TIME,
193
- ends_at: DATE_TIME,
194
- timezone: z.string().min(1).max(100),
195
- title: z.string().max(TRUSTED_SUMMARY_OUTPUT_MAX).nullable(),
196
- description: z.string().max(TRUSTED_TEXT_OUTPUT_MAX).nullable(),
197
- location: z.string().max(TRUSTED_TEXT_OUTPUT_MAX).nullable(),
198
- organizer: ORGANIZER.nullable(),
199
- attendees: z.array(ATTENDEE).max(100),
200
- reminders: z.array(REMINDER).max(20),
201
- status: z.enum(['tentative', 'confirmed', 'cancelled']),
202
- transparency: z.enum(['opaque', 'transparent']),
203
- sequence: z.number().int().nonnegative(),
204
- revision: REVISION,
205
- recurrence_rule: z.string().max(TRUSTED_SUMMARY_OUTPUT_MAX).nullable(),
206
- recurrence_exceptions: z
207
- .array(RECURRENCE_EXCEPTION)
208
- .max(TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX),
209
- recurrence_id: DATE_TIME.nullable(),
210
- })
211
- .strict();
212
148
  /**
213
149
  * Names earlier builds published, which every entry point still accepts and dispatches as the same
214
150
  * operation; nothing advertises them. The dotted names came first, before tool names had to be
@@ -425,7 +361,7 @@ const OPERATION_DEFINITIONS = [
425
361
  {
426
362
  name: 'slotlock_update_event',
427
363
  title: 'Update resource event',
428
- description: 'Apply an expected-revision event patch and rematerialize occupancy atomically.',
364
+ 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.',
429
365
  input: z
430
366
  .object({
431
367
  event_id: EVENT_ID,
@@ -445,6 +381,8 @@ const OPERATION_DEFINITIONS = [
445
381
  recurrence_rule: RECURRENCE_RULE.nullable().optional(),
446
382
  recurrence_exceptions: z.array(RECURRENCE_EXCEPTION_INPUT).max(500).optional(),
447
383
  idempotency_key: IDEMPOTENCY_KEY,
384
+ // Last, so the generated clients that take optional arguments by position keep their order.
385
+ expected_resource_id: RESOURCE_ID.optional(),
448
386
  })
449
387
  .strict()
450
388
  .refine((value) => value.starts_at !== undefined ||
@@ -558,6 +496,35 @@ export function slotlockMcpToolResult(outcome) {
558
496
  }
559
497
  return { content: [{ type: 'text', text: JSON.stringify({ error }) }], isError: true };
560
498
  }
499
+ /** The agent tools as REST serves them: each with the scope it needs. */
500
+ function restAgentTools() {
501
+ return slotlockAgentTools().map((tool) => ({ ...tool, scope: slotlockAgentOperationScope(tool.name) }));
502
+ }
503
+ /** The application operations with their input and output as JSON Schema, as REST serves them. */
504
+ export function slotlockApplicationOperations() {
505
+ return SLOTLOCK_APPLICATION_OPERATIONS.map((operation) => ({
506
+ name: operation.name,
507
+ title: operation.title,
508
+ description: operation.description,
509
+ method: operation.method,
510
+ path: operation.path,
511
+ inputSchema: toJsonSchema(operation.input, 'input'),
512
+ outputSchema: toJsonSchema(operation.output, 'output'),
513
+ }));
514
+ }
515
+ /**
516
+ * The OpenAPI 3.1 document of the REST API: what a server with `rest` (and `application`) serves at
517
+ * /openapi.json. Without `publicBaseUrl` the server URL is a variable, `{baseUrl}`, as in the
518
+ * repository's openapi.json, which client generators read.
519
+ */
520
+ export function slotlockOpenApi(options = {}) {
521
+ return slotlockOpenApiDocument({
522
+ ...(options.publicBaseUrl !== undefined ? { publicBaseUrl: options.publicBaseUrl } : {}),
523
+ version: SLOTLOCK_AGENT_SERVER_VERSION,
524
+ tools: restAgentTools(),
525
+ ...(options.application ? { application: slotlockApplicationOperations() } : {}),
526
+ });
527
+ }
561
528
  export function slotlockAgentTools() {
562
529
  return OPERATION_DEFINITIONS.map((operation) => ({
563
530
  name: operation.name,
@@ -611,13 +578,44 @@ function rpcResult(id, result) {
611
578
  function rpcError(id, code, message) {
612
579
  return { jsonrpc: '2.0', id, error: { code, message } };
613
580
  }
581
+ /**
582
+ * Whether a Content-Type names `application/json`, whatever its parameters. A media type's type
583
+ * and subtype are case-insensitive (RFC 9110 §8.3.1), so `Application/JSON` is the same type.
584
+ */
585
+ function isJsonMediaType(contentType) {
586
+ return contentType?.split(';')[0]?.trim().toLowerCase() === 'application/json';
587
+ }
588
+ /**
589
+ * Parse a JSON body of at most `maxBytes`. The body is counted as it arrives, so one sent without
590
+ * Content-Length (chunked) is refused, and its stream cancelled, as soon as it passes the limit,
591
+ * rather than buffered whole first.
592
+ */
614
593
  async function readJson(request, maxBytes) {
615
594
  const declared = request.headers.get('content-length');
616
595
  if (declared && Number(declared) > maxBytes)
617
596
  throw new Error('request_too_large');
618
- const bytes = new Uint8Array(await request.arrayBuffer());
619
- if (bytes.byteLength > maxBytes)
620
- throw new Error('request_too_large');
597
+ const chunks = [];
598
+ let size = 0;
599
+ if (request.body) {
600
+ const reader = request.body.getReader();
601
+ for (;;) {
602
+ const { done, value } = await reader.read();
603
+ if (done)
604
+ break;
605
+ size += value.byteLength;
606
+ if (size > maxBytes) {
607
+ await reader.cancel().catch(() => undefined);
608
+ throw new Error('request_too_large');
609
+ }
610
+ chunks.push(value);
611
+ }
612
+ }
613
+ const bytes = new Uint8Array(size);
614
+ let offset = 0;
615
+ for (const chunk of chunks) {
616
+ bytes.set(chunk, offset);
617
+ offset += chunk.byteLength;
618
+ }
621
619
  try {
622
620
  return JSON.parse(new TextDecoder().decode(bytes));
623
621
  }
@@ -783,10 +781,11 @@ export async function invokeSlotlockAgentOperation(args) {
783
781
  if (!principalScopesCover(principal, operation.name)) {
784
782
  return { ok: false, status: 403, code: 'forbidden' };
785
783
  }
786
- const input = args.input && typeof args.input === 'object' && !Array.isArray(args.input)
787
- ? args.input
788
- : {};
789
- const parsed = operation.input.safeParse(input);
784
+ // No input, or null, is no arguments; anything else that is not an object is refused.
785
+ if (args.input != null && !isRecord(args.input)) {
786
+ return { ok: false, status: 400, code: 'invalid_arguments' };
787
+ }
788
+ const parsed = operation.input.safeParse(args.input ?? {});
790
789
  if (!parsed.success)
791
790
  return { ok: false, status: 400, code: 'invalid_arguments' };
792
791
  if (!(await args.options.authorize({
@@ -838,12 +837,20 @@ export async function invokeSlotlockAgentOperation(args) {
838
837
  /** Hand an unexpected failure to `onError`; nothing it does can change the protocol answer. */
839
838
  function reportUnexpected(onError, error, operation) {
840
839
  try {
841
- onError?.(error, { operation });
840
+ ignoreRejection(onError?.(error, { operation }));
842
841
  }
843
842
  catch {
844
843
  // Observability must never change what a protocol client receives.
845
844
  }
846
845
  }
846
+ /** Milliseconds since `started` (a `performance.now()` reading), to a tenth. */
847
+ function elapsedMs(started) {
848
+ return Math.round((performance.now() - started) * 10) / 10;
849
+ }
850
+ /** The trace id of a valid `traceparent` (`version-traceid-parentid-flags`): its second field. */
851
+ function traceIdOf(trace) {
852
+ return trace.traceparent.split('-', 2)[1] ?? '';
853
+ }
847
854
  /** Failures that are server faults, not operation outcomes an agent could act on. */
848
855
  const INTERNAL_FAILURES = new Set(['internal_error', 'invalid_backend_result']);
849
856
  /**
@@ -1081,12 +1088,14 @@ function validateSubscriptions(value) {
1081
1088
  }
1082
1089
  return setting;
1083
1090
  };
1091
+ const maxTotal = bounded('maxTotal', 1, 10_000, 256);
1084
1092
  return {
1085
1093
  pollIntervalMs: bounded('pollIntervalMs', 1_000, 300_000, 10_000),
1086
1094
  maxDurationMs: bounded('maxDurationMs', 60_000, 86_400_000, 900_000),
1087
1095
  keepAliveMs: bounded('keepAliveMs', 1_000, 60_000, 15_000),
1088
1096
  maxPerPrincipal: bounded('maxPerPrincipal', 1, 64, 4),
1089
- maxTotal: bounded('maxTotal', 1, 10_000, 256),
1097
+ maxPerTenant: bounded('maxPerTenant', 1, 10_000, maxTotal),
1098
+ maxTotal,
1090
1099
  };
1091
1100
  }
1092
1101
  /**
@@ -1380,10 +1389,14 @@ function promptSeries(series, zone) {
1380
1389
  const booked = expandCalendarEventOccurrences(content, horizon);
1381
1390
  beyond = promptSeriesEnd(series.rule, booked.length, horizon.end);
1382
1391
  const length = end.getTime() - start.getTime();
1392
+ // Moved: by an exception, to another time or length. One the clocks change inside, or that
1393
+ // the store ends where the next starts, is where its rule puts it.
1394
+ const excepted = new Set(exceptions.flatMap((exception) => exception.cancelled !== true && exception.start ? [exception.recurrenceId.getTime()] : []));
1383
1395
  moved = promptMoved(booked.flatMap((occurrence) => {
1384
1396
  const from = new Date(occurrence.recurrenceId);
1385
- const shifted = occurrence.start.getTime() !== from.getTime() ||
1386
- occurrence.end.getTime() - occurrence.start.getTime() !== length;
1397
+ const shifted = excepted.has(from.getTime()) &&
1398
+ (occurrence.start.getTime() !== from.getTime() ||
1399
+ occurrence.end.getTime() - occurrence.start.getTime() !== length);
1387
1400
  return shifted && Number.isFinite(from.getTime())
1388
1401
  ? [{ from, start: occurrence.start, end: occurrence.end }]
1389
1402
  : [];
@@ -1540,10 +1553,14 @@ async function confirmationMessage(operation, input, lookup) {
1540
1553
  }, zone)
1541
1554
  : `for ${promptRange(promptInstantOf(input.starts_at), promptInstantOf(input.ends_at), zone)}`;
1542
1555
  const tentative = input.status === 'tentative' ? ', tentatively' : '';
1556
+ // A transparent booking does not make the resource busy: the person must know it stays free.
1557
+ const free = input.transparency === 'transparent' ? ', leaving the time free for other bookings' : '';
1558
+ const details = promptDetails(input);
1559
+ const also = details ? ` (${details})` : '';
1543
1560
  const title = typeof input.title === 'string' && input.title.length > 0
1544
1561
  ? `: ${promptQuoted(input.title)}`
1545
1562
  : '';
1546
- return `Book ${resource.label} ${when}${tentative}${title}.`;
1563
+ return `Book ${resource.label} ${when}${tentative}${free}${also}${title}.`;
1547
1564
  }
1548
1565
  const event = await lookup.event(input.event_id);
1549
1566
  const current = event ? await lookup.resource(event.resourceId) : null;
@@ -1598,8 +1615,18 @@ async function confirmationMessage(operation, input, lookup) {
1598
1615
  changes.push('no longer repeats');
1599
1616
  }
1600
1617
  else if (typeof rule === 'string' && reshaped && seriesStart && seriesEnd) {
1601
- const seriesZone = input.timezone ?? event?.timezone ?? 'UTC';
1602
- changes.push(`so it books ${promptSeries({ start: seriesStart, end: seriesEnd, timezone: seriesZone, rule, exceptions }, zone)}`);
1618
+ const seriesZone = input.timezone ?? event?.timezone;
1619
+ if (seriesZone === undefined) {
1620
+ // An unread event and no new zone: the store expands in the event's own, which the question
1621
+ // cannot know, so it states no dates.
1622
+ changes.push(`repeats ${promptRule(rule)}, on dates that follow the event's existing time zone`);
1623
+ if (input.recurrence_exceptions !== undefined) {
1624
+ changes.push(`exceptions to ${promptRequestedExceptions(input.recurrence_exceptions, zone)}`);
1625
+ }
1626
+ }
1627
+ else {
1628
+ changes.push(`so it books ${promptSeries({ start: seriesStart, end: seriesEnd, timezone: seriesZone, rule, exceptions }, zone)}`);
1629
+ }
1603
1630
  }
1604
1631
  else {
1605
1632
  if (typeof input.recurrence_rule === 'string') {
@@ -1609,15 +1636,23 @@ async function confirmationMessage(operation, input, lookup) {
1609
1636
  changes.push(`exceptions to ${promptRequestedExceptions(input.recurrence_exceptions, zone)}`);
1610
1637
  }
1611
1638
  }
1612
- const details = ['description', 'location', 'organizer', 'attendees', 'reminders'].filter((field) => input[field] !== undefined);
1613
- if (details.length > 0)
1614
- changes.push(`also ${details.join(', ')}`);
1639
+ const details = promptDetails(input);
1640
+ if (details)
1641
+ changes.push(details);
1615
1642
  // The agent's own text goes last.
1616
1643
  if (input.title !== undefined) {
1617
1644
  changes.push(input.title === null ? 'title removed' : `title to ${promptQuoted(input.title)}`);
1618
1645
  }
1619
1646
  return `Change ${target}${changes.length > 0 ? `: ${changes.join('; ')}` : ''}.${titled}`;
1620
1647
  }
1648
+ /**
1649
+ * The other fields a write sets, by name only (`also description, attendees`): their values are
1650
+ * the agent's text, and only the title is quoted. `null` when it sets none of them.
1651
+ */
1652
+ function promptDetails(input) {
1653
+ const details = ['description', 'location', 'organizer', 'attendees', 'reminders'].filter((field) => input[field] !== undefined);
1654
+ return details.length > 0 ? `also ${details.join(', ')}` : null;
1655
+ }
1621
1656
  /**
1622
1657
  * Read the client's retry of a guarded call. State this server did not seal, sealed for another
1623
1658
  * principal, another operation or other arguments is `invalid`; lapsed state, or an answer that is
@@ -1688,6 +1723,22 @@ function a2aError(id, code, message, info) {
1688
1723
  },
1689
1724
  });
1690
1725
  }
1726
+ /**
1727
+ * The request's principal, or the error when `authenticate` throws a 503
1728
+ * `SlotlockAgentOperationError`, as createSlotlockApiKeyAuthenticator does when its lookup queue is
1729
+ * full: no answer about the credential, so the client is told to retry rather than that it is
1730
+ * anonymous.
1731
+ */
1732
+ async function principalOrBusy(authenticate, request) {
1733
+ try {
1734
+ return await authenticate(request);
1735
+ }
1736
+ catch (error) {
1737
+ if (error instanceof SlotlockAgentOperationError && error.status === 503)
1738
+ return error;
1739
+ throw error;
1740
+ }
1741
+ }
1691
1742
  /** Map a request path onto the server's base path; `null` when it lies outside it. */
1692
1743
  function routePath(pathname, basePath) {
1693
1744
  if (basePath === '')
@@ -1730,14 +1781,33 @@ export function createSlotlockAgentServer(options) {
1730
1781
  if (options.rest !== undefined && typeof options.rest !== 'boolean') {
1731
1782
  throw new Error('Slotlock agent server rest must be a boolean');
1732
1783
  }
1733
- const restTools = options.rest ? slotlockAgentTools() : [];
1734
- const restInputSchemas = new Map(restTools.map((tool) => [tool.name, tool.inputSchema]));
1784
+ const application = options.application;
1785
+ if (application !== undefined) {
1786
+ if (options.rest !== true) {
1787
+ throw new Error('Slotlock agent server application needs rest: it is served only as REST');
1788
+ }
1789
+ const backend = application?.backend;
1790
+ if (typeof application !== 'object' ||
1791
+ application === null ||
1792
+ typeof application.authorize !== 'function' ||
1793
+ typeof backend !== 'object' ||
1794
+ backend === null ||
1795
+ SLOTLOCK_APPLICATION_OPERATIONS.some((operation) => typeof backend[operation.invoke] !== 'function')) {
1796
+ throw new Error('Slotlock agent server application needs an authorize function and a backend with every application method');
1797
+ }
1798
+ }
1799
+ const restTools = options.rest ? restAgentTools() : [];
1800
+ const applicationTools = application ? slotlockApplicationOperations() : [];
1801
+ const restInputSchemas = new Map([
1802
+ ...restTools.map((tool) => [tool.name, tool.inputSchema]),
1803
+ ...applicationTools.map((tool) => [tool.name, tool.inputSchema]),
1804
+ ]);
1805
+ const restRoutes = [
1806
+ ...SLOTLOCK_REST_ROUTES,
1807
+ ...(application ? SLOTLOCK_APPLICATION_REST_ROUTES : []),
1808
+ ];
1735
1809
  const openApi = options.rest
1736
- ? Object.freeze(slotlockOpenApiDocument({
1737
- publicBaseUrl: config.publicBaseUrl,
1738
- version: SLOTLOCK_AGENT_SERVER_VERSION,
1739
- tools: restTools,
1740
- }))
1810
+ ? Object.freeze(slotlockOpenApi({ publicBaseUrl: config.publicBaseUrl, application: application !== undefined }))
1741
1811
  : null;
1742
1812
  const manifest = Object.freeze({
1743
1813
  name: 'Slotlock Agent Calendar',
@@ -1761,12 +1831,33 @@ export function createSlotlockAgentServer(options) {
1761
1831
  const confirmation = validateConfirmation(options.confirmation);
1762
1832
  const emit = (event) => {
1763
1833
  try {
1764
- options.onEvent?.(event);
1834
+ ignoreRejection(options.onEvent?.(event));
1765
1835
  }
1766
1836
  catch {
1767
1837
  // Observability must never change what a protocol client receives.
1768
1838
  }
1769
1839
  };
1840
+ /**
1841
+ * Report how an MCP or A2A call that named an operation ended, as a `call` event: run, or refused
1842
+ * before it could run (a malformed message, a client that cannot confirm). Never its arguments.
1843
+ */
1844
+ const reportCall = (protocol, operation, outcome, started, trace) => {
1845
+ emit({
1846
+ type: 'call',
1847
+ protocol,
1848
+ operation,
1849
+ outcome,
1850
+ durationMs: elapsedMs(started),
1851
+ ...(trace ? { traceId: traceIdOf(trace) } : {}),
1852
+ });
1853
+ };
1854
+ /** Run an operation an MCP or A2A caller named, and report how it ended as a `call` event. */
1855
+ const invokeReported = async (protocol, args) => {
1856
+ const started = performance.now();
1857
+ const outcome = await invokeSlotlockAgentOperation(args);
1858
+ reportCall(protocol, args.operation, outcome.ok ? 'ok' : outcome.code, started, args.trace);
1859
+ return outcome;
1860
+ };
1770
1861
  const dispatchOptions = (principal) => ({
1771
1862
  backend: options.backend,
1772
1863
  // The transport authenticates once before protocol parsing/rate-limiting; the canonical
@@ -1832,10 +1923,17 @@ export function createSlotlockAgentServer(options) {
1832
1923
  const operation = resolveSlotlockAgentOperation(params.name);
1833
1924
  if (!operation)
1834
1925
  return modernError(200, id, -32602, 'Unknown tool');
1926
+ const started = performance.now();
1927
+ // `arguments` may be left out or null; anything else but an object is malformed params.
1928
+ if (params.arguments != null && !isRecord(params.arguments)) {
1929
+ reportCall('mcp', operation, 'invalid_arguments', started, call.trace);
1930
+ return modernError(200, id, -32602, 'Invalid params');
1931
+ }
1835
1932
  const dispatch = dispatchOptions(principal);
1836
1933
  const gate = {};
1837
1934
  if (confirmation?.operations.has(operation)) {
1838
1935
  if (!clientSupportsFormElicitation(call.capabilities)) {
1936
+ reportCall('mcp', operation, 'missing_capability', started, call.trace);
1839
1937
  return modernError(400, id, MCP_MISSING_REQUIRED_CLIENT_CAPABILITY, 'Missing required client capability', { requiredCapabilities: { elicitation: { form: {} } } });
1840
1938
  }
1841
1939
  dispatch.confirm = async ({ input }) => {
@@ -1848,19 +1946,18 @@ export function createSlotlockAgentServer(options) {
1848
1946
  operation,
1849
1947
  argsDigest: gate.argsDigest,
1850
1948
  });
1949
+ // Before the call it decides, as on every other protocol.
1950
+ emit({ type: 'confirmation', operation, outcome: CONFIRMATION_OUTCOME[gate.decision] });
1851
1951
  return gate.decision === 'accepted' ? null : CONFIRMATION_HALT[gate.decision];
1852
1952
  };
1853
1953
  }
1854
- const outcome = await invokeSlotlockAgentOperation({
1954
+ const outcome = await invokeReported('mcp', {
1855
1955
  operation,
1856
1956
  input: isRecord(params.arguments) ? params.arguments : {},
1857
1957
  request: call.request,
1858
1958
  options: dispatch,
1859
1959
  ...(call.trace ? { trace: call.trace } : {}),
1860
1960
  });
1861
- if (gate.decision) {
1862
- emit({ type: 'confirmation', operation, outcome: CONFIRMATION_OUTCOME[gate.decision] });
1863
- }
1864
1961
  if (confirmation &&
1865
1962
  (gate.decision === 'prompt' || gate.decision === 'expired') &&
1866
1963
  gate.input &&
@@ -2056,15 +2153,48 @@ export function createSlotlockAgentServer(options) {
2056
2153
  return modernError(503, id, -32603, 'Server is shutting down');
2057
2154
  const principalKey = `${principal.tenantRef}\0${principal.subject}`;
2058
2155
  let held = 0;
2059
- for (const open of openSubscriptions)
2156
+ let heldByTenant = 0;
2157
+ for (const open of openSubscriptions) {
2060
2158
  if (open.principalKey === principalKey)
2061
2159
  held += 1;
2160
+ if (open.tenantRef === principal.tenantRef)
2161
+ heldByTenant += 1;
2162
+ }
2163
+ if (openSubscriptions.size >= subscriptionPolicy.maxTotal &&
2164
+ held < subscriptionPolicy.maxPerPrincipal &&
2165
+ heldByTenant < subscriptionPolicy.maxPerTenant) {
2166
+ // A full server is shared out: a tenant holding fewer than the one holding most, less one,
2167
+ // ends that tenant's oldest stream, so many tenants within their share cannot keep another
2168
+ // from holding any. A slot still opening has nothing to end yet.
2169
+ const streamsOf = new Map();
2170
+ for (const open of openSubscriptions) {
2171
+ if (!open.streaming)
2172
+ continue;
2173
+ const streams = streamsOf.get(open.tenantRef);
2174
+ if (streams)
2175
+ streams.push(open);
2176
+ else
2177
+ streamsOf.set(open.tenantRef, [open]);
2178
+ }
2179
+ let most = [];
2180
+ for (const streams of streamsOf.values())
2181
+ if (streams.length > most.length)
2182
+ most = streams;
2183
+ if (most.length > heldByTenant + 1)
2184
+ most[0]?.end('preempted');
2185
+ }
2062
2186
  if (openSubscriptions.size >= subscriptionPolicy.maxTotal ||
2063
- held >= subscriptionPolicy.maxPerPrincipal) {
2187
+ held >= subscriptionPolicy.maxPerPrincipal ||
2188
+ heldByTenant >= subscriptionPolicy.maxPerTenant) {
2064
2189
  return modernError(429, id, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, 'Too many subscriptions');
2065
2190
  }
2066
2191
  // The slot is held while access is checked, so concurrent opens cannot overshoot the caps.
2067
- const slot = { principalKey, end: (_reason) => { } };
2192
+ const slot = {
2193
+ principalKey,
2194
+ tenantRef: principal.tenantRef,
2195
+ streaming: false,
2196
+ end: () => { },
2197
+ };
2068
2198
  openSubscriptions.add(slot);
2069
2199
  let streaming = false;
2070
2200
  try {
@@ -2128,6 +2258,7 @@ export function createSlotlockAgentServer(options) {
2128
2258
  });
2129
2259
  }
2130
2260
  slot.end = end;
2261
+ slot.streaming = true;
2131
2262
  stream.send({
2132
2263
  jsonrpc: '2.0',
2133
2264
  method: 'notifications/subscriptions/acknowledged',
@@ -2267,6 +2398,48 @@ export function createSlotlockAgentServer(options) {
2267
2398
  return modernError(404, id, -32601, 'Method not found');
2268
2399
  }
2269
2400
  }
2401
+ /**
2402
+ * One application operation for an authenticated principal: the admin scope (when the principal
2403
+ * carries scopes), the operation's strict input schema, `application.authorize`, the backend, and
2404
+ * its output schema. A refusal the backend throws as a `SlotlockAgentOperationError` is the answer;
2405
+ * anything else is reported to `onError` and answers only `internal_error`.
2406
+ */
2407
+ async function invokeApplicationOperation(definition, principal, given, request, trace) {
2408
+ const served = application;
2409
+ try {
2410
+ const scopes = principal.scopes;
2411
+ if (scopes !== undefined &&
2412
+ !(Array.isArray(scopes) && scopes.includes(SLOTLOCK_APPLICATION_SCOPE))) {
2413
+ return { ok: false, status: 403, code: 'forbidden' };
2414
+ }
2415
+ const parsed = definition.input.safeParse(given);
2416
+ if (!parsed.success)
2417
+ return { ok: false, status: 400, code: 'invalid_arguments' };
2418
+ if (!(await served.authorize({ principal, operation: definition.name, input: parsed.data }))) {
2419
+ return { ok: false, status: 403, code: 'forbidden' };
2420
+ }
2421
+ const context = {
2422
+ principal,
2423
+ operation: definition.name,
2424
+ signal: request.signal,
2425
+ };
2426
+ if (trace)
2427
+ context.trace = trace;
2428
+ const data = await served.backend[definition.invoke].call(served.backend, context, parsed.data);
2429
+ if (!definition.output.safeParse(data).success) {
2430
+ reportUnexpected(options.onError, Object.assign(new Error(`The application backend's ${definition.name} result does not match the operation's output schema`), { code: 'invalid_backend_result' }), definition.name);
2431
+ return { ok: false, status: 500, code: 'invalid_backend_result' };
2432
+ }
2433
+ return { ok: true, data };
2434
+ }
2435
+ catch (error) {
2436
+ if (error instanceof SlotlockAgentOperationError) {
2437
+ return { ok: false, status: error.status, code: error.code };
2438
+ }
2439
+ reportUnexpected(options.onError, error, definition.name);
2440
+ return { ok: false, status: 500, code: 'internal_error' };
2441
+ }
2442
+ }
2270
2443
  /**
2271
2444
  * One REST call, checked as /mcp and /a2a are (origin, credential, rate limit), then run through
2272
2445
  * the same dispatcher: scopes, arguments, `authorize`, and confirmation, which REST cannot ask
@@ -2279,7 +2452,7 @@ export function createSlotlockAgentServer(options) {
2279
2452
  ? json(200, openApi)
2280
2453
  : refuse(405, 'method_not_allowed', { Allow: 'GET' });
2281
2454
  }
2282
- const match = matchSlotlockRestRoute(request.method, path);
2455
+ const match = matchSlotlockRestRoute(request.method, path, restRoutes);
2283
2456
  if (match === null)
2284
2457
  return refuse(404, 'not_found');
2285
2458
  if ('allow' in match) {
@@ -2292,7 +2465,10 @@ export function createSlotlockAgentServer(options) {
2292
2465
  return refuse(403, 'origin_not_allowed');
2293
2466
  }
2294
2467
  }
2295
- const principal = await options.authenticate(request);
2468
+ const principal = await principalOrBusy(options.authenticate, request);
2469
+ if (principal instanceof SlotlockAgentOperationError) {
2470
+ return refuse(503, principal.code, { 'Retry-After': '1' });
2471
+ }
2296
2472
  if (!principal ||
2297
2473
  !isCanonicalPrincipalIdentity(principal.subject) ||
2298
2474
  !isCanonicalPrincipalIdentity(principal.tenantRef)) {
@@ -2307,41 +2483,71 @@ export function createSlotlockAgentServer(options) {
2307
2483
  if ('invalid' in match)
2308
2484
  return refuse(400, 'invalid_arguments');
2309
2485
  const { route, params } = match;
2486
+ const trace = traceContextFrom(undefined, request.headers);
2487
+ const started = performance.now();
2488
+ const report = (status) => emit({
2489
+ type: 'rest',
2490
+ operation: route.operation,
2491
+ status,
2492
+ durationMs: elapsedMs(started),
2493
+ ...(trace ? { traceId: traceIdOf(trace) } : {}),
2494
+ });
2495
+ // A call refused before its operation runs is reported as one that ran, as on MCP and A2A.
2496
+ const refuseCall = (status, code) => {
2497
+ report(status);
2498
+ return refuse(status, code);
2499
+ };
2310
2500
  let given;
2311
2501
  if (restInputFromBody(route)) {
2312
- const contentType = request.headers.get('content-type')?.split(';')[0]?.trim();
2313
- if (contentType !== 'application/json')
2314
- return refuse(415, 'unsupported_media_type');
2502
+ if (!isJsonMediaType(request.headers.get('content-type'))) {
2503
+ return refuseCall(415, 'unsupported_media_type');
2504
+ }
2315
2505
  let body;
2316
2506
  try {
2317
2507
  body = await readJson(request, config.maxRequestBytes);
2318
2508
  }
2319
2509
  catch (error) {
2320
2510
  const tooLarge = error instanceof Error && error.message === 'request_too_large';
2321
- return refuse(tooLarge ? 413 : 400, tooLarge ? 'request_too_large' : 'invalid_json');
2511
+ return refuseCall(tooLarge ? 413 : 400, tooLarge ? 'request_too_large' : 'invalid_json');
2322
2512
  }
2323
2513
  if (!isRecord(body))
2324
- return refuse(400, 'invalid_arguments');
2514
+ return refuseCall(400, 'invalid_arguments');
2325
2515
  given = body;
2326
2516
  }
2327
2517
  else {
2328
- given = restQueryInput(url.searchParams, restInputSchemas.get(route.operation) ?? {});
2518
+ given = restQueryInput(url.searchParams, (restInputSchemas.get(route.operation) ?? {}));
2329
2519
  }
2330
2520
  // The path names the event; the body or query may not name another.
2331
2521
  if (Object.keys(params).some((name) => Object.hasOwn(given, name))) {
2332
- return refuse(400, 'invalid_arguments');
2522
+ return refuseCall(400, 'invalid_arguments');
2333
2523
  }
2334
- const trace = traceContextFrom(undefined, request.headers);
2335
- const outcome = await invokeSlotlockAgentOperation({
2336
- operation: route.operation,
2337
- input: { ...given, ...params },
2338
- request,
2339
- options: withoutConfirmationRound(principal, route.operation),
2340
- ...(trace ? { trace } : {}),
2341
- });
2524
+ // An application write's retry identity may come as the Idempotency-Key header, where HTTP
2525
+ // clients put it, as well as its `idempotency_key` argument. On an operation that keeps none it
2526
+ // is refused rather than ignored: a key that was dropped would promise a replay it never gives.
2527
+ // The agent tools take theirs as an argument alone, as they always have.
2528
+ const idempotencyHeader = request.headers.get('idempotency-key');
2529
+ if (idempotencyHeader !== null && isSlotlockApplicationOperation(route.operation)) {
2530
+ const fields = (restInputSchemas.get(route.operation)?.properties ?? {});
2531
+ if (!Object.hasOwn(fields, 'idempotency_key')) {
2532
+ return refuseCall(400, 'idempotency_key_not_supported');
2533
+ }
2534
+ if (Object.hasOwn(given, 'idempotency_key') && given.idempotency_key !== idempotencyHeader) {
2535
+ return refuseCall(400, 'invalid_arguments');
2536
+ }
2537
+ given = { ...given, idempotency_key: idempotencyHeader };
2538
+ }
2539
+ const outcome = isSlotlockApplicationOperation(route.operation)
2540
+ ? await invokeApplicationOperation(slotlockApplicationOperation(route.operation), principal, { ...given, ...params }, request, trace)
2541
+ : await invokeSlotlockAgentOperation({
2542
+ operation: route.operation,
2543
+ input: { ...given, ...params },
2544
+ request,
2545
+ options: withoutConfirmationRound(principal, route.operation),
2546
+ ...(trace ? { trace } : {}),
2547
+ });
2342
2548
  const internal = !outcome.ok && INTERNAL_FAILURES.has(outcome.code);
2343
2549
  const status = outcome.ok ? 200 : internal ? 500 : outcome.status;
2344
- emit({ type: 'rest', operation: route.operation, status });
2550
+ report(status);
2345
2551
  if (outcome.ok)
2346
2552
  return json(200, outcome.data);
2347
2553
  return refuse(status, internal ? 'internal_error' : outcome.code);
@@ -2411,8 +2617,7 @@ export function createSlotlockAgentServer(options) {
2411
2617
  if (request.method !== 'POST' || (path !== '/mcp' && path !== '/a2a')) {
2412
2618
  return json(404, { error: { code: 'not_found' } });
2413
2619
  }
2414
- const contentType = request.headers.get('content-type')?.split(';')[0]?.trim();
2415
- if (contentType !== 'application/json') {
2620
+ if (!isJsonMediaType(request.headers.get('content-type'))) {
2416
2621
  return json(415, { error: { code: 'unsupported_media_type' } });
2417
2622
  }
2418
2623
  const origin = request.headers.get('origin');
@@ -2422,7 +2627,17 @@ export function createSlotlockAgentServer(options) {
2422
2627
  return json(403, rpcError(null, -32600, 'Origin not allowed'));
2423
2628
  }
2424
2629
  }
2425
- const principal = await options.authenticate(request);
2630
+ const principal = await principalOrBusy(options.authenticate, request);
2631
+ if (principal instanceof SlotlockAgentOperationError) {
2632
+ // As `slotlock serve` answers it: A2A names the reason in an ErrorInfo, as for a rate limit.
2633
+ const error = { code: -32603, message: 'Server is busy' };
2634
+ const info = {
2635
+ '@type': 'type.googleapis.com/google.rpc.ErrorInfo',
2636
+ reason: 'SERVER_BUSY',
2637
+ domain: 'slotlock',
2638
+ };
2639
+ return json(503, { jsonrpc: '2.0', id: null, error: path === '/a2a' ? { ...error, data: [info] } : error }, { 'Retry-After': '1' });
2640
+ }
2426
2641
  if (!principal ||
2427
2642
  !isCanonicalPrincipalIdentity(principal.subject) ||
2428
2643
  !isCanonicalPrincipalIdentity(principal.tenantRef)) {
@@ -2432,7 +2647,14 @@ export function createSlotlockAgentServer(options) {
2432
2647
  }
2433
2648
  if (options.consumeRateLimit &&
2434
2649
  !(await options.consumeRateLimit({ principal, operation: 'protocol' }))) {
2435
- // Answered before the body is read, so the era comes from the version header alone.
2650
+ // Answered before the body is read: the request's id is unknown, and the MCP era comes
2651
+ // from the version header alone. A2A keeps the error in its 200 envelope with an ErrorInfo.
2652
+ if (path === '/a2a') {
2653
+ return a2aError(null, -32029, 'Rate limit exceeded', {
2654
+ reason: 'RATE_LIMITED',
2655
+ domain: 'slotlock',
2656
+ });
2657
+ }
2436
2658
  const modernClient = path === '/mcp' &&
2437
2659
  MODERN_MCP_VERSIONS.includes(request.headers.get('mcp-protocol-version') ?? '');
2438
2660
  return json(429, rpcError(null, modernClient ? SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE : -32029, 'Rate limit exceeded'));
@@ -2541,14 +2763,16 @@ export function createSlotlockAgentServer(options) {
2541
2763
  const operation = resolveSlotlockAgentOperation(rpc.params?.name);
2542
2764
  if (!operation)
2543
2765
  return json(200, rpcError(id, -32602, 'Unknown tool'));
2544
- const input = rpc.params?.arguments &&
2545
- typeof rpc.params.arguments === 'object' &&
2546
- !Array.isArray(rpc.params.arguments)
2547
- ? rpc.params.arguments
2548
- : {};
2766
+ const started = performance.now();
2767
+ const trace = traceContextFrom(isRecord(rpc.params?._meta) ? rpc.params._meta : undefined, request.headers);
2768
+ // `arguments` may be left out or null; anything else but an object is malformed params.
2769
+ const input = rpc.params?.arguments;
2770
+ if (input != null && !isRecord(input)) {
2771
+ reportCall('mcp', operation, 'invalid_arguments', started, trace);
2772
+ return json(200, rpcError(id, -32602, 'Invalid params'));
2773
+ }
2549
2774
  try {
2550
- const trace = traceContextFrom(isRecord(rpc.params?._meta) ? rpc.params._meta : undefined, request.headers);
2551
- const outcome = await invokeSlotlockAgentOperation({
2775
+ const outcome = await invokeReported('mcp', {
2552
2776
  operation,
2553
2777
  input,
2554
2778
  request,
@@ -2606,38 +2830,49 @@ export function createSlotlockAgentServer(options) {
2606
2830
  if (rpc.method !== 'SendMessage') {
2607
2831
  return json(200, rpcError(id, -32601, 'Method not found'));
2608
2832
  }
2609
- const a2aParamKeys = Object.keys(rpc.params ?? {});
2610
- if (a2aParamKeys.some((key) => !['message', 'configuration', 'metadata', 'tenant'].includes(key)) ||
2611
- !isCallerTenant(rpc.params?.tenant, principal.tenantRef)) {
2612
- return a2aError(id, -32602, 'Invalid message parameters', {
2613
- reason: 'INVALID_MESSAGE_PARAMETERS',
2614
- domain: 'slotlock',
2615
- });
2616
- }
2833
+ const started = performance.now();
2834
+ const trace = traceContextFrom(undefined, request.headers);
2617
2835
  const message = rpc.params?.message;
2618
2836
  const parts = Array.isArray(message?.parts) ? message.parts : [];
2619
2837
  const part = parts.length === 1 ? parts[0] : null;
2620
2838
  const data = part?.data;
2621
2839
  const operation = resolveSlotlockAgentOperation(data?.skill);
2622
- const input = data?.arguments && typeof data.arguments === 'object' && !Array.isArray(data.arguments)
2623
- ? data.arguments
2624
- : {};
2840
+ // A refused message that names an operation is still a call, reported by the error's reason.
2841
+ const refuse = (outcome, response) => {
2842
+ if (operation)
2843
+ reportCall('a2a', operation, outcome, started, trace);
2844
+ return response;
2845
+ };
2846
+ const a2aParamKeys = Object.keys(rpc.params ?? {});
2847
+ if (a2aParamKeys.some((key) => !['message', 'configuration', 'metadata', 'tenant'].includes(key)) ||
2848
+ !isCallerTenant(rpc.params?.tenant, principal.tenantRef) ||
2849
+ // a2a.proto: a SendMessageConfiguration and a Struct, each optional (null is unset in proto3
2850
+ // JSON). What A2A 1.0 defines inside them (output modes, history length, ...) is the client's.
2851
+ (rpc.params?.configuration != null && !isRecord(rpc.params.configuration)) ||
2852
+ (rpc.params?.metadata != null && !isRecord(rpc.params.metadata))) {
2853
+ return refuse('invalid_message_parameters', a2aError(id, -32602, 'Invalid message parameters', {
2854
+ reason: 'INVALID_MESSAGE_PARAMETERS',
2855
+ domain: 'slotlock',
2856
+ }));
2857
+ }
2858
+ // `arguments` may be left out or null; anything else but an object is not arguments.
2859
+ const input = data?.arguments;
2625
2860
  if (!operation ||
2626
2861
  message?.role !== 'ROLE_USER' ||
2627
2862
  !isCanonicalBoundedIdentifier(message.messageId, 200) ||
2628
2863
  message.taskId !== undefined ||
2629
- (message.contextId !== undefined && !isCanonicalBoundedIdentifier(message.contextId, 200))) {
2630
- return a2aError(id, -32602, 'Invalid message', {
2864
+ (message.contextId !== undefined && !isCanonicalBoundedIdentifier(message.contextId, 200)) ||
2865
+ (input != null && !isRecord(input))) {
2866
+ return refuse('invalid_message', a2aError(id, -32602, 'Invalid message', {
2631
2867
  reason: 'INVALID_MESSAGE',
2632
2868
  domain: 'slotlock',
2633
2869
  metadata: {
2634
2870
  expected: 'one application/json data part {"skill": "<skill id>", "arguments": {…}}',
2635
2871
  },
2636
- });
2872
+ }));
2637
2873
  }
2638
2874
  try {
2639
- const trace = traceContextFrom(undefined, request.headers);
2640
- const outcome = await invokeSlotlockAgentOperation({
2875
+ const outcome = await invokeReported('a2a', {
2641
2876
  operation,
2642
2877
  input,
2643
2878
  request,