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.
Files changed (43) hide show
  1. package/README.md +7 -6
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/dist/coordination-api.d.ts +2 -1
  4. package/dist/coordination-api.js +269 -85
  5. package/dist/coordination-api.js.map +1 -1
  6. package/dist/credentials.d.ts +4 -6
  7. package/dist/credentials.js +43 -19
  8. package/dist/credentials.js.map +1 -1
  9. package/dist/debug-log.d.ts +2 -3
  10. package/dist/debug-log.js +2 -3
  11. package/dist/debug-log.js.map +1 -1
  12. package/dist/enrollment.d.ts +3 -11
  13. package/dist/enrollment.js +21 -60
  14. package/dist/enrollment.js.map +1 -1
  15. package/dist/https-server.d.ts +2 -20
  16. package/dist/https-server.js +33 -51
  17. package/dist/https-server.js.map +1 -1
  18. package/dist/message-taxonomy.d.ts +38 -0
  19. package/dist/message-taxonomy.js +218 -0
  20. package/dist/message-taxonomy.js.map +1 -0
  21. package/dist/migrations.js +28 -0
  22. package/dist/migrations.js.map +1 -1
  23. package/dist/service.d.ts +4 -1
  24. package/dist/service.js +25 -10
  25. package/dist/service.js.map +1 -1
  26. package/dist/store.d.ts +42 -8
  27. package/dist/store.js +380 -100
  28. package/dist/store.js.map +1 -1
  29. package/npm-shrinkwrap.json +6 -7
  30. package/package.json +2 -2
  31. package/src/coordination-api.ts +278 -82
  32. package/src/credentials.ts +44 -26
  33. package/src/debug-log.ts +5 -5
  34. package/src/enrollment.ts +32 -78
  35. package/src/https-server.ts +44 -82
  36. package/src/message-taxonomy.ts +284 -0
  37. package/src/migrations.ts +28 -0
  38. package/src/service.ts +29 -9
  39. package/src/store.ts +432 -121
  40. package/dist/protocol-draft.d.ts +0 -2
  41. package/dist/protocol-draft.js +0 -31
  42. package/dist/protocol-draft.js.map +0 -1
  43. package/src/protocol-draft.ts +0 -32
@@ -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
- AttachConflictError,
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 EnrichedActivityRecord,
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 === "/api/client/attach" && request.method === "POST") {
101
+ if (request.path === ATTACH_PATH && request.method === "POST") {
82
102
  const requestId = safeRequestId(request.body);
83
103
  try {
84
- const envelope = decodeEnvelope(request.body);
85
- exactKeys(envelope.payload, ["cube_id", "role_id", "retry_key"], ["prior_drone_id"]);
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: requiredUuid(envelope.payload, "cube_id"),
93
- roleId: requiredUuid(envelope.payload, "role_id"),
94
- retryKey: requiredUuid(envelope.payload, "retry_key"),
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.requestId, {
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
- token: attachment.credential,
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 AttachConflictError) {
112
- return failure(409, "INVALID_INPUT", "The attach request conflicts.", requestId);
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
- return success(200, "drones-read", { drones: store.listDrones(cubeId) });
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"], ["visibility", "recipientDroneIds"]);
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
- if (((visibility ?? "broadcast") === "broadcast" && recipientDroneIds !== undefined) ||
307
- (visibility === "direct" && (recipientDroneIds?.length ?? 0) === 0)) {
308
- throw new InputError();
309
- }
310
- const entry = store.appendLog(cubeId, {
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
- const pending: EnrichedActivityRecord[] = [];
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 (pending.length >= 200) queue.close();
443
- else pending.push(entry);
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 replayPage = store.readLog(cubeId, cursor, 200);
447
- const replay = replayPage.entries;
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
- const seen = new Set(replay.map(cursorKey));
465
- for (const entry of replay) queue.push(encodeLogEvent(entry));
466
- for (const entry of pending) {
467
- if (!seen.has(cursorKey(entry)) && afterCursor(entry, cursor)) {
468
- seen.add(cursorKey(entry));
469
- queue.push(encodeLogEvent(entry));
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
- const waiter = this.#waiters.shift();
517
- if (waiter === undefined) this.#values.push(value);
518
- else {
519
- this.#onDelivered();
520
- waiter({ value, done: false });
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 record = object(value);
552
- exactKeys(record, ["protocol_version", "request_id", "payload"]);
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: { protocol_version: "1", request_id: requestId, payload } };
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: "1",
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: EnrichedActivityRecord): string {
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 cursorKey(entry: EnrichedActivityRecord): string {
721
- return `${entry.created_at}\0${entry.id}`;
909
+ function encodeHeartbeat(): string {
910
+ return `event: heartbeat\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`;
722
911
  }
723
912
 
724
- function afterCursor(entry: EnrichedActivityRecord, cursor: LogCursor | null): boolean {
725
- return cursor === null || entry.created_at > cursor.created_at ||
726
- (entry.created_at === cursor.created_at && entry.id > cursor.id);
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;