@checkstack/maintenance-backend 1.3.1 → 1.4.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 +115 -0
- package/drizzle/0003_cold_bastion.sql +10 -0
- package/drizzle/meta/0003_snapshot.json +294 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +20 -17
- package/src/ai/maintenance-add-link.test.ts +77 -0
- package/src/ai/maintenance-add-link.ts +61 -0
- package/src/ai/maintenance-add-update.test.ts +67 -0
- package/src/ai/maintenance-add-update.ts +61 -0
- package/src/ai/maintenance-close.test.ts +62 -0
- package/src/ai/maintenance-close.ts +70 -0
- package/src/ai/maintenance-create.test.ts +85 -0
- package/src/ai/maintenance-create.ts +84 -0
- package/src/ai/maintenance-delete.test.ts +91 -0
- package/src/ai/maintenance-delete.ts +75 -0
- package/src/ai/maintenance-remove-link.test.ts +58 -0
- package/src/ai/maintenance-remove-link.ts +69 -0
- package/src/ai/maintenance-update.test.ts +96 -0
- package/src/ai/maintenance-update.ts +103 -0
- package/src/ai/maintenance.projection.test.ts +56 -0
- package/src/ai/register-ai-tools.ts +33 -0
- package/src/automations.test.ts +2 -1
- package/src/index.ts +44 -0
- package/src/schema.ts +19 -9
- package/src/service.ts +74 -57
- 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
|
+
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.removeLink`: the link id to remove. */
|
|
13
|
+
export const MaintenanceRemoveLinkInputSchema = z.object({
|
|
14
|
+
id: z.string(),
|
|
15
|
+
});
|
|
16
|
+
export type MaintenanceRemoveLinkInput = z.infer<
|
|
17
|
+
typeof MaintenanceRemoveLinkInputSchema
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
/** Output returned once a human applies the removal. */
|
|
21
|
+
export interface MaintenanceRemoveLinkApplyResult {
|
|
22
|
+
id: string;
|
|
23
|
+
removed: true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `maintenance.removeLink` - remove a hotlink from a maintenance window by link
|
|
28
|
+
* id.
|
|
29
|
+
*
|
|
30
|
+
* `effect: "destructive"` - removal is irreversible, so it ALWAYS routes through
|
|
31
|
+
* the propose/apply confirm card in BOTH permission modes (it can never
|
|
32
|
+
* auto-apply). `execute` (reached only via `apply`) performs the removal through
|
|
33
|
+
* the USER-SCOPED client, so handler-side authorization is enforced exactly as a
|
|
34
|
+
* direct UI/RPC call.
|
|
35
|
+
*/
|
|
36
|
+
export function createMaintenanceRemoveLinkTool(): RegisteredAiTool<
|
|
37
|
+
MaintenanceRemoveLinkInput,
|
|
38
|
+
MaintenanceRemoveLinkApplyResult
|
|
39
|
+
> {
|
|
40
|
+
const dryRun = async ({
|
|
41
|
+
input,
|
|
42
|
+
}: {
|
|
43
|
+
input: MaintenanceRemoveLinkInput;
|
|
44
|
+
principal: AuthUser;
|
|
45
|
+
rpcClient: RpcClient;
|
|
46
|
+
}): Promise<AiProposalPreview<MaintenanceRemoveLinkInput>> => {
|
|
47
|
+
return {
|
|
48
|
+
summary: `Remove link "${input.id}" from its maintenance window. This is permanent.`,
|
|
49
|
+
payload: { id: input.id },
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
name: "maintenance.removeLink",
|
|
55
|
+
description:
|
|
56
|
+
"Remove a hotlink from a maintenance window by link id. DESTRUCTIVE and irreversible. Never removes directly; a person must approve the confirmation. Find the link id with the maintenance read tools first.",
|
|
57
|
+
effect: "destructive",
|
|
58
|
+
input: MaintenanceRemoveLinkInputSchema,
|
|
59
|
+
requiredAccessRules: [
|
|
60
|
+
qualifyAccessRuleId(pluginMetadata, maintenanceAccess.maintenance.manage),
|
|
61
|
+
],
|
|
62
|
+
dryRun,
|
|
63
|
+
async execute({ input, rpcClient }) {
|
|
64
|
+
const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
|
|
65
|
+
await maintenanceClient.removeLink({ id: input.id });
|
|
66
|
+
return { id: input.id, removed: true };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import { createMaintenanceUpdateTool } from "./maintenance-update";
|
|
4
|
+
|
|
5
|
+
const principal: AuthUser = {
|
|
6
|
+
type: "user",
|
|
7
|
+
id: "u1",
|
|
8
|
+
accessRules: ["maintenance.maintenance.manage"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const existing = {
|
|
12
|
+
id: "m1",
|
|
13
|
+
title: "DB upgrade",
|
|
14
|
+
description: "Planned upgrade",
|
|
15
|
+
suppressNotifications: false,
|
|
16
|
+
status: "scheduled" as const,
|
|
17
|
+
startAt: new Date("2026-07-01T10:00:00.000Z"),
|
|
18
|
+
endAt: new Date("2026-07-01T12:00:00.000Z"),
|
|
19
|
+
createdAt: new Date(),
|
|
20
|
+
updatedAt: new Date(),
|
|
21
|
+
systemIds: ["s1"],
|
|
22
|
+
updates: [],
|
|
23
|
+
links: [],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function fakeRpcClient({
|
|
27
|
+
getMaintenance,
|
|
28
|
+
updateMaintenance,
|
|
29
|
+
}: {
|
|
30
|
+
getMaintenance: ReturnType<typeof mock>;
|
|
31
|
+
updateMaintenance: ReturnType<typeof mock>;
|
|
32
|
+
}): RpcClient {
|
|
33
|
+
return {
|
|
34
|
+
forPlugin: () => ({ getMaintenance, updateMaintenance }),
|
|
35
|
+
} as unknown as RpcClient;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("maintenance.update tool", () => {
|
|
39
|
+
test("declares mutate effect + the manage rule", () => {
|
|
40
|
+
const tool = createMaintenanceUpdateTool();
|
|
41
|
+
expect(tool.name).toBe("maintenance.update");
|
|
42
|
+
expect(tool.effect).toBe("mutate");
|
|
43
|
+
expect(tool.requiredAccessRules).toEqual([
|
|
44
|
+
"maintenance.maintenance.manage",
|
|
45
|
+
]);
|
|
46
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("dryRun fetches the maintenance and returns a diff", async () => {
|
|
50
|
+
const getMaintenance = mock(() => Promise.resolve(existing));
|
|
51
|
+
const updateMaintenance = mock(() => Promise.resolve());
|
|
52
|
+
const rpcClient = fakeRpcClient({ getMaintenance, updateMaintenance });
|
|
53
|
+
const tool = createMaintenanceUpdateTool();
|
|
54
|
+
const preview = await tool.dryRun!({
|
|
55
|
+
input: { id: "m1", title: "DB upgrade v2" },
|
|
56
|
+
principal,
|
|
57
|
+
rpcClient,
|
|
58
|
+
});
|
|
59
|
+
expect(getMaintenance).toHaveBeenCalledWith({ id: "m1" });
|
|
60
|
+
expect(updateMaintenance).not.toHaveBeenCalled();
|
|
61
|
+
expect(preview.diff).toBeDefined();
|
|
62
|
+
expect(preview.summary).toContain("DB upgrade");
|
|
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
|
+
updateMaintenance: mock(),
|
|
69
|
+
});
|
|
70
|
+
const tool = createMaintenanceUpdateTool();
|
|
71
|
+
await expect(
|
|
72
|
+
tool.dryRun!({ input: { id: "nope" }, principal, rpcClient }),
|
|
73
|
+
).rejects.toThrow(/No maintenance found/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("coerces ISO string dates to Date instances before the RPC", async () => {
|
|
77
|
+
let received: { startAt: unknown; endAt: unknown } | undefined;
|
|
78
|
+
const getMaintenance = mock(() => Promise.resolve(existing));
|
|
79
|
+
const updateMaintenance = mock(
|
|
80
|
+
(arg: { startAt: unknown; endAt: unknown }) => {
|
|
81
|
+
received = arg;
|
|
82
|
+
return Promise.resolve({ ...existing });
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
const rpcClient = fakeRpcClient({ getMaintenance, updateMaintenance });
|
|
86
|
+
const tool = createMaintenanceUpdateTool();
|
|
87
|
+
const parsed = tool.input.parse({
|
|
88
|
+
id: "m1",
|
|
89
|
+
startAt: "2026-07-02T10:00:00.000Z",
|
|
90
|
+
endAt: "2026-07-02T12:00:00.000Z",
|
|
91
|
+
});
|
|
92
|
+
await tool.execute({ input: parsed, principal, rpcClient });
|
|
93
|
+
expect(received?.startAt instanceof Date).toBe(true);
|
|
94
|
+
expect(received?.endAt instanceof Date).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
UpdateMaintenanceInputSchema,
|
|
7
|
+
maintenanceAccess,
|
|
8
|
+
pluginMetadata,
|
|
9
|
+
type MaintenanceWithSystems,
|
|
10
|
+
} from "@checkstack/maintenance-common";
|
|
11
|
+
import { computeFieldDiff, type AiProposalPreview } from "@checkstack/ai-common";
|
|
12
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tool-local update schema. The exported `UpdateMaintenanceInputSchema` types
|
|
16
|
+
* `startAt` / `endAt` as `z.date()`, but the model sends ISO strings, so we
|
|
17
|
+
* `.extend` it to coerce just those two fields to `Date` while leaving every
|
|
18
|
+
* other (already-correct) field as published. The exported schema is NOT mutated.
|
|
19
|
+
*/
|
|
20
|
+
export const MaintenanceUpdateInputSchema = UpdateMaintenanceInputSchema.extend({
|
|
21
|
+
startAt: z.coerce.date().optional(),
|
|
22
|
+
endAt: z.coerce.date().optional(),
|
|
23
|
+
});
|
|
24
|
+
export type MaintenanceUpdateInput = z.infer<typeof MaintenanceUpdateInputSchema>;
|
|
25
|
+
|
|
26
|
+
/** Output returned once a human applies the update (the updated maintenance). */
|
|
27
|
+
export interface MaintenanceUpdateApplyResult {
|
|
28
|
+
maintenance: MaintenanceWithSystems;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Fields a `maintenance.update` can change - used to build the before/after diff. */
|
|
32
|
+
const UPDATABLE_FIELDS = [
|
|
33
|
+
"title",
|
|
34
|
+
"description",
|
|
35
|
+
"suppressNotifications",
|
|
36
|
+
"startAt",
|
|
37
|
+
"endAt",
|
|
38
|
+
"systemIds",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `maintenance.update` - change an existing maintenance window by id with a
|
|
43
|
+
* partial body (only provided fields change).
|
|
44
|
+
*
|
|
45
|
+
* `effect: "mutate"` - a non-destructive change, so it auto-applies in AUTO mode
|
|
46
|
+
* and is confirm-gated in APPROVE mode. `dryRun` fetches the live maintenance,
|
|
47
|
+
* throws a clear error if it does not exist, and renders a before/after field
|
|
48
|
+
* diff over the updatable subset so the confirm card shows exactly what changes.
|
|
49
|
+
*/
|
|
50
|
+
export function createMaintenanceUpdateTool(): RegisteredAiTool<
|
|
51
|
+
MaintenanceUpdateInput,
|
|
52
|
+
MaintenanceUpdateApplyResult
|
|
53
|
+
> {
|
|
54
|
+
const dryRun = async ({
|
|
55
|
+
input,
|
|
56
|
+
rpcClient,
|
|
57
|
+
}: {
|
|
58
|
+
input: MaintenanceUpdateInput;
|
|
59
|
+
principal: AuthUser;
|
|
60
|
+
rpcClient: RpcClient;
|
|
61
|
+
}): Promise<AiProposalPreview<MaintenanceUpdateInput>> => {
|
|
62
|
+
const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
|
|
63
|
+
const existing = await maintenanceClient.getMaintenance({ id: input.id });
|
|
64
|
+
if (!existing) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`No maintenance found with id "${input.id}". List maintenances first to get a valid id.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// before -> after diff over the updatable subset only (the fetched detail
|
|
71
|
+
// also carries id/status/timestamps/updates/links that are not updatable).
|
|
72
|
+
const before: Record<string, unknown> = {};
|
|
73
|
+
const after: Record<string, unknown> = {};
|
|
74
|
+
for (const field of UPDATABLE_FIELDS) {
|
|
75
|
+
before[field] = existing[field];
|
|
76
|
+
after[field] = field in input ? input[field] : existing[field];
|
|
77
|
+
}
|
|
78
|
+
const diff = computeFieldDiff({ before, after });
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
summary: `Update maintenance "${existing.title}".`,
|
|
82
|
+
payload: input,
|
|
83
|
+
diff,
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
name: "maintenance.update",
|
|
89
|
+
description:
|
|
90
|
+
"Update an existing maintenance window by id with a partial body (only provided fields change). Provide startAt/endAt as ISO 8601 timestamps if changing them. Never updates directly; a person must approve unless the conversation is in auto mode. Find the id with the maintenance read tools first.",
|
|
91
|
+
effect: "mutate",
|
|
92
|
+
input: MaintenanceUpdateInputSchema,
|
|
93
|
+
requiredAccessRules: [
|
|
94
|
+
qualifyAccessRuleId(pluginMetadata, maintenanceAccess.maintenance.manage),
|
|
95
|
+
],
|
|
96
|
+
dryRun,
|
|
97
|
+
async execute({ input, rpcClient }) {
|
|
98
|
+
const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
|
|
99
|
+
const maintenance = await maintenanceClient.updateMaintenance(input);
|
|
100
|
+
return { maintenance };
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
maintenanceContract,
|
|
9
|
+
maintenanceAccess,
|
|
10
|
+
pluginMetadata,
|
|
11
|
+
} from "@checkstack/maintenance-common";
|
|
12
|
+
|
|
13
|
+
// Build the projected tools with the SAME inputs the plugin exposes via
|
|
14
|
+
// aiToolProjectionExtensionPoint in `index.ts`, and assert each carries the
|
|
15
|
+
// source procedure's OWN contract access rules - NOT the chat transport's
|
|
16
|
+
// `ai.chat.read` gate.
|
|
17
|
+
describe("maintenance AI projections", () => {
|
|
18
|
+
const expectedRule = qualifyAccessRuleId(
|
|
19
|
+
pluginMetadata,
|
|
20
|
+
maintenanceAccess.maintenance.read,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
test("maintenance.list is a read-only tool with the read rule", () => {
|
|
24
|
+
const tool = buildProjectedTool({
|
|
25
|
+
procedure: maintenanceContract.listMaintenances,
|
|
26
|
+
sourcePluginMetadata: pluginMetadata,
|
|
27
|
+
procedureKey: "listMaintenances",
|
|
28
|
+
name: "maintenance.list",
|
|
29
|
+
description:
|
|
30
|
+
"List planned maintenance windows with optional status/system filters. Read-only.",
|
|
31
|
+
effect: "read",
|
|
32
|
+
execute: deferredProjectionExecute,
|
|
33
|
+
});
|
|
34
|
+
expect(tool.name).toBe("maintenance.list");
|
|
35
|
+
expect(tool.effect).toBe("read");
|
|
36
|
+
expect(tool.requiredAccessRules).toEqual([expectedRule]);
|
|
37
|
+
expect(tool.requiredAccessRules).not.toEqual(["ai.chat.read"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("maintenance.get is a read-only tool with the read rule", () => {
|
|
41
|
+
const tool = buildProjectedTool({
|
|
42
|
+
procedure: maintenanceContract.getMaintenance,
|
|
43
|
+
sourcePluginMetadata: pluginMetadata,
|
|
44
|
+
procedureKey: "getMaintenance",
|
|
45
|
+
name: "maintenance.get",
|
|
46
|
+
description:
|
|
47
|
+
"Get one maintenance window with its updates and links. Read-only.",
|
|
48
|
+
effect: "read",
|
|
49
|
+
execute: deferredProjectionExecute,
|
|
50
|
+
});
|
|
51
|
+
expect(tool.name).toBe("maintenance.get");
|
|
52
|
+
expect(tool.effect).toBe("read");
|
|
53
|
+
expect(tool.requiredAccessRules).toEqual([expectedRule]);
|
|
54
|
+
expect(tool.requiredAccessRules).not.toEqual(["ai.chat.read"]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
2
|
+
import { createMaintenanceCreateTool } from "./maintenance-create";
|
|
3
|
+
import { createMaintenanceUpdateTool } from "./maintenance-update";
|
|
4
|
+
import { createMaintenanceDeleteTool } from "./maintenance-delete";
|
|
5
|
+
import { createMaintenanceAddUpdateTool } from "./maintenance-add-update";
|
|
6
|
+
import { createMaintenanceCloseTool } from "./maintenance-close";
|
|
7
|
+
import { createMaintenanceAddLinkTool } from "./maintenance-add-link";
|
|
8
|
+
import { createMaintenanceRemoveLinkTool } from "./maintenance-remove-link";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The maintenance 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/close/addLink are `mutate` (auto-applies in AUTO mode,
|
|
17
|
+
* confirm-gated in APPROVE mode); delete/removeLink are `destructive` (always
|
|
18
|
+
* confirm-gated). They all go through the USER-SCOPED client passed at call time,
|
|
19
|
+
* so handler-side authorization (access rules AND per-resource/team scoping) is
|
|
20
|
+
* enforced exactly as a direct UI/RPC call; the resolver gate + the
|
|
21
|
+
* propose/apply re-check are the additional authorization authority.
|
|
22
|
+
*/
|
|
23
|
+
export function buildMaintenanceAiTools(): RegisteredAiTool[] {
|
|
24
|
+
return [
|
|
25
|
+
createMaintenanceCreateTool(),
|
|
26
|
+
createMaintenanceUpdateTool(),
|
|
27
|
+
createMaintenanceDeleteTool(),
|
|
28
|
+
createMaintenanceAddUpdateTool(),
|
|
29
|
+
createMaintenanceCloseTool(),
|
|
30
|
+
createMaintenanceAddLinkTool(),
|
|
31
|
+
createMaintenanceRemoveLinkTool(),
|
|
32
|
+
];
|
|
33
|
+
}
|
package/src/automations.test.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* maintenance id and `apply` returns the §10.2 entity shape.
|
|
9
9
|
*/
|
|
10
10
|
import { describe, expect, it, mock } from "bun:test";
|
|
11
|
-
import type { Logger } from "@checkstack/backend-api";
|
|
11
|
+
import type { Logger, RpcClient } from "@checkstack/backend-api";
|
|
12
12
|
import type {
|
|
13
13
|
EntityHandle,
|
|
14
14
|
EntityMutationOpts,
|
|
@@ -32,6 +32,7 @@ const ctxBase = {
|
|
|
32
32
|
getService: async <T,>(): Promise<T> => {
|
|
33
33
|
throw new Error("not used");
|
|
34
34
|
},
|
|
35
|
+
rpcClient: { forPlugin: () => ({}) } as unknown as RpcClient,
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
interface RecordedMutate {
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,12 @@ import {
|
|
|
12
12
|
} from "@checkstack/maintenance-common";
|
|
13
13
|
|
|
14
14
|
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
15
|
+
import {
|
|
16
|
+
aiToolExtensionPoint,
|
|
17
|
+
aiToolProjectionExtensionPoint,
|
|
18
|
+
deferredProjectionExecute,
|
|
19
|
+
} from "@checkstack/ai-backend";
|
|
20
|
+
import { buildMaintenanceAiTools } from "./ai/register-ai-tools";
|
|
15
21
|
import {
|
|
16
22
|
automationActionExtensionPoint,
|
|
17
23
|
automationArtifactTypeExtensionPoint,
|
|
@@ -170,6 +176,44 @@ export default createBackendPlugin({
|
|
|
170
176
|
);
|
|
171
177
|
rpc.registerRouter(router, maintenanceContract);
|
|
172
178
|
|
|
179
|
+
// Register this plugin's AI tools (create/update/delete/addUpdate/
|
|
180
|
+
// close/addLink/removeLink) into the AI registry via the extension
|
|
181
|
+
// point - owned here, not in ai-backend.
|
|
182
|
+
const aiToolExt = env.getExtensionPoint(aiToolExtensionPoint);
|
|
183
|
+
for (const tool of buildMaintenanceAiTools()) {
|
|
184
|
+
aiToolExt.registerTool(tool, pluginMetadata);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Expose this plugin's OWN read-only AI projections of the existing
|
|
188
|
+
// list/get queries via aiToolProjectionExtensionPoint - owned here,
|
|
189
|
+
// not in ai-backend. The projected read tools are routed by the
|
|
190
|
+
// transport (MCP / chat) AS the principal, so each procedure's own
|
|
191
|
+
// contract access rules gate it; `deferredProjectionExecute` is the
|
|
192
|
+
// fail-closed net if a transport ever forgot to route.
|
|
193
|
+
const aiProjectionExt = env.getExtensionPoint(
|
|
194
|
+
aiToolProjectionExtensionPoint,
|
|
195
|
+
);
|
|
196
|
+
aiProjectionExt.expose({
|
|
197
|
+
procedure: maintenanceContract.listMaintenances,
|
|
198
|
+
sourcePluginMetadata: pluginMetadata,
|
|
199
|
+
procedureKey: "listMaintenances",
|
|
200
|
+
name: "maintenance.list",
|
|
201
|
+
description:
|
|
202
|
+
"List planned maintenance windows with optional status/system filters. Read-only.",
|
|
203
|
+
effect: "read",
|
|
204
|
+
execute: deferredProjectionExecute,
|
|
205
|
+
});
|
|
206
|
+
aiProjectionExt.expose({
|
|
207
|
+
procedure: maintenanceContract.getMaintenance,
|
|
208
|
+
sourcePluginMetadata: pluginMetadata,
|
|
209
|
+
procedureKey: "getMaintenance",
|
|
210
|
+
name: "maintenance.get",
|
|
211
|
+
description:
|
|
212
|
+
"Get one maintenance window with its updates and links. Read-only.",
|
|
213
|
+
effect: "read",
|
|
214
|
+
execute: deferredProjectionExecute,
|
|
215
|
+
});
|
|
216
|
+
|
|
173
217
|
// Register "Create Maintenance" command in the command palette
|
|
174
218
|
registerSearchProvider({
|
|
175
219
|
pluginMetadata,
|
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
|
/**
|
|
@@ -68,12 +69,21 @@ export const maintenanceUpdates = pgTable("maintenance_updates", {
|
|
|
68
69
|
* Hotlinks attached to a maintenance — e.g. a change ticket, runbook, or
|
|
69
70
|
* chat thread. Free-form URL + optional human label.
|
|
70
71
|
*/
|
|
71
|
-
export const maintenanceLinks = pgTable(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
export const maintenanceLinks = pgTable(
|
|
73
|
+
"maintenance_links",
|
|
74
|
+
{
|
|
75
|
+
id: text("id").primaryKey(),
|
|
76
|
+
maintenanceId: text("maintenance_id")
|
|
77
|
+
.notNull()
|
|
78
|
+
.references(() => maintenances.id, { onDelete: "cascade" }),
|
|
79
|
+
label: text("label"),
|
|
80
|
+
url: text("url").notNull(),
|
|
81
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
82
|
+
},
|
|
83
|
+
(t) => ({
|
|
84
|
+
// The same URL may be attached to a maintenance only once.
|
|
85
|
+
maintenanceUrlUnique: uniqueIndex(
|
|
86
|
+
"maintenance_links_maintenance_url_unique",
|
|
87
|
+
).on(t.maintenanceId, t.url),
|
|
88
|
+
}),
|
|
89
|
+
);
|
package/src/service.ts
CHANGED
|
@@ -257,23 +257,28 @@ export class MaintenanceService {
|
|
|
257
257
|
input: CreateMaintenanceInput,
|
|
258
258
|
id: string = generateId(),
|
|
259
259
|
): Promise<MaintenanceWithSystems> {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
await this.db.insert(maintenanceSystems).values({
|
|
273
|
-
maintenanceId: id,
|
|
274
|
-
systemId,
|
|
260
|
+
// Atomic: the maintenance row and its system associations must commit
|
|
261
|
+
// together. Without the transaction a failure mid-loop left a committed
|
|
262
|
+
// maintenance with only some (or none) of its system links.
|
|
263
|
+
await this.db.transaction(async (tx) => {
|
|
264
|
+
await tx.insert(maintenances).values({
|
|
265
|
+
id,
|
|
266
|
+
title: input.title,
|
|
267
|
+
description: input.description,
|
|
268
|
+
suppressNotifications: input.suppressNotifications ?? false,
|
|
269
|
+
status: "scheduled",
|
|
270
|
+
startAt: input.startAt,
|
|
271
|
+
endAt: input.endAt,
|
|
275
272
|
});
|
|
276
|
-
|
|
273
|
+
|
|
274
|
+
// Insert system associations
|
|
275
|
+
for (const systemId of input.systemIds) {
|
|
276
|
+
await tx.insert(maintenanceSystems).values({
|
|
277
|
+
maintenanceId: id,
|
|
278
|
+
systemId,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
});
|
|
277
282
|
|
|
278
283
|
return (await this.getMaintenance(id))!;
|
|
279
284
|
}
|
|
@@ -303,24 +308,29 @@ export class MaintenanceService {
|
|
|
303
308
|
if (input.startAt !== undefined) updateData.startAt = input.startAt;
|
|
304
309
|
if (input.endAt !== undefined) updateData.endAt = input.endAt;
|
|
305
310
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
maintenanceId
|
|
320
|
-
|
|
321
|
-
|
|
311
|
+
// Atomic: the field update and the delete-then-reinsert of system links must
|
|
312
|
+
// commit together. Without the transaction a failure after the delete left
|
|
313
|
+
// the maintenance with ALL system associations wiped.
|
|
314
|
+
await this.db.transaction(async (tx) => {
|
|
315
|
+
await tx
|
|
316
|
+
.update(maintenances)
|
|
317
|
+
.set(updateData)
|
|
318
|
+
.where(eq(maintenances.id, input.id));
|
|
319
|
+
|
|
320
|
+
// Update system associations if provided
|
|
321
|
+
if (input.systemIds !== undefined) {
|
|
322
|
+
await tx
|
|
323
|
+
.delete(maintenanceSystems)
|
|
324
|
+
.where(eq(maintenanceSystems.maintenanceId, input.id));
|
|
325
|
+
|
|
326
|
+
for (const systemId of input.systemIds) {
|
|
327
|
+
await tx.insert(maintenanceSystems).values({
|
|
328
|
+
maintenanceId: input.id,
|
|
329
|
+
systemId,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
322
332
|
}
|
|
323
|
-
}
|
|
333
|
+
});
|
|
324
334
|
|
|
325
335
|
return (await this.getMaintenance(input.id))!;
|
|
326
336
|
}
|
|
@@ -334,20 +344,24 @@ export class MaintenanceService {
|
|
|
334
344
|
): Promise<MaintenanceUpdate> {
|
|
335
345
|
const id = generateId();
|
|
336
346
|
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
347
|
+
// Atomic: the status flip and the timeline entry that records it must commit
|
|
348
|
+
// together (status/timeline divergence otherwise).
|
|
349
|
+
await this.db.transaction(async (tx) => {
|
|
350
|
+
// If status change is provided, update the maintenance status
|
|
351
|
+
if (input.statusChange) {
|
|
352
|
+
await tx
|
|
353
|
+
.update(maintenances)
|
|
354
|
+
.set({ status: input.statusChange, updatedAt: new Date() })
|
|
355
|
+
.where(eq(maintenances.id, input.maintenanceId));
|
|
356
|
+
}
|
|
344
357
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
358
|
+
await tx.insert(maintenanceUpdates).values({
|
|
359
|
+
id,
|
|
360
|
+
maintenanceId: input.maintenanceId,
|
|
361
|
+
message: input.message,
|
|
362
|
+
statusChange: input.statusChange,
|
|
363
|
+
createdBy: userId,
|
|
364
|
+
});
|
|
351
365
|
});
|
|
352
366
|
|
|
353
367
|
const [update] = await this.db
|
|
@@ -377,18 +391,21 @@ export class MaintenanceService {
|
|
|
377
391
|
|
|
378
392
|
if (!existing) return undefined;
|
|
379
393
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
394
|
+
// Atomic: mark completed + write the closing timeline entry together.
|
|
395
|
+
await this.db.transaction(async (tx) => {
|
|
396
|
+
await tx
|
|
397
|
+
.update(maintenances)
|
|
398
|
+
.set({ status: "completed", updatedAt: new Date() })
|
|
399
|
+
.where(eq(maintenances.id, id));
|
|
384
400
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
401
|
+
// Add update entry
|
|
402
|
+
await tx.insert(maintenanceUpdates).values({
|
|
403
|
+
id: generateId(),
|
|
404
|
+
maintenanceId: id,
|
|
405
|
+
message: message ?? "Maintenance completed early",
|
|
406
|
+
statusChange: "completed",
|
|
407
|
+
createdBy: userId,
|
|
408
|
+
});
|
|
392
409
|
});
|
|
393
410
|
|
|
394
411
|
return (await this.getMaintenance(id))!;
|