@checkstack/maintenance-backend 1.3.0 → 1.4.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,67 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createMaintenanceAddUpdateTool } from "./maintenance-add-update";
4
+
5
+ const principal: AuthUser = {
6
+ type: "user",
7
+ id: "u1",
8
+ accessRules: ["maintenance.maintenance.manage"],
9
+ };
10
+
11
+ function fakeRpcClient({
12
+ addUpdate,
13
+ }: {
14
+ addUpdate: ReturnType<typeof mock>;
15
+ }): RpcClient {
16
+ return {
17
+ forPlugin: () => ({ addUpdate }),
18
+ } as unknown as RpcClient;
19
+ }
20
+
21
+ describe("maintenance.addUpdate tool", () => {
22
+ test("declares mutate effect + the manage rule", () => {
23
+ const tool = createMaintenanceAddUpdateTool();
24
+ expect(tool.name).toBe("maintenance.addUpdate");
25
+ expect(tool.effect).toBe("mutate");
26
+ expect(tool.requiredAccessRules).toEqual([
27
+ "maintenance.maintenance.manage",
28
+ ]);
29
+ expect(typeof tool.dryRun).toBe("function");
30
+ });
31
+
32
+ test("dryRun summarizes without posting", async () => {
33
+ const addUpdate = mock(() => Promise.resolve());
34
+ const rpcClient = fakeRpcClient({ addUpdate });
35
+ const tool = createMaintenanceAddUpdateTool();
36
+ const preview = await tool.dryRun!({
37
+ input: { maintenanceId: "m1", message: "Patching now" },
38
+ principal,
39
+ rpcClient,
40
+ });
41
+ expect(addUpdate).not.toHaveBeenCalled();
42
+ expect(preview.summary).toContain("Patching now");
43
+ });
44
+
45
+ test("execute posts via addUpdate", async () => {
46
+ const addUpdate = mock(() =>
47
+ Promise.resolve({ id: "u1", maintenanceId: "m1", message: "Patching now" }),
48
+ );
49
+ const rpcClient = fakeRpcClient({ addUpdate });
50
+ const tool = createMaintenanceAddUpdateTool();
51
+ const result = await tool.execute({
52
+ input: {
53
+ maintenanceId: "m1",
54
+ message: "Patching now",
55
+ statusChange: "in_progress",
56
+ },
57
+ principal,
58
+ rpcClient,
59
+ });
60
+ expect(addUpdate).toHaveBeenCalledWith({
61
+ maintenanceId: "m1",
62
+ message: "Patching now",
63
+ statusChange: "in_progress",
64
+ });
65
+ expect(result.update.id).toBe("u1");
66
+ });
67
+ });
@@ -0,0 +1,61 @@
1
+ import { qualifyAccessRuleId } from "@checkstack/common";
2
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
3
+ import {
4
+ MaintenanceApi,
5
+ AddMaintenanceUpdateInputSchema,
6
+ maintenanceAccess,
7
+ pluginMetadata,
8
+ type MaintenanceUpdate,
9
+ type AddMaintenanceUpdateInput,
10
+ } from "@checkstack/maintenance-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 (the posted update). */
15
+ export interface MaintenanceAddUpdateApplyResult {
16
+ update: MaintenanceUpdate;
17
+ }
18
+
19
+ /**
20
+ * `maintenance.addUpdate` - post a status update (message + optional status
21
+ * change) to an existing maintenance window.
22
+ *
23
+ * `effect: "mutate"` - a non-destructive append, so it auto-applies in AUTO mode
24
+ * and is confirm-gated in APPROVE mode. The underlying RPC runs through the
25
+ * USER-SCOPED client, so handler-side authorization is enforced exactly as a
26
+ * direct UI/RPC call.
27
+ */
28
+ export function createMaintenanceAddUpdateTool(): RegisteredAiTool<
29
+ AddMaintenanceUpdateInput,
30
+ MaintenanceAddUpdateApplyResult
31
+ > {
32
+ const dryRun = async ({
33
+ input,
34
+ }: {
35
+ input: AddMaintenanceUpdateInput;
36
+ principal: AuthUser;
37
+ rpcClient: RpcClient;
38
+ }): Promise<AiProposalPreview<AddMaintenanceUpdateInput>> => {
39
+ return {
40
+ summary: `Post an update to maintenance "${input.maintenanceId}"${input.statusChange ? ` (status -> ${input.statusChange})` : ""}: "${input.message}".`,
41
+ payload: input,
42
+ };
43
+ };
44
+
45
+ return {
46
+ name: "maintenance.addUpdate",
47
+ description:
48
+ "Post a status update (a message, optionally changing the maintenance status) to an existing maintenance window. Never posts directly; a person must approve unless the conversation is in auto mode. Find the maintenance id with the maintenance read tools first.",
49
+ effect: "mutate",
50
+ input: AddMaintenanceUpdateInputSchema,
51
+ requiredAccessRules: [
52
+ qualifyAccessRuleId(pluginMetadata, maintenanceAccess.maintenance.manage),
53
+ ],
54
+ dryRun,
55
+ async execute({ input, rpcClient }) {
56
+ const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
57
+ const update = await maintenanceClient.addUpdate(input);
58
+ return { update };
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createMaintenanceCloseTool } from "./maintenance-close";
4
+
5
+ const principal: AuthUser = {
6
+ type: "user",
7
+ id: "u1",
8
+ accessRules: ["maintenance.maintenance.manage"],
9
+ };
10
+
11
+ function fakeRpcClient({
12
+ closeMaintenance,
13
+ }: {
14
+ closeMaintenance: ReturnType<typeof mock>;
15
+ }): RpcClient {
16
+ return {
17
+ forPlugin: () => ({ closeMaintenance }),
18
+ } as unknown as RpcClient;
19
+ }
20
+
21
+ describe("maintenance.close tool", () => {
22
+ test("declares mutate effect + the manage rule", () => {
23
+ const tool = createMaintenanceCloseTool();
24
+ expect(tool.name).toBe("maintenance.close");
25
+ expect(tool.effect).toBe("mutate");
26
+ expect(tool.requiredAccessRules).toEqual([
27
+ "maintenance.maintenance.manage",
28
+ ]);
29
+ expect(typeof tool.dryRun).toBe("function");
30
+ });
31
+
32
+ test("dryRun summarizes without closing", async () => {
33
+ const closeMaintenance = mock(() => Promise.resolve());
34
+ const rpcClient = fakeRpcClient({ closeMaintenance });
35
+ const tool = createMaintenanceCloseTool();
36
+ const preview = await tool.dryRun!({
37
+ input: { id: "m1", message: "Done early" },
38
+ principal,
39
+ rpcClient,
40
+ });
41
+ expect(closeMaintenance).not.toHaveBeenCalled();
42
+ expect(preview.summary).toContain("Done early");
43
+ });
44
+
45
+ test("execute closes via closeMaintenance", async () => {
46
+ const closeMaintenance = mock(() =>
47
+ Promise.resolve({ id: "m1", systemIds: ["s1"] }),
48
+ );
49
+ const rpcClient = fakeRpcClient({ closeMaintenance });
50
+ const tool = createMaintenanceCloseTool();
51
+ const result = await tool.execute({
52
+ input: { id: "m1", message: "Done early" },
53
+ principal,
54
+ rpcClient,
55
+ });
56
+ expect(closeMaintenance).toHaveBeenCalledWith({
57
+ id: "m1",
58
+ message: "Done early",
59
+ });
60
+ expect(result.maintenance.id).toBe("m1");
61
+ });
62
+ });
@@ -0,0 +1,70 @@
1
+ import { z } from "zod";
2
+ import { qualifyAccessRuleId } from "@checkstack/common";
3
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
4
+ import {
5
+ MaintenanceApi,
6
+ maintenanceAccess,
7
+ pluginMetadata,
8
+ type MaintenanceWithSystems,
9
+ } from "@checkstack/maintenance-common";
10
+ import type { AiProposalPreview } from "@checkstack/ai-common";
11
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
12
+
13
+ /**
14
+ * Input for `maintenance.close`: the maintenance id plus an optional closing
15
+ * message. Closing sets the maintenance status to completed early.
16
+ */
17
+ export const MaintenanceCloseInputSchema = z.object({
18
+ id: z.string(),
19
+ message: z.string().optional(),
20
+ });
21
+ export type MaintenanceCloseInput = z.infer<typeof MaintenanceCloseInputSchema>;
22
+
23
+ /** Output returned once a human applies the close (the closed maintenance). */
24
+ export interface MaintenanceCloseApplyResult {
25
+ maintenance: MaintenanceWithSystems;
26
+ }
27
+
28
+ /**
29
+ * `maintenance.close` - close a maintenance window early (sets status to
30
+ * completed).
31
+ *
32
+ * `effect: "mutate"` - a non-destructive status change, so it auto-applies in
33
+ * AUTO mode and is confirm-gated in APPROVE mode. The underlying RPC runs through
34
+ * the USER-SCOPED client, so handler-side authorization is enforced exactly as a
35
+ * direct UI/RPC call.
36
+ */
37
+ export function createMaintenanceCloseTool(): RegisteredAiTool<
38
+ MaintenanceCloseInput,
39
+ MaintenanceCloseApplyResult
40
+ > {
41
+ const dryRun = async ({
42
+ input,
43
+ }: {
44
+ input: MaintenanceCloseInput;
45
+ principal: AuthUser;
46
+ rpcClient: RpcClient;
47
+ }): Promise<AiProposalPreview<MaintenanceCloseInput>> => {
48
+ return {
49
+ summary: `Close maintenance "${input.id}" early (status -> completed)${input.message ? `: "${input.message}"` : ""}.`,
50
+ payload: input,
51
+ };
52
+ };
53
+
54
+ return {
55
+ name: "maintenance.close",
56
+ description:
57
+ "Close a maintenance window early by id, setting its status to completed, with an optional closing message. Never closes directly; a person must approve unless the conversation is in auto mode. Find the id with the maintenance read tools first.",
58
+ effect: "mutate",
59
+ input: MaintenanceCloseInputSchema,
60
+ requiredAccessRules: [
61
+ qualifyAccessRuleId(pluginMetadata, maintenanceAccess.maintenance.manage),
62
+ ],
63
+ dryRun,
64
+ async execute({ input, rpcClient }) {
65
+ const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
66
+ const maintenance = await maintenanceClient.closeMaintenance(input);
67
+ return { maintenance };
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createMaintenanceCreateTool } from "./maintenance-create";
4
+
5
+ const principal: AuthUser = {
6
+ type: "user",
7
+ id: "u1",
8
+ accessRules: ["maintenance.maintenance.manage"],
9
+ };
10
+
11
+ function fakeRpcClient({
12
+ createMaintenance,
13
+ }: {
14
+ createMaintenance: ReturnType<typeof mock>;
15
+ }): RpcClient {
16
+ return {
17
+ forPlugin: () => ({ createMaintenance }),
18
+ } as unknown as RpcClient;
19
+ }
20
+
21
+ const isoStart = "2026-07-01T10:00:00.000Z";
22
+ const isoEnd = "2026-07-01T12:00:00.000Z";
23
+
24
+ describe("maintenance.create tool", () => {
25
+ test("declares mutate effect + the manage rule", () => {
26
+ const tool = createMaintenanceCreateTool();
27
+ expect(tool.name).toBe("maintenance.create");
28
+ expect(tool.effect).toBe("mutate");
29
+ expect(tool.requiredAccessRules).toEqual([
30
+ "maintenance.maintenance.manage",
31
+ ]);
32
+ expect(typeof tool.dryRun).toBe("function");
33
+ });
34
+
35
+ test("dryRun summarizes the window without creating it", async () => {
36
+ const createMaintenance = mock(() => Promise.resolve());
37
+ const rpcClient = fakeRpcClient({ createMaintenance });
38
+ const tool = createMaintenanceCreateTool();
39
+ const preview = await tool.dryRun!({
40
+ input: {
41
+ title: "DB upgrade",
42
+ startAt: new Date(isoStart),
43
+ endAt: new Date(isoEnd),
44
+ systemIds: ["s1"],
45
+ },
46
+ principal,
47
+ rpcClient,
48
+ });
49
+ expect(createMaintenance).not.toHaveBeenCalled();
50
+ expect(preview.summary).toContain("DB upgrade");
51
+ });
52
+
53
+ test("coerces ISO string dates to Date instances before the RPC", async () => {
54
+ let received: { startAt: unknown; endAt: unknown } | undefined;
55
+ const createMaintenance = mock(
56
+ (arg: { startAt: unknown; endAt: unknown }) => {
57
+ received = arg;
58
+ return Promise.resolve({ id: "m1", systemIds: ["s1"] });
59
+ },
60
+ );
61
+ const rpcClient = fakeRpcClient({ createMaintenance });
62
+ const tool = createMaintenanceCreateTool();
63
+ // The model sends ISO strings; the tool-local schema coerces them.
64
+ const parsed = tool.input.parse({
65
+ title: "DB upgrade",
66
+ startAt: isoStart,
67
+ endAt: isoEnd,
68
+ systemIds: ["s1"],
69
+ });
70
+ await tool.execute({ input: parsed, principal, rpcClient });
71
+ expect(received?.startAt instanceof Date).toBe(true);
72
+ expect(received?.endAt instanceof Date).toBe(true);
73
+ });
74
+
75
+ test("rejects when endAt is not after startAt", () => {
76
+ const tool = createMaintenanceCreateTool();
77
+ const result = tool.input.safeParse({
78
+ title: "Bad window",
79
+ startAt: isoEnd,
80
+ endAt: isoStart,
81
+ systemIds: ["s1"],
82
+ });
83
+ expect(result.success).toBe(false);
84
+ });
85
+ });
@@ -0,0 +1,84 @@
1
+ import { z } from "zod";
2
+ import { qualifyAccessRuleId } from "@checkstack/common";
3
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
4
+ import {
5
+ MaintenanceApi,
6
+ maintenanceAccess,
7
+ pluginMetadata,
8
+ type MaintenanceWithSystems,
9
+ } from "@checkstack/maintenance-common";
10
+ import type { AiProposalPreview } from "@checkstack/ai-common";
11
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
12
+
13
+ /**
14
+ * Tool-local create schema. The exported `CreateMaintenanceInputSchema` is a
15
+ * `ZodEffects` (it carries a `.refine`) whose `startAt` / `endAt` are `z.date()`;
16
+ * the model only ever sends ISO strings, so this mirror coerces those two fields
17
+ * to `Date` (via `z.coerce.date()`) while reproducing the same field set and the
18
+ * same `endAt > startAt` invariant. The exported schema is NOT mutated. The
19
+ * coerced values are real `Date` instances, exactly what the RPC client expects.
20
+ */
21
+ export const MaintenanceCreateInputSchema = z
22
+ .object({
23
+ title: z.string().min(1),
24
+ description: z.string().optional(),
25
+ suppressNotifications: z.boolean().optional(),
26
+ startAt: z.coerce.date(),
27
+ endAt: z.coerce.date(),
28
+ systemIds: z.array(z.string()).min(1),
29
+ })
30
+ .refine((v) => v.endAt > v.startAt, {
31
+ message: "endAt must be after startAt",
32
+ path: ["endAt"],
33
+ });
34
+ export type MaintenanceCreateInput = z.infer<typeof MaintenanceCreateInputSchema>;
35
+
36
+ /** Output returned once a human applies the proposal (the created maintenance). */
37
+ export interface MaintenanceCreateApplyResult {
38
+ maintenance: MaintenanceWithSystems;
39
+ }
40
+
41
+ /**
42
+ * `maintenance.create` - schedule a new maintenance window.
43
+ *
44
+ * `effect: "mutate"` - creating a maintenance is a non-destructive create, so it
45
+ * auto-applies in AUTO mode and is confirm-gated in APPROVE mode via the
46
+ * propose/apply machinery. `dryRun` summarizes the window without creating it;
47
+ * `execute` (reached only via `apply`) performs the create through the
48
+ * USER-SCOPED client, so handler-side authorization (access rules AND
49
+ * per-resource/team scoping) is enforced exactly as a direct UI/RPC call.
50
+ */
51
+ export function createMaintenanceCreateTool(): RegisteredAiTool<
52
+ MaintenanceCreateInput,
53
+ MaintenanceCreateApplyResult
54
+ > {
55
+ const dryRun = async ({
56
+ input,
57
+ }: {
58
+ input: MaintenanceCreateInput;
59
+ principal: AuthUser;
60
+ rpcClient: RpcClient;
61
+ }): Promise<AiProposalPreview<MaintenanceCreateInput>> => {
62
+ return {
63
+ summary: `Create maintenance "${input.title}" from ${input.startAt.toISOString()} to ${input.endAt.toISOString()} affecting ${input.systemIds.length} system(s)${input.suppressNotifications ? " (notifications suppressed)" : ""}.`,
64
+ payload: input,
65
+ };
66
+ };
67
+
68
+ return {
69
+ name: "maintenance.create",
70
+ description:
71
+ "Schedule a new maintenance window over one or more systems with a start and end time. Provide startAt/endAt as ISO 8601 timestamps; endAt must be after startAt. Never creates directly; a person must approve unless the conversation is in auto mode. Find system ids with the catalog read tools first.",
72
+ effect: "mutate",
73
+ input: MaintenanceCreateInputSchema,
74
+ requiredAccessRules: [
75
+ qualifyAccessRuleId(pluginMetadata, maintenanceAccess.maintenance.manage),
76
+ ],
77
+ dryRun,
78
+ async execute({ input, rpcClient }) {
79
+ const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
80
+ const maintenance = await maintenanceClient.createMaintenance(input);
81
+ return { maintenance };
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createMaintenanceDeleteTool } from "./maintenance-delete";
4
+
5
+ const principal: AuthUser = {
6
+ type: "user",
7
+ id: "u1",
8
+ accessRules: ["maintenance.maintenance.manage"],
9
+ };
10
+
11
+ const maintenance = {
12
+ id: "m1",
13
+ title: "DB upgrade",
14
+ description: "Planned upgrade",
15
+ suppressNotifications: false,
16
+ status: "scheduled" as const,
17
+ startAt: new Date(),
18
+ endAt: new Date(),
19
+ createdAt: new Date(),
20
+ updatedAt: new Date(),
21
+ systemIds: ["s1"],
22
+ updates: [],
23
+ links: [],
24
+ };
25
+
26
+ function fakeRpcClient({
27
+ getMaintenance,
28
+ deleteMaintenance,
29
+ }: {
30
+ getMaintenance: ReturnType<typeof mock>;
31
+ deleteMaintenance: ReturnType<typeof mock>;
32
+ }): RpcClient {
33
+ return {
34
+ forPlugin: () => ({ getMaintenance, deleteMaintenance }),
35
+ } as unknown as RpcClient;
36
+ }
37
+
38
+ describe("maintenance.delete tool", () => {
39
+ test("declares destructive effect + the manage rule", () => {
40
+ const tool = createMaintenanceDeleteTool();
41
+ expect(tool.name).toBe("maintenance.delete");
42
+ expect(tool.effect).toBe("destructive");
43
+ expect(tool.requiredAccessRules).toEqual([
44
+ "maintenance.maintenance.manage",
45
+ ]);
46
+ expect(typeof tool.dryRun).toBe("function");
47
+ });
48
+
49
+ test("dryRun resolves the target and NEVER deletes", async () => {
50
+ const getMaintenance = mock(() => Promise.resolve(maintenance));
51
+ const deleteMaintenance = mock(() => Promise.resolve());
52
+ const rpcClient = fakeRpcClient({ getMaintenance, deleteMaintenance });
53
+ const tool = createMaintenanceDeleteTool();
54
+ const preview = await tool.dryRun!({
55
+ input: { id: "m1" },
56
+ principal,
57
+ rpcClient,
58
+ });
59
+ expect(deleteMaintenance).not.toHaveBeenCalled();
60
+ expect(preview.summary).toContain("DB upgrade");
61
+ expect(preview.summary).toContain("permanent");
62
+ expect(preview.payload).toEqual({ id: "m1" });
63
+ });
64
+
65
+ test("dryRun throws a clear error when the id is unknown", async () => {
66
+ const rpcClient = fakeRpcClient({
67
+ getMaintenance: mock(() => Promise.resolve(null)),
68
+ deleteMaintenance: mock(),
69
+ });
70
+ const tool = createMaintenanceDeleteTool();
71
+ await expect(
72
+ tool.dryRun!({ input: { id: "nope" }, principal, rpcClient }),
73
+ ).rejects.toThrow(/No maintenance found/);
74
+ });
75
+
76
+ test("execute (apply) deletes via deleteMaintenance", async () => {
77
+ const deleteMaintenance = mock(() => Promise.resolve({ success: true }));
78
+ const rpcClient = fakeRpcClient({
79
+ getMaintenance: mock(() => Promise.resolve(maintenance)),
80
+ deleteMaintenance,
81
+ });
82
+ const tool = createMaintenanceDeleteTool();
83
+ const result = await tool.execute({
84
+ input: { id: "m1" },
85
+ principal,
86
+ rpcClient,
87
+ });
88
+ expect(deleteMaintenance).toHaveBeenCalledWith({ id: "m1" });
89
+ expect(result).toEqual({ id: "m1", deleted: true });
90
+ });
91
+ });
@@ -0,0 +1,75 @@
1
+ import { z } from "zod";
2
+ import { qualifyAccessRuleId } from "@checkstack/common";
3
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
4
+ import {
5
+ MaintenanceApi,
6
+ maintenanceAccess,
7
+ pluginMetadata,
8
+ } from "@checkstack/maintenance-common";
9
+ import type { AiProposalPreview } from "@checkstack/ai-common";
10
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
11
+
12
+ /** Input for `maintenance.delete`: the maintenance id to remove. */
13
+ export const MaintenanceDeleteInputSchema = z.object({
14
+ id: z.string(),
15
+ });
16
+ export type MaintenanceDeleteInput = z.infer<typeof MaintenanceDeleteInputSchema>;
17
+
18
+ /** Output returned once a human applies the deletion. */
19
+ export interface MaintenanceDeleteApplyResult {
20
+ id: string;
21
+ deleted: true;
22
+ }
23
+
24
+ /**
25
+ * `maintenance.delete` - delete a maintenance window by id.
26
+ *
27
+ * `effect: "destructive"` - deletion is irreversible, so it ALWAYS routes through
28
+ * the propose/apply confirm card in BOTH permission modes (it can never
29
+ * auto-apply). `dryRun` resolves the target maintenance so the confirm card names
30
+ * exactly what will be removed; `execute` (reached only via `apply`) performs the
31
+ * delete through the USER-SCOPED client, so handler-side authorization is
32
+ * enforced exactly as a direct UI/RPC call.
33
+ */
34
+ export function createMaintenanceDeleteTool(): RegisteredAiTool<
35
+ MaintenanceDeleteInput,
36
+ MaintenanceDeleteApplyResult
37
+ > {
38
+ const dryRun = async ({
39
+ input,
40
+ rpcClient,
41
+ }: {
42
+ input: MaintenanceDeleteInput;
43
+ principal: AuthUser;
44
+ rpcClient: RpcClient;
45
+ }): Promise<AiProposalPreview<MaintenanceDeleteInput>> => {
46
+ const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
47
+ const maintenance = await maintenanceClient.getMaintenance({ id: input.id });
48
+ if (!maintenance) {
49
+ throw new Error(
50
+ `No maintenance found with id "${input.id}". List maintenances first to get a valid id.`,
51
+ );
52
+ }
53
+ return {
54
+ summary: `Delete maintenance "${maintenance.title}". This is permanent and also removes its updates and links.`,
55
+ payload: { id: input.id },
56
+ };
57
+ };
58
+
59
+ return {
60
+ name: "maintenance.delete",
61
+ description:
62
+ "Delete a maintenance window by id. DESTRUCTIVE and irreversible - it also removes the maintenance's updates and links. Never deletes directly; a person must approve the confirmation. Find the id with the maintenance read tools first.",
63
+ effect: "destructive",
64
+ input: MaintenanceDeleteInputSchema,
65
+ requiredAccessRules: [
66
+ qualifyAccessRuleId(pluginMetadata, maintenanceAccess.maintenance.manage),
67
+ ],
68
+ dryRun,
69
+ async execute({ input, rpcClient }) {
70
+ const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
71
+ await maintenanceClient.deleteMaintenance({ id: input.id });
72
+ return { id: input.id, deleted: true };
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createMaintenanceRemoveLinkTool } from "./maintenance-remove-link";
4
+
5
+ const principal: AuthUser = {
6
+ type: "user",
7
+ id: "u1",
8
+ accessRules: ["maintenance.maintenance.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("maintenance.removeLink tool", () => {
22
+ test("declares destructive effect + the manage rule", () => {
23
+ const tool = createMaintenanceRemoveLinkTool();
24
+ expect(tool.name).toBe("maintenance.removeLink");
25
+ expect(tool.effect).toBe("destructive");
26
+ expect(tool.requiredAccessRules).toEqual([
27
+ "maintenance.maintenance.manage",
28
+ ]);
29
+ expect(typeof tool.dryRun).toBe("function");
30
+ });
31
+
32
+ test("dryRun summarizes and NEVER removes", async () => {
33
+ const removeLink = mock(() => Promise.resolve());
34
+ const rpcClient = fakeRpcClient({ removeLink });
35
+ const tool = createMaintenanceRemoveLinkTool();
36
+ const preview = await tool.dryRun!({
37
+ input: { id: "l1" },
38
+ principal,
39
+ rpcClient,
40
+ });
41
+ expect(removeLink).not.toHaveBeenCalled();
42
+ expect(preview.summary).toContain("permanent");
43
+ expect(preview.payload).toEqual({ id: "l1" });
44
+ });
45
+
46
+ test("execute (apply) removes via removeLink", async () => {
47
+ const removeLink = mock(() => Promise.resolve({ success: true }));
48
+ const rpcClient = fakeRpcClient({ removeLink });
49
+ const tool = createMaintenanceRemoveLinkTool();
50
+ const result = await tool.execute({
51
+ input: { id: "l1" },
52
+ principal,
53
+ rpcClient,
54
+ });
55
+ expect(removeLink).toHaveBeenCalledWith({ id: "l1" });
56
+ expect(result).toEqual({ id: "l1", removed: true });
57
+ });
58
+ });