@checkstack/healthcheck-backend 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/drizzle/0018_abnormal_preak.sql +10 -0
  3. package/drizzle/meta/0018_snapshot.json +600 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +26 -21
  6. package/src/ai/assertion-validation.test.ts +117 -0
  7. package/src/ai/assertion-validation.ts +147 -0
  8. package/src/ai/healthcheck-capabilities.test.ts +158 -0
  9. package/src/ai/healthcheck-capabilities.ts +217 -0
  10. package/src/ai/healthcheck-delete.test.ts +81 -0
  11. package/src/ai/healthcheck-delete.ts +81 -0
  12. package/src/ai/healthcheck-projection.test.ts +36 -0
  13. package/src/ai/healthcheck-propose.test.ts +268 -0
  14. package/src/ai/healthcheck-propose.ts +290 -0
  15. package/src/ai/healthcheck-script-tools.test.ts +93 -0
  16. package/src/ai/healthcheck-script-tools.ts +179 -0
  17. package/src/ai/healthcheck-update.test.ts +123 -0
  18. package/src/ai/healthcheck-update.ts +123 -0
  19. package/src/ai/notify-subscribers.test.ts +109 -0
  20. package/src/ai/notify-subscribers.ts +176 -0
  21. package/src/ai/register-ai-tools.test.ts +41 -0
  22. package/src/ai/register-ai-tools.ts +53 -0
  23. package/src/ai/shell-env-table.test.ts +47 -0
  24. package/src/automations.test.ts +2 -1
  25. package/src/automations.ts +9 -1
  26. package/src/collector-script-test.test.ts +53 -1
  27. package/src/collector-script-test.ts +59 -7
  28. package/src/effective-environments.test.ts +93 -0
  29. package/src/effective-environments.ts +64 -0
  30. package/src/health-entity-id.ts +57 -0
  31. package/src/health-entity.test.ts +405 -31
  32. package/src/health-entity.ts +99 -43
  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 +33 -0
  37. package/src/migration-chain-contract.test.ts +57 -0
  38. package/src/queue-executor.test.ts +814 -0
  39. package/src/queue-executor.ts +342 -50
  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-evaluator.test.ts +50 -5
  49. package/src/state-evaluator.ts +9 -2
  50. package/src/state-transitions.test.ts +104 -0
  51. package/src/state-transitions.ts +39 -1
  52. package/src/validate-configuration.test.ts +205 -0
  53. package/src/validate-configuration.ts +159 -0
  54. package/tsconfig.json +9 -0
