@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.
- package/CHANGELOG.md +157 -0
- package/package.json +18 -18
- package/src/automations.test.ts +356 -5
- package/src/automations.ts +322 -34
- package/src/hooks.ts +8 -53
- package/src/incident-entity.test.ts +266 -0
- package/src/incident-entity.ts +192 -0
- package/src/index.ts +96 -16
- package/src/router.ts +162 -98
- package/src/service.test.ts +199 -0
- package/src/service.ts +147 -3
package/src/automations.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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 ${
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 ${
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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 ${
|
|
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
|
|
9
|
-
* Other plugins can subscribe to these hooks to react to incident lifecycle events.
|
|
2
|
+
* Incident cross-plugin hooks.
|
|
10
3
|
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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;
|