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]>
This commit is contained in:
2026-10-26 08:07:44 +01:00
co-authored by Claude Opus 5.5
parent b9008b22cb
commit 2df1bf83f4
18 changed files with 62 additions and 16 deletions
@@ -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", () => {