mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
fix(repositories): make provider disconnect optional and disambiguate hosts
Skip optional disconnect hooks when a worker does not advertise them. Forward provider hosts through the UI connection flow so hosted and enterprise identities can coexist. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
83d0f420ff
commit
3d4c484816
9 files changed
+314
-35
No files matched your search
@@ -1,9 +1,13 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RepositoryConnection } from "@paperclipai/shared";
|
||||
import type { HostToWorkerMethodName } from "@paperclipai/plugin-sdk";
|
||||
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||
import { FakeRepositoryProvider } from "../services/repository-providers/index.js";
|
||||
import { createPluginRepositoryProviderConnector } from "../services/repository-providers/plugin-connector.js";
|
||||
import {
|
||||
createPluginRepositoryProviderConnector,
|
||||
REQUIRED_REPOSITORY_PROVIDER_METHODS,
|
||||
} from "../services/repository-providers/plugin-connector.js";
|
||||
import {
|
||||
runProviderConformance,
|
||||
type ProviderConformanceWorld,
|
||||
@@ -157,3 +161,90 @@ function bridgedWorld(): ProviderConformanceWorld {
|
||||
describe("plugin-bridged repository provider conformance", () => {
|
||||
runProviderConformance(bridgedWorld);
|
||||
});
|
||||
|
||||
/**
|
||||
* Disconnect is the one provider hook a plugin may leave out — a provider with
|
||||
* nothing to revoke on its side has no work to do. The host must still tear the
|
||||
* connection down locally. Dispatching the RPC anyway makes the worker answer
|
||||
* `METHOD_NOT_IMPLEMENTED`, which would strand the connection in `error` and
|
||||
* leave an operator unable to disconnect at all.
|
||||
*/
|
||||
describe("plugin-bridged optional disconnect", () => {
|
||||
const connection = connectionFromSnapshot({
|
||||
id: randomUUID(),
|
||||
companyId: randomUUID(),
|
||||
provider: PROVIDER,
|
||||
host: HOST,
|
||||
installationId: "install-1",
|
||||
accountId: "account-1",
|
||||
accountName: "acme",
|
||||
syncCursor: null,
|
||||
providerMetadata: null,
|
||||
});
|
||||
|
||||
function connectorWith(worker: { supportedMethods?: string[]; call: () => Promise<unknown> }) {
|
||||
const calls: string[] = [];
|
||||
const connector = createPluginRepositoryProviderConnector({
|
||||
pluginId: randomUUID(),
|
||||
pluginKey: "acme.github-enterprise",
|
||||
declaration: { providerKey: PROVIDER, displayName: "GitHub Enterprise", host: HOST },
|
||||
workerManager: {
|
||||
isRunning: () => true,
|
||||
getWorker: ((() =>
|
||||
worker.supportedMethods ? { supportedMethods: worker.supportedMethods } : undefined) as never),
|
||||
call: (async (_pluginId: string, method: HostToWorkerMethodName) => {
|
||||
calls.push(method);
|
||||
return await worker.call();
|
||||
}) as never,
|
||||
},
|
||||
});
|
||||
return { connector, calls };
|
||||
}
|
||||
|
||||
it("does not dispatch when the worker does not advertise the hook", async () => {
|
||||
const { connector, calls } = connectorWith({
|
||||
supportedMethods: [...REQUIRED_REPOSITORY_PROVIDER_METHODS],
|
||||
call: async () => {
|
||||
throw new Error("must not dispatch");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(connector.disconnect!({ connection })).resolves.toBeUndefined();
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("tolerates METHOD_NOT_IMPLEMENTED from a worker that advertises nothing", async () => {
|
||||
const { connector, calls } = connectorWith({
|
||||
call: async () => {
|
||||
throw new JsonRpcCallError({
|
||||
code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED,
|
||||
message: "repositoryProviderDisconnect is not implemented",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(connector.disconnect!({ connection })).resolves.toBeUndefined();
|
||||
expect(calls).toEqual(["repositoryProviderDisconnect"]);
|
||||
});
|
||||
|
||||
it("dispatches when the worker advertises the hook", async () => {
|
||||
const { connector, calls } = connectorWith({
|
||||
supportedMethods: [...REQUIRED_REPOSITORY_PROVIDER_METHODS, "repositoryProviderDisconnect"],
|
||||
call: async () => undefined,
|
||||
});
|
||||
|
||||
await connector.disconnect!({ connection });
|
||||
expect(calls).toEqual(["repositoryProviderDisconnect"]);
|
||||
});
|
||||
|
||||
it("still surfaces a real provider failure", async () => {
|
||||
const { connector } = connectorWith({
|
||||
supportedMethods: [...REQUIRED_REPOSITORY_PROVIDER_METHODS, "repositoryProviderDisconnect"],
|
||||
call: async () => {
|
||||
throw new JsonRpcCallError({ code: PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE, message: "worker crashed" });
|
||||
},
|
||||
});
|
||||
|
||||
await expect(connector.disconnect!({ connection })).rejects.toThrow("worker crashed");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
PluginRepositoryConnectionSnapshot,
|
||||
PluginRepositoryProviderBaseParams,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||
import type { RepositoryConnection, PluginRepositoryProviderDeclaration } from "@paperclipai/shared";
|
||||
import { normalizeRepositoryProviderHost } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../../errors.js";
|
||||
@@ -57,6 +58,14 @@ export const REQUIRED_REPOSITORY_PROVIDER_METHODS = [
|
||||
"repositoryProviderResolveCloneCredential",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Hooks a repository provider may leave unimplemented. Disconnect is advisory —
|
||||
* it lets a provider revoke an installation on its side — so a provider that has
|
||||
* nothing to revoke is allowed to omit it, and the host still tears the
|
||||
* connection down locally.
|
||||
*/
|
||||
export const OPTIONAL_REPOSITORY_PROVIDER_METHODS = ["repositoryProviderDisconnect"] as const;
|
||||
|
||||
/**
|
||||
* Methods a running worker declared at initialization but does not implement.
|
||||
* Returns an empty list when the worker manager cannot report them, since an
|
||||
@@ -71,6 +80,29 @@ export function missingRepositoryProviderMethods(
|
||||
return REQUIRED_REPOSITORY_PROVIDER_METHODS.filter((method) => !supported.includes(method));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a worker advertises an optional method.
|
||||
*
|
||||
* A worker that reports no method list at all predates the advertisement, so it
|
||||
* is given the benefit of the doubt and the call is attempted — a worker that
|
||||
* turns out not to implement it answers `METHOD_NOT_IMPLEMENTED`, which the
|
||||
* caller treats as "nothing to do".
|
||||
*/
|
||||
function workerAdvertisesMethod(
|
||||
workerManager: RepositoryProviderWorkerHost,
|
||||
pluginId: string,
|
||||
method: string,
|
||||
): boolean {
|
||||
const supported = workerManager.getWorker?.(pluginId)?.supportedMethods;
|
||||
if (!supported) return true;
|
||||
return supported.includes(method);
|
||||
}
|
||||
|
||||
/** True when an RPC failure means "the worker does not implement this method". */
|
||||
function isMethodNotImplemented(error: unknown): boolean {
|
||||
return error instanceof JsonRpcCallError && error.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED;
|
||||
}
|
||||
|
||||
export interface PluginRepositoryProviderConnectorOptions {
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
@@ -246,17 +278,30 @@ export function createPluginRepositoryProviderConnector(
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Disconnect is optional for a provider. When the worker does not advertise
|
||||
* the hook — or answers `METHOD_NOT_IMPLEMENTED` because it predates the
|
||||
* advertisement — the host still tears the connection down locally instead
|
||||
* of failing the disconnect and stranding the connection in `error`.
|
||||
*/
|
||||
async disconnect(input) {
|
||||
assertOwnedConnection(input.connection);
|
||||
await options.workerManager.call(
|
||||
options.pluginId,
|
||||
"repositoryProviderDisconnect",
|
||||
{
|
||||
...baseParams(input.connection.companyId),
|
||||
connection: toSnapshot(input.connection),
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
if (!workerAdvertisesMethod(options.workerManager, options.pluginId, "repositoryProviderDisconnect")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await options.workerManager.call(
|
||||
options.pluginId,
|
||||
"repositoryProviderDisconnect",
|
||||
{
|
||||
...baseParams(input.connection.companyId),
|
||||
connection: toSnapshot(input.connection),
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isMethodNotImplemented(error)) throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async resolveCloneCredential(input: ResolveCloneCredentialInput) {
|
||||
|
||||
Reference in new issue
Block a user