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.
Files changed (43) hide show
  1. package/README.md +32 -11
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/dist/coordination-api.d.ts +2 -1
  4. package/dist/coordination-api.js +298 -89
  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 +66 -8
  27. package/dist/store.js +525 -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 +312 -86
  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 +593 -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,19 +1,39 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import {
3
+ ATTACH_PATH,
4
+ ErrorCode,
5
+ PROTOCOL_VERSION,
6
+ ProtocolContractError,
7
+ createProtocolEnvelope,
8
+ decodeAttachRequestEnvelope,
9
+ decodeEvictDroneRequestEnvelope,
10
+ decodeProtocolEnvelope,
11
+ decodeReassignDroneRequestEnvelope,
12
+ } from "borgmcp-shared/protocol";
2
13
  import type { Principal } from "./principal.js";
3
14
  import { assertServerDerivedPrincipal } from "./principal.js";
4
15
  import { disabledDebugLogger, type DebugLogger } from "./debug-log.js";
16
+ import {
17
+ patchMessageTaxonomy,
18
+ resolveMessageRouting,
19
+ validateMessageTaxonomy,
20
+ } from "./message-taxonomy.js";
5
21
  import type { CredentialAuthority } from "./credentials.js";
6
22
  import {
7
23
  CursorExpiredError,
8
- AttachConflictError,
24
+ AttachSessionRejectedError,
9
25
  AccessDeniedError,
10
26
  CreateCubeConflictError,
11
27
  DefaultRoleRequiredError,
12
28
  RoleConflictError,
29
+ RoleInUseError,
13
30
  RoleSectionConflictError,
14
31
  ScopedStoreError,
15
32
  StorageCapacityError,
16
- type EnrichedActivityRecord,
33
+ type DroneRecord,
34
+ type ActivityPage,
35
+ type ActivityStreamRecord,
36
+ type CubeRecord,
17
37
  type LogCursor,
18
38
  type StoreRuntime,
19
39
  } from "./store.js";
@@ -24,6 +44,7 @@ export interface CoordinationRequest {
24
44
  readonly principal: Principal;
25
45
  readonly body?: unknown;
26
46
  readonly cursor?: string;
47
+ readonly since?: string;
27
48
  readonly signal: AbortSignal;
28
49
  }
29
50
 
