feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

This commit is contained in:
LukeGus committed 2026-09-23 11:24:16 -05:00
1 parent d2ff5fc78f
commit 3b2c849514
59 files changed
+31807 -2977

No files matched your search

+7 -1
View File
@@ -32,7 +32,8 @@
"optionalDependencies": {
"workspaces": "^1.0.0",
"network-topology": "^1.0.0",
"snippets": "^1.0.0"
"snippets": "^1.0.0",
"fleets": "^1.0.0"
},
"requires": [
{
@@ -49,6 +50,11 @@
"service": "snippets.access",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "fleets.access",
"versionRange": "^1.0.0",
"optional": true
}
],
"providesSecret": [
+54
View File
@@ -52,6 +52,60 @@ export async function getNetworkTopology(
}
}
/** Another plugin's fleets, as ctx.services.get("fleets.access") returns them. */
interface FleetsAccess {
list: () => Promise<
Array<{ id: number; name: string; color: string | null }>
>;
create: (input: {
name: string;
description?: string | null;
}) => Promise<{ id: number; name: string }>;
addMember: (fleetId: number, hostId: number) => Promise<void>;
}
/**
* The user's fleets, or null when the fleets plugin is off or the user may
* not use it. Optional: the list_fleets tool disappears without it.
*/
export async function listFleets(
userId: string,
): Promise<Array<{ id: number; name: string; color: string | null }> | null> {
if (!current) return null;
try {
return await current.get<FleetsAccess>("fleets.access", { userId }).list();
} catch {
return null;
}
}
/**
* Requires the fleets plugin, unlike listFleets above: a proposal apply must
* surface a failure as an error the user sees, not silently no-op.
*/
function requireFleetsAccess(userId: string): FleetsAccess {
if (!current) {
throw new Error("The fleets plugin is disabled");
}
return current.get<FleetsAccess>("fleets.access", { userId });
}
export async function createFleet(
userId: string,
input: { name: string; description?: string | null },
): Promise<{ id: number; name: string } | null> {
if (!current) return null;
return requireFleetsAccess(userId).create(input);
}
export async function addFleetMember(
userId: string,
fleetId: number,
hostId: number,
): Promise<void> {
await requireFleetsAccess(userId).addMember(fleetId, hostId);
}
export interface SnippetSummary {
id: number;
name: string;
+7 -8
View File
@@ -1,6 +1,5 @@
import {
createCurrentAutomationRepository,
createCurrentFleetRepository,
createCurrentHostRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
@@ -13,6 +12,8 @@ import {
updateSnippet,
deleteSnippet,
getSnippet,
createFleet,
addFleetMember,
} from "../services.js";
/** Approved commands get a bounded window rather than hanging the request. */
@@ -175,10 +176,11 @@ export async function applyProposal(
}
case "propose_create_fleet": {
const fleet = await createCurrentFleetRepository().create(userId, {
const fleet = await createFleet(userId, {
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
} as any);
});
if (!fleet) throw new Error("The fleets plugin is disabled");
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
let added = 0;
@@ -191,16 +193,13 @@ export async function applyProposal(
hostId,
);
if (!host) continue;
await createCurrentFleetRepository().addMember(
(fleet as any).id,
hostId,
);
await addFleetMember(userId, fleet.id, hostId);
added += 1;
}
return {
ok: true,
summary: `Created fleet ${(fleet as any).name} with ${added} host${added === 1 ? "" : "s"}`,
summary: `Created fleet ${fleet.name} with ${added} host${added === 1 ? "" : "s"}`,
};
}
+5 -7
View File
@@ -2,7 +2,6 @@ import {
createCurrentNotificationChannelRepository,
createCurrentAutomationRepository,
createCurrentCommandHistoryRepository,
createCurrentFleetRepository,
createCurrentHomepageItemRepository,
createCurrentHostRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
@@ -11,6 +10,7 @@ import {
listSavedWorkspaces,
getNetworkTopology,
listSnippets,
listFleets,
} from "../services.js";
/**
@@ -87,18 +87,16 @@ export const readTools: AiTool[] = [
{
name: "list_fleets",
description:
"List the user's fleets. Fleets group hosts for bulk operations and inventory.",
"List the user's fleets. Fleets group hosts for bulk operations and inventory. Unavailable if the fleets plugin is disabled.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const fleets = await createCurrentFleetRepository().listByUser(
context.userId,
);
const fleets = await listFleets(context.userId);
if (fleets === null) return { unavailable: true, fleets: [] };
return {
fleets: fleets.map((fleet: any) => ({
fleets: fleets.map((fleet) => ({
id: fleet.id,
name: fleet.name,
description: fleet.description ?? null,
})),
};
},
@@ -12,13 +12,12 @@ const snippetRepository = {
updateSnippet: vi.fn(),
deleteSnippet: vi.fn(),
};
const fleetRepository = { create: vi.fn(), addMember: vi.fn() };
const automationRepository = { create: vi.fn() };
const fleetsAccess = { create: vi.fn(), addMember: vi.fn() };
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentHostRepository: () => hostRepository,
createCurrentSnippetRepository: () => snippetRepository,
createCurrentFleetRepository: () => fleetRepository,
createCurrentAutomationRepository: () => automationRepository,
}));
@@ -44,10 +43,12 @@ vi.mock("../../../src/backend/ssh.js", () => ({
const { applyProposal } =
await import("../../../src/backend/tools/executor.js");
const { setPluginServices } = await import("../../../src/backend/services.js");
describe("applyProposal", () => {
beforeEach(() => {
vi.clearAllMocks();
setPluginServices(null);
});
it("refuses a kind that is not a real tool", async () => {
@@ -221,7 +222,15 @@ describe("applyProposal", () => {
});
it("only adds fleet members the approving user owns", async () => {
fleetRepository.create.mockResolvedValue({ id: 3, name: "prod" });
setPluginServices({
get: (service: string) => {
if (service !== "fleets.access")
throw new Error(`unexpected ${service}`);
return fleetsAccess as never;
},
provide: vi.fn(),
} as never);
fleetsAccess.create.mockResolvedValue({ id: 3, name: "prod" });
hostRepository.findByIdForUser.mockImplementation(
async (_userId: string, hostId: number) =>
hostId === 1 ? { id: 1 } : null,
@@ -233,11 +242,17 @@ describe("applyProposal", () => {
"user-1",
);
expect(fleetRepository.addMember).toHaveBeenCalledTimes(1);
expect(fleetRepository.addMember).toHaveBeenCalledWith(3, 1);
expect(fleetsAccess.addMember).toHaveBeenCalledTimes(1);
expect(fleetsAccess.addMember).toHaveBeenCalledWith(3, 1);
expect(result.summary).toContain("1 host");
});
it("fails clearly when the fleets plugin is disabled", async () => {
await expect(
applyProposal("propose_create_fleet", { name: "prod" }, "user-1"),
).rejects.toThrow("fleets plugin is disabled");
});
it("reports nothing to change on an empty update", async () => {
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });