borgmcp-server 0.1.7 → 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.
@@ -39,7 +39,7 @@ export interface ServiceLimits {
39
39
 
40
40
  export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
41
41
  maxConnections: 100,
42
- maxConnectionsPerAddress: 25,
42
+ maxConnectionsPerAddress: 30,
43
43
  maxRequestsPerWindow: 120,
44
44
  maxRequestsPerAddressWindow: 600,
45
45
  maxRequestsGlobalWindow: 5_000,
@@ -48,7 +48,7 @@ export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
48
48
  maxStreamsPerCredential: 8,
49
49
  maxHeaderBytes: 16_384,
50
50
  maxRequestBodyBytes: 65_536,
51
- maxRequestsPerSocket: 100,
51
+ maxRequestsPerSocket: 0,
52
52
  requestTimeoutMs: 15_000,
53
53
  tlsHandshakeTimeoutMs: 10_000,
54
54
  headersTimeoutMs: 10_000,
@@ -63,7 +63,7 @@ export interface RequestHandlerContext {
63
63
  readonly authorizeCoordination?: (
64
64
  authorization: string | undefined,
65
65
  signal: AbortSignal,
66
- ) => Promise<Principal | "missing" | "invalid" | "revoked" | "evicted">;
66
+ ) => Promise<Principal | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected">;
67
67
  readonly handleCoordination?: (request: CoordinationRequest) => Promise<CoordinationResponse>;
68
68
  readonly debugLogger: DebugLogger;
69
69
  }
@@ -82,6 +82,7 @@ export interface HttpsServerOptions {
82
82
  readonly debugLogger?: DebugLogger;
83
83
  readonly testHooks?: {
84
84
  readonly identifyRemoteAddress?: (socket: Socket) => string;
85
+ readonly identifyConnectionAddress?: (socket: Socket) => string;
85
86
  };
86
87
  }
87
88
 
@@ -100,6 +101,15 @@ export async function startHttpsServer(options: HttpsServerOptions): Promise<Run
100
101
  validateLimits(limits);
101
102
  validateTlsCertificate(options.tls.cert, bind.host, bind.mode, options.tls.ca);
102
103
  const handlerContext = createRequestHandlerContext(options);
104
+ const identifyRemoteAddress = options.testHooks?.identifyRemoteAddress ??
105
+ ((socket: Socket) => socket.remoteAddress ?? "unknown");
106
+ const identifyConnectionAddress = options.testHooks?.identifyConnectionAddress ??
107
+ ((socket: Socket) => socket.remoteAddress ?? "unknown");
108
+ const addressConnectionLimiter = new AddressConnectionLimiter(
109
+ limits.maxConnectionsPerAddress,
110
+ limits.maxConnections,
111
+ identifyConnectionAddress,
112
+ );
103
113
 
104
114
  const server = createServer(
105
115
  {
@@ -112,11 +122,19 @@ export async function startHttpsServer(options: HttpsServerOptions): Promise<Run
112
122
  headersTimeout: limits.headersTimeoutMs,
113
123
  keepAliveTimeout: limits.keepAliveTimeoutMs,
114
124
  },
115
- createRequestListener(handlerContext, limits, options.testHooks?.identifyRemoteAddress),
125
+ createRequestListener(
126
+ handlerContext,
127
+ limits,
128
+ identifyRemoteAddress,
129
+ (socket) => addressConnectionLimiter.isRejected(socket),
130
+ ),
116
131
  );
117
132
 
