* 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.
Unified · Split
| @@ -0,0 +1,10 @@ | ||
| 1 | +ALTER TABLE "integration" ALTER COLUMN "description" DROP NOT NULL;--> statement-breakpoint | |
| 2 | +ALTER TABLE "connection" ADD COLUMN "description" text;--> statement-breakpoint | |
| 3 | +ALTER TABLE "connection" ADD COLUMN "tools_synced_at" bigint;--> statement-breakpoint | |
| 4 | +ALTER TABLE "integration" ADD COLUMN "name" text;--> statement-breakpoint | |
| 5 | +ALTER TABLE "integration" ADD COLUMN "config_revised_at" bigint;--> statement-breakpoint | |
| 6 | +-- Pre-split rows kept the display name in `description`. Move it to `name` | |
| 7 | +-- (its proper home), then clear `description` so it no longer carries a | |
| 8 | +-- duplicated title — it now means an actual, optional description. | |
| 9 | +UPDATE "integration" SET "name" = "description" WHERE "name" IS NULL;--> statement-breakpoint | |
| 10 | +UPDATE "integration" SET "description" = NULL; | |
| @@ -0,0 +1,1266 @@ | ||
| 1 | +{ | |
| 2 | + "id": "e105c61d-2fde-4d2e-a785-a74fafd0f4ea", | |
| 3 | + "prevId": "1187b7aa-fdd6-412a-bcfb-20d6ea8f8644", | |
| 4 | + "version": "7", | |
| 5 | + "dialect": "postgresql", | |
| 6 | + "tables": { | |
| 7 | + "public.accounts": { | |
| 8 | + "name": "accounts", | |
| 9 | + "schema": "", | |
| 10 | + "columns": { | |
| 11 | + "id": { | |
| 12 | + "name": "id", | |
| 13 | + "type": "text", | |
| 14 | + "primaryKey": true, | |
| 15 | + "notNull": true | |
| 16 | + }, | |
| 17 | + "created_at": { | |
| 18 | + "name": "created_at", | |
| 19 | + "type": "timestamp with time zone", | |
| 20 | + "primaryKey": false, | |
| 21 | + "notNull": true, | |
| 22 | + "default": "now()" | |
| 23 | + } | |
| 24 | + }, | |
| 25 | + "indexes": {}, | |
| 26 | + "foreignKeys": {}, | |
| 27 | + "compositePrimaryKeys": {}, | |
| 28 | + "uniqueConstraints": {}, | |
| 29 | + "policies": {}, | |
| 30 | + "checkConstraints": {}, | |
| 31 | + "isRLSEnabled": false | |
| 32 | + }, | |
| 33 | + "public.memberships": { | |
| 34 | + "name": "memberships", | |
| 35 | + "schema": "", | |
| 36 | + "columns": { | |
| 37 | + "account_id": { | |
| 38 | + "name": "account_id", | |
| 39 | + "type": "text", | |
| 40 | + "primaryKey": false, | |
| 41 | + "notNull": true | |
| 42 | + }, | |
| 43 | + "organization_id": { | |
| 44 | + "name": "organization_id", | |
| 45 | + "type": "text", | |
| 46 | + "primaryKey": false, | |
| 47 | + "notNull": true | |
| 48 | + }, | |
| 49 | + "created_at": { | |
| 50 | + "name": "created_at", | |
| 51 | + "type": "timestamp with time zone", | |
| 52 | + "primaryKey": false, | |
| 53 | + "notNull": true, | |
| 54 | + "default": "now()" | |
| 55 | + } | |
| 56 | + }, | |
| 57 | + "indexes": {}, | |
| 58 | + "foreignKeys": { | |
| 59 | + "memberships_account_id_accounts_id_fk": { | |
| 60 | + "name": "memberships_account_id_accounts_id_fk", | |
| 61 | + "tableFrom": "memberships", | |
| 62 | + "tableTo": "accounts", | |
| 63 | + "columnsFrom": ["account_id"], | |
| 64 | + "columnsTo": ["id"], | |
| 65 | + "onDelete": "cascade", | |
| 66 | + "onUpdate": "no action" | |
| 67 | + }, | |
| 68 | + "memberships_organization_id_organizations_id_fk": { | |
| 69 | + "name": "memberships_organization_id_organizations_id_fk", | |
| 70 | + "tableFrom": "memberships", | |
| 71 | + "tableTo": "organizations", | |
| 72 | + "columnsFrom": ["organization_id"], | |
| 73 | + "columnsTo": ["id"], | |
| 74 | + "onDelete": "cascade", | |
| 75 | + "onUpdate": "no action" | |
| 76 | + } | |
| 77 | + }, | |
| 78 | + "compositePrimaryKeys": { | |
| 79 | + "memberships_account_id_organization_id_pk": { | |
| 80 | + "name": "memberships_account_id_organization_id_pk", | |
| 81 | + "columns": ["account_id", "organization_id"] | |
| 82 | + } | |
| 83 | + }, | |
| 84 | + "uniqueConstraints": {}, | |
| 85 | + "policies": {}, | |
| 86 | + "checkConstraints": {}, | |
| 87 | + "isRLSEnabled": false | |
| 88 | + }, | |
| 89 | + "public.organizations": { | |
| 90 | + "name": "organizations", | |
| 91 | + "schema": "", | |
| 92 | + "columns": { | |
| 93 | + "id": { | |
| 94 | + "name": "id", | |
| 95 | + "type": "text", | |
| 96 | + "primaryKey": true, | |
| 97 | + "notNull": true | |
| 98 | + }, | |
| 99 | + "name": { | |
| 100 | + "name": "name", | |
| 101 | + "type": "text", | |
| 102 | + "primaryKey": false, | |
| 103 | + "notNull": true | |
| 104 | + }, | |
| 105 | + "slug": { | |
| 106 | + "name": "slug", | |
| 107 | + "type": "text", | |
| 108 | + "primaryKey": false, | |
| 109 | + "notNull": true | |
| 110 | + }, | |
| 111 | + "created_at": { | |
| 112 | + "name": "created_at", | |
| 113 | + "type": "timestamp with time zone", | |
| 114 | + "primaryKey": false, | |
| 115 | + "notNull": true, | |
| 116 | + "default": "now()" | |
| 117 | + } | |
| 118 | + }, | |
| 119 | + "indexes": { | |
| 120 | + "organizations_slug_unique": { | |
| 121 | + "name": "organizations_slug_unique", | |
| 122 | + "columns": [ | |
| 123 | + { | |
| 124 | + "expression": "slug", | |
| 125 | + "isExpression": false, | |
| 126 | + "asc": true, | |
| 127 | + "nulls": "last" | |
| 128 | + } | |
| 129 | + ], | |
| 130 | + "isUnique": true, | |
| 131 | + "concurrently": false, | |
| 132 | + "method": "btree", | |
| 133 | + "with": {} | |
| 134 | + } | |
| 135 | + }, | |
| 136 | + "foreignKeys": {}, | |
| 137 | + "compositePrimaryKeys": {}, | |
| 138 | + "uniqueConstraints": {}, | |
| 139 | + "policies": {}, | |
| 140 | + "checkConstraints": {}, | |
| 141 | + "isRLSEnabled": false | |
| 142 | + }, | |
| 143 | + "public.blob": { | |
| 144 | + "name": "blob", | |
| 145 | + "schema": "", | |
| 146 | + "columns": { | |
| 147 | + "namespace": { | |
| 148 | + "name": "namespace", | |
| 149 | + "type": "varchar(255)", | |
| 150 | + "primaryKey": false, | |
| 151 | + "notNull": true | |
| 152 | + }, | |
| 153 | + "key": { | |
| 154 | + "name": "key", | |
| 155 | + "type": "varchar(255)", | |
| 156 | + "primaryKey": false, | |
| 157 | + "notNull": true | |
| 158 | + }, | |
| 159 | + "value": { | |
| 160 | + "name": "value", | |
| 161 | + "type": "text", | |
| 162 | + "primaryKey": false, | |
| 163 | + "notNull": true | |
| 164 | + }, | |
| 165 | + "row_id": { | |
| 166 | + "name": "row_id", | |
| 167 | + "type": "varchar(255)", | |
| 168 | + "primaryKey": true, | |
| 169 | + "notNull": true | |
| 170 | + }, | |
| 171 | + "id": { | |
| 172 | + "name": "id", | |
| 173 | + "type": "varchar(255)", | |
| 174 | + "primaryKey": false, | |
| 175 | + "notNull": true | |
| 176 | + } | |
| 177 | + }, | |
| 178 | + "indexes": { | |
| 179 | + "blob_id_uidx": { | |
| 180 | + "name": "blob_id_uidx", | |
| 181 | + "columns": [ | |
| 182 | + { | |
| 183 | + "expression": "id", | |
| 184 | + "isExpression": false, | |
| 185 | + "asc": true, | |
| 186 | + "nulls": "last" | |
| 187 | + } | |
| 188 | + ], | |
| 189 | + "isUnique": true, | |
| 190 | + "concurrently": false, | |
| 191 | + "method": "btree", | |
| 192 | + "with": {} | |
| 193 | + } | |
| 194 | + }, | |
| 195 | + "foreignKeys": {}, | |
| 196 | + "compositePrimaryKeys": {}, | |
| 197 | + "uniqueConstraints": {}, | |
| 198 | + "policies": {}, | |
| 199 | + "checkConstraints": {}, | |
| 200 | + "isRLSEnabled": false | |
| 201 | + }, | |
| 202 | + "public.connection": { | |
| 203 | + "name": "connection", | |
| 204 | + "schema": "", | |
| 205 | + "columns": { | |
| 206 | + "integration": { | |
| 207 | + "name": "integration", | |
| 208 | + "type": "varchar(255)", | |
| 209 | + "primaryKey": false, | |
| 210 | + "notNull": true | |
| 211 | + }, | |
| 212 | + "name": { | |
| 213 | + "name": "name", | |
| 214 | + "type": "varchar(255)", | |
| 215 | + "primaryKey": false, | |
| 216 | + "notNull": true | |
| 217 | + }, | |
| 218 | + "template": { | |
| 219 | + "name": "template", | |
| 220 | + "type": "text", | |
| 221 | + "primaryKey": false, | |
| 222 | + "notNull": true | |
| 223 | + }, | |
| 224 | + "provider": { | |
| 225 | + "name": "provider", | |
| 226 | + "type": "text", | |
| 227 | + "primaryKey": false, | |
| 228 | + "notNull": true | |
| 229 | + }, | |
| 230 | + "item_ids": { | |
| 231 | + "name": "item_ids", | |
| 232 | + "type": "json", | |
| 233 | + "primaryKey": false, | |
| 234 | + "notNull": true | |
| 235 | + }, | |
| 236 | + "identity_label": { | |
| 237 | + "name": "identity_label", | |
| 238 | + "type": "text", | |
| 239 | + "primaryKey": false, | |
| 240 | + "notNull": false | |
| 241 | + }, | |
| 242 | + "description": { | |
| 243 | + "name": "description", | |
| 244 | + "type": "text", | |
| 245 | + "primaryKey": false, | |
| 246 | + "notNull": false | |
| 247 | + }, | |
| 248 | + "tools_synced_at": { | |
| 249 | + "name": "tools_synced_at", | |
| 250 | + "type": "bigint", | |
| 251 | + "primaryKey": false, | |
| 252 | + "notNull": false | |
| 253 | + }, | |
| 254 | + "oauth_client": { | |
| 255 | + "name": "oauth_client", | |
| 256 | + "type": "text", | |
| 257 | + "primaryKey": false, | |
| 258 | + "notNull": false | |
| 259 | + }, | |
| 260 | + "oauth_client_owner": { | |
| 261 | + "name": "oauth_client_owner", | |
| 262 | + "type": "text", | |
| 263 | + "primaryKey": false, | |
| 264 | + "notNull": false | |
| 265 | + }, | |
| 266 | + "refresh_item_id": { | |
| 267 | + "name": "refresh_item_id", | |
| 268 | + "type": "text", | |
| 269 | + "primaryKey": false, | |
| 270 | + "notNull": false | |
| 271 | + }, | |
| 272 | + "expires_at": { | |
| 273 | + "name": "expires_at", | |
| 274 | + "type": "bigint", | |
| 275 | + "primaryKey": false, | |
| 276 | + "notNull": false | |
| 277 | + }, | |
| 278 | + "oauth_scope": { | |
| 279 | + "name": "oauth_scope", | |
| 280 | + "type": "text", | |
| 281 | + "primaryKey": false, | |
| 282 | + "notNull": false | |
| 283 | + }, | |
| 284 | + "provider_state": { | |
| 285 | + "name": "provider_state", | |
| 286 | + "type": "json", | |
| 287 | + "primaryKey": false, | |
| 288 | + "notNull": false | |
| 289 | + }, | |
| 290 | + "created_at": { | |
| 291 | + "name": "created_at", | |
| 292 | + "type": "timestamp", | |
| 293 | + "primaryKey": false, | |
| 294 | + "notNull": true | |
| 295 | + }, | |
| 296 | + "updated_at": { | |
| 297 | + "name": "updated_at", | |
| 298 | + "type": "timestamp", | |
| 299 | + "primaryKey": false, | |
| 300 | + "notNull": true | |
| 301 | + }, | |
| 302 | + "row_id": { | |
| 303 | + "name": "row_id", | |
| 304 | + "type": "varchar(255)", | |
| 305 | + "primaryKey": true, | |
| 306 | + "notNull": true | |
| 307 | + }, | |
| 308 | + "tenant": { | |
| 309 | + "name": "tenant", | |
| 310 | + "type": "varchar(255)", | |
| 311 | + "primaryKey": false, | |
| 312 | + "notNull": true | |
| 313 | + }, | |
| 314 | + "owner": { | |
| 315 | + "name": "owner", | |
| 316 | + "type": "varchar(255)", | |
| 317 | + "primaryKey": false, | |
| 318 | + "notNull": true | |
| 319 | + }, | |
| 320 | + "subject": { | |
| 321 | + "name": "subject", | |
| 322 | + "type": "varchar(255)", | |
| 323 | + "primaryKey": false, | |
| 324 | + "notNull": true | |
| 325 | + } | |
| 326 | + }, | |
| 327 | + "indexes": { | |
| 328 | + "connection_uidx": { | |
| 329 | + "name": "connection_uidx", | |
| 330 | + "columns": [ | |
| 331 | + { | |
| 332 | + "expression": "tenant", | |
| 333 | + "isExpression": false, | |
| 334 | + "asc": true, | |
| 335 | + "nulls": "last" | |
| 336 | + }, | |
| 337 | + { | |
| 338 | + "expression": "owner", | |
| 339 | + "isExpression": false, | |
| 340 | + "asc": true, | |
| 341 | + "nulls": "last" | |
| 342 | + }, | |
| 343 | + { | |
| 344 | + "expression": "subject", | |
| 345 | + "isExpression": false, | |
| 346 | + "asc": true, | |
| 347 | + "nulls": "last" | |
| 348 | + }, | |
| 349 | + { | |
| 350 | + "expression": "integration", | |
| 351 | + "isExpression": false, | |
| 352 | + "asc": true, | |
| 353 | + "nulls": "last" | |
| 354 | + }, | |
| 355 | + { | |
| 356 | + "expression": "name", | |
| 357 | + "isExpression": false, | |
| 358 | + "asc": true, | |
| 359 | + "nulls": "last" | |
| 360 | + } | |
| 361 | + ], | |
| 362 | + "isUnique": true, | |
| 363 | + "concurrently": false, | |
| 364 | + "method": "btree", | |
| 365 | + "with": {} | |
| 366 | + } | |
| 367 | + }, | |
| 368 | + "foreignKeys": {}, | |
| 369 | + "compositePrimaryKeys": {}, | |
| 370 | + "uniqueConstraints": {}, | |
| 371 | + "policies": {}, | |
| 372 | + "checkConstraints": {}, | |
| 373 | + "isRLSEnabled": false | |
| 374 | + }, | |
| 375 | + "public.definition": { | |
| 376 | + "name": "definition", | |
| 377 | + "schema": "", | |
| 378 | + "columns": { | |
| 379 | + "integration": { | |
| 380 | + "name": "integration", | |
| 381 | + "type": "varchar(255)", | |
| 382 | + "primaryKey": false, | |
| 383 | + "notNull": true | |
| 384 | + }, | |
| 385 | + "connection": { | |
| 386 | + "name": "connection", | |
| 387 | + "type": "varchar(255)", | |
| 388 | + "primaryKey": false, | |
| 389 | + "notNull": true | |
| 390 | + }, | |
| 391 | + "plugin_id": { | |
| 392 | + "name": "plugin_id", | |
| 393 | + "type": "text", | |
| 394 | + "primaryKey": false, | |
| 395 | + "notNull": true | |
| 396 | + }, | |
| 397 | + "name": { | |
| 398 | + "name": "name", | |
| 399 | + "type": "text", | |
| 400 | + "primaryKey": false, | |
| 401 | + "notNull": true | |
| 402 | + }, | |
| 403 | + "schema": { | |
| 404 | + "name": "schema", | |
| 405 | + "type": "json", | |
| 406 | + "primaryKey": false, | |
| 407 | + "notNull": true | |
| 408 | + }, | |
| 409 | + "created_at": { | |
| 410 | + "name": "created_at", | |
| 411 | + "type": "timestamp", | |
| 412 | + "primaryKey": false, | |
| 413 | + "notNull": true | |
| 414 | + }, | |
| 415 | + "row_id": { | |
| 416 | + "name": "row_id", | |
| 417 | + "type": "varchar(255)", | |
| 418 | + "primaryKey": true, | |
| 419 | + "notNull": true | |
| 420 | + }, | |
| 421 | + "tenant": { | |
| 422 | + "name": "tenant", | |
| 423 | + "type": "varchar(255)", | |
| 424 | + "primaryKey": false, | |
| 425 | + "notNull": true | |
| 426 | + }, | |
| 427 | + "owner": { | |
| 428 | + "name": "owner", | |
| 429 | + "type": "varchar(255)", | |
| 430 | + "primaryKey": false, | |
| 431 | + "notNull": true | |
| 432 | + }, | |
| 433 | + "subject": { | |
| 434 | + "name": "subject", | |
| 435 | + "type": "varchar(255)", | |
| 436 | + "primaryKey": false, | |
| 437 | + "notNull": true | |
| 438 | + } | |
| 439 | + }, | |
| 440 | + "indexes": { | |
| 441 | + "definition_uidx": { | |
| 442 | + "name": "definition_uidx", | |
| 443 | + "columns": [ | |
| 444 | + { | |
| 445 | + "expression": "tenant", | |
| 446 | + "isExpression": false, | |
| 447 | + "asc": true, | |
| 448 | + "nulls": "last" | |
| 449 | + }, | |
| 450 | + { | |
| 451 | + "expression": "owner", | |
| 452 | + "isExpression": false, | |
| 453 | + "asc": true, | |
| 454 | + "nulls": "last" | |
| 455 | + }, | |
| 456 | + { | |
| 457 | + "expression": "subject", | |
| 458 | + "isExpression": false, | |
| 459 | + "asc": true, | |
| 460 | + "nulls": "last" | |
| 461 | + }, | |
| 462 | + { | |
| 463 | + "expression": "integration", | |
| 464 | + "isExpression": false, | |
| 465 | + "asc": true, | |
| 466 | + "nulls": "last" | |
| 467 | + }, | |
| 468 | + { | |
| 469 | + "expression": "connection", | |
| 470 | + "isExpression": false, | |
| 471 | + "asc": true, | |
| 472 | + "nulls": "last" | |
| 473 | + }, | |
| 474 | + { | |
| 475 | + "expression": "name", | |
| 476 | + "isExpression": false, | |
| 477 | + "asc": true, | |
| 478 | + "nulls": "last" | |
| 479 | + } | |
| 480 | + ], | |
| 481 | + "isUnique": true, | |
| 482 | + "concurrently": false, | |
| 483 | + "method": "btree", | |
| 484 | + "with": {} | |
| 485 | + } | |
| 486 | + }, | |
| 487 | + "foreignKeys": {}, | |
| 488 | + "compositePrimaryKeys": {}, | |
| 489 | + "uniqueConstraints": {}, | |
| 490 | + "policies": {}, | |
| 491 | + "checkConstraints": {}, | |
| 492 | + "isRLSEnabled": false | |
| 493 | + }, | |
| 494 | + "public.integration": { | |
| 495 | + "name": "integration", | |
| 496 | + "schema": "", | |
| 497 | + "columns": { | |
| 498 | + "slug": { | |
| 499 | + "name": "slug", | |
| 500 | + "type": "varchar(255)", | |
| 501 | + "primaryKey": false, | |
| 502 | + "notNull": true | |
| 503 | + }, | |
| 504 | + "plugin_id": { | |
| 505 | + "name": "plugin_id", | |
| 506 | + "type": "text", | |
| 507 | + "primaryKey": false, | |
| 508 | + "notNull": true | |
| 509 | + }, | |
| 510 | + "name": { | |
| 511 | + "name": "name", | |
| 512 | + "type": "text", | |
| 513 | + "primaryKey": false, | |
| 514 | + "notNull": false | |
| 515 | + }, | |
| 516 | + "description": { | |
| 517 | + "name": "description", | |
| 518 | + "type": "text", | |
| 519 | + "primaryKey": false, | |
| 520 | + "notNull": false | |
| 521 | + }, | |
| 522 | + "config_revised_at": { | |
| 523 | + "name": "config_revised_at", | |
| 524 | + "type": "bigint", | |
| 525 | + "primaryKey": false, | |
| 526 | + "notNull": false | |
| 527 | + }, | |
| 528 | + "config": { | |
| 529 | + "name": "config", | |
| 530 | + "type": "json", | |
| 531 | + "primaryKey": false, | |
| 532 | + "notNull": false | |
| 533 | + }, | |
| 534 | + "can_remove": { | |
| 535 | + "name": "can_remove", | |
| 536 | + "type": "boolean", | |
| 537 | + "primaryKey": false, | |
| 538 | + "notNull": true, | |
| 539 | + "default": true | |
| 540 | + }, | |
| 541 | + "can_refresh": { | |
| 542 | + "name": "can_refresh", | |
| 543 | + "type": "boolean", | |
| 544 | + "primaryKey": false, | |
| 545 | + "notNull": true, | |
| 546 | + "default": false | |
| 547 | + }, | |
| 548 | + "created_at": { | |
| 549 | + "name": "created_at", | |
| 550 | + "type": "timestamp", | |
| 551 | + "primaryKey": false, | |
| 552 | + "notNull": true | |
| 553 | + }, | |
| 554 | + "updated_at": { | |
| 555 | + "name": "updated_at", | |
| 556 | + "type": "timestamp", | |
| 557 | + "primaryKey": false, | |
| 558 | + "notNull": true | |
| 559 | + }, | |
| 560 | + "row_id": { | |
| 561 | + "name": "row_id", | |
| 562 | + "type": "varchar(255)", | |
| 563 | + "primaryKey": true, | |
| 564 | + "notNull": true | |
| 565 | + }, | |
| 566 | + "tenant": { | |
| 567 | + "name": "tenant", | |
| 568 | + "type": "varchar(255)", | |
| 569 | + "primaryKey": false, | |
| 570 | + "notNull": true | |
| 571 | + } | |
| 572 | + }, | |
| 573 | + "indexes": { | |
| 574 | + "integration_uidx": { | |
| 575 | + "name": "integration_uidx", | |
| 576 | + "columns": [ | |
| 577 | + { | |
| 578 | + "expression": "tenant", | |
| 579 | + "isExpression": false, | |
| 580 | + "asc": true, | |
| 581 | + "nulls": "last" | |
| 582 | + }, | |
| 583 | + { | |
| 584 | + "expression": "slug", | |
| 585 | + "isExpression": false, | |
| 586 | + "asc": true, | |
| 587 | + "nulls": "last" | |
| 588 | + } | |
| 589 | + ], | |
| 590 | + "isUnique": true, | |
| 591 | + "concurrently": false, | |
| 592 | + "method": "btree", | |
| 593 | + "with": {} | |
| 594 | + } | |
| 595 | + }, | |
| 596 | + "foreignKeys": {}, | |
| 597 | + "compositePrimaryKeys": {}, | |
| 598 | + "uniqueConstraints": {}, | |
| 599 | + "policies": {}, | |
| 600 | + "checkConstraints": {}, | |
| 601 | + "isRLSEnabled": false | |
| 602 | + }, | |
| 603 | + "public.oauth_client": { | |
| 604 | + "name": "oauth_client", | |
| 605 | + "schema": "", | |
| 606 | + "columns": { | |
| 607 | + "slug": { | |
| 608 | + "name": "slug", | |
| 609 | + "type": "varchar(255)", | |
| 610 | + "primaryKey": false, | |
| 611 | + "notNull": true | |
| 612 | + }, | |
| 613 | + "authorization_url": { | |
| 614 | + "name": "authorization_url", | |
| 615 | + "type": "text", | |
| 616 | + "primaryKey": false, | |
| 617 | + "notNull": true | |
| 618 | + }, | |
| 619 | + "token_url": { | |
| 620 | + "name": "token_url", | |
| 621 | + "type": "text", | |
| 622 | + "primaryKey": false, | |
| 623 | + "notNull": true | |
| 624 | + }, | |
| 625 | + "grant": { | |
| 626 | + "name": "grant", | |
| 627 | + "type": "text", | |
| 628 | + "primaryKey": false, | |
| 629 | + "notNull": true | |
| 630 | + }, | |
| 631 | + "client_id": { | |
| 632 | + "name": "client_id", | |
| 633 | + "type": "text", | |
| 634 | + "primaryKey": false, | |
| 635 | + "notNull": true | |
| 636 | + }, | |
| 637 | + "client_secret_item_id": { | |
| 638 | + "name": "client_secret_item_id", | |
| 639 | + "type": "text", | |
| 640 | + "primaryKey": false, | |
| 641 | + "notNull": false | |
| 642 | + }, | |
| 643 | + "resource": { | |
| 644 | + "name": "resource", | |
| 645 | + "type": "text", | |
| 646 | + "primaryKey": false, | |
| 647 | + "notNull": false | |
| 648 | + }, | |
| 649 | + "origin_kind": { | |
| 650 | + "name": "origin_kind", | |
| 651 | + "type": "text", | |
| 652 | + "primaryKey": false, | |
| 653 | + "notNull": false | |
| 654 | + }, | |
| 655 | + "origin_integration": { | |
| 656 | + "name": "origin_integration", | |
| 657 | + "type": "text", | |
| 658 | + "primaryKey": false, | |
| 659 | + "notNull": false | |
| 660 | + }, | |
| 661 | + "created_at": { | |
| 662 | + "name": "created_at", | |
| 663 | + "type": "timestamp", | |
| 664 | + "primaryKey": false, | |
| 665 | + "notNull": true | |
| 666 | + }, | |
| 667 | + "row_id": { | |
| 668 | + "name": "row_id", | |
| 669 | + "type": "varchar(255)", | |
| 670 | + "primaryKey": true, | |
| 671 | + "notNull": true | |
| 672 | + }, | |
| 673 | + "tenant": { | |
| 674 | + "name": "tenant", | |
| 675 | + "type": "varchar(255)", | |
| 676 | + "primaryKey": false, | |
| 677 | + "notNull": true | |
| 678 | + }, | |
| 679 | + "owner": { | |
| 680 | + "name": "owner", | |
| 681 | + "type": "varchar(255)", | |
| 682 | + "primaryKey": false, | |
| 683 | + "notNull": true | |
| 684 | + }, | |
| 685 | + "subject": { | |
| 686 | + "name": "subject", | |
| 687 | + "type": "varchar(255)", | |
| 688 | + "primaryKey": false, | |
| 689 | + "notNull": true | |
| 690 | + } | |
| 691 | + }, | |
| 692 | + "indexes": { | |
| 693 | + "oauth_client_uidx": { | |
| 694 | + "name": "oauth_client_uidx", | |
| 695 | + "columns": [ | |
| 696 | + { | |
| 697 | + "expression": "tenant", | |
| 698 | + "isExpression": false, | |
| 699 | + "asc": true, | |
| 700 | + "nulls": "last" | |
| 701 | + }, | |
| 702 | + { | |
| 703 | + "expression": "owner", | |
| 704 | + "isExpression": false, | |
| 705 | + "asc": true, | |
| 706 | + "nulls": "last" | |
| 707 | + }, | |
| 708 | + { | |
| 709 | + "expression": "subject", | |
| 710 | + "isExpression": false, | |
| 711 | + "asc": true, | |
| 712 | + "nulls": "last" | |
| 713 | + }, | |
| 714 | + { | |
| 715 | + "expression": "slug", | |
| 716 | + "isExpression": false, | |
| 717 | + "asc": true, | |
| 718 | + "nulls": "last" | |
| 719 | + } | |
| 720 | + ], | |
| 721 | + "isUnique": true, | |
| 722 | + "concurrently": false, | |
| 723 | + "method": "btree", | |
| 724 | + "with": {} | |
| 725 | + } | |
| 726 | + }, | |
| 727 | + "foreignKeys": {}, | |
| 728 | + "compositePrimaryKeys": {}, | |
| 729 | + "uniqueConstraints": {}, | |
| 730 | + "policies": {}, | |
| 731 | + "checkConstraints": {}, | |
| 732 | + "isRLSEnabled": false | |
| 733 | + }, | |
| 734 | + "public.oauth_session": { | |
| 735 | + "name": "oauth_session", | |
| 736 | + "schema": "", | |
| 737 | + "columns": { | |
| 738 | + "state": { | |
| 739 | + "name": "state", | |
| 740 | + "type": "varchar(255)", | |
| 741 | + "primaryKey": false, | |
| 742 | + "notNull": true | |
| 743 | + }, | |
| 744 | + "client_slug": { | |
| 745 | + "name": "client_slug", | |
| 746 | + "type": "text", | |
| 747 | + "primaryKey": false, | |
| 748 | + "notNull": true | |
| 749 | + }, | |
| 750 | + "integration": { | |
| 751 | + "name": "integration", | |
| 752 | + "type": "text", | |
| 753 | + "primaryKey": false, | |
| 754 | + "notNull": true | |
| 755 | + }, | |
| 756 | + "name": { | |
| 757 | + "name": "name", | |
| 758 | + "type": "text", | |
| 759 | + "primaryKey": false, | |
| 760 | + "notNull": true | |
| 761 | + }, | |
| 762 | + "template": { | |
| 763 | + "name": "template", | |
| 764 | + "type": "text", | |
| 765 | + "primaryKey": false, | |
| 766 | + "notNull": true | |
| 767 | + }, | |
| 768 | + "redirect_url": { | |
| 769 | + "name": "redirect_url", | |
| 770 | + "type": "text", | |
| 771 | + "primaryKey": false, | |
| 772 | + "notNull": true | |
| 773 | + }, | |
| 774 | + "pkce_verifier": { | |
| 775 | + "name": "pkce_verifier", | |
| 776 | + "type": "text", | |
| 777 | + "primaryKey": false, | |
| 778 | + "notNull": false | |
| 779 | + }, | |
| 780 | + "identity_label": { | |
| 781 | + "name": "identity_label", | |
| 782 | + "type": "text", | |
| 783 | + "primaryKey": false, | |
| 784 | + "notNull": false | |
| 785 | + }, | |
| 786 | + "payload": { | |
| 787 | + "name": "payload", | |
| 788 | + "type": "json", | |
| 789 | + "primaryKey": false, | |
| 790 | + "notNull": true | |
| 791 | + }, | |
| 792 | + "expires_at": { | |
| 793 | + "name": "expires_at", | |
| 794 | + "type": "bigint", | |
| 795 | + "primaryKey": false, | |
| 796 | + "notNull": true | |
| 797 | + }, | |
| 798 | + "created_at": { | |
| 799 | + "name": "created_at", | |
| 800 | + "type": "timestamp", | |
| 801 | + "primaryKey": false, | |
| 802 | + "notNull": true | |
| 803 | + }, | |
| 804 | + "row_id": { | |
| 805 | + "name": "row_id", | |
| 806 | + "type": "varchar(255)", | |
| 807 | + "primaryKey": true, | |
| 808 | + "notNull": true | |
| 809 | + }, | |
| 810 | + "tenant": { | |
| 811 | + "name": "tenant", | |
| 812 | + "type": "varchar(255)", | |
| 813 | + "primaryKey": false, | |
| 814 | + "notNull": true | |
| 815 | + }, | |
| 816 | + "owner": { | |
| 817 | + "name": "owner", | |
| 818 | + "type": "varchar(255)", | |
| 819 | + "primaryKey": false, | |
| 820 | + "notNull": true | |
| 821 | + }, | |
| 822 | + "subject": { | |
| 823 | + "name": "subject", | |
| 824 | + "type": "varchar(255)", | |
| 825 | + "primaryKey": false, | |
| 826 | + "notNull": true | |
| 827 | + } | |
| 828 | + }, | |
| 829 | + "indexes": { | |
| 830 | + "oauth_session_uidx": { | |
| 831 | + "name": "oauth_session_uidx", | |
| 832 | + "columns": [ | |
| 833 | + { | |
| 834 | + "expression": "tenant", | |
| 835 | + "isExpression": false, | |
| 836 | + "asc": true, | |
| 837 | + "nulls": "last" | |
| 838 | + }, | |
| 839 | + { | |
| 840 | + "expression": "state", | |
| 841 | + "isExpression": false, | |
| 842 | + "asc": true, | |
| 843 | + "nulls": "last" | |
| 844 | + } | |
| 845 | + ], | |
| 846 | + "isUnique": true, | |
| 847 | + "concurrently": false, | |
| 848 | + "method": "btree", | |
| 849 | + "with": {} | |
| 850 | + } | |
| 851 | + }, | |
| 852 | + "foreignKeys": {}, | |
| 853 | + "compositePrimaryKeys": {}, | |
| 854 | + "uniqueConstraints": {}, | |
| 855 | + "policies": {}, | |
| 856 | + "checkConstraints": {}, | |
| 857 | + "isRLSEnabled": false | |
| 858 | + }, | |
| 859 | + "public.plugin_storage": { | |
| 860 | + "name": "plugin_storage", | |
| 861 | + "schema": "", | |
| 862 | + "columns": { | |
| 863 | + "plugin_id": { | |
| 864 | + "name": "plugin_id", | |
| 865 | + "type": "varchar(255)", | |
| 866 | + "primaryKey": false, | |
| 867 | + "notNull": true | |
| 868 | + }, | |
| 869 | + "collection": { | |
| 870 | + "name": "collection", | |
| 871 | + "type": "varchar(255)", | |
| 872 | + "primaryKey": false, | |
| 873 | + "notNull": true | |
| 874 | + }, | |
| 875 | + "key": { | |
| 876 | + "name": "key", | |
| 877 | + "type": "varchar(255)", | |
| 878 | + "primaryKey": false, | |
| 879 | + "notNull": true | |
| 880 | + }, | |
| 881 | + "data": { | |
| 882 | + "name": "data", | |
| 883 | + "type": "json", | |
| 884 | + "primaryKey": false, | |
| 885 | + "notNull": true | |
| 886 | + }, | |
| 887 | + "created_at": { | |
| 888 | + "name": "created_at", | |
| 889 | + "type": "timestamp", | |
| 890 | + "primaryKey": false, | |
| 891 | + "notNull": true | |
| 892 | + }, | |
| 893 | + "updated_at": { | |
| 894 | + "name": "updated_at", | |
| 895 | + "type": "timestamp", | |
| 896 | + "primaryKey": false, | |
| 897 | + "notNull": true | |
| 898 | + }, | |
| 899 | + "row_id": { | |
| 900 | + "name": "row_id", | |
| 901 | + "type": "varchar(255)", | |
| 902 | + "primaryKey": true, | |
| 903 | + "notNull": true | |
| 904 | + }, | |
| 905 | + "tenant": { | |
| 906 | + "name": "tenant", | |
| 907 | + "type": "varchar(255)", | |
| 908 | + "primaryKey": false, | |
| 909 | + "notNull": true | |
| 910 | + }, | |
| 911 | + "owner": { | |
| 912 | + "name": "owner", | |
| 913 | + "type": "varchar(255)", | |
| 914 | + "primaryKey": false, | |
| 915 | + "notNull": true | |
| 916 | + }, | |
| 917 | + "subject": { | |
| 918 | + "name": "subject", | |
| 919 | + "type": "varchar(255)", | |
| 920 | + "primaryKey": false, | |
| 921 | + "notNull": true | |
| 922 | + } | |
| 923 | + }, | |
| 924 | + "indexes": { | |
| 925 | + "plugin_storage_uidx": { | |
| 926 | + "name": "plugin_storage_uidx", | |
| 927 | + "columns": [ | |
| 928 | + { | |
| 929 | + "expression": "tenant", | |
| 930 | + "isExpression": false, | |
| 931 | + "asc": true, | |
| 932 | + "nulls": "last" | |
| 933 | + }, | |
| 934 | + { | |
| 935 | + "expression": "owner", | |
| 936 | + "isExpression": false, | |
| 937 | + "asc": true, | |
| 938 | + "nulls": "last" | |
| 939 | + }, | |
| 940 | + { | |
| 941 | + "expression": "subject", | |
| 942 | + "isExpression": false, | |
| 943 | + "asc": true, | |
| 944 | + "nulls": "last" | |
| 945 | + }, | |
| 946 | + { | |
| 947 | + "expression": "plugin_id", | |
| 948 | + "isExpression": false, | |
| 949 | + "asc": true, | |
| 950 | + "nulls": "last" | |
| 951 | + }, | |
| 952 | + { | |
| 953 | + "expression": "collection", | |
| 954 | + "isExpression": false, | |
| 955 | + "asc": true, | |
| 956 | + "nulls": "last" | |
| 957 | + }, | |
| 958 | + { | |
| 959 | + "expression": "key", | |
| 960 | + "isExpression": false, | |
| 961 | + "asc": true, | |
| 962 | + "nulls": "last" | |
| 963 | + } | |
| 964 | + ], | |
| 965 | + "isUnique": true, | |
| 966 | + "concurrently": false, | |
| 967 | + "method": "btree", | |
| 968 | + "with": {} | |
| 969 | + } | |
| 970 | + }, | |
| 971 | + "foreignKeys": {}, | |
| 972 | + "compositePrimaryKeys": {}, | |
| 973 | + "uniqueConstraints": {}, | |
| 974 | + "policies": {}, | |
| 975 | + "checkConstraints": {}, | |
| 976 | + "isRLSEnabled": false | |
| 977 | + }, | |
| 978 | + "public.private_executor_cloud_settings": { | |
| 979 | + "name": "private_executor_cloud_settings", | |
| 980 | + "schema": "", | |
| 981 | + "columns": { | |
| 982 | + "id": { | |
| 983 | + "name": "id", | |
| 984 | + "type": "varchar(255)", | |
| 985 | + "primaryKey": true, | |
| 986 | + "notNull": true | |
| 987 | + }, | |
| 988 | + "version": { | |
| 989 | + "name": "version", | |
| 990 | + "type": "varchar(255)", | |
| 991 | + "primaryKey": false, | |
| 992 | + "notNull": true, | |
| 993 | + "default": "'1.0.0'" | |
| 994 | + } | |
| 995 | + }, | |
| 996 | + "indexes": {}, | |
| 997 | + "foreignKeys": {}, | |
| 998 | + "compositePrimaryKeys": {}, | |
| 999 | + "uniqueConstraints": {}, | |
| 1000 | + "policies": {}, | |
| 1001 | + "checkConstraints": {}, | |
| 1002 | + "isRLSEnabled": false | |
| 1003 | + }, | |
| 1004 | + "public.tool": { | |
| 1005 | + "name": "tool", | |
| 1006 | + "schema": "", | |
| 1007 | + "columns": { | |
| 1008 | + "integration": { | |
| 1009 | + "name": "integration", | |
| 1010 | + "type": "varchar(255)", | |
| 1011 | + "primaryKey": false, | |
| 1012 | + "notNull": true | |
| 1013 | + }, | |
| 1014 | + "connection": { | |
| 1015 | + "name": "connection", | |
| 1016 | + "type": "varchar(255)", | |
| 1017 | + "primaryKey": false, | |
| 1018 | + "notNull": true | |
| 1019 | + }, | |
| 1020 | + "plugin_id": { | |
| 1021 | + "name": "plugin_id", | |
| 1022 | + "type": "text", | |
| 1023 | + "primaryKey": false, | |
| 1024 | + "notNull": true | |
| 1025 | + }, | |
| 1026 | + "name": { | |
| 1027 | + "name": "name", | |
| 1028 | + "type": "varchar(255)", | |
| 1029 | + "primaryKey": false, | |
| 1030 | + "notNull": true | |
| 1031 | + }, | |
| 1032 | + "description": { | |
| 1033 | + "name": "description", | |
| 1034 | + "type": "text", | |
| 1035 | + "primaryKey": false, | |
| 1036 | + "notNull": true | |
| 1037 | + }, | |
| 1038 | + "input_schema": { | |
| 1039 | + "name": "input_schema", | |
| 1040 | + "type": "json", | |
| 1041 | + "primaryKey": false, | |
| 1042 | + "notNull": false | |
| 1043 | + }, | |
| 1044 | + "output_schema": { | |
| 1045 | + "name": "output_schema", | |
| 1046 | + "type": "json", | |
| 1047 | + "primaryKey": false, | |
| 1048 | + "notNull": false | |
| 1049 | + }, | |
| 1050 | + "annotations": { | |
| 1051 | + "name": "annotations", | |
| 1052 | + "type": "json", | |
| 1053 | + "primaryKey": false, | |
| 1054 | + "notNull": false | |
| 1055 | + }, | |
| 1056 | + "created_at": { | |
| 1057 | + "name": "created_at", | |
| 1058 | + "type": "timestamp", | |
| 1059 | + "primaryKey": false, | |
| 1060 | + "notNull": true | |
| 1061 | + }, | |
| 1062 | + "updated_at": { | |
| 1063 | + "name": "updated_at", | |
| 1064 | + "type": "timestamp", | |
| 1065 | + "primaryKey": false, | |
| 1066 | + "notNull": true | |
| 1067 | + }, | |
| 1068 | + "row_id": { | |
| 1069 | + "name": "row_id", | |
| 1070 | + "type": "varchar(255)", | |
| 1071 | + "primaryKey": true, | |
| 1072 | + "notNull": true | |
| 1073 | + }, | |
| 1074 | + "tenant": { | |
| 1075 | + "name": "tenant", | |
| 1076 | + "type": "varchar(255)", | |
| 1077 | + "primaryKey": false, | |
| 1078 | + "notNull": true | |
| 1079 | + }, | |
| 1080 | + "owner": { | |
| 1081 | + "name": "owner", | |
| 1082 | + "type": "varchar(255)", | |
| 1083 | + "primaryKey": false, | |
| 1084 | + "notNull": true | |
| 1085 | + }, | |
| 1086 | + "subject": { | |
| 1087 | + "name": "subject", | |
| 1088 | + "type": "varchar(255)", | |
| 1089 | + "primaryKey": false, | |
| 1090 | + "notNull": true | |
| 1091 | + } | |
| 1092 | + }, | |
| 1093 | + "indexes": { | |
| 1094 | + "tool_uidx": { | |
| 1095 | + "name": "tool_uidx", | |
| 1096 | + "columns": [ | |
| 1097 | + { | |
| 1098 | + "expression": "tenant", | |
| 1099 | + "isExpression": false, | |
| 1100 | + "asc": true, | |
| 1101 | + "nulls": "last" | |
| 1102 | + }, | |
| 1103 | + { | |
| 1104 | + "expression": "owner", | |
| 1105 | + "isExpression": false, | |
| 1106 | + "asc": true, | |
| 1107 | + "nulls": "last" | |
| 1108 | + }, | |
| 1109 | + { | |
| 1110 | + "expression": "subject", | |
| 1111 | + "isExpression": false, | |
| 1112 | + "asc": true, | |
| 1113 | + "nulls": "last" | |
| 1114 | + }, | |
| 1115 | + { | |
| 1116 | + "expression": "integration", | |
| 1117 | + "isExpression": false, | |
| 1118 | + "asc": true, | |
| 1119 | + "nulls": "last" | |
| 1120 | + }, | |
| 1121 | + { | |
| 1122 | + "expression": "connection", | |
| 1123 | + "isExpression": false, | |
| 1124 | + "asc": true, | |
| 1125 | + "nulls": "last" | |
| 1126 | + }, | |
| 1127 | + { | |
| 1128 | + "expression": "name", | |
| 1129 | + "isExpression": false, | |
| 1130 | + "asc": true, | |
| 1131 | + "nulls": "last" | |
| 1132 | + } | |
| 1133 | + ], | |
| 1134 | + "isUnique": true, | |
| 1135 | + "concurrently": false, | |
| 1136 | + "method": "btree", | |
| 1137 | + "with": {} | |
| 1138 | + } | |
| 1139 | + }, | |
| 1140 | + "foreignKeys": {}, | |
| 1141 | + "compositePrimaryKeys": {}, | |
| 1142 | + "uniqueConstraints": {}, | |
| 1143 | + "policies": {}, | |
| 1144 | + "checkConstraints": {}, | |
| 1145 | + "isRLSEnabled": false | |
| 1146 | + }, | |
| 1147 | + "public.tool_policy": { | |
| 1148 | + "name": "tool_policy", | |
| 1149 | + "schema": "", | |
| 1150 | + "columns": { | |
| 1151 | + "id": { | |
| 1152 | + "name": "id", | |
| 1153 | + "type": "varchar(255)", | |
| 1154 | + "primaryKey": false, | |
| 1155 | + "notNull": true | |
| 1156 | + }, | |
| 1157 | + "pattern": { | |
| 1158 | + "name": "pattern", | |
| 1159 | + "type": "text", | |
| 1160 | + "primaryKey": false, | |
| 1161 | + "notNull": true | |
| 1162 | + }, | |
| 1163 | + "action": { | |
| 1164 | + "name": "action", | |
| 1165 | + "type": "text", | |
| 1166 | + "primaryKey": false, | |
| 1167 | + "notNull": true | |
| 1168 | + }, | |
| 1169 | + "position": { | |
| 1170 | + "name": "position", | |
| 1171 | + "type": "text", | |
| 1172 | + "primaryKey": false, | |
| 1173 | + "notNull": true | |
| 1174 | + }, | |
| 1175 | + "created_at": { | |
| 1176 | + "name": "created_at", | |
| 1177 | + "type": "timestamp", | |
| 1178 | + "primaryKey": false, | |
| 1179 | + "notNull": true | |
| 1180 | + }, | |
| 1181 | + "updated_at": { | |
| 1182 | + "name": "updated_at", | |
| 1183 | + "type": "timestamp", | |
| 1184 | + "primaryKey": false, | |
| 1185 | + "notNull": true | |
| 1186 | + }, | |
| 1187 | + "row_id": { | |
| 1188 | + "name": "row_id", | |
| 1189 | + "type": "varchar(255)", | |
| 1190 | + "primaryKey": true, | |
| 1191 | + "notNull": true | |
| 1192 | + }, | |
| 1193 | + "tenant": { | |
| 1194 | + "name": "tenant", | |
| 1195 | + "type": "varchar(255)", | |
| 1196 | + "primaryKey": false, | |
| 1197 | + "notNull": true | |
| 1198 | + }, | |
| 1199 | + "owner": { | |
| 1200 | + "name": "owner", | |
| 1201 | + "type": "varchar(255)", | |
| 1202 | + "primaryKey": false, | |
| 1203 | + "notNull": true | |
| 1204 | + }, | |
| 1205 | + "subject": { | |
| 1206 | + "name": "subject", | |
| 1207 | + "type": "varchar(255)", | |
| 1208 | + "primaryKey": false, | |
| 1209 | + "notNull": true | |
| 1210 | + } | |
| 1211 | + }, | |
| 1212 | + "indexes": { | |
| 1213 | + "tool_policy_uidx": { | |
| 1214 | + "name": "tool_policy_uidx", | |
| 1215 | + "columns": [ | |
| 1216 | + { | |
| 1217 | + "expression": "tenant", | |
| 1218 | + "isExpression": false, | |
| 1219 | + "asc": true, | |
| 1220 | + "nulls": "last" | |
| 1221 | + }, | |
| 1222 | + { | |
| 1223 | + "expression": "owner", | |
| 1224 | + "isExpression": false, | |
| 1225 | + "asc": true, | |
| 1226 | + "nulls": "last" | |
| 1227 | + }, | |
| 1228 | + { | |
| 1229 | + "expression": "subject", | |
| 1230 | + "isExpression": false, | |
| 1231 | + "asc": true, | |
| 1232 | + "nulls": "last" | |
| 1233 | + }, | |
| 1234 | + { | |
| 1235 | + "expression": "id", | |
| 1236 | + "isExpression": false, | |
| 1237 | + "asc": true, | |
| 1238 | + "nulls": "last" | |
| 1239 | + } | |
| 1240 | + ], | |
| 1241 | + "isUnique": true, | |
| 1242 | + "concurrently": false, | |
| 1243 | + "method": "btree", | |
| 1244 | + "with": {} | |
| 1245 | + } | |
| 1246 | + }, | |
| 1247 | + "foreignKeys": {}, | |
| 1248 | + "compositePrimaryKeys": {}, | |
| 1249 | + "uniqueConstraints": {}, | |
| 1250 | + "policies": {}, | |
| 1251 | + "checkConstraints": {}, | |
| 1252 | + "isRLSEnabled": false | |
| 1253 | + } | |
| 1254 | + }, | |
| 1255 | + "enums": {}, | |
| 1256 | + "schemas": {}, | |
| 1257 | + "sequences": {}, | |
| 1258 | + "roles": {}, | |
| 1259 | + "policies": {}, | |
| 1260 | + "views": {}, | |
| 1261 | + "_meta": { | |
| 1262 | + "columns": {}, | |
| 1263 | + "schemas": {}, | |
| 1264 | + "tables": {} | |
| 1265 | + } | |
| 1266 | +} | |
| @@ -36,6 +36,13 @@ · 35 unmodified lines | ||
| 36 | 36 | "when": 1781375837041, |
| 37 | 37 | "tag": "0004_nervous_quasar", |
| 38 | 38 | "breakpoints": true |
| 39 | + }, | |
| 40 | + { | |
| 41 | + "idx": 5, | |
| 42 | + "version": "7", | |
| 43 | + "when": 1781491100009, | |
| 44 | + "tag": "0005_integration_descriptions", | |
| 45 | + "breakpoints": true | |
| 39 | 46 | } |
| 40 | 47 | ] |
| 41 | 48 | } |
| @@ -76,6 +76,7 @@ · 75 unmodified lines | ||
| 76 | 76 | "sonner": "^2.0.7" |
| 77 | 77 | }, |
| 78 | 78 | "devDependencies": { |
| 79 | + "@cloudflare/vitest-pool-workers": "^0.15.0", | |
| 79 | 80 | "@cloudflare/workers-types": "^4.20250620.0", |
| 80 | 81 | "@effect/platform-node": "catalog:", |
| 81 | 82 | "@effect/vitest": "catalog:", |
| @@ -15,7 +15,9 @@ export const integration = pgTable( · 14 unmodified lines | ||
| 15 | 15 | { |
| 16 | 16 | slug: varchar("slug", { length: 255 }).notNull(), |
| 17 | 17 | plugin_id: text("plugin_id").notNull(), |
| 18 | − description: text("description").notNull(), | |
| 18 | + name: text("name"), | |
| 19 | + description: text("description"), | |
| 20 | + config_revised_at: bigint("config_revised_at", { mode: "bigint" }), | |
| 19 | 21 | config: json("config"), |
| 20 | 22 | can_remove: boolean("can_remove").notNull().default(true), |
| 21 | 23 | can_refresh: boolean("can_refresh").notNull().default(false), |
| @@ -39,6 +41,8 @@ export const connection = pgTable( · 17 unmodified lines | ||
| 39 | 41 | provider: text("provider").notNull(), |
| 40 | 42 | item_ids: json("item_ids").notNull(), |
| 41 | 43 | identity_label: text("identity_label"), |
| 44 | + description: text("description"), | |
| 45 | + tools_synced_at: bigint("tools_synced_at", { mode: "bigint" }), | |
| 42 | 46 | oauth_client: text("oauth_client"), |
| 43 | 47 | oauth_client_owner: text("oauth_client_owner"), |
| 44 | 48 | refresh_item_id: text("refresh_item_id"), |
| @@ -4,7 +4,7 @@ import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql"; · 3 unmodified lines | ||
| 4 | 4 | import { type FumaDB } from "@executor-js/fumadb"; |
| 5 | 5 | import { |
| 6 | 6 | createDrizzleRuntimeSchemaFromTables, |
| 7 | − createDrizzleRuntimeSchemaSqlFromTables, | |
| 7 | + ensureDrizzleRuntimeSchemaFromTables, | |
| 8 | 8 | } from "@executor-js/fumadb/adapters/drizzle"; |
| 9 | 9 | import { type schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/schema"; |
| 10 | 10 | |
| @@ -49,18 +49,23 @@ export const createSqliteFumaDb = async <const TTables extends FumaTables>( · 38 unmodified lines | ||
| 49 | 49 | }); |
| 50 | 50 | const drizzleDb = drizzle({ client, schema }); |
| 51 | 51 | |
| 52 | − for (const statement of createDrizzleRuntimeSchemaSqlFromTables({ | |
| 52 | + // CREATE TABLE IF NOT EXISTS for fresh files, plus ALTER TABLE ADD COLUMN for | |
| 53 | + // every nullable column the running schema declares — so files created by an | |
| 54 | + // earlier baseline gain new columns on boot instead of 500ing on first query. | |
| 55 | + // This is the same bring-up the self-host and Cloudflare hosts run; keeping | |
| 56 | + // local on it stops per-column drift between hosts. Idempotent. | |
| 57 | + await ensureDrizzleRuntimeSchemaFromTables(drizzleDb, { | |
| 53 | 58 | tables: options.tables, |
| 54 | 59 | namespace: options.namespace, |
| 55 | 60 | version, |
| 56 | 61 | provider: "sqlite", |
| 57 | − })) { | |
| 58 | − await client.execute(statement); | |
| 59 | − } | |
| 62 | + }); | |
| 60 | 63 | |
| 61 | − // Defensive column adds for libSQL files created by earlier v2 baselines — | |
| 62 | − // the bring-up above is CREATE TABLE IF NOT EXISTS and won't add a column to | |
| 63 | − // an already-created table. Idempotent. | |
| 64 | + // Defensive column adds for libSQL files created by earlier v2 baselines. | |
| 65 | + // The generic bring-up above already evolves every nullable column the live | |
| 66 | + // schema declares, so these are mostly redundant now; they are kept as an | |
| 67 | + // explicit safety net and to cover any column the schema no longer declares. | |
| 68 | + // Idempotent. | |
| 64 | 69 | const connectionColumns = await client.execute("PRAGMA table_info('connection')"); |
| 65 | 70 | if ( |
| 66 | 71 | connectionColumns.rows.length > 0 && |
| @@ -100,6 +100,7 @@ · 99 unmodified lines | ||
| 100 | 100 | "sonner": "^2.0.7", |
| 101 | 101 | }, |
| 102 | 102 | "devDependencies": { |
| 103 | + "@cloudflare/vitest-pool-workers": "^0.15.0", | |
| 103 | 104 | "@cloudflare/workers-types": "^4.20250620.0", |
| 104 | 105 | "@effect/platform-node": "catalog:", |
| 105 | 106 | "@effect/vitest": "catalog:", |
| @@ -349,6 +350,7 @@ · 243 unmodified lines | ||
| 349 | 350 | "@types/react": "catalog:", |
| 350 | 351 | "@types/react-dom": "catalog:", |
| 351 | 352 | "@vitejs/plugin-react": "catalog:", |
| 353 | + "graphql": "^16.12.0", | |
| 352 | 354 | "iron-webcrypto": "^2.0.0", |
| 353 | 355 | "typescript": "catalog:", |
| 354 | 356 | "vite": "catalog:", |
| @@ -522,6 +524,7 @@ · 167 unmodified lines | ||
| 522 | 524 | }, |
| 523 | 525 | "devDependencies": { |
| 524 | 526 | "@effect/vitest": "catalog:", |
| 527 | + "@libsql/client": "catalog:", | |
| 525 | 528 | "@types/better-sqlite3": "^7.6.13", |
| 526 | 529 | "@types/node": "catalog:", |
| 527 | 530 | "@types/pg": "^8.20.0", |
| @@ -4300,7 +4303,7 @@ · 3772 unmodified lines | ||
| 4300 | 4303 | |
| 4301 | 4304 | "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], |
| 4302 | 4305 | |
| 4303 | − "miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="], | |
| 4306 | + "miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="], | |
| 4304 | 4307 | |
| 4305 | 4308 | "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], |
| 4306 | 4309 | |
| @@ -5400,8 +5403,6 @@ · 1093 unmodified lines | ||
| 5400 | 5403 | |
| 5401 | 5404 | "@cloudflare/vitest-pool-workers/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], |
| 5402 | 5405 | |
| 5403 | − "@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="], | |
| 5404 | − | |
| 5405 | 5406 | "@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.85.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260424.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg=="], |
| 5406 | 5407 | |
| 5407 | 5408 | "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], |
| @@ -6020,7 +6021,7 @@ · 612 unmodified lines | ||
| 6020 | 6021 | |
| 6021 | 6022 | "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], |
| 6022 | 6023 | |
| 6023 | − "miniflare/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], | |
| 6024 | + "miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], | |
| 6024 | 6025 | |
| 6025 | 6026 | "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], |
| 6026 | 6027 | |
| @@ -6144,6 +6145,8 @@ · 117 unmodified lines | ||
| 6144 | 6145 | |
| 6145 | 6146 | "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], |
| 6146 | 6147 | |
| 6148 | + "wrangler/miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="], | |
| 6149 | + | |
| 6147 | 6150 | "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], |
| 6148 | 6151 | |
| 6149 | 6152 | "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], |
| @@ -6224,10 +6227,6 @@ · 74 unmodified lines | ||
| 6224 | 6227 | |
| 6225 | 6228 | "@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], |
| 6226 | 6229 | |
| 6227 | − "@cloudflare/vitest-pool-workers/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], | |
| 6228 | − | |
| 6229 | − "@cloudflare/vitest-pool-workers/miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], | |
| 6230 | − | |
| 6231 | 6230 | "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], |
| 6232 | 6231 | |
| 6233 | 6232 | "@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], |
| @@ -6678,6 +6677,16 @@ · 444 unmodified lines | ||
| 6678 | 6677 | |
| 6679 | 6678 | "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], |
| 6680 | 6679 | |
| 6680 | + "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], | |
| 6681 | + | |
| 6682 | + "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], | |
| 6683 | + | |
| 6684 | + "miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="], | |
| 6685 | + | |
| 6686 | + "miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="], | |
| 6687 | + | |
| 6688 | + "miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], | |
| 6689 | + | |
| 6681 | 6690 | "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], |
| 6682 | 6691 | |
| 6683 | 6692 | "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], |
| @@ -6774,6 +6783,10 @@ · 90 unmodified lines | ||
| 6774 | 6783 | |
| 6775 | 6784 | "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], |
| 6776 | 6785 | |
| 6786 | + "wrangler/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], | |
| 6787 | + | |
| 6788 | + "wrangler/miniflare/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], | |
| 6789 | + | |
| 6777 | 6790 | "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], |
| 6778 | 6791 | |
| 6779 | 6792 | "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], |
| @@ -6854,16 +6867,6 @@ · 74 unmodified lines | ||
| 6854 | 6867 | |
| 6855 | 6868 | "@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ=="], |
| 6856 | 6869 | |
| 6857 | − "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], | |
| 6858 | − | |
| 6859 | − "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], | |
| 6860 | − | |
| 6861 | − "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="], | |
| 6862 | − | |
| 6863 | − "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="], | |
| 6864 | − | |
| 6865 | − "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], | |
| 6866 | − | |
| 6867 | 6870 | "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], |
| 6868 | 6871 | |
| 6869 | 6872 | "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], |
| @@ -0,0 +1,245 @@ | ||
| 1 | +// Cloud-only (needs real multi-user organizations): when one member refreshes | |
| 2 | +// a shared integration's spec, a DIFFERENT member's OWN connection converges to | |
| 3 | +// the new tool catalog on that member's next read — not just the editor's. | |
| 4 | +// | |
| 5 | +// This is the lazy-convergence design. Tools are stored per connection. The | |
| 6 | +// editor stamps the integration's `config_revised_at`, but the owner policy lets | |
| 7 | +// them rebuild catalogs only in their own partition — they cannot write a | |
| 8 | +// co-worker's personal connection rows. So each member's connection carries a | |
| 9 | +// `tools_synced_at` and rebuilds itself the next time THAT member lists tools. | |
| 10 | +// Without it, a colleague keeps calling tools that the API no longer serves. | |
| 11 | +import { randomBytes } from "node:crypto"; | |
| 12 | +import { createServer } from "node:http"; | |
| 13 | + | |
| 14 | +import { expect } from "@effect/vitest"; | |
| 15 | +import { Effect } from "effect"; | |
| 16 | +import type { HttpApiClient } from "effect/unstable/httpapi"; | |
| 17 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 18 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 19 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 20 | + | |
| 21 | +import { scenario } from "../src/scenario"; | |
| 22 | +import { Api, Target } from "../src/services"; | |
| 23 | +import type { Identity, Target as TargetShape } from "../src/target"; | |
| 24 | + | |
| 25 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 26 | +type Client = HttpApiClient.ForApi<typeof api>; | |
| 27 | + | |
| 28 | +// Same evolving spec the single-user openapi-update-spec scenario uses, so the | |
| 29 | +// expected tool names below are already an independently-verified contract. | |
| 30 | +const specV1 = JSON.stringify({ | |
| 31 | + openapi: "3.0.3", | |
| 32 | + info: { title: "Evolving API", version: "1.0.0" }, | |
| 33 | + paths: { | |
| 34 | + "/ping": { | |
| 35 | + get: { | |
| 36 | + operationId: "ping", | |
| 37 | + summary: "Return a pong", | |
| 38 | + responses: { "200": { description: "pong" } }, | |
| 39 | + }, | |
| 40 | + }, | |
| 41 | + "/legacy": { | |
| 42 | + get: { | |
| 43 | + operationId: "legacyOp", | |
| 44 | + summary: "Soon to be removed", | |
| 45 | + responses: { "200": { description: "ok" } }, | |
| 46 | + }, | |
| 47 | + }, | |
| 48 | + }, | |
| 49 | +}); | |
| 50 | + | |
| 51 | +const specV2 = JSON.stringify({ | |
| 52 | + openapi: "3.0.3", | |
| 53 | + info: { title: "Evolving API", version: "2.0.0" }, | |
| 54 | + paths: { | |
| 55 | + "/ping": { | |
| 56 | + get: { | |
| 57 | + operationId: "ping", | |
| 58 | + summary: "Return a pong", | |
| 59 | + responses: { "200": { description: "pong" } }, | |
| 60 | + }, | |
| 61 | + }, | |
| 62 | + "/widgets": { | |
| 63 | + get: { | |
| 64 | + operationId: "listWidgets", | |
| 65 | + summary: "List widgets", | |
| 66 | + responses: { "200": { description: "widgets" } }, | |
| 67 | + }, | |
| 68 | + }, | |
| 69 | + }, | |
| 70 | +}); | |
| 71 | + | |
| 72 | +const V1_TOOLS = ["legacy.legacyOp", "ping.getOperation"]; | |
| 73 | +const V2_TOOLS = ["ping.getOperation", "widgets.listWidgets"]; | |
| 74 | + | |
| 75 | +/** A real 127.0.0.1 server whose served spec can be swapped mid-scenario. */ | |
| 76 | +const serveMutableSpec = (initial: string) => | |
| 77 | + Effect.acquireRelease( | |
| 78 | + Effect.callback<{ | |
| 79 | + readonly url: string; | |
| 80 | + readonly setBody: (body: string) => void; | |
| 81 | + readonly close: () => void; | |
| 82 | + }>((resume) => { | |
| 83 | + let body = initial; | |
| 84 | + const server = createServer((_request, response) => { | |
| 85 | + response.writeHead(200, { "content-type": "application/json" }); | |
| 86 | + response.end(body); | |
| 87 | + }); | |
| 88 | + server.listen(0, "127.0.0.1", () => { | |
| 89 | + const address = server.address(); | |
| 90 | + const port = typeof address === "object" && address ? address.port : 0; | |
| 91 | + resume( | |
| 92 | + Effect.succeed({ | |
| 93 | + url: `http://127.0.0.1:${port}/spec.json`, | |
| 94 | + setBody: (next: string) => { | |
| 95 | + body = next; | |
| 96 | + }, | |
| 97 | + close: () => { | |
| 98 | + server.close(); | |
| 99 | + server.closeAllConnections(); | |
| 100 | + }, | |
| 101 | + }), | |
| 102 | + ); | |
| 103 | + }); | |
| 104 | + }), | |
| 105 | + (server) => Effect.sync(server.close), | |
| 106 | + ); | |
| 107 | + | |
| 108 | +// ── Session plumbing over the real auth endpoints (mirrors the web app) ────── | |
| 109 | +const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? ""; | |
| 110 | + | |
| 111 | +const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) => | |
| 112 | + Effect.promise(async () => { | |
| 113 | + const response = await fetch(new URL(path, target.baseUrl), { | |
| 114 | + method: "POST", | |
| 115 | + headers: { | |
| 116 | + "content-type": "application/json", | |
| 117 | + origin: new URL(target.baseUrl).origin, | |
| 118 | + cookie: cookieOf(identity), | |
| 119 | + }, | |
| 120 | + body: JSON.stringify(body), | |
| 121 | + }); | |
| 122 | + if (!response.ok) { | |
| 123 | + throw new Error(`${path} failed (${response.status}): ${await response.text()}`); | |
| 124 | + } | |
| 125 | + return response; | |
| 126 | + }); | |
| 127 | + | |
| 128 | +const withRefreshedSession = (identity: Identity, response: Response): Identity => { | |
| 129 | + const refreshed = (response.headers.getSetCookie?.() ?? []) | |
| 130 | + .find((header) => header.startsWith("wos-session=")) | |
| 131 | + ?.split(";")[0]; | |
| 132 | + if (!refreshed) throw new Error("response did not refresh the session cookie"); | |
| 133 | + return { ...identity, headers: { cookie: refreshed } }; | |
| 134 | +}; | |
| 135 | + | |
| 136 | +/** Invite `member` into `admin`'s org and accept — the real invite flow. */ | |
| 137 | +const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => | |
| 138 | + Effect.gen(function* () { | |
| 139 | + const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { | |
| 140 | + email: member.credentials?.email, | |
| 141 | + }); | |
| 142 | + const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string }; | |
| 143 | + const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, { | |
| 144 | + invitationId: invitation.id, | |
| 145 | + }); | |
| 146 | + return withRefreshedSession(member, acceptResponse); | |
| 147 | + }); | |
| 148 | + | |
| 149 | +const apiKeyTemplate = [ | |
| 150 | + { | |
| 151 | + slug: "apiKey", | |
| 152 | + type: "apiKey" as const, | |
| 153 | + headers: { authorization: ["Bearer ", { type: "variable" as const, name: "token" }] }, | |
| 154 | + }, | |
| 155 | +]; | |
| 156 | + | |
| 157 | +const personalConnection = (client: Client, integration: IntegrationSlug, name: ConnectionName) => | |
| 158 | + client.connections.create({ | |
| 159 | + payload: { | |
| 160 | + owner: "user", | |
| 161 | + name, | |
| 162 | + integration, | |
| 163 | + template: AuthTemplateSlug.make("apiKey"), | |
| 164 | + value: `tok-${randomBytes(8).toString("hex")}`, | |
| 165 | + }, | |
| 166 | + }); | |
| 167 | + | |
| 168 | +const ownToolNames = (client: Client, integration: IntegrationSlug) => | |
| 169 | + Effect.map(client.tools.list({ query: { integration, owner: "user" } }), (tools) => | |
| 170 | + tools.map((tool) => tool.name).sort(), | |
| 171 | + ); | |
| 172 | + | |
| 173 | +scenario( | |
| 174 | + "Convergence · a spec refresh reaches a co-worker's own connection on their next read", | |
| 175 | + {}, | |
| 176 | + Effect.scoped( | |
| 177 | + Effect.gen(function* () { | |
| 178 | + const target = yield* Target; | |
| 179 | + const { client } = yield* Api; | |
| 180 | + | |
| 181 | + const admin = yield* target.newIdentity(); | |
| 182 | + const invitee = yield* target.newIdentity({ org: false }); | |
| 183 | + const colleague = yield* joinOrg(target, admin, invitee); | |
| 184 | + | |
| 185 | + const adminClient = yield* client(api, admin); | |
| 186 | + const colleagueClient = yield* client(api, colleague); | |
| 187 | + | |
| 188 | + const slug = IntegrationSlug.make(`converge-${randomBytes(4).toString("hex")}`); | |
| 189 | + const adminConn = ConnectionName.make(`admin${randomBytes(3).toString("hex")}`); | |
| 190 | + const colleagueConn = ConnectionName.make(`peer${randomBytes(3).toString("hex")}`); | |
| 191 | + const specServer = yield* serveMutableSpec(specV1); | |
| 192 | + | |
| 193 | + yield* Effect.ensuring( | |
| 194 | + Effect.gen(function* () { | |
| 195 | + // The admin registers a SHARED (org) integration from the live spec. | |
| 196 | + yield* adminClient.openapi.addSpec({ | |
| 197 | + payload: { | |
| 198 | + spec: { kind: "url", url: specServer.url }, | |
| 199 | + slug, | |
| 200 | + baseUrl: "http://127.0.0.1:59999", // tools are never invoked here | |
| 201 | + authenticationTemplate: apiKeyTemplate, | |
| 202 | + }, | |
| 203 | + }); | |
| 204 | + | |
| 205 | + // Each member binds their OWN personal connection to it. | |
| 206 | + yield* personalConnection(adminClient, slug, adminConn); | |
| 207 | + yield* personalConnection(colleagueClient, slug, colleagueConn); | |
| 208 | + | |
| 209 | + // Both see the v1 catalog on their own connection to start. | |
| 210 | + expect(yield* ownToolNames(adminClient, slug), "admin starts on v1").toEqual(V1_TOOLS); | |
| 211 | + expect(yield* ownToolNames(colleagueClient, slug), "the co-worker starts on v1").toEqual( | |
| 212 | + V1_TOOLS, | |
| 213 | + ); | |
| 214 | + | |
| 215 | + // The upstream API ships v2; the admin refreshes the shared spec. | |
| 216 | + specServer.setBody(specV2); | |
| 217 | + const updated = yield* adminClient.openapi.updateSpec({ params: { slug }, payload: {} }); | |
| 218 | + expect(updated.addedTools, "the diff names the new tool").toEqual([ | |
| 219 | + "widgets.listWidgets", | |
| 220 | + ]); | |
| 221 | + | |
| 222 | + // The editor's own connection follows immediately. | |
| 223 | + expect(yield* ownToolNames(adminClient, slug), "admin converged to v2").toEqual(V2_TOOLS); | |
| 224 | + | |
| 225 | + // The co-worker did nothing but read — yet their personal connection | |
| 226 | + // has converged to v2, even though the admin could never write the | |
| 227 | + // co-worker's rows. This is the lazy sync on the colleague's read. | |
| 228 | + expect( | |
| 229 | + yield* ownToolNames(colleagueClient, slug), | |
| 230 | + "the co-worker converged to v2 on their next read", | |
| 231 | + ).toEqual(V2_TOOLS); | |
| 232 | + }), | |
| 233 | + Effect.gen(function* () { | |
| 234 | + yield* colleagueClient.connections | |
| 235 | + .remove({ params: { owner: "user", integration: slug, name: colleagueConn } }) | |
| 236 | + .pipe(Effect.ignore); | |
| 237 | + yield* adminClient.connections | |
| 238 | + .remove({ params: { owner: "user", integration: slug, name: adminConn } }) | |
| 239 | + .pipe(Effect.ignore); | |
| 240 | + yield* adminClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); | |
| 241 | + }), | |
| 242 | + ); | |
| 243 | + }), | |
| 244 | + ), | |
| 245 | +); | |
| @@ -43,6 +43,7 @@ · 42 unmodified lines | ||
| 43 | 43 | "@types/react": "catalog:", |
| 44 | 44 | "@types/react-dom": "catalog:", |
| 45 | 45 | "@vitejs/plugin-react": "catalog:", |
| 46 | + "graphql": "^16.12.0", | |
| 46 | 47 | "iron-webcrypto": "^2.0.0", |
| 47 | 48 | "typescript": "catalog:", |
| 48 | 49 | "vite": "catalog:", |
| @@ -0,0 +1,120 @@ | ||
| 1 | +// Cross-target: the curate-your-catalog promise. An integration's display | |
| 2 | +// name and description, and a connection's description, are user-editable | |
| 3 | +// metadata — set them after the fact and they persist and read back. This is | |
| 4 | +// the write half of the descriptions feature; openapi-update-spec covers the | |
| 5 | +// half where a curated description SURVIVES a spec refresh. | |
| 6 | +import { randomBytes, randomUUID } from "node:crypto"; | |
| 7 | + | |
| 8 | +import { expect } from "@effect/vitest"; | |
| 9 | +import { Effect } from "effect"; | |
| 10 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 11 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 12 | +import { | |
| 13 | + AuthTemplateSlug, | |
| 14 | + ConnectionName, | |
| 15 | + IntegrationSlug, | |
| 16 | + ProviderItemId, | |
| 17 | +} from "@executor-js/sdk/shared"; | |
| 18 | + | |
| 19 | +import { scenario } from "../src/scenario"; | |
| 20 | +import { Api, Target } from "../src/services"; | |
| 21 | + | |
| 22 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 23 | + | |
| 24 | +/** Minimal apiKey-authenticated spec — a connection can bind to it; the single | |
| 25 | + * operation is never invoked here. */ | |
| 26 | +const pingSpec = JSON.stringify({ | |
| 27 | + openapi: "3.0.3", | |
| 28 | + info: { title: "Ping API", version: "1.0.0" }, | |
| 29 | + paths: { | |
| 30 | + "/ping": { | |
| 31 | + get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, | |
| 32 | + }, | |
| 33 | + }, | |
| 34 | +}); | |
| 35 | + | |
| 36 | +scenario( | |
| 37 | + "Metadata · an integration's name/description and a connection's description are editable", | |
| 38 | + {}, | |
| 39 | + Effect.gen(function* () { | |
| 40 | + const target = yield* Target; | |
| 41 | + const { client } = yield* Api; | |
| 42 | + const apiClient = yield* client(api, yield* target.newIdentity()); | |
| 43 | + | |
| 44 | + const slug = IntegrationSlug.make(`meta-edit-${randomBytes(4).toString("hex")}`); | |
| 45 | + const connectionName = ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); | |
| 46 | + | |
| 47 | + yield* Effect.ensuring( | |
| 48 | + Effect.gen(function* () { | |
| 49 | + yield* apiClient.openapi.addSpec({ | |
| 50 | + payload: { | |
| 51 | + spec: { kind: "blob", value: pingSpec }, | |
| 52 | + slug, | |
| 53 | + baseUrl: "http://127.0.0.1:59999", // never contacted | |
| 54 | + description: "Auto-derived at add time.", | |
| 55 | + authenticationTemplate: [ | |
| 56 | + { | |
| 57 | + slug: "apiKey", | |
| 58 | + type: "apiKey", | |
| 59 | + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, | |
| 60 | + }, | |
| 61 | + ], | |
| 62 | + }, | |
| 63 | + }); | |
| 64 | + | |
| 65 | + // The add-time description is what the user first sees. | |
| 66 | + const initial = yield* apiClient.integrations.get({ params: { slug } }); | |
| 67 | + expect(initial.description, "the add-time description is stored").toBe( | |
| 68 | + "Auto-derived at add time.", | |
| 69 | + ); | |
| 70 | + | |
| 71 | + // Rename and re-describe the integration — the curate step. | |
| 72 | + const renamed = yield* apiClient.integrations.update({ | |
| 73 | + params: { slug }, | |
| 74 | + payload: { name: "Acme Ping", description: "Hand-curated for the team." }, | |
| 75 | + }); | |
| 76 | + expect(renamed.name, "the update response carries the new name").toBe("Acme Ping"); | |
| 77 | + expect(renamed.description, "the update response carries the new description").toBe( | |
| 78 | + "Hand-curated for the team.", | |
| 79 | + ); | |
| 80 | + | |
| 81 | + // It persists for the next reader, not just in the response. | |
| 82 | + const reread = yield* apiClient.integrations.get({ params: { slug } }); | |
| 83 | + expect(reread.name, "the name persisted").toBe("Acme Ping"); | |
| 84 | + expect(reread.description, "the description persisted").toBe("Hand-curated for the team."); | |
| 85 | + | |
| 86 | + // A connection carries its own editable description. | |
| 87 | + const providers = yield* apiClient.providers.list(); | |
| 88 | + yield* apiClient.connections.create({ | |
| 89 | + payload: { | |
| 90 | + owner: "org", | |
| 91 | + name: connectionName, | |
| 92 | + integration: slug, | |
| 93 | + template: AuthTemplateSlug.make("apiKey"), | |
| 94 | + from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) }, | |
| 95 | + }, | |
| 96 | + }); | |
| 97 | + const describedConnection = yield* apiClient.connections.update({ | |
| 98 | + params: { owner: "org", integration: slug, name: connectionName }, | |
| 99 | + payload: { description: "Prod key, rotates quarterly." }, | |
| 100 | + }); | |
| 101 | + expect(describedConnection.description, "the connection description was set").toBe( | |
| 102 | + "Prod key, rotates quarterly.", | |
| 103 | + ); | |
| 104 | + | |
| 105 | + const connectionReread = yield* apiClient.connections.get({ | |
| 106 | + params: { owner: "org", integration: slug, name: connectionName }, | |
| 107 | + }); | |
| 108 | + expect(connectionReread.description, "the connection description persisted").toBe( | |
| 109 | + "Prod key, rotates quarterly.", | |
| 110 | + ); | |
| 111 | + }), | |
| 112 | + Effect.gen(function* () { | |
| 113 | + yield* apiClient.connections | |
| 114 | + .remove({ params: { owner: "org", integration: slug, name: connectionName } }) | |
| 115 | + .pipe(Effect.ignore); | |
| 116 | + yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); | |
| 117 | + }), | |
| 118 | + ); | |
| 119 | + }), | |
| 120 | +); | |
| @@ -0,0 +1,204 @@ | ||
| 1 | +// Cross-target: updating an OpenAPI spec IN PLACE — the "our API changed, | |
| 2 | +// don't make me remove/re-add the integration" promise. A real 127.0.0.1 | |
| 3 | +// server serves the spec; after registration + connection the spec evolves | |
| 4 | +// (one operation removed, one added) and `openapi.updateSpec` re-fetches it. | |
| 5 | +// The tool catalog follows, the response reports the diff, and everything | |
| 6 | +// user-curated (connection, description, auth template) survives. | |
| 7 | +import { randomBytes, randomUUID } from "node:crypto"; | |
| 8 | +import { createServer } from "node:http"; | |
| 9 | + | |
| 10 | +import { expect } from "@effect/vitest"; | |
| 11 | +import { Effect } from "effect"; | |
| 12 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 13 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 14 | +import { | |
| 15 | + AuthTemplateSlug, | |
| 16 | + ConnectionName, | |
| 17 | + IntegrationSlug, | |
| 18 | + ProviderItemId, | |
| 19 | +} from "@executor-js/sdk/shared"; | |
| 20 | + | |
| 21 | +import { scenario } from "../src/scenario"; | |
| 22 | +import { Api, Target } from "../src/services"; | |
| 23 | + | |
| 24 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 25 | + | |
| 26 | +const specV1 = JSON.stringify({ | |
| 27 | + openapi: "3.0.3", | |
| 28 | + info: { title: "Evolving API", version: "1.0.0" }, | |
| 29 | + paths: { | |
| 30 | + "/ping": { | |
| 31 | + get: { | |
| 32 | + operationId: "ping", | |
| 33 | + summary: "Return a pong", | |
| 34 | + responses: { "200": { description: "pong" } }, | |
| 35 | + }, | |
| 36 | + }, | |
| 37 | + "/legacy": { | |
| 38 | + get: { | |
| 39 | + operationId: "legacyOp", | |
| 40 | + summary: "Soon to be removed", | |
| 41 | + responses: { "200": { description: "ok" } }, | |
| 42 | + }, | |
| 43 | + }, | |
| 44 | + }, | |
| 45 | +}); | |
| 46 | + | |
| 47 | +const specV2 = JSON.stringify({ | |
| 48 | + openapi: "3.0.3", | |
| 49 | + info: { title: "Evolving API", version: "2.0.0" }, | |
| 50 | + paths: { | |
| 51 | + "/ping": { | |
| 52 | + get: { | |
| 53 | + operationId: "ping", | |
| 54 | + summary: "Return a pong", | |
| 55 | + responses: { "200": { description: "pong" } }, | |
| 56 | + }, | |
| 57 | + }, | |
| 58 | + "/widgets": { | |
| 59 | + get: { | |
| 60 | + operationId: "listWidgets", | |
| 61 | + summary: "List widgets", | |
| 62 | + responses: { "200": { description: "widgets" } }, | |
| 63 | + }, | |
| 64 | + }, | |
| 65 | + }, | |
| 66 | +}); | |
| 67 | + | |
| 68 | +/** A real 127.0.0.1 server whose served spec can be swapped mid-scenario. */ | |
| 69 | +const serveMutableSpec = (initial: string) => | |
| 70 | + Effect.acquireRelease( | |
| 71 | + Effect.callback<{ | |
| 72 | + readonly url: string; | |
| 73 | + readonly setBody: (body: string) => void; | |
| 74 | + readonly close: () => void; | |
| 75 | + }>((resume) => { | |
| 76 | + let body = initial; | |
| 77 | + const server = createServer((_request, response) => { | |
| 78 | + response.writeHead(200, { "content-type": "application/json" }); | |
| 79 | + response.end(body); | |
| 80 | + }); | |
| 81 | + server.listen(0, "127.0.0.1", () => { | |
| 82 | + const address = server.address(); | |
| 83 | + const port = typeof address === "object" && address ? address.port : 0; | |
| 84 | + resume( | |
| 85 | + Effect.succeed({ | |
| 86 | + url: `http://127.0.0.1:${port}/spec.json`, | |
| 87 | + setBody: (next: string) => { | |
| 88 | + body = next; | |
| 89 | + }, | |
| 90 | + close: () => { | |
| 91 | + server.close(); | |
| 92 | + server.closeAllConnections(); | |
| 93 | + }, | |
| 94 | + }), | |
| 95 | + ); | |
| 96 | + }); | |
| 97 | + }), | |
| 98 | + (server) => Effect.sync(server.close), | |
| 99 | + ); | |
| 100 | + | |
| 101 | +scenario( | |
| 102 | + "OpenAPI · updating the spec rebuilds tools without re-adding the integration", | |
| 103 | + {}, | |
| 104 | + Effect.scoped( | |
| 105 | + Effect.gen(function* () { | |
| 106 | + const target = yield* Target; | |
| 107 | + const { client } = yield* Api; | |
| 108 | + const identity = yield* target.newIdentity(); | |
| 109 | + const apiClient = yield* client(api, identity); | |
| 110 | + | |
| 111 | + const slug = `update-spec-${randomBytes(4).toString("hex")}`; | |
| 112 | + const specServer = yield* serveMutableSpec(specV1); | |
| 113 | + | |
| 114 | + yield* Effect.ensuring( | |
| 115 | + Effect.gen(function* () { | |
| 116 | + const added = yield* apiClient.openapi.addSpec({ | |
| 117 | + payload: { | |
| 118 | + spec: { kind: "url", url: specServer.url }, | |
| 119 | + slug, | |
| 120 | + baseUrl: "http://127.0.0.1:59999", // tools are never invoked here | |
| 121 | + description: "Curated description that must survive the update.", | |
| 122 | + authenticationTemplate: [ | |
| 123 | + { | |
| 124 | + slug: "apiKey", | |
| 125 | + type: "apiKey", | |
| 126 | + headers: { "x-api-key": [{ type: "variable", name: "token" }] }, | |
| 127 | + }, | |
| 128 | + ], | |
| 129 | + }, | |
| 130 | + }); | |
| 131 | + expect(added.toolCount, "v1 spec has two operations").toBe(2); | |
| 132 | + | |
| 133 | + const providers = yield* apiClient.providers.list(); | |
| 134 | + yield* apiClient.connections.create({ | |
| 135 | + payload: { | |
| 136 | + owner: "org", | |
| 137 | + name: ConnectionName.make("main"), | |
| 138 | + integration: IntegrationSlug.make(slug), | |
| 139 | + template: AuthTemplateSlug.make("apiKey"), | |
| 140 | + from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) }, | |
| 141 | + }, | |
| 142 | + }); | |
| 143 | + const toolNames = (filter: { integration: string }) => | |
| 144 | + Effect.map( | |
| 145 | + apiClient.tools.list({ | |
| 146 | + query: { integration: IntegrationSlug.make(filter.integration) }, | |
| 147 | + }), | |
| 148 | + (tools) => tools.map((tool) => tool.name).sort(), | |
| 149 | + ); | |
| 150 | + expect(yield* toolNames({ integration: slug }), "v1 tools are live").toEqual([ | |
| 151 | + "legacy.legacyOp", | |
| 152 | + "ping.getOperation", | |
| 153 | + ]); | |
| 154 | + | |
| 155 | + // The upstream API ships v2: legacyOp is gone, listWidgets appears. | |
| 156 | + specServer.setBody(specV2); | |
| 157 | + const updated = yield* apiClient.openapi.updateSpec({ | |
| 158 | + params: { slug }, | |
| 159 | + payload: {}, | |
| 160 | + }); | |
| 161 | + | |
| 162 | + expect(updated.addedTools, "the diff names the new tool").toEqual([ | |
| 163 | + "widgets.listWidgets", | |
| 164 | + ]); | |
| 165 | + expect(updated.removedTools, "the diff names the removed tool").toEqual([ | |
| 166 | + "legacy.legacyOp", | |
| 167 | + ]); | |
| 168 | + | |
| 169 | + // The connection's catalog follows the new spec — same connection, | |
| 170 | + // no re-add. | |
| 171 | + expect(yield* toolNames({ integration: slug }), "v2 tools are live").toEqual([ | |
| 172 | + "ping.getOperation", | |
| 173 | + "widgets.listWidgets", | |
| 174 | + ]); | |
| 175 | + const connections = yield* apiClient.connections.list({ | |
| 176 | + query: { integration: IntegrationSlug.make(slug) }, | |
| 177 | + }); | |
| 178 | + expect( | |
| 179 | + connections.map((connection) => String(connection.name)), | |
| 180 | + "the connection survived", | |
| 181 | + ).toEqual(["main"]); | |
| 182 | + const integration = yield* apiClient.integrations.get({ | |
| 183 | + params: { slug: IntegrationSlug.make(slug) }, | |
| 184 | + }); | |
| 185 | + expect(integration.description, "the curated description survived").toBe( | |
| 186 | + "Curated description that must survive the update.", | |
| 187 | + ); | |
| 188 | + }), | |
| 189 | + Effect.gen(function* () { | |
| 190 | + yield* apiClient.connections | |
| 191 | + .remove({ | |
| 192 | + params: { | |
| 193 | + owner: "org", | |
| 194 | + integration: IntegrationSlug.make(slug), | |
| 195 | + name: ConnectionName.make("main"), | |
| 196 | + }, | |
| 197 | + }) | |
| 198 | + .pipe(Effect.ignore); | |
| 199 | + yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); | |
| 200 | + }), | |
| 201 | + ); | |
| 202 | + }), | |
| 203 | + ), | |
| 204 | +); | |
| @@ -0,0 +1,545 @@ | ||
| 1 | +// Cross-target: the agent-visible description surface, captured as a | |
| 2 | +// reviewable artifact. Registers description-rich OpenAPI and GraphQL | |
| 3 | +// fixtures (every description channel a spec author can use: operation | |
| 4 | +// summary/description, parameter descriptions, body property descriptions, | |
| 5 | +// response descriptions, GraphQL field/arg/type docstrings), then dumps what | |
| 6 | +// an agent actually sees — tools.list entries and the tools.schema view | |
| 7 | +// (the same compiled TypeScript previews `tools.describe.tool()` returns in | |
| 8 | +// the sandbox) — into `descriptions.md` / `descriptions.json` in the run dir. | |
| 9 | +// | |
| 10 | +// The artifact is the point: change anything in the spec→tool description | |
| 11 | +// pipeline, run this scenario, and read (or diff) one file instead of | |
| 12 | +// spinning up an app and clicking around. Fixture slugs are randomized for | |
| 13 | +// catalog isolation but normalized back out of the artifact so two runs of | |
| 14 | +// the same code produce identical files. | |
| 15 | +import { randomBytes, randomUUID } from "node:crypto"; | |
| 16 | +import { writeFileSync } from "node:fs"; | |
| 17 | +import { join } from "node:path"; | |
| 18 | + | |
| 19 | +import { expect } from "@effect/vitest"; | |
| 20 | +import { Effect } from "effect"; | |
| 21 | +import { buildSchema, introspectionFromSchema } from "graphql"; | |
| 22 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 23 | +import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | |
| 24 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 25 | +import { | |
| 26 | + AuthTemplateSlug, | |
| 27 | + ConnectionName, | |
| 28 | + IntegrationSlug, | |
| 29 | + ProviderItemId, | |
| 30 | +} from "@executor-js/sdk/shared"; | |
| 31 | + | |
| 32 | +import { scenario } from "../src/scenario"; | |
| 33 | +import { Api, Mcp, RunDir, Target } from "../src/services"; | |
| 34 | + | |
| 35 | +const api = composePluginApi([openApiHttpPlugin(), graphqlHttpPlugin()] as const); | |
| 36 | + | |
| 37 | +// --------------------------------------------------------------------------- | |
| 38 | +// Fixtures — every description channel populated, so a drop anywhere in the | |
| 39 | +// pipeline is visible as "the fixture says X, the artifact doesn't". | |
| 40 | +// --------------------------------------------------------------------------- | |
| 41 | + | |
| 42 | +const ordersOpenApiSpec = (baseUrl: string): string => | |
| 43 | + JSON.stringify({ | |
| 44 | + openapi: "3.0.3", | |
| 45 | + info: { | |
| 46 | + title: "Orders API", | |
| 47 | + version: "1.0.0", | |
| 48 | + description: "A fixture API exercising every OpenAPI description channel.", | |
| 49 | + }, | |
| 50 | + servers: [{ url: baseUrl }], | |
| 51 | + paths: { | |
| 52 | + "/orders/{orderId}": { | |
| 53 | + get: { | |
| 54 | + operationId: "getOrder", | |
| 55 | + summary: "Fetch a single order", | |
| 56 | + description: | |
| 57 | + "Fetch one order by id, including line items and the current fulfillment status.", | |
| 58 | + parameters: [ | |
| 59 | + { | |
| 60 | + name: "orderId", | |
| 61 | + in: "path", | |
| 62 | + required: true, | |
| 63 | + description: "Unique order identifier (ULID).", | |
| 64 | + schema: { type: "string" }, | |
| 65 | + }, | |
| 66 | + { | |
| 67 | + name: "include", | |
| 68 | + in: "query", | |
| 69 | + description: "Related records to embed in the response.", | |
| 70 | + schema: { type: "string", enum: ["items", "customer", "shipments"] }, | |
| 71 | + }, | |
| 72 | + ], | |
| 73 | + responses: { | |
| 74 | + "200": { | |
| 75 | + description: "The order, with any requested embeds.", | |
| 76 | + content: { | |
| 77 | + "application/json": { | |
| 78 | + schema: { | |
| 79 | + type: "object", | |
| 80 | + properties: { | |
| 81 | + id: { type: "string", description: "Unique order identifier (ULID)." }, | |
| 82 | + status: { | |
| 83 | + type: "string", | |
| 84 | + description: "Current fulfillment status.", | |
| 85 | + enum: ["pending", "shipped", "delivered"], | |
| 86 | + }, | |
| 87 | + total: { | |
| 88 | + type: "number", | |
| 89 | + description: "Order total in minor currency units (cents).", | |
| 90 | + }, | |
| 91 | + }, | |
| 92 | + }, | |
| 93 | + }, | |
| 94 | + }, | |
| 95 | + }, | |
| 96 | + }, | |
| 97 | + }, | |
| 98 | + }, | |
| 99 | + "/orders": { | |
| 100 | + post: { | |
| 101 | + operationId: "createOrder", | |
| 102 | + summary: "Create an order", | |
| 103 | + // No `description` on purpose: the tool description must fall back | |
| 104 | + // to the summary, and the artifact shows which one won. | |
| 105 | + requestBody: { | |
| 106 | + required: true, | |
| 107 | + description: "The order to create.", | |
| 108 | + content: { | |
| 109 | + "application/json": { | |
| 110 | + schema: { | |
| 111 | + type: "object", | |
| 112 | + required: ["items"], | |
| 113 | + properties: { | |
| 114 | + items: { | |
| 115 | + type: "array", | |
| 116 | + description: "Line items to order.", | |
| 117 | + items: { | |
| 118 | + type: "object", | |
| 119 | + properties: { | |
| 120 | + sku: { type: "string", description: "Product SKU." }, | |
| 121 | + quantity: { | |
| 122 | + type: "integer", | |
| 123 | + description: "How many units of this SKU.", | |
| 124 | + }, | |
| 125 | + }, | |
| 126 | + }, | |
| 127 | + }, | |
| 128 | + note: { | |
| 129 | + type: "string", | |
| 130 | + description: "Free-form note shown to the warehouse packer.", | |
| 131 | + }, | |
| 132 | + }, | |
| 133 | + }, | |
| 134 | + }, | |
| 135 | + }, | |
| 136 | + }, | |
| 137 | + responses: { "201": { description: "The created order." } }, | |
| 138 | + }, | |
| 139 | + }, | |
| 140 | + }, | |
| 141 | + }); | |
| 142 | + | |
| 143 | +const ordersGraphqlSdl = /* GraphQL */ ` | |
| 144 | + """ | |
| 145 | + A customer order. | |
| 146 | + """ | |
| 147 | + type Order { | |
| 148 | + """ | |
| 149 | + Unique order identifier (ULID). | |
| 150 | + """ | |
| 151 | + id: ID! | |
| 152 | + | |
| 153 | + """ | |
| 154 | + Current fulfillment status. | |
| 155 | + """ | |
| 156 | + status: String! | |
| 157 | + } | |
| 158 | + | |
| 159 | + type Query { | |
| 160 | + """ | |
| 161 | + Fetch one order by id, including the current fulfillment status. | |
| 162 | + """ | |
| 163 | + order( | |
| 164 | + """ | |
| 165 | + Unique order identifier (ULID). | |
| 166 | + """ | |
| 167 | + id: ID! | |
| 168 | + ): Order | |
| 169 | + } | |
| 170 | + | |
| 171 | + type Mutation { | |
| 172 | + """ | |
| 173 | + Cancel an order that has not shipped yet. | |
| 174 | + """ | |
| 175 | + cancelOrder( | |
| 176 | + """ | |
| 177 | + Unique order identifier (ULID). | |
| 178 | + """ | |
| 179 | + id: ID! | |
| 180 | + """ | |
| 181 | + Human-readable reason, shown to the customer. | |
| 182 | + """ | |
| 183 | + reason: String | |
| 184 | + ): Order | |
| 185 | + } | |
| 186 | +`; | |
| 187 | + | |
| 188 | +const ordersIntrospectionJson = (): string => | |
| 189 | + JSON.stringify({ data: introspectionFromSchema(buildSchema(ordersGraphqlSdl)) }); | |
| 190 | + | |
| 191 | +// --------------------------------------------------------------------------- | |
| 192 | +// Artifact rendering | |
| 193 | +// --------------------------------------------------------------------------- | |
| 194 | + | |
| 195 | +interface ToolSnapshot { | |
| 196 | + readonly name: string; | |
| 197 | + readonly address: string; | |
| 198 | + readonly listDescription: string; | |
| 199 | + readonly schemaDescription?: string; | |
| 200 | + readonly inputTypeScript?: string; | |
| 201 | + readonly outputTypeScript?: string; | |
| 202 | + readonly typeScriptDefinitions?: Record<string, string>; | |
| 203 | + readonly inputSchema?: unknown; | |
| 204 | +} | |
| 205 | + | |
| 206 | +const codeBlock = (lang: string, body: string): string => `\`\`\`${lang}\n${body}\n\`\`\``; | |
| 207 | + | |
| 208 | +const renderTool = (tool: ToolSnapshot): string => { | |
| 209 | + const lines: string[] = [`### \`${tool.name}\``, "", `> ${tool.listDescription || "(empty)"}`]; | |
| 210 | + if (tool.schemaDescription && tool.schemaDescription !== tool.listDescription) { | |
| 211 | + lines.push("", `tools.schema description differs:`, "", `> ${tool.schemaDescription}`); | |
| 212 | + } | |
| 213 | + if (tool.inputTypeScript) { | |
| 214 | + lines.push("", "**Input**", "", codeBlock("ts", tool.inputTypeScript)); | |
| 215 | + } | |
| 216 | + if (tool.outputTypeScript) { | |
| 217 | + lines.push("", "**Output**", "", codeBlock("ts", tool.outputTypeScript)); | |
| 218 | + } | |
| 219 | + const definitions = Object.entries(tool.typeScriptDefinitions ?? {}); | |
| 220 | + if (definitions.length > 0) { | |
| 221 | + lines.push("", "**Definitions**", ""); | |
| 222 | + for (const [name, body] of definitions) { | |
| 223 | + lines.push(codeBlock("ts", `type ${name} = ${body}`)); | |
| 224 | + } | |
| 225 | + } | |
| 226 | + if (tool.inputSchema !== undefined) { | |
| 227 | + lines.push( | |
| 228 | + "", | |
| 229 | + "<details><summary>Raw inputSchema</summary>", | |
| 230 | + "", | |
| 231 | + codeBlock("json", JSON.stringify(tool.inputSchema, null, 2)), | |
| 232 | + "", | |
| 233 | + "</details>", | |
| 234 | + ); | |
| 235 | + } | |
| 236 | + return lines.join("\n"); | |
| 237 | +}; | |
| 238 | + | |
| 239 | +scenario( | |
| 240 | + "Tools · agent-visible descriptions snapshot", | |
| 241 | + {}, | |
| 242 | + Effect.gen(function* () { | |
| 243 | + const target = yield* Target; | |
| 244 | + const { client } = yield* Api; | |
| 245 | + const mcp = yield* Mcp; | |
| 246 | + const runDir = yield* RunDir; | |
| 247 | + const identity = yield* target.newIdentity(); | |
| 248 | + const apiClient = yield* client(api, identity); | |
| 249 | + | |
| 250 | + // Unique slugs per run keep repeated/parallel runs out of each other's | |
| 251 | + // catalogs (selfhost shares the bootstrap-admin identity). | |
| 252 | + const nonce = randomBytes(4).toString("hex"); | |
| 253 | + const openapiSlug = `descr-openapi-${nonce}`; | |
| 254 | + const graphqlSlug = `descr-graphql-${nonce}`; | |
| 255 | + const specBaseUrl = "http://127.0.0.1:59999"; // never contacted | |
| 256 | + | |
| 257 | + const apiKeyTemplate = [ | |
| 258 | + { | |
| 259 | + slug: "apiKey", | |
| 260 | + type: "apiKey", | |
| 261 | + headers: { "x-api-key": [{ type: "variable", name: "token" }] }, | |
| 262 | + }, | |
| 263 | + ] as const; | |
| 264 | + | |
| 265 | + const connect = (slug: string) => | |
| 266 | + Effect.gen(function* () { | |
| 267 | + const providers = yield* apiClient.providers.list(); | |
| 268 | + yield* apiClient.connections.create({ | |
| 269 | + payload: { | |
| 270 | + owner: "org", | |
| 271 | + name: ConnectionName.make("main"), | |
| 272 | + integration: IntegrationSlug.make(slug), | |
| 273 | + template: AuthTemplateSlug.make("apiKey"), | |
| 274 | + from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) }, | |
| 275 | + }, | |
| 276 | + }); | |
| 277 | + }); | |
| 278 | + | |
| 279 | + const cleanup = (slug: string) => | |
| 280 | + Effect.gen(function* () { | |
| 281 | + yield* apiClient.connections | |
| 282 | + .remove({ | |
| 283 | + params: { | |
| 284 | + owner: "org", | |
| 285 | + integration: IntegrationSlug.make(slug), | |
| 286 | + name: ConnectionName.make("main"), | |
| 287 | + }, | |
| 288 | + }) | |
| 289 | + .pipe(Effect.ignore); | |
| 290 | + yield* apiClient.integrations | |
| 291 | + .remove({ params: { slug: IntegrationSlug.make(slug) } }) | |
| 292 | + .pipe(Effect.ignore); | |
| 293 | + }); | |
| 294 | + | |
| 295 | + yield* Effect.ensuring( | |
| 296 | + Effect.gen(function* () { | |
| 297 | + const added = yield* apiClient.openapi.addSpec({ | |
| 298 | + payload: { | |
| 299 | + spec: { kind: "blob", value: ordersOpenApiSpec(specBaseUrl) }, | |
| 300 | + slug: openapiSlug, | |
| 301 | + baseUrl: specBaseUrl, | |
| 302 | + authenticationTemplate: apiKeyTemplate, | |
| 303 | + }, | |
| 304 | + }); | |
| 305 | + expect(added.toolCount, "the OpenAPI fixture's operations became tools").toBe(2); | |
| 306 | + | |
| 307 | + // Description set AT ADD (the add form's field) — no PATCH needed. | |
| 308 | + yield* apiClient.graphql.addIntegration({ | |
| 309 | + payload: { | |
| 310 | + endpoint: `${specBaseUrl}/graphql`, | |
| 311 | + slug: graphqlSlug, | |
| 312 | + description: "Order management over GraphQL.", | |
| 313 | + introspectionJson: ordersIntrospectionJson(), | |
| 314 | + authenticationTemplate: apiKeyTemplate, | |
| 315 | + }, | |
| 316 | + }); | |
| 317 | + | |
| 318 | + yield* connect(openapiSlug); | |
| 319 | + yield* connect(graphqlSlug); | |
| 320 | + | |
| 321 | + // Add-time prefill: with neither passed, the spec's title becomes the | |
| 322 | + // display name and its `info.description` the description. | |
| 323 | + const openapiIntegration = yield* apiClient.integrations.get({ | |
| 324 | + params: { slug: IntegrationSlug.make(openapiSlug) }, | |
| 325 | + }); | |
| 326 | + expect(openapiIntegration.name, "the spec title prefills the name").toBe("Orders API"); | |
| 327 | + expect( | |
| 328 | + openapiIntegration.description, | |
| 329 | + "the spec's info.description prefills the description", | |
| 330 | + ).toBe("A fixture API exercising every OpenAPI description channel."); | |
| 331 | + | |
| 332 | + // Post-add curation the way the console's edit sheets do: a | |
| 333 | + // connection-level description on the OpenAPI connection (its prefix | |
| 334 | + // line shows it; the GraphQL connection has none, so its line falls | |
| 335 | + // back to the integration description set at add). | |
| 336 | + yield* apiClient.connections.update({ | |
| 337 | + params: { | |
| 338 | + owner: "org", | |
| 339 | + integration: IntegrationSlug.make(openapiSlug), | |
| 340 | + name: ConnectionName.make("main"), | |
| 341 | + }, | |
| 342 | + payload: { description: "Staging orders — safe to create test orders." }, | |
| 343 | + }); | |
| 344 | + | |
| 345 | + // The agent-visible surface: catalog entry + schema view (the same | |
| 346 | + // data `tools.search()` / `tools.describe.tool()` serve the sandbox). | |
| 347 | + const snapshotFor = (slug: string) => | |
| 348 | + Effect.gen(function* () { | |
| 349 | + const tools = yield* apiClient.tools.list({ | |
| 350 | + query: { integration: IntegrationSlug.make(slug) }, | |
| 351 | + }); | |
| 352 | + return yield* Effect.forEach( | |
| 353 | + [...tools].sort((a, b) => a.name.localeCompare(b.name)), | |
| 354 | + (tool) => | |
| 355 | + Effect.gen(function* () { | |
| 356 | + const schema = yield* apiClient.tools.schema({ | |
| 357 | + query: { address: tool.address }, | |
| 358 | + }); | |
| 359 | + return { | |
| 360 | + name: tool.name, | |
| 361 | + address: String(tool.address), | |
| 362 | + listDescription: tool.description, | |
| 363 | + schemaDescription: schema.description, | |
| 364 | + inputTypeScript: schema.inputTypeScript, | |
| 365 | + outputTypeScript: schema.outputTypeScript, | |
| 366 | + typeScriptDefinitions: schema.typeScriptDefinitions, | |
| 367 | + inputSchema: schema.inputSchema, | |
| 368 | + } satisfies ToolSnapshot; | |
| 369 | + }), | |
| 370 | + ); | |
| 371 | + }); | |
| 372 | + | |
| 373 | + const openapiTools = yield* snapshotFor(openapiSlug); | |
| 374 | + const graphqlTools = yield* snapshotFor(graphqlSlug); | |
| 375 | + | |
| 376 | + // The execute tool's description over the real MCP surface — the | |
| 377 | + // connection-prefix inventory an MCP client (and its model) reads. | |
| 378 | + // Only this run's lines: the shared selfhost admin may have other | |
| 379 | + // connections in the inventory. | |
| 380 | + const readInventory = () => | |
| 381 | + Effect.map(mcp.session(identity).describeTools(), (mcpTools) => | |
| 382 | + (mcpTools.find((tool) => tool.name === "execute")?.description ?? "") | |
| 383 | + .split("## Available connection prefixes")[1] | |
| 384 | + ?.split("\n") | |
| 385 | + .filter( | |
| 386 | + (line) => | |
| 387 | + line.startsWith("- ") && | |
| 388 | + (line.includes(openapiSlug) || line.includes(graphqlSlug)), | |
| 389 | + ) | |
| 390 | + .join("\n"), | |
| 391 | + ); | |
| 392 | + const inventory = yield* readInventory(); | |
| 393 | + | |
| 394 | + // Normalize the per-run randomness so artifacts diff cleanly across | |
| 395 | + // runs: slugs become stable tokens, the owner segment a placeholder. | |
| 396 | + const owner = (yield* apiClient.tools.list({ query: {} })) | |
| 397 | + .map((tool) => String(tool.owner)) | |
| 398 | + .find((value) => value.length > 0); | |
| 399 | + const normalize = (text: string): string => { | |
| 400 | + let out = text | |
| 401 | + .replaceAll(openapiSlug, "openapi-fixture") | |
| 402 | + .replaceAll(graphqlSlug, "graphql-fixture"); | |
| 403 | + if (owner) out = out.replaceAll(owner, "<owner>"); | |
| 404 | + return out; | |
| 405 | + }; | |
| 406 | + | |
| 407 | + const sections = [ | |
| 408 | + ["OpenAPI fixture (`Orders API`)", openapiTools], | |
| 409 | + ["GraphQL fixture (orders schema)", graphqlTools], | |
| 410 | + ] as const; | |
| 411 | + const markdown = normalize( | |
| 412 | + [ | |
| 413 | + `# Agent-visible tool descriptions — ${target.name}`, | |
| 414 | + "", | |
| 415 | + "What an agent sees for tools derived from description-rich fixtures:", | |
| 416 | + "the `tools.list` catalog entry (quoted) and the `tools.schema` view —", | |
| 417 | + "the same compiled TypeScript previews `tools.describe.tool()` returns", | |
| 418 | + "inside the sandbox. Anything written in the fixture sources (bottom)", | |
| 419 | + "but absent here was dropped by the spec→tool pipeline.", | |
| 420 | + "", | |
| 421 | + ...sections.flatMap(([title, tools]) => [ | |
| 422 | + `## ${title}`, | |
| 423 | + "", | |
| 424 | + tools.map(renderTool).join("\n\n"), | |
| 425 | + "", | |
| 426 | + ]), | |
| 427 | + "## Execute-tool inventory (over MCP)", | |
| 428 | + "", | |
| 429 | + "The connection-prefix lines from the `execute` tool's description,", | |
| 430 | + "as an MCP client reads them. Connection descriptions ride their", | |
| 431 | + "prefix; a connection without one falls back to its integration's.", | |
| 432 | + "", | |
| 433 | + codeBlock("md", inventory ?? "(no inventory section found)"), | |
| 434 | + "", | |
| 435 | + "## Fixture sources", | |
| 436 | + "", | |
| 437 | + "<details><summary>OpenAPI spec</summary>", | |
| 438 | + "", | |
| 439 | + codeBlock("json", JSON.stringify(JSON.parse(ordersOpenApiSpec(specBaseUrl)), null, 2)), | |
| 440 | + "", | |
| 441 | + "</details>", | |
| 442 | + "", | |
| 443 | + "<details><summary>GraphQL SDL</summary>", | |
| 444 | + "", | |
| 445 | + codeBlock("graphql", ordersGraphqlSdl.trim()), | |
| 446 | + "", | |
| 447 | + "</details>", | |
| 448 | + "", | |
| 449 | + ].join("\n"), | |
| 450 | + ); | |
| 451 | + writeFileSync(join(runDir, "descriptions.md"), markdown); | |
| 452 | + writeFileSync( | |
| 453 | + join(runDir, "descriptions.json"), | |
| 454 | + normalize( | |
| 455 | + JSON.stringify( | |
| 456 | + { | |
| 457 | + target: target.name, | |
| 458 | + openapi: openapiTools, | |
| 459 | + graphql: graphqlTools, | |
| 460 | + executeInventory: inventory ?? null, | |
| 461 | + }, | |
| 462 | + null, | |
| 463 | + 2, | |
| 464 | + ), | |
| 465 | + ), | |
| 466 | + ); | |
| 467 | + | |
| 468 | + // Lock the channels that flow today; the artifact is the review | |
| 469 | + // surface for the ones that don't (yet). | |
| 470 | + const byName = (tools: readonly ToolSnapshot[], name: string) => | |
| 471 | + tools.find((tool) => tool.name === name); | |
| 472 | + | |
| 473 | + const getOrder = byName(openapiTools, "orders.getOrder"); | |
| 474 | + expect(getOrder?.listDescription, "operation description reaches the tool").toBe( | |
| 475 | + "Fetch one order by id, including line items and the current fulfillment status.", | |
| 476 | + ); | |
| 477 | + const createOrder = byName(openapiTools, "orders.createOrder"); | |
| 478 | + expect(createOrder?.listDescription, "summary is the fallback description").toBe( | |
| 479 | + "Create an order", | |
| 480 | + ); | |
| 481 | + expect(getOrder?.inputTypeScript, "input shape is compiled to TypeScript").toContain( | |
| 482 | + "orderId", | |
| 483 | + ); | |
| 484 | + | |
| 485 | + const orderQuery = byName(graphqlTools, "query.order"); | |
| 486 | + expect(orderQuery?.listDescription, "GraphQL field docstring reaches the tool").toBe( | |
| 487 | + "Fetch one order by id, including the current fulfillment status.", | |
| 488 | + ); | |
| 489 | + const cancelOrder = byName(graphqlTools, "mutation.cancelOrder"); | |
| 490 | + expect(cancelOrder?.inputTypeScript, "GraphQL args are compiled to TypeScript").toContain( | |
| 491 | + "reason", | |
| 492 | + ); | |
| 493 | + | |
| 494 | + // The curated descriptions reach the model: the connection's own | |
| 495 | + // description rides its prefix line; the connection without one falls | |
| 496 | + // back to its integration's description. | |
| 497 | + expect(inventory, "connection description reaches the MCP inventory").toContain( | |
| 498 | + `- \`${openapiSlug}.org.main\` — Staging orders — safe to create test orders.`, | |
| 499 | + ); | |
| 500 | + expect(inventory, "integration description is the fallback").toContain( | |
| 501 | + `- \`${graphqlSlug}.org.main\` — Order management over GraphQL.`, | |
| 502 | + ); | |
| 503 | + | |
| 504 | + // EDIT PROPAGATION — the loop the edit sheets exist for: an agent has | |
| 505 | + // already read the inventory above; the user now edits both | |
| 506 | + // descriptions (the exact PATCHes the sheets make); a NEW agent | |
| 507 | + // session must see the new text. (Within one session the execute | |
| 508 | + // description is computed at session build and stays as-is — the | |
| 509 | + // re-read below is a fresh session, which is also what a reconnecting | |
| 510 | + // client gets.) | |
| 511 | + yield* apiClient.connections.update({ | |
| 512 | + params: { | |
| 513 | + owner: "org", | |
| 514 | + integration: IntegrationSlug.make(openapiSlug), | |
| 515 | + name: ConnectionName.make("main"), | |
| 516 | + }, | |
| 517 | + payload: { description: "EDITED: production orders — do not create test data." }, | |
| 518 | + }); | |
| 519 | + yield* apiClient.integrations.update({ | |
| 520 | + params: { slug: IntegrationSlug.make(graphqlSlug) }, | |
| 521 | + payload: { description: "EDITED: order admin over GraphQL." }, | |
| 522 | + }); | |
| 523 | + | |
| 524 | + const inventoryAfterEdit = yield* readInventory(); | |
| 525 | + expect( | |
| 526 | + inventoryAfterEdit, | |
| 527 | + "an edited connection description reaches a fresh agent session", | |
| 528 | + ).toContain( | |
| 529 | + `- \`${openapiSlug}.org.main\` — EDITED: production orders — do not create test data.`, | |
| 530 | + ); | |
| 531 | + expect( | |
| 532 | + inventoryAfterEdit, | |
| 533 | + "an edited integration description reaches a fresh agent session", | |
| 534 | + ).toContain(`- \`${graphqlSlug}.org.main\` — EDITED: order admin over GraphQL.`); | |
| 535 | + expect(inventoryAfterEdit, "the pre-edit connection text is gone").not.toContain( | |
| 536 | + "Staging orders", | |
| 537 | + ); | |
| 538 | + }), | |
| 539 | + Effect.gen(function* () { | |
| 540 | + yield* cleanup(openapiSlug); | |
| 541 | + yield* cleanup(graphqlSlug); | |
| 542 | + }), | |
| 543 | + ); | |
| 544 | + }), | |
| 545 | +); | |
| @@ -95,6 +95,11 @@ export interface McpCallResult { · 94 unmodified lines | ||
| 95 | 95 | readonly ok: boolean; |
| 96 | 96 | } |
| 97 | 97 | |
| 98 | +export interface McpToolDef { | |
| 99 | + readonly name: string; | |
| 100 | + readonly description: string; | |
| 101 | +} | |
| 102 | + | |
| 98 | 103 | /** How a connection surfaces a paused (approval-gated) execution. `browser` is |
| 99 | 104 | * what the browser-approval scenarios drive: the pause yields an `approvalUrl` |
| 100 | 105 | * for a human to open instead of letting the model resume inline. */ |
| @@ -133,6 +138,9 @@ export const parseBrowserApproval = (result: McpCallResult): McpBrowserApproval · 32 unmodified lines | ||
| 133 | 138 | |
| 134 | 139 | export interface McpSession { |
| 135 | 140 | readonly listTools: () => Effect.Effect<ReadonlyArray<string>>; |
| 141 | + /** Full advertised tool definitions — for asserting on the description text | |
| 142 | + * an MCP client actually reads (e.g. the execute tool's inventory). */ | |
| 143 | + readonly describeTools: () => Effect.Effect<ReadonlyArray<McpToolDef>>; | |
| 136 | 144 | readonly call: (name: string, args?: Record<string, unknown>) => Effect.Effect<McpCallResult>; |
| 137 | 145 | /** Find the paused executionId in `text` and resume it with approval. */ |
| 138 | 146 | readonly approvePaused: ( |
| @@ -298,6 +306,16 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 159 unmodified lines | ||
| 298 | 306 | return defs.map((tool: { name: string }) => tool.name); |
| 299 | 307 | }); |
| 300 | 308 | |
| 309 | + const describeTools = () => | |
| 310 | + Effect.promise(async (): Promise<ReadonlyArray<McpToolDef>> => { | |
| 311 | + const defs = await (await runtime()).listTools(serverName, callOptions); | |
| 312 | + connected = true; | |
| 313 | + return defs.map((tool: { name: string; description?: string }) => ({ | |
| 314 | + name: tool.name, | |
| 315 | + description: tool.description ?? "", | |
| 316 | + })); | |
| 317 | + }); | |
| 318 | + | |
| 301 | 319 | const call = (name: string, args: Record<string, unknown> = {}) => |
| 302 | 320 | Effect.promise(async (): Promise<McpCallResult> => { |
| 303 | 321 | if (!connected) { |
| @@ -311,6 +329,7 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 7 unmodified lines | ||
| 311 | 329 | |
| 312 | 330 | return { |
| 313 | 331 | listTools, |
| 332 | + describeTools, | |
| 314 | 333 | call, |
| 315 | 334 | approvePaused: (text, content = {}) => |
| 316 | 335 | Effect.suspend(() => { |
| @@ -295,7 +295,7 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { · 294 unmodified lines | ||
| 295 | 295 | <div className="flex flex-col gap-px"> |
| 296 | 296 | {value.map((integration: Integration) => { |
| 297 | 297 | const slug = String(integration.slug); |
| 298 | − const name = integration.description || slug; | |
| 298 | + const name = integration.name || slug; | |
| 299 | 299 | const detailPath = `/integrations/${slug}`; |
| 300 | 300 | const active = |
| 301 | 301 | props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); |
| @@ -48,6 +48,7 @@ const ConnectionResponse = Schema.Struct({ · 47 unmodified lines | ||
| 48 | 48 | provider: ProviderKey, |
| 49 | 49 | address: ConnectionAddress, |
| 50 | 50 | identityLabel: Schema.NullOr(Schema.String), |
| 51 | + description: Schema.NullOr(Schema.String), | |
| 51 | 52 | expiresAt: Schema.NullOr(Schema.Number), |
| 52 | 53 | // The OAuth app that minted this connection (its `oauth_client` slug), or null |
| 53 | 54 | // for static credentials. Lets the UI map a connection back to its app. Just a |
| @@ -80,8 +81,15 @@ const CommonCreateFields = { · 26 unmodified lines | ||
| 80 | 81 | integration: IntegrationSlug, |
| 81 | 82 | template: AuthTemplateSlug, |
| 82 | 83 | identityLabel: Schema.optional(Schema.NullOr(Schema.String)), |
| 84 | + description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 83 | 85 | } as const; |
| 84 | 86 | |
| 87 | +// User-curated metadata edits. Absent field = unchanged; null = cleared. | |
| 88 | +const UpdateConnectionPayload = Schema.Struct({ | |
| 89 | + description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 90 | + identityLabel: Schema.optional(Schema.NullOr(Schema.String)), | |
| 91 | +}); | |
| 92 | + | |
| 85 | 93 | const CreateConnectionPayload = Schema.Struct({ |
| 86 | 94 | ...CommonCreateFields, |
| 87 | 95 | value: Schema.optional(Schema.String), |
| @@ -157,6 +165,14 @@ export const ConnectionsApi = HttpApiGroup.make("connections") · 69 unmodified lines | ||
| 157 | 165 | error: [InternalError, ConnectionNotFound], |
| 158 | 166 | }), |
| 159 | 167 | ) |
| 168 | + .add( | |
| 169 | + HttpApiEndpoint.patch("update", "/connections/:owner/:integration/:name", { | |
| 170 | + params: ConnectionParams, | |
| 171 | + payload: UpdateConnectionPayload, | |
| 172 | + success: ConnectionResponse, | |
| 173 | + error: [InternalError, ConnectionNotFound], | |
| 174 | + }), | |
| 175 | + ) | |
| 160 | 176 | .add( |
| 161 | 177 | HttpApiEndpoint.delete("remove", "/connections/:owner/:integration/:name", { |
| 162 | 178 | params: ConnectionParams, |
| @@ -21,6 +21,7 @@ const toResponse = (c: Connection) => ({ · 20 unmodified lines | ||
| 21 | 21 | provider: c.provider, |
| 22 | 22 | address: c.address, |
| 23 | 23 | identityLabel: c.identityLabel ?? null, |
| 24 | + description: c.description ?? null, | |
| 24 | 25 | expiresAt: c.expiresAt ?? null, |
| 25 | 26 | oauthClient: c.oauthClient ?? null, |
| 26 | 27 | oauthClientOwner: c.oauthClientOwner ?? null, |
| @@ -83,6 +84,27 @@ export const ConnectionsHandlers = HttpApiBuilder.group(ExecutorApi, "connection · 56 unmodified lines | ||
| 83 | 84 | }), |
| 84 | 85 | ), |
| 85 | 86 | ) |
| 87 | + .handle("update", ({ params: path, payload }) => | |
| 88 | + capture( | |
| 89 | + Effect.gen(function* () { | |
| 90 | + const executor = yield* ExecutorService; | |
| 91 | + const updated = yield* executor.connections.update( | |
| 92 | + { | |
| 93 | + owner: path.owner, | |
| 94 | + integration: path.integration, | |
| 95 | + name: path.name, | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + ...(payload.description !== undefined ? { description: payload.description } : {}), | |
| 99 | + ...(payload.identityLabel !== undefined | |
| 100 | + ? { identityLabel: payload.identityLabel } | |
| 101 | + : {}), | |
| 102 | + }, | |
| 103 | + ); | |
| 104 | + return toResponse(updated); | |
| 105 | + }), | |
| 106 | + ), | |
| 107 | + ) | |
| 86 | 108 | .handle("remove", ({ params: path }) => |
| 87 | 109 | capture( |
| 88 | 110 | Effect.gen(function* () { |
| @@ -8,6 +8,7 @@ import { capture } from "@executor-js/api"; · 7 unmodified lines | ||
| 8 | 8 | |
| 9 | 9 | const toResponse = (i: Integration) => ({ |
| 10 | 10 | slug: i.slug, |
| 11 | + name: i.name, | |
| 11 | 12 | description: i.description, |
| 12 | 13 | kind: i.kind, |
| 13 | 14 | canRemove: i.canRemove, |
| @@ -44,7 +45,8 @@ export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrati · 30 unmodified lines | ||
| 44 | 45 | Effect.gen(function* () { |
| 45 | 46 | const executor = yield* ExecutorService; |
| 46 | 47 | yield* executor.integrations.update(path.slug, { |
| 47 | − description: payload.description, | |
| 48 | + ...(payload.name !== undefined ? { name: payload.name } : {}), | |
| 49 | + ...(payload.description !== undefined ? { description: payload.description } : {}), | |
| 48 | 50 | }); |
| 49 | 51 | const integration = yield* executor.integrations.get(path.slug); |
| 50 | 52 | if (integration === null) { |
| @@ -65,6 +65,8 @@ const AuthMethodDescriptorSchema = Schema.Struct({ · 64 unmodified lines | ||
| 65 | 65 | /** Public projection of an integration — mirrors the SDK's `Integration`. */ |
| 66 | 66 | const IntegrationResponse = Schema.Struct({ |
| 67 | 67 | slug: IntegrationSlug, |
| 68 | + /** Display name. */ | |
| 69 | + name: Schema.String, | |
| 68 | 70 | description: Schema.String, |
| 69 | 71 | /** The plugin that owns this integration kind (e.g. "openapi", "mcp"). */ |
| 70 | 72 | kind: Schema.String, |
| @@ -78,6 +80,7 @@ const IntegrationResponse = Schema.Struct({ · 7 unmodified lines | ||
| 78 | 80 | }); |
| 79 | 81 | |
| 80 | 82 | const UpdateIntegrationPayload = Schema.Struct({ |
| 83 | + name: Schema.optional(Schema.String), | |
| 81 | 84 | description: Schema.optional(Schema.String), |
| 82 | 85 | }); |
| 83 | 86 | |
| @@ -61,7 +61,8 @@ const slackPlugin = definePlugin(() => ({ · 60 unmodified lines | ||
| 61 | 61 | seed: () => |
| 62 | 62 | ctx.core.integrations.register({ |
| 63 | 63 | slug: SLACK, |
| 64 | − description: "Slack Workspace", | |
| 64 | + name: "Slack", | |
| 65 | + description: "Send and read workspace messages.", | |
| 65 | 66 | config: {}, |
| 66 | 67 | }), |
| 67 | 68 | }), |
| @@ -98,7 +99,7 @@ describe("buildExecuteDescription", () => { · 30 unmodified lines | ||
| 98 | 99 | expect(description).toContain("- `github.org.prod`"); |
| 99 | 100 | expect(description).toContain("- `github.user.personal`"); |
| 100 | 101 | expect(description).not.toContain("## Available namespaces"); |
| 101 | − expect(description).not.toContain("Slack Workspace"); | |
| 102 | + expect(description).not.toContain("workspace messages"); | |
| 102 | 103 | expect(description).not.toContain("`github-plugin`"); |
| 103 | 104 | expect(description).not.toContain("`slack-plugin`"); |
| 104 | 105 | expect(description).not.toContain("- `github`"); |
| @@ -111,6 +112,60 @@ describe("buildExecuteDescription", () => { · 6 unmodified lines | ||
| 111 | 112 | }), |
| 112 | 113 | ); |
| 113 | 114 | |
| 115 | + it.effect("annotates prefixes with connection or integration descriptions", () => | |
| 116 | + Effect.gen(function* () { | |
| 117 | + const executor = yield* createExecutor( | |
| 118 | + makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }), | |
| 119 | + ); | |
| 120 | + yield* executor["slack-plugin"].seed(); | |
| 121 | + yield* executor["github-plugin"].seed(); | |
| 122 | + yield* executor.connections.create({ | |
| 123 | + owner: "org", | |
| 124 | + name: ConnectionName.make("prod"), | |
| 125 | + integration: GITHUB, | |
| 126 | + template: TEMPLATE, | |
| 127 | + value: "org-token", | |
| 128 | + description: "Production org — issues and PRs only.", | |
| 129 | + }); | |
| 130 | + yield* executor.connections.create({ | |
| 131 | + owner: "user", | |
| 132 | + name: ConnectionName.make("personal"), | |
| 133 | + integration: GITHUB, | |
| 134 | + template: TEMPLATE, | |
| 135 | + value: "user-token", | |
| 136 | + }); | |
| 137 | + | |
| 138 | + const description = yield* buildExecuteDescription(executor); | |
| 139 | + | |
| 140 | + // The curated connection description rides its prefix line. | |
| 141 | + expect(description).toContain("- `github.org.prod` — Production org — issues and PRs only."); | |
| 142 | + // No connection description and the integration description ("GitHub") | |
| 143 | + // just restates the slug — the line stays bare. | |
| 144 | + expect(description).toContain("- `github.user.personal`"); | |
| 145 | + expect(description).not.toContain("- `github.user.personal` —"); | |
| 146 | + }), | |
| 147 | + ); | |
| 148 | + | |
| 149 | + it.effect("falls back to a meaningful integration description", () => | |
| 150 | + Effect.gen(function* () { | |
| 151 | + const executor = yield* createExecutor( | |
| 152 | + makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }), | |
| 153 | + ); | |
| 154 | + yield* executor["slack-plugin"].seed(); | |
| 155 | + yield* executor.connections.create({ | |
| 156 | + owner: "org", | |
| 157 | + name: ConnectionName.make("main"), | |
| 158 | + integration: SLACK, | |
| 159 | + template: TEMPLATE, | |
| 160 | + value: "slack-token", | |
| 161 | + }); | |
| 162 | + | |
| 163 | + const description = yield* buildExecuteDescription(executor); | |
| 164 | + | |
| 165 | + expect(description).toContain("- `slack.org.main` — Send and read workspace messages."); | |
| 166 | + }), | |
| 167 | + ); | |
| 168 | + | |
| 114 | 169 | it.effect("omits the Available connection prefixes section when no connections exist", () => |
| 115 | 170 | Effect.gen(function* () { |
| 116 | 171 | const executor = yield* createExecutor(makeTestConfig({ plugins: [] as const })); |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { Effect } from "effect"; |
| 2 | −import type { Connection, Executor } from "@executor-js/sdk/core"; | |
| 2 | +import type { Connection, Integration, Executor } from "@executor-js/sdk/core"; | |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | 5 | * Builds a tool description dynamically. |
| @@ -19,9 +19,14 @@ export const buildExecuteDescription = (executor: Executor): Effect.Effect<strin · 13 unmodified lines | ||
| 19 | 19 | Effect.orDie, |
| 20 | 20 | Effect.withSpan("executor.connections.list"), |
| 21 | 21 | ); |
| 22 | + const integrations: readonly Integration[] = yield* executor.integrations.list().pipe( | |
| 23 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: same getDescription error-channel constraint as connections.list above | |
| 24 | + Effect.orDie, | |
| 25 | + Effect.withSpan("executor.integrations.list"), | |
| 26 | + ); | |
| 22 | 27 | |
| 23 | 28 | const description = yield* Effect.sync(() => |
| 24 | − formatDescription(connections.map((connection) => connectionPath(connection))), | |
| 29 | + formatDescription(connections.map((connection) => connectionEntry(connection, integrations))), | |
| 25 | 30 | ).pipe( |
| 26 | 31 | Effect.withSpan("schema.compile.description", { |
| 27 | 32 | attributes: { "executor.connection_count": connections.length }, |
| @@ -53,7 +58,44 @@ const connectionPath = (connection: Connection): string => { · 25 unmodified lines | ||
| 53 | 58 | return address.startsWith("tools.") ? address.slice("tools.".length) : address; |
| 54 | 59 | }; |
| 55 | 60 | |
| 56 | −const formatDescription = (connectionPrefixes: readonly string[]): string => { | |
| 61 | +/** One inventory line: the callable prefix plus the best available context. | |
| 62 | + * Connection description wins (the user wrote it about THIS credential); | |
| 63 | + * otherwise the integration description, unless it is just the slug again. */ | |
| 64 | +interface ConnectionInventoryEntry { | |
| 65 | + readonly prefix: string; | |
| 66 | + readonly description?: string; | |
| 67 | +} | |
| 68 | + | |
| 69 | +const inventoryNote = ( | |
| 70 | + text: string | null | undefined, | |
| 71 | + identityEchoes: readonly string[], | |
| 72 | +): string | undefined => { | |
| 73 | + const firstLine = (text ?? "").split("\n", 1)[0]!.trim(); | |
| 74 | + if (firstLine.length === 0) return undefined; | |
| 75 | + // A description that just restates the slug or display name carries no | |
| 76 | + // information beyond identity — drop it from the inventory line. | |
| 77 | + if (identityEchoes.some((echo) => firstLine.toLowerCase() === echo.toLowerCase())) { | |
| 78 | + return undefined; | |
| 79 | + } | |
| 80 | + return firstLine.length > 140 ? `${firstLine.slice(0, 139)}…` : firstLine; | |
| 81 | +}; | |
| 82 | + | |
| 83 | +const connectionEntry = ( | |
| 84 | + connection: Connection, | |
| 85 | + integrations: readonly Integration[], | |
| 86 | +): ConnectionInventoryEntry => { | |
| 87 | + const slug = String(connection.integration); | |
| 88 | + const integration = integrations.find((candidate) => String(candidate.slug) === slug); | |
| 89 | + const identityEchoes = [slug, ...(integration ? [integration.name] : [])]; | |
| 90 | + return { | |
| 91 | + prefix: connectionPath(connection), | |
| 92 | + description: | |
| 93 | + inventoryNote(connection.description, identityEchoes) ?? | |
| 94 | + inventoryNote(integration?.description, identityEchoes), | |
| 95 | + }; | |
| 96 | +}; | |
| 97 | + | |
| 98 | +const formatDescription = (connectionEntries: readonly ConnectionInventoryEntry[]): string => { | |
| 57 | 99 | const lines: string[] = [ |
| 58 | 100 | "Execute TypeScript in a sandboxed runtime with access to configured API tools.", |
| 59 | 101 | "", |
| @@ -84,17 +126,23 @@ const formatDescription = (connectionPrefixes: readonly string[]): string => { · 24 unmodified lines | ||
| 84 | 126 | "- TypeScript type syntax (`: T`, `as T`, generics, interfaces, type aliases) is stripped before execution — feel free to write idiomatic TypeScript using the shapes from `tools.describe.tool()`. Decorators and `enum` are not supported.", |
| 85 | 127 | ]; |
| 86 | 128 | |
| 87 | − if (connectionPrefixes.length > 0) { | |
| 129 | + if (connectionEntries.length > 0) { | |
| 88 | 130 | lines.push(""); |
| 89 | 131 | lines.push("## Available connection prefixes"); |
| 90 | 132 | lines.push(""); |
| 91 | 133 | lines.push("These are paths under `tools.`; append the final tool segment."); |
| 92 | − const sorted = [...connectionPrefixes].sort((a, b) => a.localeCompare(b)).slice(0, 50); | |
| 93 | − for (const prefix of sorted) { | |
| 94 | − lines.push(`- \`${prefix}\``); | |
| 134 | + const sorted = [...connectionEntries] | |
| 135 | + .sort((a, b) => a.prefix.localeCompare(b.prefix)) | |
| 136 | + .slice(0, 50); | |
| 137 | + for (const entry of sorted) { | |
| 138 | + lines.push( | |
| 139 | + entry.description | |
| 140 | + ? `- \`${entry.prefix}\` — ${entry.description}` | |
| 141 | + : `- \`${entry.prefix}\``, | |
| 142 | + ); | |
| 95 | 143 | } |
| 96 | − if (connectionPrefixes.length > sorted.length) { | |
| 97 | − lines.push(`- ... ${connectionPrefixes.length - sorted.length} more`); | |
| 144 | + if (connectionEntries.length > sorted.length) { | |
| 145 | + lines.push(`- ... ${connectionEntries.length - sorted.length} more`); | |
| 98 | 146 | } |
| 99 | 147 | } |
| 100 | 148 | |
| @@ -90,6 +90,10 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => { · 89 unmodified lines | ||
| 90 | 90 | fromPromise(() => pe.connections.list(filter)), |
| 91 | 91 | get: (ref: Parameters<PromiseExecutor["connections"]["get"]>[0]) => |
| 92 | 92 | fromPromise(() => pe.connections.get(ref)), |
| 93 | + update: ( | |
| 94 | + ref: Parameters<PromiseExecutor["connections"]["update"]>[0], | |
| 95 | + input: Parameters<PromiseExecutor["connections"]["update"]>[1], | |
| 96 | + ) => fromPromise(() => pe.connections.update(ref, input)), | |
| 93 | 97 | remove: (ref: Parameters<PromiseExecutor["connections"]["remove"]>[0]) => |
| 94 | 98 | fromPromise(() => pe.connections.remove(ref)), |
| 95 | 99 | refresh: (ref: Parameters<PromiseExecutor["connections"]["refresh"]>[0]) => |
| @@ -111,7 +111,7 @@ const BUILTIN_TOOL_DESCRIPTIONS: ReadonlyMap<string, DescribedTool> = new Map< · 110 unmodified lines | ||
| 111 | 111 | "{ items: ExecutorSourceListItem[]; total: number; hasMore: boolean; nextOffset: number | null; }", |
| 112 | 112 | typeScriptDefinitions: { |
| 113 | 113 | ExecutorSourceListItem: |
| 114 | − "{ id: string; name: string; kind: string; canRemove?: boolean; canRefresh?: boolean; toolCount: number; }", | |
| 114 | + "{ id: string; name: string; description?: string; kind: string; canRemove?: boolean; canRefresh?: boolean; toolCount: number; }", | |
| 115 | 115 | }, |
| 116 | 116 | }, |
| 117 | 117 | ], |
| @@ -320,6 +320,7 @@ export type ToolDiscoveryResult = { · 202 unmodified lines | ||
| 320 | 320 | export type ExecutorSourceListItem = { |
| 321 | 321 | readonly id: string; |
| 322 | 322 | readonly name: string; |
| 323 | + readonly description?: string; | |
| 323 | 324 | readonly kind: string; |
| 324 | 325 | readonly canRemove?: boolean; |
| 325 | 326 | readonly canRefresh?: boolean; |
| @@ -682,6 +683,14 @@ export const listExecutorSources = Effect.fn("executor.sources.list")(function* · 356 unmodified lines | ||
| 682 | 683 | ({ |
| 683 | 684 | id: String(integration.slug), |
| 684 | 685 | name: String(integration.slug), |
| 686 | + // The integration's catalog description — user-editable context the | |
| 687 | + // agent can use to pick a source. Omitted when it just repeats the | |
| 688 | + // slug or display name (no information beyond identity). | |
| 689 | + ...(integration.description && | |
| 690 | + integration.description.toLowerCase() !== String(integration.slug).toLowerCase() && | |
| 691 | + integration.description.toLowerCase() !== integration.name.toLowerCase() | |
| 692 | + ? { description: integration.description } | |
| 693 | + : {}), | |
| 685 | 694 | kind: integration.kind, |
| 686 | 695 | canRemove: integration.canRemove, |
| 687 | 696 | canRefresh: integration.canRefresh, |
| @@ -88,11 +88,12 @@ · 87 unmodified lines | ||
| 88 | 88 | "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src", |
| 89 | 89 | "typecheck": "tsgo --noEmit", |
| 90 | 90 | "typecheck:slow": "tsc --noEmit", |
| 91 | − "test": "vitest run src/**/*.test.ts test/generate.test.ts test/uuid.test.ts --passWithNoTests", | |
| 91 | + "test": "vitest run src test/generate.test.ts test/uuid.test.ts --passWithNoTests", | |
| 92 | 92 | "test:integration": "vitest run test/**/*.test.ts --passWithNoTests" |
| 93 | 93 | }, |
| 94 | 94 | "devDependencies": { |
| 95 | 95 | "@effect/vitest": "catalog:", |
| 96 | + "@libsql/client": "catalog:", | |
| 96 | 97 | "@types/better-sqlite3": "^7.6.13", |
| 97 | 98 | "@types/node": "catalog:", |
| 98 | 99 | "@types/pg": "^8.20.0", |
| @@ -0,0 +1,86 @@ | ||
| 1 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 4 | + | |
| 5 | +import { expect, test } from "@effect/vitest"; | |
| 6 | +import { createClient } from "@libsql/client"; | |
| 7 | +import { drizzle } from "drizzle-orm/libsql"; | |
| 8 | + | |
| 9 | +import { | |
| 10 | + createDrizzleRuntimeSchemaSqlFromTables, | |
| 11 | + ensureDrizzleRuntimeSchemaFromTables, | |
| 12 | +} from "./index"; | |
| 13 | +import { column, idColumn, table } from "../../schema"; | |
| 14 | + | |
| 15 | +const NS = "executor"; | |
| 16 | + | |
| 17 | +// An older database baseline: `integration` has only id + slug. | |
| 18 | +const v1Tables = { | |
| 19 | + integration: table("integration", { | |
| 20 | + id: idColumn("id", "varchar(255)"), | |
| 21 | + slug: column("slug", "varchar(255)"), | |
| 22 | + }), | |
| 23 | +}; | |
| 24 | + | |
| 25 | +// The running schema adds two nullable columns the old file predates — the | |
| 26 | +// shape of the integration-descriptions feature (name + config_revised_at). | |
| 27 | +const v2Tables = { | |
| 28 | + integration: table("integration", { | |
| 29 | + id: idColumn("id", "varchar(255)"), | |
| 30 | + slug: column("slug", "varchar(255)"), | |
| 31 | + name: column("name", "string").nullable(), | |
| 32 | + config_revised_at: column("config_revised_at", "bigint").nullable(), | |
| 33 | + }), | |
| 34 | +}; | |
| 35 | + | |
| 36 | +const columnNames = async (client: ReturnType<typeof createClient>): Promise<readonly string[]> => { | |
| 37 | + const info = await client.execute("PRAGMA table_info('integration')"); | |
| 38 | + return info.rows.map((row) => String(row["name"])); | |
| 39 | +}; | |
| 40 | + | |
| 41 | +// This is the boot bring-up that backs apps/local, apps/host-selfhost, and | |
| 42 | +// apps/host-cloudflare: `CREATE TABLE IF NOT EXISTS` never alters an existing | |
| 43 | +// table, so without column evolution a file from an earlier baseline would | |
| 44 | +// 500 on the first query against a new column. (Cloud Postgres gets the same | |
| 45 | +// columns from a generated drizzle migration instead.) | |
| 46 | +test("ensure evolves an existing table with the schema's new nullable columns", async () => { | |
| 47 | + const dir = mkdtempSync(join(tmpdir(), "fumadb-ensure-")); | |
| 48 | + const client = createClient({ url: `file:${join(dir, "test.db")}` }); | |
| 49 | + const db = drizzle({ client }); | |
| 50 | + | |
| 51 | + try { | |
| 52 | + // Stand up the old database baseline directly (CREATE TABLE only). | |
| 53 | + for (const statement of createDrizzleRuntimeSchemaSqlFromTables({ | |
| 54 | + tables: v1Tables, | |
| 55 | + namespace: NS, | |
| 56 | + version: "1.0.0", | |
| 57 | + provider: "sqlite", | |
| 58 | + })) { | |
| 59 | + await client.execute(statement); | |
| 60 | + } | |
| 61 | + expect(await columnNames(client)).not.toContain("name"); | |
| 62 | + | |
| 63 | + // Boot with the new schema → the missing columns are added in place. | |
| 64 | + await ensureDrizzleRuntimeSchemaFromTables(db, { | |
| 65 | + tables: v2Tables, | |
| 66 | + namespace: NS, | |
| 67 | + version: "1.0.0", | |
| 68 | + provider: "sqlite", | |
| 69 | + }); | |
| 70 | + const evolved = await columnNames(client); | |
| 71 | + expect(evolved).toContain("name"); | |
| 72 | + expect(evolved).toContain("config_revised_at"); | |
| 73 | + | |
| 74 | + // Idempotent: a second boot tolerates the now-duplicate columns. | |
| 75 | + await ensureDrizzleRuntimeSchemaFromTables(db, { | |
| 76 | + tables: v2Tables, | |
| 77 | + namespace: NS, | |
| 78 | + version: "1.0.0", | |
| 79 | + provider: "sqlite", | |
| 80 | + }); | |
| 81 | + expect(await columnNames(client)).toEqual(evolved); | |
| 82 | + } finally { | |
| 83 | + client.close(); | |
| 84 | + rmSync(dir, { recursive: true, force: true }); | |
| 85 | + } | |
| 86 | +}); | |
| @@ -330,6 +330,39 @@ export const createDrizzleRuntimeSchemaSql = ( · 329 unmodified lines | ||
| 330 | 330 | createSettingsTableSql(options.namespace, options.schema.version, options.provider), |
| 331 | 331 | ]; |
| 332 | 332 | |
| 333 | +/** `ALTER TABLE … ADD COLUMN` statements for every NULLABLE column, one per | |
| 334 | + * column. `CREATE TABLE IF NOT EXISTS` never touches an existing table, so a | |
| 335 | + * schema-evolution column would otherwise only exist in fresh databases. Each | |
| 336 | + * statement fails with a duplicate-column error when the column already | |
| 337 | + * exists — callers run them individually and swallow exactly that error, | |
| 338 | + * which keeps this idempotent without provider-specific catalog probes. | |
| 339 | + * Non-nullable additions can't be backfilled blindly and still need a real | |
| 340 | + * migration. */ | |
| 341 | +const addNullableColumnsSql = (options: DrizzleRuntimeSchemaOptions): readonly string[] => | |
| 342 | + Object.values(options.schema.tables).flatMap((table) => | |
| 343 | + Object.values(table.columns) | |
| 344 | + .filter((column) => column.isNullable && !(column instanceof IdColumn)) | |
| 345 | + .map((column) => | |
| 346 | + [ | |
| 347 | + "ALTER TABLE", | |
| 348 | + quoteIdent(table.names.sql), | |
| 349 | + "ADD COLUMN", | |
| 350 | + columnDefinitionSql(column, options.provider), | |
| 351 | + ].join(" "), | |
| 352 | + ), | |
| 353 | + ); | |
| 354 | + | |
| 355 | +const isDuplicateColumnError = (error: unknown): boolean => { | |
| 356 | + // Drivers wrap the SQL error (e.g. DrizzleQueryError → LibsqlError), so | |
| 357 | + // match anywhere down the cause chain. | |
| 358 | + for (let current = error, depth = 0; current != null && depth < 8; depth += 1) { | |
| 359 | + const message = current instanceof Error ? current.message : String(current); | |
| 360 | + if (/duplicate column|already exists/i.test(message)) return true; | |
| 361 | + current = current instanceof Error ? current.cause : null; | |
| 362 | + } | |
| 363 | + return false; | |
| 364 | +}; | |
| 365 | + | |
| 333 | 366 | export const createDrizzleRuntimeSchemaSqlFromTables = ( |
| 334 | 367 | options: DrizzleRuntimeTablesOptions, |
| 335 | 368 | ): readonly string[] => |
| @@ -370,6 +403,17 @@ export const ensureDrizzleRuntimeSchema = async ( · 34 unmodified lines | ||
| 370 | 403 | } else { |
| 371 | 404 | await run(db); |
| 372 | 405 | } |
| 406 | + | |
| 407 | + // Evolve existing tables: add any nullable columns they predate. Outside | |
| 408 | + // the transaction — each statement individually either succeeds (column was | |
| 409 | + // missing) or fails with a duplicate-column error (column exists, ignored). | |
| 410 | + for (const statement of addNullableColumnsSql(options)) { | |
| 411 | + try { | |
| 412 | + await runStatement(db, statement); | |
| 413 | + } catch (error) { | |
| 414 | + if (!isDuplicateColumnError(error)) throw error; | |
| 415 | + } | |
| 416 | + } | |
| 373 | 417 | }; |
| 374 | 418 | |
| 375 | 419 | export const ensureDrizzleRuntimeSchemaFromTables = async ( |
| @@ -115,6 +115,18 @@ export interface IntegrationAccountHandoff { · 114 unmodified lines | ||
| 115 | 115 | readonly label?: string; |
| 116 | 116 | } |
| 117 | 117 | |
| 118 | +/** Outcome of applying an edit-sheet section's staged change. `summary` is | |
| 119 | + * toasted on success; `ok: false` keeps the sheet open (the section renders | |
| 120 | + * its own error inline). */ | |
| 121 | +export type EditSheetApplyResult = | |
| 122 | + | { readonly ok: true; readonly summary: string | null } | |
| 123 | + | { readonly ok: false }; | |
| 124 | + | |
| 125 | +export interface EditSheetSectionProps { | |
| 126 | + readonly sourceId: string; | |
| 127 | + readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void; | |
| 128 | +} | |
| 129 | + | |
| 118 | 130 | export interface IntegrationPlugin { |
| 119 | 131 | /** Unique key matching the SDK plugin id (e.g. "openapi"). */ |
| 120 | 132 | readonly key: string; |
| @@ -130,10 +142,19 @@ export interface IntegrationPlugin { · 9 unmodified lines | ||
| 130 | 142 | readonly initialPreset?: string; |
| 131 | 143 | readonly initialNamespace?: string; |
| 132 | 144 | }>; |
| 133 | − readonly edit: ComponentType<{ | |
| 145 | + /** Legacy full-page edit surface. No host renders this anymore — plugin | |
| 146 | + * configuration lives in the integration Edit sheet via `editSheet`. */ | |
| 147 | + readonly edit?: ComponentType<{ | |
| 134 | 148 | readonly sourceId: string; |
| 135 | 149 | readonly onSave: () => void; |
| 136 | 150 | }>; |
| 151 | + /** Plugin-owned configuration rendered inside the integration's Edit sheet, | |
| 152 | + * below the shared metadata fields (e.g. the OpenAPI spec-update controls). | |
| 153 | + * The sheet has ONE Save: the section stages its pending change locally and | |
| 154 | + * reports it through `onPendingChange` — a thunk that applies the staged | |
| 155 | + * change. Save runs the metadata update, then the staged apply; a failed | |
| 156 | + * apply keeps the sheet open with the section showing its own error. */ | |
| 157 | + readonly editSheet?: ComponentType<EditSheetSectionProps>; | |
| 137 | 158 | readonly summary?: ComponentType<{ |
| 138 | 159 | readonly sourceId: string; |
| 139 | 160 | readonly variant?: "badge" | "panel"; |
| @@ -32,6 +32,11 @@ export interface Connection { · 31 unmodified lines | ||
| 32 | 32 | readonly address: ConnectionAddress; |
| 33 | 33 | /** Optional human label (which account). Not load-bearing. */ |
| 34 | 34 | readonly identityLabel?: string | null; |
| 35 | + /** User-curated description of what this connection is for. Agent-visible: | |
| 36 | + * surfaces next to the connection's prefix in the execute-tool inventory | |
| 37 | + * and in `connections.list`, so it is the place to give agents context a | |
| 38 | + * spec can't (e.g. "the staging CRM — reads only"). */ | |
| 39 | + readonly description?: string | null; | |
| 35 | 40 | /** Epoch ms when an OAuth access token expires; null/absent for static creds. */ |
| 36 | 41 | readonly expiresAt?: number | null; |
| 37 | 42 | /** The OAuth app (`oauth_client` slug) that minted this connection, when it |
| @@ -87,4 +92,13 @@ export type CreateConnectionInput = { · 49 unmodified lines | ||
| 87 | 92 | readonly integration: IntegrationSlug; |
| 88 | 93 | readonly template: AuthTemplateSlug; |
| 89 | 94 | readonly identityLabel?: string | null; |
| 95 | + readonly description?: string | null; | |
| 90 | 96 | } & ConnectionValueInput; |
| 97 | + | |
| 98 | +/** Edit a connection's user-curated metadata. Only the provided fields change; | |
| 99 | + * credentials and OAuth lifecycle are untouchable here (recreate or refresh | |
| 100 | + * instead). */ | |
| 101 | +export interface UpdateConnectionInput { | |
| 102 | + readonly description?: string | null; | |
| 103 | + readonly identityLabel?: string | null; | |
| 104 | +} | |
| @@ -120,8 +120,23 @@ export const coreTables = defineTables({ · 119 unmodified lines | ||
| 120 | 120 | { |
| 121 | 121 | slug: keyColumn("slug"), |
| 122 | 122 | plugin_id: textColumn("plugin_id"), |
| 123 | − description: textColumn("description"), | |
| 123 | + // Display name. The pre-split field: `description` used to hold the | |
| 124 | + // name, so cloud backfills `name` from it (migration 0006) and other | |
| 125 | + // hosts fall back at read time (see rowToIntegration). Nullable because | |
| 126 | + // SQLite boot-ensure hosts cannot add a NOT NULL column to existing | |
| 127 | + // tables, so the column stays nullable even though it is always present | |
| 128 | + // in practice. | |
| 129 | + name: nullableTextColumn("name"), | |
| 130 | + // Actual prose description, now distinct from the name. Nullable: absent | |
| 131 | + // until a user/spec supplies one (cloud clears the old duplicated title | |
| 132 | + // to NULL in 0006). | |
| 133 | + description: nullableTextColumn("description"), | |
| 124 | 134 | config: nullableJsonColumn("config"), |
| 135 | + // Epoch ms of the last tool-affecting config change (spec update, auth | |
| 136 | + // template edit). Compared against each connection's `tools_synced_at` | |
| 137 | + // so OTHER subjects' connections — whose tool rows the updater cannot | |
| 138 | + // write under the owner policy — lazily rebuild on their next read. | |
| 139 | + config_revised_at: nullableBigintColumn("config_revised_at"), | |
| 125 | 140 | can_remove: boolColumn("can_remove", true), |
| 126 | 141 | can_refresh: boolColumn("can_refresh", false), |
| 127 | 142 | created_at: dateColumn("created_at"), |
| @@ -144,6 +159,12 @@ export const coreTables = defineTables({ · 16 unmodified lines | ||
| 144 | 159 | provider: textColumn("provider"), |
| 145 | 160 | item_ids: jsonColumn("item_ids"), |
| 146 | 161 | identity_label: nullableTextColumn("identity_label"), |
| 162 | + // User-curated, agent-visible "what is this connection for". Settable at | |
| 163 | + // create, editable after; never reset by OAuth re-mints. | |
| 164 | + description: nullableTextColumn("description"), | |
| 165 | + // Epoch ms of the last tool (re)production for this connection. Stale | |
| 166 | + // vs the integration's `config_revised_at` → re-produced on next read. | |
| 167 | + tools_synced_at: nullableBigintColumn("tools_synced_at"), | |
| 147 | 168 | oauth_client: nullableTextColumn("oauth_client"), |
| 148 | 169 | // The OWNER of `oauth_client` (a Personal connection may be minted through |
| 149 | 170 | // a shared Workspace app), set together with `oauth_client`; null for |
| @@ -70,6 +70,7 @@ const ConnectionOutput = Schema.Struct({ · 69 unmodified lines | ||
| 70 | 70 | provider: Schema.String, |
| 71 | 71 | address: Schema.String, |
| 72 | 72 | identityLabel: Schema.optional(Schema.NullOr(Schema.String)), |
| 73 | + description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 73 | 74 | expiresAt: Schema.NullOr(Schema.Number), |
| 74 | 75 | oauthClient: Schema.NullOr(Schema.String), |
| 75 | 76 | oauthClientOwner: Schema.NullOr(OwnerSchema), |
| @@ -303,6 +304,7 @@ const connectionToOutput = (connection: Connection) => ({ · 227 unmodified lines | ||
| 303 | 304 | provider: String(connection.provider), |
| 304 | 305 | address: String(connection.address), |
| 305 | 306 | identityLabel: connection.identityLabel ?? null, |
| 307 | + description: connection.description ?? null, | |
| 306 | 308 | expiresAt: connection.expiresAt ?? null, |
| 307 | 309 | oauthClient: connection.oauthClient == null ? null : String(connection.oauthClient), |
| 308 | 310 | oauthClientOwner: connection.oauthClientOwner ?? null, |
| @@ -24,6 +24,7 @@ import type { · 23 unmodified lines | ||
| 24 | 24 | ConnectionRef, |
| 25 | 25 | CreateConnectionInput, |
| 26 | 26 | ConnectionValueInput, |
| 27 | + UpdateConnectionInput, | |
| 27 | 28 | } from "./connection"; |
| 28 | 29 | import { |
| 29 | 30 | coreSchema, |
| @@ -246,7 +247,7 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 216 unmodified lines | ||
| 246 | 247 | readonly get: (slug: IntegrationSlug) => Effect.Effect<Integration | null, StorageFailure>; |
| 247 | 248 | readonly update: ( |
| 248 | 249 | slug: IntegrationSlug, |
| 249 | − patch: { readonly description?: string }, | |
| 250 | + patch: { readonly name?: string; readonly description?: string }, | |
| 250 | 251 | ) => Effect.Effect<void, IntegrationNotFoundError | StorageFailure>; |
| 251 | 252 | readonly remove: ( |
| 252 | 253 | slug: IntegrationSlug, |
| @@ -271,6 +272,12 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 18 unmodified lines | ||
| 271 | 272 | readonly owner?: Owner; |
| 272 | 273 | }) => Effect.Effect<readonly Connection[], StorageFailure>; |
| 273 | 274 | readonly get: (ref: ConnectionRef) => Effect.Effect<Connection | null, StorageFailure>; |
| 275 | + /** Edit user-curated metadata (description, identityLabel). Credentials and | |
| 276 | + * OAuth lifecycle fields are not editable here. */ | |
| 277 | + readonly update: ( | |
| 278 | + ref: ConnectionRef, | |
| 279 | + input: UpdateConnectionInput, | |
| 280 | + ) => Effect.Effect<Connection, ConnectionNotFoundError | StorageFailure>; | |
| 274 | 281 | readonly remove: ( |
| 275 | 282 | ref: ConnectionRef, |
| 276 | 283 | ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>; |
| @@ -441,7 +448,11 @@ const rowToIntegration = ( · 164 unmodified lines | ||
| 441 | 448 | displayUrl?: string, |
| 442 | 449 | ): Integration => ({ |
| 443 | 450 | slug: IntegrationSlug.make(row.slug), |
| 444 | − description: row.description, | |
| 451 | + // Pre-split rows have no `name`; their description WAS the display name. | |
| 452 | + name: row.name ?? row.description ?? row.slug, | |
| 453 | + // `description` is now nullable (cleared where it only held a duplicated | |
| 454 | + // title); present it as "" so the public Integration type stays a string. | |
| 455 | + description: row.description ?? "", | |
| 445 | 456 | kind: row.plugin_id, |
| 446 | 457 | canRemove: Boolean(row.can_remove), |
| 447 | 458 | canRefresh: Boolean(row.can_refresh), |
| @@ -469,6 +480,7 @@ const rowToConnection = (row: ConnectionRow): Connection => { · 21 unmodified lines | ||
| 469 | 480 | provider: ProviderKey.make(row.provider), |
| 470 | 481 | address: connectionAddress(owner, integration, name), |
| 471 | 482 | identityLabel: row.identity_label ?? null, |
| 483 | + description: row.description ?? null, | |
| 472 | 484 | expiresAt: row.expires_at == null ? null : Number(row.expires_at), |
| 473 | 485 | oauthClient: row.oauth_client == null ? null : OAuthClientSlug.make(String(row.oauth_client)), |
| 474 | 486 | oauthClientOwner: |
| @@ -1270,6 +1282,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 795 unmodified lines | ||
| 1270 | 1282 | |
| 1271 | 1283 | const staticSourceToIntegration = (source: StaticSourceDecl): Integration => ({ |
| 1272 | 1284 | slug: IntegrationSlug.make(source.id), |
| 1285 | + name: source.name, | |
| 1273 | 1286 | description: source.name, |
| 1274 | 1287 | kind: source.kind, |
| 1275 | 1288 | canRemove: source.canRemove ?? false, |
| @@ -1705,6 +1718,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 429 unmodified lines | ||
| 1705 | 1718 | where: (b: AnyCb) => b("slug", "=", String(input.slug)), |
| 1706 | 1719 | set: { |
| 1707 | 1720 | plugin_id: pluginId, |
| 1721 | + name: input.name ?? existing.name ?? null, | |
| 1708 | 1722 | description: input.description, |
| 1709 | 1723 | config, |
| 1710 | 1724 | can_remove: input.canRemove ?? Boolean(existing.can_remove), |
| @@ -1718,6 +1732,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines | ||
| 1718 | 1732 | tenant, |
| 1719 | 1733 | slug: String(input.slug), |
| 1720 | 1734 | plugin_id: pluginId, |
| 1735 | + name: input.name ?? null, | |
| 1721 | 1736 | description: input.description, |
| 1722 | 1737 | config, |
| 1723 | 1738 | can_remove: input.canRemove ?? true, |
| @@ -1731,14 +1746,24 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines | ||
| 1731 | 1746 | const integrationsUpdate = ( |
| 1732 | 1747 | slug: IntegrationSlug, |
| 1733 | 1748 | patch: { |
| 1749 | + readonly name?: string; | |
| 1734 | 1750 | readonly description?: string; |
| 1735 | 1751 | readonly config?: IntegrationConfig; |
| 1736 | 1752 | }, |
| 1737 | 1753 | ): Effect.Effect<void, StorageFailure> => |
| 1738 | 1754 | Effect.gen(function* () { |
| 1739 | − const set: Record<string, unknown> = { updated_at: new Date() }; | |
| 1755 | + const now = new Date(); | |
| 1756 | + const set: Record<string, unknown> = { updated_at: now }; | |
| 1757 | + if (patch.name !== undefined) set.name = patch.name; | |
| 1740 | 1758 | if (patch.description !== undefined) set.description = patch.description; |
| 1741 | − if (patch.config !== undefined) set.config = patch.config; | |
| 1759 | + if (patch.config !== undefined) { | |
| 1760 | + set.config = patch.config; | |
| 1761 | + // A config change can change the derived tools. The writer can only | |
| 1762 | + // rebuild catalogs in its own partition (owner policy), so revise | |
| 1763 | + // the integration: other subjects' connections compare this stamp | |
| 1764 | + // against their `tools_synced_at` and lazily rebuild on next read. | |
| 1765 | + set.config_revised_at = now.getTime(); | |
| 1766 | + } | |
| 1742 | 1767 | yield* core.updateMany("integration", { |
| 1743 | 1768 | where: (b: AnyCb) => b("slug", "=", String(slug)), |
| 1744 | 1769 | set, |
| @@ -1747,7 +1772,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines | ||
| 1747 | 1772 | |
| 1748 | 1773 | const integrationsUpdatePublic = ( |
| 1749 | 1774 | slug: IntegrationSlug, |
| 1750 | − patch: { readonly description?: string }, | |
| 1775 | + patch: { readonly name?: string; readonly description?: string }, | |
| 1751 | 1776 | ): Effect.Effect<void, IntegrationNotFoundError | StorageFailure> => |
| 1752 | 1777 | Effect.gen(function* () { |
| 1753 | 1778 | const existing = yield* findIntegrationRow(slug); |
| @@ -1814,6 +1839,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 60 unmodified lines | ||
| 1814 | 1839 | b("integration", "=", String(ref.integration)), |
| 1815 | 1840 | b("connection", "=", String(ref.name)), |
| 1816 | 1841 | ); |
| 1842 | + // Every exit stamps the sync time — including the cleanup paths that | |
| 1843 | + // produce zero tools — so the stale-catalog check (`config_revised_at` | |
| 1844 | + // vs `tools_synced_at`) doesn't re-attempt this connection per read. | |
| 1845 | + const stampSynced = core.updateMany("connection", { | |
| 1846 | + where: (b: AnyCb) => | |
| 1847 | + b.and( | |
| 1848 | + byOwner(owner)(b), | |
| 1849 | + b("integration", "=", String(ref.integration)), | |
| 1850 | + b("name", "=", String(ref.name)), | |
| 1851 | + ), | |
| 1852 | + set: { tools_synced_at: Date.now() }, | |
| 1853 | + }); | |
| 1817 | 1854 | |
| 1818 | 1855 | // Defense in depth (and cleanup for rows created before the create-time |
| 1819 | 1856 | // guard, or emptied by an external edit): a credentialed non-OAuth |
| @@ -1834,6 +1871,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 14 unmodified lines | ||
| 1834 | 1871 | Effect.gen(function* () { |
| 1835 | 1872 | yield* core.deleteMany("tool", { where }); |
| 1836 | 1873 | yield* core.deleteMany("definition", { where }); |
| 1874 | + yield* stampSynced; | |
| 1837 | 1875 | }), |
| 1838 | 1876 | ); |
| 1839 | 1877 | return []; |
| @@ -1845,6 +1883,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | ||
| 1845 | 1883 | Effect.gen(function* () { |
| 1846 | 1884 | yield* core.deleteMany("tool", { where }); |
| 1847 | 1885 | yield* core.deleteMany("definition", { where }); |
| 1886 | + yield* stampSynced; | |
| 1848 | 1887 | }), |
| 1849 | 1888 | ); |
| 1850 | 1889 | return []; |
| @@ -1901,6 +1940,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 50 unmodified lines | ||
| 1901 | 1940 | yield* core.deleteMany("definition", { where }); |
| 1902 | 1941 | yield* core.createMany("tool", toolRows); |
| 1903 | 1942 | yield* core.createMany("definition", definitionRows); |
| 1943 | + yield* stampSynced; | |
| 1904 | 1944 | }), |
| 1905 | 1945 | ); |
| 1906 | 1946 | |
| @@ -2041,6 +2081,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 134 unmodified lines | ||
| 2041 | 2081 | provider: providerKey, |
| 2042 | 2082 | item_ids: itemIds, |
| 2043 | 2083 | identity_label: input.identityLabel ?? null, |
| 2084 | + // Re-saving a credential keeps an existing curated description | |
| 2085 | + // unless the caller explicitly provides one. | |
| 2086 | + ...(input.description !== undefined ? { description: input.description } : {}), | |
| 2044 | 2087 | updated_at: now, |
| 2045 | 2088 | }; |
| 2046 | 2089 | if (existing) { |
| @@ -2064,6 +2107,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 17 unmodified lines | ||
| 2064 | 2107 | provider: providerKey, |
| 2065 | 2108 | item_ids: itemIds, |
| 2066 | 2109 | identity_label: input.identityLabel ?? null, |
| 2110 | + description: input.description ?? null, | |
| 2067 | 2111 | oauth_client: null, |
| 2068 | 2112 | refresh_item_id: null, |
| 2069 | 2113 | expires_at: null, |
| @@ -2099,6 +2143,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines | ||
| 2099 | 2143 | provider: providerKey, |
| 2100 | 2144 | item_ids: itemIds, |
| 2101 | 2145 | identity_label: input.identityLabel ?? null, |
| 2146 | + description: input.description ?? null, | |
| 2102 | 2147 | oauth_client: null, |
| 2103 | 2148 | refresh_item_id: null, |
| 2104 | 2149 | expires_at: null, |
| @@ -2172,6 +2217,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 67 unmodified lines | ||
| 2172 | 2217 | provider: input.provider, |
| 2173 | 2218 | item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, |
| 2174 | 2219 | identity_label: input.identityLabel ?? null, |
| 2220 | + // Curated description: never stamped by a mint — a reconnect | |
| 2221 | + // or token refresh must not erase what the user wrote. | |
| 2222 | + description: null, | |
| 2175 | 2223 | oauth_client: String(input.oauthClient), |
| 2176 | 2224 | oauth_client_owner: input.oauthClientOwner, |
| 2177 | 2225 | refresh_item_id: input.refreshItemId, |
| @@ -2204,6 +2252,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 26 unmodified lines | ||
| 2204 | 2252 | provider: input.provider, |
| 2205 | 2253 | item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, |
| 2206 | 2254 | identity_label: input.identityLabel ?? null, |
| 2255 | + description: null, | |
| 2207 | 2256 | oauth_client: String(input.oauthClient), |
| 2208 | 2257 | oauth_client_owner: input.oauthClientOwner, |
| 2209 | 2258 | refresh_item_id: input.refreshItemId, |
| @@ -2234,6 +2283,35 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 24 unmodified lines | ||
| 2234 | 2283 | const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> => |
| 2235 | 2284 | findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null))); |
| 2236 | 2285 | |
| 2286 | + const connectionsUpdate = ( | |
| 2287 | + ref: ConnectionRef, | |
| 2288 | + input: UpdateConnectionInput, | |
| 2289 | + ): Effect.Effect<Connection, ConnectionNotFoundError | StorageFailure> => | |
| 2290 | + Effect.gen(function* () { | |
| 2291 | + const row = yield* findConnectionRow(ref); | |
| 2292 | + if (!row) { | |
| 2293 | + return yield* new ConnectionNotFoundError({ | |
| 2294 | + owner: ref.owner, | |
| 2295 | + integration: ref.integration, | |
| 2296 | + name: ref.name, | |
| 2297 | + }); | |
| 2298 | + } | |
| 2299 | + const set: Record<string, unknown> = { updated_at: new Date() }; | |
| 2300 | + if (input.description !== undefined) set.description = input.description; | |
| 2301 | + if (input.identityLabel !== undefined) set.identity_label = input.identityLabel; | |
| 2302 | + yield* core.updateMany("connection", { | |
| 2303 | + where: (b: AnyCb) => | |
| 2304 | + b.and( | |
| 2305 | + byOwner(ref.owner)(b), | |
| 2306 | + b("integration", "=", String(ref.integration)), | |
| 2307 | + b("name", "=", String(ref.name)), | |
| 2308 | + ), | |
| 2309 | + set, | |
| 2310 | + }); | |
| 2311 | + const updated = yield* findConnectionRow(ref); | |
| 2312 | + return rowToConnection(updated ?? row); | |
| 2313 | + }); | |
| 2314 | + | |
| 2237 | 2315 | const connectionsRemove = ( |
| 2238 | 2316 | ref: ConnectionRef, |
| 2239 | 2317 | ): Effect.Effect<void, ConnectionNotFoundError | StorageFailure> => |
| @@ -2320,8 +2398,50 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 80 unmodified lines | ||
| 2320 | 2398 | return true; |
| 2321 | 2399 | }; |
| 2322 | 2400 | |
| 2401 | + // Rebuild any visible connection whose tool catalog predates its | |
| 2402 | + // integration's last tool-affecting config change. The change's author | |
| 2403 | + // could only rewrite catalogs in their own partition (owner policy); | |
| 2404 | + // every other subject converges here, on their own read, under their own | |
| 2405 | + // binding. Best-effort: a failed rebuild leaves the stale-but-working | |
| 2406 | + // catalog in place and retries on the next read. | |
| 2407 | + const syncStaleConnectionTools = Effect.gen(function* () { | |
| 2408 | + const revised = yield* core.findMany("integration", { | |
| 2409 | + where: (b: AnyCb) => b.isNotNull("config_revised_at"), | |
| 2410 | + }); | |
| 2411 | + if (revised.length === 0) return; | |
| 2412 | + const revisedAt = new Map( | |
| 2413 | + revised.map((row) => [row.slug, Number(row.config_revised_at)] as const), | |
| 2414 | + ); | |
| 2415 | + const connections = yield* core.findMany("connection", { | |
| 2416 | + where: (b: AnyCb) => b.or(...revised.map((row) => b("integration", "=", row.slug))), | |
| 2417 | + }); | |
| 2418 | + for (const connection of connections) { | |
| 2419 | + const revisedTime = revisedAt.get(connection.integration); | |
| 2420 | + if (revisedTime === undefined) continue; | |
| 2421 | + const syncedAt = | |
| 2422 | + connection.tools_synced_at == null ? 0 : Number(connection.tools_synced_at); | |
| 2423 | + if (syncedAt >= revisedTime) continue; | |
| 2424 | + const integrationRow = revised.find((row) => row.slug === connection.integration); | |
| 2425 | + if (!integrationRow) continue; | |
| 2426 | + yield* produceConnectionTools(integrationRow, { | |
| 2427 | + owner: connection.owner as Owner, | |
| 2428 | + integration: IntegrationSlug.make(connection.integration), | |
| 2429 | + name: ConnectionName.make(connection.name), | |
| 2430 | + }).pipe( | |
| 2431 | + Effect.catch(() => Effect.succeed([] as readonly Tool[])), | |
| 2432 | + Effect.withSpan("executor.tools.sync_stale", { | |
| 2433 | + attributes: { | |
| 2434 | + "executor.integration": connection.integration, | |
| 2435 | + "executor.connection": connection.name, | |
| 2436 | + }, | |
| 2437 | + }), | |
| 2438 | + ); | |
| 2439 | + } | |
| 2440 | + }); | |
| 2441 | + | |
| 2323 | 2442 | const toolsList = (filter?: ToolListFilter): Effect.Effect<readonly Tool[], StorageFailure> => |
| 2324 | 2443 | Effect.gen(function* () { |
| 2444 | + yield* syncStaleConnectionTools; | |
| 2325 | 2445 | // Projected: the list surface is metadata (address, description, |
| 2326 | 2446 | // annotations) — loading every tool's input/output schema JSON made |
| 2327 | 2447 | // an unbounded list scale with schema bytes, not tool count. |
| @@ -3018,6 +3138,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 690 unmodified lines | ||
| 3018 | 3138 | create: (input) => connectionsCreate(input), |
| 3019 | 3139 | list: (filter) => connectionsList(filter), |
| 3020 | 3140 | get: (ref) => connectionsGet(ref), |
| 3141 | + update: (ref, input) => connectionsUpdate(ref, input), | |
| 3021 | 3142 | remove: (ref) => connectionsRemove(ref), |
| 3022 | 3143 | refresh: (ref) => connectionsRefresh(ref), |
| 3023 | 3144 | resolveValue: (ref) => resolveConnectionValueByRef(ref), |
| @@ -3124,6 +3245,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 100 unmodified lines | ||
| 3124 | 3245 | create: connectionsCreate, |
| 3125 | 3246 | list: connectionsList, |
| 3126 | 3247 | get: connectionsGet, |
| 3248 | + update: connectionsUpdate, | |
| 3127 | 3249 | remove: connectionsRemove, |
| 3128 | 3250 | refresh: connectionsRefresh, |
| 3129 | 3251 | }, |
| @@ -91,6 +91,7 @@ export type { · 90 unmodified lines | ||
| 91 | 91 | ConnectionRef, |
| 92 | 92 | ConnectionValueInput, |
| 93 | 93 | CreateConnectionInput, |
| 94 | + UpdateConnectionInput, | |
| 94 | 95 | } from "./connection"; |
| 95 | 96 | export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; |
| 96 | 97 | |
| @@ -74,6 +74,11 @@ export interface AuthMethodDescriptor { · 73 unmodified lines | ||
| 74 | 74 | * Carries no credentials and no plugin-internal config. */ |
| 75 | 75 | export interface Integration { |
| 76 | 76 | readonly slug: IntegrationSlug; |
| 77 | + /** Display name. Pre-split rows stored the name in `description`; readers | |
| 78 | + * fall back, so this is always populated. */ | |
| 79 | + readonly name: string; | |
| 80 | + /** Agent-visible context ("what this API is and when to reach for it"). | |
| 81 | + * Distinct from the display name; may equal it on legacy rows. */ | |
| 77 | 82 | readonly description: string; |
| 78 | 83 | /** The plugin that owns this integration kind (e.g. "openapi", "mcp"). */ |
| 79 | 84 | readonly kind: string; |
| @@ -142,6 +147,9 @@ export const mergeAuthTemplates = <T extends { readonly slug: string }>( · 62 unmodified lines | ||
| 142 | 147 | * produced per-connection now). */ |
| 143 | 148 | export interface RegisterIntegrationInput { |
| 144 | 149 | readonly slug: IntegrationSlug; |
| 150 | + /** Display name. Falls back to `description` then the slug when omitted | |
| 151 | + * (legacy callers registered with description-as-name). */ | |
| 152 | + readonly name?: string; | |
| 145 | 153 | readonly description: string; |
| 146 | 154 | /** Opaque plugin config (auth templates, spec ref, mcp url, …). */ |
| 147 | 155 | readonly config: IntegrationConfig; |
| @@ -6,7 +6,12 @@ import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/sp · 5 unmodified lines | ||
| 6 | 6 | import type { StorageFailure } from "./fuma-runtime"; |
| 7 | 7 | |
| 8 | 8 | import type { PluginBlobStore } from "./blob"; |
| 9 | −import type { Connection, ConnectionRef, CreateConnectionInput } from "./connection"; | |
| 9 | +import type { | |
| 10 | + Connection, | |
| 11 | + ConnectionRef, | |
| 12 | + CreateConnectionInput, | |
| 13 | + UpdateConnectionInput, | |
| 14 | +} from "./connection"; | |
| 10 | 15 | import type { |
| 11 | 16 | AuthMethodDescriptor, |
| 12 | 17 | Integration, |
| @@ -111,7 +116,11 @@ export interface PluginCtx<TStore = unknown> { · 98 unmodified lines | ||
| 111 | 116 | readonly register: (input: RegisterIntegrationInput) => Effect.Effect<void, StorageFailure>; |
| 112 | 117 | readonly update: ( |
| 113 | 118 | slug: IntegrationSlug, |
| 114 | − patch: { readonly description?: string; readonly config?: IntegrationConfig }, | |
| 119 | + patch: { | |
| 120 | + readonly name?: string; | |
| 121 | + readonly description?: string; | |
| 122 | + readonly config?: IntegrationConfig; | |
| 123 | + }, | |
| 115 | 124 | ) => Effect.Effect<void, StorageFailure>; |
| 116 | 125 | readonly list: () => Effect.Effect<readonly Integration[], StorageFailure>; |
| 117 | 126 | readonly get: ( |
| @@ -151,6 +160,11 @@ export interface PluginCtx<TStore = unknown> { · 33 unmodified lines | ||
| 151 | 160 | readonly owner?: Owner; |
| 152 | 161 | }) => Effect.Effect<readonly Connection[], StorageFailure>; |
| 153 | 162 | readonly get: (ref: ConnectionRef) => Effect.Effect<Connection | null, StorageFailure>; |
| 163 | + /** Edit user-curated metadata (description, identityLabel). */ | |
| 164 | + readonly update: ( | |
| 165 | + ref: ConnectionRef, | |
| 166 | + input: UpdateConnectionInput, | |
| 167 | + ) => Effect.Effect<Connection, ConnectionNotFoundError | StorageFailure>; | |
| 154 | 168 | readonly remove: ( |
| 155 | 169 | ref: ConnectionRef, |
| 156 | 170 | ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>; |
| @@ -32,6 +32,7 @@ export type { · 31 unmodified lines | ||
| 32 | 32 | Connection, |
| 33 | 33 | ConnectionRef, |
| 34 | 34 | CreateConnectionInput, |
| 35 | + UpdateConnectionInput, | |
| 35 | 36 | ConnectionValueInput, |
| 36 | 37 | } from "./connection"; |
| 37 | 38 | // Credential providers are Effect-native (their `get`/`set` return `Effect`s), |
| @@ -42,6 +42,7 @@ export type { · 41 unmodified lines | ||
| 42 | 42 | ConnectionRef, |
| 43 | 43 | ConnectionValueInput, |
| 44 | 44 | CreateConnectionInput, |
| 45 | + UpdateConnectionInput, | |
| 45 | 46 | } from "./connection"; |
| 46 | 47 | export type { CredentialProvider, ProviderEntry } from "./provider"; |
| 47 | 48 | export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; |
| @@ -21,6 +21,7 @@ const AddIntegrationPayload = Schema.Struct({ · 20 unmodified lines | ||
| 21 | 21 | endpoint: Schema.String, |
| 22 | 22 | slug: Schema.optional(Schema.String), |
| 23 | 23 | name: Schema.optional(Schema.String), |
| 24 | + description: Schema.optional(Schema.String), | |
| 24 | 25 | introspectionJson: Schema.optional(Schema.String), |
| 25 | 26 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 26 | 27 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| @@ -40,6 +40,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 39 unmodified lines | ||
| 40 | 40 | endpoint: payload.endpoint, |
| 41 | 41 | slug: payload.slug, |
| 42 | 42 | name: payload.name, |
| 43 | + description: payload.description, | |
| 43 | 44 | introspectionJson: payload.introspectionJson, |
| 44 | 45 | headers: payload.headers, |
| 45 | 46 | queryParams: payload.queryParams, |
| @@ -45,6 +45,7 @@ export default function AddGraphqlSource(props: { · 44 unmodified lines | ||
| 45 | 45 | initialUrl?: string; |
| 46 | 46 | }) { |
| 47 | 47 | const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); |
| 48 | + const [description, setDescription] = useState(""); | |
| 48 | 49 | const identity = useIntegrationIdentity({ |
| 49 | 50 | fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "", |
| 50 | 51 | }); |
| @@ -111,6 +112,7 @@ export default function AddGraphqlSource(props: { · 60 unmodified lines | ||
| 111 | 112 | endpoint: trimmedEndpoint, |
| 112 | 113 | slug, |
| 113 | 114 | name: displayName, |
| 115 | + ...(description.trim().length > 0 ? { description: description.trim() } : {}), | |
| 114 | 116 | ...(authenticationTemplate.length > 0 |
| 115 | 117 | ? { authenticationTemplate: [...authenticationTemplate] } |
| 116 | 118 | : {}), |
| @@ -131,7 +133,13 @@ export default function AddGraphqlSource(props: { · 14 unmodified lines | ||
| 131 | 133 | <div className="flex flex-1 flex-col gap-6"> |
| 132 | 134 | <h1 className="text-xl font-semibold text-foreground">Add GraphQL Source</h1> |
| 133 | 135 | |
| 134 | − <GraphqlSourceFields endpoint={endpoint} onEndpointChange={setEndpoint} identity={identity} /> | |
| 136 | + <GraphqlSourceFields | |
| 137 | + endpoint={endpoint} | |
| 138 | + onEndpointChange={setEndpoint} | |
| 139 | + identity={identity} | |
| 140 | + description={description} | |
| 141 | + onDescriptionChange={setDescription} | |
| 142 | + /> | |
| 135 | 143 | |
| 136 | 144 | <AuthMethodListEditor |
| 137 | 145 | list={authMethodList} |
| @@ -1,43 +0,0 @@ | ||
| 1 | −import { useAtomValue } from "@effect/atom-react"; | |
| 2 | −import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 3 | −import * as Option from "effect/Option"; | |
| 4 | − | |
| 5 | −import { IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 6 | −import { decodeGraphqlIntegrationConfigOption } from "@executor-js/plugin-graphql"; | |
| 7 | − | |
| 8 | −import { graphqlIntegrationConfigAtom } from "./atoms"; | |
| 9 | −import GraphqlAccountsPanel from "./GraphqlAccountsPanel"; | |
| 10 | − | |
| 11 | −// --------------------------------------------------------------------------- | |
| 12 | −// Edit form — v2: custom authentication methods and connections live in the | |
| 13 | −// shared accounts hub. Keep edit and accounts on the same component path so | |
| 14 | −// GraphQL gets the same "+ Custom method" behavior as OpenAPI. | |
| 15 | −// --------------------------------------------------------------------------- | |
| 16 | − | |
| 17 | −export default function EditGraphqlSource(props: { | |
| 18 | − readonly sourceId: string; | |
| 19 | − readonly onSave: () => void; | |
| 20 | −}) { | |
| 21 | − const slug = IntegrationSlug.make(props.sourceId); | |
| 22 | − const configResult = useAtomValue(graphqlIntegrationConfigAtom(slug)); | |
| 23 | − const config = AsyncResult.isSuccess(configResult) | |
| 24 | − ? Option.getOrNull(decodeGraphqlIntegrationConfigOption(configResult.value)) | |
| 25 | − : null; | |
| 26 | − | |
| 27 | − if (!AsyncResult.isSuccess(configResult) || !config) { | |
| 28 | − return ( | |
| 29 | − <div className="space-y-3"> | |
| 30 | − <h1 className="text-xl font-semibold text-foreground">GraphQL Source</h1> | |
| 31 | − <p className="text-sm text-muted-foreground">Loading configuration…</p> | |
| 32 | − </div> | |
| 33 | − ); | |
| 34 | − } | |
| 35 | − | |
| 36 | − return ( | |
| 37 | − <GraphqlAccountsPanel | |
| 38 | − sourceId={props.sourceId} | |
| 39 | − integrationName={config.name || String(slug)} | |
| 40 | − accountHandoff={null} | |
| 41 | − /> | |
| 42 | − ); | |
| 43 | −} | |
| @@ -4,6 +4,7 @@ import { · 3 unmodified lines | ||
| 4 | 4 | CardStackEntryField, |
| 5 | 5 | } from "@executor-js/react/components/card-stack"; |
| 6 | 6 | import { Input } from "@executor-js/react/components/input"; |
| 7 | +import { Textarea } from "@executor-js/react/components/textarea"; | |
| 7 | 8 | import { |
| 8 | 9 | IntegrationIdentityFieldRows, |
| 9 | 10 | type IntegrationIdentity, |
| @@ -13,6 +14,10 @@ export function GraphqlSourceFields(props: { · 3 unmodified lines | ||
| 13 | 14 | readonly endpoint: string; |
| 14 | 15 | readonly onEndpointChange: (endpoint: string) => void; |
| 15 | 16 | readonly identity: IntegrationIdentity; |
| 17 | + /** The integration's agent-visible description. Blank = the backend falls | |
| 18 | + * back to the introspected schema's own description, then the name. */ | |
| 19 | + readonly description?: string; | |
| 20 | + readonly onDescriptionChange?: (value: string) => void; | |
| 16 | 21 | readonly endpointDisabled?: boolean; |
| 17 | 22 | readonly namespaceReadOnly?: boolean; |
| 18 | 23 | }) { |
| @@ -36,6 +41,21 @@ export function GraphqlSourceFields(props: { · 17 unmodified lines | ||
| 36 | 41 | namePlaceholder="e.g. Shopify API" |
| 37 | 42 | namespaceReadOnly={props.namespaceReadOnly} |
| 38 | 43 | /> |
| 44 | + {props.onDescriptionChange && ( | |
| 45 | + <CardStackEntryField label="Description"> | |
| 46 | + <Textarea | |
| 47 | + value={props.description ?? ""} | |
| 48 | + onChange={(e) => props.onDescriptionChange?.((e.target as HTMLTextAreaElement).value)} | |
| 49 | + placeholder="What this API is and when to reach for it" | |
| 50 | + rows={2} | |
| 51 | + maxRows={6} | |
| 52 | + className="text-sm" | |
| 53 | + /> | |
| 54 | + <p className="text-[11px] text-muted-foreground"> | |
| 55 | + Agent-visible. Leave blank to use the schema's own description when it has one. | |
| 56 | + </p> | |
| 57 | + </CardStackEntryField> | |
| 58 | + )} | |
| 39 | 59 | </CardStackContent> |
| 40 | 60 | </CardStack> |
| 41 | 61 | ); |
| @@ -59,13 +59,14 @@ export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( · 58 unmodified lines | ||
| 59 | 59 | const integration = { |
| 60 | 60 | slug, |
| 61 | 61 | kind: "graphql", |
| 62 | + name: arg.payload.name ?? arg.payload.endpoint, | |
| 62 | 63 | description: arg.payload.name ?? arg.payload.endpoint, |
| 63 | 64 | canRemove: false, |
| 64 | 65 | canRefresh: false, |
| 65 | 66 | authMethods: [], |
| 66 | 67 | }; |
| 67 | 68 | return [integration, ...rows.filter((row) => row.slug !== slug)].sort((a, b) => |
| 68 | − a.description.localeCompare(b.description), | |
| 69 | + a.name.localeCompare(b.name), | |
| 69 | 70 | ); |
| 70 | 71 | }), |
| 71 | 72 | fn: addGraphqlIntegration, |
| @@ -3,19 +3,18 @@ import type { IntegrationPlugin } from "@executor-js/sdk/client"; · 2 unmodified lines | ||
| 3 | 3 | import { graphqlPresets } from "../sdk/presets"; |
| 4 | 4 | |
| 5 | 5 | const importAdd = () => import("./AddGraphqlSource"); |
| 6 | −const importEdit = () => import("./EditGraphqlSource"); | |
| 7 | 6 | const importAccounts = () => import("./GraphqlAccountsPanel"); |
| 8 | 7 | |
| 8 | +// No `editSheet`: GraphQL has no plugin-specific configuration beyond auth | |
| 9 | +// methods, which the Accounts hub already owns ("+ Custom method"). | |
| 9 | 10 | export const graphqlIntegrationPlugin: IntegrationPlugin = { |
| 10 | 11 | key: "graphql", |
| 11 | 12 | label: "GraphQL", |
| 12 | 13 | add: lazy(importAdd), |
| 13 | − edit: lazy(importEdit), | |
| 14 | 14 | accounts: lazy(importAccounts), |
| 15 | 15 | presets: graphqlPresets, |
| 16 | 16 | preload: () => { |
| 17 | 17 | void importAdd(); |
| 18 | − void importEdit(); | |
| 19 | 18 | void importAccounts(); |
| 20 | 19 | }, |
| 21 | 20 | }; |
| @@ -95,6 +95,9 @@ const GraphqlAddIntegrationInputSchema = Schema.Struct({ · 94 unmodified lines | ||
| 95 | 95 | endpoint: Schema.String, |
| 96 | 96 | slug: Schema.optional(Schema.String), |
| 97 | 97 | name: Schema.optional(Schema.String), |
| 98 | + /** Agent-visible catalog description. Falls back to the introspected | |
| 99 | + * schema's own description, then the display name. */ | |
| 100 | + description: Schema.optional(Schema.String), | |
| 98 | 101 | introspectionJson: Schema.optional(Schema.String), |
| 99 | 102 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 100 | 103 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| @@ -603,7 +606,8 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 502 unmodified lines | ||
| 603 | 606 | yield* ctx.transaction( |
| 604 | 607 | ctx.core.integrations.register({ |
| 605 | 608 | slug, |
| 606 | − description: baseConfig.name, | |
| 609 | + name: baseConfig.name, | |
| 610 | + description: input.description?.trim() || baseConfig.name, | |
| 607 | 611 | config: baseConfig, |
| 608 | 612 | canRemove: true, |
| 609 | 613 | canRefresh: true, |
| @@ -637,9 +641,17 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 27 unmodified lines | ||
| 637 | 641 | Effect.gen(function* () { |
| 638 | 642 | yield* ctx.storage.replaceOperations(String(slug), toStoredOperations(slug, prepared)); |
| 639 | 643 | |
| 644 | + // Prefill order: caller's description, then the schema's own | |
| 645 | + // description (present when introspection ran with schema | |
| 646 | + // descriptions), then the display name. | |
| 647 | + const schemaDescription = | |
| 648 | + typeof (introspection as { description?: unknown }).description === "string" | |
| 649 | + ? ((introspection as { description?: string }).description ?? "").trim() | |
| 650 | + : ""; | |
| 640 | 651 | yield* ctx.core.integrations.register({ |
| 641 | 652 | slug, |
| 642 | − description: config.name, | |
| 653 | + name: config.name, | |
| 654 | + description: input.description?.trim() || schemaDescription || config.name, | |
| 643 | 655 | config, |
| 644 | 656 | canRemove: true, |
| 645 | 657 | canRefresh: true, |
| @@ -31,6 +31,8 @@ const StringMap = Schema.Record(Schema.String, Schema.String); · 30 unmodified lines | ||
| 31 | 31 | const AddRemoteServerPayload = Schema.Struct({ |
| 32 | 32 | transport: Schema.optional(Schema.Literal("remote")), |
| 33 | 33 | name: Schema.String, |
| 34 | + /** Agent-visible catalog description. Defaults to the display name. */ | |
| 35 | + description: Schema.optional(Schema.String), | |
| 34 | 36 | endpoint: Schema.String, |
| 35 | 37 | remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), |
| 36 | 38 | slug: Schema.optional(Schema.String), |
| @@ -46,6 +48,7 @@ const AddRemoteServerPayload = Schema.Struct({ · 9 unmodified lines | ||
| 46 | 48 | const AddStdioServerPayload = Schema.Struct({ |
| 47 | 49 | transport: Schema.Literal("stdio"), |
| 48 | 50 | name: Schema.String, |
| 51 | + description: Schema.optional(Schema.String), | |
| 49 | 52 | command: Schema.String, |
| 50 | 53 | args: Schema.optional(Schema.Array(Schema.String)), |
| 51 | 54 | env: Schema.optional(StringMap), |
| @@ -70,6 +73,8 @@ const ProbeEndpointResponse = Schema.Struct({ · 18 unmodified lines | ||
| 70 | 73 | slug: Schema.String, |
| 71 | 74 | toolCount: Schema.NullOr(Schema.Number), |
| 72 | 75 | serverName: Schema.NullOr(Schema.String), |
| 76 | + /** Server `instructions` from initialize — prefills the description field. */ | |
| 77 | + instructions: Schema.NullOr(Schema.String), | |
| 73 | 78 | }); |
| 74 | 79 | |
| 75 | 80 | // --------------------------------------------------------------------------- |
| @@ -33,6 +33,7 @@ const toServerInput = ( · 32 unmodified lines | ||
| 33 | 33 | const p = payload as { |
| 34 | 34 | transport: "stdio"; |
| 35 | 35 | name: string; |
| 36 | + description?: string; | |
| 36 | 37 | command: string; |
| 37 | 38 | args?: readonly string[]; |
| 38 | 39 | env?: Record<string, string>; |
| @@ -42,6 +43,7 @@ const toServerInput = ( · 3 unmodified lines | ||
| 42 | 43 | return { |
| 43 | 44 | transport: "stdio", |
| 44 | 45 | name: p.name, |
| 46 | + description: p.description, | |
| 45 | 47 | command: p.command, |
| 46 | 48 | args: p.args ? [...p.args] : undefined, |
| 47 | 49 | env: p.env, |
| @@ -53,6 +55,7 @@ const toServerInput = ( · 5 unmodified lines | ||
| 53 | 55 | const p = payload as { |
| 54 | 56 | transport?: "remote"; |
| 55 | 57 | name: string; |
| 58 | + description?: string; | |
| 56 | 59 | endpoint: string; |
| 57 | 60 | remoteTransport?: "streamable-http" | "sse" | "auto"; |
| 58 | 61 | queryParams?: Record<string, string>; |
| @@ -67,6 +70,7 @@ const toServerInput = ( · 8 unmodified lines | ||
| 67 | 70 | return { |
| 68 | 71 | transport: "remote", |
| 69 | 72 | name: p.name, |
| 73 | + description: p.description, | |
| 70 | 74 | endpoint: p.endpoint, |
| 71 | 75 | remoteTransport: p.remoteTransport, |
| 72 | 76 | queryParams: p.queryParams, |
| @@ -78,6 +78,7 @@ type ProbeResult = { · 77 unmodified lines | ||
| 78 | 78 | slug: string; |
| 79 | 79 | toolCount: number | null; |
| 80 | 80 | serverName: string | null; |
| 81 | + instructions: string | null; | |
| 81 | 82 | }; |
| 82 | 83 | |
| 83 | 84 | type State = |
| @@ -234,6 +235,10 @@ export default function AddMcpSource(props: { · 150 unmodified lines | ||
| 234 | 235 | fallbackName: |
| 235 | 236 | integrationDisplayNameFromUrl(state.url, "MCP") ?? probe?.serverName ?? probe?.name ?? "", |
| 236 | 237 | }); |
| 238 | + // Agent-visible description: prefilled from the server's `instructions` | |
| 239 | + // until the user types (null = untouched, keep deriving from the probe). | |
| 240 | + const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null); | |
| 241 | + const resolvedDescription = descriptionDraft ?? probe?.instructions ?? ""; | |
| 237 | 242 | const isProbing = state.step === "probing"; |
| 238 | 243 | const isAdding = state.step === "adding"; |
| 239 | 244 | |
| @@ -298,6 +303,9 @@ export default function AddMcpSource(props: { · 58 unmodified lines | ||
| 298 | 303 | payload: { |
| 299 | 304 | transport: "remote" as const, |
| 300 | 305 | name: displayName, |
| 306 | + ...(resolvedDescription.trim().length > 0 | |
| 307 | + ? { description: resolvedDescription.trim() } | |
| 308 | + : {}), | |
| 301 | 309 | endpoint: state.url.trim(), |
| 302 | 310 | ...(slug ? { slug } : {}), |
| 303 | 311 | authenticationTemplate, |
| @@ -313,7 +321,7 @@ export default function AddMcpSource(props: { · 9 unmodified lines | ||
| 313 | 321 | } |
| 314 | 322 | return exit.value.slug; |
| 315 | 323 | }, |
| 316 | − [doAddServer, probe, remoteIdentity, state.url], | |
| 324 | + [doAddServer, probe, remoteIdentity, resolvedDescription, state.url], | |
| 317 | 325 | ); |
| 318 | 326 | |
| 319 | 327 | const handleAddRemote = useCallback(async () => { |
| @@ -447,6 +455,8 @@ export default function AddMcpSource(props: { · 127 unmodified lines | ||
| 447 | 455 | url={state.url} |
| 448 | 456 | onUrlChange={(url) => dispatch({ type: "set-url", url })} |
| 449 | 457 | identity={remoteIdentity} |
| 458 | + description={resolvedDescription} | |
| 459 | + onDescriptionChange={setDescriptionDraft} | |
| 450 | 460 | preview={probe} |
| 451 | 461 | probing={isProbing} |
| 452 | 462 | error={probeError} |
| @@ -1,9 +1,10 @@ | ||
| 1 | −import { useCallback, useMemo, useState } from "react"; | |
| 1 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 2 | 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | 4 | import * as Exit from "effect/Exit"; |
| 5 | 5 | |
| 6 | 6 | import { IntegrationSlug } from "@executor-js/sdk/shared"; |
| 7 | +import type { EditSheetApplyResult, EditSheetSectionProps } from "@executor-js/sdk/client"; | |
| 7 | 8 | import { apiKeyMethodLabel, type AuthPlacement } from "@executor-js/sdk/http-auth"; |
| 8 | 9 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 9 | 10 | import { |
| @@ -12,15 +13,6 @@ import { · 2 unmodified lines | ||
| 12 | 13 | type AuthMethodRow, |
| 13 | 14 | type AuthMethodSeed, |
| 14 | 15 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 15 | −import { Button } from "@executor-js/react/components/button"; | |
| 16 | −import { | |
| 17 | − CardStack, | |
| 18 | − CardStackContent, | |
| 19 | − CardStackEntry, | |
| 20 | − CardStackEntryContent, | |
| 21 | − CardStackEntryDescription, | |
| 22 | − CardStackEntryTitle, | |
| 23 | −} from "@executor-js/react/components/card-stack"; | |
| 24 | 16 | import { Badge } from "@executor-js/react/components/badge"; |
| 25 | 17 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; |
| 26 | 18 | |
| @@ -77,12 +69,13 @@ const samePlacements = ( · 50 unmodified lines | ||
| 77 | 69 | // Remote edit — v2: the integration's endpoint is part of its identity |
| 78 | 70 | // (opaque-to-core config); the editable surface is the declared auth-method |
| 79 | 71 | // LIST, through the same shared editor as the add flow. Accounts (credentials) |
| 80 | −// are managed from the integration page's accounts hub. | |
| 72 | +// are managed from the integration page's accounts hub. Rendered inside the | |
| 73 | +// integration Edit sheet (plugin `editSheet` slot). | |
| 81 | 74 | // --------------------------------------------------------------------------- |
| 82 | 75 | |
| 83 | 76 | function RemoteEdit(props: { |
| 84 | 77 | server: McpServer & { config: McpRemoteConfig }; |
| 85 | − onSave: () => void; | |
| 78 | + onPendingChange?: EditSheetSectionProps["onPendingChange"]; | |
| 86 | 79 | }) { |
| 87 | 80 | const { server } = props; |
| 88 | 81 | const doConfigureAuth = useAtomSet(configureMcpAuth, { mode: "promiseExit" }); |
| @@ -100,7 +93,6 @@ function RemoteEdit(props: { · 11 unmodified lines | ||
| 100 | 93 | ); |
| 101 | 94 | const list = useAuthMethodList(seeds); |
| 102 | 95 | |
| 103 | − const [saving, setSaving] = useState(false); | |
| 104 | 96 | const [error, setError] = useState<string | null>(null); |
| 105 | 97 | |
| 106 | 98 | // The edited methods, slugs preserved for seeded rows so existing |
| @@ -130,8 +122,8 @@ function RemoteEdit(props: { · 23 unmodified lines | ||
| 130 | 122 | }); |
| 131 | 123 | }, [editedMethods, server.config.authenticationTemplate]); |
| 132 | 124 | |
| 133 | − const handleSave = useCallback(async () => { | |
| 134 | − setSaving(true); | |
| 125 | + // Staged apply, run by the sheet's Save when the method list changed. | |
| 126 | + const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => { | |
| 135 | 127 | setError(null); |
| 136 | 128 | const exit = await doConfigureAuth({ |
| 137 | 129 | params: { slug: server.slug }, |
| @@ -146,59 +138,37 @@ function RemoteEdit(props: { · 8 unmodified lines | ||
| 146 | 138 | }); |
| 147 | 139 | if (Exit.isFailure(exit)) { |
| 148 | 140 | setError("Failed to update authentication methods"); |
| 149 | − setSaving(false); | |
| 150 | − return; | |
| 141 | + return { ok: false }; | |
| 151 | 142 | } |
| 152 | − setSaving(false); | |
| 143 | + return { ok: true, summary: "Authentication methods updated." }; | |
| 153 | 144 | }, [doConfigureAuth, editedMethods, server.slug]); |
| 154 | 145 | |
| 146 | + const onPendingChangeRef = useRef(props.onPendingChange); | |
| 147 | + onPendingChangeRef.current = props.onPendingChange; | |
| 148 | + useEffect(() => { | |
| 149 | + onPendingChangeRef.current?.(methodsChanged ? applyStaged : null); | |
| 150 | + return () => onPendingChangeRef.current?.(null); | |
| 151 | + }, [methodsChanged, applyStaged]); | |
| 152 | + | |
| 155 | 153 | return ( |
| 156 | − <div className="space-y-6"> | |
| 157 | − <div> | |
| 158 | − <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1> | |
| 159 | − <p className="mt-1 text-sm text-muted-foreground"> | |
| 160 | − Manage how this MCP server authenticates. The endpoint is part of the server's identity — | |
| 161 | − remove and re-add to change it. Accounts are added from the integration page. | |
| 154 | + <div className="space-y-4 border-t border-border/60 pt-5"> | |
| 155 | + <div className="space-y-1"> | |
| 156 | + <p className="text-sm font-medium text-foreground">Authentication methods</p> | |
| 157 | + <p className="text-xs text-muted-foreground"> | |
| 158 | + Changes apply when you save. The endpoint ( | |
| 159 | + <span className="font-mono">{server.config.endpoint}</span>) is part of the server's | |
| 160 | + identity — remove and re-add to change it. | |
| 162 | 161 | </p> |
| 163 | 162 | </div> |
| 164 | 163 | |
| 165 | − <CardStack> | |
| 166 | − <CardStackContent className="border-t-0"> | |
| 167 | − <CardStackEntry> | |
| 168 | − <CardStackEntryContent> | |
| 169 | − <CardStackEntryTitle>{server.description || String(server.slug)}</CardStackEntryTitle> | |
| 170 | − <CardStackEntryDescription className="font-mono text-xs"> | |
| 171 | − {server.config.endpoint} | |
| 172 | − </CardStackEntryDescription> | |
| 173 | − </CardStackEntryContent> | |
| 174 | − <Badge variant="secondary" className="text-xs"> | |
| 175 | − remote | |
| 176 | − </Badge> | |
| 177 | − </CardStackEntry> | |
| 178 | − </CardStackContent> | |
| 179 | − </CardStack> | |
| 180 | − | |
| 181 | 164 | <AuthMethodListEditor |
| 182 | 165 | list={list} |
| 183 | − title="How does this server authenticate?" | |
| 184 | 166 | oauthMetadata="discovered" |
| 185 | 167 | emptyHint="No methods declared. Add one, or save to mark this server as open (no authentication)." |
| 186 | 168 | footerHint="Connections pick one of these methods. Removing a method detaches connections created against it." |
| 187 | 169 | /> |
| 188 | 170 | |
| 189 | − {methodsChanged ? ( | |
| 190 | − <div className="flex justify-end"> | |
| 191 | − <Button type="button" size="sm" onClick={() => void handleSave()} disabled={saving}> | |
| 192 | − {saving ? "Saving…" : "Save authentication methods"} | |
| 193 | − </Button> | |
| 194 | − </div> | |
| 195 | − ) : null} | |
| 196 | − | |
| 197 | 171 | {error && <FormErrorAlert message={error} />} |
| 198 | − | |
| 199 | − <div className="flex items-center justify-end border-t border-border pt-4"> | |
| 200 | − <Button onClick={props.onSave}>Done</Button> | |
| 201 | − </div> | |
| 202 | 172 | </div> |
| 203 | 173 | ); |
| 204 | 174 | } |
| @@ -209,65 +179,40 @@ function RemoteEdit(props: { · 4 unmodified lines | ||
| 209 | 179 | |
| 210 | 180 | function StdioReadOnly(props: { |
| 211 | 181 | server: McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> }; |
| 212 | − onSave: () => void; | |
| 213 | 182 | }) { |
| 214 | 183 | const { command, args } = props.server.config; |
| 215 | 184 | return ( |
| 216 | − <div className="space-y-6"> | |
| 217 | − <div> | |
| 218 | − <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1> | |
| 219 | − <p className="mt-1 text-sm text-muted-foreground"> | |
| 220 | − Stdio MCP sources cannot be edited in the UI. Remove and recreate the source with the | |
| 221 | − updated command. | |
| 185 | + <div className="space-y-3 border-t border-border/60 pt-5"> | |
| 186 | + <div className="space-y-1"> | |
| 187 | + <p className="text-sm font-medium text-foreground">Server command</p> | |
| 188 | + <p className="text-xs text-muted-foreground"> | |
| 189 | + Stdio MCP sources cannot be edited. Remove and recreate the source with the updated | |
| 190 | + command. | |
| 222 | 191 | </p> |
| 223 | 192 | </div> |
| 224 | − | |
| 225 | − <div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3"> | |
| 226 | − <div className="min-w-0 flex-1"> | |
| 227 | − <p className="truncate text-sm font-semibold text-card-foreground"> | |
| 228 | − {String(props.server.slug)} | |
| 229 | − </p> | |
| 230 | − <p className="mt-0.5 text-xs text-muted-foreground font-mono"> | |
| 231 | − {command} {(args ?? []).join(" ")} | |
| 232 | − </p> | |
| 233 | − </div> | |
| 193 | + <div className="flex items-center gap-3 rounded-md border border-border/60 bg-muted/40 px-3 py-2"> | |
| 194 | + <p className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> | |
| 195 | + {command} {(args ?? []).join(" ")} | |
| 196 | + </p> | |
| 234 | 197 | <Badge variant="secondary" className="text-xs"> |
| 235 | 198 | stdio |
| 236 | 199 | </Badge> |
| 237 | 200 | </div> |
| 238 | − | |
| 239 | − <div className="flex items-center justify-end border-t border-border pt-4"> | |
| 240 | − <Button onClick={props.onSave}>Done</Button> | |
| 241 | − </div> | |
| 242 | 201 | </div> |
| 243 | 202 | ); |
| 244 | 203 | } |
| 245 | 204 | |
| 246 | 205 | // --------------------------------------------------------------------------- |
| 247 | −// Main component — `sourceId` is the integration slug (v2). | |
| 206 | +// Main component — the mcp plugin's section of the integration Edit sheet. | |
| 207 | +// `sourceId` is the integration slug (v2). | |
| 248 | 208 | // --------------------------------------------------------------------------- |
| 249 | 209 | |
| 250 | −export default function EditMcpSource({ | |
| 251 | − sourceId, | |
| 252 | − onSave, | |
| 253 | −}: { | |
| 254 | − readonly sourceId: string; | |
| 255 | − readonly onSave: () => void; | |
| 256 | −}) { | |
| 210 | +export default function EditMcpSource({ sourceId, onPendingChange }: EditSheetSectionProps) { | |
| 257 | 211 | const slug = IntegrationSlug.make(sourceId); |
| 258 | 212 | const serverResult = useAtomValue(mcpServerAtom(slug)); |
| 259 | 213 | const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null; |
| 260 | 214 | |
| 261 | − if (!AsyncResult.isSuccess(serverResult) || server === null) { | |
| 262 | − return ( | |
| 263 | − <div className="space-y-6"> | |
| 264 | − <div> | |
| 265 | − <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1> | |
| 266 | − <p className="mt-1 text-sm text-muted-foreground">Loading configuration…</p> | |
| 267 | − </div> | |
| 268 | − </div> | |
| 269 | − ); | |
| 270 | − } | |
| 215 | + if (!AsyncResult.isSuccess(serverResult) || server === null) return null; | |
| 271 | 216 | |
| 272 | 217 | if (server.config.transport === "stdio") { |
| 273 | 218 | return ( |
| @@ -275,10 +220,14 @@ export default function EditMcpSource({ · 1 unmodified line | ||
| 275 | 220 | server={ |
| 276 | 221 | server as McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> } |
| 277 | 222 | } |
| 278 | − onSave={onSave} | |
| 279 | 223 | /> |
| 280 | 224 | ); |
| 281 | 225 | } |
| 282 | 226 | |
| 283 | − return <RemoteEdit server={server as McpServer & { config: McpRemoteConfig }} onSave={onSave} />; | |
| 227 | + return ( | |
| 228 | + <RemoteEdit | |
| 229 | + server={server as McpServer & { config: McpRemoteConfig }} | |
| 230 | + {...(onPendingChange ? { onPendingChange } : {})} | |
| 231 | + /> | |
| 232 | + ); | |
| 284 | 233 | } |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "@executor-js/react/components/card-stack"; |
| 13 | 13 | import { FieldError } from "@executor-js/react/components/field"; |
| 14 | 14 | import { Input } from "@executor-js/react/components/input"; |
| 15 | +import { Textarea } from "@executor-js/react/components/textarea"; | |
| 15 | 16 | import { Skeleton } from "@executor-js/react/components/skeleton"; |
| 16 | 17 | import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; |
| 17 | 18 | import { IOSSpinner } from "@executor-js/react/components/spinner"; |
| @@ -34,6 +35,10 @@ export function McpRemoteSourceFields(props: { · 16 unmodified lines | ||
| 34 | 35 | readonly url: string; |
| 35 | 36 | readonly onUrlChange: (url: string) => void; |
| 36 | 37 | readonly identity: IntegrationIdentity; |
| 38 | + /** The integration's agent-visible description (prefilled from the server's | |
| 39 | + * `instructions` when the probe connected). */ | |
| 40 | + readonly description?: string; | |
| 41 | + readonly onDescriptionChange?: (value: string) => void; | |
| 37 | 42 | readonly preview: McpRemoteSourcePreview | null; |
| 38 | 43 | readonly probing?: boolean; |
| 39 | 44 | readonly error?: string | null; |
| @@ -99,6 +104,23 @@ export function McpRemoteSourceFields(props: { · 59 unmodified lines | ||
| 99 | 104 | namePlaceholder="e.g. Linear" |
| 100 | 105 | namespaceReadOnly={props.namespaceReadOnly} |
| 101 | 106 | /> |
| 107 | + {props.onDescriptionChange && ( | |
| 108 | + <CardStackEntryField label="Description"> | |
| 109 | + <Textarea | |
| 110 | + value={props.description ?? ""} | |
| 111 | + onChange={(e) => | |
| 112 | + props.onDescriptionChange?.((e.target as HTMLTextAreaElement).value) | |
| 113 | + } | |
| 114 | + placeholder="What this server offers and when to reach for it" | |
| 115 | + rows={2} | |
| 116 | + maxRows={6} | |
| 117 | + className="text-sm" | |
| 118 | + /> | |
| 119 | + <p className="text-[11px] text-muted-foreground"> | |
| 120 | + Agent-visible. Prefilled from the server's instructions when it sends any. | |
| 121 | + </p> | |
| 122 | + </CardStackEntryField> | |
| 123 | + )} | |
| 102 | 124 | <CardStackEntryField label="Server URL"> |
| 103 | 125 | <Input |
| 104 | 126 | value={props.url} |
| @@ -3,11 +3,11 @@ import type { IntegrationPlugin } from "@executor-js/sdk/client"; · 2 unmodified lines | ||
| 3 | 3 | import { mcpPresets } from "../sdk/presets"; |
| 4 | 4 | |
| 5 | 5 | const importAdd = () => import("./AddMcpSource"); |
| 6 | −const importEdit = () => import("./EditMcpSource"); | |
| 6 | +const importEditSheet = () => import("./EditMcpSource"); | |
| 7 | 7 | const importAccounts = () => import("./McpAccountsPanel"); |
| 8 | 8 | |
| 9 | 9 | const LazyAddMcpSource = lazy(importAdd); |
| 10 | −const LazyEditMcpSource = lazy(importEdit); | |
| 10 | +const LazyEditMcpSheet = lazy(importEditSheet); | |
| 11 | 11 | const LazyMcpAccountsPanel = lazy(importAccounts); |
| 12 | 12 | |
| 13 | 13 | type AddProps = ComponentProps<IntegrationPlugin["add"]>; |
| @@ -42,12 +42,12 @@ export const createMcpIntegrationPlugin = ( · 28 unmodified lines | ||
| 42 | 42 | key: "mcp", |
| 43 | 43 | label: "MCP", |
| 44 | 44 | add: AddWithFlag, |
| 45 | − edit: LazyEditMcpSource, | |
| 45 | + editSheet: LazyEditMcpSheet, | |
| 46 | 46 | accounts: LazyMcpAccountsPanel, |
| 47 | 47 | presets, |
| 48 | 48 | preload: () => { |
| 49 | 49 | void importAdd(); |
| 50 | − void importEdit(); | |
| 50 | + void importEditSheet(); | |
| 51 | 51 | void importAccounts(); |
| 52 | 52 | }, |
| 53 | 53 | }; |
| @@ -14,6 +14,7 @@ import { describeMcpAuthMethods, describeMcpIntegrationDisplay } from "./plugin" · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ |
| 16 | 16 | slug: IntegrationSlug.make("server"), |
| 17 | + name: "Server", | |
| 17 | 18 | description: "Server", |
| 18 | 19 | kind: "mcp", |
| 19 | 20 | canRemove: true, |
| @@ -55,6 +55,7 @@ export const discoverTools = ( · 54 unmodified lines | ||
| 55 | 55 | |
| 56 | 56 | const manifest = extractManifestFromListToolsResult(listResult, { |
| 57 | 57 | serverInfo: connection.client.getServerVersion?.(), |
| 58 | + instructions: connection.client.getInstructions?.(), | |
| 58 | 59 | }); |
| 59 | 60 | |
| 60 | 61 | // Close the connection after discovery |
| @@ -18,6 +18,8 @@ export interface McpToolManifestEntry { · 17 unmodified lines | ||
| 18 | 18 | export interface McpServerMetadata { |
| 19 | 19 | readonly name: string | null; |
| 20 | 20 | readonly version: string | null; |
| 21 | + /** The server's `instructions` from initialize, when it sent any. */ | |
| 22 | + readonly instructions: string | null; | |
| 21 | 23 | } |
| 22 | 24 | |
| 23 | 25 | export interface McpToolManifest { |
| @@ -82,7 +84,7 @@ export const joinToolPath = (namespace: string | undefined, toolId: string): str · 58 unmodified lines | ||
| 82 | 84 | |
| 83 | 85 | export const extractManifestFromListToolsResult = ( |
| 84 | 86 | listToolsResult: unknown, |
| 85 | − metadata?: { serverInfo?: unknown }, | |
| 87 | + metadata?: { serverInfo?: unknown; instructions?: string | undefined }, | |
| 86 | 88 | ): McpToolManifest => { |
| 87 | 89 | const seen = new Map<string, number>(); |
| 88 | 90 | |
| @@ -96,6 +98,7 @@ export const extractManifestFromListToolsResult = ( · 7 unmodified lines | ||
| 96 | 98 | (info): McpServerMetadata => ({ |
| 97 | 99 | name: info.name ?? null, |
| 98 | 100 | version: info.version ?? null, |
| 101 | + instructions: metadata?.instructions ?? null, | |
| 99 | 102 | }), |
| 100 | 103 | ), |
| 101 | 104 | Option.getOrNull, |
| @@ -145,6 +145,8 @@ const readStamp = (annotations: unknown): McpToolStamp | null => · 144 unmodified lines | ||
| 145 | 145 | const McpRemoteServerInputSchema = Schema.Struct({ |
| 146 | 146 | transport: Schema.optional(Schema.Literal("remote")), |
| 147 | 147 | name: Schema.String, |
| 148 | + /** Agent-visible catalog description. Defaults to the display name. */ | |
| 149 | + description: Schema.optional(Schema.String), | |
| 148 | 150 | endpoint: Schema.String, |
| 149 | 151 | remoteTransport: Schema.optional(McpRemoteTransport), |
| 150 | 152 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| @@ -160,6 +162,7 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 9 unmodified lines | ||
| 160 | 162 | const McpStdioServerInputSchema = Schema.Struct({ |
| 161 | 163 | transport: Schema.Literal("stdio"), |
| 162 | 164 | name: Schema.String, |
| 165 | + description: Schema.optional(Schema.String), | |
| 163 | 166 | command: Schema.String, |
| 164 | 167 | args: Schema.optional(Schema.Array(Schema.String)), |
| 165 | 168 | env: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| @@ -200,6 +203,9 @@ const McpProbeEndpointOutputSchema = Schema.Struct({ · 34 unmodified lines | ||
| 200 | 203 | slug: Schema.String, |
| 201 | 204 | toolCount: Schema.NullOr(Schema.Number), |
| 202 | 205 | serverName: Schema.NullOr(Schema.String), |
| 206 | + /** The server's `instructions` from initialize — prefill for the add form's | |
| 207 | + * description. Only available when the probe connected unauthenticated. */ | |
| 208 | + instructions: Schema.NullOr(Schema.String), | |
| 203 | 209 | }); |
| 204 | 210 | |
| 205 | 211 | // --------------------------------------------------------------------------- |
| @@ -626,6 +632,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 420 unmodified lines | ||
| 626 | 632 | slug, |
| 627 | 633 | toolCount: result.manifest.tools.length, |
| 628 | 634 | serverName: result.manifest.server?.name ?? null, |
| 635 | + instructions: result.manifest.server?.instructions ?? null, | |
| 629 | 636 | } satisfies McpProbeResult; |
| 630 | 637 | } |
| 631 | 638 | |
| @@ -660,6 +667,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 28 unmodified lines | ||
| 660 | 667 | slug, |
| 661 | 668 | toolCount: null, |
| 662 | 669 | serverName: null, |
| 670 | + instructions: null, | |
| 663 | 671 | } satisfies McpProbeResult; |
| 664 | 672 | } |
| 665 | 673 | |
| @@ -673,6 +681,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 7 unmodified lines | ||
| 673 | 681 | slug, |
| 674 | 682 | toolCount: null, |
| 675 | 683 | serverName: null, |
| 684 | + instructions: null, | |
| 676 | 685 | } satisfies McpProbeResult; |
| 677 | 686 | } |
| 678 | 687 | |
| @@ -705,7 +714,8 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 26 unmodified lines | ||
| 705 | 714 | yield* ctx.core.integrations |
| 706 | 715 | .register({ |
| 707 | 716 | slug: slugFrom(slug), |
| 708 | − description: input.name, | |
| 717 | + name: input.name, | |
| 718 | + description: input.description?.trim() || input.name, | |
| 709 | 719 | config, |
| 710 | 720 | canRemove: true, |
| 711 | 721 | canRefresh: true, |
| @@ -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* () { |
| @@ -133,6 +133,9 @@ export default function AddOpenApiSource(props: { · 132 unmodified lines | ||
| 133 | 133 | // After analysis |
| 134 | 134 | const [preview, setPreview] = useState<SpecPreview | null>(null); |
| 135 | 135 | const [baseUrl, setBaseUrl] = useState(""); |
| 136 | + // Agent-visible description: prefilled from the spec's `info.description` | |
| 137 | + // until the user types (null = untouched, keep deriving from the preview). | |
| 138 | + const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null); | |
| 136 | 139 | const identityFallbackName = isGoogleBundlePreset |
| 137 | 140 | ? "Google" |
| 138 | 141 | : preview |
| @@ -220,6 +223,8 @@ export default function AddOpenApiSource(props: { · 81 unmodified lines | ||
| 220 | 223 | const resolvedDisplayName = |
| 221 | 224 | identity.name.trim() || |
| 222 | 225 | (preview ? Option.getOrElse(preview.title, () => resolvedSourceId) : resolvedSourceId); |
| 226 | + const resolvedDescription = | |
| 227 | + descriptionDraft ?? (preview ? Option.getOrElse(preview.description, () => "") : ""); | |
| 223 | 228 | |
| 224 | 229 | // Register EVERY spec-detected auth method, not just a single selected one. |
| 225 | 230 | // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the |
| @@ -327,7 +332,10 @@ export default function AddOpenApiSource(props: { · 101 unmodified lines | ||
| 327 | 332 | payload: { |
| 328 | 333 | spec: specForAdd, |
| 329 | 334 | slug: resolvedSourceId, |
| 330 | − description: resolvedDisplayName, | |
| 335 | + name: resolvedDisplayName, | |
| 336 | + ...(resolvedDescription.trim().length > 0 | |
| 337 | + ? { description: resolvedDescription.trim() } | |
| 338 | + : {}), | |
| 331 | 339 | baseUrl: resolvedBaseUrl, |
| 332 | 340 | // Always send the edited method list (even empty) when the user has |
| 333 | 341 | // inspected a preview: an explicit [] means "no auth methods", while |
| @@ -356,6 +364,7 @@ export default function AddOpenApiSource(props: { · 22 unmodified lines | ||
| 356 | 364 | doAdd, |
| 357 | 365 | resolvedSourceId, |
| 358 | 366 | resolvedDisplayName, |
| 367 | + resolvedDescription, | |
| 359 | 368 | resolvedBaseUrl, |
| 360 | 369 | editedAuthenticationTemplate, |
| 361 | 370 | ]); |
| @@ -428,10 +437,12 @@ export default function AddOpenApiSource(props: { · 66 unmodified lines | ||
| 428 | 437 | {isGoogleBundlePreset ? ( |
| 429 | 438 | <OpenApiSourceDetailsFields |
| 430 | 439 | title="Google" |
| 431 | − description={`${bundleDiscoveryUrls.length} Google API${ | |
| 440 | + subtitle={`${bundleDiscoveryUrls.length} Google API${ | |
| 432 | 441 | bundleDiscoveryUrls.length !== 1 ? "s" : "" |
| 433 | 442 | } · one shared OAuth consent`} |
| 434 | 443 | identity={identity} |
| 444 | + description={resolvedDescription} | |
| 445 | + onDescriptionChange={setDescriptionDraft} | |
| 435 | 446 | baseUrl={resolvedBaseUrl} |
| 436 | 447 | onBaseUrlChange={setBaseUrl} |
| 437 | 448 | baseUrlLabel="Base URL override (optional)" |
| @@ -441,7 +452,7 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | ||
| 441 | 452 | ) : preview ? ( |
| 442 | 453 | <OpenApiSourceDetailsFields |
| 443 | 454 | title={Option.getOrElse(preview.title, () => "API")} |
| 444 | − description={`${Option.getOrElse(preview.version, () => "")}${ | |
| 455 | + subtitle={`${Option.getOrElse(preview.version, () => "")}${ | |
| 445 | 456 | Option.isSome(preview.version) ? " · " : "" |
| 446 | 457 | }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${ |
| 447 | 458 | preview.tags.length > 0 |
| @@ -449,6 +460,8 @@ export default function AddOpenApiSource(props: { · 1 unmodified line | ||
| 449 | 460 | : "" |
| 450 | 461 | }`} |
| 451 | 462 | identity={identity} |
| 463 | + description={resolvedDescription} | |
| 464 | + onDescriptionChange={setDescriptionDraft} | |
| 452 | 465 | baseUrl={resolvedBaseUrl} |
| 453 | 466 | onBaseUrlChange={setBaseUrl} |
| 454 | 467 | baseUrlOptions={baseUrlOptions} |
| @@ -1,118 +0,0 @@ | ||
| 1 | −import { useAtomValue } from "@effect/atom-react"; | |
| 2 | −import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 3 | − | |
| 4 | −import { connectionsAllAtom } from "@executor-js/react/api/atoms"; | |
| 5 | −import { ownerLabel, useOwnerDisplay } from "@executor-js/react/api/owner-display"; | |
| 6 | −import { IntegrationSlug, type Connection } from "@executor-js/sdk/shared"; | |
| 7 | −import { Badge } from "@executor-js/react/components/badge"; | |
| 8 | −import { Button } from "@executor-js/react/components/button"; | |
| 9 | −import { | |
| 10 | − CardStack, | |
| 11 | − CardStackContent, | |
| 12 | − CardStackEntry, | |
| 13 | − CardStackEntryContent, | |
| 14 | − CardStackEntryDescription, | |
| 15 | − CardStackEntryTitle, | |
| 16 | −} from "@executor-js/react/components/card-stack"; | |
| 17 | − | |
| 18 | −import { openApiIntegrationAtom } from "./atoms"; | |
| 19 | − | |
| 20 | −// --------------------------------------------------------------------------- | |
| 21 | −// v2 edit — the integration's spec, base URL, and auth templates are part of | |
| 22 | −// its catalog config, which core treats as opaque and the API exposes as a | |
| 23 | −// read-only `getIntegration`. There are no per-scope source rows, no credential | |
| 24 | −// slots, and no binding writers to configure here anymore. Editing is reduced | |
| 25 | −// to surfacing the integration's identity and its current connections; new | |
| 26 | −// credentials are created on the integration's detail/connections surface. | |
| 27 | −// --------------------------------------------------------------------------- | |
| 28 | − | |
| 29 | −export default function EditOpenApiSource(props: { | |
| 30 | − readonly sourceId: string; | |
| 31 | − readonly onSave: () => void; | |
| 32 | −}) { | |
| 33 | − const slug = IntegrationSlug.make(props.sourceId); | |
| 34 | − const integrationResult = useAtomValue(openApiIntegrationAtom(slug)); | |
| 35 | − const ownerDisplay = useOwnerDisplay(); | |
| 36 | − // Connections across BOTH owners (omit-owner read); each row keeps its owner | |
| 37 | − // for the per-connection badge below. | |
| 38 | − const connectionsResult = useAtomValue(connectionsAllAtom); | |
| 39 | − | |
| 40 | − const integration = | |
| 41 | − AsyncResult.isSuccess(integrationResult) && integrationResult.value | |
| 42 | − ? integrationResult.value | |
| 43 | − : null; | |
| 44 | − const connections: readonly Connection[] = AsyncResult.isSuccess(connectionsResult) | |
| 45 | − ? connectionsResult.value.filter((candidate: Connection) => candidate.integration === slug) | |
| 46 | − : []; | |
| 47 | − | |
| 48 | − if (!integration) { | |
| 49 | − return ( | |
| 50 | − <div className="space-y-3"> | |
| 51 | − <h1 className="text-xl font-semibold text-foreground">OpenAPI Integration</h1> | |
| 52 | − <p className="text-sm text-muted-foreground">Loading…</p> | |
| 53 | − </div> | |
| 54 | − ); | |
| 55 | − } | |
| 56 | − | |
| 57 | − return ( | |
| 58 | − <div className="space-y-6"> | |
| 59 | − <div> | |
| 60 | − <h1 className="text-xl font-semibold text-foreground">OpenAPI Integration</h1> | |
| 61 | − </div> | |
| 62 | − | |
| 63 | − <CardStack> | |
| 64 | − <CardStackContent className="border-t-0"> | |
| 65 | − <CardStackEntry> | |
| 66 | − <CardStackEntryContent> | |
| 67 | − <CardStackEntryTitle>{integration.description || String(slug)}</CardStackEntryTitle> | |
| 68 | − <CardStackEntryDescription>{String(slug)}</CardStackEntryDescription> | |
| 69 | − </CardStackEntryContent> | |
| 70 | − <Badge variant="secondary">{integration.kind}</Badge> | |
| 71 | − </CardStackEntry> | |
| 72 | − </CardStackContent> | |
| 73 | − </CardStack> | |
| 74 | − | |
| 75 | − <CardStack> | |
| 76 | − <CardStackContent className="border-t-0"> | |
| 77 | − <CardStackEntry> | |
| 78 | − <CardStackEntryContent> | |
| 79 | − <CardStackEntryTitle>Connections</CardStackEntryTitle> | |
| 80 | − <CardStackEntryDescription> | |
| 81 | − Credentials this integration uses to call its API. | |
| 82 | − </CardStackEntryDescription> | |
| 83 | − </CardStackEntryContent> | |
| 84 | − </CardStackEntry> | |
| 85 | − {connections.length === 0 ? ( | |
| 86 | − <CardStackEntry> | |
| 87 | − <CardStackEntryContent> | |
| 88 | − <CardStackEntryDescription>No connections yet.</CardStackEntryDescription> | |
| 89 | − </CardStackEntryContent> | |
| 90 | − </CardStackEntry> | |
| 91 | − ) : ( | |
| 92 | − connections.map((connection: Connection) => ( | |
| 93 | − <CardStackEntry key={`${connection.owner}:${connection.name}`}> | |
| 94 | − <CardStackEntryContent> | |
| 95 | − <CardStackEntryTitle> | |
| 96 | − {connection.identityLabel ?? String(connection.name)} | |
| 97 | − </CardStackEntryTitle> | |
| 98 | − <CardStackEntryDescription> | |
| 99 | − {String(connection.template)} · {String(connection.provider)} | |
| 100 | − </CardStackEntryDescription> | |
| 101 | − </CardStackEntryContent> | |
| 102 | − {ownerDisplay.showOwnerLabels ? ( | |
| 103 | − <Badge variant="outline">{ownerLabel(connection.owner)}</Badge> | |
| 104 | − ) : null} | |
| 105 | − </CardStackEntry> | |
| 106 | − )) | |
| 107 | − )} | |
| 108 | − </CardStackContent> | |
| 109 | − </CardStack> | |
| 110 | − | |
| 111 | − <div className="flex items-center justify-start border-t border-border pt-4"> | |
| 112 | − <Button variant="ghost" onClick={props.onSave}> | |
| 113 | − Back | |
| 114 | − </Button> | |
| 115 | − </div> | |
| 116 | − </div> | |
| 117 | − ); | |
| 118 | −} | |
| @@ -12,16 +12,24 @@ import { · 11 unmodified lines | ||
| 12 | 12 | type FreeformComboboxOption, |
| 13 | 13 | } from "@executor-js/react/components/combobox"; |
| 14 | 14 | import { Input } from "@executor-js/react/components/input"; |
| 15 | +import { Textarea } from "@executor-js/react/components/textarea"; | |
| 15 | 16 | import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; |
| 16 | 17 | import { |
| 17 | 18 | IntegrationIdentityFieldRows, |
| 18 | 19 | type IntegrationIdentity, |
| 19 | 20 | } from "@executor-js/react/plugins/integration-identity"; |
| 20 | 21 | |
| 22 | +/** The spec input is shown as a one-line "Spec URL" field only when it IS a | |
| 23 | + * URL; pasted document content gets a multi-line editor instead. */ | |
| 24 | +const isUrlInput = (value: string): boolean => URL.canParse(value.trim()); | |
| 25 | + | |
| 21 | 26 | export function OpenApiSourceDetailsFields(props: { |
| 22 | 27 | readonly title: string; |
| 23 | − readonly description?: string; | |
| 28 | + readonly subtitle?: string; | |
| 24 | 29 | readonly identity: IntegrationIdentity; |
| 30 | + /** The integration's agent-visible description (prefilled from the spec). */ | |
| 31 | + readonly description?: string; | |
| 32 | + readonly onDescriptionChange?: (value: string) => void; | |
| 25 | 33 | readonly baseUrl: string; |
| 26 | 34 | readonly onBaseUrlChange: (value: string) => void; |
| 27 | 35 | readonly baseUrlOptions?: readonly FreeformComboboxOption[]; |
| @@ -38,6 +46,8 @@ export function OpenApiSourceDetailsFields(props: { · 10 unmodified lines | ||
| 38 | 46 | readonly baseUrlMissingMessage?: string; |
| 39 | 47 | readonly footer?: string; |
| 40 | 48 | }) { |
| 49 | + const specIsUrl = props.specUrl !== undefined && isUrlInput(props.specUrl); | |
| 50 | + | |
| 41 | 51 | return ( |
| 42 | 52 | <CardStack> |
| 43 | 53 | <CardStackContent className="border-t-0"> |
| @@ -47,8 +57,8 @@ export function OpenApiSourceDetailsFields(props: { · 3 unmodified lines | ||
| 47 | 57 | )} |
| 48 | 58 | <CardStackEntryContent> |
| 49 | 59 | <CardStackEntryTitle>{props.title}</CardStackEntryTitle> |
| 50 | − {props.description && ( | |
| 51 | − <CardStackEntryDescription>{props.description}</CardStackEntryDescription> | |
| 60 | + {props.subtitle && ( | |
| 61 | + <CardStackEntryDescription>{props.subtitle}</CardStackEntryDescription> | |
| 52 | 62 | )} |
| 53 | 63 | </CardStackEntryContent> |
| 54 | 64 | {props.saveState && props.saveState !== "idle" && ( |
| @@ -61,6 +71,18 @@ export function OpenApiSourceDetailsFields(props: { · 6 unmodified lines | ||
| 61 | 71 | identity={props.identity} |
| 62 | 72 | namespaceReadOnly={props.namespaceReadOnly} |
| 63 | 73 | /> |
| 74 | + {props.onDescriptionChange && ( | |
| 75 | + <CardStackEntryField label="Description"> | |
| 76 | + <Textarea | |
| 77 | + value={props.description ?? ""} | |
| 78 | + onChange={(e) => props.onDescriptionChange?.((e.target as HTMLTextAreaElement).value)} | |
| 79 | + placeholder="What this API is and when to reach for it" | |
| 80 | + rows={2} | |
| 81 | + maxRows={6} | |
| 82 | + className="text-sm" | |
| 83 | + /> | |
| 84 | + </CardStackEntryField> | |
| 85 | + )} | |
| 64 | 86 | <div className="grid grid-cols-1 md:grid-cols-2"> |
| 65 | 87 | <CardStackEntryField label={props.baseUrlLabel ?? "Base URL"}> |
| 66 | 88 | {props.baseUrlOptions && props.baseUrlOptions.length > 0 ? ( |
| @@ -90,7 +112,7 @@ export function OpenApiSourceDetailsFields(props: { · 23 unmodified lines | ||
| 90 | 112 | <p className="text-[11px] text-muted-foreground">{props.baseUrlHint}</p> |
| 91 | 113 | )} |
| 92 | 114 | </CardStackEntryField> |
| 93 | − {props.specUrl !== undefined && props.onSpecUrlChange && ( | |
| 115 | + {specIsUrl && props.onSpecUrlChange && ( | |
| 94 | 116 | <CardStackEntryField label="Spec URL"> |
| 95 | 117 | <Input |
| 96 | 118 | value={props.specUrl} |
| @@ -102,6 +124,19 @@ export function OpenApiSourceDetailsFields(props: { · 5 unmodified lines | ||
| 102 | 124 | </CardStackEntryField> |
| 103 | 125 | )} |
| 104 | 126 | </div> |
| 127 | + {props.specUrl !== undefined && !specIsUrl && props.onSpecUrlChange && ( | |
| 128 | + <CardStackEntryField label="Spec"> | |
| 129 | + <Textarea | |
| 130 | + value={props.specUrl} | |
| 131 | + onChange={(e) => props.onSpecUrlChange?.((e.target as HTMLTextAreaElement).value)} | |
| 132 | + placeholder="Pasted OpenAPI JSON/YAML" | |
| 133 | + rows={4} | |
| 134 | + maxRows={12} | |
| 135 | + className="font-mono text-xs" | |
| 136 | + disabled={props.specUrlDisabled} | |
| 137 | + /> | |
| 138 | + </CardStackEntryField> | |
| 139 | + )} | |
| 105 | 140 | {props.footer && ( |
| 106 | 141 | <CardStackEntry> |
| 107 | 142 | <CardStackEntryContent> |
| @@ -0,0 +1,141 @@ | ||
| 1 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 2 | +import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 3 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 4 | +import * as Exit from "effect/Exit"; | |
| 5 | + | |
| 6 | +import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | |
| 7 | +import { messageFromExit } from "@executor-js/react/api/error-reporting"; | |
| 8 | +import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; | |
| 9 | +import { IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 10 | +import type { EditSheetApplyResult, EditSheetSectionProps } from "@executor-js/sdk/client"; | |
| 11 | +import { Checkbox } from "@executor-js/react/components/checkbox"; | |
| 12 | +import { Label } from "@executor-js/react/components/label"; | |
| 13 | +import { Textarea } from "@executor-js/react/components/textarea"; | |
| 14 | + | |
| 15 | +import { openApiConfigAtom, updateOpenApiSpec } from "./atoms"; | |
| 16 | + | |
| 17 | +// --------------------------------------------------------------------------- | |
| 18 | +// Update spec — the openapi plugin's section of the integration Edit sheet. | |
| 19 | +// The user STAGES a spec update here (check re-fetch, or paste new content); | |
| 20 | +// nothing runs until the sheet's Save. The staged apply re-resolves the spec | |
| 21 | +// and rebuilds the tool catalog in place — connections, credentials, policies, | |
| 22 | +// and the curated description all survive. | |
| 23 | +// --------------------------------------------------------------------------- | |
| 24 | + | |
| 25 | +type UpdateOutcome = { | |
| 26 | + readonly toolCount: number; | |
| 27 | + readonly addedTools: readonly string[]; | |
| 28 | + readonly removedTools: readonly string[]; | |
| 29 | +}; | |
| 30 | + | |
| 31 | +const outcomeSummary = (outcome: UpdateOutcome): string => { | |
| 32 | + const added = outcome.addedTools.length; | |
| 33 | + const removed = outcome.removedTools.length; | |
| 34 | + if (added === 0 && removed === 0) { | |
| 35 | + return `Spec updated — ${outcome.toolCount} tools, no changes to the tool list.`; | |
| 36 | + } | |
| 37 | + const parts = [ | |
| 38 | + ...(added > 0 ? [`+${added} tool${added !== 1 ? "s" : ""}`] : []), | |
| 39 | + ...(removed > 0 ? [`−${removed} tool${removed !== 1 ? "s" : ""}`] : []), | |
| 40 | + ]; | |
| 41 | + return `Spec updated — ${parts.join(", ")} (${outcome.toolCount} total).`; | |
| 42 | +}; | |
| 43 | + | |
| 44 | +export default function UpdateSpecSection(props: EditSheetSectionProps) { | |
| 45 | + const slug = IntegrationSlug.make(props.sourceId); | |
| 46 | + const configResult = useAtomValue(openApiConfigAtom(slug)); | |
| 47 | + const doUpdate = useAtomSet(updateOpenApiSpec, { mode: "promiseExit" }); | |
| 48 | + const [refetchStaged, setRefetchStaged] = useState(false); | |
| 49 | + const [pasted, setPasted] = useState(""); | |
| 50 | + const [error, setError] = useState<string | null>(null); | |
| 51 | + | |
| 52 | + const config = | |
| 53 | + AsyncResult.isSuccess(configResult) && configResult.value ? configResult.value : null; | |
| 54 | + | |
| 55 | + const sourceUrl = config | |
| 56 | + ? config.googleDiscoveryUrls?.length | |
| 57 | + ? `${config.googleDiscoveryUrls.length} Google Discovery document${ | |
| 58 | + config.googleDiscoveryUrls.length !== 1 ? "s" : "" | |
| 59 | + }` | |
| 60 | + : config.sourceUrl | |
| 61 | + : undefined; | |
| 62 | + | |
| 63 | + // The staged apply, rebuilt whenever the staged inputs change. Reported to | |
| 64 | + // the sheet through a ref-stable callback so Save can run it. | |
| 65 | + const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => { | |
| 66 | + const spec = pasted.trim().length > 0 ? { kind: "blob" as const, value: pasted } : undefined; | |
| 67 | + if (!spec && !refetchStaged) return { ok: true, summary: null }; | |
| 68 | + setError(null); | |
| 69 | + const exit = await doUpdate({ | |
| 70 | + params: { slug }, | |
| 71 | + payload: spec ? { spec } : {}, | |
| 72 | + reactivityKeys: integrationWriteKeys, | |
| 73 | + }); | |
| 74 | + if (Exit.isFailure(exit)) { | |
| 75 | + setError(messageFromExit(exit, "Failed to update spec")); | |
| 76 | + return { ok: false }; | |
| 77 | + } | |
| 78 | + setRefetchStaged(false); | |
| 79 | + setPasted(""); | |
| 80 | + return { ok: true, summary: outcomeSummary(exit.value) }; | |
| 81 | + }, [doUpdate, pasted, refetchStaged, slug]); | |
| 82 | + | |
| 83 | + const onPendingChangeRef = useRef(props.onPendingChange); | |
| 84 | + onPendingChangeRef.current = props.onPendingChange; | |
| 85 | + const hasStagedChange = refetchStaged || pasted.trim().length > 0; | |
| 86 | + useEffect(() => { | |
| 87 | + onPendingChangeRef.current?.(hasStagedChange ? applyStaged : null); | |
| 88 | + // Clear the staged apply if this section unmounts mid-edit. | |
| 89 | + return () => onPendingChangeRef.current?.(null); | |
| 90 | + }, [hasStagedChange, applyStaged]); | |
| 91 | + | |
| 92 | + if (!config) return null; | |
| 93 | + | |
| 94 | + return ( | |
| 95 | + <div className="space-y-3 border-t border-border/60 pt-5"> | |
| 96 | + <div className="space-y-1"> | |
| 97 | + <p className="text-sm font-medium text-foreground">Update spec</p> | |
| 98 | + <p className="text-xs text-muted-foreground"> | |
| 99 | + Rebuild this integration's tools from the latest spec when you save. Connections, | |
| 100 | + credentials, and policies are kept. | |
| 101 | + </p> | |
| 102 | + </div> | |
| 103 | + | |
| 104 | + {sourceUrl ? ( | |
| 105 | + <div className="space-y-2"> | |
| 106 | + <Label className="flex items-start gap-2 text-sm font-normal"> | |
| 107 | + <Checkbox | |
| 108 | + checked={refetchStaged} | |
| 109 | + onCheckedChange={(checked: boolean | "indeterminate") => | |
| 110 | + setRefetchStaged(checked === true) | |
| 111 | + } | |
| 112 | + /> | |
| 113 | + <span className="space-y-0.5"> | |
| 114 | + <span className="block">Re-fetch the spec on save</span> | |
| 115 | + <span className="block break-all font-mono text-xs text-muted-foreground"> | |
| 116 | + {sourceUrl} | |
| 117 | + </span> | |
| 118 | + </span> | |
| 119 | + </Label> | |
| 120 | + </div> | |
| 121 | + ) : ( | |
| 122 | + <div className="space-y-2"> | |
| 123 | + <Label htmlFor="update-spec-content" className="text-xs text-muted-foreground"> | |
| 124 | + Updated spec content | |
| 125 | + </Label> | |
| 126 | + <Textarea | |
| 127 | + id="update-spec-content" | |
| 128 | + value={pasted} | |
| 129 | + onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setPasted(e.target.value)} | |
| 130 | + placeholder="Paste the updated OpenAPI JSON/YAML — applied when you save. This spec was added inline, so there is no URL to re-fetch." | |
| 131 | + rows={4} | |
| 132 | + maxRows={10} | |
| 133 | + className="font-mono text-xs" | |
| 134 | + /> | |
| 135 | + </div> | |
| 136 | + )} | |
| 137 | + | |
| 138 | + {error && <FormErrorAlert message={error} />} | |
| 139 | + </div> | |
| 140 | + ); | |
| 141 | +} | |
| @@ -36,6 +36,11 @@ export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec"); · 35 unmodified lines | ||
| 36 | 36 | |
| 37 | 37 | export const removeOpenApiSpec = OpenApiClient.mutation("openapi", "removeSpec"); |
| 38 | 38 | |
| 39 | +/** Re-fetch (or replace) the integration's spec and rebuild its tools in | |
| 40 | + * place — connections, policies, and the description survive. Pass | |
| 41 | + * `reactivityKeys: integrationWriteKeys` at the call site. */ | |
| 42 | +export const updateOpenApiSpec = OpenApiClient.mutation("openapi", "updateSpec"); | |
| 43 | + | |
| 39 | 44 | // Add / merge custom auth methods onto an integration's `authenticationTemplate`. |
| 40 | 45 | export const openapiConfigure = OpenApiClient.mutation("openapi", "configure"); |
| 41 | 46 | |
| @@ -3,19 +3,19 @@ import type { IntegrationPlugin } from "@executor-js/sdk/client"; · 2 unmodified lines | ||
| 3 | 3 | import { openApiPresets } from "../sdk/presets"; |
| 4 | 4 | |
| 5 | 5 | const importAdd = () => import("./AddOpenApiSource"); |
| 6 | −const importEdit = () => import("./EditOpenApiSource"); | |
| 6 | +const importEditSheet = () => import("./UpdateSpecSection"); | |
| 7 | 7 | const importAccounts = () => import("./OpenApiAccountsPanel"); |
| 8 | 8 | |
| 9 | 9 | export const openApiIntegrationPlugin: IntegrationPlugin = { |
| 10 | 10 | key: "openapi", |
| 11 | 11 | label: "OpenAPI", |
| 12 | 12 | add: lazy(importAdd), |
| 13 | − edit: lazy(importEdit), | |
| 13 | + editSheet: lazy(importEditSheet), | |
| 14 | 14 | accounts: lazy(importAccounts), |
| 15 | 15 | presets: openApiPresets, |
| 16 | 16 | preload: () => { |
| 17 | 17 | void importAdd(); |
| 18 | − void importEdit(); | |
| 18 | + void importEditSheet(); | |
| 19 | 19 | void importAccounts(); |
| 20 | 20 | }, |
| 21 | 21 | }; |
| @@ -19,6 +19,7 @@ import { type Authentication } from "./types"; · 18 unmodified lines | ||
| 19 | 19 | |
| 20 | 20 | const recordWith = (templates: readonly Authentication[]): IntegrationRecord => ({ |
| 21 | 21 | slug: IntegrationSlug.make("petstore"), |
| 22 | + name: "Petstore", | |
| 22 | 23 | description: "Petstore", |
| 23 | 24 | kind: "openapi", |
| 24 | 25 | canRemove: true, |
| @@ -84,6 +85,7 @@ describe("describeOpenApiAuthMethods", () => { · 59 unmodified lines | ||
| 84 | 85 | expect( |
| 85 | 86 | describeOpenApiAuthMethods({ |
| 86 | 87 | slug: IntegrationSlug.make("x"), |
| 88 | + name: "x", | |
| 87 | 89 | description: "x", |
| 88 | 90 | kind: "openapi", |
| 89 | 91 | canRemove: true, |
| @@ -405,6 +405,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 404 unmodified lines | ||
| 405 | 405 | |
| 406 | 406 | return ExtractionResult.make({ |
| 407 | 407 | title: Option.fromNullishOr(doc.info?.title), |
| 408 | + description: Option.fromNullishOr(doc.info?.description), | |
| 408 | 409 | version: Option.fromNullishOr(doc.info?.version), |
| 409 | 410 | servers: docServers, |
| 410 | 411 | operations, |
| @@ -25,8 +25,13 @@ import { · 24 unmodified lines | ||
| 25 | 25 | IntegrationSlug, |
| 26 | 26 | ToolAddress, |
| 27 | 27 | } from "@executor-js/sdk"; |
| 28 | +import { mkdtempSync } from "node:fs"; | |
| 29 | +import { tmpdir } from "node:os"; | |
| 30 | +import { join } from "node:path"; | |
| 31 | + | |
| 28 | 32 | import { |
| 29 | 33 | makeTestConfig, |
| 34 | + makeTestWorkspaceHarness, | |
| 30 | 35 | memoryCredentialsPlugin, |
| 31 | 36 | typeCheckOutputTypeScript, |
| 32 | 37 | } from "@executor-js/sdk/testing"; |
| @@ -36,6 +41,7 @@ import { type AuthenticationInput } from "./types"; · 3 unmodified lines | ||
| 36 | 41 | import { |
| 37 | 42 | addOpenApiTestConnection, |
| 38 | 43 | makeOpenApiHttpApiTestSourceConfig, |
| 44 | + serveMutableOpenApiSpecTestServer, | |
| 39 | 45 | serveOpenApiHttpApiTestServer, |
| 40 | 46 | unwrapInvocation, |
| 41 | 47 | } from "../testing"; |
| @@ -704,6 +710,212 @@ describe("OpenAPI Plugin", () => { · 662 unmodified lines | ||
| 704 | 710 | ), |
| 705 | 711 | ); |
| 706 | 712 | |
| 713 | + it.effect("updateSpec re-fetches the source URL and rebuilds tools in place", () => | |
| 714 | + Effect.scoped( | |
| 715 | + Effect.gen(function* () { | |
| 716 | + // A spec server whose document can change between fetches — the | |
| 717 | + // "upstream API shipped a new version" scenario. | |
| 718 | + // The mutable spec server is a real 127.0.0.1 listener — reach it over | |
| 719 | + // the default fetch-based client, like production would. | |
| 720 | + const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi }); | |
| 721 | + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | |
| 722 | + | |
| 723 | + const added = yield* executor.openapi.addSpec({ | |
| 724 | + spec: { kind: "url", url: specServer.specUrl }, | |
| 725 | + slug: "evolving", | |
| 726 | + baseUrl: specServer.baseUrl, | |
| 727 | + description: "curated by hand", | |
| 728 | + authenticationTemplate: [apiKeyTemplate], | |
| 729 | + }); | |
| 730 | + yield* executor.connections.create({ | |
| 731 | + owner: "org", | |
| 732 | + name: ConnectionName.make("main"), | |
| 733 | + integration: IntegrationSlug.make("evolving"), | |
| 734 | + template: AuthTemplateSlug.make("apiKey"), | |
| 735 | + value: "secret-key-123", | |
| 736 | + }); | |
| 737 | + const before = (yield* executor.tools.list()) | |
| 738 | + .filter((t) => String(t.address).startsWith("tools.evolving.")) | |
| 739 | + .map((t) => String(t.name)); | |
| 740 | + expect(before).toContain("items.listItems"); | |
| 741 | + expect(before).toContain("items.queryRows"); | |
| 742 | + | |
| 743 | + // Upstream evolves: queryRows is gone, a new widgets group appears. | |
| 744 | + const EvolvedItemsGroup = HttpApiGroup.make("items") | |
| 745 | + .add(HttpApiEndpoint.get("listItems", "/items", { success: Schema.Array(Item) })) | |
| 746 | + .add( | |
| 747 | + HttpApiEndpoint.post("createItem", "/items", { | |
| 748 | + payload: Schema.Struct({ name: Schema.String }), | |
| 749 | + success: Item, | |
| 750 | + }), | |
| 751 | + ) | |
| 752 | + .add( | |
| 753 | + HttpApiEndpoint.get("getItem", "/items/:itemId", { | |
| 754 | + params: Schema.Struct({ itemId: Schema.NumberFromString }), | |
| 755 | + success: Item, | |
| 756 | + }), | |
| 757 | + ) | |
| 758 | + .add(HttpApiEndpoint.get("echoHeaders", "/echo-headers", { success: EchoHeaders })); | |
| 759 | + const WidgetsGroup = HttpApiGroup.make("widgets").add( | |
| 760 | + HttpApiEndpoint.get("listWidgets", "/widgets", { success: Schema.Array(Item) }), | |
| 761 | + ); | |
| 762 | + const EvolvedApi = HttpApi.make("testApi").add(EvolvedItemsGroup).add(WidgetsGroup); | |
| 763 | + yield* specServer.setApi(EvolvedApi); | |
| 764 | + | |
| 765 | + const result = yield* executor.openapi.updateSpec("evolving"); | |
| 766 | + | |
| 767 | + expect(result.addedTools).toEqual(["widgets.listWidgets"]); | |
| 768 | + expect(result.removedTools).toEqual(["items.queryRows"]); | |
| 769 | + expect(result.toolCount).toBe(added.toolCount); // -1 +1 | |
| 770 | + | |
| 771 | + // The connection's tool catalog reflects the new spec without any | |
| 772 | + // remove/re-add: new tool present, removed tool gone. | |
| 773 | + const after = (yield* executor.tools.list()) | |
| 774 | + .filter((t) => String(t.address).startsWith("tools.evolving.")) | |
| 775 | + .map((t) => String(t.name)); | |
| 776 | + expect(after).toContain("widgets.listWidgets"); | |
| 777 | + expect(after).not.toContain("items.queryRows"); | |
| 778 | + | |
| 779 | + // Everything user-curated survives: description, auth template, and | |
| 780 | + // the connection itself. | |
| 781 | + const integration = yield* executor.openapi.getIntegration("evolving"); | |
| 782 | + expect(integration?.description).toBe("curated by hand"); | |
| 783 | + const config = yield* executor.openapi.getConfig("evolving"); | |
| 784 | + expect(config?.authenticationTemplate?.map((a) => String(a.slug))).toEqual(["apiKey"]); | |
| 785 | + expect(config?.baseUrl).toBe(specServer.baseUrl); | |
| 786 | + const connections = yield* executor.connections.list({ | |
| 787 | + integration: IntegrationSlug.make("evolving"), | |
| 788 | + }); | |
| 789 | + expect(connections.map((c) => String(c.name))).toEqual(["main"]); | |
| 790 | + }), | |
| 791 | + ), | |
| 792 | + ); | |
| 793 | + | |
| 794 | + it.effect("updateSpec accepts new inline content for blob-sourced integrations", () => | |
| 795 | + Effect.scoped( | |
| 796 | + Effect.gen(function* () { | |
| 797 | + const server = yield* servePluginTestApi(); | |
| 798 | + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | |
| 799 | + yield* addOpenApiTestConnection(executor, server, { slug: "pasted" }); | |
| 800 | + | |
| 801 | + // A pasted-blob integration has no source URL — re-fetch must say so. | |
| 802 | + const refetchError = yield* executor.openapi.updateSpec("pasted").pipe(Effect.flip); | |
| 803 | + expect(Predicate.isTagged(refetchError, "OpenApiParseError")).toBe(true); | |
| 804 | + | |
| 805 | + // But providing the updated content works, and the catalog follows. | |
| 806 | + const SpecJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); | |
| 807 | + const parsed = yield* Schema.decodeUnknownEffect(SpecJson)(server.specJson); | |
| 808 | + const evolved = { | |
| 809 | + ...parsed, | |
| 810 | + paths: { | |
| 811 | + ...(parsed.paths as Record<string, unknown>), | |
| 812 | + "/widgets": { | |
| 813 | + get: { | |
| 814 | + operationId: "widgets/list", | |
| 815 | + responses: { "200": { description: "ok" } }, | |
| 816 | + }, | |
| 817 | + }, | |
| 818 | + }, | |
| 819 | + }; | |
| 820 | + const result = yield* executor.openapi.updateSpec("pasted", { | |
| 821 | + spec: { | |
| 822 | + kind: "blob", | |
| 823 | + value: yield* Schema.encodeUnknownEffect(SpecJson)(evolved), | |
| 824 | + }, | |
| 825 | + }); | |
| 826 | + expect(result.addedTools).toEqual(["widgets.list"]); | |
| 827 | + expect(result.removedTools).toEqual([]); | |
| 828 | + | |
| 829 | + const after = (yield* executor.tools.list()) | |
| 830 | + .filter((t) => String(t.address).startsWith("tools.pasted.")) | |
| 831 | + .map((t) => String(t.name)); | |
| 832 | + expect(after).toContain("widgets.list"); | |
| 833 | + }), | |
| 834 | + ), | |
| 835 | + ); | |
| 836 | + | |
| 837 | + it.effect("updateSpec propagates to OTHER subjects' personal connections", () => | |
| 838 | + Effect.scoped( | |
| 839 | + Effect.gen(function* () { | |
| 840 | + // Two members of one workspace, each with a PERSONAL connection on | |
| 841 | + // the shared integration, against one on-disk database. The owner | |
| 842 | + // policy stops A's updateSpec from rewriting B's tool rows directly — | |
| 843 | + // B's catalog must converge lazily on B's own next read. | |
| 844 | + const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi }); | |
| 845 | + const dataDir = mkdtempSync(join(tmpdir(), "openapi-update-spec-multiuser-")); | |
| 846 | + const tenant = "shared-tenant"; | |
| 847 | + const plugins = testPlugins(); | |
| 848 | + | |
| 849 | + const alice = yield* makeTestWorkspaceHarness({ | |
| 850 | + plugins, | |
| 851 | + tenant, | |
| 852 | + subject: "alice", | |
| 853 | + dataDir, | |
| 854 | + }); | |
| 855 | + yield* alice.executor.openapi.addSpec({ | |
| 856 | + spec: { kind: "url", url: specServer.specUrl }, | |
| 857 | + slug: "shared", | |
| 858 | + baseUrl: specServer.baseUrl, | |
| 859 | + authenticationTemplate: [apiKeyTemplate], | |
| 860 | + }); | |
| 861 | + yield* alice.executor.connections.create({ | |
| 862 | + owner: "user", | |
| 863 | + name: ConnectionName.make("mine"), | |
| 864 | + integration: IntegrationSlug.make("shared"), | |
| 865 | + template: AuthTemplateSlug.make("apiKey"), | |
| 866 | + value: "alice-key", | |
| 867 | + }); | |
| 868 | + | |
| 869 | + const bob = yield* makeTestWorkspaceHarness({ | |
| 870 | + plugins, | |
| 871 | + tenant, | |
| 872 | + subject: "bob", | |
| 873 | + dataDir, | |
| 874 | + }); | |
| 875 | + yield* bob.executor.connections.create({ | |
| 876 | + owner: "user", | |
| 877 | + name: ConnectionName.make("mine"), | |
| 878 | + integration: IntegrationSlug.make("shared"), | |
| 879 | + template: AuthTemplateSlug.make("apiKey"), | |
| 880 | + value: "bob-key", | |
| 881 | + }); | |
| 882 | + const bobToolNames = () => | |
| 883 | + Effect.map(bob.executor.tools.list(), (tools) => | |
| 884 | + tools | |
| 885 | + .filter((t) => String(t.address).startsWith("tools.shared.user.mine.")) | |
| 886 | + .map((t) => String(t.name)) | |
| 887 | + .sort(), | |
| 888 | + ); | |
| 889 | + expect(yield* bobToolNames()).toContain("items.queryRows"); | |
| 890 | + | |
| 891 | + // Alice updates the spec; queryRows disappears, widgets appears. Her | |
| 892 | + // update can only rebuild HER visible connections. | |
| 893 | + const EvolvedItems = HttpApiGroup.make("items").add( | |
| 894 | + HttpApiEndpoint.get("listItems", "/items", { success: Schema.Array(Item) }), | |
| 895 | + ); | |
| 896 | + const Widgets = HttpApiGroup.make("widgets").add( | |
| 897 | + HttpApiEndpoint.get("listWidgets", "/widgets", { success: Schema.Array(Item) }), | |
| 898 | + ); | |
| 899 | + yield* specServer.setApi(HttpApi.make("testApi").add(EvolvedItems).add(Widgets)); | |
| 900 | + yield* alice.executor.openapi.updateSpec("shared"); | |
| 901 | + | |
| 902 | + // Bob's next ordinary read converges his personal catalog — no | |
| 903 | + // remove/re-add, no action from Bob. | |
| 904 | + const bobAfter = yield* bobToolNames(); | |
| 905 | + expect(bobAfter).toContain("widgets.listWidgets"); | |
| 906 | + expect(bobAfter).not.toContain("items.queryRows"); | |
| 907 | + }), | |
| 908 | + ), | |
| 909 | + ); | |
| 910 | + | |
| 911 | + it.effect("updateSpec on an unknown slug fails with IntegrationNotFoundError", () => | |
| 912 | + Effect.gen(function* () { | |
| 913 | + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | |
| 914 | + const error = yield* executor.openapi.updateSpec("missing").pipe(Effect.flip); | |
| 915 | + expect(Predicate.isTagged(error, "IntegrationNotFoundError")).toBe(true); | |
| 916 | + }), | |
| 917 | + ); | |
| 918 | + | |
| 707 | 919 | // removed: the v1-only behaviours below have no v2 equivalent — |
| 708 | 920 | // - "adds an org source whose direct credentials are owned by the user scope" |
| 709 | 921 | // - "sources.configure removes bindings for credential slots no longer present" |
| @@ -5,6 +5,7 @@ import { HttpClient } from "effect/unstable/http"; · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | IntegrationAlreadyExistsError, |
| 7 | 7 | IntegrationDetectionResult, |
| 8 | + IntegrationNotFoundError, | |
| 8 | 9 | IntegrationSlug, |
| 9 | 10 | ToolName, |
| 10 | 11 | ToolResult, |
| @@ -149,7 +150,10 @@ export interface OpenApiSpecConfig { · 138 unmodified lines | ||
| 149 | 150 | readonly spec: OpenApiSpecInput; |
| 150 | 151 | /** The catalog slug for the new integration (the `<integration>` segment). */ |
| 151 | 152 | readonly slug: string; |
| 152 | − /** Human description (defaults to the spec title). */ | |
| 153 | + /** Display name (defaults to the spec title). */ | |
| 154 | + readonly name?: string; | |
| 155 | + /** Agent-visible description (defaults to the spec's `info.description`, | |
| 156 | + * then the title). */ | |
| 153 | 157 | readonly description?: string; |
| 154 | 158 | readonly baseUrl?: string; |
| 155 | 159 | /** Static headers applied to every request (no secret material). */ |
| @@ -176,6 +180,21 @@ export interface OpenApiConfigureInput { · 20 unmodified lines | ||
| 176 | 180 | readonly mode?: "merge" | "replace"; |
| 177 | 181 | } |
| 178 | 182 | |
| 183 | +/** What changed in the tool catalog when a spec was updated in place. Tool | |
| 184 | + * names, not addresses — the same diff applies to every connection. */ | |
| 185 | +export interface UpdateSpecResult { | |
| 186 | + readonly slug: IntegrationSlug; | |
| 187 | + readonly toolCount: number; | |
| 188 | + readonly addedTools: readonly string[]; | |
| 189 | + readonly removedTools: readonly string[]; | |
| 190 | +} | |
| 191 | + | |
| 192 | +export interface OpenApiUpdateSpecInput { | |
| 193 | + /** New spec source. Omit to re-fetch from the integration's stored | |
| 194 | + * `sourceUrl` / Google Discovery bundle URLs. */ | |
| 195 | + readonly spec?: OpenApiSpecInput; | |
| 196 | +} | |
| 197 | + | |
| 179 | 198 | export interface OpenApiPluginExtension { |
| 180 | 199 | readonly previewSpec: ( |
| 181 | 200 | input: string | OpenApiPreviewInput, |
| @@ -193,6 +212,20 @@ export interface OpenApiPluginExtension { · 11 unmodified lines | ||
| 193 | 212 | | IntegrationAlreadyExistsError |
| 194 | 213 | | StorageFailure |
| 195 | 214 | >; |
| 215 | + /** Re-resolve the integration's spec (from its stored source URL, or the | |
| 216 | + * provided input) and rebuild its tools IN PLACE — connections, | |
| 217 | + * credentials, policies, and the curated description are untouched. */ | |
| 218 | + readonly updateSpec: ( | |
| 219 | + slug: string, | |
| 220 | + input?: OpenApiUpdateSpecInput, | |
| 221 | + ) => Effect.Effect< | |
| 222 | + UpdateSpecResult, | |
| 223 | + | OpenApiParseError | |
| 224 | + | OpenApiExtractionError | |
| 225 | + | OpenApiOAuthError | |
| 226 | + | IntegrationNotFoundError | |
| 227 | + | StorageFailure | |
| 228 | + >; | |
| 196 | 229 | readonly removeSpec: (slug: string) => Effect.Effect<void, StorageFailure>; |
| 197 | 230 | readonly getIntegration: (slug: string) => Effect.Effect<Integration | null, StorageFailure>; |
| 198 | 231 | /** Read the integration's full opaque config, including its |
| @@ -560,6 +593,8 @@ interface CompiledSpec { · 361 unmodified lines | ||
| 560 | 593 | readonly definitions: readonly ToolDefinition[]; |
| 561 | 594 | readonly hoistedDefs: Record<string, unknown>; |
| 562 | 595 | readonly title: string | undefined; |
| 596 | + /** The spec's `info.description`. */ | |
| 597 | + readonly description: string | undefined; | |
| 563 | 598 | } |
| 564 | 599 | |
| 565 | 600 | const compileSpec = ( |
| @@ -578,6 +613,7 @@ const compileSpec = ( · 12 unmodified lines | ||
| 578 | 613 | definitions: compileToolDefinitions(result.operations), |
| 579 | 614 | hoistedDefs, |
| 580 | 615 | title: Option.getOrUndefined(result.title), |
| 616 | + description: Option.getOrUndefined(result.description), | |
| 581 | 617 | }; |
| 582 | 618 | }); |
| 583 | 619 | |
| @@ -791,7 +827,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 207 unmodified lines | ||
| 791 | 827 | Effect.gen(function* () { |
| 792 | 828 | yield* ctx.core.integrations.register({ |
| 793 | 829 | slug, |
| 794 | − description: config.description ?? compiled.title ?? config.slug, | |
| 830 | + name: config.name?.trim() || compiled.title || config.slug, | |
| 831 | + description: | |
| 832 | + config.description ?? compiled.description ?? compiled.title ?? config.slug, | |
| 795 | 833 | config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig, |
| 796 | 834 | canRemove: true, |
| 797 | 835 | canRefresh: |
| @@ -808,6 +846,112 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 10 unmodified lines | ||
| 808 | 846 | return { slug, toolCount: compiled.definitions.length }; |
| 809 | 847 | }); |
| 810 | 848 | |
| 849 | + // Update the spec IN PLACE: re-resolve (stored source URL / bundle, or a | |
| 850 | + // caller-supplied new input), recompile, swap the stored operations, and | |
| 851 | + // rebuild every connection's tools. Auth templates, base URL, headers, | |
| 852 | + // the curated description, connections, and policies are all untouched — | |
| 853 | + // this is the "spec changed upstream" path, not a re-add. | |
| 854 | + const updateSpec = (rawSlug: string, input?: OpenApiUpdateSpecInput) => | |
| 855 | + Effect.gen(function* () { | |
| 856 | + const slug = IntegrationSlug.make(rawSlug); | |
| 857 | + const record = yield* ctx.core.integrations.get(slug); | |
| 858 | + const current = record ? decodeOpenApiIntegrationConfig(record.config) : null; | |
| 859 | + if (!record || !current) { | |
| 860 | + return yield* new IntegrationNotFoundError({ slug }); | |
| 861 | + } | |
| 862 | + | |
| 863 | + // The new spec source: explicit input wins; otherwise re-fetch from | |
| 864 | + // where the spec originally came from. A pasted-blob integration has | |
| 865 | + // no origin, so updating it requires a new input. | |
| 866 | + const specInput: OpenApiSpecInput | null = | |
| 867 | + input?.spec ?? | |
| 868 | + (current.googleDiscoveryUrls | |
| 869 | + ? { kind: "googleDiscoveryBundle", urls: current.googleDiscoveryUrls } | |
| 870 | + : current.sourceUrl | |
| 871 | + ? isGoogleDiscoveryUrl(current.sourceUrl) | |
| 872 | + ? { kind: "googleDiscovery", url: current.sourceUrl } | |
| 873 | + : { kind: "url", url: current.sourceUrl } | |
| 874 | + : null); | |
| 875 | + if (specInput === null) { | |
| 876 | + return yield* new OpenApiParseError({ | |
| 877 | + message: | |
| 878 | + "This integration's spec was pasted inline and has no source URL to re-fetch. Provide the updated spec content.", | |
| 879 | + }); | |
| 880 | + } | |
| 881 | + | |
| 882 | + // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock | |
| 883 | + // rule as addSpec: never hold BEGIN across a network fetch). | |
| 884 | + const resolved = yield* resolveSpecForInput(specInput, httpClientLayer); | |
| 885 | + const compiled = yield* compileSpec(resolved.specText); | |
| 886 | + | |
| 887 | + const previousOperations = yield* ctx.storage.listOperations(rawSlug); | |
| 888 | + const previousNames = new Set(previousOperations.map((op) => op.toolName)); | |
| 889 | + const nextNames = new Set(compiled.definitions.map((def) => def.toolPath)); | |
| 890 | + | |
| 891 | + // The resolved spec text lives in the plugin blob store keyed by its | |
| 892 | + // content hash (`spec/<hash>`); the config carries only the hash. Put | |
| 893 | + // the blob outside the transaction — re-puts are idempotent and an | |
| 894 | + // aborted config update just leaves an unreferenced blob. | |
| 895 | + const specHash = yield* sha256Hex(resolved.specText); | |
| 896 | + yield* ctx.storage.putSpec(specHash, resolved.specText); | |
| 897 | + | |
| 898 | + const nextConfig: OpenApiIntegrationConfig = { | |
| 899 | + ...current, | |
| 900 | + specHash, | |
| 901 | + ...(specInputToSourceUrl(specInput) !== undefined | |
| 902 | + ? { sourceUrl: specInputToSourceUrl(specInput) } | |
| 903 | + : {}), | |
| 904 | + ...(specInputToGoogleBundle(specInput) !== undefined | |
| 905 | + ? { googleDiscoveryUrls: specInputToGoogleBundle(specInput) } | |
| 906 | + : {}), | |
| 907 | + }; | |
| 908 | + | |
| 909 | + yield* ctx.transaction( | |
| 910 | + Effect.gen(function* () { | |
| 911 | + yield* ctx.core.integrations.update(slug, { | |
| 912 | + config: nextConfig satisfies OpenApiIntegrationConfig as IntegrationConfig, | |
| 913 | + }); | |
| 914 | + yield* ctx.storage.putOperations( | |
| 915 | + rawSlug, | |
| 916 | + storedOperationsFromCompiled(rawSlug, compiled), | |
| 917 | + ); | |
| 918 | + }), | |
| 919 | + ); | |
| 920 | + | |
| 921 | + // Rebuild each connection's tool rows from the new spec. Outside the | |
| 922 | + // transaction: refresh opens its own, and a half-refreshed catalog | |
| 923 | + // self-heals on the next refresh anyway. | |
| 924 | + const connections = yield* ctx.connections.list({ integration: slug }); | |
| 925 | + yield* Effect.forEach( | |
| 926 | + connections, | |
| 927 | + (connection) => | |
| 928 | + ctx.connections | |
| 929 | + .refresh({ | |
| 930 | + owner: connection.owner, | |
| 931 | + integration: connection.integration, | |
| 932 | + name: connection.name, | |
| 933 | + }) | |
| 934 | + .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))), | |
| 935 | + { discard: true }, | |
| 936 | + ).pipe( | |
| 937 | + Effect.catchTag("IntegrationNotFoundError", () => Effect.void), | |
| 938 | + Effect.withSpan("openapi.plugin.update_spec.refresh_connections", { | |
| 939 | + attributes: { "openapi.connection_count": connections.length }, | |
| 940 | + }), | |
| 941 | + ); | |
| 942 | + | |
| 943 | + return { | |
| 944 | + slug, | |
| 945 | + toolCount: compiled.definitions.length, | |
| 946 | + addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(), | |
| 947 | + removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(), | |
| 948 | + } satisfies UpdateSpecResult; | |
| 949 | + }).pipe( | |
| 950 | + Effect.withSpan("openapi.plugin.update_spec", { | |
| 951 | + attributes: { "openapi.integration.slug": rawSlug }, | |
| 952 | + }), | |
| 953 | + ); | |
| 954 | + | |
| 811 | 955 | return { |
| 812 | 956 | previewSpec: (input: string | OpenApiPreviewInput) => |
| 813 | 957 | Effect.gen(function* () { |
| @@ -829,6 +973,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 15 unmodified lines | ||
| 829 | 973 | |
| 830 | 974 | addSpec, |
| 831 | 975 | |
| 976 | + updateSpec, | |
| 977 | + | |
| 832 | 978 | removeSpec: (slug: string) => |
| 833 | 979 | ctx.transaction( |
| 834 | 980 | Effect.gen(function* () { |
| @@ -135,6 +135,8 @@ export type PreviewOperation = typeof PreviewOperation.Type; · 134 unmodified lines | ||
| 135 | 135 | |
| 136 | 136 | export const SpecPreview = Schema.Struct({ |
| 137 | 137 | title: Schema.OptionFromOptional(Schema.String), |
| 138 | + /** The spec's `info.description` — prefills the add form's description field. */ | |
| 139 | + description: Schema.OptionFromOptional(Schema.String), | |
| 138 | 140 | version: Schema.OptionFromOptional(Schema.String), |
| 139 | 141 | /** Reuses ServerInfo from extraction */ |
| 140 | 142 | servers: Schema.Array(ServerInfo), |
| @@ -396,6 +398,7 @@ export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (s · 255 unmodified lines | ||
| 396 | 398 | |
| 397 | 399 | return SpecPreview.make({ |
| 398 | 400 | title: result.title, |
| 401 | + description: result.description, | |
| 399 | 402 | version: result.version, |
| 400 | 403 | servers: result.servers, |
| 401 | 404 | operationCount: result.operations.length, |
| @@ -144,6 +144,7 @@ describe("OpenAPI plugin — spec blob storage", () => { · 143 unmodified lines | ||
| 144 | 144 | plugin.resolveTools!({ |
| 145 | 145 | integration: { |
| 146 | 146 | slug: IntegrationSlug.make("pointer_api"), |
| 147 | + name: "pointer", | |
| 147 | 148 | description: "pointer", |
| 148 | 149 | kind: "openapi", |
| 149 | 150 | canRemove: true, |
| @@ -168,6 +168,8 @@ export type ExtractedOperation = typeof ExtractedOperation.Type; · 167 unmodified lines | ||
| 168 | 168 | |
| 169 | 169 | export const ExtractionResult = Schema.Struct({ |
| 170 | 170 | title: Schema.OptionFromOptional(Schema.String), |
| 171 | + /** The spec's `info.description` — the author's own summary of the API. */ | |
| 172 | + description: Schema.OptionFromOptional(Schema.String), | |
| 171 | 173 | version: Schema.OptionFromOptional(Schema.String), |
| 172 | 174 | servers: Schema.Array(ServerInfo), |
| 173 | 175 | operations: Schema.Array(ExtractedOperation), |
| @@ -141,6 +141,10 @@ export const createConnection = ExecutorApiClient.mutation("connections", "creat · 140 unmodified lines | ||
| 141 | 141 | |
| 142 | 142 | export const removeConnection = ExecutorApiClient.mutation("connections", "remove"); |
| 143 | 143 | |
| 144 | +/** Edit a connection's user-curated metadata (description, identityLabel). | |
| 145 | + * Pass `reactivityKeys: connectionWriteKeys` at the call site. */ | |
| 146 | +export const updateConnection = ExecutorApiClient.mutation("connections", "update"); | |
| 147 | + | |
| 144 | 148 | export const refreshConnection = ExecutorApiClient.mutation("connections", "refresh"); |
| 145 | 149 | |
| 146 | 150 | export const updateIntegration = ExecutorApiClient.mutation("integrations", "update"); |
| @@ -279,6 +283,7 @@ type CreateConnectionArg = { · 132 unmodified lines | ||
| 279 | 283 | readonly integration: IntegrationSlug; |
| 280 | 284 | readonly template: AuthTemplateSlug; |
| 281 | 285 | readonly identityLabel?: string | null; |
| 286 | + readonly description?: string | null; | |
| 282 | 287 | } & ( |
| 283 | 288 | | { readonly value: string } |
| 284 | 289 | | { readonly values: Record<string, string> } |
| @@ -314,6 +319,7 @@ export const addConnectionOptimistic = Atom.family((owner: Owner) => · 29 unmodified lines | ||
| 314 | 319 | `tools.${payload.integration}.${payload.owner}.${payload.name}`, |
| 315 | 320 | ), |
| 316 | 321 | identityLabel: payload.identityLabel ?? null, |
| 322 | + description: payload.description ?? null, | |
| 317 | 323 | expiresAt: null, |
| 318 | 324 | // Optimistic placeholder predates the server resolving which app (if |
| 319 | 325 | // any) minted this connection; reconciled on refresh. Matches the |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | ||
| 25 | 25 | } from "../plugins/oauth-reconnect"; |
| 26 | 26 | import { useOAuthPopupFlow } from "../plugins/oauth-sign-in"; |
| 27 | 27 | import { AddAccountModal } from "./add-account-modal"; |
| 28 | +import { ConnectionEditSheet } from "./metadata-edit-sheet"; | |
| 28 | 29 | import type { CreateCustomMethod } from "./add-custom-method-modal"; |
| 29 | 30 | import { Badge } from "./badge"; |
| 30 | 31 | import { Button } from "./button"; |
| @@ -48,8 +49,9 @@ import { · 17 unmodified lines | ||
| 48 | 49 | // --------------------------------------------------------------------------- |
| 49 | 50 | // Accounts section — the integration's connections, grouped by owner. |
| 50 | 51 | // |
| 51 | −// Accounts are IMMUTABLE: the per-row menu is Reconnect + Remove only (no | |
| 52 | −// rename, no method switch). "+ Add connection" opens the create modal. When both | |
| 52 | +// Credentials are IMMUTABLE (no method switch); the editable surface is the | |
| 53 | +// user-curated metadata — description (agent-visible) and account label — via | |
| 54 | +// the per-row Edit sheet. "+ Add connection" opens the create modal. When both | |
| 53 | 55 | // owners have zero accounts, the section collapses to a single empty CTA. |
| 54 | 56 | // --------------------------------------------------------------------------- |
| 55 | 57 | |
| @@ -61,6 +63,7 @@ function AccountRow(props: { · 5 unmodified lines | ||
| 61 | 63 | * reconnect to grant the newly-needed access (e.g. after a service was added). */ |
| 62 | 64 | readonly needsReconsent: boolean; |
| 63 | 65 | readonly showOwnerLabel: boolean; |
| 66 | + readonly onEdit: () => void; | |
| 64 | 67 | readonly onReconnect: () => void; |
| 65 | 68 | readonly onRemove: () => void; |
| 66 | 69 | }) { |
| @@ -84,6 +87,11 @@ function AccountRow(props: { · 17 unmodified lines | ||
| 84 | 87 | </Badge> |
| 85 | 88 | ) : null} |
| 86 | 89 | </CardStackEntryTitle> |
| 90 | + {connection.description && connection.description.length > 0 ? ( | |
| 91 | + <CardStackEntryDescription className="mt-1 text-xs"> | |
| 92 | + {connection.description} | |
| 93 | + </CardStackEntryDescription> | |
| 94 | + ) : null} | |
| 87 | 95 | {needsReconsent ? ( |
| 88 | 96 | <CardStackEntryDescription className="mt-1 text-xs text-amber-700 dark:text-amber-400"> |
| 89 | 97 | This integration now needs access this connection wasn't granted. |
| @@ -109,6 +117,9 @@ function AccountRow(props: { · 19 unmodified lines | ||
| 109 | 117 | </Button> |
| 110 | 118 | </DropdownMenuTrigger> |
| 111 | 119 | <DropdownMenuContent align="end" className="w-40"> |
| 120 | + <DropdownMenuItem className="text-sm" onClick={props.onEdit}> | |
| 121 | + Edit | |
| 122 | + </DropdownMenuItem> | |
| 112 | 123 | <DropdownMenuItem className="text-sm" onClick={props.onReconnect}> |
| 113 | 124 | Reconnect |
| 114 | 125 | </DropdownMenuItem> |
| @@ -127,6 +138,7 @@ function OwnerAccounts(props: { · 12 unmodified lines | ||
| 127 | 138 | readonly owner: Owner; |
| 128 | 139 | readonly showOwnerLabels: boolean; |
| 129 | 140 | readonly methods: readonly AuthMethod[]; |
| 141 | + readonly onEdit: (connection: Connection) => void; | |
| 130 | 142 | readonly onDcrReconnect: (connection: Connection) => void; |
| 131 | 143 | /** The integration's declared oauth scopes — compared against each connection's |
| 132 | 144 | * granted `oauthScope` to flag connections that must reconnect for new access. */ |
| @@ -274,6 +286,7 @@ function OwnerAccounts(props: { · 141 unmodified lines | ||
| 274 | 286 | connection={connection} |
| 275 | 287 | needsReconsent={connectionNeedsReconsent(connection, props.declaredScopes)} |
| 276 | 288 | showOwnerLabel={props.showOwnerLabels} |
| 289 | + onEdit={() => props.onEdit(connection)} | |
| 277 | 290 | onReconnect={() => void handleReconnect(connection)} |
| 278 | 291 | onRemove={() => void handleRemove(connection)} |
| 279 | 292 | /> |
| @@ -302,6 +315,7 @@ export function AccountsSection(props: { · 22 unmodified lines | ||
| 302 | 315 | removeCustomMethod, |
| 303 | 316 | } = props; |
| 304 | 317 | const [adding, setAdding] = useState(false); |
| 318 | + const [editingConnection, setEditingConnection] = useState<Connection | null>(null); | |
| 305 | 319 | const [reconnectHandoff, setReconnectHandoff] = useState<IntegrationAccountHandoff | null>(null); |
| 306 | 320 | const ownerDisplay = useOwnerDisplay(); |
| 307 | 321 | const canAddConnection = methods.length > 0 || createCustomMethod !== undefined; |
| @@ -419,6 +433,7 @@ export function AccountsSection(props: { · 111 unmodified lines | ||
| 419 | 433 | owner={owner} |
| 420 | 434 | showOwnerLabels={ownerDisplay.showOwnerLabels} |
| 421 | 435 | methods={methods} |
| 436 | + onEdit={setEditingConnection} | |
| 422 | 437 | onDcrReconnect={(connection: Connection) => { |
| 423 | 438 | setReconnectHandoff({ |
| 424 | 439 | key: `reconnect:${connection.owner}:${String(connection.integration)}:${String( |
| @@ -436,6 +451,13 @@ export function AccountsSection(props: { · 11 unmodified lines | ||
| 436 | 451 | )} |
| 437 | 452 | |
| 438 | 453 | {modal} |
| 454 | + | |
| 455 | + <ConnectionEditSheet | |
| 456 | + connection={editingConnection} | |
| 457 | + onOpenChange={(open: boolean) => { | |
| 458 | + if (!open) setEditingConnection(null); | |
| 459 | + }} | |
| 460 | + /> | |
| 439 | 461 | </section> |
| 440 | 462 | ); |
| 441 | 463 | } |
| @@ -54,7 +54,7 @@ export function CommandPalette() { · 53 unmodified lines | ||
| 54 | 54 | onSuccess: ({ value }) => |
| 55 | 55 | value.map((integration: Integration) => ({ |
| 56 | 56 | id: String(integration.slug), |
| 57 | − name: integration.description || String(integration.slug), | |
| 57 | + name: integration.name || String(integration.slug), | |
| 58 | 58 | kind: integration.kind, |
| 59 | 59 | url: integration.displayUrl, |
| 60 | 60 | })), |
| @@ -0,0 +1,296 @@ | ||
| 1 | +import { Suspense, useCallback, useEffect, useRef, useState, type ComponentType } from "react"; | |
| 2 | +import { useAtomSet } from "@effect/atom-react"; | |
| 3 | +import * as Exit from "effect/Exit"; | |
| 4 | +import { type Connection, type IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 5 | +import type { EditSheetApplyResult, EditSheetSectionProps } from "@executor-js/sdk/client"; | |
| 6 | +import { toast } from "sonner"; | |
| 7 | + | |
| 8 | +import { updateConnection, updateIntegration } from "../api/atoms"; | |
| 9 | +import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys"; | |
| 10 | +import { trackEvent } from "../api/analytics"; | |
| 11 | +import { messageFromExit } from "../api/error-reporting"; | |
| 12 | +import { Button } from "./button"; | |
| 13 | +import { Input } from "./input"; | |
| 14 | +import { Label } from "./label"; | |
| 15 | +import { | |
| 16 | + Sheet, | |
| 17 | + SheetContent, | |
| 18 | + SheetDescription, | |
| 19 | + SheetFooter, | |
| 20 | + SheetHeader, | |
| 21 | + SheetTitle, | |
| 22 | +} from "./sheet"; | |
| 23 | +import { Textarea } from "./textarea"; | |
| 24 | + | |
| 25 | +// --------------------------------------------------------------------------- | |
| 26 | +// Metadata edit sheets — the user-curated, AGENT-VISIBLE metadata for an | |
| 27 | +// integration and for a connection. The point of the sheet (vs. an inline | |
| 28 | +// input) is the preview: it shows the exact inventory line an agent reads, so | |
| 29 | +// editing the description is editing what the model sees. | |
| 30 | +// --------------------------------------------------------------------------- | |
| 31 | + | |
| 32 | +/** The connection's callable prefix under `tools.` — the line item agents see | |
| 33 | + * in the execute tool's "Available connection prefixes" inventory. */ | |
| 34 | +const connectionPrefix = (connection: Connection): string => { | |
| 35 | + const address = String(connection.address); | |
| 36 | + return address.startsWith("tools.") ? address.slice("tools.".length) : address; | |
| 37 | +}; | |
| 38 | + | |
| 39 | +function AgentPreview(props: { readonly label: string; readonly line: string }) { | |
| 40 | + return ( | |
| 41 | + <div className="space-y-1.5"> | |
| 42 | + <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> | |
| 43 | + {props.label} | |
| 44 | + </p> | |
| 45 | + <pre className="overflow-x-auto rounded-md border border-border/60 bg-muted/40 px-3 py-2 font-mono text-xs leading-relaxed whitespace-pre-wrap text-foreground"> | |
| 46 | + {props.line} | |
| 47 | + </pre> | |
| 48 | + </div> | |
| 49 | + ); | |
| 50 | +} | |
| 51 | + | |
| 52 | +export function ConnectionEditSheet(props: { | |
| 53 | + readonly connection: Connection | null; | |
| 54 | + readonly onOpenChange: (open: boolean) => void; | |
| 55 | +}) { | |
| 56 | + const { connection } = props; | |
| 57 | + const doUpdate = useAtomSet(updateConnection, { mode: "promiseExit" }); | |
| 58 | + const [description, setDescription] = useState(""); | |
| 59 | + const [identityLabel, setIdentityLabel] = useState(""); | |
| 60 | + const [saving, setSaving] = useState(false); | |
| 61 | + | |
| 62 | + useEffect(() => { | |
| 63 | + setDescription(connection?.description ?? ""); | |
| 64 | + setIdentityLabel(connection?.identityLabel ?? ""); | |
| 65 | + }, [connection]); | |
| 66 | + | |
| 67 | + const handleSave = async () => { | |
| 68 | + if (!connection) return; | |
| 69 | + setSaving(true); | |
| 70 | + const exit = await doUpdate({ | |
| 71 | + params: { | |
| 72 | + owner: connection.owner, | |
| 73 | + integration: connection.integration, | |
| 74 | + name: connection.name, | |
| 75 | + }, | |
| 76 | + payload: { | |
| 77 | + description: description.trim().length > 0 ? description.trim() : null, | |
| 78 | + identityLabel: identityLabel.trim().length > 0 ? identityLabel.trim() : null, | |
| 79 | + }, | |
| 80 | + reactivityKeys: connectionWriteKeys, | |
| 81 | + }); | |
| 82 | + setSaving(false); | |
| 83 | + if (Exit.isFailure(exit)) { | |
| 84 | + toast.error(messageFromExit(exit, "Failed to update connection")); | |
| 85 | + return; | |
| 86 | + } | |
| 87 | + props.onOpenChange(false); | |
| 88 | + }; | |
| 89 | + | |
| 90 | + const previewDescription = description.trim().split("\n", 1)[0]; | |
| 91 | + const prefix = connection ? connectionPrefix(connection) : ""; | |
| 92 | + | |
| 93 | + return ( | |
| 94 | + <Sheet open={connection !== null} onOpenChange={props.onOpenChange}> | |
| 95 | + <SheetContent side="right"> | |
| 96 | + <SheetHeader> | |
| 97 | + <SheetTitle>Edit connection</SheetTitle> | |
| 98 | + <SheetDescription> | |
| 99 | + The description is agent-visible: it rides this connection's prefix in the tool | |
| 100 | + inventory, so it is the place to say what this credential reaches and how to use it. | |
| 101 | + </SheetDescription> | |
| 102 | + </SheetHeader> | |
| 103 | + | |
| 104 | + <div className="flex-1 space-y-5 overflow-y-auto px-4"> | |
| 105 | + <div className="space-y-1.5"> | |
| 106 | + <Label htmlFor="connection-description">Description</Label> | |
| 107 | + <Textarea | |
| 108 | + id="connection-description" | |
| 109 | + value={description} | |
| 110 | + onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => | |
| 111 | + setDescription(e.target.value) | |
| 112 | + } | |
| 113 | + placeholder="e.g. Production CRM — reads only; use the sandbox connection for writes." | |
| 114 | + disabled={saving} | |
| 115 | + /> | |
| 116 | + </div> | |
| 117 | + | |
| 118 | + <div className="space-y-1.5"> | |
| 119 | + <Label htmlFor="connection-label">Account label</Label> | |
| 120 | + <Input | |
| 121 | + id="connection-label" | |
| 122 | + value={identityLabel} | |
| 123 | + onChange={(e: React.ChangeEvent<HTMLInputElement>) => | |
| 124 | + setIdentityLabel(e.target.value) | |
| 125 | + } | |
| 126 | + placeholder="Which account this credential belongs to" | |
| 127 | + disabled={saving} | |
| 128 | + /> | |
| 129 | + <p className="text-xs text-muted-foreground"> | |
| 130 | + Display-only; shown in the accounts list instead of the connection name. | |
| 131 | + </p> | |
| 132 | + </div> | |
| 133 | + | |
| 134 | + {connection ? ( | |
| 135 | + <AgentPreview | |
| 136 | + label="What agents see" | |
| 137 | + line={ | |
| 138 | + previewDescription ? `- \`${prefix}\` — ${previewDescription}` : `- \`${prefix}\`` | |
| 139 | + } | |
| 140 | + /> | |
| 141 | + ) : null} | |
| 142 | + </div> | |
| 143 | + | |
| 144 | + <SheetFooter> | |
| 145 | + <Button onClick={() => void handleSave()} disabled={saving || !connection}> | |
| 146 | + {saving ? "Saving..." : "Save"} | |
| 147 | + </Button> | |
| 148 | + <Button variant="outline" onClick={() => props.onOpenChange(false)} disabled={saving}> | |
| 149 | + Cancel | |
| 150 | + </Button> | |
| 151 | + </SheetFooter> | |
| 152 | + </SheetContent> | |
| 153 | + </Sheet> | |
| 154 | + ); | |
| 155 | +} | |
| 156 | + | |
| 157 | +export function IntegrationEditSheet(props: { | |
| 158 | + readonly slug: IntegrationSlug; | |
| 159 | + readonly open: boolean; | |
| 160 | + /** The integration's display name. */ | |
| 161 | + readonly name: string; | |
| 162 | + /** The integration's agent-visible description. */ | |
| 163 | + readonly description: string; | |
| 164 | + /** Plugin-owned configuration (e.g. OpenAPI spec update) rendered below the | |
| 165 | + * shared fields — the same surface the plugin's add flow configures. The | |
| 166 | + * section STAGES its change and reports an apply thunk via | |
| 167 | + * `onPendingChange`; the sheet's one Save runs it after the metadata | |
| 168 | + * update. */ | |
| 169 | + readonly pluginSection?: ComponentType<EditSheetSectionProps>; | |
| 170 | + readonly onOpenChange: (open: boolean) => void; | |
| 171 | +}) { | |
| 172 | + const doUpdate = useAtomSet(updateIntegration, { mode: "promiseExit" }); | |
| 173 | + const [nameDraft, setNameDraft] = useState(props.name); | |
| 174 | + const [descriptionDraft, setDescriptionDraft] = useState(props.description); | |
| 175 | + const [saving, setSaving] = useState(false); | |
| 176 | + // The plugin section's staged change — a thunk that applies it and resolves | |
| 177 | + // to a summary line. Held in a ref: it changes per keystroke in the section | |
| 178 | + // and is only read at Save. | |
| 179 | + const pendingPluginApply = useRef<(() => Promise<EditSheetApplyResult>) | null>(null); | |
| 180 | + const handlePendingChange = useCallback((apply: (() => Promise<EditSheetApplyResult>) | null) => { | |
| 181 | + pendingPluginApply.current = apply; | |
| 182 | + }, []); | |
| 183 | + | |
| 184 | + useEffect(() => { | |
| 185 | + if (props.open) { | |
| 186 | + setNameDraft(props.name); | |
| 187 | + setDescriptionDraft(props.description); | |
| 188 | + } | |
| 189 | + }, [props.open, props.name, props.description]); | |
| 190 | + | |
| 191 | + const handleSave = async () => { | |
| 192 | + const nextName = nameDraft.trim(); | |
| 193 | + if (nextName.length === 0) return; | |
| 194 | + setSaving(true); | |
| 195 | + const exit = await doUpdate({ | |
| 196 | + params: { slug: props.slug }, | |
| 197 | + payload: { name: nextName, description: descriptionDraft.trim() }, | |
| 198 | + reactivityKeys: integrationWriteKeys, | |
| 199 | + }); | |
| 200 | + trackEvent("integration_renamed", { | |
| 201 | + integration_slug: String(props.slug), | |
| 202 | + success: Exit.isSuccess(exit), | |
| 203 | + }); | |
| 204 | + if (Exit.isFailure(exit)) { | |
| 205 | + setSaving(false); | |
| 206 | + toast.error(messageFromExit(exit, "Failed to update integration")); | |
| 207 | + return; | |
| 208 | + } | |
| 209 | + // Then the plugin section's staged change (e.g. the spec update). On | |
| 210 | + // failure the section shows its own error inline — keep the sheet open. | |
| 211 | + const apply = pendingPluginApply.current; | |
| 212 | + if (apply) { | |
| 213 | + const result = await apply(); | |
| 214 | + if (!result.ok) { | |
| 215 | + setSaving(false); | |
| 216 | + return; | |
| 217 | + } | |
| 218 | + if (result.summary) toast.success(result.summary); | |
| 219 | + } | |
| 220 | + setSaving(false); | |
| 221 | + props.onOpenChange(false); | |
| 222 | + }; | |
| 223 | + | |
| 224 | + const slug = String(props.slug); | |
| 225 | + const preview = descriptionDraft.trim().split("\n", 1)[0]; | |
| 226 | + | |
| 227 | + return ( | |
| 228 | + <Sheet open={props.open} onOpenChange={props.onOpenChange}> | |
| 229 | + <SheetContent side="right"> | |
| 230 | + <SheetHeader> | |
| 231 | + <SheetTitle>Edit integration</SheetTitle> | |
| 232 | + <SheetDescription> | |
| 233 | + The name is what people see; the description is agent-visible context — agents read it | |
| 234 | + when browsing sources and as fallback context on connections without one of their own. | |
| 235 | + </SheetDescription> | |
| 236 | + </SheetHeader> | |
| 237 | + | |
| 238 | + <div className="flex-1 space-y-5 overflow-y-auto px-4"> | |
| 239 | + <div className="space-y-1.5"> | |
| 240 | + <Label htmlFor="integration-name">Name</Label> | |
| 241 | + <Input | |
| 242 | + id="integration-name" | |
| 243 | + value={nameDraft} | |
| 244 | + onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameDraft(e.target.value)} | |
| 245 | + placeholder="e.g. GitHub" | |
| 246 | + disabled={saving} | |
| 247 | + /> | |
| 248 | + </div> | |
| 249 | + | |
| 250 | + <div className="space-y-1.5"> | |
| 251 | + <Label htmlFor="integration-description">Description</Label> | |
| 252 | + <Textarea | |
| 253 | + id="integration-description" | |
| 254 | + value={descriptionDraft} | |
| 255 | + onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => | |
| 256 | + setDescriptionDraft(e.target.value) | |
| 257 | + } | |
| 258 | + onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => { | |
| 259 | + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void handleSave(); | |
| 260 | + }} | |
| 261 | + placeholder="What this API is and when to reach for it" | |
| 262 | + disabled={saving} | |
| 263 | + /> | |
| 264 | + </div> | |
| 265 | + | |
| 266 | + <AgentPreview | |
| 267 | + label="What agents see" | |
| 268 | + line={ | |
| 269 | + preview && preview.toLowerCase() !== slug.toLowerCase() | |
| 270 | + ? `{ id: "${slug}", description: "${preview}", ... }` | |
| 271 | + : `{ id: "${slug}", ... }` | |
| 272 | + } | |
| 273 | + /> | |
| 274 | + | |
| 275 | + {props.pluginSection ? ( | |
| 276 | + <Suspense fallback={null}> | |
| 277 | + <props.pluginSection sourceId={slug} onPendingChange={handlePendingChange} /> | |
| 278 | + </Suspense> | |
| 279 | + ) : null} | |
| 280 | + </div> | |
| 281 | + | |
| 282 | + <SheetFooter> | |
| 283 | + <Button | |
| 284 | + onClick={() => void handleSave()} | |
| 285 | + disabled={saving || nameDraft.trim().length === 0} | |
| 286 | + > | |
| 287 | + {saving ? "Saving..." : "Save"} | |
| 288 | + </Button> | |
| 289 | + <Button variant="outline" onClick={() => props.onOpenChange(false)} disabled={saving}> | |
| 290 | + Cancel | |
| 291 | + </Button> | |
| 292 | + </SheetFooter> | |
| 293 | + </SheetContent> | |
| 294 | + </Sheet> | |
| 295 | + ); | |
| 296 | +} | |
| @@ -142,7 +142,7 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { · 141 unmodified lines | ||
| 142 | 142 | <div className="flex flex-col gap-px"> |
| 143 | 143 | {value.map((integration: Integration) => { |
| 144 | 144 | const slug = String(integration.slug); |
| 145 | − const name = integration.description || slug; | |
| 145 | + const name = integration.name || slug; | |
| 146 | 146 | const detailPath = `/integrations/${slug}`; |
| 147 | 147 | const active = |
| 148 | 148 | props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); |
| @@ -20,19 +20,18 @@ import { · 19 unmodified lines | ||
| 20 | 20 | policiesOptimisticAtom, |
| 21 | 21 | refreshConnection, |
| 22 | 22 | removeIntegrationOptimistic, |
| 23 | − updateIntegration, | |
| 24 | 23 | } from "../api/atoms"; |
| 25 | 24 | import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys"; |
| 26 | 25 | import { ToolTree } from "../components/tool-tree"; |
| 27 | 26 | import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; |
| 28 | 27 | import type { ToolSummary } from "../components/tool-tree"; |
| 29 | 28 | import { AccountsSection } from "../components/accounts-section"; |
| 29 | +import { IntegrationEditSheet } from "../components/metadata-edit-sheet"; | |
| 30 | 30 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs"; |
| 31 | 31 | import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements"; |
| 32 | 32 | import { usePolicyActions } from "../hooks/use-policy-actions"; |
| 33 | 33 | import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client"; |
| 34 | 34 | import { Button } from "../components/button"; |
| 35 | −import { Input } from "../components/input"; | |
| 36 | 35 | import { Skeleton } from "../components/skeleton"; |
| 37 | 36 | |
| 38 | 37 | // v2: the route's `namespace` param is the integration slug. Tools belong to |
| @@ -64,7 +63,6 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 25 unmodified lines | ||
| 64 | 63 | const refreshTools = useAtomRefresh(integrationToolsAllAtom(slug)); |
| 65 | 64 | const doRemove = useAtomSet(removeIntegrationOptimistic, { mode: "promiseExit" }); |
| 66 | 65 | const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" }); |
| 67 | − const doUpdate = useAtomSet(updateIntegration, { mode: "promiseExit" }); | |
| 68 | 66 | // Policies are owner-partitioned on write; the integration policy menu writes |
| 69 | 67 | // Workspace (org) rules, preserving the prior default behavior. |
| 70 | 68 | const policyActions = usePolicyActions("org"); |
| @@ -87,10 +85,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 16 unmodified lines | ||
| 87 | 85 | const [confirmDelete, setConfirmDelete] = useState(false); |
| 88 | 86 | const [deleting, setDeleting] = useState(false); |
| 89 | 87 | const [refreshing, setRefreshing] = useState(false); |
| 90 | − const [editing, setEditing] = useState(false); | |
| 91 | − const [renaming, setRenaming] = useState(false); | |
| 92 | − const [nameDraft, setNameDraft] = useState(""); | |
| 93 | − const [savingName, setSavingName] = useState(false); | |
| 88 | + const [editSheetOpen, setEditSheetOpen] = useState(false); | |
| 94 | 89 | const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts"); |
| 95 | 90 | const [locationSearch] = useState(() => |
| 96 | 91 | typeof window === "undefined" ? "" : window.location.search, |
| @@ -98,7 +93,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 1 unmodified line | ||
| 98 | 93 | |
| 99 | 94 | useEffect(() => { |
| 100 | 95 | setConfirmDelete(false); |
| 101 | − setRenaming(false); | |
| 96 | + setEditSheetOpen(false); | |
| 102 | 97 | }, [namespace]); |
| 103 | 98 | |
| 104 | 99 | const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null; |
| @@ -319,68 +314,15 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 214 unmodified lines | ||
| 319 | 314 | setRefreshing(false); |
| 320 | 315 | }; |
| 321 | 316 | |
| 322 | − const handleEditSave = () => { | |
| 323 | − setEditing(false); | |
| 324 | − }; | |
| 325 | − | |
| 326 | − const startRename = () => { | |
| 327 | − setNameDraft(integrationData?.description || namespace); | |
| 328 | − setRenaming(true); | |
| 329 | − }; | |
| 330 | − | |
| 331 | − const cancelRename = () => { | |
| 332 | − setRenaming(false); | |
| 333 | − setNameDraft(""); | |
| 334 | − }; | |
| 335 | − | |
| 336 | − // Inline rename of the integration's description. Plugin-agnostic: writes the | |
| 337 | − // core `integrations.update` mutation; the catalog list query refreshes via | |
| 338 | − // `integrationWriteKeys` so the derived `integrationAtom` reflects the change. | |
| 339 | − const handleRenameSave = async () => { | |
| 340 | − const next = nameDraft.trim(); | |
| 341 | − if (!integrationData || next.length === 0 || next === integrationData.description) { | |
| 342 | − cancelRename(); | |
| 343 | − return; | |
| 344 | − } | |
| 345 | − setSavingName(true); | |
| 346 | − const exit = await doUpdate({ | |
| 347 | − params: { slug }, | |
| 348 | − payload: { description: next }, | |
| 349 | − reactivityKeys: integrationWriteKeys, | |
| 350 | − }); | |
| 351 | − setSavingName(false); | |
| 352 | − trackEvent("integration_renamed", { | |
| 353 | − integration_slug: String(slug), | |
| 354 | − success: Exit.isSuccess(exit), | |
| 355 | − }); | |
| 356 | − if (Exit.isFailure(exit)) return; | |
| 357 | − cancelRename(); | |
| 358 | − }; | |
| 359 | − | |
| 360 | 317 | return ( |
| 361 | 318 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> |
| 362 | 319 | {/* Header bar */} |
| 363 | 320 | <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm"> |
| 364 | 321 | <div className="flex min-w-0 items-center gap-3"> |
| 365 | − {renaming ? ( | |
| 366 | − <Input | |
| 367 | − autoFocus | |
| 368 | − value={nameDraft} | |
| 369 | − onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameDraft(e.target.value)} | |
| 370 | − onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => { | |
| 371 | − if (e.key === "Enter") void handleRenameSave(); | |
| 372 | − if (e.key === "Escape") cancelRename(); | |
| 373 | − }} | |
| 374 | − disabled={savingName} | |
| 375 | − aria-label="Integration name" | |
| 376 | − className="h-7 w-56 text-sm" | |
| 377 | − /> | |
| 378 | − ) : ( | |
| 379 | − <h2 className="truncate text-sm font-semibold text-foreground"> | |
| 380 | − {integrationData?.description || namespace} | |
| 381 | − </h2> | |
| 382 | − )} | |
| 383 | − {AsyncResult.isSuccess(tools) && !editing && !renaming && ( | |
| 322 | + <h2 className="truncate text-sm font-semibold text-foreground"> | |
| 323 | + {integrationData?.name || namespace} | |
| 324 | + </h2> | |
| 325 | + {AsyncResult.isSuccess(tools) && ( | |
| 384 | 326 | <span className="hidden text-xs tabular-nums text-muted-foreground sm:block"> |
| 385 | 327 | {distinctToolCount} {distinctToolCount === 1 ? "tool" : "tools"} |
| 386 | 328 | </span> |
| @@ -388,33 +330,13 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 1 unmodified line | ||
| 388 | 330 | </div> |
| 389 | 331 | |
| 390 | 332 | <div className="flex shrink-0 items-center gap-2"> |
| 391 | − {renaming ? ( | |
| 392 | − <> | |
| 393 | − <Button variant="outline" size="sm" onClick={cancelRename} disabled={savingName}> | |
| 394 | − Cancel | |
| 395 | − </Button> | |
| 396 | − <Button size="sm" onClick={() => void handleRenameSave()} disabled={savingName}> | |
| 397 | − {savingName ? "Saving..." : "Save"} | |
| 398 | − </Button> | |
| 399 | − </> | |
| 400 | − ) : ( | |
| 401 | − !editing && | |
| 402 | − !confirmDelete && | |
| 403 | − !isBuiltInIntegration && | |
| 404 | − integrationData && ( | |
| 405 | − <Button variant="outline" size="sm" onClick={startRename}> | |
| 406 | − Edit | |
| 407 | − </Button> | |
| 408 | − ) | |
| 409 | − )} | |
| 410 | − | |
| 411 | − {editing && ( | |
| 412 | − <Button variant="outline" size="sm" onClick={() => setEditing(false)}> | |
| 413 | − Back to tools | |
| 333 | + {!confirmDelete && !isBuiltInIntegration && integrationData && ( | |
| 334 | + <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}> | |
| 335 | + Edit | |
| 414 | 336 | </Button> |
| 415 | 337 | )} |
| 416 | 338 | |
| 417 | − {canRefresh && !editing && !renaming && ( | |
| 339 | + {canRefresh && ( | |
| 418 | 340 | <Button |
| 419 | 341 | variant="outline" |
| 420 | 342 | size="sm" |
| @@ -426,8 +348,6 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 5 unmodified lines | ||
| 426 | 348 | )} |
| 427 | 349 | |
| 428 | 350 | {canRemove && |
| 429 | − !editing && | |
| 430 | − !renaming && | |
| 431 | 351 | (confirmDelete ? ( |
| 432 | 352 | <div className="flex items-center gap-2"> |
| 433 | 353 | <Button |
| @@ -460,118 +380,106 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 26 unmodified lines | ||
| 460 | 380 | </div> |
| 461 | 381 | </div> |
| 462 | 382 | |
| 463 | − {/* Edit view */} | |
| 464 | − {editing && editPlugin ? ( | |
| 465 | − <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 466 | − <div className="mx-auto max-w-2xl px-6 py-8"> | |
| 467 | − <Suspense fallback={<EditFormSkeleton />}> | |
| 468 | − <editPlugin.edit sourceId={namespace} onSave={handleEditSave} /> | |
| 469 | − </Suspense> | |
| 470 | − </div> | |
| 383 | + <Tabs | |
| 384 | + value={currentTab} | |
| 385 | + onValueChange={(value: string) => setActiveTab(value as "accounts" | "tools")} | |
| 386 | + className="min-h-0 flex-1 gap-0 overflow-hidden" | |
| 387 | + > | |
| 388 | + <div className="shrink-0 border-b border-border/60 px-4 py-2"> | |
| 389 | + <TabsList variant="line"> | |
| 390 | + {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>} | |
| 391 | + <TabsTrigger value="tools">Tools</TabsTrigger> | |
| 392 | + </TabsList> | |
| 471 | 393 | </div> |
| 472 | − ) : ( | |
| 473 | − <Tabs | |
| 474 | − value={currentTab} | |
| 475 | − onValueChange={(value: string) => setActiveTab(value as "accounts" | "tools")} | |
| 476 | − className="min-h-0 flex-1 gap-0 overflow-hidden" | |
| 477 | − > | |
| 478 | − <div className="shrink-0 border-b border-border/60 px-4 py-2"> | |
| 479 | − <TabsList variant="line"> | |
| 480 | − {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>} | |
| 481 | − <TabsTrigger value="tools">Tools</TabsTrigger> | |
| 482 | − </TabsList> | |
| 483 | − </div> | |
| 484 | 394 | |
| 485 | − {/* Hub: integration-level auth methods + accounts. Plugins that | |
| 395 | + {/* Hub: integration-level auth methods + accounts. Plugins that | |
| 486 | 396 | declare auth methods fill the `accounts` slot (real methods from |
| 487 | 397 | the plugin's config); otherwise we render the generic fallback. */} |
| 488 | − {!isBuiltInIntegration && ( | |
| 489 | − <TabsContent value="accounts" className="min-h-0 overflow-y-auto"> | |
| 490 | − {editPlugin?.accounts ? ( | |
| 491 | − <Suspense fallback={<AccountsSkeleton />}> | |
| 492 | − <editPlugin.accounts | |
| 493 | − sourceId={namespace} | |
| 494 | − integrationName={integrationData?.description || namespace} | |
| 495 | − accountHandoff={accountHandoff} | |
| 496 | − /> | |
| 497 | − </Suspense> | |
| 498 | − ) : ( | |
| 499 | − <div className="mx-auto max-w-3xl space-y-8 px-6 py-8"> | |
| 500 | − <AccountsSection | |
| 501 | − integration={slug} | |
| 502 | − integrationName={integrationData?.description || namespace} | |
| 503 | − methods={accountsMethods} | |
| 504 | − accountHandoff={accountHandoff} | |
| 505 | − /> | |
| 506 | − </div> | |
| 507 | − )} | |
| 508 | − </TabsContent> | |
| 509 | − )} | |
| 510 | − | |
| 511 | − {/* Tools -- split pane (unchanged behavior) */} | |
| 512 | − <TabsContent | |
| 513 | − value="tools" | |
| 514 | − className="flex min-h-0 flex-col overflow-hidden data-[state=inactive]:hidden" | |
| 515 | − > | |
| 516 | − {editPlugin?.summary && ( | |
| 517 | − <Suspense fallback={null}> | |
| 518 | − <editPlugin.summary | |
| 398 | + {!isBuiltInIntegration && ( | |
| 399 | + <TabsContent value="accounts" className="min-h-0 overflow-y-auto"> | |
| 400 | + {editPlugin?.accounts ? ( | |
| 401 | + <Suspense fallback={<AccountsSkeleton />}> | |
| 402 | + <editPlugin.accounts | |
| 519 | 403 | sourceId={namespace} |
| 520 | − variant="panel" | |
| 521 | − onAction={() => setEditing(true)} | |
| 404 | + integrationName={integrationData?.name || namespace} | |
| 405 | + accountHandoff={accountHandoff} | |
| 522 | 406 | /> |
| 523 | 407 | </Suspense> |
| 408 | + ) : ( | |
| 409 | + <div className="mx-auto max-w-3xl space-y-8 px-6 py-8"> | |
| 410 | + <AccountsSection | |
| 411 | + integration={slug} | |
| 412 | + integrationName={integrationData?.name || namespace} | |
| 413 | + methods={accountsMethods} | |
| 414 | + accountHandoff={accountHandoff} | |
| 415 | + /> | |
| 416 | + </div> | |
| 524 | 417 | )} |
| 418 | + </TabsContent> | |
| 419 | + )} | |
| 420 | + | |
| 421 | + {/* Tools -- split pane (unchanged behavior) */} | |
| 422 | + <TabsContent | |
| 423 | + value="tools" | |
| 424 | + className="flex min-h-0 flex-col overflow-hidden data-[state=inactive]:hidden" | |
| 425 | + > | |
| 426 | + {AsyncResult.match(tools, { | |
| 427 | + onInitial: () => <IntegrationDetailSkeleton />, | |
| 428 | + onFailure: () => ( | |
| 429 | + <div className="p-6 text-sm text-destructive">Failed to load tools</div> | |
| 430 | + ), | |
| 431 | + onSuccess: () => ( | |
| 432 | + <div className="flex min-h-0 flex-1 overflow-hidden"> | |
| 433 | + {/* Left: tool tree */} | |
| 434 | + <div className="flex w-72 shrink-0 flex-col border-r border-border/60 lg:w-80 xl:w-[22rem]"> | |
| 435 | + <ToolTree | |
| 436 | + tools={integrationTools} | |
| 437 | + selectedToolId={selectedToolId} | |
| 438 | + onSelect={setSelectedToolId} | |
| 439 | + onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | |
| 440 | + onClearPolicy={(pattern) => void policyActions.clear(pattern)} | |
| 441 | + policies={sortedPolicies} | |
| 442 | + groupByConnection={!isBuiltInIntegration} | |
| 443 | + /> | |
| 444 | + </div> | |
| 525 | 445 | |
| 526 | − {AsyncResult.match(tools, { | |
| 527 | − onInitial: () => <IntegrationDetailSkeleton />, | |
| 528 | − onFailure: () => ( | |
| 529 | − <div className="p-6 text-sm text-destructive">Failed to load tools</div> | |
| 530 | − ), | |
| 531 | − onSuccess: () => ( | |
| 532 | − <div className="flex min-h-0 flex-1 overflow-hidden"> | |
| 533 | − {/* Left: tool tree */} | |
| 534 | − <div className="flex w-72 shrink-0 flex-col border-r border-border/60 lg:w-80 xl:w-[22rem]"> | |
| 535 | − <ToolTree | |
| 536 | − tools={integrationTools} | |
| 537 | − selectedToolId={selectedToolId} | |
| 538 | − onSelect={setSelectedToolId} | |
| 446 | + {/* Right: tool detail with Schema · TypeScript · Run tabs */} | |
| 447 | + <div className="flex min-w-0 flex-1 flex-col overflow-hidden"> | |
| 448 | + {selectedTool && selectedAddress && selectedBareName ? ( | |
| 449 | + <ToolDetail | |
| 450 | + address={selectedAddress} | |
| 451 | + toolName={selectedTool.name} | |
| 452 | + staticTool={selection?.static} | |
| 453 | + policy={selectedTool.policy} | |
| 539 | 454 | onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} |
| 540 | 455 | onClearPolicy={(pattern) => void policyActions.clear(pattern)} |
| 541 | − policies={sortedPolicies} | |
| 542 | − groupByConnection={!isBuiltInIntegration} | |
| 456 | + {...(!selection?.static && selectedBareName | |
| 457 | + ? { | |
| 458 | + integration: slug, | |
| 459 | + runToolName: selectedBareName, | |
| 460 | + connections: integrationConnections, | |
| 461 | + initialConnectionName: selection?.connection ?? null, | |
| 462 | + } | |
| 463 | + : {})} | |
| 543 | 464 | /> |
| 544 | − </div> | |
| 545 | − | |
| 546 | − {/* Right: tool detail with Schema · TypeScript · Run tabs */} | |
| 547 | − <div className="flex min-w-0 flex-1 flex-col overflow-hidden"> | |
| 548 | − {selectedTool && selectedAddress && selectedBareName ? ( | |
| 549 | − <ToolDetail | |
| 550 | − address={selectedAddress} | |
| 551 | − toolName={selectedTool.name} | |
| 552 | − staticTool={selection?.static} | |
| 553 | − policy={selectedTool.policy} | |
| 554 | − onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | |
| 555 | − onClearPolicy={(pattern) => void policyActions.clear(pattern)} | |
| 556 | − {...(!selection?.static && selectedBareName | |
| 557 | − ? { | |
| 558 | − integration: slug, | |
| 559 | − runToolName: selectedBareName, | |
| 560 | − connections: integrationConnections, | |
| 561 | − initialConnectionName: selection?.connection ?? null, | |
| 562 | − } | |
| 563 | − : {})} | |
| 564 | − /> | |
| 565 | − ) : ( | |
| 566 | − <ToolDetailEmpty hasTools={integrationTools.length > 0} /> | |
| 567 | − )} | |
| 568 | − </div> | |
| 465 | + ) : ( | |
| 466 | + <ToolDetailEmpty hasTools={integrationTools.length > 0} /> | |
| 467 | + )} | |
| 569 | 468 | </div> |
| 570 | − ), | |
| 571 | − })} | |
| 572 | − </TabsContent> | |
| 573 | − </Tabs> | |
| 574 | − )} | |
| 469 | + </div> | |
| 470 | + ), | |
| 471 | + })} | |
| 472 | + </TabsContent> | |
| 473 | + </Tabs> | |
| 474 | + | |
| 475 | + <IntegrationEditSheet | |
| 476 | + slug={slug} | |
| 477 | + open={editSheetOpen} | |
| 478 | + name={integrationData?.name || namespace} | |
| 479 | + description={integrationData?.description ?? ""} | |
| 480 | + {...(editPlugin?.editSheet ? { pluginSection: editPlugin.editSheet } : {})} | |
| 481 | + onOpenChange={setEditSheetOpen} | |
| 482 | + /> | |
| 575 | 483 | </div> |
| 576 | 484 | ); |
| 577 | 485 | } |
| @@ -623,26 +531,3 @@ function AccountsSkeleton() { · 45 unmodified lines | ||
| 623 | 531 | </div> |
| 624 | 532 | ); |
| 625 | 533 | } |
| 626 | − | |
| 627 | −function EditFormSkeleton() { | |
| 628 | − return ( | |
| 629 | − <div className="flex flex-col gap-5 p-6"> | |
| 630 | − <div className="flex flex-col gap-2"> | |
| 631 | − <Skeleton className="h-4 w-20" /> | |
| 632 | − <Skeleton className="h-9 w-full rounded-md" /> | |
| 633 | − </div> | |
| 634 | − <div className="flex flex-col gap-2"> | |
| 635 | − <Skeleton className="h-4 w-24" /> | |
| 636 | − <Skeleton className="h-9 w-full rounded-md" /> | |
| 637 | − </div> | |
| 638 | − <div className="flex flex-col gap-2"> | |
| 639 | − <Skeleton className="h-4 w-28" /> | |
| 640 | − <Skeleton className="h-24 w-full rounded-md" /> | |
| 641 | − </div> | |
| 642 | − <div className="flex justify-end gap-2 pt-2"> | |
| 643 | − <Skeleton className="h-9 w-20 rounded-md" /> | |
| 644 | − <Skeleton className="h-9 w-24 rounded-md" /> | |
| 645 | − </div> | |
| 646 | − </div> | |
| 647 | − ); | |
| 648 | −} | |
| @@ -435,7 +435,7 @@ function IntegrationGrid(props: { integrations: readonly Integration[] }) { · 434 unmodified lines | ||
| 435 | 435 | const plugin = pluginByKind.get(pluginKey); |
| 436 | 436 | const SummaryComponent = plugin?.summary; |
| 437 | 437 | const slug = String(integration.slug); |
| 438 | − const name = integration.description || slug; | |
| 438 | + const name = integration.name || slug; | |
| 439 | 439 | return ( |
| 440 | 440 | <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}> |
| 441 | 441 | <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: slug }}> |