@checkstack/healthcheck-backend 1.5.0 → 1.6.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/drizzle/0018_abnormal_preak.sql +10 -0
  3. package/drizzle/meta/0018_snapshot.json +600 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +26 -21
  6. package/src/ai/assertion-validation.test.ts +117 -0
  7. package/src/ai/assertion-validation.ts +147 -0
  8. package/src/ai/healthcheck-capabilities.test.ts +158 -0
  9. package/src/ai/healthcheck-capabilities.ts +217 -0
  10. package/src/ai/healthcheck-delete.test.ts +81 -0
  11. package/src/ai/healthcheck-delete.ts +81 -0
  12. package/src/ai/healthcheck-projection.test.ts +36 -0
  13. package/src/ai/healthcheck-propose.test.ts +268 -0
  14. package/src/ai/healthcheck-propose.ts +290 -0
  15. package/src/ai/healthcheck-script-tools.test.ts +93 -0
  16. package/src/ai/healthcheck-script-tools.ts +179 -0
  17. package/src/ai/healthcheck-update.test.ts +123 -0
  18. package/src/ai/healthcheck-update.ts +123 -0
  19. package/src/ai/notify-subscribers.test.ts +109 -0
  20. package/src/ai/notify-subscribers.ts +176 -0
  21. package/src/ai/register-ai-tools.test.ts +41 -0
  22. package/src/ai/register-ai-tools.ts +53 -0
  23. package/src/ai/shell-env-table.test.ts +47 -0
  24. package/src/automations.test.ts +2 -1
  25. package/src/automations.ts +9 -1
  26. package/src/collector-script-test.test.ts +53 -1
  27. package/src/collector-script-test.ts +59 -7
  28. package/src/effective-environments.test.ts +93 -0
  29. package/src/effective-environments.ts +64 -0
  30. package/src/health-entity-id.ts +57 -0
  31. package/src/health-entity.test.ts +384 -6
  32. package/src/health-entity.ts +93 -35
  33. package/src/health-state.ts +41 -4
  34. package/src/healthcheck-gitops-kinds.test.ts +95 -0
  35. package/src/healthcheck-gitops-kinds.ts +56 -13
  36. package/src/index.ts +30 -0
  37. package/src/migration-chain-contract.test.ts +57 -0
  38. package/src/queue-executor.test.ts +801 -0
  39. package/src/queue-executor.ts +336 -52
  40. package/src/realtime-aggregation.test.ts +30 -0
  41. package/src/realtime-aggregation.ts +16 -0
  42. package/src/retention-job.ts +167 -93
  43. package/src/retention-rollup.test.ts +118 -0
  44. package/src/router.test.ts +120 -1
  45. package/src/router.ts +20 -0
  46. package/src/schema.ts +44 -6
  47. package/src/service.ts +199 -43
  48. package/src/state-transitions.test.ts +104 -0
  49. package/src/state-transitions.ts +39 -1
  50. package/src/validate-configuration.test.ts +205 -0
  51. package/src/validate-configuration.ts +159 -0
  52. package/tsconfig.json +9 -0
