From 538d834647c0deed4b50042a31cc1df2796050a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 04:25:31 -0400 Subject: [PATCH] Q172: disconnecting a provider shows what it stops, then the outcome Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-contracts/platform.ts | 1 + src/main/app/ipc-handlers.ts | 8 +++ .../services/provider-disconnect-impact.ts | 14 ++++ src/preload/index.ts | 1 + src/renderer/src/i18n/zh.ts | 9 +++ .../settings/DisconnectProviderDialog.tsx | 66 +++++++++++++++++++ .../src/pages/settings/ProvidersPanel.tsx | 15 ++++- src/shared/commands/provider.ts | 8 +++ src/shared/desktop-api.ts | 2 + src/shared/ipc.ts | 1 + tests/e2e/desktop.spec.ts | 6 ++ tests/unit/provider-disconnect-impact.test.ts | 40 +++++++++++ 12 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 src/main/services/provider-disconnect-impact.ts create mode 100644 src/renderer/src/pages/settings/DisconnectProviderDialog.tsx create mode 100644 tests/unit/provider-disconnect-impact.test.ts diff --git a/src/main/app/ipc-contracts/platform.ts b/src/main/app/ipc-contracts/platform.ts index 0e84140c..74e7c8ec 100644 --- a/src/main/app/ipc-contracts/platform.ts +++ b/src/main/app/ipc-contracts/platform.ts @@ -18,6 +18,7 @@ export const platformContracts = { clearIntegrationKey: z.strictObject({ name: z.enum(INTEGRATION_KEY_NAMES) }) satisfies z.ZodType, saveProviderCredential: z.strictObject({ orgId: id, providerId: id, secret: z.string(), model: z.string().nullable() }) satisfies z.ZodType, providerEndpoint: z.strictObject({ orgId: id, providerId: id }), + providerDisconnectImpact: z.strictObject({ orgId: id, providerId: id }), setProviderModel: z.strictObject({ orgId: id, providerId: id, model: z.string().max(200) }) satisfies z.ZodType, revokeProviderCredential: z.strictObject({ orgId: id, providerId: id }) satisfies z.ZodType, startProviderOAuth: z.strictObject({ diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index f6115840..9acab196 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -41,6 +41,7 @@ import { cancelQueuedRun } from "../services/run-cancel"; import { runHistory } from "../services/run-history"; import { deleteWorkspace, setPrimaryWorkspace, updateWorkspace } from "../services/workspace-admin"; import { setProviderModel } from "../services/provider-default-model"; +import { providerDisconnectImpact } from "../services/provider-disconnect-impact"; export interface IpcDeps { connection: DatabaseConnection; @@ -233,6 +234,13 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("provider_endpoint_failed", error instanceof Error ? error.message : String(error)); } }); + handle("providerDisconnectImpact", (input) => { + try { + return ok(providerDisconnectImpact(deps.connection.db, input.orgId, input.providerId)); + } catch (error) { + return fail("provider_impact_failed", error instanceof Error ? error.message : String(error)); + } + }); handle("setProviderModel", (input) => mutate(() => setProviderModel(deps.connection.db, input))); handle("revokeProviderCredential", async (input) => { try { diff --git a/src/main/services/provider-disconnect-impact.ts b/src/main/services/provider-disconnect-impact.ts new file mode 100644 index 00000000..511bfee5 --- /dev/null +++ b/src/main/services/provider-disconnect-impact.ts @@ -0,0 +1,14 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { ProviderDisconnectImpact } from "../../shared/commands"; + +/** What disconnecting a provider stops: its agents cannot work, running sessions stop now, queued ones fail when they start. */ +export function providerDisconnectImpact(db: DatabaseSync, orgId: string, providerId: string): ProviderDisconnectImpact { + if (!db.prepare("select 1 from providers where id = ? and org_id = ?").get(providerId, orgId)) throw new Error("Provider was not found."); + const agents = (db.prepare("select name from agents where org_id = ? and model_provider_id = ? and lifecycle = 'active' order by name").all(orgId, providerId) as Array<{ name: string }>).map((agent) => agent.name); + const openTasks = (db.prepare(` + select count(*) as n from tasks t join agents a on a.id = t.assignee_agent_id + where t.org_id = ? and a.model_provider_id = ? and a.lifecycle = 'active' and t.status <> 'done' and t.origin is null + `).get(orgId, providerId) as { n: number }).n; + const runs = (state: string) => (db.prepare("select count(*) as n from task_runs where org_id = ? and provider_id = ? and state = ?").get(orgId, providerId, state) as { n: number }).n; + return { agents, openTasks, runningRuns: runs("running"), queuedRuns: runs("queued") }; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 77b05f5b..dd3d4509 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -56,6 +56,7 @@ const api: DesktopApi = { saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input), setProviderModel: (input) => invoke(IPC_CHANNELS.setProviderModel, input), providerEndpoint: (input) => invoke(IPC_CHANNELS.providerEndpoint, input), + providerDisconnectImpact: (input) => invoke(IPC_CHANNELS.providerDisconnectImpact, input), revokeProviderCredential: (input) => invoke(IPC_CHANNELS.revokeProviderCredential, input), startProviderOAuth: (input) => invoke(IPC_CHANNELS.startProviderOAuth, input), cancelProviderOAuth: (input) => invoke(IPC_CHANNELS.cancelProviderOAuth, input), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 25307ade..b94373c8 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -695,4 +695,13 @@ export const zh: Record = { "Zero data retention switched background off.": "零数据保留已关闭后台执行。", "Resets {date} (UTC)": "{date} 重置(UTC)", "Unsaved": "未保存", + "Disconnect {label}?": "断开 {label}?", + "Checking what this affects…": "正在检查影响范围…", + "Agents on this provider stop working until it is reconnected or they move to another provider: {names}.": "使用该 Provider 的 Agent 将停止工作,直到重新连接或改用其他 Provider:{names}。", + "No agent uses this provider.": "没有 Agent 使用该 Provider。", + "Open tasks assigned to them: {count}.": "分配给它们的未完成任务:{count}。", + "Running sessions that stop now: {count}.": "立即停止的运行中会话:{count}。", + "Queued sessions that will fail when they start: {count}.": "开始时将失败的排队会话:{count}。", + "Disconnecting…": "正在断开…", + "{label} is disconnected.": "{label} 已断开。", }; diff --git a/src/renderer/src/pages/settings/DisconnectProviderDialog.tsx b/src/renderer/src/pages/settings/DisconnectProviderDialog.tsx new file mode 100644 index 00000000..cdcf7512 --- /dev/null +++ b/src/renderer/src/pages/settings/DisconnectProviderDialog.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from "react"; +import type { ProviderDisconnectImpact } from "../../../../shared/commands"; +import type { ProviderRecord } from "../../../../shared/domain"; +import type { SnapshotActions } from "../../app/useSnapshot"; +import { Dialog } from "../../shared/Dialog"; +import { t } from "../../i18n/i18n"; + +/** Shows what a disconnect stops before it happens, then its outcome, including a provider that refused to revoke. */ +export function DisconnectProviderDialog({ orgId, provider, revokesRemotely, actions, onClose }: { + orgId: string; + provider: ProviderRecord; + revokesRemotely: boolean; + actions: SnapshotActions; + onClose: () => void; +}) { + const [impact, setImpact] = useState(null); + const [state, setState] = useState<{ kind: "idle" | "busy" | "done" } | { kind: "error"; message: string }>({ kind: "idle" }); + + useEffect(() => { + let live = true; + void window.agentCompany.providerDisconnectImpact({ orgId, providerId: provider.id }).then((result) => { + if (!live) return; + if (result.ok) setImpact(result.data); + else setState({ kind: "error", message: result.error.message }); + }); + return () => { live = false; }; + }, [orgId, provider.id]); + + const disconnect = () => { + setState({ kind: "busy" }); + actions.run(() => window.agentCompany.revokeProviderCredential({ orgId, providerId: provider.id })) + .then(() => setState({ kind: "done" })) + .catch((failure: unknown) => setState({ kind: "error", message: failure instanceof Error ? failure.message : String(failure) })); + }; + + const finished = state.kind === "done"; + return ( + { event.preventDefault(); if (state.kind !== "busy") disconnect(); }} + > + {finished ? ( +

{t("{label} is disconnected.", { label: provider.label })}

+ ) : impact === null ? ( + state.kind !== "error" &&

{t("Checking what this affects…")}

+ ) : ( +
    +
  • {impact.agents.length + ? t("Agents on this provider stop working until it is reconnected or they move to another provider: {names}.", { names: impact.agents.join(", ") }) + : t("No agent uses this provider.")}
  • + {impact.agents.length > 0 &&
  • {t("Open tasks assigned to them: {count}.", { count: impact.openTasks })}
  • } +
  • {t("Running sessions that stop now: {count}.", { count: impact.runningRuns })}
  • +
  • {t("Queued sessions that will fail when they start: {count}.", { count: impact.queuedRuns })}
  • +
  • {revokesRemotely + ? t("Revokes the sign-in at {provider} and erases it from this computer.", { provider: provider.label }) + : t("Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.", { provider: provider.label })}
  • +
+ )} + {state.kind === "error" &&
{state.message}
} +
+ ); +} diff --git a/src/renderer/src/pages/settings/ProvidersPanel.tsx b/src/renderer/src/pages/settings/ProvidersPanel.tsx index 995b81c1..4c51f439 100644 --- a/src/renderer/src/pages/settings/ProvidersPanel.tsx +++ b/src/renderer/src/pages/settings/ProvidersPanel.tsx @@ -15,6 +15,7 @@ import { ProviderLogo } from "../../shared/ProviderLogo"; import { ModelPicker, ProviderConnectFields, ProviderOAuthFields } from "./ProviderConnectionDialog"; import { composeSecret } from "./provider-secret"; import { ProviderPolicyTable } from "./ProviderPolicyTable"; +import { DisconnectProviderDialog } from "./DisconnectProviderDialog"; import type { SnapshotActions } from "../../app/useSnapshot"; interface ProvidersPanelProps { @@ -35,6 +36,7 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth const [active, setActive] = useState(null); const [modelFor, setModelFor] = useState(null); const [modelError, setModelError] = useState(null); + const [disconnecting, setDisconnecting] = useState(null); const openModel = (provider: ProviderRecord | null) => { setModelError(null); setModelFor(provider); }; const modelForEntry = modelFor ? requireCatalogEntry(catalog, modelFor.providerKey) : null; const connected = providers.filter((provider) => provider.enabled); @@ -88,7 +90,7 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth onCheck={() => void actions.run(() => window.agentCompany.checkProviderHealth({ orgId, providerId: provider.id }))} onManage={() => setActive(provider)} onChangeModel={provider.providerKey === "azure-openai" ? null : () => openModel(provider)} - onRevoke={() => void actions.run(() => window.agentCompany.revokeProviderCredential({ orgId, providerId: provider.id }))} + onRevoke={() => setDisconnecting(provider)} revokesRemotely={requireCatalogEntry(catalog, provider.providerKey).revokesRemotely} /> ))} @@ -142,6 +144,17 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth )} + {disconnecting && ( + setDisconnecting(null)} + /> + )} + {/* Only the model changes here: the credential and its sign-in stay as they are. */} >; /** Null when the provider has no saved cloud or custom-endpoint credential. */ providerEndpoint(input: { orgId: string; providerId: string }): Promise>; + providerDisconnectImpact(input: { orgId: string; providerId: string }): Promise>; revokeProviderCredential(input: RevokeProviderCredentialInput): Promise>; startProviderOAuth(input: StartProviderOAuthInput): Promise>; cancelProviderOAuth(input: CancelProviderOAuthInput): Promise>; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 71837836..400b6772 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -29,6 +29,7 @@ export const IPC_CHANNELS = { saveProviderCredential: "agentcompany:save-provider-credential", setProviderModel: "agentcompany:set-provider-model", providerEndpoint: "agentcompany:provider-endpoint", + providerDisconnectImpact: "agentcompany:provider-disconnect-impact", revokeProviderCredential: "agentcompany:revoke-provider-credential", startProviderOAuth: "agentcompany:start-provider-oauth", cancelProviderOAuth: "agentcompany:cancel-provider-oauth", diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index ee109ac8..7429db5e 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -237,7 +237,13 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.locator(".modal [name=model]").fill("llama3.1"); await page.locator(".modal").getByRole("button", { name: "Save" }).click(); await expect(customRow.getByText("llama3.1 · Cloud")).toBeVisible(); + // Disconnecting first shows what it stops, then the outcome. await customRow.getByRole("button", { name: "Disconnect" }).click(); + await expect(page.locator(".modal").getByText("No agent uses this provider.")).toBeVisible(); + await expect(page.locator(".modal").getByText("Queued sessions that will fail when they start: 0.")).toBeVisible(); + await page.locator(".modal").getByRole("button", { name: "Disconnect" }).click(); + await expect(page.locator(".modal").getByRole("status")).toHaveText("Custom OpenAI-compatible is disconnected."); + await page.locator(".modal").getByRole("button", { name: "Close" }).click(); await expect(customRow).toHaveCount(0); // Skills are built-in now: no Skills tab exists, yet the bundled diff --git a/tests/unit/provider-disconnect-impact.test.ts b/tests/unit/provider-disconnect-impact.test.ts new file mode 100644 index 00000000..bb722604 --- /dev/null +++ b/tests/unit/provider-disconnect-impact.test.ts @@ -0,0 +1,40 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { providerDisconnectImpact } from "../../src/main/services/provider-disconnect-impact"; +import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain"; + +describe("what disconnecting a provider affects", () => { + test("names its agents, their open tasks, and the sessions that stop or will fail", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-disconnect-${randomUUID()}.db`)); + const db = connection.db; + const organization = new OrganizationService(db); + let orgId = ""; + let openai = ""; + let ada = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" }); + const idOf = (key: string) => (db.prepare("select id from providers where org_id = ? and provider_key = ?").get(orgId, key) as { id: string }).id; + openai = idOf("openai"); + ada = organization.createAgent({ orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: openai, modelId: null, budgetCents: 1000, managerAgentId: null }); + organization.createAgent({ orgId, name: "Bo", role: "Writer", department: "Engineering", modelProviderId: idOf("anthropic"), modelId: null, budgetCents: 1000, managerAgentId: null }); + }); + const task = (title: string, status: string) => organization.createTask({ + orgId, title, description: "d", status: status as (typeof TASK_STATUS)[keyof typeof TASK_STATUS], assigneeAgentId: ada, risk: RISK_LEVEL.LOW, + projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false, + }); + const open = task("Open", TASK_STATUS.BACKLOG); + task("Finished", TASK_STATUS.DONE); + const run = (id: string, state: string) => db.prepare("insert into task_runs(id, org_id, task_id, agent_id, provider_id, trigger, state, created_at) values (?, ?, ?, ?, ?, 'manual', ?, ?)") + .run(id, orgId, open, ada, openai, state, new Date().toISOString()); + run("run_a", "queued"); + run("run_b", "running"); + run("run_c", "completed"); + + expect(providerDisconnectImpact(db, orgId, openai)).toEqual({ agents: ["Ada"], openTasks: 1, runningRuns: 1, queuedRuns: 1 }); + expect(() => providerDisconnectImpact(db, orgId, "prv_missing")).toThrow("Provider was not found."); + }); +});