@checkstack/maintenance-backend 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,19 @@
1
1
  /**
2
- * Maintenance triggers + actions registered with the Automation Platform.
2
+ * Maintenance actions registered with the Automation Platform.
3
3
  *
4
- * Triggers re-expose `maintenanceHooks` as automation entry points
5
- * (`maintenance.created`, `maintenance.updated`). The existing index
6
- * registered these inline; this module owns them now alongside the
7
- * actions so the plugin's automation surface lives in one place.
4
+ * The `maintenance.created` / `maintenance.updated` entry points used to be
5
+ * hook-backed triggers re-exposing `maintenanceHooks`. Phase 4 (reactive
6
+ * automation engine §10.2) migrated the maintenance domain onto the entity
7
+ * state machine, and the domain is now a Model-B PLUGIN-BACKED entity: the
8
+ * `maintenances` table IS the current-state storage. The triggers + their
9
+ * hooks are removed, and the same qualified trigger event ids are derived
10
+ * from `maintenance` entity changes (see `./entity.ts`). Mutation actions
11
+ * therefore drive the REAL write through `handle.mutate` (the write runs
12
+ * inside `apply`) instead of emitting a hook; the deriver re-fires the
13
+ * equivalent trigger events for downstream automations.
8
14
  *
9
15
  * Actions wrap `MaintenanceService` for `create`, `update`, and
10
- * `add_update`. The catalog Phase-9 chunk deferred two additional
11
- * "system-shaped" actions to this chunk:
16
+ * `add_update`, plus two "system-shaped" actions:
12
17
  *
13
18
  * - `set_system`: schedule a maintenance window that starts now and
14
19
  * covers a single system, for a given duration. The convenient
@@ -16,60 +21,77 @@
16
21
  * - `clear_system`: close every active/scheduled maintenance that
17
22
  * covers a given system. The convenient "let it back into rotation
18
23
  * even if maintenance was over-scheduled" operation.
19
- *
20
- * Mutation actions emit the matching hooks themselves (via the
21
- * `emitHook` factory dep) so downstream automations + caches react
22
- * the same way they do when the mutation comes in via RPC.
23
24
  */
24
25
  import { z } from "zod";
25
- import { Versioned, type Hook } from "@checkstack/backend-api";
26
+ import { Versioned } from "@checkstack/backend-api";
26
27
  import type {
27
28
  ActionDefinition,
29
+ EntityHandle,
28
30
  TriggerDefinition,
29
31
  } from "@checkstack/automation-backend";
32
+ import { makeEntityDrivenTriggerSetup } from "@checkstack/automation-backend";
33
+ import { SYSTEM_ACTOR } from "@checkstack/common";
30
34
  import {
31
35
  MaintenanceStatusEnum,
32
36
  type MaintenanceStatus,
33
37
  } from "@checkstack/maintenance-common";
34
38
 
35
- import { maintenanceHooks } from "./hooks";
39
+ import type {
40
+ ActionRunScope,
41
+ EntityMutationOpts,
42
+ } from "@checkstack/automation-backend";
36
43
  import type { MaintenanceService } from "./service";
44
+ import {
45
+ toMaintenanceEntityState,
46
+ writeMaintenanceEntity,
47
+ type MaintenanceEntityState,
48
+ } from "./entity";
37
49
 
38
- // ─── Payload schemas ───────────────────────────────────────────────────
39
-
50
+ // ─── Triggers ──────────────────────────────────────────────────────────
51
+ //
52
+ // These two triggers are ENTITY-DRIVEN (reactive automation engine §10.2): the
53
+ // `maintenance` entity's change deriver fires `maintenance.created` /
54
+ // `maintenance.updated` via Stage-1 routing, so they no longer subscribe to a
55
+ // hook. A no-op `setup` (`makeEntityDrivenTriggerSetup`) keeps them in the
56
+ // editor's trigger catalog (and payload-introspectable) without re-introducing
57
+ // a hook — mirroring how the incident / catalog / dependency / healthcheck
58
+ // domains kept their registrations after migrating. The runtime
59
+ // `trigger.payload` matches these schemas via the `maintenanceChangeToPayload`
60
+ // mapper registered alongside the deriver.
61
+ //
62
+ // The reactive `maintenance` entity state is `{ status, systemIds, startAt,
63
+ // endAt }`. The descriptive fields the old hook carried (`title`,
64
+ // `description`) are NOT derivable from an entity change, so they are OMITTED
65
+ // from the entity-driven payload; the schemas declare only what the mapper
66
+ // produces.
40
67
  const maintenanceCreatedPayloadSchema = z.object({
41
68
  maintenanceId: z.string(),
42
- systemIds: z.array(z.string()),
43
- title: z.string(),
44
- description: z.string().optional(),
45
69
  status: MaintenanceStatusEnum,
70
+ systemIds: z.array(z.string()),
46
71
  startAt: z.string(),
47
72
  endAt: z.string(),
48
73
  });
