@checkstack/incident-common 1.4.4 → 1.5.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 +24 -0
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/signals.test.ts +97 -0
- package/src/signals.ts +86 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @checkstack/incident-common
|
|
2
2
|
|
|
3
|
+
## 1.5.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
|
+
|
|
3
27
|
## 1.4.4
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -29,6 +29,10 @@ export {
|
|
|
29
29
|
type AddIncidentUpdateInput,
|
|
30
30
|
} from "./schemas";
|
|
31
31
|
export { IncidentDetailsSlot, IncidentStatusSlot } from "./slots";
|
|
32
|
+
export {
|
|
33
|
+
INCIDENT_SIGNAL_SOURCE_ID,
|
|
34
|
+
deriveIncidentSignals,
|
|
35
|
+
} from "./signals";
|
|
32
36
|
export * from "./plugin-metadata";
|
|
33
37
|
export * from "./notifications";
|
|
34
38
|
export { incidentRoutes } from "./routes";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
INCIDENT_SIGNAL_SOURCE_ID,
|
|
4
|
+
deriveIncidentSignals,
|
|
5
|
+
} from "./signals";
|
|
6
|
+
import { incidentAccess } from "./access";
|
|
7
|
+
import type { IncidentWithSystems } from "./schemas";
|
|
8
|
+
|
|
9
|
+
const baseIncident = (
|
|
10
|
+
overrides: Partial<IncidentWithSystems>,
|
|
11
|
+
): IncidentWithSystems => ({
|
|
12
|
+
id: "inc-1",
|
|
13
|
+
title: "Database down",
|
|
14
|
+
description: undefined,
|
|
15
|
+
status: "investigating",
|
|
16
|
+
severity: "critical",
|
|
17
|
+
suppressNotifications: false,
|
|
18
|
+
createdAt: new Date("2026-06-01T10:00:00.000Z"),
|
|
19
|
+
updatedAt: new Date("2026-06-01T10:00:00.000Z"),
|
|
20
|
+
systemIds: ["sys-1"],
|
|
21
|
+
...overrides,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("deriveIncidentSignals", () => {
|
|
25
|
+
test("emits one error signal per open critical incident, keyed by system", () => {
|
|
26
|
+
const result = deriveIncidentSignals({
|
|
27
|
+
incidentsBySystem: {
|
|
28
|
+
"sys-1": [baseIncident({ id: "inc-1", severity: "critical" })],
|
|
29
|
+
},
|
|
30
|
+
systemIds: ["sys-1"],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result["sys-1"]).toHaveLength(1);
|
|
34
|
+
expect(result["sys-1"][0]).toEqual({
|
|
35
|
+
source: INCIDENT_SIGNAL_SOURCE_ID,
|
|
36
|
+
tone: "error",
|
|
37
|
+
label: "Critical incident",
|
|
38
|
+
detail: "Database down",
|
|
39
|
+
href: "/incident/inc-1",
|
|
40
|
+
accessRule: incidentAccess.incident.read,
|
|
41
|
+
since: "2026-06-01T10:00:00.000Z",
|
|
42
|
+
iconName: "TriangleAlert",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("maps severity to tone/label (major->warn, minor->info)", () => {
|
|
47
|
+
const result = deriveIncidentSignals({
|
|
48
|
+
incidentsBySystem: {
|
|
49
|
+
"sys-1": [
|
|
50
|
+
baseIncident({ id: "inc-major", severity: "major", title: "Slow" }),
|
|
51
|
+
],
|
|
52
|
+
"sys-2": [
|
|
53
|
+
baseIncident({
|
|
54
|
+
id: "inc-minor",
|
|
55
|
+
severity: "minor",
|
|
56
|
+
title: "Cosmetic",
|
|
57
|
+
systemIds: ["sys-2"],
|
|
58
|
+
}),
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
systemIds: ["sys-1", "sys-2"],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(result["sys-1"][0].tone).toBe("warn");
|
|
65
|
+
expect(result["sys-1"][0].label).toBe("Major incident");
|
|
66
|
+
expect(result["sys-2"][0].tone).toBe("info");
|
|
67
|
+
expect(result["sys-2"][0].label).toBe("Incident");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("skips resolved incidents and systems with no open problem", () => {
|
|
71
|
+
const result = deriveIncidentSignals({
|
|
72
|
+
incidentsBySystem: {
|
|
73
|
+
"sys-1": [baseIncident({ id: "inc-1", status: "resolved" })],
|
|
74
|
+
"sys-2": [],
|
|
75
|
+
},
|
|
76
|
+
systemIds: ["sys-1", "sys-2", "sys-3"],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(result).toEqual({});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("emits multiple signals for a system with multiple open incidents", () => {
|
|
83
|
+
const result = deriveIncidentSignals({
|
|
84
|
+
incidentsBySystem: {
|
|
85
|
+
"sys-1": [
|
|
86
|
+
baseIncident({ id: "inc-1", severity: "critical" }),
|
|
87
|
+
baseIncident({ id: "inc-2", severity: "minor", title: "Other" }),
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
systemIds: ["sys-1"],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result["sys-1"]).toHaveLength(2);
|
|
94
|
+
expect(result["sys-1"][0].tone).toBe("error");
|
|
95
|
+
expect(result["sys-1"][1].tone).toBe("info");
|
|
96
|
+
});
|
|
97
|
+
});
|
package/src/signals.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { resolveRoute } from "@checkstack/common";
|
|
2
|
+
import type {
|
|
3
|
+
SystemSignal,
|
|
4
|
+
SystemSignalsMap,
|
|
5
|
+
} from "@checkstack/catalog-common";
|
|
6
|
+
import { incidentAccess } from "./access";
|
|
7
|
+
import { incidentRoutes } from "./routes";
|
|
8
|
+
import type { IncidentWithSystems } from "./schemas";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Stable source id for incident-originated dashboard/system signals. Surfaced as
|
|
12
|
+
* `source` on every emitted {@link SystemSignal} and used as the contributor's
|
|
13
|
+
* `sourceId` in the backend `system.issues` aggregator. MUST stay in sync with
|
|
14
|
+
* both the frontend `SystemSignalsSlot` filler and the backend contributor.
|
|
15
|
+
*/
|
|
16
|
+
export const INCIDENT_SIGNAL_SOURCE_ID = "incident";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Incident severity -> signal tone. "critical" is an active breach (`error`),
|
|
20
|
+
* "major" is at-risk/degraded (`warn`), "minor" is informational (`info`).
|
|
21
|
+
*/
|
|
22
|
+
const severityToTone = {
|
|
23
|
+
critical: "error",
|
|
24
|
+
major: "warn",
|
|
25
|
+
minor: "info",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Incident severity -> signal label shown in the dashboard row.
|
|
30
|
+
*/
|
|
31
|
+
const severityToLabel = {
|
|
32
|
+
critical: "Critical incident",
|
|
33
|
+
major: "Major incident",
|
|
34
|
+
minor: "Incident",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pure, dependency-free deriver shared by the frontend `SystemSignalsSlot`
|
|
39
|
+
* filler and the backend `system.issues` contributor. Given the active
|
|
40
|
+
* incidents grouped by systemId, emit one {@link SystemSignal} per unresolved
|
|
41
|
+
* incident, keyed by systemId. Systems with no unresolved incident are omitted
|
|
42
|
+
* from the returned map (healthy as far as this source is concerned).
|
|
43
|
+
*
|
|
44
|
+
* Both callers run this SAME function so frontend and backend always produce
|
|
45
|
+
* identical signals (source/tone/label/detail/href/accessRule/since/iconName).
|
|
46
|
+
* The backend aggregator passes signals through and drops the frontend-only
|
|
47
|
+
* fields (href/accessRule/iconName); keeping them here lets the frontend render
|
|
48
|
+
* the deep-linking, permission-gated row unchanged.
|
|
49
|
+
*/
|
|
50
|
+
export function deriveIncidentSignals({
|
|
51
|
+
incidentsBySystem,
|
|
52
|
+
systemIds,
|
|
53
|
+
}: {
|
|
54
|
+
/** Active incidents grouped by systemId (the bulk RPC / global query shape). */
|
|
55
|
+
incidentsBySystem: Record<string, IncidentWithSystems[]>;
|
|
56
|
+
/** Systems to consider, in iteration order. */
|
|
57
|
+
systemIds: readonly string[];
|
|
58
|
+
}): SystemSignalsMap {
|
|
59
|
+
const result: SystemSignalsMap = {};
|
|
60
|
+
|
|
61
|
+
for (const systemId of systemIds) {
|
|
62
|
+
const incidents = (incidentsBySystem[systemId] ?? []).filter(
|
|
63
|
+
(incident) => incident.status !== "resolved",
|
|
64
|
+
);
|
|
65
|
+
if (incidents.length === 0) continue;
|
|
66
|
+
|
|
67
|
+
result[systemId] = incidents.map((incident) => {
|
|
68
|
+
const signal: SystemSignal = {
|
|
69
|
+
source: INCIDENT_SIGNAL_SOURCE_ID,
|
|
70
|
+
tone: severityToTone[incident.severity],
|
|
71
|
+
label: severityToLabel[incident.severity],
|
|
72
|
+
detail: incident.title,
|
|
73
|
+
href: resolveRoute(incidentRoutes.routes.detail, {
|
|
74
|
+
incidentId: incident.id,
|
|
75
|
+
}),
|
|
76
|
+
// Detail page is read-gated; render as text for users without it.
|
|
77
|
+
accessRule: incidentAccess.incident.read,
|
|
78
|
+
since: new Date(incident.createdAt).toISOString(),
|
|
79
|
+
iconName: "TriangleAlert",
|
|
80
|
+
};
|
|
81
|
+
return signal;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return result;
|
|
86
|
+
}
|