@checkstack/incident-backend 1.4.0 → 1.6.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/drizzle/0003_careful_ken_ellis.sql +10 -0
- package/drizzle/meta/0003_snapshot.json +300 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +14 -11
- package/src/ai/incident-add-link.test.ts +62 -0
- package/src/ai/incident-add-link.ts +63 -0
- package/src/ai/incident-add-update.test.ts +56 -0
- package/src/ai/incident-add-update.ts +66 -0
- package/src/ai/incident-create.test.ts +70 -0
- package/src/ai/incident-create.ts +65 -0
- package/src/ai/incident-delete.test.ts +88 -0
- package/src/ai/incident-delete.ts +76 -0
- package/src/ai/incident-get.projection.test.ts +43 -0
- package/src/ai/incident-remove-link.test.ts +56 -0
- package/src/ai/incident-remove-link.ts +69 -0
- package/src/ai/incident-resolve.test.ts +61 -0
- package/src/ai/incident-resolve.ts +69 -0
- package/src/ai/incident-update.test.ts +87 -0
- package/src/ai/incident-update.ts +94 -0
- package/src/ai/register-ai-tools.ts +33 -0
- package/src/ai-projection.test.ts +38 -0
- package/src/automations.test.ts +2 -0
- package/src/index.ts +54 -2
- package/src/schema.ts +20 -9
- package/src/service.test.ts +47 -6
- package/src/service.ts +98 -76
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
3
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
IncidentApi,
|
|
6
|
+
incidentAccess,
|
|
7
|
+
pluginMetadata,
|
|
8
|
+
type IncidentWithSystems,
|
|
9
|
+
} from "@checkstack/incident-common";
|
|
10
|
+
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
11
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
12
|
+
|
|
13
|
+
/** Input for `incident.resolve`: the incident id plus an optional message. */
|
|
14
|
+
export const IncidentResolveInputSchema = z.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
message: z.string().optional(),
|
|
17
|
+
});
|
|
18
|
+
export type IncidentResolveInput = z.infer<typeof IncidentResolveInputSchema>;
|
|
19
|
+
|
|
20
|
+
/** Output returned once a human applies the resolution (the resolved incident). */
|
|
21
|
+
export interface IncidentResolveApplyResult {
|
|
22
|
+
incident: IncidentWithSystems;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `incident.resolve` - mark an incident resolved by id, optionally with a final
|
|
27
|
+
* status-update message.
|
|
28
|
+
*
|
|
29
|
+
* `effect: "mutate"` - resolving is a non-destructive status change, so it
|
|
30
|
+
* auto-applies in AUTO mode and is confirm-gated in APPROVE mode. `dryRun`
|
|
31
|
+
* returns the captured payload for human review WITHOUT mutating; `execute`
|
|
32
|
+
* (reached only via `apply`) resolves the incident. The underlying RPC uses the
|
|
33
|
+
* USER-SCOPED client passed at call time, so handler-side authorization is
|
|
34
|
+
* enforced exactly as a direct UI/RPC call.
|
|
35
|
+
*/
|
|
36
|
+
export function createIncidentResolveTool(): RegisteredAiTool<
|
|
37
|
+
IncidentResolveInput,
|
|
38
|
+
IncidentResolveApplyResult
|
|
39
|
+
> {
|
|
40
|
+
const dryRun = async ({
|
|
41
|
+
input,
|
|
42
|
+
}: {
|
|
43
|
+
input: IncidentResolveInput;
|
|
44
|
+
principal: AuthUser;
|
|
45
|
+
rpcClient: RpcClient;
|
|
46
|
+
}): Promise<AiProposalPreview<IncidentResolveInput>> => {
|
|
47
|
+
return {
|
|
48
|
+
summary: `Resolve incident ${input.id}${input.message ? ` with message "${input.message}"` : ""}.`,
|
|
49
|
+
payload: input,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
name: "incident.resolve",
|
|
55
|
+
description:
|
|
56
|
+
"Mark an incident resolved by id, optionally with a final status-update message. Never resolves directly; a person must approve unless the conversation is in auto mode. Find the id with the incident read tools first.",
|
|
57
|
+
effect: "mutate",
|
|
58
|
+
input: IncidentResolveInputSchema,
|
|
59
|
+
requiredAccessRules: [
|
|
60
|
+
qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
|
|
61
|
+
],
|
|
62
|
+
dryRun,
|
|
63
|
+
async execute({ input, rpcClient }) {
|
|
64
|
+
const incidentClient = rpcClient.forPlugin(IncidentApi);
|
|
65
|
+
const incident = await incidentClient.resolveIncident(input);
|
|
66
|
+
return { incident };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import type { IncidentDetail } from "@checkstack/incident-common";
|
|
4
|
+
import { createIncidentUpdateTool } from "./incident-update";
|
|
5
|
+
|
|
6
|
+
const principal: AuthUser = {
|
|
7
|
+
type: "user",
|
|
8
|
+
id: "u1",
|
|
9
|
+
accessRules: ["incident.incident.manage"],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const existing: IncidentDetail = {
|
|
13
|
+
id: "inc1",
|
|
14
|
+
title: "Old title",
|
|
15
|
+
description: "old desc",
|
|
16
|
+
status: "investigating",
|
|
17
|
+
severity: "minor",
|
|
18
|
+
suppressNotifications: false,
|
|
19
|
+
systemIds: ["sys1"],
|
|
20
|
+
createdAt: new Date(),
|
|
21
|
+
updatedAt: new Date(),
|
|
22
|
+
updates: [],
|
|
23
|
+
links: [],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function fakeRpcClient({
|
|
27
|
+
getIncident,
|
|
28
|
+
updateIncident,
|
|
29
|
+
}: {
|
|
30
|
+
getIncident: ReturnType<typeof mock>;
|
|
31
|
+
updateIncident: ReturnType<typeof mock>;
|
|
32
|
+
}): RpcClient {
|
|
33
|
+
return {
|
|
34
|
+
forPlugin: () => ({ getIncident, updateIncident }),
|
|
35
|
+
} as unknown as RpcClient;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("incident.update tool", () => {
|
|
39
|
+
test("declares mutate effect + the manage rule", () => {
|
|
40
|
+
const tool = createIncidentUpdateTool();
|
|
41
|
+
expect(tool.name).toBe("incident.update");
|
|
42
|
+
expect(tool.effect).toBe("mutate");
|
|
43
|
+
expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
|
|
44
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("dryRun fetches existing, returns a diff, and NEVER updates", async () => {
|
|
48
|
+
const getIncident = mock(() => Promise.resolve(existing));
|
|
49
|
+
const updateIncident = mock(() => Promise.resolve(existing));
|
|
50
|
+
const rpcClient = fakeRpcClient({ getIncident, updateIncident });
|
|
51
|
+
const tool = createIncidentUpdateTool();
|
|
52
|
+
const preview = await tool.dryRun!({
|
|
53
|
+
input: { id: "inc1", severity: "critical" },
|
|
54
|
+
principal,
|
|
55
|
+
rpcClient,
|
|
56
|
+
});
|
|
57
|
+
expect(getIncident).toHaveBeenCalledWith({ id: "inc1" });
|
|
58
|
+
expect(updateIncident).not.toHaveBeenCalled();
|
|
59
|
+
expect(preview.diff).toBeDefined();
|
|
60
|
+
expect(preview.summary).toContain("critical");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("dryRun throws a clear error when the id is unknown", async () => {
|
|
64
|
+
const rpcClient = fakeRpcClient({
|
|
65
|
+
getIncident: mock(() => Promise.resolve(null)),
|
|
66
|
+
updateIncident: mock(),
|
|
67
|
+
});
|
|
68
|
+
const tool = createIncidentUpdateTool();
|
|
69
|
+
await expect(
|
|
70
|
+
tool.dryRun!({ input: { id: "nope" }, principal, rpcClient }),
|
|
71
|
+
).rejects.toThrow(/No incident found/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("execute (apply) updates via updateIncident", async () => {
|
|
75
|
+
const input = { id: "inc1", title: "New title" };
|
|
76
|
+
const updated = { ...existing, title: "New title" };
|
|
77
|
+
const updateIncident = mock(() => Promise.resolve(updated));
|
|
78
|
+
const rpcClient = fakeRpcClient({
|
|
79
|
+
getIncident: mock(() => Promise.resolve(existing)),
|
|
80
|
+
updateIncident,
|
|
81
|
+
});
|
|
82
|
+
const tool = createIncidentUpdateTool();
|
|
83
|
+
const result = await tool.execute({ input, principal, rpcClient });
|
|
84
|
+
expect(updateIncident).toHaveBeenCalledWith(input);
|
|
85
|
+
expect(result.incident).toEqual(updated);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
2
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
3
|
+
import {
|
|
4
|
+
IncidentApi,
|
|
5
|
+
incidentAccess,
|
|
6
|
+
pluginMetadata,
|
|
7
|
+
UpdateIncidentInputSchema,
|
|
8
|
+
type UpdateIncidentInput,
|
|
9
|
+
type IncidentWithSystems,
|
|
10
|
+
} from "@checkstack/incident-common";
|
|
11
|
+
import { computeFieldDiff, type AiProposalPreview } from "@checkstack/ai-common";
|
|
12
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
13
|
+
|
|
14
|
+
/** Output returned once a human applies the update (the updated incident). */
|
|
15
|
+
export interface IncidentUpdateApplyResult {
|
|
16
|
+
incident: IncidentWithSystems;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `incident.update` - edit an existing incident's metadata (title, description,
|
|
21
|
+
* severity, suppressNotifications, affected systems) by id. Only the provided
|
|
22
|
+
* fields change.
|
|
23
|
+
*
|
|
24
|
+
* `effect: "mutate"` - a non-destructive change, so it auto-applies in AUTO mode
|
|
25
|
+
* and is confirm-gated in APPROVE mode. `dryRun` fetches the live incident,
|
|
26
|
+
* computes a before -> after diff over the updatable fields, and rejects an
|
|
27
|
+
* unknown id with a self-correcting error; `execute` performs the update. The
|
|
28
|
+
* underlying RPC uses the USER-SCOPED client passed at call time, so
|
|
29
|
+
* handler-side authorization is enforced exactly as a direct UI/RPC call.
|
|
30
|
+
*/
|
|
31
|
+
export function createIncidentUpdateTool(): RegisteredAiTool<
|
|
32
|
+
UpdateIncidentInput,
|
|
33
|
+
IncidentUpdateApplyResult
|
|
34
|
+
> {
|
|
35
|
+
const dryRun = async ({
|
|
36
|
+
input,
|
|
37
|
+
rpcClient,
|
|
38
|
+
}: {
|
|
39
|
+
input: UpdateIncidentInput;
|
|
40
|
+
principal: AuthUser;
|
|
41
|
+
rpcClient: RpcClient;
|
|
42
|
+
}): Promise<AiProposalPreview<UpdateIncidentInput>> => {
|
|
43
|
+
const incidentClient = rpcClient.forPlugin(IncidentApi);
|
|
44
|
+
const existing = await incidentClient.getIncident({ id: input.id });
|
|
45
|
+
if (!existing) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`No incident found with id "${input.id}". List incidents first to get a valid id.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
// before -> after over the updatable subset only (the full incident also
|
|
51
|
+
// carries status/timestamps/updates/links that are not part of an update).
|
|
52
|
+
const before = {
|
|
53
|
+
title: existing.title,
|
|
54
|
+
description: existing.description,
|
|
55
|
+
severity: existing.severity,
|
|
56
|
+
suppressNotifications: existing.suppressNotifications,
|
|
57
|
+
systemIds: existing.systemIds,
|
|
58
|
+
};
|
|
59
|
+
const after = {
|
|
60
|
+
title: input.title ?? before.title,
|
|
61
|
+
description:
|
|
62
|
+
input.description === undefined
|
|
63
|
+
? before.description
|
|
64
|
+
: (input.description ?? undefined),
|
|
65
|
+
severity: input.severity ?? before.severity,
|
|
66
|
+
suppressNotifications:
|
|
67
|
+
input.suppressNotifications ?? before.suppressNotifications,
|
|
68
|
+
systemIds: input.systemIds ?? before.systemIds,
|
|
69
|
+
};
|
|
70
|
+
const diff = computeFieldDiff({ before, after });
|
|
71
|
+
return {
|
|
72
|
+
summary: `Update incident "${after.title}" (severity ${after.severity}, ${after.systemIds.length} system(s)).`,
|
|
73
|
+
payload: input,
|
|
74
|
+
diff,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
name: "incident.update",
|
|
80
|
+
description:
|
|
81
|
+
"Update an existing incident by id with the provided fields (title, description, severity, suppressNotifications, systemIds). Only provided fields change. Never updates directly; a person must approve unless the conversation is in auto mode. Find the id with the incident read tools first.",
|
|
82
|
+
effect: "mutate",
|
|
83
|
+
input: UpdateIncidentInputSchema,
|
|
84
|
+
requiredAccessRules: [
|
|
85
|
+
qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
|
|
86
|
+
],
|
|
87
|
+
dryRun,
|
|
88
|
+
async execute({ input, rpcClient }) {
|
|
89
|
+
const incidentClient = rpcClient.forPlugin(IncidentApi);
|
|
90
|
+
const incident = await incidentClient.updateIncident(input);
|
|
91
|
+
return { incident };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
2
|
+
import { createIncidentCreateTool } from "./incident-create";
|
|
3
|
+
import { createIncidentUpdateTool } from "./incident-update";
|
|
4
|
+
import { createIncidentDeleteTool } from "./incident-delete";
|
|
5
|
+
import { createIncidentAddUpdateTool } from "./incident-add-update";
|
|
6
|
+
import { createIncidentResolveTool } from "./incident-resolve";
|
|
7
|
+
import { createIncidentAddLinkTool } from "./incident-add-link";
|
|
8
|
+
import { createIncidentRemoveLinkTool } from "./incident-remove-link";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The incident plugin's AI tools, registered into the AI registry via
|
|
12
|
+
* `aiToolExtensionPoint` from this plugin's own init - NOT centralized in
|
|
13
|
+
* ai-backend. This is the canonical pattern any plugin (first- or third-party)
|
|
14
|
+
* uses to contribute AI tools without ai-backend depending on it.
|
|
15
|
+
*
|
|
16
|
+
* create/update/addUpdate/resolve/addLink are `mutate` (auto-applies in AUTO
|
|
17
|
+
* mode, confirm-gated in APPROVE mode); delete/removeLink are `destructive`, so
|
|
18
|
+
* always confirm-gated. They all go through the USER-SCOPED client passed at
|
|
19
|
+
* call time, so handler-side authorization is enforced exactly as a direct
|
|
20
|
+
* UI/RPC call; the resolver gate + the propose/apply re-check at propose AND
|
|
21
|
+
* apply time are the additional authorization authority.
|
|
22
|
+
*/
|
|
23
|
+
export function buildIncidentAiTools(): RegisteredAiTool[] {
|
|
24
|
+
return [
|
|
25
|
+
createIncidentCreateTool(),
|
|
26
|
+
createIncidentUpdateTool(),
|
|
27
|
+
createIncidentDeleteTool(),
|
|
28
|
+
createIncidentAddUpdateTool(),
|
|
29
|
+
createIncidentResolveTool(),
|
|
30
|
+
createIncidentAddLinkTool(),
|
|
31
|
+
createIncidentRemoveLinkTool(),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildProjectedTool, deferredProjectionExecute } from "@checkstack/ai-backend";
|
|
3
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
4
|
+
import {
|
|
5
|
+
incidentContract,
|
|
6
|
+
incidentAccess,
|
|
7
|
+
pluginMetadata,
|
|
8
|
+
} from "@checkstack/incident-common";
|
|
9
|
+
|
|
10
|
+
describe("incident AI projection (incident.list)", () => {
|
|
11
|
+
const tool = buildProjectedTool({
|
|
12
|
+
procedure: incidentContract.listIncidents,
|
|
13
|
+
sourcePluginMetadata: pluginMetadata,
|
|
14
|
+
procedureKey: "listIncidents",
|
|
15
|
+
name: "incident.list",
|
|
16
|
+
description: "List incidents with optional status/system filters. Read-only.",
|
|
17
|
+
effect: "read",
|
|
18
|
+
execute: deferredProjectionExecute,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("projects to a read-only tool named incident.list", () => {
|
|
22
|
+
expect(tool.name).toBe("incident.list");
|
|
23
|
+
expect(tool.effect).toBe("read");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("carries the source procedure's own qualified access rules", () => {
|
|
27
|
+
// Derived from listIncidents' own access metadata, NOT the broad
|
|
28
|
+
// ai.chat.read rule.
|
|
29
|
+
const expected = qualifyAccessRuleId(
|
|
30
|
+
pluginMetadata,
|
|
31
|
+
incidentAccess.incident.read,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(tool.requiredAccessRules.length).toBeGreaterThan(0);
|
|
35
|
+
expect(tool.requiredAccessRules).toEqual([expected]);
|
|
36
|
+
expect(tool.requiredAccessRules).not.toEqual(["ai.chat.read"]);
|
|
37
|
+
});
|
|
38
|
+
});
|
package/src/automations.test.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* `core/automation-backend` cover registration validity.
|
|
6
6
|
*/
|
|
7
7
|
import { describe, it, expect, mock } from "bun:test";
|
|
8
|
+
import type { RpcClient } from "@checkstack/backend-api";
|
|
8
9
|
import { SYSTEM_ACTOR } from "@checkstack/common";
|
|
9
10
|
import { createMockLogger } from "@checkstack/test-utils-backend";
|
|
10
11
|
|
|
@@ -48,6 +49,7 @@ const actionContext = {
|
|
|
48
49
|
getService: async <T,>(): Promise<T> => {
|
|
49
50
|
throw new Error("not used");
|
|
50
51
|
},
|
|
52
|
+
rpcClient: { forPlugin: () => ({}) } as unknown as RpcClient,
|
|
51
53
|
};
|
|
52
54
|
|
|
53
55
|
describe("incident automation actions", () => {
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import * as schema from "./schema";
|
|
2
2
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
|
+
import {
|
|
4
|
+
aiToolExtensionPoint,
|
|
5
|
+
aiToolProjectionExtensionPoint,
|
|
6
|
+
deferredProjectionExecute,
|
|
7
|
+
} from "@checkstack/ai-backend";
|
|
3
8
|
import {
|
|
4
9
|
incidentAccessRules,
|
|
5
10
|
incidentAccess,
|
|
@@ -42,6 +47,7 @@ import {
|
|
|
42
47
|
incidentArtifactType,
|
|
43
48
|
incidentTriggers,
|
|
44
49
|
} from "./automations";
|
|
50
|
+
import { buildIncidentAiTools } from "./ai/register-ai-tools";
|
|
45
51
|
|
|
46
52
|
// =============================================================================
|
|
47
53
|
// Plugin Definition
|
|
@@ -127,6 +133,7 @@ export default createBackendPlugin({
|
|
|
127
133
|
rpcClient: coreServices.rpcClient,
|
|
128
134
|
signalService: coreServices.signalService,
|
|
129
135
|
cacheManager: coreServices.cacheManager,
|
|
136
|
+
advisoryLock: coreServices.advisoryLock,
|
|
130
137
|
},
|
|
131
138
|
init: async ({
|
|
132
139
|
logger,
|
|
@@ -135,6 +142,7 @@ export default createBackendPlugin({
|
|
|
135
142
|
rpcClient,
|
|
136
143
|
signalService,
|
|
137
144
|
cacheManager,
|
|
145
|
+
advisoryLock,
|
|
138
146
|
}) => {
|
|
139
147
|
logger.debug("🔧 Initializing Incident Backend...");
|
|
140
148
|
|
|
@@ -144,6 +152,7 @@ export default createBackendPlugin({
|
|
|
144
152
|
|
|
145
153
|
const service = new IncidentService(
|
|
146
154
|
database as SafeDatabase<typeof schema>,
|
|
155
|
+
advisoryLock,
|
|
147
156
|
);
|
|
148
157
|
// Publish the service for the PLUGIN-BACKED entity `read` accessor
|
|
149
158
|
// (defined in register()). Mutations only run from here onward.
|
|
@@ -177,6 +186,44 @@ export default createBackendPlugin({
|
|
|
177
186
|
automationActions.registerAction(action, pluginMetadata);
|
|
178
187
|
}
|
|
179
188
|
|
|
189
|
+
// Register this plugin's AI tools (create/update/delete/addUpdate/
|
|
190
|
+
// resolve/addLink/removeLink) into the AI registry via the extension
|
|
191
|
+
// point - owned here, not in ai-backend.
|
|
192
|
+
const aiToolExt = env.getExtensionPoint(aiToolExtensionPoint);
|
|
193
|
+
for (const tool of buildIncidentAiTools()) {
|
|
194
|
+
aiToolExt.registerTool(tool, pluginMetadata);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Expose this plugin's read-only AI projection (`incident.list`) via
|
|
198
|
+
// the AI projection extension point. ai-backend collects its routing in
|
|
199
|
+
// afterPluginsReady and never imports incident-common.
|
|
200
|
+
const aiProjectionExt = env.getExtensionPoint(
|
|
201
|
+
aiToolProjectionExtensionPoint,
|
|
202
|
+
);
|
|
203
|
+
aiProjectionExt.expose({
|
|
204
|
+
procedure: incidentContract.listIncidents,
|
|
205
|
+
sourcePluginMetadata: pluginMetadata,
|
|
206
|
+
procedureKey: "listIncidents",
|
|
207
|
+
name: "incident.list",
|
|
208
|
+
description:
|
|
209
|
+
"List incidents with optional status/system filters. Read-only.",
|
|
210
|
+
effect: "read",
|
|
211
|
+
execute: deferredProjectionExecute,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Expose a read-only projection of `getIncident` so the model can pull
|
|
215
|
+
// one incident's full timeline (updates) + links to ground its actions.
|
|
216
|
+
aiProjectionExt.expose({
|
|
217
|
+
procedure: incidentContract.getIncident,
|
|
218
|
+
sourcePluginMetadata: pluginMetadata,
|
|
219
|
+
procedureKey: "getIncident",
|
|
220
|
+
name: "incident.get",
|
|
221
|
+
description:
|
|
222
|
+
"Get one incident with its full timeline (updates) and links. Read-only.",
|
|
223
|
+
effect: "read",
|
|
224
|
+
execute: deferredProjectionExecute,
|
|
225
|
+
});
|
|
226
|
+
|
|
180
227
|
// Register "Create Incident" command in the command palette
|
|
181
228
|
registerSearchProvider({
|
|
182
229
|
pluginMetadata,
|
|
@@ -208,9 +255,14 @@ export default createBackendPlugin({
|
|
|
208
255
|
// associations) + register subscription specs. Per-system /
|
|
209
256
|
// per-group notification group lifecycle is fully owned by
|
|
210
257
|
// notification-backend now — incident never touches it.
|
|
211
|
-
afterPluginsReady: async ({
|
|
258
|
+
afterPluginsReady: async ({
|
|
259
|
+
database,
|
|
260
|
+
logger,
|
|
261
|
+
rpcClient,
|
|
262
|
+
advisoryLock,
|
|
263
|
+
}) => {
|
|
212
264
|
const typedDb = database as SafeDatabase<typeof schema>;
|
|
213
|
-
const service = new IncidentService(typedDb);
|
|
265
|
+
const service = new IncidentService(typedDb, advisoryLock);
|
|
214
266
|
const notificationClient = rpcClient.forPlugin(NotificationApi);
|
|
215
267
|
|
|
216
268
|
await Promise.all([
|
package/src/schema.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
timestamp,
|
|
6
6
|
primaryKey,
|
|
7
7
|
boolean,
|
|
8
|
+
uniqueIndex,
|
|
8
9
|
} from "drizzle-orm/pg-core";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -77,12 +78,22 @@ export const incidentUpdates = pgTable("incident_updates", {
|
|
|
77
78
|
* Hotlinks attached to an incident — e.g. a Jira ticket, runbook, or chat
|
|
78
79
|
* thread. Free-form URL + optional human label.
|
|
79
80
|
*/
|
|
80
|
-
export const incidentLinks = pgTable(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
export const incidentLinks = pgTable(
|
|
82
|
+
"incident_links",
|
|
83
|
+
{
|
|
84
|
+
id: text("id").primaryKey(),
|
|
85
|
+
incidentId: text("incident_id")
|
|
86
|
+
.notNull()
|
|
87
|
+
.references(() => incidents.id, { onDelete: "cascade" }),
|
|
88
|
+
label: text("label"),
|
|
89
|
+
url: text("url").notNull(),
|
|
90
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
91
|
+
},
|
|
92
|
+
(t) => ({
|
|
93
|
+
// The same URL may be attached to an incident only once.
|
|
94
|
+
incidentUrlUnique: uniqueIndex("incident_links_incident_url_unique").on(
|
|
95
|
+
t.incidentId,
|
|
96
|
+
t.url,
|
|
97
|
+
),
|
|
98
|
+
}),
|
|
99
|
+
);
|
package/src/service.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import type { AdvisoryLockService } from "@checkstack/backend-api";
|
|
2
3
|
import { IncidentService } from "./service";
|
|
3
4
|
import {
|
|
4
5
|
incidents,
|
|
@@ -7,6 +8,40 @@ import {
|
|
|
7
8
|
incidentLinks,
|
|
8
9
|
} from "./schema";
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* In-memory {@link AdvisoryLockService} that faithfully serializes
|
|
13
|
+
* `withXactLock` calls per key (a racing call on the same key cannot run its
|
|
14
|
+
* `fn` until the prior call's `fn` settles) — modelling `pg_advisory_xact_lock`
|
|
15
|
+
* without a real connection. Different keys are independent.
|
|
16
|
+
*/
|
|
17
|
+
function makeFakeAdvisoryLock(): AdvisoryLockService {
|
|
18
|
+
const tails = new Map<string, Promise<unknown>>();
|
|
19
|
+
return {
|
|
20
|
+
tryAcquire: async () => ({ release: async () => {} }),
|
|
21
|
+
withXactLock<T>({
|
|
22
|
+
key,
|
|
23
|
+
fn,
|
|
24
|
+
}: {
|
|
25
|
+
key: string;
|
|
26
|
+
fn: () => Promise<T>;
|
|
27
|
+
}): Promise<T> {
|
|
28
|
+
const prior = tails.get(key) ?? Promise.resolve();
|
|
29
|
+
const result = prior.then(
|
|
30
|
+
() => fn(),
|
|
31
|
+
() => fn(),
|
|
32
|
+
);
|
|
33
|
+
tails.set(
|
|
34
|
+
key,
|
|
35
|
+
result.then(
|
|
36
|
+
() => undefined,
|
|
37
|
+
() => undefined,
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
return result;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
10
45
|
/**
|
|
11
46
|
* Programmable mock DB that records each `select(...).from(...).where(...)`
|
|
12
47
|
* (and optional `.limit(...)`) chain and returns a configurable row array
|
|
@@ -48,7 +83,7 @@ describe("IncidentService.hasActiveIncidentWithSuppression", () => {
|
|
|
48
83
|
|
|
49
84
|
const setup = (resultsByCall: unknown[][]) => {
|
|
50
85
|
dbHelper = createProgrammableSelectDb(resultsByCall);
|
|
51
|
-
service = new IncidentService(dbHelper.db as never);
|
|
86
|
+
service = new IncidentService(dbHelper.db as never, makeFakeAdvisoryLock());
|
|
52
87
|
};
|
|
53
88
|
|
|
54
89
|
beforeEach(() => {
|
|
@@ -134,7 +169,7 @@ describe("IncidentService.hasActiveIncidentWithSuppression", () => {
|
|
|
134
169
|
describe("IncidentService.getManyEntityStates (plugin-backed entity read)", () => {
|
|
135
170
|
it("returns {} for an empty id set without querying", async () => {
|
|
136
171
|
const dbHelper = createProgrammableSelectDb([]);
|
|
137
|
-
const service = new IncidentService(dbHelper.db as never);
|
|
172
|
+
const service = new IncidentService(dbHelper.db as never, makeFakeAdvisoryLock());
|
|
138
173
|
expect(await service.getManyEntityStates([])).toEqual({});
|
|
139
174
|
expect(dbHelper.getCallCount()).toBe(0);
|
|
140
175
|
});
|
|
@@ -153,7 +188,7 @@ describe("IncidentService.getManyEntityStates (plugin-backed entity read)", () =
|
|
|
153
188
|
{ incidentId: "inc-2", systemId: "sys-c" },
|
|
154
189
|
],
|
|
155
190
|
]);
|
|
156
|
-
const service = new IncidentService(dbHelper.db as never);
|
|
191
|
+
const service = new IncidentService(dbHelper.db as never, makeFakeAdvisoryLock());
|
|
157
192
|
const out = await service.getManyEntityStates(["inc-1", "inc-2", "inc-x"]);
|
|
158
193
|
expect(out).toEqual({
|
|
159
194
|
"inc-1": {
|
|
@@ -172,7 +207,7 @@ describe("IncidentService.getManyEntityStates (plugin-backed entity read)", () =
|
|
|
172
207
|
// incidents query returns nothing → no second query.
|
|
173
208
|
[],
|
|
174
209
|
]);
|
|
175
|
-
const service = new IncidentService(dbHelper.db as never);
|
|
210
|
+
const service = new IncidentService(dbHelper.db as never, makeFakeAdvisoryLock());
|
|
176
211
|
expect(await service.getManyEntityStates(["ghost"])).toEqual({});
|
|
177
212
|
expect(dbHelper.getCallCount()).toBe(1);
|
|
178
213
|
});
|
|
@@ -182,7 +217,7 @@ describe("IncidentService.getManyEntityStates (plugin-backed entity read)", () =
|
|
|
182
217
|
[{ id: "inc-1", status: "monitoring", severity: "critical" }],
|
|
183
218
|
[], // no junction rows
|
|
184
219
|
]);
|
|
185
|
-
const service = new IncidentService(dbHelper.db as never);
|
|
220
|
+
const service = new IncidentService(dbHelper.db as never, makeFakeAdvisoryLock());
|
|
186
221
|
const out = await service.getManyEntityStates(["inc-1"]);
|
|
187
222
|
expect(out["inc-1"]).toEqual({
|
|
188
223
|
status: "monitoring",
|
|
@@ -266,6 +301,12 @@ function createDedupFakeDb() {
|
|
|
266
301
|
// The lock key is embedded in the SQL the helper runs via tx.execute.
|
|
267
302
|
let lockKey = "default";
|
|
268
303
|
const tx = {
|
|
304
|
+
// A transaction handle exposes the same query surface as `db`, so a
|
|
305
|
+
// service method that wraps its writes in `db.transaction` can issue
|
|
306
|
+
// select/insert against the same backing store (e.g. createIncident,
|
|
307
|
+
// which now commits the incident + its system links atomically).
|
|
308
|
+
select: buildSelect(),
|
|
309
|
+
insert: buildInsert(),
|
|
269
310
|
execute: async (sqlObj: unknown) => {
|
|
270
311
|
// Drizzle sql`` carries the interpolated key in its params; the
|
|
271
312
|
// helper interpolates exactly one param (the lock key).
|
|
@@ -300,7 +341,7 @@ function createDedupFakeDb() {
|
|
|
300
341
|
describe("IncidentService.createIncidentDedupedForSystem (M3)", () => {
|
|
301
342
|
it("two concurrent dedupe creates for one system open exactly ONE incident", async () => {
|
|
302
343
|
const { db, store } = createDedupFakeDb();
|
|
303
|
-
const service = new IncidentService(db as never);
|
|
344
|
+
const service = new IncidentService(db as never, makeFakeAdvisoryLock());
|
|
304
345
|
|
|
305
346
|
const input = {
|
|
306
347
|
title: "Down",
|