@checkstack/healthcheck-backend 1.10.1 → 1.11.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,107 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - defb97b: fix(healthcheck): emit a realtime signal on config/assignment changes
8
+
9
+ The health-check executor broadcasts run/status signals, but config and
10
+ assignment CRUD (create/update/delete/pause/resume, associate/disassociate,
11
+ create-and-assign) emitted nothing - so a check created or edited out-of-band
12
+ (the AI assistant, GitOps, another pod/user) did not appear in an open Health
13
+ Checks list until the first run fired a status signal, up to an interval later.
14
+
15
+ Add a `HEALTHCHECK_CONFIG_CHANGED` (`healthcheck.config.changed`) signal,
16
+ broadcast from every config/assignment mutation, so the frontend signal
17
+ auto-invalidator refreshes the `[[healthcheck]]` cache on every connected client
18
+ immediately.
19
+
20
+ - defb97b: feat(healthcheck): atomically create and assign a health check in one step
21
+
22
+ Add a `createAndAssign` RPC that creates a health-check configuration and
23
+ assigns it to a system in a single transaction, so the common "one system, one
24
+ check" case can never leave a dormant, unassigned check that runs nothing. When
25
+ the assignment is enabled it is scheduled immediately, exactly like
26
+ `associateSystem`.
27
+
28
+ The AI `healthcheck.propose` tool now prefers the HTTP strategy for a URL
29
+ (instead of authoring a script health check) and, when given `assignToSystemId`,
30
+ creates, assigns, and starts the check in the same approval.
31
+
32
+ Also fixes a latent bug where the `associateSystem` handler silently dropped the
33
+ per-assignment `notificationPolicy` before it reached the database.
34
+
35
+ ### Patch Changes
36
+
37
+ - Updated dependencies [defb97b]
38
+ - Updated dependencies [defb97b]
39
+ - Updated dependencies [defb97b]
40
+ - Updated dependencies [defb97b]
41
+ - Updated dependencies [defb97b]
42
+ - Updated dependencies [defb97b]
43
+ - Updated dependencies [defb97b]
44
+ - @checkstack/ai-backend@0.10.0
45
+ - @checkstack/catalog-backend@1.6.0
46
+ - @checkstack/catalog-common@2.5.0
47
+ - @checkstack/common@0.18.0
48
+ - @checkstack/healthcheck-common@1.9.0
49
+ - @checkstack/automation-backend@0.10.2
50
+ - @checkstack/incident-backend@1.8.6
51
+ - @checkstack/incident-common@1.6.4
52
+ - @checkstack/maintenance-common@1.7.4
53
+ - @checkstack/sdk@0.116.1
54
+ - @checkstack/ai-common@0.6.2
55
+ - @checkstack/backend-api@0.26.1
56
+ - @checkstack/cache-api@0.3.15
57
+ - @checkstack/command-backend@0.2.14
58
+ - @checkstack/gitops-backend@0.5.14
59
+ - @checkstack/gitops-common@0.6.7
60
+ - @checkstack/notification-common@1.4.2
61
+ - @checkstack/queue-api@0.3.15
62
+ - @checkstack/satellite-backend@0.7.3
63
+ - @checkstack/script-packages-backend@0.3.18
64
+ - @checkstack/secrets-backend@0.2.14
65
+ - @checkstack/secrets-common@0.2.7
66
+ - @checkstack/signal-common@0.2.13
67
+ - @checkstack/status-page-backend@0.4.1
68
+ - @checkstack/status-page-common@0.4.1
69
+ - @checkstack/cache-utils@0.2.20
70
+
71
+ ## 1.10.2
72
+
73
+ ### Patch Changes
74
+
75
+ - Updated dependencies [2e20792]
76
+ - Updated dependencies [2e20792]
77
+ - Updated dependencies [2e20792]
78
+ - @checkstack/ai-backend@0.9.1
79
+ - @checkstack/backend-api@0.26.0
80
+ - @checkstack/status-page-common@0.4.0
81
+ - @checkstack/status-page-backend@0.4.0
82
+ - @checkstack/ai-common@0.6.1
83
+ - @checkstack/catalog-common@2.4.3
84
+ - @checkstack/gitops-common@0.6.6
85
+ - @checkstack/healthcheck-common@1.8.1
86
+ - @checkstack/incident-common@1.6.3
87
+ - @checkstack/maintenance-common@1.7.3
88
+ - @checkstack/notification-common@1.4.1
89
+ - @checkstack/secrets-common@0.2.6
90
+ - @checkstack/signal-common@0.2.12
91
+ - @checkstack/automation-backend@0.10.1
92
+ - @checkstack/cache-api@0.3.14
93
+ - @checkstack/cache-utils@0.2.19
94
+ - @checkstack/catalog-backend@1.5.5
95
+ - @checkstack/command-backend@0.2.13
96
+ - @checkstack/common@0.17.0
97
+ - @checkstack/gitops-backend@0.5.13
98
+ - @checkstack/incident-backend@1.8.5
99
+ - @checkstack/queue-api@0.3.14
100
+ - @checkstack/satellite-backend@0.7.2
101
+ - @checkstack/script-packages-backend@0.3.17
102
+ - @checkstack/sdk@0.115.1
103
+ - @checkstack/secrets-backend@0.2.13
104
+
3
105
  ## 1.10.1
4
106
 
