propose_skill took no strict schema, so a runbook sent under another key was dropped and the saved one proposed again; the confirm still said "Saved". Now an unknown key is an error, a change that changes nothing is refused, and both the bot and the confirm outcome are told which fields change and whether the runbook is among them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
138 lines
6.1 KiB
TypeScript
138 lines
6.1 KiB
TypeScript
import BunSQLite from "@moduletrace/bun-sqlite";
|
|
import AppError from "@/src/utils/app-error";
|
|
import redactSecrets from "@/src/utils/redact-secrets";
|
|
import grabAllEnvs from "../envs/grab-all-envs";
|
|
import grabCustomTool from "../extensions/grab-custom-tool";
|
|
import grabMcpServer from "../extensions/grab-mcp-server";
|
|
import saveCustomTool from "../extensions/save-custom-tool";
|
|
import saveMcpServer from "../extensions/save-mcp-server";
|
|
import grabSkill from "../extensions/grab-skill";
|
|
import saveSkill from "../extensions/save-skill";
|
|
import syncMcpServer from "../extensions/sync-mcp-server";
|
|
import extensionEnvUses from "@/src/utils/extension-env-uses";
|
|
import skillProposalChanges from "@/src/utils/skill-proposal-changes";
|
|
import checkProposalData from "./check-proposal-data";
|
|
import claimExtensionProposal from "./claim-extension-proposal";
|
|
import grabExtensionProposal from "./grab-extension-proposal";
|
|
import grabProposalEnvs from "./grab-proposal-envs";
|
|
|
|
type Params = {
|
|
user_id: number | string;
|
|
proposal_id: number | string;
|
|
};
|
|
|
|
/**
|
|
* The user confirmed a proposal from its card: claims it (once, and only
|
|
* while it's pending and not expired), checks it again, then saves it
|
|
* with the settings pages' own helpers. A skill is saved as proposed. A server or tool that sends a key
|
|
* that isn't saved yet is saved switched off, and the outcome names the
|
|
* keys to add. A remote server that's on is connected and its tools
|
|
* listed. Whatever happens is recorded on the proposal as its `outcome`.
|
|
*/
|
|
export default async function confirmExtensionProposal({ user_id, proposal_id }: Params) {
|
|
const proposal = await grabExtensionProposal({ proposal_id, user_id });
|
|
|
|
await claimExtensionProposal({ proposal, status: "confirmed" });
|
|
|
|
try {
|
|
if (proposal.kind == "skill") {
|
|
const current = proposal.extension_id ? await grabSkill({ skill_id: proposal.extension_id }) : null;
|
|
const changes = skillProposalChanges({ data: proposal.data, current });
|
|
|
|
const result_id = await saveSkill({
|
|
user_id,
|
|
data: { ...proposal.data, skill_id: proposal.extension_id || undefined },
|
|
});
|
|
const outcome = `Saved as skill #${result_id} (slug ${proposal.data.slug}).${
|
|
current ? describeSavedChanges({ fields: changes.fields }) : ""
|
|
}${!proposal.extension_id && !proposal.data.all_bots ? " No bot gets it yet." : ""}`;
|
|
|
|
await BunSQLite.sql({
|
|
sql: `UPDATE extension_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
|
|
values: [outcome, result_id, Date.now(), proposal.id],
|
|
});
|
|
|
|
return proposal;
|
|
}
|
|
|
|
checkProposalData(proposal);
|
|
|
|
const { sends } = extensionEnvUses(proposal);
|
|
const missing = [
|
|
...new Set(
|
|
(await grabProposalEnvs({ sends })).filter((env) => !env.saved_in.length).map((env) => env.key),
|
|
),
|
|
];
|
|
const keys_note = missing.length
|
|
? ` It's switched off until ${missing.length == 1 ? "this key is" : "these keys are"} added in Settings, under Environment: ${missing.join(", ")}. Then switch it on in Settings, under ${proposal.kind == "mcp" ? "MCPs" : "Tools"}.`
|
|
: "";
|
|
|
|
let outcome = "";
|
|
let result_id = 0;
|
|
|
|
if (proposal.kind == "mcp") {
|
|
if (proposal.extension_id) {
|
|
const current = await grabMcpServer({ mcp_server_id: proposal.extension_id });
|
|
|
|
if (current.transport != "remote") throw new AppError(`${current.name} runs a command here, so it can't be changed from a proposal.`);
|
|
}
|
|
|
|
const data = { ...proposal.data, mcp_server_id: proposal.extension_id || undefined, enabled: proposal.data.enabled && !missing.length };
|
|
|
|
result_id = await saveMcpServer({ user_id, data });
|
|
|
|
const sync = data.enabled ? await syncMcpServer({ mcp_server_id: result_id }) : null;
|
|
|
|
outcome = `Saved as MCP server #${result_id}.${keys_note}${
|
|
!sync
|
|
? missing.length
|
|
? ""
|
|
: " It's off, so it wasn't connected."
|
|
: sync.ok
|
|
? ` It connected and lists ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}.`
|
|
: ` It didn't connect: ${sync.error}`
|
|
}`;
|
|
} else {
|
|
if (proposal.extension_id) await grabCustomTool({ custom_tool_id: proposal.extension_id });
|
|
|
|
const data = { ...proposal.data, custom_tool_id: proposal.extension_id || undefined, enabled: proposal.data.enabled && !missing.length };
|
|
|
|
result_id = await saveCustomTool({ user_id, data });
|
|
outcome = `Saved as tool #${result_id}.${keys_note}`;
|
|
}
|
|
|
|
if (!proposal.extension_id && !proposal.data.all_bots) outcome += ` No bot gets it yet.`;
|
|
|
|
await BunSQLite.sql({
|
|
sql: `UPDATE extension_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
|
|
values: [outcome, result_id, Date.now(), proposal.id],
|
|
});
|
|
} catch (error: unknown) {
|
|
const secrets = (await grabAllEnvs()).filter((env) => env.secret);
|
|
const message = redactSecrets({
|
|
text: error instanceof Error ? error.message : String(error),
|
|
secrets,
|
|
}).slice(0, 2000);
|
|
|
|
await BunSQLite.sql({
|
|
sql: `UPDATE extension_proposals SET status = 'failed', outcome = ?, updated_at = ? WHERE id = ?`,
|
|
values: [`Couldn't save it: ${message}`, Date.now(), proposal.id],
|
|
});
|
|
}
|
|
|
|
return proposal;
|
|
}
|
|
|
|
/**
|
|
* What a confirmed change to a skill changed, so a runbook that stayed the
|
|
* same never reads as saved.
|
|
*/
|
|
function describeSavedChanges({ fields }: { fields: string[] }) {
|
|
if (!fields.length) return " Nothing changed: it was already saved like this.";
|
|
|
|
const named = fields.length == 1 ? fields[0] : `${fields.slice(0, -1).join(", ")} and ${fields[fields.length - 1]}`;
|
|
const runbook = fields.includes("runbook") ? "" : " The runbook is unchanged.";
|
|
|
|
return ` Changed: ${named}.${runbook}`;
|
|
}
|