49
74
 
50
75
  const maintenanceUpdatedPayloadSchema = z.object({
51
76
  maintenanceId: z.string(),
52
- systemIds: z.array(z.string()),
53
- title: z.string(),
54
- description: z.string().optional(),
55
77
  status: MaintenanceStatusEnum,
78
+ systemIds: z.array(z.string()),
56
79
  startAt: z.string(),
57
80
  endAt: z.string(),
58
- action: z.enum(["updated", "closed"]),
59
81
  });
60
82
 
61
- // ─── Triggers ──────────────────────────────────────────────────────────
62
-
63
83
  export const maintenanceCreatedTrigger: TriggerDefinition<
64
84
  z.infer<typeof maintenanceCreatedPayloadSchema>
65
85
  > = {
66
86
  id: "created",
67
87
  displayName: "Maintenance Created",
68
- description: "Fired when a new maintenance is scheduled",
88
+ description: "Fires when a new maintenance window is scheduled",
69
89
  category: "Maintenance",
70
90
  icon: "Wrench",
71
91
  payloadSchema: maintenanceCreatedPayloadSchema,
72
- hook: maintenanceHooks.maintenanceCreated,
92
+ setup: makeEntityDrivenTriggerSetup<
93
+ z.infer<typeof maintenanceCreatedPayloadSchema>
94
+ >(),
73
95
  contextKey: (p) => p.maintenanceId,
74
96
  };
75
97
 
@@ -78,19 +100,43 @@ export const maintenanceUpdatedTrigger: TriggerDefinition<
78
100
  > = {
79
101
  id: "updated",
80
102
  displayName: "Maintenance Updated",
81
- description: "Fired when a maintenance is updated or closed",
103
+ description:
104
+ "Fires when a maintenance window's status, schedule, or affected systems change",
82
105
  category: "Maintenance",
83
106
  icon: "Wrench",
84
107
  payloadSchema: maintenanceUpdatedPayloadSchema,
85
- hook: maintenanceHooks.maintenanceUpdated,
108
+ setup: makeEntityDrivenTriggerSetup<
109
+ z.infer<typeof maintenanceUpdatedPayloadSchema>
110
+ >(),
86
111
  contextKey: (p) => p.maintenanceId,
87
112
  };
88
113
 
114
+ /**
115
+ * All maintenance triggers as a heterogeneous list. Typed as
116
+ * `TriggerDefinition<unknown>[]` so the array can be iterated in the plugin
117
+ * entry without TypeScript collapsing the union to a single payload shape.
118
+ */
89
119
  export const maintenanceTriggers: TriggerDefinition<unknown>[] = [
90
120
  maintenanceCreatedTrigger as TriggerDefinition<unknown>,
91
121
  maintenanceUpdatedTrigger as TriggerDefinition<unknown>,
92
122
  ];
93
123
 
124
+ /**
125
+ * Mutation opts for an action-originated entity write: the run id (so
126
+ * run-secret masking applies to the persisted state — reactive automation
127
+ * engine §3.5) + the run's actor (so the derived change event carries the
128
+ * same actor the firing trigger had).
129
+ */
130
+ function mutationOpts(args: {
131
+ runId: string;
132
+ scope?: ActionRunScope;
133
+ }): EntityMutationOpts {
134
+ return {
135
+ runId: args.runId,
136
+ actor: args.scope?.trigger.actor ?? SYSTEM_ACTOR,
137
+ };
138
+ }
139
+
94
140
  // ─── Action configs ────────────────────────────────────────────────────
95
141
 
