mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: finish the automations plugin (B17)
This commit is contained in:
1 parent
993a91c899
commit
6b4d1432ab
96 files changed
+25106
-5077
No files matched your search
@@ -26,15 +26,13 @@
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
],
|
||||
"dependencies": {
|
||||
"automations": "^1.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"workspaces": "^1.0.0",
|
||||
"network-topology": "^1.0.0",
|
||||
"snippets": "^1.0.0",
|
||||
"fleets": "^1.0.0",
|
||||
"ssh-terminal": "^1.0.0"
|
||||
"ssh-terminal": "^1.0.0",
|
||||
"automations": "^1.0.0"
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
@@ -61,6 +59,11 @@
|
||||
"service": "terminal.history",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"service": "automations.access",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"providesSecret": [
|
||||
|
||||
@@ -248,3 +248,84 @@ export async function listCommandHistory(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The automations plugin, as ctx.services.get("automations.access") returns it. */
|
||||
interface AutomationsAccess {
|
||||
list: () => Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
triggerKind: string | null;
|
||||
lastRunAt: string | null;
|
||||
lastRunStatus: string | null;
|
||||
missingPlugins: string[];
|
||||
}>
|
||||
>;
|
||||
get: (id: number) => Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
definition: unknown;
|
||||
} | null>;
|
||||
create: (input: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
definition: unknown;
|
||||
enabled?: boolean;
|
||||
}) => Promise<{ id: number; name: string }>;
|
||||
}
|
||||
|
||||
function automations(userId: string): Partial<AutomationsAccess> {
|
||||
if (!current) return {};
|
||||
try {
|
||||
return current.get<AutomationsAccess>("automations.access", { userId });
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's automations, or null when the automations plugin is off or the
|
||||
* user may not use it.
|
||||
*/
|
||||
export async function listAutomations(
|
||||
userId: string,
|
||||
): Promise<Awaited<ReturnType<AutomationsAccess["list"]>> | null> {
|
||||
const list = automations(userId).list;
|
||||
if (typeof list !== "function") return null;
|
||||
try {
|
||||
return await list();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAutomation(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<Awaited<ReturnType<AutomationsAccess["get"]>> | undefined> {
|
||||
const get = automations(userId).get;
|
||||
if (typeof get !== "function") return undefined;
|
||||
try {
|
||||
return await get(id);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an automation through the plugin, which validates it exactly as
|
||||
* its own route does. Throws when the plugin is off.
|
||||
*/
|
||||
export async function createAutomation(
|
||||
userId: string,
|
||||
input: Parameters<AutomationsAccess["create"]>[0],
|
||||
): Promise<{ id: number; name: string }> {
|
||||
const create = automations(userId).create;
|
||||
if (typeof create !== "function") {
|
||||
throw new Error("The automations plugin is not available");
|
||||
}
|
||||
return create(input);
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentHostRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
||||
import { createCurrentHostRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import { withSshConnection } from "../ssh.js";
|
||||
@@ -14,6 +10,7 @@ import {
|
||||
getSnippet,
|
||||
createFleet,
|
||||
addFleetMember,
|
||||
createAutomation,
|
||||
} from "../services.js";
|
||||
|
||||
/** Approved commands get a bounded window rather than hanging the request. */
|
||||
@@ -204,20 +201,12 @@ export async function applyProposal(
|
||||
}
|
||||
|
||||
case "propose_create_automation": {
|
||||
// Reuses the same validator the automations route runs, so an
|
||||
// LLM-authored definition is held to exactly the human standard.
|
||||
const validation = validateDefinition(payload.definition);
|
||||
if (!validation.ok || !validation.definition) {
|
||||
throw new Error(
|
||||
validation.error ?? "The automation definition is invalid",
|
||||
);
|
||||
}
|
||||
|
||||
const created = await createCurrentAutomationRepository().create({
|
||||
userId,
|
||||
// The automations plugin runs the same validator its own route does,
|
||||
// so an LLM-authored definition is held to exactly the human standard.
|
||||
const created = await createAutomation(userId, {
|
||||
name: requireString(payload.name, "name"),
|
||||
description: optionalString(payload.description),
|
||||
definition: JSON.stringify(validation.definition),
|
||||
definition: payload.definition,
|
||||
// Starts disabled: an automation the user has not watched run once
|
||||
// should not begin firing against their servers on approval.
|
||||
enabled: false,
|
||||
@@ -225,7 +214,7 @@ export async function applyProposal(
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created automation ${(created as any).name} (disabled until you enable it)`,
|
||||
summary: `Created automation ${created.name} (disabled until you enable it)`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentHostRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
@@ -11,6 +10,8 @@ import {
|
||||
listSnippets,
|
||||
listFleets,
|
||||
listCommandHistory,
|
||||
listAutomations,
|
||||
getAutomation,
|
||||
} from "../services.js";
|
||||
|
||||
/**
|
||||
@@ -128,17 +129,18 @@ export const readTools: AiTool[] = [
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const automations = await createCurrentAutomationRepository().list(
|
||||
context.userId,
|
||||
);
|
||||
const automations = await listAutomations(context.userId);
|
||||
if (!automations) return { automations: [], unavailable: true };
|
||||
return {
|
||||
automations: automations.map((automation: any) => ({
|
||||
automations: automations.map((automation) => ({
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
description: automation.description ?? null,
|
||||
description: automation.description,
|
||||
enabled: automation.enabled,
|
||||
lastRunAt: automation.lastRunAt ?? null,
|
||||
lastRunStatus: automation.lastRunStatus ?? null,
|
||||
triggerKind: automation.triggerKind,
|
||||
lastRunAt: automation.lastRunAt,
|
||||
lastRunStatus: automation.lastRunStatus,
|
||||
needsPlugins: automation.missingPlugins,
|
||||
})),
|
||||
};
|
||||
},
|
||||
@@ -155,17 +157,18 @@ export const readTools: AiTool[] = [
|
||||
["automationId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const automation = await createCurrentAutomationRepository().findForUser(
|
||||
Number(args.automationId),
|
||||
const automation = await getAutomation(
|
||||
context.userId,
|
||||
Number(args.automationId),
|
||||
);
|
||||
if (automation === undefined) return { unavailable: true };
|
||||
if (!automation) return { error: "Automation not found" };
|
||||
return {
|
||||
automation: {
|
||||
id: (automation as any).id,
|
||||
name: (automation as any).name,
|
||||
enabled: (automation as any).enabled,
|
||||
definition: (automation as any).definition,
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
enabled: automation.enabled,
|
||||
definition: automation.definition,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -12,13 +12,12 @@ const snippetRepository = {
|
||||
updateSnippet: vi.fn(),
|
||||
deleteSnippet: vi.fn(),
|
||||
};
|
||||
const automationRepository = { create: vi.fn() };
|
||||
const automationsAccess = { create: vi.fn() };
|
||||
const fleetsAccess = { create: vi.fn(), addMember: vi.fn() };
|
||||
|
||||
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => hostRepository,
|
||||
createCurrentSnippetRepository: () => snippetRepository,
|
||||
createCurrentAutomationRepository: () => automationRepository,
|
||||
}));
|
||||
|
||||
const resolveHostById = vi.fn();
|
||||
@@ -57,9 +56,24 @@ describe("applyProposal", () => {
|
||||
).rejects.toThrow("Unknown proposal kind");
|
||||
});
|
||||
|
||||
it("validates an automation definition before creating it", async () => {
|
||||
// Reuses the automations route's own validator, so a definition the model
|
||||
function withAutomations() {
|
||||
setPluginServices({
|
||||
get: (service: string) => {
|
||||
if (service !== "automations.access")
|
||||
throw new Error(`unexpected ${service}`);
|
||||
return automationsAccess as never;
|
||||
},
|
||||
provide: vi.fn(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("hands an automation to the automations plugin, which validates it", async () => {
|
||||
// The plugin runs its own route's validator, so a definition the model
|
||||
// invented is held to the same standard as a hand-written one.
|
||||
withAutomations();
|
||||
automationsAccess.create.mockRejectedValue(
|
||||
new Error("Unknown or missing trigger kind"),
|
||||
);
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_create_automation",
|
||||
@@ -69,12 +83,22 @@ describe("applyProposal", () => {
|
||||
},
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(automationRepository.create).not.toHaveBeenCalled();
|
||||
).rejects.toThrow("Unknown or missing trigger kind");
|
||||
});
|
||||
|
||||
it("refuses an automation while the automations plugin is off", async () => {
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_create_automation",
|
||||
{ name: "x", definition: {} },
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow("The automations plugin is not available");
|
||||
});
|
||||
|
||||
it("creates a valid automation disabled so it cannot fire unwatched", async () => {
|
||||
automationRepository.create.mockResolvedValue({ id: 5, name: "nightly" });
|
||||
withAutomations();
|
||||
automationsAccess.create.mockResolvedValue({ id: 5, name: "nightly" });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_create_automation",
|
||||
@@ -89,8 +113,8 @@ describe("applyProposal", () => {
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(automationRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", enabled: false }),
|
||||
expect(automationsAccess.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "nightly", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user