borgmcp-server 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -11
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/dist/coordination-api.d.ts +2 -1
- package/dist/coordination-api.js +298 -89
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +4 -6
- package/dist/credentials.js +43 -19
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +2 -3
- package/dist/debug-log.js +2 -3
- package/dist/debug-log.js.map +1 -1
- package/dist/enrollment.d.ts +3 -11
- package/dist/enrollment.js +21 -60
- package/dist/enrollment.js.map +1 -1
- package/dist/https-server.d.ts +2 -20
- package/dist/https-server.js +33 -51
- package/dist/https-server.js.map +1 -1
- package/dist/message-taxonomy.d.ts +38 -0
- package/dist/message-taxonomy.js +218 -0
- package/dist/message-taxonomy.js.map +1 -0
- package/dist/migrations.js +28 -0
- package/dist/migrations.js.map +1 -1
- package/dist/service.d.ts +4 -1
- package/dist/service.js +25 -10
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +66 -8
- package/dist/store.js +525 -100
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -7
- package/package.json +2 -2
- package/src/coordination-api.ts +312 -86
- package/src/credentials.ts +44 -26
- package/src/debug-log.ts +5 -5
- package/src/enrollment.ts +32 -78
- package/src/https-server.ts +44 -82
- package/src/message-taxonomy.ts +284 -0
- package/src/migrations.ts +28 -0
- package/src/service.ts +29 -9
- package/src/store.ts +593 -121
- package/dist/protocol-draft.d.ts +0 -2
- package/dist/protocol-draft.js +0 -31
- package/dist/protocol-draft.js.map +0 -1
- package/src/protocol-draft.ts +0 -32
package/src/coordination-api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 ===
|
|
105
|
+
if (request.path === ATTACH_PATH && request.method === "POST") {
|
|
82
106
|
const requestId = safeRequestId(request.body);
|
|
83
107
|
try {
|
|
84
|
-
const envelope =
|
|
85
|
-
|
|
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:
|
|
93
|
-
roleId:
|
|
94
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
112
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"], [
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
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
|
-
|
|
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 (
|
|
443
|
-
else
|
|
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
|
|
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
|
-
});
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
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"]) };
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
721
|
-
return
|
|
930
|
+
function encodeHeartbeat(): string {
|
|
931
|
+
return `event: heartbeat\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`;
|
|
722
932
|
}
|
|
723
933
|
|
|
724
|
-
function
|
|
725
|
-
return
|
|
726
|
-
|
|
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;
|