5
107
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,32 +14,32 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.25.0",
18
- "@checkstack/ai-backend": "0.9.0",
19
- "@checkstack/ai-common": "0.6.0",
20
- "@checkstack/script-packages-backend": "0.3.16",
21
- "@checkstack/cache-api": "0.3.14",
22
- "@checkstack/cache-utils": "0.2.19",
23
- "@checkstack/catalog-backend": "1.5.4",
24
- "@checkstack/catalog-common": "2.4.2",
25
- "@checkstack/command-backend": "0.2.12",
26
- "@checkstack/common": "0.17.0",
27
- "@checkstack/gitops-backend": "0.5.12",
28
- "@checkstack/gitops-common": "0.6.5",
29
- "@checkstack/healthcheck-common": "1.8.0",
30
- "@checkstack/secrets-common": "0.2.5",
31
- "@checkstack/secrets-backend": "0.2.12",
32
- "@checkstack/incident-backend": "1.8.4",
33
- "@checkstack/incident-common": "1.6.2",
34
- "@checkstack/automation-backend": "0.10.0",
35
- "@checkstack/maintenance-common": "1.7.2",
36
- "@checkstack/notification-common": "1.4.0",
37
- "@checkstack/queue-api": "0.3.14",
38
- "@checkstack/satellite-backend": "0.7.1",
39
- "@checkstack/sdk": "0.113.1",
40
- "@checkstack/signal-common": "0.2.11",
41
- "@checkstack/status-page-backend": "0.3.0",
42
- "@checkstack/status-page-common": "0.3.0",
17
+ "@checkstack/backend-api": "0.26.1",
18
+ "@checkstack/ai-backend": "0.10.0",
19
+ "@checkstack/ai-common": "0.6.2",
20
+ "@checkstack/script-packages-backend": "0.3.18",
21
+ "@checkstack/cache-api": "0.3.15",
22
+ "@checkstack/cache-utils": "0.2.20",
23
+ "@checkstack/catalog-backend": "1.6.0",
24
+ "@checkstack/catalog-common": "2.5.0",
25
+ "@checkstack/command-backend": "0.2.14",
26
+ "@checkstack/common": "0.18.0",
27
+ "@checkstack/gitops-backend": "0.5.14",
28
+ "@checkstack/gitops-common": "0.6.7",
29
+ "@checkstack/healthcheck-common": "1.9.0",
30
+ "@checkstack/secrets-common": "0.2.7",
31
+ "@checkstack/secrets-backend": "0.2.14",
32
+ "@checkstack/incident-backend": "1.8.6",
33
+ "@checkstack/incident-common": "1.6.4",
34
+ "@checkstack/automation-backend": "0.10.2",
35
+ "@checkstack/maintenance-common": "1.7.4",
36
+ "@checkstack/notification-common": "1.4.2",
37
+ "@checkstack/queue-api": "0.3.15",
38
+ "@checkstack/satellite-backend": "0.7.3",
39
+ "@checkstack/sdk": "0.116.1",
40
+ "@checkstack/signal-common": "0.2.13",
41
+ "@checkstack/status-page-backend": "0.4.1",
42
+ "@checkstack/status-page-common": "0.4.1",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "drizzle-orm": "^0.45.0",
45
45
  "hono": "^4.12.25",
@@ -51,8 +51,8 @@
51
51
  },
