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,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 { CursorExpiredError, AttachConflictError, AccessDeniedError, CreateCubeConflictError, DefaultRoleRequiredError, RoleConflictError, RoleSectionConflictError, ScopedStoreError, StorageCapacityError, } from "./store.js";
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 === "/api/client/attach" && request.method === "POST") {
35
+ if (request.path === ATTACH_PATH && request.method === "POST") {
32
36
  const requestId = safeRequestId(request.body);
33
37
  try {
34
- const envelope = decodeEnvelope(request.body);
35
- exactKeys(envelope.payload, ["cube_id", "role_id", "retry_key"], ["prior_drone_id"]);
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: requiredUuid(envelope.payload, "cube_id"),
41
- roleId: requiredUuid(envelope.payload, "role_id"),
42
- retryKey: requiredUuid(envelope.payload, "retry_key"),
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.requestId, {
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
- token: attachment.credential,
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 AttachConflictError) {
60
- return failure(409, "INVALID_INPUT", "The attach request conflicts.", requestId);
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 match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream))?$/u
133
+ const droneMatch = /^\/api\/cubes\/([0-9a-f-]{36})\/drones\/([0-9a-f-]{36})$/u
124
134
  .exec(request.path);
125
- if (match === null && roleMatch === null) {
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
- if (!uuidPattern.test(cubeId) || (roleId !== undefined && !uuidPattern.test(roleId))) {
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 === null ? match?.[2] : "role";
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
- return success(200, "drones-read", { drones: store.listDrones(cubeId) });
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"], ["visibility", "recipientDroneIds"]);
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
- if (((visibility ?? "broadcast") === "broadcast" && recipientDroneIds !== undefined) ||
257
- (visibility === "direct" && (recipientDroneIds?.length ?? 0) === 0)) {
258
- throw new InputError();
259
- }
260
- const entry = store.appendLog(cubeId, {
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
- const pending = [];
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 (pending.length >= 200)
390
- queue.close();
491
+ else if ("kind" in entry)
492
+ pendingNotifications.push(entry);
391
493
  else
392
- pending.push(entry);
494
+ replayDirty = true;
393
495
  });
496
+ subscribed = true;
394
497
  signal.addEventListener("abort", () => queue.close(), { once: true });
395
- const replayPage = store.readLog(cubeId, cursor, 200);
396
- const replay = replayPage.entries;
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
- const seen = new Set(replay.map(cursorKey));
414
- for (const entry of replay)
415
- queue.push(encodeLogEvent(entry));
416
- for (const entry of pending) {
417
- if (!seen.has(cursorKey(entry)) && afterCursor(entry, cursor)) {
418
- seen.add(cursorKey(entry));
419
- queue.push(encodeLogEvent(entry));
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
- queue.push(`event: bookmark\ndata: ${JSON.stringify({
423
- as_of: new Date().toISOString(),
424
- replay_complete: true,
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
- const waiter = this.#waiters.shift();
468
- if (waiter === undefined)
469
- this.#values.push(value);
470
- else {
471
- this.#onDelivered();
472
- waiter({ value, done: false });
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 record = object(value);
504
- exactKeys(record, ["protocol_version", "request_id", "payload"]);
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: { protocol_version: "1", request_id: requestId, payload } };
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: "1",
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 cursorKey(entry) {
663
- return `${entry.created_at}\0${entry.id}`;
856
+ function encodeHeartbeat() {
857
+ return `event: heartbeat\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`;
664
858
  }
665
- function afterCursor(entry, cursor) {
666
- return cursor === null || entry.created_at > cursor.created_at ||
667
- (entry.created_at === cursor.created_at && entry.id > cursor.id);
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;