@checkstack/healthcheck-backend 1.5.0 → 1.6.1

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 +253 -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 +32 -27
  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,81 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createHealthcheckDeleteTool } from "./healthcheck-delete";
4
+
5
+ const principal: AuthUser = {
6
+ type: "user",
7
+ id: "u1",
8
+ accessRules: ["healthcheck.healthcheck.manage"],
9
+ };
10
+
11
+ const config = {
12
+ id: "hc1",
13
+ name: "google-com-http",
14
+ strategyId: "healthcheck-http.http",
15
+ config: {},
16
+ intervalSeconds: 60,
17
+ paused: false,
18
+ createdAt: new Date(),
19
+ updatedAt: new Date(),
20
+ };
21
+
22
+ function fakeRpcClient({
23
+ getConfiguration,
24
+ deleteConfiguration,
25
+ }: {
26
+ getConfiguration: ReturnType<typeof mock>;
27
+ deleteConfiguration: ReturnType<typeof mock>;
28
+ }): RpcClient {
29
+ return {
30
+ forPlugin: () => ({ getConfiguration, deleteConfiguration }),
31
+ } as unknown as RpcClient;
32
+ }
33
+
34
+ describe("healthcheck.delete tool", () => {
35
+ test("declares destructive effect + the manage rule", () => {
36
+ const tool = createHealthcheckDeleteTool();
37
+ expect(tool.name).toBe("healthcheck.delete");
38
+ expect(tool.effect).toBe("destructive");
39
+ expect(tool.requiredAccessRules).toEqual(["healthcheck.healthcheck.manage"]);
40
+ expect(typeof tool.dryRun).toBe("function");
41
+ });
42
+
43
+ test("dryRun resolves the target and NEVER deletes", async () => {
44
+ const getConfiguration = mock(() => Promise.resolve(config));
45
+ const deleteConfiguration = mock(() => Promise.resolve());
46
+ const rpcClient = fakeRpcClient({ getConfiguration, deleteConfiguration });
47
+ const tool = createHealthcheckDeleteTool();
48
+ const preview = await tool.dryRun!({
49
+ input: { id: "hc1" },
50
+ principal,
51
+ rpcClient,
52
+ });
53
+ expect(deleteConfiguration).not.toHaveBeenCalled();
54
+ expect(preview.summary).toContain("google-com-http");
55
+ expect(preview.summary).toContain("permanent");
56
+ expect(preview.payload).toEqual({ id: "hc1" });
57
+ });
58
+
59
+ test("dryRun throws a clear error when the id is unknown", async () => {
60
+ const rpcClient = fakeRpcClient({
61
+ getConfiguration: mock(() => Promise.resolve(undefined)),
62
+ deleteConfiguration: mock(),
63
+ });
64
+ const tool = createHealthcheckDeleteTool();
65
+ await expect(
66
+ tool.dryRun!({ input: { id: "nope" }, principal, rpcClient }),
67
+ ).rejects.toThrow(/No health check found/);
68
+ });
69
+
70
+ test("execute (apply) deletes via deleteConfiguration", async () => {
71
+ const deleteConfiguration = mock(() => Promise.resolve());
72
+ const rpcClient = fakeRpcClient({
73
+ getConfiguration: mock(() => Promise.resolve(config)),
74
+ deleteConfiguration,
75
+ });
76
+ const tool = createHealthcheckDeleteTool();
77
+ const result = await tool.execute({ input: { id: "hc1" }, principal, rpcClient });
78
+ expect(deleteConfiguration).toHaveBeenCalledWith("hc1");
79
+ expect(result).toEqual({ id: "hc1", deleted: true });
80
+ });
81
+ });
@@ -0,0 +1,81 @@
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
+ healthCheckAccess,
7
+ pluginMetadata as healthcheckPluginMetadata,
8
+ } from "@checkstack/healthcheck-common";
9
+ import type { AiProposalPreview } from "@checkstack/ai-common";
10
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
11
+
12
+ /** Input for `healthcheck.delete`: the configuration id to remove. */
13
+ export const HealthcheckDeleteInputSchema = z.object({
14
+ id: z.string().min(1),
15
+ });
16
+ export type HealthcheckDeleteInput = z.infer<typeof HealthcheckDeleteInputSchema>;
17
+
18
+ /** Output returned once a human applies the deletion. */
19
+ export interface HealthcheckDeleteApplyResult {
20
+ id: string;
21
+ deleted: true;
22
+ }
23
+
24
+ /**
25
+ * `healthcheck.delete` - delete a health-check configuration by id.
26
+ *
27
+ * `effect: "destructive"` - deletion is irreversible, so it ALWAYS routes
28
+ * through the propose/apply confirm card in BOTH permission modes (it can never
29
+ * auto-apply). `dryRun` resolves the target config so the confirm card names
30
+ * exactly what will be removed; `execute` (reached only via `apply`) performs
31
+ * the delete. Authorization is the same `configuration.manage` rule the UI
32
+ * delete requires, re-checked at propose AND apply time by the propose/apply
33
+ * service. The underlying RPC calls use the USER-SCOPED client passed at call
34
+ * time, so handler-side authorization (access rules AND per-resource/team
35
+ * scoping) is enforced exactly as a direct UI/RPC call.
36
+ */
37
+ export function createHealthcheckDeleteTool(): RegisteredAiTool<
38
+ HealthcheckDeleteInput,
39
+ HealthcheckDeleteApplyResult
40
+ > {
41
+ const dryRun = async ({
42
+ input,
43
+ rpcClient,
44
+ }: {
45
+ input: HealthcheckDeleteInput;
46
+ principal: AuthUser;
47
+ rpcClient: RpcClient;
48
+ }): Promise<AiProposalPreview<HealthcheckDeleteInput>> => {
49
+ const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
50
+ const config = await healthcheckClient.getConfiguration({ id: input.id });
51
+ if (!config) {
52
+ throw new Error(
53
+ `No health check found with id "${input.id}". List health checks first to get a valid id.`,
54
+ );
55
+ }
56
+ return {
57
+ summary: `Delete health check "${config.name}" (strategy ${config.strategyId}). This is permanent and also removes its system assignments.`,
58
+ payload: { id: input.id },
59
+ };
60
+ };
61
+
62
+ return {
63
+ name: "healthcheck.delete",
64
+ description:
65
+ "Delete a health-check configuration by id. DESTRUCTIVE and irreversible - it also removes the check's system assignments. Never deletes directly; a person must approve the confirmation. Find the id with the health-check read tools first.",
66
+ effect: "destructive",
67
+ input: HealthcheckDeleteInputSchema,
68
+ requiredAccessRules: [
69
+ qualifyAccessRuleId(
70
+ healthcheckPluginMetadata,
71
+ healthCheckAccess.configuration.manage,
72
+ ),
73
+ ],
74
+ dryRun,
75
+ async execute({ input, rpcClient }) {
76
+ const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
77
+ await healthcheckClient.deleteConfiguration(input.id);
78
+ return { id: input.id, deleted: true };
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildProjectedTool,
4
+ deferredProjectionExecute,
5
+ } from "@checkstack/ai-backend";
6
+ import { healthCheckContract, pluginMetadata } from "@checkstack/healthcheck-common";
7
+
8
+ // Build the projected tool with the SAME inputs the plugin exposes via
9
+ // aiToolProjectionExtensionPoint in `index.ts`, and assert the resulting tool
10
+ // carries the source procedure's contract access rules - NOT the chat
11
+ // transport's `ai.chat.read` gate.
12
+ describe("healthcheck.status projection", () => {
13
+ const tool = buildProjectedTool({
14
+ procedure: healthCheckContract.getConfigurations,
15
+ sourcePluginMetadata: pluginMetadata,
16
+ procedureKey: "getConfigurations",
17
+ name: "healthcheck.status",
18
+ description:
19
+ "List health-check configurations and their current status. Read-only.",
20
+ effect: "read",
21
+ execute: deferredProjectionExecute,
22
+ });
23
+
24
+ test("uses the overridden tool name", () => {
25
+ expect(tool.name).toBe("healthcheck.status");
26
+ });
27
+
28
+ test("is classified as a read-only effect", () => {
29
+ expect(tool.effect).toBe("read");
30
+ });
31
+
32
+ test("inherits the source procedure's access rules, not the chat gate", () => {
33
+ expect(tool.requiredAccessRules.length).toBeGreaterThan(0);
34
+ expect(tool.requiredAccessRules).not.toEqual(["ai.chat.read"]);
35
+ });
36
+ });
@@ -0,0 +1,268 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import {
4
+ createHealthcheckProposeTool,
5
+ extractCollectorScriptSource,
6
+ } from "./healthcheck-propose";
7
+
8
+ const principal: AuthUser = {
9
+ type: "user",
10
+ id: "u1",
11
+ accessRules: ["healthcheck.healthcheck.manage"],
12
+ };
13
+
14
+ const httpStrategy = {
15
+ id: "healthcheck-http.http",
16
+ displayName: "HTTP",
17
+ description: "HTTP probe",
18
+ category: "network",
19
+ configSchema: {
20
+ type: "object",
21
+ properties: { url: { type: "string" }, method: { type: "string" } },
22
+ required: ["url"],
23
+ },
24
+ };
25
+
26
+ const scriptCollector = {
27
+ id: "script.inline",
28
+ displayName: "Inline script",
29
+ description: "Inline TS collector",
30
+ configSchema: {
31
+ type: "object",
32
+ properties: { script: { type: "string" } },
33
+ required: ["script"],
34
+ },
35
+ resultSchema: {},
36
+ allowMultiple: true,
37
+ };
38
+
39
+ /** Default fake `validateConfiguration` that reports the draft as valid. */
40
+ const okValidate = () => mock(() => Promise.resolve({ valid: true, errors: [] }));
41
+
42
+ function fakeRpcClient({
43
+ getStrategies,
44
+ getCollectors,
45
+ createConfiguration,
46
+ validateConfiguration,
47
+ }: {
48
+ getStrategies: ReturnType<typeof mock>;
49
+ getCollectors: ReturnType<typeof mock>;
50
+ createConfiguration: ReturnType<typeof mock>;
51
+ validateConfiguration?: ReturnType<typeof mock>;
52
+ }): RpcClient {
53
+ return {
54
+ forPlugin: () => ({
55
+ getStrategies,
56
+ getCollectors,
57
+ createConfiguration,
58
+ validateConfiguration: validateConfiguration ?? okValidate(),
59
+ }),
60
+ } as unknown as RpcClient;
61
+ }
62
+
63
+ const validInput = {
64
+ name: "Probe foo status",
65
+ strategyId: "healthcheck-http.http",
66
+ config: { url: "https://foo.bar/status" },
67
+ intervalSeconds: 60,
68
+ };
69
+
70
+ describe("extractCollectorScriptSource", () => {
71
+ test("reads script or source string", () => {
72
+ expect(extractCollectorScriptSource({ script: "code" })).toBe("code");
73
+ expect(extractCollectorScriptSource({ source: "code2" })).toBe("code2");
74
+ expect(extractCollectorScriptSource({ other: 1 })).toBeUndefined();
75
+ });
76
+ });
77
+
78
+ describe("healthcheck.propose composite tool", () => {
79
+ test("declares mutate effect + a SINGLE healthcheck manage rule", () => {
80
+ const tool = createHealthcheckProposeTool();
81
+ expect(tool.name).toBe("healthcheck.propose");
82
+ expect(tool.effect).toBe("mutate");
83
+ expect(tool.requiredAccessRules).toEqual([
84
+ "healthcheck.healthcheck.manage",
85
+ ]);
86
+ // A single rule keeps the framework's AND-gate correct.
87
+ expect(tool.requiredAccessRules).toHaveLength(1);
88
+ expect(typeof tool.dryRun).toBe("function");
89
+ });
90
+
91
+ test("dryRun deep-validates via validateConfiguration and NEVER creates", async () => {
92
+ const validateConfiguration = okValidate();
93
+ const getStrategies = mock(() => Promise.resolve([httpStrategy]));
94
+ const getCollectors = mock(() => Promise.resolve([]));
95
+ const createConfiguration = mock(() => Promise.resolve({}));
96
+ const rpcClient = fakeRpcClient({
97
+ getStrategies,
98
+ getCollectors,
99
+ createConfiguration,
100
+ validateConfiguration,
101
+ });
102
+ const tool = createHealthcheckProposeTool();
103
+
104
+ const preview = await tool.dryRun!({
105
+ input: validInput,
106
+ principal,
107
+ rpcClient,
108
+ });
109
+
110
+ // Deep validation runs against the live registry path, not a hand-rolled
111
+ // presence check.
112
+ expect(validateConfiguration).toHaveBeenCalledTimes(1);
113
+ // The AI never silently creates a health check: create is NOT called at propose.
114
+ expect(createConfiguration).not.toHaveBeenCalled();
115
+ expect(preview.summary).toContain("Probe foo status");
116
+ expect(preview.summary).toContain("HTTP");
117
+ expect(preview.summary).toContain("60s");
118
+ const payload = preview.payload as { name: string; yaml: string };
119
+ expect(payload.name).toBe("Probe foo status");
120
+ expect(payload.yaml).toContain("strategyId:");
121
+ });
122
+
123
+ test("dryRun describes a script collector on the confirm card", async () => {
124
+ const getStrategies = mock(() => Promise.resolve([httpStrategy]));
125
+ const getCollectors = mock(() => Promise.resolve([scriptCollector]));
126
+ const rpcClient = fakeRpcClient({
127
+ getStrategies,
128
+ getCollectors,
129
+ createConfiguration: mock(),
130
+ });
131
+ const tool = createHealthcheckProposeTool();
132
+
133
+ const preview = await tool.dryRun!({
134
+ input: {
135
+ ...validInput,
136
+ collectors: [
137
+ {
138
+ id: "c1",
139
+ collectorId: "script.inline",
140
+ config: { script: "export default defineHealthCheck(() => ({}))" },
141
+ },
142
+ ],
143
+ },
144
+ principal,
145
+ rpcClient,
146
+ });
147
+
148
+ expect(getCollectors).toHaveBeenCalledTimes(1);
149
+ expect(preview.summary).toContain("script collector");
150
+ expect(preview.summary).toContain("1 collector");
151
+ });
152
+
153
+ test("dryRun surfaces an unknown-strategy error from validateConfiguration", async () => {
154
+ const validateConfiguration = mock(() =>
155
+ Promise.resolve({
156
+ valid: false,
157
+ errors: [
158
+ {
159
+ path: ["strategyId"],
160
+ message: 'Unknown health-check strategy "nope.nope".',
161
+ },
162
+ ],
163
+ }),
164
+ );
165
+ const rpcClient = fakeRpcClient({
166
+ getStrategies: mock(() => Promise.resolve([httpStrategy])),
167
+ getCollectors: mock(() => Promise.resolve([])),
168
+ createConfiguration: mock(),
169
+ validateConfiguration,
170
+ });
171
+ const tool = createHealthcheckProposeTool();
172
+
173
+ await expect(
174
+ tool.dryRun!({
175
+ input: { ...validInput, strategyId: "nope.nope" },
176
+ principal,
177
+ rpcClient,
178
+ }),
179
+ ).rejects.toThrow(/invalid/i);
180
+ });
181
+
182
+ // The deep-vs-lightweight proof at the propose layer: `url` IS present
183
+ // (the old presence check would pass), but it holds the WRONG TYPE. The deep
184
+ // validateConfiguration path rejects it, so dryRun surfaces the error.
185
+ test("dryRun surfaces a deep type error the old presence check would miss", async () => {
186
+ const validateConfiguration = mock(() =>
187
+ Promise.resolve({
188
+ valid: false,
189
+ errors: [{ path: ["config", "url"], message: "Expected string" }],
190
+ }),
191
+ );
192
+ const rpcClient = fakeRpcClient({
193
+ getStrategies: mock(() => Promise.resolve([httpStrategy])),
194
+ getCollectors: mock(() => Promise.resolve([])),
195
+ createConfiguration: mock(),
196
+ validateConfiguration,
197
+ });
198
+ const tool = createHealthcheckProposeTool();
199
+
200
+ await expect(
201
+ tool.dryRun!({
202
+ // `url` present but a number, not a string.
203
+ input: { ...validInput, config: { url: 12345 } },
204
+ principal,
205
+ rpcClient,
206
+ }),
207
+ ).rejects.toThrow(/invalid/i);
208
+ expect(validateConfiguration).toHaveBeenCalledTimes(1);
209
+ });
210
+
211
+ test("dryRun surfaces an unknown-collector error from validateConfiguration", async () => {
212
+ const validateConfiguration = mock(() =>
213
+ Promise.resolve({
214
+ valid: false,
215
+ errors: [
216
+ {
217
+ path: ["collectors", 0, "collectorId"],
218
+ message: 'Unknown collector "does.not-exist".',
219
+ },
220
+ ],
221
+ }),
222
+ );
223
+ const rpcClient = fakeRpcClient({
224
+ getStrategies: mock(() => Promise.resolve([httpStrategy])),
225
+ getCollectors: mock(() => Promise.resolve([scriptCollector])),
226
+ createConfiguration: mock(),
227
+ validateConfiguration,
228
+ });
229
+ const tool = createHealthcheckProposeTool();
230
+
231
+ await expect(
232
+ tool.dryRun!({
233
+ input: {
234
+ ...validInput,
235
+ collectors: [
236
+ { id: "c1", collectorId: "does.not-exist", config: {} },
237
+ ],
238
+ },
239
+ principal,
240
+ rpcClient,
241
+ }),
242
+ ).rejects.toThrow(/invalid/i);
243
+ });
244
+
245
+ test("execute (apply) creates the configuration via createConfiguration", async () => {
246
+ const created = {
247
+ id: "hc1",
248
+ name: "Probe foo status",
249
+ strategyId: "healthcheck-http.http",
250
+ config: { url: "https://foo.bar/status" },
251
+ intervalSeconds: 60,
252
+ paused: false,
253
+ createdAt: new Date(),
254
+ updatedAt: new Date(),
255
+ };
256
+ const createConfiguration = mock(() => Promise.resolve(created));
257
+ const rpcClient = fakeRpcClient({
258
+ getStrategies: mock(() => Promise.resolve([httpStrategy])),
259
+ getCollectors: mock(() => Promise.resolve([])),
260
+ createConfiguration,
261
+ });
262
+ const tool = createHealthcheckProposeTool();
263
+
264
+ const result = await tool.execute({ input: validInput, principal, rpcClient });
265
+ expect(createConfiguration).toHaveBeenCalledTimes(1);
266
+ expect(result.configuration.id).toBe("hc1");
267
+ });
268
+ });