52
52
  "devDependencies": {
53
53
  "@checkstack/drizzle-helper": "0.0.6",
54
- "@checkstack/scripts": "0.6.3",
55
- "@checkstack/test-utils-backend": "0.1.46",
54
+ "@checkstack/scripts": "0.6.5",
55
+ "@checkstack/test-utils-backend": "0.1.48",
56
56
  "@checkstack/tsconfig": "0.0.7",
57
57
  "@types/bun": "^1.0.0",
58
58
  "@types/tdigest": "^0.1.5",
@@ -43,11 +43,13 @@ function fakeRpcClient({
43
43
  getStrategies,
44
44
  getCollectors,
45
45
  createConfiguration,
46
+ createAndAssign,
46
47
  validateConfiguration,
47
48
  }: {
48
49
  getStrategies: ReturnType<typeof mock>;
49
50
  getCollectors: ReturnType<typeof mock>;
50
51
  createConfiguration: ReturnType<typeof mock>;
52
+ createAndAssign?: ReturnType<typeof mock>;
51
53
  validateConfiguration?: ReturnType<typeof mock>;
52
54
  }): RpcClient {
53
55
  return {
@@ -55,6 +57,7 @@ function fakeRpcClient({
55
57
  getStrategies,
56
58
  getCollectors,
57
59
  createConfiguration,
60
+ createAndAssign: createAndAssign ?? mock(() => Promise.resolve({})),
58
61
  validateConfiguration: validateConfiguration ?? okValidate(),
59
62
  }),
60
63
  } as unknown as RpcClient;
@@ -265,4 +268,90 @@ describe("healthcheck.propose composite tool", () => {
265
268
  expect(createConfiguration).toHaveBeenCalledTimes(1);
266
269
  expect(result.configuration.id).toBe("hc1");
267
270
  });
271
+
272
+ test("description steers the model to HTTP, not script", () => {
273
+ const tool = createHealthcheckProposeTool();
274
+ // The model picked a script for a plain URL; the description must now make
275
+ // HTTP the default and call out the assign-in-one-step flow.
276
+ expect(tool.description).toContain("PREFER THE HTTP STRATEGY");
277
+ expect(tool.description).toContain("healthcheck-http.request");
278
+ expect(tool.description).toContain("assignToSystemId");
279
+ });
280
+
281
+ test("dryRun without a target system warns the check will not run yet", async () => {
282
+ const rpcClient = fakeRpcClient({
283
+ getStrategies: mock(() => Promise.resolve([httpStrategy])),
284
+ getCollectors: mock(() => Promise.resolve([])),
285
+ createConfiguration: mock(),
286
+ });
287
+ const tool = createHealthcheckProposeTool();
288
+
289
+ const preview = await tool.dryRun!({ input: validInput, principal, rpcClient });
290
+ expect(preview.summary).toContain("NOT assigned");
291
+ });
292
+
293
+ test("dryRun with a target system says it will assign + start the check", async () => {
294
+ const rpcClient = fakeRpcClient({
295
+ getStrategies: mock(() => Promise.resolve([httpStrategy])),
296
+ getCollectors: mock(() => Promise.resolve([])),
297
+ createConfiguration: mock(),
298
+ });
299
+ const tool = createHealthcheckProposeTool();
300
+
301
+ const preview = await tool.dryRun!({
302
+ input: { ...validInput, assignToSystemId: "sys1" },
303
+ principal,
304
+ rpcClient,
305
+ });
306
+ expect(preview.summary).toMatch(/assign/i);
307
+ expect(preview.summary).not.toContain("NOT assigned");
308
+ });
309
+
310
+ test("execute (apply) with assignToSystemId atomically creates AND assigns", async () => {
311
+ const created = {
312
+ id: "hc2",
313
+ name: "Probe foo status",
314
+ strategyId: "healthcheck-http.http",
315
+ config: { url: "https://foo.bar/status" },
316
+ intervalSeconds: 60,
317
+ paused: false,
318
+ createdAt: new Date(),
319
+ updatedAt: new Date(),
320
+ };
321
+ let captured: {
322
+ systemId?: string;
323
+ configuration?: { name?: string; strategyId?: string };
324
+ } = {};
325
+ const createAndAssign = mock(
326
+ (arg: {
327
+ systemId: string;
328
+ configuration: { name: string; strategyId: string };
329
+ }) => {
330
+ captured = arg;
331
+ return Promise.resolve(created);
332
+ },
333
+ );
334
+ const createConfiguration = mock(() => Promise.resolve(created));
335
+ const rpcClient = fakeRpcClient({
336
+ getStrategies: mock(() => Promise.resolve([httpStrategy])),
337
+ getCollectors: mock(() => Promise.resolve([])),
338
+ createConfiguration,
339
+ createAndAssign,
340
+ });
341
+ const tool = createHealthcheckProposeTool();
342
+
343
+ const result = await tool.execute({
344
+ input: { ...validInput, assignToSystemId: "sys1" },
345
+ principal,
346
+ rpcClient,
347
+ });
348
+
349
+ // The atomic create+assign path is used; the create-only path is NOT.
350
+ expect(createAndAssign).toHaveBeenCalledTimes(1);
351
+ expect(createConfiguration).not.toHaveBeenCalled();
352
+ expect(captured.systemId).toBe("sys1");
353
+ expect(captured.configuration?.name).toBe("Probe foo status");
354
+ expect(captured.configuration?.strategyId).toBe("healthcheck-http.http");
355
+ expect(result.configuration.id).toBe("hc2");
356
+ });
268
357
  });
@@ -27,7 +27,22 @@ import { validateCollectorAssertions } from "./assertion-validation";
27
27
  * create skeleton (name, strategyId, config, intervalSeconds, collectors).
28
28
  */
29
29
  export const HealthcheckProposeInputSchema =
30
- CreateHealthCheckConfigurationSchema;
30
+ CreateHealthCheckConfigurationSchema.extend({
31
+ /**
32
+ * Optional: the id of a system to atomically assign + start the check on in
33
+ * the SAME step (resolve a system name to its id with `catalog.listSystems`
34
+ * first). When set, the check is created, assigned, and starts running
35
+ * immediately. When omitted, the check is created but stays dormant until
36
+ * the operator assigns it to a system.
37
+ */
38
+ assignToSystemId: z.string().optional(),
39
+ /**
40
+ * Optional per-assignment environment selector, only meaningful with
41
+ * `assignToSystemId`. Omit (or null) to run once per environment the system
42
+ * belongs to (the default fan-out) - this is almost always what you want.
43
+ */
44
+ environmentIds: z.array(z.string()).nullable().optional(),
45
+ });
31
46
 
32
47
  export type HealthcheckProposeInput = z.infer<
33
48
  typeof HealthcheckProposeInputSchema
@@ -67,12 +82,13 @@ function formatIssues(
67
82
  }
68
83
 
69
84
  /**
70
- * Appended to every health-check propose summary + the tool description: a newly
71
- * created health check does NOT execute until it is assigned to a system, which
72
- * the model must tell the operator (it cannot assign automatically yet).
85
+ * Appended to a health-check propose summary (and echoed in the tool
86
+ * description) when the draft is NOT being assigned: a health check only runs
87
+ * once it is assigned to a system. The model should prefer to assign in the
88
+ * same step by passing `assignToSystemId`.
73
89
  */
74
- const SYSTEM_ASSIGNMENT_HINT =
75
- "A new health check does not run until it is assigned to a system - after it is applied, tell the operator they must assign it to a system (Health Checks -> the check -> assign to a system) for it to start running.";
90
+ const UNASSIGNED_GUIDANCE =
91
+ "This check is NOT assigned to a system yet, so it will not run. Prefer to assign it in the same step: resolve the target system's id with catalog.listSystems and pass it as assignToSystemId so the check is created, assigned, and started immediately. Otherwise, tell the operator to assign it to a system (Health Checks -> the check -> assign to a system) for it to start running.";
76
92
 