@@ -48,16 +69,19 @@ export class CoordinationApi {
48
69
  readonly #runtime: StoreRuntime;
49
70
  readonly #authority: CredentialAuthority;
50
71
  readonly #debugLogger: DebugLogger;
72
+ readonly #streamHeartbeatMs: number;
51
73
  #replayBarrier: ReplayBarrier | undefined;
52
74
 
53
75
  constructor(
54
76
  runtime: StoreRuntime,
55
77
  authority: CredentialAuthority,
56
78
  debugLogger: DebugLogger = disabledDebugLogger,
79
+ streamHeartbeatMs = 5_000,
57
80
  ) {
58
81
  this.#runtime = runtime;
59
82
  this.#authority = authority;
60
83
  this.#debugLogger = debugLogger;
84
+ this.#streamHeartbeatMs = streamHeartbeatMs;
61
85
  }
62
86
 
63
87
  armReplayTransition(): { readonly reached: Promise<void>; readonly release: () => void } {
@@ -78,38 +102,38 @@ export class CoordinationApi {
78
102
  assertServerDerivedPrincipal(request.principal);
79
103
  const authentication = request.principal;
80
104
 
81
- if (request.path === "/api/client/attach" && request.method === "POST") {
105
+ if (request.path === ATTACH_PATH && request.method === "POST") {
82
106
  const requestId = safeRequestId(request.body);
83
107
  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");
108
+ const envelope = decodeAttachRequestEnvelope(request.body);
109
+ const priorDroneId = envelope.payload.prior_drone_id;
89
110
  const attachment = this.#authority.attachSeat(
90
111
  this.#runtime.forPrincipal(authentication),
91
112
  {
92
- cubeId: requiredUuid(envelope.payload, "cube_id"),
93
- roleId: requiredUuid(envelope.payload, "role_id"),
94
- retryKey: requiredUuid(envelope.payload, "retry_key"),
113
+ cubeId: envelope.payload.cube_id,
114
+ roleId: envelope.payload.role_id,
115
+ sessionCredential: envelope.payload.session_credential,
95
116
  ...(priorDroneId === undefined ? {} : { priorDroneId }),
96
117
  },
97
118
  );
98
- return success(201, envelope.requestId, {
119
+ return success(attachment.result === "created" ? 201 : 200, envelope.request_id, {
120
+ result: attachment.result,
99
121
  cube: attachment.cube,
100
122
  role: attachment.role,
101
123
  drone: attachment.drone,
102
124
  session: {
103
- token: attachment.credential,
125
+ id: attachment.sessionId,
104
126
  expires_at: attachment.expiresAt,
105
- generation: attachment.generation,
106
- posture: attachment.posture,
107
127
  },
108
- reattached: attachment.reattached,
109
128
  });
110
129
  } catch (error) {
111
- if (error instanceof AttachConflictError) {
112
- return failure(409, "INVALID_INPUT", "The attach request conflicts.", requestId);
130
+ if (error instanceof ProtocolContractError) {
131
+ return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
132
+ ? failure(426, error.code, "Unsupported protocol version.", requestId)
133
+ : failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
134
+ }
135
+ if (error instanceof AttachSessionRejectedError) {
136
+ return failure(401, ErrorCode.SESSION_REJECTED, error.message, requestId);
113
137
  }
114
138
  if (error instanceof ScopedStoreError) {
115
139
  return failure(404, "NOT_FOUND", error.message, requestId);
@@ -130,6 +154,7 @@ export class CoordinationApi {
130
154
  owner_id: cube.ownerId,
131
155
  name: cube.name,
132
156
  cube_directive: cube.directive,
157
+ message_taxonomy: cube.messageTaxonomy,
133
158
  created_at: cube.createdAt,
134
159
  updated_at: cube.updatedAt,
135
160
  }));
@@ -164,6 +189,11 @@ export class CoordinationApi {
164
189
  if (error instanceof StorageCapacityError) {
165
190
  return failure(507, "CAPACITY_EXCEEDED", error.message, requestId);
166
191
  }
192
+ if (error instanceof ProtocolContractError) {
193
+ return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
194
+ ? failure(426, error.code, "Unsupported protocol version.", requestId)
195
+ : failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
196
+ }
167
197
  if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
168
198
  return failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
169
199
  }
@@ -173,17 +203,21 @@ export class CoordinationApi {
173
203
 
174
204
  const roleMatch = /^\/api\/cubes\/([0-9a-f-]{36})\/roles\/([0-9a-f-]{36})(\/section-patch)?$/u
175
205
  .exec(request.path);
176
- const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream))?$/u
206
+ const droneMatch = /^\/api\/cubes\/([0-9a-f-]{36})\/drones\/([0-9a-f-]{36})$/u
207
+ .exec(request.path);
208
+ const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream|taxonomy-patch))?$/u
177
209
  .exec(request.path);
178
- if (match === null && roleMatch === null) {
210
+ if (match === null && roleMatch === null && droneMatch === null) {
179
211
  return failure(404, "NOT_FOUND", "The requested resource was not found.");
180
212
  }
181
- const cubeId = (roleMatch?.[1] ?? match?.[1])!;
213
+ const cubeId = (roleMatch?.[1] ?? droneMatch?.[1] ?? match?.[1])!;
182
214
  const roleId = roleMatch?.[2];
183
- if (!uuidPattern.test(cubeId) || (roleId !== undefined && !uuidPattern.test(roleId))) {
215
+ const droneId = droneMatch?.[2];
216
+ if (!uuidPattern.test(cubeId) || (roleId !== undefined && !uuidPattern.test(roleId)) ||
217
+ (droneId !== undefined && !uuidPattern.test(droneId))) {
184
218
  return failure(404, "NOT_FOUND", "The requested resource was not found.");
185
219
  }
186
- const resource = roleMatch === null ? match?.[2] : "role";
220
+ const resource = roleMatch !== null ? "role" : droneMatch !== null ? "drone" : match?.[2];
187
221
  const sectionPatch = roleMatch?.[3] !== undefined;
188
222
  const store = this.#runtime.forPrincipal(authentication);
189
223
 
@@ -197,11 +231,47 @@ export class CoordinationApi {
197
231
  owner_id: cube.ownerId,
198
232
  name: cube.name,
199
233
  cube_directive: cube.directive,
234
+ message_taxonomy: cube.messageTaxonomy,
200
235
  created_at: cube.createdAt,
201
236
  updated_at: cube.updatedAt,
202
237
  },
203
238
  });
204
239
  }
240
+ if (resource === undefined && request.method === "PATCH") {
241
+ const envelope = decodeEnvelope(request.body);
242
+ exactKeys(envelope.payload, [], ["cube_directive", "message_taxonomy"]);
243
+ if (Object.keys(envelope.payload).length === 0) throw new InputError();
244
+ const directive = optionalText(envelope.payload, "cube_directive", 100_000);
245
+ const messageTaxonomy = optionalMessageTaxonomy(envelope.payload["message_taxonomy"]);
246
+ const cube = store.updateCube(cubeId, {
247
+ ...(directive === undefined ? {} : { directive }),
248
+ ...(messageTaxonomy === undefined ? {} : { messageTaxonomy }),
249
+ });
250
+ return success(200, envelope.requestId, { cube: cubePayload(cube) });
251
+ }
252
+ if (resource === "taxonomy-patch" && request.method === "POST") {
253
+ const envelope = decodeEnvelope(request.body);
254
+ const action = envelope.payload["action"];
255
+ const cube = store.getCube(cubeId);
256
+ if (cube === null) throw new ScopedStoreError();
257
+ if (action === "remove") {
258
+ exactKeys(envelope.payload, ["action", "class"]);
259
+ const messageTaxonomy = patchMessageTaxonomy(cube.messageTaxonomy, {
260
+ action,
261
+ className: requiredString(envelope.payload, "class", 64),
262
+ });
263
+ return success(200, envelope.requestId, {
264
+ cube: cubePayload(store.updateCube(cubeId, { messageTaxonomy })),
265
+ });
266
+ }
267
+ if (action !== "add" && action !== "replace") throw new InputError();
268
+ exactKeys(envelope.payload, ["action", "class_def"]);
269
+ const classDef = validateMessageTaxonomy([envelope.payload["class_def"]])![0]!;
270
+ const messageTaxonomy = patchMessageTaxonomy(cube.messageTaxonomy, { action, classDef });
271
+ return success(200, envelope.requestId, {
272
+ cube: cubePayload(store.updateCube(cubeId, { messageTaxonomy })),
273
+ });
274
+ }
205
275
  if (resource === "roles" && request.method === "GET") {
206
276
  return success(200, "roles-read", { roles: store.listRoles(cubeId) });
207
277
  }
@@ -215,6 +285,7 @@ export class CoordinationApi {
215
285
  "is_human_seat",
216
286
  "can_broadcast",
217
287
  "receives_all_direct",
288
+ "role_class",
218
289
  ]);
219
290
  const role = store.createRole(cubeId, {
220
291
  name: requiredRoleName(envelope.payload, "name"),
@@ -225,6 +296,9 @@ export class CoordinationApi {
225
296
  isHumanSeat: optionalBoolean(envelope.payload["is_human_seat"]),
226
297
  canBroadcast: optionalBoolean(envelope.payload["can_broadcast"]),
227
298
  receivesAllDirect: optionalBoolean(envelope.payload["receives_all_direct"]),
299
+ ...(envelope.payload["role_class"] === undefined
300
+ ? {}
301
+ : { roleClass: optionalRoleClass(envelope.payload["role_class"])! }),
228
302
  });
229
303
  return success(201, envelope.requestId, { role });
230
304
  }
@@ -239,6 +313,7 @@ export class CoordinationApi {
239
313
  "is_human_seat",
240
314
  "can_broadcast",
241
315
  "receives_all_direct",
316
+ "role_class",
242
317
  ]);