118
- const acceptedSockets = applyServerLimits(server, limits);
119
- server.on("secureConnection", (socket) => socket.disableRenegotiation());
133
+ const acceptedSockets = applyServerLimits(server, limits, addressConnectionLimiter);
134
+ server.on("secureConnection", (socket) => {
135
+ socket.disableRenegotiation();
136
+ addressConnectionLimiter.admit(socket);
137
+ });
120
138
  server.on("tlsClientError", (_error, socket) => {
121
139
  handlerContext.debugLogger.emit({ event: "transport_rejection", reason: "tls_client_error" });
122
140
  socket.destroy();
@@ -173,6 +191,7 @@ function createRequestListener(
173
191
  context: RequestHandlerContext,
174
192
  limits: ServiceLimits,
175
193
  identifyRemoteAddress: (socket: Socket) => string = (socket) => socket.remoteAddress ?? "unknown",
194
+ isConnectionRejected: (socket: Socket) => boolean = () => false,
176
195
  ): (request: IncomingMessage, response: ServerResponse) => void {
177
196
  const admissionLimiter = new PreAuthAdmissionLimiter(limits);
178
197
  const credentialRateLimiter = new RequestRateLimiter(limits, limits.maxRequestsPerWindow);
@@ -204,6 +223,11 @@ function createRequestListener(
204
223
  };
205
224
  response.once("finish", emitDebug);
206
225
  response.once("close", emitDebug);
226
+ if (isConnectionRejected(request.socket)) {
227
+ request.resume();
228
+ sendRateLimited(response, 1);
229
+ return;
230
+ }
207
231
  const controller = new AbortController();
208
232
  response.once("close", () => controller.abort());
209
233
  let timer: NodeJS.Timeout | undefined;
@@ -343,16 +367,16 @@ async function handleRequest(
343
367
  sendJson(response, 410, protocolError(ErrorCode.DRONE_EVICTED, "Authentication failed."));
344
368
  return;
345
369
  }
346
- const code = authentication === "revoked"
347
- ? "SESSION_REVOKED"
370
+ const code = authentication === "expired"
371
+ ? ErrorCode.AUTH_EXPIRED
372
+ : authentication === "revoked" ? ErrorCode.SESSION_REVOKED
373
+ : authentication === "rejected" ? ErrorCode.SESSION_REJECTED
348
374
  : authentication === "missing" || authorization === undefined ? "AUTH_MISSING" : "AUTH_INVALID";
349
375
  sendJson(response, 401, protocolError(code, "Authentication failed."));
350
376
  return;
351
377
  }
352
378
  trace.principal = authentication;
353
- const clientIdentity = `client:${authentication.kind === "drone-session"
354
- ? authentication.clientId
355
- : authentication.id}`;
379
+ const clientIdentity = credentialRateLimitIdentity(authentication, request.method, path);
356
380
  const credentialRetry = credentialRateLimiter.consume(clientIdentity);
357
381
  if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
358
382
  const query = parseCoordinationQuery(request.url, path);
@@ -393,7 +417,7 @@ async function handleRequest(
393
417
  interface RequestTrace {
394
418
  readonly route: DebugRoute;
395
419
  readonly method: string;
396
- authentication: "not_required" | "missing" | "invalid" | "revoked" | "evicted" | "accepted";
420
+ authentication: "not_required" | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected" | "accepted";
397
421
  principal?: Principal;
398
422
  }
399
423
 
@@ -592,7 +616,24 @@ function isCoordinationPath(path: string | null): path is string {
592
616
  path?.startsWith("/api/cubes/") === true;
593
617
  }
594
618
 
595
- function applyServerLimits(server: HttpsServer, limits: ServiceLimits): Set<Socket> {
619
+ function credentialRateLimitIdentity(
620
+ principal: Principal,
621
+ method: string | undefined,
622
+ path: string,
623
+ ): string {
624
+ const routineRead = (method === "GET" && !path.endsWith("/stream")) ||
625
+ (method === "PUT" && (path.endsWith("/logs") || path.endsWith("/decisions")));
626
+ if (principal.kind === "drone-session" && routineRead) {
627
+ return `drone-session:${principal.id}`;
628
+ }
629
+ return `client:${principal.kind === "drone-session" ? principal.clientId : principal.id}`;
630
+ }
631
+
632
+ function applyServerLimits(
633
+ server: HttpsServer,
634
+ limits: ServiceLimits,
635
+ addressConnectionLimiter: AddressConnectionLimiter,
636
+ ): Set<Socket> {
596
637
  server.maxConnections = limits.maxConnections;
597
638
  server.maxRequestsPerSocket = limits.maxRequestsPerSocket;
598
639
  server.requestTimeout = limits.requestTimeoutMs;
@@ -602,27 +643,63 @@ function applyServerLimits(server: HttpsServer, limits: ServiceLimits): Set<Sock
602
643
  Math.min(limits.handlerTimeoutMs * 2, 2_147_483_647),
603
644
  (socket) => socket.destroy(),
604
645
  );
605
- const addressConnections = new ConcurrentQuota(limits.maxConnectionsPerAddress);
606
646
  const acceptedSockets = new Set<Socket>();
607
647
  server.on("connection", (socket) => {
608
648
  const tracked = socket as Socket;
609
649
  acceptedSockets.add(tracked);
610
650
  tracked.once("close", () => acceptedSockets.delete(tracked));
611
- const identity = tracked.remoteAddress ?? "unknown";
612
- const release = addressConnections.acquire(identity);
651
+ addressConnectionLimiter.admitRaw(tracked);
652
+ });
653
+ return acceptedSockets;
654
+ }
655
+
656
+ class AddressConnectionLimiter {
657
+ readonly #rawConnections: ConcurrentQuota;
658
+ readonly #connections: ConcurrentQuota;
659
+ readonly #rejected = new WeakSet<Socket>();
660
+ readonly #identifyRemoteAddress: (socket: Socket) => string;
661
+
662
+ constructor(
663
+ limit: number,
664
+ globalLimit: number,
665
+ identifyRemoteAddress: (socket: Socket) => string,
666
+ ) {
667
+ // Keep exactly one per-address raw socket available beyond normal secure
668
+ // admission so it can receive a controlled HTTP 429 after TLS. All later
669
+ // raw sockets are rejected before they can consume the global pool.
670
+ this.#rawConnections = new ConcurrentQuota(Math.min(limit + 1, globalLimit));
671
+ this.#connections = new ConcurrentQuota(limit);
672
+ this.#identifyRemoteAddress = identifyRemoteAddress;
673
+ }
674
+
675
+ admitRaw(socket: Socket): void {
676
+ const release = this.#rawConnections.acquire(this.#identifyRemoteAddress(socket));
613
677
  if (release === null) {
614
678
  socket.destroy();
615
679
  return;
616
680
  }
617
681
  socket.once("close", release);
618
- });
619
- return acceptedSockets;
682
+ }
683
+
684
+ admit(socket: Socket): void {
685
+ const release = this.#connections.acquire(this.#identifyRemoteAddress(socket));
686
+ if (release === null) {
687
+ this.#rejected.add(socket);
688
+ return;
689
+ }
690
+ socket.once("close", release);
691
+ }
692
+
693
+ isRejected(socket: Socket): boolean {
694
+ return this.#rejected.has(socket);
695
+ }
620
696
  }
621
697
 
622
698
  function validateLimits(limits: ServiceLimits): void {
623
699
  for (const [name, value] of Object.entries(limits)) {
624
- if (!Number.isSafeInteger(value) || value <= 0) {
625
- throw new Error(`${name} must be a positive safe integer.`);
700
+ if (!Number.isSafeInteger(value) || value < 0 || (value === 0 && name !== "maxRequestsPerSocket")) {
701
+ const range = name === "maxRequestsPerSocket" ? "non-negative" : "positive";
702
+ throw new Error(`${name} must be a ${range} safe integer.`);
626
703
  }
627
704
  }
628
705
  if (limits.headersTimeoutMs > limits.requestTimeoutMs) {
package/src/migrations.ts CHANGED
@@ -378,6 +378,29 @@ export const STORE_MIGRATIONS: readonly Migration[] = Object.freeze([
378
378
  ) STRICT;
379
379
  `,
380
380
  },
381
+ {
382
+ version: 12,
383
+ name: "drone_session_supersession",
384
+ sql: `
385
+ ALTER TABLE drone_sessions ADD COLUMN superseded_at TEXT;
386
+
387
+ WITH lineage AS (
388
+ SELECT rowid AS session_rowid,
389
+ LEAD(created_at) OVER (
390
+ PARTITION BY client_id, cube_id, drone_id
391
+ ORDER BY created_at, rowid
392
+ ) AS successor_at
393
+ FROM drone_sessions
394
+ )
395
+ UPDATE drone_sessions
396
+ SET superseded_at = (
397
+ SELECT successor_at FROM lineage WHERE session_rowid = drone_sessions.rowid
398
+ )
399
+ WHERE rowid IN (
400
+ SELECT session_rowid FROM lineage WHERE successor_at IS NOT NULL
401
+ );
402
+ `,
403
+ },
381
404
  ]);
382
405
 
383
406
  interface AppliedMigrationRow {
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: (options?: {
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 {
@@ -362,6 +358,7 @@ export interface SeatAttachInput {
362
358
  readonly credentialId: string;
363
359
  readonly credentialDigest: DigestPair;
364
360
  readonly expiresAt: string;
361
+ readonly renewIfExpiresAtOrBefore: string;
365
362
  }
366
363
 
367
364
  export interface SeatAttachRecord {
@@ -527,6 +524,33 @@ export class AttachSessionRejectedError extends Error {
527
524
  }
528
525
  }
529
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
+
530
554
  export class StorageCapacityError extends Error {
531
555
  readonly code = "CAPACITY_EXCEEDED";
532
556
 
@@ -645,7 +669,7 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
645
669
  );
646
670
  const activityHub = new ActivityHub();
647
671
  const maintenance = new SqliteMaintenanceStore(database, clock);
648
- const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
672
+ const liveness = new IdleLivenessStore();
649
673
  const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
650
674
  return Object.freeze({
651
675
  forPrincipal: (principal: Principal) => {
@@ -1283,7 +1307,6 @@ class SqliteScopedStore implements ScopedStore {
1283
1307
  this.#database.prepare(`
1284
1308
  UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
1285
1309
  `).run(createdAt, droneId, cubeId);
1286
- this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1287
1310
  }
1288
1311
  if (recipients.length > 0) {
1289
1312
  const valid = this.#database.prepare(`
@@ -1470,6 +1493,7 @@ class SqliteScopedStore implements ScopedStore {
1470
1493
  assertCanonicalUuid(input.credentialId, "Drone session credential id");
1471
1494
  validateDigest(input.credentialDigest);
1472
1495
  validateTimestamp(input.expiresAt);
1496
+ validateTimestamp(input.renewIfExpiresAtOrBefore);
1473
1497
  const scope = this.#scope("read");
1474
1498
  const authorizedRole = this.#database.prepare(`
1475
1499
  SELECT 1 FROM cubes AS c JOIN roles AS role ON role.cube_id = c.id
@@ -1492,7 +1516,7 @@ class SqliteScopedStore implements ScopedStore {
1492
1516
  SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
1493
1517
  session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
1494
1518
  session.expires_at, session.revoked_at AS session_revoked_at,
1495
- drone.role_id, drone.label, drone.evicted_at
1519
+ session.superseded_at, drone.role_id, drone.label, drone.evicted_at
1496
1520
  FROM drone_session_credentials AS credential
1497
1521
  JOIN drone_sessions AS session ON session.id = credential.session_id
1498
1522
  JOIN drones AS drone ON drone.id = session.drone_id
@@ -1502,10 +1526,6 @@ class SqliteScopedStore implements ScopedStore {
1502
1526
  if (bound !== undefined) {
1503
1527
  const verifier = requiredBuffer(bound, "verifier_digest");
1504
1528
  if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
1505
- optionalText(bound, "credential_revoked_at") !== null ||
1506
- optionalText(bound, "session_revoked_at") !== null ||
1507
- optionalText(bound, "evicted_at") !== null ||
1508
- requiredText(bound, "expires_at") <= now ||
1509
1529
  requiredText(bound, "client_id") !== this.#principal.id ||
1510
1530
  requiredText(bound, "cube_id") !== input.cubeId ||
1511
1531
  requiredText(bound, "role_id") !== input.roleId ||
@@ -1513,9 +1533,27 @@ class SqliteScopedStore implements ScopedStore {
1513
1533
  requiredText(bound, "drone_id") !== input.priorDroneId)) {
1514
1534
  throw new AttachSessionRejectedError();
1515
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();
1516
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
+ }
1517
1556
  this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
1518
- this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1519
1557
  const attachedRoleRow = this.#database.prepare(`
1520
1558
  SELECT id AS role_id, name AS role_name, role_class, is_human_seat
1521
1559
  FROM roles WHERE id = ? AND cube_id = ?
@@ -1538,33 +1576,53 @@ class SqliteScopedStore implements ScopedStore {
1538
1576
  is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
1539
1577
  },
1540
1578
  drone: { id: droneId, label: requiredText(bound, "label") },
1541
- sessionId: requiredText(bound, "session_id"),
1542
- expiresAt: requiredText(bound, "expires_at"),
1579
+ sessionId,
1580
+ expiresAt: committedExpiry,
1543
1581
  result: "reused",
1544
1582
  };
1545
1583
  }
1546
1584
 
1547
1585
  const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
1548
- SELECT id, role_id, label FROM drones
1549
- WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
1586
+ SELECT id, role_id, label, evicted_at FROM drones
1587
+ WHERE id = ? AND client_id = ? AND cube_id = ?
1550
1588
  `).get(input.priorDroneId, this.#principal.id, input.cubeId);
1551
1589
  let droneId: string;
1552
1590
  let droneLabel: string;
1553
- if (priorSeat !== undefined) {
1591
+ if (input.priorDroneId !== undefined) {
1592
+ if (priorSeat === undefined) throw new AttachSessionRejectedError();
1593
+ if (optionalText(priorSeat, "evicted_at") !== null) throw new AttachDroneEvictedError();
1554
1594
  const priorSeatId = requiredText(priorSeat, "id");
1555
1595
  if (requiredText(priorSeat, "role_id") !== input.roleId) {
1556
1596
  throw new AttachSessionRejectedError();
1557
1597
  }
1558
- const occupied = this.#database.prepare(`
1559
- SELECT 1 FROM drone_sessions AS session
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
1560
1603
  JOIN drone_session_credentials AS credential ON credential.session_id = session.id
1561
1604
  WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
1562
- AND session.revoked_at IS NULL AND credential.revoked_at IS NULL
1563
- AND session.expires_at > ?
1564
- `).get(priorSeatId, this.#principal.id, input.cubeId, now);
1565
- if (occupied !== undefined) throw new AttachSessionRejectedError();
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
+ }
1566
1617
  droneId = priorSeatId;
1567
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
+ }
1568
1626
  this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
1569
1627
  } else {
1570
1628
  droneId = input.droneId;
@@ -1576,8 +1634,6 @@ class SqliteScopedStore implements ScopedStore {
1576
1634
  droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now,
1577
1635
  );
1578
1636
  }
1579
- this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1580
-
1581
1637
  this.#database.prepare(`
1582
1638
  INSERT INTO drone_sessions (
1583
1639
  id, client_id, cube_id, drone_id, created_at, expires_at
@@ -2194,61 +2250,11 @@ class ActivityHub {
2194
2250
  }
2195
2251
  }
2196
2252
 
2197
- class SqliteLivenessStore implements LivenessStore {
2198
- constructor(
2199
- readonly database: DatabaseSync,
2200
- readonly clock: () => Date,
2201
- readonly hub: ActivityHub,
2202
- readonly capacityGuard: StorageCapacityGuard,
2203
- ) {}
2204
-
2205
- scan(options: { silentMs?: number; cooldownMs?: number; limit?: number } = {}): ActivityNotificationRecord[] {
2206
- const now = this.clock();
2207
- const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
2208
- const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
2209
- const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
2210
- const candidates = this.database.prepare(`
2211
- SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
2212
- FROM drones AS drone
2213
- JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
2214
- JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
2215
- AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
2216
- LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
2217
- WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
2218
- AND COALESCE(state.attempts, 0) < 3
2219
- AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
2220
- AND EXISTS (SELECT 1 FROM drone_sessions AS session
2221
- WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
2222
- ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
2223
- `).all(silentBefore, cooldownBefore, now.toISOString(), limit);
2224
- const published: ActivityNotificationRecord[] = [];
2225
- for (const candidate of candidates) {
2226
- const droneId = requiredText(candidate, "drone_id");
2227
- const cubeId = requiredText(candidate, "cube_id");
2228
- const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
2229
- this.capacityGuard.assertCanGrow(256);
2230
- const id = randomUUID();
2231
- const createdAt = now.toISOString();
2232
- this.database.exec("BEGIN IMMEDIATE");
2233
- try {
2234
- this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
2235
- VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
2236
- SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
2237
- `).run(droneId, now.toISOString());
2238
- this.database.exec("COMMIT");
2239
- } catch (error) {
2240
- try { this.database.exec("ROLLBACK"); } catch { /* Preserve original failure. */ }
2241
- throw error;
2242
- }
2243
- const entry: ActivityNotificationRecord = {
2244
- kind: "heartbeat_ping",
2245
- id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
2246
- drone_label: null, role_name: null, recipient_drone_ids: [droneId],
2247
- };
2248
- this.hub.publish(cubeId, entry);
2249
- published.push(entry);
2250
- }
2251
- 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 [];
2252
2258
  }
2253
2259
  }
2254
2260
 
@@ -2577,7 +2583,7 @@ class SqliteCredentialStore implements CredentialStore {
2577
2583
  credential.session_id, session.client_id, session.cube_id, session.drone_id,
2578
2584
  session.expires_at,
2579
2585
  COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
2580
- drone.evicted_at
2586
+ drone.evicted_at, session.superseded_at
2581
2587
  FROM drone_session_credentials AS credential
2582
2588
  JOIN drone_sessions AS session ON session.id = credential.session_id
2583
2589
  JOIN clients AS client ON client.id = session.client_id
@@ -3055,6 +3061,7 @@ function storedDroneSessionDigest(row: Record<string, unknown>): StoredDroneSess
3055
3061
  droneId: requiredText(row, "drone_id"),
3056
3062
  expiresAt: requiredText(row, "expires_at"),
3057
3063
  evictedAt: nullableText(row, "evicted_at"),
3064
+ takenOver: nullableText(row, "superseded_at") !== null,
3058
3065
  };
3059
3066
  }
3060
3067