@capybara-hq/kernel 0.1.0-rc.8 → 0.1.0-rc.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/background/finish.js +2 -1
  2. package/dist/background/launch.d.ts +13 -7
  3. package/dist/background/launch.js +15 -8
  4. package/dist/background/poll.job.d.ts +1 -1
  5. package/dist/background/poll.job.js +8 -3
  6. package/dist/background/session.d.ts +2 -2
  7. package/dist/background/session.js +3 -2
  8. package/dist/background/status.d.ts +1 -1
  9. package/dist/background/status.js +1 -1
  10. package/dist/background/stop.js +4 -3
  11. package/dist/background/store.d.ts +6 -1
  12. package/dist/background/store.js +5 -1
  13. package/dist/background/tasks.service.d.ts +1 -1
  14. package/dist/background/tasks.service.js +4 -4
  15. package/dist/catalog/marketplaces.service.d.ts +1 -1
  16. package/dist/config/docs.js +2 -0
  17. package/dist/config/schema.d.ts +7 -0
  18. package/dist/config/schema.js +45 -0
  19. package/dist/conversations/event-log.d.ts +4 -3
  20. package/dist/conversations/event-log.js +4 -3
  21. package/dist/conversations/events.d.ts +9 -0
  22. package/dist/conversations/events.js +14 -0
  23. package/dist/db/kernel-schema.d.ts +26 -1
  24. package/dist/devices/device-auth.controller.d.ts +42 -0
  25. package/dist/devices/device-auth.controller.js +116 -0
  26. package/dist/devices/device-auth.d.ts +119 -0
  27. package/dist/devices/device-auth.js +403 -0
  28. package/dist/devices/device-auth.module.d.ts +7 -0
  29. package/dist/devices/device-auth.module.js +25 -0
  30. package/dist/devices/device-gateway.d.ts +128 -0
  31. package/dist/devices/device-gateway.js +530 -0
  32. package/dist/devices/device-provider.d.ts +89 -0
  33. package/dist/devices/device-provider.js +151 -0
  34. package/dist/devices/device-routes.d.ts +36 -0
  35. package/dist/devices/device-routes.js +90 -0
  36. package/dist/devices/devices.module.d.ts +16 -0
  37. package/dist/devices/devices.module.js +44 -0
  38. package/dist/devices/dto.d.ts +66 -0
  39. package/dist/devices/dto.js +44 -0
  40. package/dist/devices/keys.d.ts +25 -0
  41. package/dist/devices/keys.js +25 -0
  42. package/dist/devices/machines.d.ts +106 -0
  43. package/dist/devices/machines.js +301 -0
  44. package/dist/devices/relay.d.ts +100 -0
  45. package/dist/devices/relay.js +373 -0
  46. package/dist/devices/targets.d.ts +28 -0
  47. package/dist/devices/targets.js +73 -0
  48. package/dist/environments/access.d.ts +11 -6
  49. package/dist/environments/access.js +23 -8
  50. package/dist/environments/conversation-environments.d.ts +20 -6
  51. package/dist/environments/conversation-environments.js +70 -9
  52. package/dist/environments/environments.controller.d.ts +4 -1
  53. package/dist/environments/environments.controller.js +15 -1
  54. package/dist/environments/environments.module.js +3 -1
  55. package/dist/environments/environments.service.d.ts +54 -7
  56. package/dist/environments/environments.service.js +104 -20
  57. package/dist/environments/legacy-cleanup.job.d.ts +1 -1
  58. package/dist/environments/legacy-cleanup.job.js +4 -4
  59. package/dist/errors.d.ts +24 -0
  60. package/dist/errors.js +24 -0
  61. package/dist/evals/recorded-tools.d.ts +9 -0
  62. package/dist/evals/recorded-tools.js +27 -0
  63. package/dist/http/api.module.js +4 -0
  64. package/dist/http/gateway-routes.d.ts +7 -3
  65. package/dist/http/gateway-routes.js +6 -3
  66. package/dist/infra/config/kernel-config.service.js +2 -0
  67. package/dist/infra/throttler/named-throttlers.d.ts +5 -0
  68. package/dist/infra/throttler/named-throttlers.js +38 -0
  69. package/dist/kernel.module.js +3 -0
  70. package/dist/lifecycle/kernel-lifecycle.js +5 -0
  71. package/dist/platform/exclusions.js +33 -0
  72. package/dist/platform/ops/environments.ops.js +21 -3
  73. package/dist/roles/gateway-role.d.ts +14 -3
  74. package/dist/roles/gateway-role.js +25 -5
  75. package/dist/roles/gateway.module.d.ts +2 -1
  76. package/dist/roles/gateway.module.js +6 -3
  77. package/dist/runtime/context/build.d.ts +2 -0
  78. package/dist/runtime/context/build.js +14 -0
  79. package/dist/runtime/context/compaction.js +1 -0
  80. package/dist/runtime/context/environment-paths.d.ts +40 -0
  81. package/dist/runtime/context/environment-paths.js +48 -0
  82. package/dist/runtime/model-call.js +2 -0
  83. package/dist/sandbox/providers.d.ts +29 -7
  84. package/dist/sandbox/providers.js +43 -8
  85. package/dist/sandbox/sandbox.service.d.ts +70 -18
  86. package/dist/sandbox/sandbox.service.js +176 -59
  87. package/dist/tools/builtins/sandbox/bash.d.ts +2 -2
  88. package/dist/tools/builtins/sandbox/bash.js +39 -27
  89. package/dist/tools/builtins/sandbox/common.d.ts +60 -25
  90. package/dist/tools/builtins/sandbox/common.js +121 -30
  91. package/dist/tools/builtins/sandbox/deliver.js +9 -6
  92. package/dist/tools/builtins/sandbox/edit.js +40 -37
  93. package/dist/tools/builtins/sandbox/glob.js +13 -8
  94. package/dist/tools/builtins/sandbox/grep.js +10 -6
  95. package/dist/tools/builtins/sandbox/index.d.ts +6 -4
  96. package/dist/tools/builtins/sandbox/index.js +6 -4
  97. package/dist/tools/builtins/sandbox/read.js +9 -6
  98. package/dist/tools/builtins/sandbox/task-output.d.ts +7 -3
  99. package/dist/tools/builtins/sandbox/task-output.js +14 -10
  100. package/dist/tools/builtins/sandbox/task-stop.js +3 -3
  101. package/dist/tools/builtins/sandbox/write.js +33 -30
  102. package/dist/tools/builtins/skill-load.d.ts +8 -5
  103. package/dist/tools/builtins/skill-load.js +21 -15
  104. package/dist/tools/builtins/skill-write.js +2 -2
  105. package/dist/tools/pipeline.js +2 -0
  106. package/dist/tools/registry.d.ts +21 -4
  107. package/dist/tools/registry.js +10 -2
  108. package/package.json +5 -2
  109. package/src/migrations/0093_connected_environments.sql +37 -0
  110. package/src/migrations/0094_environment_paths_idx.sql +7 -0
