* 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 | import { | 4 | import { |
| 5 | InternalError, | 5 | InternalError, |
| 6 | IntegrationAlreadyExistsError, | 6 | IntegrationAlreadyExistsError, |
| 7 | IntegrationNotFoundError, | ||
| 7 | IntegrationSlug, | 8 | IntegrationSlug, |
| 8 | } from "@executor-js/sdk/shared"; | 9 | } from "@executor-js/sdk/shared"; |
| 9 | 10 | ||
| @@ -26,6 +27,16 @@ const DomainErrors = [ · 16 unmodified lines | |||
| 26 | IntegrationAlreadyExistsError, | 27 | IntegrationAlreadyExistsError, |
| 27 | ] as const; | 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 | const SlugParams = { | 40 | const SlugParams = { |
| 30 | slug: Schema.String, | 41 | slug: Schema.String, |
| 31 | }; | 42 | }; |
| @@ -64,6 +75,7 @@ const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMet · 32 unmodified lines | |||
| 64 | const AddSpecPayload = Schema.Struct({ | 75 | const AddSpecPayload = Schema.Struct({ |
| 65 | spec: OpenApiSpecInputPayload, | 76 | spec: OpenApiSpecInputPayload, |
| 66 | slug: Schema.String, | 77 | slug: Schema.String, |
| 78 | name: Schema.optional(Schema.String), | ||
| 67 | description: Schema.optional(Schema.String), | 79 | description: Schema.optional(Schema.String), |
| 68 | baseUrl: Schema.optional(Schema.String), | 80 | baseUrl: Schema.optional(Schema.String), |
| 69 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 81 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| @@ -92,6 +104,21 @@ const AddSpecResponse = Schema.Struct({ · 22 unmodified lines | |||
| 92 | toolCount: Schema.Number, | 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 | const IntegrationView = Schema.Struct({ | 122 | const IntegrationView = Schema.Struct({ |
| 96 | slug: IntegrationSlug, | 123 | slug: IntegrationSlug, |
| 97 | description: Schema.String, | 124 | description: Schema.String, |
| @@ -161,6 +188,14 @@ export const OpenApiGroup = HttpApiGroup.make("openapi") · 63 unmodified lines | |||
| 161 | error: DomainErrors, | 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 | .add( | 199 | .add( |
| 165 | HttpApiEndpoint.delete("removeSpec", "/openapi/integrations/:slug", { | 200 | HttpApiEndpoint.delete("removeSpec", "/openapi/integrations/:slug", { |
| 166 | params: SlugParams, | 201 | params: SlugParams, |
| @@ -54,6 +54,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 53 unmodified lines | |||
| 54 | return yield* ext.addSpec({ | 54 | return yield* ext.addSpec({ |
| 55 | spec: payload.spec, | 55 | spec: payload.spec, |
| 56 | slug: payload.slug, | 56 | slug: payload.slug, |
| 57 | name: payload.name, | ||
| 57 | description: payload.description, | 58 | description: payload.description, |
| 58 | baseUrl: payload.baseUrl, | 59 | baseUrl: payload.baseUrl, |
| 59 | headers: payload.headers ? { ...payload.headers } : undefined, | 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 | .handle("removeSpec", ({ params }) => | 134 | .handle("removeSpec", ({ params }) => |
| 118 | capture( | 135 | capture( |
| 119 | Effect.gen(function* () { | 136 | Effect.gen(function* () { |