@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 +271 -0
- package/package.json +29 -27
- package/src/ai/healthcheck-delete.test.ts +1 -1
- package/src/ai/healthcheck-delete.ts +1 -1
- package/src/ai-projections.test.ts +59 -0
- package/src/ai-projections.ts +59 -0
- package/src/index.ts +80 -7
- package/src/router.test.ts +2 -2
- package/src/router.ts +10 -6
- package/src/run-stats.logic.test.ts +73 -0
- package/src/run-stats.logic.ts +148 -0
- package/src/service.ts +63 -0
- package/src/status-page/rollup.test.ts +53 -0
- package/src/status-page/rollup.ts +82 -0
- package/src/status-page/widgets.ts +290 -0
- package/tsconfig.json +6 -0
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.
|
|
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.
|
|
18
|
-
"@checkstack/ai-backend": "0.
|
|
19
|
-
"@checkstack/ai-common": "0.
|
|
20
|
-
"@checkstack/script-packages-backend": "0.3.
|
|
21
|
-
"@checkstack/cache-api": "0.3.
|
|
22
|
-
"@checkstack/cache-utils": "0.2.
|
|
23
|
-
"@checkstack/catalog-backend": "1.
|
|
24
|
-
"@checkstack/catalog-common": "2.
|
|
25
|
-
"@checkstack/command-backend": "0.2.
|
|
26
|
-
"@checkstack/common": "0.
|
|
27
|
-
"@checkstack/gitops-backend": "0.5.
|
|
28
|
-
"@checkstack/gitops-common": "0.6.
|
|
29
|
-
"@checkstack/healthcheck-common": "1.
|
|
30
|
-
"@checkstack/secrets-common": "0.2.
|
|
31
|
-
"@checkstack/secrets-backend": "0.2.
|
|
32
|
-
"@checkstack/incident-backend": "1.
|
|
33
|
-
"@checkstack/incident-common": "1.
|
|
34
|
-
"@checkstack/automation-backend": "0.
|
|
35
|
-
"@checkstack/maintenance-common": "1.
|
|
36
|
-
"@checkstack/notification-common": "1.3.
|
|
37
|
-
"@checkstack/queue-api": "0.3.
|
|
38
|
-
"@checkstack/satellite-backend": "0.6.
|
|
39
|
-
"@checkstack/sdk": "0.
|
|
40
|
-
"@checkstack/signal-common": "0.2.
|
|
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.
|
|
53
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
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
|
|
289
|
-
"
|
|
290
|
-
"`
|
|
291
|
-
"
|
|
292
|
-
"
|
|
293
|
-
"
|
|
294
|
-
"
|
|
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
|
});
|
package/src/router.test.ts
CHANGED
|
@@ -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
|
}
|