@checkstack/healthcheck-backend 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +541 -0
  2. package/drizzle/0015_quiet_meggan.sql +12 -0
  3. package/drizzle/0016_complex_maginty.sql +1 -0
  4. package/drizzle/0017_pretty_caretaker.sql +1 -0
  5. package/drizzle/meta/0015_snapshot.json +764 -0
  6. package/drizzle/meta/0016_snapshot.json +644 -0
  7. package/drizzle/meta/0017_snapshot.json +563 -0
  8. package/drizzle/meta/_journal.json +21 -0
  9. package/package.json +24 -21
  10. package/src/automations.test.ts +234 -0
  11. package/src/automations.ts +342 -0
  12. package/src/collector-script-test.test.ts +236 -0
  13. package/src/collector-script-test.ts +221 -0
  14. package/src/health-entity.test.ts +698 -0
  15. package/src/health-entity.ts +369 -0
  16. package/src/health-state.test.ts +115 -0
  17. package/src/health-state.ts +333 -0
  18. package/src/healthcheck-gitops-kinds.test.ts +6 -32
  19. package/src/healthcheck-gitops-kinds.ts +4 -19
  20. package/src/hooks.test.ts +19 -6
  21. package/src/hooks.ts +38 -28
  22. package/src/index.ts +150 -98
  23. package/src/queue-executor.test.ts +137 -0
  24. package/src/queue-executor.ts +282 -380
  25. package/src/retention-job.ts +65 -1
  26. package/src/retention-state-transitions.test.ts +49 -0
  27. package/src/router.test.ts +18 -0
  28. package/src/router.ts +56 -1
  29. package/src/schema.ts +34 -54
  30. package/src/service-assignments.test.ts +184 -0
  31. package/src/service-notification-policy.test.ts +28 -71
  32. package/src/service.ts +154 -0
  33. package/src/state-transitions.test.ts +126 -0
  34. package/src/state-transitions.ts +112 -0
  35. package/tsconfig.json +12 -3
  36. package/src/auto-incident-close-job.ts +0 -164
  37. package/src/auto-incident.test.ts +0 -196
  38. package/src/auto-incident.ts +0 -332
