@checkstack/satellite-backend 0.3.6 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # @checkstack/satellite-backend
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 41c77f4: feat(satellite): Phase 9 — connection lifecycle triggers
8
+
9
+ - New hooks `satelliteHooks.connected`, `satelliteHooks.disconnected`,
10
+ and `satelliteHooks.heartbeatLost`. `connected` and `disconnected`
11
+ fire from the WS handler at auth completion and `onClose`
12
+ respectively; `heartbeatLost` fires from the heartbeat monitor on
13
+ the `online → offline` edge only (the opposite edge is observable
14
+ via `connected`).
15
+ - Triggers `satellite.connected`, `satellite.disconnected`,
16
+ `satellite.heartbeat_lost` registered against the Automation
17
+ Platform. All carry `contextKey: (p) => p.satelliteId` so a
18
+ long-running automation can resume on the same satellite.
19
+ - No mutation actions in this chunk — connection lifecycle is
20
+ observed only, not commanded.
21
+
22
+ Plumbing: `SatelliteWsHandler` and `HeartbeatMonitor` both take an
23
+ optional hook sink in their constructor. The sink is provided from
24
+ `afterPluginsReady` where `emitHook` is available; until then, the
25
+ classes behave exactly as before (no hooks fired, no behavioural
26
+ change).
27
+
28
+ ### Patch Changes
29
+
30
+ - Updated dependencies [e2d6f25]
31
+ - Updated dependencies [41c77f4]
32
+ - Updated dependencies [41c77f4]
33
+ - Updated dependencies [e1a2077]
34
+ - Updated dependencies [41c77f4]
35
+ - Updated dependencies [41c77f4]
36
+ - Updated dependencies [41c77f4]
37
+ - Updated dependencies [41c77f4]
38
+ - Updated dependencies [41c77f4]
39
+ - Updated dependencies [41c77f4]
40
+ - Updated dependencies [6d52276]
41
+ - Updated dependencies [6d52276]
42
+ - Updated dependencies [35bc682]
43
+ - @checkstack/automation-backend@0.2.0
44
+ - @checkstack/healthcheck-backend@1.3.0
45
+ - @checkstack/common@0.12.0
46
+ - @checkstack/backend-api@0.18.0
47
+ - @checkstack/healthcheck-common@1.3.0
48
+ - @checkstack/satellite-common@0.6.0
49
+ - @checkstack/gitops-backend@0.3.7
50
+ - @checkstack/gitops-common@0.4.2
51
+ - @checkstack/signal-common@0.2.5
52
+ - @checkstack/queue-api@0.3.6
53
+
3
54
  ## 0.3.6
4
55
 
5
56
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/satellite-backend",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -11,18 +11,20 @@
11
11
  "typecheck": "tsgo -b",
12
12
  "generate": "drizzle-kit generate",
13
13
  "lint": "bun run lint:code",
14
- "lint:code": "eslint . --max-warnings 0"
14
+ "lint:code": "eslint . --max-warnings 0",
15
+ "test": "bun test"
15
16
  },