77
93
  /**
78
94
  * Validate a drafted health-check configuration via the health-check plugin's
@@ -248,7 +264,10 @@ export function createHealthcheckProposeTool(): RegisteredAiTool<
248
264
  scriptCollectors.length > 0
249
265
  ? ` (includes ${scriptCollectors.length} script collector${scriptCollectors.length === 1 ? "" : "s"})`
250
266
  : "";
251
- const summary = `Create health check "${input.name}" using strategy "${strategy.displayName}" with ${collectorCount} collector(s), running every ${input.intervalSeconds}s${scriptNote}. ${SYSTEM_ASSIGNMENT_HINT}`;
267
+ const assignmentNote = input.assignToSystemId
268
+ ? " Then assign it to the selected system and start it immediately - it runs once per environment the system belongs to."
269
+ : ` ${UNASSIGNED_GUIDANCE}`;
270
+ const summary = `Create health check "${input.name}" using strategy "${strategy.displayName}" with ${collectorCount} collector(s), running every ${input.intervalSeconds}s${scriptNote}.${assignmentNote}`;
252
271
 
253
272
  return {
254
273
  summary,
@@ -261,7 +280,7 @@ export function createHealthcheckProposeTool(): RegisteredAiTool<
261
280
  return {
262
281
  name: "healthcheck.propose",
263
282
  description:
264
- "Validate a drafted health check (strategy, collectors, interval, and any inline script source) and return it for a human to review and apply. Never creates a health check directly - a person must approve the proposal. Use this to turn a natural-language health-check request (including a script health check) into a concrete, validated draft after testing the script with testScript. If you do not know what an endpoint returns, call probeUrl first to inspect its status code and body, then assert on the real response. Use getCapabilitySchema to get exact collector config fields AND the assertable result fields + valid operators before drafting assertions (assertion field must be a result-schema field like statusCode, operator must be a full word like equals/greaterThan, never an abbreviation). Note: a newly created health check does not run until the operator assigns it to a system.",
283
+ 'Validate a drafted health check (strategy, collectors, interval, and any inline script source) and return it for a human to review and apply. Never creates a health check directly - a person must approve the proposal. PREFER THE HTTP STRATEGY for anything reachable over HTTP(S): for a plain URL, use strategyId "http" with the "healthcheck-http.request" collector (config { url }) and assert on statusCode - do NOT author a SCRIPT health check unless the user explicitly asks for a script or no built-in strategy fits the target. To make the check actually run, pass assignToSystemId so it is created AND assigned in one step (resolve the system\'s id with catalog.listSystems first; create the system with catalog.createSystem if it does not exist). Most systems have exactly one check. If you do not know what an endpoint returns, call probeUrl first to inspect its status code and body, then assert on the real response. For a script health check, test it with testScript first. Use getCapabilitySchema to get exact collector config fields AND the assertable result fields + valid operators before drafting assertions (assertion field must be a result-schema field like statusCode, operator must be a full word like equals/greaterThan, never an abbreviation).',
265
284
  effect: "mutate",
266
285
  input: HealthcheckProposeInputSchema,
267
286
  requiredAccessRules: [
@@ -273,17 +292,32 @@ export function createHealthcheckProposeTool(): RegisteredAiTool<
273
292
  dryRun,
274
293
  async execute({ input, rpcClient }) {
275
294
  // Only reached via `apply` (the propose/apply token gate). The create
276
- // handler runs its own zod + registry validation; this re-validates the
295
+ // handler runs its own zod + registry validation; re-validating the
277
296
  // server-stored payload against the input schema is already done by the
278
297
  // propose/apply service before we get here.
279
298
  const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
280
- const configuration = await healthcheckClient.createConfiguration({
299
+ const configurationInput = {
281
300
  name: input.name,
282
301
  strategyId: input.strategyId,
283
302
  config: input.config,
284
303
  intervalSeconds: input.intervalSeconds,
285
304
  collectors: input.collectors,
286
- });
305
+ };
306
+
307
+ // When a target system is named, atomically create AND assign so the
308
+ // check starts running immediately - no dormant, unassigned check. The
309
+ // user-scoped client enforces `catalog.system` manage on that system.
310
+ if (input.assignToSystemId) {
311
+ const configuration = await healthcheckClient.createAndAssign({
312
+ configuration: configurationInput,
313
+ systemId: input.assignToSystemId,
314
+ environmentIds: input.environmentIds,
315
+ });
316
+ return { configuration };
317
+ }
318
+
319
+ const configuration =
320
+ await healthcheckClient.createConfiguration(configurationInput);
287
321
  return { configuration };
288
322
  },
289
323
  };
package/src/index.ts CHANGED
@@ -479,6 +479,7 @@ export default createBackendPlugin({
479
479
  catalogClient,
480
480
  maintenanceClient,
481
481
  logger,
482
+ signalService,
482
483
  });
483
484
  rpc.registerRouter(healthCheckRouter, healthCheckContract);
484
485
 
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { createHealthCheckRouter } from "./router";
3
+ import { createMockRpcContext } from "@checkstack/backend-api";
4
+ import { call } from "@orpc/server";
5
+ import type { HealthCheckCache } from "./cache";
6
+
7
+ /**
8
+ * Router-level tests for the atomic create+assign path and the
9
+ * `notificationPolicy` forwarding regression. They use a capturing DB so the
10
+ * exact insert values reaching the database can be asserted (the bug being
11
+ * guarded was the `associateSystem` handler silently dropping
12
+ * `input.body.notificationPolicy`).
13
+ */
14
+
15
+ const passthroughCache: HealthCheckCache = {
16
+ wrapSystemHealthStatus: (_systemId, loader) => loader(),
17
+ invalidateSystem: async () => {},
18
+ invalidateAllSystems: async () => 0,
19
+ scope: {} as HealthCheckCache["scope"],
20
+ };
21
+
22
+ const mockUser = {
23
+ type: "user" as const,
24
+ id: "test-user",
25
+ accessRules: ["*"],
26
+ roles: ["admin"],
27
+ };
28
+
29
+ interface CapturedInsert {
30
+ values: Record<string, unknown>;
31
+ }
32
+
33
+ /**
34
+ * A DB that records every `.values(...)` payload. Supports the chains the
35
+ * service uses: `insert().values().returning()`,
36
+ * `insert().values().onConflictDoUpdate()`, a bare awaited
37
+ * `insert().values()`, and `transaction(fn)` (createAndAssign).
38
+ */
39
+ function createCapturingDb(captured: CapturedInsert[]) {
40
+ const insert = () => ({
41
+ values: (values: Record<string, unknown>) => {
42
+ captured.push({ values });
43
+ return Object.assign(Promise.resolve(undefined), {
44
+ onConflictDoUpdate: () => Promise.resolve(undefined),
45
+ onConflictDoNothing: () => Promise.resolve(undefined),
46
+ returning: () =>
47
+ Promise.resolve([
48
+ {
49
+ id: "cfg-new",
50
+ paused: false,
51
+ createdAt: new Date(),
52
+ updatedAt: new Date(),
53
+ collectors: null,
54
+ ...values,
55
+ },
56
+ ]),
57
+ });
58
+ },
59
+ });
60
+ const emptyWhere = Object.assign(Promise.resolve([]), {
61
+ where: () => Promise.resolve([]),
62
+ });
63
+ return {
64
+ insert,
65
+ select: () => ({ from: () => emptyWhere }),
66
+ transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
67
+ fn({ insert }),
68
+ };
69
+ }
70
+
71
+ interface SignalBroadcast {
72
+ signalId: string;
73
+ payload: Record<string, unknown>;
74
+ }
75
+
76
+ function buildRouter(
77
+ captured: CapturedInsert[],
78
+ broadcasts?: SignalBroadcast[],
79
+ ) {
80
+ const signalService = broadcasts
81
+ ? ({
82
+ broadcast: (
83
+ signal: { id: string },
84
+ payload: Record<string, unknown>,
85
+ ) => {
86
+ broadcasts.push({ signalId: signal.id, payload });
87
+ return Promise.resolve();
88
+ },
89
+ } as never)
90
+ : undefined;
91
+ return createHealthCheckRouter({
92
+ database: createCapturingDb(captured) as never,
93
+ registry: { getStrategy: mock(() => undefined) } as never,
94
+ collectorRegistry: { getCollector: mock(() => undefined) } as never,
95
+ gitOpsClient: {
96
+ getProvenance: mock(() => Promise.resolve(null)),
97
+ } as never,
98
+ signalService,
99
+ getEmitHook: () => undefined,
100
+ cache: passthroughCache,
101
+ configService: {
102
+ get: mock(async () => undefined),
103
+ set: mock(async () => {}),
104
+ } as never,
105
+ catalogClient: { getSystem: mock(async () => null) } as never,
106
+ maintenanceClient: {
107
+ hasActiveMaintenance: mock(async () => ({ active: false })),
108
+ } as never,
109
+ logger: {
110
+ debug: mock(() => {}),
111
+ info: mock(() => {}),
112
+ warn: mock(() => {}),
113
+ error: mock(() => {}),
114
+ } as never,
115
+ });
116
+ }
117
+
118
+ const policy = { suppressDeEscalations: true };
119
+ const CONFIG_ID = "12345678-1234-4234-8234-123456789012";
120
+
121
+ describe("associateSystem notificationPolicy forwarding (regression)", () => {
122
+ it("passes the per-assignment notificationPolicy through to the DB write", async () => {
123
+ const captured: CapturedInsert[] = [];
124
+ const router = buildRouter(captured);
125
+ const context = createMockRpcContext({ user: mockUser });
126
+
127
+ await call(
128
+ router.associateSystem,
129
+ {
130
+ systemId: "sys-1",
131
+ body: {
132
+ configurationId: CONFIG_ID,
133
+ enabled: false, // skip scheduling; we only assert the persisted row
134
+ includeLocal: true,
135
+ notificationPolicy: policy,
136
+ },
137
+ },
138
+ { context },
139
+ );
140
+
141
+ expect(captured).toHaveLength(1);
142
+ expect(captured[0].values.notificationPolicy).toEqual(policy);
143
+ expect(captured[0].values.systemId).toBe("sys-1");
144
+ });
145
+ });
146
+
147
+ describe("createAndAssign router handler", () => {
148
+ it("atomically writes the config AND the assignment, forwarding policy + env fan-out", async () => {
149
+ const captured: CapturedInsert[] = [];
150
+ const router = buildRouter(captured);
151
+ const context = createMockRpcContext({ user: mockUser });
152
+
153
+ const result = await call(
154
+ router.createAndAssign,
155
+ {
156
+ systemId: "sys-1",
157
+ configuration: {
158
+ name: "Payments API root",
159
+ strategyId: "healthcheck-http.http",
160
+ config: { url: "https://api.example.com/healthz" },
161
+ intervalSeconds: 60,
162
+ },
163
+ enabled: false,
164
+ includeLocal: true,
165
+ environmentIds: null,
166
+ notificationPolicy: policy,
167
+ },
168
+ { context },
169
+ );
170
+
171
+ // Two writes: the config row, then the assignment row.
172
+ expect(captured).toHaveLength(2);
173
+ const configInsert = captured.find(
174
+ (c) => c.values.name === "Payments API root",
175
+ );
176
+ const assignmentInsert = captured.find(
177
+ (c) => c.values.configurationId !== undefined,
178
+ );
179
+ expect(configInsert).toBeDefined();
180
+ expect(assignmentInsert).toBeDefined();
181
+ expect(assignmentInsert?.values.systemId).toBe("sys-1");
182
+ expect(assignmentInsert?.values.notificationPolicy).toEqual(policy);
183
+ // null environmentIds = fan out to all of the system's environments.
184
+ expect(assignmentInsert?.values.environmentIds).toBeNull();
185
+ // Returns the created configuration.
186
+ expect(result.name).toBe("Payments API root");
187
+ expect(result.id).toBe("cfg-new");
188
+ });
189
+
190
+ it("broadcasts healthcheck.config.changed so open clients refresh", async () => {
191
+ const captured: CapturedInsert[] = [];
192
+ const broadcasts: SignalBroadcast[] = [];
193
+ const router = buildRouter(captured, broadcasts);
194
+ const context = createMockRpcContext({ user: mockUser });
195
+
196
+ await call(
197
+ router.createAndAssign,
198
+ {
199
+ systemId: "sys-1",
200
+ configuration: {
201
+ name: "Payments API root",
202
+ strategyId: "healthcheck-http.http",
203
+ config: { url: "https://api.example.com/healthz" },
204
+ intervalSeconds: 60,
205
+ },
206
+ enabled: false,
207
+ includeLocal: true,
208
+ environmentIds: null,
209
+ },
210
+ { context },
211
+ );
212
+
213
+ const change = broadcasts.find(
214
+ (b) => b.signalId === "healthcheck.config.changed",
215
+ );
216
+ expect(change).toBeDefined();
217
+ expect(change?.payload.entity).toBe("assignment");
218
+ expect(change?.payload.systemId).toBe("sys-1");
219
+ });
220
+ });
package/src/router.ts CHANGED
@@ -9,8 +9,12 @@ import {
9
9
  type CollectorRegistry,
10
10
  type ConfigService,
11
11
  } from "@checkstack/backend-api";