@@ -0,0 +1,128 @@
1
+ import { type ExtensionContext } from "@capybara-hq/plugin-sdk";
2
+ import type { WebSocket } from "@fastify/websocket";
3
+ import type { Redis } from "ioredis";
4
+ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
5
+ import { LockService } from "../infra/lock/lock.service.js";
6
+ import type { KernelLogger } from "../log.js";
7
+ import { type MachineIdentity, MachinesService } from "./machines.js";
8
+ /** WebSocket close codes the gateway uses (the program reads 4002 and 4003). */
9
+ export declare const DEVICE_CLOSE: {
10
+ /** The kernel is shutting down: reconnect (another gateway replica takes over). */
11
+ readonly shutdown: 1001;
12
+ /** A frame larger than `environments.device.maxFrameBytes` (sent by `ws` itself). */
13
+ readonly tooBig: 1009;
14
+ /** The kernel failed (e.g. the database): reconnect. */
15
+ readonly internal: 1011;
16
+ /** The lease was lost (renewal kept failing or the key was taken): reconnect. */
17
+ readonly leaseLost: 4001;
18
+ /** A newer connection of the environment took over: the program exits. */
19
+ readonly replaced: 4002;
20
+ /** The machine's credential was revoked: the program exits. */
21
+ readonly revoked: 4003;
22
+ /** No valid `hello` first, or a frame that is not protocol v1 from a device. */
23
+ readonly protocol: 4400;
24
+ /** The lease could not be claimed in time: reconnect. */
25
+ readonly claimTimeout: 4409;
26
+ };
27
+ /** How long the program has to send `hello` after the upgrade. */
28
+ export declare const HELLO_TIMEOUT_MS = 10000;
29
+ /** How often a connection is pinged; two pings without a pong close it. */
30
+ export declare const PING_INTERVAL_MS = 20000;
31
+ /** How long a new connection retries the lease after asking the holder to let go. */
32
+ export declare const CLAIM_TIMEOUT_MS = 5000;
33
+ /** The pause between two tries at the lease while claiming. */
34
+ export declare const CLAIM_RETRY_MS = 250;
35
+ type State = "hello" | "claiming" | "holding" | "closed";
36
+ /**
37
+ * One machine connection on this replica. Its state (the socket, the lease handle, the requests
38
+ * it forwarded) is the socket's own and lives exactly as long as the socket.
39
+ */
40
+ declare class DeviceConnection {
41
+ private readonly gw;
42
+ private readonly socket;
43
+ readonly identity: MachineIdentity;
44
+ private readonly credential;
45
+ readonly token: `${string}-${string}-${string}-${string}-${string}`;
46
+ state: State;
47
+ private lease;
48
+ private readonly unsubscribes;
49
+ private helloTimer;
50
+ private pingTimer;
51
+ private missedPongs;
52
+ private lastTouch;
53
+ private lastAuth;
54
+ /** The drains (and the close after a revocation) run one after another. */
55
+ private chain;
56
+ /** Resolves once the lease is handed back (or was never held). */
57
+ private released;
58
+ constructor(gw: DeviceGateway, socket: WebSocket, identity: MachineIdentity, credential: string);
59
+ get environmentId(): string;
60
+ start(): void;
61
+ private onFrame;
62
+ /**
63
+ * Stores one result and announces it (the relay's wire, relay.ts), only for a request taken for
64
+ * this environment (ruling R20); anything else is dropped.
65
+ */
66
+ private store;
67
+ /**
68
+ * Claims the environment's lease; true when this connection holds it. The newest connection
69
+ * wins (ruling R21): when another connection holds the lease, a takeover is published (the
70
+ * holder yields to any takeover) and the lease retried every {@link CLAIM_RETRY_MS} for
71
+ * {@link CLAIM_TIMEOUT_MS}, then 4409 (the program reconnects). A claimer never yields.
72
+ */
73
+ private claim;
74
+ /** Subscribes for this connection; a subscription that completes after the close is ended. */
75
+ private subscribe;
76
+ /** Queues one drain of the request list after the ones already queued. */
77
+ private drain;
78
+ private drainOnce;
79
+ /** The next ping (a chain of timeouts; the lease renewal is LockService's, not this timer's). */
80
+ private schedulePing;
81
+ private ping;
82
+ /** The credential still stands (membership, org and environment are read live). */
83
+ private reauthenticate;
84
+ private traffic;
85
+ /** Closes the socket with `code` and hands the lease back; idempotent. */
86
+ close(code: number, reason: string): Promise<void>;
87
+ /** Stops timers and subscriptions and releases the lease (a lost one is abandoned). */
88
+ shutdown(): Promise<void>;
89
+ }
90
+ /**
91
+ * The gateway role's half of the device relay (environments spec §13.4): holds the WebSocket
92
+ * connections of connected machines (`GET /device/connect`, device-routes.ts). One connection per
93
+ * environment across replicas, under the lease `capk:device-lease:<environmentId>` (the newest
94
+ * connection wins: it publishes `device.takeover.<environmentId>` and the holder lets go). The
95
+ * holder drains `capk:device-req:<environmentId>` on every `device.req.<environmentId>` nudge,
96
+ * once after claiming and on every ping, marking each forwarded request taken for its environment
97
+ * (`capk:device-taken:<id>`, TTL the reconnect grace plus the request's deadline plus a margin).
98
+ * A `res` is accepted only for a request taken for the socket's environment: it is stored under
99
+ * `capk:device-res:<id>` for the rest of that TTL and published on `device.res.<id>`.
100
+ * `device.revoked.<environmentId>` closes the connection of that machine only (ruling R16). The
101
+ * sockets are this process's own; everything other replicas need is in Redis.
102
+ */
103
+ export declare class DeviceGateway {
104
+ readonly machines: Pick<MachinesService, "authenticate" | "recordHello" | "touch">;
105
+ readonly locks: Pick<LockService, "hold">;
106
+ readonly bus: ExtensionContext["bus"];
107
+ readonly redis: Redis;
108
+ private readonly config;
109
+ readonly log: Pick<KernelLogger, "info" | "warn">;
110
+ /** This replica's open connections (the sockets cannot live anywhere else). */
111
+ private readonly connections;
112
+ private stopping;
113
+ /** How often connections are pinged ({@link PING_INTERVAL_MS}; a test seam). */
114
+ pingIntervalMs: number;
115
+ constructor(machines: Pick<MachinesService, "authenticate" | "recordHello" | "touch">, locks: Pick<LockService, "hold">, bus: ExtensionContext["bus"], redis: Redis, config: KernelConfigService, log: Pick<KernelLogger, "info" | "warn">);
116
+ graceMs(): number;
117
+ leaseTtlMs(): number;
118
+ maxFrameBytes(): number;
119
+ /** Who a `Bearer` machine credential stands for, or null (checked before the upgrade). */
120
+ authenticate(credential: string): Promise<MachineIdentity | null>;
121
+ /** Serves one upgraded socket of an authenticated machine until it closes. */
122
+ accept(socket: WebSocket, identity: MachineIdentity, credential: string): void;
123
+ /** @internal a connection that ended. */
124
+ forget(conn: DeviceConnection): void;
125
+ /** Closes every connection (1001) and hands their leases back. */
126
+ stop(): Promise<void>;
127
+ }
128
+ export {};
@@ -0,0 +1,530 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
+ return function (target, key) { decorator(target, key, paramIndex); }
9
+ };
10
+ import { randomUUID } from "node:crypto";
11
+ import { DEVICE_PROTOCOL_VERSION, parseDeviceFrame, } from "@capybara-hq/plugin-sdk";
12
+ import { Inject, Injectable } from "@nestjs/common";
13
+ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
14
+ import { LockService } from "../infra/lock/lock.service.js";
15
+ import { KERNEL_BUS, KERNEL_LOGGER, REDIS } from "../infra/tokens.js";
16
+ import { DEVICE_TAKEN_PREFIX, deviceLeaseKey, deviceReqKey, deviceReqTopic, deviceResKey, deviceResTopic, deviceRevokedTopic, deviceTakenKey, deviceTakeoverTopic, } from "./keys.js";
17
+ import { MachinesService } from "./machines.js";
18
+ import { LIST_TTL_MARGIN_MS } from "./relay.js";
19
+ /** WebSocket close codes the gateway uses (the program reads 4002 and 4003). */
20
+ export const DEVICE_CLOSE = {
21
+ /** The kernel is shutting down: reconnect (another gateway replica takes over). */
22
+ shutdown: 1001,
23
+ /** A frame larger than `environments.device.maxFrameBytes` (sent by `ws` itself). */
24
+ tooBig: 1009,
25
+ /** The kernel failed (e.g. the database): reconnect. */
26
+ internal: 1011,
27
+ /** The lease was lost (renewal kept failing or the key was taken): reconnect. */
28
+ leaseLost: 4001,
29
+ /** A newer connection of the environment took over: the program exits. */
30
+ replaced: 4002,
31
+ /** The machine's credential was revoked: the program exits. */
32
+ revoked: 4003,
33
+ /** No valid `hello` first, or a frame that is not protocol v1 from a device. */
34
+ protocol: 4400,
35
+ /** The lease could not be claimed in time: reconnect. */
36
+ claimTimeout: 4409,
37
+ };
38
+ /** How long the program has to send `hello` after the upgrade. */
39
+ export const HELLO_TIMEOUT_MS = 10_000;
40
+ /** How often a connection is pinged; two pings without a pong close it. */
41
+ export const PING_INTERVAL_MS = 20_000;
42
+ /** How long a new connection retries the lease after asking the holder to let go. */
43
+ export const CLAIM_TIMEOUT_MS = 5_000;
44
+ /** The pause between two tries at the lease while claiming. */
45
+ export const CLAIM_RETRY_MS = 250;
46
+ /** Traffic marks the machine seen at most this often per connection (the row has its own gate). */
47
+ const TOUCH_EVERY_MS = 30_000;
48
+ /** How often a held connection re-checks its credential (membership, org, environment). */
49
+ const REAUTH_EVERY_MS = 60_000;
50
+ /**
51
+ * Puts a frame the holder popped but could not send back at the head of the list, keeping the
52
+ * list's TTL at least `ARGV[2]` ms (the list never exists without one).
53
+ */
54
+ const PUT_BACK = `
55
+ redis.call('LPUSH', KEYS[1], ARGV[1])
56
+ if redis.call('PTTL', KEYS[1]) < tonumber(ARGV[2]) then
57
+ redis.call('PEXPIRE', KEYS[1], ARGV[2])
58
+ end
59
+ return 1
60
+ `;
61
+ /**
62
+ * Pops the next frame of the request list (`KEYS[1]`) and, when it is a `req`, marks it taken in
63
+ * the same step: `<ARGV[1]><id>` → the environment id `ARGV[2]`, for the reconnect grace
64
+ * `ARGV[3]` ms plus the request's deadline plus the margin `ARGV[4]` ms (ruling R20). Returns the
65
+ * frame, or false when the list is empty. A frame that is not JSON is returned as is (the caller
66
+ * drops it). The taken key cannot be declared in KEYS (its id is known only after the pop): like
67
+ * the rest of the kernel, this assumes a single-node Valkey / Redis, not a cluster.
68
+ */
69
+ const TAKE = `
70
+ local raw = redis.call('LPOP', KEYS[1])
71
+ if not raw then return false end
72
+ local ok, f = pcall(cjson.decode, raw)
73
+ if ok and type(f) == 'table' and f.type == 'req' and type(f.id) == 'string'
74
+ and type(f.deadlineMs) == 'number' and f.deadlineMs > 0 then
75
+ local ttl = tonumber(ARGV[3]) + math.ceil(f.deadlineMs) + tonumber(ARGV[4])
76
+ redis.call('SET', ARGV[1] .. f.id, ARGV[2], 'PX', ttl)
77
+ end
78
+ return raw
79
+ `;
80
+ /**
81
+ * Accepts a result (ruling R20): only when its request is marked taken (`KEYS[1]`) for the
82
+ * environment `ARGV[1]`. Stores the frame `ARGV[2]` under `KEYS[2]` for what is left of the taken
83
+ * mark's TTL (the caller's whole window, also after a reconnect) and clears the mark. Returns 1
84
+ * when stored, 0 otherwise.
85
+ */
86
+ const ACCEPT = `
87
+ if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
88
+ local ttl = redis.call('PTTL', KEYS[1])
89
+ if ttl <= 0 then return 0 end
90
+ redis.call('SET', KEYS[2], ARGV[2], 'PX', ttl)
91
+ redis.call('DEL', KEYS[1])
92
+ return 1
93
+ `;
94
+ const errMsg = (e) => (e instanceof Error ? e.message : String(e));
95
+ /**
96
+ * One machine connection on this replica. Its state (the socket, the lease handle, the requests
97
+ * it forwarded) is the socket's own and lives exactly as long as the socket.
98
+ */
99
+ class DeviceConnection {
100
+ gw;
101
+ socket;
102
+ identity;
103
+ credential;
104
+ token = randomUUID();
105
+ state = "hello";
106
+ lease;
107
+ unsubscribes = [];
108
+ helloTimer;
109
+ pingTimer;
110
+ missedPongs = 0;
111
+ lastTouch = 0;
112
+ lastAuth = Date.now();
113
+ /** The drains (and the close after a revocation) run one after another. */
114
+ chain = Promise.resolve();
115
+ /** Resolves once the lease is handed back (or was never held). */
116
+ released;
117
+ constructor(gw, socket, identity, credential) {
118
+ this.gw = gw;
119
+ this.socket = socket;
120
+ this.identity = identity;
121
+ this.credential = credential;
122
+ }
123
+ get environmentId() {
124
+ return this.identity.environmentId;
125
+ }
126
+ start() {
127
+ const { socket } = this;
128
+ this.helloTimer = setTimeout(() => this.close(DEVICE_CLOSE.protocol, "hello expected"), HELLO_TIMEOUT_MS);
129
+ this.schedulePing();
130
+ socket.on("pong", () => {
131
+ this.missedPongs = 0;
132
+ this.traffic();
133
+ });
134
+ socket.on("message", (data, isBinary) => {
135
+ if (this.state === "closed")
136
+ return;
137
+ this.traffic();
138
+ if (isBinary) {
139
+ this.close(DEVICE_CLOSE.protocol, "frames are JSON text");
140
+ return;
141
+ }
142
+ const text = Array.isArray(data)
143
+ ? Buffer.concat(data).toString("utf8")
144
+ : Buffer.isBuffer(data)
145
+ ? data.toString("utf8")
146
+ : Buffer.from(data).toString("utf8");
147
+ void this.onFrame(text);
148
+ });
149
+ socket.on("close", () => {
150
+ void this.shutdown();
151
+ });
152
+ }
153
+ async onFrame(text) {
154
+ let frame;
155
+ try {
156
+ frame = parseDeviceFrame(text, "device");
157
+ }
158
+ catch (e) {
159
+ this.gw.log.warn("device sent an invalid frame", {
160
+ environmentId: this.environmentId,
161
+ error: errMsg(e),
162
+ });
163
+ this.close(DEVICE_CLOSE.protocol, "invalid frame");
164
+ return;
165
+ }
166
+ if (frame.type === "hello") {
167
+ if (this.state !== "hello") {
168
+ this.close(DEVICE_CLOSE.protocol, "hello sent twice");
169
+ return;
170
+ }
171
+ if (frame.protocol !== DEVICE_PROTOCOL_VERSION) {
172
+ this.close(DEVICE_CLOSE.protocol, "unsupported protocol");
173
+ return;
174
+ }
175
+ clearTimeout(this.helloTimer);
176
+ this.state = "claiming";
177
+ if (!(await this.claim()))
178
+ return;
179
+ try {
180
+ await this.gw.machines.recordHello(this.identity.machineId, frame);
181
+ }
182
+ catch (e) {
183
+ this.gw.log.warn("could not record a device hello", {
184
+ environmentId: this.environmentId,
185
+ error: errMsg(e),
186
+ });
187
+ }
188
+ return;
189
+ }
190
+ if (frame.type !== "res" || this.state === "hello") {
191
+ this.close(DEVICE_CLOSE.protocol, "hello expected");
192
+ return;
193
+ }
194
+ // results are stored in any state after hello: the program sends those it kept while
195
+ // disconnected right after its hello, before the lease is claimed
196
+ await this.store(frame);
197
+ }
198
+ /**
199
+ * Stores one result and announces it (the relay's wire, relay.ts), only for a request taken for
200
+ * this environment (ruling R20); anything else is dropped.
201
+ */
202
+ async store(res) {
203
+ try {
204
+ const stored = await this.gw.redis.eval(ACCEPT, 2, deviceTakenKey(res.id), deviceResKey(res.id), this.environmentId, JSON.stringify(res));
205
+ if (Number(stored) !== 1) {
206
+ this.gw.log.warn("dropping a device result for a request not taken for its environment", {
207
+ environmentId: this.environmentId,
208
+ });
209
+ return;
210
+ }
211
+ await this.gw.bus.publish(deviceResTopic(res.id), res);
212
+ }
213
+ catch (e) {
214
+ this.gw.log.warn("could not store a device result", {
215
+ environmentId: this.environmentId,
216
+ error: errMsg(e),
217
+ });
218
+ }
219
+ }
220
+ /**
221
+ * Claims the environment's lease; true when this connection holds it. The newest connection
222
+ * wins (ruling R21): when another connection holds the lease, a takeover is published (the
223
+ * holder yields to any takeover) and the lease retried every {@link CLAIM_RETRY_MS} for
224
+ * {@link CLAIM_TIMEOUT_MS}, then 4409 (the program reconnects). A claimer never yields.
225
+ */
226
+ async claim() {
227
+ const env = this.environmentId;
228
+ try {
229
+ // subscribed before claiming, so a takeover right after the claim is not missed
230
+ await this.subscribe(deviceTakeoverTopic(env), (p) => {
231
+ const token = p?.token;
232
+ if (this.state === "holding" && token !== this.token) {
233
+ this.close(DEVICE_CLOSE.replaced, "replaced by a newer connection");
234
+ }
235
+ });
236
+ if (this.state !== "claiming")
237
+ return false;
238
+ const ttl = this.gw.leaseTtlMs();
239
+ let lease = await this.gw.locks.hold(deviceLeaseKey(env), this.token, ttl);
240
+ if (!lease) {
241
+ await this.gw.bus.publish(deviceTakeoverTopic(env), { token: this.token });
242
+ const until = Date.now() + CLAIM_TIMEOUT_MS;
243
+ while (!lease && this.state === "claiming" && Date.now() < until) {
244
+ await new Promise((r) => setTimeout(r, CLAIM_RETRY_MS));
245
+ if (this.state !== "claiming")
246
+ break;
247
+ lease = await this.gw.locks.hold(deviceLeaseKey(env), this.token, ttl);
248
+ }
249
+ }
250
+ if (this.state !== "claiming") {
251
+ // closed while claiming (replaced, revoked, shutdown, the machine left)
252
+ await lease?.release();
253
+ return false;
254
+ }
255
+ if (!lease) {
256
+ this.close(DEVICE_CLOSE.claimTimeout, "the environment is held by another connection");
257
+ return false;
258
+ }
259
+ this.lease = lease;
260
+ this.state = "holding";
261
+ const onLost = () => this.close(DEVICE_CLOSE.leaseLost, "lease lost");
262
+ if (lease.lost.aborted) {
263
+ onLost();
264
+ return false;
265
+ }
266
+ lease.lost.addEventListener("abort", onLost, { once: true });
267
+ await this.subscribe(deviceReqTopic(env), () => this.drain());
268
+ await this.subscribe(deviceRevokedTopic(env), (p) => {
269
+ const id = p?.machineId;
270
+ if (id !== this.identity.machineId)
271
+ return;
272
+ // one more drain first: a stop frame whose nudge was lost still goes out before the close
273
+ this.drain();
274
+ this.chain = this.chain.then(() => this.close(DEVICE_CLOSE.revoked, "the machine was disconnected"));
275
+ });
276
+ if (this.state !== "holding")
277
+ return false;
278
+ this.gw.log.info("device connected", {
279
+ environmentId: env,
280
+ machineId: this.identity.machineId,
281
+ });
282
+ // what was queued while nobody held the machine
283
+ this.drain();
284
+ return true;
285
+ }
286
+ catch (e) {
287
+ this.gw.log.warn("could not claim a device connection", {
288
+ environmentId: env,
289
+ error: errMsg(e),
290
+ });
291
+ this.close(DEVICE_CLOSE.internal, "internal error");
292
+ return false;
293
+ }
294
+ }
295
+ /** Subscribes for this connection; a subscription that completes after the close is ended. */
296
+ async subscribe(topic, handler) {
297
+ const unsubscribe = await this.gw.bus.subscribe(topic, handler);
298
+ if (this.state === "closed")
299
+ await unsubscribe().catch(() => { });
300
+ else
301
+ this.unsubscribes.push(unsubscribe);
302
+ }
303
+ /** Queues one drain of the request list after the ones already queued. */
304
+ drain() {
305
+ this.chain = this.chain
306
+ .then(() => this.drainOnce())
307
+ .catch((e) => {
308
+ this.gw.log.warn("device request drain failed", {
309
+ environmentId: this.environmentId,
310
+ error: errMsg(e),
311
+ });
312
+ });
313
+ }
314
+ async drainOnce() {
315
+ const key = deviceReqKey(this.environmentId);
316
+ while (this.state === "holding") {
317
+ // no lease fencing needed: the pop is atomic, so each frame reaches one connection only, and
318
+ // a holder that lost its lease still talks to the environment's own machine (or puts it back)
319
+ const raw = (await this.gw.redis.eval(TAKE, 1, key, DEVICE_TAKEN_PREFIX, this.environmentId, String(this.gw.graceMs()), String(LIST_TTL_MARGIN_MS)));
320
+ if (raw === null)
321
+ return;
322
+ let frame;
323
+ try {
324
+ frame = parseDeviceFrame(raw, "kernel");
325
+ }
326
+ catch (e) {
327
+ this.gw.log.warn("dropping an invalid device request", {
328
+ environmentId: this.environmentId,
329
+ error: errMsg(e),
330
+ });
331
+ continue;
332
+ }
333
+ const ttlMs = this.gw.graceMs() + (frame.type === "req" ? frame.deadlineMs : 0) + LIST_TTL_MARGIN_MS;
334
+ if (this.state !== "holding" || this.socket.readyState !== this.socket.OPEN) {
335
+ // closed while popping: back to the head of the list for the next connection
336
+ await this.gw.redis.eval(PUT_BACK, 1, key, raw, String(ttlMs));
337
+ return;
338
+ }
339
+ if (frame.type === "req" && Buffer.byteLength(raw) > this.gw.maxFrameBytes()) {
340
+ await this.store({
341
+ type: "res",
342
+ id: frame.id,
343
+ ok: false,
344
+ error: {
345
+ kind: "error",
346
+ message: "The request is larger than environments.device.maxFrameBytes",
347
+ },
348
+ });
349
+ continue;
350
+ }
351
+ this.socket.send(raw);
352
+ }
353
+ }
354
+ /** The next ping (a chain of timeouts; the lease renewal is LockService's, not this timer's). */
355
+ schedulePing() {
356
+ this.pingTimer = setTimeout(() => {
357
+ this.ping();
358
+ if (this.state !== "closed")
359
+ this.schedulePing();
360
+ }, this.gw.pingIntervalMs);
361
+ }
362
+ ping() {
363
+ if (this.state === "closed")
364
+ return;
365
+ if (this.missedPongs >= 2) {
366
+ this.gw.log.info("device connection missed two pongs", { environmentId: this.environmentId });
367
+ this.socket.terminate();
368
+ void this.shutdown();
369
+ return;
370
+ }
371
+ this.missedPongs++;
372
+ this.socket.ping();
373
+ // a backstop for a lost nudge (the bus delivers at most once)
374
+ if (this.state === "holding")
375
+ this.drain();
376
+ if (this.state === "holding" && Date.now() - this.lastAuth >= REAUTH_EVERY_MS) {
377
+ this.lastAuth = Date.now();
378
+ void this.reauthenticate();
379
+ }
380
+ }
381
+ /** The credential still stands (membership, org and environment are read live). */
382
+ async reauthenticate() {
383
+ try {
384
+ const id = await this.gw.machines.authenticate(this.credential);
385
+ if (id === null || id.machineId !== this.identity.machineId) {
386
+ this.close(DEVICE_CLOSE.revoked, "the machine was disconnected");
387
+ }
388
+ }
389
+ catch (e) {
390
+ this.gw.log.warn("could not re-check a device credential", {
391
+ environmentId: this.environmentId,
392
+ error: errMsg(e),
393
+ });
394
+ }
395
+ }
396
+ traffic() {
397
+ const now = Date.now();
398
+ if (now - this.lastTouch < TOUCH_EVERY_MS)
399
+ return;
400
+ this.lastTouch = now;
401
+ this.gw.machines.touch(this.identity.machineId).catch((e) => {
402
+ this.gw.log.warn("could not mark a machine seen", {
403
+ environmentId: this.environmentId,
404
+ error: errMsg(e),
405
+ });
406
+ });
407
+ }
408
+ /** Closes the socket with `code` and hands the lease back; idempotent. */
409
+ close(code, reason) {
410
+ if (this.state !== "closed") {
411
+ try {
412
+ this.socket.close(code, reason);
413
+ }
414
+ catch {
415
+ this.socket.terminate();
416
+ }
417
+ }
418
+ return this.shutdown();
419
+ }
420
+ /** Stops timers and subscriptions and releases the lease (a lost one is abandoned). */
421
+ shutdown() {
422
+ if (this.released)
423
+ return this.released;
424
+ this.state = "closed";
425
+ clearTimeout(this.helloTimer);
426
+ clearTimeout(this.pingTimer);
427
+ const lease = this.lease;
428
+ this.released = (async () => {
429
+ try {
430
+ if (lease) {
431
+ if (lease.lost.aborted)
432
+ lease.abandon();
433
+ else
434
+ await lease.release();
435
+ }
436
+ }
437
+ catch (e) {
438
+ this.gw.log.warn("could not release a device lease", {
439
+ environmentId: this.environmentId,
440
+ error: errMsg(e),
441
+ });
442
+ }
443
+ await Promise.all(this.unsubscribes.splice(0).map((u) => u().catch(() => { })));
444
+ this.gw.forget(this);
445
+ if (lease) {
446
+ this.gw.log.info("device disconnected", {
447
+ environmentId: this.environmentId,
448
+ machineId: this.identity.machineId,
449
+ });
450
+ }
451
+ })();
452
+ return this.released;
453
+ }
454
+ }
455
+ /**
456
+ * The gateway role's half of the device relay (environments spec §13.4): holds the WebSocket
457
+ * connections of connected machines (`GET /device/connect`, device-routes.ts). One connection per
458
+ * environment across replicas, under the lease `capk:device-lease:<environmentId>` (the newest
459
+ * connection wins: it publishes `device.takeover.<environmentId>` and the holder lets go). The
460
+ * holder drains `capk:device-req:<environmentId>` on every `device.req.<environmentId>` nudge,
461
+ * once after claiming and on every ping, marking each forwarded request taken for its environment
462
+ * (`capk:device-taken:<id>`, TTL the reconnect grace plus the request's deadline plus a margin).
463
+ * A `res` is accepted only for a request taken for the socket's environment: it is stored under
464
+ * `capk:device-res:<id>` for the rest of that TTL and published on `device.res.<id>`.
465
+ * `device.revoked.<environmentId>` closes the connection of that machine only (ruling R16). The
466
+ * sockets are this process's own; everything other replicas need is in Redis.
467
+ */
468
+ let DeviceGateway = class DeviceGateway {
469
+ machines;
470
+ locks;
471
+ bus;
472
+ redis;
473
+ config;
474
+ log;
475
+ /** This replica's open connections (the sockets cannot live anywhere else). */
476
+ connections = new Set();
477
+ stopping = false;
478
+ /** How often connections are pinged ({@link PING_INTERVAL_MS}; a test seam). */
479
+ pingIntervalMs = PING_INTERVAL_MS;
480
+ constructor(machines, locks, bus, redis, config, log) {
481
+ this.machines = machines;
482
+ this.locks = locks;
483
+ this.bus = bus;
484
+ this.redis = redis;
485
+ this.config = config;
486
+ this.log = log;
487
+ }
488
+ graceMs() {
489
+ return this.config.get().environments.device.reconnectGraceSeconds * 1000;
490
+ }
491
+ leaseTtlMs() {
492
+ return this.config.get().environments.device.leaseTtlMs;
493
+ }
494
+ maxFrameBytes() {
495
+ return this.config.get().environments.device.maxFrameBytes;
496
+ }
497
+ /** Who a `Bearer` machine credential stands for, or null (checked before the upgrade). */
498
+ authenticate(credential) {
499
+ return this.machines.authenticate(credential);
500
+ }
501
+ /** Serves one upgraded socket of an authenticated machine until it closes. */
502
+ accept(socket, identity, credential) {
503
+ if (this.stopping) {
504
+ socket.close(DEVICE_CLOSE.shutdown, "shutting down");
505
+ return;
506
+ }
507
+ const conn = new DeviceConnection(this, socket, identity, credential);
508
+ this.connections.add(conn);
509
+ conn.start();
510
+ }
511
+ /** @internal a connection that ended. */
512
+ forget(conn) {
513
+ this.connections.delete(conn);
514
+ }
515
+ /** Closes every connection (1001) and hands their leases back. */
516
+ async stop() {
517
+ this.stopping = true;
518
+ await Promise.all([...this.connections].map((c) => c.close(DEVICE_CLOSE.shutdown, "shutting down")));
519
+ }
520
+ };
521
+ DeviceGateway = __decorate([
522
+ Injectable(),
523
+ __param(0, Inject(MachinesService)),
524
+ __param(1, Inject(LockService)),
525
+ __param(2, Inject(KERNEL_BUS)),
526
+ __param(3, Inject(REDIS)),
527
+ __param(4, Inject(KernelConfigService)),
528
+ __param(5, Inject(KERNEL_LOGGER))
529
+ ], DeviceGateway);
530
+ export { DeviceGateway };