@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.
- package/CHANGELOG.md +223 -0
- package/drizzle/0018_abnormal_preak.sql +10 -0
- package/drizzle/meta/0018_snapshot.json +600 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +26 -21
- package/src/ai/assertion-validation.test.ts +117 -0
- package/src/ai/assertion-validation.ts +147 -0
- package/src/ai/healthcheck-capabilities.test.ts +158 -0
- package/src/ai/healthcheck-capabilities.ts +217 -0
- package/src/ai/healthcheck-delete.test.ts +81 -0
- package/src/ai/healthcheck-delete.ts +81 -0
- package/src/ai/healthcheck-projection.test.ts +36 -0
- package/src/ai/healthcheck-propose.test.ts +268 -0
- package/src/ai/healthcheck-propose.ts +290 -0
- package/src/ai/healthcheck-script-tools.test.ts +93 -0
- package/src/ai/healthcheck-script-tools.ts +179 -0
- package/src/ai/healthcheck-update.test.ts +123 -0
- package/src/ai/healthcheck-update.ts +123 -0
- package/src/ai/notify-subscribers.test.ts +109 -0
- package/src/ai/notify-subscribers.ts +176 -0
- package/src/ai/register-ai-tools.test.ts +41 -0
- package/src/ai/register-ai-tools.ts +53 -0
- package/src/ai/shell-env-table.test.ts +47 -0
- package/src/automations.test.ts +2 -1
- package/src/automations.ts +9 -1
- package/src/collector-script-test.test.ts +53 -1
- package/src/collector-script-test.ts +59 -7
- package/src/effective-environments.test.ts +93 -0
- package/src/effective-environments.ts +64 -0
- package/src/health-entity-id.ts +57 -0
- package/src/health-entity.test.ts +384 -6
- package/src/health-entity.ts +93 -35
- package/src/health-state.ts +41 -4
- package/src/healthcheck-gitops-kinds.test.ts +95 -0
- package/src/healthcheck-gitops-kinds.ts +56 -13
- package/src/index.ts +30 -0
- package/src/migration-chain-contract.test.ts +57 -0
- package/src/queue-executor.test.ts +801 -0
- package/src/queue-executor.ts +336 -52
- package/src/realtime-aggregation.test.ts +30 -0
- package/src/realtime-aggregation.ts +16 -0
- package/src/retention-job.ts +167 -93
- package/src/retention-rollup.test.ts +118 -0
- package/src/router.test.ts +120 -1
- package/src/router.ts +20 -0
- package/src/schema.ts +44 -6
- package/src/service.ts +199 -43
- package/src/state-transitions.test.ts +104 -0
- package/src/state-transitions.ts +39 -1
- package/src/validate-configuration.test.ts +205 -0
- package/src/validate-configuration.ts +159 -0
- 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
|
+
}
|