2 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 2259b6895f Release v0.3.2
Release image / image (release) Waiting to run
CI / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:07:44 +01:00
tbenandClaude Opus 5.5 2df1bf83f4 Show when each extension was last updated
MCP, skill and tool rows in Settings say "Updated 5m ago" beside how
they're reached and who gets them. updated_at now means a change to an
extension's settings (an edit, or any Access save): runs, connections and
MCP syncs keep their own last_checked_at / last_synced_at and no longer
touch it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:07:44 +01:00
19 changed files with 63 additions and 17 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cobble", "name": "cobble",
"version": "0.3.1", "version": "0.3.2",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -37,6 +37,7 @@ export default async function grabToolsOverview({ user_id }: Params): Promise<Cu
status: tool.status, status: tool.status,
last_error: tool.last_error, last_error: tool.last_error,
last_checked_at: tool.last_checked_at, last_checked_at: tool.last_checked_at,
updated_at: tool.updated_at,
access: { all_bots: tool.all_bots, ...access(tool.id) }, access: { all_bots: tool.all_bots, ...access(tool.id) },
...(targets.can_edit ? { parameters: tool.parameters, config: tool.config } : {}), ...(targets.can_edit ? { parameters: tool.parameters, config: tool.config } : {}),
}; };
@@ -24,6 +24,7 @@ export default function parseCustomToolRow({ row }: Params): CustomToolRecord {
status: (row.status || "untested") as CustomToolStatus, status: (row.status || "untested") as CustomToolStatus,
last_error: row.last_error || "", last_error: row.last_error || "",
last_checked_at: typeof row.last_checked_at == "number" ? row.last_checked_at : null, last_checked_at: typeof row.last_checked_at == "number" ? row.last_checked_at : null,
updated_at: typeof row.updated_at == "number" ? row.updated_at : null,
kind: row.kind || "prompt", kind: row.kind || "prompt",
config: parseJsonColumn<Record<string, unknown>>({ text: row.config, fallback: {} }), config: parseJsonColumn<Record<string, unknown>>({ text: row.config, fallback: {} }),
} as CustomToolRecord; } as CustomToolRecord;
@@ -29,5 +29,6 @@ export default function parseMcpServerRow({ row }: Params): McpServerRecord {
status: (row.status || "untested") as McpServerStatus, status: (row.status || "untested") as McpServerStatus,
last_error: row.last_error || "", last_error: row.last_error || "",
last_synced_at: typeof row.last_synced_at == "number" ? row.last_synced_at : null, last_synced_at: typeof row.last_synced_at == "number" ? row.last_synced_at : null,
updated_at: typeof row.updated_at == "number" ? row.updated_at : null,
}; };
} }
@@ -17,5 +17,6 @@ export default function parseSkillRow({ row }: Params): SkillRecord {
body: row.body || "", body: row.body || "",
all_bots: row.all_bots == 1, all_bots: row.all_bots == 1,
enabled: row.enabled != 0, enabled: row.enabled != 0,
updated_at: typeof row.updated_at == "number" ? row.updated_at : null,
}; };
} }
@@ -11,13 +11,12 @@ type Params = {
/** /**
* Saves how a custom tool's run went (`status`, `last_error`, * Saves how a custom tool's run went (`status`, `last_error`,
* `last_checked_at`), for its row in Settings, under Tools. Never throws: * `last_checked_at`), for its row in Settings, under Tools. Never throws:
* a run doesn't fail over its bookkeeping. * a run doesn't fail over its bookkeeping. `updated_at` is left alone: it
* says when the tool's settings last changed.
*/ */
export default async function recordCustomToolRun({ custom_tool_id, error }: Params) { export default async function recordCustomToolRun({ custom_tool_id, error }: Params) {
const now = Date.now();
await BunSQLite.sql({ await BunSQLite.sql({
sql: `UPDATE custom_tools SET status = ?, last_error = ?, last_checked_at = ?, updated_at = ? WHERE id = ?`, sql: `UPDATE custom_tools SET status = ?, last_error = ?, last_checked_at = ? WHERE id = ?`,
values: [error ? "error" : "ok", (error || "").slice(0, 4000), now, now, custom_tool_id], values: [error ? "error" : "ok", (error || "").slice(0, 4000), Date.now(), custom_tool_id],
}).catch(() => {}); }).catch(() => {});
} }
@@ -11,13 +11,12 @@ type Params = {
/** /**
* Saves whether a bot's run could connect to an MCP server, so its row in * Saves whether a bot's run could connect to an MCP server, so its row in
* Settings, under MCPs, shows a server that stopped answering (or came * Settings, under MCPs, shows a server that stopped answering (or came
* back). Its tools listing is left as it was. Never throws. * back). Its tools listing and `updated_at` (when its settings last
* changed) are left as they were. Never throws.
*/ */
export default async function recordMcpServerConnection({ mcp_server_id, error }: Params) { export default async function recordMcpServerConnection({ mcp_server_id, error }: Params) {
const now = Date.now();
await BunSQLite.sql({ await BunSQLite.sql({
sql: `UPDATE mcp_servers SET status = ?, last_error = ?, last_synced_at = ?, updated_at = ? WHERE id = ?`, sql: `UPDATE mcp_servers SET status = ?, last_error = ?, last_synced_at = ? WHERE id = ?`,
values: [error ? "error" : "ok", (error || "").slice(0, 4000), now, now, mcp_server_id], values: [error ? "error" : "ok", (error || "").slice(0, 4000), Date.now(), mcp_server_id],
}).catch(() => {}); }).catch(() => {});
} }
@@ -21,7 +21,8 @@ type Params = {
* super admins and admins. An admin never sees the coordinator, so its * super admins and admins. An admin never sees the coordinator, so its
* attachment is kept as it is; only a super admin sets "every bot", which * attachment is kept as it is; only a super admin sets "every bot", which
* when turned on gives it to every bot (`attachToAllBots`) and every new * when turned on gives it to every bot (`attachToAllBots`) and every new
* one. Turning it off takes it from no one. * one. Turning it off takes it from no one. Either way it counts as a
* change to the extension (`updated_at`).
*/ */
export default async function setExtensionAccess({ user_id, kind, id, bot_ids, workspace_ids, all_bots }: Params) { export default async function setExtensionAccess({ user_id, kind, id, bot_ids, workspace_ids, all_bots }: Params) {
const role = await assertCrewManager({ user_id }); const role = await assertCrewManager({ user_id });
@@ -63,5 +64,7 @@ export default async function setExtensionAccess({ user_id, kind, id, bot_ids, w
}); });
if (all_bots && found.payload?.[0]?.all_bots != 1) await attachToAllBots({ kind, id }); if (all_bots && found.payload?.[0]?.all_bots != 1) await attachToAllBots({ kind, id });
} else {
await BunSQLite.sql({ sql: `UPDATE ${table} SET updated_at = ? WHERE id = ?`, values: [now, id] });
} }
} }
@@ -37,8 +37,8 @@ export default async function syncMcpServer({ mcp_server_id, reinstall }: Params
const tools = await listMcpTools({ client: await open.client() }); const tools = await listMcpTools({ client: await open.client() });
await BunSQLite.sql({ await BunSQLite.sql({
sql: `UPDATE mcp_servers SET tools_cache = ?, status = 'ok', last_error = '', last_synced_at = ?, updated_at = ? WHERE id = ?`, sql: `UPDATE mcp_servers SET tools_cache = ?, status = 'ok', last_error = '', last_synced_at = ? WHERE id = ?`,
values: [JSON.stringify(tools), now, now, server.id], values: [JSON.stringify(tools), now, server.id],
}); });
return { ok: true as const, tools }; return { ok: true as const, tools };
@@ -49,8 +49,8 @@ export default async function syncMcpServer({ mcp_server_id, reinstall }: Params
}).slice(0, 4000); }).slice(0, 4000);
await BunSQLite.sql({ await BunSQLite.sql({
sql: `UPDATE mcp_servers SET status = 'error', last_error = ?, last_synced_at = ?, updated_at = ? WHERE id = ?`, sql: `UPDATE mcp_servers SET status = 'error', last_error = ?, last_synced_at = ? WHERE id = ?`,
values: [message, now, now, server.id], values: [message, now, server.id],
}); });
return { ok: false as const, error: message }; return { ok: false as const, error: message };
@@ -39,6 +39,7 @@ export default async function testMcpDraft({
status: "untested", status: "untested",
last_error: "", last_error: "",
last_synced_at: null, last_synced_at: null,
updated_at: null,
}, },
envs, envs,
}); });
@@ -12,6 +12,7 @@ const weather: CustomToolRecord = {
status: "ok", status: "ok",
last_error: "", last_error: "",
last_checked_at: null, last_checked_at: null,
updated_at: null,
kind: "http", kind: "http",
config: { config: {
method: "GET", method: "GET",
@@ -12,6 +12,7 @@ function skill(overrides: Partial<SkillRecord> & { slug: string }): SkillRecord
body: `${overrides.slug} body`, body: `${overrides.slug} body`,
all_bots: false, all_bots: false,
enabled: true, enabled: true,
updated_at: null,
...overrides, ...overrides,
}; };
} }
@@ -0,0 +1,13 @@
import checkedAgo from "./checked-ago";
type Params = {
at: number | null;
};
/**
* When an extension's settings last changed, for a row's subtitle:
* "Updated 5m ago". Nothing when it's never been stamped.
*/
export default function updatedAgo({ at }: Params) {
return at ? `Updated ${checkedAgo({ at })}` : undefined;
}
@@ -11,6 +11,7 @@ import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action"; import SettingsRowAction from "../../(partials)/settings-row-action";
import checkedAgo from "../../(utils)/checked-ago"; import checkedAgo from "../../(utils)/checked-ago";
import extensionAccessLabel from "../../(utils)/extension-access-label"; import extensionAccessLabel from "../../(utils)/extension-access-label";
import updatedAgo from "../../(utils)/updated-ago";
import McpServerModal from "./mcp-server-modal"; import McpServerModal from "./mcp-server-modal";
type Props = { type Props = {
@@ -63,6 +64,7 @@ export default function McpServerRow({ server, targets, onChanged, onError }: Pr
subtitle={[ subtitle={[
McpTransports.find((t) => t.value == server.transport)?.title, McpTransports.find((t) => t.value == server.transport)?.title,
extensionAccessLabel({ access: server.access }), extensionAccessLabel({ access: server.access }),
updatedAgo({ at: server.updated_at }),
] ]
.filter(Boolean) .filter(Boolean)
.join(" · ")} .join(" · ")}
@@ -8,6 +8,7 @@ import ExtensionAccessModal from "../../(partials)/extension-access-modal";
import SettingsListRow from "../../(partials)/settings-list-row"; import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action"; import SettingsRowAction from "../../(partials)/settings-row-action";
import extensionAccessLabel from "../../(utils)/extension-access-label"; import extensionAccessLabel from "../../(utils)/extension-access-label";
import updatedAgo from "../../(utils)/updated-ago";
import SkillModal from "./skill-modal"; import SkillModal from "./skill-modal";
type Props = { type Props = {
@@ -51,7 +52,12 @@ export default function SkillRow({ skill, targets, limits, onChanged, onError }:
avatar={<IconTile Icon={BookOpen} />} avatar={<IconTile Icon={BookOpen} />}
title={skill.name} title={skill.name}
badge={skill.enabled ? undefined : "Off"} badge={skill.enabled ? undefined : "Off"}
subtitle={[skill.slug, extensionAccessLabel({ access: skill.access }), skill.description] subtitle={[
skill.slug,
extensionAccessLabel({ access: skill.access }),
updatedAgo({ at: skill.updated_at }),
skill.description,
]
.filter(Boolean) .filter(Boolean)
.join(" · ")} .join(" · ")}
> >
@@ -11,6 +11,7 @@ import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action"; import SettingsRowAction from "../../(partials)/settings-row-action";
import checkedAgo from "../../(utils)/checked-ago"; import checkedAgo from "../../(utils)/checked-ago";
import extensionAccessLabel from "../../(utils)/extension-access-label"; import extensionAccessLabel from "../../(utils)/extension-access-label";
import updatedAgo from "../../(utils)/updated-ago";
import CustomToolModal from "./custom-tool-modal"; import CustomToolModal from "./custom-tool-modal";
import CustomToolTryModal from "./custom-tool-try-modal"; import CustomToolTryModal from "./custom-tool-try-modal";
@@ -72,6 +73,7 @@ export default function CustomToolRow({ tool, targets, onChanged, onRefreshed, o
subtitle={[ subtitle={[
CustomToolKinds.find((k) => k.value == tool.kind)?.title, CustomToolKinds.find((k) => k.value == tool.kind)?.title,
extensionAccessLabel({ access: tool.access }), extensionAccessLabel({ access: tool.access }),
updatedAgo({ at: tool.updated_at }),
tool.description, tool.description,
] ]
.filter(Boolean) .filter(Boolean)
+13
View File
@@ -752,6 +752,10 @@ export type McpServerRecord = {
status: McpServerStatus; status: McpServerStatus;
last_error: string; last_error: string;
last_synced_at: number | null; last_synced_at: number | null;
/**
* When its settings last changed (an edit or who gets it), not a run or check.
*/
updated_at: number | null;
}; };
export type CustomToolHttpConfig = { export type CustomToolHttpConfig = {
@@ -785,6 +789,10 @@ export type CustomToolRecord = {
status: CustomToolStatus; status: CustomToolStatus;
last_error: string; last_error: string;
last_checked_at: number | null; last_checked_at: number | null;
/**
* When its settings last changed (an edit or who gets it), not a run or check.
*/
updated_at: number | null;
} & ( } & (
| { kind: "http"; config: CustomToolHttpConfig } | { kind: "http"; config: CustomToolHttpConfig }
| { kind: "script"; config: CustomToolScriptConfig } | { kind: "script"; config: CustomToolScriptConfig }
@@ -802,6 +810,10 @@ export type SkillRecord = {
body: string; body: string;
all_bots: boolean; all_bots: boolean;
enabled: boolean; enabled: boolean;
/**
* When its settings last changed (an edit or who gets it), not a run or check.
*/
updated_at: number | null;
}; };
/** /**
@@ -943,6 +955,7 @@ export type PublicCustomTool = {
status: CustomToolStatus; status: CustomToolStatus;
last_error: string; last_error: string;
last_checked_at: number | null; last_checked_at: number | null;
updated_at: number | null;
access: ExtensionAccess; access: ExtensionAccess;
parameters?: Record<string, unknown>; parameters?: Record<string, unknown>;
config?: CustomToolHttpConfig | CustomToolScriptConfig | CustomToolPromptConfig; config?: CustomToolHttpConfig | CustomToolScriptConfig | CustomToolPromptConfig;
@@ -10,6 +10,7 @@ const current: SkillRecord = {
body: "1. Search the client's rankings.\n2. Write the report.\n3. Attach it.", body: "1. Search the client's rankings.\n2. Write the report.\n3. Attach it.",
all_bots: false, all_bots: false,
enabled: true, enabled: true,
updated_at: null,
}; };
describe("describeSkillProposal", () => { describe("describeSkillProposal", () => {
+1
View File
@@ -10,6 +10,7 @@ const current: SkillRecord = {
body: "1. Install Baileys.\n2. Pair.", body: "1. Install Baileys.\n2. Pair.",
all_bots: false, all_bots: false,
enabled: true, enabled: true,
updated_at: null,
}; };
describe("skillProposalChanges", () => { describe("skillProposalChanges", () => {