@checkstack/incident-backend 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +242 -0
- package/package.json +18 -16
- package/src/automations.test.ts +523 -0
- package/src/automations.ts +601 -0
- package/src/hooks.ts +9 -45
- package/src/incident-entity.test.ts +266 -0
- package/src/incident-entity.ts +192 -0
- package/src/index.ts +110 -76
- package/src/router.ts +162 -98
- package/src/service.test.ts +199 -0
- package/src/service.ts +147 -3
- package/tsconfig.json +6 -0
package/src/index.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as schema from "./schema";
|
|
2
2
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
|
-
import { z } from "zod";
|
|
4
3
|
import {
|
|
5
4
|
incidentAccessRules,
|
|
6
5
|
incidentAccess,
|
|
@@ -11,7 +10,13 @@ import {
|
|
|
11
10
|
incidentGroupSubscription,
|
|
12
11
|
} from "@checkstack/incident-common";
|
|
13
12
|
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
14
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
automationActionExtensionPoint,
|
|
15
|
+
automationArtifactTypeExtensionPoint,
|
|
16
|
+
automationTriggerExtensionPoint,
|
|
17
|
+
entityExtensionPoint,
|
|
18
|
+
type EntityHandle,
|
|
19
|
+
} from "@checkstack/automation-backend";
|
|
15
20
|
import {
|
|
16
21
|
NotificationApi,
|
|
17
22
|
specToRegistration,
|
|
@@ -20,47 +25,38 @@ import { IncidentService } from "./service";
|
|
|
20
25
|
import { createRouter } from "./router";
|
|
21
26
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
22
27
|
import { AuthApi } from "@checkstack/auth-common";
|
|
23
|
-
import {
|
|
28
|
+
import { CATALOG_SYSTEM_ENTITY_KIND } from "@checkstack/catalog-backend";
|
|
29
|
+
import {
|
|
30
|
+
INCIDENT_ENTITY_KIND,
|
|
31
|
+
IncidentEntityStateSchema,
|
|
32
|
+
createIncidentEntityRead,
|
|
33
|
+
deriveIncidentTriggerEvents,
|
|
34
|
+
incidentChangeToPayload,
|
|
35
|
+
type IncidentEntityState,
|
|
36
|
+
} from "./incident-entity";
|
|
24
37
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
25
38
|
import { resolveRoute } from "@checkstack/common";
|
|
26
|
-
import { incidentHooks } from "./hooks";
|
|
27
39
|
import { createIncidentCache } from "./cache";
|
|
40
|
+
import {
|
|
41
|
+
createIncidentActions,
|
|
42
|
+
incidentArtifactType,
|
|
43
|
+
incidentTriggers,
|
|
44
|
+
} from "./automations";
|
|
28
45
|
|
|
29
46
|
// =============================================================================
|
|
30
|
-
//
|
|
47
|
+
// Plugin Definition
|
|
31
48
|
// =============================================================================
|
|
32
49
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
title: z.string(),
|
|
37
|
-
description: z.string().optional(),
|
|
38
|
-
severity: z.string(),
|
|
39
|
-
status: z.string(),
|
|
40
|
-
createdAt: z.string(),
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const incidentUpdatedPayloadSchema = z.object({
|
|
44
|
-
incidentId: z.string(),
|
|
45
|
-
systemIds: z.array(z.string()),
|
|
46
|
-
title: z.string(),
|
|
47
|
-
description: z.string().optional(),
|
|
48
|
-
severity: z.string(),
|
|
49
|
-
status: z.string(),
|
|
50
|
-
statusChange: z.string().optional(),
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
const incidentResolvedPayloadSchema = z.object({
|
|
54
|
-
incidentId: z.string(),
|
|
55
|
-
systemIds: z.array(z.string()),
|
|
56
|
-
title: z.string(),
|
|
57
|
-
severity: z.string(),
|
|
58
|
-
resolvedAt: z.string(),
|
|
59
|
-
});
|
|
50
|
+
// Reactive `incident` entity handle (§10.1). Defined in register(); mutated
|
|
51
|
+
// from the router onward.
|
|
52
|
+
let incidentEntity: EntityHandle<IncidentEntityState> | undefined;
|
|
60
53
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
54
|
+
// The incident service is created in init() (it needs the resolved database),
|
|
55
|
+
// but the PLUGIN-BACKED entity `read` accessor must be supplied at
|
|
56
|
+
// `defineEntity` time in register(). This holder bridges the two: the `read`
|
|
57
|
+
// closure resolves the service lazily, and init() sets it before any mutation
|
|
58
|
+
// runs (the registry only mutates from init() onward).
|
|
59
|
+
let incidentServiceRef: IncidentService | undefined;
|
|
64
60
|
|
|
65
61
|
export default createBackendPlugin({
|
|
66
62
|
metadata: pluginMetadata,
|
|
@@ -71,42 +67,51 @@ export default createBackendPlugin({
|
|
|
71
67
|
incidentGroupSubscription,
|
|
72
68
|
]);
|
|
73
69
|
|
|
74
|
-
// Register
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
// Register triggers — buffered until the automation plugin's
|
|
71
|
+
// `register()` runs and the extension point resolves. Triggers expose
|
|
72
|
+
// `contextKey` so wait_for_trigger can match resume events back to the
|
|
73
|
+
// originating incident.
|
|
74
|
+
const automationTriggers = env.getExtensionPoint(
|
|
75
|
+
automationTriggerExtensionPoint,
|
|
77
76
|
);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
77
|
+
for (const trigger of incidentTriggers) {
|
|
78
|
+
automationTriggers.registerTrigger(trigger, pluginMetadata);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Reactive `incident` entity (§10.1) ────────────────────────────
|
|
82
|
+
// PLUGIN-BACKED (Model B): the `incidents` + `incident_systems` tables ARE
|
|
83
|
+
// the current-state storage. `read` routes straight to the service's
|
|
84
|
+
// batched authoritative read — no framework `entity_state` row, so no
|
|
85
|
+
// `indexes` (those only apply to store-backed kinds). The `read` closure
|
|
86
|
+
// resolves the service set by init() (mutations only happen from init on).
|
|
87
|
+
const entityPoint = env.getExtensionPoint(entityExtensionPoint);
|
|
88
|
+
incidentEntity = entityPoint.defineEntity<IncidentEntityState>({
|
|
89
|
+
kind: INCIDENT_ENTITY_KIND,
|
|
90
|
+
state: IncidentEntityStateSchema,
|
|
91
|
+
read: (ids) => {
|
|
92
|
+
const svc = incidentServiceRef;
|
|
93
|
+
if (!svc) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
"incident entity read before init: service not yet resolved",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return createIncidentEntityRead(svc)(ids);
|
|
86
99
|
},
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
});
|
|
101
|
+
entityPoint.registerChangeDeriver({
|
|
102
|
+
kind: INCIDENT_ENTITY_KIND,
|
|
103
|
+
derive: deriveIncidentTriggerEvents,
|
|
104
|
+
toPayload: incidentChangeToPayload,
|
|
105
|
+
});
|
|
106
|
+
const onEntityChanged = entityPoint.onEntityChanged;
|
|
89
107
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
description:
|
|
95
|
-
"Fired when an incident is updated (info or status change)",
|
|
96
|
-
category: "Incidents",
|
|
97
|
-
payloadSchema: incidentUpdatedPayloadSchema,
|
|
98
|
-
},
|
|
99
|
-
pluginMetadata,
|
|
108
|
+
// Register the `incident` artifact type so `incident.create` can
|
|
109
|
+
// `produces` it and the close/update actions can `consumes` it.
|
|
110
|
+
const automationArtifactTypes = env.getExtensionPoint(
|
|
111
|
+
automationArtifactTypeExtensionPoint,
|
|
100
112
|
);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
{
|
|
104
|
-
hook: incidentHooks.incidentResolved,
|
|
105
|
-
displayName: "Incident Resolved",
|
|
106
|
-
description: "Fired when an incident is marked as resolved",
|
|
107
|
-
category: "Incidents",
|
|
108
|
-
payloadSchema: incidentResolvedPayloadSchema,
|
|
109
|
-
},
|
|
113
|
+
automationArtifactTypes.registerArtifactType(
|
|
114
|
+
incidentArtifactType,
|
|
110
115
|
pluginMetadata,
|
|
111
116
|
);
|
|
112
117
|
|
|
@@ -140,6 +145,9 @@ export default createBackendPlugin({
|
|
|
140
145
|
const service = new IncidentService(
|
|
141
146
|
database as SafeDatabase<typeof schema>,
|
|
142
147
|
);
|
|
148
|
+
// Publish the service for the PLUGIN-BACKED entity `read` accessor
|
|
149
|
+
// (defined in register()). Mutations only run from here onward.
|
|
150
|
+
incidentServiceRef = service;
|
|
143
151
|
const cache = createIncidentCache({ cacheManager, logger });
|
|
144
152
|
incidentCache = cache;
|
|
145
153
|
const router = createRouter(
|
|
@@ -150,9 +158,25 @@ export default createBackendPlugin({
|
|
|
150
158
|
authClient,
|
|
151
159
|
logger,
|
|
152
160
|
cache,
|
|
161
|
+
() => incidentEntity,
|
|
153
162
|
);
|
|
154
163
|
rpc.registerRouter(router, incidentContract);
|
|
155
164
|
|
|
165
|
+
// Register incident actions with the Automation platform. We
|
|
166
|
+
// capture the service in closure here (rather than via a
|
|
167
|
+
// service ref + ctx.getService at execute time) because the
|
|
168
|
+
// service has no per-request state — one instance for the life
|
|
169
|
+
// of the plugin is correct.
|
|
170
|
+
const automationActions = env.getExtensionPoint(
|
|
171
|
+
automationActionExtensionPoint,
|
|
172
|
+
);
|
|
173
|
+
for (const action of createIncidentActions({
|
|
174
|
+
service,
|
|
175
|
+
getIncidentEntity: () => incidentEntity,
|
|
176
|
+
})) {
|
|
177
|
+
automationActions.registerAction(action, pluginMetadata);
|
|
178
|
+
}
|
|
179
|
+
|
|
156
180
|
// Register "Create Incident" command in the command palette
|
|
157
181
|
registerSearchProvider({
|
|
158
182
|
pluginMetadata,
|
|
@@ -184,7 +208,7 @@ export default createBackendPlugin({
|
|
|
184
208
|
// associations) + register subscription specs. Per-system /
|
|
185
209
|
// per-group notification group lifecycle is fully owned by
|
|
186
210
|
// notification-backend now — incident never touches it.
|
|
187
|
-
afterPluginsReady: async ({ database, logger,
|
|
211
|
+
afterPluginsReady: async ({ database, logger, rpcClient }) => {
|
|
188
212
|
const typedDb = database as SafeDatabase<typeof schema>;
|
|
189
213
|
const service = new IncidentService(typedDb);
|
|
190
214
|
const notificationClient = rpcClient.forPlugin(NotificationApi);
|
|
@@ -198,17 +222,27 @@ export default createBackendPlugin({
|
|
|
198
222
|
),
|
|
199
223
|
]);
|
|
200
224
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
225
|
+
// React to catalog system deletion (tombstone) via the reactive
|
|
226
|
+
// `catalog-system` entity instead of the (being-removed)
|
|
227
|
+
// `system.deleted` hook (§10.1). `work-queue` delivery preserved:
|
|
228
|
+
// association cleanup is a side-effecting write that must run once
|
|
229
|
+
// per cluster, not per-instance.
|
|
230
|
+
onEntityChanged({
|
|
231
|
+
kind: CATALOG_SYSTEM_ENTITY_KIND,
|
|
232
|
+
handler: async (change) => {
|
|
233
|
+
if (change.next !== null) return; // tombstone only
|
|
234
|
+
const systemId = change.id;
|
|
204
235
|
logger.debug(
|
|
205
|
-
`Cleaning up incident associations for deleted system: ${
|
|
236
|
+
`Cleaning up incident associations for deleted system: ${systemId}`,
|
|
206
237
|
);
|
|
207
|
-
await service.removeSystemAssociations(
|
|
208
|
-
await incidentCache?.invalidateSystem(
|
|
238
|
+
await service.removeSystemAssociations(systemId);
|
|
239
|
+
await incidentCache?.invalidateSystem(systemId);
|
|
209
240
|
},
|
|
210
|
-
|
|
211
|
-
|
|
241
|
+
delivery: {
|
|
242
|
+
mode: "work-queue",
|
|
243
|
+
workerGroup: "incident-system-cleanup",
|
|
244
|
+
},
|
|
245
|
+
});
|
|
212
246
|
|
|
213
247
|
logger.debug("✅ Incident Backend afterPluginsReady complete.");
|
|
214
248
|
},
|
package/src/router.ts
CHANGED
|
@@ -14,10 +14,19 @@ import type { IncidentService } from "./service";
|
|
|
14
14
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
15
15
|
import { AuthApi } from "@checkstack/auth-common";
|
|
16
16
|
import type { InferClient } from "@checkstack/common";
|
|
17
|
-
import { incidentHooks } from "./hooks";
|
|
18
17
|
import { notifyAffectedSystems } from "./notifications";
|
|
19
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
IncidentUpdate,
|
|
20
|
+
IncidentWithSystems,
|
|
21
|
+
} from "@checkstack/incident-common";
|
|
20
22
|
import type { IncidentCache } from "./cache";
|
|
23
|
+
import type { EntityHandle } from "@checkstack/automation-backend";
|
|
24
|
+
import {
|
|
25
|
+
writeIncidentEntity,
|
|
26
|
+
removeIncidentEntity,
|
|
27
|
+
toIncidentEntityState,
|
|
28
|
+
type IncidentEntityState,
|
|
29
|
+
} from "./incident-entity";
|
|
21
30
|
|
|
22
31
|
export function createRouter(
|
|
23
32
|
service: IncidentService,
|
|
@@ -29,6 +38,8 @@ export function createRouter(
|
|
|
29
38
|
authClient: InferClient<typeof AuthApi>,
|
|
30
39
|
logger: Logger,
|
|
31
40
|
cache: IncidentCache,
|
|
41
|
+
/** Resolver for the reactive `incident` entity (§10.1). Undefined in tests. */
|
|
42
|
+
getIncidentEntity?: () => EntityHandle<IncidentEntityState> | undefined,
|
|
32
43
|
) {
|
|
33
44
|
/**
|
|
34
45
|
* Resolve user IDs to profile names for a list of updates.
|
|
@@ -148,7 +159,23 @@ export function createRouter(
|
|
|
148
159
|
createIncident: os.createIncident.handler(async ({ input, context }) => {
|
|
149
160
|
const userId =
|
|
150
161
|
context.user && "id" in context.user ? context.user.id : undefined;
|
|
151
|
-
|
|
162
|
+
|
|
163
|
+
// Drive the create through the reactive `incident` entity (§10.1):
|
|
164
|
+
// `apply` performs the REAL `incidents`/junction write (the plugin's own
|
|
165
|
+
// db/tx) and returns the new reactive state; the deriver fires
|
|
166
|
+
// `incident.created` from the resulting change. The id is generated up
|
|
167
|
+
// front so the handle is keyed on it and the create's `prev` snapshot
|
|
168
|
+
// correctly reads the not-yet-existing row as absent.
|
|
169
|
+
const incidentId = crypto.randomUUID();
|
|
170
|
+
let result!: Awaited<ReturnType<typeof service.createIncident>>;
|
|
171
|
+
await writeIncidentEntity({
|
|
172
|
+
handle: getIncidentEntity?.(),
|
|
173
|
+
incidentId,
|
|
174
|
+
apply: async () => {
|
|
175
|
+
result = await service.createIncident(input, userId, incidentId);
|
|
176
|
+
return toIncidentEntityState(result);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
152
179
|
|
|
153
180
|
// Invalidate before signal so any frontend that refetches in response
|
|
154
181
|
// sees fresh data. The mutation invariant for every handler in this
|
|
@@ -165,17 +192,6 @@ export function createRouter(
|
|
|
165
192
|
action: "created",
|
|
166
193
|
});
|
|
167
194
|
|
|
168
|
-
// Emit hook for cross-plugin coordination
|
|
169
|
-
await context.emitHook(incidentHooks.incidentCreated, {
|
|
170
|
-
incidentId: result.id,
|
|
171
|
-
systemIds: result.systemIds,
|
|
172
|
-
title: result.title,
|
|
173
|
-
description: result.description,
|
|
174
|
-
severity: result.severity,
|
|
175
|
-
status: result.status,
|
|
176
|
-
createdAt: result.createdAt.toISOString(),
|
|
177
|
-
});
|
|
178
|
-
|
|
179
195
|
// Send notifications to system subscribers
|
|
180
196
|
const systemNames = await resolveSystemNames(result.systemIds);
|
|
181
197
|
await notifyAffectedSystems({
|
|
@@ -193,12 +209,32 @@ export function createRouter(
|
|
|
193
209
|
return result;
|
|
194
210
|
}),
|
|
195
211
|
|
|
196
|
-
updateIncident: os.updateIncident.handler(async ({ input
|
|
197
|
-
|
|
198
|
-
|
|
212
|
+
updateIncident: os.updateIncident.handler(async ({ input }) => {
|
|
213
|
+
// Probe existence first so a missing incident still surfaces as
|
|
214
|
+
// NOT_FOUND without driving an entity write.
|
|
215
|
+
const exists = await service.getIncident(input.id);
|
|
216
|
+
if (!exists) {
|
|
199
217
|
throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
|
|
200
218
|
}
|
|
201
219
|
|
|
220
|
+
// Drive the update through the reactive `incident` entity (§10.1);
|
|
221
|
+
// `apply` performs the REAL update (the plugin's own db/tx) and returns
|
|
222
|
+
// the new reactive state. The deriver fires `incident.updated` (or
|
|
223
|
+
// `incident.resolved` on a resolution) from the resulting change.
|
|
224
|
+
let result!: NonNullable<Awaited<ReturnType<typeof service.updateIncident>>>;
|
|
225
|
+
await writeIncidentEntity({
|
|
226
|
+
handle: getIncidentEntity?.(),
|
|
227
|
+
incidentId: input.id,
|
|
228
|
+
apply: async () => {
|
|
229
|
+
const updated = await service.updateIncident(input);
|
|
230
|
+
if (!updated) {
|
|
231
|
+
throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
|
|
232
|
+
}
|
|
233
|
+
result = updated;
|
|
234
|
+
return toIncidentEntityState(result);
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
202
238
|
await cache.invalidateForMutation({
|
|
203
239
|
incidentId: result.id,
|
|
204
240
|
systemIds: result.systemIds,
|
|
@@ -211,16 +247,6 @@ export function createRouter(
|
|
|
211
247
|
action: "updated",
|
|
212
248
|
});
|
|
213
249
|
|
|
214
|
-
// Emit hook for cross-plugin coordination
|
|
215
|
-
await context.emitHook(incidentHooks.incidentUpdated, {
|
|
216
|
-
incidentId: result.id,
|
|
217
|
-
systemIds: result.systemIds,
|
|
218
|
-
title: result.title,
|
|
219
|
-
description: result.description,
|
|
220
|
-
severity: result.severity,
|
|
221
|
-
status: result.status,
|
|
222
|
-
});
|
|
223
|
-
|
|
224
250
|
// Send notifications to system subscribers
|
|
225
251
|
const systemNames = await resolveSystemNames(result.systemIds);
|
|
226
252
|
await notifyAffectedSystems({
|
|
@@ -248,11 +274,30 @@ export function createRouter(
|
|
|
248
274
|
: undefined;
|
|
249
275
|
const previousStatus = previousIncident?.status;
|
|
250
276
|
|
|
251
|
-
|
|
277
|
+
// Drive the update through the reactive `incident` entity (§10.1).
|
|
278
|
+
// `apply` posts the update row + (optionally) flips status in the
|
|
279
|
+
// plugin's own db/tx, then re-reads the post-write reactive state. The
|
|
280
|
+
// deriver fires `incident.resolved` on a transition to resolved,
|
|
281
|
+
// otherwise `incident.updated` — purely from the entity diff (so the
|
|
282
|
+
// `statusChange` branch collapses into the single driven write). When
|
|
283
|
+
// the status is unchanged, the diff is empty and no event fires.
|
|
284
|
+
let result!: Awaited<ReturnType<typeof service.addUpdate>>;
|
|
285
|
+
let incident: Awaited<ReturnType<typeof service.getIncident>>;
|
|
286
|
+
await writeIncidentEntity({
|
|
287
|
+
handle: getIncidentEntity?.(),
|
|
288
|
+
incidentId: input.incidentId,
|
|
289
|
+
apply: async () => {
|
|
290
|
+
result = await service.addUpdate(input, userId);
|
|
291
|
+
incident = await service.getIncident(input.incidentId);
|
|
292
|
+
// The incident must exist (the update FK-references it); guard for
|
|
293
|
+
// the type and to fail loudly if it vanished mid-write.
|
|
294
|
+
if (!incident) {
|
|
295
|
+
throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
|
|
296
|
+
}
|
|
297
|
+
return toIncidentEntityState(incident);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
252
300
|
|
|
253
|
-
// Read post-write state directly from the service so the broadcast
|
|
254
|
-
// payload is fresh; the cache is invalidated below before the signal.
|
|
255
|
-
const incident = await service.getIncident(input.incidentId);
|
|
256
301
|
if (incident) {
|
|
257
302
|
await cache.invalidateForMutation({
|
|
258
303
|
incidentId: input.incidentId,
|
|
@@ -265,28 +310,6 @@ export function createRouter(
|
|
|
265
310
|
action: "updated",
|
|
266
311
|
});
|
|
267
312
|
|
|
268
|
-
// Emit hook for cross-plugin coordination
|
|
269
|
-
await context.emitHook(incidentHooks.incidentUpdated, {
|
|
270
|
-
incidentId: input.incidentId,
|
|
271
|
-
systemIds: incident.systemIds,
|
|
272
|
-
title: incident.title,
|
|
273
|
-
description: incident.description,
|
|
274
|
-
severity: incident.severity,
|
|
275
|
-
status: incident.status,
|
|
276
|
-
statusChange: input.statusChange,
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
// If status changed to resolved, emit resolved hook
|
|
280
|
-
if (input.statusChange === "resolved") {
|
|
281
|
-
await context.emitHook(incidentHooks.incidentResolved, {
|
|
282
|
-
incidentId: input.incidentId,
|
|
283
|
-
systemIds: incident.systemIds,
|
|
284
|
-
title: incident.title,
|
|
285
|
-
severity: incident.severity,
|
|
286
|
-
resolvedAt: new Date().toISOString(),
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
|
|
290
313
|
// Send notifications when status changes
|
|
291
314
|
if (input.statusChange && previousStatus !== input.statusChange) {
|
|
292
315
|
// Determine notification action based on status transition
|
|
@@ -321,15 +344,34 @@ export function createRouter(
|
|
|
321
344
|
resolveIncident: os.resolveIncident.handler(async ({ input, context }) => {
|
|
322
345
|
const userId =
|
|
323
346
|
context.user && "id" in context.user ? context.user.id : undefined;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
userId,
|
|
328
|
-
);
|
|
329
|
-
if (!result) {
|
|
347
|
+
|
|
348
|
+
const exists = await service.getIncident(input.id);
|
|
349
|
+
if (!exists) {
|
|
330
350
|
throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
|
|
331
351
|
}
|
|
332
352
|
|
|
353
|
+
// Drive the resolve through the reactive `incident` entity (§10.1);
|
|
354
|
+
// `apply` performs the REAL resolve (the plugin's own db/tx) and returns
|
|
355
|
+
// the new reactive state. The deriver fires `incident.resolved` from the
|
|
356
|
+
// status → resolved transition.
|
|
357
|
+
let result!: NonNullable<Awaited<ReturnType<typeof service.resolveIncident>>>;
|
|
358
|
+
await writeIncidentEntity({
|
|
359
|
+
handle: getIncidentEntity?.(),
|
|
360
|
+
incidentId: input.id,
|
|
361
|
+
apply: async () => {
|
|
362
|
+
const resolved = await service.resolveIncident(
|
|
363
|
+
input.id,
|
|
364
|
+
input.message,
|
|
365
|
+
userId,
|
|
366
|
+
);
|
|
367
|
+
if (!resolved) {
|
|
368
|
+
throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
|
|
369
|
+
}
|
|
370
|
+
result = resolved;
|
|
371
|
+
return toIncidentEntityState(result);
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
333
375
|
await cache.invalidateForMutation({
|
|
334
376
|
incidentId: result.id,
|
|
335
377
|
systemIds: result.systemIds,
|
|
@@ -342,15 +384,6 @@ export function createRouter(
|
|
|
342
384
|
action: "resolved",
|
|
343
385
|
});
|
|
344
386
|
|
|
345
|
-
// Emit hook for cross-plugin coordination
|
|
346
|
-
await context.emitHook(incidentHooks.incidentResolved, {
|
|
347
|
-
incidentId: result.id,
|
|
348
|
-
systemIds: result.systemIds,
|
|
349
|
-
title: result.title,
|
|
350
|
-
severity: result.severity,
|
|
351
|
-
resolvedAt: new Date().toISOString(),
|
|
352
|
-
});
|
|
353
|
-
|
|
354
387
|
// Send notifications to system subscribers
|
|
355
388
|
const systemNames = await resolveSystemNames(result.systemIds);
|
|
356
389
|
await notifyAffectedSystems({
|
|
@@ -369,10 +402,27 @@ export function createRouter(
|
|
|
369
402
|
}),
|
|
370
403
|
|
|
371
404
|
deleteIncident: os.deleteIncident.handler(async ({ input }) => {
|
|
372
|
-
// Get incident before deleting to get systemIds
|
|
405
|
+
// Get incident before deleting to get systemIds.
|
|
373
406
|
const incident = await service.getIncident(input.id);
|
|
374
|
-
|
|
375
|
-
|
|
407
|
+
if (!incident) {
|
|
408
|
+
return { success: false };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Drive the delete through the reactive `incident` entity tombstone
|
|
412
|
+
// (§10.1). `apply` performs the REAL delete (the plugin's own db/tx);
|
|
413
|
+
// the framework records the tombstone transition and emits a tombstone
|
|
414
|
+
// change. No `incident.deleted` trigger event exists, so the deriver
|
|
415
|
+
// fires nothing. `success` tracks whether the row was actually deleted.
|
|
416
|
+
let success = false;
|
|
417
|
+
await removeIncidentEntity({
|
|
418
|
+
handle: getIncidentEntity?.(),
|
|
419
|
+
incidentId: input.id,
|
|
420
|
+
apply: async () => {
|
|
421
|
+
success = await service.deleteIncident(input.id);
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
if (success) {
|
|
376
426
|
await cache.invalidateForMutation({
|
|
377
427
|
incidentId: input.id,
|
|
378
428
|
systemIds: incident.systemIds,
|
|
@@ -396,11 +446,22 @@ export function createRouter(
|
|
|
396
446
|
}),
|
|
397
447
|
|
|
398
448
|
createAutoIncident: os.createAutoIncident.handler(
|
|
399
|
-
async ({ input
|
|
400
|
-
// No user context for service-initiated incidents; createdBy
|
|
401
|
-
//
|
|
402
|
-
// the
|
|
403
|
-
|
|
449
|
+
async ({ input }) => {
|
|
450
|
+
// No user context for service-initiated incidents; createdBy stays
|
|
451
|
+
// null and the timeline shows the originating plugin via the entity
|
|
452
|
+
// state. Driven through the reactive `incident` entity (§10.1); the
|
|
453
|
+
// deriver fires `incident.created` from the resulting change. The id
|
|
454
|
+
// is generated up front so the create's `prev` snapshot is null.
|
|
455
|
+
const incidentId = crypto.randomUUID();
|
|
456
|
+
let result!: Awaited<ReturnType<typeof service.createIncident>>;
|
|
457
|
+
await writeIncidentEntity({
|
|
458
|
+
handle: getIncidentEntity?.(),
|
|
459
|
+
incidentId,
|
|
460
|
+
apply: async () => {
|
|
461
|
+
result = await service.createIncident(input, undefined, incidentId);
|
|
462
|
+
return toIncidentEntityState(result);
|
|
463
|
+
},
|
|
464
|
+
});
|
|
404
465
|
|
|
405
466
|
await cache.invalidateForMutation({
|
|
406
467
|
incidentId: result.id,
|
|
@@ -413,16 +474,6 @@ export function createRouter(
|
|
|
413
474
|
action: "created",
|
|
414
475
|
});
|
|
415
476
|
|
|
416
|
-
await context.emitHook(incidentHooks.incidentCreated, {
|
|
417
|
-
incidentId: result.id,
|
|
418
|
-
systemIds: result.systemIds,
|
|
419
|
-
title: result.title,
|
|
420
|
-
description: result.description,
|
|
421
|
-
severity: result.severity,
|
|
422
|
-
status: result.status,
|
|
423
|
-
createdAt: result.createdAt.toISOString(),
|
|
424
|
-
});
|
|
425
|
-
|
|
426
477
|
const systemNames = await resolveSystemNames(result.systemIds);
|
|
427
478
|
await notifyAffectedSystems({
|
|
428
479
|
catalogClient,
|
|
@@ -441,10 +492,31 @@ export function createRouter(
|
|
|
441
492
|
),
|
|
442
493
|
|
|
443
494
|
resolveAutoIncident: os.resolveAutoIncident.handler(
|
|
444
|
-
async ({ input
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
//
|
|
495
|
+
async ({ input }) => {
|
|
496
|
+
// Idempotent: a missing incident is treated as success so the
|
|
497
|
+
// auto-close worker can be re-run safely. Probe first so the no-op
|
|
498
|
+
// case never drives an entity write.
|
|
499
|
+
const exists = await service.getIncident(input.id);
|
|
500
|
+
if (!exists) {
|
|
501
|
+
return { success: true };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Drive the resolve through the reactive `incident` entity (§10.1):
|
|
505
|
+
// the REAL resolve runs INSIDE `apply`, so `prev` is snapshotted
|
|
506
|
+
// before the status flips and the deriver fires `incident.resolved`
|
|
507
|
+
// from the status → resolved transition. An already-resolved incident
|
|
508
|
+
// yields an empty diff and no event — the idempotent re-run case.
|
|
509
|
+
let result: IncidentWithSystems | undefined;
|
|
510
|
+
await writeIncidentEntity({
|
|
511
|
+
handle: getIncidentEntity?.(),
|
|
512
|
+
incidentId: input.id,
|
|
513
|
+
apply: async () => {
|
|
514
|
+
result = await service.resolveIncident(input.id, input.message);
|
|
515
|
+
// The probe found it; a race could still delete it mid-write.
|
|
516
|
+
// Fall back to the pre-write state so the diff is a no-op.
|
|
517
|
+
return toIncidentEntityState(result ?? exists);
|
|
518
|
+
},
|
|
519
|
+
});
|
|
448
520
|
if (!result) {
|
|
449
521
|
return { success: true };
|
|
450
522
|
}
|
|
@@ -460,14 +532,6 @@ export function createRouter(
|
|
|
460
532
|
action: "resolved",
|
|
461
533
|
});
|
|
462
534
|
|
|
463
|
-
await context.emitHook(incidentHooks.incidentResolved, {
|
|
464
|
-
incidentId: result.id,
|
|
465
|
-
systemIds: result.systemIds,
|
|
466
|
-
title: result.title,
|
|
467
|
-
severity: result.severity,
|
|
468
|
-
resolvedAt: new Date().toISOString(),
|
|
469
|
-
});
|
|
470
|
-
|
|
471
535
|
const systemNames = await resolveSystemNames(result.systemIds);
|
|
472
536
|
await notifyAffectedSystems({
|
|
473
537
|
catalogClient,
|