@checkstack/maintenance-backend 1.1.6 → 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 CHANGED
@@ -1,5 +1,223 @@
1
1
  # @checkstack/maintenance-backend
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 270ef29: Add the health-state provider data contract (automation sensing layer, Wave 2 Phase 13).
8
+
9
+ - New `health_check_state_transitions` table records every aggregate health-status transition for a system (all statuses, not just unhealthy), giving a reliable "in current status since" timestamp. Written wherever an aggregate transition is detected. Pruned with raw-run retention, but the single most-recent row per system is always kept so an active streak never blanks.
10
+ - New service-typed RPCs on `HealthCheckApi`: `getHealthState({ systemId, configurationId? })` returns `{ status, inStatusSince, inStatusForMs, latencyMs?, avgLatencyMs?, p95LatencyMs?, successRate?, lastRunAt?, inMaintenance, evaluatedAt }`, and `getBulkHealthState({ systemIds })` (POST) resolves many systems against one shared timestamp.
11
+ - New service-typed RPC on `MaintenanceApi`: `hasActiveMaintenance({ systemId })` reports whether a system is in an active maintenance window regardless of notification-suppression (suppression-agnostic), folded into `getHealthState` as `inMaintenance`.
12
+
13
+ All reads are fail-safe: a missing transition row yields `inStatusSince: null`, and a maintenance-plugin error fails open to `inMaintenance: false`.
14
+
15
+ - b995afb: Make `maintenance` a plugin-backed reactive entity via the Model-B entity state machine.
16
+
17
+ Maintenance defines a `maintenance` entity `{ status, systemIds, startAt, endAt }`. The `maintenances` + `maintenance_systems` tables are BOTH authoritative AND the entity's current-state storage - there is no framework `entity_state` mirror. `defineEntity` is given a plugin `read` accessor (`MaintenanceService.getManyEntityStates`, projecting the reactive subset with ISO-serialized timestamps straight off those tables), and every create / update / add-update / close / delete site (plus the automation actions) drives the REAL service write through `handle.mutate` / `handle.remove` via the `writeMaintenanceEntity` / `removeMaintenanceEntity` helpers: `apply` runs the write in the plugin's own transaction and returns the new state; the framework snapshots `prev` via `read` BEFORE the write, appends the transition log, and emits `ENTITY_CHANGED` AFTER the write commits. `MaintenanceService.createMaintenance` accepts an optional pre-generated `id` so a create is keyed on a known id and its `prev` snapshot reads the not-yet-existing row as absent.
18
+
19
+ A registered change-deriver maps `maintenance` entity changes back to the `maintenance.created` / `maintenance.updated` trigger events, so existing automations keep firing via the reactive Stage-1/Stage-2 dispatch pipeline. The old `maintenance.created` / `maintenance.updated` change hooks and their hook-backed triggers are removed in favor of the reactive entity.
20
+
21
+ BREAKING CHANGES:
22
+
23
+ - Removed the `maintenance.created` and `maintenance.updated` hooks (`createHook`) and their re-export from the plugin entry point. Use the `maintenance` entity's auto-emitted change events (subscribe via the `automation.entity` extension point's `onEntityChanged`, or author automations against the derived `maintenance.created` / `maintenance.updated` trigger events).
24
+ - The `created` / `updated` automation triggers are now ENTITY-DRIVEN instead of hook-backed: they are fired by the `maintenance` entity change-deriver (Stage-1 routing) rather than a `createHook`, but stay REGISTERED in the automation editor's trigger catalog (a no-op `setup` via `makeEntityDrivenTriggerSetup`), so they remain offered as picker entries and payload-introspectable. Already-authored automations referencing `maintenance.created` / `maintenance.updated` continue to fire. A registered `toPayload` mapper keeps the runtime `trigger.payload` matching each trigger's declared `payloadSchema` (`maintenanceId`, `status`, `systemIds`, `startAt`, `endAt`). The descriptive fields the old hook carried (`title`, `description`, the `updated`/`closed` `action` discriminator) are NOT part of the reactive entity state, so they are no longer present on the payload.
25
+ - NARROWING: `maintenance.updated` now fires only on a change to the REACTIVE state (`status`, `startAt` / `endAt` window, or affected `systemIds`). A title / description / message-only edit no longer fires `maintenance.updated` (those fields are not reactive entity state). Re-author any automation that needed to react to a metadata-only maintenance edit against a different signal.
26
+
27
+ ### Patch Changes
28
+
29
+ - b995afb: Extract a shared `withEntityWrite` / `withEntityRemove` guard for PLUGIN-BACKED (Model B) reactive entities and refactor the per-domain copies onto it.
30
+
31
+ Every plugin-backed domain (incident, catalog, dependency, maintenance, slo, satellite) reimplemented the same "no handle wired → run the plugin write directly; handle wired → route through `handle.mutate` / `handle.remove`" guard, varying only in the id-key name. `@checkstack/automation-backend` now exports `withEntityWrite` / `withEntityRemove` (from the entity barrel) and each domain's thin, well-named wrappers (`writeIncidentEntity`, `writeMaintenanceEntity`, satellite's `mirror`, …) delegate to it, so the branch lives in exactly one place. Behavior is unchanged.
32
+
33
+ `writeHealthEntity` (healthcheck-backend) is intentionally NOT migrated onto the helper — it is genuinely bespoke (closure-captured durable state, distinct rethrow-vs-fail-soft branches, a per-system serializer, and it returns the computed state). SLO keeps its fail-soft `onError` wrapper around the shared guard.
34
+
35
+ - Updated dependencies [270ef29]
36
+ - Updated dependencies [b995afb]
37
+ - Updated dependencies [b995afb]
38
+ - Updated dependencies [b995afb]
39
+ - Updated dependencies [270ef29]
40
+ - Updated dependencies [270ef29]
41
+ - Updated dependencies [270ef29]
42
+ - Updated dependencies [270ef29]
43
+ - Updated dependencies [270ef29]
44
+ - Updated dependencies [270ef29]
45
+ - Updated dependencies [270ef29]
46
+ - Updated dependencies [270ef29]
47
+ - Updated dependencies [270ef29]
48
+ - Updated dependencies [b995afb]
49
+ - Updated dependencies [b995afb]
50
+ - Updated dependencies [b995afb]
51
+ - Updated dependencies [b995afb]
52
+ - Updated dependencies [270ef29]
53
+ - Updated dependencies [b995afb]
54
+ - Updated dependencies [270ef29]
55
+ - Updated dependencies [b995afb]
56
+ - Updated dependencies [b995afb]
57
+ - Updated dependencies [270ef29]
58
+ - Updated dependencies [b995afb]
59
+ - Updated dependencies [b995afb]
60
+ - Updated dependencies [b995afb]
61
+ - Updated dependencies [b995afb]
62
+ - Updated dependencies [b995afb]
63
+ - Updated dependencies [b995afb]
64
+ - Updated dependencies [b995afb]
65
+ - Updated dependencies [270ef29]
66
+ - Updated dependencies [270ef29]
67
+ - Updated dependencies [270ef29]
68
+ - Updated dependencies [270ef29]
69
+ - Updated dependencies [270ef29]
70
+ - Updated dependencies [270ef29]
71
+ - Updated dependencies [270ef29]
72
+ - Updated dependencies [270ef29]
73
+ - Updated dependencies [b995afb]
74
+ - Updated dependencies [b995afb]
75
+ - @checkstack/backend-api@0.19.0
76
+ - @checkstack/automation-backend@0.3.0
77
+ - @checkstack/automation-common@0.3.0
78
+ - @checkstack/maintenance-common@1.3.0
79
+ - @checkstack/catalog-backend@1.3.0
80
+ - @checkstack/cache-api@0.3.7
81
+ - @checkstack/command-backend@0.1.32
82
+ - @checkstack/cache-utils@0.2.12
83
+
84
+ ## 1.2.0
85
+
86
+ ### Minor Changes
87
+
88
+ - 41c77f4: feat(automation): type enum-able trigger/artifact fields as enums for editor value autocompletion
89
+
90
+ The automation editor's staged completion offers concrete values after a
91
+ comparator (`{{ trigger.payload.severity == "high" }}`) only when the
92
+ field's JSON Schema carries an `enum`. Several trigger payload + artifact
93
+ schemas declared closed-set fields as loose `z.string()`, so no values
94
+ were suggested. Tightened them to the canonical enums that already
95
+ existed in each plugin's `-common` package (and matched the hook payload
96
+ types in lockstep so the trigger's `payloadSchema` and `hook` keep the
97
+ same `TPayload`):
98
+
99
+ - **incident** — trigger payloads: `severity` → `IncidentSeverityEnum`,
100
+ `status` / `statusChange` → `IncidentStatusEnum`.
101
+ - **healthcheck** — trigger payloads: `previousStatus` / `newStatus` /
102
+ `status` → `HealthCheckStatusSchema` (across systemDegraded,
103
+ systemHealthy, systemHealthChanged, checkFailed; plus checkCompleted's
104
+ hook type).
105
+ - **dependency** — trigger + artifact: `impactType` → `ImpactTypeSchema`;
106
+ impactPropagated `previousState` / `newState` → `DerivedStateSchema`.
107
+ Also deduped the inline `impactTypeSchema` action-config enum to reuse
108
+ the canonical `ImpactTypeSchema`.
109
+ - **maintenance** — trigger + artifact: `status` →
110
+ `MaintenanceStatusEnum`; deduped the inline `maintenanceStatusEnum`
111
+ (used by `add_update.statusChange`) to the canonical one.
112
+ - **slo** — `achievement.unlocked` trigger + hook: `achievement` →
113
+ `AchievementTypeSchema`.
114
+
115
+ Runtime behaviour is unchanged — these fields always carried valid enum
116
+ values (the underlying records are enum-constrained); only the schema
117
+ types were loose. The hook payload generics are now precise too, which
118
+ caught one stale test fixture asserting an invalid `impactType: "soft"`.
119
+
120
+ Fields that look enum-ish but are genuinely free-form were intentionally
121
+ left as `z.string()`: satellite `region` (user-entered), Jira issue
122
+ `status` (per-instance workflow name), notification `strategyQualifiedId`
123
+ / `errorMessage`, healthcheck collector `result`, and script
124
+ `stdout` / `stderr`.
125
+
126
+ - 41c77f4: feat(maintenance): Phase 9 — actions + system-shaped helpers
127
+
128
+ - Triggers `maintenance.created`, `maintenance.updated` are unchanged;
129
+ they're now lifted out of the inline `register()` block into
130
+ `automations.ts` alongside the new actions.
131
+ - Actions `maintenance.create`, `maintenance.update`,
132
+ `maintenance.add_update` wrapping `MaintenanceService`. Each emits
133
+ the appropriate `maintenanceHooks.*` so downstream automations and
134
+ caches react identically to RPC-driven changes; `add_update`
135
+ re-fetches the window before emitting so the hook payload reflects
136
+ the new status.
137
+ - The two deferred catalog actions land here as
138
+ `maintenance.set_system` (schedule a `now → now+durationMinutes`
139
+ window covering a single system — the "park this system" operation)
140
+ and `maintenance.clear_system` (close every active or scheduled
141
+ window covering a given system — the "let it back into rotation"
142
+ operation).
143
+ - Artifact type `maintenance.window` for downstream steps to consume.
144
+
145
+ ### Patch Changes
146
+
147
+ - 41c77f4: feat(automation): one-time migration of webhook subscriptions + remove legacy integration backend
148
+
149
+ **BREAKING CHANGES** (platform is in BETA — no major bump):
150
+
151
+ - `IntegrationProvider` no longer carries `config` (subscription
152
+ config) or `deliver`. The interface now models a connection provider
153
+ only: connection schema + `getConnectionOptions` + `testConnection`.
154
+ - The legacy subscription / delivery-log / event endpoints
155
+ (`listSubscriptions`, `createSubscription`, `getDeliveryLogs`,
156
+ `listEventTypes`, …) are removed from `integrationContract`.
157
+ - `delivery-coordinator`, `hook-subscriber`, `event-registry`, and the
158
+ `integrationEventExtensionPoint` are deleted. Plugins that
159
+ previously called `integrationEvents.registerEvent(...)` now
160
+ register their hooks as automation triggers via
161
+ `automationTriggerExtensionPoint.registerTrigger(...)`.
162
+ - Frontend pages `IntegrationsPage` and `DeliveryLogsPage` are gone;
163
+ the integration plugin's only remaining UI is connection
164
+ management. Subscription management lives under `/automation/...`.
165
+ - `webhook_subscriptions` and `delivery_logs` tables stay in the
166
+ database for one release as a safety net (no code reads or writes
167
+ them), and will be dropped in a follow-up migration.
168
+
169
+ **New**:
170
+
171
+ - `jira.create_issue`, `teams.post_message`, `webex.post_message`,
172
+ `webhook.send`, `integration-script.run_shell`, and
173
+ `integration-script.run_script` actions registered against the
174
+ Automation Platform with matching `*.message`, `*.delivery`,
175
+ `shell.result`, and `script.result` artifact types. The script
176
+ plugin exposes **two** actions — `run_shell` runs bash via the
177
+ shared `ShellScriptRunner` (Monaco `shell` editor), `run_script`
178
+ runs an ESM module in a Bun subprocess via `EsmScriptRunner`
179
+ (Monaco `typescript` editor + `defineIntegration` helper) — to
180
+ preserve the legacy provider split. `jira.create_issue` keeps the
181
+ dynamic field-mapping dropdown (driven by
182
+ `JIRA_RESOLVERS.FIELD_OPTIONS`).
183
+ - One-time data migration runs on boot in
184
+ `automation-backend.afterPluginsReady`. It reads
185
+ `webhook_subscriptions` via a new service RPC
186
+ `IntegrationApi.listLegacySubscriptions`, translates each row into
187
+ a single-trigger / single-action automation (marked with
188
+ `managed_by = "migrated-subscription:<id>"`), and is idempotent
189
+ across restarts.
190
+ - Failed translations are recorded in a new
191
+ `automation_migration_failures` table and surfaced via
192
+ `AutomationApi.listMigrationFailures` /
193
+ `acknowledgeMigrationFailure` so admins can review and re-create
194
+ failed entries by hand.
195
+
196
+ - Updated dependencies [e2d6f25]
197
+ - Updated dependencies [41c77f4]
198
+ - Updated dependencies [e1a2077]
199
+ - Updated dependencies [41c77f4]
200
+ - Updated dependencies [41c77f4]
201
+ - Updated dependencies [41c77f4]
202
+ - Updated dependencies [41c77f4]
203
+ - Updated dependencies [41c77f4]
204
+ - Updated dependencies [41c77f4]
205
+ - Updated dependencies [6d52276]
206
+ - Updated dependencies [6d52276]
207
+ - Updated dependencies [35bc682]
208
+ - @checkstack/automation-backend@0.2.0
209
+ - @checkstack/catalog-backend@1.2.0
210
+ - @checkstack/common@0.12.0
211
+ - @checkstack/backend-api@0.18.0
212
+ - @checkstack/catalog-common@2.2.3
213
+ - @checkstack/maintenance-common@1.2.3
214
+ - @checkstack/auth-common@0.7.2
215
+ - @checkstack/command-backend@0.1.31
216
+ - @checkstack/notification-common@1.2.1
217
+ - @checkstack/signal-common@0.2.5
218
+ - @checkstack/cache-api@0.3.6
219
+ - @checkstack/cache-utils@0.2.11
220
+
3
221
  ## 1.1.6
