@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 +218 -0
- package/package.json +18 -17
- package/src/automations.test.ts +372 -0
- package/src/automations.ts +519 -0
- 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 +90 -54
- package/src/router.ts +141 -63
- package/src/service.ts +112 -3
- package/tsconfig.json +6 -6
- package/src/hooks.ts +0 -37
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.
|
|
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.
|
|
18
|
-
"@checkstack/cache-api": "0.3.
|
|
19
|
-
"@checkstack/cache-utils": "0.2.
|
|
20
|
-
"@checkstack/maintenance-common": "1.2.
|
|
21
|
-
"@checkstack/notification-common": "1.2.
|
|
22
|
-
"@checkstack/catalog-common": "2.2.
|
|
23
|
-
"@checkstack/catalog-backend": "1.
|
|
24
|
-
"@checkstack/auth-common": "0.7.
|
|
25
|
-
"@checkstack/command-backend": "0.1.
|
|
26
|
-
"@checkstack/signal-common": "0.2.
|
|
27
|
-
"@checkstack/
|
|
28
|
-
"@checkstack/
|
|
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.
|
|
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.
|
|
37
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
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
|
+
});
|