borgmcp-server 0.1.4 → 0.1.7
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/README.md +32 -11
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/dist/coordination-api.d.ts +2 -1
- package/dist/coordination-api.js +298 -89
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +4 -6
- package/dist/credentials.js +43 -19
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +2 -3
- package/dist/debug-log.js +2 -3
- package/dist/debug-log.js.map +1 -1
- package/dist/enrollment.d.ts +3 -11
- package/dist/enrollment.js +21 -60
- package/dist/enrollment.js.map +1 -1
- package/dist/https-server.d.ts +2 -20
- package/dist/https-server.js +33 -51
- package/dist/https-server.js.map +1 -1
- package/dist/message-taxonomy.d.ts +38 -0
- package/dist/message-taxonomy.js +218 -0
- package/dist/message-taxonomy.js.map +1 -0
- package/dist/migrations.js +28 -0
- package/dist/migrations.js.map +1 -1
- package/dist/service.d.ts +4 -1
- package/dist/service.js +25 -10
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +66 -8
- package/dist/store.js +525 -100
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -7
- package/package.json +2 -2
- package/src/coordination-api.ts +312 -86
- package/src/credentials.ts +44 -26
- package/src/debug-log.ts +5 -5
- package/src/enrollment.ts +32 -78
- package/src/https-server.ts +44 -82
- package/src/message-taxonomy.ts +284 -0
- package/src/migrations.ts +28 -0
- package/src/service.ts +29 -9
- package/src/store.ts +593 -121
- package/dist/protocol-draft.d.ts +0 -2
- package/dist/protocol-draft.js +0 -31
- package/dist/protocol-draft.js.map +0 -1
- package/src/protocol-draft.ts +0 -32
package/dist/coordination-api.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { ATTACH_PATH, ErrorCode, PROTOCOL_VERSION, ProtocolContractError, createProtocolEnvelope, decodeAttachRequestEnvelope, decodeEvictDroneRequestEnvelope, decodeProtocolEnvelope, decodeReassignDroneRequestEnvelope, } from "borgmcp-shared/protocol";
|
|
2
3
|
import { assertServerDerivedPrincipal } from "./principal.js";
|
|
3
4
|
import { disabledDebugLogger } from "./debug-log.js";
|
|
4
|
-
import {
|
|
5
|
+
import { patchMessageTaxonomy, resolveMessageRouting, validateMessageTaxonomy, } from "./message-taxonomy.js";
|
|
6
|
+
import { CursorExpiredError, AttachSessionRejectedError, AccessDeniedError, CreateCubeConflictError, DefaultRoleRequiredError, RoleConflictError, RoleInUseError, RoleSectionConflictError, ScopedStoreError, StorageCapacityError, } from "./store.js";
|
|
5
7
|
export class CoordinationApi {
|
|
6
8
|
#runtime;
|
|
7
9
|
#authority;
|
|
8
10
|
#debugLogger;
|
|
11
|
+
#streamHeartbeatMs;
|
|
9
12
|
#replayBarrier;
|
|
10
|
-
constructor(runtime, authority, debugLogger = disabledDebugLogger) {
|
|
13
|
+
constructor(runtime, authority, debugLogger = disabledDebugLogger, streamHeartbeatMs = 5_000) {
|
|
11
14
|
this.#runtime = runtime;
|
|
12
15
|
this.#authority = authority;
|
|
13
16
|
this.#debugLogger = debugLogger;
|
|
17
|
+
this.#streamHeartbeatMs = streamHeartbeatMs;
|
|
14
18
|
}
|
|
15
19
|
armReplayTransition() {
|
|
16
20
|
let markReached;
|
|
@@ -28,36 +32,36 @@ export class CoordinationApi {
|
|
|
28
32
|
async handle(request) {
|
|
29
33
|
assertServerDerivedPrincipal(request.principal);
|
|
30
34
|
const authentication = request.principal;
|
|
31
|
-
if (request.path ===
|
|
35
|
+
if (request.path === ATTACH_PATH && request.method === "POST") {
|
|
32
36
|
const requestId = safeRequestId(request.body);
|
|
33
37
|
try {
|
|
34
|
-
const envelope =
|
|
35
|
-
|
|
36
|
-
const priorDroneId = envelope.payload["prior_drone_id"] === undefined
|
|
37
|
-
? undefined
|
|
38
|
-
: requiredUuid(envelope.payload, "prior_drone_id");
|
|
38
|
+
const envelope = decodeAttachRequestEnvelope(request.body);
|
|
39
|
+
const priorDroneId = envelope.payload.prior_drone_id;
|
|
39
40
|
const attachment = this.#authority.attachSeat(this.#runtime.forPrincipal(authentication), {
|
|
40
|
-
cubeId:
|
|
41
|
-
roleId:
|
|
42
|
-
|
|
41
|
+
cubeId: envelope.payload.cube_id,
|
|
42
|
+
roleId: envelope.payload.role_id,
|
|
43
|
+
sessionCredential: envelope.payload.session_credential,
|
|
43
44
|
...(priorDroneId === undefined ? {} : { priorDroneId }),
|
|
44
45
|
});
|
|
45
|
-
return success(201, envelope.
|
|
46
|
+
return success(attachment.result === "created" ? 201 : 200, envelope.request_id, {
|
|
47
|
+
result: attachment.result,
|
|
46
48
|
cube: attachment.cube,
|
|
47
49
|
role: attachment.role,
|
|
48
50
|
drone: attachment.drone,
|
|
49
51
|
session: {
|
|
50
|
-
|
|
52
|
+
id: attachment.sessionId,
|
|
51
53
|
expires_at: attachment.expiresAt,
|
|
52
|
-
generation: attachment.generation,
|
|
53
|
-
posture: attachment.posture,
|
|
54
54
|
},
|
|
55
|
-
reattached: attachment.reattached,
|
|
56
55
|
});
|
|
57
56
|
}
|
|
58
57
|
catch (error) {
|
|
59
|
-
if (error instanceof
|
|
60
|
-
return
|
|
58
|
+
if (error instanceof ProtocolContractError) {
|
|
59
|
+
return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
|
|
60
|
+
? failure(426, error.code, "Unsupported protocol version.", requestId)
|
|
61
|
+
: failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
|
|
62
|
+
}
|
|
63
|
+
if (error instanceof AttachSessionRejectedError) {
|
|
64
|
+
return failure(401, ErrorCode.SESSION_REJECTED, error.message, requestId);
|
|
61
65
|
}
|
|
62
66
|
if (error instanceof ScopedStoreError) {
|
|
63
67
|
return failure(404, "NOT_FOUND", error.message, requestId);
|
|
@@ -77,6 +81,7 @@ export class CoordinationApi {
|
|
|
77
81
|
owner_id: cube.ownerId,
|
|
78
82
|
name: cube.name,
|
|
79
83
|
cube_directive: cube.directive,
|
|
84
|
+
message_taxonomy: cube.messageTaxonomy,
|
|
80
85
|
created_at: cube.createdAt,
|
|
81
86
|
updated_at: cube.updatedAt,
|
|
82
87
|
}));
|
|
@@ -112,6 +117,11 @@ export class CoordinationApi {
|
|
|
112
117
|
if (error instanceof StorageCapacityError) {
|
|
113
118
|
return failure(507, "CAPACITY_EXCEEDED", error.message, requestId);
|
|
114
119
|
}
|
|
120
|
+
if (error instanceof ProtocolContractError) {
|
|
121
|
+
return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
|
|
122
|
+
? failure(426, error.code, "Unsupported protocol version.", requestId)
|
|
123
|
+
: failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
|
|
124
|
+
}
|
|
115
125
|
if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
|
|
116
126
|
return failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
|
|
117
127
|
}
|
|
@@ -120,17 +130,21 @@ export class CoordinationApi {
|
|
|
120
130
|
}
|
|
121
131
|
const roleMatch = /^\/api\/cubes\/([0-9a-f-]{36})\/roles\/([0-9a-f-]{36})(\/section-patch)?$/u
|
|
122
132
|
.exec(request.path);
|
|
123
|
-
const
|
|
133
|
+
const droneMatch = /^\/api\/cubes\/([0-9a-f-]{36})\/drones\/([0-9a-f-]{36})$/u
|
|
124
134
|
.exec(request.path);
|
|
125
|
-
|
|
135
|
+
const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream|taxonomy-patch))?$/u
|
|
136
|
+
.exec(request.path);
|
|
137
|
+
if (match === null && roleMatch === null && droneMatch === null) {
|
|
126
138
|
return failure(404, "NOT_FOUND", "The requested resource was not found.");
|
|
127
139
|
}
|
|
128
|
-
const cubeId = (roleMatch?.[1] ?? match?.[1]);
|
|
140
|
+
const cubeId = (roleMatch?.[1] ?? droneMatch?.[1] ?? match?.[1]);
|
|
129
141
|
const roleId = roleMatch?.[2];
|
|
130
|
-
|
|
142
|
+
const droneId = droneMatch?.[2];
|
|
143
|
+
if (!uuidPattern.test(cubeId) || (roleId !== undefined && !uuidPattern.test(roleId)) ||
|
|
144
|
+
(droneId !== undefined && !uuidPattern.test(droneId))) {
|
|
131
145
|
return failure(404, "NOT_FOUND", "The requested resource was not found.");
|
|
132
146
|
}
|
|
133
|
-
const resource = roleMatch
|
|
147
|
+
const resource = roleMatch !== null ? "role" : droneMatch !== null ? "drone" : match?.[2];
|
|
134
148
|
const sectionPatch = roleMatch?.[3] !== undefined;
|
|
135
149
|
const store = this.#runtime.forPrincipal(authentication);
|
|
136
150
|
try {
|
|
@@ -144,11 +158,50 @@ export class CoordinationApi {
|
|
|
144
158
|
owner_id: cube.ownerId,
|
|
145
159
|
name: cube.name,
|
|
146
160
|
cube_directive: cube.directive,
|
|
161
|
+
message_taxonomy: cube.messageTaxonomy,
|
|
147
162
|
created_at: cube.createdAt,
|
|
148
163
|
updated_at: cube.updatedAt,
|
|
149
164
|
},
|
|
150
165
|
});
|
|
151
166
|
}
|
|
167
|
+
if (resource === undefined && request.method === "PATCH") {
|
|
168
|
+
const envelope = decodeEnvelope(request.body);
|
|
169
|
+
exactKeys(envelope.payload, [], ["cube_directive", "message_taxonomy"]);
|
|
170
|
+
if (Object.keys(envelope.payload).length === 0)
|
|
171
|
+
throw new InputError();
|
|
172
|
+
const directive = optionalText(envelope.payload, "cube_directive", 100_000);
|
|
173
|
+
const messageTaxonomy = optionalMessageTaxonomy(envelope.payload["message_taxonomy"]);
|
|
174
|
+
const cube = store.updateCube(cubeId, {
|
|
175
|
+
...(directive === undefined ? {} : { directive }),
|
|
176
|
+
...(messageTaxonomy === undefined ? {} : { messageTaxonomy }),
|
|
177
|
+
});
|
|
178
|
+
return success(200, envelope.requestId, { cube: cubePayload(cube) });
|
|
179
|
+
}
|
|
180
|
+
if (resource === "taxonomy-patch" && request.method === "POST") {
|
|
181
|
+
const envelope = decodeEnvelope(request.body);
|
|
182
|
+
const action = envelope.payload["action"];
|
|
183
|
+
const cube = store.getCube(cubeId);
|
|
184
|
+
if (cube === null)
|
|
185
|
+
throw new ScopedStoreError();
|
|
186
|
+
if (action === "remove") {
|
|
187
|
+
exactKeys(envelope.payload, ["action", "class"]);
|
|
188
|
+
const messageTaxonomy = patchMessageTaxonomy(cube.messageTaxonomy, {
|
|
189
|
+
action,
|
|
190
|
+
className: requiredString(envelope.payload, "class", 64),
|
|
191
|
+
});
|
|
192
|
+
return success(200, envelope.requestId, {
|
|
193
|
+
cube: cubePayload(store.updateCube(cubeId, { messageTaxonomy })),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (action !== "add" && action !== "replace")
|
|
197
|
+
throw new InputError();
|
|
198
|
+
exactKeys(envelope.payload, ["action", "class_def"]);
|
|
199
|
+
const classDef = validateMessageTaxonomy([envelope.payload["class_def"]])[0];
|
|
200
|
+
const messageTaxonomy = patchMessageTaxonomy(cube.messageTaxonomy, { action, classDef });
|
|
201
|
+
return success(200, envelope.requestId, {
|
|
202
|
+
cube: cubePayload(store.updateCube(cubeId, { messageTaxonomy })),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
152
205
|
if (resource === "roles" && request.method === "GET") {
|
|
153
206
|
return success(200, "roles-read", { roles: store.listRoles(cubeId) });
|
|
154
207
|
}
|
|
@@ -162,6 +215,7 @@ export class CoordinationApi {
|
|
|
162
215
|
"is_human_seat",
|
|
163
216
|
"can_broadcast",
|
|
164
217
|
"receives_all_direct",
|
|
218
|
+
"role_class",
|
|
165
219
|
]);
|
|
166
220
|
const role = store.createRole(cubeId, {
|
|
167
221
|
name: requiredRoleName(envelope.payload, "name"),
|
|
@@ -172,6 +226,9 @@ export class CoordinationApi {
|
|
|
172
226
|
isHumanSeat: optionalBoolean(envelope.payload["is_human_seat"]),
|
|
173
227
|
canBroadcast: optionalBoolean(envelope.payload["can_broadcast"]),
|
|
174
228
|
receivesAllDirect: optionalBoolean(envelope.payload["receives_all_direct"]),
|
|
229
|
+
...(envelope.payload["role_class"] === undefined
|
|
230
|
+
? {}
|
|
231
|
+
: { roleClass: optionalRoleClass(envelope.payload["role_class"]) }),
|
|
175
232
|
});
|
|
176
233
|
return success(201, envelope.requestId, { role });
|
|
177
234
|
}
|
|
@@ -186,6 +243,7 @@ export class CoordinationApi {
|
|
|
186
243
|
"is_human_seat",
|
|
187
244
|
"can_broadcast",
|
|
188
245
|
"receives_all_direct",
|
|
246
|
+
"role_class",
|
|
189
247
|
]);
|
|
190
248
|
if (Object.keys(envelope.payload).length === 0)
|
|
191
249
|
throw new InputError();
|
|
@@ -199,6 +257,7 @@ export class CoordinationApi {
|
|
|
199
257
|
const isHumanSeat = optionalBooleanValue(envelope.payload["is_human_seat"]);
|
|
200
258
|
const canBroadcast = optionalBooleanValue(envelope.payload["can_broadcast"]);
|
|
201
259
|
const receivesAllDirect = optionalBooleanValue(envelope.payload["receives_all_direct"]);
|
|
260
|
+
const roleClass = optionalRoleClass(envelope.payload["role_class"]);
|
|
202
261
|
const role = store.updateRole(cubeId, roleId, {
|
|
203
262
|
...(name === undefined ? {} : { name }),
|
|
204
263
|
...(shortDescription === undefined ? {} : { shortDescription }),
|
|
@@ -208,6 +267,7 @@ export class CoordinationApi {
|
|
|
208
267
|
...(isHumanSeat === undefined ? {} : { isHumanSeat }),
|
|
209
268
|
...(canBroadcast === undefined ? {} : { canBroadcast }),
|
|
210
269
|
...(receivesAllDirect === undefined ? {} : { receivesAllDirect }),
|
|
270
|
+
...(roleClass === undefined ? {} : { roleClass }),
|
|
211
271
|
});
|
|
212
272
|
return success(200, envelope.requestId, { role });
|
|
213
273
|
}
|
|
@@ -245,22 +305,48 @@ export class CoordinationApi {
|
|
|
245
305
|
});
|
|
246
306
|
}
|
|
247
307
|
if (resource === "drones" && request.method === "GET") {
|
|
248
|
-
|
|
308
|
+
if (request.since === undefined) {
|
|
309
|
+
return success(200, "drones-read", { drones: store.listDrones(cubeId) });
|
|
310
|
+
}
|
|
311
|
+
const since = decodeSince(request.since);
|
|
312
|
+
return success(200, "drones-read", store.listDronesSince(cubeId, since));
|
|
313
|
+
}
|
|
314
|
+
if (resource === "drone" && request.method === "PATCH") {
|
|
315
|
+
const envelope = decodeReassignDroneRequestEnvelope(request.body);
|
|
316
|
+
const drone = store.reassignDrone(cubeId, droneId, envelope.payload.role_id);
|
|
317
|
+
return success(200, envelope.request_id, { drone: managedDronePayload(drone) });
|
|
318
|
+
}
|
|
319
|
+
if (resource === "drone" && request.method === "DELETE") {
|
|
320
|
+
const envelope = decodeEvictDroneRequestEnvelope(request.body);
|
|
321
|
+
store.evictDrone(cubeId, droneId);
|
|
322
|
+
return success(200, envelope.request_id, { drone_id: droneId, evicted: true });
|
|
249
323
|
}
|
|
250
324
|
if (resource === "logs" && request.method === "POST") {
|
|
251
325
|
const envelope = decodeEnvelope(request.body);
|
|
252
|
-
exactKeys(envelope.payload, ["message"], [
|
|
326
|
+
exactKeys(envelope.payload, ["message"], [
|
|
327
|
+
"visibility", "recipientDroneIds", "class", "to",
|
|
328
|
+
]);
|
|
253
329
|
const message = requiredString(envelope.payload, "message", 10_240);
|
|
254
330
|
const visibility = optionalVisibility(envelope.payload["visibility"]);
|
|
255
331
|
const recipientDroneIds = optionalUuidArray(envelope.payload["recipientDroneIds"]);
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
332
|
+
const className = optionalString(envelope.payload, "class", 64);
|
|
333
|
+
const to = optionalStringArray(envelope.payload["to"]);
|
|
334
|
+
const cube = store.getCube(cubeId);
|
|
335
|
+
if (cube === null)
|
|
336
|
+
throw new ScopedStoreError();
|
|
337
|
+
const resolved = resolveMessageRouting({
|
|
261
338
|
message,
|
|
262
339
|
...(visibility === undefined ? {} : { visibility }),
|
|
263
340
|
...(recipientDroneIds === undefined ? {} : { recipientDroneIds }),
|
|
341
|
+
...(className === undefined ? {} : { className }),
|
|
342
|
+
...(to === undefined ? {} : { to }),
|
|
343
|
+
}, cube.messageTaxonomy, store.listRoles(cubeId), store.listDrones(cubeId));
|
|
344
|
+
const entry = store.appendLog(cubeId, {
|
|
345
|
+
message,
|
|
346
|
+
visibility: resolved.visibility,
|
|
347
|
+
...(resolved.visibility === "direct"
|
|
348
|
+
? { recipientDroneIds: resolved.recipientDroneIds }
|
|
349
|
+
: {}),
|
|
264
350
|
});
|
|
265
351
|
this.#debugLogger.emit({
|
|
266
352
|
event: "activity_append",
|
|
@@ -334,6 +420,9 @@ export class CoordinationApi {
|
|
|
334
420
|
if (error instanceof CursorExpiredError) {
|
|
335
421
|
return failure(410, "CURSOR_EXPIRED", error.message);
|
|
336
422
|
}
|
|
423
|
+
if (error instanceof AccessDeniedError) {
|
|
424
|
+
return failure(403, ErrorCode.ACCESS_DENIED, error.message);
|
|
425
|
+
}
|
|
337
426
|
if (error instanceof ScopedStoreError) {
|
|
338
427
|
return failure(404, "NOT_FOUND", error.message);
|
|
339
428
|
}
|
|
@@ -346,9 +435,17 @@ export class CoordinationApi {
|
|
|
346
435
|
if (error instanceof RoleSectionConflictError) {
|
|
347
436
|
return failure(409, error.code, error.message, safeRequestId(request.body));
|
|
348
437
|
}
|
|
438
|
+
if (error instanceof RoleInUseError) {
|
|
439
|
+
return failure(409, error.code, error.message, safeRequestId(request.body));
|
|
440
|
+
}
|
|
349
441
|
if (error instanceof StorageCapacityError) {
|
|
350
442
|
return failure(507, "CAPACITY_EXCEEDED", error.message, safeRequestId(request.body));
|
|
351
443
|
}
|
|
444
|
+
if (error instanceof ProtocolContractError) {
|
|
445
|
+
return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
|
|
446
|
+
? failure(426, error.code, "Unsupported protocol version.", safeRequestId(request.body))
|
|
447
|
+
: failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
|
|
448
|
+
}
|
|
352
449
|
if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
|
|
353
450
|
return failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
|
|
354
451
|
}
|
|
@@ -363,7 +460,10 @@ export class CoordinationApi {
|
|
|
363
460
|
let unsubscribe = () => undefined;
|
|
364
461
|
let subscribed = false;
|
|
365
462
|
let deliveryCount = 0;
|
|
463
|
+
let heartbeat;
|
|
366
464
|
const queue = new AsyncStringQueue(() => {
|
|
465
|
+
if (heartbeat !== undefined)
|
|
466
|
+
clearInterval(heartbeat);
|
|
367
467
|
unsubscribe();
|
|
368
468
|
session.release();
|
|
369
469
|
if (subscribed) {
|
|
@@ -376,7 +476,9 @@ export class CoordinationApi {
|
|
|
376
476
|
});
|
|
377
477
|
}
|
|
378
478
|
}, () => { deliveryCount += 1; });
|
|
379
|
-
|
|
479
|
+
let replayDirty = false;
|
|
480
|
+
const pendingNotifications = [];
|
|
481
|
+
let notificationIndex = 0;
|
|
380
482
|
let live = false;
|
|
381
483
|
try {
|
|
382
484
|
unsubscribe = store.subscribeActivity(cubeId, (entry) => {
|
|
@@ -386,23 +488,15 @@ export class CoordinationApi {
|
|
|
386
488
|
}
|
|
387
489
|
if (live)
|
|
388
490
|
queue.push(encodeLogEvent(entry));
|
|
389
|
-
else if (
|
|
390
|
-
|
|
491
|
+
else if ("kind" in entry)
|
|
492
|
+
pendingNotifications.push(entry);
|
|
391
493
|
else
|
|
392
|
-
|
|
494
|
+
replayDirty = true;
|
|
393
495
|
});
|
|
496
|
+
subscribed = true;
|
|
394
497
|
signal.addEventListener("abort", () => queue.close(), { once: true });
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
this.#debugLogger.emit({
|
|
398
|
-
event: "cursor_replay",
|
|
399
|
-
mode: "sse",
|
|
400
|
-
cubeId,
|
|
401
|
-
cursorId: cursor?.id ?? null,
|
|
402
|
-
returnedCount: replay.length,
|
|
403
|
-
behindBy: replayPage.behind_by,
|
|
404
|
-
truncated: replayPage.has_more,
|
|
405
|
-
});
|
|
498
|
+
const firstPage = store.readLog(cubeId, cursor, 200);
|
|
499
|
+
this.#logReplayPage(cubeId, cursor, firstPage);
|
|
406
500
|
const barrier = this.#replayBarrier;
|
|
407
501
|
this.#replayBarrier = undefined;
|
|
408
502
|
if (barrier !== undefined) {
|
|
@@ -410,29 +504,63 @@ export class CoordinationApi {
|
|
|
410
504
|
barrier.markReached();
|
|
411
505
|
await barrier.release;
|
|
412
506
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
507
|
+
void (async () => {
|
|
508
|
+
let page = firstPage;
|
|
509
|
+
let replayCursor = cursor;
|
|
510
|
+
let replayCount = 0;
|
|
511
|
+
try {
|
|
512
|
+
for (;;) {
|
|
513
|
+
for (const entry of page.entries) {
|
|
514
|
+
if (!await queue.write(encodeLogEvent(entry)))
|
|
515
|
+
return;
|
|
516
|
+
replayCursor = { id: entry.id, created_at: entry.created_at };
|
|
517
|
+
replayCount += 1;
|
|
518
|
+
}
|
|
519
|
+
if (page.has_more || replayDirty) {
|
|
520
|
+
replayDirty = false;
|
|
521
|
+
page = store.readLog(cubeId, replayCursor, 200);
|
|
522
|
+
this.#logReplayPage(cubeId, replayCursor, page);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
while (notificationIndex < pendingNotifications.length) {
|
|
526
|
+
if (!await queue.write(encodeLogEvent(pendingNotifications[notificationIndex])))
|
|
527
|
+
return;
|
|
528
|
+
notificationIndex += 1;
|
|
529
|
+
}
|
|
530
|
+
// Reserve room for the bookmark before making live callbacks visible.
|
|
531
|
+
// Any append while waiting marks replay dirty and is fetched first.
|
|
532
|
+
if (!await queue.waitForSpace())
|
|
533
|
+
return;
|
|
534
|
+
if (replayDirty || notificationIndex < pendingNotifications.length) {
|
|
535
|
+
replayDirty = false;
|
|
536
|
+
page = store.readLog(cubeId, replayCursor, 200);
|
|
537
|
+
this.#logReplayPage(cubeId, replayCursor, page);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
queue.push(`event: bookmark\ndata: ${JSON.stringify({
|
|
541
|
+
as_of: new Date().toISOString(),
|
|
542
|
+
replay_complete: true,
|
|
543
|
+
})}\n\n`);
|
|
544
|
+
live = true;
|
|
545
|
+
heartbeat = setInterval(() => {
|
|
546
|
+
queue.tryPush(encodeHeartbeat());
|
|
547
|
+
}, this.#streamHeartbeatMs);
|
|
548
|
+
heartbeat.unref();
|
|
549
|
+
this.#debugLogger.emit({
|
|
550
|
+
event: "sse_subscribe",
|
|
551
|
+
connectionId,
|
|
552
|
+
cubeId,
|
|
553
|
+
principal,
|
|
554
|
+
replayCount,
|
|
555
|
+
truncated: false,
|
|
556
|
+
});
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
420
559
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
})}\n\n`);
|
|
426
|
-
live = true;
|
|
427
|
-
subscribed = true;
|
|
428
|
-
this.#debugLogger.emit({
|
|
429
|
-
event: "sse_subscribe",
|
|
430
|
-
connectionId,
|
|
431
|
-
cubeId,
|
|
432
|
-
principal,
|
|
433
|
-
replayCount: replay.length,
|
|
434
|
-
truncated: replayPage.has_more,
|
|
435
|
-
});
|
|
560
|
+
catch {
|
|
561
|
+
queue.close();
|
|
562
|
+
}
|
|
563
|
+
})();
|
|
436
564
|
return { status: 200, stream: queue };
|
|
437
565
|
}
|
|
438
566
|
catch (error) {
|
|
@@ -444,12 +572,24 @@ export class CoordinationApi {
|
|
|
444
572
|
throw error;
|
|
445
573
|
}
|
|
446
574
|
}
|
|
575
|
+
#logReplayPage(cubeId, cursor, page) {
|
|
576
|
+
this.#debugLogger.emit({
|
|
577
|
+
event: "cursor_replay",
|
|
578
|
+
mode: "sse",
|
|
579
|
+
cubeId,
|
|
580
|
+
cursorId: cursor?.id ?? null,
|
|
581
|
+
returnedCount: page.entries.length,
|
|
582
|
+
behindBy: page.behind_by,
|
|
583
|
+
truncated: page.has_more,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
447
586
|
}
|
|
448
587
|
class InputError extends Error {
|
|
449
588
|
}
|
|
450
589
|
class AsyncStringQueue {
|
|
451
590
|
#values = [];
|
|
452
591
|
#waiters = [];
|
|
592
|
+
#spaceWaiters = [];
|
|
453
593
|
#cleanup;
|
|
454
594
|
#onDelivered;
|
|
455
595
|
#closed = false;
|
|
@@ -464,13 +604,30 @@ class AsyncStringQueue {
|
|
|
464
604
|
this.close();
|
|
465
605
|
return;
|
|
466
606
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
607
|
+
this.#enqueue(value);
|
|
608
|
+
}
|
|
609
|
+
tryPush(value) {
|
|
610
|
+
if (this.#closed || this.#values.length >= 200)
|
|
611
|
+
return false;
|
|
612
|
+
this.#enqueue(value);
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
async write(value) {
|
|
616
|
+
while (!this.#closed && this.#values.length >= 200) {
|
|
617
|
+
if (!await this.waitForSpace())
|
|
618
|
+
return false;
|
|
473
619
|
}
|
|
620
|
+
if (this.#closed)
|
|
621
|
+
return false;
|
|
622
|
+
this.#enqueue(value);
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
waitForSpace() {
|
|
626
|
+
if (this.#closed)
|
|
627
|
+
return Promise.resolve(false);
|
|
628
|
+
if (this.#values.length < 200)
|
|
629
|
+
return Promise.resolve(true);
|
|
630
|
+
return new Promise((resolve) => this.#spaceWaiters.push(resolve));
|
|
474
631
|
}
|
|
475
632
|
close() {
|
|
476
633
|
if (this.#closed)
|
|
@@ -479,12 +636,15 @@ class AsyncStringQueue {
|
|
|
479
636
|
this.#cleanup();
|
|
480
637
|
for (const waiter of this.#waiters.splice(0))
|
|
481
638
|
waiter({ value: undefined, done: true });
|
|
639
|
+
for (const waiter of this.#spaceWaiters.splice(0))
|
|
640
|
+
waiter(false);
|
|
482
641
|
}
|
|
483
642
|
[Symbol.asyncIterator]() {
|
|
484
643
|
return {
|
|
485
644
|
next: async () => {
|
|
486
645
|
const value = this.#values.shift();
|
|
487
646
|
if (value !== undefined) {
|
|
647
|
+
this.#spaceWaiters.shift()?.(true);
|
|
488
648
|
this.#onDelivered();
|
|
489
649
|
return { value, done: false };
|
|
490
650
|
}
|
|
@@ -498,17 +658,19 @@ class AsyncStringQueue {
|
|
|
498
658
|
},
|
|
499
659
|
};
|
|
500
660
|
}
|
|
661
|
+
#enqueue(value) {
|
|
662
|
+
const waiter = this.#waiters.shift();
|
|
663
|
+
if (waiter === undefined)
|
|
664
|
+
this.#values.push(value);
|
|
665
|
+
else {
|
|
666
|
+
this.#onDelivered();
|
|
667
|
+
waiter({ value, done: false });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
501
670
|
}
|
|
502
671
|
function decodeEnvelope(value) {
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
if (record["protocol_version"] !== "1")
|
|
506
|
-
throw new InputError();
|
|
507
|
-
const requestId = record["request_id"];
|
|
508
|
-
if (typeof requestId !== "string" || !/^[A-Za-z0-9._-]{8,128}$/u.test(requestId)) {
|
|
509
|
-
throw new InputError();
|
|
510
|
-
}
|
|
511
|
-
return { requestId, payload: object(record["payload"]) };
|
|
672
|
+
const envelope = decodeProtocolEnvelope(value, object);
|
|
673
|
+
return { requestId: envelope.request_id, payload: envelope.payload };
|
|
512
674
|
}
|
|
513
675
|
function object(value) {
|
|
514
676
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
@@ -568,6 +730,28 @@ function optionalBooleanValue(value) {
|
|
|
568
730
|
throw new InputError();
|
|
569
731
|
return value;
|
|
570
732
|
}
|
|
733
|
+
function optionalRoleClass(value) {
|
|
734
|
+
if (value === undefined)
|
|
735
|
+
return undefined;
|
|
736
|
+
if (value !== "queen" && value !== "worker")
|
|
737
|
+
throw new InputError();
|
|
738
|
+
return value;
|
|
739
|
+
}
|
|
740
|
+
function optionalMessageTaxonomy(value) {
|
|
741
|
+
return value === undefined ? undefined : validateMessageTaxonomy(value);
|
|
742
|
+
}
|
|
743
|
+
function optionalStringArray(value) {
|
|
744
|
+
if (value === undefined)
|
|
745
|
+
return undefined;
|
|
746
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 100 ||
|
|
747
|
+
value.some((entry) => typeof entry !== "string" || entry.length < 1 || entry.length > 120)) {
|
|
748
|
+
throw new InputError();
|
|
749
|
+
}
|
|
750
|
+
const entries = value;
|
|
751
|
+
if (new Set(entries).size !== entries.length)
|
|
752
|
+
throw new InputError();
|
|
753
|
+
return entries;
|
|
754
|
+
}
|
|
571
755
|
function requiredSectionHeading(record, key) {
|
|
572
756
|
const value = requiredString(record, key, 60);
|
|
573
757
|
const heading = value.trim();
|
|
@@ -632,6 +816,13 @@ function decodeOpaqueCursor(value) {
|
|
|
632
816
|
throw new InputError();
|
|
633
817
|
}
|
|
634
818
|
}
|
|
819
|
+
function decodeSince(value) {
|
|
820
|
+
if (uuidPattern.test(value))
|
|
821
|
+
return value.toLowerCase();
|
|
822
|
+
if (timestampPattern.test(value) && new Date(value).toISOString() === value)
|
|
823
|
+
return value;
|
|
824
|
+
throw new InputError();
|
|
825
|
+
}
|
|
635
826
|
function safeRequestId(value) {
|
|
636
827
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
637
828
|
return undefined;
|
|
@@ -641,30 +832,48 @@ function safeRequestId(value) {
|
|
|
641
832
|
: undefined;
|
|
642
833
|
}
|
|
643
834
|
function success(status, requestId, payload) {
|
|
644
|
-
return { status, body:
|
|
835
|
+
return { status, body: createProtocolEnvelope(requestId, payload) };
|
|
645
836
|
}
|
|
646
837
|
function failure(status, code, message, requestId) {
|
|
647
838
|
return {
|
|
648
839
|
status,
|
|
649
840
|
body: {
|
|
650
|
-
protocol_version:
|
|
841
|
+
protocol_version: PROTOCOL_VERSION,
|
|
651
842
|
...(requestId === undefined ? {} : { request_id: requestId }),
|
|
652
843
|
error: { code, message },
|
|
653
844
|
},
|
|
654
845
|
};
|
|
655
846
|
}
|
|
656
847
|
function encodeLogEvent(entry) {
|
|
848
|
+
if ("kind" in entry) {
|
|
849
|
+
return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({ entry })}\n\n`;
|
|
850
|
+
}
|
|
657
851
|
return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({
|
|
658
852
|
cursor: { id: entry.id, created_at: entry.created_at },
|
|
659
853
|
entry,
|
|
660
854
|
})}\n\n`;
|
|
661
855
|
}
|
|
662
|
-
function
|
|
663
|
-
return
|
|
856
|
+
function encodeHeartbeat() {
|
|
857
|
+
return `event: heartbeat\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`;
|
|
664
858
|
}
|
|
665
|
-
function
|
|
666
|
-
return
|
|
667
|
-
|
|
859
|
+
function cubePayload(cube) {
|
|
860
|
+
return {
|
|
861
|
+
id: cube.id,
|
|
862
|
+
owner_id: cube.ownerId,
|
|
863
|
+
name: cube.name,
|
|
864
|
+
cube_directive: cube.directive,
|
|
865
|
+
message_taxonomy: cube.messageTaxonomy,
|
|
866
|
+
created_at: cube.createdAt,
|
|
867
|
+
updated_at: cube.updatedAt,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
function managedDronePayload(drone) {
|
|
871
|
+
return {
|
|
872
|
+
id: drone.id,
|
|
873
|
+
cube_id: drone.cube_id,
|
|
874
|
+
role_id: drone.role_id,
|
|
875
|
+
label: drone.label,
|
|
876
|
+
};
|
|
668
877
|
}
|
|
669
878
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
670
879
|
const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|