@checkstack/healthcheck-backend 1.8.1 → 1.9.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,283 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.9.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [b1a5f3c]
8
+ - @checkstack/backend-api@0.24.0
9
+ - @checkstack/status-page-common@0.2.0
10
+ - @checkstack/status-page-backend@0.2.0
11
+ - @checkstack/ai-backend@0.7.1
12
+ - @checkstack/automation-backend@0.9.1
13
+ - @checkstack/catalog-backend@1.5.1
14
+ - @checkstack/command-backend@0.2.10
15
+ - @checkstack/gitops-backend@0.5.10
16
+ - @checkstack/incident-backend@1.8.1
17
+ - @checkstack/satellite-backend@0.6.14
18
+ - @checkstack/script-packages-backend@0.3.13
19
+ - @checkstack/secrets-backend@0.2.10
20
+ - @checkstack/catalog-common@2.4.1
21
+ - @checkstack/healthcheck-common@1.7.1
22
+ - @checkstack/incident-common@1.6.1
23
+ - @checkstack/maintenance-common@1.7.1
24
+ - @checkstack/sdk@0.109.1
25
+
26
+ ## 1.9.0
27
+
28
+ ### Minor Changes
29
+
30
+ - 551eaa9: AI assistant context-window management + leaner health-check history for chat.
31
+
32
+ The assistant previously sent the full conversation history verbatim every turn
33
+ with no size bounds, so analyzing historical health-check runs blew the model's
34
+ context window fast. Two problems are addressed:
35
+
36
+ **Verbosity.** Read-tool results are now shaped for the model:
37
+
38
+ - A generic, last-resort size clamp on every read result (head-trims the largest
39
+ arrays and adds a `_truncated` hint to narrow/paginate) so one wide pull can't
40
+ blow the context — and, since history replays each turn, keep blowing it.
41
+ - Projections can declare an optional `projectResult` to return a LEANER
42
+ model-facing shape than the UI procedure (authz + audit still see the full
43
+ result). `healthcheck.runHistory` uses it to drop the opaque ids the model
44
+ merely echoes, keeping time/status/latency/source.
45
+ - New `healthcheck.runStats` AI tool (backed by a new public `getRunStats`
46
+ procedure): compact window totals (counts by status, uptime %, latency
47
+ avg/min/max/p95) plus a small capped time series, so "how often / how much
48
+ downtime / uptime over the last N days" questions return aggregates instead of
49
+ thousands of rows. `runHistory`'s description now steers wide-window questions
50
+ here.
51
+
52
+ **Context limits.** The chat loop now estimates the prompt's tokens (a
53
+ provider-agnostic heuristic) against a budget derived from the connection's
54
+ context window, and COMPACTS the conversation before it overflows: the oldest
55
+ turns are summarized into a durable running summary (persisted on the
56
+ conversation row in shared Postgres, so any pod resumes consistently) and dropped
57
+ from the verbatim replay, with the summary folded into the system prompt.
58
+ Splitting at message-row boundaries keeps tool-call/result pairs intact, and the
59
+ summarization step is fail-open. A new optional `contextWindowTokens` on the
60
+ OpenAI-compatible connection sets the window (blank = conservative default).
61
+
62
+ All additive: a new optional connection field, a new public read endpoint, and an
63
+ additive `ai-backend` migration (`0009`) adding nullable `summary` /
64
+ `summarized_through_message_id` columns to `ai_conversations`.
65
+
66
+ - d2077bd: Platform-wide team-scoped access control on a unified relation-tuple store.
67
+
68
+ Admins can scope any resource to teams, and the **platform** (not each plugin)
69
+ enforces it. A plugin opts in declaratively by adding `instanceAccess` to a
70
+ procedure's contract; the auth middleware does the rest, so enforcement is
71
+ consistent across catalog, health checks, incidents, maintenances, SLOs,
72
+ automations, and the dependency map, and any third-party plugin gets it for free.
73
+
74
+ Core model:
75
+
76
+ - **Teams are optional.** A resource with no team grants behaves exactly as
77
+ before.
78
+ - **Team grants are additive and restrict who can CHANGE a resource, not who can
79
+ SEE it.** Granting a team `Manage` lets its members view and change the
80
+ resource; `Read-only` lets them view it. Either level grants access to team
81
+ members **even when they lack the global permission**, and granting never
82
+ removes read from anyone who already had it (e.g. a public status page stays
83
+ readable). Privacy is a separate, explicit opt-in via the **Private** toggle,
84
+ which removes the global read path so only the resource's teams can see it.
85
+ - **Ownership at creation.** Create forms expose an **Owning team** picker. A
86
+ non-admin can create a resource for a team they belong to that holds a
87
+ create-capability grant for that type; the new resource is auto-granted to that
88
+ team. Incidents and maintenances are **parent-gated**: anyone who can manage a
89
+ system may open incidents/maintenances for it, no separate grant needed.
90
+ - **Meaningful authorization errors.** A caller with neither the global rule nor
91
+ any team grant for a resource type gets a `403` with a structured body instead
92
+ of a silently-empty `200`. Anonymous callers on public endpoints are never
93
+ `403`'d, so status pages keep rendering.
94
+
95
+ Unified relation-tuple store:
96
+
97
+ - The previously separate access primitives (`resource_team_access.canRead` /
98
+ `.canManage`, ownership, `resource_access_settings.teamOnly`, and
99
+ `resource_create_grant`) are collapsed onto ONE
100
+ `relation_tuple(object, relation, subject)` store: "a team has
101
+ `viewer`/`editor`/`owner` on an object, or `creator` on a type". Privacy is an
102
+ explicit **`private` marker** tuple — its **presence** closes the global read
103
+ path (team grants only), its **absence** is the readable-by-default state, so a
104
+ private resource with zero grants is correctly inaccessible to everyone rather
105
+ than silently globalized. The access decision is a pure, unit-tested function.
106
+ - The auth API is generic: `writeRelation` / `removeRelation` / `setObjectPublic`
107
+ / `listObjectRelations` / `listSubjectRelations` / `setCreateGrant` /
108
+ `listTeamCreateGrants` (user-facing) and `check` / `listAccessibleObjectIds` /
109
+ `hasAnyTypeGrant` / `authorizeCreate` / `setOwner` / `deleteObjectRelations`
110
+ (service-to-service). Migration `0008` backfills tuples from the legacy tables
111
+ and drops them.
112
+
113
+ Explicit per-procedure scoping:
114
+
115
+ - Access rules (`access()` / `accessPair()`) define only the rule (id, level,
116
+ defaults); every procedure declares its own `instanceAccess`. This removes a
117
+ "loaded gun" default that silently applied a shared `idParam` to any procedure
118
+ which forgot its own override.
119
+ - Modes: `idParam` (single-resource pre-check, fails **closed** if the id does
120
+ not resolve), `listKey` / `recordKey` (post-filter a list/record to the
121
+ accessible subset), `create` (authorize creation + write the owning-team
122
+ grant), `parentScope` (scope by read/manage access to a PARENT type,
123
+ cross-plugin single-hop: "you may see incidents/maintenances/SLOs/health for
124
+ system S iff you may see S"), and `global: true` (the honest "intentionally not
125
+ team-scoped" opt-out). A boot-time validator **rejects** any procedure gated on
126
+ a team-scopable resource type that declares no `instanceAccess`, turning the
127
+ previous fail-open into a boot error.
128
+
129
+ Teams administration:
130
+
131
+ - **Team managers** manage their own team's members and managers without the
132
+ global `auth.teams.manage` rule; creating, deleting, and granting a team access
133
+ remain admin-only.
134
+ - A **standalone Teams page** (gated on `auth.teams.read`) lets managers reach
135
+ team administration without the admin Auth Settings page; members are added via
136
+ a debounced directory picker.
137
+ - A **cross-plugin `ResourceResolverRegistry`** lets owning plugins register a
138
+ name/search resolver for their resource types, so the Teams page lists a team's
139
+ grants **by name** (grouped by type) and offers a resource picker — an admin can
140
+ change a grant's level, revoke it, or add one, without auth depending on every
141
+ plugin. Resolvers shipped for catalog systems, health-check configurations,
142
+ incidents, maintenances, SLO objectives, and automations.
143
+
144
+ Frontend:
145
+
146
+ - The resource-side editor is **"Who can change this"** (one Manage checkbox per
147
+ team; unticked = read-only), with an always-visible **Private** toggle
148
+ (disabled until a team that can Manage exists, so a resource can't be stranded).
149
+ - `TeamOwnershipPicker` explains _why_ there's nothing to pick (not a member of
150
+ any team, or none of your teams manage the selected parent) instead of a bare
151
+ "global resource" line.
152
+ - Read-only **"who can change this"** indicators on resource detail pages expand
153
+ to the actual people by name; bulk + per-row **Scope to team** actions in the
154
+ catalog systems list; and the team-access copy spells out that grants are
155
+ additive and that Read-only grants view (not change) even without the global
156
+ permission.
157
+
158
+ Security hardening:
159
+
160
+ - Child deletes in catalog (`removeSystemContact` / `removeSystemLink`) are scoped
161
+ to both the child id and its parent `systemId`, closing a cross-system IDOR for
162
+ team-scoped managers.
163
+ - `searchUsers` is restricted to team administrators, closing a directory/email
164
+ enumeration path opened by the default `auth.teams.read` rule.
165
+ - Grant setters reject unregistered resource types.
166
+
167
+ BREAKING CHANGES (beta; shipped as minor bumps):
168
+
169
+ - `access()` and `accessPair()` no longer accept `idParam` / `listKey` /
170
+ `recordKey`; move instance config to the procedure's `instanceAccess`.
171
+ - Boot fails if a procedure gated on a team-scopable resource type omits
172
+ `instanceAccess`. Declare a scoping mode or `instanceAccess: { global: true }`.
173
+ - The `AuthService` interface is reshaped: `check`, `listAccessibleObjectIds`,
174
+ `hasAnyTypeGrant`, `authorizeCreate` (returns `isPrivate`), `setOwner`
175
+ (`isPrivate`), and `deleteObjectRelations`. Custom `AuthService` implementations
176
+ and mocks must update.
177
+ - The auth RPC contract's per-concept resource-access endpoints are replaced by
178
+ the generic tuple API above; external callers of the old
179
+ `getResourceTeamAccess` / `setResourceTeamAccess` / `setResourceAccessSettings`
180
+ / `grantResourceCreate` / etc. must move to the new procedures.
181
+ - Several contract inputs changed from a bare `string` to an object so the
182
+ middleware can resolve the resource id: catalog `deleteSystem` (`{ id }`),
183
+ `removeSystemContact` / `removeSystemLink` (`{ id, systemId }`); health-check
184
+ `deleteConfiguration` / `pauseConfiguration` / `resumeConfiguration` (`{ id }`).
185
+ All in-tree callers are updated.
186
+ - List/record endpoints that relied on returning an empty `200` to signal "no
187
+ access" now return a `403` for categorically-unauthorized principals.
188
+ - The mis-keyed bulk endpoints `getBulkIncidentsForSystems`,
189
+ `getBulkMaintenancesForSystems`, and `getBulkObjectivesForSystems` no longer
190
+ post-filter their (systemId-keyed) result; access is already gated by
191
+ `catalog.system` upstream.
192
+ - Team membership/manager mutations (`addUserToTeam`, `removeUserFromTeam`,
193
+ `addTeamManager`, `removeTeamManager`) now require `auth.teams.read` instead of
194
+ `auth.teams.manage` at the contract level (broadened to per-team managers).
195
+ - The `resource_team_access`, `resource_access_settings`, and
196
+ `resource_create_grant` tables are dropped (data backfilled into
197
+ `relation_tuple` by migration `0008`). A previously inconsistent "team-only with
198
+ zero grants" resource is now correctly inaccessible to global-access holders.
199
+
200
+ - 5c6393f: Add operator-built public Status Pages (phase 1: secure, extensible core).
201
+
202
+ Operators compose a public status page from widgets (status banner, system
203
+ health, group status, 90-day uptime, incidents, scheduled maintenance) plus
204
+ content blocks (text/Markdown, heading, links, image, divider), each bound to the
205
+ resources they choose, then publish it.
206
+
207
+ Security model — "only published widgets reveal data":
208
+
209
+ - A single public endpoint, `getPublishedStatusPage(slug)`, returns the layout
210
+ plus each widget's already-resolved, field-ALLOW-LISTED DTO. The public surface
211
+ has no generic data API, so it can only ever show what was placed on the page.
212
+ - Three gates: edit-time (you can only bind resources you can access), publish-time
213
+ (an audited, deliberate exposure that re-checks the editor can read every bound
214
+ resource via a user-scoped client), and render-time (resolvers run as a trusted
215
+ service but emit only DTO fields — never internal config, ids, or `createdBy`;
216
+ the service re-validates each DTO against its schema, so a resolver bug fails
217
+ closed).
218
+ - The overall banner rolls up only the bound systems; private resources are never
219
+ exposed beyond their public-safe status; per-binding label overrides avoid
220
+ internal-name leaks.
221
+
222
+ Coherence + extensibility:
223
+
224
+ - Status pages are team-scopable resources (RLAC): created via the standard
225
+ owning-team picker + create-capability flow, resolvable by name in the Teams
226
+ admin.
227
+ - Widget types come from an extension-point registry, so any plugin can contribute
228
+ a widget (config schema + public DTO + `resolvePublic`); the public renderers
229
+ are pure, prop-only components with no data access, so third-party widgets can
230
+ never leak.
231
+ - Draft vs published layouts; per-page visibility (public / authenticated-only)
232
+ and theming (brand color, logo).
233
+
234
+ Dependency direction: the status-page platform owns the widget-type registry and
235
+ the content widgets, but the DOMAIN widgets are contributed by their owning
236
+ plugins via the `statusWidgetTypeExtensionPoint` — system health / uptime /
237
+ banner / group status by `healthcheck-backend`, incidents by `incident-backend`,
238
+ scheduled maintenance by `maintenance-backend`. So `status-page-backend` depends
239
+ only on `backend-api` / `common` / `status-page-common`; the owning plugins
240
+ depend on the platform, never the reverse. `catalog-common` gains
241
+ `assertCatalogResourcesReadable` for the publish-time access check.
242
+
243
+ Phase 1 scope: the secure core, the admin builder, and the public page (served as
244
+ a no-access-rule route). A fully separate public bundle, custom domains + TLS,
245
+ drag-reorder, live-data preview, and distribution (embeds/badges/RSS/subscriptions)
246
+ are the next phases.
247
+
248
+ ### Patch Changes
249
+
250
+ - Updated dependencies [551eaa9]
251
+ - Updated dependencies [d2077bd]
252
+ - Updated dependencies [9ab73c5]
253
+ - Updated dependencies [5c6393f]
254
+ - @checkstack/ai-backend@0.7.0
255
+ - @checkstack/ai-common@0.5.0
256
+ - @checkstack/healthcheck-common@1.7.0
257
+ - @checkstack/backend-api@0.23.0
258
+ - @checkstack/common@0.16.0
259
+ - @checkstack/automation-backend@0.9.0
260
+ - @checkstack/catalog-backend@1.5.0
261
+ - @checkstack/catalog-common@2.4.0
262
+ - @checkstack/incident-backend@1.8.0
263
+ - @checkstack/incident-common@1.6.0
264
+ - @checkstack/maintenance-common@1.7.0
265
+ - @checkstack/status-page-common@0.1.0
266
+ - @checkstack/status-page-backend@0.1.0
267
+ - @checkstack/satellite-backend@0.6.13
268
+ - @checkstack/sdk@0.108.1
269
+ - @checkstack/script-packages-backend@0.3.12
270
+ - @checkstack/secrets-backend@0.2.9
271
+ - @checkstack/command-backend@0.2.9
272
+ - @checkstack/gitops-backend@0.5.9
273
+ - @checkstack/cache-api@0.3.13
274
+ - @checkstack/gitops-common@0.6.4
275
+ - @checkstack/notification-common@1.3.4
276
+ - @checkstack/queue-api@0.3.13
277
+ - @checkstack/secrets-common@0.2.4
278
+ - @checkstack/signal-common@0.2.10
279
+ - @checkstack/cache-utils@0.2.18
280
+
3
281
  ## 1.8.1
4
282
 
5
283
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.8.1",
3
+ "version": "1.9.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,30 +14,32 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.22.0",
18
- "@checkstack/ai-backend": "0.6.1",
19
- "@checkstack/ai-common": "0.4.0",
20
- "@checkstack/script-packages-backend": "0.3.11",
21
- "@checkstack/cache-api": "0.3.12",
22
- "@checkstack/cache-utils": "0.2.17",
23
- "@checkstack/catalog-backend": "1.4.12",
24
- "@checkstack/catalog-common": "2.3.6",
25
- "@checkstack/command-backend": "0.2.8",
26
- "@checkstack/common": "0.15.0",
27
- "@checkstack/gitops-backend": "0.5.8",
28
- "@checkstack/gitops-common": "0.6.3",
29
- "@checkstack/healthcheck-common": "1.6.2",
30
- "@checkstack/secrets-common": "0.2.3",
31
- "@checkstack/secrets-backend": "0.2.8",
32
- "@checkstack/incident-backend": "1.7.4",
33
- "@checkstack/incident-common": "1.5.2",
34
- "@checkstack/automation-backend": "0.8.1",
35
- "@checkstack/maintenance-common": "1.6.0",
36
- "@checkstack/notification-common": "1.3.3",
37
- "@checkstack/queue-api": "0.3.12",
38
- "@checkstack/satellite-backend": "0.6.12",
39
- "@checkstack/sdk": "0.107.1",
40
- "@checkstack/signal-common": "0.2.9",
17
+ "@checkstack/backend-api": "0.24.0",
18
+ "@checkstack/ai-backend": "0.7.1",
19
+ "@checkstack/ai-common": "0.5.0",
20
+ "@checkstack/script-packages-backend": "0.3.13",
21
+ "@checkstack/cache-api": "0.3.13",
22
+ "@checkstack/cache-utils": "0.2.18",
23
+ "@checkstack/catalog-backend": "1.5.1",
24
+ "@checkstack/catalog-common": "2.4.1",
25
+ "@checkstack/command-backend": "0.2.10",
26
+ "@checkstack/common": "0.16.0",
27
+ "@checkstack/gitops-backend": "0.5.10",
28
+ "@checkstack/gitops-common": "0.6.4",
29
+ "@checkstack/healthcheck-common": "1.7.1",
30
+ "@checkstack/secrets-common": "0.2.4",
31
+ "@checkstack/secrets-backend": "0.2.10",
32
+ "@checkstack/incident-backend": "1.8.1",
33
+ "@checkstack/incident-common": "1.6.1",
34
+ "@checkstack/automation-backend": "0.9.1",
35
+ "@checkstack/maintenance-common": "1.7.1",
36
+ "@checkstack/notification-common": "1.3.4",
37
+ "@checkstack/queue-api": "0.3.13",
38
+ "@checkstack/satellite-backend": "0.6.14",
39
+ "@checkstack/sdk": "0.109.1",
40
+ "@checkstack/signal-common": "0.2.10",
41
+ "@checkstack/status-page-backend": "0.2.0",
42
+ "@checkstack/status-page-common": "0.2.0",
41
43
  "@hono/zod-validator": "^0.7.6",
42
44
  "drizzle-orm": "^0.45.0",
43
45
  "hono": "^4.12.23",
@@ -49,8 +51,8 @@
49
51
  },
