@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.
- package/CHANGELOG.md +93 -0
- package/package.json +16 -15
- package/src/automations.test.ts +120 -116
- package/src/automations.ts +212 -115
- package/src/entity.test.ts +280 -0
- package/src/entity.ts +187 -0
- package/src/has-active-maintenance.test.ts +69 -0
- package/src/index.ts +65 -9
- package/src/router.ts +141 -63
- package/src/service.ts +112 -3
- package/tsconfig.json +3 -0
- package/src/hooks.ts +0 -42
package/src/automations.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Maintenance
|
|
2
|
+
* Maintenance actions registered with the Automation Platform.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
// ───
|
|
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: "
|
|
88
|
+
description: "Fires when a new maintenance window is scheduled",
|
|
69
89
|
category: "Maintenance",
|
|
70
90
|
icon: "Wrench",
|
|
71
91
|
payloadSchema: maintenanceCreatedPayloadSchema,
|
|
72
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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))`,
|