@checkstack/healthcheck-backend 1.8.0 → 1.9.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 CHANGED
@@ -1,5 +1,276 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 551eaa9: AI assistant context-window management + leaner health-check history for chat.
8
+
9
+ The assistant previously sent the full conversation history verbatim every turn
10
+ with no size bounds, so analyzing historical health-check runs blew the model's
11
+ context window fast. Two problems are addressed:
12
+
13
+ **Verbosity.** Read-tool results are now shaped for the model:
14
+
15
+ - A generic, last-resort size clamp on every read result (head-trims the largest
16
+ arrays and adds a `_truncated` hint to narrow/paginate) so one wide pull can't
17
+ blow the context — and, since history replays each turn, keep blowing it.
18
+ - Projections can declare an optional `projectResult` to return a LEANER
19
+ model-facing shape than the UI procedure (authz + audit still see the full
20
+ result). `healthcheck.runHistory` uses it to drop the opaque ids the model
21
+ merely echoes, keeping time/status/latency/source.
22
+ - New `healthcheck.runStats` AI tool (backed by a new public `getRunStats`
23
+ procedure): compact window totals (counts by status, uptime %, latency
24
+ avg/min/max/p95) plus a small capped time series, so "how often / how much
25
+ downtime / uptime over the last N days" questions return aggregates instead of
26
+ thousands of rows. `runHistory`'s description now steers wide-window questions
27
+ here.
28
+
29
+ **Context limits.** The chat loop now estimates the prompt's tokens (a
30
+ provider-agnostic heuristic) against a budget derived from the connection's
31
+ context window, and COMPACTS the conversation before it overflows: the oldest
32
+ turns are summarized into a durable running summary (persisted on the
33
+ conversation row in shared Postgres, so any pod resumes consistently) and dropped
34
+ from the verbatim replay, with the summary folded into the system prompt.
35
+ Splitting at message-row boundaries keeps tool-call/result pairs intact, and the
36
+ summarization step is fail-open. A new optional `contextWindowTokens` on the
37
+ OpenAI-compatible connection sets the window (blank = conservative default).
38
+
39
+ All additive: a new optional connection field, a new public read endpoint, and an
40
+ additive `ai-backend` migration (`0009`) adding nullable `summary` /
41
+ `summarized_through_message_id` columns to `ai_conversations`.
42
+
43
+ - d2077bd: Platform-wide team-scoped access control on a unified relation-tuple store.
44
+
45
+ Admins can scope any resource to teams, and the **platform** (not each plugin)
46
+ enforces it. A plugin opts in declaratively by adding `instanceAccess` to a
47
+ procedure's contract; the auth middleware does the rest, so enforcement is
48
+ consistent across catalog, health checks, incidents, maintenances, SLOs,
49
+ automations, and the dependency map, and any third-party plugin gets it for free.
50
+
51
+ Core model:
52
+
53
+ - **Teams are optional.** A resource with no team grants behaves exactly as
54
+ before.
55
+ - **Team grants are additive and restrict who can CHANGE a resource, not who can
56
+ SEE it.** Granting a team `Manage` lets its members view and change the
57
+ resource; `Read-only` lets them view it. Either level grants access to team
58
+ members **even when they lack the global permission**, and granting never
59
+ removes read from anyone who already had it (e.g. a public status page stays
60
+ readable). Privacy is a separate, explicit opt-in via the **Private** toggle,
61
+ which removes the global read path so only the resource's teams can see it.
62
+ - **Ownership at creation.** Create forms expose an **Owning team** picker. A
63
+ non-admin can create a resource for a team they belong to that holds a
64
+ create-capability grant for that type; the new resource is auto-granted to that
65
+ team. Incidents and maintenances are **parent-gated**: anyone who can manage a
66
+ system may open incidents/maintenances for it, no separate grant needed.
67
+ - **Meaningful authorization errors.** A caller with neither the global rule nor
68
+ any team grant for a resource type gets a `403` with a structured body instead
69
+ of a silently-empty `200`. Anonymous callers on public endpoints are never
70
+ `403`'d, so status pages keep rendering.
71
+
72
+ Unified relation-tuple store:
73
+
74
+ - The previously separate access primitives (`resource_team_access.canRead` /
75
+ `.canManage`, ownership, `resource_access_settings.teamOnly`, and
76
+ `resource_create_grant`) are collapsed onto ONE
77
+ `relation_tuple(object, relation, subject)` store: "a team has
78
+ `viewer`/`editor`/`owner` on an object, or `creator` on a type". Privacy is an
79
+ explicit **`private` marker** tuple — its **presence** closes the global read
80
+ path (team grants only), its **absence** is the readable-by-default state, so a
81
+ private resource with zero grants is correctly inaccessible to everyone rather
82
+ than silently globalized. The access decision is a pure, unit-tested function.
83
+ - The auth API is generic: `writeRelation` / `removeRelation` / `setObjectPublic`
84
+ / `listObjectRelations` / `listSubjectRelations` / `setCreateGrant` /
85
+ `listTeamCreateGrants` (user-facing) and `check` / `listAccessibleObjectIds` /
86
+ `hasAnyTypeGrant` / `authorizeCreate` / `setOwner` / `deleteObjectRelations`
87
+ (service-to-service). Migration `0008` backfills tuples from the legacy tables
88
+ and drops them.
89
+
90
+ Explicit per-procedure scoping:
91
+
92
+ - Access rules (`access()` / `accessPair()`) define only the rule (id, level,
93
+ defaults); every procedure declares its own `instanceAccess`. This removes a
94
+ "loaded gun" default that silently applied a shared `idParam` to any procedure
95
+ which forgot its own override.
96
+ - Modes: `idParam` (single-resource pre-check, fails **closed** if the id does
97
+ not resolve), `listKey` / `recordKey` (post-filter a list/record to the
98
+ accessible subset), `create` (authorize creation + write the owning-team
99
+ grant), `parentScope` (scope by read/manage access to a PARENT type,
100
+ cross-plugin single-hop: "you may see incidents/maintenances/SLOs/health for
101
+ system S iff you may see S"), and `global: true` (the honest "intentionally not
102
+ team-scoped" opt-out). A boot-time validator **rejects** any procedure gated on
103
+ a team-scopable resource type that declares no `instanceAccess`, turning the
104
+ previous fail-open into a boot error.
105
+
106
+ Teams administration:
107
+
108
+ - **Team managers** manage their own team's members and managers without the
109
+ global `auth.teams.manage` rule; creating, deleting, and granting a team access
110
+ remain admin-only.
111
+ - A **standalone Teams page** (gated on `auth.teams.read`) lets managers reach
112
+ team administration without the admin Auth Settings page; members are added via
113
+ a debounced directory picker.
114
+ - A **cross-plugin `ResourceResolverRegistry`** lets owning plugins register a
115
+ name/search resolver for their resource types, so the Teams page lists a team's
116
+ grants **by name** (grouped by type) and offers a resource picker — an admin can
117
+ change a grant's level, revoke it, or add one, without auth depending on every
118
+ plugin. Resolvers shipped for catalog systems, health-check configurations,
119
+ incidents, maintenances, SLO objectives, and automations.
120
+
121
+ Frontend:
122
+
123
+ - The resource-side editor is **"Who can change this"** (one Manage checkbox per
124
+ team; unticked = read-only), with an always-visible **Private** toggle
125
+ (disabled until a team that can Manage exists, so a resource can't be stranded).
126
+ - `TeamOwnershipPicker` explains _why_ there's nothing to pick (not a member of
127
+ any team, or none of your teams manage the selected parent) instead of a bare
128
+ "global resource" line.
129
+ - Read-only **"who can change this"** indicators on resource detail pages expand
130
+ to the actual people by name; bulk + per-row **Scope to team** actions in the
131
+ catalog systems list; and the team-access copy spells out that grants are
132
+ additive and that Read-only grants view (not change) even without the global
133
+ permission.
134
+
135
+ Security hardening:
136
+
137
+ - Child deletes in catalog (`removeSystemContact` / `removeSystemLink`) are scoped
138
+ to both the child id and its parent `systemId`, closing a cross-system IDOR for
139
+ team-scoped managers.
140
+ - `searchUsers` is restricted to team administrators, closing a directory/email
141
+ enumeration path opened by the default `auth.teams.read` rule.
142
+ - Grant setters reject unregistered resource types.
143
+
144
+ BREAKING CHANGES (beta; shipped as minor bumps):
145
+
146
+ - `access()` and `accessPair()` no longer accept `idParam` / `listKey` /
147
+ `recordKey`; move instance config to the procedure's `instanceAccess`.
148
+ - Boot fails if a procedure gated on a team-scopable resource type omits
149
+ `instanceAccess`. Declare a scoping mode or `instanceAccess: { global: true }`.
150
+ - The `AuthService` interface is reshaped: `check`, `listAccessibleObjectIds`,
151
+ `hasAnyTypeGrant`, `authorizeCreate` (returns `isPrivate`), `setOwner`
152
+ (`isPrivate`), and `deleteObjectRelations`. Custom `AuthService` implementations
153
+ and mocks must update.
154
+ - The auth RPC contract's per-concept resource-access endpoints are replaced by
155
+ the generic tuple API above; external callers of the old
156
+ `getResourceTeamAccess` / `setResourceTeamAccess` / `setResourceAccessSettings`
157
+ / `grantResourceCreate` / etc. must move to the new procedures.
158
+ - Several contract inputs changed from a bare `string` to an object so the
159
+ middleware can resolve the resource id: catalog `deleteSystem` (`{ id }`),
160
+ `removeSystemContact` / `removeSystemLink` (`{ id, systemId }`); health-check
161
+ `deleteConfiguration` / `pauseConfiguration` / `resumeConfiguration` (`{ id }`).
162
+ All in-tree callers are updated.
163
+ - List/record endpoints that relied on returning an empty `200` to signal "no
164
+ access" now return a `403` for categorically-unauthorized principals.
165
+ - The mis-keyed bulk endpoints `getBulkIncidentsForSystems`,
166
+ `getBulkMaintenancesForSystems`, and `getBulkObjectivesForSystems` no longer
167
+ post-filter their (systemId-keyed) result; access is already gated by
168
+ `catalog.system` upstream.
169
+ - Team membership/manager mutations (`addUserToTeam`, `removeUserFromTeam`,
170
+ `addTeamManager`, `removeTeamManager`) now require `auth.teams.read` instead of
171
+ `auth.teams.manage` at the contract level (broadened to per-team managers).
172
+ - The `resource_team_access`, `resource_access_settings`, and
173
+ `resource_create_grant` tables are dropped (data backfilled into
174
+ `relation_tuple` by migration `0008`). A previously inconsistent "team-only with
175
+ zero grants" resource is now correctly inaccessible to global-access holders.
176
+
177
+ - 5c6393f: Add operator-built public Status Pages (phase 1: secure, extensible core).
178
+
179
+ Operators compose a public status page from widgets (status banner, system
180
+ health, group status, 90-day uptime, incidents, scheduled maintenance) plus
181
+ content blocks (text/Markdown, heading, links, image, divider), each bound to the
182
+ resources they choose, then publish it.
183
+
184
+ Security model — "only published widgets reveal data":
185
+
186
+ - A single public endpoint, `getPublishedStatusPage(slug)`, returns the layout
187
+ plus each widget's already-resolved, field-ALLOW-LISTED DTO. The public surface
188
+ has no generic data API, so it can only ever show what was placed on the page.
189
+ - Three gates: edit-time (you can only bind resources you can access), publish-time
190
+ (an audited, deliberate exposure that re-checks the editor can read every bound
191
+ resource via a user-scoped client), and render-time (resolvers run as a trusted
192
+ service but emit only DTO fields — never internal config, ids, or `createdBy`;
193
+ the service re-validates each DTO against its schema, so a resolver bug fails
194
+ closed).
195
+ - The overall banner rolls up only the bound systems; private resources are never
196
+ exposed beyond their public-safe status; per-binding label overrides avoid
197
+ internal-name leaks.
198
+
199
+ Coherence + extensibility:
200
+
201
+ - Status pages are team-scopable resources (RLAC): created via the standard
202
+ owning-team picker + create-capability flow, resolvable by name in the Teams
203
+ admin.
204
+ - Widget types come from an extension-point registry, so any plugin can contribute
205
+ a widget (config schema + public DTO + `resolvePublic`); the public renderers
206
+ are pure, prop-only components with no data access, so third-party widgets can
207
+ never leak.
208
+ - Draft vs published layouts; per-page visibility (public / authenticated-only)
209
+ and theming (brand color, logo).
210
+
211
+ Dependency direction: the status-page platform owns the widget-type registry and
212
+ the content widgets, but the DOMAIN widgets are contributed by their owning
213
+ plugins via the `statusWidgetTypeExtensionPoint` — system health / uptime /
214
+ banner / group status by `healthcheck-backend`, incidents by `incident-backend`,
215
+ scheduled maintenance by `maintenance-backend`. So `status-page-backend` depends
216
+ only on `backend-api` / `common` / `status-page-common`; the owning plugins
217
+ depend on the platform, never the reverse. `catalog-common` gains
218
+ `assertCatalogResourcesReadable` for the publish-time access check.
219
+
220
+ Phase 1 scope: the secure core, the admin builder, and the public page (served as
221
+ a no-access-rule route). A fully separate public bundle, custom domains + TLS,
222
+ drag-reorder, live-data preview, and distribution (embeds/badges/RSS/subscriptions)
223
+ are the next phases.
224
+
225
+ ### Patch Changes
226
+
227
+ - Updated dependencies [551eaa9]
228
+ - Updated dependencies [d2077bd]
229
+ - Updated dependencies [9ab73c5]
230
+ - Updated dependencies [5c6393f]
231
+ - @checkstack/ai-backend@0.7.0
232
+ - @checkstack/ai-common@0.5.0
233
+ - @checkstack/healthcheck-common@1.7.0
234
+ - @checkstack/backend-api@0.23.0
235
+ - @checkstack/common@0.16.0
236
+ - @checkstack/automation-backend@0.9.0
237
+ - @checkstack/catalog-backend@1.5.0
238
+ - @checkstack/catalog-common@2.4.0
239
+ - @checkstack/incident-backend@1.8.0
240
+ - @checkstack/incident-common@1.6.0
241
+ - @checkstack/maintenance-common@1.7.0
242
+ - @checkstack/status-page-common@0.1.0
243
+ - @checkstack/status-page-backend@0.1.0
244
+ - @checkstack/satellite-backend@0.6.13
245
+ - @checkstack/sdk@0.108.1
246
+ - @checkstack/script-packages-backend@0.3.12
247
+ - @checkstack/secrets-backend@0.2.9
248
+ - @checkstack/command-backend@0.2.9
249
+ - @checkstack/gitops-backend@0.5.9
250
+ - @checkstack/cache-api@0.3.13
251
+ - @checkstack/gitops-common@0.6.4
252
+ - @checkstack/notification-common@1.3.4
253
+ - @checkstack/queue-api@0.3.13
254
+ - @checkstack/secrets-common@0.2.4
255
+ - @checkstack/signal-common@0.2.10
256
+ - @checkstack/cache-utils@0.2.18
257
+
258
+ ## 1.8.1
259
+
260
+ ### Patch Changes
261
+
262
+ - Updated dependencies [bb6f0fe]
263
+ - Updated dependencies [bb6f0fe]
264
+ - @checkstack/maintenance-common@1.6.0
265
+ - @checkstack/ai-backend@0.6.1
266
+ - @checkstack/sdk@0.107.1
267
+ - @checkstack/automation-backend@0.8.1
268
+ - @checkstack/secrets-backend@0.2.8
269
+ - @checkstack/catalog-backend@1.4.12
270
+ - @checkstack/incident-backend@1.7.4
271
+ - @checkstack/satellite-backend@0.6.12
272
+ - @checkstack/script-packages-backend@0.3.11
273
+
3
274
  ## 1.8.0
4
275
 
5
276
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
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.0",
19
- "@checkstack/ai-common": "0.4.0",
20
- "@checkstack/script-packages-backend": "0.3.10",
21
- "@checkstack/cache-api": "0.3.12",
22
- "@checkstack/cache-utils": "0.2.17",
23
- "@checkstack/catalog-backend": "1.4.11",
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.3",
33
- "@checkstack/incident-common": "1.5.2",
34
- "@checkstack/automation-backend": "0.8.0",
35
- "@checkstack/maintenance-common": "1.5.2",
36
- "@checkstack/notification-common": "1.3.3",
37
- "@checkstack/queue-api": "0.3.12",
38
- "@checkstack/satellite-backend": "0.6.11",
39
- "@checkstack/sdk": "0.106.1",
40
- "@checkstack/signal-common": "0.2.9",
17
+ "@checkstack/backend-api": "0.23.0",
18
+ "@checkstack/ai-backend": "0.7.0",
19
+ "@checkstack/ai-common": "0.5.0",
20
+ "@checkstack/script-packages-backend": "0.3.12",
21
+ "@checkstack/cache-api": "0.3.13",
22
+ "@checkstack/cache-utils": "0.2.18",
23
+ "@checkstack/catalog-backend": "1.5.0",
24
+ "@checkstack/catalog-common": "2.4.0",
25
+ "@checkstack/command-backend": "0.2.9",
26
+ "@checkstack/common": "0.16.0",
27
+ "@checkstack/gitops-backend": "0.5.9",
28
+ "@checkstack/gitops-common": "0.6.4",
29
+ "@checkstack/healthcheck-common": "1.7.0",
30
+ "@checkstack/secrets-common": "0.2.4",
31
+ "@checkstack/secrets-backend": "0.2.9",
32
+ "@checkstack/incident-backend": "1.8.0",
33
+ "@checkstack/incident-common": "1.6.0",
34
+ "@checkstack/automation-backend": "0.9.0",
35
+ "@checkstack/maintenance-common": "1.7.0",
36
+ "@checkstack/notification-common": "1.3.4",
37
+ "@checkstack/queue-api": "0.3.13",
38
+ "@checkstack/satellite-backend": "0.6.13",
39
+ "@checkstack/sdk": "0.108.1",
40
+ "@checkstack/signal-common": "0.2.10",
41
+ "@checkstack/status-page-backend": "0.1.0",
42
+ "@checkstack/status-page-common": "0.1.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.43",
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
  }