Q172: disconnecting a provider shows what it stops, then the outcome
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
059970e7e1
commit
538d834647
12 files changed
+170
-1
No files matched your search
@@ -18,6 +18,7 @@ export const platformContracts = {
|
||||
clearIntegrationKey: z.strictObject({ name: z.enum(INTEGRATION_KEY_NAMES) }) satisfies z.ZodType<C.ClearIntegrationKeyInput>,
|
||||
saveProviderCredential: z.strictObject({ orgId: id, providerId: id, secret: z.string(), model: z.string().nullable() }) satisfies z.ZodType<C.SaveProviderCredentialInput>,
|
||||
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<C.SetProviderModelInput>,
|
||||
revokeProviderCredential: z.strictObject({ orgId: id, providerId: id }) satisfies z.ZodType<C.RevokeProviderCredentialInput>,
|
||||
startProviderOAuth: z.strictObject({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") };
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -695,4 +695,13 @@ export const zh: Record<string, string> = {
|
||||
"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} 已断开。",
|
||||
};
|
||||
@@ -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<ProviderDisconnectImpact | null>(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 (
|
||||
<Dialog
|
||||
title={t("Disconnect {label}?", { label: provider.label })}
|
||||
open
|
||||
submitLabel={state.kind === "busy" ? t("Disconnecting…") : t("Disconnect")}
|
||||
hideSubmit={finished || impact === null}
|
||||
onClose={onClose}
|
||||
onSubmit={(event) => { event.preventDefault(); if (state.kind !== "busy") disconnect(); }}
|
||||
>
|
||||
{finished ? (
|
||||
<p role="status" style={{ margin: 0 }}>{t("{label} is disconnected.", { label: provider.label })}</p>
|
||||
) : impact === null ? (
|
||||
state.kind !== "error" && <p style={{ margin: 0, color: "var(--text-3)" }}>{t("Checking what this affects…")}</p>
|
||||
) : (
|
||||
<ul style={{ margin: 0, paddingLeft: 18, lineHeight: 1.6, fontSize: 13 }}>
|
||||
<li>{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.")}</li>
|
||||
{impact.agents.length > 0 && <li>{t("Open tasks assigned to them: {count}.", { count: impact.openTasks })}</li>}
|
||||
<li>{t("Running sessions that stop now: {count}.", { count: impact.runningRuns })}</li>
|
||||
<li>{t("Queued sessions that will fail when they start: {count}.", { count: impact.queuedRuns })}</li>
|
||||
<li>{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 })}</li>
|
||||
</ul>
|
||||
)}
|
||||
{state.kind === "error" && <div className="form-error" role="alert">{state.message}</div>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<ProviderRecord | null>(null);
|
||||
const [modelFor, setModelFor] = useState<ProviderRecord | null>(null);
|
||||
const [modelError, setModelError] = useState<string | null>(null);
|
||||
const [disconnecting, setDisconnecting] = useState<ProviderRecord | null>(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
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{disconnecting && (
|
||||
<DisconnectProviderDialog
|
||||
key={disconnecting.id}
|
||||
orgId={orgId}
|
||||
provider={disconnecting}
|
||||
revokesRemotely={requireCatalogEntry(catalog, disconnecting.providerKey).revokesRemotely}
|
||||
actions={actions}
|
||||
onClose={() => setDisconnecting(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Only the model changes here: the credential and its sign-in stay as they are. */}
|
||||
<Dialog
|
||||
key={modelFor?.id ?? "none"}
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface ProviderEndpoint {
|
||||
deployment: string;
|
||||
}
|
||||
|
||||
/** What disconnecting a provider stops, shown before the user confirms. */
|
||||
export interface ProviderDisconnectImpact {
|
||||
agents: string[];
|
||||
openTasks: number;
|
||||
runningRuns: number;
|
||||
queuedRuns: number;
|
||||
}
|
||||
|
||||
export interface SetProviderModelInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
|
||||
@@ -74,6 +74,7 @@ import type {
|
||||
SaveProviderCredentialInput,
|
||||
SetProviderModelInput,
|
||||
ProviderEndpoint,
|
||||
ProviderDisconnectImpact,
|
||||
ConnectComposioToolkitInput,
|
||||
ConnectComposioToolkitResult,
|
||||
ConnectorCatalogEntry,
|
||||
@@ -168,6 +169,7 @@ export interface DesktopApi {
|
||||
setProviderModel(input: SetProviderModelInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
/** Null when the provider has no saved cloud or custom-endpoint credential. */
|
||||
providerEndpoint(input: { orgId: string; providerId: string }): Promise<DesktopResult<ProviderEndpoint | null>>;
|
||||
providerDisconnectImpact(input: { orgId: string; providerId: string }): Promise<DesktopResult<ProviderDisconnectImpact>>;
|
||||
revokeProviderCredential(input: RevokeProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
startProviderOAuth(input: StartProviderOAuthInput): Promise<DesktopResult<StartProviderOAuthResult>>;
|
||||
cancelProviderOAuth(input: CancelProviderOAuthInput): Promise<DesktopResult<null>>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user