Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/repair-provider-catalogs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@executor-js/plugin-openapi": patch
"@executor-js/plugin-toolkits": patch
---

Repair missing Google and Microsoft operation catalogs through the stored service URL when an integration administrator reads tools. Preserve existing catalogs when repair cannot complete, and include connected accounts with zero tools in the toolkit connection picker.
3 changes: 3 additions & 0 deletions apps/host-cloudflare/src/db/data-migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ describe("runCloudflareDataMigrations", () => {
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
"2026-06-20-google-openapi-ownership",
"2026-07-08-provider-service-split",
"2026-07-10-provider-service-empty-catalog-repair",
"2026-07-09-openapi-ndjson-output-arrays",
"2026-07-27-encrypted-secrets-owner-repartition",
]);
Expand Down Expand Up @@ -285,6 +286,7 @@ describe("runCloudflareDataMigrations", () => {
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
"2026-06-20-google-openapi-ownership",
"2026-07-08-provider-service-split",
"2026-07-10-provider-service-empty-catalog-repair",
"2026-07-09-openapi-ndjson-output-arrays",
"2026-07-27-encrypted-secrets-owner-repartition",
]);
Expand Down Expand Up @@ -337,6 +339,7 @@ describe("runCloudflareDataMigrations", () => {
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
"2026-06-20-google-openapi-ownership",
"2026-07-08-provider-service-split",
"2026-07-10-provider-service-empty-catalog-repair",
"2026-07-09-openapi-ndjson-output-arrays",
"2026-07-27-encrypted-secrets-owner-repartition",
]);
Expand Down
2 changes: 2 additions & 0 deletions apps/host-cloudflare/src/db/data-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-en

import {
providerServiceSplitDataMigration,
providerServiceCatalogRepairDataMigration,
runSqliteProviderServiceSplitMigration,
type BlobRow,
} from "@executor-js/plugin-provider-service-split";
Expand Down Expand Up @@ -248,6 +249,7 @@ const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly Sqlite
}).pipe(Effect.asVoid);
}),
},
providerServiceCatalogRepairDataMigration,
// Stale-mark connections whose operations return NDJSON so their tool rows
// rebuild with array-wrapped output schemas (mirrors cloud's drizzle 0010).
openApiNdjsonOutputDataMigration,
Expand Down
6 changes: 5 additions & 1 deletion apps/host-selfhost/src/db/data-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import {
import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";
import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";

import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split";
import {
providerServiceSplitDataMigration,
providerServiceCatalogRepairDataMigration,
} from "@executor-js/plugin-provider-service-split";
import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-encrypted-secrets";
import { authConfigTransforms } from "./auth-config-migration";

Expand All @@ -44,6 +47,7 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
graphqlIntrospectionBlobDataMigration,
googleOpenApiOwnershipDataMigration,
providerServiceSplitDataMigration,
providerServiceCatalogRepairDataMigration,
// Stale-mark connections whose operations return NDJSON so their tool rows
// rebuild with array-wrapped output schemas (mirrors cloud's drizzle 0010).
openApiNdjsonOutputDataMigration,
Expand Down
6 changes: 5 additions & 1 deletion apps/local/src/db/data-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import {
import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";
import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";

import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split";
import {
providerServiceSplitDataMigration,
providerServiceCatalogRepairDataMigration,
} from "@executor-js/plugin-provider-service-split";
import { authConfigTransforms } from "./auth-config-migration";
import { LOCAL_V1_V2_LEDGER_NAME } from "./v1-v2-migration";

Expand Down Expand Up @@ -51,6 +54,7 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [
graphqlIntrospectionBlobDataMigration,
googleOpenApiOwnershipDataMigration,
providerServiceSplitDataMigration,
providerServiceCatalogRepairDataMigration,
// GC dead DCR oauth_client rows (old always-register duplicates) and backfill
// the surviving DCR rows' origin_issuer from token_url (issue #1120, Part C).
oauthClientGcSqliteMigration,
Expand Down
89 changes: 89 additions & 0 deletions e2e/selfhost/toolkits-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,27 @@ const hiddenPersonalSpec = (baseUrl: string): string =>
},
});

