@checkstack/ai-backend 0.1.6 → 0.3.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.
@@ -0,0 +1,236 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { AuthUser } from "@checkstack/backend-api";
3
+ import type { SystemSignal, SystemSignalsMap } from "@checkstack/catalog-common";
4
+ import type {
5
+ SystemSignalsContributor,
6
+ SystemSignalsContribution,
7
+ } from "../extension-points";
8
+ import {
9
+ mergeSystemSignalsMaps,
10
+ collectSystemSignals,
11
+ toSystemIssuesOutput,
12
+ type SystemSignalsCollection,
13
+ } from "./system-issues";
14
+
15
+ const signal = (over: Partial<SystemSignal> = {}): SystemSignal => ({
16
+ source: "incident",
17
+ tone: "error",
18
+ label: "Critical incident",
19
+ ...over,
20
+ });
21
+
22
+ const principal: AuthUser = {
23
+ type: "user",
24
+ id: "u1",
25
+ accessRules: ["catalog.system.read"],
26
+ };
27
+
28
+ /** A contributor that is accessible and returns `signals`. */
29
+ const ok = (
30
+ sourceId: string,
31
+ signals: SystemSignalsMap,
32
+ ): SystemSignalsContributor => ({
33
+ sourceId,
34
+ read: async (): Promise<SystemSignalsContribution> => ({
35
+ accessible: true,
36
+ signals,
37
+ }),
38
+ });
39
+
40
+ /** A contributor the principal cannot access. */
41
+ const denied = (sourceId: string): SystemSignalsContributor => ({
42
+ sourceId,
43
+ read: async (): Promise<SystemSignalsContribution> => ({
44
+ accessible: false,
45
+ signals: {},
46
+ }),
47
+ });
48
+
49
+ /** A contributor that throws while reading. */
50
+ const throwing = (sourceId: string): SystemSignalsContributor => ({
51
+ sourceId,
52
+ read: async (): Promise<SystemSignalsContribution> => {
53
+ throw new Error(`${sourceId} exploded`);
54
+ },
55
+ });
56
+
57
+ const emptyCollection = (
58
+ merged: SystemSignalsMap,
59
+ ): SystemSignalsCollection => ({
60
+ merged,
61
+ checkedSources: [],
62
+ inaccessibleSources: [],
63
+ failedSources: [],
64
+ });
65
+
66
+ describe("mergeSystemSignalsMaps", () => {
67
+ test("merges two maps by systemId, concatenating signal arrays", () => {
68
+ const a: SystemSignalsMap = {
69
+ sysA: [signal({ source: "incident", label: "Incident" })],
70
+ sysB: [signal({ source: "incident", label: "B incident" })],
71
+ };
72
+ const b: SystemSignalsMap = {
73
+ sysA: [signal({ source: "slo", tone: "warn", label: "SLO at risk" })],
74
+ sysC: [signal({ source: "anomaly", tone: "warn", label: "Anomaly" })],
75
+ };
76
+
77
+ const merged = mergeSystemSignalsMaps([a, b]);
78
+
79
+ expect(Object.keys(merged).sort()).toEqual(["sysA", "sysB", "sysC"]);
80
+ // sysA gets both sources concatenated.
81
+ expect(merged.sysA).toHaveLength(2);
82
+ expect(merged.sysA.map((s) => s.source)).toEqual(["incident", "slo"]);
83
+ expect(merged.sysB).toHaveLength(1);
84
+ expect(merged.sysC).toHaveLength(1);
85
+ });
86
+
87
+ test("does not mutate the input maps", () => {
88
+ const a: SystemSignalsMap = { sysA: [signal()] };
89
+ const b: SystemSignalsMap = { sysA: [signal({ source: "slo" })] };
90
+
91
+ mergeSystemSignalsMaps([a, b]);
92
+
93
+ expect(a.sysA).toHaveLength(1);
94
+ expect(b.sysA).toHaveLength(1);
95
+ });
96
+
97
+ test("skips empty signal arrays", () => {
98
+ const merged = mergeSystemSignalsMaps([{ sysA: [] }, { sysB: [signal()] }]);
99
+ expect(Object.keys(merged)).toEqual(["sysB"]);
100
+ });
101
+ });
102
+
103
+ describe("collectSystemSignals", () => {
104
+ test("merges signals from accessible contributors and lists them as checked", async () => {
105
+ const contributors = [
106
+ ok("incident", { sysA: [signal({ source: "incident" })] }),
107
+ ok("slo", {
108
+ sysA: [signal({ source: "slo", tone: "warn", label: "SLO" })],
109
+ sysB: [signal({ source: "slo", tone: "warn", label: "SLO B" })],
110
+ }),
111
+ ];
112
+
113
+ const result = await collectSystemSignals({ contributors, principal });
114
+
115
+ expect(result.merged.sysA.map((s) => s.source)).toEqual([
116
+ "incident",
117
+ "slo",
118
+ ]);
119
+ expect(result.merged.sysB).toHaveLength(1);
120
+ expect(result.checkedSources.sort()).toEqual(["incident", "slo"]);
121
+ expect(result.inaccessibleSources).toEqual([]);
122
+ expect(result.failedSources).toEqual([]);
123
+ });
124
+
125
+ test("reports an inaccessible source distinctly (not as empty/clear)", async () => {
126
+ const contributors = [
127
+ denied("incident"),
128
+ ok("slo", { sysA: [signal({ source: "slo" })] }),
129
+ ];
130
+
131
+ const result = await collectSystemSignals({ contributors, principal });
132
+
133
+ // The denied source contributes no signals but IS surfaced so the model
134
+ // can say "could not check incidents" instead of "no incidents".
135
+ expect(Object.keys(result.merged)).toEqual(["sysA"]);
136
+ expect(result.inaccessibleSources).toEqual(["incident"]);
137
+ expect(result.checkedSources).toEqual(["slo"]);
138
+ expect(result.failedSources).toEqual([]);
139
+ });
140
+
141
+ test("reports a throwing contributor as failed without breaking the call", async () => {
142
+ const contributors = [
143
+ throwing("boom"),
144
+ ok("incident", { sysA: [signal({ source: "incident" })] }),
145
+ ];
146
+
147
+ const result = await collectSystemSignals({ contributors, principal });
148
+
149
+ expect(Object.keys(result.merged)).toEqual(["sysA"]);
150
+ expect(result.failedSources).toEqual(["boom"]);
151
+ expect(result.checkedSources).toEqual(["incident"]);
152
+ });
153
+
154
+ test("no contributors produces no entries and empty coverage", async () => {
155
+ const result = await collectSystemSignals({ contributors: [], principal });
156
+ expect(result.merged).toEqual({});
157
+ expect(result.checkedSources).toEqual([]);
158
+ expect(result.inaccessibleSources).toEqual([]);
159
+ expect(result.failedSources).toEqual([]);
160
+ });
161
+ });
162
+
163
+ describe("toSystemIssuesOutput", () => {
164
+ test("groups signals by system and counts totals", () => {
165
+ const out = toSystemIssuesOutput({
166
+ collection: emptyCollection({
167
+ sysA: [signal({ source: "incident" }), signal({ source: "slo" })],
168
+ sysB: [signal({ source: "anomaly" })],
169
+ }),
170
+ });
171
+
172
+ expect(out.totalSystems).toBe(2);
173
+ expect(out.totalSignals).toBe(3);
174
+ const sysA = out.systems.find((s) => s.systemId === "sysA");
175
+ expect(sysA?.signals.map((s) => s.source)).toEqual(["incident", "slo"]);
176
+ });
177
+
178
+ test("passes per-source coverage through to the output", () => {
179
+ const out = toSystemIssuesOutput({
180
+ collection: {
181
+ merged: { sysA: [signal()] },
182
+ checkedSources: ["incident", "slo"],
183
+ inaccessibleSources: ["healthcheck"],
184
+ failedSources: ["dependency"],
185
+ },
186
+ });
187
+
188
+ expect(out.checkedSources).toEqual(["incident", "slo"]);
189
+ expect(out.inaccessibleSources).toEqual(["healthcheck"]);
190
+ expect(out.failedSources).toEqual(["dependency"]);
191
+ });
192
+
193
+ test("narrows to the requested systemIds", () => {
194
+ const out = toSystemIssuesOutput({
195
+ collection: emptyCollection({
196
+ sysA: [signal()],
197
+ sysB: [signal()],
198
+ }),
199
+ systemIds: ["sysB"],
200
+ });
201
+
202
+ expect(out.systems.map((s) => s.systemId)).toEqual(["sysB"]);
203
+ expect(out.totalSystems).toBe(1);
204
+ });
205
+
206
+ test("drops href/accessRule/iconName, keeps source/tone/label/detail/since", () => {
207
+ const out = toSystemIssuesOutput({
208
+ collection: emptyCollection({
209
+ sysA: [
210
+ signal({
211
+ detail: "2 of 3 checks failing",
212
+ since: "2026-06-07T00:00:00Z",
213
+ href: "/checkstack/x",
214
+ accessRule: {
215
+ id: "x",
216
+ resource: "x",
217
+ level: "read",
218
+ pluginId: "p",
219
+ description: "view x",
220
+ },
221
+ iconName: "TriangleAlert",
222
+ }),
223
+ ],
224
+ }),
225
+ });
226
+
227
+ const s = out.systems[0].signals[0];
228
+ expect(s).toEqual({
229
+ source: "incident",
230
+ tone: "error",
231
+ label: "Critical incident",
232
+ detail: "2 of 3 checks failing",
233
+ since: "2026-06-07T00:00:00Z",
234
+ });
235
+ });
236
+ });
@@ -0,0 +1,209 @@
1
+ import { z } from "zod";
2
+ import { qualifyAccessRuleId } from "@checkstack/common";
3
+ import type { AuthUser } from "@checkstack/backend-api";
4
+ import {
5
+ catalogAccess,
6
+ pluginMetadata as catalogPluginMetadata,
7
+ } from "@checkstack/catalog-common";
8
+ import type { SystemSignal, SystemSignalsMap } from "@checkstack/catalog-common";
9
+ import type { SystemSignalsContributor } from "../extension-points";
10
+ import type { RegisteredAiTool } from "../tool-registry";
11
+
12
+ /** Input for `system.issues`: optionally narrow the answer to specific systems. */
13
+ export const SystemIssuesInputSchema = z.object({
14
+ /**
15
+ * When provided, only signals for these system ids are returned. Omit to get
16
+ * issues across ALL systems the principal can see.
17
+ */
18
+ systemIds: z.array(z.string()).optional(),
19
+ });
20
+ export type SystemIssuesInput = z.infer<typeof SystemIssuesInputSchema>;
21
+
22
+ /** One signal as surfaced to the model (the source it came from is carried inline). */
23
+ export const SystemIssueSignalSchema = z.object({
24
+ source: z.string(),
25
+ tone: z.enum(["error", "warn", "info"]),
26
+ label: z.string(),
27
+ detail: z.string().optional(),
28
+ since: z.string().optional(),
29
+ });
30
+
31
+ /** All current issues for one system. */
32
+ export const SystemIssuesGroupSchema = z.object({
33
+ systemId: z.string(),
34
+ signals: z.array(SystemIssueSignalSchema),
35
+ });
36
+
37
+ /** The model-facing result: issues grouped by system, plus per-source coverage. */
38
+ export const SystemIssuesOutputSchema = z.object({
39
+ /** One entry per system that currently has at least one issue. */
40
+ systems: z.array(SystemIssuesGroupSchema),
41
+ /** Total number of systems with issues (== `systems.length`). */
42
+ totalSystems: z.number(),
43
+ /** Total number of individual signals across all systems. */
44
+ totalSignals: z.number(),
45
+ /** Sources that were successfully checked (the answer covers these). */
46
+ checkedSources: z.array(z.string()),
47
+ /**
48
+ * Sources the caller lacks permission to read. These were NOT checked, so an
49
+ * empty `systems` does NOT mean these sources are clear - tell the operator
50
+ * they could not be checked due to access.
51
+ */
52
+ inaccessibleSources: z.array(z.string()),
53
+ /** Sources that errored while being read (also not reflected in `systems`). */
54
+ failedSources: z.array(z.string()),
55
+ });
56
+ export type SystemIssuesOutput = z.infer<typeof SystemIssuesOutputSchema>;
57
+
58
+ /** Per-source outcome of a {@link collectSystemSignals} fan-out. */
59
+ export interface SystemSignalsCollection {
60
+ merged: SystemSignalsMap;
61
+ checkedSources: string[];
62
+ inaccessibleSources: string[];
63
+ failedSources: string[];
64
+ }
65
+
66
+ /**
67
+ * Merge several contributors' {@link SystemSignalsMap}s into one map keyed by
68
+ * systemId, concatenating the signal arrays for systems that appear in more than
69
+ * one source. Pure and order-stable (sources are concatenated in input order),
70
+ * so it is unit-testable without standing up an environment.
71
+ */
72
+ export function mergeSystemSignalsMaps(
73
+ maps: SystemSignalsMap[],
74
+ ): SystemSignalsMap {
75
+ const merged: SystemSignalsMap = {};
76
+ for (const map of maps) {
77
+ for (const [systemId, signals] of Object.entries(map)) {
78
+ if (signals.length === 0) continue;
79
+ const existing = merged[systemId];
80
+ if (existing) {
81
+ existing.push(...signals);
82
+ } else {
83
+ merged[systemId] = [...signals];
84
+ }
85
+ }
86
+ }
87
+ return merged;
88
+ }
89
+
90
+ /**
91
+ * Shape a merged {@link SystemSignalsMap} into the model-friendly grouped
92
+ * result, optionally narrowed to `systemIds`. Drops the link/icon fields the
93
+ * model does not need (`href`, `accessRule`, `iconName`) and keeps
94
+ * source/tone/label/detail/since.
95
+ */
96
+ export function toSystemIssuesOutput({
97
+ collection,
98
+ systemIds,
99
+ }: {
100
+ collection: SystemSignalsCollection;
101
+ systemIds?: string[];
102
+ }): SystemIssuesOutput {
103
+ const allow = systemIds ? new Set(systemIds) : undefined;
104
+ const systems = Object.entries(collection.merged)
105
+ .filter(([systemId]) => !allow || allow.has(systemId))
106
+ .map(([systemId, signals]) => ({
107
+ systemId,
108
+ signals: signals.map((s: SystemSignal) => ({
109
+ source: s.source,
110
+ tone: s.tone,
111
+ label: s.label,
112
+ detail: s.detail,
113
+ since: s.since,
114
+ })),
115
+ }));
116
+ return {
117
+ systems,
118
+ totalSystems: systems.length,
119
+ totalSignals: systems.reduce((sum, s) => sum + s.signals.length, 0),
120
+ checkedSources: collection.checkedSources,
121
+ inaccessibleSources: collection.inaccessibleSources,
122
+ failedSources: collection.failedSources,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Read every contributor's signals for `principal` and return the merged map
128
+ * plus per-source coverage. Each source is classified so the caller can tell
129
+ * "checked and clear" apart from "skipped":
130
+ * - checked: read succeeded and the principal could access it,
131
+ * - inaccessible: the principal lacked access (`accessible: false`),
132
+ * - failed: the contributor threw.
133
+ * A throwing/denied source never breaks the whole call.
134
+ */
135
+ export async function collectSystemSignals({
136
+ contributors,
137
+ principal,
138
+ }: {
139
+ contributors: SystemSignalsContributor[];
140
+ principal: AuthUser;
141
+ }): Promise<SystemSignalsCollection> {
142
+ const settled = await Promise.allSettled(
143
+ contributors.map((c) => c.read({ principal })),
144
+ );
145
+ const maps: SystemSignalsMap[] = [];
146
+ const checkedSources: string[] = [];
147
+ const inaccessibleSources: string[] = [];
148
+ const failedSources: string[] = [];
149
+ for (const [index, result] of settled.entries()) {
150
+ const { sourceId } = contributors[index];
151
+ if (result.status !== "fulfilled") {
152
+ failedSources.push(sourceId);
153
+ continue;
154
+ }
155
+ if (!result.value.accessible) {
156
+ inaccessibleSources.push(sourceId);
157
+ continue;
158
+ }
159
+ checkedSources.push(sourceId);
160
+ maps.push(result.value.signals);
161
+ }
162
+ return {
163
+ merged: mergeSystemSignalsMaps(maps),
164
+ checkedSources,
165
+ inaccessibleSources,
166
+ failedSources,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * `system.issues` - the single "what are the current issues" tool. It fans out
172
+ * across every backend `SystemSignalsContributor` (failing health checks,
173
+ * breaching/at-risk SLOs, active anomalies, open incidents, active
174
+ * maintenances, dependency problems) and returns them aggregated across systems
175
+ * in ONE call. `effect: "read"` (auto-runs).
176
+ *
177
+ * The `contributors` array is the live array from
178
+ * `createSystemSignalsExtensionPoint`, read at execute time so contributors
179
+ * registered during plugin init are always seen.
180
+ */
181
+ export function createSystemIssuesTool({
182
+ contributors,
183
+ }: {
184
+ contributors: SystemSignalsContributor[];
185
+ }): RegisteredAiTool<SystemIssuesInput, SystemIssuesOutput> {
186
+ return {
187
+ name: "system.issues",
188
+ description:
189
+ "Return ALL current system issues - failing health checks, breaching or at-risk SLOs, active anomalies, open incidents, active maintenances, and dependency problems - aggregated across every system in ONE call. Use this FIRST whenever asked whether there are any issues, what is wrong, what is down, or for an overall health overview, before reaching for any per-domain tool. Read-only. Optionally pass systemIds to narrow the answer to specific systems. The result lists `checkedSources`, plus `inaccessibleSources` (you lack permission to read these - they were NOT checked, so do not report them as clear) and `failedSources` (errored). If either is non-empty, tell the operator those sources could not be checked.",
190
+ effect: "read",
191
+ input: SystemIssuesInputSchema,
192
+ output: SystemIssuesOutputSchema,
193
+ // The TOOL is gated by catalog.system.read; PER-SOURCE access is enforced
194
+ // inside each contributor (a source the principal cannot see returns {}).
195
+ requiredAccessRules: [
196
+ qualifyAccessRuleId(catalogPluginMetadata, catalogAccess.system.read),
197
+ ],
198
+ async execute({
199
+ input,
200
+ principal,
201
+ }: {
202
+ input: SystemIssuesInput;
203
+ principal: AuthUser;
204
+ }) {
205
+ const collection = await collectSystemSignals({ contributors, principal });
206
+ return toSystemIssuesOutput({ collection, systemIds: input.systemIds });
207
+ },
208
+ };
209
+ }
@@ -38,7 +38,7 @@ describe("ai-backend's own platform tool set", () => {
38
38
  test("docs + probe tools are registered and qualified", () => {
39
39
  const registry = buildOwnRegistry();
40
40
  const names = registry.getTools().map((t) => t.name);
41
- for (const expected of ["ai.searchDocs", "ai.getDoc", "ai.probeUrl"]) {
41
+ for (const expected of ["ai_searchDocs", "ai_getDoc", "ai_probeUrl"]) {
42
42
  expect(names).toContain(expected);
43
43
  }
44
44
  });
package/tsconfig.json CHANGED
@@ -7,9 +7,15 @@
7
7
  {
8
8
  "path": "../ai-common"
9
9
  },
10
+ {
11
+ "path": "../auth-common"
12
+ },
10
13
  {
11
14
  "path": "../backend-api"
12
15
  },
16
+ {
17
+ "path": "../catalog-common"
18
+ },
13
19
  {
14
20
  "path": "../common"
15
21
  },