@checkstack/satellite-backend 0.3.6 → 0.5.0

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.
@@ -2,10 +2,12 @@ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
2
  import {
3
3
  SatelliteWsHandler,
4
4
  type SatelliteResultHandler,
5
+ type SatelliteScriptPackageSink,
5
6
  } from "./satellite-ws-handler";
6
7
  import { createMockLogger } from "@checkstack/test-utils-backend";
7
8
  import type { SatelliteService } from "./service";
8
9
  import type { ConfigRelay } from "./config-relay";
10
+ import type { SatelliteConnectionEvent } from "./entity";
9
11
  import type { SatelliteWithStatus } from "@checkstack/satellite-common";
10
12
 
11
13
  const MOCK_SATELLITE: SatelliteWithStatus = {
@@ -262,4 +264,269 @@ describe("SatelliteWsHandler", () => {
262
264
  await handler.pushConfigUpdate("non-existent");
263
265
  });
264
266
  });
267
+
268
+ describe("script-package distribution", () => {
269
+ function makeSink(
270
+ lockfileHash: string | null,
271
+ ): {
272
+ sink: SatelliteScriptPackageSink;
273
+ reports: Parameters<SatelliteScriptPackageSink["reportSyncState"]>[0][];
274
+ } {
275
+ const reports: Parameters<
276
+ SatelliteScriptPackageSink["reportSyncState"]
277
+ >[0][] = [];
278
+ return {
279
+ reports,
280
+ sink: {
281
+ getDesiredLockfileHash: mock(async () => lockfileHash),
282
+ reportSyncState: mock(async (input) => {
283
+ reports.push(input);
284
+ }),
285
+ getManifest: mock(async () => [
286
+ { name: "leftpad", version: "0.0.1", integrity: "sha-1" },
287
+ ]),
288
+ getBlobBase64: mock(async () => "YmxvYg=="),
289
+ },
290
+ };
291
+ }
292
+
293
+ async function authedHandlerWithSink(lockfileHash: string | null) {
294
+ const { sink, reports } = makeSink(lockfileHash);
295
+ const h = new SatelliteWsHandler(
296
+ service,
297
+ configRelay,
298
+ resultHandler,
299
+ logger,
300
+ undefined,
301
+ sink,
302
+ );
303
+ const ws = createMockWs();
304
+ const { onMessage } = h.onConnection(ws);
305
+ await onMessage(
306
+ JSON.stringify({
307
+ type: "authenticate",
308
+ clientId: "sat-1",
309
+ token: "csat_valid-token",
310
+ }),
311
+ );
312
+ return { h, ws, onMessage, reports };
313
+ }
314
+
315
+ it("carries the desired lockfile hash in the authenticated payload", async () => {
316
+ const { ws } = await authedHandlerWithSink("hash-123");
317
+ const auth = JSON.parse(ws.messages[0]);
318
+ expect(auth.type).toBe("authenticated");
319
+ expect(auth.scriptPackagesLockfileHash).toBe("hash-123");
320
+ });
321
+
322
+ it("omits the hash entirely when no sink is wired (version-skew safe)", async () => {
323
+ // Default `handler` has no script-package sink.
324
+ const ws = createMockWs();
325
+ const { onMessage } = handler.onConnection(ws);
326
+ await onMessage(
327
+ JSON.stringify({
328
+ type: "authenticate",
329
+ clientId: "sat-1",
330
+ token: "csat_valid-token",
331
+ }),
332
+ );
333
+ const auth = JSON.parse(ws.messages[0]);
334
+ expect("scriptPackagesLockfileHash" in auth).toBe(false);
335
+ });
336
+
337
+ it("fans refresh_script_packages out to every connected satellite", async () => {
338
+ const { h, ws } = await authedHandlerWithSink("hash-123");
339
+ ws.messages.length = 0;
340
+
341
+ h.pushRefreshScriptPackagesToAll("hash-456");
342
+
343
+ expect(ws.messages).toHaveLength(1);
344
+ const msg = JSON.parse(ws.messages[0]);
345
+ expect(msg.type).toBe("refresh_script_packages");
346
+ expect(msg.lockfileHash).toBe("hash-456");
347
+ });
348
+
349
+ it("persists a satellite's reported sync state", async () => {
350
+ const { onMessage, reports } = await authedHandlerWithSink("hash-123");
351
+ await onMessage(
352
+ JSON.stringify({
353
+ type: "script_package_sync_state",
354
+ lockfileHash: "hash-123",
355
+ status: "ready",
356
+ }),
357
+ );
358
+ expect(reports).toHaveLength(1);
359
+ expect(reports[0]).toMatchObject({
360
+ satelliteId: "sat-1",
361
+ lockfileHash: "hash-123",
362
+ status: "ready",
363
+ });
364
+ });
365
+
366
+ it("answers a manifest request over the WS channel", async () => {
367
+ const { ws, onMessage } = await authedHandlerWithSink("hash-123");
368
+ ws.messages.length = 0;
369
+ await onMessage(
370
+ JSON.stringify({
371
+ type: "request_script_package_manifest",
372
+ lockfileHash: "hash-123",
373
+ }),
374
+ );
375
+ const reply = JSON.parse(ws.messages[0]);
376
+ expect(reply.type).toBe("script_package_manifest");
377
+ expect(reply.entries[0].name).toBe("leftpad");
378
+ });
379
+
380
+ it("answers a blob request over the WS channel", async () => {
381
+ const { ws, onMessage } = await authedHandlerWithSink("hash-123");
382
+ ws.messages.length = 0;
383
+ await onMessage(
384
+ JSON.stringify({
385
+ type: "request_script_package_blob",
386
+ integrity: "sha-1",
387
+ }),
388
+ );
389
+ const reply = JSON.parse(ws.messages[0]);
390
+ expect(reply.type).toBe("script_package_blob");
391
+ expect(reply.data).toBe("YmxvYg==");
392
+ });
393
+ });
394
+
395
+ describe("connection-state entity mirror", () => {
396
+ function makeEntitySink() {
397
+ const mirrors: Array<{
398
+ satelliteId: string;
399
+ lastEvent: SatelliteConnectionEvent;
400
+ lastHeartbeatAt: Date | null;
401
+ }> = [];
402
+ return {
403
+ sink: {
404
+ mirror: mock(
405
+ async (input: {
406
+ satelliteId: string;
407
+ lastEvent: SatelliteConnectionEvent;
408
+ lastHeartbeatAt: Date | null;
409
+ }) => {
410
+ mirrors.push(input);
411
+ },
412
+ ),
413
+ },
414
+ mirrors,
415
+ };
416
+ }
417
+
418
+ it("drives the connected edge with lastHeartbeatAt=now on successful authentication", async () => {
419
+ const { sink, mirrors } = makeEntitySink();
420
+ const h = new SatelliteWsHandler(
421
+ service,
422
+ configRelay,
423
+ resultHandler,
424
+ logger,
425
+ sink,
426
+ );
427
+ const ws = createMockWs();
428
+ const { onMessage } = h.onConnection(ws);
429
+ await onMessage(
430
+ JSON.stringify({
431
+ type: "authenticate",
432
+ clientId: "sat-1",
433
+ token: "csat_valid-token",
434
+ }),
435
+ );
436
+
437
+ expect(mirrors).toHaveLength(1);
438
+ expect(mirrors[0]!.satelliteId).toBe("sat-1");
439
+ expect(mirrors[0]!.lastEvent).toBe("connected");
440
+ // lastHeartbeatAt = now (non-null) so the computed status reads online.
441
+ expect(mirrors[0]!.lastHeartbeatAt).toBeInstanceOf(Date);
442
+ });
443
+
444
+ it("does NOT also call updateHeartbeat on connect when a sink is wired (the mirror writes the heartbeat)", async () => {
445
+ const { sink } = makeEntitySink();
446
+ const h = new SatelliteWsHandler(
447
+ service,
448
+ configRelay,
449
+ resultHandler,
450
+ logger,
451
+ sink,
452
+ );
453
+ const ws = createMockWs();
454
+ const { onMessage } = h.onConnection(ws);
455
+ await onMessage(
456
+ JSON.stringify({
457
+ type: "authenticate",
458
+ clientId: "sat-1",
459
+ token: "csat_valid-token",
460
+ }),
461
+ );
462
+ // The connect-time heartbeat is written by the mirror's apply, not by a
463
+ // separate updateHeartbeat call.
464
+ expect(service.updateHeartbeat).not.toHaveBeenCalled();
465
+ });
466
+
467
+ it("writes the connect heartbeat directly when NO sink is wired", async () => {
468
+ // The default `handler` has no sink: it must still record the heartbeat.
469
+ const ws = createMockWs();
470
+ const { onMessage } = handler.onConnection(ws);
471
+ await onMessage(
472
+ JSON.stringify({
473
+ type: "authenticate",
474
+ clientId: "sat-1",
475
+ token: "csat_valid-token",
476
+ }),
477
+ );
478
+ expect(service.updateHeartbeat).toHaveBeenCalledWith("sat-1", {});
479
+ });
480
+
481
+ it("drives the disconnected edge with lastHeartbeatAt=null (immediate offline) when the socket closes", async () => {
482
+ const { sink, mirrors } = makeEntitySink();
483
+ const h = new SatelliteWsHandler(
484
+ service,
485
+ configRelay,
486
+ resultHandler,
487
+ logger,
488
+ sink,
489
+ );
490
+ const ws = createMockWs();
491
+ const { onMessage, onClose } = h.onConnection(ws);
492
+ await onMessage(
493
+ JSON.stringify({
494
+ type: "authenticate",
495
+ clientId: "sat-1",
496
+ token: "csat_valid-token",
497
+ }),
498
+ );
499
+ onClose?.();
500
+ // onClose fires the mirror fire-and-forget; flush the microtask queue.
501
+ await Promise.resolve();
502
+ await Promise.resolve();
503
+
504
+ const disconnected = mirrors.find((m) => m.lastEvent === "disconnected");
505
+ expect(disconnected).toBeDefined();
506
+ // Clearing lastHeartbeatAt makes the computed status flip offline at once.
507
+ expect(disconnected!.lastHeartbeatAt).toBeNull();
508
+ expect(disconnected!.satelliteId).toBe("sat-1");
509
+ });
510
+
511
+ it("does not mirror on a failed authentication", async () => {
512
+ const { sink, mirrors } = makeEntitySink();
513
+ const h = new SatelliteWsHandler(
514
+ service,
515
+ configRelay,
516
+ resultHandler,
517
+ logger,
518
+ sink,
519
+ );
520
+ const ws = createMockWs();
521
+ const { onMessage } = h.onConnection(ws);
522
+ await onMessage(
523
+ JSON.stringify({
524
+ type: "authenticate",
525
+ clientId: "sat-1",
526
+ token: "csat_invalid-token",
527
+ }),
528
+ );
529
+ expect(mirrors).toHaveLength(0);
530
+ });
531
+ });
265
532
  });
