@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
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incident triggers + actions registered with the Automation platform.
|
|
3
|
+
*
|
|
4
|
+
* Triggers re-expose the existing incident hooks as automation entry
|
|
5
|
+
* points; actions wrap the existing `IncidentService` methods so
|
|
6
|
+
* operators can compose them into automation flows (e.g. "when an
|
|
7
|
+
* incident is created, file a Jira ticket and post an update").
|
|
8
|
+
*
|
|
9
|
+
* Each trigger declares a `contextKey` extractor returning the
|
|
10
|
+
* `incidentId` — the dispatch engine uses it to scope artifact lookups
|
|
11
|
+
* and to match `wait_for_trigger` waits against the same incident.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { Versioned } from "@checkstack/backend-api";
|
|
15
|
+
import type {
|
|
16
|
+
ActionDefinition,
|
|
17
|
+
ArtifactTypeDefinition,
|
|
18
|
+
EntityHandle,
|
|
19
|
+
TriggerDefinition,
|
|
20
|
+
} from "@checkstack/automation-backend";
|
|
21
|
+
import { makeEntityDrivenTriggerSetup } from "@checkstack/automation-backend";
|
|
22
|
+
import {
|
|
23
|
+
IncidentSeverityEnum,
|
|
24
|
+
IncidentStatusEnum,
|
|
25
|
+
} from "@checkstack/incident-common";
|
|
26
|
+
|
|
27
|
+
import type { IncidentService } from "./service";
|
|
28
|
+
import {
|
|
29
|
+
toIncidentEntityState,
|
|
30
|
+
writeIncidentEntity,
|
|
31
|
+
type IncidentEntityState,
|
|
32
|
+
} from "./incident-entity";
|
|
33
|
+
|
|
34
|
+
// ─── Payload schemas — match the hook payloads exactly ─────────────────
|
|
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.
|
|
43
|
+
const incidentCreatedPayloadSchema = z.object({
|
|
44
|
+
incidentId: z.string(),
|
|
45
|
+
systemIds: z.array(z.string()),
|
|
46
|
+
title: z.string().optional(),
|
|
47
|
+
description: z.string().optional(),
|
|
48
|
+
severity: IncidentSeverityEnum,
|
|
49
|
+
status: IncidentStatusEnum,
|
|
50
|
+
createdAt: z.string().optional(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const incidentUpdatedPayloadSchema = z.object({
|
|
54
|
+
incidentId: z.string(),
|
|
55
|
+
systemIds: z.array(z.string()),
|
|
56
|
+
title: z.string().optional(),
|
|
57
|
+
description: z.string().optional(),
|
|
58
|
+
severity: IncidentSeverityEnum,
|
|
59
|
+
status: IncidentStatusEnum,
|
|
60
|
+
statusChange: IncidentStatusEnum.optional(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const incidentResolvedPayloadSchema = z.object({
|
|
64
|
+
incidentId: z.string(),
|
|
65
|
+
systemIds: z.array(z.string()),
|
|
66
|
+
title: z.string().optional(),
|
|
67
|
+
severity: IncidentSeverityEnum,
|
|
68
|
+
resolvedAt: z.string().optional(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ─── Triggers ──────────────────────────────────────────────────────────
|
|
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.
|
|
77
|
+
export const incidentCreatedTrigger: TriggerDefinition<
|
|
78
|
+
z.infer<typeof incidentCreatedPayloadSchema>
|
|
79
|
+
> = {
|
|
80
|
+
id: "created",
|
|
81
|
+
displayName: "Incident Created",
|
|
82
|
+
description: "Fires when a new incident is created",
|
|
83
|
+
category: "Incidents",
|
|
84
|
+
icon: "CircleAlert",
|
|
85
|
+
payloadSchema: incidentCreatedPayloadSchema,
|
|
86
|
+
setup: makeEntityDrivenTriggerSetup<
|
|
87
|
+
z.infer<typeof incidentCreatedPayloadSchema>
|
|
88
|
+
>(),
|
|
89
|
+
contextKey: (p) => p.incidentId,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const incidentUpdatedTrigger: TriggerDefinition<
|
|
93
|
+
z.infer<typeof incidentUpdatedPayloadSchema>
|
|
94
|
+
> = {
|
|
95
|
+
id: "updated",
|
|
96
|
+
displayName: "Incident Updated",
|
|
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.",
|
|
99
|
+
category: "Incidents",
|
|
100
|
+
icon: "CircleAlert",
|
|
101
|
+
payloadSchema: incidentUpdatedPayloadSchema,
|
|
102
|
+
setup: makeEntityDrivenTriggerSetup<
|
|
103
|
+
z.infer<typeof incidentUpdatedPayloadSchema>
|
|
104
|
+
>(),
|
|
105
|
+
contextKey: (p) => p.incidentId,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const incidentResolvedTrigger: TriggerDefinition<
|
|
109
|
+
z.infer<typeof incidentResolvedPayloadSchema>
|
|
110
|
+
> = {
|
|
111
|
+
id: "resolved",
|
|
112
|
+
displayName: "Incident Resolved",
|
|
113
|
+
description: "Fires when an incident is marked as resolved",
|
|
114
|
+
category: "Incidents",
|
|
115
|
+
icon: "CircleCheck",
|
|
116
|
+
payloadSchema: incidentResolvedPayloadSchema,
|
|
117
|
+
setup: makeEntityDrivenTriggerSetup<
|
|
118
|
+
z.infer<typeof incidentResolvedPayloadSchema>
|
|
119
|
+
>(),
|
|
120
|
+
contextKey: (p) => p.incidentId,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* All incident triggers as a heterogeneous list. Typed as
|
|
125
|
+
* `TriggerDefinition<unknown>[]` so the array can be iterated in the
|
|
126
|
+
* plugin entry without TypeScript collapsing the union to a single
|
|
127
|
+
* payload shape.
|
|
128
|
+
*/
|
|
129
|
+
export const incidentTriggers: TriggerDefinition<unknown>[] = [
|
|
130
|
+
incidentCreatedTrigger as TriggerDefinition<unknown>,
|
|
131
|
+
incidentUpdatedTrigger as TriggerDefinition<unknown>,
|
|
132
|
+
incidentResolvedTrigger as TriggerDefinition<unknown>,
|
|
133
|
+
];
|
|
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
|
+
|
|
161
|
+
// ─── Action configs ────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
const incidentCreateConfigSchema = z.object({
|
|
164
|
+
title: z.string().min(1),
|
|
165
|
+
description: z.string().optional(),
|
|
166
|
+
severity: IncidentSeverityEnum,
|
|
167
|
+
systemIds: z.array(z.string()).min(1),
|
|
168
|
+
initialMessage: z.string().optional(),
|
|
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),
|
|
179
|
+
});
|
|
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.
|
|
184
|
+
const incidentResolveConfigSchema = z.object({
|
|
185
|
+
incidentId: z
|
|
186
|
+
.string()
|
|
187
|
+
.optional()
|
|
188
|
+
.describe("Defaults to the upstream incident artifact in the run."),
|
|
189
|
+
message: z.string().optional(),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const incidentAddUpdateConfigSchema = z.object({
|
|
193
|
+
incidentId: z
|
|
194
|
+
.string()
|
|
195
|
+
.optional()
|
|
196
|
+
.describe("Defaults to the upstream incident artifact in the run."),
|
|
197
|
+
message: z.string().min(1),
|
|
198
|
+
statusChange: IncidentStatusEnum.optional(),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const incidentUpdateStatusConfigSchema = z.object({
|
|
202
|
+
incidentId: z
|
|
203
|
+
.string()
|
|
204
|
+
.optional()
|
|
205
|
+
.describe("Defaults to the upstream incident artifact in the run."),
|
|
206
|
+
status: IncidentStatusEnum,
|
|
207
|
+
/**
|
|
208
|
+
* Optional accompanying message. Defaults to a generic transition note
|
|
209
|
+
* so the audit trail is never empty.
|
|
210
|
+
*/
|
|
211
|
+
message: z.string().optional(),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ─── Action artifact shapes ────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
interface IncidentArtifact {
|
|
217
|
+
incidentId: string;
|
|
218
|
+
status: string;
|
|
219
|
+
severity: string;
|
|
220
|
+
systemIds: string[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface IncidentUpdateArtifact {
|
|
224
|
+
updateId: string;
|
|
225
|
+
incidentId: string;
|
|
226
|
+
}
|
|
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
|
+
|
|
250
|
+
// ─── Actions ───────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
export interface IncidentActionDeps {
|
|
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;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function createIncidentActions(
|
|
265
|
+
deps: IncidentActionDeps,
|
|
266
|
+
): ActionDefinition<unknown, unknown>[] {
|
|
267
|
+
const { service, getIncidentEntity } = deps;
|
|
268
|
+
|
|
269
|
+
const createAction: ActionDefinition<
|
|
270
|
+
z.infer<typeof incidentCreateConfigSchema>,
|
|
271
|
+
IncidentArtifact
|
|
272
|
+
> = {
|
|
273
|
+
id: "create",
|
|
274
|
+
displayName: "Create Incident",
|
|
275
|
+
description: "Open a new incident affecting one or more systems",
|
|
276
|
+
category: "Incidents",
|
|
277
|
+
icon: "CircleAlert",
|
|
278
|
+
config: new Versioned({
|
|
279
|
+
version: 1,
|
|
280
|
+
schema: incidentCreateConfigSchema,
|
|
281
|
+
}),
|
|
282
|
+
produces: "incident",
|
|
283
|
+
execute: async ({ config, logger, runId }) => {
|
|
284
|
+
const createInput = {
|
|
285
|
+
title: config.title,
|
|
286
|
+
description: config.description,
|
|
287
|
+
severity: config.severity,
|
|
288
|
+
systemIds: config.systemIds,
|
|
289
|
+
initialMessage: config.initialMessage,
|
|
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
|
+
},
|
|
365
|
+
});
|
|
366
|
+
logger.info(`Automation created incident ${incident.id}`);
|
|
367
|
+
return {
|
|
368
|
+
success: true,
|
|
369
|
+
externalId: incident.id,
|
|
370
|
+
artifact: {
|
|
371
|
+
incidentId: incident.id,
|
|
372
|
+
status: incident.status,
|
|
373
|
+
severity: incident.severity,
|
|
374
|
+
systemIds: incident.systemIds,
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const resolveAction: ActionDefinition<
|
|
381
|
+
z.infer<typeof incidentResolveConfigSchema>,
|
|
382
|
+
IncidentArtifact
|
|
383
|
+
> = {
|
|
384
|
+
id: "resolve",
|
|
385
|
+
displayName: "Resolve Incident",
|
|
386
|
+
description: "Mark an existing incident as resolved",
|
|
387
|
+
category: "Incidents",
|
|
388
|
+
icon: "CircleCheck",
|
|
389
|
+
config: new Versioned({
|
|
390
|
+
version: 1,
|
|
391
|
+
schema: incidentResolveConfigSchema,
|
|
392
|
+
}),
|
|
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;
|
|
438
|
+
if (!incident) {
|
|
439
|
+
return {
|
|
440
|
+
success: false,
|
|
441
|
+
error: `Incident ${incidentId} not found`,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
logger.info(`Automation resolved incident ${incident.id}`);
|
|
445
|
+
return {
|
|
446
|
+
success: true,
|
|
447
|
+
externalId: incident.id,
|
|
448
|
+
artifact: {
|
|
449
|
+
incidentId: incident.id,
|
|
450
|
+
status: incident.status,
|
|
451
|
+
severity: incident.severity,
|
|
452
|
+
systemIds: incident.systemIds,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const addUpdateAction: ActionDefinition<
|
|
459
|
+
z.infer<typeof incidentAddUpdateConfigSchema>,
|
|
460
|
+
IncidentUpdateArtifact
|
|
461
|
+
> = {
|
|
462
|
+
id: "add_update",
|
|
463
|
+
displayName: "Add Incident Update",
|
|
464
|
+
description: "Post a status update to an existing incident",
|
|
465
|
+
category: "Incidents",
|
|
466
|
+
icon: "MessageSquare",
|
|
467
|
+
config: new Versioned({
|
|
468
|
+
version: 1,
|
|
469
|
+
schema: incidentAddUpdateConfigSchema,
|
|
470
|
+
}),
|
|
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
|
+
},
|
|
505
|
+
});
|
|
506
|
+
const update = captured.update;
|
|
507
|
+
if (!update) {
|
|
508
|
+
return {
|
|
509
|
+
success: false,
|
|
510
|
+
error: `Incident ${incidentId} not found`,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
logger.info(
|
|
514
|
+
`Automation added update ${update.id} to incident ${incidentId}`,
|
|
515
|
+
);
|
|
516
|
+
return {
|
|
517
|
+
success: true,
|
|
518
|
+
externalId: update.id,
|
|
519
|
+
artifact: {
|
|
520
|
+
updateId: update.id,
|
|
521
|
+
incidentId: update.incidentId,
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const updateStatusAction: ActionDefinition<
|
|
528
|
+
z.infer<typeof incidentUpdateStatusConfigSchema>,
|
|
529
|
+
IncidentUpdateArtifact
|
|
530
|
+
> = {
|
|
531
|
+
id: "update_status",
|
|
532
|
+
displayName: "Update Incident Status",
|
|
533
|
+
description: "Change an incident's status and post an audit update",
|
|
534
|
+
category: "Incidents",
|
|
535
|
+
icon: "Activity",
|
|
536
|
+
config: new Versioned({
|
|
537
|
+
version: 1,
|
|
538
|
+
schema: incidentUpdateStatusConfigSchema,
|
|
539
|
+
}),
|
|
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
|
+
},
|
|
573
|
+
});
|
|
574
|
+
const update = captured.update;
|
|
575
|
+
if (!update) {
|
|
576
|
+
return {
|
|
577
|
+
success: false,
|
|
578
|
+
error: `Incident ${incidentId} not found`,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
logger.info(
|
|
582
|
+
`Automation set incident ${incidentId} status → ${config.status}`,
|
|
583
|
+
);
|
|
584
|
+
return {
|
|
585
|
+
success: true,
|
|
586
|
+
externalId: update.id,
|
|
587
|
+
artifact: {
|
|
588
|
+
updateId: update.id,
|
|
589
|
+
incidentId: update.incidentId,
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
},
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
return [
|
|
596
|
+
createAction as ActionDefinition<unknown, unknown>,
|
|
597
|
+
resolveAction as ActionDefinition<unknown, unknown>,
|
|
598
|
+
addUpdateAction as ActionDefinition<unknown, unknown>,
|
|
599
|
+
updateStatusAction as ActionDefinition<unknown, unknown>,
|
|
600
|
+
];
|
|
601
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -1,47 +1,11 @@
|
|
|
1
|
-
import { createHook } from "@checkstack/backend-api";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
* Incident
|
|
5
|
-
*
|
|
2
|
+
* Incident cross-plugin hooks.
|
|
3
|
+
*
|
|
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).
|
|
6
10
|
*/
|
|
7
|
-
export const incidentHooks = {
|
|
8
|
-
/**
|
|
9
|
-
* Emitted when a new incident is created.
|
|
10
|
-
* Plugins can subscribe (work-queue mode) to react to new incidents.
|
|
11
|
-
*/
|
|
12
|
-
incidentCreated: createHook<{
|
|
13
|
-
incidentId: string;
|
|
14
|
-
systemIds: string[];
|
|
15
|
-
title: string;
|
|
16
|
-
description?: string;
|
|
17
|
-
severity: string;
|
|
18
|
-
status: string;
|
|
19
|
-
createdAt: string;
|
|
20
|
-
}>("incident.created"),
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Emitted when an incident is updated (info or status change).
|
|
24
|
-
* Plugins can subscribe (work-queue mode) to react to updates.
|
|
25
|
-
*/
|
|
26
|
-
incidentUpdated: createHook<{
|
|
27
|
-
incidentId: string;
|
|
28
|
-
systemIds: string[];
|
|
29
|
-
title: string;
|
|
30
|
-
description?: string;
|
|
31
|
-
severity: string;
|
|
32
|
-
status: string;
|
|
33
|
-
statusChange?: string;
|
|
34
|
-
}>("incident.updated"),
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Emitted when an incident is resolved.
|
|
38
|
-
* Plugins can subscribe to clean up or log incident resolutions.
|
|
39
|
-
*/
|
|
40
|
-
incidentResolved: createHook<{
|
|
41
|
-
incidentId: string;
|
|
42
|
-
systemIds: string[];
|
|
43
|
-
title: string;
|
|
44
|
-
severity: string;
|
|
45
|
-
resolvedAt: string;
|
|
46
|
-
}>("incident.resolved"),
|
|
47
|
-
} as const;
|
|
11
|
+
export const incidentHooks = {} as const;
|