const connectedGoogleSpec = (): string =>
JSON.stringify({
openapi: "3.0.3",
info: { title: "Gmail", version: "1.0.0" },
paths: {
"/messages": {
get: {
operationId: "gmail.users.messages.list",
responses: { "200": { description: "Messages" } },
},
},
},
});

const connectedWithoutToolsSpec = (): string =>
JSON.stringify({
openapi: "3.0.3",
info: { title: "Gmail", version: "1.0.0" },
paths: {},
});

scenario(
"Toolkits · self-host UI creates a toolkit and configures tools",
{ timeout: 180_000 },
Expand All @@ -43,6 +64,8 @@ scenario(
const slug = name;
const hiddenPersonalIntegration = `${prefix}-personal-api`;
const hiddenPersonalConnection = "mine";
const connectedWithoutToolsIntegration = `${prefix}-gmail`;
const connectedWithoutToolsConnection = "waitingAccount";
const seededToolkits = [
{ owner: "org" as const, name: `${prefix}-workspace-a` },
{ owner: "org" as const, name: `${prefix}-workspace-b` },
Expand All @@ -63,9 +86,21 @@ scenario(
},
})
.pipe(Effect.ignore);
yield* client.connections
.remove({
params: {
owner: "org",
integration: IntegrationSlug.make(connectedWithoutToolsIntegration),
name: ConnectionName.make(connectedWithoutToolsConnection),
},
})
.pipe(Effect.ignore);
yield* client.openapi
.removeSpec({ params: { slug: hiddenPersonalIntegration } })
.pipe(Effect.ignore);
yield* client.openapi
.removeSpec({ params: { slug: connectedWithoutToolsIntegration } })
.pipe(Effect.ignore);
const listed = yield* client.toolkits.list();
yield* Effect.forEach(
listed.toolkits.filter((row) => row.slug.startsWith(prefix)),
Expand Down Expand Up @@ -103,6 +138,42 @@ scenario(
value: "unused-token",
},
});
yield* client.openapi.addSpec({
payload: {
spec: { kind: "blob", value: connectedGoogleSpec() },
slug: IntegrationSlug.make(connectedWithoutToolsIntegration),
authenticationTemplate: [
{
slug: "apiKey",
type: "apiKey",
headers: { "x-api-key": [{ type: "variable", name: "token" }] },
},
],
},
});
yield* client.connections.create({
payload: {
owner: "org",
name: ConnectionName.make(connectedWithoutToolsConnection),
integration: IntegrationSlug.make(connectedWithoutToolsIntegration),
template: AuthTemplateSlug.make("apiKey"),
value: "unused-token",
},
});
yield* client.openapi.updateSpec({
params: { slug: connectedWithoutToolsIntegration },
payload: { spec: { kind: "blob", value: connectedWithoutToolsSpec() } },
});
const connectedAccounts = yield* client.connections.list({
query: {
owner: "org",
integration: IntegrationSlug.make(connectedWithoutToolsIntegration),
},
});
expect(
connectedAccounts.map((connection) => connection.name),
"the Google account remains connected while its refreshed tool catalog is empty",
).toContain(connectedWithoutToolsConnection);

