borgmcp-server 0.1.4 → 0.1.5
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 +7 -6
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/dist/coordination-api.d.ts +2 -1
- package/dist/coordination-api.js +269 -85
- 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 +42 -8
- package/dist/store.js +380 -100
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -7
- package/package.json +2 -2
- package/src/coordination-api.ts +278 -82
- 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 +432 -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/src/coordination-api.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
ATTACH_PATH,
|
|
4
|
+
ErrorCode,
|
|
5
|
+
PROTOCOL_VERSION,
|
|
6
|
+
ProtocolContractError,
|
|
7
|
+
createProtocolEnvelope,
|
|
8
|
+
decodeAttachRequestEnvelope,
|
|
9
|
+
decodeProtocolEnvelope,
|
|
10
|
+
} from "borgmcp-shared/protocol";
|
|
2
11
|
import type { Principal } from "./principal.js";
|
|
3
12
|
import { assertServerDerivedPrincipal } from "./principal.js";
|
|
4
13
|
import { disabledDebugLogger, type DebugLogger } from "./debug-log.js";
|
|
14
|
+
import {
|
|
15
|
+
patchMessageTaxonomy,
|
|
16
|
+
resolveMessageRouting,
|
|
17
|
+
validateMessageTaxonomy,
|
|
18
|
+
} from "./message-taxonomy.js";
|
|
5
19
|
import type { CredentialAuthority } from "./credentials.js";
|
|
6
20
|
import {
|
|
7
21
|
CursorExpiredError,
|
|
8
|
-
|
|
22
|
+
AttachSessionRejectedError,
|
|
9
23
|
AccessDeniedError,
|
|
10
24
|
CreateCubeConflictError,
|
|
11
25
|
DefaultRoleRequiredError,
|
|
@@ -13,7 +27,9 @@ import {
|
|
|
13
27
|
RoleSectionConflictError,
|
|
14
28
|
ScopedStoreError,
|
|
15
29
|
StorageCapacityError,
|
|
16
|
-
type
|
|
30
|
+
type ActivityPage,
|
|
31
|
+
type ActivityStreamRecord,
|
|
32
|
+
type CubeRecord,
|
|
17
33
|
type LogCursor,
|
|
18
34
|
type StoreRuntime,
|
|
19
35
|
} from "./store.js";
|
|
@@ -24,6 +40,7 @@ export interface CoordinationRequest {
|
|
|
24
40
|
readonly principal: Principal;
|
|
25
41
|
readonly body?: unknown;
|
|
26
42
|
readonly cursor?: string;
|
|
43
|
+
readonly since?: string;
|
|
27
44
|
readonly signal: AbortSignal;
|
|
28
45
|
}
|
|
29
46
|
|
|
@@ -48,16 +65,19 @@ export class CoordinationApi {
|
|
|
48
65
|
readonly #runtime: StoreRuntime;
|
|
49
66
|
readonly #authority: CredentialAuthority;
|
|
50
67
|
readonly #debugLogger: DebugLogger;
|
|
68
|
+
readonly #streamHeartbeatMs: number;
|
|
51
69
|
#replayBarrier: ReplayBarrier | undefined;
|
|
52
70
|
|
|
53
71
|
constructor(
|
|
54
72
|
runtime: StoreRuntime,
|
|
55
73
|
authority: CredentialAuthority,
|
|
56
74
|
debugLogger: DebugLogger = disabledDebugLogger,
|
|
75
|
+
streamHeartbeatMs = 5_000,
|
|
57
76
|
) {
|
|
58
77
|
this.#runtime = runtime;
|
|
59
78
|
this.#authority = authority;
|
|
60
79
|
this.#debugLogger = debugLogger;
|
|
80
|
+
this.#streamHeartbeatMs = streamHeartbeatMs;
|
|
61
81
|
}
|
|
62
82
|
|
|
63
83
|
armReplayTransition(): { readonly reached: Promise<void>; readonly release: () => void } {
|
|
@@ -78,38 +98,38 @@ export class CoordinationApi {
|
|
|
78
98
|
assertServerDerivedPrincipal(request.principal);
|
|
79
99
|
const authentication = request.principal;
|
|
80
100
|
|
|
81
|
-
if (request.path ===
|
|
101
|
+
if (request.path === ATTACH_PATH && request.method === "POST") {
|
|
82
102
|
const requestId = safeRequestId(request.body);
|
|
83
103
|
try {
|
|
84
|
-
const envelope =
|
|
85
|
-
|
|
86
|
-
const priorDroneId = envelope.payload["prior_drone_id"] === undefined
|
|
87
|
-
? undefined
|
|
88
|
-
: requiredUuid(envelope.payload, "prior_drone_id");
|
|
104
|
+
const envelope = decodeAttachRequestEnvelope(request.body);
|
|
105
|
+
const priorDroneId = envelope.payload.prior_drone_id;
|
|
89
106
|
const attachment = this.#authority.attachSeat(
|
|
90
107
|
this.#runtime.forPrincipal(authentication),
|
|
91
108
|
{
|
|
92
|
-
cubeId:
|
|
93
|
-
roleId:
|
|
94
|
-
|
|
109
|
+
cubeId: envelope.payload.cube_id,
|
|
110
|
+
roleId: envelope.payload.role_id,
|
|
111
|
+
sessionCredential: envelope.payload.session_credential,
|
|
95
112
|
...(priorDroneId === undefined ? {} : { priorDroneId }),
|
|
96
113
|
},
|
|
97
114
|
);
|
|
98
|
-
return success(201, envelope.
|
|
115
|
+
return success(attachment.result === "created" ? 201 : 200, envelope.request_id, {
|
|
116
|
+
result: attachment.result,
|
|
99
117
|
cube: attachment.cube,
|
|
100
118
|
role: attachment.role,
|
|
101
119
|
drone: attachment.drone,
|
|
102
120
|
session: {
|
|
103
|
-
|
|
121
|
+
id: attachment.sessionId,
|
|
104
122
|
expires_at: attachment.expiresAt,
|
|
105
|
-
generation: attachment.generation,
|
|
106
|
-
posture: attachment.posture,
|
|
107
123
|
},
|
|
108
|
-
reattached: attachment.reattached,
|
|
109
124
|
});
|
|
110
125
|
} catch (error) {
|
|
111
|
-
if (error instanceof
|
|
112
|
-
return
|
|
126
|
+
if (error instanceof ProtocolContractError) {
|
|
127
|
+
return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
|
|
128
|
+
? failure(426, error.code, "Unsupported protocol version.", requestId)
|
|
129
|
+
: failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
|
|
130
|
+
}
|
|
131
|
+
if (error instanceof AttachSessionRejectedError) {
|
|
132
|
+
return failure(401, ErrorCode.SESSION_REJECTED, error.message, requestId);
|
|
113
133
|
}
|
|
114
134
|
if (error instanceof ScopedStoreError) {
|
|
115
135
|
return failure(404, "NOT_FOUND", error.message, requestId);
|
|
@@ -130,6 +150,7 @@ export class CoordinationApi {
|
|
|
130
150
|
owner_id: cube.ownerId,
|
|
131
151
|
name: cube.name,
|
|
132
152
|
cube_directive: cube.directive,
|
|
153
|
+
message_taxonomy: cube.messageTaxonomy,
|
|
133
154
|
created_at: cube.createdAt,
|
|
134
155
|
updated_at: cube.updatedAt,
|
|
135
156
|
}));
|
|
@@ -164,6 +185,11 @@ export class CoordinationApi {
|
|
|
164
185
|
if (error instanceof StorageCapacityError) {
|
|
165
186
|
return failure(507, "CAPACITY_EXCEEDED", error.message, requestId);
|
|
166
187
|
}
|
|
188
|
+
if (error instanceof ProtocolContractError) {
|
|
189
|
+
return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
|
|
190
|
+
? failure(426, error.code, "Unsupported protocol version.", requestId)
|
|
191
|
+
: failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
|
|
192
|
+
}
|
|
167
193
|
if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
|
|
168
194
|
return failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
|
|
169
195
|
}
|
|
@@ -173,7 +199,7 @@ export class CoordinationApi {
|
|
|
173
199
|
|
|
174
200
|
const roleMatch = /^\/api\/cubes\/([0-9a-f-]{36})\/roles\/([0-9a-f-]{36})(\/section-patch)?$/u
|
|
175
201
|
.exec(request.path);
|
|
176
|
-
const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream))?$/u
|
|
202
|
+
const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream|taxonomy-patch))?$/u
|
|
177
203
|
.exec(request.path);
|
|
178
204
|
if (match === null && roleMatch === null) {
|
|
179
205
|
return failure(404, "NOT_FOUND", "The requested resource was not found.");
|
|
@@ -197,11 +223,47 @@ export class CoordinationApi {
|
|
|
197
223
|
owner_id: cube.ownerId,
|
|
198
224
|
name: cube.name,
|
|
199
225
|
cube_directive: cube.directive,
|
|
226
|
+
message_taxonomy: cube.messageTaxonomy,
|
|
200
227
|
created_at: cube.createdAt,
|
|
201
228
|
updated_at: cube.updatedAt,
|
|
202
229
|
},
|
|
203
230
|
});
|
|
204
231
|
}
|
|
232
|
+
if (resource === undefined && request.method === "PATCH") {
|
|
233
|
+
const envelope = decodeEnvelope(request.body);
|
|
234
|
+
exactKeys(envelope.payload, [], ["cube_directive", "message_taxonomy"]);
|
|
235
|
+
if (Object.keys(envelope.payload).length === 0) throw new InputError();
|
|
236
|
+
const directive = optionalText(envelope.payload, "cube_directive", 100_000);
|
|
237
|
+
const messageTaxonomy = optionalMessageTaxonomy(envelope.payload["message_taxonomy"]);
|
|
238
|
+
const cube = store.updateCube(cubeId, {
|
|
239
|
+
...(directive === undefined ? {} : { directive }),
|
|
240
|
+
...(messageTaxonomy === undefined ? {} : { messageTaxonomy }),
|
|
241
|
+
});
|
|
242
|
+
return success(200, envelope.requestId, { cube: cubePayload(cube) });
|
|
243
|
+
}
|
|
244
|
+
if (resource === "taxonomy-patch" && request.method === "POST") {
|
|
245
|
+
const envelope = decodeEnvelope(request.body);
|
|
246
|
+
const action = envelope.payload["action"];
|
|
247
|
+
const cube = store.getCube(cubeId);
|
|
248
|
+
if (cube === null) throw new ScopedStoreError();
|
|
249
|
+
if (action === "remove") {
|
|
250
|
+
exactKeys(envelope.payload, ["action", "class"]);
|
|
251
|
+
const messageTaxonomy = patchMessageTaxonomy(cube.messageTaxonomy, {
|
|
252
|
+
action,
|
|
253
|
+
className: requiredString(envelope.payload, "class", 64),
|
|
254
|
+
});
|
|
255
|
+
return success(200, envelope.requestId, {
|
|
256
|
+
cube: cubePayload(store.updateCube(cubeId, { messageTaxonomy })),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (action !== "add" && action !== "replace") throw new InputError();
|
|
260
|
+
exactKeys(envelope.payload, ["action", "class_def"]);
|
|
261
|
+
const classDef = validateMessageTaxonomy([envelope.payload["class_def"]])![0]!;
|
|
262
|
+
const messageTaxonomy = patchMessageTaxonomy(cube.messageTaxonomy, { action, classDef });
|
|
263
|
+
return success(200, envelope.requestId, {
|
|
264
|
+
cube: cubePayload(store.updateCube(cubeId, { messageTaxonomy })),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
205
267
|
if (resource === "roles" && request.method === "GET") {
|
|
206
268
|
return success(200, "roles-read", { roles: store.listRoles(cubeId) });
|
|
207
269
|
}
|
|
@@ -215,6 +277,7 @@ export class CoordinationApi {
|
|
|
215
277
|
"is_human_seat",
|
|
216
278
|
"can_broadcast",
|
|
217
279
|
"receives_all_direct",
|
|
280
|
+
"role_class",
|
|
218
281
|
]);
|
|
219
282
|
const role = store.createRole(cubeId, {
|
|
220
283
|
name: requiredRoleName(envelope.payload, "name"),
|
|
@@ -225,6 +288,9 @@ export class CoordinationApi {
|
|
|
225
288
|
isHumanSeat: optionalBoolean(envelope.payload["is_human_seat"]),
|
|
226
289
|
canBroadcast: optionalBoolean(envelope.payload["can_broadcast"]),
|
|
227
290
|
receivesAllDirect: optionalBoolean(envelope.payload["receives_all_direct"]),
|
|
291
|
+
...(envelope.payload["role_class"] === undefined
|
|
292
|
+
? {}
|
|
293
|
+
: { roleClass: optionalRoleClass(envelope.payload["role_class"])! }),
|
|
228
294
|
});
|
|
229
295
|
return success(201, envelope.requestId, { role });
|
|
230
296
|
}
|
|
@@ -239,6 +305,7 @@ export class CoordinationApi {
|
|
|
239
305
|
"is_human_seat",
|
|
240
306
|
"can_broadcast",
|
|
241
307
|
"receives_all_direct",
|
|
308
|
+
"role_class",
|
|
242
309
|
]);
|
|
243
310
|
if (Object.keys(envelope.payload).length === 0) throw new InputError();
|
|
244
311
|
const name = envelope.payload["name"] === undefined
|
|
@@ -251,6 +318,7 @@ export class CoordinationApi {
|
|
|
251
318
|
const isHumanSeat = optionalBooleanValue(envelope.payload["is_human_seat"]);
|
|
252
319
|
const canBroadcast = optionalBooleanValue(envelope.payload["can_broadcast"]);
|
|
253
320
|
const receivesAllDirect = optionalBooleanValue(envelope.payload["receives_all_direct"]);
|
|
321
|
+
const roleClass = optionalRoleClass(envelope.payload["role_class"]);
|
|
254
322
|
const role = store.updateRole(cubeId, roleId!, {
|
|
255
323
|
...(name === undefined ? {} : { name }),
|
|
256
324
|
...(shortDescription === undefined ? {} : { shortDescription }),
|
|
@@ -260,6 +328,7 @@ export class CoordinationApi {
|
|
|
260
328
|
...(isHumanSeat === undefined ? {} : { isHumanSeat }),
|
|
261
329
|
...(canBroadcast === undefined ? {} : { canBroadcast }),
|
|
262
330
|
...(receivesAllDirect === undefined ? {} : { receivesAllDirect }),
|
|
331
|
+
...(roleClass === undefined ? {} : { roleClass }),
|
|
263
332
|
});
|
|
264
333
|
return success(200, envelope.requestId, { role });
|
|
265
334
|
}
|
|
@@ -295,22 +364,37 @@ export class CoordinationApi {
|
|
|
295
364
|
});
|
|
296
365
|
}
|
|
297
366
|
if (resource === "drones" && request.method === "GET") {
|
|
298
|
-
|
|
367
|
+
if (request.since === undefined) {
|
|
368
|
+
return success(200, "drones-read", { drones: store.listDrones(cubeId) });
|
|
369
|
+
}
|
|
370
|
+
const since = decodeSince(request.since);
|
|
371
|
+
return success(200, "drones-read", store.listDronesSince(cubeId, since));
|
|
299
372
|
}
|
|
300
373
|
if (resource === "logs" && request.method === "POST") {
|
|
301
374
|
const envelope = decodeEnvelope(request.body);
|
|
302
|
-
exactKeys(envelope.payload, ["message"], [
|
|
375
|
+
exactKeys(envelope.payload, ["message"], [
|
|
376
|
+
"visibility", "recipientDroneIds", "class", "to",
|
|
377
|
+
]);
|
|
303
378
|
const message = requiredString(envelope.payload, "message", 10_240);
|
|
304
379
|
const visibility = optionalVisibility(envelope.payload["visibility"]);
|
|
305
380
|
const recipientDroneIds = optionalUuidArray(envelope.payload["recipientDroneIds"]);
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
381
|
+
const className = optionalString(envelope.payload, "class", 64);
|
|
382
|
+
const to = optionalStringArray(envelope.payload["to"]);
|
|
383
|
+
const cube = store.getCube(cubeId);
|
|
384
|
+
if (cube === null) throw new ScopedStoreError();
|
|
385
|
+
const resolved = resolveMessageRouting({
|
|
311
386
|
message,
|
|
312
387
|
...(visibility === undefined ? {} : { visibility }),
|
|
313
388
|
...(recipientDroneIds === undefined ? {} : { recipientDroneIds }),
|
|
389
|
+
...(className === undefined ? {} : { className }),
|
|
390
|
+
...(to === undefined ? {} : { to }),
|
|
391
|
+
}, cube.messageTaxonomy, store.listRoles(cubeId), store.listDrones(cubeId));
|
|
392
|
+
const entry = store.appendLog(cubeId, {
|
|
393
|
+
message,
|
|
394
|
+
visibility: resolved.visibility,
|
|
395
|
+
...(resolved.visibility === "direct"
|
|
396
|
+
? { recipientDroneIds: resolved.recipientDroneIds }
|
|
397
|
+
: {}),
|
|
314
398
|
});
|
|
315
399
|
this.#debugLogger.emit({
|
|
316
400
|
event: "activity_append",
|
|
@@ -382,6 +466,9 @@ export class CoordinationApi {
|
|
|
382
466
|
if (error instanceof CursorExpiredError) {
|
|
383
467
|
return failure(410, "CURSOR_EXPIRED", error.message);
|
|
384
468
|
}
|
|
469
|
+
if (error instanceof AccessDeniedError) {
|
|
470
|
+
return failure(403, ErrorCode.ACCESS_DENIED, error.message);
|
|
471
|
+
}
|
|
385
472
|
if (error instanceof ScopedStoreError) {
|
|
386
473
|
return failure(404, "NOT_FOUND", error.message);
|
|
387
474
|
}
|
|
@@ -397,6 +484,11 @@ export class CoordinationApi {
|
|
|
397
484
|
if (error instanceof StorageCapacityError) {
|
|
398
485
|
return failure(507, "CAPACITY_EXCEEDED", error.message, safeRequestId(request.body));
|
|
399
486
|
}
|
|
487
|
+
if (error instanceof ProtocolContractError) {
|
|
488
|
+
return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
|
|
489
|
+
? failure(426, error.code, "Unsupported protocol version.", safeRequestId(request.body))
|
|
490
|
+
: failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
|
|
491
|
+
}
|
|
400
492
|
if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
|
|
401
493
|
return failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
|
|
402
494
|
}
|
|
@@ -417,7 +509,9 @@ export class CoordinationApi {
|
|
|
417
509
|
let unsubscribe = (): void => undefined;
|
|
418
510
|
let subscribed = false;
|
|
419
511
|
let deliveryCount = 0;
|
|
512
|
+
let heartbeat: NodeJS.Timeout | undefined;
|
|
420
513
|
const queue = new AsyncStringQueue(() => {
|
|
514
|
+
if (heartbeat !== undefined) clearInterval(heartbeat);
|
|
421
515
|
unsubscribe();
|
|
422
516
|
session.release();
|
|
423
517
|
if (subscribed) {
|
|
@@ -430,7 +524,9 @@ export class CoordinationApi {
|
|
|
430
524
|
});
|
|
431
525
|
}
|
|
432
526
|
}, () => { deliveryCount += 1; });
|
|
433
|
-
|
|
527
|
+
let replayDirty = false;
|
|
528
|
+
const pendingNotifications: ActivityStreamRecord[] = [];
|
|
529
|
+
let notificationIndex = 0;
|
|
434
530
|
let live = false;
|
|
435
531
|
try {
|
|
436
532
|
unsubscribe = store.subscribeActivity(cubeId, (entry) => {
|
|
@@ -439,21 +535,13 @@ export class CoordinationApi {
|
|
|
439
535
|
return;
|
|
440
536
|
}
|
|
441
537
|
if (live) queue.push(encodeLogEvent(entry));
|
|
442
|
-
else if (
|
|
443
|
-
else
|
|
538
|
+
else if ("kind" in entry) pendingNotifications.push(entry);
|
|
539
|
+
else replayDirty = true;
|
|
444
540
|
});
|
|
541
|
+
subscribed = true;
|
|
445
542
|
signal.addEventListener("abort", () => queue.close(), { once: true });
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
this.#debugLogger.emit({
|
|
449
|
-
event: "cursor_replay",
|
|
450
|
-
mode: "sse",
|
|
451
|
-
cubeId,
|
|
452
|
-
cursorId: cursor?.id ?? null,
|
|
453
|
-
returnedCount: replay.length,
|
|
454
|
-
behindBy: replayPage.behind_by,
|
|
455
|
-
truncated: replayPage.has_more,
|
|
456
|
-
});
|
|
543
|
+
const firstPage = store.readLog(cubeId, cursor, 200);
|
|
544
|
+
this.#logReplayPage(cubeId, cursor, firstPage);
|
|
457
545
|
const barrier = this.#replayBarrier;
|
|
458
546
|
this.#replayBarrier = undefined;
|
|
459
547
|
if (barrier !== undefined) {
|
|
@@ -461,28 +549,61 @@ export class CoordinationApi {
|
|
|
461
549
|
barrier.markReached();
|
|
462
550
|
await barrier.release;
|
|
463
551
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
552
|
+
void (async () => {
|
|
553
|
+
let page = firstPage;
|
|
554
|
+
let replayCursor = cursor;
|
|
555
|
+
let replayCount = 0;
|
|
556
|
+
try {
|
|
557
|
+
for (;;) {
|
|
558
|
+
for (const entry of page.entries) {
|
|
559
|
+
if (!await queue.write(encodeLogEvent(entry))) return;
|
|
560
|
+
replayCursor = { id: entry.id, created_at: entry.created_at };
|
|
561
|
+
replayCount += 1;
|
|
562
|
+
}
|
|
563
|
+
if (page.has_more || replayDirty) {
|
|
564
|
+
replayDirty = false;
|
|
565
|
+
page = store.readLog(cubeId, replayCursor, 200);
|
|
566
|
+
this.#logReplayPage(cubeId, replayCursor, page);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
while (notificationIndex < pendingNotifications.length) {
|
|
571
|
+
if (!await queue.write(encodeLogEvent(pendingNotifications[notificationIndex]!))) return;
|
|
572
|
+
notificationIndex += 1;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Reserve room for the bookmark before making live callbacks visible.
|
|
576
|
+
// Any append while waiting marks replay dirty and is fetched first.
|
|
577
|
+
if (!await queue.waitForSpace()) return;
|
|
578
|
+
if (replayDirty || notificationIndex < pendingNotifications.length) {
|
|
579
|
+
replayDirty = false;
|
|
580
|
+
page = store.readLog(cubeId, replayCursor, 200);
|
|
581
|
+
this.#logReplayPage(cubeId, replayCursor, page);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
queue.push(`event: bookmark\ndata: ${JSON.stringify({
|
|
585
|
+
as_of: new Date().toISOString(),
|
|
586
|
+
replay_complete: true,
|
|
587
|
+
})}\n\n`);
|
|
588
|
+
live = true;
|
|
589
|
+
heartbeat = setInterval(() => {
|
|
590
|
+
queue.tryPush(encodeHeartbeat());
|
|
591
|
+
}, this.#streamHeartbeatMs);
|
|
592
|
+
heartbeat.unref();
|
|
593
|
+
this.#debugLogger.emit({
|
|
594
|
+
event: "sse_subscribe",
|
|
595
|
+
connectionId,
|
|
596
|
+
cubeId,
|
|
597
|
+
principal,
|
|
598
|
+
replayCount,
|
|
599
|
+
truncated: false,
|
|
600
|
+
});
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
} catch {
|
|
604
|
+
queue.close();
|
|
470
605
|
}
|
|
471
|
-
}
|
|
472
|
-
queue.push(`event: bookmark\ndata: ${JSON.stringify({
|
|
473
|
-
as_of: new Date().toISOString(),
|
|
474
|
-
replay_complete: true,
|
|
475
|
-
})}\n\n`);
|
|
476
|
-
live = true;
|
|
477
|
-
subscribed = true;
|
|
478
|
-
this.#debugLogger.emit({
|
|
479
|
-
event: "sse_subscribe",
|
|
480
|
-
connectionId,
|
|
481
|
-
cubeId,
|
|
482
|
-
principal,
|
|
483
|
-
replayCount: replay.length,
|
|
484
|
-
truncated: replayPage.has_more,
|
|
485
|
-
});
|
|
606
|
+
})();
|
|
486
607
|
return { status: 200, stream: queue };
|
|
487
608
|
} catch (error) {
|
|
488
609
|
queue.close();
|
|
@@ -491,6 +612,22 @@ export class CoordinationApi {
|
|
|
491
612
|
throw error;
|
|
492
613
|
}
|
|
493
614
|
}
|
|
615
|
+
|
|
616
|
+
#logReplayPage(
|
|
617
|
+
cubeId: string,
|
|
618
|
+
cursor: LogCursor | null,
|
|
619
|
+
page: ActivityPage,
|
|
620
|
+
): void {
|
|
621
|
+
this.#debugLogger.emit({
|
|
622
|
+
event: "cursor_replay",
|
|
623
|
+
mode: "sse",
|
|
624
|
+
cubeId,
|
|
625
|
+
cursorId: cursor?.id ?? null,
|
|
626
|
+
returnedCount: page.entries.length,
|
|
627
|
+
behindBy: page.behind_by,
|
|
628
|
+
truncated: page.has_more,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
494
631
|
}
|
|
495
632
|
|
|
496
633
|
class InputError extends Error {}
|
|
@@ -498,6 +635,7 @@ class InputError extends Error {}
|
|
|
498
635
|
class AsyncStringQueue implements AsyncIterable<string> {
|
|
499
636
|
readonly #values: string[] = [];
|
|
500
637
|
readonly #waiters: Array<(result: IteratorResult<string>) => void> = [];
|
|
638
|
+
readonly #spaceWaiters: Array<(available: boolean) => void> = [];
|
|
501
639
|
readonly #cleanup: () => void;
|
|
502
640
|
readonly #onDelivered: () => void;
|
|
503
641
|
#closed = false;
|
|
@@ -513,12 +651,28 @@ class AsyncStringQueue implements AsyncIterable<string> {
|
|
|
513
651
|
this.close();
|
|
514
652
|
return;
|
|
515
653
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
654
|
+
this.#enqueue(value);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
tryPush(value: string): boolean {
|
|
658
|
+
if (this.#closed || this.#values.length >= 200) return false;
|
|
659
|
+
this.#enqueue(value);
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async write(value: string): Promise<boolean> {
|
|
664
|
+
while (!this.#closed && this.#values.length >= 200) {
|
|
665
|
+
if (!await this.waitForSpace()) return false;
|
|
521
666
|
}
|
|
667
|
+
if (this.#closed) return false;
|
|
668
|
+
this.#enqueue(value);
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
waitForSpace(): Promise<boolean> {
|
|
673
|
+
if (this.#closed) return Promise.resolve(false);
|
|
674
|
+
if (this.#values.length < 200) return Promise.resolve(true);
|
|
675
|
+
return new Promise((resolve) => this.#spaceWaiters.push(resolve));
|
|
522
676
|
}
|
|
523
677
|
|
|
524
678
|
close(): void {
|
|
@@ -526,6 +680,7 @@ class AsyncStringQueue implements AsyncIterable<string> {
|
|
|
526
680
|
this.#closed = true;
|
|
527
681
|
this.#cleanup();
|
|
528
682
|
for (const waiter of this.#waiters.splice(0)) waiter({ value: undefined, done: true });
|
|
683
|
+
for (const waiter of this.#spaceWaiters.splice(0)) waiter(false);
|
|
529
684
|
}
|
|
530
685
|
|
|
531
686
|
[Symbol.asyncIterator](): AsyncIterator<string> {
|
|
@@ -533,6 +688,7 @@ class AsyncStringQueue implements AsyncIterable<string> {
|
|
|
533
688
|
next: async () => {
|
|
534
689
|
const value = this.#values.shift();
|
|
535
690
|
if (value !== undefined) {
|
|
691
|
+
this.#spaceWaiters.shift()?.(true);
|
|
536
692
|
this.#onDelivered();
|
|
537
693
|
return { value, done: false };
|
|
538
694
|
}
|
|
@@ -545,17 +701,20 @@ class AsyncStringQueue implements AsyncIterable<string> {
|
|
|
545
701
|
},
|
|
546
702
|
};
|
|
547
703
|
}
|
|
704
|
+
|
|
705
|
+
#enqueue(value: string): void {
|
|
706
|
+
const waiter = this.#waiters.shift();
|
|
707
|
+
if (waiter === undefined) this.#values.push(value);
|
|
708
|
+
else {
|
|
709
|
+
this.#onDelivered();
|
|
710
|
+
waiter({ value, done: false });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
548
713
|
}
|
|
549
714
|
|
|
550
715
|
function decodeEnvelope(value: unknown): RequestEnvelope {
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
if (record["protocol_version"] !== "1") throw new InputError();
|
|
554
|
-
const requestId = record["request_id"];
|
|
555
|
-
if (typeof requestId !== "string" || !/^[A-Za-z0-9._-]{8,128}$/u.test(requestId)) {
|
|
556
|
-
throw new InputError();
|
|
557
|
-
}
|
|
558
|
-
return { requestId, payload: object(record["payload"]) };
|
|
716
|
+
const envelope = decodeProtocolEnvelope(value, object);
|
|
717
|
+
return { requestId: envelope.request_id, payload: envelope.payload };
|
|
559
718
|
}
|
|
560
719
|
|
|
561
720
|
function object(value: unknown): Record<string, unknown> {
|
|
@@ -622,6 +781,27 @@ function optionalBooleanValue(value: unknown): boolean | undefined {
|
|
|
622
781
|
return value;
|
|
623
782
|
}
|
|
624
783
|
|
|
784
|
+
function optionalRoleClass(value: unknown): "queen" | "worker" | undefined {
|
|
785
|
+
if (value === undefined) return undefined;
|
|
786
|
+
if (value !== "queen" && value !== "worker") throw new InputError();
|
|
787
|
+
return value;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function optionalMessageTaxonomy(value: unknown) {
|
|
791
|
+
return value === undefined ? undefined : validateMessageTaxonomy(value);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function optionalStringArray(value: unknown): string[] | undefined {
|
|
795
|
+
if (value === undefined) return undefined;
|
|
796
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 100 ||
|
|
797
|
+
value.some((entry) => typeof entry !== "string" || entry.length < 1 || entry.length > 120)) {
|
|
798
|
+
throw new InputError();
|
|
799
|
+
}
|
|
800
|
+
const entries = value as string[];
|
|
801
|
+
if (new Set(entries).size !== entries.length) throw new InputError();
|
|
802
|
+
return entries;
|
|
803
|
+
}
|
|
804
|
+
|
|
625
805
|
function requiredSectionHeading(record: Record<string, unknown>, key: string): string {
|
|
626
806
|
const value = requiredString(record, key, 60);
|
|
627
807
|
const heading = value.trim();
|
|
@@ -682,6 +862,12 @@ function decodeOpaqueCursor(value: string | undefined): LogCursor | null {
|
|
|
682
862
|
}
|
|
683
863
|
}
|
|
684
864
|
|
|
865
|
+
function decodeSince(value: string): string {
|
|
866
|
+
if (uuidPattern.test(value)) return value.toLowerCase();
|
|
867
|
+
if (timestampPattern.test(value) && new Date(value).toISOString() === value) return value;
|
|
868
|
+
throw new InputError();
|
|
869
|
+
}
|
|
870
|
+
|
|
685
871
|
function safeRequestId(value: unknown): string | undefined {
|
|
686
872
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
687
873
|
const requestId = (value as Record<string, unknown>)["request_id"];
|
|
@@ -691,7 +877,7 @@ function safeRequestId(value: unknown): string | undefined {
|
|
|
691
877
|
}
|
|
692
878
|
|
|
693
879
|
function success(status: number, requestId: string, payload: unknown): CoordinationResponse {
|
|
694
|
-
return { status, body:
|
|
880
|
+
return { status, body: createProtocolEnvelope(requestId, payload) };
|
|
695
881
|
}
|
|
696
882
|
|
|
697
883
|
function failure(
|
|
@@ -703,27 +889,37 @@ function failure(
|
|
|
703
889
|
return {
|
|
704
890
|
status,
|
|
705
891
|
body: {
|
|
706
|
-
protocol_version:
|
|
892
|
+
protocol_version: PROTOCOL_VERSION,
|
|
707
893
|
...(requestId === undefined ? {} : { request_id: requestId }),
|
|
708
894
|
error: { code, message },
|
|
709
895
|
},
|
|
710
896
|
};
|
|
711
897
|
}
|
|
712
898
|
|
|
713
|
-
function encodeLogEvent(entry:
|
|
899
|
+
function encodeLogEvent(entry: ActivityStreamRecord): string {
|
|
900
|
+
if ("kind" in entry) {
|
|
901
|
+
return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({ entry })}\n\n`;
|
|
902
|
+
}
|
|
714
903
|
return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({
|
|
715
904
|
cursor: { id: entry.id, created_at: entry.created_at },
|
|
716
905
|
entry,
|
|
717
906
|
})}\n\n`;
|
|
718
907
|
}
|
|
719
908
|
|
|
720
|
-
function
|
|
721
|
-
return
|
|
909
|
+
function encodeHeartbeat(): string {
|
|
910
|
+
return `event: heartbeat\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`;
|
|
722
911
|
}
|
|
723
912
|
|
|
724
|
-
function
|
|
725
|
-
return
|
|
726
|
-
|
|
913
|
+
function cubePayload(cube: CubeRecord) {
|
|
914
|
+
return {
|
|
915
|
+
id: cube.id,
|
|
916
|
+
owner_id: cube.ownerId,
|
|
917
|
+
name: cube.name,
|
|
918
|
+
cube_directive: cube.directive,
|
|
919
|
+
message_taxonomy: cube.messageTaxonomy,
|
|
920
|
+
created_at: cube.createdAt,
|
|
921
|
+
updated_at: cube.updatedAt,
|
|
922
|
+
};
|
|
727
923
|
}
|
|
728
924
|
|
|
729
925
|
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;
|