@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/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 { integrationEventExtensionPoint } from "@checkstack/integration-backend";
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 { catalogHooks } from "@checkstack/catalog-backend";
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
- // Integration Event Payload Schemas
47
+ // Plugin Definition
31
48
  // =============================================================================
32
49
 
33
- const incidentCreatedPayloadSchema = z.object({
34
- incidentId: z.string(),
35
- systemIds: z.array(z.string()),
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
- // Plugin Definition
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 hooks as integration events
75
- const integrationEvents = env.getExtensionPoint(
76
- integrationEventExtensionPoint,
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
- integrationEvents.registerEvent(
80
- {
81
- hook: incidentHooks.incidentCreated,
82
- displayName: "Incident Created",
83
- description: "Fired when a new incident is created",
84
- category: "Incidents",
85
- payloadSchema: incidentCreatedPayloadSchema,
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
- pluginMetadata,
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
- integrationEvents.registerEvent(
91
- {
92
- hook: incidentHooks.incidentUpdated,
93
- displayName: "Incident Updated",
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
- integrationEvents.registerEvent(
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, onHook, rpcClient }) => {
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
- onHook(
202
- catalogHooks.systemDeleted,
203
- async (payload) => {
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: ${payload.systemId}`,
236
+ `Cleaning up incident associations for deleted system: ${systemId}`,
206
237
  );
207
- await service.removeSystemAssociations(payload.systemId);
208
- await incidentCache?.invalidateSystem(payload.systemId);
238
+ await service.removeSystemAssociations(systemId);
239
+ await incidentCache?.invalidateSystem(systemId);
209
240
  },
210
- { mode: "work-queue", workerGroup: "incident-system-cleanup" },
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 { IncidentUpdate } from "@checkstack/incident-common";
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
- const result = await service.createIncident(input, userId);
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, context }) => {
197
- const result = await service.updateIncident(input);
198
- if (!result) {
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
- const result = await service.addUpdate(input, userId);
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
- const result = await service.resolveIncident(
325
- input.id,
326
- input.message,
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
- const success = await service.deleteIncident(input.id);
375
- if (success && incident) {
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, context }) => {
400
- // No user context for service-initiated incidents; createdBy
401
- // stays null and the timeline shows the originating plugin via
402
- // the hook payload.
403
- const result = await service.createIncident(input);
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, context }) => {
445
- const result = await service.resolveIncident(input.id, input.message);
446
- // Idempotent: a missing or already-resolved incident is treated
447
- // as success so the auto-close worker can be re-run safely.
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,