@checkstack/healthcheck-backend 1.6.7 → 1.7.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,82 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0b6f01b: feat(healthcheck): contribute health problems to the backend system.issues aggregator
8
+
9
+ The healthcheck plugin now registers a `system.issues` contributor (sourceId
10
+ `healthcheck`) from its backend `init`, so the AI assistant surfaces degraded
11
+ and unhealthy systems alongside incidents, SLOs, anomalies, and dependency
12
+ problems.
13
+
14
+ The contributor enforces its own `healthcheck.status` access gate (returning an
15
+ empty map - never throwing - when the principal lacks access; service users get
16
+ no signals), then reads the current problem rows for every system from the
17
+ shared, durable `health_check_runs` / `system_health_checks` tables via a new
18
+ global `getAllUnhealthySystemStatuses` service method (every system with an
19
+ enabled check association, evaluated with the same per-system evaluator the
20
+ dashboard uses, healthy systems omitted). The answer is therefore identical on
21
+ every pod, and only systems with a current problem appear in the result.
22
+
23
+ The row->signal mapping (source/tone/label/detail/href/accessRule/iconName) is
24
+ extracted into a new pure `deriveHealthcheckSignals` deriver in
25
+ `@checkstack/healthcheck-common`, shared by both the backend contributor and the
26
+ frontend `HealthSignalsFiller` so the two surfaces stay in lockstep. The
27
+ frontend filler now delegates to that deriver with unchanged behavior.
28
+
29
+ ### Patch Changes
30
+
31
+ - dbb76a2: fix(ai): guide the assistant to find all issues and fix the anomaly tool
32
+
33
+ Two assistant problems reported in production:
34
+
35
+ 1. Asked "are there any issues?", the model answered from a single source (an
36
+ SLO breach) and missed a system with a failing health check. The chat
37
+ system prompt now instructs the model to check ALL issue sources before
38
+ answering - failing health checks (`healthcheck_status`), breaching/at-risk
39
+ SLOs (`slo_listObjectives`), active anomalies (`anomaly_list`), and open
40
+ incidents (`incident_list`) - and not to stop after the first source. It
41
+ also tells the model that `systemId` must be a real system UUID (resolve a
42
+ name via the catalog tool first) and to never invent ids or filter values.
43
+
44
+ 2. The anomaly tool was named `anomaly.explain` but actually LISTS anomalies
45
+ with optional filters. The misleading name led the model to pass a
46
+ non-existent filter value ("Type validation failed") and a system
47
+ name/anomaly id as `systemId` ("a value was malformed"). Renamed to
48
+ `anomaly.list` with a description that spells out the optional filters and
49
+ their valid enum values (state: suspicious|anomaly|recovered, kind:
50
+ spike|drift, suppression: active|suppressed|all) and that `systemId` is a
51
+ system UUID.
52
+
53
+ Also sharpened the `healthcheck.status` and `slo.listObjectives` tool
54
+ descriptions to be use-case oriented ("use when asked what is failing /
55
+ breaching").
56
+
57
+ BREAKING: the anomaly read tool's name changes from `anomaly_explain` to
58
+ `anomaly_list` over the MCP `tools/list` surface. MCP clients referencing it by
59
+ the old name must update.
60
+
61
+ - Updated dependencies [dbb76a2]
62
+ - Updated dependencies [0b6f01b]
63
+ - Updated dependencies [0b6f01b]
64
+ - Updated dependencies [0b6f01b]
65
+ - @checkstack/ai-backend@0.3.0
66
+ - @checkstack/healthcheck-common@1.6.0
67
+ - @checkstack/incident-backend@1.7.0
68
+ - @checkstack/incident-common@1.5.0
69
+ - @checkstack/maintenance-common@1.5.0
70
+ - @checkstack/automation-backend@0.5.8
71
+ - @checkstack/catalog-backend@1.4.8
72
+ - @checkstack/satellite-backend@0.6.8
73
+ - @checkstack/sdk@0.103.1
74
+ - @checkstack/backend-api@0.21.6
75
+ - @checkstack/script-packages-backend@0.3.7
76
+ - @checkstack/command-backend@0.2.6
77
+ - @checkstack/gitops-backend@0.5.6
78
+ - @checkstack/secrets-backend@0.2.6
79
+
3
80
  ## 1.6.7
4
81
 
5
82
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.6.7",
3
+ "version": "1.7.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,29 +14,29 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.21.5",
18
- "@checkstack/ai-backend": "0.2.0",
17
+ "@checkstack/backend-api": "0.21.6",
18
+ "@checkstack/ai-backend": "0.3.0",
19
19
  "@checkstack/ai-common": "0.1.3",
20
- "@checkstack/script-packages-backend": "0.3.6",
20
+ "@checkstack/script-packages-backend": "0.3.7",
21
21
  "@checkstack/cache-api": "0.3.12",
22
22
  "@checkstack/cache-utils": "0.2.17",
23
- "@checkstack/catalog-backend": "1.4.7",
23
+ "@checkstack/catalog-backend": "1.4.8",
24
24
  "@checkstack/catalog-common": "2.3.4",
25
- "@checkstack/command-backend": "0.2.5",
25
+ "@checkstack/command-backend": "0.2.6",
26
26
  "@checkstack/common": "0.15.0",
27
- "@checkstack/gitops-backend": "0.5.5",
27
+ "@checkstack/gitops-backend": "0.5.6",
28
28
  "@checkstack/gitops-common": "0.6.3",
29
- "@checkstack/healthcheck-common": "1.5.4",
29
+ "@checkstack/healthcheck-common": "1.6.0",
30
30
  "@checkstack/secrets-common": "0.2.3",
31
- "@checkstack/secrets-backend": "0.2.5",
32
- "@checkstack/incident-backend": "1.6.7",
33
- "@checkstack/incident-common": "1.4.4",
34
- "@checkstack/automation-backend": "0.5.7",
35
- "@checkstack/maintenance-common": "1.4.4",
31
+ "@checkstack/secrets-backend": "0.2.6",
32
+ "@checkstack/incident-backend": "1.7.0",
33
+ "@checkstack/incident-common": "1.5.0",
34
+ "@checkstack/automation-backend": "0.5.8",
35
+ "@checkstack/maintenance-common": "1.5.0",
36
36
  "@checkstack/notification-common": "1.3.3",
37
37
  "@checkstack/queue-api": "0.3.12",
38
- "@checkstack/satellite-backend": "0.6.7",
39
- "@checkstack/sdk": "0.102.0",
38
+ "@checkstack/satellite-backend": "0.6.8",
39
+ "@checkstack/sdk": "0.103.1",
40
40
  "@checkstack/signal-common": "0.2.9",
41
41
  "@hono/zod-validator": "^0.7.6",
42
42
  "drizzle-orm": "^0.45.0",
@@ -50,7 +50,7 @@
50
50
  "devDependencies": {
51
51
  "@checkstack/drizzle-helper": "0.0.5",
52
52
  "@checkstack/scripts": "0.6.1",
53
- "@checkstack/test-utils-backend": "0.1.39",
53
+ "@checkstack/test-utils-backend": "0.1.40",
54
54
  "@checkstack/tsconfig": "0.0.7",
55
55
  "@types/bun": "^1.0.0",
56
56
  "@types/tdigest": "^0.1.5",
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ HEALTHCHECK_SIGNAL_SOURCE_ID,
4
+ type HealthcheckSignalStatuses,
5
+ } from "@checkstack/healthcheck-common";
6
+ import type { AuthUser } from "@checkstack/backend-api";
7
+ import type { SystemAccessResolver } from "@checkstack/ai-backend";
8
+ import {
9
+ createHealthcheckSignalsContributor,
10
+ type HealthcheckSignalsSource,
11
+ } from "./system-signals-contributor";
12
+
13
+ const unhealthyStatuses: HealthcheckSignalStatuses = {
14
+ s1: {
15
+ status: "unhealthy",
16
+ evaluatedAt: new Date(),
17
+ checkStatuses: [
18
+ {
19
+ configurationId: "c1",
20
+ configurationName: "Ping",
21
+ status: "unhealthy",
22
+ runsConsidered: 5,
23
+ },
24
+ ],
25
+ },
26
+ };
27
+
28
+ function sourceReturning(statuses: HealthcheckSignalStatuses): {
29
+ source: HealthcheckSignalsSource;
30
+ } {
31
+ return { source: { getAllUnhealthySystemStatuses: async () => statuses } };
32
+ }
33
+
34
+ // The per-source gate is owned/tested by createGatedSystemSignalsContributor;
35
+ // these resolver stubs let us focus on this plugin's wiring.
36
+ const allowAll: SystemAccessResolver = {
37
+ accessibleSystemIds: async ({ systemIds }) => systemIds,
38
+ };
39
+ const denyAll: SystemAccessResolver = { accessibleSystemIds: async () => [] };
40
+ const userWith = (accessRules: string[]): AuthUser => ({
41
+ type: "user",
42
+ id: "u1",
43
+ accessRules,
44
+ });
45
+
46
+ describe("createHealthcheckSignalsContributor", () => {
47
+ it("exposes the shared source id", () => {
48
+ const { source } = sourceReturning({});
49
+ const contributor = createHealthcheckSignalsContributor({
50
+ service: source,
51
+ resolver: allowAll,
52
+ });
53
+ expect(contributor.sourceId).toBe(HEALTHCHECK_SIGNAL_SOURCE_ID);
54
+ });
55
+
56
+ it("wires the service + shared deriver for an authorized principal", async () => {
57
+ const { source } = sourceReturning(unhealthyStatuses);
58
+ const contributor = createHealthcheckSignalsContributor({
59
+ service: source,
60
+ resolver: allowAll,
61
+ });
62
+
63
+ const result = await contributor.read({
64
+ principal: userWith(["healthcheck.healthcheck.status.read"]),
65
+ });
66
+
67
+ expect(Object.keys(result.signals)).toEqual(["s1"]);
68
+ expect(result.signals.s1[0]).toMatchObject({
69
+ source: HEALTHCHECK_SIGNAL_SOURCE_ID,
70
+ tone: "error",
71
+ label: "Unhealthy",
72
+ });
73
+ });
74
+
75
+ it("routes a non-global user through the team gate (no grants -> nothing)", async () => {
76
+ const { source } = sourceReturning(unhealthyStatuses);
77
+ const contributor = createHealthcheckSignalsContributor({
78
+ service: source,
79
+ resolver: denyAll,
80
+ });
81
+
82
+ const result = await contributor.read({ principal: userWith([]) });
83
+
84
+ expect(result).toEqual({ accessible: false, signals: {} });
85
+ });
86
+ });
@@ -0,0 +1,46 @@
1
+ import {
2
+ deriveHealthcheckSignals,
3
+ healthCheckAccess,
4
+ HEALTHCHECK_SIGNAL_SOURCE_ID,
5
+ type HealthcheckSignalStatuses,
6
+ } from "@checkstack/healthcheck-common";
7
+ import {
8
+ createGatedSystemSignalsContributor,
9
+ type SystemAccessResolver,
10
+ type SystemSignalsContributor,
11
+ } from "@checkstack/ai-backend";
12
+
13
+ /**
14
+ * The slice of `HealthCheckService` this contributor needs: a global,
15
+ * pod-independent scan of every currently-degraded/unhealthy system. Narrowed
16
+ * to an interface so the contributor is unit-testable without constructing the
17
+ * full service (or a database).
18
+ */
19
+ export interface HealthcheckSignalsSource {
20
+ getAllUnhealthySystemStatuses(): Promise<HealthcheckSignalStatuses>;
21
+ }
22
+
23
+ /**
24
+ * Build the healthcheck contributor for the AI `system.issues` aggregator. Reads
25
+ * the global durable scan of unhealthy systems and runs the SAME deriver the
26
+ * dashboard filler uses. The per-source access gate (global `healthcheck.status`
27
+ * plus per-system team grants) is applied by
28
+ * {@link createGatedSystemSignalsContributor}.
29
+ */
30
+ export function createHealthcheckSignalsContributor({
31
+ service,
32
+ resolver,
33
+ }: {
34
+ service: HealthcheckSignalsSource;
35
+ resolver: SystemAccessResolver;
36
+ }): SystemSignalsContributor {
37
+ return createGatedSystemSignalsContributor({
38
+ sourceId: HEALTHCHECK_SIGNAL_SOURCE_ID,
39
+ accessRule: healthCheckAccess.status,
40
+ resolver,
41
+ readSignals: async () =>
42
+ deriveHealthcheckSignals({
43
+ statuses: await service.getAllUnhealthySystemStatuses(),
44
+ }),
45
+ });
46
+ }
package/src/index.ts CHANGED
@@ -21,8 +21,11 @@ import {
21
21
  aiToolExtensionPoint,
22
22
  aiToolProjectionExtensionPoint,
23
23
  deferredProjectionExecute,
24
+ systemSignalsExtensionPoint,
25
+ createSystemAccessResolver,
24
26
  } from "@checkstack/ai-backend";
25
27
  import { buildHealthcheckAiTools } from "./ai/register-ai-tools";
28
+ import { createHealthcheckSignalsContributor } from "./ai/system-signals-contributor";
26
29
  import {
27
30
  createBackendPlugin,
28
31
  coreServices,
@@ -234,11 +237,12 @@ export default createBackendPlugin({
234
237
  // computes each system's aggregate from durable `health_check_runs`,
235
238
  // and the queue worker (set up just below — the only mutation site)
236
239
  // drives writes through `handle.mutate`.
237
- healthEntityService = new HealthCheckService(
240
+ const service = new HealthCheckService(
238
241
  database,
239
242
  healthCheckRegistry,
240
243
  collectorRegistry,
241
244
  );
245
+ healthEntityService = service;
242
246
 
243
247
  // Register this plugin's AI tools (propose/update/delete) into the AI
244
248
  // registry via the extension point - owned here, not in ai-backend.
@@ -259,11 +263,29 @@ export default createBackendPlugin({
259
263
  procedureKey: "getConfigurations",
260
264
  name: "healthcheck.status",
261
265
  description:
262
- "List health-check configurations and their current status. Read-only.",
266
+ "List systems' health checks and their current status, including " +
267
+ "which are failing/unhealthy. Use this when asked what is down, " +
268
+ "failing, or unhealthy. Read-only.",
263
269
  effect: "read",
264
270
  execute: deferredProjectionExecute,
265
271
  });
266
272
 
273
+ // Contribute this plugin's per-system health problems to the AI
274
+ // `system.issues` aggregator. PER-SOURCE access is OUR job: gate on the
275
+ // principal's `healthcheck.status` grant and return {} (never throw)
276
+ // when not satisfied. The read derives from the durable
277
+ // `health_check_runs` / `system_health_checks` tables (global, identical
278
+ // on every pod) and reuses the SAME pure deriver as the dashboard
279
+ // filler, so backend signals match the UI's source/tone/label/detail.
280
+ env
281
+ .getExtensionPoint(systemSignalsExtensionPoint)
282
+ .contribute(
283
+ createHealthcheckSignalsContributor({
284
+ service,
285
+ resolver: createSystemAccessResolver(rpcClient),
286
+ }),
287
+ );
288
+
267
289
  // Create catalog client for notification delegation
268
290
  const catalogClient = rpcClient.forPlugin(CatalogApi);
269
291
 
package/src/service.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  NotificationPolicySchema,
11
11
  DEFAULT_NOTIFICATION_POLICY,
12
12
  type CollectorConfigEntry,
13
+ type HealthcheckSignalStatuses,
13
14
  } from "@checkstack/healthcheck-common";
14
15
  import type { ConfigService } from "@checkstack/backend-api";
15
16
  import type { InferClient } from "@checkstack/common";
@@ -624,6 +625,36 @@ export class HealthCheckService {
624
625
  };
625
626
  }
626
627
 
628
+ /**
629
+ * Global problem scan across EVERY system that has at least one enabled
630
+ * health-check association. Returns the evaluated status keyed by systemId,
631
+ * containing ONLY systems that are currently degraded or unhealthy (healthy
632
+ * systems are omitted). This is the read source for the AI system-signals
633
+ * contributor: it must answer the same on every pod, so it derives entirely
634
+ * from the durable `health_check_runs` / `system_health_checks` tables via
635
+ * the same per-system evaluator the dashboard uses - no per-caller systemId
636
+ * list and no process-local state.
637
+ */
638
+ async getAllUnhealthySystemStatuses(): Promise<HealthcheckSignalStatuses> {
639
+ // Distinct systemIds that have at least one ENABLED check association.
640
+ // `getSystemHealthStatus` already short-circuits to healthy for systems
641
+ // with no enabled associations, so this is the complete candidate set.
642
+ const rows = await this.db
643
+ .selectDistinct({ systemId: systemHealthChecks.systemId })
644
+ .from(systemHealthChecks)
645
+ .where(eq(systemHealthChecks.enabled, true));
646
+
647
+ const result: HealthcheckSignalStatuses = {};
648
+ await Promise.all(
649
+ rows.map(async ({ systemId }) => {
650
+ const status = await this.getSystemHealthStatus(systemId);
651
+ if (status.status === "healthy") return; // problems only
652
+ result[systemId] = status;
653
+ }),
654
+ );
655
+ return result;
656
+ }
657
+
627
658
  /**
628
659
  * Live health-state snapshot for a single system (Wave-2 sensing
629
660
  * contract). When `configurationId` is given, status reflects that