@@ -0,0 +1,234 @@
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
+ healthCheckTriggers,
14
+ systemDegradedTrigger,
15
+ systemHealthChangedTrigger,
16
+ systemHealthyTrigger,
17
+ } from "./automations";
18
+ import { healthCheckHooks } from "./hooks";
19
+ import type { HealthCheckService } from "./service";
20
+
21
+ const logger = createMockLogger() as Logger;
22
+
23
+ const ctxBase = {
24
+ runId: "run-1",
25
+ automationId: "auto-1",
26
+ contextKey: null,
27
+ logger,
28
+ getService: async <T,>(): Promise<T> => {
29
+ throw new Error("not used");
30
+ },
31
+ };
32
+
33
+ describe("healthcheck triggers", () => {
34
+ it("exposes four triggers in a stable order", () => {
35
+ expect(healthCheckTriggers).toHaveLength(4);
36
+ expect(healthCheckTriggers[0]).toBe(
37
+ systemDegradedTrigger as unknown as (typeof healthCheckTriggers)[number],
38
+ );
39
+ expect(healthCheckTriggers[1]).toBe(
40
+ systemHealthyTrigger as unknown as (typeof healthCheckTriggers)[number],
41
+ );
42
+ expect(healthCheckTriggers[2]).toBe(
43
+ systemHealthChangedTrigger as unknown as (typeof healthCheckTriggers)[number],
44
+ );
45
+ expect(healthCheckTriggers[3]).toBe(
46
+ checkFailedTrigger as unknown as (typeof healthCheckTriggers)[number],
47
+ );
48
+ });
49
+
50
+ it("validates checkFailed payload and extracts systemId", () => {
51
+ const ok = checkFailedTrigger.payloadSchema.safeParse({
52
+ systemId: "sys-1",
53
+ configurationId: "cfg-1",
54
+ status: "unhealthy",
55
+ timestamp: "2026-05-29T12:00:00Z",
56
+ });
57
+ expect(ok.success).toBe(true);
58
+ expect(
59
+ checkFailedTrigger.contextKey?.({
60
+ systemId: "sys-1",
61
+ configurationId: "cfg-1",
62
+ status: "unhealthy",
63
+ timestamp: "2026-05-29T12:00:00Z",
64
+ }),
65
+ ).toBe("sys-1");
66
+ });
67
+
68
+
69
+ it("extracts systemId as the contextKey on all three", () => {
70
+ const degradedOrChanged = {
71
+ systemId: "sys-1",
72
+ previousStatus: "healthy",
73
+ newStatus: "degraded",
74
+ healthyChecks: 1,
75
+ totalChecks: 2,
76
+ timestamp: "2026-05-29T11:00:00Z",
77
+ } as const;
78
+ const healthy = {
79
+ systemId: "sys-1",
80
+ previousStatus: "degraded",
81
+ healthyChecks: 2,
82
+ totalChecks: 2,
83
+ timestamp: "2026-05-29T11:00:00Z",
84
+ } as const;
85
+ expect(systemDegradedTrigger.contextKey?.(degradedOrChanged)).toBe("sys-1");
86
+ expect(systemHealthyTrigger.contextKey?.(healthy)).toBe("sys-1");
87
+ expect(systemHealthChangedTrigger.contextKey?.(degradedOrChanged)).toBe(
88
+ "sys-1",
89
+ );
90
+ });
91
+ });
92
+
93
+ describe("assignmentArtifactType", () => {
94
+ it("validates the canonical assignment artifact", () => {
95
+ const ok = assignmentArtifactType.schema.safeParse({
96
+ systemId: "sys-1",
97
+ configurationId: "cfg-1",
98
+ enabled: true,
99
+ });
100
+ expect(ok.success).toBe(true);
101
+ });
102
+ });
103
+
104
+ function makeService(args: {
105
+ setAssignmentEnabledReturn?: boolean;
106
+ }): HealthCheckService & { setMock: ReturnType<typeof mock> } {
107
+ const setMock = mock(
108
+ async (_sysId: string, _cfgId: string, _enabled: boolean) =>
109
+ args.setAssignmentEnabledReturn ?? true,
110
+ );
111
+ return {
112
+ setAssignmentEnabled: setMock,
113
+ setMock,
114
+ } as unknown as HealthCheckService & { setMock: ReturnType<typeof mock> };
115
+ }
116
+
117
+ interface QueueEnqueueRecorder {
118
+ queueManager: QueueManager;
119
+ enqueueMock: ReturnType<typeof mock>;
120
+ }
121
+
122
+ function makeQueueManager(): QueueEnqueueRecorder {
123
+ const enqueueMock = mock(async (_payload: unknown) => "job-id");
124
+ const queue = {
125
+ enqueue: enqueueMock,
126
+ // Other queue methods aren't exercised by the action.
127
+ };
128
+ const queueManager = {
129
+ getQueue: () => queue,
130
+ } as unknown as QueueManager;
131
+ return { queueManager, enqueueMock };
132
+ }
133
+
134
+ describe("healthcheck.run_now", () => {
135
+ it("enqueues a one-off job and emits an enqueued=true artifact", async () => {
136
+ const service = makeService({});
137
+ const { queueManager, enqueueMock } = makeQueueManager();
138
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
139
+ const [runNow] = createHealthCheckActions({
140
+ service,
141
+ queueManager,
142
+ emitHook: emitHook as never,
143
+ });
144
+
145
+ const result = await runNow!.execute({
146
+ ...ctxBase,
147
+ consumedArtifacts: {},
148
+ config: { systemId: "sys-1", configurationId: "cfg-1" } as never,
149
+ });
150
+
151
+ expect(result.success).toBe(true);
152
+ if (!result.success) return;
153
+ expect(result.externalId).toBe("sys-1:cfg-1");
154
+ expect(enqueueMock).toHaveBeenCalledTimes(1);
155
+ expect(enqueueMock.mock.calls[0]![0]).toEqual({
156
+ configId: "cfg-1",
157
+ systemId: "sys-1",
158
+ });
159
+ // run_now doesn't mutate any DB row → no hook to emit.
160
+ expect(emitHook).not.toHaveBeenCalled();
161
+ });
162
+ });
163
+
164
+ describe("healthcheck.enable_assignment", () => {
165
+ it("flips enabled=true on the existing row, fires assignmentChanged, and emits the artifact", async () => {
166
+ const service = makeService({ setAssignmentEnabledReturn: true });
167
+ const { queueManager } = makeQueueManager();
168
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
169
+ const [, enable] = createHealthCheckActions({
170
+ service,
171
+ queueManager,
172
+ emitHook: emitHook as never,
173
+ });
174
+
175
+ const result = await enable!.execute({
176
+ ...ctxBase,
177
+ consumedArtifacts: {},
178
+ config: { systemId: "sys-1", configurationId: "cfg-1" } as never,
179
+ });
180
+
181
+ expect(result.success).toBe(true);
182
+ if (!result.success) return;
183
+ expect((result.artifact as { enabled: boolean }).enabled).toBe(true);
184
+ expect(service.setMock).toHaveBeenCalledWith("sys-1", "cfg-1", true);
185
+ expect(emitHook).toHaveBeenCalledTimes(1);
186
+ expect(emitHook.mock.calls[0]![0]).toBe(healthCheckHooks.assignmentChanged);
187
+ });
188
+
189
+ it("returns failure when the assignment row does not exist", async () => {
190
+ const service = makeService({ setAssignmentEnabledReturn: false });
191
+ const { queueManager } = makeQueueManager();
192
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
193
+ const [, enable] = createHealthCheckActions({
194
+ service,
195
+ queueManager,
196
+ emitHook: emitHook as never,
197
+ });
198
+
199
+ const result = await enable!.execute({
200
+ ...ctxBase,
201
+ consumedArtifacts: {},
202
+ config: { systemId: "sys-1", configurationId: "missing" } as never,
203
+ });
204
+
205
+ expect(result.success).toBe(false);
206
+ if (result.success) return;
207
+ expect(result.error).toMatch(/Assignment not found/);
208
+ expect(emitHook).not.toHaveBeenCalled();
209
+ });
210
+ });
211
+
212
+ describe("healthcheck.disable_assignment", () => {
213
+ it("flips enabled=false on the existing row and emits the artifact", async () => {
214
+ const service = makeService({ setAssignmentEnabledReturn: true });
215
+ const { queueManager } = makeQueueManager();
216
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
217
+ const [, , disable] = createHealthCheckActions({
218
+ service,
219
+ queueManager,
220
+ emitHook: emitHook as never,
221
+ });
222
+
223
+ const result = await disable!.execute({
224
+ ...ctxBase,
225
+ consumedArtifacts: {},
226
+ config: { systemId: "sys-1", configurationId: "cfg-1" } as never,
227
+ });
228
+
229
+ expect(result.success).toBe(true);
230
+ if (!result.success) return;
231
+ expect((result.artifact as { enabled: boolean }).enabled).toBe(false);
232
+ expect(service.setMock).toHaveBeenCalledWith("sys-1", "cfg-1", false);
233
+ });
234
+ });
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Healthcheck triggers + actions registered with the Automation Platform.
3
+ *
4
+ * Triggers:
5
+ * - `healthcheck.system.degraded` — existing directional hook
6
+ * - `healthcheck.system.healthy` — existing directional hook
7
+ * - `healthcheck.system.health_changed` — new umbrella hook,
8
+ * fires on every aggregated-health transition. Carries both the
9
+ * previous and new statuses so subscribers don't have to listen
10
+ * to two hooks and coalesce themselves.
11
+ *
12
+ * Actions:
13
+ * - `healthcheck.run_now`: enqueue a one-off run of a specific
14
+ * `(systemId, configurationId)` assignment. The recurring
15
+ * schedule keeps ticking; this just nudges the queue.
16
+ * - `healthcheck.enable_assignment` /
17
+ * `healthcheck.disable_assignment`: flip the `enabled` flag on an
18
+ * existing assignment via `service.setAssignmentEnabled`. Emits
19
+ * the existing `assignmentChanged` hook so the satellite-config
20
+ * relay picks up the change.
21
+ *
22
+ * Mutation actions emit hooks themselves (via the `emitHook` factory
23
+ * dep) so downstream automations + caches react the same way as
24
+ * RPC-driven mutations.
25
+ */
26
+ import { z } from "zod";
27
+ import { Versioned, type Hook } from "@checkstack/backend-api";
28
+ import type { QueueManager } from "@checkstack/queue-api";
29
+ import type {
30
+ ActionDefinition,
31
+ TriggerDefinition,
32
+ } from "@checkstack/automation-backend";
33
+ import { makeEntityDrivenTriggerSetup } from "@checkstack/automation-backend";
34
+ import { HealthCheckStatusSchema } from "@checkstack/healthcheck-common";
35
+
36
+ import { healthCheckHooks } from "./hooks";
37
+ import {
38
+ HEALTH_CHECK_QUEUE,
39
+ type HealthCheckJobPayload,
40
+ } from "./queue-executor";
41
+ import type { HealthCheckService } from "./service";
42
+
43
+ // ─── Payload schemas — match the hook payloads exactly ─────────────────
44
+
45
+ const systemDegradedPayloadSchema = z.object({
46
+ systemId: z.string(),
47
+ systemName: z.string().optional(),
48
+ previousStatus: HealthCheckStatusSchema,
49
+ newStatus: HealthCheckStatusSchema,
50
+ healthyChecks: z.number(),
51
+ totalChecks: z.number(),
52
+ timestamp: z.string(),
53
+ });
54
+
55
+ const systemHealthyPayloadSchema = z.object({
56
+ systemId: z.string(),
57
+ systemName: z.string().optional(),
58
+ previousStatus: HealthCheckStatusSchema,
59
+ healthyChecks: z.number(),
60
+ totalChecks: z.number(),
61
+ timestamp: z.string(),
62
+ });
63
+
64
+ const systemHealthChangedPayloadSchema = z.object({
65
+ systemId: z.string(),
66
+ systemName: z.string().optional(),
67
+ previousStatus: HealthCheckStatusSchema,
68
+ newStatus: HealthCheckStatusSchema,
69
+ healthyChecks: z.number(),
70
+ totalChecks: z.number(),
71
+ timestamp: z.string(),
72
+ });
73
+
74
+ const checkFailedPayloadSchema = z.object({
75
+ systemId: z.string(),
76
+ configurationId: z.string(),
77
+ status: HealthCheckStatusSchema,
78
+ latencyMs: z.number().optional(),
79
+ result: z.record(z.string(), z.unknown()).optional(),
80
+ timestamp: z.string(),
81
+ });
82
+
83
+ // ─── Triggers ──────────────────────────────────────────────────────────
84
+
85
+ export const systemDegradedTrigger: TriggerDefinition<
86
+ z.infer<typeof systemDegradedPayloadSchema>
87
+ > = {
88
+ id: "system_degraded",
89
+ displayName: "System Health Degraded",
90
+ description:
91
+ "Fires when a system's health transitions from healthy to degraded/unhealthy",
92
+ category: "Health",
93
+ icon: "HeartPulse",
94
+ payloadSchema: systemDegradedPayloadSchema,
95
+ // Entity-driven (§10.3): fired by the `health` entity change deriver via
96
+ // Stage-1 routing, not a hook. No-op setup keeps it in the editor catalog.
97
+ setup: makeEntityDrivenTriggerSetup<
98
+ z.infer<typeof systemDegradedPayloadSchema>
99
+ >(),
100
+ contextKey: (p) => p.systemId,
101
+ contextKeyLabel: "system",
102
+ };
103
+
104
+ export const systemHealthyTrigger: TriggerDefinition<
105
+ z.infer<typeof systemHealthyPayloadSchema>
106
+ > = {
107
+ id: "system_healthy",
108
+ displayName: "System Health Restored",
109
+ description: "Fires when a system's health recovers to healthy",
110
+ category: "Health",
111
+ icon: "HeartPulse",
112
+ payloadSchema: systemHealthyPayloadSchema,
113
+ // Entity-driven (§10.3): fired by the `health` entity change deriver.
114
+ setup: makeEntityDrivenTriggerSetup<
115
+ z.infer<typeof systemHealthyPayloadSchema>
116
+ >(),
117
+ contextKey: (p) => p.systemId,
118
+ contextKeyLabel: "system",
119
+ };
120
+
121
+ export const systemHealthChangedTrigger: TriggerDefinition<
122
+ z.infer<typeof systemHealthChangedPayloadSchema>
123
+ > = {
124
+ id: "system_health_changed",
125
+ displayName: "System Health Changed",
126
+ description:
127
+ "Fires on every aggregated-health transition — carries previous + new status",
128
+ category: "Health",
129
+ icon: "HeartPulse",
130
+ payloadSchema: systemHealthChangedPayloadSchema,
131
+ // Entity-driven (§10.3): fired by the `health` entity change deriver.
132
+ setup: makeEntityDrivenTriggerSetup<
133
+ z.infer<typeof systemHealthChangedPayloadSchema>
134
+ >(),
135
+ contextKey: (p) => p.systemId,
136
+ contextKeyLabel: "system",
137
+ };
138
+
139
+ export const checkFailedTrigger: TriggerDefinition<
140
+ z.infer<typeof checkFailedPayloadSchema>
141
+ > = {
142
+ id: "check_failed",
143
+ displayName: "Health Check Failed",
144
+ description:
145
+ "Fires when an individual check run completes with a non-`healthy` status",
146
+ category: "Health",
147
+ icon: "TriangleAlert",
148
+ payloadSchema: checkFailedPayloadSchema,
149
+ hook: healthCheckHooks.checkFailed,
150
+ contextKey: (p) => p.systemId,
151
+ contextKeyLabel: "system",
152
+ };
153
+
154
+ // The flapping trigger + its `flapping_detected` hook were removed. Flapping
155
+ // is now detected in the automation engine by a windowed-count gate on the
156
+ // `system_health_changed` trigger (raw change event + `filter` +
157
+ // `window: { count, minutes, refire: "once" }`) — no per-derived event.
158
+
159
+ // Triggers carry heterogeneous config types (all healthcheck triggers are
160
+ // currently config-less). The registry accepts the `<unknown, unknown>` shape
161
+ // and re-validates config against each trigger's own `configSchema` at load,
162
+ // so the registration array is widened here — mirroring
163
+ // `registerBuiltinTriggers` in automation-backend.
164
+ export const healthCheckTriggers: TriggerDefinition<unknown, unknown>[] = [
165
+ systemDegradedTrigger as unknown as TriggerDefinition<unknown, unknown>,
166
+ systemHealthyTrigger as unknown as TriggerDefinition<unknown, unknown>,
167
+ systemHealthChangedTrigger as unknown as TriggerDefinition<unknown, unknown>,
168
+ checkFailedTrigger as unknown as TriggerDefinition<unknown, unknown>,
169
+ ];
170
+
171
+ // ─── Action configs ────────────────────────────────────────────────────
172
+
173
+ const runNowConfigSchema = z.object({
174
+ systemId: z.string().min(1).describe("Target system id"),
175
+ configurationId: z
176
+ .string()
177
+ .min(1)
178
+ .describe("Target health-check configuration id"),
179
+ });
180
+
181
+ const assignmentToggleConfigSchema = z.object({
182
+ systemId: z.string().min(1),
183
+ configurationId: z.string().min(1),
184
+ });
185
+
186
+ // ─── Artifact ──────────────────────────────────────────────────────────
187
+
188
+ const assignmentArtifactSchema = z.object({
189
+ systemId: z.string(),
190
+ configurationId: z.string(),
191
+ enabled: z.boolean().optional(),
192
+ enqueued: z.boolean().optional(),
193
+ });
194
+
195
+ export type AssignmentArtifact = z.infer<typeof assignmentArtifactSchema>;
196
+
197
+ export const assignmentArtifactType = {
198
+ id: "assignment",
199
+ displayName: "Healthcheck Assignment",
200
+ description:
201
+ "Identifies the system↔configuration assignment touched by an automation action",
202
+ schema: assignmentArtifactSchema,
203
+ } as const;
204
+
205
+ // ─── Action factory ────────────────────────────────────────────────────
206
+
207
+ export interface HealthCheckActionDeps {
208
+ service: HealthCheckService;
209
+ queueManager: QueueManager;
210
+ emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
211
+ }
212
+
213
+ export function createHealthCheckActions(
214
+ deps: HealthCheckActionDeps,
215
+ ): ActionDefinition<unknown, unknown>[] {
216
+ const runNow: ActionDefinition<
217
+ z.infer<typeof runNowConfigSchema>,
218
+ AssignmentArtifact
219
+ > = {
220
+ id: "run_now",
221
+ displayName: "Run Health Check Now",
222
+ description:
223
+ "Enqueue a one-off run of the given assignment. Doesn't disturb the recurring schedule.",
224
+ category: "Health",
225
+ icon: "Play",
226
+ config: new Versioned({ version: 1, schema: runNowConfigSchema }),
227
+ produces: "healthcheck.assignment",
228
+ execute: async ({ config, logger }) => {
229
+ const queue = deps.queueManager.getQueue<HealthCheckJobPayload>(
230
+ HEALTH_CHECK_QUEUE,
231
+ );
232
+ await queue.enqueue({
233
+ configId: config.configurationId,
234
+ systemId: config.systemId,
235
+ });
236
+ logger.info(
237
+ `Automation enqueued run for ${config.systemId}:${config.configurationId}`,
238
+ );
239
+ return {
240
+ success: true,
241
+ externalId: `${config.systemId}:${config.configurationId}`,
242
+ artifact: {
243
+ systemId: config.systemId,
244
+ configurationId: config.configurationId,
245
+ enqueued: true,
246
+ },
247
+ };
248
+ },
249
+ };
250
+
251
+ const enableAssignment: ActionDefinition<
252
+ z.infer<typeof assignmentToggleConfigSchema>,
253
+ AssignmentArtifact
254
+ > = {
255
+ id: "enable_assignment",
256
+ displayName: "Enable Health Check Assignment",
257
+ description:
258
+ "Flip the `enabled` flag on an existing system↔configuration assignment to true.",
259
+ category: "Health",
260
+ icon: "Power",
261
+ config: new Versioned({ version: 1, schema: assignmentToggleConfigSchema }),
262
+ produces: "healthcheck.assignment",
263
+ execute: async ({ config, logger }) => {
264
+ const updated = await deps.service.setAssignmentEnabled(
265
+ config.systemId,
266
+ config.configurationId,
267
+ true,
268
+ );
269
+ if (!updated) {
270
+ return {
271
+ success: false,
272
+ error: `Assignment not found: ${config.systemId} ↔ ${config.configurationId}`,
273
+ };
274
+ }
275
+ await deps.emitHook(healthCheckHooks.assignmentChanged, {
276
+ systemId: config.systemId,
277
+ configurationId: config.configurationId,
278
+ });
279
+ logger.info(
280
+ `Automation enabled assignment ${config.systemId}:${config.configurationId}`,
281
+ );
282
+ return {
283
+ success: true,
284
+ externalId: `${config.systemId}:${config.configurationId}`,
285
+ artifact: {
286
+ systemId: config.systemId,
287
+ configurationId: config.configurationId,
288
+ enabled: true,
289
+ },
290
+ };
291
+ },
292
+ };
293
+
294
+ const disableAssignment: ActionDefinition<
295
+ z.infer<typeof assignmentToggleConfigSchema>,
296
+ AssignmentArtifact
297
+ > = {
298
+ id: "disable_assignment",
299
+ displayName: "Disable Health Check Assignment",
300
+ description:
301
+ "Flip the `enabled` flag on an existing system↔configuration assignment to false.",
302
+ category: "Health",
303
+ icon: "PowerOff",
304
+ config: new Versioned({ version: 1, schema: assignmentToggleConfigSchema }),
305
+ produces: "healthcheck.assignment",
306
+ execute: async ({ config, logger }) => {
307
+ const updated = await deps.service.setAssignmentEnabled(
308
+ config.systemId,
309
+ config.configurationId,
310
+ false,
311
+ );
312
+ if (!updated) {
313
+ return {
314
+ success: false,
315
+ error: `Assignment not found: ${config.systemId} ↔ ${config.configurationId}`,
316
+ };
317
+ }
318
+ await deps.emitHook(healthCheckHooks.assignmentChanged, {
319
+ systemId: config.systemId,
320
+ configurationId: config.configurationId,
321
+ });
322
+ logger.info(
323
+ `Automation disabled assignment ${config.systemId}:${config.configurationId}`,
324
+ );
325
+ return {
326
+ success: true,
327
+ externalId: `${config.systemId}:${config.configurationId}`,
328
+ artifact: {
329
+ systemId: config.systemId,
330
+ configurationId: config.configurationId,
331
+ enabled: false,
332
+ },
333
+ };
334
+ },
335
+ };
336
+
337
+ return [
338
+ runNow as ActionDefinition<unknown, unknown>,
339
+ enableAssignment as ActionDefinition<unknown, unknown>,
340
+ disableAssignment as ActionDefinition<unknown, unknown>,
341
+ ];
342
+ }