4
222
 
5
223
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/maintenance-backend",
3
- "version": "1.1.6",
3
+ "version": "1.3.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -11,30 +11,31 @@
11
11
  "typecheck": "tsgo -b",
12
12
  "generate": "drizzle-kit generate",
13
13
  "lint": "bun run lint:code",
14
- "lint:code": "eslint . --max-warnings 0"
14
+ "lint:code": "eslint . --max-warnings 0",
15
+ "test": "bun test"
15
16
  },
16
17
  "dependencies": {
17
- "@checkstack/backend-api": "0.17.0",
18
- "@checkstack/cache-api": "0.3.4",
19
- "@checkstack/cache-utils": "0.2.9",
20
- "@checkstack/maintenance-common": "1.2.2",
21
- "@checkstack/notification-common": "1.2.0",
22
- "@checkstack/catalog-common": "2.2.2",
23
- "@checkstack/catalog-backend": "1.1.5",
24
- "@checkstack/auth-common": "0.7.1",
25
- "@checkstack/command-backend": "0.1.29",
26
- "@checkstack/signal-common": "0.2.4",
27
- "@checkstack/integration-backend": "0.1.29",
28
- "@checkstack/integration-common": "0.5.0",
18
+ "@checkstack/backend-api": "0.18.0",
19
+ "@checkstack/cache-api": "0.3.6",
20
+ "@checkstack/cache-utils": "0.2.11",
21
+ "@checkstack/maintenance-common": "1.2.3",
22
+ "@checkstack/notification-common": "1.2.1",
23
+ "@checkstack/catalog-common": "2.2.3",
24
+ "@checkstack/catalog-backend": "1.2.0",
25
+ "@checkstack/auth-common": "0.7.2",
26
+ "@checkstack/command-backend": "0.1.31",
27
+ "@checkstack/signal-common": "0.2.5",
28
+ "@checkstack/automation-backend": "0.2.0",
29
+ "@checkstack/automation-common": "0.2.0",
29
30
  "drizzle-orm": "^0.45.0",
30
31
  "zod": "^4.2.1",
31
- "@checkstack/common": "0.11.0",
32
+ "@checkstack/common": "0.12.0",
32
33
  "@orpc/server": "^1.13.2"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@checkstack/drizzle-helper": "0.0.5",
36
- "@checkstack/scripts": "0.3.3",
37
- "@checkstack/test-utils-backend": "0.1.29",
37
+ "@checkstack/scripts": "0.3.4",
38
+ "@checkstack/test-utils-backend": "0.1.31",
38
39
  "@checkstack/tsconfig": "0.0.7",
39
40
  "@types/bun": "^1.0.0",
40
41
  "drizzle-kit": "^0.31.10",
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Behaviour tests for the maintenance automation actions.
3
+ *
4
+ * The maintenance domain is now a Model-B PLUGIN-BACKED entity (reactive
5
+ * automation engine §10.2): the `maintenances` table IS the current-state
6
+ * storage. Mutation actions drive the REAL write through `handle.mutate`
7
+ * (the write runs inside `apply`); these tests assert the mutate is keyed by
8
+ * maintenance id and `apply` returns the §10.2 entity shape.
9
+ */
10
+ import { describe, expect, it, mock } from "bun:test";
11
+ import type { Logger } from "@checkstack/backend-api";
12
+ import type {
13
+ EntityHandle,
14
+ EntityMutationOpts,
15
+ } from "@checkstack/automation-backend";
16
+ import { createMockLogger } from "@checkstack/test-utils-backend";
17
+
18
+ import {
19
+ createMaintenanceActions,
20
+ maintenanceArtifactType,
21
+ } from "./automations";
22
+ import type { MaintenanceEntityState } from "./entity";
23
+ import type { MaintenanceService } from "./service";
24
+
25
+ const logger = createMockLogger() as Logger;
26
+
27
+ const ctxBase = {
28
+ runId: "run-1",
29
+ automationId: "auto-1",
30
+ contextKey: null,
31
+ logger,
32
+ getService: async <T,>(): Promise<T> => {
33
+ throw new Error("not used");
34
+ },
35
+ };
36
+
37
+ interface RecordedMutate {
38
+ id: string;
39
+ next: MaintenanceEntityState;
40
+ opts?: EntityMutationOpts;
41
+ }
42
+
43
+ /**
44
+ * A fake PLUGIN-BACKED entity handle that records `mutate` / `remove` calls
45
+ * for assertion. `mutate` runs the driven `apply()` (the action's real write)
46
+ * and captures the returned next-state, mirroring how the framework drives a
47
+ * plugin-backed write.
48
+ */
49
+ function makeEntityHandle(): EntityHandle<MaintenanceEntityState> & {
50
+ mutates: RecordedMutate[];
51
+ removes: string[];
52
+ } {
53
+ const mutates: RecordedMutate[] = [];
54
+ const removes: string[] = [];
55
+ return {
56
+ kind: "maintenance",
57
+ mutates,
58
+ removes,
59
+ async mutate(input) {
60
+ const next = await input.apply();
61
+ mutates.push({ id: input.id, next, opts: input.opts });
62
+ return next;
63
+ },
64
+ async get() {
65
+ return undefined;
66
+ },
67
+ async getMany() {
68
+ return {};
69
+ },
70
+ async remove(input) {
71
+ // Plugin-backed remove takes `{ id, apply }`; run apply + record the id.
72
+ await input.apply();
73
+ removes.push(input.id);
74
+ },
75
+ async inStateSince() {
76
+ return null;
77
+ },
78
+ async inStateForMs() {
79
+ return 0;
80
+ },
81
+ async transitionCount() {
82
+ return 0;
83
+ },
84
+ };
85
+ }
86
+
87
+ describe("maintenanceArtifactType", () => {
88
+ it("validates the canonical artifact shape", () => {
89
+ const ok = maintenanceArtifactType.schema.safeParse({
90
+ maintenanceId: "m-1",
91
+ status: "scheduled",
92
+ systemIds: ["sys-1"],
93
+ startAt: "2026-05-29T11:00:00Z",
94
+ endAt: "2026-05-29T12:00:00Z",
95
+ });
96
+ expect(ok.success).toBe(true);
97
+ });
98
+ });
99
+
100
+ // ─── Actions ───────────────────────────────────────────────────────────
101
+
102
+ interface FakeMaintenance {
103
+ id: string;
104
+ title: string;
105
+ description?: string;
106
+ status: string;
107
+ systemIds: string[];
108
+ startAt: Date;
109
+ endAt: Date;
110
+ }
111
+
112
+ function makeService(args: {
113
+ rowToReturn?: FakeMaintenance;
114
+ updateReturn?: FakeMaintenance | undefined;
115
+ getMaintenanceReturn?: FakeMaintenance | undefined;
116
+ activeForSystem?: FakeMaintenance[];
117
+ closeReturn?: FakeMaintenance | undefined;
118
+ }): MaintenanceService & {
119
+ createMock: ReturnType<typeof mock>;
120
+ updateMock: ReturnType<typeof mock>;
121
+ addUpdateMock: ReturnType<typeof mock>;
122
+ getMock: ReturnType<typeof mock>;
123
+ activeMock: ReturnType<typeof mock>;
124
+ closeMock: ReturnType<typeof mock>;
125
+ } {
126
+ // The real service inserts with the server-generated id passed as the 2nd
127
+ // arg and returns the row carrying THAT id. Echo it so the entity key (the
128
+ // generated id) and the returned `externalId` agree, like production.
129
+ const createMock = mock(async (_input: unknown, id?: string) =>
130
+ args.rowToReturn ? { ...args.rowToReturn, id: id ?? args.rowToReturn.id } : args.rowToReturn,
131
+ );
132
+ const updateMock = mock(async (_input: unknown) => args.updateReturn);
133
+ const addUpdateMock = mock(async (_input: unknown) => ({
134
+ id: "upd-1",
135
+ maintenanceId: "m-1",
136
+ message: "x",
137
+ statusChange: undefined,
138
+ createdBy: undefined,
139
+ createdAt: new Date(),
140
+ }));
141
+ const getMock = mock(async (_id: string) => args.getMaintenanceReturn);
142
+ const activeMock = mock(async (_id: string) => args.activeForSystem ?? []);
143
+ const closeMock = mock(async (_id: string, _msg?: string) => args.closeReturn);
144
+ return {
145
+ createMaintenance: createMock,
146
+ updateMaintenance: updateMock,
147
+ addUpdate: addUpdateMock,
148
+ getMaintenance: getMock,
149
+ getMaintenancesForSystem: activeMock,
150
+ closeMaintenance: closeMock,
151
+ createMock,
152
+ updateMock,
153
+ addUpdateMock,
154
+ getMock,
155
+ activeMock,
156
+ closeMock,
157
+ } as unknown as MaintenanceService & {
158
+ createMock: ReturnType<typeof mock>;
159
+ updateMock: ReturnType<typeof mock>;
160
+ addUpdateMock: ReturnType<typeof mock>;
161
+ getMock: ReturnType<typeof mock>;
162
+ activeMock: ReturnType<typeof mock>;
163
+ closeMock: ReturnType<typeof mock>;
164
+ };
165
+ }
166
+
167
+ const sampleRow: FakeMaintenance = {
168
+ id: "m-1",
169
+ title: "Deploy",
170
+ status: "scheduled",
171
+ systemIds: ["sys-1"],
172
+ startAt: new Date("2026-05-29T11:00:00Z"),
173
+ endAt: new Date("2026-05-29T12:00:00Z"),
174
+ };
175
+
176
+ describe("maintenance.create", () => {
177
+ it("creates a maintenance, mirrors the entity state, and emits a maintenance.window artifact", async () => {
178
+ const service = makeService({ rowToReturn: sampleRow });
179
+ const entityHandle = makeEntityHandle();
180
+ const [create] = createMaintenanceActions({ service, entityHandle });
181
+
182
+ const result = await create!.execute({
183
+ ...ctxBase,
184
+ consumedArtifacts: {},
185
+ config: {
186
+ title: "Deploy",
187
+ systemIds: ["sys-1"],
188
+ startAt: "2026-05-29T11:00:00Z",
189
+ endAt: "2026-05-29T12:00:00Z",
190
+ } as never,
191
+ });
192
+
193
+ expect(result.success).toBe(true);
194
+ if (!result.success) return;
195
+ expect(entityHandle.mutates).toHaveLength(1);
196
+ // The entity is keyed on the server-generated id (the create's `prev`
197
+ // snapshot reads it as absent); the action's `externalId` echoes that
198
+ // same id, so the two MUST agree.
199
+ expect(typeof result.externalId).toBe("string");
200
+ expect(entityHandle.mutates[0]!.id).toBe(result.externalId!);
201
+ expect(entityHandle.mutates[0]!.next).toEqual({
202
+ status: "scheduled",
203
+ systemIds: ["sys-1"],
204
+ startAt: "2026-05-29T11:00:00.000Z",
205
+ endAt: "2026-05-29T12:00:00.000Z",
206
+ });
207
+ // Run-originated writes carry the runId (masking) + the run actor.
208
+ expect(entityHandle.mutates[0]!.opts?.runId).toBe("run-1");
209
+ });
210
+ });
211
+
212
+ describe("maintenance.update", () => {
213
+ it("returns failure and mirrors nothing when the maintenance doesn't exist", async () => {
214
+ const service = makeService({ updateReturn: undefined });
215
+ const entityHandle = makeEntityHandle();
216
+ const [, update] = createMaintenanceActions({ service, entityHandle });
217
+
218
+ const result = await update!.execute({
219
+ ...ctxBase,
220
+ consumedArtifacts: {},
221
+ config: { maintenanceId: "missing", title: "x" } as never,
222
+ });
223
+
224
+ expect(result.success).toBe(false);
225
+ if (result.success) return;
226
+ expect(result.error).toMatch(/not found/i);
227
+ expect(entityHandle.mutates).toHaveLength(0);
228
+ });
229
+
230
+ it("mirrors the updated state on success", async () => {
231
+ const service = makeService({
232
+ // The action probes existence first, then updates inside `apply`.
233
+ getMaintenanceReturn: sampleRow,
234
+ updateReturn: { ...sampleRow, status: "in_progress" },
235
+ });
236
+ const entityHandle = makeEntityHandle();
237
+ const [, update] = createMaintenanceActions({ service, entityHandle });
238
+
239
+ const result = await update!.execute({
240
+ ...ctxBase,
241
+ consumedArtifacts: {},
242
+ config: { maintenanceId: "m-1", title: "Deploy v2" } as never,
243
+ });
244
+
245
+ expect(result.success).toBe(true);
246
+ expect(entityHandle.mutates).toHaveLength(1);
247
+ expect(entityHandle.mutates[0]!.next.status).toBe("in_progress");
248
+ });
249
+ });
250
+
251
+ describe("maintenance.add_update", () => {
252
+ it("mirrors the refreshed state when statusChange is 'completed'", async () => {
253
+ const service = makeService({
254
+ getMaintenanceReturn: { ...sampleRow, status: "completed" },
255
+ });
256
+ const entityHandle = makeEntityHandle();
257
+ const [, , addUpdate] = createMaintenanceActions({ service, entityHandle });
258
+
259
+ const result = await addUpdate!.execute({
260
+ ...ctxBase,
261
+ consumedArtifacts: {},
262
+ config: {
263
+ maintenanceId: "m-1",
264
+ message: "done",
265
+ statusChange: "completed",
266
+ } as never,
267
+ });
268
+
269
+ expect(result.success).toBe(true);
270
+ expect(entityHandle.mutates).toHaveLength(1);
271
+ expect(entityHandle.mutates[0]!.next.status).toBe("completed");
272
+ });
273
+
274
+ it("returns failure when the maintenance vanishes between addUpdate and getMaintenance", async () => {
275
+ const service = makeService({ getMaintenanceReturn: undefined });
276
+ const entityHandle = makeEntityHandle();
277
+ const [, , addUpdate] = createMaintenanceActions({ service, entityHandle });
278
+
279
+ const result = await addUpdate!.execute({
280
+ ...ctxBase,
281
+ consumedArtifacts: {},
282
+ config: { maintenanceId: "m-1", message: "x" } as never,
283
+ });
284
+
285
+ expect(result.success).toBe(false);
286
+ if (result.success) return;
287
+ expect(result.error).toMatch(/not found/i);
288
+ expect(entityHandle.mutates).toHaveLength(0);
289
+ });
290
+ });
291
+
292
+ describe("maintenance.set_system", () => {
293
+ it("schedules a now+durationMinutes window and mirrors it covering one system", async () => {
294
+ const service = makeService({ rowToReturn: sampleRow });
295
+ const entityHandle = makeEntityHandle();
296
+ const fixedNow = new Date("2026-05-29T11:00:00Z");
297
+ const [, , , setSystem] = createMaintenanceActions({
298
+ service,
299
+ entityHandle,
300
+ now: () => fixedNow,
301
+ });
302
+
303
+ await setSystem!.execute({
304
+ ...ctxBase,
305
+ consumedArtifacts: {},
306
+ config: {
307
+ systemId: "sys-1",
308
+ durationMinutes: 60,
309
+ } as never,
310
+ });
311
+
312
+ expect(service.createMock).toHaveBeenCalledTimes(1);
313
+ const call = service.createMock.mock.calls[0]![0] as {
314
+ systemIds: string[];
315
+ startAt: Date;
316
+ endAt: Date;
317
+ };
318
+ expect(call.systemIds).toEqual(["sys-1"]);
319
+ expect(call.startAt.toISOString()).toBe("2026-05-29T11:00:00.000Z");
320
+ expect(call.endAt.toISOString()).toBe("2026-05-29T12:00:00.000Z");
321
+ expect(entityHandle.mutates).toHaveLength(1);
322
+ expect(entityHandle.mutates[0]!.next.systemIds).toEqual(["sys-1"]);
323
+ });
324
+ });
325
+
326
+ describe("maintenance.clear_system", () => {
327
+ it("closes every active window for the system + mirrors one state per close", async () => {
328
+ const window1 = { ...sampleRow, id: "m-1", status: "completed" };
329
+ const window2 = { ...sampleRow, id: "m-2" };
330
+ const service = makeService({
331
+ activeForSystem: [window1, window2],
332
+ closeReturn: window1,
333
+ });
334
+ // closeMaintenance returns the same row both times in the fixture
335
+ // — that's fine for this test; we only assert the count + ids.
336
+ const entityHandle = makeEntityHandle();
337
+ const actions = createMaintenanceActions({ service, entityHandle });
338
+ const clearSystem = actions[4]!;
339
+
340
+ const result = await clearSystem.execute({
341
+ ...ctxBase,
342
+ consumedArtifacts: {},
343
+ config: { systemId: "sys-1" } as never,
344
+ });
345
+
346
+ expect(result.success).toBe(true);
347
+ expect(service.closeMock).toHaveBeenCalledTimes(2);
348
+ expect(entityHandle.mutates).toHaveLength(2);
349
+ for (const recorded of entityHandle.mutates) {
350
+ expect(recorded.next.status).toBe("completed");
351
+ }
352
+ });
353
+
354
+ it("succeeds and mirrors nothing when no windows are active for the system", async () => {
355
+ const service = makeService({ activeForSystem: [] });
356
+ const entityHandle = makeEntityHandle();
357
+ const actions = createMaintenanceActions({ service, entityHandle });
358
+ const clearSystem = actions[4]!;
359
+
360
+ const result = await clearSystem.execute({
361
+ ...ctxBase,
362
+ consumedArtifacts: {},
363
+ config: { systemId: "sys-1" } as never,
364
+ });
365
+
366
+ expect(result.success).toBe(true);
367
+ if (!result.success) return;
368
+ const artifact = result.artifact as { closedMaintenanceIds: string[] };
369
+ expect(artifact.closedMaintenanceIds).toEqual([]);
370
+ expect(entityHandle.mutates).toHaveLength(0);
371
+ });
372
+ });