@checkstack/incident-backend 1.3.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.
@@ -14,32 +14,46 @@ import { z } from "zod";
14
14
  import { Versioned } from "@checkstack/backend-api";
15
15
  import type {
16
16
  ActionDefinition,
17
+ ArtifactTypeDefinition,
18
+ EntityHandle,
17
19
  TriggerDefinition,
18
20
  } from "@checkstack/automation-backend";
21
+ import { makeEntityDrivenTriggerSetup } from "@checkstack/automation-backend";
19
22
  import {
20
23
  IncidentSeverityEnum,
21
24
  IncidentStatusEnum,
22
25
  } from "@checkstack/incident-common";
23
26
 
24
- import { incidentHooks } from "./hooks";
25
27
  import type { IncidentService } from "./service";
28
+ import {
29
+ toIncidentEntityState,
30
+ writeIncidentEntity,
31
+ type IncidentEntityState,
32
+ } from "./incident-entity";
26
33
 
27
34
  // ─── Payload schemas — match the hook payloads exactly ─────────────────
28
35
 
36
+ // NOTE (reactive entity payload mapper): the reactive `incident` entity state
37
+ // is only `{ status, severity, systemIds }`. The descriptive fields below
38
+ // (`title`, `description`, `createdAt`, `resolvedAt`) are NOT derivable from an
39
+ // entity change, so they are OPTIONAL — the entity-driven `trigger.payload`
40
+ // (see `incidentChangeToPayload`) carries `incidentId` / `systemIds` /
41
+ // `severity` / `status` / `statusChange` only. They stay in the schema for
42
+ // editor introspection / forward compatibility.
29
43
  const incidentCreatedPayloadSchema = z.object({
30
44
  incidentId: z.string(),
31
45
  systemIds: z.array(z.string()),
32
- title: z.string(),
46
+ title: z.string().optional(),
33
47
  description: z.string().optional(),
34
48
  severity: IncidentSeverityEnum,
35
49
  status: IncidentStatusEnum,
36
- createdAt: z.string(),
50
+ createdAt: z.string().optional(),
37
51
  });
38
52
 
