2 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 2259b6895f Release v0.3.2
Release image / image (release) Canceled after 0s
CI / check (push) Canceled after 0s
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",
"version": "0.3.1",
"version": "0.3.2",
"module": "index.ts",
"type": "module",
"private": true,
@@ -37,6 +37,7 @@ export default async function grabToolsOverview({ user_id }: Params): Promise<Cu
status: tool.status,
last_error: tool.last_error,
last_checked_at: tool.last_checked_at,
updated_at: tool.updated_at,
access: { all_bots: tool.all_bots, ...access(tool.id) },
...(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,
last_error: row.last_error || "",
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",
config: parseJsonColumn<Record<string, unknown>>({ text: row.config, fallback: {} }),
} as CustomToolRecord;
@@ -29,5 +29,6 @@ export default function parseMcpServerRow({ row }: Params): McpServerRecord {
status: (row.status || "untested") as McpServerStatus,
last_error: row.last_error || "",
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 || "",
all_bots: row.all_bots == 1,
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`,
* `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) {
const now = Date.now();
await BunSQLite.sql({
sql: `UPDATE custom_tools SET status = ?, last_error = ?, last_checked_at = ?, updated_at = ? WHERE id = ?`,
values: [error ? "error" : "ok", (error || "").slice(0, 4000), now, now, custom_tool_id],
sql: `UPDATE custom_tools SET status = ?, last_error = ?, last_checked_at = ? WHERE id = ?`,
values: [error ? "error" : "ok", (error || "").slice(0, 4000), Date.now(), custom_tool_id],
}).catch(() => {});
}
@@ -11,13 +11,12 @@ type Params = {
/**
* 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
* 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) {
const now = Date.now();
await BunSQLite.sql({
sql: `UPDATE mcp_servers SET status = ?, last_error = ?, last_synced_at = ?, updated_at = ? WHERE id = ?`,
values: [error ? "error" : "ok", (error || "").slice(0, 4000), now, now, mcp_server_id],
sql: `UPDATE mcp_servers SET status = ?, last_error = ?, last_synced_at = ? WHERE id = ?`,
values: [error ? "error" : "ok", (error || "").slice(0, 4000), Date.now(), mcp_server_id],
}).catch(() => {});
}
@@ -21,7 +21,8 @@ type Params = {
* 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
* 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) {
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 });
} 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() });
await BunSQLite.sql({
sql: `UPDATE mcp_servers SET tools_cache = ?, status = 'ok', last_error = '', last_synced_at = ?, updated_at = ? WHERE id = ?`,
values: [JSON.stringify(tools), now, now, server.id],
sql: `UPDATE mcp_servers SET tools_cache = ?, status = 'ok', last_error = '', last_synced_at = ? WHERE id = ?`,
values: [JSON.stringify(tools), now, server.id],
});
return { ok: true as const, tools };
@@ -49,8 +49,8 @@ export default async function syncMcpServer({ mcp_server_id, reinstall }: Params
}).slice(0, 4000);
await BunSQLite.sql({
sql: `UPDATE mcp_servers SET status = 'error', last_error = ?, last_synced_at = ?, updated_at = ? WHERE id = ?`,
values: [message, now, now, server.id],
sql: `UPDATE mcp_servers SET status = 'error', last_error = ?, last_synced_at = ? WHERE id = ?`,
values: [message, now, server.id],
});
return { ok: false as const, error: message };
@@ -39,6 +39,7 @@ export default async function testMcpDraft({
status: "untested",
last_error: "",
last_synced_at: null,
updated_at: null,
},
envs,
});
@@ -12,6 +12,7 @@ const weather: CustomToolRecord = {
status: "ok",
last_error: "",
last_checked_at: null,
updated_at: null,
kind: "http",
config: {
method: "GET",
@@ -12,6 +12,7 @@ function skill(overrides: Partial<SkillRecord> & { slug: string }): SkillRecord
body: `${overrides.slug} body`,
all_bots: false,
enabled: true,
updated_at: null,
...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 checkedAgo from "../../(utils)/checked-ago";
import extensionAccessLabel from "../../(utils)/extension-access-label";
import updatedAgo from "../../(utils)/updated-ago";
import McpServerModal from "./mcp-server-modal";
type Props = {
@@ -63,6 +64,7 @@ export default function McpServerRow({ server, targets, onChanged, onError }: Pr
subtitle={[
McpTransports.find((t) => t.value == server.transport)?.title,
extensionAccessLabel({ access: server.access }),
updatedAgo({ at: server.updated_at }),
]
.filter(Boolean)
.join(" · ")}
@@ -8,6 +8,7 @@ import ExtensionAccessModal from "../../(partials)/extension-access-modal";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
import extensionAccessLabel from "../../(utils)/extension-access-label";
import updatedAgo from "../../(utils)/updated-ago";
import SkillModal from "./skill-modal";
type Props = {
@@ -51,7 +52,12 @@ export default function SkillRow({ skill, targets, limits, onChanged, onError }:
avatar={<IconTile Icon={BookOpen} />}
title={skill.name}
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)
.join(" · ")}
>
@@ -11,6 +11,7 @@ import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
import checkedAgo from "../../(utils)/checked-ago";
import extensionAccessLabel from "../../(utils)/extension-access-label";
import updatedAgo from "../../(utils)/updated-ago";
import CustomToolModal from "./custom-tool-modal";
import CustomToolTryModal from "./custom-tool-try-modal";
@@ -72,6 +73,7 @@ export default function CustomToolRow({ tool, targets, onChanged, onRefreshed, o
subtitle={[
CustomToolKinds.find((k) => k.value == tool.kind)?.title,
extensionAccessLabel({ access: tool.access }),
updatedAgo({ at: tool.updated_at }),
tool.description,
]
.filter(Boolean)
+13
View File
@@ -752,6 +752,10 @@ export type McpServerRecord = {
status: McpServerStatus;
last_error: string;
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 = {
@@ -785,6 +789,10 @@ export type CustomToolRecord = {
status: CustomToolStatus;
last_error: string;
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: "script"; config: CustomToolScriptConfig }
@@ -802,6 +810,10 @@ export type SkillRecord = {
body: string;
all_bots: 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;
last_error: string;
last_checked_at: number | null;
updated_at: number | null;
access: ExtensionAccess;
parameters?: Record<string, unknown>;
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.",
all_bots: false,
enabled: true,
updated_at: null,
};
describe("describeSkillProposal", () => {
+1
View File
@@ -10,6 +10,7 @@ const current: SkillRecord = {
body: "1. Install Baileys.\n2. Pair.",
all_bots: false,
enabled: true,
updated_at: null,
};
describe("skillProposalChanges", () => {