243
318
  if (Object.keys(envelope.payload).length === 0) throw new InputError();
244
319
  const name = envelope.payload["name"] === undefined
@@ -251,6 +326,7 @@ export class CoordinationApi {
251
326
  const isHumanSeat = optionalBooleanValue(envelope.payload["is_human_seat"]);
252
327
  const canBroadcast = optionalBooleanValue(envelope.payload["can_broadcast"]);
253
328
  const receivesAllDirect = optionalBooleanValue(envelope.payload["receives_all_direct"]);
329
+ const roleClass = optionalRoleClass(envelope.payload["role_class"]);
254
330
  const role = store.updateRole(cubeId, roleId!, {
255
331
  ...(name === undefined ? {} : { name }),
256
332
  ...(shortDescription === undefined ? {} : { shortDescription }),
@@ -260,6 +336,7 @@ export class CoordinationApi {
260
336
  ...(isHumanSeat === undefined ? {} : { isHumanSeat }),
261
337
  ...(canBroadcast === undefined ? {} : { canBroadcast }),
262
338
  ...(receivesAllDirect === undefined ? {} : { receivesAllDirect }),
339
+ ...(roleClass === undefined ? {} : { roleClass }),
263
340
  });
264
341
  return success(200, envelope.requestId, { role });
265
342
  }
@@ -295,22 +372,47 @@ export class CoordinationApi {
295
372
  });
