From cd9b978d5aca9bf33f9aa2c92ad0d2a102c27c69 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:16:34 -0700 Subject: [PATCH 1/2] Add isolate lifecycle signals to worker spans (#1694) Every span we emit starts inside the fetch handler, so nothing could tell a cold isolate paying Start's loadEntries import from an isolate that came up slowly before our code ran. Measured with a temporary entry-lag probe against wrangler tail event timestamps: a cold isolate spends 840-1844ms before our first line, a reused one 0-2ms. Stamp request_seq and start_graph.entered on the worker spans so cold-vs-warm is queryable from Axiom without a probe. Also drops a stray plans file that #1692 swept in via git add -A. --- apps/cloud/src/server.ts | 53 +++- plans/kill-plugin-system.md | 515 ------------------------------------ 2 files changed, 52 insertions(+), 516 deletions(-) delete mode 100644 plans/kill-plugin-system.md diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 0304ab0246..3d5a191ee4 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -103,12 +103,26 @@ export { McpExecutionOwnerDirectoryDO } from "@executor-js/cloudflare/mcp/execut // until the in-flight export resolves. // --------------------------------------------------------------------------- -const fetchHandler = handler.fetch as ( +const rawFetchHandler = handler.fetch as ( request: Request, env: Env, ctx: ExecutionContext, ) => Response | Promise; +/** + * Every entry into TanStack Start goes through here so `startGraphEntered` + * reflects whether this isolate has already paid the lazy `loadEntries` + * import — the cost that dominates a cold page request. + */ +const fetchHandler = ( + request: Request, + env: Env, + ctx: ExecutionContext, +): Response | Promise => { + markStartGraphEntered(); + return rawFetchHandler(request, env, ctx); +}; + const tracer = trace.getTracer("executor-cloud-worker"); const traceparentValueFor = (spanContext: SpanContext): string => @@ -178,8 +192,43 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ traceRequest: traceCloudMcpRequest, }); +// --------------------------------------------------------------------------- +// Isolate lifecycle signals +// --------------------------------------------------------------------------- +// +// The Aug 2026 page-latency hunt kept stalling on one blind spot: every span we +// emit starts INSIDE the fetch handler, so nothing could distinguish "this +// isolate is cold and paying Start's lazy `loadEntries` import" from "this +// isolate came up slowly before our code ran at all". Both look like one slow +// span. These three attributes make the distinction queryable: +// +// executor.isolate.request_seq - 1 means this request is the isolate's +// first; page requests measured 1.04 per +// isolate, which is why nearly every one +// pays the cold cost. +// executor.start_graph.entered - whether anything had already driven +// TanStack Start in this isolate. /mcp and +// the passthrough proxies return before +// `fetchHandler`, so an isolate can serve +// plenty of traffic and still be cold here. +// Measured with a temporary entry-lag probe correlated against `wrangler tail` +// event timestamps: a cold isolate (`request_seq` 1) spends **840-1844ms** +// coming up before our first line runs, while a reused one spends **0-2ms**. +// That startup is invisible to every span we emit, and it sits on top of the +// ~3.1s `loadEntries` import — together the 3-5s a signed-in page costs. +// +// Both counters are cheap: two increments and no I/O. +let isolateRequestSeq = 0; +let startGraphEntered = false; + +const markStartGraphEntered = (): void => { + startGraphEntered = true; +}; + const cloudflareHandler: ExportedHandler = { fetch: async (request, env, ctx) => { + isolateRequestSeq += 1; + // Public pages must not enter TanStack Start: its first-request dynamic // import loads the entire React + Effect server graph and can take seconds // on a cold isolate. Classify and service-bind marketing at the Worker @@ -250,6 +299,8 @@ const cloudflareHandler: ExportedHandler = { async (span) => { span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method); span.setAttribute(ATTR_URL_PATH, url.pathname); + span.setAttribute("executor.isolate.request_seq", isolateRequestSeq); + span.setAttribute("executor.start_graph.entered", startGraphEntered); // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep the flush alive past the response try { const response = await fetchHandler( diff --git a/plans/kill-plugin-system.md b/plans/kill-plugin-system.md deleted file mode 100644 index 0b1c6ae666..0000000000 --- a/plans/kill-plugin-system.md +++ /dev/null @@ -1,515 +0,0 @@ -# Kill the plugin system — in-place migration plan - -Decision (Rhys, 2026-07-27): abandon the big-bang rework -(`UsefulSoftwareCo/executor-rework`, cloned read-only at -`.reference/executor-rework`); fix this repo in place over a sequence of PRs, -each keeping format/lint/typecheck/test/e2e green. Priority order: **data -models first**, then the plugin kill, then the concept-package (apps) model, -then the minimal SDK / platform split. - -RESEQUENCED (Rhys, 2026-07-28): **Phase 3 (apps/concept-package model) now -lands BEFORE Phase 2 (plugin kill), side by side with the protocol plugins.** -Rationale: the new model's tables are greenfield and additive — landing it -first means the legacy apps tables never need an in-place migration; the whole -model swaps underneath while openapi/mcp/graphql keep running as plugins. -Structurally sound because (a) the kernel + MCP host are already plugin-free, -so the new model's runtime never touched the registry, and (b) the apps plugin -already exercises every extension point, so the new model ships as the apps -plugin's new internals wearing the same costume. When 2.3 later converts the -four kinds, apps converts as a stable, proven model instead of mid-rewrite. -Phase 0 and 2.1 remain freely parallel — they don't touch apps. - -Hard line: side-by-side means new-apps-model alongside the _protocol plugins_, -never two app models with dual read paths inside the apps kind. Existing app -sources get a one-off migration to published-source form (enumerated -invariants, explicit approval, no permanent compatibility path). Accepted -cost: the new model's HTTP surface temporarily wears plugin-route ceremony -(HttpApiGroup + handler layers) that 2.2/2.6 later deletes — wiring, not -model. The authoring SDK (`defineApp`/`defineProvider`/`defineTool`) goes -public early; its boundaries take Standard Schema (StandardSchemaV1 & -StandardJSONSchemaV1), never Effect Schema, and Phase 4's vocabulary rules -(opaque owner refs, no "tenant") bind on it from day one. - -## Diagnosis (verified against the code) - -**The plugin system is a fiction.** `packages/core/sdk/src/plugin.ts` (~850 -lines, ~20 optional hooks) pretends the plugin set is open, but: - -- `Integration.kind` _is_ the plugin id string; all hot paths dispatch via - `runtimes.get(row.plugin_id)` (`executor.ts`). -- Core and React hardcode the closed set anyway: `migration-spec.ts` hardcodes - plugin ids; `packages/react` has `KIND_TO_PLUGIN_KEY` maps and an - `if (pluginId === "toolkits")` route branch. -- Nobody ever shipped a fourth protocol plugin, and the two provider plugins - (google/microsoft) were already collapsed into openapi (#1366). -- Whole packages exist only to support the open set: `plugin-routes.ts` - type-union machinery, `@executor-js/config`'s jsonc+jiti loader, - `packages/core/vite-plugin`'s virtual client module. The jsonc plugin path is - vestigial — no `executor.jsonc` exists anywhere in the repo, no docs mention - it. -- Toolkits (~880 LoC of pure curation + the single `toolPolicyProvider` slot) - and apps (~6.8k custom-tools gateway) are first-party capabilities wearing a - plugin costume. Kernel packages and `packages/hosts/mcp` have zero plugin - references — the indirection is not load-bearing for the real surfaces. - -**The data model is connection-first, not user-first** -(`packages/core/sdk/src/core-schema.ts`): - -- No user/subject table. `subject` is an opaque partition-key string smeared - across owned tables; a user "exists" only if they have a connection row. -- Disconnect is a hard delete (`connectionsRemove`) — a signed-out user is - bit-identical to one who never connected. "Which users are signed out" is - structurally unanswerable. -- Every Executor is bound to one `{tenant, subject}` via `owner-policy.ts`; - there is no cross-subject read path, so no admin view. -- Toolkit state lives in generic `plugin_storage` KV, not core tables. -- Cloud's real identity tables (WorkOS accounts/organizations) have no join to - `subject`. - -**What to salvage from the rework** (its model docs are the valuable artifact; -its implementation ported only ~10 of 131 legacy e2e scenarios — that's why it -failed, and why this plan is incremental): - -- Apps-as-published-source: `defineApp`/`defineProvider`/`defineTool`/ - `defineToolGroup`, immutable content-hashed versions, manifest derived by - executing the source, rollback = pointer move. -- Generators: OpenAPI/MCP specs generate the _same_ app source a human would - write, with a re-verifiable `GeneratorClaim` (first-party vs third-party). -- Minimal SDK boundary: `createExecutor`/`createRemoteExecutor`, no Effect - crossing the boundary, MCP exposure returns a standard MCP SDK server. -- Custody separation: secrets never on the wire; opaque owner refs. - -## Constraints discovered (do not relearn these) - -1. **Published packages make this semver-major.** `scripts/publish-packages.ts` - publishes `@executor-js/{config,sdk}` and plugins - `{example,file-secrets,graphql,keychain,mcp,onepassword,openapi}` — killing - `definePlugin` is a breaking release for seven packages. Changesets + - deprecation story required; `publish-packages.ts`, `pkg-pr-new.yml`, - `check-changelog-stubs.ts`, and `examples/*` all have hardcoded lists. -2. **`plugin_id = 'google'` is a live persisted value with no runtime.** The - provider-service-split migration skips orgs with missing spec blobs - (`sqlite.test.ts` asserts survivors). The `googleDiscovery → "google"` - entries in React are compensating, not dangling. The closed SourceType - switch must map `"google"`/`"googleDiscovery"` → openapi or degraded rows - become hard failures. -3. **FumaDB is not versioned in practice.** All hosts pin `version: "1.0.0"`; - runtime bring-up is `ensureDrizzleRuntimeSchemaFromTables` (CREATE TABLE IF - NOT EXISTS + additive nullable ALTERs). New tables/nullable columns are free - on local/self-host/D1. **Cloud is the exception**: hand-edit - `apps/cloud/src/db/executor-schema.ts` + drizzle-kit migration + - `db:migrate:prod`; `db.schema.test.ts` enforces the mirror, so "forgot - cloud" fails tests. -4. **93 e2e files import `composePluginApi`** purely to type the client. Keep - it as a deprecated argument-ignoring shim returning the static - `FullExecutorApi`; sweep the call sites mechanically in one final PR. -5. **Keep the `plugin_id` column name** (four tables + blob namespace format - `o:/`). Renaming to `source_kind` is a cross-host - migration for zero behavior. Retype on the read side only. -6. **URL contracts:** `/integrations/add/$pluginKey` is navigated directly by - e2e; `/plugins/desktop-settings/` is asserted by the desktop-packaged suite - and baked into a tool's returned URL. Keep shapes / add redirects. -7. **CI gaps:** PRs run cloud (4 shards) + selfhost e2e only; local/desktop - suites gate push-to-main. PRs touching desktop-settings or the local loader - need a manual local e2e run pre-merge. -8. **Toolkit selection filters _connections_, not just tools** - (`connectionsList` goes through the active policy provider). Preserve when - moving toolkits to core, or the toolkit e2e suites go red. - -## Phase 0 — declutter (3 PRs) - -- **0.1** Delete empty `packages/plugins/{google,microsoft}`; drop the unused - `@executor-js/config` dep from openapi/mcp/graphql; move - `provider-service-split` to `packages/migrations/` (keep package name — it's - imported by three host data-migration registries). In the React maps, keep - `google`/`googleDiscovery` → openapi with a comment citing constraint 2. -- **0.2** Bake google/microsoft catalogs + spec-format adapters in as openapi - defaults; strip the re-threading from all four host configs. Gate: - `provider-plugins-ui`, `google-health-checks`, `microsoft-*` scenarios. -- **0.3** Move desktop-settings (438-line Electron-IPC client, not a - one-liner) to a first-class desktop-only route in `packages/app`; update the - static tool's URL and the desktop-packaged e2e selector; delete the plugin. - -## Phase 1 — data model (4 PRs) — FIRST PRIORITY - -Design choices (least-invasive, verified feasible): - -- The new **subject table is `tenant`-scoped, not owner-scoped** — readable by - any executor bound to the tenant with **zero policy changes** (the - `integration` table already proves the pattern). -- Cross-subject connection reads: add `reach: "bound" | "tenant"` to - `ExecutorOwnerPolicyContext` — widens `ownerVisibilityCondition` for reads - only; `assertOwnerWritable`/`assertOwnerPatch` untouched, so admin reach can - never write. Only two construction sites exist (`executor.ts`, - `test-config.ts`). -- Identity stays with the hosts (WorkOS / Better Auth member lists already - exist behind `AccountProvider.listMembers`); the subject table is the missing - **join** between host identity and executor connections, not a new identity - system. Tolerate `subject = null` (pure-org) and `subject = "local"`. - -PRs: - -- **1.1** Add tenant-scoped `subject` table (id, tenant, external_id, - created_at, last_seen_at, status) to `coreTables` + cloud mirror + one - drizzle migration. No readers yet. -- **1.2** Populate: upsert in `makeScopedExecutor` - (`packages/core/api/src/server/scoped-executor.ts` — every HTTP request and - MCP session on all four hosts passes through it) and at connection-create. - Add to `purgeOrganizationData` + its `TENANT_TABLES` test. -- **1.3** `reach` mode + `admin.listSubjectsWithConnections`-shaped read on a - derived handle. Policy test proving admin reach cannot write. Scope includes - (customer ask, owner.com 2026-07-27): per-subject reads ("connections for - subject X"), not just the aggregate list, and a privileged org-level API key - (keys already carry `{accountId, organizationId}` — - `apps/cloud/src/auth/api-keys.ts`) that resolves to the tenant-reach - read-only handle so customers can inspect connections programmatically. -- **1.4** Connection lifecycle: nullable `status` column - (active/disconnected/revoked/expired) plus `status_changed_at`; disconnect - writes status instead of deleting. Wide blast radius — status predicate - through `findConnectionRow`, `connectionsList`, credential resolution, - `toolsList`, org purge; own e2e sweep (`connections-*`, `health-checks*`, - `oauth-*`). PRIORITY RAISED (customer, 2026-07-28): the customer's - block-startup-until-reconnect flow needs connected-then-disconnected to be - distinguishable from never-connected, which hard-delete makes impossible - today; expose status + statusChangedAt through the admin API when this - lands. - -Toolkits data does NOT move in this phase (it moves with its reader in 2.4 — -moving it earlier means either rewriting the plugin storage layer twice or a -forbidden dual read path). - -### Framing: product view vs platform view (Rhys, 2026-07-27) - -The two-view model for consumer-facing surfaces, longer-term: - -- **Product view** — the bound, per-individual executor every request gets - today (their connections, their tools). The default; unchanged. -- **Platform view** — an operator-level view attached alongside it: read-only, - cross-user, explicitly reached for with elevated access (an escape hatch, - never a mode product view drifts into). The `reach: "bound" | "tenant"` - split in 1.3 is this model's first implementation; 1.5's admin API and the - privileged key are its first surface. Later platform-view candidates (usage, - policy overview, cross-user health) land on the same handle. - -Cloud wrinkle: cloud consumers like owner.com don't bring their own owner ids — -their users authenticate through our WorkOS, so `subject` is _our_ accountId -and the customer correlates on their side. The opaque-owner-ids-in-the- -customer's-id-space story fully applies only to embedded/API consumers; for -cloud, the platform view (+ `external_id` on the subject table) is what makes -the correlation tractable. - -### Fast path: owner.com admin-visibility ask (ships from Phase 1 alone) - -The owner.com ask (admin inspects users' connections; API key that can do the -same; available-vs-connected icon grid; direct connect links) needs only -1.1 → 1.2 → 1.3 plus a thin delivery layer — no dependency on Phase 0 or the -plugin kill. Delivery PRs on top of 1.3: - -- **1.5** Public admin API surface: `GET .../admin/users`, - `GET .../admin/users/:externalId/connections` (integration slug + status + - last_health only — never item_ids/oauth fields), gated on the privileged key - / admin membership. Public paths say `users`, not `subjects`/`tenants` — see - "Vocabulary" below. The available-vs-connected diff is computable client-side - from this plus the existing integration catalog. -- **1.6** Connect deep links: stable - `/connect/:integrationSlug`-shaped URL that lands the arriving user in the - connect flow for that integration (auth-aware redirect). Uses integration - slugs, not plugin routes, so it survives Phase 2.5 unchanged. - - 1.4 (lifecycle status) upgrades the grid from binary connected/not to - expired/disconnected states but is not a blocker for first ship. - -## Phase 2 — kill the registry (6 PRs) - -- **2.1** Remove dynamic loading: delete `load-plugins.ts` from - `@executor-js/config` (keep the rest — jsonc integration config is a separate - feature), drop `loadPluginsFromJsonc` from `apps/local`, delete - `packages/core/vite-plugin`, replace `virtual:executor/plugins-client` with a - static module in `packages/app` (5 `__root.tsx` importers + 3 ambient - `.d.ts`), edit `turbo.json` `dev.dependsOn` + root `prepare`, fix - `packages/app/vite.config.ts`'s dangling jsonc path. Breaking changeset. -- **2.2** Static route list: `composePluginApi` becomes the deprecated shim - (constraint 4); hosts stop passing plugin tuples; delete - `apps/{cloud,host-selfhost}/src/plugins.ts` (exist only to hold - `PluginExtensionServices` typing). -- **2.3** The switch: closed internal `SourceType` interface implemented by - openapi/graphql/mcp/**apps (current model, unchanged)** as plain libraries, - replacing `runtimes.get`. All four kinds convert together — converting three - and leaving apps on the plugin path means carrying dual dispatch; rewriting - the apps model in the same PR is what killed the rework. Map - `"google"`/`"googleDiscovery"` → openapi. `plugin_id` column name unchanged. -- **2.4** Toolkits to core: core tables + one-off migration out of - `plugin_storage` (enumerated invariants, no dual path), `toolPolicyProvider` - slot → native policy input (one call site, one implementation, one test), - ordinary core routes, `activeToolkitSlug` becomes an executor option. - Preserve the connection-visibility filter (constraint 8). -- **2.5** React: four named kind renderers; move plugin React code (~2.9k LoC, - e.g. `AddMcpIntegration.tsx`) into `packages/react` — watch the peer-dep - edge for cycles; delete `defineClientPlugin`, the plugins context, both - `KIND_TO_PLUGIN_KEY` maps, and `/plugins/$pluginId/$` (with redirects for - desktop-settings + toolkits). Keep `/integrations/add/$pluginKey` URL shape. - Don't forget the second axis: `useSecretProviderPlugins` (secrets page). -- **2.6** Detach `CredentialProvider` from `PluginSpec` (the interface in - `provider.ts` is already narrow — secret-store plugins keep it as their - seam); delete `plugin.ts`, `plugin-routes.ts`, `plugin-example`, and the - shim; mechanical sweep of the 93 e2e files; changesets for all seven - published packages. - -## Phase 3 — custom = concept package (3–4 PRs) — NOW BEFORE PHASE 2 - -(See RESEQUENCED note at top: lands side by side with the protocol plugins, -inside the apps plugin's existing costume; Phase 2 kills the registry -afterward, with apps converting in 2.3 as an already-stable model.) - -Port the rework's app model in place as the `apps` source kind's new model: -published source, manifest derived by execution, content-hashed immutable -versions, rollback = pointer move. New tables are additive greenfield (same -pattern as 1.1: coreTables + cloud mirror + migration, no readers first). -Existing app sources migrate one-off to published-source form — no dual model -within the apps kind. Then the OpenAPI/MCP generators + `GeneratorClaim` as an -optional on-ramp (spec → generated app source); GraphQL generator later. -Long-term option (explicitly out of scope): protocol kinds themselves become -generators and "custom" is the only kind. - -### Feasibility findings (two deep read-throughs, 2026-07-28) - -**Current plugin** (~7.6k LoC): only ~2.65k encodes the old model (store -collections, sync state machine, publish/discover/descriptor, routes, -console client). ~2.6k is model-agnostic and survives verbatim: all three -sandbox executors, the bundler stack, bindings/bridge, authoring + -standard-schema, conformance. ~1.9k (hand-rolled git client + source -fetchers) hangs on the publish-input decision below. Storage is entirely -generic `plugin_storage` rows + content-addressed blobs — zero apps-specific -tables or migrations exist, so new tables are pure greenfield. Cloud prod is -git-source-only; **source text is never persisted today (only compiled -bundles)**, so the one-off migration re-fetches source from the recorded git -URLs (fetcher exists). Load-bearing invariants to preserve: tool addresses -`tools..org.published.` + the `__` naming rule, -app-slug↔integration-slug 1:1 guard, publish CAS -(`AppPublishConflictError`), tombstone-not-delete, token-in-provider-item -(never in the source row). Safety net: `custom-tools.test.ts` (534-line -cloud+selfhost console loop), local directory-picker, packed-CLI, desktop -e2e, ~2.9k LoC in-package tests. Pre-existing gaps flagged: R2 blobs never -GC'd and org purge misses them; cloud silently falls back to in-process -executor without the worker loader; host-cloudflare doesn't register apps. - -**Rework model** (portable tiers): T1 model proper ~3.9k LoC (authoring SDK, -publish-by-execution, projection, generator verification, storage port, -content hashing); T2 generators ~3.2k (fromOpenAPI 1k — complete; fromMCP -0.6k — computed group with real streamable-HTTP client; json-schema engine; -byte-for-byte GeneratorClaim verification; render endpoint); T4 host/registry -~2.9k explicitly skipped. Authoring is Standard Schema -(StandardSchemaV1 & StandardJSONSchemaV1) with raw-JSON-Schema literals as a -typed first-class alternative — matches our rule. Effect beta.93→beta.59 -delta is small (ConstraintDecoder → one-line fix; hand-roll -StandardJSONSchemaV1 ~10 lines; reuse core sha256Hex). NOT portable as-is: -rework `storage-db.ts` (write a new adapter over our tables against the -~90-line Storage port interface) and `engine.ts` (extract only ~700 LoC of -model-owned logic: computed-surface cache + requirement resolution + -runTool). Known rework gaps to fix at port time, not inherit: derive-time -execution is unsandboxed in-process (`code-executor.ts:23` records it) — our -port MUST run publish-time evaluation in the existing sandbox `collect` path -from day one; fan-in computed groups unsupported; OAuth discovery -unsupported. - -**Semantic deltas — decided by Rhys before 3.1 specs:** - -1. **Handler credential contract (the deep one).** Current: handlers get an - opaque callable `AppIntegrationClient` proxy that re-enters - `ctx.execute` — app code never sees credentials, can only call other - executor tools. Rework (D49): engine resolves credentials once and hands - the handler **credential fields as data** (`ResolvedConnection.fields`, - e.g. access_token) for direct HTTP — that's what makes generators - possible (generated code does raw fetch with auth) and what - `defineProvider` exists for. Not either/or in principle (a tool-call - bridge can coexist as a second requirement kind later), but the ported - model's primary contract is fields-as-data per the rework. -2. **Publish input.** Rework publish takes `SourceFiles` (push-based). - Decision: keep git/local-directory as _fetchers that produce - SourceFiles_ (preserves prod sources, the e2e suite, and the migration - path; git-client survives), with direct push publish added as the new - API. Delete-git-later stays open. -3. **App identity** moves from caller-supplied slug to the `defineApp` - export inside the source (rework shape). -4. **Versioning**: content hash of source IS the version identity; the - caller-supplied `sourceRef` + descriptor v6 die with the old model. -5. **Ownership**: app/version rows carry `space` and NO owner — spaces - curate apps, principals own custody (connections stay on the legacy - owner model in this phase; the opaque-OwnerRef swap is Phase 4). - -### Phase 3 PR sequence (revised) - -- **3.1** Port the model core as internal packages, no readers: authoring - SDK + runtime brands, publish pipeline (evaluation routed through the - sandbox `collect` seam), Storage port + adapter, greenfield tables - (`app`, `app_version`, `computed_surface` — space column, content-hash - identity, `current` pointer) in coreTables + cloud mirror + migration. - Ported rework tests come along (authoring-boundary, tool-groups, - publish). -- **3.2** Swap the apps plugin's internals onto the new model behind the - same costume: resolveTools/projectToolSchema/invokeTool read the new - tables; publish endpoint (`POST` source files); setCurrent/rollback + - versions listing routes; git/local-dir sync becomes fetch→publish. - Tool addresses and slug guards preserved; old collections - (`apps_descriptors`/`apps_tools`/`apps_sources`) still written by - NOTHING — one-off migration converts existing sources (re-fetch from - git), then the collections are dead. e2e: full existing custom-tools - suite green + new version-history/rollback scenario. -- **3.3** Console UI for the new model (sources → apps with versions, - publish status, rollback) replacing the ~1.3k LoC old client/panels. -- **3.4** Generators: fromOpenAPI + fromMCP + GeneratorClaim verification + - render endpoint, surfaced as the "add from spec/server" on-ramp. - (Vendors google/microsoft conversion ~1.15k LoC ports later with 0.2.) - -## Spaces: the rework's tenancy primitive (added Rhys, 2026-07-28) - -The rework's SDK has exactly one tenancy concept: `SpaceId` -(`.reference/executor-rework/docs/TENANCY.md`, `docs/MODEL.md`). Adopt it as -the end-state tenancy model here. The shape: - -- **Flat, no hierarchy, no default.** No parent, no inheritance, no - distinguished rows (rework D39/D44b). Sharing is an explicit act, never a - tree walk (R2 — re-ruled twice, do not relitigate). Every scoped row - carries a `space` column; every space-scoped route carries the space in - the URL path, never ambient context. -- **No org/project/team/user below the deployment edge.** The engine answers - "what is in this space"; the deployment answers "who is this and which - spaces may they touch" through named seams (`authorizeBrowserResource`, - `dashboardSpacesForPrincipal`, MCP space resolver). Membership is - authorized LIVE per request at the edge — projection may be cached, - authorization never. -- **Deployment mappings:** cloud = one WorkOS org mirrors lazily to one - space (mirror row `workosOrgId → space` is also the billing join — Autumn - keys on workosOrgId, never a space); self-host = one instance, one - found-or-created "Default" space, Better Auth org repurposed as the - instance membership table; local/desktop = one "local" space, one static - principal, single-tenancy declared via explicit sentinel, not defaulted. -- **Spaces curate, principals own.** Custody (secrets, connections' - secret-bearing halves) belongs to principals via opaque `OwnerRef` + - per-request `Reach` (R3); spaces curate apps and hold rows. Publishing an - app into a space IS its presence there — no installation rows, no - bindings; cross-space arrival (sharing/registry) is a future share-edge - row, not golden-path ceremony (D45/D42). -- Cross-org OAuth callbacks complete into the _initiating_ org's space; the - org header is a routing hint, not an authorization statement (R7). - -**DECIDED (Rhys, 2026-07-28): spaces land in the CORE PRODUCT NOW — Phase S, -before the apps port.** Apps then publishes into real spaces from birth. - -**Mapping onto this repo — the no-migration trick:** today's `tenant` column -is de facto the space — cloud writes the WorkOS org id there, self-host/local -write one constant. So: keep the persisted `tenant` column name (same rule as -`plugin_id`), and declare that **its value IS the space id**. Each org's -first space is grandfathered with `spaceId = ` (the -org id); NEW spaces mint fresh ids written into the same column. The org→ -space mirror table records which space ids belong to which org (and carries -the billing join — Autumn keys on workosOrgId, never a space). Existing rows -never move; multi-space needs no schema change on scoped tables. "Space" is -contract vocabulary; "tenant" retreats to cloud-internal (see Vocabulary). - -### Phase S — spaces in core (before apps) - -- **S1 — space table + boot ensure (no readers).** Greenfield `space` table - (id, name, created_at) in coreTables + cloud mirror + migration, plus the - cloud `org→space` mirror table. Boot/first-contact ensures the degenerate - space per deployment: cloud lazily on first authenticated org contact - (grandfathered id = org id), self-host "Default", local "local". Same - no-readers pattern as PR 1.1. -- **S2 — space threading.** `SpaceId` brand in the SDK; the scoped-executor - seam resolves request → space id (today: identical to tenant) and stamps - it; reads interpret `tenant` columns as space ids. No behavior change — - this is the retype + vocabulary PR. `reach: "tenant"` reads as - "space-wide reach" from here on. -- **S3 — multi-space on cloud + product surface.** Create/rename space, - space switcher in the console, membership = org membership (all spaces of - the org for now), org→N mirror rows, space-scoped catalog/connection - views (rows follow automatically — they're keyed by the space id in the - tenant column). Billing stays org-keyed via the mirror. e2e: two spaces - in one org, isolation of catalogs/connections between them, switcher. -- **S4 — sharing model on spaces.** Connect-time "share with space" writes - the space's shared owner-ref (replaces the org tier semantically; the - persisted `owner: "org"|"user"` column keeps its name, read-side - reinterpretation only). Edge computes reach as - [principal, sharedRef(space) for each member space]. This is the bridge - to Phase 4's full owner-set reach. -- **Later (not scheduled): local multi-space, file-backed.** Each local - space is its own store file (portable — copy/backup/sync/check in - alongside a repo); the daemon opens one storage handle per space and - routes by space id; the engine still sees flat SpaceIds. Custody never - enters the space file — secrets stay in the principal-keyed provider - (keychain), so a copied file carries connection metadata, never tokens. -- Phase 4 completes the picture: space-in-path routes, - `dashboardSpacesForPrincipal`-style seams, reach as an explicit owner - set. - -Apps (Phase 3) then lands after S1/S2 — app rows carry a real `space` FK -from birth; S3/S4 can proceed in parallel with the apps port. - -Interaction with shipped Phase 1 work: the subject table and platform view -are edge/deployment concepts (who exists in this identity world, what may -an operator read) — they sit above spaces and survive unchanged; `reach: -"tenant"` becomes "space-wide reach" vocabulary-wise at Phase 4. - -## Vocabulary: retire "tenant" from consumer-facing surfaces (Rhys, 2026-07-27) - -Direction: the SDK should be agnostic to the consumer's data-model shape, the -way Composio's is — consumers pass an opaque user/owner id string and hang -whatever hierarchy they have (user, team, workspace) off it; the SDK never -models their org chart. The rework already designed this (`OwnerRef` opaque -brand, `Reach = {owners}`, deployments translate identity at the edge) — adopt -its vocabulary, not its schedule. "Tenant" remains a real _internal_ concept -(which Executor-cloud org owns the rows) but must not leak into surfaces -consumers touch. - -Rules, effective immediately: - -- Persisted column names (`tenant`, `subject`, `owner`) stay — renaming - storage is a cross-host migration for zero behavior (same rule as - `plugin_id`). -- Internal policy code (`reach: "tenant"`, `ExecutorOwnerPolicyContext`) may - keep the words. -- One contract noun: **`owner`** — the opaque ref naming who a connection - belongs to (end-user, team, workspace, or the org itself; the rework's - `OwnerRef`). "User" cannot be the contract noun because org-shared - connections are a load-bearing feature an end-user word can't cover. -- **"user" is presentation vocabulary only**, for surfaces whose rows genuinely - are the customer's end-users — the Phase 1.5 admin API says `users` because - the subject table is populated from authenticated principals. -- Hazard: the persisted `owner` column keeps its old meaning (the - `"org" | "user"` tier discriminator), while Phase 4 introduces the opaque - ref as a distinctly named type (`OwnerRef`). Same word, two meanings, - separated by layer — say so in policy-layer comments. -- The full swap (opaque owner refs at the SDK boundary, tenant retreating to a - cloud-internal detail) lands with the Phase 4 minimal SDK. - -## Phase 4 — minimal SDK + platform/product split - -New consumer boundary modeled on the rework: `createExecutor` / -`createRemoteExecutor`, no Effect at the boundary, `tools.list/execute` + -connections + the new users/admin surface; policies/toolkits become optional -layers consumers opt into, not baked opinions (Composio-style minimalism). - -Authorization end-state (Rhys, 2026-07-27): the engine enforces scope -mechanically but never decides policy — the host computes, per request, the -set of owners the request may read (the rework's `Reach = { owners }`), and -the storage predicate is just `row.owner ∈ reach.owners`. The 1.3 -`reach: "bound" | "tenant"` enum is a stepping stone and is replaced by this -set shape here: "bound" = a reach of one, "tenant" = all owners (wildcard so -cloud needn't enumerate), and in-between cases (team views, support sessions, -subset-restricted org keys) are just different sets. Writes stay hard-pinned -to a single owner; only read reach generalizes. Trust model becomes a -per-deployment choice: a trusted backend with a server key gets tenant-wide -reach and layers its own authorization (Composio-style); an end-user session -gets a reach of one. The enum-to-set swap is contained: the policy context is -constructed in exactly two places. -Start the platform/product package separation _during_ 2.3–2.6 (each PR moves -one thing to its final home) rather than as a big bang; the SDK boundary -itself lands last. - -## Bookkeeping - -- Create `MISTAKES.md` / `DESIRES.md` / `LEARNINGS.md` (required by AGENTS.md, - currently missing) with the first entries from this migration. -- `apps/local/src/db/executor-schema.ts` is already stale vs `coreTables` - (missing several columns); its "keep in sync" comment is untrustworthy — - runtime schema comes from the ensure path, not this file. From 54cde5e26b78a98b35daba9a75d50bf717c9ca72 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:13:41 -0700 Subject: [PATCH 2/2] Drop the MCP Durable Object from the SSR app graph (#1696) app.ts imported McpSessionDOSqlite only to pass it through as mcpExport and re-export it. ExecutorApp.make documents mcpExport as passed back out untouched, its result cloudMcpExport has no consumers, and server.ts imports the DO class directly for Cloudflare's required export. So the import was dead weight that pulled the MCP DO stack and the QuickJS WASM into TanStack Start's loadEntries closure (9.22MB -> 8.58MB without it). No measured latency win: loadEntries cost does not track closure size, same as isolate startup. Kept as dead-code removal and because the SSR graph should not depend on the MCP session DO. --- apps/cloud/src/app.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/cloud/src/app.ts b/apps/cloud/src/app.ts index 77afa9c7f9..19b0213ead 100644 --- a/apps/cloud/src/app.ts +++ b/apps/cloud/src/app.ts @@ -13,7 +13,6 @@ import { ApiKeyService } from "./auth/api-keys"; import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider"; import { DbService } from "./db/db"; import { cloudMcpAuth } from "./mcp"; -import { McpSessionDOSqlite } from "./mcp/session-durable-object"; import { ErrorCaptureLive } from "./observability"; import { AutumnService } from "./extensions/billing/service"; import { @@ -31,7 +30,7 @@ import { WorkerTelemetryLive } from "./observability/telemetry"; // The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed- // session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket, // the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object -// session store (the DO surfaced via `config.mcpExport`), console+Sentry error +// session store (the DO class is exported straight from server.ts), console+Sentry error // capture — and Autumn BILLING entering ONLY as extensions: the engine // metering decorator, the account seat-gate, the `/api/billing/*` proxy route, // and the createOrganization free-limit gate. `diff` against @@ -66,7 +65,7 @@ const controlPlane = Layer.mergeAll(CoreSharedServices, apiKeyService); // satisfied by `boot`, `DbService` per request via `requestScoped`). const cloudDb: Layer.Layer = CloudDbProvider; -const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ +const { appLayer, toWebHandler } = ExecutorApp.make({ plugins: cloudPlugins, providers: { // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS @@ -112,7 +111,6 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ failure: cloudIdentityFailureStrategy, // The MCP session Durable Object class — a top-level Workers export a Layer // can't return; surfaced so `server.ts` can re-export it. - mcpExport: McpSessionDOSqlite, }, // The long-lived (boot-scoped) context provideMerge'd under everything: the // WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request @@ -135,10 +133,7 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ requestScoped: RequestScopedServicesLive, }); -export { McpSessionDOSqlite }; - export const CloudAppLayer = appLayer; -export const cloudMcpExport = mcpExport; // The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs), // /mcp, /.well-known/* — everything the worker dispatches.