@checkstack/incident-backend 1.5.0 → 1.6.1
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 +120 -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 +22 -19
- 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 +44 -0
- package/src/schema.ts +20 -9
- package/src/service.test.ts +6 -0
- package/src/service.ts +84 -66
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import type { AddIncidentUpdateInput } from "@checkstack/incident-common";
|
|
4
|
+
import { createIncidentAddUpdateTool } from "./incident-add-update";
|
|
5
|
+
|
|
6
|
+
const principal: AuthUser = {
|
|
7
|
+
type: "user",
|
|
8
|
+
id: "u1",
|
|
9
|
+
accessRules: ["incident.incident.manage"],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const input: AddIncidentUpdateInput = {
|
|
13
|
+
incidentId: "inc1",
|
|
14
|
+
message: "We identified the root cause",
|
|
15
|
+
statusChange: "identified",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function fakeRpcClient({
|
|
19
|
+
addUpdate,
|
|
20
|
+
}: {
|
|
21
|
+
addUpdate: ReturnType<typeof mock>;
|
|
22
|
+
}): RpcClient {
|
|
23
|
+
return {
|
|
24
|
+
forPlugin: () => ({ addUpdate }),
|
|
25
|
+
} as unknown as RpcClient;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("incident.addUpdate tool", () => {
|
|
29
|
+
test("declares mutate effect + the manage rule", () => {
|
|
30
|
+
const tool = createIncidentAddUpdateTool();
|
|
31
|
+
expect(tool.name).toBe("incident.addUpdate");
|
|
32
|
+
expect(tool.effect).toBe("mutate");
|
|
33
|
+
expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
|
|
34
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("dryRun returns a payload and NEVER posts the update", async () => {
|
|
38
|
+
const addUpdate = mock(() => Promise.resolve({}));
|
|
39
|
+
const rpcClient = fakeRpcClient({ addUpdate });
|
|
40
|
+
const tool = createIncidentAddUpdateTool();
|
|
41
|
+
const preview = await tool.dryRun!({ input, principal, rpcClient });
|
|
42
|
+
expect(addUpdate).not.toHaveBeenCalled();
|
|
43
|
+
expect(preview.summary).toContain("identified");
|
|
44
|
+
expect(preview.payload).toEqual(input);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("execute (apply) posts via addUpdate", async () => {
|
|
48
|
+
const created = { id: "upd1", ...input, createdAt: new Date() };
|
|
49
|
+
const addUpdate = mock(() => Promise.resolve(created));
|
|
50
|
+
const rpcClient = fakeRpcClient({ addUpdate });
|
|
51
|
+
const tool = createIncidentAddUpdateTool();
|
|
52
|
+
const result = await tool.execute({ input, principal, rpcClient });
|
|
53
|
+
expect(addUpdate).toHaveBeenCalledWith(input);
|
|
54
|
+
expect(result.update).toEqual(created);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
2
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
3
|
+
import {
|
|
4
|
+
IncidentApi,
|
|
5
|
+
incidentAccess,
|
|
6
|
+
pluginMetadata,
|
|
7
|
+
AddIncidentUpdateInputSchema,
|
|
8
|
+
type AddIncidentUpdateInput,
|
|
9
|
+
type IncidentUpdate,
|
|
10
|
+
} from "@checkstack/incident-common";
|
|
11
|
+
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
12
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
13
|
+
|
|
14
|
+
/** Output returned once a human applies the update post (the new update). */
|
|
15
|
+
export interface IncidentAddUpdateApplyResult {
|
|
16
|
+
update: IncidentUpdate;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `incident.addUpdate` - post a status update (timeline entry) to an incident,
|
|
21
|
+
* optionally changing its status.
|
|
22
|
+
*
|
|
23
|
+
* `effect: "mutate"` - appending an update is a non-destructive change, so it
|
|
24
|
+
* auto-applies in AUTO mode and is confirm-gated in APPROVE mode. `dryRun`
|
|
25
|
+
* returns the captured payload for human review WITHOUT mutating; `execute`
|
|
26
|
+
* (reached only via `apply`) posts the update. The underlying RPC uses the
|
|
27
|
+
* USER-SCOPED client passed at call time, so handler-side authorization is
|
|
28
|
+
* enforced exactly as a direct UI/RPC call.
|
|
29
|
+
*/
|
|
30
|
+
export function createIncidentAddUpdateTool(): RegisteredAiTool<
|
|
31
|
+
AddIncidentUpdateInput,
|
|
32
|
+
IncidentAddUpdateApplyResult
|
|
33
|
+
> {
|
|
34
|
+
const dryRun = async ({
|
|
35
|
+
input,
|
|
36
|
+
}: {
|
|
37
|
+
input: AddIncidentUpdateInput;
|
|
38
|
+
principal: AuthUser;
|
|
39
|
+
rpcClient: RpcClient;
|
|
40
|
+
}): Promise<AiProposalPreview<AddIncidentUpdateInput>> => {
|
|
41
|
+
const statusNote = input.statusChange
|
|
42
|
+
? ` and set status to "${input.statusChange}"`
|
|
43
|
+
: "";
|
|
44
|
+
return {
|
|
45
|
+
summary: `Post an update to incident ${input.incidentId}${statusNote}.`,
|
|
46
|
+
payload: input,
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
name: "incident.addUpdate",
|
|
52
|
+
description:
|
|
53
|
+
"Post a status update (timeline entry) to an incident, optionally changing its status (investigating/identified/fixing/monitoring/resolved). Never posts directly; a person must approve unless the conversation is in auto mode. Find the incidentId with the incident read tools first.",
|
|
54
|
+
effect: "mutate",
|
|
55
|
+
input: AddIncidentUpdateInputSchema,
|
|
56
|
+
requiredAccessRules: [
|
|
57
|
+
qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
|
|
58
|
+
],
|
|
59
|
+
dryRun,
|
|
60
|
+
async execute({ input, rpcClient }) {
|
|
61
|
+
const incidentClient = rpcClient.forPlugin(IncidentApi);
|
|
62
|
+
const update = await incidentClient.addUpdate(input);
|
|
63
|
+
return { update };
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import type {
|
|
4
|
+
CreateIncidentInput,
|
|
5
|
+
IncidentWithSystems,
|
|
6
|
+
} from "@checkstack/incident-common";
|
|
7
|
+
import { createIncidentCreateTool } from "./incident-create";
|
|
8
|
+
|
|
9
|
+
const principal: AuthUser = {
|
|
10
|
+
type: "user",
|
|
11
|
+
id: "u1",
|
|
12
|
+
accessRules: ["incident.incident.manage"],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const input: CreateIncidentInput = {
|
|
16
|
+
title: "Checkout is down",
|
|
17
|
+
severity: "critical",
|
|
18
|
+
suppressNotifications: false,
|
|
19
|
+
systemIds: ["sys1", "sys2"],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function fakeRpcClient({
|
|
23
|
+
createIncident,
|
|
24
|
+
}: {
|
|
25
|
+
createIncident: ReturnType<typeof mock>;
|
|
26
|
+
}): RpcClient {
|
|
27
|
+
return {
|
|
28
|
+
forPlugin: () => ({ createIncident }),
|
|
29
|
+
} as unknown as RpcClient;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("incident.create tool", () => {
|
|
33
|
+
test("declares mutate effect + the manage rule", () => {
|
|
34
|
+
const tool = createIncidentCreateTool();
|
|
35
|
+
expect(tool.name).toBe("incident.create");
|
|
36
|
+
expect(tool.effect).toBe("mutate");
|
|
37
|
+
expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
|
|
38
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("dryRun returns a payload and NEVER creates", async () => {
|
|
42
|
+
const createIncident = mock(() => Promise.resolve({}));
|
|
43
|
+
const rpcClient = fakeRpcClient({ createIncident });
|
|
44
|
+
const tool = createIncidentCreateTool();
|
|
45
|
+
const preview = await tool.dryRun!({ input, principal, rpcClient });
|
|
46
|
+
expect(createIncident).not.toHaveBeenCalled();
|
|
47
|
+
expect(preview.summary).toContain("Checkout is down");
|
|
48
|
+
expect(preview.payload).toEqual(input);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("execute (apply) creates via createIncident", async () => {
|
|
52
|
+
const created: IncidentWithSystems = {
|
|
53
|
+
id: "inc1",
|
|
54
|
+
title: input.title,
|
|
55
|
+
description: input.description,
|
|
56
|
+
status: "investigating",
|
|
57
|
+
severity: input.severity,
|
|
58
|
+
suppressNotifications: input.suppressNotifications,
|
|
59
|
+
systemIds: input.systemIds,
|
|
60
|
+
createdAt: new Date(),
|
|
61
|
+
updatedAt: new Date(),
|
|
62
|
+
};
|
|
63
|
+
const createIncident = mock(() => Promise.resolve(created));
|
|
64
|
+
const rpcClient = fakeRpcClient({ createIncident });
|
|
65
|
+
const tool = createIncidentCreateTool();
|
|
66
|
+
const result = await tool.execute({ input, principal, rpcClient });
|
|
67
|
+
expect(createIncident).toHaveBeenCalledWith(input);
|
|
68
|
+
expect(result.incident).toEqual(created);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
2
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
3
|
+
import {
|
|
4
|
+
IncidentApi,
|
|
5
|
+
incidentAccess,
|
|
6
|
+
pluginMetadata,
|
|
7
|
+
CreateIncidentInputSchema,
|
|
8
|
+
type CreateIncidentInput,
|
|
9
|
+
type IncidentWithSystems,
|
|
10
|
+
} from "@checkstack/incident-common";
|
|
11
|
+
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
12
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
13
|
+
|
|
14
|
+
/** Output returned once a human applies the create (the created incident). */
|
|
15
|
+
export interface IncidentCreateApplyResult {
|
|
16
|
+
incident: IncidentWithSystems;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `incident.create` - open a new incident affecting one or more systems.
|
|
21
|
+
*
|
|
22
|
+
* `effect: "mutate"` - creating an incident is a non-destructive create, so it
|
|
23
|
+
* auto-applies in AUTO mode and is confirm-gated in APPROVE mode. `dryRun`
|
|
24
|
+
* returns the captured payload for human review WITHOUT mutating; `execute`
|
|
25
|
+
* (reached only via `apply`) performs the create. Authorization is the same
|
|
26
|
+
* `incident.manage` rule the UI create form requires; the underlying RPC uses
|
|
27
|
+
* the USER-SCOPED client passed at call time, so handler-side authorization
|
|
28
|
+
* (access rules AND per-resource/team scoping) is enforced exactly as a direct
|
|
29
|
+
* UI/RPC call.
|
|
30
|
+
*/
|
|
31
|
+
export function createIncidentCreateTool(): RegisteredAiTool<
|
|
32
|
+
CreateIncidentInput,
|
|
33
|
+
IncidentCreateApplyResult
|
|
34
|
+
> {
|
|
35
|
+
const dryRun = async ({
|
|
36
|
+
input,
|
|
37
|
+
}: {
|
|
38
|
+
input: CreateIncidentInput;
|
|
39
|
+
principal: AuthUser;
|
|
40
|
+
rpcClient: RpcClient;
|
|
41
|
+
}): Promise<AiProposalPreview<CreateIncidentInput>> => {
|
|
42
|
+
const systemCount = input.systemIds.length;
|
|
43
|
+
return {
|
|
44
|
+
summary: `Create ${input.severity} incident "${input.title}" affecting ${systemCount} system(s)${input.suppressNotifications ? " (notifications suppressed)" : ""}.`,
|
|
45
|
+
payload: input,
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
name: "incident.create",
|
|
51
|
+
description:
|
|
52
|
+
"Open a new incident affecting one or more systems. Provide a title, severity (minor/major/critical), and the affected systemIds. Never creates directly; a person must approve unless the conversation is in auto mode. Find systemIds with the catalog read tools first.",
|
|
53
|
+
effect: "mutate",
|
|
54
|
+
input: CreateIncidentInputSchema,
|
|
55
|
+
requiredAccessRules: [
|
|
56
|
+
qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
|
|
57
|
+
],
|
|
58
|
+
dryRun,
|
|
59
|
+
async execute({ input, rpcClient }) {
|
|
60
|
+
const incidentClient = rpcClient.forPlugin(IncidentApi);
|
|
61
|
+
const incident = await incidentClient.createIncident(input);
|
|
62
|
+
return { incident };
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import { createIncidentDeleteTool } from "./incident-delete";
|
|
4
|
+
|
|
5
|
+
const principal: AuthUser = {
|
|
6
|
+
type: "user",
|
|
7
|
+
id: "u1",
|
|
8
|
+
accessRules: ["incident.incident.manage"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const incident = {
|
|
12
|
+
id: "inc1",
|
|
13
|
+
title: "Checkout is down",
|
|
14
|
+
description: undefined,
|
|
15
|
+
status: "investigating",
|
|
16
|
+
severity: "critical",
|
|
17
|
+
suppressNotifications: false,
|
|
18
|
+
systemIds: ["sys1"],
|
|
19
|
+
createdAt: new Date(),
|
|
20
|
+
updatedAt: new Date(),
|
|
21
|
+
updates: [],
|
|
22
|
+
links: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function fakeRpcClient({
|
|
26
|
+
getIncident,
|
|
27
|
+
deleteIncident,
|
|
28
|
+
}: {
|
|
29
|
+
getIncident: ReturnType<typeof mock>;
|
|
30
|
+
deleteIncident: ReturnType<typeof mock>;
|
|
31
|
+
}): RpcClient {
|
|
32
|
+
return {
|
|
33
|
+
forPlugin: () => ({ getIncident, deleteIncident }),
|
|
34
|
+
} as unknown as RpcClient;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("incident.delete tool", () => {
|
|
38
|
+
test("declares destructive effect + the manage rule", () => {
|
|
39
|
+
const tool = createIncidentDeleteTool();
|
|
40
|
+
expect(tool.name).toBe("incident.delete");
|
|
41
|
+
expect(tool.effect).toBe("destructive");
|
|
42
|
+
expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
|
|
43
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("dryRun resolves the target and NEVER deletes", async () => {
|
|
47
|
+
const getIncident = mock(() => Promise.resolve(incident));
|
|
48
|
+
const deleteIncident = mock(() => Promise.resolve({ success: true }));
|
|
49
|
+
const rpcClient = fakeRpcClient({ getIncident, deleteIncident });
|
|
50
|
+
const tool = createIncidentDeleteTool();
|
|
51
|
+
const preview = await tool.dryRun!({
|
|
52
|
+
input: { id: "inc1" },
|
|
53
|
+
principal,
|
|
54
|
+
rpcClient,
|
|
55
|
+
});
|
|
56
|
+
expect(deleteIncident).not.toHaveBeenCalled();
|
|
57
|
+
expect(preview.summary).toContain("Checkout is down");
|
|
58
|
+
expect(preview.summary).toContain("permanent");
|
|
59
|
+
expect(preview.payload).toEqual({ id: "inc1" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("dryRun throws a clear error when the id is unknown", async () => {
|
|
63
|
+
const rpcClient = fakeRpcClient({
|
|
64
|
+
getIncident: mock(() => Promise.resolve(null)),
|
|
65
|
+
deleteIncident: mock(),
|
|
66
|
+
});
|
|
67
|
+
const tool = createIncidentDeleteTool();
|
|
68
|
+
await expect(
|
|
69
|
+
tool.dryRun!({ input: { id: "nope" }, principal, rpcClient }),
|
|
70
|
+
).rejects.toThrow(/No incident found/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("execute (apply) deletes via deleteIncident with {id}", async () => {
|
|
74
|
+
const deleteIncident = mock(() => Promise.resolve({ success: true }));
|
|
75
|
+
const rpcClient = fakeRpcClient({
|
|
76
|
+
getIncident: mock(() => Promise.resolve(incident)),
|
|
77
|
+
deleteIncident,
|
|
78
|
+
});
|
|
79
|
+
const tool = createIncidentDeleteTool();
|
|
80
|
+
const result = await tool.execute({
|
|
81
|
+
input: { id: "inc1" },
|
|
82
|
+
principal,
|
|
83
|
+
rpcClient,
|
|
84
|
+
});
|
|
85
|
+
expect(deleteIncident).toHaveBeenCalledWith({ id: "inc1" });
|
|
86
|
+
expect(result).toEqual({ id: "inc1", deleted: true });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
} from "@checkstack/incident-common";
|
|
9
|
+
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
10
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
11
|
+
|
|
12
|
+
/** Input for `incident.delete`: the incident id to remove. */
|
|
13
|
+
export const IncidentDeleteInputSchema = z.object({
|
|
14
|
+
id: z.string(),
|
|
15
|
+
});
|
|
16
|
+
export type IncidentDeleteInput = z.infer<typeof IncidentDeleteInputSchema>;
|
|
17
|
+
|
|
18
|
+
/** Output returned once a human applies the deletion. */
|
|
19
|
+
export interface IncidentDeleteApplyResult {
|
|
20
|
+
id: string;
|
|
21
|
+
deleted: true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `incident.delete` - permanently delete an incident by id.
|
|
26
|
+
*
|
|
27
|
+
* `effect: "destructive"` - deletion is irreversible, so it ALWAYS routes
|
|
28
|
+
* through the propose/apply confirm card in BOTH permission modes (it can never
|
|
29
|
+
* auto-apply). `dryRun` resolves the target so the confirm card names exactly
|
|
30
|
+
* what will be removed and rejects an unknown id; `execute` (reached only via
|
|
31
|
+
* `apply`) performs the delete. The underlying RPC uses the USER-SCOPED client
|
|
32
|
+
* passed at call time, so handler-side authorization is enforced exactly as a
|
|
33
|
+
* direct UI/RPC call.
|
|
34
|
+
*/
|
|
35
|
+
export function createIncidentDeleteTool(): RegisteredAiTool<
|
|
36
|
+
IncidentDeleteInput,
|
|
37
|
+
IncidentDeleteApplyResult
|
|
38
|
+
> {
|
|
39
|
+
const dryRun = async ({
|
|
40
|
+
input,
|
|
41
|
+
rpcClient,
|
|
42
|
+
}: {
|
|
43
|
+
input: IncidentDeleteInput;
|
|
44
|
+
principal: AuthUser;
|
|
45
|
+
rpcClient: RpcClient;
|
|
46
|
+
}): Promise<AiProposalPreview<IncidentDeleteInput>> => {
|
|
47
|
+
const incidentClient = rpcClient.forPlugin(IncidentApi);
|
|
48
|
+
const incident = await incidentClient.getIncident({ id: input.id });
|
|
49
|
+
if (!incident) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`No incident found with id "${input.id}". List incidents first to get a valid id.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
summary: `Delete incident "${incident.title}" (severity ${incident.severity}). This is permanent and also removes its updates, links, and system associations.`,
|
|
56
|
+
payload: { id: input.id },
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
name: "incident.delete",
|
|
62
|
+
description:
|
|
63
|
+
"Delete an incident by id. DESTRUCTIVE and irreversible - it also removes the incident's updates, links, and system associations. Never deletes directly; a person must approve the confirmation. Find the id with the incident read tools first.",
|
|
64
|
+
effect: "destructive",
|
|
65
|
+
input: IncidentDeleteInputSchema,
|
|
66
|
+
requiredAccessRules: [
|
|
67
|
+
qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
|
|
68
|
+
],
|
|
69
|
+
dryRun,
|
|
70
|
+
async execute({ input, rpcClient }) {
|
|
71
|
+
const incidentClient = rpcClient.forPlugin(IncidentApi);
|
|
72
|
+
await incidentClient.deleteIncident({ id: input.id });
|
|
73
|
+
return { id: input.id, deleted: true };
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildProjectedTool,
|
|
4
|
+
deferredProjectionExecute,
|
|
5
|
+
} from "@checkstack/ai-backend";
|
|
6
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
7
|
+
import {
|
|
8
|
+
incidentContract,
|
|
9
|
+
incidentAccess,
|
|
10
|
+
pluginMetadata,
|
|
11
|
+
} from "@checkstack/incident-common";
|
|
12
|
+
|
|
13
|
+
// Build the projected tool with the SAME inputs the plugin exposes via
|
|
14
|
+
// aiToolProjectionExtensionPoint in `index.ts`, and assert the resulting tool
|
|
15
|
+
// carries the source procedure's contract access rules - NOT the chat
|
|
16
|
+
// transport's `ai.chat.read` gate.
|
|
17
|
+
describe("incident.get projection", () => {
|
|
18
|
+
const tool = buildProjectedTool({
|
|
19
|
+
procedure: incidentContract.getIncident,
|
|
20
|
+
sourcePluginMetadata: pluginMetadata,
|
|
21
|
+
procedureKey: "getIncident",
|
|
22
|
+
name: "incident.get",
|
|
23
|
+
description:
|
|
24
|
+
"Get one incident with its full timeline (updates) and links. Read-only.",
|
|
25
|
+
effect: "read",
|
|
26
|
+
execute: deferredProjectionExecute,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("uses the overridden tool name", () => {
|
|
30
|
+
expect(tool.name).toBe("incident.get");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("is classified as a read-only effect", () => {
|
|
34
|
+
expect(tool.effect).toBe("read");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("inherits the source procedure's read rule, not the chat gate", () => {
|
|
38
|
+
expect(tool.requiredAccessRules).toEqual([
|
|
39
|
+
qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.read),
|
|
40
|
+
]);
|
|
41
|
+
expect(tool.requiredAccessRules).not.toEqual(["ai.chat.read"]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import { createIncidentRemoveLinkTool } from "./incident-remove-link";
|
|
4
|
+
|
|
5
|
+
const principal: AuthUser = {
|
|
6
|
+
type: "user",
|
|
7
|
+
id: "u1",
|
|
8
|
+
accessRules: ["incident.incident.manage"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function fakeRpcClient({
|
|
12
|
+
removeLink,
|
|
13
|
+
}: {
|
|
14
|
+
removeLink: ReturnType<typeof mock>;
|
|
15
|
+
}): RpcClient {
|
|
16
|
+
return {
|
|
17
|
+
forPlugin: () => ({ removeLink }),
|
|
18
|
+
} as unknown as RpcClient;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("incident.removeLink tool", () => {
|
|
22
|
+
test("declares destructive effect + the manage rule", () => {
|
|
23
|
+
const tool = createIncidentRemoveLinkTool();
|
|
24
|
+
expect(tool.name).toBe("incident.removeLink");
|
|
25
|
+
expect(tool.effect).toBe("destructive");
|
|
26
|
+
expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
|
|
27
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("dryRun returns a payload and NEVER removes the link", async () => {
|
|
31
|
+
const removeLink = mock(() => Promise.resolve({ success: true }));
|
|
32
|
+
const rpcClient = fakeRpcClient({ removeLink });
|
|
33
|
+
const tool = createIncidentRemoveLinkTool();
|
|
34
|
+
const preview = await tool.dryRun!({
|
|
35
|
+
input: { id: "lnk1" },
|
|
36
|
+
principal,
|
|
37
|
+
rpcClient,
|
|
38
|
+
});
|
|
39
|
+
expect(removeLink).not.toHaveBeenCalled();
|
|
40
|
+
expect(preview.summary).toContain("permanent");
|
|
41
|
+
expect(preview.payload).toEqual({ id: "lnk1" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("execute (apply) removes via removeLink with {id}", async () => {
|
|
45
|
+
const removeLink = mock(() => Promise.resolve({ success: true }));
|
|
46
|
+
const rpcClient = fakeRpcClient({ removeLink });
|
|
47
|
+
const tool = createIncidentRemoveLinkTool();
|
|
48
|
+
const result = await tool.execute({
|
|
49
|
+
input: { id: "lnk1" },
|
|
50
|
+
principal,
|
|
51
|
+
rpcClient,
|
|
52
|
+
});
|
|
53
|
+
expect(removeLink).toHaveBeenCalledWith({ id: "lnk1" });
|
|
54
|
+
expect(result).toEqual({ id: "lnk1", removed: true });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -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
|
+
} from "@checkstack/incident-common";
|
|
9
|
+
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
10
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
11
|
+
|
|
12
|
+
/** Input for `incident.removeLink`: the link id to remove. */
|
|
13
|
+
export const IncidentRemoveLinkInputSchema = z.object({
|
|
14
|
+
id: z.string(),
|
|
15
|
+
});
|
|
16
|
+
export type IncidentRemoveLinkInput = z.infer<
|
|
17
|
+
typeof IncidentRemoveLinkInputSchema
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
/** Output returned once a human applies the link removal. */
|
|
21
|
+
export interface IncidentRemoveLinkApplyResult {
|
|
22
|
+
id: string;
|
|
23
|
+
removed: true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `incident.removeLink` - detach a hotlink from an incident by link id.
|
|
28
|
+
*
|
|
29
|
+
* `effect: "destructive"` - removing a link is irreversible, so it ALWAYS routes
|
|
30
|
+
* through the propose/apply confirm card in BOTH permission modes (it can never
|
|
31
|
+
* auto-apply). `dryRun` returns the captured payload for human review WITHOUT
|
|
32
|
+
* mutating; `execute` (reached only via `apply`) removes the link. The
|
|
33
|
+
* underlying RPC uses the USER-SCOPED client passed at call time, so
|
|
34
|
+
* handler-side authorization is enforced exactly as a direct UI/RPC call.
|
|
35
|
+
*/
|
|
36
|
+
export function createIncidentRemoveLinkTool(): RegisteredAiTool<
|
|
37
|
+
IncidentRemoveLinkInput,
|
|
38
|
+
IncidentRemoveLinkApplyResult
|
|
39
|
+
> {
|
|
40
|
+
const dryRun = async ({
|
|
41
|
+
input,
|
|
42
|
+
}: {
|
|
43
|
+
input: IncidentRemoveLinkInput;
|
|
44
|
+
principal: AuthUser;
|
|
45
|
+
rpcClient: RpcClient;
|
|
46
|
+
}): Promise<AiProposalPreview<IncidentRemoveLinkInput>> => {
|
|
47
|
+
return {
|
|
48
|
+
summary: `Remove link ${input.id} from its incident. This is permanent.`,
|
|
49
|
+
payload: { id: input.id },
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
name: "incident.removeLink",
|
|
55
|
+
description:
|
|
56
|
+
"Remove a hotlink from an incident by link id. DESTRUCTIVE and irreversible. Never removes directly; a person must approve the confirmation. Find the link id by reading the incident's details first.",
|
|
57
|
+
effect: "destructive",
|
|
58
|
+
input: IncidentRemoveLinkInputSchema,
|
|
59
|
+
requiredAccessRules: [
|
|
60
|
+
qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
|
|
61
|
+
],
|
|
62
|
+
dryRun,
|
|
63
|
+
async execute({ input, rpcClient }) {
|
|
64
|
+
const incidentClient = rpcClient.forPlugin(IncidentApi);
|
|
65
|
+
await incidentClient.removeLink({ id: input.id });
|
|
66
|
+
return { id: input.id, removed: true };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import type { IncidentWithSystems } from "@checkstack/incident-common";
|
|
4
|
+
import { createIncidentResolveTool } from "./incident-resolve";
|
|
5
|
+
|
|
6
|
+
const principal: AuthUser = {
|
|
7
|
+
type: "user",
|
|
8
|
+
id: "u1",
|
|
9
|
+
accessRules: ["incident.incident.manage"],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const input = { id: "inc1", message: "All clear" };
|
|
13
|
+
|
|
14
|
+
function fakeRpcClient({
|
|
15
|
+
resolveIncident,
|
|
16
|
+
}: {
|
|
17
|
+
resolveIncident: ReturnType<typeof mock>;
|
|
18
|
+
}): RpcClient {
|
|
19
|
+
return {
|
|
20
|
+
forPlugin: () => ({ resolveIncident }),
|
|
21
|
+
} as unknown as RpcClient;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("incident.resolve tool", () => {
|
|
25
|
+
test("declares mutate effect + the manage rule", () => {
|
|
26
|
+
const tool = createIncidentResolveTool();
|
|
27
|
+
expect(tool.name).toBe("incident.resolve");
|
|
28
|
+
expect(tool.effect).toBe("mutate");
|
|
29
|
+
expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
|
|
30
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("dryRun returns a payload and NEVER resolves", async () => {
|
|
34
|
+
const resolveIncident = mock(() => Promise.resolve({}));
|
|
35
|
+
const rpcClient = fakeRpcClient({ resolveIncident });
|
|
36
|
+
const tool = createIncidentResolveTool();
|
|
37
|
+
const preview = await tool.dryRun!({ input, principal, rpcClient });
|
|
38
|
+
expect(resolveIncident).not.toHaveBeenCalled();
|
|
39
|
+
expect(preview.summary).toContain("inc1");
|
|
40
|
+
expect(preview.payload).toEqual(input);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("execute (apply) resolves via resolveIncident", async () => {
|
|
44
|
+
const resolved: IncidentWithSystems = {
|
|
45
|
+
id: "inc1",
|
|
46
|
+
title: "Checkout is down",
|
|
47
|
+
status: "resolved",
|
|
48
|
+
severity: "critical",
|
|
49
|
+
suppressNotifications: false,
|
|
50
|
+
systemIds: ["sys1"],
|
|
51
|
+
createdAt: new Date(),
|
|
52
|
+
updatedAt: new Date(),
|
|
53
|
+
};
|
|
54
|
+
const resolveIncident = mock(() => Promise.resolve(resolved));
|
|
55
|
+
const rpcClient = fakeRpcClient({ resolveIncident });
|
|
56
|
+
const tool = createIncidentResolveTool();
|
|
57
|
+
const result = await tool.execute({ input, principal, rpcClient });
|
|
58
|
+
expect(resolveIncident).toHaveBeenCalledWith(input);
|
|
59
|
+
expect(result.incident).toEqual(resolved);
|
|
60
|
+
});
|
|
61
|
+
});
|