Skip to content
Draft
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
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",
]);
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
Expand Down Expand Up @@ -284,6 +285,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",
]);
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
Expand Down Expand Up @@ -335,6 +337,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",
]);
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
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 @@ -11,6 +11,7 @@ import { openApiNdjsonOutputDataMigration } from "@executor-js/plugin-openapi";
import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";

import {
providerServiceCatalogRepairDataMigration,
providerServiceSplitDataMigration,
runSqliteProviderServiceSplitMigration,
type BlobRow,
Expand Down Expand Up @@ -243,6 +244,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 @@ -15,7 +15,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 {
providerServiceCatalogRepairDataMigration,
providerServiceSplitDataMigration,
} from "@executor-js/plugin-provider-service-split";
import { authConfigTransforms } from "./auth-config-migration";

export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
Expand All @@ -34,6 +37,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 @@ -20,7 +20,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 {
providerServiceCatalogRepairDataMigration,
providerServiceSplitDataMigration,
} from "@executor-js/plugin-provider-service-split";
import { authConfigTransforms } from "./auth-config-migration";
import { LOCAL_V1_V2_LEDGER_NAME } from "./v1-v2-migration";

Expand All @@ -45,6 +48,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 = "localcoregoogle";
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
59 changes: 59 additions & 0 deletions packages/plugins/openapi/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1301,6 +1301,65 @@ 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);

// 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");
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("updateSpec accepts new inline content for blob-sourced integrations", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
Loading
Loading