39
53
  const incidentUpdatedPayloadSchema = z.object({
40
54
  incidentId: z.string(),
41
55
  systemIds: z.array(z.string()),
42
- title: z.string(),
56
+ title: z.string().optional(),
43
57
  description: z.string().optional(),
44
58
  severity: IncidentSeverityEnum,
45
59
  status: IncidentStatusEnum,
@@ -49,13 +63,17 @@ const incidentUpdatedPayloadSchema = z.object({
49
63
  const incidentResolvedPayloadSchema = z.object({
50
64
  incidentId: z.string(),
51
65
  systemIds: z.array(z.string()),
52
- title: z.string(),
66
+ title: z.string().optional(),
53
67
  severity: IncidentSeverityEnum,
54
- resolvedAt: z.string(),
68
+ resolvedAt: z.string().optional(),
55
69
  });
56
70
 
57
71
  // ─── Triggers ──────────────────────────────────────────────────────────
58
72
 
73
+ // These triggers are ENTITY-DRIVEN (§10.1): the `incident` entity's change
74
+ // deriver fires `incident.created/.updated/.resolved` via Stage-1 routing,
75
+ // so they no longer subscribe to a hook. A no-op `setup` keeps them in the
76
+ // editor's trigger catalog without re-introducing a hook.
59
77
  export const incidentCreatedTrigger: TriggerDefinition<
60
78
  z.infer<typeof incidentCreatedPayloadSchema>
61
79
  > = {
@@ -65,7 +83,9 @@ export const incidentCreatedTrigger: TriggerDefinition<
65
83
  category: "Incidents",
66
84
  icon: "CircleAlert",
67
85
  payloadSchema: incidentCreatedPayloadSchema,
68
- hook: incidentHooks.incidentCreated,
86
+ setup: makeEntityDrivenTriggerSetup<
87
+ z.infer<typeof incidentCreatedPayloadSchema>
88
+ >(),
69
89
  contextKey: (p) => p.incidentId,
70
90
  };
71
91
 
@@ -74,11 +94,14 @@ export const incidentUpdatedTrigger: TriggerDefinition<
74
94
  > = {
75
95
  id: "updated",
76
96
  displayName: "Incident Updated",
77
- description: "Fires when an incident is updated (info or status change)",
97
+ description:
98
+ "Fires when an incident's reactive state changes (status, severity, or affected systems). A comment-only update does not fire this trigger.",
78
99
  category: "Incidents",
79
100
  icon: "CircleAlert",
80
101
  payloadSchema: incidentUpdatedPayloadSchema,
81
- hook: incidentHooks.incidentUpdated,
102
+ setup: makeEntityDrivenTriggerSetup<
103
+ z.infer<typeof incidentUpdatedPayloadSchema>
104
+ >(),
82
105
  contextKey: (p) => p.incidentId,
83
106
  };
84
107
 
@@ -91,7 +114,9 @@ export const incidentResolvedTrigger: TriggerDefinition<
91
114
  category: "Incidents",
92
115
  icon: "CircleCheck",
93
116
  payloadSchema: incidentResolvedPayloadSchema,
94
- hook: incidentHooks.incidentResolved,
117
+ setup: makeEntityDrivenTriggerSetup<
118
+ z.infer<typeof incidentResolvedPayloadSchema>
119
+ >(),
95
120
  contextKey: (p) => p.incidentId,
96
121
  };
97
122
 
@@ -107,6 +132,32 @@ export const incidentTriggers: TriggerDefinition<unknown>[] = [
107
132
  incidentResolvedTrigger as TriggerDefinition<unknown>,
108
133
  ];
109
134
 
135
+ // ─── incident artifact type ────────────────────────────────────────────
136
+
137
+ /**
138
+ * The `incident` artifact represents an incident opened by an upstream
139
+ * action (e.g. `incident.create`). Downstream actions in the same run
140
+ * (`incident.resolve`, `incident.add_update`, `incident.update_status`)
141
+ * can consume it to act on that incident without the operator repeating
142
+ * the id — the open-then-wait-then-resolve flow the default auto-incident
143
+ * automations rely on.
144
+ */
145
+ const incidentDataSchema = z.object({
146
+ incidentId: z.string(),
147
+ status: z.string(),
148
+ severity: z.string(),
149
+ systemIds: z.array(z.string()),
150
+ });
151
+
152
+ export const incidentArtifactType: ArtifactTypeDefinition<
153
+ z.infer<typeof incidentDataSchema>
154
+ > = {
155
+ id: "incident",
156
+ displayName: "Incident",
157
+ description: "An incident opened by an upstream automation action",
158
+ schema: incidentDataSchema,
159
+ };
160
+
110
161
  // ─── Action configs ────────────────────────────────────────────────────
111
162
 
112
163
  const incidentCreateConfigSchema = z.object({
@@ -116,21 +167,42 @@ const incidentCreateConfigSchema = z.object({
116
167
  systemIds: z.array(z.string()).min(1),
117
168
  initialMessage: z.string().optional(),
118
169
  suppressNotifications: z.boolean().optional().default(false),
170
+ /**
171
+ * When true, reuse an existing OPEN incident on the first target
172
+ * system instead of opening a duplicate (the old auto-incident
173
+ * `findActiveAutoIncident(systemId)` semantic). The reused incident is
174
+ * returned as the produced `incident` artifact so downstream
175
+ * resolve/update actions still work. Default false — existing and
176
+ * custom automations always create.
177
+ */
178
+ dedupe_open_for_system: z.boolean().optional().default(false),
119
179
  });
120
180
 
181
+ // `incidentId` is optional on the close/update actions: when omitted the
182
+ // action falls back to the upstream `incident` artifact in run scope
183
+ // (config takes priority, else artifact). Mirrors Jira's resolveIssueKey.
121
184
  const incidentResolveConfigSchema = z.object({
122
- incidentId: z.string().min(1),
185
+ incidentId: z
186
+ .string()
187
+ .optional()
188
+ .describe("Defaults to the upstream incident artifact in the run."),
123
189
  message: z.string().optional(),
124
190
  });
125
191
 
126
192
  const incidentAddUpdateConfigSchema = z.object({
127
- incidentId: z.string().min(1),
193
+ incidentId: z
194
+ .string()
195
+ .optional()
196
+ .describe("Defaults to the upstream incident artifact in the run."),
128
197
  message: z.string().min(1),
129
198
  statusChange: IncidentStatusEnum.optional(),
130
199
  });
131
200
 
132
201
  const incidentUpdateStatusConfigSchema = z.object({
133
- incidentId: z.string().min(1),
202
+ incidentId: z
203
+ .string()
204
+ .optional()
205
+ .describe("Defaults to the upstream incident artifact in the run."),
134
206
  status: IncidentStatusEnum,
135
207
  /**
136
208
  * Optional accompanying message. Defaults to a generic transition note
@@ -153,16 +225,46 @@ interface IncidentUpdateArtifact {
153
225
  incidentId: string;
154
226
  }
155
227
 
228
+ /**
229
+ * Resolve an incident id from explicit config or fall back to the
230
+ * upstream `incident` artifact in the run scope (config takes priority).
231
+ * Mirrors Jira's `resolveIssueKey` pattern.
232
+ */
233
+ function resolveIncidentId(
234
+ configId: string | undefined,
235
+ consumed: Record<string, unknown>,
236
+ ): string | undefined {
237
+ if (configId && configId.trim().length > 0) return configId;
238
+ const incident = consumed["incident"];
239
+ if (
240
+ incident &&
241
+ typeof incident === "object" &&
242
+ "incidentId" in incident &&
243
+ typeof (incident as { incidentId: unknown }).incidentId === "string"
244
+ ) {
245
+ return (incident as { incidentId: string }).incidentId;
246
+ }
247
+ return;
248
+ }
249
+
156
250
  // ─── Actions ───────────────────────────────────────────────────────────
157
251
 
158
252
  export interface IncidentActionDeps {
159
253
  service: IncidentService;
254
+ /**
255
+ * Resolver for the reactive `incident` entity (§10.1). When present, the
256
+ * `incident.create` action drives its write through `handle.mutate` so an
257
+ * action-created incident becomes reactive like any other (6(a)). Undefined
258
+ * in tests / before the handle is wired — the create still runs, just
259
+ * non-reactively.
260
+ */
261
+ getIncidentEntity?: () => EntityHandle<IncidentEntityState> | undefined;
160
262
  }
161
263
 
162
264
  export function createIncidentActions(
163
265
  deps: IncidentActionDeps,
164
266
  ): ActionDefinition<unknown, unknown>[] {
165
- const { service } = deps;
267
+ const { service, getIncidentEntity } = deps;
166
268
 
167
269
  const createAction: ActionDefinition<
168
270
  z.infer<typeof incidentCreateConfigSchema>,
@@ -177,14 +279,89 @@ export function createIncidentActions(
177
279
  version: 1,
178
280
  schema: incidentCreateConfigSchema,
179
281
  }),
180
- execute: async ({ config, logger }) => {
181
- const incident = await service.createIncident({
282
+ produces: "incident",
283
+ execute: async ({ config, logger, runId }) => {
284
+ const createInput = {
182
285
  title: config.title,
183
286
  description: config.description,
184
287
  severity: config.severity,
185
288
  systemIds: config.systemIds,
186
289
  initialMessage: config.initialMessage,
187
290
  suppressNotifications: config.suppressNotifications,
291
+ };
292
+ const handle = getIncidentEntity?.();
293
+
294
+ // Per-system dedup (opt-in): if an open incident already exists on
295
+ // the first target system, reuse it instead of opening a duplicate.
296
+ // Reproduces the old auto-incident `findActiveAutoIncident` semantic
297
+ // and keeps at most one open auto-incident per system across all the
298
+ // default sustained/flapping automations. The check + create are
299
+ // serialized per system inside the service (advisory lock), so two
300
+ // concurrent triggers (e.g. sustained + flapping) for the same system
301
+ // can't both find none and both create.
302
+ //
303
+ // 6(a): an action-created incident is now reactive — the create runs
304
+ // through `handle.mutate`, so the deriver fires `incident.created` and
305
+ // automations can trigger on it. A REUSED incident drives NO entity
306
+ // write (it already exists and is unchanged, so no duplicate
307
+ // `incident.created`). The id is generated up front so a real create's
308
+ // `prev` snapshot reads the not-yet-existing row as absent.
309
+ if (config.dedupe_open_for_system) {
310
+ const newId = crypto.randomUUID();
311
+ // The create (when not reused) runs INSIDE the dedup lock via
312
+ // `onCreate`, driven through `handle.mutate` so `prev` is snapshotted
313
+ // (absent → null) BEFORE the insert and `incident.created` fires. A
314
+ // REUSE never calls `onCreate`, so no entity write and no duplicate
315
+ // `incident.created` — matching the pre-reactive dedupe behavior.
316
+ const { incident, reused } =
317
+ await service.createIncidentDedupedForSystem(
318
+ createInput,
319
+ config.systemIds[0]!,
320
+ undefined,
321
+ newId,
322
+ async (create) => {
323
+ let created!: Awaited<ReturnType<typeof create>>;
324
+ await writeIncidentEntity({
325
+ handle,
326
+ incidentId: newId,
327
+ opts: { runId },
328
+ apply: async () => {
329
+ created = await create();
330
+ return toIncidentEntityState(created);
331
+ },
332
+ });
333
+ return created;
334
+ },
335
+ );
336
+ if (reused) {
337
+ logger.info(
338
+ `Automation reused open incident ${incident.id} for system ${config.systemIds[0]} (dedupe)`,
339
+ );
340
+ } else {
341
+ logger.info(`Automation created incident ${incident.id}`);
342
+ }
343
+ return {
344
+ success: true,
345
+ externalId: incident.id,
346
+ artifact: {
347
+ incidentId: incident.id,
348
+ status: incident.status,
349
+ severity: incident.severity,
350
+ systemIds: incident.systemIds,
351
+ },
352
+ };
353
+ }
354
+
355
+ const newId = crypto.randomUUID();
356
+ let incident!: Awaited<ReturnType<typeof service.createIncident>>;
357
+ await writeIncidentEntity({
358
+ handle,
359
+ incidentId: newId,
360
+ opts: { runId },
361
+ apply: async () => {
362
+ incident = await service.createIncident(createInput, undefined, newId);
363
+ return toIncidentEntityState(incident);
364
+ },
188
365
  });
189
366
  logger.info(`Automation created incident ${incident.id}`);
190
367
  return {
@@ -213,15 +390,55 @@ export function createIncidentActions(
213
390
  version: 1,
214
391
  schema: incidentResolveConfigSchema,
215
392
  }),
216
- execute: async ({ config, logger }) => {
217
- const incident = await service.resolveIncident(
218
- config.incidentId,
219
- config.message,
220
- );
393
+ consumes: ["incident"],
394
+ execute: async ({ config, consumedArtifacts, logger, runId }) => {
395
+ const incidentId = resolveIncidentId(config.incidentId, consumedArtifacts);
396
+ if (!incidentId) {
397
+ return {
398
+ success: false,
399
+ error: "No incidentId given and no upstream incident artifact found",
400
+ };
401
+ }
402
+ // Guard existence BEFORE the driven write so `apply` never throws on a
403
+ // missing incident (a graceful action failure, not a run error).
404
+ if (!(await service.getIncident(incidentId))) {
405
+ return {
406
+ success: false,
407
+ error: `Incident ${incidentId} not found`,
408
+ };
409
+ }
410
+ // 6(a): route the resolve through the reactive `incident` entity (like
411
+ // the RPC router) so the status flip appends an `entity_transitions` row,
412
+ // emits `ENTITY_CHANGED` (waking any `wait_until`), and fires the
413
+ // `incident.resolved` deriver. `apply` performs the REAL resolve and
414
+ // re-reads the post-write state inside the mutate window. `opts.runId`
415
+ // masks any run-resolved secret that lands in the reactive state.
416
+ const captured: {
417
+ incident: NonNullable<
418
+ Awaited<ReturnType<typeof service.resolveIncident>>
419
+ > | null;
420
+ } = { incident: null };
421
+ await writeIncidentEntity({
422
+ handle: getIncidentEntity?.(),
423
+ incidentId,
424
+ opts: { runId },
425
+ apply: async () => {
426
+ const resolved = await service.resolveIncident(
427
+ incidentId,
428
+ config.message,
429
+ );
430
+ if (!resolved) {
431
+ throw new Error(`Incident ${incidentId} not found`);
432
+ }
433
+ captured.incident = resolved;
434
+ return toIncidentEntityState(resolved);
435
+ },
436
+ });
437
+ const incident = captured.incident;
221
438
  if (!incident) {
222
439
  return {
223
440
  success: false,
224
- error: `Incident ${config.incidentId} not found`,
441
+ error: `Incident ${incidentId} not found`,
225
442
  };
226
443
  }
227
444
  logger.info(`Automation resolved incident ${incident.id}`);
@@ -251,14 +468,50 @@ export function createIncidentActions(
251
468
  version: 1,
252
469
  schema: incidentAddUpdateConfigSchema,
253
470
  }),
254
- execute: async ({ config, logger }) => {
255
- const update = await service.addUpdate({
256
- incidentId: config.incidentId,
257
- message: config.message,
258
- statusChange: config.statusChange,
471
+ consumes: ["incident"],
472
+ execute: async ({ config, consumedArtifacts, logger, runId }) => {
473
+ const incidentId = resolveIncidentId(config.incidentId, consumedArtifacts);
474
+ if (!incidentId) {
475
+ return {
476
+ success: false,
477
+ error: "No incidentId given and no upstream incident artifact found",
478
+ };
479
+ }
480
+ // 6(a): route the update through the reactive `incident` entity (like the
481
+ // RPC router). `apply` posts the update row + (optionally) flips status,
482
+ // then re-reads the post-write reactive state. When `statusChange` flips
483
+ // the status to resolved the deriver fires `incident.resolved`; any other
484
+ // status change fires `incident.updated`; an update with no status change
485
+ // is a no-op diff (no event). `opts.runId` masks run-resolved secrets.
486
+ const captured: {
487
+ update: Awaited<ReturnType<typeof service.addUpdate>> | null;
488
+ } = { update: null };
489
+ await writeIncidentEntity({
490
+ handle: getIncidentEntity?.(),
491
+ incidentId,
492
+ opts: { runId },
493
+ apply: async () => {
494
+ captured.update = await service.addUpdate({
495
+ incidentId,
496
+ message: config.message,
497
+ statusChange: config.statusChange,
498
+ });
499
+ const incident = await service.getIncident(incidentId);
500
+ if (!incident) {
501
+ throw new Error(`Incident ${incidentId} not found`);
502
+ }
503
+ return toIncidentEntityState(incident);
504
+ },
259
505
  });
506
+ const update = captured.update;
507
+ if (!update) {
508
+ return {
509
+ success: false,
510
+ error: `Incident ${incidentId} not found`,
511
+ };
512
+ }
260
513
  logger.info(
261
- `Automation added update ${update.id} to incident ${config.incidentId}`,
514
+ `Automation added update ${update.id} to incident ${incidentId}`,
262
515
  );
263
516
  return {
264
517
  success: true,
@@ -284,14 +537,49 @@ export function createIncidentActions(
284
537
  version: 1,
285
538
  schema: incidentUpdateStatusConfigSchema,
286
539
  }),
287
- execute: async ({ config, logger }) => {
288
- const update = await service.addUpdate({
289
- incidentId: config.incidentId,
290
- message: config.message ?? `Status changed to ${config.status}`,
291
- statusChange: config.status,
540
+ consumes: ["incident"],
541
+ execute: async ({ config, consumedArtifacts, logger, runId }) => {
542
+ const incidentId = resolveIncidentId(config.incidentId, consumedArtifacts);
543
+ if (!incidentId) {
544
+ return {
545
+ success: false,
546
+ error: "No incidentId given and no upstream incident artifact found",
547
+ };
548
+ }
549
+ // 6(a): route the status flip through the reactive `incident` entity
550
+ // (like the RPC router) so it appends an `entity_transitions` row, emits
551
+ // `ENTITY_CHANGED` (waking any `wait_until`), and fires `incident.resolved`
552
+ // (→ resolved) or `incident.updated`. `apply` performs the REAL write +
553
+ // re-reads post-write state. `opts.runId` masks run-resolved secrets.
554
+ const captured: {
555
+ update: Awaited<ReturnType<typeof service.addUpdate>> | null;
556
+ } = { update: null };
557
+ await writeIncidentEntity({
558
+ handle: getIncidentEntity?.(),
559
+ incidentId,
560
+ opts: { runId },
561
+ apply: async () => {
562
+ captured.update = await service.addUpdate({
563
+ incidentId,
564
+ message: config.message ?? `Status changed to ${config.status}`,
565
+ statusChange: config.status,
566
+ });
567
+ const incident = await service.getIncident(incidentId);
568
+ if (!incident) {
569
+ throw new Error(`Incident ${incidentId} not found`);
570
+ }
571
+ return toIncidentEntityState(incident);
572
+ },
292
573
  });
574
+ const update = captured.update;
575
+ if (!update) {
576
+ return {
577
+ success: false,
578
+ error: `Incident ${incidentId} not found`,
579
+ };
580
+ }
293
581
  logger.info(
294
- `Automation set incident ${config.incidentId} status → ${config.status}`,
582
+ `Automation set incident ${incidentId} status → ${config.status}`,
295
583
  );
296
584
  return {
297
585
  success: true,
package/src/hooks.ts CHANGED
@@ -1,56 +1,11 @@
1
- import { createHook } from "@checkstack/backend-api";
2
- import type {
3
- IncidentSeverity,
4
- IncidentStatus,
5
- } from "@checkstack/incident-common";
6
-
7
1
  /**
8
- * Incident hooks for cross-plugin communication.
9
- * Other plugins can subscribe to these hooks to react to incident lifecycle events.
2
+ * Incident cross-plugin hooks.
10
3
  *
11
- * `severity` / `status` carry the canonical enum values
12
- * (`IncidentSeverity` / `IncidentStatus`) rather than loose strings, so
13
- * automation triggers built on these hooks can offer the known values
14
- * for `==` comparisons in the editor.
4
+ * The `incident.created` / `.updated` / `.resolved` hooks were removed in
5
+ * Phase 4 (§10.1): incidents are now the reactive `incident` entity, whose
6
+ * change deriver fires the matching `incident.created` / `.updated` /
7
+ * `.resolved` trigger events through Stage-1 routing. No cross-plugin hook
8
+ * remains, so this object is intentionally empty (kept for the stable
9
+ * `export { incidentHooks }` surface).
15
10
  */
16
- export const incidentHooks = {
17
- /**
18
- * Emitted when a new incident is created.
19
- * Plugins can subscribe (work-queue mode) to react to new incidents.
20
- */
21
- incidentCreated: createHook<{
22
- incidentId: string;
23
- systemIds: string[];
24
- title: string;
25
- description?: string;
26
- severity: IncidentSeverity;
27
- status: IncidentStatus;
28
- createdAt: string;
29
- }>("incident.created"),
30
-
31
- /**
32
- * Emitted when an incident is updated (info or status change).
33
- * Plugins can subscribe (work-queue mode) to react to updates.
34
- */
35
- incidentUpdated: createHook<{
36
- incidentId: string;
37
- systemIds: string[];
38
- title: string;
39
- description?: string;
40
- severity: IncidentSeverity;
41
- status: IncidentStatus;
42
- statusChange?: IncidentStatus;
43
- }>("incident.updated"),
44
-
45
- /**
46
- * Emitted when an incident is resolved.
47
- * Plugins can subscribe to clean up or log incident resolutions.
48
- */
49
- incidentResolved: createHook<{
50
- incidentId: string;
51
- systemIds: string[];
52
- title: string;
53
- severity: IncidentSeverity;
54
- resolvedAt: string;
55
- }>("incident.resolved"),
56
- } as const;
11
+ export const incidentHooks = {} as const;