50
52
  "devDependencies": {
51
53
  "@checkstack/drizzle-helper": "0.0.5",
52
- "@checkstack/scripts": "0.6.1",
53
- "@checkstack/test-utils-backend": "0.1.42",
54
+ "@checkstack/scripts": "0.6.2",
55
+ "@checkstack/test-utils-backend": "0.1.44",
54
56
  "@checkstack/tsconfig": "0.0.7",
55
57
  "@types/bun": "^1.0.0",
56
58
  "@types/tdigest": "^0.1.5",
@@ -75,7 +75,7 @@ describe("healthcheck.delete tool", () => {
75
75
  });
76
76
  const tool = createHealthcheckDeleteTool();
77
77
  const result = await tool.execute({ input: { id: "hc1" }, principal, rpcClient });
78
- expect(deleteConfiguration).toHaveBeenCalledWith("hc1");
78
+ expect(deleteConfiguration).toHaveBeenCalledWith({ id: "hc1" });
79
79
  expect(result).toEqual({ id: "hc1", deleted: true });
80
80
  });
81
81
  });
@@ -74,7 +74,7 @@ export function createHealthcheckDeleteTool(): RegisteredAiTool<
74
74
  dryRun,
75
75
  async execute({ input, rpcClient }) {
76
76
  const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
77
- await healthcheckClient.deleteConfiguration(input.id);
77
+ await healthcheckClient.deleteConfiguration({ id: input.id });
78
78
  return { id: input.id, deleted: true };
79
79
  },
