Let the coordinator manage skills, MCP servers and tools, behind a confirm card
- Coordinator tools in its own chat (super admin only): list_extensions,
set_extension_access, delete_extension, and update_skill /
update_mcp_server / update_custom_tool for what can't change where
anything is sent (on/off, names, arguments, left-out tools)
- Adding a skill, MCP server or tool, or changing a skill's words, a
server's URL or headers or a tool's kind or config, goes through
propose_skill / propose_mcp_server / propose_custom_tool: stored in
extension_proposals with the bot's background check, shown as a
[proposal:ID] chip that opens a card with Confirm and Reject
- The card's facts are worked out by the app from what Confirm saves: the
host, every {{env.KEY}} and whether it's saved, the config; for a skill
its links, hosts, tool names, mentions of secrets and a diff against
the saved runbook
- Confirming decides once and checks again; a server or tool missing a key
is saved switched off, and the outcome is posted to the bot in chat
- Never from a bot: stdio servers, a URL whose host a template decides,
envs in a prompt tool, a key's value
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import type { SaveCustomToolRequest, SaveMcpServerRequest } from "@/src/schemas/extension-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import hasEnvTemplate from "@/src/utils/has-env-template";
|
||||
import literalUrlHost from "@/src/utils/literal-url-host";
|
||||
|
||||
type Params =
|
||||
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||
| { kind: "tool"; data: SaveCustomToolRequest };
|
||||
|
||||
/**
|
||||
* Throws for what a bot may never propose, checked when it proposes and
|
||||
* again on confirm: a stdio server (a command in the app's container), a
|
||||
* URL whose host a template decides (where a key goes must be readable,
|
||||
* and a bot must not pick it per call), and envs in a prompt tool (the
|
||||
* model's answer would carry the secret back past redaction).
|
||||
*/
|
||||
export default function checkProposalData(params: Params) {
|
||||
if (params.kind == "mcp") {
|
||||
if (params.data.transport != "remote") {
|
||||
throw new AppError(`Only remote MCP servers can be proposed. A stdio one is added by the user in Settings, under MCPs.`);
|
||||
}
|
||||
|
||||
if (!literalUrlHost({ url: params.data.url })) {
|
||||
throw new AppError(`The URL's host has to be written out (https://host/...), with no template in it.`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = params.data;
|
||||
|
||||
if (tool.kind == "http" && !literalUrlHost({ url: tool.config.url })) {
|
||||
throw new AppError(`The URL's host has to be written out (https://host/...), with no template in it.`);
|
||||
}
|
||||
|
||||
if (tool.kind == "prompt" && hasEnvTemplate({ values: [tool.config.prompt] })) {
|
||||
throw new AppError(`A prompt tool can't read envs: the model's answer would carry the value back.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import type { ExtensionProposalRecord } from "./parse-proposal-row";
|
||||
|
||||
type Params = {
|
||||
proposal: ExtensionProposalRecord;
|
||||
status: "confirmed" | "rejected";
|
||||
};
|
||||
|
||||
/**
|
||||
* Decides a proposal exactly once: only a pending one moves, and only a
|
||||
* proposal that hasn't expired can be confirmed, so two tabs or a double
|
||||
* click never save it twice. Throws why when it can't.
|
||||
*/
|
||||
export default async function claimExtensionProposal({ proposal, status }: Params) {
|
||||
const now = Date.now();
|
||||
const claimed = await BunSQLite.sql<{ id: number }>({
|
||||
sql: `UPDATE extension_proposals SET status = ?, decided_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'pending' ${status == "confirmed" ? "AND expires_at > ?" : ""} RETURNING id`,
|
||||
values: [status, now, now, proposal.id, ...(status == "confirmed" ? [now] : [])],
|
||||
});
|
||||
|
||||
if (claimed.payload?.[0]) return;
|
||||
|
||||
if (proposal.status != "pending") throw new AppError(`This proposal was already ${proposal.status}.`);
|
||||
|
||||
throw new AppError(`This proposal expired. Ask the bot to propose it again.`);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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 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") {
|
||||
if (proposal.extension_id) await grabSkill({ skill_id: proposal.extension_id });
|
||||
|
||||
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}).${
|
||||
!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;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS, BunSQLiteTables } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { SaveCustomToolRequest, SaveMcpServerRequest, SaveSkillRequest } from "@/src/schemas/extension-schema";
|
||||
import checkProposalData from "./check-proposal-data";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
session_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
research: string;
|
||||
sources: string[];
|
||||
} & (
|
||||
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||
| { kind: "tool"; data: SaveCustomToolRequest }
|
||||
| { kind: "skill"; data: SaveSkillRequest }
|
||||
);
|
||||
|
||||
/**
|
||||
* Stores what a bot proposes adding or changing (an MCP server, a tool or
|
||||
* a skill), pending until the user
|
||||
* confirms it from the chat. Nothing is saved, connected or run here. A
|
||||
* proposal it makes again for the same server or tool in the same
|
||||
* conversation replaces the one before, so only the latest card can be
|
||||
* confirmed. Returns its id.
|
||||
*/
|
||||
export default async function createExtensionProposal(params: Params) {
|
||||
if (params.kind != "skill") checkProposalData(params);
|
||||
|
||||
const extension_id =
|
||||
(params.kind == "mcp"
|
||||
? params.data.mcp_server_id
|
||||
: params.kind == "skill"
|
||||
? params.data.skill_id
|
||||
: params.data.custom_tool_id) || undefined;
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS, (typeof BunSQLiteTables)[number]>({
|
||||
table: "extension_proposals",
|
||||
data: [
|
||||
{
|
||||
kind: params.kind,
|
||||
extension_id,
|
||||
name: params.data.name,
|
||||
data: JSON.stringify(params.data),
|
||||
research: params.research,
|
||||
sources: JSON.stringify(params.sources),
|
||||
status: "pending",
|
||||
outcome: "",
|
||||
expires_at: now + AppData.ExtensionProposalTtlMs,
|
||||
user_id: Number(params.user_id),
|
||||
bot_id: Number(params.bot_id),
|
||||
session_id: Number(params.session_id),
|
||||
workspace_id: params.workspace_id ? Number(params.workspace_id) : undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const id = Number(res.singleRes?.id || res.postInsertReturn?.insertId);
|
||||
|
||||
if (!res.success || !id) throw new Error(`Couldn't store the proposal: ${res.msg}`);
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE extension_proposals SET status = 'replaced', outcome = ?, decided_at = ?, updated_at = ?
|
||||
WHERE session_id = ? AND kind = ? AND status = 'pending' AND id != ?
|
||||
AND (${extension_id ? "extension_id = ?" : "extension_id IS NULL AND name = ? COLLATE NOCASE"})`,
|
||||
values: [
|
||||
`Replaced by proposal #${id}.`,
|
||||
now,
|
||||
now,
|
||||
Number(params.session_id),
|
||||
params.kind,
|
||||
id,
|
||||
extension_id || params.data.name,
|
||||
],
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
import describeProposalDetails from "@/src/utils/describe-proposal-details";
|
||||
import describeSkillProposal from "@/src/utils/describe-skill-proposal";
|
||||
import grabSkill from "../extensions/grab-skill";
|
||||
import extensionEnvUses from "@/src/utils/extension-env-uses";
|
||||
import grabExtensionProposal from "./grab-extension-proposal";
|
||||
import grabProposalEnvs from "./grab-proposal-envs";
|
||||
import type { ExtensionProposalRecord } from "./parse-proposal-row";
|
||||
|
||||
type Params = {
|
||||
proposal_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A proposal as its chip and confirm card show it: the facts (host, envs
|
||||
* and whether each is saved, every detail of the config; for a skill what
|
||||
* its runbook points at and what changed) worked out from the config now,
|
||||
* so a key added since shows as saved; the bot's research beside them,
|
||||
* labelled as its own.
|
||||
*/
|
||||
export default async function grabExtensionProposalView({ proposal_id, user_id }: Params): Promise<ExtensionProposalView> {
|
||||
const proposal = await grabExtensionProposal({ proposal_id, user_id });
|
||||
const facts = await grabProposalFacts({ proposal });
|
||||
|
||||
return {
|
||||
id: proposal.id,
|
||||
kind: proposal.kind,
|
||||
extension_id: proposal.extension_id,
|
||||
name: proposal.name,
|
||||
status: proposal.status,
|
||||
expired: proposal.status == "pending" && proposal.expires_at < Date.now(),
|
||||
expires_at: proposal.expires_at,
|
||||
outcome: proposal.outcome,
|
||||
result_id: proposal.result_id,
|
||||
...facts,
|
||||
research: proposal.research,
|
||||
sources: proposal.sources,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The facts a proposal's card shows, worked out now: for a server or tool
|
||||
* its host, its envs and whether each is saved, and its config; for a
|
||||
* skill what its runbook points bots at and what changed against the
|
||||
* saved one (a deleted skill shows as new).
|
||||
*/
|
||||
async function grabProposalFacts({ proposal }: { proposal: ExtensionProposalRecord }) {
|
||||
if (proposal.kind == "skill") {
|
||||
const current = proposal.extension_id
|
||||
? await grabSkill({ skill_id: proposal.extension_id }).catch(() => null)
|
||||
: null;
|
||||
|
||||
return { host: null, envs: [], details: describeSkillProposal({ data: proposal.data, current }) };
|
||||
}
|
||||
|
||||
const typed =
|
||||
proposal.kind == "mcp"
|
||||
? ({ kind: "mcp", data: proposal.data } as const)
|
||||
: ({ kind: "tool", data: proposal.data } as const);
|
||||
const { host, sends } = extensionEnvUses(typed);
|
||||
|
||||
return {
|
||||
host,
|
||||
envs: await grabProposalEnvs({ sends }),
|
||||
details: describeProposalDetails({ ...typed, is_new: !proposal.extension_id }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS } from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseProposalRow from "./parse-proposal-row";
|
||||
|
||||
type Params = {
|
||||
proposal_id: number | string;
|
||||
/**
|
||||
* The super admin it was proposed to: nobody else reads it.
|
||||
*/
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One proposal made to this user, or an `AppError`.
|
||||
*/
|
||||
export default async function grabExtensionProposal({ proposal_id, user_id }: Params) {
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS>({
|
||||
sql: `SELECT * FROM extension_proposals WHERE id = ? AND user_id = ?`,
|
||||
values: [proposal_id, user_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!row) throw new AppError(`That proposal doesn't exist.`);
|
||||
|
||||
return parseProposalRow({ row });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { EnvDict } from "@/src/dict/env-dict";
|
||||
import type { ExtensionProposalEnv } from "@/src/types";
|
||||
import type { ExtensionEnvUse } from "@/src/utils/extension-env-uses";
|
||||
import grabAllEnvs from "../envs/grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
sends: ExtensionEnvUse[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Each env a proposed server or tool sends, with where it's saved: the
|
||||
* scopes templates read (`grabRunEnvs`: general, a bot's own, a
|
||||
* workspace's), named. App settings never fill a template, so one of
|
||||
* those keys reads as missing.
|
||||
*/
|
||||
export default async function grabProposalEnvs({ sends }: Params): Promise<ExtensionProposalEnv[]> {
|
||||
const app_keys = new Set(EnvDict.map((env) => env.key));
|
||||
const envs = (await grabAllEnvs()).filter(
|
||||
(env) => !app_keys.has(env.key) && ["general", "bot", "workspace"].includes(env.paradigm),
|
||||
);
|
||||
|
||||
const bot_ids = [...new Set(envs.map((env) => env.bot_id).filter(Boolean))];
|
||||
const workspace_ids = [...new Set(envs.map((env) => env.workspace_id).filter(Boolean))];
|
||||
const [bots, workspaces] = await Promise.all([
|
||||
names({ table: "bots", ids: bot_ids as number[] }),
|
||||
names({ table: "workspaces", ids: workspace_ids as number[] }),
|
||||
]);
|
||||
|
||||
return sends.map((use) => ({
|
||||
...use,
|
||||
saved_in: envs
|
||||
.filter((env) => env.key == use.key)
|
||||
.map((env) =>
|
||||
env.paradigm == "general"
|
||||
? "general"
|
||||
: env.paradigm == "bot"
|
||||
? `bot ${bots.get(Number(env.bot_id)) || env.bot_id} only`
|
||||
: `workspace ${workspaces.get(Number(env.workspace_id)) || env.workspace_id} only`,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
async function names({ table, ids }: { table: "bots" | "workspaces"; ids: number[] }) {
|
||||
if (!ids.length) return new Map<number, string>();
|
||||
|
||||
const rows = await BunSQLite.sql<{ id: number; name: string }>({
|
||||
sql: `SELECT id, name FROM ${table} WHERE id IN (${ids.map(() => "?").join(", ")})`,
|
||||
values: ids,
|
||||
});
|
||||
|
||||
return new Map((rows.payload || []).map((row) => [Number(row.id), row.name || ""]));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS } from "@/db/types/db";
|
||||
import type { ExtensionProposalKind, ExtensionProposalStatus } from "@/src/dict/extension-proposal-dict";
|
||||
import {
|
||||
SaveCustomToolRequestSchema,
|
||||
SaveMcpServerRequestSchema,
|
||||
SaveSkillRequestSchema,
|
||||
type SaveCustomToolRequest,
|
||||
type SaveMcpServerRequest,
|
||||
type SaveSkillRequest,
|
||||
} from "@/src/schemas/extension-schema";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import parseJsonColumn from "../extensions/parse-json-column";
|
||||
|
||||
type Params = {
|
||||
row: BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS;
|
||||
};
|
||||
|
||||
export type ExtensionProposalRecord = {
|
||||
id: number;
|
||||
extension_id: number | null;
|
||||
name: string;
|
||||
research: string;
|
||||
sources: string[];
|
||||
status: ExtensionProposalStatus;
|
||||
outcome: string;
|
||||
result_id: number | null;
|
||||
expires_at: number;
|
||||
user_id: number;
|
||||
bot_id: number;
|
||||
session_id: number;
|
||||
} & (
|
||||
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||
| { kind: "tool"; data: SaveCustomToolRequest }
|
||||
| { kind: "skill"; data: SaveSkillRequest }
|
||||
);
|
||||
|
||||
/**
|
||||
* An `extension_proposals` row with its config read and checked again by
|
||||
* the settings forms' schemas, so what confirm saves is what they'd allow.
|
||||
*/
|
||||
export default function parseProposalRow({ row }: Params): ExtensionProposalRecord {
|
||||
const kind = (row.kind || "mcp") as ExtensionProposalKind;
|
||||
const raw = parseJsonColumn<Record<string, unknown>>({ text: row.data, fallback: {} });
|
||||
const base = {
|
||||
id: Number(row.id),
|
||||
extension_id: row.extension_id ? Number(row.extension_id) : null,
|
||||
name: row.name || "",
|
||||
research: row.research || "",
|
||||
sources: parseJsonColumn<string[]>({ text: row.sources, fallback: [] }),
|
||||
status: (row.status || "pending") as ExtensionProposalStatus,
|
||||
outcome: row.outcome || "",
|
||||
result_id: row.result_id ? Number(row.result_id) : null,
|
||||
expires_at: Number(row.expires_at) || 0,
|
||||
user_id: Number(row.user_id),
|
||||
bot_id: Number(row.bot_id),
|
||||
session_id: Number(row.session_id),
|
||||
};
|
||||
|
||||
if (kind == "mcp") return { ...base, kind, data: parseWithSchema({ schema: SaveMcpServerRequestSchema, data: raw }) };
|
||||
|
||||
if (kind == "skill") return { ...base, kind, data: parseWithSchema({ schema: SaveSkillRequestSchema, data: raw }) };
|
||||
|
||||
return { ...base, kind: "tool", data: parseWithSchema({ schema: SaveCustomToolRequestSchema, data: raw }) };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import claimExtensionProposal from "./claim-extension-proposal";
|
||||
import grabExtensionProposal from "./grab-extension-proposal";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
proposal_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The user rejected a proposal from its card: nothing is saved.
|
||||
*/
|
||||
export default async function rejectExtensionProposal({ user_id, proposal_id }: Params) {
|
||||
const proposal = await grabExtensionProposal({ proposal_id, user_id });
|
||||
|
||||
await claimExtensionProposal({ proposal, status: "rejected" });
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE extension_proposals SET outcome = 'Rejected. Nothing was saved.', updated_at = ? WHERE id = ?`,
|
||||
values: [Date.now(), proposal.id],
|
||||
});
|
||||
|
||||
return proposal;
|
||||
}
|
||||
Reference in New Issue
Block a user