@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,117 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateCollectorAssertions } from "./assertion-validation";
3
+ import type { CollectorConfigEntry } from "@checkstack/healthcheck-common";
4
+
5
+ const httpResultSchema: Record<string, unknown> = {
6
+ type: "object",
7
+ properties: {
8
+ statusCode: { type: "number" },
9
+ body: { type: "string" },
10
+ ok: { type: "boolean" },
11
+ },
12
+ };
13
+
14
+ const schemas = new Map([["healthcheck-http.request", httpResultSchema]]);
15
+
16
+ function collector(
17
+ assertions: CollectorConfigEntry["assertions"],
18
+ ): CollectorConfigEntry[] {
19
+ return [
20
+ {
21
+ id: "c1",
22
+ collectorId: "healthcheck-http.request",
23
+ config: {},
24
+ assertions,
25
+ },
26
+ ];
27
+ }
28
+
29
+ describe("validateCollectorAssertions", () => {
30
+ test("accepts a valid field + operator", () => {
31
+ const issues = validateCollectorAssertions({
32
+ collectors: collector([
33
+ { field: "statusCode", operator: "equals", value: 200 },
34
+ ]),
35
+ resultSchemasById: schemas,
36
+ });
37
+ expect(issues).toEqual([]);
38
+ });
39
+
40
+ test("rejects an unknown operator (the reported 'eq' bug)", () => {
41
+ const issues = validateCollectorAssertions({
42
+ collectors: collector([
43
+ { field: "statusCode", operator: "eq", value: 200 },
44
+ ]),
45
+ resultSchemasById: schemas,
46
+ });
47
+ expect(issues).toHaveLength(1);
48
+ expect(issues[0].path).toEqual(["collectors", 0, "assertions", 0, "operator"]);
49
+ expect(issues[0].message).toContain("Unknown operator");
50
+ expect(issues[0].message).toContain("equals");
51
+ });
52
+
53
+ test("rejects an unknown field (the reported 'status' bug)", () => {
54
+ const issues = validateCollectorAssertions({
55
+ collectors: collector([
56
+ { field: "status", operator: "equals", value: 200 },
57
+ ]),
58
+ resultSchemasById: schemas,
59
+ });
60
+ expect(issues).toHaveLength(1);
61
+ expect(issues[0].path).toEqual(["collectors", 0, "assertions", 0, "field"]);
62
+ expect(issues[0].message).toContain("statusCode");
63
+ });
64
+
65
+ test("rejects an operator not valid for the field's type", () => {
66
+ const issues = validateCollectorAssertions({
67
+ // contains is a string operator; statusCode is a number field.
68
+ collectors: collector([
69
+ { field: "statusCode", operator: "contains", value: "2" },
70
+ ]),
71
+ resultSchemasById: schemas,
72
+ });
73
+ expect(issues).toHaveLength(1);
74
+ expect(issues[0].message).toContain("not valid for number field");
75
+ });
76
+
77
+ test("validates a JSONPath assertion against the dynamic operator set only", () => {
78
+ const ok = validateCollectorAssertions({
79
+ collectors: collector([
80
+ { field: "body", jsonPath: "$.data.id", operator: "exists" },
81
+ ]),
82
+ resultSchemasById: schemas,
83
+ });
84
+ expect(ok).toEqual([]);
85
+
86
+ const bad = validateCollectorAssertions({
87
+ collectors: collector([
88
+ { field: "body", jsonPath: "$.items", operator: "lengthEquals", value: 3 },
89
+ ]),
90
+ resultSchemasById: schemas,
91
+ });
92
+ expect(bad).toHaveLength(1);
93
+ expect(bad[0].message).toContain("JSONPath");
94
+ });
95
+
96
+ test("skips validation when the collector's result schema is unknown", () => {
97
+ const issues = validateCollectorAssertions({
98
+ collectors: collector([
99
+ { field: "anything", operator: "equals", value: 1 },
100
+ ]),
101
+ resultSchemasById: new Map(),
102
+ });
103
+ expect(issues).toEqual([]);
104
+ });
105
+
106
+ test("no collectors or no assertions yields no issues", () => {
107
+ expect(
108
+ validateCollectorAssertions({ collectors: undefined, resultSchemasById: schemas }),
109
+ ).toEqual([]);
110
+ expect(
111
+ validateCollectorAssertions({
112
+ collectors: collector(undefined),
113
+ resultSchemasById: schemas,
114
+ }),
115
+ ).toEqual([]);
116
+ });
117
+ });
@@ -0,0 +1,147 @@
1
+ import {
2
+ NumericOperators,
3
+ StringOperators,
4
+ BooleanOperators,
5
+ ArrayOperators,
6
+ DynamicOperators,
7
+ } from "@checkstack/backend-api";
8
+ import type { CollectorConfigEntry } from "@checkstack/healthcheck-common";
9
+
10
+ /**
11
+ * Validate the `field`/`operator` of every collector assertion against the
12
+ * collector's RESULT schema and the canonical operator vocabulary.
13
+ *
14
+ * WHY this exists: `CollectorAssertionSchema` types `field` and `operator` as
15
+ * free-form `z.string()`, and `validateConfiguration` does not check them, so a
16
+ * model that guesses (e.g. `field: "status", operator: "eq"` instead of
17
+ * `field: "statusCode", operator: "equals"`) produces a config that saves but
18
+ * renders as EMPTY dropdowns in the editor (the values are not in the options
19
+ * derived from the result schema + operator set). This validator rejects such
20
+ * assertions at propose/update time with a precise, self-correcting error that
21
+ * lists the assertable fields and valid operators, so the model fixes it.
22
+ *
23
+ * The operator vocabulary is the SAME canonical set the runtime evaluator and
24
+ * the editor's AssertionBuilder use (`@checkstack/backend-api` assertion enums).
25
+ */
26
+
27
+ /** A structured validation issue, mirroring `validateConfiguration` errors. */
28
+ export interface AssertionIssue {
29
+ path: Array<string | number>;
30
+ message: string;
31
+ }
32
+
33
+ /** Every valid operator across all field types (for the "unknown operator" check). */
34
+ const ALL_OPERATORS: ReadonlySet<string> = new Set<string>([
35
+ ...NumericOperators.options,
36
+ ...StringOperators.options,
37
+ ...BooleanOperators.options,
38
+ ...ArrayOperators.options,
39
+ ...DynamicOperators.options,
40
+ ]);
41
+
42
+ /** Operators valid for a given JSON Schema `type` (boolean is value-less). */
43
+ const OPERATORS_BY_JSON_TYPE: Record<string, readonly string[]> = {
44
+ number: NumericOperators.options,
45
+ integer: NumericOperators.options,
46
+ string: StringOperators.options,
47
+ boolean: BooleanOperators.options,
48
+ array: ArrayOperators.options,
49
+ };
50
+
51
+ const DYNAMIC_OPERATORS: ReadonlySet<string> = new Set<string>(
52
+ DynamicOperators.options,
53
+ );
54
+
55
+ /** Extract the top-level `properties` record from a JSON-Schema-ish object. */
56
+ function extractProperties(
57
+ resultSchema: Record<string, unknown> | undefined,
58
+ ): Record<string, { type?: unknown }> {
59
+ if (!resultSchema) return {};
60
+ const properties = resultSchema.properties;
61
+ if (typeof properties !== "object" || properties === null) return {};
62
+ return properties as Record<string, { type?: unknown }>;
63
+ }
64
+
65
+ /** Sorted, comma-joined operator list for an error message. */
66
+ function listOperators(operators: ReadonlySet<string> | readonly string[]): string {
67
+ return [...operators].toSorted().join(", ");
68
+ }
69
+
70
+ /**
71
+ * Validate every assertion on every collector. `resultSchemasById` maps a
72
+ * collector id to its result JSON Schema (from the collector DTO registry).
73
+ * Returns one issue per problem; an empty array means all assertions are valid.
74
+ */
75
+ export function validateCollectorAssertions({
76
+ collectors,
77
+ resultSchemasById,
78
+ }: {
79
+ collectors: CollectorConfigEntry[] | undefined;
80
+ resultSchemasById: ReadonlyMap<string, Record<string, unknown>>;
81
+ }): AssertionIssue[] {
82
+ const issues: AssertionIssue[] = [];
83
+ if (!collectors) return issues;
84
+
85
+ for (const [collectorIndex, entry] of collectors.entries()) {
86
+ const assertions = entry.assertions;
87
+ if (!assertions?.length) continue;
88
+
89
+ const properties = extractProperties(resultSchemasById.get(entry.collectorId));
90
+ const fieldNames = Object.keys(properties);
91
+
92
+ for (const [assertionIndex, assertion] of assertions.entries()) {
93
+ const at: Array<string | number> = [
94
+ "collectors",
95
+ collectorIndex,
96
+ "assertions",
97
+ assertionIndex,
98
+ ];
99
+ const isJsonPath =
100
+ typeof assertion.jsonPath === "string" && assertion.jsonPath.length > 0;
101
+
102
+ // Every assertion's operator must be a known operator.
103
+ if (!ALL_OPERATORS.has(assertion.operator)) {
104
+ issues.push({
105
+ path: [...at, "operator"],
106
+ message: `Unknown operator "${assertion.operator}". Valid operators: ${listOperators(ALL_OPERATORS)}.`,
107
+ });
108
+ continue;
109
+ }
110
+
111
+ // JSONPath assertions assert against an arbitrary path, not a result-schema
112
+ // field, so only the operator vocabulary applies (the dynamic set).
113
+ if (isJsonPath) {
114
+ if (!DYNAMIC_OPERATORS.has(assertion.operator)) {
115
+ issues.push({
116
+ path: [...at, "operator"],
117
+ message: `Operator "${assertion.operator}" is not valid for a JSONPath assertion. Valid: ${listOperators(DYNAMIC_OPERATORS)}.`,
118
+ });
119
+ }
120
+ continue;
121
+ }
122
+
123
+ // Field-based assertion: the field must exist in the collector's result
124
+ // schema (when we know the schema). Empty schema -> skip (cannot validate).
125
+ if (fieldNames.length > 0 && !fieldNames.includes(assertion.field)) {
126
+ issues.push({
127
+ path: [...at, "field"],
128
+ message: `Unknown assertable field "${assertion.field}" for collector "${entry.collectorId}". Assertable fields: ${fieldNames.join(", ") || "(none)"}. For an arbitrary path, set jsonPath instead.`,
129
+ });
130
+ continue;
131
+ }
132
+
133
+ // The operator must be valid for the field's JSON type.
134
+ const prop = properties[assertion.field];
135
+ const jsonType = prop && typeof prop.type === "string" ? prop.type : undefined;
136
+ const allowed = jsonType ? OPERATORS_BY_JSON_TYPE[jsonType] : undefined;
137
+ if (allowed && !allowed.includes(assertion.operator)) {
138
+ issues.push({
139
+ path: [...at, "operator"],
140
+ message: `Operator "${assertion.operator}" is not valid for ${jsonType} field "${assertion.field}". Valid: ${allowed.join(", ")}.`,
141
+ });
142
+ }
143
+ }
144
+ }
145
+
146
+ return issues;
147
+ }
@@ -0,0 +1,158 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import {
4
+ ListCapabilitiesOutputSchema,
5
+ GetCapabilitySchemaOutputSchema,
6
+ } from "@checkstack/ai-common";
7
+ import {
8
+ createHealthcheckListCapabilitiesTool,
9
+ createHealthcheckGetCapabilitySchemaTool,
10
+ } from "./healthcheck-capabilities";
11
+
12
+ const principal: AuthUser = {
13
+ type: "user",
14
+ id: "u1",
15
+ accessRules: ["healthcheck.healthcheck.read"],
16
+ };
17
+
18
+ /** A real-shaped collector config schema; the round-trip must preserve it byte-for-byte. */
19
+ const HTTP_COLLECTOR_SCHEMA = {
20
+ type: "object",
21
+ properties: {
22
+ url: { type: "string", format: "uri", description: "Endpoint to probe" },
23
+ method: { type: "string", enum: ["GET", "HEAD", "POST"], default: "GET" },
24
+ timeoutMs: { type: "integer", minimum: 100, maximum: 30000 },
25
+ expectedStatus: {
26
+ type: "array",
27
+ items: { type: "integer" },
28
+ default: [200],
29
+ },
30
+ },
31
+ required: ["url"],
32
+ additionalProperties: false,
33
+ };
34
+
35
+ const HTTP_STRATEGY = {
36
+ id: "healthcheck-http",
37
+ displayName: "HTTP",
38
+ description: "Probe HTTP endpoints",
39
+ category: "network",
40
+ configSchema: { type: "object", properties: {} },
41
+ };
42
+
43
+ const HTTP_RESULT_SCHEMA = {
44
+ type: "object",
45
+ properties: { statusCode: { type: "number" }, body: { type: "string" } },
46
+ };
47
+
48
+ const HTTP_COLLECTOR = {
49
+ id: "healthcheck-http.http",
50
+ displayName: "HTTP request",
51
+ description: "Issue an HTTP request and assert on the response",
52
+ configSchema: HTTP_COLLECTOR_SCHEMA,
53
+ resultSchema: HTTP_RESULT_SCHEMA,
54
+ allowMultiple: true,
55
+ };
56
+
57
+ function fakeHealthcheckRpcClient(): RpcClient {
58
+ return {
59
+ forPlugin: () => ({
60
+ getStrategies: mock(() => Promise.resolve([HTTP_STRATEGY])),
61
+ getCollectors: mock(() => Promise.resolve([HTTP_COLLECTOR])),
62
+ }),
63
+ } as unknown as RpcClient;
64
+ }
65
+
66
+ describe("healthcheck.listCapabilities tool", () => {
67
+ test("declares read effect + healthcheck config read gate, no dryRun", () => {
68
+ const tool = createHealthcheckListCapabilitiesTool();
69
+ expect(tool.name).toBe("healthcheck.listCapabilities");
70
+ expect(tool.effect).toBe("read");
71
+ expect(tool.requiredAccessRules).toEqual(["healthcheck.healthcheck.read"]);
72
+ expect(tool.dryRun).toBeUndefined();
73
+ });
74
+
75
+ test("maps strategies + collectors to roles with compact summaries", async () => {
76
+ const tool = createHealthcheckListCapabilitiesTool();
77
+ const out = await tool.execute({
78
+ input: {},
79
+ principal,
80
+ rpcClient: fakeHealthcheckRpcClient(),
81
+ });
82
+ expect(ListCapabilitiesOutputSchema.safeParse(out).success).toBe(true);
83
+ expect(out.context).toBe("healthcheck");
84
+ expect(out.truncated).toBe(false);
85
+
86
+ const strategy = out.entries.find((e) => e.id === "healthcheck-http");
87
+ const collector = out.entries.find((e) => e.id === "healthcheck-http.http");
88
+ expect(strategy?.role).toBe("strategy");
89
+ expect(collector?.role).toBe("collector");
90
+ // Compact summary only - never the full schema.
91
+ expect(collector?.configSummary).toEqual([
92
+ { name: "url", type: "string", required: true },
93
+ { name: "method", type: "enum", required: false },
94
+ { name: "timeoutMs", type: "number", required: false },
95
+ { name: "expectedStatus", type: "array", required: false },
96
+ ]);
97
+ expect(
98
+ (collector as unknown as Record<string, unknown>).configSchema,
99
+ ).toBeUndefined();
100
+ expect(
101
+ (collector as unknown as Record<string, unknown>).resultSchema,
102
+ ).toBeUndefined();
103
+ });
104
+ });
105
+
106
+ describe("healthcheck.getCapabilitySchema tool", () => {
107
+ test("declares read effect + healthcheck config read gate, no dryRun", () => {
108
+ const tool = createHealthcheckGetCapabilitySchemaTool();
109
+ expect(tool.name).toBe("healthcheck.getCapabilitySchema");
110
+ expect(tool.effect).toBe("read");
111
+ expect(tool.requiredAccessRules).toEqual(["healthcheck.healthcheck.read"]);
112
+ expect(tool.dryRun).toBeUndefined();
113
+ });
114
+
115
+ test("returns ONE collector's FULL config schema + result schema + operators", async () => {
116
+ const tool = createHealthcheckGetCapabilitySchemaTool();
117
+ const out = await tool.execute({
118
+ input: { kind: "healthcheck-http.http" },
119
+ principal,
120
+ rpcClient: fakeHealthcheckRpcClient(),
121
+ });
122
+ expect(GetCapabilitySchemaOutputSchema.safeParse(out).success).toBe(true);
123
+ expect(out.context).toBe("healthcheck");
124
+ expect(out.id).toBe("healthcheck-http.http");
125
+ expect(out.role).toBe("collector");
126
+ // The crux: the FULL schema is returned unchanged - same object the UI form uses.
127
+ expect(out.configSchema).toEqual(HTTP_COLLECTOR_SCHEMA);
128
+ // A collector ALSO exposes the assertable result fields + operator vocabulary
129
+ // so the model authors assertions correctly instead of guessing field/operator.
130
+ expect(out.resultSchema).toEqual(HTTP_RESULT_SCHEMA);
131
+ expect(out.assertionOperators?.number).toContain("equals");
132
+ expect(out.assertionOperators?.number).toContain("greaterThanOrEqual");
133
+ expect(out.assertionOperators?.string).toContain("contains");
134
+ });
135
+
136
+ test("a non-collector kind (strategy) omits resultSchema + assertionOperators", async () => {
137
+ const tool = createHealthcheckGetCapabilitySchemaTool();
138
+ const out = await tool.execute({
139
+ input: { kind: "healthcheck-http" },
140
+ principal,
141
+ rpcClient: fakeHealthcheckRpcClient(),
142
+ });
143
+ expect(out.role).toBe("strategy");
144
+ expect(out.resultSchema).toBeUndefined();
145
+ expect(out.assertionOperators).toBeUndefined();
146
+ });
147
+
148
+ test("throws a clear error for an unknown kind", async () => {
149
+ const tool = createHealthcheckGetCapabilitySchemaTool();
150
+ await expect(
151
+ tool.execute({
152
+ input: { kind: "does-not-exist" },
153
+ principal,
154
+ rpcClient: fakeHealthcheckRpcClient(),
155
+ }),
156
+ ).rejects.toThrow(/unknown.*does-not-exist/i);
157
+ });
158
+ });
@@ -0,0 +1,217 @@
1
+ import { qualifyAccessRuleId } from "@checkstack/common";
2
+ import type { RpcClient } from "@checkstack/backend-api";
3
+ import {
4
+ NumericOperators,
5
+ StringOperators,
6
+ BooleanOperators,
7
+ ArrayOperators,
8
+ DynamicOperators,
9
+ } from "@checkstack/backend-api";
10
+ import {
11
+ HealthCheckApi,
12
+ healthCheckAccess,
13
+ pluginMetadata as healthcheckPluginMetadata,
14
+ } from "@checkstack/healthcheck-common";
15
+ import {
16
+ type GetCapabilitySchemaOutput,
17
+ type ListCapabilitiesOutput,
18
+ GetCapabilitySchemaOutputSchema,
19
+ ListCapabilitiesOutputSchema,
20
+ applyCapabilitySizeGate,
21
+ summarizeConfigSchema,
22
+ type RawCapabilityEntry,
23
+ } from "@checkstack/ai-common";
24
+ import { z } from "zod";
25
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
26
+
27
+ /**
28
+ * A fully-derived health-check catalog entry that ALSO carries its full config
29
+ * JSON Schema. `listCapabilities` size-gates the summary out of
30
+ * `RawCapabilityEntry`; `getCapabilitySchema` reads `configSchema` from the same
31
+ * source to return one kind's full schema intact. Keeping both on one row means
32
+ * a single fan-out powers both tools.
33
+ */
34
+ interface CatalogEntryWithSchema extends RawCapabilityEntry {
35
+ configSchema: Record<string, unknown>;
36
+ /** Health-check collectors carry a result schema (its fields are assertable). */
37
+ resultSchema?: Record<string, unknown>;
38
+ }
39
+
40
+ /**
41
+ * Valid assertion operators per JSON type (and `jsonpath`), surfaced alongside a
42
+ * collector's result schema so the model authors assertions with the canonical
43
+ * operator words instead of guessing. Same vocabulary as the runtime evaluator
44
+ * and the editor's AssertionBuilder (`@checkstack/backend-api` assertion enums).
45
+ */
46
+ const ASSERTION_OPERATORS: Record<string, string[]> = {
47
+ number: [...NumericOperators.options],
48
+ integer: [...NumericOperators.options],
49
+ string: [...StringOperators.options],
50
+ boolean: [...BooleanOperators.options],
51
+ array: [...ArrayOperators.options],
52
+ jsonpath: [...DynamicOperators.options],
53
+ };
54
+
55
+ /**
56
+ * The healthcheck config read rule that gates BOTH capability tools. This is a
57
+ * single-context reader (healthcheck only), so the resolver gate by the
58
+ * healthcheck config read rule is the authority - there is no cross-context
59
+ * surface, so no in-execute context assertion is needed: the tool only ever
60
+ * reads healthcheck data via the USER-SCOPED client passed at call time, so
61
+ * handler-side authorization is enforced exactly as a direct UI/RPC call.
62
+ */
63
+ const HEALTHCHECK_READ_RULE = qualifyAccessRuleId(
64
+ healthcheckPluginMetadata,
65
+ healthCheckAccess.configuration.read,
66
+ );
67
+
68
+ /**
69
+ * Fetch + normalize every health-check capability into rows carrying both the
70
+ * compact summary and the full config schema. Pure mapping over the registry
71
+ * DTOs; the only I/O is the user-scoped-client fan-out
72
+ * (`getStrategies` + per-strategy `getCollectors`).
73
+ */
74
+ async function fetchCatalog({
75
+ rpcClient,
76
+ }: {
77
+ rpcClient: RpcClient;
78
+ }): Promise<CatalogEntryWithSchema[]> {
79
+ const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
80
+ const strategies = await healthcheckClient.getStrategies();
81
+ const rows: CatalogEntryWithSchema[] = [];
82
+ for (const strategy of strategies) {
83
+ rows.push({
84
+ id: strategy.id,
85
+ displayName: strategy.displayName,
86
+ description: strategy.description,
87
+ role: "strategy",
88
+ category: strategy.category,
89
+ configSchema: strategy.configSchema,
90
+ configSummary: summarizeConfigSchema({
91
+ configSchema: strategy.configSchema,
92
+ }),
93
+ });
94
+ const collectors = await healthcheckClient.getCollectors({
95
+ strategyId: strategy.id,
96
+ });
97
+ for (const collector of collectors) {
98
+ rows.push({
99
+ id: collector.id,
100
+ displayName: collector.displayName,
101
+ description: collector.description,
102
+ role: "collector",
103
+ category: strategy.displayName,
104
+ configSchema: collector.configSchema,
105
+ // The collector's result schema: its top-level fields are the
106
+ // assertable fields the model authors assertions against.
107
+ resultSchema: collector.resultSchema,
108
+ configSummary: summarizeConfigSchema({
109
+ configSchema: collector.configSchema,
110
+ }),
111
+ });
112
+ }
113
+ }
114
+ return rows;
115
+ }
116
+
117
+ export const HealthcheckListCapabilitiesInputSchema = z.object({});
118
+ export type HealthcheckListCapabilitiesInput = z.infer<
119
+ typeof HealthcheckListCapabilitiesInputSchema
120
+ >;
121
+
122
+ /**
123
+ * `healthcheck.listCapabilities` - the broad, size-gated catalog of health-check
124
+ * strategies + collectors. Sourced from the registry-introspection RPCs
125
+ * (`getStrategies`/`getCollectors`). Each entry returns id, displayName, a short
126
+ * description, and a COMPACT config summary (field names + types + required),
127
+ * gated so the broad catalog stays small. `effect: "read"`.
128
+ *
129
+ * This is a single-context (healthcheck-only) tool, so it is gated directly by
130
+ * the healthcheck config read rule - the resolver gate is the authority and no
131
+ * in-execute context check is needed (it only ever reads healthcheck data via
132
+ * the USER-SCOPED client passed at call time).
133
+ */
134
+ export function createHealthcheckListCapabilitiesTool(): RegisteredAiTool<
135
+ HealthcheckListCapabilitiesInput,
136
+ ListCapabilitiesOutput
137
+ > {
138
+ return {
139
+ name: "healthcheck.listCapabilities",
140
+ description:
141
+ "List the available health-check capability kinds: strategies + collectors. Returns each kind's id, name, short description, and a COMPACT config summary (field names + types + required). The summary is omitted for large catalogs (truncated=true) - pull one kind's full schema with healthcheck.getCapabilitySchema before configuring it.",
142
+ effect: "read",
143
+ input: HealthcheckListCapabilitiesInputSchema,
144
+ output: ListCapabilitiesOutputSchema,
145
+ requiredAccessRules: [HEALTHCHECK_READ_RULE],
146
+ async execute({ rpcClient }) {
147
+ const rows = await fetchCatalog({ rpcClient });
148
+ const stripped: RawCapabilityEntry[] = rows.map(
149
+ ({ configSchema: _schema, resultSchema: _result, ...rest }) => rest,
150
+ );
151
+ const { entries, truncated } = applyCapabilitySizeGate({
152
+ entries: stripped,
153
+ });
154
+ return { context: "healthcheck", entries, truncated };
155
+ },
156
+ };
157
+ }
158
+
159
+ export const HealthcheckGetCapabilitySchemaInputSchema = z.object({
160
+ /** The fully-qualified kind id from a `healthcheck.listCapabilities` entry. */
161
+ kind: z.string().min(1),
162
+ });
163
+ export type HealthcheckGetCapabilitySchemaInput = z.infer<
164
+ typeof HealthcheckGetCapabilitySchemaInputSchema
165
+ >;
166
+
167
+ /**
168
+ * `healthcheck.getCapabilitySchema` - the FULL config JSON Schema for ONE
169
+ * health-check kind, returned intact (the same schema that powers the UI config
170
+ * form). The two-level design keeps `listCapabilities` small while giving the
171
+ * model precise, complete detail only for the specific strategy/collector it is
172
+ * configuring. For collectors it ALSO returns the assertable `resultSchema` plus
173
+ * the valid `assertionOperators` vocabulary. `effect: "read"`; gated identically
174
+ * to `healthcheck.listCapabilities`.
175
+ */
176
+ export function createHealthcheckGetCapabilitySchemaTool(): RegisteredAiTool<
177
+ HealthcheckGetCapabilitySchemaInput,
178
+ GetCapabilitySchemaOutput
179
+ > {
180
+ return {
181
+ name: "healthcheck.getCapabilitySchema",
182
+ description:
183
+ "Return the FULL config JSON Schema for ONE health-check capability kind (a strategy or collector), identified by the id from healthcheck.listCapabilities. Use this to get exact field shapes, types, required fields, and enums before drafting a config. For collectors it also returns the assertable result fields (resultSchema) and the valid assertion operators per type.",
184
+ effect: "read",
185
+ input: HealthcheckGetCapabilitySchemaInputSchema,
186
+ output: GetCapabilitySchemaOutputSchema,
187
+ requiredAccessRules: [HEALTHCHECK_READ_RULE],
188
+ async execute({ input, rpcClient }) {
189
+ const rows = await fetchCatalog({ rpcClient });
190
+ const match = rows.find((row) => row.id === input.kind);
191
+ if (!match) {
192
+ const known = rows.map((row) => row.id).join(", ");
193
+ throw new Error(
194
+ `Unknown healthcheck capability kind "${input.kind}". Known kinds: ${known || "(none)"}.`,
195
+ );
196
+ }
197
+ const result: GetCapabilitySchemaOutput = {
198
+ context: "healthcheck",
199
+ id: match.id,
200
+ displayName: match.displayName,
201
+ description: match.description,
202
+ role: match.role,
203
+ configSchema: match.configSchema,
204
+ // For collectors, also expose the assertable result fields + the valid
205
+ // operator vocabulary so the model authors assertions correctly (field
206
+ // from resultSchema, operator a full word) instead of guessing.
207
+ ...(match.role === "collector" && match.resultSchema
208
+ ? {
209
+ resultSchema: match.resultSchema,
210
+ assertionOperators: ASSERTION_OPERATORS,
211
+ }
212
+ : {}),
213
+ };
214
+ return result;
215
+ },
216
+ };
217
+ }