12
- import { healthCheckContract } from "@checkstack/healthcheck-common";
12
+ import {
13
+ healthCheckContract,
14
+ HEALTHCHECK_CONFIG_CHANGED,
15
+ } from "@checkstack/healthcheck-common";
13
16
  import type { StrategyCategory } from "@checkstack/healthcheck-common";
17
+ import { type SignalService } from "@checkstack/signal-common";
14
18
  import {
15
19
  resolveResolutionRootFromStore,
16
20
  resolveScriptPackagesDir,
@@ -21,7 +25,7 @@ import { runCollectorScriptTest } from "./collector-script-test";
21
25
  import { healthCheckHooks } from "./hooks";
22
26
  import * as schema from "./schema";
23
27
  import { toJsonSchemaWithChartMeta } from "./schema-utils";
24
- import type { InferClient } from "@checkstack/common";
28
+ import { extractErrorMessage, type InferClient } from "@checkstack/common";
25
29
  import { GitOpsApi } from "@checkstack/gitops-common";
26
30
  import { CatalogApi } from "@checkstack/catalog-common";
27
31
  import { MaintenanceApi } from "@checkstack/maintenance-common";
@@ -45,6 +49,14 @@ export const createHealthCheckRouter = (opts: {
45
49
  catalogClient: InferClient<typeof CatalogApi>;
46
50
  maintenanceClient: InferClient<typeof MaintenanceApi>;
47
51
  logger: Logger;
52
+ /**
53
+ * Broadcasts `healthcheck.config.changed` so every client's `[[healthcheck]]`
54
+ * cache refreshes after a config/assignment mutation - the only way an
55
+ * out-of-band write (AI assistant, GitOps, another pod/user) reaches an
56
+ * already-open Health Checks list before the first run fires a status signal.
57
+ * Optional so existing tests can omit it.
58
+ */
59
+ signalService?: SignalService;
48
60
  }) => {
49
61
  const {
50
62
  database,
@@ -56,6 +68,7 @@ export const createHealthCheckRouter = (opts: {
56
68
  catalogClient,
57
69
  maintenanceClient,
58
70
  logger,
71
+ signalService,
59
72
  } = opts;
60
73
  // Create service instance once - shared across all handlers
61
74
  const service = new HealthCheckService(
@@ -84,6 +97,77 @@ export const createHealthCheckRouter = (opts: {
84
97
  }
85
98
  };
86
99
 
100
+ /**
101
+ * Fire the `healthcheck.config.changed` signal so every client's
102
+ * `[[healthcheck]]` cache refreshes after a config/assignment write (the
103
+ * executor's run/status signals only fire once a check actually runs).
104
+ * Best-effort: a signal failure must never fail the mutation.
105
+ */
106
+ const broadcastConfigChanged = async (payload: {
107
+ entity: "configuration" | "assignment";
108
+ action: "created" | "updated" | "deleted";
109
+ configurationId?: string;
110
+ systemId?: string;
111
+ }) => {
112
+ try {
113
+ await signalService?.broadcast(HEALTHCHECK_CONFIG_CHANGED, payload);
114
+ } catch (error) {
115
+ logger.warn(
116
+ `Failed to broadcast healthcheck.config.changed signal: ${extractErrorMessage(error, "unknown")}`,
117
+ );
118
+ }
119
+ };
120
+
121
+ /**
122
+ * Post-assignment side effects shared by `associateSystem` and
123
+ * `createAndAssign`: invalidate the system cache, schedule the first run when
124
+ * the assignment is enabled, and emit the `assignmentChanged` hook. Kept in
125
+ * one place so both entry points stay in lock-step and an enabled assignment
126
+ * always starts running immediately.
127
+ */
128
+ const scheduleAndNotifyAssignment = async (args: {
129
+ systemId: string;
130
+ configurationId: string;
131
+ enabled: boolean;
132
+ queueManager: RpcContext["queueManager"];
133
+ }) => {
134
+ await cache.invalidateSystem(args.systemId);
135
+
136
+ // If enabling the health check, schedule it immediately so it starts
137
+ // probing right away.
138
+ if (args.enabled) {
139
+ const config = await service.getConfiguration(args.configurationId);
140
+ if (config) {
141
+ const { scheduleHealthCheck } = await import("./queue-executor");
142
+ await scheduleHealthCheck({
143
+ queueManager: args.queueManager,
144
+ payload: {
145
+ configId: config.id,
146
+ systemId: args.systemId,
147
+ },
148
+ intervalSeconds: config.intervalSeconds,
149
+ });
150
+ }
151
+ }
152
+
153
+ // Notify subscribers (e.g., satellite-backend) that assignments changed.
154
+ const emitHook = getEmitHook();
155
+ if (emitHook) {
156
+ await emitHook(healthCheckHooks.assignmentChanged, {
157
+ systemId: args.systemId,
158
+ configurationId: args.configurationId,
159
+ });
160
+ }
161
+
162
+ // Refresh every client's Health Checks / assignment views.
163
+ await broadcastConfigChanged({
164
+ entity: "assignment",
165
+ action: "updated",
166
+ configurationId: args.configurationId,
167
+ systemId: args.systemId,
168
+ });
169
+ };
170
+
87
171
  return os.router({
88
172
  getStrategies: os.getStrategies.handler(async ({ context }) => {
89
173
  return context.healthCheckRegistry.getStrategiesWithMeta().map((r) => ({
@@ -164,6 +248,11 @@ export const createHealthCheckRouter = (opts: {
164
248
  // safe move is to drop every per-system status cache so the next read
165
249
  // recomputes from fresh DB state.
166
250
  await cache.invalidateAllSystems();
251
+ await broadcastConfigChanged({
252
+ entity: "configuration",
253
+ action: "created",
254
+ configurationId: created.id,
255
+ });
167
256
  return created;
168
257
  }),
169
258
 
@@ -195,6 +284,11 @@ export const createHealthCheckRouter = (opts: {
195
284
  }
196
285
  // Configuration update affects every system that has it associated.
197
286
  await cache.invalidateAllSystems();
287
+ await broadcastConfigChanged({
288
+ entity: "configuration",
289
+ action: "updated",
290
+ configurationId: config.id,
291
+ });
198
292
  return config;
199
293
  }),
200
294
 
@@ -202,18 +296,33 @@ export const createHealthCheckRouter = (opts: {
202
296
  await enforceNotGitOpsLocked("Healthcheck", input.id);
203
297
  await service.deleteConfiguration(input.id);
204
298
  await cache.invalidateAllSystems();
299
+ await broadcastConfigChanged({
300
+ entity: "configuration",
301
+ action: "deleted",
302
+ configurationId: input.id,
303
+ });
205
304
  }),
206
305
 
207
306
  pauseConfiguration: os.pauseConfiguration.handler(async ({ input }) => {
208
307
  await enforceNotGitOpsLocked("Healthcheck", input.id);
209
308
  await service.pauseConfiguration(input.id);
210
309
  await cache.invalidateAllSystems();
310
+ await broadcastConfigChanged({
311
+ entity: "configuration",
312
+ action: "updated",
313
+ configurationId: input.id,
314
+ });
211
315
  }),
212
316
 
213
317
  resumeConfiguration: os.resumeConfiguration.handler(async ({ input }) => {
214
318
  await enforceNotGitOpsLocked("Healthcheck", input.id);
215
319
  await service.resumeConfiguration(input.id);
216
320
  await cache.invalidateAllSystems();
321
+ await broadcastConfigChanged({
322
+ entity: "configuration",
323
+ action: "updated",
324
+ configurationId: input.id,
325
+ });
217
326
  }),
218
327
 
219
328
  getSystemConfigurations: os.getSystemConfigurations.handler(
@@ -238,35 +347,37 @@ export const createHealthCheckRouter = (opts: {
238
347
  satelliteIds: input.body.satelliteIds,
239
348
  environmentIds: input.body.environmentIds,
240
349
  includeLocal: input.body.includeLocal,
350
+ notificationPolicy: input.body.notificationPolicy,
241
351
  });
242
- await cache.invalidateSystem(input.systemId);
243
-
244
- // If enabling the health check, schedule it immediately
245
- if (input.body.enabled) {
246
- const config = await service.getConfiguration(
247
- input.body.configurationId,
248
- );
249
- if (config) {
250
- const { scheduleHealthCheck } = await import("./queue-executor");
251
- await scheduleHealthCheck({
252
- queueManager: context.queueManager,
253
- payload: {
254
- configId: config.id,
255
- systemId: input.systemId,
256
- },
257
- intervalSeconds: config.intervalSeconds,
258
- });
259
- }
260
- }
352
+ await scheduleAndNotifyAssignment({
353
+ systemId: input.systemId,
354
+ configurationId: input.body.configurationId,
355
+ enabled: input.body.enabled,
356
+ queueManager: context.queueManager,
357
+ });
358
+ }),
261
359
 
262
- // Notify subscribers (e.g., satellite-backend) that assignments changed
263
- const emitHook = getEmitHook();
264
- if (emitHook) {
265
- await emitHook(healthCheckHooks.assignmentChanged, {
266
- systemId: input.systemId,
267
- configurationId: input.body.configurationId,
268
- });
269
- }
360
+ createAndAssign: os.createAndAssign.handler(async ({ input, context }) => {
361
+ await enforceNotGitOpsLocked("System", input.systemId);
362
+ // Atomic create + assign in one transaction so the common 1-1 case can
363
+ // never leave a dormant, unassigned check.
364
+ const configuration = await service.createAndAssign({
365
+ configuration: input.configuration,
366
+ systemId: input.systemId,
367
+ enabled: input.enabled,
368
+ stateThresholds: input.stateThresholds,
369
+ satelliteIds: input.satelliteIds,
370
+ environmentIds: input.environmentIds,
371
+ includeLocal: input.includeLocal,
372
+ notificationPolicy: input.notificationPolicy,
373
+ });
374
+ await scheduleAndNotifyAssignment({
375
+ systemId: input.systemId,
376
+ configurationId: configuration.id,
377
+ enabled: input.enabled,
378
+ queueManager: context.queueManager,
379
+ });
380
+ return configuration;
270
381
  }),
271
382
 
272
383
  disassociateSystem: os.disassociateSystem.handler(async ({ input }) => {
@@ -282,6 +393,13 @@ export const createHealthCheckRouter = (opts: {
282
393
  configurationId: input.configId,
283
394
  });
284
395
  }
396
+
397
+ await broadcastConfigChanged({
398
+ entity: "assignment",
399
+ action: "deleted",
400
+ configurationId: input.configId,
401
+ systemId: input.systemId,
402
+ });
285
403
  }),
286
404
 
287
405
  getPlatformNotificationDefaults:
package/src/service.ts CHANGED
@@ -291,6 +291,74 @@ export class HealthCheckService {
291
291
  });
292
292
  }
293
293
 
294
+ /**
295
+ * Atomically create a health-check configuration AND assign it to a system in
296
+ * a single transaction. Backs the `createAndAssign` RPC: the common 1-1
297
+ * onboarding case ("this system gets one check") can never leave a dormant,
298
+ * unassigned check because the config row and its `systemHealthChecks`
299
+ * assignment row commit together or not at all. Scheduling of the first run
300
+ * is the caller's (router's) responsibility, mirroring `associateSystem`.
301
+ */
302
+ async createAndAssign(props: {
303
+ configuration: CreateHealthCheckConfiguration;
304
+ systemId: string;
305
+ enabled?: boolean;
306
+ stateThresholds?: StateThresholds;
307
+ satelliteIds?: string[];
308
+ /**
309
+ * Per-assignment environment selector. `null` (or `undefined`) = all
310
+ * current environments; `[]` = opt out (env-less); non-empty = those ids.
311
+ */
312
+ environmentIds?: string[] | null;
313
+ includeLocal?: boolean;
314
+ notificationPolicy?: NotificationPolicy;
315
+ }): Promise<HealthCheckConfiguration> {
316
+ const {
317
+ configuration,
318
+ systemId,
319
+ enabled = true,
320
+ stateThresholds: stateThresholds_,
321
+ satelliteIds,
322
+ environmentIds,
323
+ includeLocal = true,
324
+ notificationPolicy,
325
+ } = props;
326
+
327
+ // Preserve the null/[]/list distinction faithfully (see associateSystem).
328
+ const environmentIdsValue: string[] | null = environmentIds ?? null;
329
+ const versionedThresholds: VersionedStateThresholds | undefined =
330
+ stateThresholds_ ? stateThresholds.create(stateThresholds_) : undefined;
331
+
332
+ const created = await this.db.transaction(async (tx) => {
333
+ const [config] = await tx
334
+ .insert(healthCheckConfigurations)
335
+ .values({
336
+ name: configuration.name,
337
+ strategyId: configuration.strategyId,
338
+ config: configuration.config,
339
+ collectors: configuration.collectors ?? undefined,
340
+ intervalSeconds: configuration.intervalSeconds,
341
+ isTemplate: false,
342
+ })
343
+ .returning();
344
+
345
+ await tx.insert(systemHealthChecks).values({
346
+ systemId,
347
+ configurationId: config.id,
348
+ enabled,
349
+ stateThresholds: versionedThresholds,
350
+ satelliteIds: satelliteIds ?? undefined,
351
+ environmentIds: environmentIdsValue,
352
+ includeLocal,
353
+ notificationPolicy: notificationPolicy ?? undefined,
354
+ });
355
+
356
+ return config;
357
+ });
358
+
359
+ return this.mapConfig(created);
360
+ }
361
+
294
362
  /**
295
363
  * Flip the `enabled` flag on an existing `systemHealthChecks` row
296
364
  * without touching any of the other configuration (thresholds,