yield* browser.session(identity, async ({ page, step }) => {
await step("Open the Toolkits plugin page", async () => {
Expand Down Expand Up @@ -213,6 +284,24 @@ scenario(
.waitFor();
});

await step(
"A connected Google account remains addable before its tools are available",
async () => {
const dialog = page.getByRole("dialog", { name: "Manage connections" });
await dialog
.getByLabel("Search connections and tools")
.fill(connectedWithoutToolsConnection);
await dialog.getByText(connectedWithoutToolsConnection, { exact: true }).waitFor();
await dialog.getByText("Gmail · 0 tools", { exact: true }).waitFor();
const add = dialog.getByRole("button", { name: /^Add connection / });
await add.click();
const remove = dialog.getByRole("button", { name: /^Remove connection / });
await remove.waitFor();
await remove.click();
await add.waitFor();
},
);

await step("Add a connection to the toolkit", async () => {
const dialog = page.getByRole("dialog", { name: "Manage connections" });
await dialog.waitFor();
Expand Down
124 changes: 124 additions & 0 deletions packages/plugins/openapi/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1394,6 +1394,130 @@ paths:
}),
);

it.effect("repairs a migrated provider connection whose operation catalog is missing", () =>
Effect.scoped(
Effect.gen(function* () {
const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi });
const config = makeTestConfig({ plugins: testPlugins() });
const executor = yield* createExecutor(config);
const integration = IntegrationSlug.make("google_gmail");

yield* executor.openapi.addSpec({
spec: { kind: "url", url: specServer.specUrl },
slug: integration,
family: "google",
baseUrl: specServer.baseUrl,
authenticationTemplate: [apiKeyTemplate],
});
yield* executor.connections.create({
owner: "org",
name: ConnectionName.make("main"),
integration,
template: AuthTemplateSlug.make("apiKey"),
value: "secret-key-123",
});
expect((yield* executor.tools.list({ integration })).length).toBeGreaterThan(0);

// The migrated hash can still refer to a monolith. Re-fetch the
// selected service instead of rebuilding tools from that old blob.
yield* specServer.setApi(
HttpApi.make("selectedService").add(
HttpApiGroup.make("items").add(
HttpApiEndpoint.get("listItems", "/items", { success: Schema.Array(Item) }),
),
),
);

// Reproduce the bad service-split state: the integration + source URL
// and connection survived, but operation storage and tool rows did not,
// while the follow-up migration has marked the connection stale.
yield* Effect.promise(() =>
config.db.deleteMany("plugin_storage", {
where: (b) => b.and(b("plugin_id", "=", "openapi"), b("collection", "=", "operation")),
}),
);
yield* Effect.promise(() =>
config.db.deleteMany("tool", {
where: (b) => b("integration", "=", String(integration)),
}),
);
yield* Effect.promise(() =>
config.db.updateMany("connection", {
where: (b) => b("integration", "=", String(integration)),
set: { tools_synced_at: null },
}),
);

const repaired = yield* executor.tools.list({ integration });
expect(
repaired.map((tool) => String(tool.name)),
"the next catalog read refetches the provider spec and restores tools",
).toContain("items.listItems");
expect(repaired.map((tool) => String(tool.name))).not.toContain("items.queryRows");
const operations = yield* Effect.promise(() =>
config.db.findMany("plugin_storage", {
where: (b) => b.and(b("plugin_id", "=", "openapi"), b("collection", "=", "operation")),
}),
);
expect(operations.length, "the repaired operation catalog is persisted").toBeGreaterThan(0);
}),
),
);

it.effect("member reads preserve the prior provider tools without repairing org storage", () =>
Effect.scoped(
Effect.gen(function* () {
const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi });
const config = makeTestConfig({ plugins: testPlugins() });
const blobs = recordingBlobStore();
const admin = yield* createExecutor({ ...config, blobs: blobs.store });
const integration = IntegrationSlug.make("microsoft_calendar");
yield* admin.openapi.addSpec({
spec: { kind: "url", url: specServer.specUrl },
slug: integration,
family: "microsoft",
authenticationTemplate: [apiKeyTemplate],
});
yield* admin.connections.create({
owner: "org",
name: ConnectionName.make("main"),
integration,
template: AuthTemplateSlug.make("apiKey"),
value: "synthetic-api-key",
});
const before = yield* admin.tools.list({ integration });
yield* Effect.promise(() =>
config.db.deleteMany("plugin_storage", {
where: (b) => b.and(b("plugin_id", "=", "openapi"), b("collection", "=", "operation")),
}),
);
yield* Effect.promise(() =>
config.db.updateMany("connection", {
where: (b) => b("integration", "=", String(integration)),
set: { tools_synced_at: null },
}),
);
const writes = blobs.writeCount();
const member = yield* createExecutor({
...config,
blobs: blobs.store,
orgWrites: "denied",
});
const after = yield* member.tools.list({ integration });
expect(after.map((tool) => String(tool.name))).toEqual(
before.map((tool) => String(tool.name)),
);
expect(blobs.writeCount()).toBe(writes);
const operations = yield* Effect.promise(() =>
config.db.findMany("plugin_storage", {
where: (b) => b.and(b("plugin_id", "=", "openapi"), b("collection", "=", "operation")),
}),
);
expect(operations).toEqual([]);
}),
),
);

it.effect("updateSpec accepts new inline content for blob-sourced integrations", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
Loading
Loading