@@ -29,10 +29,17 @@ import {
29
29
 
30
30
  export type CollectorScriptTestKind = "typescript" | "shell";
31
31
 
32
- /** Curated check/system metadata a collector script can read. */
32
+ /** Curated check/system/environment metadata a collector script can read. */
33
33
  export interface CollectorTestRunContext {
34
34
  check?: { id: string; name: string; intervalSeconds: number };
35
35
  system?: { id: string; name: string };
36
+ /**
37
+ * The resolved environment for the previewed run. `fields` is the
38
+ * environment's free-form custom metadata. Mirrors the runtime
39
+ * `CollectorRunContext.environment` so the test panel previews exactly the
40
+ * `CHECKSTACK_ENV_*` / `context.environment` surface the real run exposes.
41
+ */
42
+ environment?: { id: string; name: string; fields: Record<string, unknown> };
36
43
  }
37
44
 
38
45
  export interface CollectorScriptTestInput {
@@ -77,11 +84,42 @@ export interface CollectorScriptTestDeps {
77
84
  resolutionRoot?: string;
78
85
  }
79
86
 
87
+ const CHECKSTACK_ENV_PREFIX = "CHECKSTACK_ENV_";
88
+
89
+ /**
90
+ * Derive the `CHECKSTACK_ENV_<KEY>` shell var name for a custom field key.
91
+ * Mirrors `toEnvFieldShellKey` in `@checkstack/healthcheck-script-backend`
92
+ * (kept local - we don't import across plugins) so the test panel and the
93
+ * real run produce identical var names. Splits camelCase, uppercases,
94
+ * collapses non-alphanumeric runs to `_`, trims leading/trailing `_` using a
95
+ * ReDoS-safe negative look-behind.
96
+ */
97
+ function toEnvFieldShellKey(key: string): string {
98
+ const normalized = key
99
+ .replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2")
100
+ .toUpperCase()
101
+ .replaceAll(/[^A-Z0-9]+/g, "_")
102
+ .replaceAll(/^_+|(?<!_)_+$/g, "");
103
+ return `${CHECKSTACK_ENV_PREFIX}${normalized}`;
104
+ }
105
+
106
+ /** Stringify a custom-field value for a shell env var. */
107
+ function stringifyFieldValue(value: unknown): string {
108
+ if (value === null || value === undefined) return "";
109
+ if (typeof value === "string") return value;
110
+ if (typeof value === "number" || typeof value === "boolean") {
111
+ return String(value);
112
+ }
113
+ return JSON.stringify(value);
114
+ }
115
+
80
116
  /**
81
117
  * Map curated run-context metadata to the reserved `CHECKSTACK_*` env vars
82
- * the shell collector exposes. Mirrors `runContextEnv` in
83
- * `@checkstack/healthcheck-script-backend` (kept local - we don't import
84
- * across plugins). Only emits vars for the parts of the context provided.
118
+ * the shell collector exposes. Mirrors `runContextEnv` /
119
+ * `buildEnvironmentShellEnv` in `@checkstack/healthcheck-script-backend`
120
+ * (kept local - we don't import across plugins). Only emits vars for the
121
+ * parts of the context provided. Custom-field key collisions keep the first
122
+ * and skip later ones (first-wins, never last-write-wins).
85
123
  */
86
124
  export function buildShellRunContextEnv(
87
125
  runContext: CollectorTestRunContext | undefined,
@@ -98,13 +136,24 @@ export function buildShellRunContextEnv(
98
136
  env.CHECKSTACK_SYSTEM_ID = runContext.system.id;
99
137
  env.CHECKSTACK_SYSTEM_NAME = runContext.system.name;
100
138
  }
139
+ if (runContext?.environment) {
140
+ env.CHECKSTACK_ENV_ID = runContext.environment.id;
141
+ env.CHECKSTACK_ENV_NAME = runContext.environment.name;
142
+ const claimed = new Set<string>();
143
+ for (const [key, value] of Object.entries(runContext.environment.fields)) {
144
+ const shellKey = toEnvFieldShellKey(key);
145
+ if (shellKey === CHECKSTACK_ENV_PREFIX || claimed.has(shellKey)) continue;
146
+ env[shellKey] = stringifyFieldValue(value);
147
+ claimed.add(shellKey);
148
+ }
149
+ }
101
150
  return env;
102
151
  }
103
152
 
104
153
  /**
105
154
  * Build the `globalThis.context` object the inline-script (TS) collector
106
- * sees: `{ config, check?, system? }`. Matches the runtime collector so a
107
- * test mirrors production.
155
+ * sees: `{ config, check?, system?, environment? }`. Matches the runtime
156
+ * collector so a test mirrors production.
108
157
  */
109
158
  export function buildCollectorContext(
110
159
  input: Pick<CollectorScriptTestInput, "config" | "runContext">,
@@ -113,6 +162,9 @@ export function buildCollectorContext(
113
162
  config: input.config ?? {},
114
163
  ...(input.runContext?.check ? { check: input.runContext.check } : {}),
115
164
  ...(input.runContext?.system ? { system: input.runContext.system } : {}),
165
+ ...(input.runContext?.environment
166
+ ? { environment: input.runContext.environment }
167
+ : {}),
116
168
  };
117
169
  }
118
170
 
@@ -193,7 +245,7 @@ export async function runCollectorScriptTest({
193
245
  script: input.script,
194
246
  context: buildCollectorContext(input),
195
247
  timeoutMs: input.timeoutMs,
196
- helperModuleName: "@checkstack/healthcheck",
248
+ helperModuleName: "@checkstack/sdk/healthcheck",
197
249
  helperFunctionName: "defineHealthCheck",
198
250
  ...(Object.keys(secretTest.env).length > 0
199
251
  ? { env: secretTest.env }
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { resolveEffectiveEnvironments } from "./effective-environments";
3
+ import type { Environment } from "@checkstack/catalog-common";
4
+
5
+ const env = (
6
+ id: string,
7
+ name: string,
8
+ metadata: Record<string, unknown> | null = {},
9
+ ): Environment => ({
10
+ id,
11
+ name,
12
+ description: null,
13
+ systemIds: [],
14
+ metadata,
15
+ createdAt: new Date(),
16
+ updatedAt: new Date(),
17
+ });
18
+
19
+ describe("resolveEffectiveEnvironments", () => {
20
+ const membership = [
21
+ env("prod", "Production", { baseUrl: "https://prod" }),
22
+ env("staging", "Staging", { baseUrl: "https://staging" }),
23
+ ];
24
+
25
+ it("null selector returns ALL current environments", () => {
26
+ const result = resolveEffectiveEnvironments({
27
+ environmentIds: null,
28
+ membership,
29
+ });
30
+ expect(result.map((e) => e.id)).toEqual(["prod", "staging"]);
31
+ expect(result[0]).toEqual({
32
+ id: "prod",
33
+ name: "Production",
34
+ fields: { baseUrl: "https://prod" },
35
+ });
36
+ });
37
+
38
+ it("undefined selector behaves like null (all environments)", () => {
39
+ const result = resolveEffectiveEnvironments({
40
+ environmentIds: undefined,
41
+ membership,
42
+ });
43
+ expect(result.map((e) => e.id)).toEqual(["prod", "staging"]);
44
+ });
45
+
46
+ it("empty array selector opts out (env-less single run)", () => {
47
+ const result = resolveEffectiveEnvironments({
48
+ environmentIds: [],
49
+ membership,
50
+ });
51
+ expect(result).toEqual([]);
52
+ });
53
+
54
+ it("explicit subset returns exactly those, intersected with membership", () => {
55
+ const result = resolveEffectiveEnvironments({
56
+ environmentIds: ["staging"],
57
+ membership,
58
+ });
59
+ expect(result.map((e) => e.id)).toEqual(["staging"]);
60
+ });
61
+
62
+ it("preserves membership order regardless of selector order", () => {
63
+ const result = resolveEffectiveEnvironments({
64
+ environmentIds: ["staging", "prod"],
65
+ membership,
66
+ });
67
+ expect(result.map((e) => e.id)).toEqual(["prod", "staging"]);
68
+ });
69
+
70
+ it("silently drops explicit ids no longer in membership (stale-ref prune)", () => {
71
+ const result = resolveEffectiveEnvironments({
72
+ environmentIds: ["prod", "deleted-env"],
73
+ membership,
74
+ });
75
+ expect(result.map((e) => e.id)).toEqual(["prod"]);
76
+ });
77
+
78
+ it("null metadata becomes empty fields", () => {
79
+ const result = resolveEffectiveEnvironments({
80
+ environmentIds: null,
81
+ membership: [env("e1", "E1", null)],
82
+ });
83
+ expect(result[0]?.fields).toEqual({});
84
+ });
85
+
86
+ it("empty membership under null selector yields env-less (empty result)", () => {
87
+ const result = resolveEffectiveEnvironments({
88
+ environmentIds: null,
89
+ membership: [],
90
+ });
91
+ expect(result).toEqual([]);
92
+ });
93
+ });
@@ -0,0 +1,64 @@
1
+ import type { Environment } from "@checkstack/catalog-common";
2
+
3
+ /**
4
+ * The resolved environment a single fanned-out run executes for. A subset of
5
+ * the catalog `Environment` carrying only the run-relevant fields. `fields`
6
+ * is the environment's free-form custom metadata (verbatim values) - it is
7
+ * surfaced to collectors via `CollectorRunContext.environment.fields`
8
+ * (metadata only, never secrets).
9
+ */
10
+ export interface EffectiveEnvironment {
11
+ id: string;
12
+ name: string;
13
+ fields: Record<string, unknown>;
14
+ }
15
+
16
+ /**
17
+ * Resolve the effective set of environments a (config, system) assignment
18
+ * fans out into for one tick.
19
+ *
20
+ * Semantics (locked, §2/§7.1 of the environments plan):
21
+ * - `environmentIds === null` => ALL environments the system currently
22
+ * belongs to (the `membership` set).
23
+ * - `environmentIds === []` => OPT OUT: an empty result, meaning the check
24
+ * runs exactly ONCE with no environment in context (env-less run).
25
+ * - non-empty `environmentIds` => exactly those ids, INTERSECTED with the
26
+ * current membership. An explicit id no longer in membership silently
27
+ * drops (consistent with stale-ref pruning in the GitOps groups reconcile).
28
+ *
29
+ * The caller turns an empty result into a single env-less run (so a fanned
30
+ * check with no effective environments behaves exactly like the pre-feature
31
+ * single run). A non-empty result drives one run per environment.
32
+ *
33
+ * Membership order is preserved so fan-out order is deterministic.
34
+ */
35
+ export function resolveEffectiveEnvironments({
36
+ environmentIds,
37
+ membership,
38
+ }: {
39
+ environmentIds: string[] | null | undefined;
40
+ membership: Environment[];
41
+ }): EffectiveEnvironment[] {
42
+ const toEffective = (env: Environment): EffectiveEnvironment => ({
43
+ id: env.id,
44
+ name: env.name,
45
+ fields: env.metadata ?? {},
46
+ });
47
+
48
+ // null / undefined => all current environments.
49
+ if (environmentIds === null || environmentIds === undefined) {
50
+ return membership.map((env) => toEffective(env));
51
+ }
52
+
53
+ // [] => opt out (env-less single run).
54
+ if (environmentIds.length === 0) {
55
+ return [];
56
+ }
57
+
58
+ // Non-empty => explicit subset intersected with current membership,
59
+ // preserving membership order; stale ids silently drop.
60
+ const wanted = new Set(environmentIds);
61
+ return membership
62
+ .filter((env) => wanted.has(env.id))
63
+ .map((env) => toEffective(env));
64
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The `health` reactive entity id-shape (§7.4.1, Phase 3b).
3
+ *
4
+ * The reactive engine keys entities by a single opaque string id. The `health`
5
+ * kind encodes TWO views into that one id space:
6
+ *
7
+ * - **System rollup** — the BARE `"<systemId>"`. Unchanged from the pre-3b
8
+ * contract: it is the worst-status rollup across the system's environments
9
+ * (and env-less runs). Dashboards, badges, and existing system-level
10
+ * automations reference this id and keep working WITHOUT re-authoring.
11
+ * - **Per-environment** — `"<systemId>::<environmentId>"` (double-colon
12
+ * separator). Catalog `text` ids never contain `::`, so the separator is
13
+ * unambiguous. State shape is identical; only the id distinguishes them.
14
+ *
15
+ * This module is the SINGLE source of truth for encoding / decoding that id so
16
+ * the read accessor, the write helper, the change deriver, the payload mapper,
17
+ * and (read-only) the automation scope enrichment all agree on the shape.
18
+ */
19
+
20
+ /** The separator between systemId and environmentId in a per-env entity id. */
21
+ export const HEALTH_ENTITY_ID_SEPARATOR = "::";
22
+
23
+ /** A decoded `health` entity id. `environmentId === null` ⇒ the system rollup. */
24
+ export interface ParsedHealthEntityId {
25
+ systemId: string;
26
+ /** null for the bare system rollup id; the env id for a per-env id. */
27
+ environmentId: string | null;
28
+ }
29
+
30
+ /**
31
+ * Encode a `health` entity id from its parts. A `null`/`undefined`
32
+ * environmentId yields the bare rollup id `"<systemId>"`; a concrete
33
+ * environmentId yields `"<systemId>::<environmentId>"`.
34
+ */
35
+ export function encodeHealthEntityId(args: {
36
+ systemId: string;
37
+ environmentId?: string | null;
38
+ }): string {
39
+ const { systemId, environmentId } = args;
40
+ if (environmentId === null || environmentId === undefined) return systemId;
41
+ return `${systemId}${HEALTH_ENTITY_ID_SEPARATOR}${environmentId}`;
42
+ }
43
+
44
+ /**
45
+ * Decode a `health` entity id into `(systemId, environmentId)`. An id with no
46
+ * separator is the system rollup (`environmentId: null`). Splits on the FIRST
47
+ * separator so a (hypothetical) env id containing `::` still resolves a stable
48
+ * systemId; catalog ids don't contain `::`, so this is defensive only.
49
+ */
50
+ export function parseHealthEntityId(id: string): ParsedHealthEntityId {
51
+ const idx = id.indexOf(HEALTH_ENTITY_ID_SEPARATOR);
52
+ if (idx === -1) return { systemId: id, environmentId: null };
53
+ return {
54
+ systemId: id.slice(0, idx),
55
+ environmentId: id.slice(idx + HEALTH_ENTITY_ID_SEPARATOR.length),
56
+ };
57
+ }