296
373
  }
297
374
  if (resource === "drones" && request.method === "GET") {
298
- return success(200, "drones-read", { drones: store.listDrones(cubeId) });
375
+ if (request.since === undefined) {
376
+ return success(200, "drones-read", { drones: store.listDrones(cubeId) });
377
+ }
378
+ const since = decodeSince(request.since);
379
+ return success(200, "drones-read", store.listDronesSince(cubeId, since));
380
+ }
381
+ if (resource === "drone" && request.method === "PATCH") {
382
+ const envelope = decodeReassignDroneRequestEnvelope(request.body);
383
+ const drone = store.reassignDrone(cubeId, droneId!, envelope.payload.role_id);
384
+ return success(200, envelope.request_id, { drone: managedDronePayload(drone) });
385
+ }
386
+ if (resource === "drone" && request.method === "DELETE") {
387
+ const envelope = decodeEvictDroneRequestEnvelope(request.body);
388
+ store.evictDrone(cubeId, droneId!);
389
+ return success(200, envelope.request_id, { drone_id: droneId!, evicted: true });
299
390
  }
300
391
  if (resource === "logs" && request.method === "POST") {
301
392
  const envelope = decodeEnvelope(request.body);
302
- exactKeys(envelope.payload, ["message"], ["visibility", "recipientDroneIds"]);
393
+ exactKeys(envelope.payload, ["message"], [
394
+ "visibility", "recipientDroneIds", "class", "to",
395
+ ]);
303
396
  const message = requiredString(envelope.payload, "message", 10_240);
304
397
  const visibility = optionalVisibility(envelope.payload["visibility"]);
305
398
  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, {
399
+ const className = optionalString(envelope.payload, "class", 64);
400
+ const to = optionalStringArray(envelope.payload["to"]);
401
+ const cube = store.getCube(cubeId);
402
+ if (cube === null) throw new ScopedStoreError();
403
+ const resolved = resolveMessageRouting({
311
404
  message,
312
405
  ...(visibility === undefined ? {} : { visibility }),
313
406
  ...(recipientDroneIds === undefined ? {} : { recipientDroneIds }),
407
+ ...(className === undefined ? {} : { className }),
408
+ ...(to === undefined ? {} : { to }),
409
+ }, cube.messageTaxonomy, store.listRoles(cubeId), store.listDrones(cubeId));
410
+ const entry = store.appendLog(cubeId, {
411
+ message,
412
+ visibility: resolved.visibility,
413
+ ...(resolved.visibility === "direct"
414
+ ? { recipientDroneIds: resolved.recipientDroneIds }
415
+ : {}),
314
416
  });
315
417
  this.#debugLogger.emit({
316
418
  event: "activity_append",
@@ -382,6 +484,9 @@ export class CoordinationApi {
382
484
  if (error instanceof CursorExpiredError) {
383
485
  return failure(410, "CURSOR_EXPIRED", error.message);
384
486
  }
487
+ if (error instanceof AccessDeniedError) {
488
+ return failure(403, ErrorCode.ACCESS_DENIED, error.message);
489
+ }
385
490
  if (error instanceof ScopedStoreError) {
386
491
  return failure(404, "NOT_FOUND", error.message);
387
492
  }
@@ -394,9 +499,17 @@ export class CoordinationApi {
394
499
  if (error instanceof RoleSectionConflictError) {
395
500
  return failure(409, error.code, error.message, safeRequestId(request.body));
396
501
  }
502
+ if (error instanceof RoleInUseError) {
503
+ return failure(409, error.code, error.message, safeRequestId(request.body));
504
+ }
397
505
  if (error instanceof StorageCapacityError) {
398
506
  return failure(507, "CAPACITY_EXCEEDED", error.message, safeRequestId(request.body));
399
507
  }
508
+ if (error instanceof ProtocolContractError) {
509
+ return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
510
+ ? failure(426, error.code, "Unsupported protocol version.", safeRequestId(request.body))
511
+ : failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
512
+ }
400
513
  if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
401
514
  return failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
402
515
  }
@@ -417,7 +530,9 @@ export class CoordinationApi {
417
530
  let unsubscribe = (): void => undefined;
418
531
  let subscribed = false;
419
532
  let deliveryCount = 0;
533
+ let heartbeat: NodeJS.Timeout | undefined;
420
534
  const queue = new AsyncStringQueue(() => {
535
+ if (heartbeat !== undefined) clearInterval(heartbeat);
421
536
  unsubscribe();
422
537
  session.release();
423
538
  if (subscribed) {
@@ -430,7 +545,9 @@ export class CoordinationApi {
430
545
  });
431
546
  }
432
547
  }, () => { deliveryCount += 1; });
433
- const pending: EnrichedActivityRecord[] = [];
548
+ let replayDirty = false;
549
+ const pendingNotifications: ActivityStreamRecord[] = [];
550
+ let notificationIndex = 0;
434
551
  let live = false;
435
552
  try {
436
553
  unsubscribe = store.subscribeActivity(cubeId, (entry) => {
@@ -439,21 +556,13 @@ export class CoordinationApi {
439
556
  return;
440
557
  }
441
558
  if (live) queue.push(encodeLogEvent(entry));
442
- else if (pending.length >= 200) queue.close();
443
- else pending.push(entry);
559
+ else if ("kind" in entry) pendingNotifications.push(entry);
560
+ else replayDirty = true;
444
561
  });
562
+ subscribed = true;
445
563
  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
- });
564
+ const firstPage = store.readLog(cubeId, cursor, 200);
565
+ this.#logReplayPage(cubeId, cursor, firstPage);
457
566
  const barrier = this.#replayBarrier;
458
567
  this.#replayBarrier = undefined;
459
568
  if (barrier !== undefined) {
@@ -461,28 +570,61 @@ export class CoordinationApi {
461
570
  barrier.markReached();
462
571
  await barrier.release;
463
572
  }
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));
573
+ void (async () => {
574
+ let page = firstPage;
575
+ let replayCursor = cursor;
576
+ let replayCount = 0;
577
+ try {
578
+ for (;;) {
579
+ for (const entry of page.entries) {
580
+ if (!await queue.write(encodeLogEvent(entry))) return;
581
+ replayCursor = { id: entry.id, created_at: entry.created_at };
582
+ replayCount += 1;
583
+ }
584
+ if (page.has_more || replayDirty) {
585
+ replayDirty = false;
586
+ page = store.readLog(cubeId, replayCursor, 200);
587
+ this.#logReplayPage(cubeId, replayCursor, page);
588
+ continue;
589
+ }
590
+
591
+ while (notificationIndex < pendingNotifications.length) {
592
+ if (!await queue.write(encodeLogEvent(pendingNotifications[notificationIndex]!))) return;
593
+ notificationIndex += 1;
594
+ }
595
+
596
+ // Reserve room for the bookmark before making live callbacks visible.
597
+ // Any append while waiting marks replay dirty and is fetched first.
598
+ if (!await queue.waitForSpace()) return;
599
+ if (replayDirty || notificationIndex < pendingNotifications.length) {
600
+ replayDirty = false;
601
+ page = store.readLog(cubeId, replayCursor, 200);
602
+ this.#logReplayPage(cubeId, replayCursor, page);
603
+ continue;
604
+ }
605
+ queue.push(`event: bookmark\ndata: ${JSON.stringify({
606
+ as_of: new Date().toISOString(),
607
+ replay_complete: true,
608
+ })}\n\n`);
609
+ live = true;
610
+ heartbeat = setInterval(() => {
611
+ queue.tryPush(encodeHeartbeat());
612
+ }, this.#streamHeartbeatMs);
613
+ heartbeat.unref();
614
+ this.#debugLogger.emit({
615
+ event: "sse_subscribe",
616
+ connectionId,
617
+ cubeId,
618
+ principal,
619
+ replayCount,
620
+ truncated: false,
621
+ });
622
+ return;
623
+ }
624
+ } catch {
625
+ queue.close();
470
626
  }
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
- });
627
+ })();
486
628
  return { status: 200, stream: queue };