80
80
  };
@@ -0,0 +1,59 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { projectRunHistoryForModel } from "./ai-projections";
3
+
4
+ describe("projectRunHistoryForModel", () => {
5
+ test("trims each run to time/status/latency/source and keeps total", () => {
6
+ const ts = new Date("2026-06-10T12:00:00.000Z");
7
+ const out = projectRunHistoryForModel({
8
+ runs: [
9
+ {
10
+ id: "run-1",
11
+ configurationId: "cfg-1",
12
+ systemId: "sys-1",
13
+ environmentId: "env-1",
14
+ sourceId: "src-1",
15
+ status: "unhealthy",
16
+ timestamp: ts,
17
+ latencyMs: 142,
18
+ sourceLabel: "EU West",
19
+ },
20
+ ],
21
+ total: 57,
22
+ }) as {
23
+ runs: Array<Record<string, unknown>>;
24
+ returned: number;
25
+ total: number;
26
+ };
27
+
28
+ expect(out.total).toBe(57);
29
+ expect(out.returned).toBe(1);
30
+ expect(out.runs).toHaveLength(1);
31
+ // Kept the useful fields...
32
+ expect(out.runs[0]).toEqual({
33
+ timestamp: "2026-06-10T12:00:00.000Z",
34
+ status: "unhealthy",
35
+ latencyMs: 142,
36
+ sourceLabel: "EU West",
37
+ });
38
+ // ...and dropped the opaque ids.
39
+ expect(out.runs[0]).not.toHaveProperty("id");
40
+ expect(out.runs[0]).not.toHaveProperty("configurationId");
41
+ expect(out.runs[0]).not.toHaveProperty("systemId");
42
+ });
43
+
44
+ test("omits absent optional fields", () => {
45
+ const out = projectRunHistoryForModel({
46
+ runs: [{ status: "healthy", timestamp: "2026-06-10T12:00:00.000Z" }],
47
+ total: 1,
48
+ }) as { runs: Array<Record<string, unknown>> };
49
+ expect(out.runs[0]).toEqual({
50
+ timestamp: "2026-06-10T12:00:00.000Z",
51
+ status: "healthy",
52
+ });
53
+ });
54
+
55
+ test("returns the input unchanged when the shape is unexpected", () => {
56
+ const weird = { foo: "bar" };
57
+ expect(projectRunHistoryForModel(weird)).toBe(weird);
58
+ });
59
+ });
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+ import { HealthCheckStatusSchema } from "@checkstack/healthcheck-common";
3
+
4
+ /**
5
+ * Model-facing lean shapes for the AI tool projections (see
6
+ * `aiToolProjectionExtensionPoint.expose({ projectResult })`). The chat/MCP
7
+ * read-loop still re-enters the real procedure as the principal and gets the
8
+ * FULL output (authz + audit unchanged); these mappers only trim the shape that
9
+ * enters the model's context window.
10
+ *
11
+ * `getHistory` returns ~9 fields per run. For the timeline/root-cause questions
12
+ * the assistant uses it for, the model only needs WHEN, WHAT status, how SLOW,
13
+ * and from WHERE — the opaque ids (`id`, `configurationId`, `systemId`,
14
+ * `environmentId`, `sourceId`) are echoed back to it from its own arguments and
15
+ * waste context, especially since history is replayed verbatim every turn.
16
+ */
17
+
18
+ /** A run as the public `getHistory` returns it (only the fields we read). */
19
+ const RunShape = z.object({
20
+ status: HealthCheckStatusSchema,
21
+ timestamp: z.union([z.date(), z.string()]),
22
+ latencyMs: z.number().optional(),
23
+ sourceLabel: z.string().optional(),
24
+ });
25
+
26
+ const HistoryShape = z.object({
27
+ runs: z.array(RunShape),
28
+ total: z.number(),
29
+ });
30
+
31
+ /** Compact run: time + status + latency + source label. */
32
+ export interface LeanRun {
33
+ timestamp: string;
34
+ status: z.infer<typeof HealthCheckStatusSchema>;
35
+ latencyMs?: number;
36
+ sourceLabel?: string;
37
+ }
38
+
39
+ /**
40
+ * Project a `getHistory` result into the lean per-run shape. Defensive: if the
41
+ * output does not match the expected shape (a future schema change), it is
42
+ * returned unchanged — the generic clamp is the backstop, so a mismatch never
43
+ * crashes the read, it just sends the full shape.
44
+ */
45
+ export function projectRunHistoryForModel(output: unknown): unknown {
46
+ const parsed = HistoryShape.safeParse(output);
47
+ if (!parsed.success) return output;
48
+ const { runs, total } = parsed.data;
49
+ const lean: LeanRun[] = runs.map((r) => ({
50
+ timestamp:
51
+ r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
52
+ status: r.status,
53
+ ...(r.latencyMs === undefined ? {} : { latencyMs: r.latencyMs }),
54
+ ...(r.sourceLabel === undefined ? {} : { sourceLabel: r.sourceLabel }),
55
+ }));
56
+ // `returned` makes the page size explicit next to `total`, so the model knows
57
+ // how much of the window it actually saw without counting the array.
58
+ return { runs: lean, returned: lean.length, total };
59
+ }
package/src/index.ts CHANGED
@@ -26,6 +26,9 @@ import {
26
26
  } from "@checkstack/ai-backend";
