slotlock 0.5.1 → 0.5.3
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 +122 -0
- package/README.md +152 -19
- package/SPEC.md +10 -7
- package/agent-server.d.ts +52 -4
- package/agent-server.js +479 -145
- package/agent-store-backend.d.ts +9 -0
- package/agent-store-backend.js +83 -15
- package/api-keys.d.ts +12 -2
- package/api-keys.js +36 -7
- package/application-api.d.ts +7 -1
- package/application-api.js +67 -7
- package/assistant.d.ts +263 -0
- package/assistant.js +772 -0
- package/booking-rules.d.ts +61 -9
- package/booking-rules.js +106 -16
- package/change-watch.d.ts +56 -0
- package/change-watch.js +213 -0
- package/config.d.ts +10 -0
- package/config.js +11 -0
- package/dashboard.d.ts +64 -1
- package/dashboard.js +687 -26
- package/ddl.d.ts +5 -5
- package/ddl.js +594 -6
- package/event-feed.d.ts +37 -4
- package/event-feed.js +74 -6
- package/index.d.ts +11 -8
- package/index.js +7 -5
- package/maintenance.d.ts +2 -1
- package/maintenance.js +33 -9
- package/openapi.json +1284 -82
- package/package.json +1 -1
- package/rate-limit.d.ts +8 -0
- package/rate-limit.js +20 -0
- package/self-host.d.ts +2 -2
- package/self-host.js +42 -7
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +132 -3
- package/store.js +482 -124
- package/types.d.ts +14 -1
- package/webhooks.d.ts +33 -2
- package/webhooks.js +102 -20
package/agent-server.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { AsyncResource } from 'node:async_hooks';
|
|
1
2
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
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
5
|
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
6
|
import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, slotlockApplicationOperation, } from './application-api.js';
|
|
7
|
+
import { createSlotlockChangeReaders, } from './change-watch.js';
|
|
6
8
|
import { SLOTLOCK_EVENT_OUTPUT as EVENT } from './event-schema.js';
|
|
7
9
|
import { ignoreRejection } from './hooks.js';
|
|
8
10
|
import { slotlockPackageVersion } from './package-version.js';
|
|
@@ -1185,6 +1187,8 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
1185
1187
|
};
|
|
1186
1188
|
if (args.trace)
|
|
1187
1189
|
context.trace = args.trace;
|
|
1190
|
+
if (args.origin)
|
|
1191
|
+
context.origin = args.origin;
|
|
1188
1192
|
const data = await method.call(backend, context, parsed.data);
|
|
1189
1193
|
if (!operation.output.safeParse(data).success) {
|
|
1190
1194
|
// Neither the result nor the schema's complaint about it: either may hold tenant data.
|
|
@@ -1226,12 +1230,14 @@ function traceIdOf(trace) {
|
|
|
1226
1230
|
/** Failures that are server faults, not operation outcomes an agent could act on. */
|
|
1227
1231
|
const INTERNAL_FAILURES = new Set(['internal_error', 'invalid_backend_result']);
|
|
1228
1232
|
/**
|
|
1229
|
-
* A2A 1.0 methods (a2a.proto `A2AService`) this
|
|
1230
|
-
*
|
|
1231
|
-
* `PushNotificationNotSupportedError
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1233
|
+
* A2A 1.0 methods (a2a.proto `A2AService`) this binding does not implement. A2A §3.3.4 fixes the
|
|
1234
|
+
* error for the capability-gated ones: push-notification configuration answers
|
|
1235
|
+
* `PushNotificationNotSupportedError` (a tenant's changes reach a URL as signed webhooks instead);
|
|
1236
|
+
* task subscription and the extended card answer `UnsupportedOperationError`. `SendStreamingMessage`
|
|
1237
|
+
* is served: a skill's reply as a one-message stream, and the watch skill as a task whose stream is
|
|
1238
|
+
* the only place it lives, so it cannot be subscribed to again. GetTask, CancelTask and ListTasks are
|
|
1239
|
+
* core operations every agent answers; no task is kept to find: `TaskNotFoundError` for the first
|
|
1240
|
+
* two (§3.1) and an empty page for the list.
|
|
1235
1241
|
*/
|
|
1236
1242
|
const A2A_PUSH_NOTIFICATION_METHODS = new Set([
|
|
1237
1243
|
'CreateTaskPushNotificationConfig',
|
|
@@ -1240,7 +1246,6 @@ const A2A_PUSH_NOTIFICATION_METHODS = new Set([
|
|
|
1240
1246
|
'DeleteTaskPushNotificationConfig',
|
|
1241
1247
|
]);
|
|
1242
1248
|
const A2A_UNSUPPORTED_METHODS = new Set([
|
|
1243
|
-
'SendStreamingMessage',
|
|
1244
1249
|
'SubscribeToTask',
|
|
1245
1250
|
'GetExtendedAgentCard',
|
|
1246
1251
|
]);
|
|
@@ -1450,9 +1455,19 @@ const INVISIBLE_STATUSES = new Set([400, 403, 404]);
|
|
|
1450
1455
|
const MAX_RESOURCE_SUBSCRIPTIONS = 20;
|
|
1451
1456
|
/** Consecutive failed re-reads after which a subscription is abandoned. */
|
|
1452
1457
|
const MAX_POLL_FAILURES = 3;
|
|
1458
|
+
/** The least time between the starts of two re-reads of one subscription a change asked for, in ms. */
|
|
1459
|
+
const CHANGE_REREAD_MIN_MS = 1_000;
|
|
1453
1460
|
function validateSubscriptions(value) {
|
|
1454
1461
|
if (value === false)
|
|
1455
1462
|
return null;
|
|
1463
|
+
const changes = value?.changes ?? null;
|
|
1464
|
+
if (changes !== null &&
|
|
1465
|
+
(typeof changes !== 'object' ||
|
|
1466
|
+
typeof changes.head !== 'function' ||
|
|
1467
|
+
typeof changes.listChanges !== 'function' ||
|
|
1468
|
+
(changes.watch !== undefined && typeof changes.watch !== 'function'))) {
|
|
1469
|
+
throw new Error('Slotlock agent server subscriptions.changes is invalid');
|
|
1470
|
+
}
|
|
1456
1471
|
const bounded = (name, min, max, fallback) => {
|
|
1457
1472
|
const setting = value?.[name] ?? fallback;
|
|
1458
1473
|
if (!Number.isInteger(setting) || setting < min || setting > max) {
|
|
@@ -1462,7 +1477,8 @@ function validateSubscriptions(value) {
|
|
|
1462
1477
|
};
|
|
1463
1478
|
const maxTotal = bounded('maxTotal', 1, 10_000, 256);
|
|
1464
1479
|
return {
|
|
1465
|
-
pollIntervalMs: bounded('pollIntervalMs', 1_000, 300_000, 10_000),
|
|
1480
|
+
pollIntervalMs: bounded('pollIntervalMs', 1_000, 300_000, changes ? 15_000 : 10_000),
|
|
1481
|
+
changes,
|
|
1466
1482
|
maxDurationMs: bounded('maxDurationMs', 60_000, 86_400_000, 900_000),
|
|
1467
1483
|
keepAliveMs: bounded('keepAliveMs', 1_000, 60_000, 15_000),
|
|
1468
1484
|
maxPerPrincipal: bounded('maxPerPrincipal', 1, 64, 4),
|
|
@@ -1912,6 +1928,21 @@ function confirmationLookup(options, context) {
|
|
|
1912
1928
|
},
|
|
1913
1929
|
};
|
|
1914
1930
|
}
|
|
1931
|
+
/**
|
|
1932
|
+
* The sentence a person is asked to confirm for a write, as the confirmation flow words it: the
|
|
1933
|
+
* resource by name and the times in its own zone, and for a change or deletion the event as it
|
|
1934
|
+
* stands (its title, resource and time), each read as `principal` only where its scopes and
|
|
1935
|
+
* `authorize` allow that read. The dashboard assistant shows it on a proposed change.
|
|
1936
|
+
*/
|
|
1937
|
+
export async function describeSlotlockAgentWrite(args) {
|
|
1938
|
+
const lookup = confirmationLookup({ backend: args.backend, authorize: args.authorize }, { principal: args.principal, signal: args.signal });
|
|
1939
|
+
try {
|
|
1940
|
+
return await confirmationMessage(args.operation, args.input, lookup);
|
|
1941
|
+
}
|
|
1942
|
+
finally {
|
|
1943
|
+
lookup.close();
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1915
1946
|
/**
|
|
1916
1947
|
* The sentence a person confirms: what changes, where and when. The facts the server establishes
|
|
1917
1948
|
* (the resource, the times in its own zone, every date a repeat books, the event as it stands) come
|
|
@@ -2146,8 +2177,15 @@ function routePath(pathname, basePath) {
|
|
|
2146
2177
|
return '/';
|
|
2147
2178
|
return pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : null;
|
|
2148
2179
|
}
|
|
2180
|
+
/**
|
|
2181
|
+
* The A2A skill that watches calendars: `SendStreamingMessage` only. Its stream opens with a task
|
|
2182
|
+
* in TASK_STATE_WORKING and carries a status update for each change to a watched resource's
|
|
2183
|
+
* free/busy, from the same feed as MCP resource subscriptions, until the watch's duration is up.
|
|
2184
|
+
*/
|
|
2185
|
+
export const SLOTLOCK_A2A_WATCH_SKILL = 'slotlock_watch_resources';
|
|
2186
|
+
const A2A_WATCH_EXAMPLE = { resource_ids: ['018f2f8e-7f8a-7c3e-9b1a-2a4c6e8f0a1b'] };
|
|
2149
2187
|
/** The A2A 1.0 agent card, in the JSON form of a2a.proto's `AgentCard`. */
|
|
2150
|
-
function agentCard(publicBaseUrl) {
|
|
2188
|
+
function agentCard(publicBaseUrl, watch) {
|
|
2151
2189
|
return Object.freeze({
|
|
2152
2190
|
name: 'Slotlock Agent Calendar',
|
|
2153
2191
|
description: `Tenant-isolated resource events, recurrence, free-busy, and scheduling. Invoke a skill with one application/json data part {"skill": "<skill id>", "arguments": {…}}; argument and result JSON Schemas are published at ${publicBaseUrl}/manifest.json.`,
|
|
@@ -2159,20 +2197,37 @@ function agentCard(publicBaseUrl) {
|
|
|
2159
2197
|
},
|
|
2160
2198
|
],
|
|
2161
2199
|
version: SLOTLOCK_AGENT_SERVER_VERSION,
|
|
2162
|
-
capabilities: { streaming:
|
|
2200
|
+
capabilities: { streaming: true, pushNotifications: false, extendedAgentCard: false },
|
|
2163
2201
|
securitySchemes: { bearer: { httpAuthSecurityScheme: { scheme: 'Bearer' } } },
|
|
2164
2202
|
securityRequirements: [{ schemes: { bearer: { list: [] } } }],
|
|
2165
2203
|
defaultInputModes: ['application/json'],
|
|
2166
2204
|
defaultOutputModes: ['application/json'],
|
|
2167
|
-
skills:
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2205
|
+
skills: [
|
|
2206
|
+
...OPERATION_DEFINITIONS.map((operation) => ({
|
|
2207
|
+
id: operation.name,
|
|
2208
|
+
name: operation.title,
|
|
2209
|
+
description: operation.description,
|
|
2210
|
+
tags: ['calendar', 'scheduling', 'resources'],
|
|
2211
|
+
examples: [JSON.stringify({ skill: operation.name, arguments: operation.example })],
|
|
2212
|
+
inputModes: ['application/json'],
|
|
2213
|
+
outputModes: ['application/json'],
|
|
2214
|
+
})),
|
|
2215
|
+
...(watch
|
|
2216
|
+
? [
|
|
2217
|
+
{
|
|
2218
|
+
id: SLOTLOCK_A2A_WATCH_SKILL,
|
|
2219
|
+
name: 'Watch calendar resources',
|
|
2220
|
+
description: 'SendStreamingMessage only. Watch up to 20 resources (resource_ids) for changes to their free/busy: the stream opens with a task in TASK_STATE_WORKING and carries a status update {"resource_id", "uri"} for each change, until the watch ends (TASK_STATE_COMPLETED) or access is withdrawn (TASK_STATE_FAILED). Read the resource again on each update.',
|
|
2221
|
+
tags: ['calendar', 'resources', 'watch'],
|
|
2222
|
+
examples: [
|
|
2223
|
+
JSON.stringify({ skill: SLOTLOCK_A2A_WATCH_SKILL, arguments: A2A_WATCH_EXAMPLE }),
|
|
2224
|
+
],
|
|
2225
|
+
inputModes: ['application/json'],
|
|
2226
|
+
outputModes: ['application/json'],
|
|
2227
|
+
},
|
|
2228
|
+
]
|
|
2229
|
+
: []),
|
|
2230
|
+
],
|
|
2176
2231
|
});
|
|
2177
2232
|
}
|
|
2178
2233
|
export function createSlotlockAgentServer(options) {
|
|
@@ -2226,7 +2281,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2226
2281
|
},
|
|
2227
2282
|
capabilities: slotlockAgentTools(),
|
|
2228
2283
|
});
|
|
2229
|
-
const card = agentCard(config.publicBaseUrl);
|
|
2284
|
+
const card = agentCard(config.publicBaseUrl, options.subscriptions !== false);
|
|
2230
2285
|
const confirmation = validateConfirmation(options.confirmation);
|
|
2231
2286
|
const emit = (event) => {
|
|
2232
2287
|
try {
|
|
@@ -2253,7 +2308,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2253
2308
|
/** Run an operation an MCP or A2A caller named, and report how it ended as a `call` event. */
|
|
2254
2309
|
const invokeReported = async (protocol, args) => {
|
|
2255
2310
|
const started = performance.now();
|
|
2256
|
-
const outcome = await invokeSlotlockAgentOperation(args);
|
|
2311
|
+
const outcome = await invokeSlotlockAgentOperation({ ...args, origin: protocol });
|
|
2257
2312
|
reportCall(protocol, args.operation, outcome.ok ? 'ok' : outcome.code, started, args.trace);
|
|
2258
2313
|
return outcome;
|
|
2259
2314
|
};
|
|
@@ -2291,6 +2346,10 @@ export function createSlotlockAgentServer(options) {
|
|
|
2291
2346
|
/** Open subscriptions (and slots reserved by ones still opening), for the caps and shutdown. */
|
|
2292
2347
|
const openSubscriptions = new Set();
|
|
2293
2348
|
let shuttingDown = false;
|
|
2349
|
+
/** One feed reader per tenant with an open subscription, when there is a feed to follow. */
|
|
2350
|
+
const changeReaders = subscriptionPolicy?.changes
|
|
2351
|
+
? createSlotlockChangeReaders(subscriptionPolicy.changes, (error) => reportUnexpected(options.onError, error, null))
|
|
2352
|
+
: null;
|
|
2294
2353
|
const discoverResult = Object.freeze({
|
|
2295
2354
|
supportedVersions: [...SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS],
|
|
2296
2355
|
capabilities: {
|
|
@@ -2429,6 +2488,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2429
2488
|
input: { resource_ids: resourceIds, start: window.start, end: window.end },
|
|
2430
2489
|
request: call.request,
|
|
2431
2490
|
options: dispatch,
|
|
2491
|
+
origin: 'mcp',
|
|
2432
2492
|
...(call.trace ? { trace: call.trace } : {}),
|
|
2433
2493
|
});
|
|
2434
2494
|
/**
|
|
@@ -2447,6 +2507,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2447
2507
|
input: cursor === undefined ? {} : { cursor },
|
|
2448
2508
|
request: call.request,
|
|
2449
2509
|
options: dispatchOptions(call.principal),
|
|
2510
|
+
origin: 'mcp',
|
|
2450
2511
|
...(call.trace ? { trace: call.trace } : {}),
|
|
2451
2512
|
});
|
|
2452
2513
|
if (!outcome.ok) {
|
|
@@ -2545,22 +2606,10 @@ export function createSlotlockAgentServer(options) {
|
|
|
2545
2606
|
return { ok: true, digests };
|
|
2546
2607
|
}
|
|
2547
2608
|
/**
|
|
2548
|
-
*
|
|
2549
|
-
*
|
|
2550
|
-
* principal may watch, then carries `notifications/resources/updated` for each change until the
|
|
2551
|
-
* client closes it or the server ends it gracefully (its duration, shutdown, lost access) with the
|
|
2552
|
-
* request's result. Nothing is honored but resource subscriptions; a subscription honoring nothing
|
|
2553
|
-
* ends right after its acknowledgment.
|
|
2609
|
+
* A subscription slot for the principal, held while it opens so concurrent opens cannot
|
|
2610
|
+
* overshoot the caps; null when a cap is reached. MCP subscriptions and A2A watches share them.
|
|
2554
2611
|
*/
|
|
2555
|
-
|
|
2556
|
-
const { id, principal } = call;
|
|
2557
|
-
if (!subscriptionPolicy)
|
|
2558
|
-
return modernError(404, id, -32601, 'Method not found');
|
|
2559
|
-
const filter = parseSubscriptionFilter(call.params.notifications);
|
|
2560
|
-
if (!filter.ok)
|
|
2561
|
-
return modernError(200, id, -32602, filter.message, filter.data);
|
|
2562
|
-
if (shuttingDown)
|
|
2563
|
-
return modernError(503, id, -32603, 'Server is shutting down');
|
|
2612
|
+
function reserveSubscriptionSlot(policy, principal) {
|
|
2564
2613
|
const principalKey = `${principal.tenantRef}\0${principal.subject}`;
|
|
2565
2614
|
let held = 0;
|
|
2566
2615
|
let heldByTenant = 0;
|
|
@@ -2570,9 +2619,9 @@ export function createSlotlockAgentServer(options) {
|
|
|
2570
2619
|
if (open.tenantRef === principal.tenantRef)
|
|
2571
2620
|
heldByTenant += 1;
|
|
2572
2621
|
}
|
|
2573
|
-
if (openSubscriptions.size >=
|
|
2574
|
-
held <
|
|
2575
|
-
heldByTenant <
|
|
2622
|
+
if (openSubscriptions.size >= policy.maxTotal &&
|
|
2623
|
+
held < policy.maxPerPrincipal &&
|
|
2624
|
+
heldByTenant < policy.maxPerTenant) {
|
|
2576
2625
|
// A full server is shared out: a tenant holding fewer than the one holding most, less one,
|
|
2577
2626
|
// ends that tenant's oldest stream, so many tenants within their share cannot keep another
|
|
2578
2627
|
// from holding any. A slot still opening has nothing to end yet.
|
|
@@ -2593,12 +2642,11 @@ export function createSlotlockAgentServer(options) {
|
|
|
2593
2642
|
if (most.length > heldByTenant + 1)
|
|
2594
2643
|
most[0]?.end('preempted');
|
|
2595
2644
|
}
|
|
2596
|
-
if (openSubscriptions.size >=
|
|
2597
|
-
held >=
|
|
2598
|
-
heldByTenant >=
|
|
2599
|
-
return
|
|
2645
|
+
if (openSubscriptions.size >= policy.maxTotal ||
|
|
2646
|
+
held >= policy.maxPerPrincipal ||
|
|
2647
|
+
heldByTenant >= policy.maxPerTenant) {
|
|
2648
|
+
return null;
|
|
2600
2649
|
}
|
|
2601
|
-
// The slot is held while access is checked, so concurrent opens cannot overshoot the caps.
|
|
2602
2650
|
const slot = {
|
|
2603
2651
|
principalKey,
|
|
2604
2652
|
tenantRef: principal.tenantRef,
|
|
@@ -2606,13 +2654,43 @@ export function createSlotlockAgentServer(options) {
|
|
|
2606
2654
|
end: () => { },
|
|
2607
2655
|
};
|
|
2608
2656
|
openSubscriptions.add(slot);
|
|
2657
|
+
return slot;
|
|
2658
|
+
}
|
|
2659
|
+
/**
|
|
2660
|
+
* The window a watch covers. A read's window rolls forward with the clock; a watch covers one
|
|
2661
|
+
* fixed window that holds every window a read can return while it is open (from now to its last
|
|
2662
|
+
* possible read's end), so a booking entering the rolling end is announced, and the clock alone
|
|
2663
|
+
* changes nothing.
|
|
2664
|
+
*/
|
|
2665
|
+
function watchWindow(policy) {
|
|
2666
|
+
const window = calendarResourceWindow(Date.now(), resourceWindowDays);
|
|
2667
|
+
window.end = new Date(Date.parse(window.end) + policy.maxDurationMs).toISOString();
|
|
2668
|
+
return window;
|
|
2669
|
+
}
|
|
2670
|
+
/**
|
|
2671
|
+
* `subscriptions/listen` (2026-07-28 basic/patterns/subscriptions). The response is an SSE stream
|
|
2672
|
+
* that opens with `notifications/subscriptions/acknowledged` naming the calendar resources this
|
|
2673
|
+
* principal may watch, then carries `notifications/resources/updated` for each change until the
|
|
2674
|
+
* client closes it or the server ends it gracefully (its duration, shutdown, lost access) with the
|
|
2675
|
+
* request's result. Nothing is honored but resource subscriptions; a subscription honoring nothing
|
|
2676
|
+
* ends right after its acknowledgment.
|
|
2677
|
+
*/
|
|
2678
|
+
async function modernListen(call) {
|
|
2679
|
+
const { id, principal } = call;
|
|
2680
|
+
if (!subscriptionPolicy)
|
|
2681
|
+
return modernError(404, id, -32601, 'Method not found');
|
|
2682
|
+
const filter = parseSubscriptionFilter(call.params.notifications);
|
|
2683
|
+
if (!filter.ok)
|
|
2684
|
+
return modernError(200, id, -32602, filter.message, filter.data);
|
|
2685
|
+
if (shuttingDown)
|
|
2686
|
+
return modernError(503, id, -32603, 'Server is shutting down');
|
|
2687
|
+
const slot = reserveSubscriptionSlot(subscriptionPolicy, principal);
|
|
2688
|
+
if (!slot) {
|
|
2689
|
+
return modernError(429, id, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, 'Too many subscriptions');
|
|
2690
|
+
}
|
|
2609
2691
|
let streaming = false;
|
|
2610
2692
|
try {
|
|
2611
|
-
|
|
2612
|
-
// holds every window a read can return while it is open (from now to its last possible read's
|
|
2613
|
-
// end), so a booking entering the rolling end is announced, and the clock alone changes nothing.
|
|
2614
|
-
const window = calendarResourceWindow(Date.now(), resourceWindowDays);
|
|
2615
|
-
window.end = new Date(Date.parse(window.end) + subscriptionPolicy.maxDurationMs).toISOString();
|
|
2693
|
+
const window = watchWindow(subscriptionPolicy);
|
|
2616
2694
|
const watchable = await watchableCalendars(call, filter.resourceIds, window);
|
|
2617
2695
|
if (!watchable.ok)
|
|
2618
2696
|
return modernFailure(id, watchable);
|
|
@@ -2629,12 +2707,153 @@ export function createSlotlockAgentServer(options) {
|
|
|
2629
2707
|
openSubscriptions.delete(slot);
|
|
2630
2708
|
}
|
|
2631
2709
|
}
|
|
2710
|
+
/**
|
|
2711
|
+
* Watch `watched.resourceIds` for the principal until the returned function is called: re-read
|
|
2712
|
+
* their free/busy when the tenant's feed says a change touched one (and every `pollIntervalMs`
|
|
2713
|
+
* regardless), and call `updated` for each whose free/busy changed. Every re-read authenticates
|
|
2714
|
+
* the request's credentials again and must find the same principal, and is authorized like any
|
|
2715
|
+
* read; neither the rate limiter nor a confirmation applies to reads the server makes on its own.
|
|
2716
|
+
*/
|
|
2717
|
+
function watchResources(call, policy, window, watched, sink) {
|
|
2718
|
+
const { principal, request } = call;
|
|
2719
|
+
const stop = new AbortController();
|
|
2720
|
+
let stopped = false;
|
|
2721
|
+
let refreshing = false;
|
|
2722
|
+
let again = false;
|
|
2723
|
+
// The feed's cursor expired: the next read that succeeds announces every resource.
|
|
2724
|
+
let owedAll = false;
|
|
2725
|
+
let failures = 0;
|
|
2726
|
+
let nextPoll;
|
|
2727
|
+
// Changes coalesce: a re-read starts at most once every CHANGE_REREAD_MIN_MS.
|
|
2728
|
+
let lastRefresh = 0;
|
|
2729
|
+
let touchTimer;
|
|
2730
|
+
const pollRequest = new Request(request.url, {
|
|
2731
|
+
method: 'POST',
|
|
2732
|
+
headers: request.headers,
|
|
2733
|
+
signal: AbortSignal.any([request.signal, stop.signal]),
|
|
2734
|
+
});
|
|
2735
|
+
const pollDispatch = {
|
|
2736
|
+
backend: options.backend,
|
|
2737
|
+
authenticate: async (current) => {
|
|
2738
|
+
const again = await options.authenticate(current);
|
|
2739
|
+
return again?.subject === principal.subject && again.tenantRef === principal.tenantRef
|
|
2740
|
+
? again
|
|
2741
|
+
: null;
|
|
2742
|
+
},
|
|
2743
|
+
authorize: options.authorize,
|
|
2744
|
+
...(options.onError ? { onError: options.onError } : {}),
|
|
2745
|
+
};
|
|
2746
|
+
const schedule = () => {
|
|
2747
|
+
clearTimeout(nextPoll);
|
|
2748
|
+
nextPoll = unref(setTimeout(() => void refresh(false), policy.pollIntervalMs));
|
|
2749
|
+
};
|
|
2750
|
+
const refresh = async (all) => {
|
|
2751
|
+
if (stopped)
|
|
2752
|
+
return;
|
|
2753
|
+
if (all)
|
|
2754
|
+
owedAll = true;
|
|
2755
|
+
if (refreshing) {
|
|
2756
|
+
again = true;
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
refreshing = true;
|
|
2760
|
+
lastRefresh = Date.now();
|
|
2761
|
+
clearTimeout(nextPoll);
|
|
2762
|
+
clearTimeout(touchTimer);
|
|
2763
|
+
touchTimer = undefined;
|
|
2764
|
+
try {
|
|
2765
|
+
const outcome = await readFreeBusy({ request: pollRequest, trace: call.trace }, pollDispatch, watched.resourceIds, window);
|
|
2766
|
+
if (stopped)
|
|
2767
|
+
return;
|
|
2768
|
+
if (!outcome.ok) {
|
|
2769
|
+
if (outcome.status === 401 || INVISIBLE_STATUSES.has(outcome.status)) {
|
|
2770
|
+
sink.end('revoked');
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2773
|
+
failures += 1;
|
|
2774
|
+
if (failures >= MAX_POLL_FAILURES) {
|
|
2775
|
+
sink.end('failed');
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
else {
|
|
2780
|
+
failures = 0;
|
|
2781
|
+
const announceAll = owedAll;
|
|
2782
|
+
owedAll = false;
|
|
2783
|
+
const digests = freeBusyDigests(outcome.data);
|
|
2784
|
+
for (const resourceId of watched.resourceIds) {
|
|
2785
|
+
const digest = digests.get(resourceId) ?? '';
|
|
2786
|
+
if (!announceAll && digest === watched.digests.get(resourceId))
|
|
2787
|
+
continue;
|
|
2788
|
+
watched.digests.set(resourceId, digest);
|
|
2789
|
+
sink.updated(resourceId);
|
|
2790
|
+
}
|
|
2791
|
+
if (!sink.healthy()) {
|
|
2792
|
+
sink.end('failed');
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
catch {
|
|
2798
|
+
sink.end('failed');
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
finally {
|
|
2802
|
+
refreshing = false;
|
|
2803
|
+
}
|
|
2804
|
+
if (stopped)
|
|
2805
|
+
return;
|
|
2806
|
+
schedule();
|
|
2807
|
+
if (again) {
|
|
2808
|
+
again = false;
|
|
2809
|
+
touch(false);
|
|
2810
|
+
}
|
|
2811
|
+
};
|
|
2812
|
+
const touch = (all) => {
|
|
2813
|
+
if (stopped)
|
|
2814
|
+
return;
|
|
2815
|
+
if (all)
|
|
2816
|
+
owedAll = true;
|
|
2817
|
+
if (refreshing) {
|
|
2818
|
+
again = true;
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2821
|
+
const wait = lastRefresh + CHANGE_REREAD_MIN_MS - Date.now();
|
|
2822
|
+
if (wait <= 0) {
|
|
2823
|
+
void refresh(false);
|
|
2824
|
+
}
|
|
2825
|
+
else if (touchTimer === undefined) {
|
|
2826
|
+
touchTimer = unref(setTimeout(() => {
|
|
2827
|
+
touchTimer = undefined;
|
|
2828
|
+
void refresh(false);
|
|
2829
|
+
}, wait));
|
|
2830
|
+
}
|
|
2831
|
+
};
|
|
2832
|
+
// The tenant's reader is shared and runs outside any request; this subscription's re-reads run
|
|
2833
|
+
// in its own request's async context.
|
|
2834
|
+
const watcher = {
|
|
2835
|
+
resourceIds: new Set(watched.resourceIds),
|
|
2836
|
+
touched: AsyncResource.bind(() => touch(false)),
|
|
2837
|
+
resync: AsyncResource.bind(() => touch(true)),
|
|
2838
|
+
};
|
|
2839
|
+
const leave = changeReaders?.join(principal.tenantRef, watcher);
|
|
2840
|
+
schedule();
|
|
2841
|
+
return () => {
|
|
2842
|
+
if (stopped)
|
|
2843
|
+
return;
|
|
2844
|
+
stopped = true;
|
|
2845
|
+
clearTimeout(nextPoll);
|
|
2846
|
+
clearTimeout(touchTimer);
|
|
2847
|
+
stop.abort();
|
|
2848
|
+
leave?.();
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2632
2851
|
function streamSubscription(call, policy, slot, window, watched) {
|
|
2633
|
-
const { id,
|
|
2852
|
+
const { id, request } = call;
|
|
2634
2853
|
const subscriptionMeta = { [MCP_META_SUBSCRIPTION_ID]: id };
|
|
2635
|
-
const stop = new AbortController();
|
|
2636
2854
|
let ended = false;
|
|
2637
2855
|
const timers = {};
|
|
2856
|
+
let unwatch;
|
|
2638
2857
|
const stream = openMcpEventStream(() => end('client'));
|
|
2639
2858
|
const onAbort = () => end('client');
|
|
2640
2859
|
function end(reason) {
|
|
@@ -2643,8 +2862,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2643
2862
|
ended = true;
|
|
2644
2863
|
clearInterval(timers.keepAlive);
|
|
2645
2864
|
clearTimeout(timers.lifetime);
|
|
2646
|
-
|
|
2647
|
-
stop.abort();
|
|
2865
|
+
unwatch?.();
|
|
2648
2866
|
request.signal.removeEventListener('abort', onAbort);
|
|
2649
2867
|
openSubscriptions.delete(slot);
|
|
2650
2868
|
// A server-side end the client should not retry is graceful: the listen request's result.
|
|
@@ -2689,73 +2907,176 @@ export function createSlotlockAgentServer(options) {
|
|
|
2689
2907
|
return stream.response;
|
|
2690
2908
|
}
|
|
2691
2909
|
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2910
|
+
unwatch = watchResources(call, policy, window, watched, {
|
|
2911
|
+
updated: (resourceId) => stream.send({
|
|
2912
|
+
jsonrpc: '2.0',
|
|
2913
|
+
method: 'notifications/resources/updated',
|
|
2914
|
+
params: { _meta: subscriptionMeta, uri: slotlockCalendarResourceUri(resourceId) },
|
|
2915
|
+
}),
|
|
2916
|
+
healthy: () => !stream.lagging(),
|
|
2917
|
+
end,
|
|
2699
2918
|
});
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2919
|
+
timers.keepAlive = unref(setInterval(() => {
|
|
2920
|
+
if (!stream.keepAlive() || stream.lagging())
|
|
2921
|
+
end('failed');
|
|
2922
|
+
}, policy.keepAliveMs));
|
|
2923
|
+
timers.lifetime = unref(setTimeout(() => end('duration'), policy.maxDurationMs));
|
|
2924
|
+
return stream.response;
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* The A2A watch skill over `SendStreamingMessage`: the same caps, visibility check, feed and
|
|
2928
|
+
* re-reads as an MCP subscription, framed as A2A 1.0 stream responses. The stream opens with the
|
|
2929
|
+
* task (TASK_STATE_WORKING, naming the resources watched), carries a status update naming each
|
|
2930
|
+
* resource whose free/busy changed, and ends with TASK_STATE_COMPLETED when its duration is up or
|
|
2931
|
+
* the server shuts down, or TASK_STATE_FAILED when the credential or permission is withdrawn. A
|
|
2932
|
+
* refusal before it opens is the skill's reply, as for any skill.
|
|
2933
|
+
*/
|
|
2934
|
+
async function a2aWatch(args) {
|
|
2935
|
+
const { id, request, principal, trace, contextId, reply } = args;
|
|
2936
|
+
const policy = subscriptionPolicy;
|
|
2937
|
+
if (!policy)
|
|
2938
|
+
return reply({ error: { code: 'not_supported' } });
|
|
2939
|
+
const given = args.input;
|
|
2940
|
+
const requested = isRecord(given) ? given.resource_ids : undefined;
|
|
2941
|
+
if (!isRecord(given) ||
|
|
2942
|
+
Object.keys(given).some((key) => key !== 'resource_ids') ||
|
|
2943
|
+
!Array.isArray(requested) ||
|
|
2944
|
+
requested.length === 0 ||
|
|
2945
|
+
requested.length > MAX_RESOURCE_SUBSCRIPTIONS ||
|
|
2946
|
+
!requested.every((resourceId) => isCanonicalBoundedIdentifier(resourceId, 500))) {
|
|
2947
|
+
return reply({ error: { code: 'invalid_arguments' } });
|
|
2948
|
+
}
|
|
2949
|
+
if (shuttingDown)
|
|
2950
|
+
return json(200, rpcError(id, -32603, 'Server is shutting down'));
|
|
2951
|
+
const slot = reserveSubscriptionSlot(policy, principal);
|
|
2952
|
+
if (!slot) {
|
|
2953
|
+
return a2aError(id, -32029, 'Too many subscriptions', {
|
|
2954
|
+
reason: 'RATE_LIMITED',
|
|
2955
|
+
domain: 'slotlock',
|
|
2956
|
+
});
|
|
2957
|
+
}
|
|
2958
|
+
let streaming = false;
|
|
2959
|
+
try {
|
|
2960
|
+
const resourceIds = [...new Set(requested)];
|
|
2961
|
+
const window = watchWindow(policy);
|
|
2962
|
+
const call = { request, principal, trace };
|
|
2963
|
+
const watchable = await watchableCalendars(call, resourceIds, window);
|
|
2964
|
+
if (!watchable.ok) {
|
|
2965
|
+
if (INTERNAL_FAILURES.has(watchable.code)) {
|
|
2966
|
+
return json(200, rpcError(id, -32603, 'Internal error'));
|
|
2746
2967
|
}
|
|
2747
|
-
|
|
2968
|
+
return reply({ error: { code: watchable.code } });
|
|
2748
2969
|
}
|
|
2749
|
-
|
|
2750
|
-
|
|
2970
|
+
const watched = {
|
|
2971
|
+
resourceIds: resourceIds.filter((resourceId) => watchable.digests.has(resourceId)),
|
|
2972
|
+
digests: watchable.digests,
|
|
2973
|
+
};
|
|
2974
|
+
if (watched.resourceIds.length === 0)
|
|
2975
|
+
return reply({ error: { code: 'not_found' } });
|
|
2976
|
+
if (shuttingDown)
|
|
2977
|
+
return json(200, rpcError(id, -32603, 'Server is shutting down'));
|
|
2978
|
+
streaming = true;
|
|
2979
|
+
return streamA2AWatch({ id, request, principal, trace, contextId }, policy, slot, window, watched);
|
|
2980
|
+
}
|
|
2981
|
+
finally {
|
|
2982
|
+
if (!streaming)
|
|
2983
|
+
openSubscriptions.delete(slot);
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
function streamA2AWatch(call, policy, slot, window, watched) {
|
|
2987
|
+
const { id, request, contextId } = call;
|
|
2988
|
+
const taskId = `watch_${randomBytes(16).toString('base64url')}`;
|
|
2989
|
+
let ended = false;
|
|
2990
|
+
let unwatch;
|
|
2991
|
+
const timers = {};
|
|
2992
|
+
const stream = openMcpEventStream(() => end('client'));
|
|
2993
|
+
const onAbort = () => end('client');
|
|
2994
|
+
const status = (state, data) => ({
|
|
2995
|
+
state,
|
|
2996
|
+
timestamp: new Date().toISOString(),
|
|
2997
|
+
...(data === undefined
|
|
2998
|
+
? {}
|
|
2999
|
+
: {
|
|
3000
|
+
message: {
|
|
3001
|
+
messageId: `msg_${randomBytes(16).toString('base64url')}`,
|
|
3002
|
+
contextId,
|
|
3003
|
+
taskId,
|
|
3004
|
+
role: 'ROLE_AGENT',
|
|
3005
|
+
parts: [{ data, mediaType: 'application/json' }],
|
|
3006
|
+
},
|
|
3007
|
+
}),
|
|
3008
|
+
});
|
|
3009
|
+
const send = (result) => stream.send({ jsonrpc: '2.0', id, result });
|
|
3010
|
+
function end(reason) {
|
|
3011
|
+
if (ended)
|
|
3012
|
+
return;
|
|
3013
|
+
ended = true;
|
|
3014
|
+
clearInterval(timers.keepAlive);
|
|
3015
|
+
clearTimeout(timers.lifetime);
|
|
3016
|
+
unwatch?.();
|
|
3017
|
+
request.signal.removeEventListener('abort', onAbort);
|
|
3018
|
+
openSubscriptions.delete(slot);
|
|
3019
|
+
// A server-side end is the task's terminal state; a failure closes the stream without one,
|
|
3020
|
+
// which a client may treat as a reason to watch again.
|
|
3021
|
+
if (reason === 'revoked') {
|
|
3022
|
+
send({
|
|
3023
|
+
statusUpdate: {
|
|
3024
|
+
taskId,
|
|
3025
|
+
contextId,
|
|
3026
|
+
status: status('TASK_STATE_FAILED', { error: { code: 'forbidden' } }),
|
|
3027
|
+
},
|
|
3028
|
+
});
|
|
2751
3029
|
}
|
|
2752
|
-
|
|
3030
|
+
else if (reason !== 'client' && reason !== 'failed') {
|
|
3031
|
+
send({ statusUpdate: { taskId, contextId, status: status('TASK_STATE_COMPLETED') } });
|
|
3032
|
+
}
|
|
3033
|
+
stream.close();
|
|
3034
|
+
emit({
|
|
3035
|
+
type: 'subscription',
|
|
3036
|
+
outcome: 'closed',
|
|
3037
|
+
reason,
|
|
3038
|
+
resources: watched.resourceIds.length,
|
|
3039
|
+
});
|
|
3040
|
+
}
|
|
3041
|
+
slot.end = end;
|
|
3042
|
+
slot.streaming = true;
|
|
3043
|
+
send({
|
|
3044
|
+
task: {
|
|
3045
|
+
id: taskId,
|
|
3046
|
+
contextId,
|
|
3047
|
+
status: status('TASK_STATE_WORKING', {
|
|
3048
|
+
resources: watched.resourceIds.map((resourceId) => ({
|
|
3049
|
+
resource_id: resourceId,
|
|
3050
|
+
uri: slotlockCalendarResourceUri(resourceId),
|
|
3051
|
+
})),
|
|
3052
|
+
}),
|
|
3053
|
+
},
|
|
3054
|
+
});
|
|
3055
|
+
emit({ type: 'subscription', outcome: 'opened', resources: watched.resourceIds.length });
|
|
3056
|
+
if (request.signal.aborted) {
|
|
3057
|
+
end('client');
|
|
3058
|
+
return stream.response;
|
|
3059
|
+
}
|
|
3060
|
+
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
3061
|
+
unwatch = watchResources(call, policy, window, watched, {
|
|
3062
|
+
updated: (resourceId) => send({
|
|
3063
|
+
statusUpdate: {
|
|
3064
|
+
taskId,
|
|
3065
|
+
contextId,
|
|
3066
|
+
status: status('TASK_STATE_WORKING', {
|
|
3067
|
+
resource_id: resourceId,
|
|
3068
|
+
uri: slotlockCalendarResourceUri(resourceId),
|
|
3069
|
+
}),
|
|
3070
|
+
},
|
|
3071
|
+
}),
|
|
3072
|
+
healthy: () => !stream.lagging(),
|
|
3073
|
+
end,
|
|
3074
|
+
});
|
|
2753
3075
|
timers.keepAlive = unref(setInterval(() => {
|
|
2754
3076
|
if (!stream.keepAlive() || stream.lagging())
|
|
2755
3077
|
end('failed');
|
|
2756
3078
|
}, policy.keepAliveMs));
|
|
2757
3079
|
timers.lifetime = unref(setTimeout(() => end('duration'), policy.maxDurationMs));
|
|
2758
|
-
timers.nextPoll = unref(setTimeout(poll, policy.pollIntervalMs));
|
|
2759
3080
|
return stream.response;
|
|
2760
3081
|
}
|
|
2761
3082
|
/** One MCP 2026-07-28 request: stateless, validated against its own `_meta` and headers. */
|
|
@@ -2832,6 +3153,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2832
3153
|
principal,
|
|
2833
3154
|
operation: definition.name,
|
|
2834
3155
|
signal: request.signal,
|
|
3156
|
+
origin: 'application',
|
|
2835
3157
|
};
|
|
2836
3158
|
if (trace)
|
|
2837
3159
|
context.trace = trace;
|
|
@@ -2970,6 +3292,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2970
3292
|
input: { ...given, ...params },
|
|
2971
3293
|
request,
|
|
2972
3294
|
options: withoutConfirmationRound(principal, route.operation),
|
|
3295
|
+
origin: 'rest',
|
|
2973
3296
|
...(trace ? { trace } : {}),
|
|
2974
3297
|
});
|
|
2975
3298
|
const internal = !outcome.ok && INTERNAL_FAILURES.has(outcome.code);
|
|
@@ -3248,7 +3571,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
3248
3571
|
return a2aError(id, -32004, 'Unsupported operation', {
|
|
3249
3572
|
reason: 'UNSUPPORTED_OPERATION',
|
|
3250
3573
|
domain: 'a2a-protocol.org',
|
|
3251
|
-
metadata: { supportedMethods: 'SendMessage' },
|
|
3574
|
+
metadata: { supportedMethods: 'SendMessage, SendStreamingMessage' },
|
|
3252
3575
|
});
|
|
3253
3576
|
}
|
|
3254
3577
|
if (A2A_TASK_METHOD_FIELDS.has(rpc.method)) {
|
|
@@ -3267,7 +3590,8 @@ export function createSlotlockAgentServer(options) {
|
|
|
3267
3590
|
const pageSize = rpc.params?.pageSize ?? SLOTLOCK_A2A_TASK_PAGE_SIZE.default;
|
|
3268
3591
|
return json(200, rpcResult(id, { tasks: [], nextPageToken: '', pageSize, totalSize: 0 }));
|
|
3269
3592
|
}
|
|
3270
|
-
|
|
3593
|
+
const streamingRequest = rpc.method === 'SendStreamingMessage';
|
|
3594
|
+
if (rpc.method !== 'SendMessage' && !streamingRequest) {
|
|
3271
3595
|
return json(200, rpcError(id, -32601, 'Method not found'));
|
|
3272
3596
|
}
|
|
3273
3597
|
const started = performance.now();
|
|
@@ -3276,7 +3600,8 @@ export function createSlotlockAgentServer(options) {
|
|
|
3276
3600
|
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
3277
3601
|
const part = parts.length === 1 ? parts[0] : null;
|
|
3278
3602
|
const data = part?.data;
|
|
3279
|
-
const
|
|
3603
|
+
const watchSkill = data?.skill === SLOTLOCK_A2A_WATCH_SKILL;
|
|
3604
|
+
const operation = watchSkill ? null : resolveSlotlockAgentOperation(data?.skill);
|
|
3280
3605
|
// A refused message that names an operation is still a call, reported by the error's reason.
|
|
3281
3606
|
const refuse = (outcome, response) => {
|
|
3282
3607
|
if (operation)
|
|
@@ -3297,7 +3622,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
3297
3622
|
}
|
|
3298
3623
|
// `arguments` may be left out or null; anything else but an object is not arguments.
|
|
3299
3624
|
const input = data?.arguments;
|
|
3300
|
-
if (!operation ||
|
|
3625
|
+
if ((!operation && !watchSkill) ||
|
|
3301
3626
|
message?.role !== 'ROLE_USER' ||
|
|
3302
3627
|
!isCanonicalBoundedIdentifier(message.messageId, 200) ||
|
|
3303
3628
|
message.taskId !== undefined ||
|
|
@@ -3311,6 +3636,44 @@ export function createSlotlockAgentServer(options) {
|
|
|
3311
3636
|
},
|
|
3312
3637
|
}));
|
|
3313
3638
|
}
|
|
3639
|
+
const requestedContextId = message.contextId;
|
|
3640
|
+
const contextId = typeof requestedContextId === 'string' &&
|
|
3641
|
+
requestedContextId.length > 0 &&
|
|
3642
|
+
requestedContextId.length <= 200
|
|
3643
|
+
? requestedContextId
|
|
3644
|
+
: `ctx_${createHash('sha256')
|
|
3645
|
+
.update(`${principal.tenantRef}\0${message.messageId}`)
|
|
3646
|
+
.digest('base64url')
|
|
3647
|
+
.slice(0, 32)}`;
|
|
3648
|
+
const skillName = operation ?? SLOTLOCK_A2A_WATCH_SKILL;
|
|
3649
|
+
// A skill's outcome is the agent's reply, success or not — the same contract as an MCP
|
|
3650
|
+
// `isError` result, so the calling agent reads `{"error":{"code":…}}` and can act on it.
|
|
3651
|
+
// Streamed, the reply is the stream's one event (A2A: a Message, then the stream closes).
|
|
3652
|
+
const reply = (replyData) => {
|
|
3653
|
+
const replyId = createHash('sha256')
|
|
3654
|
+
.update(`${principal.subject}\0${message.messageId}\0${skillName}`)
|
|
3655
|
+
.digest('base64url')
|
|
3656
|
+
.slice(0, 32);
|
|
3657
|
+
const result = {
|
|
3658
|
+
message: {
|
|
3659
|
+
messageId: `reply_${replyId}`,
|
|
3660
|
+
contextId,
|
|
3661
|
+
role: 'ROLE_AGENT',
|
|
3662
|
+
parts: [{ data: replyData, mediaType: 'application/json' }],
|
|
3663
|
+
},
|
|
3664
|
+
};
|
|
3665
|
+
if (!streamingRequest)
|
|
3666
|
+
return json(200, rpcResult(id, result));
|
|
3667
|
+
const stream = openMcpEventStream(() => { });
|
|
3668
|
+
stream.send({ jsonrpc: '2.0', id, result });
|
|
3669
|
+
stream.close();
|
|
3670
|
+
return stream.response;
|
|
3671
|
+
};
|
|
3672
|
+
if (!operation) {
|
|
3673
|
+
if (!streamingRequest)
|
|
3674
|
+
return reply({ error: { code: 'streaming_required' } });
|
|
3675
|
+
return a2aWatch({ id, request, principal, trace, input, contextId, reply });
|
|
3676
|
+
}
|
|
3314
3677
|
try {
|
|
3315
3678
|
const outcome = await invokeReported('a2a', {
|
|
3316
3679
|
operation,
|
|
@@ -3322,36 +3685,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
3322
3685
|
if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
|
|
3323
3686
|
return json(200, rpcError(id, -32603, 'Internal error'));
|
|
3324
3687
|
}
|
|
3325
|
-
|
|
3326
|
-
.update(`${principal.subject}\0${message.messageId}\0${operation}`)
|
|
3327
|
-
.digest('base64url')
|
|
3328
|
-
.slice(0, 32);
|
|
3329
|
-
const requestedContextId = message.contextId;
|
|
3330
|
-
const contextId = typeof requestedContextId === 'string' &&
|
|
3331
|
-
requestedContextId.length > 0 &&
|
|
3332
|
-
requestedContextId.length <= 200
|
|
3333
|
-
? requestedContextId
|
|
3334
|
-
: `ctx_${createHash('sha256')
|
|
3335
|
-
.update(`${principal.tenantRef}\0${message.messageId}`)
|
|
3336
|
-
.digest('base64url')
|
|
3337
|
-
.slice(0, 32)}`;
|
|
3338
|
-
// A skill's outcome is the agent's reply, success or not — the same contract as an MCP
|
|
3339
|
-
// `isError` result, so the calling agent reads `{"error":{"code":…}}` and can act on it.
|
|
3340
|
-
return json(200, rpcResult(id, {
|
|
3341
|
-
message: {
|
|
3342
|
-
messageId: `reply_${replyId}`,
|
|
3343
|
-
contextId,
|
|
3344
|
-
role: 'ROLE_AGENT',
|
|
3345
|
-
parts: [
|
|
3346
|
-
{
|
|
3347
|
-
data: outcome.ok
|
|
3348
|
-
? outcome.data
|
|
3349
|
-
: { error: { ...outcome.details, code: outcome.code } },
|
|
3350
|
-
mediaType: 'application/json',
|
|
3351
|
-
},
|
|
3352
|
-
],
|
|
3353
|
-
},
|
|
3354
|
-
}));
|
|
3688
|
+
return reply(outcome.ok ? outcome.data : { error: { ...outcome.details, code: outcome.code } });
|
|
3355
3689
|
}
|
|
3356
3690
|
catch {
|
|
3357
3691
|
return json(200, rpcError(id, -32603, 'Internal error'));
|