@@ -4,8 +4,10 @@ import type {
4
4
  WsConnection,
5
5
  WsConnectionHandlers,
6
6
  } from "@checkstack/backend-api";
7
+ import { extractErrorMessage } from "@checkstack/common";
7
8
  import type { SatelliteService } from "./service";
8
9
  import type { ConfigRelay } from "./config-relay";
10
+ import type { SatelliteConnectionEvent } from "./entity";
9
11
  import {
10
12
  SatelliteToCoreMessageSchema,
11
13
  type CoreToSatelliteMessage,
@@ -13,6 +15,27 @@ import {
13
15
  type SatelliteWithStatus,
14
16
  } from "@checkstack/satellite-common";
15
17
 
18
+ /**
19
+ * Optional plug-point for driving a satellite connection lifecycle edge into
20
+ * the reactive `satellite-connection` entity (reactive automation engine
21
+ * §10.6). Bound from `afterPluginsReady` where the entity handle is available —
22
+ * when not provided, no entity state is mirrored (graceful no-op in unit tests).
23
+ *
24
+ * The WS handler calls `mirror` at the same connect / disconnect lifecycle
25
+ * points it previously emitted the `satellite.connected` / `.disconnected`
26
+ * hooks; the change-deriver re-fires the equivalent trigger events. The status
27
+ * is COMPUTED on read from `lastHeartbeatAt`, so the sink carries the new
28
+ * heartbeat value for the edge rather than a status: `now` on connect (online),
29
+ * `null` on clean disconnect (offline immediately).
30
+ */
31
+ export interface SatelliteConnectionEntitySink {
32
+ mirror: (input: {
33
+ satelliteId: string;
34
+ lastEvent: SatelliteConnectionEvent;
35
+ lastHeartbeatAt: Date | null;
36
+ }) => Promise<void>;
37
+ }
38
+
16
39
  /**
17
40
  * Callback for handling health check results received from satellites.
18
41
  */
@@ -24,6 +47,49 @@ export interface SatelliteResultHandler {
24
47
  }): Promise<void>;
