@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
package/src/state-transitions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, desc, eq, gte, sql } from "drizzle-orm";
|
|
1
|
+
import { and, desc, eq, gte, isNull, sql } from "drizzle-orm";
|
|
2
2
|
import type { HealthCheckStatus } from "@checkstack/healthcheck-common";
|
|
3
3
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
4
4
|
import { healthCheckStateTransitions } from "./schema";
|
|
@@ -16,6 +16,7 @@ export async function recordStateTransition({
|
|
|
16
16
|
db,
|
|
17
17
|
systemId,
|
|
18
18
|
configurationId,
|
|
19
|
+
environmentId,
|
|
19
20
|
fromStatus,
|
|
20
21
|
toStatus,
|
|
21
22
|
now = new Date(),
|
|
@@ -23,6 +24,12 @@ export async function recordStateTransition({
|
|
|
23
24
|
db: Db;
|
|
24
25
|
systemId: string;
|
|
25
26
|
configurationId: string;
|
|
27
|
+
/**
|
|
28
|
+
* Environment this transition belongs to (per-environment fan-out).
|
|
29
|
+
* null/undefined = env-less run. Kept distinct so "in status since" is
|
|
30
|
+
* env-scoped.
|
|
31
|
+
*/
|
|
32
|
+
environmentId?: string | null;
|
|
26
33
|
fromStatus: HealthCheckStatus | undefined;
|
|
27
34
|
toStatus: HealthCheckStatus;
|
|
28
35
|
now?: Date;
|
|
@@ -30,6 +37,7 @@ export async function recordStateTransition({
|
|
|
30
37
|
await db.insert(healthCheckStateTransitions).values({
|
|
31
38
|
systemId,
|
|
32
39
|
configurationId,
|
|
40
|
+
environmentId: environmentId ?? null,
|
|
33
41
|
fromStatus: fromStatus ?? null,
|
|
34
42
|
toStatus,
|
|
35
43
|
transitionedAt: now,
|
|
@@ -49,11 +57,27 @@ export async function findInStatusSince({
|
|
|
49
57
|
db,
|
|
50
58
|
systemId,
|
|
51
59
|
status,
|
|
60
|
+
environmentId,
|
|
52
61
|
}: {
|
|
53
62
|
db: Db;
|
|
54
63
|
systemId: string;
|
|
55
64
|
status: HealthCheckStatus;
|
|
65
|
+
/**
|
|
66
|
+
* Environment to scope the lookup to (Phase 3b). `undefined` = system-wide
|
|
67
|
+
* (rollup; any environment + env-less). `null` = the env-less slice only. A
|
|
68
|
+
* string = that environment's transitions only. The lookup index leads with
|
|
69
|
+
* (system_id, environment_id, to_status, transitioned_at) so the env-scoped
|
|
70
|
+
* query is index-efficient.
|
|
71
|
+
*/
|
|
72
|
+
environmentId?: string | null;
|
|
56
73
|
}): Promise<Date | null> {
|
|
74
|
+
const envFilter =
|
|
75
|
+
environmentId === undefined
|
|
76
|
+
? undefined
|
|
77
|
+
: environmentId === null
|
|
78
|
+
? isNull(healthCheckStateTransitions.environmentId)
|
|
79
|
+
: eq(healthCheckStateTransitions.environmentId, environmentId);
|
|
80
|
+
|
|
57
81
|
const [row] = await db
|
|
58
82
|
.select({ transitionedAt: healthCheckStateTransitions.transitionedAt })
|
|
59
83
|
.from(healthCheckStateTransitions)
|
|
@@ -61,6 +85,7 @@ export async function findInStatusSince({
|
|
|
61
85
|
and(
|
|
62
86
|
eq(healthCheckStateTransitions.systemId, systemId),
|
|
63
87
|
eq(healthCheckStateTransitions.toStatus, status),
|
|
88
|
+
...(envFilter ? [envFilter] : []),
|
|
64
89
|
),
|
|
65
90
|
)
|
|
66
91
|
.orderBy(desc(healthCheckStateTransitions.transitionedAt))
|
|
@@ -86,12 +111,18 @@ export async function countStateTransitionsInWindow({
|
|
|
86
111
|
systemId,
|
|
87
112
|
windowMinutes,
|
|
88
113
|
toStatus,
|
|
114
|
+
environmentId,
|
|
89
115
|
now = new Date(),
|
|
90
116
|
}: {
|
|
91
117
|
db: Db;
|
|
92
118
|
systemId: string;
|
|
93
119
|
windowMinutes: number;
|
|
94
120
|
toStatus?: HealthCheckStatus;
|
|
121
|
+
/**
|
|
122
|
+
* Environment to scope the count to (Phase 3b). `undefined` = system-wide
|
|
123
|
+
* (rollup). `null` = env-less slice only. A string = that environment only.
|
|
124
|
+
*/
|
|
125
|
+
environmentId?: string | null;
|
|
95
126
|
now?: Date;
|
|
96
127
|
}): Promise<number> {
|
|
97
128
|
const windowStart = new Date(now.getTime() - windowMinutes * 60_000);
|
|
@@ -102,6 +133,13 @@ export async function countStateTransitionsInWindow({
|
|
|
102
133
|
if (toStatus) {
|
|
103
134
|
conditions.push(eq(healthCheckStateTransitions.toStatus, toStatus));
|
|
104
135
|
}
|
|
136
|
+
if (environmentId !== undefined) {
|
|
137
|
+
conditions.push(
|
|
138
|
+
environmentId === null
|
|
139
|
+
? isNull(healthCheckStateTransitions.environmentId)
|
|
140
|
+
: eq(healthCheckStateTransitions.environmentId, environmentId),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
105
143
|
|
|
106
144
|
const [row] = await db
|
|
107
145
|
.select({ count: sql<number>`COUNT(*)::int` })
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Versioned } from "@checkstack/backend-api";
|
|
4
|
+
import type {
|
|
5
|
+
HealthCheckRegistry,
|
|
6
|
+
CollectorRegistry,
|
|
7
|
+
RegisteredStrategy,
|
|
8
|
+
RegisteredCollector,
|
|
9
|
+
} from "@checkstack/backend-api";
|
|
10
|
+
import { collectConfigurationIssues } from "./validate-configuration";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a minimal `Versioned` config wrapper from a single zod schema. The
|
|
14
|
+
* real registry wraps strategy/collector configs in a versioning chain; for
|
|
15
|
+
* validation we only exercise `parseStrictAssumingV1`, which for a v1-only
|
|
16
|
+
* chain is a strict parse of the supplied schema.
|
|
17
|
+
*/
|
|
18
|
+
function versionedFrom<T>(schema: z.ZodType<T>): Versioned<T> {
|
|
19
|
+
return new Versioned<T>({ version: 1, schema });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A strategy config with a required, typed `url` field. The lightweight
|
|
23
|
+
// "required-field present" check only looks at key presence, so a wrong TYPE
|
|
24
|
+
// (number instead of string) or an unknown key slips past it — but the strict
|
|
25
|
+
// migrate-then-validate path rejects both.
|
|
26
|
+
const strategyConfigSchema = z.object({
|
|
27
|
+
url: z.string().url(),
|
|
28
|
+
timeout: z.number().int().min(1).default(5000),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const collectorConfigSchema = z.object({
|
|
32
|
+
path: z.string().min(1),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function makeRegistries(): {
|
|
36
|
+
registry: HealthCheckRegistry;
|
|
37
|
+
collectorRegistry: CollectorRegistry;
|
|
38
|
+
} {
|
|
39
|
+
const strategy = {
|
|
40
|
+
id: "http",
|
|
41
|
+
displayName: "HTTP",
|
|
42
|
+
config: versionedFrom(strategyConfigSchema),
|
|
43
|
+
aggregatedResult: {} as RegisteredStrategy["strategy"]["aggregatedResult"],
|
|
44
|
+
createClient: async () => {
|
|
45
|
+
throw new Error("not used");
|
|
46
|
+
},
|
|
47
|
+
mergeResult: () => ({}),
|
|
48
|
+
} as unknown as RegisteredStrategy["strategy"];
|
|
49
|
+
|
|
50
|
+
const registeredStrategy: RegisteredStrategy = {
|
|
51
|
+
strategy,
|
|
52
|
+
ownerPluginId: "healthcheck-http",
|
|
53
|
+
qualifiedId: "healthcheck-http.http",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const collector = {
|
|
57
|
+
displayName: "File",
|
|
58
|
+
description: "reads a file",
|
|
59
|
+
config: versionedFrom(collectorConfigSchema),
|
|
60
|
+
result: { schema: z.object({}) },
|
|
61
|
+
supportedPlugins: [{ pluginId: "healthcheck-http" }],
|
|
62
|
+
} as unknown as RegisteredCollector["collector"];
|
|
63
|
+
|
|
64
|
+
const registeredCollector: RegisteredCollector = {
|
|
65
|
+
qualifiedId: "collector-file.file",
|
|
66
|
+
collector,
|
|
67
|
+
ownerPlugin: { pluginId: "collector-file" },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const registry: HealthCheckRegistry = {
|
|
71
|
+
register: () => {},
|
|
72
|
+
getStrategy: (id) =>
|
|
73
|
+
id === registeredStrategy.qualifiedId ? strategy : undefined,
|
|
74
|
+
getStrategies: () => [strategy],
|
|
75
|
+
getStrategiesWithMeta: () => [registeredStrategy],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const collectorRegistry: CollectorRegistry = {
|
|
79
|
+
register: () => {},
|
|
80
|
+
getCollector: (id) =>
|
|
81
|
+
id === registeredCollector.qualifiedId ? registeredCollector : undefined,
|
|
82
|
+
getCollectorsForPlugin: () => [registeredCollector],
|
|
83
|
+
getCollectors: () => [registeredCollector],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return { registry, collectorRegistry };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("collectConfigurationIssues", () => {
|
|
90
|
+
it("returns no issues for a fully valid configuration", async () => {
|
|
91
|
+
const { registry, collectorRegistry } = makeRegistries();
|
|
92
|
+
const issues = await collectConfigurationIssues({
|
|
93
|
+
input: {
|
|
94
|
+
name: "valid",
|
|
95
|
+
strategyId: "healthcheck-http.http",
|
|
96
|
+
config: { url: "https://example.test" },
|
|
97
|
+
intervalSeconds: 60,
|
|
98
|
+
collectors: [
|
|
99
|
+
{
|
|
100
|
+
id: "c1",
|
|
101
|
+
collectorId: "collector-file.file",
|
|
102
|
+
config: { path: "/tmp/x" },
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
registry,
|
|
107
|
+
collectorRegistry,
|
|
108
|
+
});
|
|
109
|
+
expect(issues).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("rejects an unknown strategy id", async () => {
|
|
113
|
+
const { registry, collectorRegistry } = makeRegistries();
|
|
114
|
+
const issues = await collectConfigurationIssues({
|
|
115
|
+
input: {
|
|
116
|
+
name: "x",
|
|
117
|
+
strategyId: "healthcheck-http.nope",
|
|
118
|
+
config: { url: "https://example.test" },
|
|
119
|
+
intervalSeconds: 60,
|
|
120
|
+
},
|
|
121
|
+
registry,
|
|
122
|
+
collectorRegistry,
|
|
123
|
+
});
|
|
124
|
+
expect(issues.length).toBe(1);
|
|
125
|
+
expect(issues[0].path).toEqual(["strategyId"]);
|
|
126
|
+
expect(issues[0].message).toContain("Unknown");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("rejects an unknown collector id", async () => {
|
|
130
|
+
const { registry, collectorRegistry } = makeRegistries();
|
|
131
|
+
const issues = await collectConfigurationIssues({
|
|
132
|
+
input: {
|
|
133
|
+
name: "x",
|
|
134
|
+
strategyId: "healthcheck-http.http",
|
|
135
|
+
config: { url: "https://example.test" },
|
|
136
|
+
intervalSeconds: 60,
|
|
137
|
+
collectors: [
|
|
138
|
+
{ id: "c1", collectorId: "collector-file.ghost", config: {} },
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
registry,
|
|
142
|
+
collectorRegistry,
|
|
143
|
+
});
|
|
144
|
+
expect(issues.length).toBe(1);
|
|
145
|
+
expect(issues[0].path).toEqual(["collectors", 0, "collectorId"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// The key deep-vs-lightweight test: `url` IS present (so the old
|
|
149
|
+
// required-field check passes) but holds the wrong TYPE. Only the strict
|
|
150
|
+
// migrate-then-validate path catches it.
|
|
151
|
+
it("rejects a deep field/type error the presence check would miss", async () => {
|
|
152
|
+
const { registry, collectorRegistry } = makeRegistries();
|
|
153
|
+
const issues = await collectConfigurationIssues({
|
|
154
|
+
input: {
|
|
155
|
+
name: "x",
|
|
156
|
+
strategyId: "healthcheck-http.http",
|
|
157
|
+
// `url` present but a number, not a URL string.
|
|
158
|
+
config: { url: 12345 },
|
|
159
|
+
intervalSeconds: 60,
|
|
160
|
+
},
|
|
161
|
+
registry,
|
|
162
|
+
collectorRegistry,
|
|
163
|
+
});
|
|
164
|
+
expect(issues.length).toBeGreaterThan(0);
|
|
165
|
+
expect(issues[0].path[0]).toBe("config");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Strict parse also rejects unknown/typo'd keys, which a presence check
|
|
169
|
+
// (only asserting required keys EXIST) cannot.
|
|
170
|
+
it("rejects an unknown/typo'd strategy config key", async () => {
|
|
171
|
+
const { registry, collectorRegistry } = makeRegistries();
|
|
172
|
+
const issues = await collectConfigurationIssues({
|
|
173
|
+
input: {
|
|
174
|
+
name: "x",
|
|
175
|
+
strategyId: "healthcheck-http.http",
|
|
176
|
+
config: { url: "https://example.test", tiemout: 5 },
|
|
177
|
+
intervalSeconds: 60,
|
|
178
|
+
},
|
|
179
|
+
registry,
|
|
180
|
+
collectorRegistry,
|
|
181
|
+
});
|
|
182
|
+
expect(issues.length).toBeGreaterThan(0);
|
|
183
|
+
expect(issues[0].path[0]).toBe("config");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("rejects a deep collector config error", async () => {
|
|
187
|
+
const { registry, collectorRegistry } = makeRegistries();
|
|
188
|
+
const issues = await collectConfigurationIssues({
|
|
189
|
+
input: {
|
|
190
|
+
name: "x",
|
|
191
|
+
strategyId: "healthcheck-http.http",
|
|
192
|
+
config: { url: "https://example.test" },
|
|
193
|
+
intervalSeconds: 60,
|
|
194
|
+
collectors: [
|
|
195
|
+
// `path` present but empty string -> violates min(1).
|
|
196
|
+
{ id: "c1", collectorId: "collector-file.file", config: { path: "" } },
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
registry,
|
|
200
|
+
collectorRegistry,
|
|
201
|
+
});
|
|
202
|
+
expect(issues.length).toBeGreaterThan(0);
|
|
203
|
+
expect(issues[0].path.slice(0, 2)).toEqual(["collectors", 0]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep validation of a proposed health-check configuration.
|
|
3
|
+
*
|
|
4
|
+
* `CreateHealthCheckConfigurationSchema` only validates the structural shape -
|
|
5
|
+
* `config` and each collector `config` are typed as `z.record(z.unknown())`,
|
|
6
|
+
* so it never checks a value against the strategy's / collector's own schema.
|
|
7
|
+
* This module fills the gap with the SAME migrate-then-validate-strict logic
|
|
8
|
+
* the GitOps apply path uses (`parseStrictAssumingV1`), so propose-time errors
|
|
9
|
+
* match apply-time errors:
|
|
10
|
+
*
|
|
11
|
+
* - unknown strategy / collector ids,
|
|
12
|
+
* - strategy `config` that violates the strategy's versioned config schema
|
|
13
|
+
* (wrong type, missing required field, AND - because validation is strict -
|
|
14
|
+
* unknown/typo'd keys),
|
|
15
|
+
* - each collector `config` that violates the collector's config schema.
|
|
16
|
+
*
|
|
17
|
+
* Returned issue `path`s are dot-joinable for display, e.g. `config.url` or
|
|
18
|
+
* `collectors.0.config.path`. This is the shared validator behind the
|
|
19
|
+
* `validateConfiguration` RPC; the GitOps reconcile path uses the same
|
|
20
|
+
* migrate-then-strict helper ({@link validateVersionedConfigStrict}) so the two
|
|
21
|
+
* code paths agree on what counts as valid.
|
|
22
|
+
*/
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import type { Versioned } from "@checkstack/backend-api";
|
|
25
|
+
import type {
|
|
26
|
+
HealthCheckRegistry,
|
|
27
|
+
CollectorRegistry,
|
|
28
|
+
} from "@checkstack/backend-api";
|
|
29
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
30
|
+
import type { ValidateConfigurationInput } from "@checkstack/healthcheck-common";
|
|
31
|
+
|
|
32
|
+
export interface ConfigurationIssue {
|
|
33
|
+
path: Array<string | number>;
|
|
34
|
+
message: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Migrate (assuming the stored value was written at v1) then STRICT-parse a
|
|
39
|
+
* `Versioned` config. Returns the validated value on success, or the list of
|
|
40
|
+
* issues on failure. Migration errors (a broken chain or a throwing `migrate`)
|
|
41
|
+
* are reported as a single issue at `basePath` rather than thrown, so one bad
|
|
42
|
+
* config can't abort the whole validation. Shared by the `validateConfiguration`
|
|
43
|
+
* RPC and the GitOps reconcile path.
|
|
44
|
+
*/
|
|
45
|
+
export async function validateVersionedConfigStrict({
|
|
46
|
+
config,
|
|
47
|
+
value,
|
|
48
|
+
basePath,
|
|
49
|
+
}: {
|
|
50
|
+
config: Versioned<unknown>;
|
|
51
|
+
value: unknown;
|
|
52
|
+
basePath: Array<string | number>;
|
|
53
|
+
}): Promise<
|
|
54
|
+
{ ok: true; value: unknown } | { ok: false; issues: ConfigurationIssue[] }
|
|
55
|
+
> {
|
|
56
|
+
try {
|
|
57
|
+
const validated = await config.parseStrictAssumingV1(value);
|
|
58
|
+
return { ok: true, value: validated };
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof z.ZodError) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
issues: error.issues.map((issue) => ({
|
|
64
|
+
path: [
|
|
65
|
+
...basePath,
|
|
66
|
+
...issue.path.map((segment) => toPathSegment(segment)),
|
|
67
|
+
],
|
|
68
|
+
message: issue.message,
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
issues: [{ path: basePath, message: extractErrorMessage(error) }],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Deep-validate a proposed configuration against the live registries WITHOUT
|
|
81
|
+
* persisting anything. Returns an empty array when the configuration is fully
|
|
82
|
+
* valid. Strategy/collector lookup is by fully-qualified id, matching the
|
|
83
|
+
* GitOps reconcile path and the create path's stored `strategyId`.
|
|
84
|
+
*/
|
|
85
|
+
export async function collectConfigurationIssues({
|
|
86
|
+
input,
|
|
87
|
+
registry,
|
|
88
|
+
collectorRegistry,
|
|
89
|
+
}: {
|
|
90
|
+
input: ValidateConfigurationInput;
|
|
91
|
+
registry: HealthCheckRegistry;
|
|
92
|
+
collectorRegistry: CollectorRegistry;
|
|
93
|
+
}): Promise<ConfigurationIssue[]> {
|
|
94
|
+
const issues: ConfigurationIssue[] = [];
|
|
95
|
+
|
|
96
|
+
// ── Strategy resolution ──────────────────────────────────────────────────
|
|
97
|
+
const allStrategies = registry.getStrategiesWithMeta();
|
|
98
|
+
const matchedStrategy = allStrategies.find(
|
|
99
|
+
(s) => s.qualifiedId === input.strategyId,
|
|
100
|
+
);
|
|
101
|
+
if (!matchedStrategy) {
|
|
102
|
+
const known = allStrategies.map((s) => s.qualifiedId).join(", ");
|
|
103
|
+
issues.push({
|
|
104
|
+
path: ["strategyId"],
|
|
105
|
+
message: `Unknown health-check strategy "${input.strategyId}". Available: ${known || "(none)"}.`,
|
|
106
|
+
});
|
|
107
|
+
// No strategy means the config can't be schema-validated; surface the
|
|
108
|
+
// strategy issue alone so the operator fixes that first.
|
|
109
|
+
return issues;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Strategy config (migrate-then-validate-strict) ───────────────────────
|
|
113
|
+
const strategyResult = await validateVersionedConfigStrict({
|
|
114
|
+
config: matchedStrategy.strategy.config,
|
|
115
|
+
value: input.config,
|
|
116
|
+
basePath: ["config"],
|
|
117
|
+
});
|
|
118
|
+
if (!strategyResult.ok) {
|
|
119
|
+
issues.push(...strategyResult.issues);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Collector resolution + config (migrate-then-validate-strict) ─────────
|
|
123
|
+
const allCollectors = collectorRegistry.getCollectors();
|
|
124
|
+
for (const [index, entry] of (input.collectors ?? []).entries()) {
|
|
125
|
+
const matchedCollector = allCollectors.find(
|
|
126
|
+
(c) => c.qualifiedId === entry.collectorId,
|
|
127
|
+
);
|
|
128
|
+
if (!matchedCollector) {
|
|
129
|
+
const known = allCollectors.map((c) => c.qualifiedId).join(", ");
|
|
130
|
+
issues.push({
|
|
131
|
+
path: ["collectors", index, "collectorId"],
|
|
132
|
+
message: `Unknown collector "${entry.collectorId}". Available: ${known || "(none)"}.`,
|
|
133
|
+
});
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const collectorResult = await validateVersionedConfigStrict({
|
|
138
|
+
config: matchedCollector.collector.config,
|
|
139
|
+
value: entry.config,
|
|
140
|
+
basePath: ["collectors", index, "config"],
|
|
141
|
+
});
|
|
142
|
+
if (!collectorResult.ok) {
|
|
143
|
+
issues.push(...collectorResult.issues);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return issues;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Zod issue paths are `PropertyKey[]` (string | number | symbol). The contract
|
|
152
|
+
* issue path is `(string | number)[]`, so coerce the rare symbol segment to its
|
|
153
|
+
* string form.
|
|
154
|
+
*/
|
|
155
|
+
function toPathSegment(segment: PropertyKey): string | number {
|
|
156
|
+
return typeof segment === "number" || typeof segment === "string"
|
|
157
|
+
? segment
|
|
158
|
+
: String(segment);
|
|
159
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
"src"
|
|
5
5
|
],
|
|
6
6
|
"references": [
|
|
7
|
+
{
|
|
8
|
+
"path": "../ai-backend"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "../ai-common"
|
|
12
|
+
},
|
|
7
13
|
{
|
|
8
14
|
"path": "../automation-backend"
|
|
9
15
|
},
|
|
@@ -61,6 +67,9 @@
|
|
|
61
67
|
{
|
|
62
68
|
"path": "../script-packages-backend"
|
|
63
69
|
},
|
|
70
|
+
{
|
|
71
|
+
"path": "../sdk"
|
|
72
|
+
},
|
|
64
73
|
{
|
|
65
74
|
"path": "../secrets-backend"
|
|
66
75
|
},
|