96
142
  const createConfigSchema = z.object({
@@ -166,7 +212,14 @@ export const maintenanceArtifactType = {
166
212
 
167
213
  export interface MaintenanceActionDeps {
168
214
  service: MaintenanceService;
169
- emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
215
+ /**
216
+ * Reactive `maintenance` entity handle (PLUGIN-BACKED, §10.2). Driving a
217
+ * window's write through `handle.mutate` (the REAL write runs inside
218
+ * `apply`, instead of emitting the old `maintenance.created`/`.updated`
219
+ * hooks) is what re-fires the equivalent trigger events for downstream
220
+ * automations via the change-deriver.
221
+ */
222
+ entityHandle: EntityHandle<MaintenanceEntityState>;
170
223
  /**
171
224
  * Override for `Date.now()`. Only used by `set_system` to compute
172
225
  * `endAt = now + durationMinutes`. Tests inject a fixed clock; the
@@ -208,25 +261,33 @@ export function createMaintenanceActions(
208
261
  icon: "Wrench",
209
262
  config: new Versioned({ version: 1, schema: createConfigSchema }),
210
263
  produces: "maintenance.window",
211
- execute: async ({ config, logger }) => {
212
- const created = await deps.service.createMaintenance({
213
- title: config.title,
214
- description: config.description,
215
- systemIds: config.systemIds,
216
- startAt: new Date(config.startAt),
217
- endAt: new Date(config.endAt),
218
- suppressNotifications: config.suppressNotifications,
264
+ execute: async ({ config, logger, runId, scope }) => {
265
+ // Drive the create through the reactive `maintenance` entity (§10.2):
266
+ // the REAL write runs inside `apply` and the deriver fires
267
+ // `maintenance.created`. The id is generated up front so the create's
268
+ // `prev` snapshot reads the not-yet-existing row as absent.
269
+ const maintenanceId = crypto.randomUUID();
270
+ let created!: Awaited<ReturnType<typeof deps.service.createMaintenance>>;
271
+ await writeMaintenanceEntity({
272
+ handle: deps.entityHandle,
273
+ maintenanceId,
274
+ opts: mutationOpts({ runId, scope }),
275
+ apply: async () => {
276
+ created = await deps.service.createMaintenance(
277
+ {
278
+ title: config.title,
279
+ description: config.description,
280
+ systemIds: config.systemIds,
281
+ startAt: new Date(config.startAt),
282
+ endAt: new Date(config.endAt),
283
+ suppressNotifications: config.suppressNotifications,
284
+ },
285
+ maintenanceId,
286
+ );
287
+ return toMaintenanceEntityState(created);
288
+ },
219
289
  });
220
290
  const artifact = toArtifact(created);
221
- await deps.emitHook(maintenanceHooks.maintenanceCreated, {
222
- maintenanceId: created.id,
223
- systemIds: created.systemIds,
224
- title: created.title,
225
- description: created.description,
226
- status: created.status,
227
- startAt: artifact.startAt,
228
- endAt: artifact.endAt,
229
- });
230
291
  logger.info(`Automation scheduled maintenance ${created.id}`);
231
292
  return {
232
293
  success: true,
@@ -247,33 +308,44 @@ export function createMaintenanceActions(
247
308
  icon: "Wrench",
248
309
  config: new Versioned({ version: 1, schema: updateConfigSchema }),
249
310
  produces: "maintenance.window",
250
- execute: async ({ config, logger }) => {
251
- const updated = await deps.service.updateMaintenance({
252
- id: config.maintenanceId,
253
- title: config.title,
254
- description: config.description,
255
- systemIds: config.systemIds,
256
- startAt: config.startAt ? new Date(config.startAt) : undefined,
257
- endAt: config.endAt ? new Date(config.endAt) : undefined,
258
- suppressNotifications: config.suppressNotifications,
259
- });
260
- if (!updated) {
311
+ execute: async ({ config, logger, runId, scope }) => {
312
+ // Probe existence first so a missing window returns a clean failure
313
+ // without driving an entity write (no `prev` to snapshot).
314
+ const exists = await deps.service.getMaintenance(config.maintenanceId);
315
+ if (!exists) {
261
316
  return {
262
317
  success: false,
263
318
  error: `Maintenance not found: ${config.maintenanceId}`,
264
319
  };
265
320
  }
266
- const artifact = toArtifact(updated);
267
- await deps.emitHook(maintenanceHooks.maintenanceUpdated, {
268
- maintenanceId: updated.id,
269
- systemIds: updated.systemIds,
270
- title: updated.title,
271
- description: updated.description,
272
- status: updated.status,
273
- startAt: artifact.startAt,
274
- endAt: artifact.endAt,
275
- action: "updated",
321
+ // Drive the update through the reactive `maintenance` entity (§10.2);
322
+ // the REAL write runs inside `apply` and the deriver fires
323
+ // `maintenance.updated`.
324
+ let updated!: NonNullable<
325
+ Awaited<ReturnType<typeof deps.service.updateMaintenance>>
326
+ >;
327
+ await writeMaintenanceEntity({
328
+ handle: deps.entityHandle,
329
+ maintenanceId: config.maintenanceId,
330
+ opts: mutationOpts({ runId, scope }),
331
+ apply: async () => {
332
+ const result = await deps.service.updateMaintenance({
333
+ id: config.maintenanceId,
334
+ title: config.title,
335
+ description: config.description,
336
+ systemIds: config.systemIds,
337
+ startAt: config.startAt ? new Date(config.startAt) : undefined,
338
+ endAt: config.endAt ? new Date(config.endAt) : undefined,
339
+ suppressNotifications: config.suppressNotifications,
340
+ });
341
+ if (!result) {
342
+ throw new Error(`Maintenance not found: ${config.maintenanceId}`);
343
+ }
344
+ updated = result;
345
+ return toMaintenanceEntityState(updated);
346
+ },
276
347
  });
348
+ const artifact = toArtifact(updated);
277
349
  logger.info(`Automation updated maintenance ${updated.id}`);
278
350
  return { success: true, externalId: updated.id, artifact };
279
351
  },
@@ -290,16 +362,39 @@ export function createMaintenanceActions(
290
362
  icon: "MessageSquarePlus",
291
363
  config: new Versioned({ version: 1, schema: addUpdateConfigSchema }),
292
364
  produces: "maintenance.window",
293
- execute: async ({ config, logger }) => {
294
- await deps.service.addUpdate({
365
+ execute: async ({ config, logger, runId, scope }) => {
366
+ // Drive the update through the reactive `maintenance` entity (§10.2):
367
+ // `apply` posts the update row + (optionally) flips status, then
368
+ // re-reads the post-write state. The deriver fires `maintenance.updated`
369
+ // purely from the entity diff (no diff → no event).
370
+ let refreshed: Awaited<ReturnType<typeof deps.service.getMaintenance>>;
371
+ let missing = false;
372
+ await writeMaintenanceEntity({
373
+ handle: deps.entityHandle,
295
374
  maintenanceId: config.maintenanceId,
296
- message: config.message,
297
- statusChange: config.statusChange,
375
+ opts: mutationOpts({ runId, scope }),
376
+ apply: async () => {
377
+ await deps.service.addUpdate({
378
+ maintenanceId: config.maintenanceId,
379
+ message: config.message,
380
+ statusChange: config.statusChange,
381
+ });
382
+ // Re-fetch so we surface the latest window state to the next step +
383
+ // so the entity state matches the (now-updated) row.
384
+ refreshed = await deps.service.getMaintenance(config.maintenanceId);
385
+ if (!refreshed) {
386
+ missing = true;
387
+ throw new Error(
388
+ `Maintenance ${config.maintenanceId} not found after update`,
389
+ );
390
+ }
391
+ return toMaintenanceEntityState(refreshed);
392
+ },
393
+ }).catch((error) => {
394
+ // The "vanished mid-write" case is a soft failure for the action, not
395
+ // a thrown run error; rethrow anything else.
396
+ if (!missing) throw error;
298
397
  });
299
- // Re-fetch so we surface the latest window state to the next
300
- // step + so the emitted hook payload matches the (now-updated)
301
- // row.
302
- const refreshed = await deps.service.getMaintenance(config.maintenanceId);
303
398
  if (!refreshed) {
304
399
  return {
305
400
  success: false,
@@ -307,16 +402,6 @@ export function createMaintenanceActions(
307
402
  };
308
403
  }
309
404
  const artifact = toArtifact(refreshed);
310
- await deps.emitHook(maintenanceHooks.maintenanceUpdated, {
311
- maintenanceId: refreshed.id,
312
- systemIds: refreshed.systemIds,
313
- title: refreshed.title,
314
- description: refreshed.description,
315
- status: refreshed.status,
316
- startAt: artifact.startAt,
317
- endAt: artifact.endAt,
318
- action: config.statusChange === "completed" ? "closed" : "updated",
319
- });
320
405
  logger.info(`Automation added update to maintenance ${refreshed.id}`);
321
406
  return { success: true, externalId: refreshed.id, artifact };
322
407
  },
@@ -334,29 +419,38 @@ export function createMaintenanceActions(
334
419
  icon: "Wrench",
335
420
  config: new Versioned({ version: 1, schema: setSystemConfigSchema }),
336
421
  produces: "maintenance.window",
337
- execute: async ({ config, logger }) => {
422
+ execute: async ({ config, logger, runId, scope }) => {
338
423
  const startAt = now();
339
424
  const endAt = new Date(
340
425
  startAt.getTime() + config.durationMinutes * 60_000,
341
426
  );
342
- const created = await deps.service.createMaintenance({
343
- title: config.title ?? `Automation maintenance (${config.systemId})`,
344
- description: config.description,
345
- systemIds: [config.systemId],
346
- startAt,
347
- endAt,
348
- suppressNotifications: config.suppressNotifications,
427
+ // Drive the create through the reactive `maintenance` entity (§10.2):
428
+ // the REAL write runs inside `apply` and the deriver fires
429
+ // `maintenance.created`. The id is generated up front so the create's
430
+ // `prev` snapshot reads the not-yet-existing row as absent.
431
+ const maintenanceId = crypto.randomUUID();
432
+ let created!: Awaited<ReturnType<typeof deps.service.createMaintenance>>;
433
+ await writeMaintenanceEntity({
434
+ handle: deps.entityHandle,
435
+ maintenanceId,
436
+ opts: mutationOpts({ runId, scope }),
437
+ apply: async () => {
438
+ created = await deps.service.createMaintenance(
439
+ {
440
+ title:
441
+ config.title ?? `Automation maintenance (${config.systemId})`,
442
+ description: config.description,
443
+ systemIds: [config.systemId],
444
+ startAt,
445
+ endAt,
446
+ suppressNotifications: config.suppressNotifications,
447
+ },
448
+ maintenanceId,
449
+ );
450
+ return toMaintenanceEntityState(created);
451
+ },
349
452
  });
350
453
  const artifact = toArtifact(created);
351
- await deps.emitHook(maintenanceHooks.maintenanceCreated, {
352
- maintenanceId: created.id,
353
- systemIds: created.systemIds,
354
- title: created.title,
355
- description: created.description,
356
- status: created.status,
357
- startAt: artifact.startAt,
358
- endAt: artifact.endAt,
359
- });
360
454
  logger.info(
361
455
  `Automation parked system ${config.systemId} via maintenance ${created.id}`,
362
456
  );
@@ -381,25 +475,28 @@ export function createMaintenanceActions(
381
475
  icon: "Wrench",
382
476
  config: new Versioned({ version: 1, schema: clearSystemConfigSchema }),
383
477
  produces: "maintenance.window",
384
- execute: async ({ config, logger }) => {
478
+ execute: async ({ config, logger, runId, scope }) => {
385
479
  const active = await deps.service.getMaintenancesForSystem(config.systemId);
386
480
  const closedIds: string[] = [];
387
481
  const message = config.message ?? "Cleared by automation";
388
482
  for (const window of active) {
389
- const closed = await deps.service.closeMaintenance(window.id, message);
483
+ // Drive each close through the reactive `maintenance` entity (§10.2):
484
+ // the REAL close runs inside `apply` and the deriver fires
485
+ // `maintenance.updated` from the status → completed transition.
486
+ let closed: Awaited<ReturnType<typeof deps.service.closeMaintenance>>;
487
+ await writeMaintenanceEntity({
488
+ handle: deps.entityHandle,
489
+ maintenanceId: window.id,
490
+ opts: mutationOpts({ runId, scope }),
491
+ apply: async () => {
492
+ closed = await deps.service.closeMaintenance(window.id, message);
493
+ // Fall back to the pre-close window so the diff is a no-op when the
494
+ // row vanished mid-write (the loop just skips it below).
495
+ return toMaintenanceEntityState(closed ?? window);
496
+ },
497
+ });
390
498
  if (!closed) continue;
391
499
  closedIds.push(closed.id);
392
- const artifact = toArtifact(closed);
393
- await deps.emitHook(maintenanceHooks.maintenanceUpdated, {
394
- maintenanceId: closed.id,
395
- systemIds: closed.systemIds,
396
- title: closed.title,
397
- description: closed.description,
398
- status: closed.status,
399
- startAt: artifact.startAt,
400
- endAt: artifact.endAt,
401
- action: "closed",
402
- });
403
500
  }
404
501
  logger.info(
405
502
  `Automation cleared maintenance for system ${config.systemId} (${closedIds.length} window(s))`,