25
48
  }
26
49
 
50
+ /**
51
+ * Optional plug-point for script-package distribution to satellites. Wired
52
+ * from `afterPluginsReady` against the script-packages RPC. When absent,
53
+ * satellites simply never receive a `scriptPackagesLockfileHash` or refresh
54
+ * push (graceful no-op on installs without the plugin).
55
+ */
56
+ export interface SatelliteScriptPackageSink {
57
+ /** The desired lockfile hash to carry in assignment payloads, or null. */
58
+ getDesiredLockfileHash(): Promise<string | null>;
59
+ /** Persist a satellite's reconcile state for the admin UI. */
60
+ reportSyncState(input: {
61
+ satelliteId: string;
62
+ lockfileHash: string | null;
63
+ status: "pending" | "syncing" | "ready" | "error";
64
+ errorMessage?: string;
65
+ }): Promise<void>;
66
+ /** Manifest entries for a lockfile hash (for satellite delta diffing). */
67
+ getManifest(input: {
68
+ lockfileHash: string;
69
+ }): Promise<{ name: string; version: string; integrity: string }[]>;
70
+ /** One content-addressed blob as base64, or null if not found. */
71
+ getBlobBase64(input: { integrity: string }): Promise<string | null>;
72
+ }
73
+
74
+ /**
75
+ * Optional plug-point for just-in-time secret delivery to satellites.
76
+ * Wired from `afterPluginsReady` against `secretResolverRef`. When absent,
77
+ * a `request_run_secrets` is answered with an error (no secrets available),
78
+ * so a collector that declares `secretEnv` fails clearly rather than
79
+ * running without it.
80
+ *
81
+ * The resolver reads the declared `secretEnv` from the satellite's persisted
82
+ * assignment (the satellite does not choose which secrets), resolves ONLY
83
+ * those refs, and returns the env map. Resolved values are never persisted.
84
+ */
85
+ export interface SatelliteSecretSink {
86
+ resolveRunSecrets(input: {
87
+ satelliteId: string;
88
+ configId: string;
89
+ collectorId: string;
90
+ }): Promise<Record<string, string>>;
91
+ }
92
+
27
93
  /**
28
94
  * Active satellite connection tracking.
29
95
  */