487
629
  } catch (error) {
488
630
  queue.close();
@@ -491,6 +633,22 @@ export class CoordinationApi {
491
633
  throw error;
492
634
  }
493
635
  }
636
+
637
+ #logReplayPage(
638
+ cubeId: string,
639
+ cursor: LogCursor | null,
640
+ page: ActivityPage,
641
+ ): void {
642
+ this.#debugLogger.emit({
643
+ event: "cursor_replay",
644
+ mode: "sse",
645
+ cubeId,
646
+ cursorId: cursor?.id ?? null,
647
+ returnedCount: page.entries.length,
648
+ behindBy: page.behind_by,
649
+ truncated: page.has_more,
650
+ });
651
+ }
494
652
  }
495
653
 
496
654
  class InputError extends Error {}
@@ -498,6 +656,7 @@ class InputError extends Error {}
498
656
  class AsyncStringQueue implements AsyncIterable<string> {
499
657
  readonly #values: string[] = [];
500
658
  readonly #waiters: Array<(result: IteratorResult<string>) => void> = [];
659
+ readonly #spaceWaiters: Array<(available: boolean) => void> = [];
501
660
  readonly #cleanup: () => void;
502
661
  readonly #onDelivered: () => void;
503
662
  #closed = false;
@@ -513,12 +672,28 @@ class AsyncStringQueue implements AsyncIterable<string> {
513
672
  this.close();
514
673
  return;
515
674
  }
516
- const waiter = this.#waiters.shift();
517
- if (waiter === undefined) this.#values.push(value);
518
- else {
519
- this.#onDelivered();
520
- waiter({ value, done: false });
675
+ this.#enqueue(value);
676
+ }
677
+
678
+ tryPush(value: string): boolean {
679
+ if (this.#closed || this.#values.length >= 200) return false;
680
+ this.#enqueue(value);
681
+ return true;
682
+ }
683
+
684
+ async write(value: string): Promise<boolean> {
685
+ while (!this.#closed && this.#values.length >= 200) {
686
+ if (!await this.waitForSpace()) return false;
521
687
  }
688
+ if (this.#closed) return false;
689
+ this.#enqueue(value);
690
+ return true;
691
+ }
692
+
693
+ waitForSpace(): Promise<boolean> {
694
+ if (this.#closed) return Promise.resolve(false);
695
+ if (this.#values.length < 200) return Promise.resolve(true);
696
+ return new Promise((resolve) => this.#spaceWaiters.push(resolve));
522
697
  }
523
698
 
524
699
  close(): void {
@@ -526,6 +701,7 @@ class AsyncStringQueue implements AsyncIterable<string> {
526
701
  this.#closed = true;
527
702
  this.#cleanup();
528
703
  for (const waiter of this.#waiters.splice(0)) waiter({ value: undefined, done: true });
704
+ for (const waiter of this.#spaceWaiters.splice(0)) waiter(false);
529
705
  }
530
706
 
531
707
  [Symbol.asyncIterator](): AsyncIterator<string> {
@@ -533,6 +709,7 @@ class AsyncStringQueue implements AsyncIterable<string> {
533
709
  next: async () => {
534
710
  const value = this.#values.shift();
535
711
  if (value !== undefined) {
712
+ this.#spaceWaiters.shift()?.(true);
536
713
  this.#onDelivered();
537
714
  return { value, done: false };
538
715
  }
@@ -545,17 +722,20 @@ class AsyncStringQueue implements AsyncIterable<string> {
545
722
  },
546
723
  };
547
724
  }
725
+
726
+ #enqueue(value: string): void {
727
+ const waiter = this.#waiters.shift();
728
+ if (waiter === undefined) this.#values.push(value);
729
+ else {
730
+ this.#onDelivered();
731
+ waiter({ value, done: false });
732
+ }
733
+ }
548
734
  }
549
735
 
550
736
  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"]) };
737
+ const envelope = decodeProtocolEnvelope(value, object);
738
+ return { requestId: envelope.request_id, payload: envelope.payload };
559
739
  }
560
740
 
561
741
  function object(value: unknown): Record<string, unknown> {
@@ -622,6 +802,27 @@ function optionalBooleanValue(value: unknown): boolean | undefined {
622
802
  return value;
623
803
  }
624
804
 
805
+ function optionalRoleClass(value: unknown): "queen" | "worker" | undefined {
806
+ if (value === undefined) return undefined;
807
+ if (value !== "queen" && value !== "worker") throw new InputError();
808
+ return value;
809
+ }
810
+
811
+ function optionalMessageTaxonomy(value: unknown) {
812
+ return value === undefined ? undefined : validateMessageTaxonomy(value);
813
+ }
814
+
815
+ function optionalStringArray(value: unknown): string[] | undefined {
816
+ if (value === undefined) return undefined;
817
+ if (!Array.isArray(value) || value.length < 1 || value.length > 100 ||
818
+ value.some((entry) => typeof entry !== "string" || entry.length < 1 || entry.length > 120)) {
819
+ throw new InputError();
820
+ }
821
+ const entries = value as string[];
822
+ if (new Set(entries).size !== entries.length) throw new InputError();
823
+ return entries;
824
+ }
825
+
625
826
  function requiredSectionHeading(record: Record<string, unknown>, key: string): string {
626
827
  const value = requiredString(record, key, 60);
627
828
  const heading = value.trim();
@@ -682,6 +883,12 @@ function decodeOpaqueCursor(value: string | undefined): LogCursor | null {
682
883
  }
683
884
  }
684
885
 
886
+ function decodeSince(value: string): string {
887
+ if (uuidPattern.test(value)) return value.toLowerCase();
888
+ if (timestampPattern.test(value) && new Date(value).toISOString() === value) return value;
889
+ throw new InputError();
890
+ }
891
+
685
892
  function safeRequestId(value: unknown): string | undefined {
686
893
  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
687
894
  const requestId = (value as Record<string, unknown>)["request_id"];
@@ -691,7 +898,7 @@ function safeRequestId(value: unknown): string | undefined {
691
898
  }
692
899
 
693
900
  function success(status: number, requestId: string, payload: unknown): CoordinationResponse {
694
- return { status, body: { protocol_version: "1", request_id: requestId, payload } };
901
+ return { status, body: createProtocolEnvelope(requestId, payload) };
695
902
  }
696
903
 
697
904
  function failure(
@@ -703,27 +910,46 @@ function failure(
703
910
  return {
704
911
  status,
705
912
  body: {
706
- protocol_version: "1",
913
+ protocol_version: PROTOCOL_VERSION,
707
914
  ...(requestId === undefined ? {} : { request_id: requestId }),
708
915
  error: { code, message },
709
916
  },
710
917
  };
711
918
  }
712
919
 
713
- function encodeLogEvent(entry: EnrichedActivityRecord): string {
920
+ function encodeLogEvent(entry: ActivityStreamRecord): string {
921
+ if ("kind" in entry) {
922
+ return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({ entry })}\n\n`;
923
+ }
714
924
  return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({
715
925
  cursor: { id: entry.id, created_at: entry.created_at },
716
926
  entry,
717
927
  })}\n\n`;
718
928
  }
719
929
 
720
- function cursorKey(entry: EnrichedActivityRecord): string {
721
- return `${entry.created_at}\0${entry.id}`;
930
+ function encodeHeartbeat(): string {
931
+ return `event: heartbeat\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`;
722
932
  }
723
933
 
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);
934
+ function cubePayload(cube: CubeRecord) {
935
+ return {
936
+ id: cube.id,
937
+ owner_id: cube.ownerId,
938
+ name: cube.name,
939
+ cube_directive: cube.directive,
940
+ message_taxonomy: cube.messageTaxonomy,
941
+ created_at: cube.createdAt,
942
+ updated_at: cube.updatedAt,
943
+ };
944
+ }
945
+
946
+ function managedDronePayload(drone: DroneRecord) {
947
+ return {
948
+ id: drone.id,
949
+ cube_id: drone.cube_id,
950
+ role_id: drone.role_id,
951
+ label: drone.label,
952
+ };
727
953
  }
728
954
 
729
955
  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;