borgmcp-server 0.1.5 → 0.1.8
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 -1
- package/dist/coordination-api.js +40 -6
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +4 -1
- package/dist/credentials.js +28 -3
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +1 -1
- package/dist/debug-log.js +1 -1
- package/dist/debug-log.js.map +1 -1
- package/dist/https-server.d.ts +2 -1
- package/dist/https-server.js +68 -20
- package/dist/https-server.js.map +1 -1
- package/dist/migrations.js +23 -0
- package/dist/migrations.js.map +1 -1
- package/dist/store.d.ts +39 -7
- package/dist/store.js +230 -83
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
- package/src/coordination-api.ts +46 -4
- package/src/credentials.ts +29 -5
- package/src/debug-log.ts +2 -2
- package/src/https-server.ts +97 -20
- package/src/migrations.ts +23 -0
- package/src/store.ts +251 -83
package/src/store.ts
CHANGED
|
@@ -65,7 +65,6 @@ export type ActivityNotificationRecord = EnrichedActivityRecord & (
|
|
|
65
65
|
readonly claimant_drone_id?: string;
|
|
66
66
|
readonly claimant_role?: string | null;
|
|
67
67
|
}
|
|
68
|
-
| { readonly kind: "heartbeat_ping" }
|
|
69
68
|
);
|
|
70
69
|
|
|
71
70
|
export type ActivityStreamRecord = EnrichedActivityRecord | ActivityNotificationRecord;
|
|
@@ -180,11 +179,7 @@ export interface StoreRuntime {
|
|
|
180
179
|
}
|
|
181
180
|
|
|
182
181
|
export interface LivenessStore {
|
|
183
|
-
readonly scan: (
|
|
184
|
-
readonly silentMs?: number;
|
|
185
|
-
readonly cooldownMs?: number;
|
|
186
|
-
readonly limit?: number;
|
|
187
|
-
}) => ActivityNotificationRecord[];
|
|
182
|
+
readonly scan: () => ActivityNotificationRecord[];
|
|
188
183
|
}
|
|
189
184
|
|
|
190
185
|
export interface DigestPair {
|
|
@@ -237,6 +232,7 @@ export interface StoredDroneSessionDigest extends StoredSecretDigest {
|
|
|
237
232
|
readonly droneId: string;
|
|
238
233
|
readonly expiresAt: string;
|
|
239
234
|
readonly evictedAt: string | null;
|
|
235
|
+
readonly takenOver: boolean;
|
|
240
236
|
}
|
|
241
237
|
|
|
242
238
|
export interface CredentialStore {
|
|
@@ -289,6 +285,8 @@ export interface ScopedStore {
|
|
|
289
285
|
readonly drones: DroneRecord[];
|
|
290
286
|
readonly since: string;
|
|
291
287
|
};
|
|
288
|
+
readonly reassignDrone: (cubeId: string, droneId: string, roleId: string) => DroneRecord;
|
|
289
|
+
readonly evictDrone: (cubeId: string, droneId: string) => void;
|
|
292
290
|
readonly appendLog: (cubeId: string, input: {
|
|
293
291
|
readonly message: string;
|
|
294
292
|
readonly visibility?: "broadcast" | "direct";
|
|
@@ -360,6 +358,7 @@ export interface SeatAttachInput {
|
|
|
360
358
|
readonly credentialId: string;
|
|
361
359
|
readonly credentialDigest: DigestPair;
|
|
362
360
|
readonly expiresAt: string;
|
|
361
|
+
readonly renewIfExpiresAtOrBefore: string;
|
|
363
362
|
}
|
|
364
363
|
|
|
365
364
|
export interface SeatAttachRecord {
|
|
@@ -441,6 +440,24 @@ export interface MaintenanceStore {
|
|
|
441
440
|
}) => void;
|
|
442
441
|
readonly revokeClient: (clientId: string) => void;
|
|
443
442
|
readonly revokeDroneSession: (sessionId: string) => void;
|
|
443
|
+
readonly expireDroneSession: (sessionId: string) => void;
|
|
444
|
+
readonly inspectManagedDrone: (droneId: string) => {
|
|
445
|
+
readonly role_id: string;
|
|
446
|
+
readonly evicted: boolean;
|
|
447
|
+
readonly session_revoked: boolean;
|
|
448
|
+
};
|
|
449
|
+
readonly inspectCubeManagementState: (cubeId: string) => {
|
|
450
|
+
readonly directive: string;
|
|
451
|
+
readonly taxonomy_marker: string | null;
|
|
452
|
+
readonly role_ids: readonly string[];
|
|
453
|
+
readonly active_decision_ids: readonly string[];
|
|
454
|
+
readonly drones: ReadonlyArray<{
|
|
455
|
+
readonly id: string;
|
|
456
|
+
readonly role_id: string;
|
|
457
|
+
readonly evicted: boolean;
|
|
458
|
+
readonly session_revoked: boolean;
|
|
459
|
+
}>;
|
|
460
|
+
};
|
|
444
461
|
readonly expireActivityCursor: (cubeId: string, cursor: LogCursor) => void;
|
|
445
462
|
}
|
|
446
463
|
|
|
@@ -480,6 +497,15 @@ export class RoleSectionConflictError extends Error {
|
|
|
480
497
|
}
|
|
481
498
|
}
|
|
482
499
|
|
|
500
|
+
export class RoleInUseError extends Error {
|
|
501
|
+
readonly code = "ROLE_IN_USE";
|
|
502
|
+
|
|
503
|
+
constructor() {
|
|
504
|
+
super("The human-seat role is already occupied.");
|
|
505
|
+
this.name = "RoleInUseError";
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
483
509
|
export class CursorExpiredError extends Error {
|
|
484
510
|
readonly code = "CURSOR_EXPIRED";
|
|
485
511
|
|
|
@@ -498,6 +524,33 @@ export class AttachSessionRejectedError extends Error {
|
|
|
498
524
|
}
|
|
499
525
|
}
|
|
500
526
|
|
|
527
|
+
export class AttachSessionExpiredError extends Error {
|
|
528
|
+
readonly code = "AUTH_EXPIRED";
|
|
529
|
+
|
|
530
|
+
constructor() {
|
|
531
|
+
super("Authentication failed.");
|
|
532
|
+
this.name = "AttachSessionExpiredError";
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export class AttachSessionRevokedError extends Error {
|
|
537
|
+
readonly code = "SESSION_REVOKED";
|
|
538
|
+
|
|
539
|
+
constructor() {
|
|
540
|
+
super("Authentication failed.");
|
|
541
|
+
this.name = "AttachSessionRevokedError";
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export class AttachDroneEvictedError extends Error {
|
|
546
|
+
readonly code = "DRONE_EVICTED";
|
|
547
|
+
|
|
548
|
+
constructor() {
|
|
549
|
+
super("Authentication failed.");
|
|
550
|
+
this.name = "AttachDroneEvictedError";
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
501
554
|
export class StorageCapacityError extends Error {
|
|
502
555
|
readonly code = "CAPACITY_EXCEEDED";
|
|
503
556
|
|
|
@@ -616,7 +669,7 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
|
|
|
616
669
|
);
|
|
617
670
|
const activityHub = new ActivityHub();
|
|
618
671
|
const maintenance = new SqliteMaintenanceStore(database, clock);
|
|
619
|
-
const liveness = new
|
|
672
|
+
const liveness = new IdleLivenessStore();
|
|
620
673
|
const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
|
|
621
674
|
return Object.freeze({
|
|
622
675
|
forPrincipal: (principal: Principal) => {
|
|
@@ -1125,6 +1178,85 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1125
1178
|
};
|
|
1126
1179
|
}
|
|
1127
1180
|
|
|
1181
|
+
reassignDrone(cubeId: string, droneId: string, roleId: string): DroneRecord {
|
|
1182
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
1183
|
+
assertCanonicalUuid(droneId, "Drone id");
|
|
1184
|
+
assertCanonicalUuid(roleId, "Role id");
|
|
1185
|
+
this.#requireCube(cubeId, "manage");
|
|
1186
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1187
|
+
try {
|
|
1188
|
+
this.#requireCube(cubeId, "manage");
|
|
1189
|
+
const droneRow = this.#database.prepare(`
|
|
1190
|
+
SELECT id, cube_id, role_id, label FROM drones
|
|
1191
|
+
WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1192
|
+
`).get(droneId, cubeId);
|
|
1193
|
+
const targetRoleRow = this.#database.prepare(`
|
|
1194
|
+
SELECT id, is_human_seat, role_class FROM roles WHERE id = ? AND cube_id = ?
|
|
1195
|
+
`).get(roleId, cubeId);
|
|
1196
|
+
if (droneRow === undefined || targetRoleRow === undefined) throw new ScopedStoreError();
|
|
1197
|
+
const sourceRoleRow = this.#database.prepare(`
|
|
1198
|
+
SELECT is_human_seat FROM roles WHERE id = ? AND cube_id = ?
|
|
1199
|
+
`).get(requiredText(droneRow, "role_id"), cubeId);
|
|
1200
|
+
if (sourceRoleRow === undefined) throw new ScopedStoreError();
|
|
1201
|
+
if (requiredText(targetRoleRow, "role_class") === "queen" &&
|
|
1202
|
+
requiredInteger(sourceRoleRow, "is_human_seat") !== 1) {
|
|
1203
|
+
throw new AccessDeniedError();
|
|
1204
|
+
}
|
|
1205
|
+
if (requiredInteger(targetRoleRow, "is_human_seat") === 1) {
|
|
1206
|
+
const occupied = this.#database.prepare(`
|
|
1207
|
+
SELECT 1 AS present FROM drones
|
|
1208
|
+
WHERE cube_id = ? AND role_id = ? AND id <> ? AND evicted_at IS NULL
|
|
1209
|
+
`).get(cubeId, roleId, droneId);
|
|
1210
|
+
if (occupied !== undefined) throw new RoleInUseError();
|
|
1211
|
+
}
|
|
1212
|
+
this.#database.prepare("UPDATE drones SET role_id = ? WHERE id = ? AND cube_id = ?")
|
|
1213
|
+
.run(roleId, droneId, cubeId);
|
|
1214
|
+
const row = this.#database.prepare(`
|
|
1215
|
+
SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
|
|
1216
|
+
COALESCE(drone.last_seen, drone.created_at) AS last_seen,
|
|
1217
|
+
drone.hostname, drone.created_at,
|
|
1218
|
+
CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
1219
|
+
THEN 'participant' ELSE 'observer' END AS posture
|
|
1220
|
+
FROM drones AS drone
|
|
1221
|
+
LEFT JOIN clients AS client ON client.id = drone.client_id
|
|
1222
|
+
LEFT JOIN client_cube_grants AS grant_row
|
|
1223
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1224
|
+
WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1225
|
+
`).get(droneId, cubeId);
|
|
1226
|
+
if (row === undefined) throw new ScopedStoreError();
|
|
1227
|
+
this.#database.exec("COMMIT");
|
|
1228
|
+
return droneRecord(row);
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
1231
|
+
throw error;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
evictDrone(cubeId: string, droneId: string): void {
|
|
1236
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
1237
|
+
assertCanonicalUuid(droneId, "Drone id");
|
|
1238
|
+
this.#requireCube(cubeId, "manage");
|
|
1239
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1240
|
+
try {
|
|
1241
|
+
this.#requireCube(cubeId, "manage");
|
|
1242
|
+
const active = this.#database.prepare(`
|
|
1243
|
+
SELECT 1 AS present FROM drones WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1244
|
+
`).get(droneId, cubeId);
|
|
1245
|
+
if (active === undefined) throw new ScopedStoreError();
|
|
1246
|
+
const now = this.#now();
|
|
1247
|
+
this.#database.prepare("UPDATE drones SET evicted_at = ? WHERE id = ? AND cube_id = ?")
|
|
1248
|
+
.run(now, droneId, cubeId);
|
|
1249
|
+
this.#database.prepare(`
|
|
1250
|
+
UPDATE drone_sessions SET revoked_at = ?
|
|
1251
|
+
WHERE drone_id = ? AND cube_id = ? AND revoked_at IS NULL
|
|
1252
|
+
`).run(now, droneId, cubeId);
|
|
1253
|
+
this.#database.exec("COMMIT");
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
1256
|
+
throw error;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1128
1260
|
appendLog(cubeId: string, input: {
|
|
1129
1261
|
readonly message: string;
|
|
1130
1262
|
readonly visibility?: "broadcast" | "direct";
|
|
@@ -1175,7 +1307,6 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1175
1307
|
this.#database.prepare(`
|
|
1176
1308
|
UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1177
1309
|
`).run(createdAt, droneId, cubeId);
|
|
1178
|
-
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1179
1310
|
}
|
|
1180
1311
|
if (recipients.length > 0) {
|
|
1181
1312
|
const valid = this.#database.prepare(`
|
|
@@ -1362,6 +1493,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1362
1493
|
assertCanonicalUuid(input.credentialId, "Drone session credential id");
|
|
1363
1494
|
validateDigest(input.credentialDigest);
|
|
1364
1495
|
validateTimestamp(input.expiresAt);
|
|
1496
|
+
validateTimestamp(input.renewIfExpiresAtOrBefore);
|
|
1365
1497
|
const scope = this.#scope("read");
|
|
1366
1498
|
const authorizedRole = this.#database.prepare(`
|
|
1367
1499
|
SELECT 1 FROM cubes AS c JOIN roles AS role ON role.cube_id = c.id
|
|
@@ -1384,7 +1516,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1384
1516
|
SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
|
|
1385
1517
|
session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
|
|
1386
1518
|
session.expires_at, session.revoked_at AS session_revoked_at,
|
|
1387
|
-
drone.role_id, drone.label, drone.evicted_at
|
|
1519
|
+
session.superseded_at, drone.role_id, drone.label, drone.evicted_at
|
|
1388
1520
|
FROM drone_session_credentials AS credential
|
|
1389
1521
|
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
1390
1522
|
JOIN drones AS drone ON drone.id = session.drone_id
|
|
@@ -1394,10 +1526,6 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1394
1526
|
if (bound !== undefined) {
|
|
1395
1527
|
const verifier = requiredBuffer(bound, "verifier_digest");
|
|
1396
1528
|
if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
|
|
1397
|
-
optionalText(bound, "credential_revoked_at") !== null ||
|
|
1398
|
-
optionalText(bound, "session_revoked_at") !== null ||
|
|
1399
|
-
optionalText(bound, "evicted_at") !== null ||
|
|
1400
|
-
requiredText(bound, "expires_at") <= now ||
|
|
1401
1529
|
requiredText(bound, "client_id") !== this.#principal.id ||
|
|
1402
1530
|
requiredText(bound, "cube_id") !== input.cubeId ||
|
|
1403
1531
|
requiredText(bound, "role_id") !== input.roleId ||
|
|
@@ -1405,9 +1533,27 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1405
1533
|
requiredText(bound, "drone_id") !== input.priorDroneId)) {
|
|
1406
1534
|
throw new AttachSessionRejectedError();
|
|
1407
1535
|
}
|
|
1536
|
+
if (optionalText(bound, "evicted_at") !== null) throw new AttachDroneEvictedError();
|
|
1537
|
+
if (optionalText(bound, "credential_revoked_at") !== null ||
|
|
1538
|
+
optionalText(bound, "session_revoked_at") !== null) {
|
|
1539
|
+
throw new AttachSessionRevokedError();
|
|
1540
|
+
}
|
|
1541
|
+
if (optionalText(bound, "superseded_at") !== null) {
|
|
1542
|
+
throw new AttachSessionRejectedError();
|
|
1543
|
+
}
|
|
1544
|
+
if (requiredText(bound, "expires_at") <= now) throw new AttachSessionExpiredError();
|
|
1408
1545
|
const droneId = requiredText(bound, "drone_id");
|
|
1546
|
+
const sessionId = requiredText(bound, "session_id");
|
|
1547
|
+
const previousExpiry = requiredText(bound, "expires_at");
|
|
1548
|
+
const committedExpiry = input.expiresAt > previousExpiry &&
|
|
1549
|
+
previousExpiry <= input.renewIfExpiresAtOrBefore
|
|
1550
|
+
? input.expiresAt
|
|
1551
|
+
: previousExpiry;
|
|
1552
|
+
if (committedExpiry !== previousExpiry) {
|
|
1553
|
+
this.#database.prepare("UPDATE drone_sessions SET expires_at = ? WHERE id = ?")
|
|
1554
|
+
.run(committedExpiry, sessionId);
|
|
1555
|
+
}
|
|
1409
1556
|
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
1410
|
-
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1411
1557
|
const attachedRoleRow = this.#database.prepare(`
|
|
1412
1558
|
SELECT id AS role_id, name AS role_name, role_class, is_human_seat
|
|
1413
1559
|
FROM roles WHERE id = ? AND cube_id = ?
|
|
@@ -1430,33 +1576,53 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1430
1576
|
is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
|
|
1431
1577
|
},
|
|
1432
1578
|
drone: { id: droneId, label: requiredText(bound, "label") },
|
|
1433
|
-
sessionId
|
|
1434
|
-
expiresAt:
|
|
1579
|
+
sessionId,
|
|
1580
|
+
expiresAt: committedExpiry,
|
|
1435
1581
|
result: "reused",
|
|
1436
1582
|
};
|
|
1437
1583
|
}
|
|
1438
1584
|
|
|
1439
1585
|
const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
|
|
1440
|
-
SELECT id, role_id, label FROM drones
|
|
1441
|
-
WHERE id = ? AND client_id = ? AND cube_id = ?
|
|
1586
|
+
SELECT id, role_id, label, evicted_at FROM drones
|
|
1587
|
+
WHERE id = ? AND client_id = ? AND cube_id = ?
|
|
1442
1588
|
`).get(input.priorDroneId, this.#principal.id, input.cubeId);
|
|
1443
1589
|
let droneId: string;
|
|
1444
1590
|
let droneLabel: string;
|
|
1445
|
-
if (
|
|
1591
|
+
if (input.priorDroneId !== undefined) {
|
|
1592
|
+
if (priorSeat === undefined) throw new AttachSessionRejectedError();
|
|
1593
|
+
if (optionalText(priorSeat, "evicted_at") !== null) throw new AttachDroneEvictedError();
|
|
1446
1594
|
const priorSeatId = requiredText(priorSeat, "id");
|
|
1447
1595
|
if (requiredText(priorSeat, "role_id") !== input.roleId) {
|
|
1448
1596
|
throw new AttachSessionRejectedError();
|
|
1449
1597
|
}
|
|
1450
|
-
const
|
|
1451
|
-
SELECT
|
|
1598
|
+
const latestSession = this.#database.prepare(`
|
|
1599
|
+
SELECT session.id, session.expires_at, session.revoked_at AS session_revoked_at,
|
|
1600
|
+
MAX(CASE WHEN credential.revoked_at IS NOT NULL THEN 1 ELSE 0 END)
|
|
1601
|
+
AS credential_revoked
|
|
1602
|
+
FROM drone_sessions AS session
|
|
1452
1603
|
JOIN drone_session_credentials AS credential ON credential.session_id = session.id
|
|
1453
1604
|
WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
|
|
1454
|
-
AND session.
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1605
|
+
AND session.superseded_at IS NULL
|
|
1606
|
+
GROUP BY session.id
|
|
1607
|
+
LIMIT 1
|
|
1608
|
+
`).get(priorSeatId, this.#principal.id, input.cubeId);
|
|
1609
|
+
if (latestSession === undefined) throw new AttachSessionRejectedError();
|
|
1610
|
+
if (optionalText(latestSession, "session_revoked_at") !== null ||
|
|
1611
|
+
requiredInteger(latestSession, "credential_revoked") === 1) {
|
|
1612
|
+
throw new AttachSessionRevokedError();
|
|
1613
|
+
}
|
|
1614
|
+
if (requiredText(latestSession, "expires_at") > now) {
|
|
1615
|
+
throw new AttachSessionRejectedError();
|
|
1616
|
+
}
|
|
1458
1617
|
droneId = priorSeatId;
|
|
1459
1618
|
droneLabel = requiredText(priorSeat, "label");
|
|
1619
|
+
const superseded = this.#database.prepare(`
|
|
1620
|
+
UPDATE drone_sessions SET superseded_at = ?
|
|
1621
|
+
WHERE id = ? AND superseded_at IS NULL
|
|
1622
|
+
`).run(now, requiredText(latestSession, "id"));
|
|
1623
|
+
if (Number(superseded.changes) !== 1) {
|
|
1624
|
+
throw new Error("Expired session supersession was not atomic.");
|
|
1625
|
+
}
|
|
1460
1626
|
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
1461
1627
|
} else {
|
|
1462
1628
|
droneId = input.droneId;
|
|
@@ -1468,8 +1634,6 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1468
1634
|
droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now,
|
|
1469
1635
|
);
|
|
1470
1636
|
}
|
|
1471
|
-
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1472
|
-
|
|
1473
1637
|
this.#database.prepare(`
|
|
1474
1638
|
INSERT INTO drone_sessions (
|
|
1475
1639
|
id, client_id, cube_id, drone_id, created_at, expires_at
|
|
@@ -1990,6 +2154,59 @@ class SqliteMaintenanceStore implements MaintenanceStore {
|
|
|
1990
2154
|
).run(this.#now(), sessionId);
|
|
1991
2155
|
}
|
|
1992
2156
|
|
|
2157
|
+
expireDroneSession(sessionId: string): void {
|
|
2158
|
+
assertCanonicalUuid(sessionId, "Drone session id");
|
|
2159
|
+
this.#database.prepare("UPDATE drone_sessions SET expires_at = ? WHERE id = ?")
|
|
2160
|
+
.run(new Date(0).toISOString(), sessionId);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
inspectManagedDrone(droneId: string) {
|
|
2164
|
+
assertCanonicalUuid(droneId, "Drone id");
|
|
2165
|
+
const row = this.#database.prepare(`
|
|
2166
|
+
SELECT drone.role_id, drone.evicted_at,
|
|
2167
|
+
EXISTS(SELECT 1 FROM drone_sessions AS session
|
|
2168
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NOT NULL) AS session_revoked
|
|
2169
|
+
FROM drones AS drone WHERE drone.id = ?
|
|
2170
|
+
`).get(droneId);
|
|
2171
|
+
if (row === undefined) throw new ScopedStoreError();
|
|
2172
|
+
return {
|
|
2173
|
+
role_id: requiredText(row, "role_id"),
|
|
2174
|
+
evicted: nullableText(row, "evicted_at") !== null,
|
|
2175
|
+
session_revoked: requiredInteger(row, "session_revoked") === 1,
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
inspectCubeManagementState(cubeId: string) {
|
|
2180
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
2181
|
+
const cube = this.#database.prepare(
|
|
2182
|
+
"SELECT directive, message_taxonomy FROM cubes WHERE id = ?",
|
|
2183
|
+
).get(cubeId);
|
|
2184
|
+
if (cube === undefined) throw new ScopedStoreError();
|
|
2185
|
+
const taxonomy = nullableText(cube, "message_taxonomy");
|
|
2186
|
+
const parsed = taxonomy === null ? null : JSON.parse(taxonomy) as Array<{ class?: unknown }>;
|
|
2187
|
+
return {
|
|
2188
|
+
directive: requiredText(cube, "directive"),
|
|
2189
|
+
taxonomy_marker: typeof parsed?.[0]?.class === "string" ? parsed[0].class : null,
|
|
2190
|
+
role_ids: this.#database.prepare(
|
|
2191
|
+
"SELECT id FROM roles WHERE cube_id = ? ORDER BY id",
|
|
2192
|
+
).all(cubeId).map((row) => requiredText(row, "id")),
|
|
2193
|
+
active_decision_ids: this.#database.prepare(`
|
|
2194
|
+
SELECT id FROM decisions WHERE cube_id = ? AND status = 'active' ORDER BY id
|
|
2195
|
+
`).all(cubeId).map((row) => requiredText(row, "id")),
|
|
2196
|
+
drones: this.#database.prepare(`
|
|
2197
|
+
SELECT drone.id, drone.role_id, drone.evicted_at,
|
|
2198
|
+
EXISTS(SELECT 1 FROM drone_sessions AS session
|
|
2199
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NOT NULL) AS session_revoked
|
|
2200
|
+
FROM drones AS drone WHERE drone.cube_id = ? ORDER BY drone.id
|
|
2201
|
+
`).all(cubeId).map((row) => ({
|
|
2202
|
+
id: requiredText(row, "id"),
|
|
2203
|
+
role_id: requiredText(row, "role_id"),
|
|
2204
|
+
evicted: nullableText(row, "evicted_at") !== null,
|
|
2205
|
+
session_revoked: requiredInteger(row, "session_revoked") === 1,
|
|
2206
|
+
})),
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
|
|
1993
2210
|
expireActivityCursor(cubeId: string, cursor: LogCursor): void {
|
|
1994
2211
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
1995
2212
|
assertCanonicalUuid(cursor.id, "Activity cursor id");
|
|
@@ -2033,61 +2250,11 @@ class ActivityHub {
|
|
|
2033
2250
|
}
|
|
2034
2251
|
}
|
|
2035
2252
|
|
|
2036
|
-
class
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
readonly capacityGuard: StorageCapacityGuard,
|
|
2042
|
-
) {}
|
|
2043
|
-
|
|
2044
|
-
scan(options: { silentMs?: number; cooldownMs?: number; limit?: number } = {}): ActivityNotificationRecord[] {
|
|
2045
|
-
const now = this.clock();
|
|
2046
|
-
const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
|
|
2047
|
-
const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
|
|
2048
|
-
const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
|
|
2049
|
-
const candidates = this.database.prepare(`
|
|
2050
|
-
SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
|
|
2051
|
-
FROM drones AS drone
|
|
2052
|
-
JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
|
|
2053
|
-
JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
|
|
2054
|
-
AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
|
|
2055
|
-
LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
|
|
2056
|
-
WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
|
|
2057
|
-
AND COALESCE(state.attempts, 0) < 3
|
|
2058
|
-
AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
|
|
2059
|
-
AND EXISTS (SELECT 1 FROM drone_sessions AS session
|
|
2060
|
-
WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
|
|
2061
|
-
ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
|
|
2062
|
-
`).all(silentBefore, cooldownBefore, now.toISOString(), limit);
|
|
2063
|
-
const published: ActivityNotificationRecord[] = [];
|
|
2064
|
-
for (const candidate of candidates) {
|
|
2065
|
-
const droneId = requiredText(candidate, "drone_id");
|
|
2066
|
-
const cubeId = requiredText(candidate, "cube_id");
|
|
2067
|
-
const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
|
|
2068
|
-
this.capacityGuard.assertCanGrow(256);
|
|
2069
|
-
const id = randomUUID();
|
|
2070
|
-
const createdAt = now.toISOString();
|
|
2071
|
-
this.database.exec("BEGIN IMMEDIATE");
|
|
2072
|
-
try {
|
|
2073
|
-
this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
|
|
2074
|
-
VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
|
|
2075
|
-
SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
|
|
2076
|
-
`).run(droneId, now.toISOString());
|
|
2077
|
-
this.database.exec("COMMIT");
|
|
2078
|
-
} catch (error) {
|
|
2079
|
-
try { this.database.exec("ROLLBACK"); } catch { /* Preserve original failure. */ }
|
|
2080
|
-
throw error;
|
|
2081
|
-
}
|
|
2082
|
-
const entry: ActivityNotificationRecord = {
|
|
2083
|
-
kind: "heartbeat_ping",
|
|
2084
|
-
id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
|
|
2085
|
-
drone_label: null, role_name: null, recipient_drone_ids: [droneId],
|
|
2086
|
-
};
|
|
2087
|
-
this.hub.publish(cubeId, entry);
|
|
2088
|
-
published.push(entry);
|
|
2089
|
-
}
|
|
2090
|
-
return published;
|
|
2253
|
+
class IdleLivenessStore implements LivenessStore {
|
|
2254
|
+
scan(): ActivityNotificationRecord[] {
|
|
2255
|
+
// Silence is not a delivery failure. Keep the scheduler's lifecycle seam without
|
|
2256
|
+
// creating a directed event that crosses the client/model wake boundary.
|
|
2257
|
+
return [];
|
|
2091
2258
|
}
|
|
2092
2259
|
}
|
|
2093
2260
|
|
|
@@ -2416,7 +2583,7 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
2416
2583
|
credential.session_id, session.client_id, session.cube_id, session.drone_id,
|
|
2417
2584
|
session.expires_at,
|
|
2418
2585
|
COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
|
|
2419
|
-
drone.evicted_at
|
|
2586
|
+
drone.evicted_at, session.superseded_at
|
|
2420
2587
|
FROM drone_session_credentials AS credential
|
|
2421
2588
|
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
2422
2589
|
JOIN clients AS client ON client.id = session.client_id
|
|
@@ -2894,6 +3061,7 @@ function storedDroneSessionDigest(row: Record<string, unknown>): StoredDroneSess
|
|
|
2894
3061
|
droneId: requiredText(row, "drone_id"),
|
|
2895
3062
|
expiresAt: requiredText(row, "expires_at"),
|
|
2896
3063
|
evictedAt: nullableText(row, "evicted_at"),
|
|
3064
|
+
takenOver: nullableText(row, "superseded_at") !== null,
|
|
2897
3065
|
};
|
|
2898
3066
|
}
|
|
2899
3067
|
|