@checkstack/incident-backend 1.6.6 → 1.7.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 CHANGED
@@ -1,5 +1,51 @@
1
1
  # @checkstack/incident-backend
2
2
 
3
+ ## 1.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0b6f01b: feat(incident): contribute incident signals to the backend system.issues aggregator
8
+
9
+ The incident plugin now registers a `system.issues` contributor (sourceId
10
+ `incident`) from its backend `init`, so the AI assistant surfaces open incidents
11
+ alongside SLOs, health checks, anomalies, and dependency problems.
12
+
13
+ The contributor enforces its own `incident.read` access gate (returning an empty
14
+ map - never throwing - when the principal lacks access; service users carry no
15
+ access rules and so get no signals), then reads every OPEN (not-resolved)
16
+ incident for all systems from the shared, durable `incidents` +
17
+ `incident_systems` tables via a new global `listOpenIncidentsBySystem` service
18
+ method. The answer is therefore identical on every pod, and only systems with an
19
+ open incident appear in the result.
20
+
21
+ The row->signal mapping (source/tone/label/detail/href/accessRule/since/iconName)
22
+ is extracted into a new pure `deriveIncidentSignals` deriver in
23
+ `@checkstack/incident-common`, shared by both the backend contributor and the
24
+ frontend `IncidentSignalsFiller` so the two surfaces stay in lockstep. The
25
+ frontend filler now delegates to that deriver with unchanged behavior.
26
+
27
+ ### Patch Changes
28
+
29
+ - Updated dependencies [dbb76a2]
30
+ - Updated dependencies [0b6f01b]
31
+ - Updated dependencies [0b6f01b]
32
+ - @checkstack/ai-backend@0.3.0
33
+ - @checkstack/incident-common@1.5.0
34
+ - @checkstack/automation-backend@0.5.8
35
+ - @checkstack/catalog-backend@1.4.8
36
+ - @checkstack/backend-api@0.21.6
37
+ - @checkstack/command-backend@0.2.6
38
+ - @checkstack/integration-backend@0.4.6
39
+
40
+ ## 1.6.7
41
+
42
+ ### Patch Changes
43
+
44
+ - Updated dependencies [2428bfc]
45
+ - @checkstack/ai-backend@0.2.0
46
+ - @checkstack/automation-backend@0.5.7
47
+ - @checkstack/catalog-backend@1.4.7
48
+
3
49
  ## 1.6.6
4
50
 
5
51
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/incident-backend",
3
- "version": "1.6.6",
3
+ "version": "1.7.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,21 +14,21 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.1.6",
17
+ "@checkstack/ai-backend": "0.3.0",
18
18
  "@checkstack/ai-common": "0.1.3",
19
- "@checkstack/backend-api": "0.21.5",
19
+ "@checkstack/backend-api": "0.21.6",
20
20
  "@checkstack/cache-api": "0.3.12",
21
21
  "@checkstack/cache-utils": "0.2.17",
22
- "@checkstack/incident-common": "1.4.4",
22
+ "@checkstack/incident-common": "1.5.0",
23
23
  "@checkstack/catalog-common": "2.3.4",
24
- "@checkstack/catalog-backend": "1.4.6",
24
+ "@checkstack/catalog-backend": "1.4.8",
25
25
  "@checkstack/notification-common": "1.3.3",
26
26
  "@checkstack/auth-common": "0.8.3",
27
- "@checkstack/command-backend": "0.2.5",
27
+ "@checkstack/command-backend": "0.2.6",
28
28
  "@checkstack/signal-common": "0.2.9",
29
- "@checkstack/integration-backend": "0.4.5",
29
+ "@checkstack/integration-backend": "0.4.6",
30
30
  "@checkstack/integration-common": "0.7.3",
31
- "@checkstack/automation-backend": "0.5.6",
31
+ "@checkstack/automation-backend": "0.5.8",
32
32
  "@checkstack/automation-common": "0.4.3",