16
17
  "dependencies": {
17
- "@checkstack/backend-api": "0.17.0",
18
- "@checkstack/satellite-common": "0.5.2",
19
- "@checkstack/healthcheck-common": "1.1.2",
18
+ "@checkstack/backend-api": "0.17.1",
19
+ "@checkstack/automation-backend": "0.1.0",
20
+ "@checkstack/satellite-common": "0.5.3",
21
+ "@checkstack/healthcheck-common": "1.2.0",
20
22
  "@checkstack/signal-common": "0.2.4",
21
- "@checkstack/healthcheck-backend": "1.1.4",
22
- "@checkstack/gitops-backend": "0.3.5",
23
+ "@checkstack/healthcheck-backend": "1.2.0",
24
+ "@checkstack/gitops-backend": "0.3.6",
23
25
  "@checkstack/gitops-common": "0.4.1",
24
26
  "@checkstack/common": "0.11.0",
25
- "@checkstack/queue-api": "0.3.4",
27
+ "@checkstack/queue-api": "0.3.5",
26
28
  "drizzle-orm": "^0.45.0",
27
29
  "zod": "^4.2.1",
28
30
  "@orpc/server": "^1.13.2"
@@ -30,7 +32,7 @@
30
32
  "devDependencies": {
31
33
  "@checkstack/drizzle-helper": "0.0.5",
32
34
  "@checkstack/scripts": "0.3.3",
33
- "@checkstack/test-utils-backend": "0.1.29",
35
+ "@checkstack/test-utils-backend": "0.1.30",
34
36
  "@checkstack/tsconfig": "0.0.7",
35
37
  "@types/bun": "^1.0.0",
36
38
  "drizzle-kit": "^0.31.10",
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Behaviour tests for the satellite automation triggers.
3
+ * No mutation actions in this chunk — connection lifecycle is observed
4
+ * only, not commanded.
5
+ */
6
+ import { describe, expect, it } from "bun:test";
7
+
8
+ import {
9
+ satelliteConnectedTrigger,
10
+ satelliteDisconnectedTrigger,
11
+ satelliteHeartbeatLostTrigger,
12
+ satelliteTriggers,
13
+ } from "./automations";
14
+ import { satelliteHooks } from "./hooks";
15
+
16
+ describe("satellite triggers", () => {
17
+ it("exposes three triggers in a stable order", () => {
18
+ expect(satelliteTriggers).toHaveLength(3);
19
+ expect(satelliteTriggers[0]).toBe(
20
+ satelliteConnectedTrigger as (typeof satelliteTriggers)[number],
21
+ );
22
+ expect(satelliteTriggers[1]).toBe(
23
+ satelliteDisconnectedTrigger as (typeof satelliteTriggers)[number],
24
+ );
25
+ expect(satelliteTriggers[2]).toBe(
26
+ satelliteHeartbeatLostTrigger as (typeof satelliteTriggers)[number],
27
+ );
28
+ });
29
+
30
+ it("binds each trigger to the matching hook", () => {
31
+ expect(satelliteConnectedTrigger.hook).toBe(satelliteHooks.connected);
32
+ expect(satelliteDisconnectedTrigger.hook).toBe(satelliteHooks.disconnected);
33
+ expect(satelliteHeartbeatLostTrigger.hook).toBe(satelliteHooks.heartbeatLost);
34
+ });
35
+
36
+ it("extracts satelliteId as the contextKey on all three", () => {
37
+ const payload = {
38
+ satelliteId: "sat-1",
39
+ name: "EU-Central",
40
+ region: "eu-central-1",
41
+ timestamp: "2026-05-29T11:00:00Z",
42
+ };
43
+ expect(satelliteConnectedTrigger.contextKey?.(payload)).toBe("sat-1");
44
+ expect(satelliteDisconnectedTrigger.contextKey?.(payload)).toBe("sat-1");
45
+ expect(satelliteHeartbeatLostTrigger.contextKey?.(payload)).toBe("sat-1");
46
+ });
47
+
48
+ it("rejects payloads missing required fields", () => {
49
+ const bad = satelliteConnectedTrigger.payloadSchema.safeParse({
50
+ satelliteId: "sat-1",
51
+ });
52
+ expect(bad.success).toBe(false);
53
+ });
54
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Satellite triggers registered with the Automation Platform.
3
+ *
4
+ * The plan calls for `satellite.connected`, `satellite.disconnected`,
5
+ * and `satellite.heartbeat_lost` — all three were added as new hooks
6
+ * in `./hooks.ts` and emitted from the WS handler (connect/disconnect)
7
+ * and heartbeat monitor (online → offline). No mutation actions for
8
+ * satellite in this phase.
9
+ */
10
+ import { z } from "zod";
11
+ import type { TriggerDefinition } from "@checkstack/automation-backend";
12
+
13
+ import { satelliteHooks } from "./hooks";
14
+
15
+ const satelliteEventPayloadSchema = z.object({
16
+ satelliteId: z.string(),
17
+ name: z.string(),
18
+ region: z.string(),
19
+ timestamp: z.string(),
20
+ });
21
+
22
+ export const satelliteConnectedTrigger: TriggerDefinition<
23
+ z.infer<typeof satelliteEventPayloadSchema>
24
+ > = {
25
+ id: "connected",
26
+ displayName: "Satellite Connected",
27
+ description: "Fires when a satellite WebSocket completes authentication",
28
+ category: "Satellites",
29
+ icon: "Satellite",
30
+ payloadSchema: satelliteEventPayloadSchema,
31
+ hook: satelliteHooks.connected,
32
+ contextKey: (p) => p.satelliteId,
33
+ };
34
+
35
+ export const satelliteDisconnectedTrigger: TriggerDefinition<
36
+ z.infer<typeof satelliteEventPayloadSchema>
37
+ > = {
38
+ id: "disconnected",
39
+ displayName: "Satellite Disconnected",
40
+ description: "Fires when a satellite's WebSocket closes",
41
+ category: "Satellites",
42
+ icon: "Satellite",
43
+ payloadSchema: satelliteEventPayloadSchema,
44
+ hook: satelliteHooks.disconnected,
45
+ contextKey: (p) => p.satelliteId,
46
+ };
47
+
48
+ export const satelliteHeartbeatLostTrigger: TriggerDefinition<
49
+ z.infer<typeof satelliteEventPayloadSchema>
50
+ > = {
51
+ id: "heartbeat_lost",
52
+ displayName: "Satellite Heartbeat Lost",
53
+ description:
54
+ "Fires when a satellite transitions online → offline (no heartbeat within threshold)",
55
+ category: "Satellites",
56
+ icon: "Satellite",
57
+ payloadSchema: satelliteEventPayloadSchema,
58
+ hook: satelliteHooks.heartbeatLost,
59
+ contextKey: (p) => p.satelliteId,
60
+ };
61
+
62
+ export const satelliteTriggers: TriggerDefinition<unknown>[] = [
63
+ satelliteConnectedTrigger as TriggerDefinition<unknown>,
64
+ satelliteDisconnectedTrigger as TriggerDefinition<unknown>,
65
+ satelliteHeartbeatLostTrigger as TriggerDefinition<unknown>,
66
+ ];
@@ -1,4 +1,4 @@
1
- import type { Logger } from "@checkstack/backend-api";
1
+ import type { Hook, Logger } from "@checkstack/backend-api";
2
2
  import type { SignalService } from "@checkstack/signal-common";
3
3
  import type { SatelliteService } from "./service";
4
4
  import {
@@ -6,6 +6,22 @@ import {
6
6
  OFFLINE_THRESHOLD_MS,
7
7
  } from "@checkstack/satellite-common";
8
8
 
9
+ /**
10
+ * Optional plug-point for firing the automation
11
+ * `satellite.heartbeat_lost` trigger when the monitor observes a
12
+ * satellite transitioning `online` → `offline`. Bound from
13
+ * `afterPluginsReady`; when not provided, no hook fires.
14
+ */
15
+ export interface SatelliteHeartbeatHookSink {
16
+ emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
17
+ heartbeatLostHook: Hook<{
18
+ satelliteId: string;
19
+ name: string;
20
+ region: string;
21
+ timestamp: string;
22
+ }>;
23
+ }
24
+
9
25
  /**
10
26
  * Monitors satellite heartbeats and broadcasts status change signals.
11
27
  * Tracks previous state in-memory to detect transitions (online → offline, offline → online).
@@ -21,6 +37,7 @@ export class HeartbeatMonitor {
21
37
  private service: SatelliteService,
22
38
  private signalService: SignalService,
23
39
  private logger: Logger,
40
+ private hookSink?: SatelliteHeartbeatHookSink,
24
41
  ) {}
25
42
 
26
43
  /**
@@ -46,6 +63,29 @@ export class HeartbeatMonitor {
46
63
  name: satellite.name,
47
64
  region: satellite.region,
48
65
  });
66
+
67
+ // Fire the automation `heartbeat_lost` hook only on the
68
+ // online → offline edge. The opposite transition is observable
69
+ // via the `satellite.connected` hook fired by the WS handler.
70
+ if (
71
+ previousStatus === "online" &&
72
+ currentStatus === "offline" &&
73
+ this.hookSink
74
+ ) {
75
+ try {
76
+ await this.hookSink.emitHook(this.hookSink.heartbeatLostHook, {
77
+ satelliteId: satellite.id,
78
+ name: satellite.name,
79
+ region: satellite.region,
80
+ timestamp: new Date().toISOString(),
81
+ });
82
+ } catch (error) {
83
+ this.logger.error(
84
+ `Failed to emit satellite.heartbeat_lost hook for ${satellite.name}:`,
85
+ error,
86
+ );
87
+ }
88
+ }
49
89
  }
50
90
 
51
91
  this.previousStatuses.set(satellite.id, currentStatus);
package/src/hooks.ts CHANGED
@@ -14,4 +14,41 @@ export const satelliteHooks = {
14
14
  satelliteRemoved: createHook<{
15
15
  satelliteId: string;
16
16
  }>("satellite.removed"),
17
+
18
+ /**
19
+ * Emitted when a satellite WebSocket completes authentication and
20
+ * registers itself in the in-memory connection map.
21
+ */
22
+ connected: createHook<{
23
+ satelliteId: string;
24
+ name: string;
25
+ region: string;
26
+ timestamp: string;
27
+ }>("satellite.connected"),
28
+
29
+ /**
30
+ * Emitted when a previously-connected satellite's WebSocket closes
31
+ * (graceful or otherwise). Distinct from `heartbeatLost`: this fires
32
+ * the moment the socket drops, regardless of whether the satellite
33
+ * comes back within the heartbeat window.
34
+ */
35
+ disconnected: createHook<{
36
+ satelliteId: string;
37
+ name: string;
38
+ region: string;
39
+ timestamp: string;
40
+ }>("satellite.disconnected"),
41
+
42
+ /**
43
+ * Emitted by the heartbeat monitor when a satellite's status
44
+ * transitions from `online` to `offline` — i.e. no heartbeat for
45
+ * longer than `OFFLINE_THRESHOLD_MS`. Used by automations that
46
+ * page on stale satellites.
47
+ */
48
+ heartbeatLost: createHook<{
49
+ satelliteId: string;
50
+ name: string;
51
+ region: string;
52
+ timestamp: string;
53
+ }>("satellite.heartbeat_lost"),
17
54
  } as const;
package/src/index.ts CHANGED
@@ -16,6 +16,9 @@ import { SatelliteWsHandler } from "./satellite-ws-handler";
16
16
  import { ConfigRelay } from "./config-relay";
17
17
  import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
18
18
  import { registerSatelliteGitOpsKinds } from "./satellite-gitops-kinds";
19
+ import { automationTriggerExtensionPoint } from "@checkstack/automation-backend";
20
+ import { satelliteTriggers } from "./automations";
21
+ import { satelliteHooks } from "./hooks";
19
22
 
20
23
  // Queue and job constants
21
24
  const HEARTBEAT_QUEUE = "satellite-heartbeat";
@@ -27,6 +30,14 @@ export default createBackendPlugin({
27
30
  register(env) {
28
31
  env.registerAccessRules(satelliteAccessRules);
29
32
 
33
+ // ─── Automation Platform: triggers ───────────────────────────────
34
+ const automationTriggers = env.getExtensionPoint(
35
+ automationTriggerExtensionPoint,
36
+ );
37
+ for (const trigger of satelliteTriggers) {
38
+ automationTriggers.registerTrigger(trigger, pluginMetadata);
39
+ }
40
+
30
41
  // ─── GitOps Entity Kind Registration ─────────────────────────────
31
42
  let gitopsService: SatelliteService | undefined;
32
43
  const kindRegistry = env.getExtensionPoint(entityKindExtensionPoint);
@@ -73,6 +84,7 @@ export default createBackendPlugin({
73
84
  wsRegistry,
74
85
  rpcClient,
75
86
  onHook,
87
+ emitHook,
76
88
  }) => {
77
89
  const service = new SatelliteService(
78
90
  database as SafeDatabase<typeof schema>,
@@ -112,6 +124,11 @@ export default createBackendPlugin({
112
124
  },
113
125
  },
114
126
  logger,
127
+ {
128
+ emitHook,
129
+ connectedHook: satelliteHooks.connected,
130
+ disconnectedHook: satelliteHooks.disconnected,
131
+ },
115
132
  );
116
133
 
117
134
  // Register satellite WebSocket endpoint via the scoped WS registry
@@ -124,6 +141,10 @@ export default createBackendPlugin({
124
141
  service,
125
142
  signalService,
126
143
  logger,
144
+ {
145
+ emitHook,
146
+ heartbeatLostHook: satelliteHooks.heartbeatLost,
147
+ },
127
148
  );
128
149
 
129
150
  const queue = queueManager.getQueue<Record<string, never>>(
@@ -1,4 +1,4 @@
1
- import type { Logger } from "@checkstack/backend-api";
1
+ import type { Hook, Logger } from "@checkstack/backend-api";
2
2
  import type {
3
3
  WebSocketRouteHandler,
4
4
  WsConnection,
@@ -13,6 +13,27 @@ import {
13
13
  type SatelliteWithStatus,
14
14
  } from "@checkstack/satellite-common";
15
15
 
16
+ /**
17
+ * Optional plug-point for firing automation triggers when a satellite
18
+ * connects or disconnects. Bound from `afterPluginsReady` where
19
+ * `emitHook` is available — when not provided, no hook fires.
20
+ */
21
+ export interface SatelliteConnectionHookSink {
22
+ emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
23
+ connectedHook: Hook<{
24
+ satelliteId: string;
25
+ name: string;
26
+ region: string;
27
+ timestamp: string;
28
+ }>;
29
+ disconnectedHook: Hook<{
30
+ satelliteId: string;
31
+ name: string;
32
+ region: string;
33
+ timestamp: string;
34
+ }>;
35
+ }
36
+
16
37
  /**
17
38
  * Callback for handling health check results received from satellites.
18
39
  */
@@ -45,6 +66,13 @@ export class SatelliteWsHandler implements WebSocketRouteHandler {
45
66
  private configRelay: ConfigRelay,
46
67
  private resultHandler: SatelliteResultHandler,
47
68
  private logger: Logger,
69
+ /**
70
+ * Optional. When set, the handler fires `connected` / `disconnected`
71
+ * hooks at the same lifecycle points it logs. Wired by
72
+ * `afterPluginsReady` so the action graph stays decoupled from
73
+ * `emitHook` availability.
74
+ */
75
+ private connectionHookSink?: SatelliteConnectionHookSink,
48
76
  ) {}
49
77
 
50
78
  /**
@@ -94,6 +122,27 @@ export class SatelliteWsHandler implements WebSocketRouteHandler {
94
122
  // Track connection
95
123
  this.connections.set(satellite.id, { satellite, ws });
96
124
 
125
+ // Fire the automation `connected` hook (best-effort — never
126
+ // block the auth handshake on a hook subscriber failure).
127
+ if (this.connectionHookSink) {
128
+ try {
129
+ await this.connectionHookSink.emitHook(
130
+ this.connectionHookSink.connectedHook,
131
+ {
132
+ satelliteId: satellite.id,
133
+ name: satellite.name,
134
+ region: satellite.region,
135
+ timestamp: new Date().toISOString(),
136
+ },
137
+ );
138
+ } catch (error) {
139
+ this.logger.error(
140
+ `Failed to emit satellite.connected hook for ${satellite.name}:`,
141
+ error,
142
+ );
143
+ }
144
+ }
145
+
97
146
  // Update heartbeat on connect
98
147
  await this.service.updateHeartbeat(satellite.id, {});
99
148
 
@@ -147,10 +196,28 @@ export class SatelliteWsHandler implements WebSocketRouteHandler {
147
196
 
148
197
  const onClose = () => {
149
198
  if (authenticatedSatellite) {
150
- this.connections.delete(authenticatedSatellite.id);
199
+ const closedSatellite = authenticatedSatellite;
200
+ this.connections.delete(closedSatellite.id);
151
201
  this.logger.info(
152
- `Satellite disconnected: ${authenticatedSatellite.name} (${authenticatedSatellite.region})`,
202
+ `Satellite disconnected: ${closedSatellite.name} (${closedSatellite.region})`,
153
203
  );
204
+ if (this.connectionHookSink) {
205
+ // Fire-and-forget — `onClose` is sync, so don't await; we
206
+ // don't have a place to surface a rejection anyway.
207
+ void this.connectionHookSink
208
+ .emitHook(this.connectionHookSink.disconnectedHook, {
209
+ satelliteId: closedSatellite.id,
210
+ name: closedSatellite.name,
211
+ region: closedSatellite.region,
212
+ timestamp: new Date().toISOString(),
213
+ })
214
+ .catch((error: unknown) => {
215
+ this.logger.error(
216
+ `Failed to emit satellite.disconnected hook for ${closedSatellite.name}:`,
217
+ error,
218
+ );
219
+ });
220
+ }
154
221
  }
155
222
  };
156
223
 
package/tsconfig.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "src"
5
5
  ],
6
6
  "references": [
7
+ {
8
+ "path": "../automation-backend"
9
+ },
7
10
  {
8
11
  "path": "../backend-api"
9
12
  },