@checkstack/catalog-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.
- package/CHANGELOG.md +149 -0
- package/drizzle/0003_tan_spot.sql +17 -0
- package/drizzle/0004_heavy_sharon_carter.sql +13 -0
- package/drizzle/0005_normal_shaman.sql +60 -0
- package/drizzle/0006_optimal_gamora.sql +43 -0
- package/drizzle/meta/0003_snapshot.json +479 -0
- package/drizzle/meta/0004_snapshot.json +495 -0
- package/drizzle/meta/0005_snapshot.json +592 -0
- package/drizzle/meta/0006_snapshot.json +592 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +15 -12
- package/src/ai/catalog-add-system-to-group.test.ts +51 -0
- package/src/ai/catalog-add-system-to-group.ts +68 -0
- package/src/ai/catalog-create-group.test.ts +62 -0
- package/src/ai/catalog-create-group.ts +71 -0
- package/src/ai/catalog-create-system.test.ts +62 -0
- package/src/ai/catalog-create-system.ts +78 -0
- package/src/ai/catalog-delete-group.test.ts +83 -0
- package/src/ai/catalog-delete-group.ts +77 -0
- package/src/ai/catalog-delete-system.test.ts +84 -0
- package/src/ai/catalog-delete-system.ts +77 -0
- package/src/ai/catalog-remove-system-from-group.test.ts +55 -0
- package/src/ai/catalog-remove-system-from-group.ts +74 -0
- package/src/ai/catalog-update-group.test.ts +85 -0
- package/src/ai/catalog-update-group.ts +88 -0
- package/src/ai/catalog-update-system.test.ts +87 -0
- package/src/ai/catalog-update-system.ts +93 -0
- package/src/ai/catalog.projection.test.ts +37 -0
- package/src/ai/register-ai-tools.ts +35 -0
- package/src/automations.test.ts +2 -1
- package/src/catalog-gitops-kinds.test.ts +288 -0
- package/src/index.ts +149 -0
- package/src/router.test.ts +107 -0
- package/src/router.ts +200 -26
- package/src/schema.ts +124 -38
- package/src/services/entity-service.test.ts +28 -0
- package/src/services/entity-service.ts +154 -1
- package/src/services/environment-membership.test.ts +66 -0
- package/src/services/environment-membership.ts +40 -0
- package/src/services/pg-errors.test.ts +24 -0
- package/src/services/pg-errors.ts +21 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
3
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
CatalogApi,
|
|
6
|
+
catalogAccess,
|
|
7
|
+
pluginMetadata,
|
|
8
|
+
} from "@checkstack/catalog-common";
|
|
9
|
+
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
10
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
11
|
+
|
|
12
|
+
/** Input for `catalog.deleteSystem`: the system id to remove. */
|
|
13
|
+
export const CatalogDeleteSystemInputSchema = z.object({
|
|
14
|
+
id: z.string(),
|
|
15
|
+
});
|
|
16
|
+
export type CatalogDeleteSystemInput = z.infer<
|
|
17
|
+
typeof CatalogDeleteSystemInputSchema
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
/** Output returned once a human applies the deletion. */
|
|
21
|
+
export interface CatalogDeleteSystemApplyResult {
|
|
22
|
+
id: string;
|
|
23
|
+
deleted: true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `catalog.deleteSystem` - delete a system by id.
|
|
28
|
+
*
|
|
29
|
+
* `effect: "destructive"` - deletion is irreversible, so it ALWAYS routes through
|
|
30
|
+
* the propose/apply confirm card in BOTH permission modes (it can never
|
|
31
|
+
* auto-apply). `dryRun` resolves the target system so the confirm card names
|
|
32
|
+
* exactly what will be removed; `execute` (reached only via `apply`) performs the
|
|
33
|
+
* delete via the contract's POSITIONAL string id. Authorization is the
|
|
34
|
+
* `system.manage` rule the UI delete requires.
|
|
35
|
+
*/
|
|
36
|
+
export function createCatalogDeleteSystemTool(): RegisteredAiTool<
|
|
37
|
+
CatalogDeleteSystemInput,
|
|
38
|
+
CatalogDeleteSystemApplyResult
|
|
39
|
+
> {
|
|
40
|
+
const dryRun = async ({
|
|
41
|
+
input,
|
|
42
|
+
rpcClient,
|
|
43
|
+
}: {
|
|
44
|
+
input: CatalogDeleteSystemInput;
|
|
45
|
+
principal: AuthUser;
|
|
46
|
+
rpcClient: RpcClient;
|
|
47
|
+
}): Promise<AiProposalPreview<CatalogDeleteSystemInput>> => {
|
|
48
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
49
|
+
const system = await catalogClient.getSystem({ systemId: input.id });
|
|
50
|
+
if (!system) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`No system found with id "${input.id}". List systems first to get a valid id.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
summary: `Delete system "${system.name}". This is permanent and also removes its group memberships and associations.`,
|
|
57
|
+
payload: { id: input.id },
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
name: "catalog.deleteSystem",
|
|
63
|
+
description:
|
|
64
|
+
"Delete a system by id. DESTRUCTIVE and irreversible - it also removes the system's group memberships and associations. Never deletes directly; a person must approve the confirmation. Find the id with the catalog read tools first.",
|
|
65
|
+
effect: "destructive",
|
|
66
|
+
input: CatalogDeleteSystemInputSchema,
|
|
67
|
+
requiredAccessRules: [
|
|
68
|
+
qualifyAccessRuleId(pluginMetadata, catalogAccess.system.manage),
|
|
69
|
+
],
|
|
70
|
+
dryRun,
|
|
71
|
+
async execute({ input, rpcClient }) {
|
|
72
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
73
|
+
await catalogClient.deleteSystem(input.id);
|
|
74
|
+
return { id: input.id, deleted: true };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import { createCatalogRemoveSystemFromGroupTool } from "./catalog-remove-system-from-group";
|
|
4
|
+
|
|
5
|
+
const principal: AuthUser = {
|
|
6
|
+
type: "user",
|
|
7
|
+
id: "u1",
|
|
8
|
+
accessRules: ["catalog.system.manage", "catalog.group.manage"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function fakeRpcClient({
|
|
12
|
+
removeSystemFromGroup,
|
|
13
|
+
}: {
|
|
14
|
+
removeSystemFromGroup: ReturnType<typeof mock>;
|
|
15
|
+
}): RpcClient {
|
|
16
|
+
return {
|
|
17
|
+
forPlugin: () => ({ removeSystemFromGroup }),
|
|
18
|
+
} as unknown as RpcClient;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("catalog.removeSystemFromGroup tool", () => {
|
|
22
|
+
test("declares mutate effect (reversible unassignment) + the system manage rule", () => {
|
|
23
|
+
const tool = createCatalogRemoveSystemFromGroupTool();
|
|
24
|
+
expect(tool.name).toBe("catalog.removeSystemFromGroup");
|
|
25
|
+
expect(tool.effect).toBe("mutate");
|
|
26
|
+
expect(tool.requiredAccessRules).toEqual(["catalog.system.manage"]);
|
|
27
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("dryRun summarizes the unassignment and NEVER mutates", async () => {
|
|
31
|
+
const removeSystemFromGroup = mock(() => Promise.resolve({ success: true }));
|
|
32
|
+
const rpcClient = fakeRpcClient({ removeSystemFromGroup });
|
|
33
|
+
const tool = createCatalogRemoveSystemFromGroupTool();
|
|
34
|
+
const input = { groupId: "grp1", systemId: "sys1" };
|
|
35
|
+
const preview = await tool.dryRun!({ input, principal, rpcClient });
|
|
36
|
+
expect(removeSystemFromGroup).not.toHaveBeenCalled();
|
|
37
|
+
expect(preview.summary).toContain("sys1");
|
|
38
|
+
expect(preview.summary).toContain("grp1");
|
|
39
|
+
expect(preview.payload).toEqual(input);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("execute (apply) calls removeSystemFromGroup with { groupId, systemId }", async () => {
|
|
43
|
+
const removeSystemFromGroup = mock(() => Promise.resolve({ success: true }));
|
|
44
|
+
const rpcClient = fakeRpcClient({ removeSystemFromGroup });
|
|
45
|
+
const tool = createCatalogRemoveSystemFromGroupTool();
|
|
46
|
+
const input = { groupId: "grp1", systemId: "sys1" };
|
|
47
|
+
const result = await tool.execute({ input, principal, rpcClient });
|
|
48
|
+
expect(removeSystemFromGroup).toHaveBeenCalledWith(input);
|
|
49
|
+
expect(result).toEqual({
|
|
50
|
+
groupId: "grp1",
|
|
51
|
+
systemId: "sys1",
|
|
52
|
+
removed: true,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
3
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
CatalogApi,
|
|
6
|
+
catalogAccess,
|
|
7
|
+
pluginMetadata,
|
|
8
|
+
} from "@checkstack/catalog-common";
|
|
9
|
+
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
10
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
11
|
+
|
|
12
|
+
/** Input for `catalog.removeSystemFromGroup`: the group + system to unassign. */
|
|
13
|
+
export const CatalogRemoveSystemFromGroupInputSchema = z.object({
|
|
14
|
+
groupId: z.string(),
|
|
15
|
+
systemId: z.string(),
|
|
16
|
+
});
|
|
17
|
+
export type CatalogRemoveSystemFromGroupInput = z.infer<
|
|
18
|
+
typeof CatalogRemoveSystemFromGroupInputSchema
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
/** Output returned once a human applies the membership change. */
|
|
22
|
+
export interface CatalogRemoveSystemFromGroupApplyResult {
|
|
23
|
+
groupId: string;
|
|
24
|
+
systemId: string;
|
|
25
|
+
removed: true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `catalog.removeSystemFromGroup` - remove a system from a group.
|
|
30
|
+
*
|
|
31
|
+
* `effect: "mutate"` - this is a reversible UNASSIGNMENT (the system and group
|
|
32
|
+
* both still exist; only the membership edge is dropped), so it is `mutate`, NOT
|
|
33
|
+
* `destructive`. It auto-applies in AUTO mode and is confirm-gated in APPROVE
|
|
34
|
+
* mode. Membership is part of the system surface, so authorization is the
|
|
35
|
+
* `system.manage` rule (matching the contract).
|
|
36
|
+
*/
|
|
37
|
+
export function createCatalogRemoveSystemFromGroupTool(): RegisteredAiTool<
|
|
38
|
+
CatalogRemoveSystemFromGroupInput,
|
|
39
|
+
CatalogRemoveSystemFromGroupApplyResult
|
|
40
|
+
> {
|
|
41
|
+
const dryRun = async ({
|
|
42
|
+
input,
|
|
43
|
+
}: {
|
|
44
|
+
input: CatalogRemoveSystemFromGroupInput;
|
|
45
|
+
principal: AuthUser;
|
|
46
|
+
rpcClient: RpcClient;
|
|
47
|
+
}): Promise<AiProposalPreview<CatalogRemoveSystemFromGroupInput>> => {
|
|
48
|
+
return {
|
|
49
|
+
summary: `Remove system "${input.systemId}" from group "${input.groupId}".`,
|
|
50
|
+
payload: input,
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
name: "catalog.removeSystemFromGroup",
|
|
56
|
+
description:
|
|
57
|
+
"Remove a system from a group by their ids. This only removes the membership; the system and group are kept. Never changes membership directly; a person must approve unless the conversation is in auto mode. Find the ids with the catalog read tools first.",
|
|
58
|
+
effect: "mutate",
|
|
59
|
+
input: CatalogRemoveSystemFromGroupInputSchema,
|
|
60
|
+
requiredAccessRules: [
|
|
61
|
+
qualifyAccessRuleId(pluginMetadata, catalogAccess.system.manage),
|
|
62
|
+
],
|
|
63
|
+
dryRun,
|
|
64
|
+
async execute({ input, rpcClient }) {
|
|
65
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
66
|
+
await catalogClient.removeSystemFromGroup(input);
|
|
67
|
+
return {
|
|
68
|
+
groupId: input.groupId,
|
|
69
|
+
systemId: input.systemId,
|
|
70
|
+
removed: true,
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import { createCatalogUpdateGroupTool } from "./catalog-update-group";
|
|
4
|
+
|
|
5
|
+
const principal: AuthUser = {
|
|
6
|
+
type: "user",
|
|
7
|
+
id: "u1",
|
|
8
|
+
accessRules: ["catalog.system.manage", "catalog.group.manage"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const group = {
|
|
12
|
+
id: "grp1",
|
|
13
|
+
name: "Payments",
|
|
14
|
+
systemIds: [],
|
|
15
|
+
metadata: null,
|
|
16
|
+
createdAt: new Date(),
|
|
17
|
+
updatedAt: new Date(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function fakeRpcClient({
|
|
21
|
+
getGroups,
|
|
22
|
+
updateGroup,
|
|
23
|
+
}: {
|
|
24
|
+
getGroups: ReturnType<typeof mock>;
|
|
25
|
+
updateGroup: ReturnType<typeof mock>;
|
|
26
|
+
}): RpcClient {
|
|
27
|
+
return {
|
|
28
|
+
forPlugin: () => ({ getGroups, updateGroup }),
|
|
29
|
+
} as unknown as RpcClient;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("catalog.updateGroup tool", () => {
|
|
33
|
+
test("declares mutate effect + the group manage rule", () => {
|
|
34
|
+
const tool = createCatalogUpdateGroupTool();
|
|
35
|
+
expect(tool.name).toBe("catalog.updateGroup");
|
|
36
|
+
expect(tool.effect).toBe("mutate");
|
|
37
|
+
expect(tool.requiredAccessRules).toEqual(["catalog.group.manage"]);
|
|
38
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("dryRun resolves the target via getGroups + returns a diff", async () => {
|
|
42
|
+
const getGroups = mock(() => Promise.resolve([group]));
|
|
43
|
+
const updateGroup = mock(() => Promise.resolve(group));
|
|
44
|
+
const rpcClient = fakeRpcClient({ getGroups, updateGroup });
|
|
45
|
+
const tool = createCatalogUpdateGroupTool();
|
|
46
|
+
const preview = await tool.dryRun!({
|
|
47
|
+
input: { id: "grp1", data: { name: "Billing" } },
|
|
48
|
+
principal,
|
|
49
|
+
rpcClient,
|
|
50
|
+
});
|
|
51
|
+
expect(updateGroup).not.toHaveBeenCalled();
|
|
52
|
+
expect(preview.summary).toContain("Payments");
|
|
53
|
+
expect(preview.diff).toEqual([
|
|
54
|
+
{ path: "name", before: "Payments", after: "Billing" },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("dryRun throws a clear error when the id is unknown", async () => {
|
|
59
|
+
const rpcClient = fakeRpcClient({
|
|
60
|
+
getGroups: mock(() => Promise.resolve([group])),
|
|
61
|
+
updateGroup: mock(),
|
|
62
|
+
});
|
|
63
|
+
const tool = createCatalogUpdateGroupTool();
|
|
64
|
+
await expect(
|
|
65
|
+
tool.dryRun!({
|
|
66
|
+
input: { id: "nope", data: { name: "x" } },
|
|
67
|
+
principal,
|
|
68
|
+
rpcClient,
|
|
69
|
+
}),
|
|
70
|
+
).rejects.toThrow(/No group found/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("execute (apply) updates via updateGroup", async () => {
|
|
74
|
+
const updateGroup = mock(() => Promise.resolve(group));
|
|
75
|
+
const rpcClient = fakeRpcClient({
|
|
76
|
+
getGroups: mock(() => Promise.resolve([group])),
|
|
77
|
+
updateGroup,
|
|
78
|
+
});
|
|
79
|
+
const tool = createCatalogUpdateGroupTool();
|
|
80
|
+
const input = { id: "grp1", data: { name: "Billing" } };
|
|
81
|
+
const result = await tool.execute({ input, principal, rpcClient });
|
|
82
|
+
expect(updateGroup).toHaveBeenCalledWith(input);
|
|
83
|
+
expect(result).toEqual({ group });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
3
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
CatalogApi,
|
|
6
|
+
catalogAccess,
|
|
7
|
+
pluginMetadata,
|
|
8
|
+
type Group,
|
|
9
|
+
} from "@checkstack/catalog-common";
|
|
10
|
+
import { computeFieldDiff, type AiProposalPreview } from "@checkstack/ai-common";
|
|
11
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Input for `catalog.updateGroup`. Mirrors the contract's (module-private)
|
|
15
|
+
* `UpdateGroupInputSchema`: the group id plus a PARTIAL data body. Only the
|
|
16
|
+
* provided fields change; `metadata` is nullable so it can be explicitly cleared.
|
|
17
|
+
*/
|
|
18
|
+
export const CatalogUpdateGroupInputSchema = z.object({
|
|
19
|
+
id: z.string(),
|
|
20
|
+
data: z.object({
|
|
21
|
+
name: z.string().optional(),
|
|
22
|
+
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
export type CatalogUpdateGroupInput = z.infer<
|
|
26
|
+
typeof CatalogUpdateGroupInputSchema
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
/** Output returned once a human applies the update (the updated group). */
|
|
30
|
+
export interface CatalogUpdateGroupApplyResult {
|
|
31
|
+
group: Group;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `catalog.updateGroup` - update an existing group by id with a partial body.
|
|
36
|
+
*
|
|
37
|
+
* `effect: "mutate"` - a non-destructive change, so it auto-applies in AUTO mode
|
|
38
|
+
* and is confirm-gated in APPROVE mode. `dryRun` lists groups, finds the target,
|
|
39
|
+
* and returns a before -> after field diff. There is no single-group fetch on the
|
|
40
|
+
* contract, so the dry-run resolves it from `getGroups()`.
|
|
41
|
+
*/
|
|
42
|
+
export function createCatalogUpdateGroupTool(): RegisteredAiTool<
|
|
43
|
+
CatalogUpdateGroupInput,
|
|
44
|
+
CatalogUpdateGroupApplyResult
|
|
45
|
+
> {
|
|
46
|
+
const dryRun = async ({
|
|
47
|
+
input,
|
|
48
|
+
rpcClient,
|
|
49
|
+
}: {
|
|
50
|
+
input: CatalogUpdateGroupInput;
|
|
51
|
+
principal: AuthUser;
|
|
52
|
+
rpcClient: RpcClient;
|
|
53
|
+
}): Promise<AiProposalPreview<CatalogUpdateGroupInput>> => {
|
|
54
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
55
|
+
const groups = await catalogClient.getGroups();
|
|
56
|
+
const group = groups.find((g) => g.id === input.id);
|
|
57
|
+
if (!group) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`No group found with id "${input.id}". List groups first to get a valid id.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const before = { name: group.name, metadata: group.metadata };
|
|
63
|
+
const after = { ...before, ...input.data };
|
|
64
|
+
const diff = computeFieldDiff({ before, after });
|
|
65
|
+
return {
|
|
66
|
+
summary: `Update group "${group.name}".`,
|
|
67
|
+
payload: input,
|
|
68
|
+
diff,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
name: "catalog.updateGroup",
|
|
74
|
+
description:
|
|
75
|
+
"Update an existing group by id with a partial body (only provided fields change). Never updates directly; a person must approve unless the conversation is in auto mode. Find the id with the catalog read tools first.",
|
|
76
|
+
effect: "mutate",
|
|
77
|
+
input: CatalogUpdateGroupInputSchema,
|
|
78
|
+
requiredAccessRules: [
|
|
79
|
+
qualifyAccessRuleId(pluginMetadata, catalogAccess.group.manage),
|
|
80
|
+
],
|
|
81
|
+
dryRun,
|
|
82
|
+
async execute({ input, rpcClient }) {
|
|
83
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
84
|
+
const group = await catalogClient.updateGroup(input);
|
|
85
|
+
return { group };
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import { createCatalogUpdateSystemTool } from "./catalog-update-system";
|
|
4
|
+
|
|
5
|
+
const principal: AuthUser = {
|
|
6
|
+
type: "user",
|
|
7
|
+
id: "u1",
|
|
8
|
+
accessRules: ["catalog.system.manage", "catalog.group.manage"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const system = {
|
|
12
|
+
id: "sys1",
|
|
13
|
+
name: "Checkout API",
|
|
14
|
+
description: "Handles checkout",
|
|
15
|
+
metadata: null,
|
|
16
|
+
createdAt: new Date(),
|
|
17
|
+
updatedAt: new Date(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function fakeRpcClient({
|
|
21
|
+
getSystem,
|
|
22
|
+
updateSystem,
|
|
23
|
+
}: {
|
|
24
|
+
getSystem: ReturnType<typeof mock>;
|
|
25
|
+
updateSystem: ReturnType<typeof mock>;
|
|
26
|
+
}): RpcClient {
|
|
27
|
+
return {
|
|
28
|
+
forPlugin: () => ({ getSystem, updateSystem }),
|
|
29
|
+
} as unknown as RpcClient;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("catalog.updateSystem tool", () => {
|
|
33
|
+
test("declares mutate effect + the system manage rule", () => {
|
|
34
|
+
const tool = createCatalogUpdateSystemTool();
|
|
35
|
+
expect(tool.name).toBe("catalog.updateSystem");
|
|
36
|
+
expect(tool.effect).toBe("mutate");
|
|
37
|
+
expect(tool.requiredAccessRules).toEqual(["catalog.system.manage"]);
|
|
38
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("dryRun fetches via getSystem with the systemId key + returns a diff", async () => {
|
|
42
|
+
const getSystem = mock(() => Promise.resolve(system));
|
|
43
|
+
const updateSystem = mock(() => Promise.resolve(system));
|
|
44
|
+
const rpcClient = fakeRpcClient({ getSystem, updateSystem });
|
|
45
|
+
const tool = createCatalogUpdateSystemTool();
|
|
46
|
+
const preview = await tool.dryRun!({
|
|
47
|
+
input: { id: "sys1", data: { name: "Checkout" } },
|
|
48
|
+
principal,
|
|
49
|
+
rpcClient,
|
|
50
|
+
});
|
|
51
|
+
// systemId-key trap: the contract's getSystem input is { systemId }.
|
|
52
|
+
expect(getSystem).toHaveBeenCalledWith({ systemId: "sys1" });
|
|
53
|
+
expect(updateSystem).not.toHaveBeenCalled();
|
|
54
|
+
expect(preview.summary).toContain("Checkout API");
|
|
55
|
+
expect(preview.diff).toEqual([
|
|
56
|
+
{ path: "name", before: "Checkout API", after: "Checkout" },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("dryRun throws a clear error when the id is unknown", async () => {
|
|
61
|
+
const rpcClient = fakeRpcClient({
|
|
62
|
+
getSystem: mock(() => Promise.resolve(null)),
|
|
63
|
+
updateSystem: mock(),
|
|
64
|
+
});
|
|
65
|
+
const tool = createCatalogUpdateSystemTool();
|
|
66
|
+
await expect(
|
|
67
|
+
tool.dryRun!({
|
|
68
|
+
input: { id: "nope", data: { name: "x" } },
|
|
69
|
+
principal,
|
|
70
|
+
rpcClient,
|
|
71
|
+
}),
|
|
72
|
+
).rejects.toThrow(/No system found/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("execute (apply) updates via updateSystem", async () => {
|
|
76
|
+
const updateSystem = mock(() => Promise.resolve(system));
|
|
77
|
+
const rpcClient = fakeRpcClient({
|
|
78
|
+
getSystem: mock(() => Promise.resolve(system)),
|
|
79
|
+
updateSystem,
|
|
80
|
+
});
|
|
81
|
+
const tool = createCatalogUpdateSystemTool();
|
|
82
|
+
const input = { id: "sys1", data: { name: "Checkout" } };
|
|
83
|
+
const result = await tool.execute({ input, principal, rpcClient });
|
|
84
|
+
expect(updateSystem).toHaveBeenCalledWith(input);
|
|
85
|
+
expect(result).toEqual({ system });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
3
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
CatalogApi,
|
|
6
|
+
catalogAccess,
|
|
7
|
+
pluginMetadata,
|
|
8
|
+
type System,
|
|
9
|
+
} from "@checkstack/catalog-common";
|
|
10
|
+
import { computeFieldDiff, type AiProposalPreview } from "@checkstack/ai-common";
|
|
11
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Input for `catalog.updateSystem`. Mirrors the contract's (module-private)
|
|
15
|
+
* `UpdateSystemInputSchema`: the system id plus a PARTIAL data body. Only the
|
|
16
|
+
* provided fields change; `description` / `metadata` are nullable so they can be
|
|
17
|
+
* explicitly cleared.
|
|
18
|
+
*/
|
|
19
|
+
export const CatalogUpdateSystemInputSchema = z.object({
|
|
20
|
+
id: z.string(),
|
|
21
|
+
data: z.object({
|
|
22
|
+
name: z.string().optional(),
|
|
23
|
+
description: z.string().nullable().optional(),
|
|
24
|
+
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
export type CatalogUpdateSystemInput = z.infer<
|
|
28
|
+
typeof CatalogUpdateSystemInputSchema
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
/** Output returned once a human applies the update (the updated system). */
|
|
32
|
+
export interface CatalogUpdateSystemApplyResult {
|
|
33
|
+
system: System;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `catalog.updateSystem` - update an existing system by id with a partial body.
|
|
38
|
+
*
|
|
39
|
+
* `effect: "mutate"` - a non-destructive change, so it auto-applies in AUTO mode
|
|
40
|
+
* and is confirm-gated in APPROVE mode. `dryRun` fetches the live system, merges
|
|
41
|
+
* the partial body, and returns a before -> after field diff so the confirm card
|
|
42
|
+
* shows exactly what changes.
|
|
43
|
+
*/
|
|
44
|
+
export function createCatalogUpdateSystemTool(): RegisteredAiTool<
|
|
45
|
+
CatalogUpdateSystemInput,
|
|
46
|
+
CatalogUpdateSystemApplyResult
|
|
47
|
+
> {
|
|
48
|
+
const dryRun = async ({
|
|
49
|
+
input,
|
|
50
|
+
rpcClient,
|
|
51
|
+
}: {
|
|
52
|
+
input: CatalogUpdateSystemInput;
|
|
53
|
+
principal: AuthUser;
|
|
54
|
+
rpcClient: RpcClient;
|
|
55
|
+
}): Promise<AiProposalPreview<CatalogUpdateSystemInput>> => {
|
|
56
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
57
|
+
const system = await catalogClient.getSystem({ systemId: input.id });
|
|
58
|
+
if (!system) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`No system found with id "${input.id}". List systems first to get a valid id.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const before = {
|
|
64
|
+
name: system.name,
|
|
65
|
+
description: system.description,
|
|
66
|
+
metadata: system.metadata,
|
|
67
|
+
};
|
|
68
|
+
const after = { ...before, ...input.data };
|
|
69
|
+
const diff = computeFieldDiff({ before, after });
|
|
70
|
+
return {
|
|
71
|
+
summary: `Update system "${system.name}".`,
|
|
72
|
+
payload: input,
|
|
73
|
+
diff,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
name: "catalog.updateSystem",
|
|
79
|
+
description:
|
|
80
|
+
"Update an existing system by id with a partial body (only provided fields change). Never updates directly; a person must approve unless the conversation is in auto mode. Find the id with the catalog read tools first.",
|
|
81
|
+
effect: "mutate",
|
|
82
|
+
input: CatalogUpdateSystemInputSchema,
|
|
83
|
+
requiredAccessRules: [
|
|
84
|
+
qualifyAccessRuleId(pluginMetadata, catalogAccess.system.manage),
|
|
85
|
+
],
|
|
86
|
+
dryRun,
|
|
87
|
+
async execute({ input, rpcClient }) {
|
|
88
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
89
|
+
const system = await catalogClient.updateSystem(input);
|
|
90
|
+
return { system };
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildProjectedTool } from "@checkstack/ai-backend";
|
|
3
|
+
import { catalogContract, pluginMetadata } from "@checkstack/catalog-common";
|
|
4
|
+
|
|
5
|
+
describe("catalog read projections", () => {
|
|
6
|
+
test("catalog.listSystems projects getSystems as a read tool with the system read rule", () => {
|
|
7
|
+
const tool = buildProjectedTool({
|
|
8
|
+
procedure: catalogContract.getSystems,
|
|
9
|
+
sourcePluginMetadata: pluginMetadata,
|
|
10
|
+
procedureKey: "getSystems",
|
|
11
|
+
name: "catalog.listSystems",
|
|
12
|
+
description:
|
|
13
|
+
"List all systems (services/resources) with their ids and names. Read-only.",
|
|
14
|
+
effect: "read",
|
|
15
|
+
execute: () => Promise.resolve({}),
|
|
16
|
+
});
|
|
17
|
+
expect(tool.name).toBe("catalog.listSystems");
|
|
18
|
+
expect(tool.effect).toBe("read");
|
|
19
|
+
expect(tool.requiredAccessRules).toEqual(["catalog.system.read"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("catalog.listGroups projects getGroups as a read tool with the group read rule", () => {
|
|
23
|
+
const tool = buildProjectedTool({
|
|
24
|
+
// unavoidable oRPC typing cast (established projection pattern):
|
|
25
|
+
procedure: catalogContract.getGroups,
|
|
26
|
+
sourcePluginMetadata: pluginMetadata,
|
|
27
|
+
procedureKey: "getGroups",
|
|
28
|
+
name: "catalog.listGroups",
|
|
29
|
+
description: "List all system groups with ids and names. Read-only.",
|
|
30
|
+
effect: "read",
|
|
31
|
+
execute: () => Promise.resolve({}),
|
|
32
|
+
});
|
|
33
|
+
expect(tool.name).toBe("catalog.listGroups");
|
|
34
|
+
expect(tool.effect).toBe("read");
|
|
35
|
+
expect(tool.requiredAccessRules).toEqual(["catalog.group.read"]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
2
|
+
import { createCatalogCreateSystemTool } from "./catalog-create-system";
|
|
3
|
+
import { createCatalogUpdateSystemTool } from "./catalog-update-system";
|
|
4
|
+
import { createCatalogDeleteSystemTool } from "./catalog-delete-system";
|
|
5
|
+
import { createCatalogCreateGroupTool } from "./catalog-create-group";
|
|
6
|
+
import { createCatalogUpdateGroupTool } from "./catalog-update-group";
|
|
7
|
+
import { createCatalogDeleteGroupTool } from "./catalog-delete-group";
|
|
8
|
+
import { createCatalogAddSystemToGroupTool } from "./catalog-add-system-to-group";
|
|
9
|
+
import { createCatalogRemoveSystemFromGroupTool } from "./catalog-remove-system-from-group";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The catalog plugin's AI tools, registered into the AI registry via
|
|
13
|
+
* `aiToolExtensionPoint` from this plugin's own init - NOT centralized in
|
|
14
|
+
* ai-backend. This is the canonical pattern any plugin uses to contribute AI
|
|
15
|
+
* tools without ai-backend depending on it.
|
|
16
|
+
*
|
|
17
|
+
* Create/update + membership tools are `mutate` (auto-applies in AUTO mode,
|
|
18
|
+
* confirm-gated in APPROVE mode); the two delete tools are `destructive` (always
|
|
19
|
+
* confirm-gated). All go through the USER-SCOPED client passed at call time, so
|
|
20
|
+
* handler-side authorization is enforced exactly as a direct UI/RPC call; the
|
|
21
|
+
* resolver gate + the propose/apply re-check at propose AND apply time are the
|
|
22
|
+
* additional authorization authority.
|
|
23
|
+
*/
|
|
24
|
+
export function buildCatalogAiTools(): RegisteredAiTool[] {
|
|
25
|
+
return [
|
|
26
|
+
createCatalogCreateSystemTool(),
|
|
27
|
+
createCatalogUpdateSystemTool(),
|
|
28
|
+
createCatalogDeleteSystemTool(),
|
|
29
|
+
createCatalogCreateGroupTool(),
|
|
30
|
+
createCatalogUpdateGroupTool(),
|
|
31
|
+
createCatalogDeleteGroupTool(),
|
|
32
|
+
createCatalogAddSystemToGroupTool(),
|
|
33
|
+
createCatalogRemoveSystemFromGroupTool(),
|
|
34
|
+
];
|
|
35
|
+
}
|
package/src/automations.test.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* failure path.
|
|
10
10
|
*/
|
|
11
11
|
import { describe, expect, it, mock } from "bun:test";
|
|
12
|
-
import type { Logger } from "@checkstack/backend-api";
|
|
12
|
+
import type { Logger, RpcClient } from "@checkstack/backend-api";
|
|
13
13
|
import { createMockLogger } from "@checkstack/test-utils-backend";
|
|
14
14
|
|
|
15
15
|
import {
|
|
@@ -33,6 +33,7 @@ const ctxBase = {
|
|
|
33
33
|
getService: async <T,>(): Promise<T> => {
|
|
34
34
|
throw new Error("not used");
|
|
35
35
|
},
|
|
36
|
+
rpcClient: { forPlugin: () => ({}) } as unknown as RpcClient,
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
// ─── Triggers ──────────────────────────────────────────────────────────
|