Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/gap-free-catalog-rebuild.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/sdk": patch
---

Tool-catalog rebuilds no longer empty the catalog while they run. A rebuild now upserts the new tool and definition rows and then prunes only the names the upstream stopped listing, instead of deleting every row and re-inserting. On databases without interactive transactions (Cloudflare D1), each statement commits on its own, so a search during a rebuild used to find zero tools for that connection, and a rebuild cut off partway (or overlapping another session's rebuild) left the catalog partial; both now keep a complete catalog, and an interrupted rebuild stays stale and retries. Catalog rows are written in size-bounded calls, so a large spec's catalog (Cloudflare's own API) no longer exceeds D1's 32MiB batch limit.
6 changes: 6 additions & 0 deletions .changeset/scoped-operation-scan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@executor-js/sdk": patch
"@executor-js/plugin-openapi": patch
---

Plugin storage key-prefix reads narrow in the database instead of loading the whole collection and filtering in memory. OpenAPI catalog rebuilds now read only the rebuilt integration's operations, decoding each once, where they previously loaded every OpenAPI integration's operations for each connection — the allocation that pushed Cloudflare-hosted sessions with large specs (for example Cloudflare's own API) past the Workers memory limit during tool search.
5 changes: 5 additions & 0 deletions .changeset/tools-sync-concurrency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/sdk": patch
---

`ExecutorConfig.toolsSyncConcurrency` sets how many stale tool catalogs one tools read rebuilds at once (default 10, unchanged). Each in-flight rebuild holds its resolved catalog in memory until its write commits, so memory-constrained hosts can narrow the fan-out; the Cloudflare host now rebuilds two at a time, keeping a full stale fan-out over large OpenAPI specs inside the Workers isolate limit.
54 changes: 54 additions & 0 deletions apps/host-cloudflare/src/account/account-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";

import { AccountUnauthorized } from "@executor-js/api";
import { AccountProvider } from "@executor-js/api/server";

import type { CloudflareConfig } from "../config";
import { cloudflareAccountProvider } from "./account-provider";

const config = (overrides: Partial<CloudflareConfig> = {}): CloudflareConfig => ({
accessTeamDomain: "team.cloudflareaccess.com",
accessAud: "aud-tag",
accessNameClaim: "name",
accessGroupsClaim: "groups",
adminEmails: ["admin@example.com"],
organizationId: "default",
organizationName: "Default",
organizationSlug: "default",
secretKey: "x".repeat(32),
allowLocalNetwork: false,
webBaseUrl: "https://localhost",
enableDevAuth: true,
...overrides,
});

describe("cloudflareAccountProvider.listMembers", () => {
it.effect("reports the Access principal so the console can see ADMIN_EMAILS", () =>
Effect.gen(function* () {
const provider = yield* AccountProvider;
const { members } = yield* provider.listMembers({});
expect(members).toEqual([
{
id: "dev",
userId: "dev",
email: "admin@example.com",
name: "Dev",
avatarUrl: null,
role: "admin",
status: "active",
lastActiveAt: null,
isCurrentUser: true,
},
]);
}).pipe(Effect.provide(cloudflareAccountProvider(config()))),
);

it.effect("refuses when Access did not authenticate the request", () =>
Effect.gen(function* () {
const provider = yield* AccountProvider;
const error = yield* provider.listMembers({}).pipe(Effect.flip);
expect(error).toBeInstanceOf(AccountUnauthorized);
}).pipe(Effect.provide(cloudflareAccountProvider(config({ enableDevAuth: false })))),
);
});
31 changes: 26 additions & 5 deletions apps/host-cloudflare/src/account/account-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import type { CloudflareConfig } from "../config";
// uses), reading the `Cf-Access-Jwt-Assertion` header off the request.
//
// Single-tenant + Access-managed: members, roles, and API keys live in
// Cloudflare Access, NOT in the app. The shell hides the API-keys footer and
// shows no members page, so those methods are never reached from the UI; they
// return empty (reads) or a clear "managed by Cloudflare Access" error (writes)
// to satisfy the provider shape.
// Cloudflare Access, NOT in the app. Writes stay refused. `listMembers` still
// has to return the current Access principal — the console infers admin from
// that list (`isCurrentUser` + role), and an empty list fail-closes every
// workspace-admin action even when `ADMIN_EMAILS` granted `orgRole: "admin"`.
// ---------------------------------------------------------------------------

const NOT_IN_APP = "Managed by Cloudflare Access, not in the app.";
Expand Down Expand Up @@ -66,7 +66,28 @@ export const cloudflareAccountProvider = (
listOrgApiKeys: () => Effect.succeed({ apiKeys: [] }),
createOrgApiKey: () => forbiddenWrite,
revokeOrgApiKey: () => forbiddenWrite,
listMembers: () => Effect.succeed({ members: [] }),
listMembers: (headers) =>
principalFrom(headers).pipe(
Effect.flatMap((principal) =>
principal
? Effect.succeed({
members: [
{
id: principal.accountId,
userId: principal.accountId,
email: principal.email.length > 0 ? principal.email : null,
name: principal.name,
avatarUrl: principal.avatarUrl,
role: principal.orgRole === "admin" ? "admin" : "member",
status: "active",
lastActiveAt: null,
isCurrentUser: true,
},
],
})
: Effect.fail(new AccountUnauthorized()),
),
),
listRoles: () => Effect.succeed({ roles: [] }),
inviteMember: () => forbiddenWrite,
removeMember: () => forbiddenWrite,
Expand Down
8 changes: 8 additions & 0 deletions apps/host-cloudflare/src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,19 @@ export const makeCloudflarePluginsProvider = (
}),
});

// Two stale catalogs rebuild at once, not the SDK's ten: each in-flight
// rebuild holds its resolved tools and schema definitions until its write
// commits, and a full fan-out over a few large OpenAPI specs overruns the
// 128MB Workers isolate. Writes are serialized anyway, so the narrower
// fan-out costs little convergence time.
const CLOUDFLARE_TOOLS_SYNC_CONCURRENCY = 2;

export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> =>
Layer.succeed(HostConfig)({
allowLocalNetwork: config.allowLocalNetwork,
webBaseUrl: config.webBaseUrl,
oauthCallbackPath: "/api/oauth/callback",
toolsSyncConcurrency: CLOUDFLARE_TOOLS_SYNC_CONCURRENCY,
});

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/core/api/src/server/scoped-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ export interface HostConfigShape {
* operator knob.
*/
readonly toolsSyncTtlMs?: number | null;
/**
* Forwarded verbatim to `ExecutorConfig.toolsSyncConcurrency`: how many
* stale tool catalogs one read rebuilds at once. Omit to take the SDK
* default; memory-constrained hosts lower it because every in-flight
* rebuild holds its resolved catalog until its write commits.
*/
readonly toolsSyncConcurrency?: number;
/**
* Forwarded to `ExecutorConfig.waitUntil`: the host's keep-alive
* for background work that outlives a request (stale tool-catalog rebuilds
Expand Down Expand Up @@ -334,6 +341,9 @@ export const makeScopedExecutor = <
fetch: hostedFetch,
onIntegrationChange: config.onIntegrationChange,
...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}),
...(config.toolsSyncConcurrency !== undefined
? { toolsSyncConcurrency: config.toolsSyncConcurrency }
: {}),
...(waitUntil !== undefined ? { waitUntil } : {}),
onElicitation: "accept-all",
...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }),
Expand Down
Loading
Loading