33
33
  "@checkstack/common": "0.15.0",
34
34
  "drizzle-orm": "^0.45.0",
@@ -39,7 +39,7 @@
39
39
  "devDependencies": {
40
40
  "@checkstack/drizzle-helper": "0.0.5",
41
41
  "@checkstack/scripts": "0.6.1",
42
- "@checkstack/test-utils-backend": "0.1.39",
42
+ "@checkstack/test-utils-backend": "0.1.40",
43
43
  "@checkstack/tsconfig": "0.0.7",
44
44
  "@types/bun": "^1.0.0",
45
45
  "drizzle-kit": "^0.31.10",
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@ import type { SafeDatabase } from "@checkstack/backend-api";
3
3
  import {
4
4
  aiToolExtensionPoint,
5
5
  aiToolProjectionExtensionPoint,
6
+ systemSignalsExtensionPoint,
7
+ createSystemAccessResolver,
6
8
  deferredProjectionExecute,
7
9
  } from "@checkstack/ai-backend";
8
10
  import {
@@ -48,6 +50,7 @@ import {
48
50
  incidentTriggers,
49
51
  } from "./automations";
50
52
  import { buildIncidentAiTools } from "./ai/register-ai-tools";
53
+ import { createIncidentSignalsContributor } from "./system-signals";
51
54
 
52
55
  // =============================================================================
53
56
  // Plugin Definition
@@ -224,6 +227,19 @@ export default createBackendPlugin({
224
227
  execute: deferredProjectionExecute,
225
228
  });
226
229
 
230
+ // Contribute incident problem state to the `system.issues` AI tool.
231
+ // Mirrors the frontend `SystemSignalsSlot` filler: returns one signal
232
+ // per OPEN incident for EVERY system globally (keyed by systemId),
233
+ // using the SAME shared deriver so frontend and backend agree. The
234
+ // per-source access gate + global read live in the contributor factory.
235
+ const signalsExt = env.getExtensionPoint(systemSignalsExtensionPoint);
236
+ signalsExt.contribute(
237
+ createIncidentSignalsContributor({
238
+ service,
239
+ resolver: createSystemAccessResolver(rpcClient),
240
+ }),
241
+ );
242
+
227
243
  // Register "Create Incident" command in the command palette
228
244
  registerSearchProvider({
229
245
  pluginMetadata,
@@ -227,6 +227,99 @@ describe("IncidentService.getManyEntityStates (plugin-backed entity read)", () =
227
227
  });
228
228
  });
229
229
 
230
+ describe("IncidentService.listOpenIncidentsBySystem (global signals read)", () => {
231
+ it("returns {} without a junction query when no open incidents exist", async () => {
232
+ const dbHelper = createProgrammableSelectDb([
233
+ // 1st query: open incidents -> none, so the junction query is skipped.
234
+ [],
235
+ ]);
236
+ const service = new IncidentService(
237
+ dbHelper.db as never,
238
+ makeFakeAdvisoryLock(),
239
+ );
240
+ expect(await service.listOpenIncidentsBySystem()).toEqual({});
241
+ expect(dbHelper.getCallCount()).toBe(1);
242
+ });
243
+
244
+ it("groups open incidents under each affected system, full systemIds per entry", async () => {
245
+ const createdAt = new Date("2026-06-01T10:00:00.000Z");
246
+ const updatedAt = new Date("2026-06-01T10:05:00.000Z");
247
+ const dbHelper = createProgrammableSelectDb([
248
+ // 1st query: open incident rows (resolved excluded by the WHERE clause).
249
+ [
250
+ {
251
+ id: "inc-1",
252
+ title: "DB down",
253
+ description: null,
254
+ status: "investigating",
255
+ severity: "critical",
256
+ suppressNotifications: false,
257
+ createdAt,
258
+ updatedAt,
259
+ },
260
+ {
261
+ id: "inc-2",
262
+ title: "Slow",
263
+ description: "elevated latency",
264
+ status: "monitoring",
265
+ severity: "major",
266
+ suppressNotifications: false,
267
+ createdAt,
268
+ updatedAt,
269
+ },
270
+ ],
271
+ // 2nd query: junction rows. inc-1 spans two systems; inc-2 one.
272
+ [
273
+ { incidentId: "inc-1", systemId: "sys-a" },
274
+ { incidentId: "inc-1", systemId: "sys-b" },
275
+ { incidentId: "inc-2", systemId: "sys-a" },
276
+ ],
277
+ ]);
278
+ const service = new IncidentService(
279
+ dbHelper.db as never,
280
+ makeFakeAdvisoryLock(),
281
+ );
282
+
283
+ const out = await service.listOpenIncidentsBySystem();
284
+
285
+ expect(Object.keys(out).sort()).toEqual(["sys-a", "sys-b"]);
286
+ // sys-a sees both incidents; sys-b only inc-1.
287
+ expect(out["sys-a"].map((i) => i.id)).toEqual(["inc-1", "inc-2"]);
288
+ expect(out["sys-b"].map((i) => i.id)).toEqual(["inc-1"]);
289
+ // Multi-system incident carries its FULL systemIds under each key.
290
+ expect(out["sys-a"][0].systemIds).toEqual(["sys-a", "sys-b"]);
291
+ expect(out["sys-b"][0].systemIds).toEqual(["sys-a", "sys-b"]);
292
+ // null description is normalized to undefined (IncidentWithSystems shape).
293
+ expect(out["sys-a"][0].description).toBeUndefined();
294
+ expect(out["sys-a"][1].description).toBe("elevated latency");
295
+ expect(dbHelper.getCallCount()).toBe(2);
296
+ });
297
+
298
+ it("yields no entry for an open incident with no system associations", async () => {
299
+ const dbHelper = createProgrammableSelectDb([
300
+ [
301
+ {
302
+ id: "inc-orphan",
303
+ title: "Orphan",
304
+ description: null,
305
+ status: "identified",
306
+ severity: "minor",
307
+ suppressNotifications: false,
308
+ createdAt: new Date("2026-06-01T10:00:00.000Z"),
309
+ updatedAt: new Date("2026-06-01T10:00:00.000Z"),
310
+ },
311
+ ],
312
+ [], // no junction rows
313
+ ]);
314
+ const service = new IncidentService(
315
+ dbHelper.db as never,
316
+ makeFakeAdvisoryLock(),
317
+ );
318
+ expect(await service.listOpenIncidentsBySystem()).toEqual({});
319
+ expect(dbHelper.getCallCount()).toBe(2);
320
+ });
321
+ });
322
+
230
323
  /**
231
324
  * Table-backed fake `db` for the dedup-create path. Models just enough of
232
325
  * the Drizzle surface the service touches (select/insert by TABLE IDENTITY,
package/src/service.ts CHANGED
@@ -224,6 +224,59 @@ export class IncidentService {
224
224
  return result;
225
225
  }
226
226
 
227
+ /**
228
+ * Global read of every OPEN (not-resolved) incident across ALL systems,
229
+ * grouped by systemId. Powers the backend `system.issues` contributor, which
230
+ * needs problem state for every system on every pod (not a caller-supplied
231
+ * systemId list). Reads the authoritative `incidents` + `incident_systems`
232
+ * tables, so the answer is identical on every pod (state-and-scale rule).
233
+ *
234
+ * An incident affecting multiple systems appears under each of its systemIds,
235
+ * each entry carrying the incident's full `systemIds` list (mirrors the bulk
236
+ * RPC shape `getBulkIncidentsForSystems` returns). Systems with no open
237
+ * incident are simply absent from the returned record.
238
+ */
239
+ async listOpenIncidentsBySystem(): Promise<
240
+ Record<string, IncidentWithSystems[]>
241
+ > {
242
+ const openRows = await this.db
243
+ .select()
244
+ .from(incidents)
245
+ .where(ne(incidents.status, "resolved"));
246
+ if (openRows.length === 0) return {};
247
+
248
+ const openIds = openRows.map((i) => i.id);
249
+ const systemRows = await this.db
250
+ .select({
251
+ incidentId: incidentSystems.incidentId,
252
+ systemId: incidentSystems.systemId,
253
+ })
254
+ .from(incidentSystems)
255
+ .where(inArray(incidentSystems.incidentId, openIds));
256
+
257
+ const systemsByIncident = new Map<string, string[]>();
258
+ for (const r of systemRows) {
259
+ const list = systemsByIncident.get(r.incidentId);
260
+ if (list) list.push(r.systemId);
261
+ else systemsByIncident.set(r.incidentId, [r.systemId]);
262
+ }
263
+
264
+ const result: Record<string, IncidentWithSystems[]> = {};
265
+ for (const i of openRows) {
266
+ const systemIds = systemsByIncident.get(i.id) ?? [];
267
+ const incident: IncidentWithSystems = {
268
+ ...i,
269
+ description: i.description ?? undefined,
270
+ systemIds,
271
+ };
272
+ for (const systemId of systemIds) {
273
+ (result[systemId] ??= []).push(incident);
274
+ }
275
+ }
276
+
277
+ return result;
278
+ }
279
+
227
280
  /**
228
281
  * Create a new incident.
229
282
  *
@@ -0,0 +1,100 @@
1
+ import { describe, test, expect, mock } from "bun:test";
2
+ import type { AuthUser } from "@checkstack/backend-api";
3
+ import type { SystemAccessResolver } from "@checkstack/ai-backend";
4
+ import type { IncidentWithSystems } from "@checkstack/incident-common";
5
+ import { createIncidentSignalsContributor } from "./system-signals";
6
+ import type { IncidentService } from "./service";
7
+
8
+ const openIncident: IncidentWithSystems = {
9
+ id: "inc-1",
10
+ title: "Database down",
11
+ description: undefined,
12
+ status: "investigating",
13
+ severity: "critical",
14
+ suppressNotifications: false,
15
+ createdAt: new Date("2026-06-01T10:00:00.000Z"),
16
+ updatedAt: new Date("2026-06-01T10:00:00.000Z"),
17
+ systemIds: ["sys-1"],
18
+ };
19
+
20
+ function makeService(
21
+ bySystem: Record<string, IncidentWithSystems[]>,
22
+ ): Pick<IncidentService, "listOpenIncidentsBySystem"> {
23
+ return {
24
+ listOpenIncidentsBySystem: mock(async () => bySystem),
25
+ };
26
+ }
27
+
28
+ // The per-source gate (global rule, team grants, service trust) is owned and
29
+ // tested by `createGatedSystemSignalsContributor`; here we use simple resolver
30
+ // stubs and focus on this plugin's wiring (source id, service, shared deriver).
31
+ const allowAll: SystemAccessResolver = {
32
+ accessibleSystemIds: async ({ systemIds }) => systemIds,
33
+ };
34
+ const denyAll: SystemAccessResolver = { accessibleSystemIds: async () => [] };
35
+
36
+ const userWithRead: AuthUser = {
37
+ type: "user",
38
+ id: "u1",
39
+ accessRules: ["incident.incident.read"],
40
+ };
41
+ const userWithoutRead: AuthUser = { type: "user", id: "u2", accessRules: [] };
42
+
43
+ describe("incident system-signals contributor", () => {
44
+ test("uses the shared incident source id", () => {
45
+ const contributor = createIncidentSignalsContributor({
46
+ service: makeService({}),
47
+ resolver: allowAll,
48
+ });
49
+ expect(contributor.sourceId).toBe("incident");
50
+ });
51
+
52
+ test("wires the service + shared deriver for an authorized principal", async () => {
53
+ const service = makeService({ "sys-1": [openIncident] });
54
+ const contributor = createIncidentSignalsContributor({
55
+ service,
56
+ resolver: allowAll,
57
+ });
58
+
59
+ const result = await contributor.read({ principal: userWithRead });
60
+
61
+ expect(result.accessible).toBe(true);
62
+ expect(Object.keys(result.signals)).toEqual(["sys-1"]);
63
+ expect(result.signals["sys-1"][0]).toMatchObject({
64
+ source: "incident",
65
+ tone: "error",
66
+ label: "Critical incident",
67
+ detail: "Database down",
68
+ since: "2026-06-01T10:00:00.000Z",
69
+ });
70
+ });
71
+
72
+ test("filters to team-granted systems for a non-global user", async () => {
73
+ const service = makeService({
74
+ "sys-1": [openIncident],
75
+ "sys-2": [{ ...openIncident, id: "inc-2", systemIds: ["sys-2"] }],
76
+ });
77
+ const recorded: string[] = [];
78
+ const resolver: SystemAccessResolver = {
79
+ accessibleSystemIds: async ({ resourceType, systemIds }) => {
80
+ recorded.push(resourceType);
81
+ return systemIds.filter((id) => id === "sys-2");
82
+ },
83
+ };
84
+ const contributor = createIncidentSignalsContributor({ service, resolver });
85
+
86
+ const result = await contributor.read({ principal: userWithoutRead });
87
+
88
+ expect(Object.keys(result.signals)).toEqual(["sys-2"]);
89
+ expect(recorded).toEqual(["incident.incident"]);
90
+ });
91
+
92
+ test("a non-global user with no team grants gets nothing", async () => {
93
+ const contributor = createIncidentSignalsContributor({
94
+ service: makeService({ "sys-1": [openIncident] }),
95
+ resolver: denyAll,
96
+ });
97
+ const result = await contributor.read({ principal: userWithoutRead });
98
+ expect(result).toEqual({ accessible: false, signals: {} });
99
+ });
100
+ });
@@ -0,0 +1,44 @@
1
+ import {
2
+ createGatedSystemSignalsContributor,
3
+ type SystemAccessResolver,
4
+ type SystemSignalsContributor,
5
+ } from "@checkstack/ai-backend";
6
+ import {
7
+ incidentAccess,
8
+ INCIDENT_SIGNAL_SOURCE_ID,
9
+ deriveIncidentSignals,
10
+ } from "@checkstack/incident-common";
11
+ import type { IncidentService } from "./service";
12
+
13
+ /** The slice of `IncidentService` the contributor needs - eases testing. */
14
+ type SignalsService = Pick<IncidentService, "listOpenIncidentsBySystem">;
15
+
16
+ /**
17
+ * Build the incident `SystemSignalsContributor` for the backend `system.issues`
18
+ * AI tool. Reads OPEN incidents for every system globally and runs the SAME
19
+ * shared `deriveIncidentSignals` deriver the dashboard filler uses, so frontend
20
+ * and backend signals match. The per-source access gate (global rule plus
21
+ * per-system team grants) is applied by
22
+ * {@link createGatedSystemSignalsContributor}; the global read resolves from the
23
+ * authoritative incident tables, so the answer is identical on every pod.
24
+ */
25
+ export function createIncidentSignalsContributor({
26
+ service,
27
+ resolver,
28
+ }: {
29
+ service: SignalsService;
30
+ resolver: SystemAccessResolver;
31
+ }): SystemSignalsContributor {
32
+ return createGatedSystemSignalsContributor({
33
+ sourceId: INCIDENT_SIGNAL_SOURCE_ID,
34
+ accessRule: incidentAccess.incident.read,
35
+ resolver,
36
+ readSignals: async () => {
37
+ const incidentsBySystem = await service.listOpenIncidentsBySystem();
38
+ return deriveIncidentSignals({
39
+ incidentsBySystem,
40
+ systemIds: Object.keys(incidentsBySystem),
41
+ });
42
+ },
43
+ });
44
+ }