* Curate integration & connection metadata; update OpenAPI specs in place Integrations and connections gain user-curated, agent-visible metadata, a way to refresh an OpenAPI spec without re-adding it, and lazy propagation of tool changes across every member's connections. Descriptions & name/description split - integration rows gain a `name` column distinct from `description` (name = display, description = agent-visible context); readers fall back to description on pre-split rows - connections carry a nullable, agent-visible `description` - both surface to agents: the execute tool's connection-prefix inventory, sources.list, and connections.list (slug/name echoes suppressed) - add flows prefill the description from the source (OpenAPI info.description, GraphQL schema description, MCP server instructions) - the integration Edit sheet has separate Name and Description fields with a live "what agents see" preview; per-connection Edit sheet for description + account label; the rename telemetry event is preserved updateSpec - openapi.updateSpec(slug, {spec?}) re-resolves the spec (stored source URL, Google Discovery bundle, or a pasted blob), rebuilds the tool catalog in place, and reports an added/removed tool diff; connections, credentials, policies, and curated metadata survive. Spec text is stored in the plugin blob store by content hash, matching addSpec. - POST /openapi/integrations/:slug/spec - spec fetches stay unauthenticated by design (a spec can be hosted by a third party; sending a credential there would leak it) — auth-gated specs use the paste path Plugin config in the Edit sheet - plugin-owned configuration moved into the integration Edit sheet via a new `editSheet` slot; OpenAPI contributes the spec-update controls, MCP its auth-method editor. One Save applies the metadata change then the staged plugin change. Multi-user tool convergence - integration `tools_revised_at` + connection `tools_synced_at` stamps; tools.list lazily rebuilds any visible connection whose catalog predates its integration's last config change, under the reader's own binding — so a spec update by one member reaches other members' personal connections on their next read (the owner policy prevents the updater from writing their rows directly) - the drizzle runtime schema-ensure now adds nullable columns to existing tables on boot (self-host / D1); cloud migration 0003 adds the four columns for Postgres Add-time description prefill, the metadata edit sheets, updateSpec, and the multi-user convergence are covered by new SDK tests (including a two-subject shared-database test) and e2e scenarios. * Evolve apps/local SQLite columns on boot like the other hosts apps/local stood up its schema with CREATE TABLE IF NOT EXISTS plus a hand-maintained list of ALTER TABLE ADD COLUMN statements, so columns added by a later schema (integration.name, integration.tools_revised_at, connection.description, connection.tools_synced_at) never reached databases created by an earlier baseline — the first query against one of them would 500. Route local through ensureDrizzleRuntimeSchemaFromTables, the same boot bring-up the self-host and Cloudflare hosts already use, so every nullable column the live schema declares is added in place. This removes the per-column drift between hosts; cloud Postgres keeps getting the same columns from its generated drizzle migration. Add a fumadb test proving an existing table gains the schema's new nullable columns on ensure, and that a second ensure tolerates the duplicates. * Cover metadata editing and multi-user spec convergence with e2e scenarios Two behaviors PR #997 introduced had no watchable coverage: - metadata-editing (cross-target): an integration's name and description, and a connection's description, are editable after the fact and read back — the write half that openapi-update-spec's survives-a-refresh assertion assumes. - spec-update-convergence (cloud, real multi-user org): when one member refreshes a shared integration's spec, a co-worker's OWN connection converges to the new tool catalog on the co-worker's next read, even though the editor can never write the co-worker's rows. Proves the lazy syncStaleConnectionTools path end-to-end. * Let the e2e viewer bind a specific host (HOST env) Defaults to all-interfaces as before. Setting HOST=127.0.0.1 binds loopback so the viewer can sit behind a `tailscale serve` proxy without colliding with tailscaled's own bind on the tailnet IP:port. * Rename integration.tools_revised_at → config_revised_at Tools are materialized per connection (the tool rows are keyed by owner/integration/connection); an integration has no tools, it has the config that derives them. So the integration-side staleness marker is about the config that changed, not the tools — name it for the cause. The connection keeps tools_synced_at, which is accurate (connections do have tools). The comparison now reads tools_synced_at < config_revised_at: 'this connection's tools predate the current config'. Pre-release column, so the cloud migration + snapshot are edited in place; the libSQL/D1 hosts pick up the new name via boot schema-ensure. * Regenerate the self-host route tree the merge left stale The merge commit captured bootstrap's transient regen of the self-host routes against a stale built console-routes, dropping the {-$orgSlug} prefix (admin/api-keys routes + routeTree.gen). Regenerate them so the self-host console routes match main's org-slug scope. * Split integration name from description; backfill cloud data clean Production data confirms `integration.description` was universally the display NAME (367 rows: avg 13 chars, ~0 real sentences), never a description. - description is now nullable — an actual, optional description distinct from the name. - ONE cloud migration 0005: add name + config_revised_at (+ connection description/tools_synced_at), DROP NOT NULL on description, then backfill name = description (367/367 get a real name, verified none empty) and clear description to NULL so no row carries a duplicated title. name stays column-nullable on purpose: SQLite boot-ensure hosts (self-host, Cloudflare, local) cannot add a NOT NULL column to an existing table, so the read path keeps the name ?? description ?? slug fallback.
Showing the changes to packages/plugins/openapi/src/api (2 of 75 files). Show all
Unified · Split
| @@ -4,6 +4,7 @@ import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth · 3 unmodified lines | ||
| 4 | 4 | import { |
| 5 | 5 | InternalError, |
| 6 | 6 | IntegrationAlreadyExistsError, |
| 7 | + IntegrationNotFoundError, | |
| 7 | 8 | IntegrationSlug, |
| 8 | 9 | } from "@executor-js/sdk/shared"; |
| 9 | 10 | |
| @@ -26,6 +27,16 @@ const DomainErrors = [ · 16 unmodified lines | ||
| 26 | 27 | IntegrationAlreadyExistsError, |
| 27 | 28 | ] as const; |
| 28 | 29 | |
| 30 | +const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 }); | |
| 31 | + | |
| 32 | +const UpdateSpecErrors = [ | |
| 33 | + InternalError, | |
| 34 | + OpenApiParseError, | |
| 35 | + OpenApiExtractionError, | |
| 36 | + OpenApiOAuthError, | |
| 37 | + IntegrationNotFound, | |
| 38 | +] as const; | |
| 39 | + | |
| 29 | 40 | const SlugParams = { |
| 30 | 41 | slug: Schema.String, |
| 31 | 42 | }; |
| @@ -64,6 +75,7 @@ const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMet · 32 unmodified lines | ||
| 64 | 75 | const AddSpecPayload = Schema.Struct({ |
| 65 | 76 | spec: OpenApiSpecInputPayload, |
| 66 | 77 | slug: Schema.String, |
| 78 | + name: Schema.optional(Schema.String), | |
| 67 | 79 | description: Schema.optional(Schema.String), |
| 68 | 80 | baseUrl: Schema.optional(Schema.String), |
| 69 | 81 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| @@ -92,6 +104,21 @@ const AddSpecResponse = Schema.Struct({ · 22 unmodified lines | ||
| 92 | 104 | toolCount: Schema.Number, |
| 93 | 105 | }); |
| 94 | 106 | |
| 107 | +// Update the spec in place. Body optional fields only: an empty payload means | |
| 108 | +// "re-fetch from the stored source URL". | |
| 109 | +const UpdateSpecPayload = Schema.Struct({ | |
| 110 | + spec: Schema.optional(OpenApiSpecInputPayload), | |
| 111 | +}); | |
| 112 | + | |
| 113 | +const UpdateSpecResponse = Schema.Struct({ | |
| 114 | + slug: IntegrationSlug, | |
| 115 | + toolCount: Schema.Number, | |
| 116 | + /** Tool names new in this spec version (same diff for every connection). */ | |
| 117 | + addedTools: Schema.Array(Schema.String), | |
| 118 | + /** Tool names the new spec no longer defines. */ | |
| 119 | + removedTools: Schema.Array(Schema.String), | |
| 120 | +}); | |
| 121 | + | |
| 95 | 122 | const IntegrationView = Schema.Struct({ |
| 96 | 123 | slug: IntegrationSlug, |
| 97 | 124 | description: Schema.String, |
| @@ -161,6 +188,14 @@ export const OpenApiGroup = HttpApiGroup.make("openapi") · 63 unmodified lines | ||
| 161 | 188 | error: DomainErrors, |
| 162 | 189 | }), |
| 163 | 190 | ) |
| 191 | + .add( | |
| 192 | + HttpApiEndpoint.post("updateSpec", "/openapi/integrations/:slug/spec", { | |
| 193 | + params: SlugParams, | |
| 194 | + payload: UpdateSpecPayload, | |
| 195 | + success: UpdateSpecResponse, | |
| 196 | + error: UpdateSpecErrors, | |
| 197 | + }), | |
| 198 | + ) | |
| 164 | 199 | .add( |
| 165 | 200 | HttpApiEndpoint.delete("removeSpec", "/openapi/integrations/:slug", { |
| 166 | 201 | params: SlugParams, |
| @@ -54,6 +54,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 53 unmodified lines | ||
| 54 | 54 | return yield* ext.addSpec({ |
| 55 | 55 | spec: payload.spec, |
| 56 | 56 | slug: payload.slug, |
| 57 | + name: payload.name, | |
| 57 | 58 | description: payload.description, |
| 58 | 59 | baseUrl: payload.baseUrl, |
| 59 | 60 | headers: payload.headers ? { ...payload.headers } : undefined, |
| @@ -114,6 +115,22 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 54 unmodified lines | ||
| 114 | 115 | }), |
| 115 | 116 | ), |
| 116 | 117 | ) |
| 118 | + .handle("updateSpec", ({ params, payload }) => | |
| 119 | + capture( | |
| 120 | + Effect.gen(function* () { | |
| 121 | + const ext = yield* OpenApiExtensionService; | |
| 122 | + const result = yield* ext.updateSpec(params.slug, { | |
| 123 | + ...(payload.spec !== undefined ? { spec: payload.spec } : {}), | |
| 124 | + }); | |
| 125 | + return { | |
| 126 | + slug: result.slug, | |
| 127 | + toolCount: result.toolCount, | |
| 128 | + addedTools: [...result.addedTools], | |
| 129 | + removedTools: [...result.removedTools], | |
| 130 | + }; | |
| 131 | + }), | |
| 132 | + ), | |
| 133 | + ) | |
| 117 | 134 | .handle("removeSpec", ({ params }) => |
| 118 | 135 | capture( |
| 119 | 136 | Effect.gen(function* () { |