@checkstack/healthcheck-backend 1.2.0 → 1.3.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 +212 -0
- package/package.json +14 -14
- package/src/automations.test.ts +255 -0
- package/src/automations.ts +340 -0
- package/src/hooks.ts +69 -4
- package/src/index.ts +37 -52
- package/src/queue-executor.test.ts +137 -0
- package/src/queue-executor.ts +130 -27
- package/src/router.test.ts +5 -0
- package/src/router.ts +12 -1
- package/src/service-assignments.test.ts +184 -0
- package/src/service.ts +65 -0
- package/tsconfig.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,217 @@
|
|
|
1
1
|
# @checkstack/healthcheck-backend
|
|
2
2
|
|
|
3
|
+
## 1.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 41c77f4: feat(automation): type enum-able trigger/artifact fields as enums for editor value autocompletion
|
|
8
|
+
|
|
9
|
+
The automation editor's staged completion offers concrete values after a
|
|
10
|
+
comparator (`{{ trigger.payload.severity == "high" }}`) only when the
|
|
11
|
+
field's JSON Schema carries an `enum`. Several trigger payload + artifact
|
|
12
|
+
schemas declared closed-set fields as loose `z.string()`, so no values
|
|
13
|
+
were suggested. Tightened them to the canonical enums that already
|
|
14
|
+
existed in each plugin's `-common` package (and matched the hook payload
|
|
15
|
+
types in lockstep so the trigger's `payloadSchema` and `hook` keep the
|
|
16
|
+
same `TPayload`):
|
|
17
|
+
|
|
18
|
+
- **incident** — trigger payloads: `severity` → `IncidentSeverityEnum`,
|
|
19
|
+
`status` / `statusChange` → `IncidentStatusEnum`.
|
|
20
|
+
- **healthcheck** — trigger payloads: `previousStatus` / `newStatus` /
|
|
21
|
+
`status` → `HealthCheckStatusSchema` (across systemDegraded,
|
|
22
|
+
systemHealthy, systemHealthChanged, checkFailed; plus checkCompleted's
|
|
23
|
+
hook type).
|
|
24
|
+
- **dependency** — trigger + artifact: `impactType` → `ImpactTypeSchema`;
|
|
25
|
+
impactPropagated `previousState` / `newState` → `DerivedStateSchema`.
|
|
26
|
+
Also deduped the inline `impactTypeSchema` action-config enum to reuse
|
|
27
|
+
the canonical `ImpactTypeSchema`.
|
|
28
|
+
- **maintenance** — trigger + artifact: `status` →
|
|
29
|
+
`MaintenanceStatusEnum`; deduped the inline `maintenanceStatusEnum`
|
|
30
|
+
(used by `add_update.statusChange`) to the canonical one.
|
|
31
|
+
- **slo** — `achievement.unlocked` trigger + hook: `achievement` →
|
|
32
|
+
`AchievementTypeSchema`.
|
|
33
|
+
|
|
34
|
+
Runtime behaviour is unchanged — these fields always carried valid enum
|
|
35
|
+
values (the underlying records are enum-constrained); only the schema
|
|
36
|
+
types were loose. The hook payload generics are now precise too, which
|
|
37
|
+
caught one stale test fixture asserting an invalid `impactType: "soft"`.
|
|
38
|
+
|
|
39
|
+
Fields that look enum-ish but are genuinely free-form were intentionally
|
|
40
|
+
left as `z.string()`: satellite `region` (user-entered), Jira issue
|
|
41
|
+
`status` (per-instance workflow name), notification `strategyQualifiedId`
|
|
42
|
+
/ `errorMessage`, healthcheck collector `result`, and script
|
|
43
|
+
`stdout` / `stderr`.
|
|
44
|
+
|
|
45
|
+
- 41c77f4: feat(healthcheck): Phase 9 — run_now / enable / disable actions + umbrella health-changed trigger
|
|
46
|
+
|
|
47
|
+
- New hook `healthCheckHooks.systemHealthChanged`, an umbrella variant
|
|
48
|
+
of `systemDegraded` + `systemHealthy` that fires on **every**
|
|
49
|
+
aggregated-health transition (with both `previousStatus` and
|
|
50
|
+
`newStatus`). Emitted alongside the directional hooks at both
|
|
51
|
+
emission sites in `queue-executor.ts`, so existing subscribers keep
|
|
52
|
+
working unchanged.
|
|
53
|
+
- New hook `healthCheckHooks.checkFailed` — fires alongside the
|
|
54
|
+
existing `checkCompleted` whenever an individual run's status
|
|
55
|
+
isn't `healthy`. Exists as a narrow alternative so an automation
|
|
56
|
+
doesn't need "trigger on completion → filter by status" — useful
|
|
57
|
+
for incident-style flows.
|
|
58
|
+
- New hook `healthCheckHooks.flappingDetected` — fires from inside
|
|
59
|
+
the auto-incident evaluator whenever the unhealthy-transition count
|
|
60
|
+
crosses `policy.flappingTrigger.transitions` within
|
|
61
|
+
`policy.flappingTrigger.windowMinutes`, regardless of whether
|
|
62
|
+
`autoOpenIncidentOnUnhealthy` is enabled. Carries the observed
|
|
63
|
+
count + window so subscribers can reason about both. Re-fires on
|
|
64
|
+
every additional transition past the threshold while the check
|
|
65
|
+
stays flapping — debounce on `(systemId, configurationId)` if
|
|
66
|
+
"page once and only once" is wanted.
|
|
67
|
+
- Triggers `healthcheck.system_degraded`,
|
|
68
|
+
`healthcheck.system_healthy`, the umbrella
|
|
69
|
+
`healthcheck.system_health_changed`, plus the new
|
|
70
|
+
`healthcheck.check_failed` and `healthcheck.flapping_detected`.
|
|
71
|
+
Inline trigger registrations moved out of `register()` into
|
|
72
|
+
`automations.ts`.
|
|
73
|
+
- Actions `healthcheck.run_now` (enqueues a one-off job on the
|
|
74
|
+
shared `HEALTH_CHECK_QUEUE`), `healthcheck.enable_assignment`, and
|
|
75
|
+
`healthcheck.disable_assignment`. The enable/disable actions use a
|
|
76
|
+
new service method `setAssignmentEnabled(systemId, configurationId,
|
|
77
|
+
enabled)` that flips just the `enabled` flag without touching
|
|
78
|
+
thresholds / satellite assignment / notification policy. Both fire
|
|
79
|
+
the existing `assignmentChanged` hook so the satellite config relay
|
|
80
|
+
picks up the change.
|
|
81
|
+
- Artifact type `healthcheck.assignment` for downstream steps to
|
|
82
|
+
consume.
|
|
83
|
+
|
|
84
|
+
`HEALTH_CHECK_QUEUE` is exported so the `run_now` action can enqueue
|
|
85
|
+
without re-importing the recurring-job factory.
|
|
86
|
+
|
|
87
|
+
- 35bc682: feat(healthcheck): expose check + system run-context to script collectors
|
|
88
|
+
|
|
89
|
+
Script health checks can now read which check and system a run is for.
|
|
90
|
+
Previously shell scripts got only a curated env whitelist and inline
|
|
91
|
+
scripts only `context.config`, so a script had no built-in way to know
|
|
92
|
+
its own check name or the system it was checking.
|
|
93
|
+
|
|
94
|
+
- `@checkstack/backend-api`: new `CollectorRunContext` type
|
|
95
|
+
(`{ check: { id, name, intervalSeconds }, system: { id, name } }`) and
|
|
96
|
+
an optional `runContext` param on `CollectorStrategy.execute`. Optional,
|
|
97
|
+
so existing collector implementations are unaffected.
|
|
98
|
+
- Shell-script collector: injects reserved `CHECKSTACK_CHECK_ID`,
|
|
99
|
+
`CHECKSTACK_CHECK_NAME`, `CHECKSTACK_CHECK_INTERVAL_SECONDS`,
|
|
100
|
+
`CHECKSTACK_SYSTEM_ID`, `CHECKSTACK_SYSTEM_NAME` env vars (user-supplied
|
|
101
|
+
`env` still wins on collision).
|
|
102
|
+
- Inline-script collector: exposes `context.check` and `context.system`
|
|
103
|
+
alongside `context.config`; the inline-script editor now types them for
|
|
104
|
+
autocomplete.
|
|
105
|
+
- Shell editors (health-check collectors and automation shell actions) now
|
|
106
|
+
also suggest the user's own `env` (JSON) keys as `$NAME` completions, via
|
|
107
|
+
the new exported `customShellEnvVars` helper. Keys that aren't valid shell
|
|
108
|
+
identifiers are omitted.
|
|
109
|
+
- Fix: the Typefox `CodeEditor` captured a stale `onChange` at editor start,
|
|
110
|
+
so editing one `DynamicForm` field reverted sibling fields changed since
|
|
111
|
+
mount (e.g. typing in a shell `script` field wiped an unsaved `env` value,
|
|
112
|
+
or deleted a sibling automation action added after mount). The change
|
|
113
|
+
handler now routes through a ref to the current `onChange`.
|
|
114
|
+
- Fix: focusing a JSON editor threw "LanguageStatusService.addStatus is not
|
|
115
|
+
supported" because the standalone service set omitted `ILanguageStatusService`.
|
|
116
|
+
That one service is now registered via `serviceOverrides`.
|
|
117
|
+
- Fix: the automation trigger card nested a `<Badge>` (a `<div>`) inside a
|
|
118
|
+
`<p>`, producing a `validateDOMNesting` warning. Switched the wrapper to a
|
|
119
|
+
`<div>`.
|
|
120
|
+
- Local runs (`queue-executor`) and satellite runs both populate the
|
|
121
|
+
context. `SatelliteAssignment` (and the `getAssignmentsForSatellite`
|
|
122
|
+
RPC output) gained optional `configName` / `systemName` so the metadata
|
|
123
|
+
reaches satellite-side execution; `HealthCheckService` resolves the
|
|
124
|
+
system name via the catalog client.
|
|
125
|
+
|
|
126
|
+
BREAKING CHANGE: `createHealthCheckRouter` now requires a `catalogClient`
|
|
127
|
+
option (used to resolve system names for satellite assignments). Update
|
|
128
|
+
call sites to pass the catalog RPC client.
|
|
129
|
+
|
|
130
|
+
### Patch Changes
|
|
131
|
+
|
|
132
|
+
- 41c77f4: feat(automation): one-time migration of webhook subscriptions + remove legacy integration backend
|
|
133
|
+
|
|
134
|
+
**BREAKING CHANGES** (platform is in BETA — no major bump):
|
|
135
|
+
|
|
136
|
+
- `IntegrationProvider` no longer carries `config` (subscription
|
|
137
|
+
config) or `deliver`. The interface now models a connection provider
|
|
138
|
+
only: connection schema + `getConnectionOptions` + `testConnection`.
|
|
139
|
+
- The legacy subscription / delivery-log / event endpoints
|
|
140
|
+
(`listSubscriptions`, `createSubscription`, `getDeliveryLogs`,
|
|
141
|
+
`listEventTypes`, …) are removed from `integrationContract`.
|
|
142
|
+
- `delivery-coordinator`, `hook-subscriber`, `event-registry`, and the
|
|
143
|
+
`integrationEventExtensionPoint` are deleted. Plugins that
|
|
144
|
+
previously called `integrationEvents.registerEvent(...)` now
|
|
145
|
+
register their hooks as automation triggers via
|
|
146
|
+
`automationTriggerExtensionPoint.registerTrigger(...)`.
|
|
147
|
+
- Frontend pages `IntegrationsPage` and `DeliveryLogsPage` are gone;
|
|
148
|
+
the integration plugin's only remaining UI is connection
|
|
149
|
+
management. Subscription management lives under `/automation/...`.
|
|
150
|
+
- `webhook_subscriptions` and `delivery_logs` tables stay in the
|
|
151
|
+
database for one release as a safety net (no code reads or writes
|
|
152
|
+
them), and will be dropped in a follow-up migration.
|
|
153
|
+
|
|
154
|
+
**New**:
|
|
155
|
+
|
|
156
|
+
- `jira.create_issue`, `teams.post_message`, `webex.post_message`,
|
|
157
|
+
`webhook.send`, `integration-script.run_shell`, and
|
|
158
|
+
`integration-script.run_script` actions registered against the
|
|
159
|
+
Automation Platform with matching `*.message`, `*.delivery`,
|
|
160
|
+
`shell.result`, and `script.result` artifact types. The script
|
|
161
|
+
plugin exposes **two** actions — `run_shell` runs bash via the
|
|
162
|
+
shared `ShellScriptRunner` (Monaco `shell` editor), `run_script`
|
|
163
|
+
runs an ESM module in a Bun subprocess via `EsmScriptRunner`
|
|
164
|
+
(Monaco `typescript` editor + `defineIntegration` helper) — to
|
|
165
|
+
preserve the legacy provider split. `jira.create_issue` keeps the
|
|
166
|
+
dynamic field-mapping dropdown (driven by
|
|
167
|
+
`JIRA_RESOLVERS.FIELD_OPTIONS`).
|
|
168
|
+
- One-time data migration runs on boot in
|
|
169
|
+
`automation-backend.afterPluginsReady`. It reads
|
|
170
|
+
`webhook_subscriptions` via a new service RPC
|
|
171
|
+
`IntegrationApi.listLegacySubscriptions`, translates each row into
|
|
172
|
+
a single-trigger / single-action automation (marked with
|
|
173
|
+
`managed_by = "migrated-subscription:<id>"`), and is idempotent
|
|
174
|
+
across restarts.
|
|
175
|
+
- Failed translations are recorded in a new
|
|
176
|
+
`automation_migration_failures` table and surfaced via
|
|
177
|
+
`AutomationApi.listMigrationFailures` /
|
|
178
|
+
`acknowledgeMigrationFailure` so admins can review and re-create
|
|
179
|
+
failed entries by hand.
|
|
180
|
+
|
|
181
|
+
- Updated dependencies [e2d6f25]
|
|
182
|
+
- Updated dependencies [41c77f4]
|
|
183
|
+
- Updated dependencies [41c77f4]
|
|
184
|
+
- Updated dependencies [e1a2077]
|
|
185
|
+
- Updated dependencies [41c77f4]
|
|
186
|
+
- Updated dependencies [41c77f4]
|
|
187
|
+
- Updated dependencies [41c77f4]
|
|
188
|
+
- Updated dependencies [41c77f4]
|
|
189
|
+
- Updated dependencies [41c77f4]
|
|
190
|
+
- Updated dependencies [41c77f4]
|
|
191
|
+
- Updated dependencies [41c77f4]
|
|
192
|
+
- Updated dependencies [41c77f4]
|
|
193
|
+
- Updated dependencies [6d52276]
|
|
194
|
+
- Updated dependencies [6d52276]
|
|
195
|
+
- Updated dependencies [35bc682]
|
|
196
|
+
- @checkstack/automation-backend@0.2.0
|
|
197
|
+
- @checkstack/incident-backend@1.3.0
|
|
198
|
+
- @checkstack/catalog-backend@1.2.0
|
|
199
|
+
- @checkstack/satellite-backend@0.4.0
|
|
200
|
+
- @checkstack/common@0.12.0
|
|
201
|
+
- @checkstack/backend-api@0.18.0
|
|
202
|
+
- @checkstack/healthcheck-common@1.3.0
|
|
203
|
+
- @checkstack/catalog-common@2.2.3
|
|
204
|
+
- @checkstack/incident-common@1.3.1
|
|
205
|
+
- @checkstack/maintenance-common@1.2.3
|
|
206
|
+
- @checkstack/command-backend@0.1.31
|
|
207
|
+
- @checkstack/gitops-backend@0.3.7
|
|
208
|
+
- @checkstack/gitops-common@0.4.2
|
|
209
|
+
- @checkstack/notification-common@1.2.1
|
|
210
|
+
- @checkstack/signal-common@0.2.5
|
|
211
|
+
- @checkstack/cache-api@0.3.6
|
|
212
|
+
- @checkstack/queue-api@0.3.6
|
|
213
|
+
- @checkstack/cache-utils@0.2.11
|
|
214
|
+
|
|
3
215
|
## 1.2.0
|
|
4
216
|
|
|
5
217
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,23 +14,23 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/backend-api": "0.17.
|
|
18
|
-
"@checkstack/cache-api": "0.3.
|
|
19
|
-
"@checkstack/cache-utils": "0.2.
|
|
20
|
-
"@checkstack/catalog-backend": "1.1.
|
|
17
|
+
"@checkstack/backend-api": "0.17.1",
|
|
18
|
+
"@checkstack/cache-api": "0.3.5",
|
|
19
|
+
"@checkstack/cache-utils": "0.2.10",
|
|
20
|
+
"@checkstack/catalog-backend": "1.1.6",
|
|
21
21
|
"@checkstack/catalog-common": "2.2.2",
|
|
22
|
-
"@checkstack/command-backend": "0.1.
|
|
22
|
+
"@checkstack/command-backend": "0.1.30",
|
|
23
23
|
"@checkstack/common": "0.11.0",
|
|
24
|
-
"@checkstack/gitops-backend": "0.3.
|
|
24
|
+
"@checkstack/gitops-backend": "0.3.6",
|
|
25
25
|
"@checkstack/gitops-common": "0.4.1",
|
|
26
|
-
"@checkstack/healthcheck-common": "1.
|
|
27
|
-
"@checkstack/incident-backend": "1.
|
|
28
|
-
"@checkstack/incident-common": "1.
|
|
29
|
-
"@checkstack/
|
|
26
|
+
"@checkstack/healthcheck-common": "1.2.0",
|
|
27
|
+
"@checkstack/incident-backend": "1.2.0",
|
|
28
|
+
"@checkstack/incident-common": "1.3.0",
|
|
29
|
+
"@checkstack/automation-backend": "0.1.0",
|
|
30
30
|
"@checkstack/maintenance-common": "1.2.2",
|
|
31
31
|
"@checkstack/notification-common": "1.2.0",
|
|
32
|
-
"@checkstack/queue-api": "0.3.
|
|
33
|
-
"@checkstack/satellite-backend": "0.3.
|
|
32
|
+
"@checkstack/queue-api": "0.3.5",
|
|
33
|
+
"@checkstack/satellite-backend": "0.3.6",
|
|
34
34
|
"@checkstack/signal-common": "0.2.4",
|
|
35
35
|
"@hono/zod-validator": "^0.7.6",
|
|
36
36
|
"drizzle-orm": "^0.45.0",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
44
44
|
"@checkstack/scripts": "0.3.3",
|
|
45
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
45
|
+
"@checkstack/test-utils-backend": "0.1.30",
|
|
46
46
|
"@checkstack/tsconfig": "0.0.7",
|
|
47
47
|
"@types/bun": "^1.0.0",
|
|
48
48
|
"@types/tdigest": "^0.1.5",
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behaviour tests for the healthcheck automation triggers + actions.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
5
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
6
|
+
import type { QueueManager } from "@checkstack/queue-api";
|
|
7
|
+
import { createMockLogger } from "@checkstack/test-utils-backend";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
assignmentArtifactType,
|
|
11
|
+
checkFailedTrigger,
|
|
12
|
+
createHealthCheckActions,
|
|
13
|
+
flappingDetectedTrigger,
|
|
14
|
+
healthCheckTriggers,
|
|
15
|
+
systemDegradedTrigger,
|
|
16
|
+
systemHealthChangedTrigger,
|
|
17
|
+
systemHealthyTrigger,
|
|
18
|
+
} from "./automations";
|
|
19
|
+
import { healthCheckHooks } from "./hooks";
|
|
20
|
+
import type { HealthCheckService } from "./service";
|
|
21
|
+
|
|
22
|
+
const logger = createMockLogger() as Logger;
|
|
23
|
+
|
|
24
|
+
const ctxBase = {
|
|
25
|
+
runId: "run-1",
|
|
26
|
+
automationId: "auto-1",
|
|
27
|
+
contextKey: null,
|
|
28
|
+
logger,
|
|
29
|
+
getService: async <T,>(): Promise<T> => {
|
|
30
|
+
throw new Error("not used");
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("healthcheck triggers", () => {
|
|
35
|
+
it("exposes five triggers in a stable order", () => {
|
|
36
|
+
expect(healthCheckTriggers).toHaveLength(5);
|
|
37
|
+
expect(healthCheckTriggers[0]).toBe(
|
|
38
|
+
systemDegradedTrigger as (typeof healthCheckTriggers)[number],
|
|
39
|
+
);
|
|
40
|
+
expect(healthCheckTriggers[1]).toBe(
|
|
41
|
+
systemHealthyTrigger as (typeof healthCheckTriggers)[number],
|
|
42
|
+
);
|
|
43
|
+
expect(healthCheckTriggers[2]).toBe(
|
|
44
|
+
systemHealthChangedTrigger as (typeof healthCheckTriggers)[number],
|
|
45
|
+
);
|
|
46
|
+
expect(healthCheckTriggers[3]).toBe(
|
|
47
|
+
checkFailedTrigger as (typeof healthCheckTriggers)[number],
|
|
48
|
+
);
|
|
49
|
+
expect(healthCheckTriggers[4]).toBe(
|
|
50
|
+
flappingDetectedTrigger as (typeof healthCheckTriggers)[number],
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("validates checkFailed payload and extracts systemId", () => {
|
|
55
|
+
const ok = checkFailedTrigger.payloadSchema.safeParse({
|
|
56
|
+
systemId: "sys-1",
|
|
57
|
+
configurationId: "cfg-1",
|
|
58
|
+
status: "unhealthy",
|
|
59
|
+
timestamp: "2026-05-29T12:00:00Z",
|
|
60
|
+
});
|
|
61
|
+
expect(ok.success).toBe(true);
|
|
62
|
+
expect(
|
|
63
|
+
checkFailedTrigger.contextKey?.({
|
|
64
|
+
systemId: "sys-1",
|
|
65
|
+
configurationId: "cfg-1",
|
|
66
|
+
status: "unhealthy",
|
|
67
|
+
timestamp: "2026-05-29T12:00:00Z",
|
|
68
|
+
}),
|
|
69
|
+
).toBe("sys-1");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("validates flappingDetected payload and requires transitionCount + windowMinutes", () => {
|
|
73
|
+
const ok = flappingDetectedTrigger.payloadSchema.safeParse({
|
|
74
|
+
systemId: "sys-1",
|
|
75
|
+
configurationId: "cfg-1",
|
|
76
|
+
transitionCount: 5,
|
|
77
|
+
windowMinutes: 10,
|
|
78
|
+
timestamp: "2026-05-29T12:00:00Z",
|
|
79
|
+
});
|
|
80
|
+
expect(ok.success).toBe(true);
|
|
81
|
+
|
|
82
|
+
const bad = flappingDetectedTrigger.payloadSchema.safeParse({
|
|
83
|
+
systemId: "sys-1",
|
|
84
|
+
configurationId: "cfg-1",
|
|
85
|
+
timestamp: "2026-05-29T12:00:00Z",
|
|
86
|
+
});
|
|
87
|
+
expect(bad.success).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("extracts systemId as the contextKey on all three", () => {
|
|
91
|
+
const degradedOrChanged = {
|
|
92
|
+
systemId: "sys-1",
|
|
93
|
+
previousStatus: "healthy",
|
|
94
|
+
newStatus: "degraded",
|
|
95
|
+
healthyChecks: 1,
|
|
96
|
+
totalChecks: 2,
|
|
97
|
+
timestamp: "2026-05-29T11:00:00Z",
|
|
98
|
+
} as const;
|
|
99
|
+
const healthy = {
|
|
100
|
+
systemId: "sys-1",
|
|
101
|
+
previousStatus: "degraded",
|
|
102
|
+
healthyChecks: 2,
|
|
103
|
+
totalChecks: 2,
|
|
104
|
+
timestamp: "2026-05-29T11:00:00Z",
|
|
105
|
+
} as const;
|
|
106
|
+
expect(systemDegradedTrigger.contextKey?.(degradedOrChanged)).toBe("sys-1");
|
|
107
|
+
expect(systemHealthyTrigger.contextKey?.(healthy)).toBe("sys-1");
|
|
108
|
+
expect(systemHealthChangedTrigger.contextKey?.(degradedOrChanged)).toBe(
|
|
109
|
+
"sys-1",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("assignmentArtifactType", () => {
|
|
115
|
+
it("validates the canonical assignment artifact", () => {
|
|
116
|
+
const ok = assignmentArtifactType.schema.safeParse({
|
|
117
|
+
systemId: "sys-1",
|
|
118
|
+
configurationId: "cfg-1",
|
|
119
|
+
enabled: true,
|
|
120
|
+
});
|
|
121
|
+
expect(ok.success).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
function makeService(args: {
|
|
126
|
+
setAssignmentEnabledReturn?: boolean;
|
|
127
|
+
}): HealthCheckService & { setMock: ReturnType<typeof mock> } {
|
|
128
|
+
const setMock = mock(
|
|
129
|
+
async (_sysId: string, _cfgId: string, _enabled: boolean) =>
|
|
130
|
+
args.setAssignmentEnabledReturn ?? true,
|
|
131
|
+
);
|
|
132
|
+
return {
|
|
133
|
+
setAssignmentEnabled: setMock,
|
|
134
|
+
setMock,
|
|
135
|
+
} as unknown as HealthCheckService & { setMock: ReturnType<typeof mock> };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface QueueEnqueueRecorder {
|
|
139
|
+
queueManager: QueueManager;
|
|
140
|
+
enqueueMock: ReturnType<typeof mock>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function makeQueueManager(): QueueEnqueueRecorder {
|
|
144
|
+
const enqueueMock = mock(async (_payload: unknown) => "job-id");
|
|
145
|
+
const queue = {
|
|
146
|
+
enqueue: enqueueMock,
|
|
147
|
+
// Other queue methods aren't exercised by the action.
|
|
148
|
+
};
|
|
149
|
+
const queueManager = {
|
|
150
|
+
getQueue: () => queue,
|
|
151
|
+
} as unknown as QueueManager;
|
|
152
|
+
return { queueManager, enqueueMock };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe("healthcheck.run_now", () => {
|
|
156
|
+
it("enqueues a one-off job and emits an enqueued=true artifact", async () => {
|
|
157
|
+
const service = makeService({});
|
|
158
|
+
const { queueManager, enqueueMock } = makeQueueManager();
|
|
159
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
160
|
+
const [runNow] = createHealthCheckActions({
|
|
161
|
+
service,
|
|
162
|
+
queueManager,
|
|
163
|
+
emitHook: emitHook as never,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const result = await runNow!.execute({
|
|
167
|
+
...ctxBase,
|
|
168
|
+
consumedArtifacts: {},
|
|
169
|
+
config: { systemId: "sys-1", configurationId: "cfg-1" } as never,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(result.success).toBe(true);
|
|
173
|
+
if (!result.success) return;
|
|
174
|
+
expect(result.externalId).toBe("sys-1:cfg-1");
|
|
175
|
+
expect(enqueueMock).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(enqueueMock.mock.calls[0]![0]).toEqual({
|
|
177
|
+
configId: "cfg-1",
|
|
178
|
+
systemId: "sys-1",
|
|
179
|
+
});
|
|
180
|
+
// run_now doesn't mutate any DB row → no hook to emit.
|
|
181
|
+
expect(emitHook).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("healthcheck.enable_assignment", () => {
|
|
186
|
+
it("flips enabled=true on the existing row, fires assignmentChanged, and emits the artifact", async () => {
|
|
187
|
+
const service = makeService({ setAssignmentEnabledReturn: true });
|
|
188
|
+
const { queueManager } = makeQueueManager();
|
|
189
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
190
|
+
const [, enable] = createHealthCheckActions({
|
|
191
|
+
service,
|
|
192
|
+
queueManager,
|
|
193
|
+
emitHook: emitHook as never,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const result = await enable!.execute({
|
|
197
|
+
...ctxBase,
|
|
198
|
+
consumedArtifacts: {},
|
|
199
|
+
config: { systemId: "sys-1", configurationId: "cfg-1" } as never,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(result.success).toBe(true);
|
|
203
|
+
if (!result.success) return;
|
|
204
|
+
expect((result.artifact as { enabled: boolean }).enabled).toBe(true);
|
|
205
|
+
expect(service.setMock).toHaveBeenCalledWith("sys-1", "cfg-1", true);
|
|
206
|
+
expect(emitHook).toHaveBeenCalledTimes(1);
|
|
207
|
+
expect(emitHook.mock.calls[0]![0]).toBe(healthCheckHooks.assignmentChanged);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns failure when the assignment row does not exist", async () => {
|
|
211
|
+
const service = makeService({ setAssignmentEnabledReturn: false });
|
|
212
|
+
const { queueManager } = makeQueueManager();
|
|
213
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
214
|
+
const [, enable] = createHealthCheckActions({
|
|
215
|
+
service,
|
|
216
|
+
queueManager,
|
|
217
|
+
emitHook: emitHook as never,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const result = await enable!.execute({
|
|
221
|
+
...ctxBase,
|
|
222
|
+
consumedArtifacts: {},
|
|
223
|
+
config: { systemId: "sys-1", configurationId: "missing" } as never,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(result.success).toBe(false);
|
|
227
|
+
if (result.success) return;
|
|
228
|
+
expect(result.error).toMatch(/Assignment not found/);
|
|
229
|
+
expect(emitHook).not.toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("healthcheck.disable_assignment", () => {
|
|
234
|
+
it("flips enabled=false on the existing row and emits the artifact", async () => {
|
|
235
|
+
const service = makeService({ setAssignmentEnabledReturn: true });
|
|
236
|
+
const { queueManager } = makeQueueManager();
|
|
237
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
238
|
+
const [, , disable] = createHealthCheckActions({
|
|
239
|
+
service,
|
|
240
|
+
queueManager,
|
|
241
|
+
emitHook: emitHook as never,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = await disable!.execute({
|
|
245
|
+
...ctxBase,
|
|
246
|
+
consumedArtifacts: {},
|
|
247
|
+
config: { systemId: "sys-1", configurationId: "cfg-1" } as never,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(result.success).toBe(true);
|
|
251
|
+
if (!result.success) return;
|
|
252
|
+
expect((result.artifact as { enabled: boolean }).enabled).toBe(false);
|
|
253
|
+
expect(service.setMock).toHaveBeenCalledWith("sys-1", "cfg-1", false);
|
|
254
|
+
});
|
|
255
|
+
});
|