@@ -37,7 +103,15 @@ interface SatelliteConnection {
37
103
  * Manages authentication, heartbeats, result ingestion, and config pushes.
38
104
  */
39
105
  export class SatelliteWsHandler implements WebSocketRouteHandler {
40
- /** Map of satelliteId → active WebSocket connection */
106
+ /**
107
+ * Pod-local live-socket registry: satelliteId → the WebSocket connection
108
+ * physically held by THIS pod. This is NOT the reactive entity's source of
109
+ * truth (that is the durable `satellites` connection columns, globally
110
+ * readable from any pod). It exists ONLY to route messages — config pushes,
111
+ * script-package refreshes, shutdowns — to a socket this pod actually owns;
112
+ * a satellite connected to another pod is simply absent here. Treat it as
113
+ * transport infrastructure, not state.
114
+ */
41
115
  private connections = new Map<string, SatelliteConnection>();
42
116
 
43
117
  constructor(
@@ -45,6 +119,25 @@ export class SatelliteWsHandler implements WebSocketRouteHandler {
45
119
  private configRelay: ConfigRelay,
46
120
  private resultHandler: SatelliteResultHandler,
47
121
  private logger: Logger,
122
+ /**
123
+ * Optional. When set, the handler mirrors `online` / `offline`
124
+ * connection state into the reactive `satellite-connection` entity at
125
+ * the same lifecycle points it logs. Wired by `afterPluginsReady` so the
126
+ * action graph stays decoupled from entity-handle availability.
127
+ */
128
+ private connectionEntitySink?: SatelliteConnectionEntitySink,
129
+ /**
130
+ * Optional. When set, assignment payloads carry the desired script-package
131
+ * lockfile hash and the handler can push `refresh_script_packages` +
132
+ * persist per-satellite sync state.
133
+ */
134
+ private scriptPackageSink?: SatelliteScriptPackageSink,
135
+ /**
136
+ * Optional. When set, the handler answers `request_run_secrets` by
137
+ * resolving the collector's declared secretEnv just-in-time. When
138
+ * unset, such a request is answered with an error.
139
+ */
140
+ private secretSink?: SatelliteSecretSink,
48
141
  ) {}
49
142
 
50
143
  /**
@@ -94,17 +187,47 @@ export class SatelliteWsHandler implements WebSocketRouteHandler {
94
187
  // Track connection
95
188
  this.connections.set(satellite.id, { satellite, ws });
96
189
 
97
- // Update heartbeat on connect
98
- await this.service.updateHeartbeat(satellite.id, {});
190
+ // Drive the `connected` edge into the reactive entity (best-effort —
191
+ // never block the auth handshake on a mirror failure). `apply` sets
192
+ // `lastHeartbeatAt = now` so the computed status reads `online`, and
193
+ // `lastConnectionEvent = "connected"`; the change-deriver re-fires the
194
+ // `satellite.connected` trigger event. This is also the connect-time
195
+ // heartbeat write (no separate `updateHeartbeat` needed), and it runs
196
+ // through `handle.mutate` so `prev` is snapshotted BEFORE the write.
197
+ if (this.connectionEntitySink) {
198
+ try {
199
+ await this.connectionEntitySink.mirror({
200
+ satelliteId: satellite.id,
201
+ lastEvent: "connected",
202
+ lastHeartbeatAt: new Date(),
203
+ });
204
+ } catch (error) {
205
+ this.logger.error(
206
+ `Failed to mirror satellite-connection (connected) for ${satellite.name}:`,
207
+ error,
208
+ );
209
+ }
210
+ } else {
211
+ // No entity sink wired (e.g. unit tests): still record the
212
+ // connect-time heartbeat directly so liveness is correct.
213
+ await this.service.updateHeartbeat(satellite.id, {});
214
+ }
99
215
 
100
- // Send authenticated response with full config
216
+ // Send authenticated response with full config. Carry the desired
217
+ // script-package lockfile hash as the durable convergence backstop:
218
+ // a satellite that missed a refresh push reconciles on connect.
101
219
  const assignments =
102
220
  await this.configRelay.getAssignmentsForSatellite(satellite.id);
221
+ const scriptPackagesLockfileHash =
222
+ await this.resolveDesiredLockfileHash();
103
223
 
104
224
  this.sendMessage(ws, {
105
225
  type: "authenticated",
106
226
  satelliteId: satellite.id,
107
227
  assignments,
228
+ ...(scriptPackagesLockfileHash === undefined
229
+ ? {}
230
+ : { scriptPackagesLockfileHash }),
108
231
  });
109
232
 
110
233
  this.logger.info(
@@ -135,6 +258,80 @@ export class SatelliteWsHandler implements WebSocketRouteHandler {
135
258
  );
136
259
  break;
137
260
  }
261
+ case "script_package_sync_state": {
262
+ // Persist the satellite's reconcile state for the admin UI.
263
+ try {
264
+ await this.scriptPackageSink?.reportSyncState({
265
+ satelliteId: authenticatedSatellite.id,
266
+ lockfileHash: parsed.lockfileHash,
267
+ status: parsed.status,
268
+ errorMessage: parsed.errorMessage,
269
+ });
270
+ } catch (error) {
271
+ this.logger.error(
272
+ `Failed to persist script-package sync state for ${authenticatedSatellite.name}:`,
273
+ error,
274
+ );
275
+ }
276
+ break;
277
+ }
278
+ case "request_script_package_manifest": {
279
+ const entries =
280
+ (await this.scriptPackageSink?.getManifest({
281
+ lockfileHash: parsed.lockfileHash,
282
+ })) ?? [];
283
+ this.sendMessage(ws, {
284
+ type: "script_package_manifest",
285
+ lockfileHash: parsed.lockfileHash,
286
+ entries,
287
+ });
288
+ break;
289
+ }
290
+ case "request_script_package_blob": {
291
+ const data =
292
+ (await this.scriptPackageSink?.getBlobBase64({
293
+ integrity: parsed.integrity,
294
+ })) ?? null;
295
+ this.sendMessage(ws, {
296
+ type: "script_package_blob",
297
+ integrity: parsed.integrity,
298
+ data,
299
+ });
300
+ break;
301
+ }
302
+ case "request_run_secrets": {
303
+ // JIT secret delivery: resolve ONLY the collector's declared
304
+ // secretEnv (read from the persisted assignment, not chosen by
305
+ // the satellite) and reply with the env map. On any failure,
306
+ // reply with an error so the satellite fails the run clearly.
307
+ if (!this.secretSink) {
308
+ this.sendMessage(ws, {
309
+ type: "run_secrets",
310
+ requestId: parsed.requestId,
311
+ error: "Secret delivery is not available on this core instance.",
312
+ });
313
+ break;
314
+ }
315
+ try {
316
+ const env = await this.secretSink.resolveRunSecrets({
317
+ satelliteId: authenticatedSatellite.id,
318
+ configId: parsed.configId,
319
+ collectorId: parsed.collectorId,
320
+ });
321
+ this.sendMessage(ws, {
322
+ type: "run_secrets",
323
+ requestId: parsed.requestId,
324
+ env,
325
+ });
326
+ } catch (error) {
327
+ this.sendMessage(ws, {
328
+ type: "run_secrets",
329
+ requestId: parsed.requestId,
330
+ error: extractErrorMessage(error),
331
+ });
332
+ }
333
+ break;
334
+ }
138
335
  case "authenticate": {
139
336
  // Already authenticated, ignore duplicate auth attempts
140
337
  this.logger.debug(
@@ -147,10 +344,34 @@ export class SatelliteWsHandler implements WebSocketRouteHandler {
147
344
 
148
345
  const onClose = () => {
149
346
  if (authenticatedSatellite) {
150
- this.connections.delete(authenticatedSatellite.id);
347
+ const closedSatellite = authenticatedSatellite;
348
+ this.connections.delete(closedSatellite.id);
151
349
  this.logger.info(
152
- `Satellite disconnected: ${authenticatedSatellite.name} (${authenticatedSatellite.region})`,
350
+ `Satellite disconnected: ${closedSatellite.name} (${closedSatellite.region})`,
153
351
  );
352
+ if (this.connectionEntitySink) {
353
+ // Fire-and-forget — `onClose` is sync, so don't await; we don't have
354
+ // a place to surface a rejection anyway. Clear `lastHeartbeatAt`
355
+ // (`null`) so the computed status flips `offline` IMMEDIATELY on a
356
+ // clean disconnect (no waiting for the heartbeat to age out), and set
357
+ // `lastConnectionEvent = "disconnected"` so the deriver re-fires
358
+ // `satellite.disconnected`. Nulling the heartbeat coincides with the
359
+ // "never connected" representation, but `lastConnectionEvent` stays
360
+ // `"disconnected"` (non-null), so the entity still HAS state — the
361
+ // read only omits a satellite whose `lastConnectionEvent` is null.
362
+ void this.connectionEntitySink
363
+ .mirror({
364
+ satelliteId: closedSatellite.id,
365
+ lastEvent: "disconnected",
366
+ lastHeartbeatAt: null,
367
+ })
368
+ .catch((error: unknown) => {
369
+ this.logger.error(
370
+ `Failed to mirror satellite-connection (disconnected) for ${closedSatellite.name}:`,
371
+ error,
372
+ );
373
+ });
374
+ }
154
375
  }
155
376
  };
156
377
 
@@ -166,10 +387,14 @@ export class SatelliteWsHandler implements WebSocketRouteHandler {
166
387
 
167
388
  const assignments =
168
389
  await this.configRelay.getAssignmentsForSatellite(satelliteId);
390
+ const scriptPackagesLockfileHash = await this.resolveDesiredLockfileHash();
169
391
 
170
392
  this.sendMessage(conn.ws, {
171
393
  type: "config_updated",
172
394
  assignments,
395
+ ...(scriptPackagesLockfileHash === undefined
396
+ ? {}
397
+ : { scriptPackagesLockfileHash }),
173
398
  });
174
399
 
175
400
  this.logger.debug(
@@ -177,6 +402,41 @@ export class SatelliteWsHandler implements WebSocketRouteHandler {
177
402
  );
178
403
  }
179
404
 
405
+ /**
406
+ * Push a `refresh_script_packages` to every connected satellite. Called by
407
+ * the `script-packages.changed` broadcast handler so each core instance
408
+ * fans the refresh out to its own satellites. Best-effort liveness; the
409
+ * assignment-carried hash is the durable backstop.
410
+ */
411
+ pushRefreshScriptPackagesToAll(lockfileHash: string): void {
412
+ for (const conn of this.connections.values()) {
413
+ this.sendMessage(conn.ws, {
414
+ type: "refresh_script_packages",
415
+ lockfileHash,
416
+ });
417
+ }
418
+ this.logger.debug(
419
+ `Pushed refresh_script_packages (${lockfileHash}) to ${this.connections.size} satellite(s)`,
420
+ );
421
+ }
422
+
423
+ /**
424
+ * Resolve the desired lockfile hash for assignment payloads. Returns
425
+ * `undefined` when the sink isn't wired (so the field is omitted entirely
426
+ * for version-skew safety), or `string | null` from the sink.
427
+ */
428
+ private async resolveDesiredLockfileHash(): Promise<
429
+ string | null | undefined
430
+ > {
431
+ if (!this.scriptPackageSink) return undefined;
432
+ try {
433
+ return await this.scriptPackageSink.getDesiredLockfileHash();
434
+ } catch (error) {
435
+ this.logger.error("Failed to resolve desired lockfile hash:", error);
436
+ return undefined;
437
+ }
438
+ }
439
+
180
440
  /**
181
441
  * Send a shutdown message to a specific satellite (e.g., on token revocation).
182
442
  */
package/src/schema.ts CHANGED
@@ -19,9 +19,30 @@ export const satellites = pgTable("satellites", {
19
19
  tags: jsonb("tags").$type<Record<string, string>>().default({}).notNull(),
20
20
  /** Bcrypt hash of the satellite's API token */
21
21
  tokenHash: text("token_hash").notNull(),
22
- /** Last heartbeat timestamp — null means never connected */
22
+ /**
23
+ * Last heartbeat timestamp — null means never connected (or cleanly
24
+ * disconnected). This is the SINGLE durable liveness source of truth: the
25
+ * reactive `satellite-connection` entity's `status` and `lastSeenAt` are
26
+ * COMPUTED on read from it (via `computeStatus` / `OFFLINE_THRESHOLD_MS`), so
27
+ * the entity is globally consistent from any pod and self-heals — a stale row
28
+ * reads `offline` once this timestamp ages past the offline threshold, even
29
+ * if the pod that owned the socket crashed without writing offline.
30
+ */
23
31
  lastHeartbeatAt: timestamp("last_heartbeat_at"),
24
32
  /** Satellite version reported on connect/heartbeat */
25
33
  version: text("version"),
34
+ /**
35
+ * Which lifecycle edge produced the latest connection-status change. Preserves
36
+ * the distinction between a socket drop (`disconnected`) and the heartbeat-lost
37
+ * offline edge (`heartbeat_lost`) that a bare status diff cannot encode. This
38
+ * is the ONLY durable connection column the reactive `satellite-connection`
39
+ * entity needs beyond `lastHeartbeatAt`: the deriver reads it as `lastEvent`,
40
+ * and the heartbeat monitor uses it to make heartbeat-lost detection
41
+ * idempotent (once it is `"heartbeat_lost"`, re-runs are no-ops). Nullable: a
42
+ * satellite that never connected has no last event.
43
+ */
44
+ lastConnectionEvent: text("last_connection_event", {
45
+ enum: ["connected", "disconnected", "heartbeat_lost"],
46
+ }),
26
47
  createdAt: timestamp("created_at").defaultNow().notNull(),
27
48
  });