@@ -0,0 +1,123 @@
1
+ import { z } from "zod";
2
+ import { qualifyAccessRuleId } from "@checkstack/common";
3
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
4
+ import {
5
+ HealthCheckApi,
6
+ UpdateHealthCheckConfigurationSchema,
7
+ healthCheckAccess,
8
+ pluginMetadata as healthcheckPluginMetadata,
9
+ type HealthCheckConfiguration,
10
+ } from "@checkstack/healthcheck-common";
11
+ import { computeFieldDiff, type AiProposalPreview } from "@checkstack/ai-common";
12
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
13
+ import {
14
+ validateHealthcheckDraft,
15
+ type HealthcheckProposeInput,
16
+ } from "./healthcheck-propose";
17
+
18
+ /**
19
+ * Input for `healthcheck.update`: the id plus a PARTIAL configuration body. Only
20
+ * the provided fields change; the rest are kept from the existing config. The
21
+ * merged result is deep-validated before apply, exactly like create.
22
+ */
23
+ export const HealthcheckUpdateInputSchema = z.object({
24
+ id: z.string().min(1),
25
+ body: UpdateHealthCheckConfigurationSchema,
26
+ });
27
+ export type HealthcheckUpdateInput = z.infer<typeof HealthcheckUpdateInputSchema>;
28
+
29
+ /** Output returned once a human applies the update (the updated config). */
30
+ export interface HealthcheckUpdateApplyResult {
31
+ configuration: HealthCheckConfiguration;
32
+ }
33
+
34
+ /**
35
+ * Merge a partial update body over the existing config into a full create-shaped
36
+ * configuration, so it can run through the SAME deep validation as create.
37
+ */
38
+ function mergeConfig({
39
+ existing,
40
+ body,
41
+ }: {
42
+ existing: HealthCheckConfiguration;
43
+ body: HealthcheckUpdateInput["body"];
44
+ }): HealthcheckProposeInput {
45
+ return {
46
+ name: body.name ?? existing.name,
47
+ strategyId: body.strategyId ?? existing.strategyId,
48
+ config: body.config ?? existing.config,
49
+ intervalSeconds: body.intervalSeconds ?? existing.intervalSeconds,
50
+ collectors: body.collectors ?? existing.collectors,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * `healthcheck.update` - update an existing health-check configuration by id.
56
+ *
57
+ * `effect: "mutate"` - a non-destructive change, so it auto-applies in AUTO mode
58
+ * and is confirm-gated in APPROVE mode, like `healthcheck.propose`. `dryRun`
59
+ * merges the partial body over the live config and deep-validates the RESULT
60
+ * (the same migrate-then-validate-strict path as create, including assertion
61
+ * field/operator validation), so an invalid edit is rejected with a precise,
62
+ * self-correcting error before apply.
63
+ */
64
+ export function createHealthcheckUpdateTool(): RegisteredAiTool<
65
+ HealthcheckUpdateInput,
66
+ HealthcheckUpdateApplyResult
67
+ > {
68
+ const dryRun = async ({
69
+ input,
70
+ rpcClient,
71
+ }: {
72
+ input: HealthcheckUpdateInput;
73
+ principal: AuthUser;
74
+ rpcClient: RpcClient;
75
+ }): Promise<AiProposalPreview<HealthcheckUpdateInput>> => {
76
+ const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
77
+ const existing = await healthcheckClient.getConfiguration({ id: input.id });
78
+ if (!existing) {
79
+ throw new Error(
80
+ `No health check found with id "${input.id}". List health checks first to get a valid id.`,
81
+ );
82
+ }
83
+ const merged = mergeConfig({ existing, body: input.body });
84
+ // Deep-validate the merged result (throws with structured detail if invalid,
85
+ // including bad assertion fields/operators).
86
+ const { strategy } = await validateHealthcheckDraft({
87
+ input: merged,
88
+ rpcClient,
89
+ });
90
+ // before -> after diff over the updatable fields only (the existing config
91
+ // also carries id/timestamps/paused that are not part of an update).
92
+ const before = mergeConfig({ existing, body: {} });
93
+ const diff = computeFieldDiff({ before, after: merged });
94
+ return {
95
+ summary: `Update health check "${merged.name}" (strategy "${strategy.displayName}", every ${merged.intervalSeconds}s, ${merged.collectors?.length ?? 0} collector(s)).`,
96
+ payload: input,
97
+ diff,
98
+ };
99
+ };
100
+
101
+ return {
102
+ name: "healthcheck.update",
103
+ description:
104
+ "Update an existing health-check configuration by id with a partial body (only provided fields change). The merged result is validated like create (use getCapabilitySchema for exact config fields and assertable result fields + operators). Never updates directly; a person must approve unless the conversation is in auto mode. Find the id with the health-check read tools first.",
105
+ effect: "mutate",
106
+ input: HealthcheckUpdateInputSchema,
107
+ requiredAccessRules: [
108
+ qualifyAccessRuleId(
109
+ healthcheckPluginMetadata,
110
+ healthCheckAccess.configuration.manage,
111
+ ),
112
+ ],
113
+ dryRun,
114
+ async execute({ input, rpcClient }) {
115
+ const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
116
+ const configuration = await healthcheckClient.updateConfiguration({
117
+ id: input.id,
118
+ body: input.body,
119
+ });
120
+ return { configuration };
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import {
4
+ healthcheckSystemSubscription,
5
+ healthcheckGroupSubscription,
6
+ } from "@checkstack/healthcheck-common";
7
+ import {
8
+ createNotifySystemSubscribersTool,
9
+ createNotifySystemGroupSubscribersTool,
10
+ } from "./notify-subscribers";
11
+
12
+ const principal: AuthUser = {
13
+ type: "application",
14
+ id: "svc",
15
+ name: "Svc",
16
+ accessRules: ["notification.notification.send.manage"],
17
+ teamIds: [],
18
+ };
19
+
20
+ function makeRpcClient() {
21
+ const notifyMock = mock(async (_input: unknown) => ({ notifiedCount: 3 }));
22
+ const rpcClient = {
23
+ forPlugin: () => ({ notifyForSubscription: notifyMock }),
24
+ } as unknown as RpcClient;
25
+ return { rpcClient, notifyMock };
26
+ }
27
+
28
+ describe("notify-subscriber tools", () => {
29
+ it("system tool: metadata + dispatches under the system spec", async () => {
30
+ const tool = createNotifySystemSubscribersTool();
31
+ expect(tool.name).toBe("healthcheck.notifySystemSubscribers");
32
+ expect(tool.effect).toBe("mutate");
33
+ expect(tool.requiredAccessRules).toEqual([
34
+ "notification.notification.send.manage",
35
+ ]);
36
+ expect(typeof tool.dryRun).toBe("function");
37
+
38
+ const { rpcClient, notifyMock } = makeRpcClient();
39
+ const result = await tool.execute({
40
+ input: {
41
+ systemId: "sys-1",
42
+ systemName: "API Gateway",
43
+ title: "Degraded",
44
+ body: "API Gateway is unhealthy",
45
+ importance: "warning",
46
+ actionLabel: "View",
47
+ actionUrl: "https://x/sys-1",
48
+ },
49
+ principal,
50
+ rpcClient,
51
+ });
52
+
53
+ expect(result).toEqual({ notifiedCount: 3 });
54
+ const call = notifyMock.mock.calls[0]![0] as {
55
+ specId: string;
56
+ resourceKeys: string[];
57
+ subjects: Array<{ kind: string; id: string; name: string }>;
58
+ action?: { label: string; url: string };
59
+ };
60
+ expect(call.specId).toBe(healthcheckSystemSubscription.specId);
61
+ expect(call.resourceKeys).toEqual(["sys-1"]);
62
+ expect(call.subjects[0]).toMatchObject({
63
+ kind: "catalog.system",
64
+ id: "sys-1",
65
+ name: "API Gateway",
66
+ });
67
+ expect(call.action).toEqual({ label: "View", url: "https://x/sys-1" });
68
+ });
69
+
70
+ it("system tool: dryRun summarizes without sending", async () => {
71
+ const tool = createNotifySystemSubscribersTool();
72
+ const { rpcClient, notifyMock } = makeRpcClient();
73
+ const preview = await tool.dryRun!({
74
+ input: {
75
+ systemId: "sys-1",
76
+ title: "Hi",
77
+ body: "Body",
78
+ } as never,
79
+ principal,
80
+ rpcClient,
81
+ });
82
+ expect(preview.summary).toContain("sys-1");
83
+ expect(notifyMock).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it("group tool: dispatches under the group spec with a group subject", async () => {
87
+ const tool = createNotifySystemGroupSubscribersTool();
88
+ expect(tool.name).toBe("healthcheck.notifySystemGroupSubscribers");
89
+ const { rpcClient, notifyMock } = makeRpcClient();
90
+ await tool.execute({
91
+ input: {
92
+ groupId: "grp-1",
93
+ groupName: "Prod",
94
+ title: "Degraded",
95
+ body: "A system in Prod is unhealthy",
96
+ },
97
+ principal,
98
+ rpcClient,
99
+ });
100
+ const call = notifyMock.mock.calls[0]![0] as {
101
+ specId: string;
102
+ resourceKeys: string[];
103
+ subjects: Array<{ kind: string; id: string }>;
104
+ };
105
+ expect(call.specId).toBe(healthcheckGroupSubscription.specId);
106
+ expect(call.resourceKeys).toEqual(["grp-1"]);
107
+ expect(call.subjects[0]).toMatchObject({ kind: "catalog.group", id: "grp-1" });
108
+ });
109
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * AI tools to notify the subscribers of a system or system group under the
3
+ * health-status subscription specs this plugin owns.
4
+ *
5
+ * These are hand-authored `mutate` tools. They call
6
+ * `notification.notifyForSubscription` through the call-time `rpcClient`
7
+ * (user-scoped in chat; the automation's `runAs` service account in the AI
8
+ * action), so the caller must hold `notification.send`. The notification
9
+ * backend enforces that rule for non-service callers and validates the
10
+ * resource keys, so a tool can only reach real subscribers.
11
+ */
12
+ import { z } from "zod";
13
+ import { qualifyAccessRuleId } from "@checkstack/common";
14
+ import type { RpcClient } from "@checkstack/backend-api";
15
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
16
+ import type { AiProposalPreview } from "@checkstack/ai-common";
17
+ import {
18
+ NotificationApi,
19
+ notificationAccess,
20
+ pluginMetadata as notificationPluginMetadata,
21
+ } from "@checkstack/notification-common";
22
+ import {
23
+ createSystemSubject,
24
+ createGroupSubject,
25
+ } from "@checkstack/catalog-common";
26
+ import {
27
+ healthcheckSystemSubscription,
28
+ healthcheckGroupSubscription,
29
+ } from "@checkstack/healthcheck-common";
30
+
31
+ const NOTIFICATION_SEND_RULE = qualifyAccessRuleId(
32
+ notificationPluginMetadata,
33
+ notificationAccess.send,
34
+ );
35
+
36
+ const baseInputShape = {
37
+ title: z.string().min(1).describe("Notification title"),
38
+ body: z.string().min(1).describe("Notification body (supports markdown)"),
39
+ importance: z
40
+ .enum(["info", "warning", "critical"])
41
+ .optional()
42
+ .describe("Severity; defaults to 'info'"),
43
+ actionLabel: z.string().optional().describe("Optional action button label"),
44
+ actionUrl: z.string().optional().describe("Optional action button URL"),
45
+ };
46
+
47
+ const NotifySystemSubscribersInputSchema = z.object({
48
+ systemId: z.string().min(1).describe("Id of the system whose subscribers to notify"),
49
+ systemName: z
50
+ .string()
51
+ .optional()
52
+ .describe("Display name of the system (falls back to the id)"),
53
+ ...baseInputShape,
54
+ });
55
+ export type NotifySystemSubscribersInput = z.infer<
56
+ typeof NotifySystemSubscribersInputSchema
57
+ >;
58
+
59
+ const NotifyGroupSubscribersInputSchema = z.object({
60
+ groupId: z.string().min(1).describe("Id of the system group whose subscribers to notify"),
61
+ groupName: z
62
+ .string()
63
+ .optional()
64
+ .describe("Display name of the group (falls back to the id)"),
65
+ ...baseInputShape,
66
+ });
67
+ export type NotifyGroupSubscribersInput = z.infer<
68
+ typeof NotifyGroupSubscribersInputSchema
69
+ >;
70
+
71
+ export interface NotifyResult {
72
+ notifiedCount: number;
73
+ }
74
+
75
+ function actionFrom(input: {
76
+ actionLabel?: string;
77
+ actionUrl?: string;
78
+ }): { label: string; url: string } | undefined {
79
+ return input.actionLabel && input.actionUrl
80
+ ? { label: input.actionLabel, url: input.actionUrl }
81
+ : undefined;
82
+ }
83
+
84
+ /**
85
+ * `healthcheck.notifySystemSubscribers` - notify everyone subscribed to a
86
+ * system's health status.
87
+ */
88
+ export function createNotifySystemSubscribersTool(): RegisteredAiTool<
89
+ NotifySystemSubscribersInput,
90
+ NotifyResult
91
+ > {
92
+ const dryRun = async ({
93
+ input,
94
+ }: {
95
+ input: NotifySystemSubscribersInput;
96
+ rpcClient: RpcClient;
97
+ }): Promise<AiProposalPreview<NotifySystemSubscribersInput>> => ({
98
+ summary: `Notify subscribers of system "${input.systemName ?? input.systemId}": ${input.title}`,
99
+ payload: input,
100
+ });
101
+
102
+ return {
103
+ name: "healthcheck.notifySystemSubscribers",
104
+ description:
105
+ "Notify everyone subscribed to a system's health status. Requires confirmation before it sends (unless in auto mode). Provide the system id (and ideally its name).",
106
+ effect: "mutate",
107
+ input: NotifySystemSubscribersInputSchema,
108
+ requiredAccessRules: [NOTIFICATION_SEND_RULE],
109
+ dryRun,
110
+ async execute({ input, rpcClient }) {
111
+ const result = await rpcClient.forPlugin(NotificationApi).notifyForSubscription({
112
+ specId: healthcheckSystemSubscription.specId,
113
+ resourceKeys: [input.systemId],
114
+ title: input.title,
115
+ body: input.body,
116
+ importance: input.importance,
117
+ action: actionFrom(input),
118
+ subjects: [
119
+ createSystemSubject({
120
+ id: input.systemId,
121
+ name: input.systemName ?? input.systemId,
122
+ url: input.actionUrl,
123
+ }),
124
+ ],
125
+ });
126
+ return { notifiedCount: result.notifiedCount };
127
+ },
128
+ };
129
+ }
130
+
131
+ /**
132
+ * `healthcheck.notifySystemGroupSubscribers` - notify everyone subscribed to a
133
+ * system group's health status.
134
+ */
135
+ export function createNotifySystemGroupSubscribersTool(): RegisteredAiTool<
136
+ NotifyGroupSubscribersInput,
137
+ NotifyResult
138
+ > {
139
+ const dryRun = async ({
140
+ input,
141
+ }: {
142
+ input: NotifyGroupSubscribersInput;
143
+ rpcClient: RpcClient;
144
+ }): Promise<AiProposalPreview<NotifyGroupSubscribersInput>> => ({
145
+ summary: `Notify subscribers of system group "${input.groupName ?? input.groupId}": ${input.title}`,
146
+ payload: input,
147
+ });
148
+
149
+ return {
150
+ name: "healthcheck.notifySystemGroupSubscribers",
151
+ description:
152
+ "Notify everyone subscribed to a system group's health status. Requires confirmation before it sends (unless in auto mode). Provide the group id (and ideally its name).",
153
+ effect: "mutate",
154
+ input: NotifyGroupSubscribersInputSchema,
155
+ requiredAccessRules: [NOTIFICATION_SEND_RULE],
156
+ dryRun,
157
+ async execute({ input, rpcClient }) {
158
+ const result = await rpcClient.forPlugin(NotificationApi).notifyForSubscription({
159
+ specId: healthcheckGroupSubscription.specId,
160
+ resourceKeys: [input.groupId],
161
+ title: input.title,
162
+ body: input.body,
163
+ importance: input.importance,
164
+ action: actionFrom(input),
165
+ subjects: [
166
+ createGroupSubject({
167
+ id: input.groupId,
168
+ name: input.groupName ?? input.groupId,
169
+ url: input.actionUrl,
170
+ }),
171
+ ],
172
+ });
173
+ return { notifiedCount: result.notifiedCount };
174
+ },
175
+ };
176
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildHealthcheckAiTools } from "./register-ai-tools";
3
+
4
+ describe("buildHealthcheckAiTools", () => {
5
+ test("registers propose/update/delete with the right effects + manage rule", () => {
6
+ const tools = buildHealthcheckAiTools();
7
+ const byName = new Map(tools.map((t) => [t.name, t]));
8
+
9
+ expect(byName.get("healthcheck.propose")?.effect).toBe("mutate");
10
+ expect(byName.get("healthcheck.update")?.effect).toBe("mutate");
11
+ // Delete is destructive, so the propose/apply gate ALWAYS confirms it.
12
+ expect(byName.get("healthcheck.delete")?.effect).toBe("destructive");
13
+
14
+ // The mutating tools are gated by the manage rule.
15
+ for (const name of [
16
+ "healthcheck.propose",
17
+ "healthcheck.update",
18
+ "healthcheck.delete",
19
+ ] as const) {
20
+ expect(byName.get(name)?.requiredAccessRules).toEqual([
21
+ "healthcheck.healthcheck.manage",
22
+ ]);
23
+ }
24
+ });
25
+
26
+ test("registers the read-only capability tools gated by the read rule", () => {
27
+ const tools = buildHealthcheckAiTools();
28
+ const byName = new Map(tools.map((t) => [t.name, t]));
29
+
30
+ for (const name of [
31
+ "healthcheck.listCapabilities",
32
+ "healthcheck.getCapabilitySchema",
33
+ ] as const) {
34
+ const tool = byName.get(name);
35
+ expect(tool?.effect).toBe("read");
36
+ expect(tool?.requiredAccessRules).toEqual([
37
+ "healthcheck.healthcheck.read",
38
+ ]);
39
+ }
40
+ });
41
+ });
@@ -0,0 +1,53 @@
1
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
2
+ import { createHealthcheckProposeTool } from "./healthcheck-propose";
3
+ import { createHealthcheckUpdateTool } from "./healthcheck-update";
4
+ import { createHealthcheckDeleteTool } from "./healthcheck-delete";
5
+ import {
6
+ createHealthcheckListCapabilitiesTool,
7
+ createHealthcheckGetCapabilitySchemaTool,
8
+ } from "./healthcheck-capabilities";
9
+ import {
10
+ createHealthcheckGetScriptContextTool,
11
+ createHealthcheckTestScriptTool,
12
+ } from "./healthcheck-script-tools";
13
+ import {
14
+ createNotifySystemSubscribersTool,
15
+ createNotifySystemGroupSubscribersTool,
16
+ } from "./notify-subscribers";
17
+
18
+ /**
19
+ * The health-check plugin's AI tools, registered into the AI registry via
20
+ * `aiToolExtensionPoint` from this plugin's own init - NOT centralized in
21
+ * ai-backend. This is the canonical pattern any plugin (first- or third-party)
22
+ * uses to contribute AI tools without ai-backend depending on it.
23
+ *
24
+ * The propose/update/delete tools are propose/apply-gated mutating tools
25
+ * (create/update are `mutate`; delete is `destructive`, so always confirm-gated).
26
+ * They go through the USER-SCOPED client passed at call time, so handler-side
27
+ * authorization is enforced exactly as a direct UI/RPC call; the resolver gate +
28
+ * the propose/apply re-check at propose AND apply time are the additional
29
+ * authorization authority.
30
+ *
31
+ * The two capability tools (`healthcheck.listCapabilities` /
32
+ * `healthcheck.getCapabilitySchema`) are `read` tools gated by the healthcheck
33
+ * config read rule; the resolver gate is their authority.
34
+ *
35
+ * The two script tools (`healthcheck.getScriptContext` /
36
+ * `healthcheck.testScript`) are `read` tools gated by the healthcheck
37
+ * configuration-manage rule. They are the healthcheck half of what used to be
38
+ * cross-plugin script-context tools in ai-backend; being single-context, the
39
+ * resolver gate is their authority (no in-execute context re-check).
40
+ */
41
+ export function buildHealthcheckAiTools(): RegisteredAiTool[] {
42
+ return [
43
+ createHealthcheckProposeTool(),
44
+ createHealthcheckUpdateTool(),
45
+ createHealthcheckDeleteTool(),
46
+ createHealthcheckListCapabilitiesTool(),
47
+ createHealthcheckGetCapabilitySchemaTool(),
48
+ createHealthcheckGetScriptContextTool(),
49
+ createHealthcheckTestScriptTool(),
50
+ createNotifySystemSubscribersTool(),
51
+ createNotifySystemGroupSubscribersTool(),
52
+ ];
53
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildShellRunContextEnv } from "../collector-script-test";
3
+ import { HEALTHCHECK_SHELL_ENV } from "@checkstack/ai-backend";
4
+
5
+ /**
6
+ * Drift guard (OQ-2): the static `HEALTHCHECK_SHELL_ENV` descriptor table is
7
+ * hand-maintained in ai-backend (cross-plugin import is deliberately avoided in
8
+ * the runtime code). This test asserts every reserved `CHECKSTACK_*` var the
9
+ * real producer (`buildShellRunContextEnv`) emits for a representative sample
10
+ * context is described in the static table. If the producer gains a new
11
+ * reserved var, this test fails until the table is updated.
12
+ */
13
+ describe("HEALTHCHECK_SHELL_ENV drift guard", () => {
14
+ test("the static table describes every reserved var the producer emits", () => {
15
+ const produced = buildShellRunContextEnv({
16
+ check: { id: "c1", name: "Check One", intervalSeconds: 60 },
17
+ system: { id: "s1", name: "System One" },
18
+ environment: {
19
+ id: "e1",
20
+ name: "prod",
21
+ // A custom field exercises the CHECKSTACK_ENV_<FIELD> derivation.
22
+ fields: { region: "eu-central-1" },
23
+ },
24
+ });
25
+
26
+ const tableNames = new Set(HEALTHCHECK_SHELL_ENV.map((v) => v.name));
27
+ // The dynamic per-field var is covered by the wildcard descriptor.
28
+ tableNames.add("CHECKSTACK_ENV_REGION");
29
+
30
+ for (const producedName of Object.keys(produced)) {
31
+ expect(tableNames.has(producedName)).toBe(true);
32
+ }
33
+ // And the well-known reserved names must each be present verbatim.
34
+ for (const reserved of [
35
+ "CHECKSTACK_CHECK_ID",
36
+ "CHECKSTACK_CHECK_NAME",
37
+ "CHECKSTACK_CHECK_INTERVAL_SECONDS",
38
+ "CHECKSTACK_SYSTEM_ID",
39
+ "CHECKSTACK_SYSTEM_NAME",
40
+ "CHECKSTACK_ENV_ID",
41
+ "CHECKSTACK_ENV_NAME",
42
+ ]) {
43
+ expect(Object.keys(produced)).toContain(reserved);
44
+ expect(HEALTHCHECK_SHELL_ENV.some((v) => v.name === reserved)).toBe(true);
45
+ }
46
+ });
47
+ });
@@ -2,7 +2,7 @@
2
2
  * Behaviour tests for the healthcheck automation triggers + actions.
3
3
  */
4
4
  import { describe, expect, it, mock } from "bun:test";
5
- import type { Logger } from "@checkstack/backend-api";
5
+ import type { Logger, RpcClient } from "@checkstack/backend-api";
6
6
  import type { QueueManager } from "@checkstack/queue-api";
7
7
  import { createMockLogger } from "@checkstack/test-utils-backend";
8
8
 
@@ -28,6 +28,7 @@ const ctxBase = {
28
28
  getService: async <T,>(): Promise<T> => {
29
29
  throw new Error("not used");
30
30
  },
31
+ rpcClient: { forPlugin: () => ({}) } as unknown as RpcClient,
31
32
  };
32
33
 
33
34
  describe("healthcheck triggers", () => {
@@ -42,8 +42,14 @@ import type { HealthCheckService } from "./service";
42
42
 
43
43
  // ─── Payload schemas — match the hook payloads exactly ─────────────────
44
44
 
45
+ // Phase 3b: the optional `environmentId` is present only for a PER-ENVIRONMENT
46
+ // health change (the env-qualified `health` entity id); it is ABSENT for the
47
+ // system-rollup change. Existing automations reading `systemId` are unaffected
48
+ // (the rollup carries the bare systemId); new automations can filter on
49
+ // `environmentId` to react to a specific environment's health.
45
50
  const systemDegradedPayloadSchema = z.object({
46
51
  systemId: z.string(),
52
+ environmentId: z.string().optional(),
47
53
  systemName: z.string().optional(),
48
54
  previousStatus: HealthCheckStatusSchema,
49
55
  newStatus: HealthCheckStatusSchema,
@@ -54,6 +60,7 @@ const systemDegradedPayloadSchema = z.object({
54
60
 
55
61
  const systemHealthyPayloadSchema = z.object({
56
62
  systemId: z.string(),
63
+ environmentId: z.string().optional(),
57
64
  systemName: z.string().optional(),
58
65
  previousStatus: HealthCheckStatusSchema,
59
66
  healthyChecks: z.number(),
@@ -63,6 +70,7 @@ const systemHealthyPayloadSchema = z.object({
63
70
 
64
71
  const systemHealthChangedPayloadSchema = z.object({
65
72
  systemId: z.string(),
73
+ environmentId: z.string().optional(),
66
74
  systemName: z.string().optional(),
67
75
  previousStatus: HealthCheckStatusSchema,
68
76
  newStatus: HealthCheckStatusSchema,
@@ -158,7 +166,7 @@ export const checkFailedTrigger: TriggerDefinition<
158
166
 
159
167
  // Triggers carry heterogeneous config types (all healthcheck triggers are
160
168
  // currently config-less). The registry accepts the `<unknown, unknown>` shape
161
- // and re-validates config against each trigger's own `configSchema` at load,
169
+ // and re-validates config against each trigger's own versioned `config` at load,
162
170
  // so the registration array is widened here — mirroring
163
171
  // `registerBuiltinTriggers` in automation-backend.
164
172
  export const healthCheckTriggers: TriggerDefinition<unknown, unknown>[] = [
@@ -53,6 +53,36 @@ describe("buildShellRunContextEnv", () => {
53
53
  CHECKSTACK_SYSTEM_NAME: "web-1",
54
54
  });
55
55
  });
56
+
57
+ test("emits CHECKSTACK_ENV_* vars mirroring the real shell collector", () => {
58
+ const env = buildShellRunContextEnv({
59
+ system: { id: "s1", name: "web-1" },
60
+ environment: {
61
+ id: "env-prod",
62
+ name: "production",
63
+ fields: { baseUrl: "https://prod.example.com", region: "eu-west-1" },
64
+ },
65
+ });
66
+ expect(env).toEqual({
67
+ CHECKSTACK_SYSTEM_ID: "s1",
68
+ CHECKSTACK_SYSTEM_NAME: "web-1",
69
+ CHECKSTACK_ENV_ID: "env-prod",
70
+ CHECKSTACK_ENV_NAME: "production",
71
+ CHECKSTACK_ENV_BASE_URL: "https://prod.example.com",
72
+ CHECKSTACK_ENV_REGION: "eu-west-1",
73
+ });
74
+ });
75
+
76
+ test("keeps the first key on a normalized collision (first-wins)", () => {
77
+ const env = buildShellRunContextEnv({
78
+ environment: {
79
+ id: "e",
80
+ name: "n",
81
+ fields: { baseUrl: "first", "base-url": "second" },
82
+ },
83
+ });
84
+ expect(env.CHECKSTACK_ENV_BASE_URL).toBe("first");
85
+ });
56
86
  });
57
87
 
58
88
  describe("buildCollectorContext", () => {
@@ -74,6 +104,28 @@ describe("buildCollectorContext", () => {
74
104
  test("defaults config to an empty object", () => {
75
105
  expect(buildCollectorContext({})).toEqual({ config: {} });
76
106
  });
107
+
108
+ test("includes environment when present, mirroring the inline collector", () => {
109
+ expect(
110
+ buildCollectorContext({
111
+ config: {},
112
+ runContext: {
113
+ environment: {
114
+ id: "env-prod",
115
+ name: "production",
116
+ fields: { baseUrl: "https://prod.example.com" },
117
+ },
118
+ },
119
+ }),
120
+ ).toEqual({
121
+ config: {},
122
+ environment: {
123
+ id: "env-prod",
124
+ name: "production",
125
+ fields: { baseUrl: "https://prod.example.com" },
126
+ },
127
+ });
128
+ });
77
129
  });
78
130
 
79
131
  describe("runCollectorScriptTest — typescript", () => {
@@ -94,7 +146,7 @@ describe("runCollectorScriptTest — typescript", () => {
94
146
  },
95
147
  deps: { esmRunner: runner },
96
148
  });
97
- expect(calls[0]?.helperModuleName).toBe("@checkstack/healthcheck");
149
+ expect(calls[0]?.helperModuleName).toBe("@checkstack/sdk/healthcheck");
98
150
  expect(calls[0]?.helperFunctionName).toBe("defineHealthCheck");
99
151
  expect(calls[0]?.context).toEqual({
100
152
  config: { threshold: 0.6 },