27
27
  import { buildHealthcheckAiTools } from "./ai/register-ai-tools";
28
28
  import { createHealthcheckSignalsContributor } from "./ai/system-signals-contributor";
29
+ import { projectRunHistoryForModel } from "./ai-projections";
30
+ import { statusWidgetTypeExtensionPoint } from "@checkstack/status-page-backend";
31
+ import { registerHealthcheckStatusWidgets } from "./status-page/widgets";
29
32
  import {
30
33
  createBackendPlugin,
31
34
  coreServices,
@@ -69,6 +72,7 @@ import { GitOpsApi } from "@checkstack/gitops-common";
69
72
  import { registerSearchProvider } from "@checkstack/command-backend";
70
73
  import { resolveRoute } from "@checkstack/common";
71
74
  import { createHealthCheckCache } from "./cache";
75
+ import { inArray, ilike } from "drizzle-orm";
72
76
 
73
77
  // Store emitHook reference for use during Phase 2 init
74
78
  let storedEmitHook: EmitHookFn | undefined;
@@ -97,6 +101,13 @@ export default createBackendPlugin({
97
101
  healthcheckGroupSubscription,
98
102
  ]);
99
103
 
104
+ // Status-page widgets owned by healthcheck (system health, uptime, banner,
105
+ // group status). Buffered behind the extension point until status-page-backend
106
+ // registers it — so the status-page platform never depends on healthcheck.
107
+ registerHealthcheckStatusWidgets(
108
+ env.getExtensionPoint(statusWidgetTypeExtensionPoint),
109
+ );
110
+
100
111
  // ─── Automation Platform: triggers + artifact type ─────────────────
101
112
  // Buffered behind the extension point until automation-backend's
102
113
  // register() runs. Actions are wired in afterPluginsReady where
@@ -208,6 +219,7 @@ export default createBackendPlugin({
208
219
  config: coreServices.config,
209
220
  secretResolver: secretResolverRef,
210
221
  advisoryLock: coreServices.advisoryLock,
222
+ resourceResolverRegistry: coreServices.resourceResolverRegistry,
211
223
  },
212
224
  // Phase 2: Register router and setup worker
213
225
  init: async ({
@@ -223,9 +235,41 @@ export default createBackendPlugin({
223
235
  config,
224
236
  secretResolver,
225
237
  advisoryLock,
238
+ resourceResolverRegistry,
226
239
  }) => {
227
240
  logger.debug("🏥 Initializing Health Check Backend...");
228
241
 
242
+ const typedDb = database as SafeDatabase<typeof schema>;
243
+
244
+ // Resolve/search health-check configurations by name for the Teams admin
245
+ // UI (team grants are stored as opaque healthcheck.configuration:<id>
246
+ // rows). Lets the auth backend render grants by name and power the grant
247
+ // picker without depending on healthcheck internals.
248
+ resourceResolverRegistry.register("healthcheck.configuration", {
249
+ resolveNames: async (ids) => {
250
+ if (ids.length === 0) return new Map();
251
+ const rows = await typedDb
252
+ .select({
253
+ id: schema.healthCheckConfigurations.id,
254
+ name: schema.healthCheckConfigurations.name,
255
+ })
256
+ .from(schema.healthCheckConfigurations)
257
+ .where(inArray(schema.healthCheckConfigurations.id, ids));
258
+ return new Map(rows.map((r) => [r.id, r.name]));
259
+ },
260
+ search: async (query, limit) => {
261
+ const rows = await typedDb
262
+ .select({
263
+ id: schema.healthCheckConfigurations.id,
264
+ name: schema.healthCheckConfigurations.name,
265
+ })
266
+ .from(schema.healthCheckConfigurations)
267
+ .where(ilike(schema.healthCheckConfigurations.name, `%${query}%`))
268
+ .limit(limit);
269
+ return rows;
270
+ },
271
+ });
272
+
229
273
  // Populate mutable refs for GitOps reconcile closures
230
274
  gitopsDb = database;
231
275
  gitopsHealthCheckRegistry = healthCheckRegistry;
@@ -285,13 +329,42 @@ export default createBackendPlugin({
285
329
  procedureKey: "getHistory",
286
330
  name: "healthcheck.runHistory",
287
331
  description:
288
- "List historical health-check runs (individual timestamped results) " +
289
- "for root-cause and timeline questions. Filter by `systemId`, a " +
290
- "`startDate`/`endDate` window, and/or `statusFilter` (e.g. " +
291
- "[\"unhealthy\",\"degraded\"]) to find when and how a system was " +
292
- "failing over a period. Pass `sortOrder` (\"desc\" for most recent " +
293
- "first) and use `limit`/`offset` to page. Use this for past/timespan " +
294
- "questions; use healthcheck.status for the current state. Read-only.",
332
+ "List INDIVIDUAL historical health-check runs for a SMALL window or " +
333
+ "a few specific failures. Filter by `systemId`, a `startDate`/" +
334
+ "`endDate` window, and/or `statusFilter` (e.g. [\"unhealthy\"," +
335
+ "\"degraded\"]); keep `limit` small (each run is a row). For a WIDE " +
336
+ "window or 'how often / how much downtime / uptime over the last N " +
337
+ "days' questions, use healthcheck.runStats instead — it returns " +
338
+ "counts and latency stats without thousands of rows. Use " +
339
+ "healthcheck.status for the current state. Read-only.",
340
+ effect: "read",
341
+ execute: deferredProjectionExecute,
342
+ // Lean shape for the model: drop the opaque ids it merely echoes and
343
+ // keep time/status/latency/source, so a page of runs doesn't blow the
344
+ // context window (and keep doing so on every verbatim history replay).
345
+ projectResult: projectRunHistoryForModel,
346
+ });
347
+
348
+ // Aggregate run statistics over a window: counts by status, uptime %,
349
+ // and latency stats, plus a small capped time series — the COMPACT way
350
+ // to answer "how often / how much downtime / uptime over the last N
351
+ // days" without pulling thousands of raw rows into the model's context.
352
+ // Same public `healthcheck.status` gate as runHistory (no `result`
353
+ // payload). Output is already small and bounded, so no projectResult.
354
+ env.getExtensionPoint(aiToolProjectionExtensionPoint).expose({
355
+ procedure: healthCheckContract.getRunStats,
356
+ sourcePluginMetadata: pluginMetadata,
357
+ procedureKey: "getRunStats",
358
+ name: "healthcheck.runStats",
359
+ description:
360
+ "Summarize health-check runs over a window: total and per-bucket " +
361
+ "counts by status, uptime %, and latency stats (avg/min/max/p95). " +
362
+ "PREFER THIS over healthcheck.runHistory for any 'how often / how " +
363
+ "much downtime / uptime / latency trend over the last N hours or " +
364
+ "days' question — it returns compact aggregates, not raw rows. " +
365
+ "Filter by `systemId`, `configurationId`, `startDate`/`endDate`, " +
366
+ "`statusFilter`; set `maxBuckets` for the time-series resolution " +
367
+ "(default 24). Read-only.",
295
368
  effect: "read",
296
369
  execute: deferredProjectionExecute,
297
370
  });
@@ -334,7 +334,7 @@ describe("HealthCheck Router", () => {
334
334
  const context = createMockRpcContext({ user: mockUser });
335
335
 
336
336
  try {
337
- await call(router.deleteConfiguration, "config-1", { context });
337
+ await call(router.deleteConfiguration, { id: "config-1" }, { context });
338
338
  } catch (e: any) {
339
339
  // If it throws anything other than FORBIDDEN, it passed the lock check
340
340
  expect(e.code).not.toBe("FORBIDDEN");
@@ -357,7 +357,7 @@ describe("HealthCheck Router", () => {
357
357
 
358
358
  let error;
359
359
  try {
360
- await call(router.deleteConfiguration, "config-1", { context });
360
+ await call(router.deleteConfiguration, { id: "config-1" }, { context });
361
361
  } catch (e) {
362
362
  error = e;
363
363
  }