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/dist/store.js
CHANGED
|
@@ -54,6 +54,13 @@ export class RoleSectionConflictError extends Error {
|
|
|
54
54
|
this.name = "RoleSectionConflictError";
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
export class RoleInUseError extends Error {
|
|
58
|
+
code = "ROLE_IN_USE";
|
|
59
|
+
constructor() {
|
|
60
|
+
super("The human-seat role is already occupied.");
|
|
61
|
+
this.name = "RoleInUseError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
57
64
|
export class CursorExpiredError extends Error {
|
|
58
65
|
code = "CURSOR_EXPIRED";
|
|
59
66
|
constructor() {
|
|
@@ -68,6 +75,27 @@ export class AttachSessionRejectedError extends Error {
|
|
|
68
75
|
this.name = "AttachSessionRejectedError";
|
|
69
76
|
}
|
|
70
77
|
}
|
|
78
|
+
export class AttachSessionExpiredError extends Error {
|
|
79
|
+
code = "AUTH_EXPIRED";
|
|
80
|
+
constructor() {
|
|
81
|
+
super("Authentication failed.");
|
|
82
|
+
this.name = "AttachSessionExpiredError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export class AttachSessionRevokedError extends Error {
|
|
86
|
+
code = "SESSION_REVOKED";
|
|
87
|
+
constructor() {
|
|
88
|
+
super("Authentication failed.");
|
|
89
|
+
this.name = "AttachSessionRevokedError";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export class AttachDroneEvictedError extends Error {
|
|
93
|
+
code = "DRONE_EVICTED";
|
|
94
|
+
constructor() {
|
|
95
|
+
super("Authentication failed.");
|
|
96
|
+
this.name = "AttachDroneEvictedError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
71
99
|
export class StorageCapacityError extends Error {
|
|
72
100
|
code = "CAPACITY_EXCEEDED";
|
|
73
101
|
constructor() {
|
|
@@ -153,7 +181,7 @@ export async function openStore(options) {
|
|
|
153
181
|
const capacityGuard = new StorageCapacityGuard(storageLimits, capacityProbe, requiredInteger(pageRow, "page_size"));
|
|
154
182
|
const activityHub = new ActivityHub();
|
|
155
183
|
const maintenance = new SqliteMaintenanceStore(database, clock);
|
|
156
|
-
const liveness = new
|
|
184
|
+
const liveness = new IdleLivenessStore();
|
|
157
185
|
const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
|
|
158
186
|
return Object.freeze({
|
|
159
187
|
forPrincipal: (principal) => {
|
|
@@ -615,6 +643,96 @@ class SqliteScopedStore {
|
|
|
615
643
|
})),
|
|
616
644
|
};
|
|
617
645
|
}
|
|
646
|
+
reassignDrone(cubeId, droneId, roleId) {
|
|
647
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
648
|
+
assertCanonicalUuid(droneId, "Drone id");
|
|
649
|
+
assertCanonicalUuid(roleId, "Role id");
|
|
650
|
+
this.#requireCube(cubeId, "manage");
|
|
651
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
652
|
+
try {
|
|
653
|
+
this.#requireCube(cubeId, "manage");
|
|
654
|
+
const droneRow = this.#database.prepare(`
|
|
655
|
+
SELECT id, cube_id, role_id, label FROM drones
|
|
656
|
+
WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
657
|
+
`).get(droneId, cubeId);
|
|
658
|
+
const targetRoleRow = this.#database.prepare(`
|
|
659
|
+
SELECT id, is_human_seat, role_class FROM roles WHERE id = ? AND cube_id = ?
|
|
660
|
+
`).get(roleId, cubeId);
|
|
661
|
+
if (droneRow === undefined || targetRoleRow === undefined)
|
|
662
|
+
throw new ScopedStoreError();
|
|
663
|
+
const sourceRoleRow = this.#database.prepare(`
|
|
664
|
+
SELECT is_human_seat FROM roles WHERE id = ? AND cube_id = ?
|
|
665
|
+
`).get(requiredText(droneRow, "role_id"), cubeId);
|
|
666
|
+
if (sourceRoleRow === undefined)
|
|
667
|
+
throw new ScopedStoreError();
|
|
668
|
+
if (requiredText(targetRoleRow, "role_class") === "queen" &&
|
|
669
|
+
requiredInteger(sourceRoleRow, "is_human_seat") !== 1) {
|
|
670
|
+
throw new AccessDeniedError();
|
|
671
|
+
}
|
|
672
|
+
if (requiredInteger(targetRoleRow, "is_human_seat") === 1) {
|
|
673
|
+
const occupied = this.#database.prepare(`
|
|
674
|
+
SELECT 1 AS present FROM drones
|
|
675
|
+
WHERE cube_id = ? AND role_id = ? AND id <> ? AND evicted_at IS NULL
|
|
676
|
+
`).get(cubeId, roleId, droneId);
|
|
677
|
+
if (occupied !== undefined)
|
|
678
|
+
throw new RoleInUseError();
|
|
679
|
+
}
|
|
680
|
+
this.#database.prepare("UPDATE drones SET role_id = ? WHERE id = ? AND cube_id = ?")
|
|
681
|
+
.run(roleId, droneId, cubeId);
|
|
682
|
+
const row = this.#database.prepare(`
|
|
683
|
+
SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
|
|
684
|
+
COALESCE(drone.last_seen, drone.created_at) AS last_seen,
|
|
685
|
+
drone.hostname, drone.created_at,
|
|
686
|
+
CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
687
|
+
THEN 'participant' ELSE 'observer' END AS posture
|
|
688
|
+
FROM drones AS drone
|
|
689
|
+
LEFT JOIN clients AS client ON client.id = drone.client_id
|
|
690
|
+
LEFT JOIN client_cube_grants AS grant_row
|
|
691
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
692
|
+
WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
693
|
+
`).get(droneId, cubeId);
|
|
694
|
+
if (row === undefined)
|
|
695
|
+
throw new ScopedStoreError();
|
|
696
|
+
this.#database.exec("COMMIT");
|
|
697
|
+
return droneRecord(row);
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
try {
|
|
701
|
+
this.#database.exec("ROLLBACK");
|
|
702
|
+
}
|
|
703
|
+
catch { /* Preserve the original failure. */ }
|
|
704
|
+
throw error;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
evictDrone(cubeId, droneId) {
|
|
708
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
709
|
+
assertCanonicalUuid(droneId, "Drone id");
|
|
710
|
+
this.#requireCube(cubeId, "manage");
|
|
711
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
712
|
+
try {
|
|
713
|
+
this.#requireCube(cubeId, "manage");
|
|
714
|
+
const active = this.#database.prepare(`
|
|
715
|
+
SELECT 1 AS present FROM drones WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
716
|
+
`).get(droneId, cubeId);
|
|
717
|
+
if (active === undefined)
|
|
718
|
+
throw new ScopedStoreError();
|
|
719
|
+
const now = this.#now();
|
|
720
|
+
this.#database.prepare("UPDATE drones SET evicted_at = ? WHERE id = ? AND cube_id = ?")
|
|
721
|
+
.run(now, droneId, cubeId);
|
|
722
|
+
this.#database.prepare(`
|
|
723
|
+
UPDATE drone_sessions SET revoked_at = ?
|
|
724
|
+
WHERE drone_id = ? AND cube_id = ? AND revoked_at IS NULL
|
|
725
|
+
`).run(now, droneId, cubeId);
|
|
726
|
+
this.#database.exec("COMMIT");
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
try {
|
|
730
|
+
this.#database.exec("ROLLBACK");
|
|
731
|
+
}
|
|
732
|
+
catch { /* Preserve the original failure. */ }
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
618
736
|
appendLog(cubeId, input) {
|
|
619
737
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
620
738
|
if (input.message.length === 0 || Buffer.byteLength(input.message) > 10_240) {
|
|
@@ -664,7 +782,6 @@ class SqliteScopedStore {
|
|
|
664
782
|
this.#database.prepare(`
|
|
665
783
|
UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
666
784
|
`).run(createdAt, droneId, cubeId);
|
|
667
|
-
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
668
785
|
}
|
|
669
786
|
if (recipients.length > 0) {
|
|
670
787
|
const valid = this.#database.prepare(`
|
|
@@ -851,6 +968,7 @@ class SqliteScopedStore {
|
|
|
851
968
|
assertCanonicalUuid(input.credentialId, "Drone session credential id");
|
|
852
969
|
validateDigest(input.credentialDigest);
|
|
853
970
|
validateTimestamp(input.expiresAt);
|
|
971
|
+
validateTimestamp(input.renewIfExpiresAtOrBefore);
|
|
854
972
|
const scope = this.#scope("read");
|
|
855
973
|
const authorizedRole = this.#database.prepare(`
|
|
856
974
|
SELECT 1 FROM cubes AS c JOIN roles AS role ON role.cube_id = c.id
|
|
@@ -875,7 +993,7 @@ class SqliteScopedStore {
|
|
|
875
993
|
SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
|
|
876
994
|
session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
|
|
877
995
|
session.expires_at, session.revoked_at AS session_revoked_at,
|
|
878
|
-
drone.role_id, drone.label, drone.evicted_at
|
|
996
|
+
session.superseded_at, drone.role_id, drone.label, drone.evicted_at
|
|
879
997
|
FROM drone_session_credentials AS credential
|
|
880
998
|
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
881
999
|
JOIN drones AS drone ON drone.id = session.drone_id
|
|
@@ -885,10 +1003,6 @@ class SqliteScopedStore {
|
|
|
885
1003
|
if (bound !== undefined) {
|
|
886
1004
|
const verifier = requiredBuffer(bound, "verifier_digest");
|
|
887
1005
|
if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
|
|
888
|
-
optionalText(bound, "credential_revoked_at") !== null ||
|
|
889
|
-
optionalText(bound, "session_revoked_at") !== null ||
|
|
890
|
-
optionalText(bound, "evicted_at") !== null ||
|
|
891
|
-
requiredText(bound, "expires_at") <= now ||
|
|
892
1006
|
requiredText(bound, "client_id") !== this.#principal.id ||
|
|
893
1007
|
requiredText(bound, "cube_id") !== input.cubeId ||
|
|
894
1008
|
requiredText(bound, "role_id") !== input.roleId ||
|
|
@@ -896,9 +1010,29 @@ class SqliteScopedStore {
|
|
|
896
1010
|
requiredText(bound, "drone_id") !== input.priorDroneId)) {
|
|
897
1011
|
throw new AttachSessionRejectedError();
|
|
898
1012
|
}
|
|
1013
|
+
if (optionalText(bound, "evicted_at") !== null)
|
|
1014
|
+
throw new AttachDroneEvictedError();
|
|
1015
|
+
if (optionalText(bound, "credential_revoked_at") !== null ||
|
|
1016
|
+
optionalText(bound, "session_revoked_at") !== null) {
|
|
1017
|
+
throw new AttachSessionRevokedError();
|
|
1018
|
+
}
|
|
1019
|
+
if (optionalText(bound, "superseded_at") !== null) {
|
|
1020
|
+
throw new AttachSessionRejectedError();
|
|
1021
|
+
}
|
|
1022
|
+
if (requiredText(bound, "expires_at") <= now)
|
|
1023
|
+
throw new AttachSessionExpiredError();
|
|
899
1024
|
const droneId = requiredText(bound, "drone_id");
|
|
1025
|
+
const sessionId = requiredText(bound, "session_id");
|
|
1026
|
+
const previousExpiry = requiredText(bound, "expires_at");
|
|
1027
|
+
const committedExpiry = input.expiresAt > previousExpiry &&
|
|
1028
|
+
previousExpiry <= input.renewIfExpiresAtOrBefore
|
|
1029
|
+
? input.expiresAt
|
|
1030
|
+
: previousExpiry;
|
|
1031
|
+
if (committedExpiry !== previousExpiry) {
|
|
1032
|
+
this.#database.prepare("UPDATE drone_sessions SET expires_at = ? WHERE id = ?")
|
|
1033
|
+
.run(committedExpiry, sessionId);
|
|
1034
|
+
}
|
|
900
1035
|
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
901
|
-
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
902
1036
|
const attachedRoleRow = this.#database.prepare(`
|
|
903
1037
|
SELECT id AS role_id, name AS role_name, role_class, is_human_seat
|
|
904
1038
|
FROM roles WHERE id = ? AND cube_id = ?
|
|
@@ -922,33 +1056,55 @@ class SqliteScopedStore {
|
|
|
922
1056
|
is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
|
|
923
1057
|
},
|
|
924
1058
|
drone: { id: droneId, label: requiredText(bound, "label") },
|
|
925
|
-
sessionId
|
|
926
|
-
expiresAt:
|
|
1059
|
+
sessionId,
|
|
1060
|
+
expiresAt: committedExpiry,
|
|
927
1061
|
result: "reused",
|
|
928
1062
|
};
|
|
929
1063
|
}
|
|
930
1064
|
const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
|
|
931
|
-
SELECT id, role_id, label FROM drones
|
|
932
|
-
WHERE id = ? AND client_id = ? AND cube_id = ?
|
|
1065
|
+
SELECT id, role_id, label, evicted_at FROM drones
|
|
1066
|
+
WHERE id = ? AND client_id = ? AND cube_id = ?
|
|
933
1067
|
`).get(input.priorDroneId, this.#principal.id, input.cubeId);
|
|
934
1068
|
let droneId;
|
|
935
1069
|
let droneLabel;
|
|
936
|
-
if (
|
|
1070
|
+
if (input.priorDroneId !== undefined) {
|
|
1071
|
+
if (priorSeat === undefined)
|
|
1072
|
+
throw new AttachSessionRejectedError();
|
|
1073
|
+
if (optionalText(priorSeat, "evicted_at") !== null)
|
|
1074
|
+
throw new AttachDroneEvictedError();
|
|
937
1075
|
const priorSeatId = requiredText(priorSeat, "id");
|
|
938
1076
|
if (requiredText(priorSeat, "role_id") !== input.roleId) {
|
|
939
1077
|
throw new AttachSessionRejectedError();
|
|
940
1078
|
}
|
|
941
|
-
const
|
|
942
|
-
SELECT
|
|
1079
|
+
const latestSession = this.#database.prepare(`
|
|
1080
|
+
SELECT session.id, session.expires_at, session.revoked_at AS session_revoked_at,
|
|
1081
|
+
MAX(CASE WHEN credential.revoked_at IS NOT NULL THEN 1 ELSE 0 END)
|
|
1082
|
+
AS credential_revoked
|
|
1083
|
+
FROM drone_sessions AS session
|
|
943
1084
|
JOIN drone_session_credentials AS credential ON credential.session_id = session.id
|
|
944
1085
|
WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
|
|
945
|
-
AND session.
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1086
|
+
AND session.superseded_at IS NULL
|
|
1087
|
+
GROUP BY session.id
|
|
1088
|
+
LIMIT 1
|
|
1089
|
+
`).get(priorSeatId, this.#principal.id, input.cubeId);
|
|
1090
|
+
if (latestSession === undefined)
|
|
1091
|
+
throw new AttachSessionRejectedError();
|
|
1092
|
+
if (optionalText(latestSession, "session_revoked_at") !== null ||
|
|
1093
|
+
requiredInteger(latestSession, "credential_revoked") === 1) {
|
|
1094
|
+
throw new AttachSessionRevokedError();
|
|
1095
|
+
}
|
|
1096
|
+
if (requiredText(latestSession, "expires_at") > now) {
|
|
949
1097
|
throw new AttachSessionRejectedError();
|
|
1098
|
+
}
|
|
950
1099
|
droneId = priorSeatId;
|
|
951
1100
|
droneLabel = requiredText(priorSeat, "label");
|
|
1101
|
+
const superseded = this.#database.prepare(`
|
|
1102
|
+
UPDATE drone_sessions SET superseded_at = ?
|
|
1103
|
+
WHERE id = ? AND superseded_at IS NULL
|
|
1104
|
+
`).run(now, requiredText(latestSession, "id"));
|
|
1105
|
+
if (Number(superseded.changes) !== 1) {
|
|
1106
|
+
throw new Error("Expired session supersession was not atomic.");
|
|
1107
|
+
}
|
|
952
1108
|
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
953
1109
|
}
|
|
954
1110
|
else {
|
|
@@ -959,7 +1115,6 @@ class SqliteScopedStore {
|
|
|
959
1115
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
960
1116
|
`).run(droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now);
|
|
961
1117
|
}
|
|
962
|
-
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
963
1118
|
this.#database.prepare(`
|
|
964
1119
|
INSERT INTO drone_sessions (
|
|
965
1120
|
id, client_id, cube_id, drone_id, created_at, expires_at
|
|
@@ -1397,6 +1552,54 @@ class SqliteMaintenanceStore {
|
|
|
1397
1552
|
assertCanonicalUuid(sessionId, "Drone session id");
|
|
1398
1553
|
this.#database.prepare("UPDATE drone_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(this.#now(), sessionId);
|
|
1399
1554
|
}
|
|
1555
|
+
expireDroneSession(sessionId) {
|
|
1556
|
+
assertCanonicalUuid(sessionId, "Drone session id");
|
|
1557
|
+
this.#database.prepare("UPDATE drone_sessions SET expires_at = ? WHERE id = ?")
|
|
1558
|
+
.run(new Date(0).toISOString(), sessionId);
|
|
1559
|
+
}
|
|
1560
|
+
inspectManagedDrone(droneId) {
|
|
1561
|
+
assertCanonicalUuid(droneId, "Drone id");
|
|
1562
|
+
const row = this.#database.prepare(`
|
|
1563
|
+
SELECT drone.role_id, drone.evicted_at,
|
|
1564
|
+
EXISTS(SELECT 1 FROM drone_sessions AS session
|
|
1565
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NOT NULL) AS session_revoked
|
|
1566
|
+
FROM drones AS drone WHERE drone.id = ?
|
|
1567
|
+
`).get(droneId);
|
|
1568
|
+
if (row === undefined)
|
|
1569
|
+
throw new ScopedStoreError();
|
|
1570
|
+
return {
|
|
1571
|
+
role_id: requiredText(row, "role_id"),
|
|
1572
|
+
evicted: nullableText(row, "evicted_at") !== null,
|
|
1573
|
+
session_revoked: requiredInteger(row, "session_revoked") === 1,
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
inspectCubeManagementState(cubeId) {
|
|
1577
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
1578
|
+
const cube = this.#database.prepare("SELECT directive, message_taxonomy FROM cubes WHERE id = ?").get(cubeId);
|
|
1579
|
+
if (cube === undefined)
|
|
1580
|
+
throw new ScopedStoreError();
|
|
1581
|
+
const taxonomy = nullableText(cube, "message_taxonomy");
|
|
1582
|
+
const parsed = taxonomy === null ? null : JSON.parse(taxonomy);
|
|
1583
|
+
return {
|
|
1584
|
+
directive: requiredText(cube, "directive"),
|
|
1585
|
+
taxonomy_marker: typeof parsed?.[0]?.class === "string" ? parsed[0].class : null,
|
|
1586
|
+
role_ids: this.#database.prepare("SELECT id FROM roles WHERE cube_id = ? ORDER BY id").all(cubeId).map((row) => requiredText(row, "id")),
|
|
1587
|
+
active_decision_ids: this.#database.prepare(`
|
|
1588
|
+
SELECT id FROM decisions WHERE cube_id = ? AND status = 'active' ORDER BY id
|
|
1589
|
+
`).all(cubeId).map((row) => requiredText(row, "id")),
|
|
1590
|
+
drones: this.#database.prepare(`
|
|
1591
|
+
SELECT drone.id, drone.role_id, drone.evicted_at,
|
|
1592
|
+
EXISTS(SELECT 1 FROM drone_sessions AS session
|
|
1593
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NOT NULL) AS session_revoked
|
|
1594
|
+
FROM drones AS drone WHERE drone.cube_id = ? ORDER BY drone.id
|
|
1595
|
+
`).all(cubeId).map((row) => ({
|
|
1596
|
+
id: requiredText(row, "id"),
|
|
1597
|
+
role_id: requiredText(row, "role_id"),
|
|
1598
|
+
evicted: nullableText(row, "evicted_at") !== null,
|
|
1599
|
+
session_revoked: requiredInteger(row, "session_revoked") === 1,
|
|
1600
|
+
})),
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1400
1603
|
expireActivityCursor(cubeId, cursor) {
|
|
1401
1604
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
1402
1605
|
assertCanonicalUuid(cursor.id, "Activity cursor id");
|
|
@@ -1438,68 +1641,11 @@ class ActivityHub {
|
|
|
1438
1641
|
}
|
|
1439
1642
|
}
|
|
1440
1643
|
}
|
|
1441
|
-
class
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
constructor(database, clock, hub, capacityGuard) {
|
|
1447
|
-
this.database = database;
|
|
1448
|
-
this.clock = clock;
|
|
1449
|
-
this.hub = hub;
|
|
1450
|
-
this.capacityGuard = capacityGuard;
|
|
1451
|
-
}
|
|
1452
|
-
scan(options = {}) {
|
|
1453
|
-
const now = this.clock();
|
|
1454
|
-
const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
|
|
1455
|
-
const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
|
|
1456
|
-
const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
|
|
1457
|
-
const candidates = this.database.prepare(`
|
|
1458
|
-
SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
|
|
1459
|
-
FROM drones AS drone
|
|
1460
|
-
JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
|
|
1461
|
-
JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
|
|
1462
|
-
AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
|
|
1463
|
-
LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
|
|
1464
|
-
WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
|
|
1465
|
-
AND COALESCE(state.attempts, 0) < 3
|
|
1466
|
-
AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
|
|
1467
|
-
AND EXISTS (SELECT 1 FROM drone_sessions AS session
|
|
1468
|
-
WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
|
|
1469
|
-
ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
|
|
1470
|
-
`).all(silentBefore, cooldownBefore, now.toISOString(), limit);
|
|
1471
|
-
const published = [];
|
|
1472
|
-
for (const candidate of candidates) {
|
|
1473
|
-
const droneId = requiredText(candidate, "drone_id");
|
|
1474
|
-
const cubeId = requiredText(candidate, "cube_id");
|
|
1475
|
-
const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
|
|
1476
|
-
this.capacityGuard.assertCanGrow(256);
|
|
1477
|
-
const id = randomUUID();
|
|
1478
|
-
const createdAt = now.toISOString();
|
|
1479
|
-
this.database.exec("BEGIN IMMEDIATE");
|
|
1480
|
-
try {
|
|
1481
|
-
this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
|
|
1482
|
-
VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
|
|
1483
|
-
SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
|
|
1484
|
-
`).run(droneId, now.toISOString());
|
|
1485
|
-
this.database.exec("COMMIT");
|
|
1486
|
-
}
|
|
1487
|
-
catch (error) {
|
|
1488
|
-
try {
|
|
1489
|
-
this.database.exec("ROLLBACK");
|
|
1490
|
-
}
|
|
1491
|
-
catch { /* Preserve original failure. */ }
|
|
1492
|
-
throw error;
|
|
1493
|
-
}
|
|
1494
|
-
const entry = {
|
|
1495
|
-
kind: "heartbeat_ping",
|
|
1496
|
-
id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
|
|
1497
|
-
drone_label: null, role_name: null, recipient_drone_ids: [droneId],
|
|
1498
|
-
};
|
|
1499
|
-
this.hub.publish(cubeId, entry);
|
|
1500
|
-
published.push(entry);
|
|
1501
|
-
}
|
|
1502
|
-
return published;
|
|
1644
|
+
class IdleLivenessStore {
|
|
1645
|
+
scan() {
|
|
1646
|
+
// Silence is not a delivery failure. Keep the scheduler's lifecycle seam without
|
|
1647
|
+
// creating a directed event that crosses the client/model wake boundary.
|
|
1648
|
+
return [];
|
|
1503
1649
|
}
|
|
1504
1650
|
}
|
|
1505
1651
|
class SqliteCredentialStore {
|
|
@@ -1794,7 +1940,7 @@ class SqliteCredentialStore {
|
|
|
1794
1940
|
credential.session_id, session.client_id, session.cube_id, session.drone_id,
|
|
1795
1941
|
session.expires_at,
|
|
1796
1942
|
COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
|
|
1797
|
-
drone.evicted_at
|
|
1943
|
+
drone.evicted_at, session.superseded_at
|
|
1798
1944
|
FROM drone_session_credentials AS credential
|
|
1799
1945
|
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
1800
1946
|
JOIN clients AS client ON client.id = session.client_id
|
|
@@ -2225,6 +2371,7 @@ function storedDroneSessionDigest(row) {
|
|
|
2225
2371
|
droneId: requiredText(row, "drone_id"),
|
|
2226
2372
|
expiresAt: requiredText(row, "expires_at"),
|
|
2227
2373
|
evictedAt: nullableText(row, "evicted_at"),
|
|
2374
|
+
takenOver: nullableText(row, "superseded_at") !== null,
|
|
2228
2375
|
};
|
|
2229
2376
|
}
|
|
2230
2377
|
function requiredBuffer(row, key) {
|