feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)

This commit is contained in:
LukeGus committed 2026-09-23 08:59:24 -05:00
1 parent 3656caf168
commit c77301577f
16 files changed
+473 -52

No files matched your search

+7 -1
View File
@@ -31,7 +31,8 @@
},
"optionalDependencies": {
"workspaces": "^1.0.0",
"network-topology": "^1.0.0"
"network-topology": "^1.0.0",
"snippets": "^1.0.0"
},
"requires": [
{
@@ -43,6 +44,11 @@
"service": "network-topology.graph",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "snippets.access",
"versionRange": "^1.0.0",
"optional": true
}
],
"providesSecret": [
+116
View File
@@ -51,3 +51,119 @@ export async function getNetworkTopology(
return null;
}
}
export interface SnippetSummary {
id: number;
name: string;
content: string;
description: string | null;
isNote: boolean;
folder: string | null;
}
/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */
interface SnippetsAccess {
list: () => Promise<SnippetSummary[]>;
get: (id: number) => Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null>;
create: (input: {
name: string;
content: string;
description?: string | null;
folder?: string | null;
}) => Promise<{ id: number; name: string }>;
update: (
id: number,
changes: {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
},
) => Promise<void>;
remove: (id: number) => Promise<boolean>;
}
/**
* The user's own snippets, or null when the snippets plugin is off or the
* user may not use it. Optional: the list_snippets tool disappears without
* it, and the propose_*_snippet tools fail with a clear message.
*/
export async function listSnippets(
userId: string,
): Promise<SnippetSummary[] | null> {
if (!current) return null;
try {
return await current
.get<SnippetsAccess>("snippets.access", { userId })
.list();
} catch {
return null;
}
}
export async function getSnippet(
userId: string,
id: number,
): Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null> {
if (!current) return null;
try {
return await current
.get<SnippetsAccess>("snippets.access", { userId })
.get(id);
} catch {
return null;
}
}
/**
* Requires the snippets plugin, unlike the read helpers above: a failed
* proposal apply must surface as an error the user sees, not silently no-op.
*/
function requireSnippetsAccess(userId: string): SnippetsAccess {
if (!current) {
throw new Error("The snippets plugin is disabled");
}
return current.get<SnippetsAccess>("snippets.access", { userId });
}
export async function createSnippet(
userId: string,
input: {
name: string;
content: string;
description?: string | null;
folder?: string | null;
},
): Promise<{ id: number; name: string }> {
return requireSnippetsAccess(userId).create(input);
}
export async function updateSnippet(
userId: string,
id: number,
changes: {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
},
): Promise<void> {
return requireSnippetsAccess(userId).update(id, changes);
}
export async function deleteSnippet(
userId: string,
id: number,
): Promise<boolean> {
return requireSnippetsAccess(userId).remove(id);
}
+16 -24
View File
@@ -2,13 +2,18 @@ import {
createCurrentAutomationRepository,
createCurrentFleetRepository,
createCurrentHostRepository,
createCurrentSnippetRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { withSshConnection } from "../ssh.js";
import { getTool } from "./catalog.js";
import {
createSnippet,
updateSnippet,
deleteSnippet,
getSnippet,
} from "../services.js";
/** Approved commands get a bounded window rather than hanging the request. */
const COMMAND_TIMEOUT_MS = 60_000;
@@ -125,18 +130,15 @@ export async function applyProposal(
}
case "propose_create_snippet": {
const created = await createCurrentSnippetRepository().createSnippet(
userId,
{
name: requireString(payload.name, "name"),
content: requireString(payload.content, "content"),
description: optionalString(payload.description),
folder: optionalString(payload.folder),
} as any,
);
const created = await createSnippet(userId, {
name: requireString(payload.name, "name"),
content: requireString(payload.content, "content"),
description: optionalString(payload.description),
folder: optionalString(payload.folder),
});
return {
ok: true,
summary: `Created snippet ${(created as any)?.name ?? ""}`.trim(),
summary: `Created snippet ${created.name}`.trim(),
};
}
@@ -144,10 +146,7 @@ export async function applyProposal(
const snippetId = requireNumber(payload.snippetId, "snippetId");
const changes = (payload.changes ?? {}) as Record<string, unknown>;
const existing = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
const existing = await getSnippet(userId, snippetId);
if (!existing) throw new Error("Snippet not found");
const updates: Record<string, unknown> = {};
@@ -164,20 +163,13 @@ export async function applyProposal(
return { ok: false, summary: "Nothing to change" };
}
await createCurrentSnippetRepository().updateSnippet(
userId,
snippetId,
updates as any,
);
await updateSnippet(userId, snippetId, updates);
return { ok: true, summary: `Updated snippet ${snippetId}` };
}
case "propose_delete_snippet": {
const snippetId = requireNumber(payload.snippetId, "snippetId");
const deleted = await createCurrentSnippetRepository().deleteSnippet(
userId,
snippetId,
);
const deleted = await deleteSnippet(userId, snippetId);
if (!deleted) throw new Error("Snippet not found");
return { ok: true, summary: `Deleted snippet ${snippetId}` };
}
+9 -7
View File
@@ -5,10 +5,13 @@ import {
createCurrentFleetRepository,
createCurrentHomepageItemRepository,
createCurrentHostRepository,
createCurrentSnippetRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import { num, objectSchema, type AiTool } from "./types.js";
import { listSavedWorkspaces, getNetworkTopology } from "../services.js";
import {
listSavedWorkspaces,
getNetworkTopology,
listSnippets,
} from "../services.js";
/**
* Read tools project explicit fields rather than spreading rows. Redaction runs
@@ -103,15 +106,14 @@ export const readTools: AiTool[] = [
{
name: "list_snippets",
description:
"List the user's saved command snippets, including their folder and the command text.",
"List the user's saved command snippets, including their folder and the command text. Unavailable if the snippets plugin is disabled.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const snippets = await createCurrentSnippetRepository().listOwnedSnippets(
context.userId,
);
const snippets = await listSnippets(context.userId);
if (!snippets) return { unavailable: true, snippets: [] };
return {
snippets: snippets.map((snippet: any) => ({
snippets: snippets.map((snippet) => ({
id: snippet.id,
name: snippet.name,
content: snippet.content,