diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d84ae3324..4590f89b6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,6 +7,7 @@ "start": "vite", "dev": "vite", "typecheck": "tsc --noEmit", + "test:plugins": "node --experimental-strip-types --test tests/pluginAccounts.test.ts", "typecheck:node": "tsc --noEmit -p tsconfig.node.json", "i18n:scan": "cross-env STATIC_I18N_SCAN=true vite build", "build": "vite build", diff --git a/apps/desktop/src/demo/api.ts b/apps/desktop/src/demo/api.ts index 732379367..76d68db21 100644 --- a/apps/desktop/src/demo/api.ts +++ b/apps/desktop/src/demo/api.ts @@ -1,3 +1,4 @@ +import { createPluginFixture } from "./pluginFixture"; import type { CallDetail, CursorHarnessStatus, @@ -99,6 +100,8 @@ let tabSettings: TabSettings = { mode: "public", address: "" }; let storage: StatisticsStorage = { call_count: calls.length, trace_count: calls.length }; export function installDemoApi() { + const fixture = createPluginFixture(Date.now(), Number(new URLSearchParams(window.location.search).get("fixtureDelay") ?? 350)); + (window as typeof window & { pluginFixture: typeof fixture }).pluginFixture = fixture; const nativeFetch = window.fetch.bind(window); window.fetch = async (input, init) => { @@ -109,7 +112,10 @@ export function installDemoApi() { const path = url.pathname.slice(API_ROOT.length) || "/"; const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); const body = await readBody(input, init); + const pluginResponse = await fixture.handle(path, method, body); + if (pluginResponse) return pluginResponse; + if (path === "/settings/pricing") return json({ input_per_million: 5, output_per_million: 25, cache_read_per_million: 0.5, cache_write_per_million: 6.25 }); if (path === "/promotions") return json({ slots: [] }); if (path === "/models" && method === "GET") return json(models); if (path === "/models" && method === "POST") return json(models); diff --git a/apps/desktop/src/demo/index.tsx b/apps/desktop/src/demo/index.tsx index a073f3c7c..e8e8a8aab 100644 --- a/apps/desktop/src/demo/index.tsx +++ b/apps/desktop/src/demo/index.tsx @@ -3,7 +3,8 @@ import { installDemoApi } from "./api"; installDemoApi(); const params = new URLSearchParams(window.location.search); -const locale = params.get("locale") === "en-US" ? "en-US" : "zh-CN"; +const requestedLocale = params.get("locale"); +const locale = requestedLocale === "en-US" || requestedLocale === "pt-BR" ? requestedLocale : "zh-CN"; const theme = params.get("theme") === "default-light" ? "default-light" : "default-dark"; document.documentElement.dataset.platform = "macos"; diff --git a/apps/desktop/src/demo/pluginFixture.ts b/apps/desktop/src/demo/pluginFixture.ts new file mode 100644 index 000000000..5eeb1fa6b --- /dev/null +++ b/apps/desktop/src/demo/pluginFixture.ts @@ -0,0 +1,98 @@ +import type { PluginDescriptor, PluginResourceSelection } from "../shared/api.ts"; + +/** In-memory browser/test fixture, installed only by the demo entry point. */ +export function createPluginFixture(now = Date.now(), delayMs = 350) { + const calls: { method: string; path: string }[] = []; + const plugins: PluginDescriptor[] = ["codex", "grok", "antigravity"].map((name, index) => ({ + id: `fixture-${name}`, + name: `${name[0].toUpperCase()}${name.slice(1)} fixture`, + version: "1.0.0", + author: "Local test data", + icon: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23666'/%3E%3Cpath d='M8 16h16M16 8v16' stroke='white' stroke-width='2'/%3E%3C/svg%3E", + providers: [{ + id: name, pluginId: `fixture-${name}`, displayName: name, description: null, + providerType: "openai", resourceType: "accounts", hasModels: true, configured: false, models: [], + }], + resources: [{ + type: "accounts", displayName: { "en-US": "Accounts", "zh-CN": "账号", "pt-BR": "Contas" }, + selection: { activeResourceId: index === 0 ? "account-12" : "account-1", automaticSwitching: index !== 1, revision: 1 }, + canRefresh: true, canRemove: true, + import: { displayName: "Import fixture", description: null, accept: [".json"], multiple: true }, + add: [{ id: "fixture-oauth", type: "oauth2.0", displayName: "Demo OAuth", description: "Local fixture only; no external authorization." }], + actions: [], + resources: Array.from({ length: index === 0 ? 12 : 2 }, (_, account) => ({ + id: `account-${account + 1}`, displayName: `${name}-${account + 1}@example.test`, + description: account === 2 ? "Fixture: refresh and selection fail for this account." : null, + createdAtMs: now - account * 3600000, + state: account === 1 ? { status: "cooling" as const, retryAtMs: now + 15000 } + : account === 2 ? { status: "invalid" as const, message: "Fixture authorization expired" } + : { status: "ready" as const }, + metrics: [ + { id: "quota", label: { "en-US": "Weekly quota", "zh-CN": "每周额度", "pt-BR": "Cota semanal" }, unit: "percent" as const, value: account === 1 ? 0 : 78 - account, resetAtMs: account === 3 ? null : now + 10000 + account * 3000 }, + ...(index === 0 ? [{ id: "cards", label: { "en-US": "Reset cards", "zh-CN": "重置卡", "pt-BR": "Cartões de redefinição" }, unit: "count" as const, value: 2, expiresAtMs: account === 3 ? null : now + 60000 }] : []), + ], + })), + }], + })); + const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); + const empty = () => new Response(null, { status: 204 }); + const wait = () => new Promise((resolve) => setTimeout(resolve, delayMs)); + let failSnapshot = false; + let failSelection = false; + let oauthPlugin: PluginDescriptor | undefined; + + const addAccount = (pluginId = plugins[0].id) => { + const resource = plugins.find((plugin) => plugin.id === pluginId)!.resources[0]; + const id = `added-${resource.resources.length + 1}`; + resource.resources.push({ id, displayName: `${id}@example.test`, description: null, createdAtMs: Date.now(), state: { status: "ready" }, metrics: [] }); + return id; + }; + + return { + calls, plugins, addAccount, + setSnapshotFailure(value: boolean) { failSnapshot = value; }, + setSelectionFailure(value: boolean) { failSelection = value; }, + async handle(path: string, method: string, body: unknown): Promise { + if (!path.startsWith("/plugins")) return null; + calls.push({ method, path }); + if (path === "/plugins/runtime") return json({ state: "ready", version: "fixture", target: null, phase: null, downloaded_bytes: 0, total_bytes: null, error: null }); + if (path === "/plugins") return failSnapshot ? json({ message: "Fixture local snapshot failure" }, 503) : json(plugins); + if (path.startsWith("/plugins/oauth/")) { + await wait(); + if (oauthPlugin) addAccount(oauthPlugin.id); + oauthPlugin = undefined; + return json({ status: "completed", added: 1, updated: 0, modelSyncError: "Fixture model sync failure" }); + } + const parts = path.split("/").filter(Boolean).map(decodeURIComponent); + const plugin = plugins.find((item) => item.id === parts[1]); + if (!plugin) return json({ message: "Fixture plugin not found" }, 404); + const resource = plugin.resources[0]; + await wait(); + if (parts.at(-1) === "selection" && method === "PUT") { + const next = body as Omit; + if (failSelection || next.activeResourceId === "account-3") return json({ message: "Fixture selection rejected" }, 409); + resource.selection = { ...next, revision: resource.selection.revision + 1 }; + return json(resource.selection); + } + if (parts.at(-1) === "refresh") { + if (parts[4] === "account-3") return json({ message: "Fixture quota refresh failed; previous data retained" }, 503); + return empty(); + } + if (parts.at(-1) === "sync") return json({ message: "Fixture model sync failed; accounts remain manageable" }, 503); + if (parts.at(-1) === "begin") { + oauthPlugin = plugin; + return json({ sessionId: "fixture-session", userCode: "DEMO-CODE", verificationUrl: "https://example.test/fixture", verificationUrlComplete: null, expiresAtMs: now + 60000, pollIntervalMs: 1000 }); + } + if (parts.at(-1) === "import") { + addAccount(plugin.id); + return json({ added: 1, updated: 0, warnings: [], modelSyncError: "Fixture model sync failure" }); + } + if (method === "DELETE" && parts.length === 5) { + resource.resources = resource.resources.filter((item) => item.id !== parts[4]); + if (resource.selection.activeResourceId === parts[4]) resource.selection = { ...resource.selection, activeResourceId: null, revision: resource.selection.revision + 1 }; + return empty(); + } + return json({ message: `Unhandled plugin fixture: ${method} ${path}` }, 404); + }, + }; +} diff --git a/apps/desktop/src/features/plugins/PluginManagementPage.tsx b/apps/desktop/src/features/plugins/PluginManagementPage.tsx index 9878d9a7a..962af99b7 100644 --- a/apps/desktop/src/features/plugins/PluginManagementPage.tsx +++ b/apps/desktop/src/features/plugins/PluginManagementPage.tsx @@ -1,3 +1,4 @@ +import { useKeepAliveContext } from "keepalive-for-react"; import { useEffect, useRef, useState } from "react"; import { api, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api"; import { useI18n } from "../../i18n/store"; @@ -13,6 +14,7 @@ import { PluginAddPanel, PluginSettingsPanel } from "./PluginResourcePanels"; import styles from "./PluginManagementPage.module.scss"; export function PluginManagementPage() { + const { active } = useKeepAliveContext(); const { pluginRuntime, plugins } = useAppStore(); const [progressOpen, setProgressOpen] = useState(false); const [starting, setStarting] = useState(false); @@ -20,6 +22,10 @@ export function PluginManagementPage() { const cancelRequested = useRef(false); const selectedPlugin = selected ? plugins.find((plugin) => plugin.id === selected.pluginId) ?? null : null; + useEffect(() => { + if (!active) setSelected(null); + }, [active]); + useEffect(() => { if (!pluginRuntime) void appStore.refreshPluginRuntime(); }, [pluginRuntime]); @@ -73,7 +79,7 @@ export function PluginManagementPage() { /> onOpen(plugin.id, "add")} /> - {configured && ( + {(configured || accountCount > 0) && ( void }) { return
{plugin.resources.map((resource) => setCopied(false), 2000); }; - useEffect(() => () => { stopped.current = true; }, []); + useEffect(() => { stopped.current = false; return () => { stopped.current = true; }; }, []); useEffect(() => { if (!begun || status !== "polling") return; let timer = 0; + let cancelled = false; const poll = async (intervalMs: number) => { - if (stopped.current) return; + if (stopped.current || cancelled) return; try { const result = await api.pluginOAuthPoll(begun.sessionId); - if (stopped.current) return; + if (stopped.current || cancelled) return; if (result.status === "pending") { timer = window.setTimeout(() => void poll(result.pollIntervalMs), Math.max(1000, result.pollIntervalMs)); return; } if (result.status === "completed") { await appStore.refreshPlugins(); + if (stopped.current || cancelled) return; if (result.modelSyncError) { setStatus("error"); setError(t("账号已保存,但同步模型失败:{error}", { error: result.modelSyncError })); @@ -99,13 +101,13 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: { setStatus("error"); setError(result.message || t("授权被拒绝或已失败。")); } catch (cause) { - if (stopped.current) return; + if (stopped.current || cancelled) return; setError(errorText(cause)); timer = window.setTimeout(() => void poll(intervalMs), Math.max(1000, intervalMs)); } }; timer = window.setTimeout(() => void poll(begun.pollIntervalMs), Math.max(1000, begun.pollIntervalMs)); - return () => window.clearTimeout(timer); + return () => { cancelled = true; window.clearTimeout(timer); }; }, [begun, onConfigured, status]); const start = async () => { @@ -113,11 +115,13 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: { setError(null); try { const next = await api.pluginOAuthBegin(pluginId, resourceType, method.id); + if (stopped.current) return; setBegun(next); setStatus("polling"); if (next.userCode) await api.copyCursorText(next.userCode).catch(() => undefined); await api.openExternalUrl(next.verificationUrlComplete || next.verificationUrl); } catch (cause) { + if (stopped.current) return; setStatus("error"); setError(errorText(cause)); } @@ -146,6 +150,9 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: { } export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { + const manager = useAccountManager(plugin); + const mounted = useRef(false); + useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [modelProviderId, setModelProviderId] = useState(null); @@ -155,6 +162,8 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { } | null>(null); const [resourceActionResult, setResourceActionResult] = useState(null); const [resourceActionError, setResourceActionError] = useState(null); + const [actionBusy, setActionBusy] = useState(false); + const actionVersion = useRef(0); const modelProvider = modelProviderId ? plugin.providers.find((provider) => provider.id === modelProviderId) ?? null : null; const run = async (key: string, task: () => Promise) => { @@ -162,11 +171,11 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { setError(null); try { await task(); - await appStore.refreshPlugins(); + await appStore.readPlugins(true); } catch (cause) { - setError(errorText(cause)); + if (mounted.current) setError(errorText(cause)); } finally { - setBusy(null); + if (mounted.current) setBusy(null); } }; @@ -175,8 +184,8 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { action: PluginResourceAction, input: unknown = {}, ) => { - const key = `action:${target.item.id}:${action.id}`; - setBusy(key); + const version = ++actionVersion.current; + setActionBusy(true); setResourceActionError(null); try { const result = await api.pluginResourceAction( @@ -186,12 +195,12 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { action.id, input, ); - setResourceActionResult(result); - await appStore.refreshPlugins(); + if (mounted.current && version === actionVersion.current) setResourceActionResult(result); + await appStore.readPlugins(true); } catch (cause) { - setResourceActionError(errorText(cause)); + if (mounted.current && version === actionVersion.current) setResourceActionError(errorText(cause)); } finally { - setBusy(null); + if (mounted.current && version === actionVersion.current) setActionBusy(false); } }; @@ -206,25 +215,23 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { {plugin.providers.map((provider) => setModelProviderId(provider.id)} - onSync={() => void run(`sync:${provider.id}`, async () => { + onSync={() => void manager.run("provider", provider.id, "sync", async () => { await api.syncPluginModels(plugin.id, provider.id); + await appStore.readPlugins(true); })} />)} - {plugin.resources.map((resource) => openResourceAction(resource, item, action)} - onRefresh={(item) => void run(`refresh:${item.id}`, async () => { - await api.refreshPluginResource(plugin.id, resource.type, item.id); - })} - onDelete={(item) => void run(`delete:${item.id}`, async () => { - await api.deletePluginResource(plugin.id, resource.type, item.id); - })} />)} + {manager.snapshotError && {t("本地状态同步失败:{error}", { error: manager.snapshotError })}} {error && {error}} {modelProvider && item.target === "resource") ?? null} cardAction={resourceAction.resource.actions.find((item) => item.target === "card") ?? null} result={resourceActionResult} - busy={busy !== null} + busy={actionBusy} error={resourceActionError} - onClose={() => setResourceAction(null)} + onClose={() => { actionVersion.current += 1; setResourceAction(null); }} onCardAction={(action, card) => void executeResourceAction(resourceAction, action, { cardId: card.id })} />}
; } -function ProviderRow({ provider, busy, syncing, onManageModels, onSync }: { +function ProviderRow({ provider, busy, syncing, error, onManageModels, onSync }: { provider: PluginProviderDescriptor; busy: boolean; syncing: boolean; + error: string | null; onManageModels: () => void; onSync: () => void; }) { @@ -267,6 +275,7 @@ function ProviderRow({ provider, busy, syncing, onManageModels, onSync }: { {" · "} {provider.configured ? t("可调用") : t("未就绪")} + {error && {error}} {provider.hasModels &&
@@ -330,81 +339,6 @@ function ModelManagementModal({ provider, busy, onClose, onSubmit }: { ; } -function ResourceList({ resource, busy, onAction, onRefresh, onDelete }: { - resource: PluginResourceDescriptor; - busy: boolean; - onAction: (item: PluginResourceView, action: PluginResourceAction) => void; - onRefresh: (item: PluginResourceView) => void; - onDelete: (item: PluginResourceView) => void; -}) { - const { locale } = useI18n(); - const [query, setQuery] = useState(""); - const [page, setPage] = useState(1); - const filtered = useMemo( - () => resource.resources.filter((item) => item.displayName.toLowerCase().includes(query.trim().toLowerCase())), - [resource.resources, query], - ); - const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); - const visible = filtered.slice((Math.min(page, pageCount) - 1) * PAGE_SIZE, Math.min(page, pageCount) * PAGE_SIZE); - - useEffect(() => setPage(1), [query]); - - return -
- {resource.resources.length > PAGE_SIZE &&
- setQuery(event.target.value)} /> -
} -
- {visible.map((item) => action.target === "resource")} - canRefresh={resource.canRefresh} - disabled={busy} - onAction={(action) => onAction(item, action)} - onRefresh={() => onRefresh(item)} - onDelete={() => onDelete(item)} - />)} - {visible.length === 0 && {t("还没有资源,请先添加。")}} -
- {pageCount > 1 &&
- - {t("第 {page} / {total} 页", { page: Math.min(page, pageCount), total: pageCount })} - -
} -
-
; -} - -function ResourceRow({ item, actions, canRefresh, disabled, onAction, onRefresh, onDelete }: { - item: PluginResourceView; - actions: PluginResourceAction[]; - canRefresh: boolean; - disabled: boolean; - onAction: (action: PluginResourceAction) => void; - onRefresh: () => void; - onDelete: () => void; -}) { - const { locale } = useI18n(); - return -
- {item.displayName} - {item.description && {pluginText(item.description, locale)}} - {item.metrics.map((metric) => - {metric.unit === "percent" - ? t("{label} 剩余 {percent}%", { label: pluginText(metric.label, locale), percent: Math.round(metric.value) }) - : `${pluginText(metric.label, locale)}: ${metric.value}`} - )} -
-
- - {actions.map((action) => )} - {canRefresh && } - -
-
; -} - function ResourceActionModal({ action, cardAction, result, busy, error, onClose, onCardAction }: { action: PluginResourceAction | null; cardAction: PluginResourceAction | null; @@ -480,16 +414,6 @@ function formatActionDate(value: number, locale: string) { return new Date(value).toLocaleString(locale); } -function StateBadge({ state }: { state: PluginResourceView["state"] }) { - if (state.status === "cooling") { - return {t("冷却中")}; - } - if (state.status === "invalid") { - return {t("已失效")}; - } - return {t("可用")}; -} - function errorText(cause: unknown) { return cause instanceof Error ? cause.message : String(cause); } diff --git a/apps/desktop/src/features/plugins/ResourceAccountList.tsx b/apps/desktop/src/features/plugins/ResourceAccountList.tsx new file mode 100644 index 000000000..0de3ce119 --- /dev/null +++ b/apps/desktop/src/features/plugins/ResourceAccountList.tsx @@ -0,0 +1,153 @@ +import { useId, useMemo, useState } from "react"; +import { api, pluginText, type PluginResourceAction, type PluginResourceDescriptor, type PluginResourceView } from "../../shared/api"; +import { useI18n } from "../../i18n/store"; +import { appStore, useAppStore } from "../../shared/store/appStore"; +import { Button } from "../../shared/ui/Button"; +import { Card } from "../../shared/ui/Card"; +import { ConfirmDialog } from "../../shared/ui/ConfirmDialog"; +import { FormField, TextInput } from "../../shared/ui/FormControls"; +import { Switch } from "../../shared/ui/Switch"; +import { remainingSeconds, resourceKey, showsMetricDeadline } from "./accountLifecycle"; +import type { AccountTask, useAccountManager } from "./useAccountManager"; +import styles from "./PluginResourcePanels.module.scss"; + +const PAGE_SIZE = 10; + +export function ResourceAccountList({ pluginId, resource, manager, onAction }: { + pluginId: string; + resource: PluginResourceDescriptor; + manager: ReturnType; + onAction: (item: PluginResourceView, action: PluginResourceAction) => void; +}) { + const { locale } = useI18n(); + const [query, setQuery] = useState(""); + const automaticSwitchingDescriptionId = useId(); + const [page, setPage] = useState(1); + const [selectionTarget, setSelectionTarget] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + const filtered = useMemo(() => resource.resources.filter((item) => item.displayName.toLowerCase().includes(query.trim().toLowerCase())), [resource.resources, query]); + const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const currentPage = Math.min(page, pageCount); + const visible = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + const selected = resource.resources.find((item) => item.id === resource.selection.activeResourceId); + const selectionTask = manager.tasks[resourceKey("selection", resource.type)]; + const { pluginSelectionPending } = useAppStore(); + const selectionPending = Boolean(selectionTask?.pending) || pluginSelectionPending.includes(JSON.stringify([pluginId, resource.type])); + const select = (patch: { activeResourceId?: string; automaticSwitching?: boolean }) => { + setSelectionTarget(patch.activeResourceId ?? null); + void manager.run("selection", resource.type, "selection", async () => { + const current = appStore.getSnapshot().plugins.find((plugin) => plugin.id === pluginId)?.resources.find((item) => item.type === resource.type)?.selection; + if (!current) throw new Error(t("账号配置已发生变化,请重新打开账号管理。")); + await appStore.setPluginResourceSelection(pluginId, resource.type, { + activeResourceId: patch.activeResourceId ?? current.activeResourceId, + automaticSwitching: patch.automaticSwitching ?? current.automaticSwitching, + }); + }); + }; + + return +
+
+ {t("当前账号:{name}", { name: selected?.displayName ?? (resource.selection.activeResourceId ? t("账号不可用") : t("未选择账号")) })} +
+ {t("自动切换账号")} + select({ automaticSwitching })} /> + {selectionPending && {t("正在保存…")}} +
+ {t("开启后,当前账号不可用时自动切换到其他可用账号;关闭后仅使用当前账号。")} + {selectionTask?.error && !visible.some((item) => item.id === selectionTarget) && {selectionTask.error}} +
+ {(resource.resources.length > PAGE_SIZE || query) &&
+ { setQuery(event.target.value); setPage(1); }} /> +
} +
+ {visible.map((item) => select({ activeResourceId: item.id })} + onAction={(action) => onAction(item, action)} + onRefresh={() => void manager.refresh(resource.type, item.id)} + onDelete={() => setPendingDelete(item)} + />)} + {visible.length === 0 && {query ? t("没有匹配的账号") : t("还没有资源,请先添加。")}} +
+ {pageCount > 1 &&
+ + {t("第 {page} / {total} 页", { page: currentPage, total: pageCount })} + +
} +
+ {pendingDelete && setPendingDelete(null)} + onConfirm={() => { + const item = pendingDelete; + setPendingDelete(null); + void manager.run(resource.type, item.id, "delete", async () => { + await api.deletePluginResource(pluginId, resource.type, item.id); + await appStore.readPlugins(true); + }); + }} + > +

{t("确定删除账号 {name} 吗?删除后需要重新添加才能使用。", { name: pendingDelete.displayName })}

+
} +
; +} + +function ResourceRow({ item, resource, active, selectionPending, selectionError, selecting, task, now, onSelect, onAction, onRefresh, onDelete }: { + item: PluginResourceView; + resource: PluginResourceDescriptor; + active: boolean; + selectionPending: boolean; + selectionError?: string | null; + selecting: boolean; + task?: AccountTask; + now: number; + onSelect: () => void; + onAction: (action: PluginResourceAction) => void; + onRefresh: () => void; + onDelete: () => void; +}) { + const { locale } = useI18n(); + const busy = Boolean(task?.pending); + return +
+ {item.displayName} {active && {t("当前账号")}} + {item.description && {pluginText(item.description, locale)}} + {item.state.status === "cooling" ? t("冷却中") : item.state.status === "invalid" ? t("已失效") : t("可用")} + {item.state.message && {item.state.message}} + {item.state.status === "cooling" && } + {item.metrics.map((metric) =>
+ {metric.unit === "percent" + ? t("{label} 剩余 {percent}%", { label: pluginText(metric.label, locale), percent: Math.round(metric.value) }) + : `${pluginText(metric.label, locale)}: ${metric.value}`} + {showsMetricDeadline(metric, "reset") && } + {showsMetricDeadline(metric, "expiry") && } +
)} + {task?.error && {task.error}} + {selectionError && {selectionError}} +
+
+ + {resource.actions.filter((action) => action.target === "resource").map((action) => )} + {resource.canRefresh && } + +
+
; +} + +function Deadline({ kind, at, now }: { kind: "reset" | "retry" | "expiry"; at?: number | null; now: number }) { + const { locale } = useI18n(); + const seconds = remainingSeconds(at, now); + const label = kind === "reset" ? t("额度重置") : kind === "retry" ? t("冷却结束") : t("最近重置卡到期"); + if (seconds === null) return {t("{label}:时间未知", { label })}; + const countdown = seconds === 0 + ? (kind === "expiry" ? t("已到期,等待刷新确认") : t("已到时间,等待服务端确认")) + : t("剩余 {hours} 小时 {minutes} 分 {seconds} 秒", { hours: Math.floor(seconds / 3600), minutes: Math.floor(seconds / 60) % 60, seconds: seconds % 60 }); + return {label}{": "}{" · "}{countdown}; +} diff --git a/apps/desktop/src/features/plugins/accountLifecycle.ts b/apps/desktop/src/features/plugins/accountLifecycle.ts new file mode 100644 index 000000000..f5d00b5a1 --- /dev/null +++ b/apps/desktop/src/features/plugins/accountLifecycle.ts @@ -0,0 +1,25 @@ +import type { PluginResourceDescriptor, PluginResourceMetric } from "../../shared/api.ts"; + +export function showsMetricDeadline(metric: PluginResourceMetric, kind: "reset" | "expiry") { + return kind === "reset" + ? metric.unit === "percent" || metric.resetAtMs != null + : metric.unit === "count" || metric.expiresAtMs != null; +} + +export function resourceKey(type: string, id: string) { + return JSON.stringify([type, id]); +} + +/** The set belongs to one open manager, not to a snapshot or pagination page. */ +export function takeNewRefreshTargets(seen: Set, resources: PluginResourceDescriptor[]) { + return resources.flatMap((resource) => !resource.canRefresh ? [] : resource.resources.flatMap((item) => { + const key = resourceKey(resource.type, item.id); + if (seen.has(key)) return []; + seen.add(key); + return [{ type: resource.type, id: item.id }]; + })); +} + +export function remainingSeconds(timestamp: number | null | undefined, now: number): number | null { + return timestamp == null || !Number.isFinite(timestamp) ? null : Math.max(0, Math.ceil((timestamp - now) / 1000)); +} diff --git a/apps/desktop/src/features/plugins/useAccountManager.ts b/apps/desktop/src/features/plugins/useAccountManager.ts new file mode 100644 index 000000000..126b4d640 --- /dev/null +++ b/apps/desktop/src/features/plugins/useAccountManager.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { api, type PluginDescriptor } from "../../shared/api"; +import { appStore } from "../../shared/store/appStore"; +import { createSingleFlight } from "../../shared/store/pluginState"; +import { resourceKey, takeNewRefreshTargets } from "./accountLifecycle"; + +// Shared across actual close/reopen, but only while a request is in flight. +const refreshRequests = createSingleFlight(); +export type AccountTask = { pending: string | null; error: string | null }; + +export function useAccountManager(plugin: PluginDescriptor) { + const [tasks, setTasks] = useState>({}); + const [snapshotError, setSnapshotError] = useState(null); + const [visible, setVisible] = useState(document.visibilityState === "visible"); + const [now, setNow] = useState(Date.now); + const mounted = useRef(false); + const seen = useRef(new Set()); + const running = useRef(new Set()); + + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; }; + }, []); + + const run = useCallback(async (type: string, id: string, operation: string, task: () => Promise) => { + const key = resourceKey(type, id); + if (running.current.has(key)) return; + running.current.add(key); + setTasks((current) => ({ ...current, [key]: { pending: operation, error: null } })); + try { + return await task(); + } catch (cause) { + if (mounted.current) setTasks((current) => ({ ...current, [key]: { pending: null, error: String(cause instanceof Error ? cause.message : cause) } })); + } finally { + running.current.delete(key); + if (mounted.current) setTasks((current) => ({ ...current, [key]: { pending: null, error: current[key]?.error ?? null } })); + } + }, []); + + const refresh = useCallback((type: string, id: string) => run(type, id, "refresh", () => + refreshRequests(JSON.stringify([plugin.id, type, id]), async () => { + await api.refreshPluginResource(plugin.id, type, id); + await appStore.readPlugins(true); + })), [plugin.id, run]); + + useEffect(() => { + const onVisibility = () => setVisible(document.visibilityState === "visible"); + document.addEventListener("visibilitychange", onVisibility); + return () => document.removeEventListener("visibilitychange", onVisibility); + }, []); + + useEffect(() => { + if (!visible) return; + for (const target of takeNewRefreshTargets(seen.current, plugin.resources)) void refresh(target.type, target.id); + }, [plugin.resources, refresh, visible]); + + useEffect(() => { + if (!visible) return; + let stopped = false; + let polling = false; + const poll = async () => { + if (polling) return; + polling = true; + try { + await appStore.readPlugins(); + if (!stopped) setSnapshotError(null); + } catch (cause) { + if (!stopped) setSnapshotError(String(cause instanceof Error ? cause.message : cause)); + } finally { polling = false; } + }; + void poll(); + setNow(Date.now()); + const snapshotTimer = window.setInterval(() => void poll(), 5000); + const clockTimer = window.setInterval(() => setNow(Date.now()), 1000); + return () => { + stopped = true; + window.clearInterval(snapshotTimer); + window.clearInterval(clockTimer); + }; + }, [visible]); + + return { tasks, run, refresh, now, snapshotError }; +} diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index e0edae80c..f5c0887a1 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -47,7 +47,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 266, + "line": 274, "column": 39 } ] @@ -77,8 +77,8 @@ ], "refs": [ { - "file": "features/plugins/PluginResourcePanels.tsx", - "line": 372, + "file": "features/plugins/ResourceAccountList.tsx", + "line": 78, "column": 16 } ] @@ -187,7 +187,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 314, + "line": 320, "column": 21 } ] @@ -247,7 +247,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 221, + "line": 227, "column": 12 } ] @@ -311,12 +311,12 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 266, + "line": 274, "column": 93 }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 328, + "line": 337, "column": 72 } ] @@ -667,7 +667,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 123, + "line": 129, "column": 16 } ] @@ -728,7 +728,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 157, + "line": 163, "column": 23 } ] @@ -740,7 +740,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 131, + "line": 135, "column": 15 } ] @@ -786,6 +786,18 @@ } ] }, + "1bb1e785ad5a8608": { + "source": "当前账号", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 120, + "column": 83 + } + ] + }, "1c6926877b1bbfb2": { "source": "总请求:{tokens}", "kind": "template", @@ -841,7 +853,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 452, + "line": 386, "column": 14 } ] @@ -928,7 +940,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 426, + "line": 360, "column": 50 } ] @@ -992,7 +1004,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 298, + "line": 304, "column": 122 } ] @@ -1021,6 +1033,18 @@ } ] }, + "28326a69ee50573c": { + "source": "未选择账号", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 51, + "column": 120 + } + ] + }, "29585d7193539200": { "source": "当前版本 {version}", "kind": "template", @@ -1095,7 +1119,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 139, + "line": 143, "column": 73 } ] @@ -1127,7 +1151,7 @@ }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 278, + "line": 284, "column": 70 }, { @@ -1325,9 +1349,14 @@ "column": 99 }, { - "file": "features/plugins/PluginResourcePanels.tsx", - "line": 403, - "column": 68 + "file": "features/plugins/ResourceAccountList.tsx", + "line": 85, + "column": 21 + }, + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 139, + "column": 177 } ] }, @@ -1338,7 +1367,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 474, + "line": 408, "column": 28 } ] @@ -1349,9 +1378,9 @@ "placeholders": [], "refs": [ { - "file": "features/plugins/PluginResourcePanels.tsx", - "line": 488, - "column": 81 + "file": "features/plugins/ResourceAccountList.tsx", + "line": 122, + "column": 131 } ] }, @@ -1361,13 +1390,13 @@ "placeholders": [], "refs": [ { - "file": "features/plugins/PluginResourcePanels.tsx", - "line": 355, + "file": "features/plugins/ResourceAccountList.tsx", + "line": 61, "column": 32 }, { - "file": "features/plugins/PluginResourcePanels.tsx", - "line": 355, + "file": "features/plugins/ResourceAccountList.tsx", + "line": 61, "column": 56 } ] @@ -1396,7 +1425,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 143, + "line": 147, "column": 64 } ] @@ -1498,7 +1527,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 246, + "line": 252, "column": 32 } ] @@ -1563,7 +1592,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 307, + "line": 316, "column": 106 }, { @@ -1614,6 +1643,18 @@ } ] }, + "3c2e935e260f4c63": { + "source": "账号不可用", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 51, + "column": 107 + } + ] + }, "3c94b4c75940c178": { "source": "输入 Token", "kind": "text", @@ -1705,12 +1746,12 @@ }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 278, + "line": 284, "column": 80 }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 423, + "line": 357, "column": 82 } ] @@ -1722,7 +1763,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 112, + "line": 118, "column": 68 } ] @@ -1868,7 +1909,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 139, + "line": 143, "column": 92 } ] @@ -2003,7 +2044,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 277, + "line": 283, "column": 12 } ] @@ -2027,7 +2068,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 112, + "line": 118, "column": 46 } ] @@ -2068,13 +2109,27 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 471, + "line": 405, "column": 30 }, { - "file": "features/plugins/PluginResourcePanels.tsx", - "line": 490, - "column": 42 + "file": "features/plugins/ResourceAccountList.tsx", + "line": 122, + "column": 142 + } + ] + }, + "4de7f5f1b34f038d": { + "source": "{label}:时间未知", + "kind": "template", + "placeholders": [ + "label" + ], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 148, + "column": 39 } ] }, @@ -2122,6 +2177,30 @@ } ] }, + "4f28e8ae1123b6c3": { + "source": "没有匹配的账号", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 74, + "column": 74 + } + ] + }, + "4fd4f40094d40891": { + "source": "正在删除…", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 139, + "column": 164 + } + ] + }, "51d04bc3d286f018": { "source": "近1自然日", "kind": "text", @@ -2394,7 +2473,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 454, + "line": 388, "column": 21 } ] @@ -2425,6 +2504,18 @@ } ] }, + "5bf0e29af701addd": { + "source": "额度重置", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 147, + "column": 36 + } + ] + }, "5c55a67935af8f45": { "source": "全部", "kind": "text", @@ -2444,7 +2535,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 270, + "line": 276, "column": 7 } ] @@ -2520,7 +2611,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 272, + "line": 281, "column": 102 } ] @@ -2652,7 +2743,7 @@ }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 134, + "line": 138, "column": 30 } ] @@ -2666,7 +2757,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 300, + "line": 309, "column": 12 } ] @@ -2678,7 +2769,7 @@ "refs": [ { "file": "shared/api.ts", - "line": 532, + "line": 541, "column": 43 } ] @@ -2810,6 +2901,18 @@ } ] }, + "68a79a6de12c601e": { + "source": "账号配置已发生变化,请重新打开账号管理。", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 40, + "column": 37 + } + ] + }, "68ad603fafe4e0d6": { "source": "导入旧版配置", "kind": "text", @@ -2885,6 +2988,23 @@ } ] }, + "6bdb4435095e5d28": { + "source": "正在保存…", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 55, + "column": 53 + }, + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 136, + "column": 172 + } + ] + }, "6d1876364ac6457d": { "source": "代理方式", "kind": "text", @@ -2933,6 +3053,18 @@ } ] }, + "71659de804df039d": { + "source": "正在刷新…", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 138, + "column": 159 + } + ] + }, "72644ec4389da2f7": { "source": "默认平铺", "kind": "text", @@ -2945,6 +3077,18 @@ } ] }, + "72cc6674739c45da": { + "source": "开启后,当前账号不可用时自动切换到其他可用账号;关闭后仅使用当前账号。", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 57, + "column": 90 + } + ] + }, "736c9dc2a04c65fd": { "source": "模型配置已发生变化,请刷新后重试", "kind": "text", @@ -2952,12 +3096,12 @@ "refs": [ { "file": "shared/store/appStore.ts", - "line": 218, + "line": 251, "column": 23 }, { "file": "shared/store/appStore.ts", - "line": 225, + "line": 258, "column": 25 } ] @@ -3017,7 +3161,7 @@ "refs": [ { "file": "shared/api.ts", - "line": 527, + "line": 536, "column": 43 } ] @@ -3053,7 +3197,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 313, + "line": 319, "column": 31 } ] @@ -3170,8 +3314,8 @@ ], "refs": [ { - "file": "features/plugins/PluginResourcePanels.tsx", - "line": 395, + "file": "features/plugins/ResourceAccountList.tsx", + "line": 127, "column": 13 } ] @@ -3183,7 +3327,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 216, + "line": 222, "column": 36 } ] @@ -3195,11 +3339,25 @@ "refs": [ { "file": "shared/api.ts", - "line": 461, + "line": 469, "column": 21 } ] }, + "81e7c66cd452e204": { + "source": "本地状态同步失败:{error}", + "kind": "template", + "placeholders": [ + "error" + ], + "refs": [ + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 234, + "column": 76 + } + ] + }, "8213941f12320ce1": { "source": "当前操作系统或 CPU 架构暂不受支持", "kind": "text", @@ -3207,7 +3365,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 105, + "line": 111, "column": 25 } ] @@ -3237,7 +3395,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 298, + "line": 304, "column": 20 } ] @@ -3331,7 +3489,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 274, + "line": 283, "column": 33 } ] @@ -3382,7 +3540,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 310, + "line": 316, "column": 32 } ] @@ -3405,9 +3563,9 @@ "placeholders": [], "refs": [ { - "file": "features/plugins/PluginResourcePanels.tsx", - "line": 373, - "column": 110 + "file": "features/plugins/ResourceAccountList.tsx", + "line": 79, + "column": 108 }, { "file": "shared/ui/Pagination.tsx", @@ -3505,7 +3663,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 134, + "line": 138, "column": 19 } ] @@ -3536,6 +3694,18 @@ } ] }, + "91208fd9658fe7ab": { + "source": "已到时间,等待服务端确认", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 150, + "column": 46 + } + ] + }, "919cb0ce0c8db4e7": { "source": "留空表示保留当前密码", "kind": "text", @@ -3567,7 +3737,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 234, + "line": 240, "column": 18 } ] @@ -3634,7 +3804,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 312, + "line": 318, "column": 31 } ] @@ -3658,7 +3828,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 274, + "line": 283, "column": 20 } ] @@ -3704,7 +3874,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 473, + "line": 407, "column": 25 } ] @@ -3754,7 +3924,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 419, + "line": 353, "column": 111 } ] @@ -3778,7 +3948,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 141, + "line": 145, "column": 147 } ] @@ -3817,12 +3987,12 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 98, + "line": 104, "column": 9 }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 272, + "line": 278, "column": 9 } ] @@ -3860,7 +4030,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 78, + "line": 84, "column": 11 } ] @@ -3872,7 +4042,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 124, + "line": 130, "column": 14 } ] @@ -3884,7 +4054,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 311, + "line": 317, "column": 30 } ] @@ -3898,12 +4068,12 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 159, + "line": 165, "column": 17 }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 92, + "line": 94, "column": 22 } ] @@ -4144,6 +4314,18 @@ } ] }, + "a520a13adc25418a": { + "source": "最近重置卡到期", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 147, + "column": 79 + } + ] + }, "a5fb6189a8ad011d": { "source": "打开教程", "kind": "text", @@ -4182,7 +4364,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 79, + "line": 85, "column": 11 } ] @@ -4233,6 +4415,36 @@ } ] }, + "a7670b2f1e88da05": { + "source": "剩余 {hours} 小时 {minutes} 分 {seconds} 秒", + "kind": "template", + "placeholders": [ + "hours", + "minutes", + "seconds" + ], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 151, + "column": 7 + } + ] + }, + "a7fb81b3ef8729b3": { + "source": "确定删除账号 {name} 吗?删除后需要重新添加才能使用。", + "kind": "template", + "placeholders": [ + "name" + ], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 97, + "column": 11 + } + ] + }, "a8036485f9227f2c": { "source": "拖动排序", "kind": "text", @@ -4293,7 +4505,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 308, + "line": 317, "column": 107 }, { @@ -4395,9 +4607,9 @@ "column": 89 }, { - "file": "features/plugins/PluginResourcePanels.tsx", - "line": 402, - "column": 84 + "file": "features/plugins/ResourceAccountList.tsx", + "line": 138, + "column": 172 }, { "file": "shell/AppLayout.tsx", @@ -4420,7 +4632,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 435, + "line": 369, "column": 84 } ] @@ -4478,12 +4690,12 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 103, + "line": 109, "column": 7 }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 301, + "line": 307, "column": 70 } ] @@ -4524,7 +4736,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 101, + "line": 107, "column": 11 } ] @@ -4725,9 +4937,9 @@ "placeholders": [], "refs": [ { - "file": "features/plugins/PluginResourcePanels.tsx", - "line": 485, - "column": 81 + "file": "features/plugins/ResourceAccountList.tsx", + "line": 122, + "column": 86 } ] }, @@ -4809,7 +5021,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 112, + "line": 118, "column": 83 } ] @@ -4864,7 +5076,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 278, + "line": 284, "column": 45 } ] @@ -4907,7 +5119,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 100, + "line": 106, "column": 11 } ] @@ -4918,9 +5130,9 @@ "placeholders": [], "refs": [ { - "file": "features/plugins/PluginResourcePanels.tsx", - "line": 368, - "column": 66 + "file": "features/plugins/ResourceAccountList.tsx", + "line": 74, + "column": 89 } ] }, @@ -4983,9 +5195,9 @@ "placeholders": [], "refs": [ { - "file": "features/plugins/PluginResourcePanels.tsx", - "line": 371, - "column": 102 + "file": "features/plugins/ResourceAccountList.tsx", + "line": 77, + "column": 100 }, { "file": "shared/ui/Pagination.tsx", @@ -5030,7 +5242,7 @@ }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 312, + "line": 321, "column": 37 } ] @@ -5042,11 +5254,23 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 106, + "line": 112, "column": 9 } ] }, + "cd3266d91ae10abc": { + "source": "删除账号", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 84, + "column": 14 + } + ] + }, "cea1aafe9416de7b": { "source": "请求头", "kind": "text", @@ -5064,6 +5288,18 @@ } ] }, + "cee9f804abbb8ab6": { + "source": "冷却结束", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 147, + "column": 67 + } + ] + }, "cfae1a14d2120c57": { "source": "详细模式", "kind": "text", @@ -5153,7 +5389,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 188, + "line": 194, "column": 20 } ] @@ -5235,7 +5471,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 268, + "line": 276, "column": 32 } ] @@ -5295,7 +5531,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 302, + "line": 308, "column": 44 } ] @@ -5321,7 +5557,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 322, + "line": 331, "column": 22 } ] @@ -5439,7 +5675,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 216, + "line": 222, "column": 25 } ] @@ -5451,7 +5687,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 462, + "line": 396, "column": 11 } ] @@ -5636,7 +5872,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 139, + "line": 143, "column": 34 } ] @@ -5648,7 +5884,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 100, + "line": 102, "column": 36 } ] @@ -5768,7 +6004,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 268, + "line": 276, "column": 43 } ] @@ -5838,7 +6074,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 428, + "line": 362, "column": 100 } ] @@ -5898,11 +6134,23 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 240, + "line": 246, "column": 20 } ] }, + "ebaf9fe3042917f0": { + "source": "已到期,等待刷新确认", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 150, + "column": 28 + } + ] + }, "ec917db99e814b58": { "source": "{label}必须是非负数", "kind": "template", @@ -5979,7 +6227,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 434, + "line": 368, "column": 84 } ] @@ -6001,6 +6249,32 @@ } ] }, + "f0066d9b38394e82": { + "source": "当前账号:{name}", + "kind": "template", + "placeholders": [ + "name" + ], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 51, + "column": 18 + } + ] + }, + "f3c1c197e6c604e2": { + "source": "使用此账号", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 136, + "column": 185 + } + ] + }, "f4694c46b1e19602": { "source": "最终请求类型", "kind": "text", @@ -6047,7 +6321,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 312, + "line": 321, "column": 69 } ] @@ -6095,7 +6369,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 178, + "line": 184, "column": 32 } ] @@ -6107,7 +6381,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 65, + "line": 71, "column": 14 }, { @@ -6224,12 +6498,12 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 82, + "line": 88, "column": 20 }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 304, + "line": 313, "column": 18 } ] @@ -6241,7 +6515,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 287, + "line": 293, "column": 23 } ] @@ -6253,7 +6527,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 112, + "line": 118, "column": 19 }, { @@ -6282,12 +6556,12 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 96, + "line": 102, "column": 7 }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 309, + "line": 315, "column": 29 } ] @@ -6350,7 +6624,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 178, + "line": 184, "column": 45 } ] @@ -6367,6 +6641,23 @@ } ] }, + "fde0acab820590f1": { + "source": "自动切换账号", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 53, + "column": 18 + }, + { + "file": "features/plugins/ResourceAccountList.tsx", + "line": 54, + "column": 182 + } + ] + }, "fe6ec799e02ed9a7": { "source": "Token 定价", "kind": "text", diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index d09d8d99d..a5b246a04 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -51,6 +51,7 @@ "1ae6b0a0f8266382": "Close window", "1b5932b8946d2d68": "Delete model", "1b7d5b1a9315fc64": "Calculating…", + "1bb1e785ad5a8608": "Active account", "1c6926877b1bbfb2": "Total requests: {tokens}", "1cb7e646ac883fb2": "Proxy port", "1cdde778cfe6a130": "Formula: cache read / (cache read + non-cached input)", @@ -68,6 +69,7 @@ "2400fbd0aeab9e13": "Downloaded {downloaded}", "24a0a24864454575": "Existing, skipped", "2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.", + "28326a69ee50573c": "No account selected", "29585d7193539200": "Current version {version}", "296da37506328d89": "Disable Cursor takeover", "29fbbef32a6eb58b": "Do not show this ad again", @@ -105,6 +107,7 @@ "3a8c76b2ce785f96": "Review and import", "3b63f1f9d5922e65": "Cache write price ($/1M)", "3b67824289b5fa1e": "Dock icon hidden", + "3c2e935e260f4c63": "Account unavailable", "3c94b4c75940c178": "Input Tokens", "3cfae5728b92b334": "Token usage: {tokens}", "3d13868593ae4eeb": "Display language", @@ -137,8 +140,11 @@ "4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.", "4d0680f9efaef147": "Unread", "4d99c976beb8827e": "Ready", + "4de7f5f1b34f038d": "{label}: time unknown", "4e30d7c9ed2b0eee": "Not set", "4eafa9e925b30bcd": "Custom", + "4f28e8ae1123b6c3": "No matching accounts", + "4fd4f40094d40891": "Deleting…", "51d04bc3d286f018": "Last calendar day", "51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models", "5228358a6db59fe7": "For example: now, 2026-08-23 18:00", @@ -161,6 +167,7 @@ "5acb7d61688e3c4f": "Confirm use", "5b17f59d33bde39e": "Error: {error}", "5ba65a74c4e792c5": "By type", + "5bf0e29af701addd": "Quota reset", "5c55a67935af8f45": "All", "5c62e36c152dfc7c": "Plugin runtime initialized", "5d59857bf039cac9": "Cursor Assistant v{version}", @@ -189,15 +196,19 @@ "66f7ceff962da68c": "Token unit prices used for the home page estimated value. Unit: USD per million tokens.", "680680288a6d2ad2": "Show in Dock", "68102220092c1f0f": "Detailed records cleared", + "68a79a6de12c601e": "Account configuration changed. Reopen account management.", "68ad603fafe4e0d6": "Import legacy configuration", "68ea5dd4d7af20e6": "System settings", "6a9906c79f26c0ba": "Start time", "6aa8f49cc992dfd7": "Test", "6ae80538c2b2572d": "Minimize window", + "6bdb4435095e5d28": "Saving…", "6d1876364ac6457d": "Proxy mode", "6e86570183c3cdd0": "You're up to date", "7005693f4f050bce": "Cache I/O {cost}", + "71659de804df039d": "Refreshing…", "72644ec4389da2f7": "Default layout", + "72cc6674739c45da": "When enabled, switches to another available account if the current account is unavailable. When disabled, uses only the current account.", "736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.", "7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.", "77c9e582e85583af": "Test failed", @@ -218,6 +229,7 @@ "802b0faf0ceb513e": "{label}: {percent}% left", "80a57e03f0717f91": "Not configured", "811a3b22a5a7f2d5": "Unable to connect to the local management service", + "81e7c66cd452e204": "Local status sync failed: {error}", "8213941f12320ce1": "This operating system or CPU architecture is not currently supported", "83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed", "83e8d0b7aff2b394": "Downloaded {downloaded} / {total}", @@ -242,6 +254,7 @@ "8f6f8d979c981ced": "Copied", "8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.", "90800c48a1dd0655": "{label} must be a JSON object", + "91208fd9658fe7ab": "Time reached; awaiting server confirmation", "919cb0ce0c8db4e7": "Leave blank to keep the current password", "91aaf184cfc17ffd": "Overview", "91af6e57e7453fbe": "Add account", @@ -284,12 +297,15 @@ "a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.", "a49ffd73bc85333d": "Average", "a4d222236dc1003d": "Failed to cancel test: {error}", + "a520a13adc25418a": "Next reset-card expiry", "a5fb6189a8ad011d": "Open tutorial", "a621ab606db2a11f": "Password", "a66e11477dcc97c1": "Add {name} account", "a693d69af48bfe48": "Save and test", "a748cc074f78de00": "View details", "a7617f42f898b2bf": "Use complete request URL", + "a7670b2f1e88da05": "{hours} h {minutes} min {seconds} s remaining", + "a7fb81b3ef8729b3": "Delete account {name}? You will need to add it again before using it.", "a8036485f9227f2c": "Drag to reorder", "a80b53f8848e6d27": "Failed to install update: {error}", "a98585871c5313ff": "Display name", @@ -345,7 +361,9 @@ "c9d146d006993cc1": "Cache statistics policy: default ({rate})", "cb2f1709f983d2f4": "Model name", "cb99f0138b032687": "Initialization downloads and installs the plugin runtime.", + "cd3266d91ae10abc": "Delete account", "cea1aafe9416de7b": "Request headers", + "cee9f804abbb8ab6": "Cooldown ends", "cfae1a14d2120c57": "Detailed mode", "cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.", "d0bfccc77315d887": "Last month", @@ -409,6 +427,7 @@ "eb4a3db23661fb52": "Applies to every model in this group and is used as the badge label in Cursor's model picker; clear it to fall back to the server domain.", "eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.", "eba54690937bc532": "Manage accounts", + "ebaf9fe3042917f0": "Expiry reached; awaiting refresh confirmation", "ec917db99e814b58": "{label} must be a non-negative number", "ed31fbb483ee1b0a": "Actions", "edc70de18c6da1a6": "Install local CA", @@ -416,6 +435,8 @@ "ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.", "eec6bf2dad677b9f": "Granted: {time}", "ef5d9908c45f4b88": "Cache read price ($/1M)", + "f0066d9b38394e82": "Current account: {name}", + "f3c1c197e6c604e2": "Use account", "f4694c46b1e19602": "Final request type", "f4a0b686421619eb": "Cache write price", "f4dcb6a3ceb32247": "Page {page} of {count}", @@ -444,6 +465,7 @@ "fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.", "fd77192739703811": "Bulk import", "fdc4cabc370fa3f7": "No options", + "fde0acab820590f1": "Automatic account switching", "fe6ec799e02ed9a7": "Token pricing", "fea405f9b01d1416": "Summary", "fec45092945f8790": "User guide", diff --git a/apps/desktop/src/i18n/locales/pt-BR.json b/apps/desktop/src/i18n/locales/pt-BR.json index 1ed853abf..0f5077699 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.json +++ b/apps/desktop/src/i18n/locales/pt-BR.json @@ -51,6 +51,7 @@ "1ae6b0a0f8266382": "Fechar janela", "1b5932b8946d2d68": "Excluir modelo", "1b7d5b1a9315fc64": "Calculando…", + "1bb1e785ad5a8608": "Conta ativa", "1c6926877b1bbfb2": "Total de requisições: {tokens}", "1cb7e646ac883fb2": "Porta do proxy", "1cdde778cfe6a130": "Fórmula: leitura de cache / (leitura de cache + entrada não em cache)", @@ -68,6 +69,7 @@ "2400fbd0aeab9e13": "Baixado {downloaded}", "24a0a24864454575": "Já existe, ignorado", "2555d6c7fbb7e070": "Insira o ID do modelo diretamente ou carregue os modelos retornados pela API.", + "28326a69ee50573c": "Nenhuma conta selecionada", "29585d7193539200": "Versão atual {version}", "296da37506328d89": "Desativar integração com o Cursor", "29fbbef32a6eb58b": "Não mostrar este anúncio novamente", @@ -105,6 +107,7 @@ "3a8c76b2ce785f96": "Revisar e importar", "3b63f1f9d5922e65": "Preço de gravação em cache ($/1M)", "3b67824289b5fa1e": "Ícone do Dock ocultado", + "3c2e935e260f4c63": "Conta indisponível", "3c94b4c75940c178": "Tokens de entrada", "3cfae5728b92b334": "Uso de tokens: {tokens}", "3d13868593ae4eeb": "Idioma da interface", @@ -137,8 +140,11 @@ "4b458e6e147221d7": "O caminho padrão do endpoint é adicionado automaticamente de acordo com o protocolo selecionado.", "4d0680f9efaef147": "Não lido", "4d99c976beb8827e": "Disponível", + "4de7f5f1b34f038d": "{label}: horário desconhecido", "4e30d7c9ed2b0eee": "Não definir", "4eafa9e925b30bcd": "Personalizado", + "4f28e8ae1123b6c3": "Nenhuma conta correspondente", + "4fd4f40094d40891": "Excluindo…", "51d04bc3d286f018": "Último dia civil", "51de3bcec137ab1b": "Testes de conectividade concluídos com sucesso para todos os {count} modelos", "5228358a6db59fe7": "Ex.: agora, 2026-08-23 18:00", @@ -161,6 +167,7 @@ "5acb7d61688e3c4f": "Confirmar uso", "5b17f59d33bde39e": "Erro: {error}", "5ba65a74c4e792c5": "Por tipo", + "5bf0e29af701addd": "Redefinição da cota", "5c55a67935af8f45": "Todos", "5c62e36c152dfc7c": "Runtime de plugins inicializado com sucesso", "5d59857bf039cac9": "Assistente Cursor v{version}", @@ -189,15 +196,19 @@ "66f7ceff962da68c": "Preços unitários de tokens usados no valor estimado da página inicial. Unidade: USD por milhão de tokens.", "680680288a6d2ad2": "Exibir no Dock", "68102220092c1f0f": "Registros detalhados limpos", + "68a79a6de12c601e": "A configuração da conta mudou. Reabra o gerenciamento de contas.", "68ad603fafe4e0d6": "Importar configuração legada", "68ea5dd4d7af20e6": "Configurações do sistema", "6a9906c79f26c0ba": "Horário de início", "6aa8f49cc992dfd7": "Testar", "6ae80538c2b2572d": "Minimizar janela", + "6bdb4435095e5d28": "Salvando…", "6d1876364ac6457d": "Modo do proxy", "6e86570183c3cdd0": "Você já está na versão mais recente", "7005693f4f050bce": "E/S de cache {cost}", + "71659de804df039d": "Atualizando…", "72644ec4389da2f7": "Layout padrão", + "72cc6674739c45da": "Quando ativada, troca para outra conta disponível se a conta atual estiver indisponível. Quando desativada, usa apenas a conta atual.", "736c9dc2a04c65fd": "A configuração do modelo foi alterada. Atualize e tente novamente.", "7392e20d61abaa07": "Também armazena requisições completas e respostas em fluxo; por padrão, apenas tempo, status e uso são gravados.", "77c9e582e85583af": "Falha no teste", @@ -218,6 +229,7 @@ "802b0faf0ceb513e": "{label}: {percent}% restante", "80a57e03f0717f91": "Não configurado", "811a3b22a5a7f2d5": "Não foi possível conectar ao serviço de gerenciamento local", + "81e7c66cd452e204": "Falha ao sincronizar o estado local: {error}", "8213941f12320ce1": "Este sistema operacional ou arquitetura de CPU não é suportado no momento", "83c4efccd9a6bf69": "Teste de conectividade cancelado: {successful} com sucesso, {failed} com falha", "83e8d0b7aff2b394": "Baixado {downloaded} / {total}", @@ -242,6 +254,7 @@ "8f6f8d979c981ced": "Copiado", "8f9b0d6cc477d334": "Escolha como o Cursor se conecta aos endpoints do TAB.", "90800c48a1dd0655": "{label} deve ser um objeto JSON", + "91208fd9658fe7ab": "Horário atingido; aguardando confirmação do servidor", "919cb0ce0c8db4e7": "Deixe em branco para manter a senha atual", "91aaf184cfc17ffd": "Visão geral", "91af6e57e7453fbe": "Adicionar conta", @@ -284,12 +297,15 @@ "a3ab741ceb188e9e": "Conteúdo da requisição não gravado. Ative os registros detalhados e tente novamente.", "a49ffd73bc85333d": "Média", "a4d222236dc1003d": "Falha ao cancelar o teste: {error}", + "a520a13adc25418a": "Próximo vencimento de cartão de redefinição", "a5fb6189a8ad011d": "Abrir tutorial", "a621ab606db2a11f": "Senha", "a66e11477dcc97c1": "Adicionar conta {name}", "a693d69af48bfe48": "Salvar e testar", "a748cc074f78de00": "Ver detalhes", "a7617f42f898b2bf": "Usar URL completa da requisição", + "a7670b2f1e88da05": "Restam {hours} h {minutes} min {seconds} s", + "a7fb81b3ef8729b3": "Excluir a conta {name}? Será necessário adicioná-la novamente para usá-la.", "a8036485f9227f2c": "Arrastar para reordenar", "a80b53f8848e6d27": "Falha ao instalar atualização: {error}", "a98585871c5313ff": "Nome de exibição", @@ -345,7 +361,9 @@ "c9d146d006993cc1": "Política de estatísticas de cache: padrão ({rate})", "cb2f1709f983d2f4": "Nome do modelo", "cb99f0138b032687": "A inicialização baixará e instalará o runtime de plugins.", + "cd3266d91ae10abc": "Excluir conta", "cea1aafe9416de7b": "Cabeçalhos da requisição", + "cee9f804abbb8ab6": "Fim do intervalo de espera", "cfae1a14d2120c57": "Modo detalhado", "cfe085015632e9c8": "Porta do serviço de gerenciamento local usada pelo frontend desktop. Digite 0 para escolher uma porta aleatória ao iniciar.", "d0bfccc77315d887": "Último mês", @@ -409,6 +427,7 @@ "eb4a3db23661fb52": "Aplica-se a todos os modelos deste grupo e é usado como rótulo de destaque no seletor de modelos do Cursor; limpe para voltar a exibir o domínio do servidor.", "eb77492c9f76a7e1": "O comando de instalação foi copiado automaticamente. Clique em \"Abrir terminal\", cole o comando no terminal e insira sua senha quando solicitado.", "eba54690937bc532": "Gerenciamento de contas", + "ebaf9fe3042917f0": "Vencimento atingido; aguardando confirmação da atualização", "ec917db99e814b58": "{label} deve ser um número não negativo", "ed31fbb483ee1b0a": "Ações", "edc70de18c6da1a6": "Instalar CA local", @@ -416,6 +435,8 @@ "ee6b89a6a740a4c4": "Se uma porta estiver ocupada, uma nova porta aleatória será selecionada e salva automaticamente. Reinicie o aplicativo após alterar essas configurações.", "eec6bf2dad677b9f": "Concedido em: {time}", "ef5d9908c45f4b88": "Preço de leitura de cache ($/1M)", + "f0066d9b38394e82": "Conta atual: {name}", + "f3c1c197e6c604e2": "Usar conta", "f4694c46b1e19602": "Tipo final de requisição", "f4a0b686421619eb": "Preço de gravação em cache", "f4dcb6a3ceb32247": "Página {page} de {count}", @@ -444,6 +465,7 @@ "fd415f8e0097c832": "Leituras e gravações de cache já estão incluídas nas estatísticas do prompt.", "fd77192739703811": "Importação em lote", "fdc4cabc370fa3f7": "Nenhuma opção", + "fde0acab820590f1": "Troca automática de conta", "fe6ec799e02ed9a7": "Preços de tokens", "fea405f9b01d1416": "Visão geral", "fec45092945f8790": "Tutorial de uso", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 23750229f..73d61ab6b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -51,6 +51,7 @@ "1ae6b0a0f8266382": "关闭窗口", "1b5932b8946d2d68": "删除模型", "1b7d5b1a9315fc64": "计算中…", + "1bb1e785ad5a8608": "当前账号", "1c6926877b1bbfb2": "总请求:{tokens}", "1cb7e646ac883fb2": "代理端口", "1cdde778cfe6a130": "公式:缓存读取 /(缓存读取 + 非缓存输入)", @@ -68,6 +69,7 @@ "2400fbd0aeab9e13": "已下载 {downloaded}", "24a0a24864454575": "已存在,跳过", "2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。", + "28326a69ee50573c": "未选择账号", "29585d7193539200": "当前版本 {version}", "296da37506328d89": "关闭接管Cursor", "29fbbef32a6eb58b": "不再显示此广告", @@ -105,6 +107,7 @@ "3a8c76b2ce785f96": "查看并导入", "3b63f1f9d5922e65": "缓存写入价格($/1M)", "3b67824289b5fa1e": "已隐藏 Dock 栏图标", + "3c2e935e260f4c63": "账号不可用", "3c94b4c75940c178": "输入 Token", "3cfae5728b92b334": "Token 用量:{tokens}", "3d13868593ae4eeb": "界面语言", @@ -137,8 +140,11 @@ "4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。", "4d0680f9efaef147": "未读", "4d99c976beb8827e": "可用", + "4de7f5f1b34f038d": "{label}:时间未知", "4e30d7c9ed2b0eee": "不设置", "4eafa9e925b30bcd": "自定义", + "4f28e8ae1123b6c3": "没有匹配的账号", + "4fd4f40094d40891": "正在删除…", "51d04bc3d286f018": "近1自然日", "51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功", "5228358a6db59fe7": "如:现在、2026-08-23 18:00", @@ -161,6 +167,7 @@ "5acb7d61688e3c4f": "确认使用", "5b17f59d33bde39e": "错误:{error}", "5ba65a74c4e792c5": "按类型", + "5bf0e29af701addd": "额度重置", "5c55a67935af8f45": "全部", "5c62e36c152dfc7c": "插件运行时初始化完成", "5d59857bf039cac9": "Cursor 助手 v{version}", @@ -189,15 +196,19 @@ "66f7ceff962da68c": "用于首页价值估算的 Token 单价,单位:美元 / 百万 Token。", "680680288a6d2ad2": "在 Dock 栏显示", "68102220092c1f0f": "详细记录已清理", + "68a79a6de12c601e": "账号配置已发生变化,请重新打开账号管理。", "68ad603fafe4e0d6": "导入旧版配置", "68ea5dd4d7af20e6": "系统设置", "6a9906c79f26c0ba": "开始时间", "6aa8f49cc992dfd7": "测试", "6ae80538c2b2572d": "最小化窗口", + "6bdb4435095e5d28": "正在保存…", "6d1876364ac6457d": "代理方式", "6e86570183c3cdd0": "当前已是最新版本", "7005693f4f050bce": "缓存读写 {cost}", + "71659de804df039d": "正在刷新…", "72644ec4389da2f7": "默认平铺", + "72cc6674739c45da": "开启后,当前账号不可用时自动切换到其他可用账号;关闭后仅使用当前账号。", "736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试", "7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。", "77c9e582e85583af": "测试失败", @@ -218,6 +229,7 @@ "802b0faf0ceb513e": "{label} 剩余 {percent}%", "80a57e03f0717f91": "未配置", "811a3b22a5a7f2d5": "无法连接本地管理服务", + "81e7c66cd452e204": "本地状态同步失败:{error}", "8213941f12320ce1": "当前操作系统或 CPU 架构暂不受支持", "83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}", "83e8d0b7aff2b394": "已下载 {downloaded} / {total}", @@ -242,6 +254,7 @@ "8f6f8d979c981ced": "已复制", "8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。", "90800c48a1dd0655": "{label} 必须是 JSON 对象", + "91208fd9658fe7ab": "已到时间,等待服务端确认", "919cb0ce0c8db4e7": "留空表示保留当前密码", "91aaf184cfc17ffd": "数据概览", "91af6e57e7453fbe": "添加账号", @@ -284,12 +297,15 @@ "a3ab741ceb188e9e": "未记录请求内容,请开启详细记录后重试。", "a49ffd73bc85333d": "平均", "a4d222236dc1003d": "取消测试失败:{error}", + "a520a13adc25418a": "最近重置卡到期", "a5fb6189a8ad011d": "打开教程", "a621ab606db2a11f": "密码", "a66e11477dcc97c1": "添加 {name} 账号", "a693d69af48bfe48": "保存并测试", "a748cc074f78de00": "查看详情", "a7617f42f898b2bf": "使用完整请求地址", + "a7670b2f1e88da05": "剩余 {hours} 小时 {minutes} 分 {seconds} 秒", + "a7fb81b3ef8729b3": "确定删除账号 {name} 吗?删除后需要重新添加才能使用。", "a8036485f9227f2c": "拖动排序", "a80b53f8848e6d27": "安装更新失败:{error}", "a98585871c5313ff": "显示名称", @@ -345,7 +361,9 @@ "c9d146d006993cc1": "缓存统计策略:默认口径({rate})", "cb2f1709f983d2f4": "模型名称", "cb99f0138b032687": "初始化将下载并安装插件运行时。", + "cd3266d91ae10abc": "删除账号", "cea1aafe9416de7b": "请求头", + "cee9f804abbb8ab6": "冷却结束", "cfae1a14d2120c57": "详细模式", "cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。", "d0bfccc77315d887": "近一个月", @@ -409,6 +427,7 @@ "eb4a3db23661fb52": "应用于该分组下的全部模型,并作为 Cursor 模型选择器中的徽章标签;清空则恢复显示服务器域名。", "eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。", "eba54690937bc532": "账号管理", + "ebaf9fe3042917f0": "已到期,等待刷新确认", "ec917db99e814b58": "{label}必须是非负数", "ed31fbb483ee1b0a": "操作", "edc70de18c6da1a6": "安装本地 CA", @@ -416,6 +435,8 @@ "ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。", "eec6bf2dad677b9f": "发放时间:{time}", "ef5d9908c45f4b88": "缓存读取价格($/1M)", + "f0066d9b38394e82": "当前账号:{name}", + "f3c1c197e6c604e2": "使用此账号", "f4694c46b1e19602": "最终请求类型", "f4a0b686421619eb": "缓存写入价格", "f4dcb6a3ceb32247": "第 {page} / {count} 页", @@ -444,6 +465,7 @@ "fd415f8e0097c832": "缓存读写已计入提示词侧统计。", "fd77192739703811": "批量导入", "fdc4cabc370fa3f7": "暂无选项", + "fde0acab820590f1": "自动切换账号", "fe6ec799e02ed9a7": "Token 定价", "fea405f9b01d1416": "概览", "fec45092945f8790": "使用教程", diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index bb1f36bf6..08568d1fe 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -212,6 +212,7 @@ export interface PluginResourceMetric { unit: "percent" | "count"; value: number; resetAtMs?: number | null; + expiresAtMs?: number | null; } export interface PluginResourceView { @@ -267,7 +268,14 @@ export interface PluginResourceActionResult { cards: PluginResourceActionCard[]; } +export interface PluginResourceSelection { + activeResourceId: string | null; + automaticSwitching: boolean; + revision: number; +} + export interface PluginResourceDescriptor { + selection: PluginResourceSelection; type: string; displayName: PluginLocalizedText; add: PluginAddMethod[]; @@ -510,6 +518,7 @@ export const api = { cursorHarness: () => request("/harness/cursor/status"), initializeCursorCa: () => request("/harness/cursor/ca/initialize", { method: "POST" }), plugins: () => request("/plugins"), + setPluginResourceSelection: (pluginId: string, resourceType: string, selection: Omit) => request(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/selection`, { method: "PUT", body: JSON.stringify(selection) }), pluginOAuthBegin: (pluginId: string, resourceType: string, methodId: string) => request(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/add/${encodeURIComponent(methodId)}/begin`, { method: "POST" }), pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }), importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }), diff --git a/apps/desktop/src/shared/store/appStore.ts b/apps/desktop/src/shared/store/appStore.ts index 03c98f8f7..0701316a1 100644 --- a/apps/desktop/src/shared/store/appStore.ts +++ b/apps/desktop/src/shared/store/appStore.ts @@ -1,7 +1,11 @@ import { useSyncExternalStore } from "react"; -import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings, type TokenPricingSettings } from "../api"; +import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginResourceSelection, type PluginRuntimeStatus, type PortSettings, type TokenPricingSettings } from "../api"; +import { applyPluginSelection, createSingleFlight, mergePluginSnapshots } from "./pluginState"; import { applyTheme, isThemeId, type ThemeId } from "../theme/theme"; +const pluginRequests = createSingleFlight(); +let pluginSnapshotRevision = 0; + export const DEFAULT_TOKEN_PRICING: TokenPricingSettings = { input_per_million: 5.0, output_per_million: 25.0, @@ -23,6 +27,7 @@ export type AppSnapshot = { cursorBusy: boolean; pluginRuntime: PluginRuntimeStatus | null; plugins: PluginDescriptor[]; + pluginSelectionPending: string[]; }; const savedTheme = (): ThemeId => { @@ -58,11 +63,13 @@ let snapshot: AppSnapshot = { cursorBusy: false, pluginRuntime: null, plugins: [], + pluginSelectionPending: [], }; const listeners = new Set<() => void>(); function update(patch: Partial) { + if (patch.plugins) patch = { ...patch, plugins: mergePluginSnapshots(snapshot.plugins, patch.plugins) }; snapshot = { ...snapshot, ...patch }; listeners.forEach((listener) => listener()); } @@ -159,13 +166,39 @@ export const appStore = { return null; } }, + // Account-manager reads are local snapshots, never upstream quota refreshes. + readPlugins(afterMutation = false) { + if (afterMutation) pluginSnapshotRevision += 1; + return pluginRequests("snapshot", async () => { + for (;;) { + const revision = pluginSnapshotRevision; + const plugins = await api.plugins(); + if (revision !== pluginSnapshotRevision) continue; + update({ plugins }); + return; + } + }); + }, async refreshPlugins() { try { - update({ plugins: await api.plugins() }); + await appStore.readPlugins(); } catch (cause) { update({ error: cause instanceof Error ? cause.message : String(cause) }); } }, + async setPluginResourceSelection(pluginId: string, resourceType: string, selection: Omit) { + const key = JSON.stringify([pluginId, resourceType]); + return pluginRequests(key, async () => { + update({ pluginSelectionPending: [...snapshot.pluginSelectionPending, key] }); + try { + const saved = await api.setPluginResourceSelection(pluginId, resourceType, selection); + update({ plugins: applyPluginSelection(snapshot.plugins, pluginId, resourceType, saved) }); + return saved; + } finally { + update({ pluginSelectionPending: snapshot.pluginSelectionPending.filter((item) => item !== key) }); + } + }); + }, async removePluginConfiguration(pluginId: string) { await perform(async () => { await api.removePluginConfiguration(pluginId); diff --git a/apps/desktop/src/shared/store/pluginState.ts b/apps/desktop/src/shared/store/pluginState.ts new file mode 100644 index 000000000..b87f7b2e5 --- /dev/null +++ b/apps/desktop/src/shared/store/pluginState.ts @@ -0,0 +1,36 @@ +import type { PluginDescriptor, PluginResourceSelection } from "../api.ts"; + +/** Share only in-flight work. A later explicit operation always runs again. */ +export function createSingleFlight() { + const pending = new Map>(); + return function singleFlight(key: string, task: () => Promise): Promise { + const existing = pending.get(key); + if (existing) return existing as Promise; + const promise = Promise.resolve().then(task); + pending.set(key, promise); + const clear = () => { if (pending.get(key) === promise) pending.delete(key); }; + void promise.then(clear, clear); + return promise; + }; +} + +/** A slow local snapshot must not overwrite a newer selection mutation. */ +export function mergePluginSnapshots(current: PluginDescriptor[], incoming: PluginDescriptor[]): PluginDescriptor[] { + return incoming.map((plugin) => { + const previous = current.find((item) => item.id === plugin.id); + return { ...plugin, resources: plugin.resources.map((resource) => { + const old = previous?.resources.find((item) => item.type === resource.type); + return old && old.selection.revision > resource.selection.revision + ? { ...resource, selection: old.selection } + : resource; + }) }; + }); +} + +export function applyPluginSelection(plugins: PluginDescriptor[], pluginId: string, type: string, selection: PluginResourceSelection) { + return plugins.map((plugin) => plugin.id !== pluginId ? plugin : { + ...plugin, + resources: plugin.resources.map((resource) => resource.type !== type || resource.selection.revision > selection.revision + ? resource : { ...resource, selection }), + }); +} diff --git a/apps/desktop/tests/README.md b/apps/desktop/tests/README.md new file mode 100644 index 000000000..105867d0c --- /dev/null +++ b/apps/desktop/tests/README.md @@ -0,0 +1,27 @@ +# Plugin account tests + +`npm run test:plugins` uses Node's built-in test runner (Node 22.6+), with no extra dependencies. It covers refresh identity/lifetime, in-flight deduplication and retries, selection revision ordering, countdown boundaries, and the isolated fixture API. + +## Browser fixture + +From `apps/desktop`, run: + +```sh +npm run start -- --config vite.demo.config.ts --host 127.0.0.1 --port 4179 --strictPort +``` + +Open `http://127.0.0.1:4179/product-demo/demo/index.html?locale=en-US#/plugins`. + +The demo intercepts management API requests in memory. It never calls the management server or real account providers. The production entry point does not import the fixture. Use `locale=zh-CN` or `locale=pt-BR`, `theme=default-light`, and `fixtureDelay=3000` as needed. + +The three fixture plugins all support quota refresh and have saved accounts but failed model synchronization. Codex has 12 accounts, with account 12 selected outside the first page. Account 2 is cooling, account 3 deliberately rejects refresh and selection, and account 4 has unknown timestamps. Remaining values are retained at elapsed countdowns. + +The demo exposes `window.pluginFixture` for browser test control: + +- `calls` records method/path for every plugin request. Opening Codex management should issue one refresh POST per account, including under StrictMode. Further 5-second ticks only issue local GET snapshots. +- `addAccount()` adds a synthetic account. The next local snapshot should reveal it and trigger exactly one automatic refresh for that new ID. +- `setSnapshotFailure(true)` simulates local snapshot failure; existing rows and values should remain visible. Set it back to `false` to recover on the next tick. +- `setSelectionFailure(true)` rejects both account selection and automatic switching; the displayed selection must remain unchanged. +- `plugins` contains mutable fixture snapshots. Update selection and increment its `revision` to simulate automatic server switching; it should appear on the next poll. + +Verify search/page changes do not refresh accounts; explicit manual refresh does. A new opening starts a new refresh lifecycle, while a rapid close/reopen during an unfinished request shares that in-flight request. Closing the manager, switching away from the cached route, or hiding the document stops polling. Late completions may update shared server snapshots but must not reopen closed UI. diff --git a/apps/desktop/tests/pluginAccounts.test.ts b/apps/desktop/tests/pluginAccounts.test.ts new file mode 100644 index 000000000..aaa9fd64e --- /dev/null +++ b/apps/desktop/tests/pluginAccounts.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createSingleFlight, mergePluginSnapshots, applyPluginSelection } from "../src/shared/store/pluginState.ts"; +import { remainingSeconds, showsMetricDeadline, takeNewRefreshTargets } from "../src/features/plugins/accountLifecycle.ts"; +import { createPluginFixture } from "../src/demo/pluginFixture.ts"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +test("automatic refresh consumes IDs once per manager, not per snapshot or page", () => { + const fixture = createPluginFixture(1000, 0); + const resources = fixture.plugins[0].resources; + const seen = new Set(); + assert.equal(takeNewRefreshTargets(seen, resources).length, 12); + assert.equal(takeNewRefreshTargets(seen, structuredClone(resources)).length, 0); + const id = fixture.addAccount(); + assert.deepEqual(takeNewRefreshTargets(seen, resources), [{ type: "accounts", id }]); + assert.equal(takeNewRefreshTargets(seen, resources).length, 0); + assert.equal(takeNewRefreshTargets(new Set(), resources).length, 13); +}); + +test("capabilities govern refresh for all providers and types remain distinct", () => { + const fixture = createPluginFixture(1000, 0); + for (const plugin of fixture.plugins) assert.ok(takeNewRefreshTargets(new Set(), plugin.resources).length > 0); + const resource = fixture.plugins[0].resources[0]; + assert.equal(takeNewRefreshTargets(new Set(), [{ ...resource, canRefresh: false }]).length, 0); + assert.equal(takeNewRefreshTargets(new Set(), [resource, { ...resource, type: "other" }]).length, 24); +}); + +test("StrictMode/reopen joins in-flight refresh without preventing subsequent manual retry", async () => { + const run = createSingleFlight(); + const pending = deferred(); + let count = 0; + const task = () => { count++; return pending.promise; }; + const first = run("account", task); + const reopened = run("account", task); + assert.equal(first, reopened); + await Promise.resolve(); + assert.equal(count, 1); + pending.resolve(7); + assert.equal(await reopened, 7); + await run("account", task); + assert.equal(count, 2); +}); + +test("failed refresh is retryable and independent accounts remain concurrent", async () => { + const run = createSingleFlight(); + const blocked = deferred(); + const first = run("one", () => blocked.promise); + assert.equal(await run("two", async () => 2), 2); + await assert.rejects(run("failure", async () => { throw new Error("offline"); }), /offline/); + assert.equal(await run("failure", async () => 3), 3); + blocked.resolve(1); + assert.equal(await first, 1); +}); + +test("authoritative selection survives slow snapshots while newer revisions are adopted", () => { + const fixture = createPluginFixture(1000, 0); + const previous = structuredClone(fixture.plugins); + const selected = applyPluginSelection(previous, "fixture-codex", "accounts", { activeResourceId: "account-2", automaticSwitching: false, revision: 5 }); + const merged = mergePluginSnapshots(selected, fixture.plugins); + assert.deepEqual(merged[0].resources[0].selection, selected[0].resources[0].selection); + fixture.plugins[0].resources[0].selection = { activeResourceId: "account-4", automaticSwitching: true, revision: 6 }; + assert.equal(mergePluginSnapshots(merged, fixture.plugins)[0].resources[0].selection.activeResourceId, "account-4"); + assert.equal(previous[0].resources[0].selection.revision, 1); +}); + +test("late PUT response cannot replace a newer polled selection", () => { + const fixture = createPluginFixture(1000, 0); + fixture.plugins[0].resources[0].selection.revision = 10; + const updated = applyPluginSelection(fixture.plugins, "fixture-codex", "accounts", { activeResourceId: "account-2", automaticSwitching: false, revision: 9 }); + assert.equal(updated[0].resources[0].selection.activeResourceId, "account-12"); +}); + +test("countdown handles missing/invalid timestamps and clamps elapsed time without changing quota", () => { + assert.equal(remainingSeconds(null, 1000), null); + assert.equal(remainingSeconds(undefined, 1000), null); + assert.equal(remainingSeconds(NaN, 1000), null); + assert.equal(remainingSeconds(1001, 1000), 1); + assert.equal(remainingSeconds(1000, 1000), 0); + assert.equal(remainingSeconds(999, 1000), 0); +}); + +test("Rust nullable metric fields show only the relevant unknown deadline", () => { + const quota = { id: "weekly", label: "Quota", unit: "percent" as const, value: 50, resetAtMs: null, expiresAtMs: null }; + const cards = { ...quota, id: "cards", unit: "count" as const, value: 2 }; + assert.equal(showsMetricDeadline(quota, "reset"), true); + assert.equal(showsMetricDeadline(quota, "expiry"), false); + assert.equal(showsMetricDeadline(cards, "reset"), false); + assert.equal(showsMetricDeadline(cards, "expiry"), true); + assert.equal(showsMetricDeadline({ ...quota, expiresAtMs: undefined }, "expiry"), false); + assert.equal(showsMetricDeadline({ ...cards, resetAtMs: undefined }, "reset"), false); +}); + +test("explicit timestamps remain visible regardless of metric unit, including epoch zero", () => { + const quota = { id: "weekly", label: "Quota", unit: "percent" as const, value: 50, resetAtMs: 1000, expiresAtMs: null }; + const cards = { id: "cards", label: "Reset cards", unit: "count" as const, value: 2, resetAtMs: null, expiresAtMs: 1000 }; + assert.equal(showsMetricDeadline(quota, "expiry"), false); + assert.equal(showsMetricDeadline(cards, "reset"), false); + assert.equal(showsMetricDeadline({ ...quota, expiresAtMs: 0 }, "expiry"), true); + assert.equal(showsMetricDeadline({ ...cards, resetAtMs: 0 }, "reset"), true); +}); + +test("isolated fixture exercises successful and rejected selection without real accounts", async () => { + const fixture = createPluginFixture(1000, 0); + assert.equal(fixture.plugins[0].providers[0].configured, false); + const url = "/plugins/fixture-codex/resources/accounts/selection"; + const response = await fixture.handle(url, "PUT", { activeResourceId: "account-2", automaticSwitching: false }); + assert.equal(response?.status, 200); + assert.deepEqual(await response?.json(), { activeResourceId: "account-2", automaticSwitching: false, revision: 2 }); + const rejected = await fixture.handle(url, "PUT", { activeResourceId: "account-3", automaticSwitching: true }); + assert.equal(rejected?.status, 409); + assert.equal(fixture.plugins[0].resources[0].selection.activeResourceId, "account-2"); + assert.equal(fixture.plugins[0].resources[0].selection.automaticSwitching, false); +}); diff --git a/server/plugins/build-in/codex-auth/codex_test.ts b/server/plugins/build-in/codex-auth/codex_test.ts index 94f21bccf..fe441b32a 100644 --- a/server/plugins/build-in/codex-auth/codex_test.ts +++ b/server/plugins/build-in/codex-auth/codex_test.ts @@ -11,6 +11,7 @@ import { parseOfficialModels } from "./models.ts"; import { buildResponsesBody } from "cursor-byok:protocol/openai-responses"; import { codexProvider, isQuotaError } from "./provider.ts"; import { + accountData, accountIdentity, consumeResetCardAction, credentialDraft, @@ -19,6 +20,7 @@ import { parseCredentialFiles, presentAccount, quotaState, + refreshAccount, RESOURCE_TYPE, } from "./resources.ts"; @@ -206,7 +208,7 @@ Deno.test("reset card action consumes a selected card and refreshes quota state" context({ fetch: (url, init) => { requestNumber += 1; - if (requestNumber === 1 || requestNumber === 4) { + if (requestNumber === 1 || requestNumber === 4 || requestNumber === 5) { assertEquals(url, "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"); return { status: 200, @@ -238,7 +240,7 @@ Deno.test("reset card action consumes a selected card and refreshes quota state" }, }), ); - assertEquals(requestNumber, 4); + assertEquals(requestNumber, 5); assertEquals(result.cards, []); const quota = (result.patch?.privateData as Record).quota as Record< string, @@ -249,6 +251,282 @@ Deno.test("reset card action consumes a selected card and refreshes quota state" assertEquals(quota.fiveHour, null); }); +function quotaResource(): ResourceSnapshot { + return snapshot({ + accessToken: "access-secret", + refreshToken: "refresh-secret", + accountId: "acct-1", + displayName: "person@example.com", + quota: { + ...parseCodexUsage({ rate_limit_reset_credits: { available_count: 9 } }), + resetCreditsExpiresAtMs: Date.parse("2100-01-01T00:00:00Z"), + }, + } as unknown as JsonValue); +} + +const freshUsage = { + plan_type: "plus", + rate_limit: { + primary_window: { used_percent: 20, reset_at: 4_102_444_800 }, + secondary_window: { used_percent: 25, reset_at: 4_103_049_600 }, + }, + rate_limit_reset_credits: { available_count: 3 }, +}; + +async function refreshWithDetails(details: NetworkResponse | Error): Promise { + const resource = quotaResource(); + let requests = 0; + const patch = await refreshAccount( + resource, + context({ + fetch: (url, init) => { + requests += 1; + assertEquals(init?.headers?.authorization, "Bearer access-secret"); + assertEquals(init?.headers?.["ChatGPT-Account-Id"], "acct-1"); + if (requests === 1) { + assertEquals(url, "https://chatgpt.com/backend-api/wham/usage"); + return { status: 200, headers: {}, body: JSON.stringify(freshUsage) }; + } + assertEquals(requests, 2); + assertEquals(url, "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"); + if (details instanceof Error) throw details; + return details; + }, + }), + ); + assertEquals(requests, 2); + assertEquals(patch.state, { status: "ready" }); + assert(patch.privateData); + const updated = { ...resource, privateData: patch.privateData }; + const data = accountData(updated); + assertEquals(data.accessToken, "access-secret"); + assertEquals(data.refreshToken, "refresh-secret"); + assertEquals(data.quota?.weekly?.remainingPercent, 75); + assertEquals(data.quota?.fiveHour?.remainingPercent, 80); + const view = JSON.stringify(presentAccount(updated)); + assert(!view.includes("access-secret")); + assert(!view.includes("refresh-secret")); + return updated; +} + +function jsonResponse(body: unknown): NetworkResponse { + return { status: 200, headers: {}, body: JSON.stringify(body) }; +} + +Deno.test("refresh stores authoritative card count and earliest future available expiry", async () => { + const resource = await refreshWithDetails(jsonResponse({ + available_count: 7, + credits: [ + { id: "used", status: "used", expires_at: "2098-01-01T00:00:00Z" }, + { id: "expired", status: "expired", expires_at: "2098-02-01T00:00:00Z" }, + { id: "elapsed", status: "available", expires_at: 1 }, + { id: "unknown", status: "available" }, + { id: "late", status: "available", expires_at: "2100-01-01T00:00:00Z" }, + { id: "first", status: "available", expires_at: "2099-01-01T00:00:00Z" }, + ], + access_token: "must-not-be-exposed", + })); + assertEquals(accountData(resource).quota?.resetCreditsAvailable, 7); + assertEquals( + accountData(resource).quota?.resetCreditsExpiresAtMs, + Date.parse("2099-01-01T00:00:00Z"), + ); + const metric = presentAccount(resource).metrics?.find((item) => item.id === "reset-credits"); + assertEquals(metric, { + id: "reset-credits", + label: { "en-US": "Reset cards", "zh-CN": "重置卡" }, + unit: "count", + value: 7, + expiresAtMs: Date.parse("2099-01-01T00:00:00Z"), + }); + assert(!JSON.stringify(presentAccount(resource)).includes("must-not-be-exposed")); +}); + +Deno.test("refresh preserves usage and clears stale expiry on failed or malformed details", async () => { + const failures: (NetworkResponse | Error)[] = [ + new Error("offline"), + { status: 500, headers: {}, body: "unavailable" }, + { status: 401, headers: {}, body: "unauthorized" }, + { status: 200, headers: {}, body: "not JSON" }, + { status: 200, headers: {}, body: "" }, + ...[ + null, + [], + {}, + { available_count: 0 }, + { credits: [] }, + { credits: "invalid" }, + { credits: [null] }, + { credits: [{ id: "missing-status" }] }, + { credits: [{ status: "available" }] }, + { available_count: "invalid", credits: [] }, + { available_count: -1, credits: [] }, + { available_count: 1.5, credits: [] }, + ].map(jsonResponse), + ]; + for (const details of failures) { + const resource = await refreshWithDetails(details); + assertEquals(accountData(resource).quota?.resetCreditsAvailable, 3); + assertEquals(accountData(resource).quota?.resetCreditsExpiresAtMs, null); + const metric = presentAccount(resource).metrics?.find((item) => item.id === "reset-credits"); + assertEquals(metric?.value, 3); + assert(metric && !("expiresAtMs" in metric)); + assert(!("resetAtMs" in metric)); + } +}); + +Deno.test("usage 401 invalidates authorization without requesting details or renewing tokens", async () => { + let requests = 0; + const patch = await refreshAccount( + quotaResource(), + context({ + fetch: (url) => { + requests += 1; + assertEquals(url, "https://chatgpt.com/backend-api/wham/usage"); + return { status: 401, headers: {}, body: "unauthorized" }; + }, + }), + ); + assertEquals(requests, 1); + assertEquals(patch, { + state: { status: "invalid", message: "ChatGPT authorization expired; sign in again" }, + }); +}); + +Deno.test("explicit zero card balance has no expiry even with inconsistent available cards", async () => { + for ( + const credits of [[], [{ + id: "stale-card", + status: "available", + expires_at: "2100-01-01T00:00:00Z", + }]] + ) { + const resource = await refreshWithDetails(jsonResponse({ available_count: 0, credits })); + assertEquals(accountData(resource).quota?.resetCreditsAvailable, 0); + assertEquals(accountData(resource).quota?.resetCreditsExpiresAtMs, null); + const metric = presentAccount(resource).metrics?.find((item) => item.id === "reset-credits"); + assertEquals(metric?.value, 0); + assert(metric && !("expiresAtMs" in metric)); + } +}); + +Deno.test("available cards without valid future expiry keep their count with unknown expiry", async () => { + for (const expires_at of [undefined, null, "invalid", 1]) { + const resource = await refreshWithDetails(jsonResponse({ + available_count: 2, + credits: [{ id: "credit-1", status: "available", expires_at }], + })); + assertEquals(accountData(resource).quota?.resetCreditsAvailable, 2); + assertEquals(accountData(resource).quota?.resetCreditsExpiresAtMs, null); + assertEquals( + presentAccount(resource).metrics?.find((item) => item.id === "reset-credits")?.expiresAtMs, + undefined, + ); + } +}); + +Deno.test("card expiry accepts seconds, milliseconds, numeric strings and ISO dates", async () => { + const expected = Date.parse("2100-01-01T00:00:00Z"); + for ( + const expires_at of [ + expected / 1000, + expected, + String(expected / 1000), + String(expected), + "2100-01-01T00:00:00Z", + ] + ) { + const resource = await refreshWithDetails(jsonResponse({ + available_count: 1, + credits: [{ id: "credit-1", status: "available", expires_at }], + })); + assertEquals(accountData(resource).quota?.resetCreditsExpiresAtMs, expected); + } +}); + +Deno.test("complete nonempty card details can supply a count when the total is omitted", async () => { + const resource = await refreshWithDetails(jsonResponse({ + credits: [ + { id: "available", status: "available" }, + { id: "used", status: "used" }, + { id: "expired", status: "expired" }, + ], + })); + assertEquals(accountData(resource).quota?.resetCreditsAvailable, 1); +}); + +Deno.test("presentation omits elapsed expiry without clearing the entire card balance", () => { + const resource = quotaResource(); + const data = accountData(resource); + assert(data.quota); + data.quota.resetCreditsExpiresAtMs = Date.now() - 1; + const metric = presentAccount(snapshot(data as unknown as JsonValue)).metrics?.find((item) => + item.id === "reset-credits" + ); + assertEquals(metric?.value, 9); + assert(metric && !("expiresAtMs" in metric)); + assert(!("resetAtMs" in metric)); +}); + +Deno.test("reset card consumption rejects elapsed available cards before POST", async () => { + let requests = 0; + let error = ""; + try { + await consumeResetCardAction.run( + quotaResource(), + { cardId: "credit-1" }, + context({ + fetch: (url) => { + requests += 1; + assertEquals(url, "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"); + return jsonResponse({ + available_count: 1, + credits: [{ id: "credit-1", status: "available", expires_at: Date.now() - 1 }], + }); + }, + }), + ); + } catch (caught) { + error = (caught as Error).message; + } + assertEquals(error, "The selected reset card is not available"); + assertEquals(requests, 1); +}); + +Deno.test("reset card consumption requires the upstream reset result code", async () => { + for (const body of [JSON.stringify({ code: "not_reset" }), "{}", "not JSON"]) { + let requests = 0; + let error = ""; + try { + await consumeResetCardAction.run( + quotaResource(), + { cardId: "credit-1" }, + context({ + fetch: (url) => { + requests += 1; + if (requests === 1) { + assertEquals(url, "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"); + return jsonResponse({ + available_count: 1, + credits: [{ id: "credit-1", status: "available" }], + }); + } + assertEquals( + url, + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", + ); + return { status: 200, headers: {}, body }; + }, + }), + ); + } catch (caught) { + error = (caught as Error).message; + } + assert(error.startsWith("Codex reset card consumption")); + assertEquals(requests, 2); + } +}); + Deno.test("exhausted quota projects a cooling state until the latest reset", () => { const quota = parseCodexUsage({ rate_limit: { diff --git a/server/plugins/build-in/codex-auth/plugin.json b/server/plugins/build-in/codex-auth/plugin.json index 6d48c161a..e2ad5db27 100644 --- a/server/plugins/build-in/codex-auth/plugin.json +++ b/server/plugins/build-in/codex-auth/plugin.json @@ -2,7 +2,7 @@ "apiVersion": 1, "id": "dev.cursorbyok.examples.codex-auth", "name": "Codex", - "version": "0.1.7", + "version": "0.1.8", "author": "@leookun", "minAppVersion": "0.1.0", "icon": "assets/codex.svg", diff --git a/server/plugins/build-in/codex-auth/resources.ts b/server/plugins/build-in/codex-auth/resources.ts index 94f3df81e..869dbe599 100644 --- a/server/plugins/build-in/codex-auth/resources.ts +++ b/server/plugins/build-in/codex-auth/resources.ts @@ -35,6 +35,8 @@ export type AccountQuota = { weekly: QuotaWindow | null; fiveHour: QuotaWindow | null; resetCreditsAvailable: number | null; + /** Earliest known future expiry of one available card, not of the entire balance. */ + resetCreditsExpiresAtMs: number | null; limitReached: boolean; updatedAtMs: number; }; @@ -232,6 +234,7 @@ export function parseCodexUsage(body: unknown, nowMs = Date.now()): AccountQuota resetCreditsAvailable: resetCreditsAvailable === null ? null : Math.max(0, Math.floor(resetCreditsAvailable)), + resetCreditsExpiresAtMs: null, limitReached: typeof explicitLimit === "boolean" ? explicitLimit : [weekly, fiveHour].some( (window) => window?.remainingPercent !== null && window?.remainingPercent === 0, ), @@ -293,6 +296,7 @@ export function quotaExhaustedPatch( resetAtMs: resetFromError(error, nowMs), }, resetCreditsAvailable: data.quota?.resetCreditsAvailable ?? null, + resetCreditsExpiresAtMs: data.quota?.resetCreditsExpiresAtMs ?? null, limitReached: true, updatedAtMs: nowMs, }; @@ -305,7 +309,7 @@ export function quotaExhaustedPatch( async function fetchResetCredits( data: AccountData, context: PluginContext, -): Promise<{ cards: ResourceActionCard[]; availableCount: number }> { +): Promise<{ cards: ResourceActionCard[]; availableCount: number | null }> { const accountId = data.accountId ?? chatGptAccountId(data.accessToken); if (!accountId) throw new Error("ChatGPT account is missing its account ID"); const response = await context.network.fetch(RESET_CREDITS_URL, { @@ -323,19 +327,25 @@ async function fetchResetCredits( } catch { throw new Error("Codex reset card lookup returned invalid JSON"); } - const root = object(body) ?? {}; - const rawCredits = Array.isArray(root.credits) ? root.credits : []; - const cards = rawCredits.flatMap((value, index): ResourceActionCard[] => { + const root = object(body); + if (!root || !Array.isArray(root.credits)) { + throw new Error("Codex reset card lookup returned invalid card details"); + } + const rawCredits = root.credits; + const cards = rawCredits.map((value, index): ResourceActionCard => { const credit = object(value); const id = text(credit?.id); - if (!id) return []; + const status = text(credit?.status); + if (!id || !status) { + throw new Error("Codex reset card lookup returned invalid card details"); + } const resetType = text(credit?.reset_type ?? credit?.resetType); const grantedAt = timestampMs(credit?.granted_at ?? credit?.grantedAt); const expiresAt = timestampMs(credit?.expires_at ?? credit?.expiresAt); - return [{ + return { id, title: text(credit?.title) ?? resetType ?? `Codex reset card ${index + 1}`, - ...(text(credit?.status) ? { status: text(credit?.status)! } : {}), + status, ...(grantedAt !== null ? { grantedAtMs: grantedAt } : {}), ...(expiresAt !== null ? { expiresAtMs: expiresAt } : {}), fields: resetType @@ -345,14 +355,21 @@ async function fetchResetCredits( value: resetType, }] : [], - }]; + }; }); - const availableCount = number(root.available_count ?? root.availableCount); + const rawCount = root.available_count ?? root.availableCount; + const availableCount = number(rawCount); + if ( + rawCount !== undefined && rawCount !== null && + (availableCount === null || !Number.isInteger(availableCount) || availableCount < 0) + ) { + throw new Error("Codex reset card lookup returned an invalid available count"); + } return { cards, - availableCount: availableCount === null - ? cards.filter((card) => card.status === "available").length - : Math.max(0, Math.floor(availableCount)), + // An empty list without a count may be an incomplete response, not a zero balance. + availableCount: availableCount ?? + (cards.length > 0 ? cards.filter((card) => card.status === "available").length : null), }; } @@ -366,7 +383,10 @@ function timestampMs(value: unknown): number | null { return null; } -function actionDescription(availableCount: number): ResourceActionResult["description"] { +function actionDescription(availableCount: number | null): ResourceActionResult["description"] { + if (availableCount === null) { + return { "en-US": "Available reset card count unknown", "zh-CN": "可用重置卡数量未知" }; + } return { "en-US": `${availableCount} reset card${availableCount === 1 ? "" : "s"} available`, "zh-CN": `可用重置卡 ${availableCount} 张`, @@ -397,7 +417,10 @@ async function consumeResetCard( const data = accountData(resource); const available = await fetchResetCredits(data, context); - const card = available.cards.find((item) => item.id === creditId && item.status === "available"); + const card = available.cards.find((item) => + item.id === creditId && item.status === "available" && + (item.expiresAtMs === undefined || item.expiresAtMs > Date.now()) + ); if (!card) throw new Error("The selected reset card is not available"); const response = await context.network.fetch(RESET_CREDITS_CONSUME_URL, { @@ -411,6 +434,16 @@ async function consumeResetCard( ); } + let result: unknown; + try { + result = JSON.parse(response.body); + } catch { + throw new Error("Codex reset card consumption returned invalid JSON"); + } + if (object(result)?.code !== "reset") { + throw new Error("Codex reset card consumption did not confirm a quota reset"); + } + const patch = await refreshAccount(resource, context); const refreshedResource: ResourceSnapshot = { ...resource, @@ -478,6 +511,11 @@ export function presentAccount(resource: ResourceSnapshot): ResourceView { label: { "en-US": "Reset cards", "zh-CN": "重置卡" }, unit: "count", value: resetCreditsAvailable, + ...(resetCreditsAvailable > 0 && + typeof data.quota?.resetCreditsExpiresAtMs === "number" && + data.quota.resetCreditsExpiresAtMs > Date.now() + ? { expiresAtMs: data.quota.resetCreditsExpiresAtMs } + : {}), }); } return { @@ -512,6 +550,22 @@ export async function refreshAccount( throw new Error("Codex usage lookup returned invalid JSON"); } const quota = parseCodexUsage(body); + try { + const details = await fetchResetCredits(data, context); + quota.resetCreditsAvailable = details.availableCount ?? quota.resetCreditsAvailable; + const nowMs = Date.now(); + const expiries = details.cards.flatMap((card) => + card.status === "available" && card.expiresAtMs !== undefined && card.expiresAtMs > nowMs + ? [card.expiresAtMs] + : [] + ); + if (quota.resetCreditsAvailable !== 0 && expiries.length > 0) { + quota.resetCreditsExpiresAtMs = Math.min(...expiries); + } + } catch { + // Usage remains valid even if optional card details fail. The freshly parsed quota + // intentionally keeps expiry unknown rather than claiming old details are current. + } return { privateData: { ...data, quota } as unknown as JsonValue, state: quotaState(quota), diff --git a/server/src/control/mod.rs b/server/src/control/mod.rs index 9cad7bafc..a026742d9 100644 --- a/server/src/control/mod.rs +++ b/server/src/control/mod.rs @@ -168,6 +168,10 @@ pub fn api_router(service: ControlService) -> Router { "/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/export", get(plugins::export_resources), ) + .route( + "/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/selection", + put(plugins::set_resource_selection), + ) .route( "/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/{resource_id}", axum::routing::delete(plugins::delete_resource), diff --git a/server/src/control/plugins.rs b/server/src/control/plugins.rs index 1abbc3cab..5f245a6fa 100644 --- a/server/src/control/plugins.rs +++ b/server/src/control/plugins.rs @@ -77,6 +77,30 @@ pub async fn export_resources( Ok(response) } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResourceSelectionInput { + pub active_resource_id: Option, + pub automatic_switching: bool, +} + +pub async fn set_resource_selection( + State(service): State, + Path((plugin_id, resource_type)): Path<(String, String)>, + Json(input): Json, +) -> Result> { + Ok(Json( + service + .plugin_set_resource_selection( + &plugin_id, + &resource_type, + input.active_resource_id, + input.automatic_switching, + ) + .await?, + )) +} + pub async fn refresh_resource( State(service): State, Path((plugin_id, resource_type, resource_id)): Path<(String, String, String)>, diff --git a/server/src/control/service.rs b/server/src/control/service.rs index da8acfd42..2e7526477 100644 --- a/server/src/control/service.rs +++ b/server/src/control/service.rs @@ -215,6 +215,23 @@ impl ControlService { .await } + pub async fn plugin_set_resource_selection( + &self, + plugin_id: &str, + resource_type: &str, + active_resource_id: Option, + automatic_switching: bool, + ) -> Result { + self.plugins + .set_resource_selection( + plugin_id, + resource_type, + active_resource_id, + automatic_switching, + ) + .await + } + pub async fn plugin_refresh_resource( &self, plugin_id: &str, diff --git a/server/src/cursor/conversation/output.rs b/server/src/cursor/conversation/output.rs index 55dd9b6e6..7072051db 100644 --- a/server/src/cursor/conversation/output.rs +++ b/server/src/cursor/conversation/output.rs @@ -116,7 +116,9 @@ impl ConversationOutput { if !self.superseded.is_cancelled() { self.abort_execs().await; let (category, summary) = match error { - Error::Provider(_) | Error::Http(_) => ("provider", error.to_string()), + Error::Provider(_) | Error::Resource { .. } | Error::Http(_) => { + ("provider", error.to_string()) + } Error::Store(_) | Error::Database(_) | Error::Migration(_) => { ("store", error.to_string()) } @@ -1000,6 +1002,7 @@ fn cursor_error(failure: RunFailure) -> Error { match failure { RunFailure::Protocol(message) => Error::Protocol(message), RunFailure::Provider(message) => Error::Provider(message), + RunFailure::Resource { message, retryable } => Error::Resource { message, retryable }, RunFailure::Store(message) => Error::Store(message), RunFailure::Client(message) => Error::Protocol(message), } @@ -1045,14 +1048,14 @@ pub(crate) fn finish_failed(handle: &TransportHandle, error: &Error) -> Result<( details: Vec::new(), }; let stream_error = match error { - Error::Provider(_) | Error::Http(_) => { + Error::Provider(_) | Error::Resource { .. } | Error::Http(_) => { let detail = ai::ErrorDetails { error: ai::error_details::Error::ProviderError as i32, details: Some(ai::CustomErrorDetails { title: "Provider Error".into(), detail: error.to_string(), allow_command_links_potentially_unsafe_please_only_use_for_handwritten_trusted_markdown: Some(true), - is_retryable: Some(true), + is_retryable: Some(!matches!(error, Error::Resource { retryable: false, .. })), show_request_id: Some(true), should_show_immediate_error: Some(false), }), diff --git a/server/src/error.rs b/server/src/error.rs index 157d2610e..18d978cf8 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -15,6 +15,8 @@ pub enum Error { Protocol(String), #[error("provider error: {0}")] Provider(String), + #[error("account error: {message}")] + Resource { message: String, retryable: bool }, #[error("store error: {0}")] Store(String), #[error("run was cancelled")] @@ -48,7 +50,7 @@ impl IntoResponse for Error { StatusCode::BAD_REQUEST } Self::RunNotFound(_) => StatusCode::NOT_FOUND, - Self::Provider(_) | Self::Http(_) => StatusCode::BAD_GATEWAY, + Self::Provider(_) | Self::Resource { .. } | Self::Http(_) => StatusCode::BAD_GATEWAY, Self::Cancelled => StatusCode::CONFLICT, Self::Store(_) | Self::Database(_) diff --git a/server/src/plugin/data.rs b/server/src/plugin/data.rs index 2e0d1c123..975d82079 100644 --- a/server/src/plugin/data.rs +++ b/server/src/plugin/data.rs @@ -6,7 +6,7 @@ use std::{ }; use parking_lot::Mutex; -use tokio::sync::Mutex as AsyncMutex; +use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; use crate::{config, Error, Result}; @@ -39,7 +39,42 @@ impl PluginDataStore { let path = self.path(plugin_id, key)?; let lock = self.lock(plugin_id); let _guard = lock.lock().await; - match tokio::fs::read(&path).await { + Self::read_locked(&path).await + } + + /// Reads and mutates related JSON documents under one shared plugin lock. + /// The callback is synchronous: callers cannot perform network work while locked. + /// Each changed document is atomically replaced; this is not a crash-atomic + /// transaction across multiple files. + pub async fn modify( + &self, + plugin_id: &str, + keys: &[String], + change: impl FnOnce(&mut [serde_json::Value]) -> Result, + ) -> Result { + let paths = keys + .iter() + .map(|key| self.path(plugin_id, key)) + .collect::>>()?; + let guard = self.lock(plugin_id).lock_owned().await; + let mut values = Vec::with_capacity(paths.len()); + for path in &paths { + values.push(Self::read_locked(path).await?); + } + let previous = values.clone(); + let result = change(&mut values)?; + let mut writes = Vec::new(); + for (((path, key), value), old) in paths.into_iter().zip(keys).zip(&values).zip(&previous) { + if value != old { + writes.push((path, key.clone(), serde_json::to_vec_pretty(value)?)); + } + } + Self::write_locked(writes, guard).await?; + Ok(result) + } + + async fn read_locked(path: &Path) -> Result { + match tokio::fs::read(path).await { Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { Ok(serde_json::Value::Null) @@ -51,61 +86,21 @@ impl PluginDataStore { } } - pub async fn update( - &self, - plugin_id: &str, - key: &str, - value: &serde_json::Value, + /// Blocking IO owns the lock until every replacement completes, even if the + /// requesting future is cancelled. Handles are closed before each rename. + async fn write_locked( + writes: Vec<(PathBuf, String, Vec)>, + guard: OwnedMutexGuard<()>, ) -> Result<()> { - let path = self.path(plugin_id, key)?; - let lock = self.lock(plugin_id); - let _guard = lock.lock().await; - self.write_locked(&path, key, value) - .await - // 带上具体路径,Windows 上的拒绝访问才能定位到是哪一步。 - .map_err(|error| { - Error::Config(format!( - "plugin data write failed at {}: {error}", - path.display() - )) - }) - } - - /// 全程使用同步 IO 在阻塞线程完成:tokio 异步文件的关闭是延迟的, - /// 替换前句柄可能仍被本进程持有;同步写入保证替换时句柄已确定关闭。 - async fn write_locked(&self, path: &Path, key: &str, value: &serde_json::Value) -> Result<()> { - let directory = path - .parent() - .expect("plugin data path has a parent") - .to_owned(); - let temporary = directory.join(format!(".{key}.{}.tmp", uuid::Uuid::new_v4())); - let target = path.to_owned(); - let bytes = serde_json::to_vec_pretty(value)?; + if writes.is_empty() { + return Ok(()); + } tokio::task::spawn_blocking(move || { - // Windows 上杀软或索引器会短暂锁住新建文件,任何一步都可能 - // 拒绝访问,因此把整个序列作为一个整体重试。 - let mut attempts = 0; - loop { - match write_once(&directory, &temporary, &target, &bytes) { - Ok(()) => return Ok(()), - Err((step, error)) if attempts < 20 && transient(&error) => { - attempts += 1; - tracing::debug!(step, attempts, %error, "retrying plugin data write"); - std::thread::sleep(std::time::Duration::from_millis(100)); - } - Err((step, error)) => { - let _ = std::fs::remove_file(&temporary); - tracing::warn!( - path = %target.display(), - step, - attempts, - %error, - "plugin data write failed" - ); - return Err(Error::Config(format!("{step}: {error}"))); - } - } + let _guard = guard; + for (target, key, bytes) in writes { + write_document(&target, &key, &bytes)?; } + Ok(()) }) .await .expect("plugin data write task panicked") @@ -113,17 +108,21 @@ impl PluginDataStore { pub async fn clear(&self, plugin_id: &str) -> Result<()> { validate_component(plugin_id, "plugin id")?; - let lock = self.lock(plugin_id); - let _guard = lock.lock().await; + let guard = self.lock(plugin_id).lock_owned().await; let path = self.root.join(plugin_id); - match tokio::fs::remove_dir_all(&path).await { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(Error::Config(format!( - "plugin data cleanup failed at {}: {error}", - path.display() - ))), - } + tokio::task::spawn_blocking(move || { + let _guard = guard; + match std::fs::remove_dir_all(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(Error::Config(format!( + "plugin data cleanup failed at {}: {error}", + path.display() + ))), + } + }) + .await + .expect("plugin data cleanup task panicked") } fn path(&self, plugin_id: &str, key: &str) -> Result { @@ -141,6 +140,34 @@ impl PluginDataStore { } } +fn write_document(target: &Path, key: &str, bytes: &[u8]) -> Result<()> { + let directory = target.parent().expect("plugin data path has a parent"); + let temporary = directory.join(format!(".{key}.{}.tmp", uuid::Uuid::new_v4())); + // Windows scanners can briefly hold new files open; retry the whole sequence. + let mut attempts = 0; + loop { + match write_once(directory, &temporary, target, bytes) { + Ok(()) => return Ok(()), + Err((step, error)) if attempts < 20 && transient(&error) => { + attempts += 1; + tracing::debug!(step, attempts, %error, "retrying plugin data write"); + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Err((step, error)) => { + let _ = std::fs::remove_file(&temporary); + tracing::warn!( + path = %target.display(), step, attempts, %error, + "plugin data write failed" + ); + return Err(Error::Config(format!( + "plugin data write failed at {}: {step}: {error}", + target.display() + ))); + } + } + } +} + /// 单次完整写入:建目录、写临时文件、落盘、原子替换。 /// 失败时返回失败步骤的标签,供上层区分重试与报错。 fn write_once( @@ -160,13 +187,8 @@ fn write_once( .map_err(|error| ("sync temporary file", error))?; drop(file); let _ = set_file_permissions(temporary); - // Windows 的 rename 不覆盖已存在文件,先删除旧文件。 - #[cfg(windows)] - match std::fs::remove_file(target) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(("remove previous file", error)), - } + // std::fs::rename replaces an existing file on supported platforms (including + // Windows). Never delete the target first: readers must see old or new JSON. std::fs::rename(temporary, target).map_err(|error| ("replace target file", error))?; let _ = set_file_permissions(target); Ok(()) @@ -232,16 +254,95 @@ fn set_file_permissions(path: &Path) -> Result<()> { #[cfg(test)] mod tests { use super::*; + #[test] + fn cancelled_write_keeps_lock_until_disk_work_finishes() { + let root = tempfile::tempdir().unwrap(); + let store = PluginDataStore::new(root.path().join("data")).unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .max_blocking_threads(1) + .build() + .unwrap(); + runtime.block_on(async { + // Occupy the only blocking thread so the write is deterministically queued. + let (release, wait) = std::sync::mpsc::channel(); + let blocker = tokio::task::spawn_blocking(move || { + let _ = wait.recv(); + }); + let lock = store.lock("com.example"); + let guard = lock.clone().lock_owned().await; + let path = store.path("com.example", "counter").unwrap(); + let (started, queued) = tokio::sync::oneshot::channel(); + let writer = tokio::spawn(async move { + started.send(()).unwrap(); + PluginDataStore::write_locked(vec![(path, "counter".into(), b"1".to_vec())], guard) + .await + }); + queued.await.unwrap(); + writer.abort(); + assert!(writer.await.unwrap_err().is_cancelled()); + assert!( + lock.try_lock().is_err(), + "queued disk work must retain the lock" + ); + release.send(()).unwrap(); + blocker.await.unwrap(); + assert_eq!(store.read("com.example", "counter").await.unwrap(), 1); + }); + } + + #[tokio::test] + async fn concurrent_read_modify_write_keeps_every_update() { + let root = tempfile::tempdir().unwrap(); + let store = PluginDataStore::new(root.path().join("data")).unwrap(); + let barrier = Arc::new(tokio::sync::Barrier::new(17)); + let mut tasks = Vec::new(); + for _ in 0..16 { + let store = store.clone(); + let barrier = barrier.clone(); + tasks.push(tokio::spawn(async move { + barrier.wait().await; + store + .modify("com.example", &["counter".into()], |values| { + let count = values[0].as_u64().unwrap_or_default(); + values[0] = serde_json::json!(count + 1); + Ok(()) + }) + .await + .unwrap(); + })); + } + barrier.wait().await; + for task in tasks { + task.await.unwrap(); + } + assert_eq!(store.read("com.example", "counter").await.unwrap(), 16); + } + + #[tokio::test] + async fn rejected_mutation_does_not_write_any_document() { + let root = tempfile::tempdir().unwrap(); + let store = PluginDataStore::new(root.path().join("data")).unwrap(); + let result: Result<()> = store + .modify("com.example", &["a".into(), "b".into()], |values| { + values[0] = serde_json::json!("changed"); + Err(Error::Config("rejected".into())) + }) + .await; + assert!(result.is_err()); + assert!(store.read("com.example", "a").await.unwrap().is_null()); + assert!(store.read("com.example", "b").await.unwrap().is_null()); + } + #[tokio::test] async fn writes_reads_and_removes_json() { let root = tempfile::tempdir().unwrap(); let store = PluginDataStore::new(root.path().join("data")).unwrap(); store - .update( - "com.example", - "state", - &serde_json::json!({"token":"secret"}), - ) + .modify("com.example", &["state".into()], |values| { + values[0] = serde_json::json!({"token":"secret"}); + Ok(()) + }) .await .unwrap(); assert_eq!( diff --git a/server/src/plugin/descriptor.rs b/server/src/plugin/descriptor.rs index 841b1e90d..59da71ad8 100644 --- a/server/src/plugin/descriptor.rs +++ b/server/src/plugin/descriptor.rs @@ -1,7 +1,7 @@ //! Defines serializable plugin capability definitions and desktop descriptors. use serde::{Deserialize, Serialize}; -use super::state::{ResourceRecord, ResourceState, StoredModel}; +use super::state::{ResourceRecord, ResourceSelection, ResourceState, StoredModel}; /// 由 collect.ts 输出的能力摘要;不含任何可执行内容。 #[derive(Clone, Debug, Deserialize)] @@ -150,6 +150,7 @@ pub struct PluginResourceDescriptor { pub actions: Vec, pub can_refresh: bool, pub can_remove: bool, + pub selection: ResourceSelection, pub resources: Vec, } @@ -158,6 +159,7 @@ pub struct PluginResourceDescriptor { #[serde(rename_all = "camelCase")] pub struct PluginResourceView { pub id: String, + #[serde(serialize_with = "serialize_resource_state")] pub state: ResourceState, pub display_name: String, pub description: LocalizedText, @@ -165,6 +167,13 @@ pub struct PluginResourceView { pub created_at_ms: i64, } +fn serialize_resource_state( + state: &ResourceState, + serializer: S, +) -> std::result::Result { + super::state::state_json(state).serialize(serializer) +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ResourceMetric { @@ -174,6 +183,8 @@ pub struct ResourceMetric { pub value: f64, #[serde(default)] pub reset_at_ms: Option, + #[serde(default)] + pub expires_at_ms: Option, } /// 插件对一条资源的展示投影(resource.present 的返回值)。 @@ -302,6 +313,33 @@ impl PluginModelDescriptor { mod tests { use super::*; + #[test] + fn public_resource_state_uses_the_desktop_timestamp_contract() { + let record = ResourceRecord { + id: "account".into(), + key: "key".into(), + private_data: serde_json::json!({"accessToken": "private-token"}), + state: ResourceState::Cooling { + retry_at_ms: Some(12345), + message: None, + }, + created_at_ms: 0, + updated_at_ms: 0, + }; + let view = PluginResourceView::from_record( + &record, + ResourcePresentation { + display_name: "Test account".into(), + description: serde_json::Value::Null, + metrics: Vec::new(), + }, + ); + let value = serde_json::to_value(view).unwrap(); + assert_eq!(value["state"]["retryAtMs"], 12345); + assert!(value["state"].get("retry_at_ms").is_none()); + assert!(!value.to_string().contains("private-token")); + } + #[test] fn parses_stable_model_ids_with_slashes() { let id = model_id("dev.example", "codex", "org/gpt-5"); diff --git a/server/src/plugin/mod.rs b/server/src/plugin/mod.rs index 48ccac699..cc8bdca8f 100644 --- a/server/src/plugin/mod.rs +++ b/server/src/plugin/mod.rs @@ -21,6 +21,7 @@ pub use descriptor::{ }; pub use registry::{ImportResponse, OAuthBeginResponse, OAuthPollResponse, PluginRegistry}; pub use runtime::{PluginRuntime, PluginRuntimePhase, PluginRuntimeState, PluginRuntimeStatus}; +pub use state::ResourceSelection; /// Windows 下阻止 Deno 子进程弹出控制台窗口(CREATE_NO_WINDOW)。 #[cfg(windows)] diff --git a/server/src/plugin/registry.rs b/server/src/plugin/registry.rs index 12c5d72f8..9dc158ae1 100644 --- a/server/src/plugin/registry.rs +++ b/server/src/plugin/registry.rs @@ -19,7 +19,10 @@ use super::{ }, oauth_callback::{self, CallbackHandle, CallbackOutcome, CallbackRequest}, runtime::PluginRuntime, - state::{now_ms, PluginStateStore, ResourceDraft, ResourcePatch, ResourceRecord, StoredModel}, + state::{ + now_ms, PluginStateStore, ResourceDraft, ResourcePatch, ResourceRecord, ResourceSelection, + StoredModel, + }, wire, worker::{PluginWorker, WorkerStreamItem}, }; @@ -34,6 +37,14 @@ use crate::{ const OAUTH_SLOW_DOWN_STEP_MS: i64 = 5_000; const MAX_IMPORT_DRAFTS: usize = 256; +/// Preserve resource failure semantics through the outer run retry policy. +fn account_failure(message: &str, replacement_ready: bool, emitted: bool) -> Error { + Error::Resource { + message: message.to_owned(), + retryable: replacement_ready && !emitted, + } +} + #[derive(Clone)] pub struct PluginRegistry { inner: Arc, @@ -255,51 +266,63 @@ impl PluginRegistry { .find(|model| model.id == upstream_id) .ok_or_else(|| Error::RunNotFound(format!("plugin model {model_id}")))?; let resource = match &provider.resource_type { - Some(resource_type) => Some(( - resource_type.clone(), - registry.select_resource(&plugin_id, resource_type).await?, - )), + Some(resource_type) => { + let (record, selection) = registry.select_resource(&plugin_id, resource_type, true).await?; + Some((resource_type.clone(), record, selection)) + } None => None, }; + if cancellation.is_cancelled() { Err(Error::Cancelled)?; } let request = wire::llm_request(&invocation)?; let params = serde_json::json!({ "providerId": provider_id, "model": stored.snapshot(), - "resource": resource.as_ref().map(|(resource_type, record)| record.snapshot(resource_type)), + "resource": resource.as_ref().map(|(resource_type, record, _)| record.snapshot(resource_type)), "request": request, }); let worker = registry.worker(&entry, &executable).await; let mut items = worker.invoke_streaming("provider.invoke", params, cancellation.clone(), Some(recorder)).await?; yield ModelEvent::Start { model_call_id: invocation.call_id.clone() }; + let mut emitted = false; while let Some(item) = items.recv().await { match item { WorkerStreamItem::Event(event) => { + emitted = true; yield wire::model_event(&event)?; } WorkerStreamItem::Result(result) => { let value = result?; + if cancellation.is_cancelled() { Err(Error::Cancelled)?; } let status = value.get("status").and_then(serde_json::Value::as_str).unwrap_or_default(); let patch = value.get("patch") .filter(|patch| !patch.is_null()) .map(|patch| serde_json::from_value::(patch.clone())) .transpose()?; - if let (Some(patch), Some((resource_type, record))) = (patch, resource.as_ref()) { - if let Err(error) = registry.inner.state - .apply_patch(&plugin_id, resource_type, &record.id, patch).await - { - tracing::warn!(plugin = %plugin_id, %error, "failed to apply plugin resource patch"); - } - } - match status { - "completed" => return, - "resource-error" | "request-error" => { - let message = value.get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("plugin provider call failed"); - Err(Error::Provider(message.to_owned()))?; + let message = value.get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("plugin provider call failed"); + if status == "resource-error" { + let can_switch = match (patch, resource.as_ref()) { + (Some(patch), Some((resource_type, record, selection))) => { + if matches!(patch.state.as_ref(), Some(super::state::ResourceStateInput::Cooling { .. } | super::state::ResourceStateInput::Invalid { .. })) { + registry.inner.state.fail_resource(&plugin_id, resource_type, record, selection, patch).await? + } else { + registry.inner.state.apply_patch_if_current(&plugin_id, resource_type, record, patch).await?; + false + } + } + _ => false, + }; + Err(account_failure(message, can_switch, emitted))?; + } else { + if let (Some(patch), Some((resource_type, record, _))) = (patch, resource.as_ref()) { + registry.inner.state + .apply_patch_if_current(&plugin_id, resource_type, record, patch).await?; } - status => { - Err(Error::Protocol(format!("unknown plugin provider result: {status}")))?; + match status { + "completed" => return, + "request-error" => Err(Error::Provider(message.to_owned()))?, + status => Err(Error::Protocol(format!("unknown plugin provider result: {status}")))?, } } } @@ -803,6 +826,27 @@ impl PluginRegistry { })) } + pub async fn set_resource_selection( + &self, + plugin_id: &str, + resource_type: &str, + active_resource_id: Option, + automatic_switching: bool, + ) -> Result { + let executable = self.executable()?; + let entry = self.find_entry(&executable, plugin_id).await?; + find_resource(&entry, resource_type)?; + self.inner + .state + .set_selection( + plugin_id, + resource_type, + active_resource_id, + automatic_switching, + ) + .await + } + pub async fn refresh_resource( &self, plugin_id: &str, @@ -835,8 +879,9 @@ impl PluginRegistry { let patch: ResourcePatch = serde_json::from_value(value)?; self.inner .state - .apply_patch(plugin_id, resource_type, resource_id, patch) - .await + .apply_patch_if_current(plugin_id, resource_type, &record, patch) + .await?; + Ok(()) } pub async fn resource_action( @@ -885,7 +930,7 @@ impl PluginRegistry { if let Some(patch) = result.patch.clone() { self.inner .state - .apply_patch(plugin_id, resource_type, resource_id, patch) + .apply_patch_if_current(plugin_id, resource_type, &record, patch) .await?; } Ok(serde_json::to_value(ResourceActionResponse::from(result))?) @@ -1017,6 +1062,12 @@ impl PluginRegistry { actions: definition.actions.clone(), can_refresh: definition.can_refresh, can_remove: definition.can_remove, + selection: self + .inner + .state + .selection(plugin_id, &definition.resource_type) + .await + .unwrap_or_default(), resources: views, }); } @@ -1146,13 +1197,17 @@ impl PluginRegistry { ))); } let plugin_id = &entry.manifest.id; - let resource = match &provider.resource_type { - Some(resource_type) => { - let record = self.select_resource(plugin_id, resource_type).await?; - Some(record.snapshot(resource_type)) - } + let selected = match &provider.resource_type { + Some(resource_type) => Some(( + resource_type, + self.select_resource(plugin_id, resource_type, false) + .await?, + )), None => None, }; + let resource = selected + .as_ref() + .map(|(resource_type, (record, _))| record.snapshot(resource_type)); let value = self .worker(entry, executable) .await @@ -1179,32 +1234,49 @@ impl PluginRegistry { provider.id ))); } - self.inner - .state - .replace_models(plugin_id, &provider.id, &models) - .await?; + if let Some((resource_type, (_, selection))) = &selected { + if !self + .inner + .state + .replace_models_if_selected( + plugin_id, + resource_type, + selection, + &provider.id, + &models, + ) + .await? + { + return Err(Error::Config( + "active account changed during model sync; sync again".into(), + )); + } + } else { + self.inner + .state + .replace_models(plugin_id, &provider.id, &models) + .await?; + } Ok(models.len()) } - /// 第一版选择策略:按创建顺序取首个可用资源;冷却到期视为可用。 + /// The host owns a persisted selection shared by every model in the account pool. async fn select_resource( &self, plugin_id: &str, resource_type: &str, - ) -> Result { - let records = self.inner.state.resources(plugin_id, resource_type).await?; - if records.is_empty() { - return Err(Error::Provider(format!( - "plugin '{plugin_id}' has no '{resource_type}' resource; add one first" - ))); - } - let now = now_ms(); - records - .iter() - .find(|record| record.state.is_ready(now)) - .or_else(|| records.first()) - .cloned() - .ok_or_else(|| Error::Provider("no plugin resource is available".into())) + require_ready: bool, + ) -> Result<(ResourceRecord, ResourceSelection)> { + self.inner + .state + .select_resource(plugin_id, resource_type, require_ready) + .await + .map_err(|error| match error { + Error::Provider(message) | Error::Config(message) => { + account_failure(&message, false, false) + } + error => error, + }) } async fn find_record( @@ -1356,6 +1428,28 @@ fn find_provider<'a>(entry: &'a PluginEntry, provider_id: &str) -> Result<&'a Pr }) } +#[cfg(test)] +mod selection_tests { + use super::*; + + #[test] + fn only_pre_output_failures_with_a_replacement_can_retry() { + for (replacement, emitted, expected) in [ + (false, false, false), + (true, false, true), + (false, true, false), + (true, true, false), + ] { + let Error::Resource { retryable, .. } = + account_failure("quota exhausted", replacement, emitted) + else { + panic!("resource failures must retain their retry disposition"); + }; + assert_eq!(retryable, expected); + } + } +} + fn find_resource<'a>( entry: &'a PluginEntry, resource_type: &str, diff --git a/server/src/plugin/sdk/resource.ts b/server/src/plugin/sdk/resource.ts index 4757bd4c3..a2283c720 100644 --- a/server/src/plugin/sdk/resource.ts +++ b/server/src/plugin/sdk/resource.ts @@ -42,6 +42,8 @@ export type ResourceMetric = { /** percent 指标表示剩余占比,0..100。 */ value: number; resetAtMs?: number; + /** 最近一项的到期时间,不表示整个指标在此时清零。缺省表示未知。 */ + expiresAtMs?: number; }; export type ResourceActionTarget = "resource" | "card"; diff --git a/server/src/plugin/state.rs b/server/src/plugin/state.rs index 74e9fb31e..8cf4b7ba2 100644 --- a/server/src/plugin/state.rs +++ b/server/src/plugin/state.rs @@ -56,7 +56,7 @@ impl ResourceRecord { } } -fn state_json(state: &ResourceState) -> serde_json::Value { +pub(super) fn state_json(state: &ResourceState) -> serde_json::Value { match state { ResourceState::Ready => serde_json::json!({ "status": "ready" }), ResourceState::Cooling { @@ -206,6 +206,26 @@ impl StoredModel { } } +/// Global selection for one plugin/resource type, independent of conversations. +/// Revision also invalidates discovery results when the active credentials change. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ResourceSelection { + pub active_resource_id: Option, + pub automatic_switching: bool, + pub revision: u64, +} + +impl ResourceSelection { + fn advance(&mut self) -> Result<()> { + self.revision = self + .revision + .checked_add(1) + .ok_or_else(|| Error::Config("plugin selection revision exhausted".into()))?; + Ok(()) + } +} + /// 资源与模型目录的核心存储,构建在插件私有 JSON 文件之上。 #[derive(Clone)] pub struct PluginStateStore { @@ -227,14 +247,121 @@ impl PluginStateStore { plugin_id: &str, resource_type: &str, ) -> Result> { - let value = self - .data - .read(plugin_id, &resource_key(resource_type)) - .await?; - if value.is_null() { - return Ok(Vec::new()); - } - Ok(serde_json::from_value(value)?) + decode( + self.data + .read(plugin_id, &resource_key(resource_type)) + .await?, + ) + } + + pub async fn selection( + &self, + plugin_id: &str, + resource_type: &str, + ) -> Result { + decode( + self.data + .read(plugin_id, &selection_key(resource_type)) + .await?, + ) + } + + /// Explicit user edits always advance revision, including selecting the same ID. + pub async fn set_selection( + &self, + plugin_id: &str, + resource_type: &str, + active_resource_id: Option, + automatic_switching: bool, + ) -> Result { + self.modify_resources(plugin_id, resource_type, |records, selection| { + if let Some(id) = &active_resource_id { + if !records.iter().any(|record| &record.id == id) { + return Err(Error::RunNotFound(format!("plugin resource {id}"))); + } + } + selection.active_resource_id = active_resource_id; + selection.automatic_switching = automatic_switching; + selection.advance()?; + Ok(selection.clone()) + }) + .await + } + + /// Selects only usable resources. Discovery may use a cooling active resource, + /// but neither discovery nor execution ever falls back to an invalid resource. + pub async fn select_resource( + &self, + plugin_id: &str, + resource_type: &str, + require_ready: bool, + ) -> Result<(ResourceRecord, ResourceSelection)> { + self.modify_resources(plugin_id, resource_type, |records, selection| { + let now = now_ms(); + let active = active_index(records, selection); + if let Some(index) = active { + let record = &records[index]; + if record.state.is_ready(now) + || (!require_ready && !matches!(record.state, ResourceState::Invalid { .. })) + { + return Ok((record.clone(), selection.clone())); + } + } + if selection.automatic_switching { + let start = active.map_or(0, |index| index + 1); + if let Some(index) = next_ready(records, start, now) { + selection.active_resource_id = Some(records[index].id.clone()); + selection.advance()?; + return Ok((records[index].clone(), selection.clone())); + } + } + let mut reason = match active.map(|index| &records[index].state) { + Some(ResourceState::Cooling { + retry_at_ms, + message, + }) => { + let mut reason = "selected plugin resource is cooling down".to_owned(); + if let Some(message) = message + .as_deref() + .filter(|message| !message.trim().is_empty()) + { + reason.push_str(&format!(": {message}")); + } + if let Some(at) = retry_at_ms { + let retry_at = chrono::DateTime::from_timestamp_millis(*at) + .map(|date| date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) + .unwrap_or_else(|| format!("{at} milliseconds since Unix epoch")); + reason.push_str(&format!("; retry at {retry_at}")); + } + reason + } + Some(ResourceState::Invalid { message }) => { + let mut reason = "selected plugin resource is invalid".to_owned(); + if let Some(message) = message + .as_deref() + .filter(|message| !message.trim().is_empty()) + { + reason.push_str(&format!(": {message}")); + } + reason.push_str("; refresh or reimport the resource"); + reason + } + _ if records.is_empty() => { + "no plugin resources are configured; add a resource".into() + } + _ if selection.active_resource_id.is_none() => { + "no plugin resource is selected; select a resource".into() + } + _ => "selected plugin resource no longer exists; select a resource".into(), + }; + if selection.automatic_switching { + reason.push_str("; no ready alternative resource is available"); + } + Err(Error::Config(format!( + "{plugin_id}/{resource_type}: {reason}" + ))) + }) + .await } pub async fn upsert_resources( @@ -243,65 +370,115 @@ impl PluginStateStore { resource_type: &str, drafts: Vec, ) -> Result { - let mut records = self.resources(plugin_id, resource_type).await?; - let now = now_ms(); - let mut outcome = UpsertOutcome { - added: 0, - updated: 0, - }; - for draft in drafts { - if draft.key.trim().is_empty() { - return Err(Error::Protocol("plugin resource draft requires key".into())); - } - let state = draft - .state - .map_or(ResourceState::Ready, ResourceState::from); - match records.iter_mut().find(|record| record.key == draft.key) { - Some(existing) => { - existing.private_data = draft.private_data; - existing.state = state; - existing.updated_at_ms = now; - outcome.updated += 1; + self.modify_resources(plugin_id, resource_type, |records, selection| { + let was_empty = records.is_empty(); + let now = now_ms(); + let mut outcome = UpsertOutcome { + added: 0, + updated: 0, + }; + for draft in drafts { + if draft.key.trim().is_empty() { + return Err(Error::Protocol("plugin resource draft requires key".into())); } - None => { - records.push(ResourceRecord { - id: uuid::Uuid::new_v4().to_string(), - key: draft.key, - private_data: draft.private_data, - state, - created_at_ms: now, - updated_at_ms: now, - }); - outcome.added += 1; + let state = draft + .state + .map_or(ResourceState::Ready, ResourceState::from); + match records.iter_mut().find(|record| record.key == draft.key) { + Some(existing) => { + existing.private_data = draft.private_data; + existing.state = state; + advance_resource(existing)?; + if selection.active_resource_id.as_deref() == Some(existing.id.as_str()) { + selection.advance()?; + } + outcome.updated += 1; + } + None => { + records.push(ResourceRecord { + id: uuid::Uuid::new_v4().to_string(), + key: draft.key, + private_data: draft.private_data, + state, + created_at_ms: now, + updated_at_ms: now, + }); + outcome.added += 1; + } } } - } - self.save_resources(plugin_id, resource_type, &records) - .await?; - Ok(outcome) + // Import order is stable. Later additions/reimports never choose an account. + if was_empty && !records.is_empty() { + selection.active_resource_id = Some(records[0].id.clone()); + selection.advance()?; + } + Ok(outcome) + }) + .await } - pub async fn apply_patch( + /// Compare-and-swap on the resource snapshot, including imported credentials. + pub async fn apply_patch_if_current( &self, plugin_id: &str, resource_type: &str, - resource_id: &str, + expected: &ResourceRecord, patch: ResourcePatch, - ) -> Result<()> { - let mut records = self.resources(plugin_id, resource_type).await?; - let record = records - .iter_mut() - .find(|record| record.id == resource_id) - .ok_or_else(|| Error::RunNotFound(format!("plugin resource {resource_id}")))?; - if let Some(private_data) = patch.private_data { - record.private_data = private_data; - } - if let Some(state) = patch.state { - record.state = state.into(); - } - record.updated_at_ms = now_ms(); - self.save_resources(plugin_id, resource_type, &records) - .await + ) -> Result { + self.modify_resources(plugin_id, resource_type, |records, selection| { + let Some(record) = records.iter_mut().find(|record| current(record, expected)) else { + return Ok(false); + }; + patch_record(record, patch)?; + if selection.active_resource_id.as_deref() == Some(record.id.as_str()) { + selection.advance()?; + } + Ok(true) + }) + .await + } + + /// Applies a failure only to its original resource snapshot. Selection may be + /// advanced only while its snapshot is unchanged; later user edits take priority. + /// Returns retry permission, not whether the patch was written. + pub async fn fail_resource( + &self, + plugin_id: &str, + resource_type: &str, + expected_resource: &ResourceRecord, + expected_selection: &ResourceSelection, + patch: ResourcePatch, + ) -> Result { + self.modify_resources(plugin_id, resource_type, |records, selection| { + let may_switch = &*selection == expected_selection; + let matched = records + .iter() + .position(|record| current(record, expected_resource)); + if let Some(index) = matched { + patch_record(&mut records[index], patch)?; + if selection.active_resource_id.as_deref() == Some(expected_resource.id.as_str()) { + selection.advance()?; + } + if may_switch + && selection.automatic_switching + && selection.active_resource_id.as_deref() + == Some(expected_resource.id.as_str()) + { + if let Some(next) = next_ready(records, index + 1, now_ms()) { + if records[next].id != expected_resource.id { + selection.active_resource_id = Some(records[next].id.clone()); + selection.advance()?; + } + } + } + } + Ok(selection.automatic_switching + && active_index(records, selection).is_some_and(|index| { + records[index].id != expected_resource.id + && records[index].state.is_ready(now_ms()) + })) + }) + .await } pub async fn remove_resource( @@ -310,23 +487,27 @@ impl PluginStateStore { resource_type: &str, resource_id: &str, ) -> Result { - let mut records = self.resources(plugin_id, resource_type).await?; - let index = records - .iter() - .position(|record| record.id == resource_id) - .ok_or_else(|| Error::RunNotFound(format!("plugin resource {resource_id}")))?; - let removed = records.remove(index); - self.save_resources(plugin_id, resource_type, &records) - .await?; - Ok(removed) + self.modify_resources(plugin_id, resource_type, |records, selection| { + let index = records + .iter() + .position(|record| record.id == resource_id) + .ok_or_else(|| Error::RunNotFound(format!("plugin resource {resource_id}")))?; + let removed = records.remove(index); + if selection.active_resource_id.as_deref() == Some(resource_id) { + selection.active_resource_id = if selection.automatic_switching { + next_ready(records, index, now_ms()).map(|next| records[next].id.clone()) + } else { + None + }; + selection.advance()?; + } + Ok(removed) + }) + .await } pub async fn models(&self, plugin_id: &str, provider_id: &str) -> Result> { - let value = self.data.read(plugin_id, &model_key(provider_id)).await?; - if value.is_null() { - return Ok(Vec::new()); - } - Ok(serde_json::from_value(value)?) + decode(self.data.read(plugin_id, &model_key(provider_id)).await?) } pub async fn replace_models( @@ -335,22 +516,43 @@ impl PluginStateStore { provider_id: &str, models: &[StoredModel], ) -> Result<()> { - let previous = self.models(plugin_id, provider_id).await?; - let models = models - .iter() - .cloned() - .map(|mut model| { - if let Some(old) = previous.iter().find(|old| old.id == model.id) { - model.enabled = old.enabled; - } - model + self.data + .modify(plugin_id, &[model_key(provider_id)], |values| { + values[0] = merged_models(&values[0], models)?; + Ok(()) }) - .collect::>(); + .await + } + + pub async fn replace_models_if_selected( + &self, + plugin_id: &str, + resource_type: &str, + expected_selection: &ResourceSelection, + provider_id: &str, + models: &[StoredModel], + ) -> Result { self.data - .update( + .modify( plugin_id, - &model_key(provider_id), - &serde_json::to_value(models)?, + &[ + resource_key(resource_type), + selection_key(resource_type), + model_key(provider_id), + ], + |values| { + let records: Vec = decode(values[0].clone())?; + let selection: ResourceSelection = decode(values[1].clone())?; + if &selection != expected_selection + || active_index(&records, &selection).is_none_or(|index| { + matches!(records[index].state, ResourceState::Invalid { .. }) + }) + { + return Ok(false); + } + values[2] = merged_models(&values[2], models)?; + Ok(true) + }, ) .await } @@ -362,18 +564,17 @@ impl PluginStateStore { model_id: &str, enabled: bool, ) -> Result<()> { - let mut models = self.models(plugin_id, provider_id).await?; - let model = models - .iter_mut() - .find(|model| model.id == model_id) - .ok_or_else(|| Error::RunNotFound(format!("plugin model {model_id}")))?; - model.enabled = enabled; self.data - .update( - plugin_id, - &model_key(provider_id), - &serde_json::to_value(models)?, - ) + .modify(plugin_id, &[model_key(provider_id)], |values| { + let mut models: Vec = decode(values[0].clone())?; + let model = models + .iter_mut() + .find(|model| model.id == model_id) + .ok_or_else(|| Error::RunNotFound(format!("plugin model {model_id}")))?; + model.enabled = enabled; + values[0] = serde_json::to_value(models)?; + Ok(()) + }) .await } @@ -381,22 +582,96 @@ impl PluginStateStore { self.data.clear(plugin_id).await } - async fn save_resources( + async fn modify_resources( &self, plugin_id: &str, resource_type: &str, - records: &[ResourceRecord], - ) -> Result<()> { + change: impl FnOnce(&mut Vec, &mut ResourceSelection) -> Result, + ) -> Result { self.data - .update( + .modify( plugin_id, - &resource_key(resource_type), - &serde_json::to_value(records)?, + &[resource_key(resource_type), selection_key(resource_type)], + |values| { + let mut records = decode(values[0].clone())?; + let mut selection = decode(values[1].clone())?; + let result = change(&mut records, &mut selection)?; + values[0] = serde_json::to_value(records)?; + values[1] = serde_json::to_value(selection)?; + Ok(result) + }, ) .await } } +fn decode(value: serde_json::Value) -> Result { + if value.is_null() { + Ok(T::default()) + } else { + Ok(serde_json::from_value(value)?) + } +} + +fn advance_resource(record: &mut ResourceRecord) -> Result<()> { + record.updated_at_ms = now_ms().max( + record + .updated_at_ms + .checked_add(1) + .ok_or_else(|| Error::Config("plugin resource revision exhausted".into()))?, + ); + Ok(()) +} + +fn patch_record(record: &mut ResourceRecord, patch: ResourcePatch) -> Result<()> { + if let Some(private_data) = patch.private_data { + record.private_data = private_data; + } + if let Some(state) = patch.state { + record.state = state.into(); + } + advance_resource(record) +} + +fn current(record: &ResourceRecord, expected: &ResourceRecord) -> bool { + record.id == expected.id && record.updated_at_ms == expected.updated_at_ms +} + +fn active_index(records: &[ResourceRecord], selection: &ResourceSelection) -> Option { + records + .iter() + .position(|record| selection.active_resource_id.as_deref() == Some(record.id.as_str())) +} + +/// Insertion order, starting just after the previous active resource and wrapping. +fn next_ready(records: &[ResourceRecord], start: usize, now: i64) -> Option { + (0..records.len()) + .map(|offset| (start + offset) % records.len()) + .find(|&index| records[index].state.is_ready(now)) +} + +fn merged_models( + previous: &serde_json::Value, + models: &[StoredModel], +) -> Result { + let previous: Vec = decode(previous.clone())?; + let models: Vec<_> = models + .iter() + .cloned() + .map(|mut model| { + if let Some(old) = previous.iter().find(|old| old.id == model.id) { + model.enabled = old.enabled; + } + model + }) + .collect(); + Ok(serde_json::to_value(models)?) +} + +fn selection_key(resource_type: &str) -> String { + format!("selection-{resource_type}") +} + pub fn now_ms() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -412,6 +687,10 @@ fn model_key(provider_id: &str) -> String { format!("models-{provider_id}") } +#[cfg(test)] +#[path = "state_tests.rs"] +mod selection_tests; + #[cfg(test)] mod tests { use super::*; @@ -455,11 +734,11 @@ mod tests { assert_eq!(records.len(), 1); assert_eq!(records[0].private_data["token"], "two"); - store - .apply_patch( + assert!(store + .apply_patch_if_current( "dev.example", "account", - &records[0].id, + &records[0], ResourcePatch { private_data: None, state: Some(ResourceStateInput::Cooling { @@ -469,7 +748,7 @@ mod tests { }, ) .await - .unwrap(); + .unwrap()); let records = store.resources("dev.example", "account").await.unwrap(); assert!(!records[0].state.is_ready(100)); assert!(records[0].state.is_ready(300), "cooling expires over time"); diff --git a/server/src/plugin/state_tests.rs b/server/src/plugin/state_tests.rs new file mode 100644 index 000000000..2d9fc7753 --- /dev/null +++ b/server/src/plugin/state_tests.rs @@ -0,0 +1,654 @@ +//! Selection, snapshot concurrency, and restart invariants for JSON persistence. +use super::*; + +const PLUGIN: &str = "dev.example"; +const TYPE: &str = "account"; + +fn store() -> (tempfile::TempDir, PluginStateStore) { + let root = tempfile::tempdir().unwrap(); + let data = PluginDataStore::for_test(root.path().join("data")).unwrap(); + (root, PluginStateStore::new(data)) +} + +fn draft(key: &str, token: &str) -> ResourceDraft { + ResourceDraft { + key: key.into(), + private_data: serde_json::json!({"token": token}), + state: None, + } +} + +fn invalid() -> ResourcePatch { + ResourcePatch { + private_data: None, + state: Some(ResourceStateInput::Invalid { message: None }), + } +} + +fn cooling() -> ResourcePatch { + ResourcePatch { + private_data: None, + state: Some(ResourceStateInput::Cooling { + retry_at_ms: None, + message: None, + }), + } +} + +async fn patch(store: &PluginStateStore, id: &str, patch: ResourcePatch) -> Result<()> { + let expected = store + .resources(PLUGIN, TYPE) + .await? + .into_iter() + .find(|record| record.id == id) + .expect("test resource exists"); + assert!( + store + .apply_patch_if_current(PLUGIN, TYPE, &expected, patch) + .await? + ); + Ok(()) +} + +fn model(id: &str) -> StoredModel { + StoredModel::from_definition(&serde_json::json!({"id": id, "displayName": id})).unwrap() +} + +async fn seed(store: &PluginStateStore) -> Vec { + store + .upsert_resources( + PLUGIN, + TYPE, + vec![draft("a", "a"), draft("b", "b"), draft("c", "c")], + ) + .await + .unwrap(); + store.resources(PLUGIN, TYPE).await.unwrap() +} + +#[tokio::test] +async fn selection_is_manual_persistent_and_scoped() { + let (root, store) = store(); + assert_eq!( + store.selection(PLUGIN, TYPE).await.unwrap(), + ResourceSelection::default() + ); + let records = seed(&store).await; + let initial = store.selection(PLUGIN, TYPE).await.unwrap(); + assert_eq!( + initial.active_resource_id.as_deref(), + Some(records[0].id.as_str()) + ); + assert!(!initial.automatic_switching); + let selected = store + .set_selection(PLUGIN, TYPE, Some(records[1].id.clone()), true) + .await + .unwrap(); + assert!(selected.revision > initial.revision); + assert!(store + .set_selection(PLUGIN, "other", Some(records[1].id.clone()), false) + .await + .is_err()); + assert!(store + .set_selection("other.plugin", TYPE, Some(records[1].id.clone()), false) + .await + .is_err()); + assert_eq!( + store.selection(PLUGIN, "other").await.unwrap(), + ResourceSelection::default() + ); + let value = serde_json::to_value(&selected).unwrap(); + assert_eq!(value["activeResourceId"], records[1].id); + assert_eq!(value["automaticSwitching"], true); + assert!(value.get("active_resource_id").is_none()); + drop(store); + let data = PluginDataStore::for_test(root.path().join("data")).unwrap(); + assert!(data + .read(PLUGIN, "resources-account") + .await + .unwrap() + .is_array()); + assert_eq!(data.read(PLUGIN, "selection-account").await.unwrap(), value); + let restarted = PluginStateStore::new(data); + assert_eq!(restarted.selection(PLUGIN, TYPE).await.unwrap(), selected); + assert_eq!( + restarted + .select_resource(PLUGIN, TYPE, true) + .await + .unwrap() + .0 + .id, + records[1].id + ); +} + +#[tokio::test] +async fn imports_preserve_selection_and_only_empty_collection_selects_first_added() { + let (_root, store) = store(); + let records = seed(&store).await; + let selected = store + .set_selection(PLUGIN, TYPE, Some(records[1].id.clone()), false) + .await + .unwrap(); + store + .upsert_resources(PLUGIN, TYPE, vec![draft("b", "new"), draft("d", "d")]) + .await + .unwrap(); + let selection = store.selection(PLUGIN, TYPE).await.unwrap(); + assert_eq!(selection.active_resource_id, selected.active_resource_id); + assert!(!selection.automatic_switching); + assert!(selection.revision > selected.revision); + store + .set_selection(PLUGIN, TYPE, None, false) + .await + .unwrap(); + store + .upsert_resources(PLUGIN, TYPE, vec![draft("e", "e")]) + .await + .unwrap(); + assert!(store + .selection(PLUGIN, TYPE) + .await + .unwrap() + .active_resource_id + .is_none()); + for record in store.resources(PLUGIN, TYPE).await.unwrap() { + store + .remove_resource(PLUGIN, TYPE, &record.id) + .await + .unwrap(); + } + store + .upsert_resources(PLUGIN, TYPE, vec![draft("z", "z"), draft("y", "y")]) + .await + .unwrap(); + assert_eq!( + store + .select_resource(PLUGIN, TYPE, true) + .await + .unwrap() + .0 + .key, + "z" + ); +} + +#[tokio::test] +async fn manual_never_falls_back_and_discovery_only_relaxes_cooling() { + let (_root, store) = store(); + let records = seed(&store).await; + patch(&store, &records[0].id, cooling()).await.unwrap(); + assert!(store.select_resource(PLUGIN, TYPE, true).await.is_err()); + assert_eq!( + store + .select_resource(PLUGIN, TYPE, false) + .await + .unwrap() + .0 + .id, + records[0].id + ); + patch(&store, &records[0].id, invalid()).await.unwrap(); + assert!(store.select_resource(PLUGIN, TYPE, false).await.is_err()); + let current = store.resources(PLUGIN, TYPE).await.unwrap().remove(0); + let selection = store.selection(PLUGIN, TYPE).await.unwrap(); + assert!(!store + .fail_resource(PLUGIN, TYPE, ¤t, &selection, invalid()) + .await + .unwrap()); + assert_eq!( + store + .selection(PLUGIN, TYPE) + .await + .unwrap() + .active_resource_id, + Some(records[0].id.clone()) + ); +} + +#[tokio::test] +async fn auto_selects_ready_in_stable_circular_order_and_never_unavailable() { + let (_root, store) = store(); + let records = seed(&store).await; + store + .set_selection(PLUGIN, TYPE, Some(records[1].id.clone()), true) + .await + .unwrap(); + patch(&store, &records[1].id, cooling()).await.unwrap(); + patch(&store, &records[2].id, invalid()).await.unwrap(); + assert_eq!( + store + .select_resource(PLUGIN, TYPE, true) + .await + .unwrap() + .0 + .id, + records[0].id + ); + patch(&store, &records[0].id, invalid()).await.unwrap(); + assert!(store.select_resource(PLUGIN, TYPE, true).await.is_err()); + assert!(store.select_resource(PLUGIN, TYPE, false).await.is_err()); + patch( + &store, + &records[1].id, + ResourcePatch { + private_data: None, + state: Some(ResourceStateInput::Cooling { + retry_at_ms: Some(0), + message: None, + }), + }, + ) + .await + .unwrap(); + assert_eq!( + store + .select_resource(PLUGIN, TYPE, true) + .await + .unwrap() + .0 + .id, + records[1].id + ); +} + +#[tokio::test] +async fn deletion_clears_manual_and_advances_auto_without_readding_records() { + let (_root, store) = store(); + let records = seed(&store).await; + store + .remove_resource(PLUGIN, TYPE, &records[0].id) + .await + .unwrap(); + assert!(store + .selection(PLUGIN, TYPE) + .await + .unwrap() + .active_resource_id + .is_none()); + assert!(store.select_resource(PLUGIN, TYPE, true).await.is_err()); + assert!(!store + .apply_patch_if_current(PLUGIN, TYPE, &records[0], invalid()) + .await + .unwrap()); + store + .set_selection(PLUGIN, TYPE, Some(records[1].id.clone()), true) + .await + .unwrap(); + store + .remove_resource(PLUGIN, TYPE, &records[1].id) + .await + .unwrap(); + assert_eq!( + store + .selection(PLUGIN, TYPE) + .await + .unwrap() + .active_resource_id, + Some(records[2].id.clone()) + ); + store + .remove_resource(PLUGIN, TYPE, &records[2].id) + .await + .unwrap(); + assert!(store + .selection(PLUGIN, TYPE) + .await + .unwrap() + .active_resource_id + .is_none()); + assert!(store.resources(PLUGIN, TYPE).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn auto_delete_skips_unavailable_and_nonactive_delete_preserves_selection() { + let (_root, store) = store(); + let records = seed(&store).await; + patch(&store, &records[1].id, cooling()).await.unwrap(); + let selection = store + .set_selection(PLUGIN, TYPE, Some(records[0].id.clone()), true) + .await + .unwrap(); + store + .remove_resource(PLUGIN, TYPE, &records[1].id) + .await + .unwrap(); + assert_eq!(store.selection(PLUGIN, TYPE).await.unwrap(), selection); + patch(&store, &records[2].id, invalid()).await.unwrap(); + store + .remove_resource(PLUGIN, TYPE, &records[0].id) + .await + .unwrap(); + assert!(store + .selection(PLUGIN, TYPE) + .await + .unwrap() + .active_resource_id + .is_none()); +} + +#[tokio::test] +async fn delayed_refresh_and_failure_cannot_overwrite_reimported_credentials() { + let (_root, store) = store(); + let records = seed(&store).await; + let selected = store + .set_selection(PLUGIN, TYPE, Some(records[0].id.clone()), true) + .await + .unwrap(); + let (release, wait) = tokio::sync::oneshot::channel(); + let late_store = store.clone(); + let snapshot = records[0].clone(); + let late = tokio::spawn(async move { + wait.await.unwrap(); + late_store + .apply_patch_if_current( + PLUGIN, + TYPE, + &snapshot, + ResourcePatch { + private_data: Some(serde_json::json!({"token": "stale"})), + state: None, + }, + ) + .await + .unwrap() + }); + store + .upsert_resources(PLUGIN, TYPE, vec![draft("a", "imported")]) + .await + .unwrap(); + release.send(()).unwrap(); + assert!(!late.await.unwrap()); + assert!(!store + .fail_resource(PLUGIN, TYPE, &records[0], &selected, invalid()) + .await + .unwrap()); + let current = store.resources(PLUGIN, TYPE).await.unwrap(); + assert_eq!(current[0].private_data["token"], "imported"); + assert_eq!(current[0].state, ResourceState::Ready); + assert!(current[0].updated_at_ms > records[0].updated_at_ms); + assert_eq!( + store + .selection(PLUGIN, TYPE) + .await + .unwrap() + .active_resource_id, + selected.active_resource_id + ); +} + +#[tokio::test] +async fn concurrent_snapshot_patches_have_exactly_one_winner() { + let (_root, store) = store(); + let records = seed(&store).await; + let (a, b) = tokio::join!( + store.apply_patch_if_current(PLUGIN, TYPE, &records[0], invalid()), + store.apply_patch_if_current(PLUGIN, TYPE, &records[0], cooling()), + ); + assert_ne!(a.unwrap(), b.unwrap()); +} + +#[tokio::test] +async fn concurrent_imports_and_model_edits_do_not_lose_updates() { + let (_root, store) = store(); + let (a, b) = tokio::join!( + store.upsert_resources(PLUGIN, TYPE, vec![draft("a", "a")]), + store.upsert_resources(PLUGIN, TYPE, vec![draft("b", "b")]), + ); + assert_eq!(a.unwrap().added, 1); + assert_eq!(b.unwrap().added, 1); + assert_eq!(store.resources(PLUGIN, TYPE).await.unwrap().len(), 2); + store + .replace_models(PLUGIN, "provider", &[model("m")]) + .await + .unwrap(); + let replacement = [model("m"), model("n")]; + let (replace, toggle) = tokio::join!( + store.replace_models(PLUGIN, "provider", &replacement), + store.set_model_enabled(PLUGIN, "provider", "m", false), + ); + replace.unwrap(); + toggle.unwrap(); + let models = store.models(PLUGIN, "provider").await.unwrap(); + assert_eq!(models.len(), 2); + assert!(!models[0].enabled); +} + +#[tokio::test] +async fn failure_switches_once_and_respects_later_user_edits() { + let (_root, store) = store(); + let records = seed(&store).await; + let selection = store + .set_selection(PLUGIN, TYPE, Some(records[0].id.clone()), true) + .await + .unwrap(); + assert!(store + .fail_resource(PLUGIN, TYPE, &records[0], &selection, invalid()) + .await + .unwrap()); + let switched = store.selection(PLUGIN, TYPE).await.unwrap(); + assert_eq!(switched.active_resource_id, Some(records[1].id.clone())); + assert!(store + .fail_resource(PLUGIN, TYPE, &records[0], &selection, invalid()) + .await + .unwrap()); + assert_eq!(store.selection(PLUGIN, TYPE).await.unwrap(), switched); + + // A user explicitly reselecting even the same account prevents a late switch. + let old = store + .set_selection(PLUGIN, TYPE, Some(records[1].id.clone()), true) + .await + .unwrap(); + store + .set_selection(PLUGIN, TYPE, Some(records[1].id.clone()), true) + .await + .unwrap(); + assert!(!store + .fail_resource(PLUGIN, TYPE, &records[1], &old, invalid()) + .await + .unwrap()); + assert_eq!( + store + .selection(PLUGIN, TYPE) + .await + .unwrap() + .active_resource_id, + Some(records[1].id.clone()) + ); + + let old = store + .set_selection(PLUGIN, TYPE, Some(records[2].id.clone()), true) + .await + .unwrap(); + let manual = store + .set_selection(PLUGIN, TYPE, None, false) + .await + .unwrap(); + assert!(!store + .fail_resource(PLUGIN, TYPE, &records[2], &old, invalid()) + .await + .unwrap()); + assert_eq!(store.selection(PLUGIN, TYPE).await.unwrap(), manual); +} + +#[tokio::test] +async fn delayed_model_sync_rejects_selection_credentials_and_deletion_changes() { + let (_root, store) = store(); + let records = seed(&store).await; + let initial = store.selection(PLUGIN, TYPE).await.unwrap(); + assert!(store + .replace_models_if_selected(PLUGIN, TYPE, &initial, "provider", &[model("first")]) + .await + .unwrap()); + store + .set_model_enabled(PLUGIN, "provider", "first", false) + .await + .unwrap(); + assert!(store + .replace_models_if_selected(PLUGIN, TYPE, &initial, "provider", &[model("first")]) + .await + .unwrap()); + assert!(!store.models(PLUGIN, "provider").await.unwrap()[0].enabled); + store + .upsert_resources(PLUGIN, TYPE, vec![draft("a", "new")]) + .await + .unwrap(); + assert!(!store + .replace_models_if_selected(PLUGIN, TYPE, &initial, "provider", &[model("late")]) + .await + .unwrap()); + let imported = store.selection(PLUGIN, TYPE).await.unwrap(); + store + .set_selection(PLUGIN, TYPE, Some(records[1].id.clone()), false) + .await + .unwrap(); + assert!(!store + .replace_models_if_selected(PLUGIN, TYPE, &imported, "provider", &[model("late")]) + .await + .unwrap()); + let selected = store.selection(PLUGIN, TYPE).await.unwrap(); + store + .remove_resource(PLUGIN, TYPE, &records[1].id) + .await + .unwrap(); + assert!(!store + .replace_models_if_selected(PLUGIN, TYPE, &selected, "provider", &[model("late")]) + .await + .unwrap()); + assert_eq!( + store.models(PLUGIN, "provider").await.unwrap()[0].id, + "first" + ); +} + +#[tokio::test] +async fn invalid_import_is_transactional_and_recreated_key_has_new_identity() { + let (_root, store) = store(); + let records = seed(&store).await; + let selection = store.selection(PLUGIN, TYPE).await.unwrap(); + assert!(store + .upsert_resources(PLUGIN, TYPE, vec![draft("a", "bad"), draft("", "bad")]) + .await + .is_err()); + assert_eq!( + store.resources(PLUGIN, TYPE).await.unwrap()[0].private_data["token"], + "a" + ); + assert_eq!(store.selection(PLUGIN, TYPE).await.unwrap(), selection); + store + .remove_resource(PLUGIN, TYPE, &records[0].id) + .await + .unwrap(); + store + .upsert_resources(PLUGIN, TYPE, vec![draft("a", "new")]) + .await + .unwrap(); + assert!(!store + .apply_patch_if_current(PLUGIN, TYPE, &records[0], invalid()) + .await + .unwrap()); + let recreated = store + .resources(PLUGIN, TYPE) + .await + .unwrap() + .into_iter() + .find(|record| record.key == "a") + .unwrap(); + assert_ne!(recreated.id, records[0].id); + assert_eq!(recreated.private_data["token"], "new"); +} + +#[tokio::test] +async fn selection_errors_explain_missing_cooling_invalid_and_exhausted_resources() { + let (_root, store) = store(); + let error = store + .select_resource(PLUGIN, TYPE, true) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("no plugin resources are configured")); + let records = seed(&store).await; + store + .set_selection(PLUGIN, TYPE, None, false) + .await + .unwrap(); + let error = store + .select_resource(PLUGIN, TYPE, true) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("no plugin resource is selected")); + store + .set_selection(PLUGIN, TYPE, Some(records[0].id.clone()), false) + .await + .unwrap(); + patch( + &store, + &records[0].id, + ResourcePatch { + private_data: None, + state: Some(ResourceStateInput::Cooling { + retry_at_ms: Some(4_102_444_800_000), + message: Some("quota exhausted".into()), + }), + }, + ) + .await + .unwrap(); + let error = store + .select_resource(PLUGIN, TYPE, true) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("cooling down: quota exhausted")); + assert!(error.contains("retry at 2100-01-01T00:00:00Z")); + patch( + &store, + &records[0].id, + ResourcePatch { + private_data: None, + state: Some(ResourceStateInput::Invalid { + message: Some("credentials expired".into()), + }), + }, + ) + .await + .unwrap(); + let error = store + .select_resource(PLUGIN, TYPE, false) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("invalid: credentials expired")); + assert!(error.contains("refresh or reimport")); + for record in &records[1..] { + patch(&store, &record.id, invalid()).await.unwrap(); + } + store + .set_selection(PLUGIN, TYPE, Some(records[0].id.clone()), true) + .await + .unwrap(); + let error = store + .select_resource(PLUGIN, TYPE, true) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("no ready alternative resource is available")); +} + +#[test] +fn resource_revision_strictly_advances_even_when_clock_is_behind() { + let mut record = ResourceRecord { + id: "id".into(), + key: "key".into(), + private_data: serde_json::Value::Null, + state: ResourceState::Ready, + created_at_ms: 0, + updated_at_ms: i64::MAX - 2, + }; + advance_resource(&mut record).unwrap(); + assert_eq!(record.updated_at_ms, i64::MAX - 1); + advance_resource(&mut record).unwrap(); + assert_eq!(record.updated_at_ms, i64::MAX); + assert!(advance_resource(&mut record).is_err()); +} diff --git a/server/src/provider/recorder.rs b/server/src/provider/recorder.rs index e60f1767f..cac008ea1 100644 --- a/server/src/provider/recorder.rs +++ b/server/src/provider/recorder.rs @@ -365,7 +365,9 @@ fn finish_reason(reason: FinishReason) -> &'static str { fn error_kind(error: &crate::Error) -> &'static str { match error { - crate::Error::Provider(_) | crate::Error::Http(_) => "provider", + crate::Error::Provider(_) | crate::Error::Resource { .. } | crate::Error::Http(_) => { + "provider" + } crate::Error::Cancelled => "cancelled", crate::Error::Database(_) | crate::Error::Store(_) => "store", _ => "internal", diff --git a/server/src/run/engine.rs b/server/src/run/engine.rs index fdd039391..3af33a7b0 100644 --- a/server/src/run/engine.rs +++ b/server/src/run/engine.rs @@ -1060,6 +1060,7 @@ fn failure_message(failure: &RunFailure) -> String { match failure { RunFailure::Protocol(message) | RunFailure::Provider(message) + | RunFailure::Resource { message, .. } | RunFailure::Store(message) | RunFailure::Client(message) => message.clone(), } diff --git a/server/src/run/event.rs b/server/src/run/event.rs index 01918aac9..984132af9 100644 --- a/server/src/run/event.rs +++ b/server/src/run/event.rs @@ -10,6 +10,7 @@ use crate::model::{CheckpointId, ToolCall, ToolRoundId, Usage}; pub enum RunFailure { Protocol(String), Provider(String), + Resource { message: String, retryable: bool }, Store(String), Client(String), } @@ -18,7 +19,7 @@ impl RunFailure { pub fn category(&self) -> &'static str { match self { Self::Protocol(_) => "protocol", - Self::Provider(_) => "provider", + Self::Provider(_) | Self::Resource { .. } => "provider", Self::Store(_) => "store", Self::Client(_) => "client", } @@ -31,6 +32,7 @@ impl From for RunFailure { match error { Error::Protocol(message) | Error::Config(message) => Self::Protocol(message), Error::Provider(message) => Self::Provider(message), + Error::Resource { message, retryable } => Self::Resource { message, retryable }, Error::Store(message) => Self::Store(message), Error::Cancelled => Self::Client("run was cancelled".into()), Error::Http(error) => Self::Provider(error.to_string()), diff --git a/server/src/run/model_cycle.rs b/server/src/run/model_cycle.rs index 469dc5fe9..086e80091 100644 --- a/server/src/run/model_cycle.rs +++ b/server/src/run/model_cycle.rs @@ -374,6 +374,7 @@ fn failure( usage: Option, ) -> Box { let retryable = match &failure { + RunFailure::Resource { retryable, .. } => *retryable, RunFailure::Protocol(_) => true, RunFailure::Provider(message) if is_rejected_request(message) => false, RunFailure::Provider(_) => true, @@ -501,6 +502,31 @@ mod tests { assert!(event_rx.try_recv().is_err(), "usage must be forwarded once"); } + #[tokio::test] + async fn account_retry_disposition_overrides_http_status_heuristics() { + for (message, retryable) in [ + ("HTTP 429: quota exhausted", false), + ("HTTP 401: authorization expired", true), + ("no ready account is available", false), + ] { + let stream = Box::pin(tokio_stream::iter(vec![ + Ok(ModelEvent::Start { + model_call_id: "attempt".into(), + }), + Err(crate::Error::Resource { + message: message.into(), + retryable, + }), + ])); + let (tx, _rx) = tokio::sync::mpsc::channel(4); + let failure = consume_model_cycle(stream, &tx, &CancellationToken::new()) + .await + .unwrap_err(); + assert_eq!(failure.retryable, retryable); + assert_eq!(failure.failure.category(), "provider"); + } + } + #[test] fn other_provider_errors_are_retryable() { let result = failure( diff --git a/server/tests/error_lifecycle.rs b/server/tests/error_lifecycle.rs index 250a937fb..fefb2fb2f 100644 --- a/server/tests/error_lifecycle.rs +++ b/server/tests/error_lifecycle.rs @@ -701,6 +701,94 @@ async fn assert_run_starts_without_parent_dependency( assert_eq!(row, ("root".into(), None, None)); } +#[tokio::test] +async fn account_failures_follow_explicit_retry_policy() { + for retryable in [false, true] { + let (_directory, store) = fixtures::temp_store().await; + let provider = fake_provider::FakeProvider::default(); + provider.push_error(Error::Resource { + // 401 is normally terminal; a confirmed account switch permits retry. + message: "HTTP 401: account authorization expired".into(), + retryable, + }); + if retryable { + provider.push(vec![ + ModelEvent::Start { + model_call_id: "replacement-call".into(), + }, + ModelEvent::TextStart, + ModelEvent::TextDelta("replacement account".into()), + ModelEvent::TextEnd, + ModelEvent::Done(FinishReason::Stop), + ]); + } + let assets = PromptAssets::load( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("prompt/cursor") + .as_path(), + ) + .unwrap(); + let registry = TransportRegistry::new( + store, + Arc::new(provider.clone()), + PromptCompiler::new(assets), + ); + let handle = registry.get_or_create("account-failure").await.unwrap(); + let mut output = handle.subscribe(); + handle + .command(TransportCommand::Append { + seqno: 0, + message: Box::new(protocol_client_run("use selected account", "account-user")), + }) + .await + .unwrap(); + let mut seqno = 1; + let terminal = loop { + let frame = tokio::time::timeout(std::time::Duration::from_secs(15), output.recv()) + .await + .unwrap() + .expect("terminal account result"); + let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap(); + if flags & connect::END_STREAM_FLAG != 0 { + break serde_json::from_slice::(&payload).unwrap(); + } + let server = pb::AgentServerMessage::decode(payload).unwrap(); + if let Some(pb::agent_server_message::Message::KvServerMessage(kv)) = server.message { + handle + .command(TransportCommand::Append { + seqno, + message: Box::new(kv_ack(kv.id)), + }) + .await + .unwrap(); + seqno += 1; + } + }; + let requests = provider.requests(); + assert_eq!(requests.len(), if retryable { 2 } else { 1 }); + if retryable { + assert!(terminal.get("error").is_none(), "{terminal}"); + assert_eq!( + requests[0], requests[1], + "account failover preserves the request" + ); + let call_ids = provider.call_ids(); + assert_ne!( + call_ids[0], call_ids[1], + "each attempt has an independent recording ID" + ); + assert!(call_ids[1].ends_with(":retry-1")); + } else { + assert_eq!(terminal["error"]["code"], "unavailable"); + let detail = STANDARD_NO_PAD + .decode(terminal["error"]["details"][0]["value"].as_str().unwrap()) + .unwrap(); + let detail = ai::ErrorDetails::decode(detail.as_slice()).unwrap(); + assert_eq!(detail.details.unwrap().is_retryable, Some(false)); + } + } +} + fn client_run() -> pb::AgentClientMessage { pb::AgentClientMessage { message: Some(pb::agent_client_message::Message::RunRequest( diff --git a/server/tests/support/fake_provider.rs b/server/tests/support/fake_provider.rs index 8b2d3e1cc..4e54f6cf9 100644 --- a/server/tests/support/fake_provider.rs +++ b/server/tests/support/fake_provider.rs @@ -27,6 +27,7 @@ enum FakeResponse { pub struct FakeProvider { responses: Arc>>, requests: Arc>>, + call_ids: Arc>>, } impl FakeProvider { @@ -62,6 +63,9 @@ impl FakeProvider { }); ready } + pub fn call_ids(&self) -> Vec { + self.call_ids.lock().unwrap().clone() + } pub fn requests(&self) -> Vec { self.requests.lock().unwrap().clone() } @@ -73,6 +77,7 @@ impl Provider for FakeProvider { invocation: ModelInvocation, _cancellation: CancellationToken, ) -> ProviderStream { + self.call_ids.lock().unwrap().push(invocation.call_id); self.requests.lock().unwrap().push(invocation.request); let events = self .responses