* 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 | "when": 1781375837041, | 36 | "when": 1781375837041, |
| 37 | "tag": "0004_nervous_quasar", | 37 | "tag": "0004_nervous_quasar", |
| 38 | "breakpoints": true | 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 | "sonner": "^2.0.7" | 76 | "sonner": "^2.0.7" |
| 77 | }, | 77 | }, |
| 78 | "devDependencies": { | 78 | "devDependencies": { |
| 79 | "@cloudflare/vitest-pool-workers": "^0.15.0", | ||
| 79 | "@cloudflare/workers-types": "^4.20250620.0", | 80 | "@cloudflare/workers-types": "^4.20250620.0", |
| 80 | "@effect/platform-node": "catalog:", | 81 | "@effect/platform-node": "catalog:", |
| 81 | "@effect/vitest": "catalog:", | 82 | "@effect/vitest": "catalog:", |
| @@ -15,7 +15,9 @@ export const integration = pgTable( · 14 unmodified lines | |||
| 15 | { | 15 | { |
| 16 | slug: varchar("slug", { length: 255 }).notNull(), | 16 | slug: varchar("slug", { length: 255 }).notNull(), |
| 17 | plugin_id: text("plugin_id").notNull(), | 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 | config: json("config"), | 21 | config: json("config"), |
| 20 | can_remove: boolean("can_remove").notNull().default(true), | 22 | can_remove: boolean("can_remove").notNull().default(true), |
| 21 | can_refresh: boolean("can_refresh").notNull().default(false), | 23 | can_refresh: boolean("can_refresh").notNull().default(false), |
| @@ -39,6 +41,8 @@ export const connection = pgTable( · 17 unmodified lines | |||
| 39 | provider: text("provider").notNull(), | 41 | provider: text("provider").notNull(), |
| 40 | item_ids: json("item_ids").notNull(), | 42 | item_ids: json("item_ids").notNull(), |
| 41 | identity_label: text("identity_label"), | 43 | identity_label: text("identity_label"), |
| 44 | description: text("description"), | ||
| 45 | tools_synced_at: bigint("tools_synced_at", { mode: "bigint" }), | ||
| 42 | oauth_client: text("oauth_client"), | 46 | oauth_client: text("oauth_client"), |
| 43 | oauth_client_owner: text("oauth_client_owner"), | 47 | oauth_client_owner: text("oauth_client_owner"), |
| 44 | refresh_item_id: text("refresh_item_id"), | 48 | refresh_item_id: text("refresh_item_id"), |
| @@ -4,7 +4,7 @@ import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql"; · 3 unmodified lines | |||
| 4 | import { type FumaDB } from "@executor-js/fumadb"; | 4 | import { type FumaDB } from "@executor-js/fumadb"; |
| 5 | import { | 5 | import { |
| 6 | createDrizzleRuntimeSchemaFromTables, | 6 | createDrizzleRuntimeSchemaFromTables, |
| 7 | createDrizzleRuntimeSchemaSqlFromTables, | 7 | ensureDrizzleRuntimeSchemaFromTables, |
| 8 | } from "@executor-js/fumadb/adapters/drizzle"; | 8 | } from "@executor-js/fumadb/adapters/drizzle"; |
| 9 | import { type schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/schema"; | 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 | const drizzleDb = drizzle({ client, schema }); | 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 | tables: options.tables, | 58 | tables: options.tables, |
| 54 | namespace: options.namespace, | 59 | namespace: options.namespace, |
| 55 | version, | 60 | version, |
| 56 | provider: "sqlite", | 61 | provider: "sqlite", |
| 57 | })) { | 62 | }); |
| 58 | await client.execute(statement); | ||
| 59 | } | ||
| 60 | 63 | ||
| 61 | // Defensive column adds for libSQL files created by earlier v2 baselines — | 64 | // 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 | 65 | // The generic bring-up above already evolves every nullable column the live |
| 63 | // an already-created table. Idempotent. | 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 | const connectionColumns = await client.execute("PRAGMA table_info('connection')"); | 69 | const connectionColumns = await client.execute("PRAGMA table_info('connection')"); |
| 65 | if ( | 70 | if ( |
| 66 | connectionColumns.rows.length > 0 && | 71 | connectionColumns.rows.length > 0 && |
| @@ -100,6 +100,7 @@ · 99 unmodified lines | |||
| 100 | "sonner": "^2.0.7", | 100 | "sonner": "^2.0.7", |
| 101 | }, | 101 | }, |
| 102 | "devDependencies": { | 102 | "devDependencies": { |
| 103 | "@cloudflare/vitest-pool-workers": "^0.15.0", | ||
| 103 | "@cloudflare/workers-types": "^4.20250620.0", | 104 | "@cloudflare/workers-types": "^4.20250620.0", |
| 104 | "@effect/platform-node": "catalog:", | 105 | "@effect/platform-node": "catalog:", |
| 105 | "@effect/vitest": "catalog:", | 106 | "@effect/vitest": "catalog:", |
| @@ -349,6 +350,7 @@ · 243 unmodified lines | |||
| 349 | "@types/react": "catalog:", | 350 | "@types/react": "catalog:", |
| 350 | "@types/react-dom": "catalog:", | 351 | "@types/react-dom": "catalog:", |
| 351 | "@vitejs/plugin-react": "catalog:", | 352 | "@vitejs/plugin-react": "catalog:", |
| 353 | "graphql": "^16.12.0", | ||
| 352 | "iron-webcrypto": "^2.0.0", | 354 | "iron-webcrypto": "^2.0.0", |
| 353 | "typescript": "catalog:", | 355 | "typescript": "catalog:", |
| 354 | "vite": "catalog:", | 356 | "vite": "catalog:", |
| @@ -522,6 +524,7 @@ · 167 unmodified lines | |||
| 522 | }, | 524 | }, |
| 523 | "devDependencies": { | 525 | "devDependencies": { |
| 524 | "@effect/vitest": "catalog:", | 526 | "@effect/vitest": "catalog:", |
| 527 | "@libsql/client": "catalog:", | ||
| 525 | "@types/better-sqlite3": "^7.6.13", | 528 | "@types/better-sqlite3": "^7.6.13", |
| 526 | "@types/node": "catalog:", | 529 | "@types/node": "catalog:", |
| 527 | "@types/pg": "^8.20.0", | 530 | "@types/pg": "^8.20.0", |
| @@ -4300,7 +4303,7 @@ · 3772 unmodified lines | |||
| 4300 | 4303 | ||
| 4301 | "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], | 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 | "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], | 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 | "@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=="], | 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 | "@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 | "@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 | "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], | 5408 | "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], |
| @@ -6020,7 +6021,7 @@ · 612 unmodified lines | |||
| 6020 | 6021 | ||
| 6021 | "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], | 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 | "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], | 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 | "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 | "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 | "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=="], | 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 | "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], | 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 | "@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], | 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 | "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], | 6230 | "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], |
| 6232 | 6231 | ||
| 6233 | "@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=="], | 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 | "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], | 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 | "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], | 6690 | "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], |
| 6682 | 6691 | ||
| 6683 | "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=="], | 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 | "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], | 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 | "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], | 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 | "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], | 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 | "@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=="], | 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 | "@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=="], | 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 | "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], | 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 | "@types/react": "catalog:", | 43 | "@types/react": "catalog:", |
| 44 | "@types/react-dom": "catalog:", | 44 | "@types/react-dom": "catalog:", |
| 45 | "@vitejs/plugin-react": "catalog:", | 45 | "@vitejs/plugin-react": "catalog:", |
| 46 | "graphql": "^16.12.0", | ||
| 46 | "iron-webcrypto": "^2.0.0", | 47 | "iron-webcrypto": "^2.0.0", |
| 47 | "typescript": "catalog:", | 48 | "typescript": "catalog:", |
| 48 | "vite": "catalog:", | 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 | readonly ok: boolean; | 95 | readonly ok: boolean; |
| 96 | } | 96 | } |
| 97 | 97 | ||
| 98 | export interface McpToolDef { | ||
| 99 | readonly name: string; | ||
| 100 | readonly description: string; | ||
| 101 | } | ||
| 102 | |||
| 98 | /** How a connection surfaces a paused (approval-gated) execution. `browser` is | 103 | /** How a connection surfaces a paused (approval-gated) execution. `browser` is |
| 99 | * what the browser-approval scenarios drive: the pause yields an `approvalUrl` | 104 | * what the browser-approval scenarios drive: the pause yields an `approvalUrl` |
| 100 | * for a human to open instead of letting the model resume inline. */ | 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 | export interface McpSession { | 139 | export interface McpSession { |
| 135 | readonly listTools: () => Effect.Effect<ReadonlyArray<string>>; | 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 | readonly call: (name: string, args?: Record<string, unknown>) => Effect.Effect<McpCallResult>; | 144 | readonly call: (name: string, args?: Record<string, unknown>) => Effect.Effect<McpCallResult>; |
| 137 | /** Find the paused executionId in `text` and resume it with approval. */ | 145 | /** Find the paused executionId in `text` and resume it with approval. */ |
| 138 | readonly approvePaused: ( | 146 | readonly approvePaused: ( |
| @@ -298,6 +306,16 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 159 unmodified lines | |||
| 298 | return defs.map((tool: { name: string }) => tool.name); | 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 | const call = (name: string, args: Record<string, unknown> = {}) => | 319 | const call = (name: string, args: Record<string, unknown> = {}) => |
| 302 | Effect.promise(async (): Promise<McpCallResult> => { | 320 | Effect.promise(async (): Promise<McpCallResult> => { |
| 303 | if (!connected) { | 321 | if (!connected) { |
| @@ -311,6 +329,7 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 7 unmodified lines | |||
| 311 | 329 | ||
| 312 | return { | 330 | return { |
| 313 | listTools, | 331 | listTools, |
| 332 | describeTools, | ||
| 314 | call, | 333 | call, |
| 315 | approvePaused: (text, content = {}) => | 334 | approvePaused: (text, content = {}) => |
| 316 | Effect.suspend(() => { | 335 | Effect.suspend(() => { |
| @@ -295,7 +295,7 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { · 294 unmodified lines | |||
| 295 | <div className="flex flex-col gap-px"> | 295 | <div className="flex flex-col gap-px"> |
| 296 | {value.map((integration: Integration) => { | 296 | {value.map((integration: Integration) => { |
| 297 | const slug = String(integration.slug); | 297 | const slug = String(integration.slug); |
| 298 | const name = integration.description || slug; | 298 | const name = integration.name || slug; |
| 299 | const detailPath = `/integrations/${slug}`; | 299 | const detailPath = `/integrations/${slug}`; |
| 300 | const active = | 300 | const active = |
| 301 | props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); | 301 | props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); |
| @@ -48,6 +48,7 @@ const ConnectionResponse = Schema.Struct({ · 47 unmodified lines | |||
| 48 | provider: ProviderKey, | 48 | provider: ProviderKey, |
| 49 | address: ConnectionAddress, | 49 | address: ConnectionAddress, |
| 50 | identityLabel: Schema.NullOr(Schema.String), | 50 | identityLabel: Schema.NullOr(Schema.String), |
| 51 | description: Schema.NullOr(Schema.String), | ||
| 51 | expiresAt: Schema.NullOr(Schema.Number), | 52 | expiresAt: Schema.NullOr(Schema.Number), |
| 52 | // The OAuth app that minted this connection (its `oauth_client` slug), or null | 53 | // The OAuth app that minted this connection (its `oauth_client` slug), or null |
| 53 | // for static credentials. Lets the UI map a connection back to its app. Just a | 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 | integration: IntegrationSlug, | 81 | integration: IntegrationSlug, |
| 81 | template: AuthTemplateSlug, | 82 | template: AuthTemplateSlug, |
| 82 | identityLabel: Schema.optional(Schema.NullOr(Schema.String)), | 83 | identityLabel: Schema.optional(Schema.NullOr(Schema.String)), |
| 84 | description: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 83 | } as const; | 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 | const CreateConnectionPayload = Schema.Struct({ | 93 | const CreateConnectionPayload = Schema.Struct({ |
| 86 | ...CommonCreateFields, | 94 | ...CommonCreateFields, |
| 87 | value: Schema.optional(Schema.String), | 95 | value: Schema.optional(Schema.String), |
| @@ -157,6 +165,14 @@ export const ConnectionsApi = HttpApiGroup.make("connections") · 69 unmodified lines | |||
| 157 | error: [InternalError, ConnectionNotFound], | 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 | .add( | 176 | .add( |
| 161 | HttpApiEndpoint.delete("remove", "/connections/:owner/:integration/:name", { | 177 | HttpApiEndpoint.delete("remove", "/connections/:owner/:integration/:name", { |
| 162 | params: ConnectionParams, | 178 | params: ConnectionParams, |
| @@ -21,6 +21,7 @@ const toResponse = (c: Connection) => ({ · 20 unmodified lines | |||
| 21 | provider: c.provider, | 21 | provider: c.provider, |
| 22 | address: c.address, | 22 | address: c.address, |
| 23 | identityLabel: c.identityLabel ?? null, | 23 | identityLabel: c.identityLabel ?? null, |
| 24 | description: c.description ?? null, | ||
| 24 | expiresAt: c.expiresAt ?? null, | 25 | expiresAt: c.expiresAt ?? null, |
| 25 | oauthClient: c.oauthClient ?? null, | 26 | oauthClient: c.oauthClient ?? null, |
| 26 | oauthClientOwner: c.oauthClientOwner ?? null, | 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 | .handle("remove", ({ params: path }) => | 108 | .handle("remove", ({ params: path }) => |
| 87 | capture( | 109 | capture( |
| 88 | Effect.gen(function* () { | 110 | Effect.gen(function* () { |
| @@ -8,6 +8,7 @@ import { capture } from "@executor-js/api"; · 7 unmodified lines | |||
| 8 | 8 | ||
| 9 | const toResponse = (i: Integration) => ({ | 9 | const toResponse = (i: Integration) => ({ |
| 10 | slug: i.slug, | 10 | slug: i.slug, |
| 11 | name: i.name, | ||
| 11 | description: i.description, | 12 | description: i.description, |
| 12 | kind: i.kind, | 13 | kind: i.kind, |
| 13 | canRemove: i.canRemove, | 14 | canRemove: i.canRemove, |
| @@ -44,7 +45,8 @@ export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrati · 30 unmodified lines | |||
| 44 | Effect.gen(function* () { | 45 | Effect.gen(function* () { |
| 45 | const executor = yield* ExecutorService; | 46 | const executor = yield* ExecutorService; |
| 46 | yield* executor.integrations.update(path.slug, { | 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 | const integration = yield* executor.integrations.get(path.slug); | 51 | const integration = yield* executor.integrations.get(path.slug); |
| 50 | if (integration === null) { | 52 | if (integration === null) { |
| @@ -65,6 +65,8 @@ const AuthMethodDescriptorSchema = Schema.Struct({ · 64 unmodified lines | |||
| 65 | /** Public projection of an integration — mirrors the SDK's `Integration`. */ | 65 | /** Public projection of an integration — mirrors the SDK's `Integration`. */ |
| 66 | const IntegrationResponse = Schema.Struct({ | 66 | const IntegrationResponse = Schema.Struct({ |
| 67 | slug: IntegrationSlug, | 67 | slug: IntegrationSlug, |
| 68 | /** Display name. */ | ||
| 69 | name: Schema.String, | ||
| 68 | description: Schema.String, | 70 | description: Schema.String, |
| 69 | /** The plugin that owns this integration kind (e.g. "openapi", "mcp"). */ | 71 | /** The plugin that owns this integration kind (e.g. "openapi", "mcp"). */ |
| 70 | kind: Schema.String, | 72 | kind: Schema.String, |
| @@ -78,6 +80,7 @@ const IntegrationResponse = Schema.Struct({ · 7 unmodified lines | |||
| 78 | }); | 80 | }); |
| 79 | 81 | ||
| 80 | const UpdateIntegrationPayload = Schema.Struct({ | 82 | const UpdateIntegrationPayload = Schema.Struct({ |
| 83 | name: Schema.optional(Schema.String), | ||
| 81 | description: Schema.optional(Schema.String), | 84 | description: Schema.optional(Schema.String), |
| 82 | }); | 85 | }); |
| 83 | 86 | ||
| @@ -61,7 +61,8 @@ const slackPlugin = definePlugin(() => ({ · 60 unmodified lines | |||
| 61 | seed: () => | 61 | seed: () => |
| 62 | ctx.core.integrations.register({ | 62 | ctx.core.integrations.register({ |
| 63 | slug: SLACK, | 63 | slug: SLACK, |
| 64 | description: "Slack Workspace", | 64 | name: "Slack", |
| 65 | description: "Send and read workspace messages.", | ||
| 65 | config: {}, | 66 | config: {}, |
| 66 | }), | 67 | }), |
| 67 | }), | 68 | }), |
| @@ -98,7 +99,7 @@ describe("buildExecuteDescription", () => { · 30 unmodified lines | |||
| 98 | expect(description).toContain("- `github.org.prod`"); | 99 | expect(description).toContain("- `github.org.prod`"); |
| 99 | expect(description).toContain("- `github.user.personal`"); | 100 | expect(description).toContain("- `github.user.personal`"); |
| 100 | expect(description).not.toContain("## Available namespaces"); | 101 | expect(description).not.toContain("## Available namespaces"); |
| 101 | expect(description).not.toContain("Slack Workspace"); | 102 | expect(description).not.toContain("workspace messages"); |
| 102 | expect(description).not.toContain("`github-plugin`"); | 103 | expect(description).not.toContain("`github-plugin`"); |
| 103 | expect(description).not.toContain("`slack-plugin`"); | 104 | expect(description).not.toContain("`slack-plugin`"); |
| 104 | expect(description).not.toContain("- `github`"); | 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 | it.effect("omits the Available connection prefixes section when no connections exist", () => | 169 | it.effect("omits the Available connection prefixes section when no connections exist", () => |
| 115 | Effect.gen(function* () { | 170 | Effect.gen(function* () { |
| 116 | const executor = yield* createExecutor(makeTestConfig({ plugins: [] as const })); | 171 | const executor = yield* createExecutor(makeTestConfig({ plugins: [] as const })); |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { Effect } from "effect"; | 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 | * Builds a tool description dynamically. | 5 | * Builds a tool description dynamically. |
| @@ -19,9 +19,14 @@ export const buildExecuteDescription = (executor: Executor): Effect.Effect<strin · 13 unmodified lines | |||
| 19 | Effect.orDie, | 19 | Effect.orDie, |
| 20 | Effect.withSpan("executor.connections.list"), | 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 | const description = yield* Effect.sync(() => | 28 | const description = yield* Effect.sync(() => |
| 24 | formatDescription(connections.map((connection) => connectionPath(connection))), | 29 | formatDescription(connections.map((connection) => connectionEntry(connection, integrations))), |
| 25 | ).pipe( | 30 | ).pipe( |
| 26 | Effect.withSpan("schema.compile.description", { | 31 | Effect.withSpan("schema.compile.description", { |
| 27 | attributes: { "executor.connection_count": connections.length }, | 32 | attributes: { "executor.connection_count": connections.length }, |
| @@ -53,7 +58,44 @@ const connectionPath = (connection: Connection): string => { · 25 unmodified lines | |||
| 53 | return address.startsWith("tools.") ? address.slice("tools.".length) : address; | 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 | const lines: string[] = [ | 99 | const lines: string[] = [ |
| 58 | "Execute TypeScript in a sandboxed runtime with access to configured API tools.", | 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 | "- 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.", | 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 | lines.push(""); | 130 | lines.push(""); |
| 89 | lines.push("## Available connection prefixes"); | 131 | lines.push("## Available connection prefixes"); |
| 90 | lines.push(""); | 132 | lines.push(""); |
| 91 | lines.push("These are paths under `tools.`; append the final tool segment."); | 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); | 134 | const sorted = [...connectionEntries] |
| 93 | for (const prefix of sorted) { | 135 | .sort((a, b) => a.prefix.localeCompare(b.prefix)) |
| 94 | lines.push(`- \`${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) { | 144 | if (connectionEntries.length > sorted.length) { |
| 97 | lines.push(`- ... ${connectionPrefixes.length - sorted.length} more`); | 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 | fromPromise(() => pe.connections.list(filter)), | 90 | fromPromise(() => pe.connections.list(filter)), |
| 91 | get: (ref: Parameters<PromiseExecutor["connections"]["get"]>[0]) => | 91 | get: (ref: Parameters<PromiseExecutor["connections"]["get"]>[0]) => |
| 92 | fromPromise(() => pe.connections.get(ref)), | 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 | remove: (ref: Parameters<PromiseExecutor["connections"]["remove"]>[0]) => | 97 | remove: (ref: Parameters<PromiseExecutor["connections"]["remove"]>[0]) => |
| 94 | fromPromise(() => pe.connections.remove(ref)), | 98 | fromPromise(() => pe.connections.remove(ref)), |
| 95 | refresh: (ref: Parameters<PromiseExecutor["connections"]["refresh"]>[0]) => | 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 | "{ items: ExecutorSourceListItem[]; total: number; hasMore: boolean; nextOffset: number | null; }", | 111 | "{ items: ExecutorSourceListItem[]; total: number; hasMore: boolean; nextOffset: number | null; }", |
| 112 | typeScriptDefinitions: { | 112 | typeScriptDefinitions: { |
| 113 | ExecutorSourceListItem: | 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 | export type ExecutorSourceListItem = { | 320 | export type ExecutorSourceListItem = { |
| 321 | readonly id: string; | 321 | readonly id: string; |
| 322 | readonly name: string; | 322 | readonly name: string; |
| 323 | readonly description?: string; | ||
| 323 | readonly kind: string; | 324 | readonly kind: string; |
| 324 | readonly canRemove?: boolean; | 325 | readonly canRemove?: boolean; |
| 325 | readonly canRefresh?: boolean; | 326 | readonly canRefresh?: boolean; |
| @@ -682,6 +683,14 @@ export const listExecutorSources = Effect.fn("executor.sources.list")(function* · 356 unmodified lines | |||
| 682 | ({ | 683 | ({ |
| 683 | id: String(integration.slug), | 684 | id: String(integration.slug), |
| 684 | name: String(integration.slug), | 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 | kind: integration.kind, | 694 | kind: integration.kind, |
| 686 | canRemove: integration.canRemove, | 695 | canRemove: integration.canRemove, |
| 687 | canRefresh: integration.canRefresh, | 696 | canRefresh: integration.canRefresh, |
| @@ -88,11 +88,12 @@ · 87 unmodified lines | |||
| 88 | "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src", | 88 | "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src", |
| 89 | "typecheck": "tsgo --noEmit", | 89 | "typecheck": "tsgo --noEmit", |
| 90 | "typecheck:slow": "tsc --noEmit", | 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 | "test:integration": "vitest run test/**/*.test.ts --passWithNoTests" | 92 | "test:integration": "vitest run test/**/*.test.ts --passWithNoTests" |
| 93 | }, | 93 | }, |
| 94 | "devDependencies": { | 94 | "devDependencies": { |
| 95 | "@effect/vitest": "catalog:", | 95 | "@effect/vitest": "catalog:", |
| 96 | "@libsql/client": "catalog:", | ||
| 96 | "@types/better-sqlite3": "^7.6.13", | 97 | "@types/better-sqlite3": "^7.6.13", |
| 97 | "@types/node": "catalog:", | 98 | "@types/node": "catalog:", |
| 98 | "@types/pg": "^8.20.0", | 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 | createSettingsTableSql(options.namespace, options.schema.version, options.provider), | 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 | export const createDrizzleRuntimeSchemaSqlFromTables = ( | 366 | export const createDrizzleRuntimeSchemaSqlFromTables = ( |
| 334 | options: DrizzleRuntimeTablesOptions, | 367 | options: DrizzleRuntimeTablesOptions, |
| 335 | ): readonly string[] => | 368 | ): readonly string[] => |
| @@ -370,6 +403,17 @@ export const ensureDrizzleRuntimeSchema = async ( · 34 unmodified lines | |||
| 370 | } else { | 403 | } else { |
| 371 | await run(db); | 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 | export const ensureDrizzleRuntimeSchemaFromTables = async ( | 419 | export const ensureDrizzleRuntimeSchemaFromTables = async ( |
| @@ -115,6 +115,18 @@ export interface IntegrationAccountHandoff { · 114 unmodified lines | |||
| 115 | readonly label?: string; | 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 | export interface IntegrationPlugin { | 130 | export interface IntegrationPlugin { |
| 119 | /** Unique key matching the SDK plugin id (e.g. "openapi"). */ | 131 | /** Unique key matching the SDK plugin id (e.g. "openapi"). */ |
| 120 | readonly key: string; | 132 | readonly key: string; |
| @@ -130,10 +142,19 @@ export interface IntegrationPlugin { · 9 unmodified lines | |||
| 130 | readonly initialPreset?: string; | 142 | readonly initialPreset?: string; |
| 131 | readonly initialNamespace?: string; | 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 | readonly sourceId: string; | 148 | readonly sourceId: string; |
| 135 | readonly onSave: () => void; | 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 | readonly summary?: ComponentType<{ | 158 | readonly summary?: ComponentType<{ |
| 138 | readonly sourceId: string; | 159 | readonly sourceId: string; |
| 139 | readonly variant?: "badge" | "panel"; | 160 | readonly variant?: "badge" | "panel"; |
| @@ -32,6 +32,11 @@ export interface Connection { · 31 unmodified lines | |||
| 32 | readonly address: ConnectionAddress; | 32 | readonly address: ConnectionAddress; |
| 33 | /** Optional human label (which account). Not load-bearing. */ | 33 | /** Optional human label (which account). Not load-bearing. */ |
| 34 | readonly identityLabel?: string | null; | 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 | /** Epoch ms when an OAuth access token expires; null/absent for static creds. */ | 40 | /** Epoch ms when an OAuth access token expires; null/absent for static creds. */ |
| 36 | readonly expiresAt?: number | null; | 41 | readonly expiresAt?: number | null; |
| 37 | /** The OAuth app (`oauth_client` slug) that minted this connection, when it | 42 | /** The OAuth app (`oauth_client` slug) that minted this connection, when it |
| @@ -87,4 +92,13 @@ export type CreateConnectionInput = { · 49 unmodified lines | |||
| 87 | readonly integration: IntegrationSlug; | 92 | readonly integration: IntegrationSlug; |
| 88 | readonly template: AuthTemplateSlug; | 93 | readonly template: AuthTemplateSlug; |
| 89 | readonly identityLabel?: string | null; | 94 | readonly identityLabel?: string | null; |
| 95 | readonly description?: string | null; | ||
| 90 | } & ConnectionValueInput; | 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 | slug: keyColumn("slug"), | 121 | slug: keyColumn("slug"), |
| 122 | plugin_id: textColumn("plugin_id"), | 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 | config: nullableJsonColumn("config"), | 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 | can_remove: boolColumn("can_remove", true), | 140 | can_remove: boolColumn("can_remove", true), |
| 126 | can_refresh: boolColumn("can_refresh", false), | 141 | can_refresh: boolColumn("can_refresh", false), |
| 127 | created_at: dateColumn("created_at"), | 142 | created_at: dateColumn("created_at"), |
| @@ -144,6 +159,12 @@ export const coreTables = defineTables({ · 16 unmodified lines | |||
| 144 | provider: textColumn("provider"), | 159 | provider: textColumn("provider"), |
| 145 | item_ids: jsonColumn("item_ids"), | 160 | item_ids: jsonColumn("item_ids"), |
| 146 | identity_label: nullableTextColumn("identity_label"), | 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 | oauth_client: nullableTextColumn("oauth_client"), | 168 | oauth_client: nullableTextColumn("oauth_client"), |
| 148 | // The OWNER of `oauth_client` (a Personal connection may be minted through | 169 | // The OWNER of `oauth_client` (a Personal connection may be minted through |
| 149 | // a shared Workspace app), set together with `oauth_client`; null for | 170 | // a shared Workspace app), set together with `oauth_client`; null for |
| @@ -70,6 +70,7 @@ const ConnectionOutput = Schema.Struct({ · 69 unmodified lines | |||
| 70 | provider: Schema.String, | 70 | provider: Schema.String, |
| 71 | address: Schema.String, | 71 | address: Schema.String, |
| 72 | identityLabel: Schema.optional(Schema.NullOr(Schema.String)), | 72 | identityLabel: Schema.optional(Schema.NullOr(Schema.String)), |
| 73 | description: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 73 | expiresAt: Schema.NullOr(Schema.Number), | 74 | expiresAt: Schema.NullOr(Schema.Number), |
| 74 | oauthClient: Schema.NullOr(Schema.String), | 75 | oauthClient: Schema.NullOr(Schema.String), |
| 75 | oauthClientOwner: Schema.NullOr(OwnerSchema), | 76 | oauthClientOwner: Schema.NullOr(OwnerSchema), |
| @@ -303,6 +304,7 @@ const connectionToOutput = (connection: Connection) => ({ · 227 unmodified lines | |||
| 303 | provider: String(connection.provider), | 304 | provider: String(connection.provider), |
| 304 | address: String(connection.address), | 305 | address: String(connection.address), |
| 305 | identityLabel: connection.identityLabel ?? null, | 306 | identityLabel: connection.identityLabel ?? null, |
| 307 | description: connection.description ?? null, | ||
| 306 | expiresAt: connection.expiresAt ?? null, | 308 | expiresAt: connection.expiresAt ?? null, |
| 307 | oauthClient: connection.oauthClient == null ? null : String(connection.oauthClient), | 309 | oauthClient: connection.oauthClient == null ? null : String(connection.oauthClient), |
| 308 | oauthClientOwner: connection.oauthClientOwner ?? null, | 310 | oauthClientOwner: connection.oauthClientOwner ?? null, |
| @@ -24,6 +24,7 @@ import type { · 23 unmodified lines | |||
| 24 | ConnectionRef, | 24 | ConnectionRef, |
| 25 | CreateConnectionInput, | 25 | CreateConnectionInput, |
| 26 | ConnectionValueInput, | 26 | ConnectionValueInput, |
| 27 | UpdateConnectionInput, | ||
| 27 | } from "./connection"; | 28 | } from "./connection"; |
| 28 | import { | 29 | import { |
| 29 | coreSchema, | 30 | coreSchema, |
| @@ -246,7 +247,7 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 216 unmodified lines | |||
| 246 | readonly get: (slug: IntegrationSlug) => Effect.Effect<Integration | null, StorageFailure>; | 247 | readonly get: (slug: IntegrationSlug) => Effect.Effect<Integration | null, StorageFailure>; |
| 247 | readonly update: ( | 248 | readonly update: ( |
| 248 | slug: IntegrationSlug, | 249 | slug: IntegrationSlug, |
| 249 | patch: { readonly description?: string }, | 250 | patch: { readonly name?: string; readonly description?: string }, |
| 250 | ) => Effect.Effect<void, IntegrationNotFoundError | StorageFailure>; | 251 | ) => Effect.Effect<void, IntegrationNotFoundError | StorageFailure>; |
| 251 | readonly remove: ( | 252 | readonly remove: ( |
| 252 | slug: IntegrationSlug, | 253 | slug: IntegrationSlug, |
| @@ -271,6 +272,12 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 18 unmodified lines | |||
| 271 | readonly owner?: Owner; | 272 | readonly owner?: Owner; |
| 272 | }) => Effect.Effect<readonly Connection[], StorageFailure>; | 273 | }) => Effect.Effect<readonly Connection[], StorageFailure>; |
| 273 | readonly get: (ref: ConnectionRef) => Effect.Effect<Connection | null, StorageFailure>; | 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 | readonly remove: ( | 281 | readonly remove: ( |
| 275 | ref: ConnectionRef, | 282 | ref: ConnectionRef, |
| 276 | ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>; | 283 | ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>; |
| @@ -441,7 +448,11 @@ const rowToIntegration = ( · 164 unmodified lines | |||
| 441 | displayUrl?: string, | 448 | displayUrl?: string, |
| 442 | ): Integration => ({ | 449 | ): Integration => ({ |
| 443 | slug: IntegrationSlug.make(row.slug), | 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 | kind: row.plugin_id, | 456 | kind: row.plugin_id, |
| 446 | canRemove: Boolean(row.can_remove), | 457 | canRemove: Boolean(row.can_remove), |
| 447 | canRefresh: Boolean(row.can_refresh), | 458 | canRefresh: Boolean(row.can_refresh), |
| @@ -469,6 +480,7 @@ const rowToConnection = (row: ConnectionRow): Connection => { · 21 unmodified lines | |||
| 469 | provider: ProviderKey.make(row.provider), | 480 | provider: ProviderKey.make(row.provider), |
| 470 | address: connectionAddress(owner, integration, name), | 481 | address: connectionAddress(owner, integration, name), |
| 471 | identityLabel: row.identity_label ?? null, | 482 | identityLabel: row.identity_label ?? null, |
| 483 | description: row.description ?? null, | ||
| 472 | expiresAt: row.expires_at == null ? null : Number(row.expires_at), | 484 | expiresAt: row.expires_at == null ? null : Number(row.expires_at), |
| 473 | oauthClient: row.oauth_client == null ? null : OAuthClientSlug.make(String(row.oauth_client)), | 485 | oauthClient: row.oauth_client == null ? null : OAuthClientSlug.make(String(row.oauth_client)), |
| 474 | oauthClientOwner: | 486 | oauthClientOwner: |
| @@ -1270,6 +1282,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 795 unmodified lines | |||
| 1270 | 1282 | ||
| 1271 | const staticSourceToIntegration = (source: StaticSourceDecl): Integration => ({ | 1283 | const staticSourceToIntegration = (source: StaticSourceDecl): Integration => ({ |
| 1272 | slug: IntegrationSlug.make(source.id), | 1284 | slug: IntegrationSlug.make(source.id), |
| 1285 | name: source.name, | ||
| 1273 | description: source.name, | 1286 | description: source.name, |
| 1274 | kind: source.kind, | 1287 | kind: source.kind, |
| 1275 | canRemove: source.canRemove ?? false, | 1288 | canRemove: source.canRemove ?? false, |
| @@ -1705,6 +1718,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 429 unmodified lines | |||
| 1705 | where: (b: AnyCb) => b("slug", "=", String(input.slug)), | 1718 | where: (b: AnyCb) => b("slug", "=", String(input.slug)), |
| 1706 | set: { | 1719 | set: { |
| 1707 | plugin_id: pluginId, | 1720 | plugin_id: pluginId, |
| 1721 | name: input.name ?? existing.name ?? null, | ||
| 1708 | description: input.description, | 1722 | description: input.description, |
| 1709 | config, | 1723 | config, |
| 1710 | can_remove: input.canRemove ?? Boolean(existing.can_remove), | 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 | tenant, | 1732 | tenant, |
| 1719 | slug: String(input.slug), | 1733 | slug: String(input.slug), |
| 1720 | plugin_id: pluginId, | 1734 | plugin_id: pluginId, |
| 1735 | name: input.name ?? null, | ||
| 1721 | description: input.description, | 1736 | description: input.description, |
| 1722 | config, | 1737 | config, |
| 1723 | can_remove: input.canRemove ?? true, | 1738 | can_remove: input.canRemove ?? true, |
| @@ -1731,14 +1746,24 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines | |||
| 1731 | const integrationsUpdate = ( | 1746 | const integrationsUpdate = ( |
| 1732 | slug: IntegrationSlug, | 1747 | slug: IntegrationSlug, |
| 1733 | patch: { | 1748 | patch: { |
| 1749 | readonly name?: string; | ||
| 1734 | readonly description?: string; | 1750 | readonly description?: string; |
| 1735 | readonly config?: IntegrationConfig; | 1751 | readonly config?: IntegrationConfig; |
| 1736 | }, | 1752 | }, |
| 1737 | ): Effect.Effect<void, StorageFailure> => | 1753 | ): Effect.Effect<void, StorageFailure> => |
| 1738 | Effect.gen(function* () { | 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 | if (patch.description !== undefined) set.description = patch.description; | 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 | yield* core.updateMany("integration", { | 1767 | yield* core.updateMany("integration", { |
| 1743 | where: (b: AnyCb) => b("slug", "=", String(slug)), | 1768 | where: (b: AnyCb) => b("slug", "=", String(slug)), |
| 1744 | set, | 1769 | set, |
| @@ -1747,7 +1772,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines | |||
| 1747 | 1772 | ||
| 1748 | const integrationsUpdatePublic = ( | 1773 | const integrationsUpdatePublic = ( |
| 1749 | slug: IntegrationSlug, | 1774 | slug: IntegrationSlug, |
| 1750 | patch: { readonly description?: string }, | 1775 | patch: { readonly name?: string; readonly description?: string }, |
| 1751 | ): Effect.Effect<void, IntegrationNotFoundError | StorageFailure> => | 1776 | ): Effect.Effect<void, IntegrationNotFoundError | StorageFailure> => |
| 1752 | Effect.gen(function* () { | 1777 | Effect.gen(function* () { |
| 1753 | const existing = yield* findIntegrationRow(slug); | 1778 | const existing = yield* findIntegrationRow(slug); |
| @@ -1814,6 +1839,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 60 unmodified lines | |||
| 1814 | b("integration", "=", String(ref.integration)), | 1839 | b("integration", "=", String(ref.integration)), |
| 1815 | b("connection", "=", String(ref.name)), | 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 | // Defense in depth (and cleanup for rows created before the create-time | 1855 | // Defense in depth (and cleanup for rows created before the create-time |
| 1819 | // guard, or emptied by an external edit): a credentialed non-OAuth | 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 | Effect.gen(function* () { | 1871 | Effect.gen(function* () { |
| 1835 | yield* core.deleteMany("tool", { where }); | 1872 | yield* core.deleteMany("tool", { where }); |
| 1836 | yield* core.deleteMany("definition", { where }); | 1873 | yield* core.deleteMany("definition", { where }); |
| 1874 | yield* stampSynced; | ||
| 1837 | }), | 1875 | }), |
| 1838 | ); | 1876 | ); |
| 1839 | return []; | 1877 | return []; |
| @@ -1845,6 +1883,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | |||
| 1845 | Effect.gen(function* () { | 1883 | Effect.gen(function* () { |
| 1846 | yield* core.deleteMany("tool", { where }); | 1884 | yield* core.deleteMany("tool", { where }); |
| 1847 | yield* core.deleteMany("definition", { where }); | 1885 | yield* core.deleteMany("definition", { where }); |
| 1886 | yield* stampSynced; | ||
| 1848 | }), | 1887 | }), |
| 1849 | ); | 1888 | ); |
| 1850 | return []; | 1889 | return []; |
| @@ -1901,6 +1940,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 50 unmodified lines | |||
| 1901 | yield* core.deleteMany("definition", { where }); | 1940 | yield* core.deleteMany("definition", { where }); |
| 1902 | yield* core.createMany("tool", toolRows); | 1941 | yield* core.createMany("tool", toolRows); |
| 1903 | yield* core.createMany("definition", definitionRows); | 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 | provider: providerKey, | 2081 | provider: providerKey, |
| 2042 | item_ids: itemIds, | 2082 | item_ids: itemIds, |
| 2043 | identity_label: input.identityLabel ?? null, | 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 | updated_at: now, | 2087 | updated_at: now, |
| 2045 | }; | 2088 | }; |
| 2046 | if (existing) { | 2089 | if (existing) { |
| @@ -2064,6 +2107,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 17 unmodified lines | |||
| 2064 | provider: providerKey, | 2107 | provider: providerKey, |
| 2065 | item_ids: itemIds, | 2108 | item_ids: itemIds, |
| 2066 | identity_label: input.identityLabel ?? null, | 2109 | identity_label: input.identityLabel ?? null, |
| 2110 | description: input.description ?? null, | ||
| 2067 | oauth_client: null, | 2111 | oauth_client: null, |
| 2068 | refresh_item_id: null, | 2112 | refresh_item_id: null, |
| 2069 | expires_at: null, | 2113 | expires_at: null, |
| @@ -2099,6 +2143,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 29 unmodified lines | |||
| 2099 | provider: providerKey, | 2143 | provider: providerKey, |
| 2100 | item_ids: itemIds, | 2144 | item_ids: itemIds, |
| 2101 | identity_label: input.identityLabel ?? null, | 2145 | identity_label: input.identityLabel ?? null, |
| 2146 | description: input.description ?? null, | ||
| 2102 | oauth_client: null, | 2147 | oauth_client: null, |
| 2103 | refresh_item_id: null, | 2148 | refresh_item_id: null, |
| 2104 | expires_at: null, | 2149 | expires_at: null, |
| @@ -2172,6 +2217,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 67 unmodified lines | |||
| 2172 | provider: input.provider, | 2217 | provider: input.provider, |
| 2173 | item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, | 2218 | item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, |
| 2174 | identity_label: input.identityLabel ?? null, | 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 | oauth_client: String(input.oauthClient), | 2223 | oauth_client: String(input.oauthClient), |
| 2176 | oauth_client_owner: input.oauthClientOwner, | 2224 | oauth_client_owner: input.oauthClientOwner, |
| 2177 | refresh_item_id: input.refreshItemId, | 2225 | refresh_item_id: input.refreshItemId, |
| @@ -2204,6 +2252,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 26 unmodified lines | |||
| 2204 | provider: input.provider, | 2252 | provider: input.provider, |
| 2205 | item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, | 2253 | item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, |
| 2206 | identity_label: input.identityLabel ?? null, | 2254 | identity_label: input.identityLabel ?? null, |
| 2255 | description: null, | ||
| 2207 | oauth_client: String(input.oauthClient), | 2256 | oauth_client: String(input.oauthClient), |
| 2208 | oauth_client_owner: input.oauthClientOwner, | 2257 | oauth_client_owner: input.oauthClientOwner, |
| 2209 | refresh_item_id: input.refreshItemId, | 2258 | refresh_item_id: input.refreshItemId, |
| @@ -2234,6 +2283,35 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 24 unmodified lines | |||
| 2234 | const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> => | 2283 | const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> => |
| 2235 | findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null))); | 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 | const connectionsRemove = ( | 2315 | const connectionsRemove = ( |
| 2238 | ref: ConnectionRef, | 2316 | ref: ConnectionRef, |
| 2239 | ): Effect.Effect<void, ConnectionNotFoundError | StorageFailure> => | 2317 | ): Effect.Effect<void, ConnectionNotFoundError | StorageFailure> => |
| @@ -2320,8 +2398,50 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 80 unmodified lines | |||
| 2320 | return true; | 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 | const toolsList = (filter?: ToolListFilter): Effect.Effect<readonly Tool[], StorageFailure> => | 2442 | const toolsList = (filter?: ToolListFilter): Effect.Effect<readonly Tool[], StorageFailure> => |
| 2324 | Effect.gen(function* () { | 2443 | Effect.gen(function* () { |
| 2444 | yield* syncStaleConnectionTools; | ||
| 2325 | // Projected: the list surface is metadata (address, description, | 2445 | // Projected: the list surface is metadata (address, description, |
| 2326 | // annotations) — loading every tool's input/output schema JSON made | 2446 | // annotations) — loading every tool's input/output schema JSON made |
| 2327 | // an unbounded list scale with schema bytes, not tool count. | 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 | create: (input) => connectionsCreate(input), | 3138 | create: (input) => connectionsCreate(input), |
| 3019 | list: (filter) => connectionsList(filter), | 3139 | list: (filter) => connectionsList(filter), |
| 3020 | get: (ref) => connectionsGet(ref), | 3140 | get: (ref) => connectionsGet(ref), |
| 3141 | update: (ref, input) => connectionsUpdate(ref, input), | ||
| 3021 | remove: (ref) => connectionsRemove(ref), | 3142 | remove: (ref) => connectionsRemove(ref), |
| 3022 | refresh: (ref) => connectionsRefresh(ref), | 3143 | refresh: (ref) => connectionsRefresh(ref), |
| 3023 | resolveValue: (ref) => resolveConnectionValueByRef(ref), | 3144 | resolveValue: (ref) => resolveConnectionValueByRef(ref), |
| @@ -3124,6 +3245,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 100 unmodified lines | |||
| 3124 | create: connectionsCreate, | 3245 | create: connectionsCreate, |
| 3125 | list: connectionsList, | 3246 | list: connectionsList, |
| 3126 | get: connectionsGet, | 3247 | get: connectionsGet, |
| 3248 | update: connectionsUpdate, | ||
| 3127 | remove: connectionsRemove, | 3249 | remove: connectionsRemove, |
| 3128 | refresh: connectionsRefresh, | 3250 | refresh: connectionsRefresh, |
| 3129 | }, | 3251 | }, |
| @@ -91,6 +91,7 @@ export type { · 90 unmodified lines | |||
| 91 | ConnectionRef, | 91 | ConnectionRef, |
| 92 | ConnectionValueInput, | 92 | ConnectionValueInput, |
| 93 | CreateConnectionInput, | 93 | CreateConnectionInput, |
| 94 | UpdateConnectionInput, | ||
| 94 | } from "./connection"; | 95 | } from "./connection"; |
| 95 | export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; | 96 | export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; |
| 96 | 97 | ||
| @@ -74,6 +74,11 @@ export interface AuthMethodDescriptor { · 73 unmodified lines | |||
| 74 | * Carries no credentials and no plugin-internal config. */ | 74 | * Carries no credentials and no plugin-internal config. */ |
| 75 | export interface Integration { | 75 | export interface Integration { |
| 76 | readonly slug: IntegrationSlug; | 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 | readonly description: string; | 82 | readonly description: string; |
| 78 | /** The plugin that owns this integration kind (e.g. "openapi", "mcp"). */ | 83 | /** The plugin that owns this integration kind (e.g. "openapi", "mcp"). */ |
| 79 | readonly kind: string; | 84 | readonly kind: string; |
| @@ -142,6 +147,9 @@ export const mergeAuthTemplates = <T extends { readonly slug: string }>( · 62 unmodified lines | |||
| 142 | * produced per-connection now). */ | 147 | * produced per-connection now). */ |
| 143 | export interface RegisterIntegrationInput { | 148 | export interface RegisterIntegrationInput { |
| 144 | readonly slug: IntegrationSlug; | 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 | readonly description: string; | 153 | readonly description: string; |
| 146 | /** Opaque plugin config (auth templates, spec ref, mcp url, …). */ | 154 | /** Opaque plugin config (auth templates, spec ref, mcp url, …). */ |
| 147 | readonly config: IntegrationConfig; | 155 | readonly config: IntegrationConfig; |
| @@ -6,7 +6,12 @@ import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/sp · 5 unmodified lines | |||
| 6 | import type { StorageFailure } from "./fuma-runtime"; | 6 | import type { StorageFailure } from "./fuma-runtime"; |
| 7 | 7 | ||
| 8 | import type { PluginBlobStore } from "./blob"; | 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 | import type { | 15 | import type { |
| 11 | AuthMethodDescriptor, | 16 | AuthMethodDescriptor, |
| 12 | Integration, | 17 | Integration, |
| @@ -111,7 +116,11 @@ export interface PluginCtx<TStore = unknown> { · 98 unmodified lines | |||
| 111 | readonly register: (input: RegisterIntegrationInput) => Effect.Effect<void, StorageFailure>; | 116 | readonly register: (input: RegisterIntegrationInput) => Effect.Effect<void, StorageFailure>; |
| 112 | readonly update: ( | 117 | readonly update: ( |
| 113 | slug: IntegrationSlug, | 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 | ) => Effect.Effect<void, StorageFailure>; | 124 | ) => Effect.Effect<void, StorageFailure>; |
| 116 | readonly list: () => Effect.Effect<readonly Integration[], StorageFailure>; | 125 | readonly list: () => Effect.Effect<readonly Integration[], StorageFailure>; |
| 117 | readonly get: ( | 126 | readonly get: ( |
| @@ -151,6 +160,11 @@ export interface PluginCtx<TStore = unknown> { · 33 unmodified lines | |||
| 151 | readonly owner?: Owner; | 160 | readonly owner?: Owner; |
| 152 | }) => Effect.Effect<readonly Connection[], StorageFailure>; | 161 | }) => Effect.Effect<readonly Connection[], StorageFailure>; |
| 153 | readonly get: (ref: ConnectionRef) => Effect.Effect<Connection | null, StorageFailure>; | 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 | readonly remove: ( | 168 | readonly remove: ( |
| 155 | ref: ConnectionRef, | 169 | ref: ConnectionRef, |
| 156 | ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>; | 170 | ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>; |
| @@ -32,6 +32,7 @@ export type { · 31 unmodified lines | |||
| 32 | Connection, | 32 | Connection, |
| 33 | ConnectionRef, | 33 | ConnectionRef, |
| 34 | CreateConnectionInput, | 34 | CreateConnectionInput, |
| 35 | UpdateConnectionInput, | ||
| 35 | ConnectionValueInput, | 36 | ConnectionValueInput, |
| 36 | } from "./connection"; | 37 | } from "./connection"; |
| 37 | // Credential providers are Effect-native (their `get`/`set` return `Effect`s), | 38 | // Credential providers are Effect-native (their `get`/`set` return `Effect`s), |
| @@ -42,6 +42,7 @@ export type { · 41 unmodified lines | |||
| 42 | ConnectionRef, | 42 | ConnectionRef, |
| 43 | ConnectionValueInput, | 43 | ConnectionValueInput, |
| 44 | CreateConnectionInput, | 44 | CreateConnectionInput, |
| 45 | UpdateConnectionInput, | ||
| 45 | } from "./connection"; | 46 | } from "./connection"; |
| 46 | export type { CredentialProvider, ProviderEntry } from "./provider"; | 47 | export type { CredentialProvider, ProviderEntry } from "./provider"; |
| 47 | export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; | 48 | export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; |
| @@ -21,6 +21,7 @@ const AddIntegrationPayload = Schema.Struct({ · 20 unmodified lines | |||
| 21 | endpoint: Schema.String, | 21 | endpoint: Schema.String, |
| 22 | slug: Schema.optional(Schema.String), | 22 | slug: Schema.optional(Schema.String), |
| 23 | name: Schema.optional(Schema.String), | 23 | name: Schema.optional(Schema.String), |
| 24 | description: Schema.optional(Schema.String), | ||
| 24 | introspectionJson: Schema.optional(Schema.String), | 25 | introspectionJson: Schema.optional(Schema.String), |
| 25 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 26 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 26 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 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 | endpoint: payload.endpoint, | 40 | endpoint: payload.endpoint, |
| 41 | slug: payload.slug, | 41 | slug: payload.slug, |
| 42 | name: payload.name, | 42 | name: payload.name, |
| 43 | description: payload.description, | ||
| 43 | introspectionJson: payload.introspectionJson, | 44 | introspectionJson: payload.introspectionJson, |
| 44 | headers: payload.headers, | 45 | headers: payload.headers, |
| 45 | queryParams: payload.queryParams, | 46 | queryParams: payload.queryParams, |
| @@ -45,6 +45,7 @@ export default function AddGraphqlSource(props: { · 44 unmodified lines | |||
| 45 | initialUrl?: string; | 45 | initialUrl?: string; |
| 46 | }) { | 46 | }) { |
| 47 | const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); | 47 | const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); |
| 48 | const [description, setDescription] = useState(""); | ||
| 48 | const identity = useIntegrationIdentity({ | 49 | const identity = useIntegrationIdentity({ |
| 49 | fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "", | 50 | fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "", |
| 50 | }); | 51 | }); |
| @@ -111,6 +112,7 @@ export default function AddGraphqlSource(props: { · 60 unmodified lines | |||
| 111 | endpoint: trimmedEndpoint, | 112 | endpoint: trimmedEndpoint, |
| 112 | slug, | 113 | slug, |
| 113 | name: displayName, | 114 | name: displayName, |
| 115 | ...(description.trim().length > 0 ? { description: description.trim() } : {}), | ||
| 114 | ...(authenticationTemplate.length > 0 | 116 | ...(authenticationTemplate.length > 0 |
| 115 | ? { authenticationTemplate: [...authenticationTemplate] } | 117 | ? { authenticationTemplate: [...authenticationTemplate] } |
| 116 | : {}), | 118 | : {}), |
| @@ -131,7 +133,13 @@ export default function AddGraphqlSource(props: { · 14 unmodified lines | |||
| 131 | <div className="flex flex-1 flex-col gap-6"> | 133 | <div className="flex flex-1 flex-col gap-6"> |
| 132 | <h1 className="text-xl font-semibold text-foreground">Add GraphQL Source</h1> | 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 | <AuthMethodListEditor | 144 | <AuthMethodListEditor |
| 137 | list={authMethodList} | 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 | CardStackEntryField, | 4 | CardStackEntryField, |
| 5 | } from "@executor-js/react/components/card-stack"; | 5 | } from "@executor-js/react/components/card-stack"; |
| 6 | import { Input } from "@executor-js/react/components/input"; | 6 | import { Input } from "@executor-js/react/components/input"; |
| 7 | import { Textarea } from "@executor-js/react/components/textarea"; | ||
| 7 | import { | 8 | import { |
| 8 | IntegrationIdentityFieldRows, | 9 | IntegrationIdentityFieldRows, |
| 9 | type IntegrationIdentity, | 10 | type IntegrationIdentity, |
| @@ -13,6 +14,10 @@ export function GraphqlSourceFields(props: { · 3 unmodified lines | |||
| 13 | readonly endpoint: string; | 14 | readonly endpoint: string; |
| 14 | readonly onEndpointChange: (endpoint: string) => void; | 15 | readonly onEndpointChange: (endpoint: string) => void; |
| 15 | readonly identity: IntegrationIdentity; | 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 | readonly endpointDisabled?: boolean; | 21 | readonly endpointDisabled?: boolean; |
| 17 | readonly namespaceReadOnly?: boolean; | 22 | readonly namespaceReadOnly?: boolean; |
| 18 | }) { | 23 | }) { |
| @@ -36,6 +41,21 @@ export function GraphqlSourceFields(props: { · 17 unmodified lines | |||
| 36 | namePlaceholder="e.g. Shopify API" | 41 | namePlaceholder="e.g. Shopify API" |
| 37 | namespaceReadOnly={props.namespaceReadOnly} | 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 | </CardStackContent> | 59 | </CardStackContent> |
| 40 | </CardStack> | 60 | </CardStack> |
| 41 | ); | 61 | ); |
| @@ -59,13 +59,14 @@ export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( · 58 unmodified lines | |||
| 59 | const integration = { | 59 | const integration = { |
| 60 | slug, | 60 | slug, |
| 61 | kind: "graphql", | 61 | kind: "graphql", |
| 62 | name: arg.payload.name ?? arg.payload.endpoint, | ||
| 62 | description: arg.payload.name ?? arg.payload.endpoint, | 63 | description: arg.payload.name ?? arg.payload.endpoint, |
| 63 | canRemove: false, | 64 | canRemove: false, |
| 64 | canRefresh: false, | 65 | canRefresh: false, |
| 65 | authMethods: [], | 66 | authMethods: [], |
| 66 | }; | 67 | }; |
| 67 | return [integration, ...rows.filter((row) => row.slug !== slug)].sort((a, b) => | 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 | fn: addGraphqlIntegration, | 72 | fn: addGraphqlIntegration, |
| @@ -3,19 +3,18 @@ import type { IntegrationPlugin } from "@executor-js/sdk/client"; · 2 unmodified lines | |||
| 3 | import { graphqlPresets } from "../sdk/presets"; | 3 | import { graphqlPresets } from "../sdk/presets"; |
| 4 | 4 | ||
| 5 | const importAdd = () => import("./AddGraphqlSource"); | 5 | const importAdd = () => import("./AddGraphqlSource"); |
| 6 | const importEdit = () => import("./EditGraphqlSource"); | ||
| 7 | const importAccounts = () => import("./GraphqlAccountsPanel"); | 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 | export const graphqlIntegrationPlugin: IntegrationPlugin = { | 10 | export const graphqlIntegrationPlugin: IntegrationPlugin = { |
| 10 | key: "graphql", | 11 | key: "graphql", |
| 11 | label: "GraphQL", | 12 | label: "GraphQL", |
| 12 | add: lazy(importAdd), | 13 | add: lazy(importAdd), |
| 13 | edit: lazy(importEdit), | ||
| 14 | accounts: lazy(importAccounts), | 14 | accounts: lazy(importAccounts), |
| 15 | presets: graphqlPresets, | 15 | presets: graphqlPresets, |
| 16 | preload: () => { | 16 | preload: () => { |
| 17 | void importAdd(); | 17 | void importAdd(); |
| 18 | void importEdit(); | ||
| 19 | void importAccounts(); | 18 | void importAccounts(); |
| 20 | }, | 19 | }, |
| 21 | }; | 20 | }; |
| @@ -95,6 +95,9 @@ const GraphqlAddIntegrationInputSchema = Schema.Struct({ · 94 unmodified lines | |||
| 95 | endpoint: Schema.String, | 95 | endpoint: Schema.String, |
| 96 | slug: Schema.optional(Schema.String), | 96 | slug: Schema.optional(Schema.String), |
| 97 | name: Schema.optional(Schema.String), | 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 | introspectionJson: Schema.optional(Schema.String), | 101 | introspectionJson: Schema.optional(Schema.String), |
| 99 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 102 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 100 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 103 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| @@ -603,7 +606,8 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 502 unmodified lines | |||
| 603 | yield* ctx.transaction( | 606 | yield* ctx.transaction( |
| 604 | ctx.core.integrations.register({ | 607 | ctx.core.integrations.register({ |
| 605 | slug, | 608 | slug, |
| 606 | description: baseConfig.name, | 609 | name: baseConfig.name, |
| 610 | description: input.description?.trim() || baseConfig.name, | ||
| 607 | config: baseConfig, | 611 | config: baseConfig, |
| 608 | canRemove: true, | 612 | canRemove: true, |
| 609 | canRefresh: true, | 613 | canRefresh: true, |
| @@ -637,9 +641,17 @@ const makeGraphqlExtension = (ctx: PluginCtx<GraphqlStore>) => { · 27 unmodified lines | |||
| 637 | Effect.gen(function* () { | 641 | Effect.gen(function* () { |
| 638 | yield* ctx.storage.replaceOperations(String(slug), toStoredOperations(slug, prepared)); | 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 | yield* ctx.core.integrations.register({ | 651 | yield* ctx.core.integrations.register({ |
| 641 | slug, | 652 | slug, |
| 642 | description: config.name, | 653 | name: config.name, |
| 654 | description: input.description?.trim() || schemaDescription || config.name, | ||
| 643 | config, | 655 | config, |
| 644 | canRemove: true, | 656 | canRemove: true, |
| 645 | canRefresh: true, | 657 | canRefresh: true, |
| @@ -31,6 +31,8 @@ const StringMap = Schema.Record(Schema.String, Schema.String); · 30 unmodified lines | |||
| 31 | const AddRemoteServerPayload = Schema.Struct({ | 31 | const AddRemoteServerPayload = Schema.Struct({ |
| 32 | transport: Schema.optional(Schema.Literal("remote")), | 32 | transport: Schema.optional(Schema.Literal("remote")), |
| 33 | name: Schema.String, | 33 | name: Schema.String, |
| 34 | /** Agent-visible catalog description. Defaults to the display name. */ | ||
| 35 | description: Schema.optional(Schema.String), | ||
| 34 | endpoint: Schema.String, | 36 | endpoint: Schema.String, |
| 35 | remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), | 37 | remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), |
| 36 | slug: Schema.optional(Schema.String), | 38 | slug: Schema.optional(Schema.String), |
| @@ -46,6 +48,7 @@ const AddRemoteServerPayload = Schema.Struct({ · 9 unmodified lines | |||
| 46 | const AddStdioServerPayload = Schema.Struct({ | 48 | const AddStdioServerPayload = Schema.Struct({ |
| 47 | transport: Schema.Literal("stdio"), | 49 | transport: Schema.Literal("stdio"), |
| 48 | name: Schema.String, | 50 | name: Schema.String, |
| 51 | description: Schema.optional(Schema.String), | ||
| 49 | command: Schema.String, | 52 | command: Schema.String, |
| 50 | args: Schema.optional(Schema.Array(Schema.String)), | 53 | args: Schema.optional(Schema.Array(Schema.String)), |
| 51 | env: Schema.optional(StringMap), | 54 | env: Schema.optional(StringMap), |
| @@ -70,6 +73,8 @@ const ProbeEndpointResponse = Schema.Struct({ · 18 unmodified lines | |||
| 70 | slug: Schema.String, | 73 | slug: Schema.String, |
| 71 | toolCount: Schema.NullOr(Schema.Number), | 74 | toolCount: Schema.NullOr(Schema.Number), |
| 72 | serverName: Schema.NullOr(Schema.String), | 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 | const p = payload as { | 33 | const p = payload as { |
| 34 | transport: "stdio"; | 34 | transport: "stdio"; |
| 35 | name: string; | 35 | name: string; |
| 36 | description?: string; | ||
| 36 | command: string; | 37 | command: string; |
| 37 | args?: readonly string[]; | 38 | args?: readonly string[]; |
| 38 | env?: Record<string, string>; | 39 | env?: Record<string, string>; |
| @@ -42,6 +43,7 @@ const toServerInput = ( · 3 unmodified lines | |||
| 42 | return { | 43 | return { |
| 43 | transport: "stdio", | 44 | transport: "stdio", |
| 44 | name: p.name, | 45 | name: p.name, |
| 46 | description: p.description, | ||
| 45 | command: p.command, | 47 | command: p.command, |
| 46 | args: p.args ? [...p.args] : undefined, | 48 | args: p.args ? [...p.args] : undefined, |
| 47 | env: p.env, | 49 | env: p.env, |
| @@ -53,6 +55,7 @@ const toServerInput = ( · 5 unmodified lines | |||
| 53 | const p = payload as { | 55 | const p = payload as { |
| 54 | transport?: "remote"; | 56 | transport?: "remote"; |
| 55 | name: string; | 57 | name: string; |
| 58 | description?: string; | ||
| 56 | endpoint: string; | 59 | endpoint: string; |
| 57 | remoteTransport?: "streamable-http" | "sse" | "auto"; | 60 | remoteTransport?: "streamable-http" | "sse" | "auto"; |
| 58 | queryParams?: Record<string, string>; | 61 | queryParams?: Record<string, string>; |
| @@ -67,6 +70,7 @@ const toServerInput = ( · 8 unmodified lines | |||
| 67 | return { | 70 | return { |
| 68 | transport: "remote", | 71 | transport: "remote", |
| 69 | name: p.name, | 72 | name: p.name, |
| 73 | description: p.description, | ||
| 70 | endpoint: p.endpoint, | 74 | endpoint: p.endpoint, |
| 71 | remoteTransport: p.remoteTransport, | 75 | remoteTransport: p.remoteTransport, |
| 72 | queryParams: p.queryParams, | 76 | queryParams: p.queryParams, |
| @@ -78,6 +78,7 @@ type ProbeResult = { · 77 unmodified lines | |||
| 78 | slug: string; | 78 | slug: string; |
| 79 | toolCount: number | null; | 79 | toolCount: number | null; |
| 80 | serverName: string | null; | 80 | serverName: string | null; |
| 81 | instructions: string | null; | ||
| 81 | }; | 82 | }; |
| 82 | 83 | ||
| 83 | type State = | 84 | type State = |
| @@ -234,6 +235,10 @@ export default function AddMcpSource(props: { · 150 unmodified lines | |||
| 234 | fallbackName: | 235 | fallbackName: |
| 235 | integrationDisplayNameFromUrl(state.url, "MCP") ?? probe?.serverName ?? probe?.name ?? "", | 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 | const isProbing = state.step === "probing"; | 242 | const isProbing = state.step === "probing"; |
| 238 | const isAdding = state.step === "adding"; | 243 | const isAdding = state.step === "adding"; |
| 239 | 244 | ||
| @@ -298,6 +303,9 @@ export default function AddMcpSource(props: { · 58 unmodified lines | |||
| 298 | payload: { | 303 | payload: { |
| 299 | transport: "remote" as const, | 304 | transport: "remote" as const, |
| 300 | name: displayName, | 305 | name: displayName, |
| 306 | ...(resolvedDescription.trim().length > 0 | ||
| 307 | ? { description: resolvedDescription.trim() } | ||
| 308 | : {}), | ||
| 301 | endpoint: state.url.trim(), | 309 | endpoint: state.url.trim(), |
| 302 | ...(slug ? { slug } : {}), | 310 | ...(slug ? { slug } : {}), |
| 303 | authenticationTemplate, | 311 | authenticationTemplate, |
| @@ -313,7 +321,7 @@ export default function AddMcpSource(props: { · 9 unmodified lines | |||
| 313 | } | 321 | } |
| 314 | return exit.value.slug; | 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 | const handleAddRemote = useCallback(async () => { | 327 | const handleAddRemote = useCallback(async () => { |
| @@ -447,6 +455,8 @@ export default function AddMcpSource(props: { · 127 unmodified lines | |||
| 447 | url={state.url} | 455 | url={state.url} |
| 448 | onUrlChange={(url) => dispatch({ type: "set-url", url })} | 456 | onUrlChange={(url) => dispatch({ type: "set-url", url })} |
| 449 | identity={remoteIdentity} | 457 | identity={remoteIdentity} |
| 458 | description={resolvedDescription} | ||
| 459 | onDescriptionChange={setDescriptionDraft} | ||
| 450 | preview={probe} | 460 | preview={probe} |
| 451 | probing={isProbing} | 461 | probing={isProbing} |
| 452 | error={probeError} | 462 | error={probeError} |
| @@ -1,9 +1,10 @@ | |||
| 1 | import { useCallback, useMemo, useState } from "react"; | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 5 | 5 | ||
| 6 | import { IntegrationSlug } from "@executor-js/sdk/shared"; | 6 | import { IntegrationSlug } from "@executor-js/sdk/shared"; |
| 7 | import type { EditSheetApplyResult, EditSheetSectionProps } from "@executor-js/sdk/client"; | ||
| 7 | import { apiKeyMethodLabel, type AuthPlacement } from "@executor-js/sdk/http-auth"; | 8 | import { apiKeyMethodLabel, type AuthPlacement } from "@executor-js/sdk/http-auth"; |
| 8 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 9 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 9 | import { | 10 | import { |
| @@ -12,15 +13,6 @@ import { · 2 unmodified lines | |||
| 12 | type AuthMethodRow, | 13 | type AuthMethodRow, |
| 13 | type AuthMethodSeed, | 14 | type AuthMethodSeed, |
| 14 | } from "@executor-js/react/components/auth-method-list-editor"; | 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 | import { Badge } from "@executor-js/react/components/badge"; | 16 | import { Badge } from "@executor-js/react/components/badge"; |
| 25 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; | 17 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; |
| 26 | 18 | ||
| @@ -77,12 +69,13 @@ const samePlacements = ( · 50 unmodified lines | |||
| 77 | // Remote edit — v2: the integration's endpoint is part of its identity | 69 | // Remote edit — v2: the integration's endpoint is part of its identity |
| 78 | // (opaque-to-core config); the editable surface is the declared auth-method | 70 | // (opaque-to-core config); the editable surface is the declared auth-method |
| 79 | // LIST, through the same shared editor as the add flow. Accounts (credentials) | 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 | function RemoteEdit(props: { | 76 | function RemoteEdit(props: { |
| 84 | server: McpServer & { config: McpRemoteConfig }; | 77 | server: McpServer & { config: McpRemoteConfig }; |
| 85 | onSave: () => void; | 78 | onPendingChange?: EditSheetSectionProps["onPendingChange"]; |
| 86 | }) { | 79 | }) { |
| 87 | const { server } = props; | 80 | const { server } = props; |
| 88 | const doConfigureAuth = useAtomSet(configureMcpAuth, { mode: "promiseExit" }); | 81 | const doConfigureAuth = useAtomSet(configureMcpAuth, { mode: "promiseExit" }); |
| @@ -100,7 +93,6 @@ function RemoteEdit(props: { · 11 unmodified lines | |||
| 100 | ); | 93 | ); |
| 101 | const list = useAuthMethodList(seeds); | 94 | const list = useAuthMethodList(seeds); |
| 102 | 95 | ||
| 103 | const [saving, setSaving] = useState(false); | ||
| 104 | const [error, setError] = useState<string | null>(null); | 96 | const [error, setError] = useState<string | null>(null); |
| 105 | 97 | ||
| 106 | // The edited methods, slugs preserved for seeded rows so existing | 98 | // The edited methods, slugs preserved for seeded rows so existing |
| @@ -130,8 +122,8 @@ function RemoteEdit(props: { · 23 unmodified lines | |||
| 130 | }); | 122 | }); |
| 131 | }, [editedMethods, server.config.authenticationTemplate]); | 123 | }, [editedMethods, server.config.authenticationTemplate]); |
| 132 | 124 | ||
| 133 | const handleSave = useCallback(async () => { | 125 | // Staged apply, run by the sheet's Save when the method list changed. |
| 134 | setSaving(true); | 126 | const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => { |
| 135 | setError(null); | 127 | setError(null); |
| 136 | const exit = await doConfigureAuth({ | 128 | const exit = await doConfigureAuth({ |
| 137 | params: { slug: server.slug }, | 129 | params: { slug: server.slug }, |
| @@ -146,59 +138,37 @@ function RemoteEdit(props: { · 8 unmodified lines | |||
| 146 | }); | 138 | }); |
| 147 | if (Exit.isFailure(exit)) { | 139 | if (Exit.isFailure(exit)) { |
| 148 | setError("Failed to update authentication methods"); | 140 | setError("Failed to update authentication methods"); |
| 149 | setSaving(false); | 141 | return { ok: false }; |
| 150 | return; | ||
| 151 | } | 142 | } |
| 152 | setSaving(false); | 143 | return { ok: true, summary: "Authentication methods updated." }; |
| 153 | }, [doConfigureAuth, editedMethods, server.slug]); | 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 | return ( | 153 | return ( |
| 156 | <div className="space-y-6"> | 154 | <div className="space-y-4 border-t border-border/60 pt-5"> |
| 157 | <div> | 155 | <div className="space-y-1"> |
| 158 | <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1> | 156 | <p className="text-sm font-medium text-foreground">Authentication methods</p> |
| 159 | <p className="mt-1 text-sm text-muted-foreground"> | 157 | <p className="text-xs text-muted-foreground"> |
| 160 | Manage how this MCP server authenticates. The endpoint is part of the server's identity — | 158 | Changes apply when you save. The endpoint ( |
| 161 | remove and re-add to change it. Accounts are added from the integration page. | 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 | </p> | 161 | </p> |
| 163 | </div> | 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 | <AuthMethodListEditor | 164 | <AuthMethodListEditor |
| 182 | list={list} | 165 | list={list} |
| 183 | title="How does this server authenticate?" | ||
| 184 | oauthMetadata="discovered" | 166 | oauthMetadata="discovered" |
| 185 | emptyHint="No methods declared. Add one, or save to mark this server as open (no authentication)." | 167 | emptyHint="No methods declared. Add one, or save to mark this server as open (no authentication)." |
| 186 | footerHint="Connections pick one of these methods. Removing a method detaches connections created against it." | 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 | {error && <FormErrorAlert message={error} />} | 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 | </div> | 172 | </div> |
| 203 | ); | 173 | ); |
| 204 | } | 174 | } |
| @@ -209,65 +179,40 @@ function RemoteEdit(props: { · 4 unmodified lines | |||
| 209 | 179 | ||
| 210 | function StdioReadOnly(props: { | 180 | function StdioReadOnly(props: { |
| 211 | server: McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> }; | 181 | server: McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> }; |
| 212 | onSave: () => void; | ||
| 213 | }) { | 182 | }) { |
| 214 | const { command, args } = props.server.config; | 183 | const { command, args } = props.server.config; |
| 215 | return ( | 184 | return ( |
| 216 | <div className="space-y-6"> | 185 | <div className="space-y-3 border-t border-border/60 pt-5"> |
| 217 | <div> | 186 | <div className="space-y-1"> |
| 218 | <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1> | 187 | <p className="text-sm font-medium text-foreground">Server command</p> |
| 219 | <p className="mt-1 text-sm text-muted-foreground"> | 188 | <p className="text-xs text-muted-foreground"> |
| 220 | Stdio MCP sources cannot be edited in the UI. Remove and recreate the source with the | 189 | Stdio MCP sources cannot be edited. Remove and recreate the source with the updated |
| 221 | updated command. | 190 | command. |
| 222 | </p> | 191 | </p> |
| 223 | </div> | 192 | </div> |
| 224 | 193 | <div className="flex items-center gap-3 rounded-md border border-border/60 bg-muted/40 px-3 py-2"> | |
| 225 | <div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3"> | 194 | <p className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> |
| 226 | <div className="min-w-0 flex-1"> | 195 | {command} {(args ?? []).join(" ")} |
| 227 | <p className="truncate text-sm font-semibold text-card-foreground"> | 196 | </p> |
| 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> | ||
| 234 | <Badge variant="secondary" className="text-xs"> | 197 | <Badge variant="secondary" className="text-xs"> |
| 235 | stdio | 198 | stdio |
| 236 | </Badge> | 199 | </Badge> |
| 237 | </div> | 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 | </div> | 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({ | 210 | export default function EditMcpSource({ sourceId, onPendingChange }: EditSheetSectionProps) { |
| 251 | sourceId, | ||
| 252 | onSave, | ||
| 253 | }: { | ||
| 254 | readonly sourceId: string; | ||
| 255 | readonly onSave: () => void; | ||
| 256 | }) { | ||
| 257 | const slug = IntegrationSlug.make(sourceId); | 211 | const slug = IntegrationSlug.make(sourceId); |
| 258 | const serverResult = useAtomValue(mcpServerAtom(slug)); | 212 | const serverResult = useAtomValue(mcpServerAtom(slug)); |
| 259 | const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null; | 213 | const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null; |
| 260 | 214 | ||
| 261 | if (!AsyncResult.isSuccess(serverResult) || server === null) { | 215 | if (!AsyncResult.isSuccess(serverResult) || server === null) return 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 | } | ||
| 271 | 216 | ||
| 272 | if (server.config.transport === "stdio") { | 217 | if (server.config.transport === "stdio") { |
| 273 | return ( | 218 | return ( |
| @@ -275,10 +220,14 @@ export default function EditMcpSource({ · 1 unmodified line | |||
| 275 | server={ | 220 | server={ |
| 276 | server as McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> } | 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 | } from "@executor-js/react/components/card-stack"; | 12 | } from "@executor-js/react/components/card-stack"; |
| 13 | import { FieldError } from "@executor-js/react/components/field"; | 13 | import { FieldError } from "@executor-js/react/components/field"; |
| 14 | import { Input } from "@executor-js/react/components/input"; | 14 | import { Input } from "@executor-js/react/components/input"; |
| 15 | import { Textarea } from "@executor-js/react/components/textarea"; | ||
| 15 | import { Skeleton } from "@executor-js/react/components/skeleton"; | 16 | import { Skeleton } from "@executor-js/react/components/skeleton"; |
| 16 | import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; | 17 | import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; |
| 17 | import { IOSSpinner } from "@executor-js/react/components/spinner"; | 18 | import { IOSSpinner } from "@executor-js/react/components/spinner"; |
| @@ -34,6 +35,10 @@ export function McpRemoteSourceFields(props: { · 16 unmodified lines | |||
| 34 | readonly url: string; | 35 | readonly url: string; |
| 35 | readonly onUrlChange: (url: string) => void; | 36 | readonly onUrlChange: (url: string) => void; |
| 36 | readonly identity: IntegrationIdentity; | 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 | readonly preview: McpRemoteSourcePreview | null; | 42 | readonly preview: McpRemoteSourcePreview | null; |
| 38 | readonly probing?: boolean; | 43 | readonly probing?: boolean; |
| 39 | readonly error?: string | null; | 44 | readonly error?: string | null; |
| @@ -99,6 +104,23 @@ export function McpRemoteSourceFields(props: { · 59 unmodified lines | |||
| 99 | namePlaceholder="e.g. Linear" | 104 | namePlaceholder="e.g. Linear" |
| 100 | namespaceReadOnly={props.namespaceReadOnly} | 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 | <CardStackEntryField label="Server URL"> | 124 | <CardStackEntryField label="Server URL"> |
| 103 | <Input | 125 | <Input |
| 104 | value={props.url} | 126 | value={props.url} |
| @@ -3,11 +3,11 @@ import type { IntegrationPlugin } from "@executor-js/sdk/client"; · 2 unmodified lines | |||
| 3 | import { mcpPresets } from "../sdk/presets"; | 3 | import { mcpPresets } from "../sdk/presets"; |
| 4 | 4 | ||
| 5 | const importAdd = () => import("./AddMcpSource"); | 5 | const importAdd = () => import("./AddMcpSource"); |
| 6 | const importEdit = () => import("./EditMcpSource"); | 6 | const importEditSheet = () => import("./EditMcpSource"); |
| 7 | const importAccounts = () => import("./McpAccountsPanel"); | 7 | const importAccounts = () => import("./McpAccountsPanel"); |
| 8 | 8 | ||
| 9 | const LazyAddMcpSource = lazy(importAdd); | 9 | const LazyAddMcpSource = lazy(importAdd); |
| 10 | const LazyEditMcpSource = lazy(importEdit); | 10 | const LazyEditMcpSheet = lazy(importEditSheet); |
| 11 | const LazyMcpAccountsPanel = lazy(importAccounts); | 11 | const LazyMcpAccountsPanel = lazy(importAccounts); |
| 12 | 12 | ||
| 13 | type AddProps = ComponentProps<IntegrationPlugin["add"]>; | 13 | type AddProps = ComponentProps<IntegrationPlugin["add"]>; |
| @@ -42,12 +42,12 @@ export const createMcpIntegrationPlugin = ( · 28 unmodified lines | |||
| 42 | key: "mcp", | 42 | key: "mcp", |
| 43 | label: "MCP", | 43 | label: "MCP", |
| 44 | add: AddWithFlag, | 44 | add: AddWithFlag, |
| 45 | edit: LazyEditMcpSource, | 45 | editSheet: LazyEditMcpSheet, |
| 46 | accounts: LazyMcpAccountsPanel, | 46 | accounts: LazyMcpAccountsPanel, |
| 47 | presets, | 47 | presets, |
| 48 | preload: () => { | 48 | preload: () => { |
| 49 | void importAdd(); | 49 | void importAdd(); |
| 50 | void importEdit(); | 50 | void importEditSheet(); |
| 51 | void importAccounts(); | 51 | void importAccounts(); |
| 52 | }, | 52 | }, |
| 53 | }; | 53 | }; |
| @@ -14,6 +14,7 @@ import { describeMcpAuthMethods, describeMcpIntegrationDisplay } from "./plugin" · 13 unmodified lines | |||
| 14 | 14 | ||
| 15 | const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ | 15 | const recordWith = (config: IntegrationConfig): IntegrationRecord => ({ |
| 16 | slug: IntegrationSlug.make("server"), | 16 | slug: IntegrationSlug.make("server"), |
| 17 | name: "Server", | ||
| 17 | description: "Server", | 18 | description: "Server", |
| 18 | kind: "mcp", | 19 | kind: "mcp", |
| 19 | canRemove: true, | 20 | canRemove: true, |
| @@ -55,6 +55,7 @@ export const discoverTools = ( · 54 unmodified lines | |||
| 55 | 55 | ||
| 56 | const manifest = extractManifestFromListToolsResult(listResult, { | 56 | const manifest = extractManifestFromListToolsResult(listResult, { |
| 57 | serverInfo: connection.client.getServerVersion?.(), | 57 | serverInfo: connection.client.getServerVersion?.(), |
| 58 | instructions: connection.client.getInstructions?.(), | ||
| 58 | }); | 59 | }); |
| 59 | 60 | ||
| 60 | // Close the connection after discovery | 61 | // Close the connection after discovery |
| @@ -18,6 +18,8 @@ export interface McpToolManifestEntry { · 17 unmodified lines | |||
| 18 | export interface McpServerMetadata { | 18 | export interface McpServerMetadata { |
| 19 | readonly name: string | null; | 19 | readonly name: string | null; |
| 20 | readonly version: string | null; | 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 | export interface McpToolManifest { | 25 | export interface McpToolManifest { |
| @@ -82,7 +84,7 @@ export const joinToolPath = (namespace: string | undefined, toolId: string): str · 58 unmodified lines | |||
| 82 | 84 | ||
| 83 | export const extractManifestFromListToolsResult = ( | 85 | export const extractManifestFromListToolsResult = ( |
| 84 | listToolsResult: unknown, | 86 | listToolsResult: unknown, |
| 85 | metadata?: { serverInfo?: unknown }, | 87 | metadata?: { serverInfo?: unknown; instructions?: string | undefined }, |
| 86 | ): McpToolManifest => { | 88 | ): McpToolManifest => { |
| 87 | const seen = new Map<string, number>(); | 89 | const seen = new Map<string, number>(); |
| 88 | 90 | ||
| @@ -96,6 +98,7 @@ export const extractManifestFromListToolsResult = ( · 7 unmodified lines | |||
| 96 | (info): McpServerMetadata => ({ | 98 | (info): McpServerMetadata => ({ |
| 97 | name: info.name ?? null, | 99 | name: info.name ?? null, |
| 98 | version: info.version ?? null, | 100 | version: info.version ?? null, |
| 101 | instructions: metadata?.instructions ?? null, | ||
| 99 | }), | 102 | }), |
| 100 | ), | 103 | ), |
| 101 | Option.getOrNull, | 104 | Option.getOrNull, |
| @@ -145,6 +145,8 @@ const readStamp = (annotations: unknown): McpToolStamp | null => · 144 unmodified lines | |||
| 145 | const McpRemoteServerInputSchema = Schema.Struct({ | 145 | const McpRemoteServerInputSchema = Schema.Struct({ |
| 146 | transport: Schema.optional(Schema.Literal("remote")), | 146 | transport: Schema.optional(Schema.Literal("remote")), |
| 147 | name: Schema.String, | 147 | name: Schema.String, |
| 148 | /** Agent-visible catalog description. Defaults to the display name. */ | ||
| 149 | description: Schema.optional(Schema.String), | ||
| 148 | endpoint: Schema.String, | 150 | endpoint: Schema.String, |
| 149 | remoteTransport: Schema.optional(McpRemoteTransport), | 151 | remoteTransport: Schema.optional(McpRemoteTransport), |
| 150 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 152 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| @@ -160,6 +162,7 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 9 unmodified lines | |||
| 160 | const McpStdioServerInputSchema = Schema.Struct({ | 162 | const McpStdioServerInputSchema = Schema.Struct({ |
| 161 | transport: Schema.Literal("stdio"), | 163 | transport: Schema.Literal("stdio"), |
| 162 | name: Schema.String, | 164 | name: Schema.String, |
| 165 | description: Schema.optional(Schema.String), | ||
| 163 | command: Schema.String, | 166 | command: Schema.String, |
| 164 | args: Schema.optional(Schema.Array(Schema.String)), | 167 | args: Schema.optional(Schema.Array(Schema.String)), |
| 165 | env: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 168 | env: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| @@ -200,6 +203,9 @@ const McpProbeEndpointOutputSchema = Schema.Struct({ · 34 unmodified lines | |||
| 200 | slug: Schema.String, | 203 | slug: Schema.String, |
| 201 | toolCount: Schema.NullOr(Schema.Number), | 204 | toolCount: Schema.NullOr(Schema.Number), |
| 202 | serverName: Schema.NullOr(Schema.String), | 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 | slug, | 632 | slug, |
| 627 | toolCount: result.manifest.tools.length, | 633 | toolCount: result.manifest.tools.length, |
| 628 | serverName: result.manifest.server?.name ?? null, | 634 | serverName: result.manifest.server?.name ?? null, |
| 635 | instructions: result.manifest.server?.instructions ?? null, | ||
| 629 | } satisfies McpProbeResult; | 636 | } satisfies McpProbeResult; |
| 630 | } | 637 | } |
| 631 | 638 | ||
| @@ -660,6 +667,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 28 unmodified lines | |||
| 660 | slug, | 667 | slug, |
| 661 | toolCount: null, | 668 | toolCount: null, |
| 662 | serverName: null, | 669 | serverName: null, |
| 670 | instructions: null, | ||
| 663 | } satisfies McpProbeResult; | 671 | } satisfies McpProbeResult; |
| 664 | } | 672 | } |
| 665 | 673 | ||
| @@ -673,6 +681,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 7 unmodified lines | |||
| 673 | slug, | 681 | slug, |
| 674 | toolCount: null, | 682 | toolCount: null, |
| 675 | serverName: null, | 683 | serverName: null, |
| 684 | instructions: null, | ||
| 676 | } satisfies McpProbeResult; | 685 | } satisfies McpProbeResult; |
| 677 | } | 686 | } |
| 678 | 687 | ||
| @@ -705,7 +714,8 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 26 unmodified lines | |||
| 705 | yield* ctx.core.integrations | 714 | yield* ctx.core.integrations |
| 706 | .register({ | 715 | .register({ |
| 707 | slug: slugFrom(slug), | 716 | slug: slugFrom(slug), |
| 708 | description: input.name, | 717 | name: input.name, |
| 718 | description: input.description?.trim() || input.name, | ||
| 709 | config, | 719 | config, |
| 710 | canRemove: true, | 720 | canRemove: true, |
| 711 | canRefresh: true, | 721 | canRefresh: true, |
| @@ -4,6 +4,7 @@ import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth · 3 unmodified lines | |||
| 4 | import { | 4 | import { |
| 5 | InternalError, | 5 | InternalError, |
| 6 | IntegrationAlreadyExistsError, | 6 | IntegrationAlreadyExistsError, |
| 7 | IntegrationNotFoundError, | ||
| 7 | IntegrationSlug, | 8 | IntegrationSlug, |
| 8 | } from "@executor-js/sdk/shared"; | 9 | } from "@executor-js/sdk/shared"; |
| 9 | 10 | ||
| @@ -26,6 +27,16 @@ const DomainErrors = [ · 16 unmodified lines | |||
| 26 | IntegrationAlreadyExistsError, | 27 | IntegrationAlreadyExistsError, |
| 27 | ] as const; | 28 | ] as const; |
| 28 | 29 | ||
| 30 | const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 }); | ||
| 31 | |||
| 32 | const UpdateSpecErrors = [ | ||
| 33 | InternalError, | ||
| 34 | OpenApiParseError, | ||
| 35 | OpenApiExtractionError, | ||
| 36 | OpenApiOAuthError, | ||
| 37 | IntegrationNotFound, | ||
| 38 | ] as const; | ||
| 39 | |||
| 29 | const SlugParams = { | 40 | const SlugParams = { |
| 30 | slug: Schema.String, | 41 | slug: Schema.String, |
| 31 | }; | 42 | }; |
| @@ -64,6 +75,7 @@ const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMet · 32 unmodified lines | |||
| 64 | const AddSpecPayload = Schema.Struct({ | 75 | const AddSpecPayload = Schema.Struct({ |
| 65 | spec: OpenApiSpecInputPayload, | 76 | spec: OpenApiSpecInputPayload, |
| 66 | slug: Schema.String, | 77 | slug: Schema.String, |
| 78 | name: Schema.optional(Schema.String), | ||
| 67 | description: Schema.optional(Schema.String), | 79 | description: Schema.optional(Schema.String), |
| 68 | baseUrl: Schema.optional(Schema.String), | 80 | baseUrl: Schema.optional(Schema.String), |
| 69 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | 81 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| @@ -92,6 +104,21 @@ const AddSpecResponse = Schema.Struct({ · 22 unmodified lines | |||
| 92 | toolCount: Schema.Number, | 104 | toolCount: Schema.Number, |
| 93 | }); | 105 | }); |
| 94 | 106 | ||
| 107 | // Update the spec in place. Body optional fields only: an empty payload means | ||
| 108 | // "re-fetch from the stored source URL". | ||
| 109 | const UpdateSpecPayload = Schema.Struct({ | ||
| 110 | spec: Schema.optional(OpenApiSpecInputPayload), | ||
| 111 | }); | ||
| 112 | |||
| 113 | const UpdateSpecResponse = Schema.Struct({ | ||
| 114 | slug: IntegrationSlug, | ||
| 115 | toolCount: Schema.Number, | ||
| 116 | /** Tool names new in this spec version (same diff for every connection). */ | ||
| 117 | addedTools: Schema.Array(Schema.String), | ||
| 118 | /** Tool names the new spec no longer defines. */ | ||
| 119 | removedTools: Schema.Array(Schema.String), | ||
| 120 | }); | ||
| 121 | |||
| 95 | const IntegrationView = Schema.Struct({ | 122 | const IntegrationView = Schema.Struct({ |
| 96 | slug: IntegrationSlug, | 123 | slug: IntegrationSlug, |
| 97 | description: Schema.String, | 124 | description: Schema.String, |
| @@ -161,6 +188,14 @@ export const OpenApiGroup = HttpApiGroup.make("openapi") · 63 unmodified lines | |||
| 161 | error: DomainErrors, | 188 | error: DomainErrors, |
| 162 | }), | 189 | }), |
| 163 | ) | 190 | ) |
| 191 | .add( | ||
| 192 | HttpApiEndpoint.post("updateSpec", "/openapi/integrations/:slug/spec", { | ||
| 193 | params: SlugParams, | ||
| 194 | payload: UpdateSpecPayload, | ||
| 195 | success: UpdateSpecResponse, | ||
| 196 | error: UpdateSpecErrors, | ||
| 197 | }), | ||
| 198 | ) | ||
| 164 | .add( | 199 | .add( |
| 165 | HttpApiEndpoint.delete("removeSpec", "/openapi/integrations/:slug", { | 200 | HttpApiEndpoint.delete("removeSpec", "/openapi/integrations/:slug", { |
| 166 | params: SlugParams, | 201 | params: SlugParams, |
| @@ -54,6 +54,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 53 unmodified lines | |||
| 54 | return yield* ext.addSpec({ | 54 | return yield* ext.addSpec({ |
| 55 | spec: payload.spec, | 55 | spec: payload.spec, |
| 56 | slug: payload.slug, | 56 | slug: payload.slug, |
| 57 | name: payload.name, | ||
| 57 | description: payload.description, | 58 | description: payload.description, |
| 58 | baseUrl: payload.baseUrl, | 59 | baseUrl: payload.baseUrl, |
| 59 | headers: payload.headers ? { ...payload.headers } : undefined, | 60 | headers: payload.headers ? { ...payload.headers } : undefined, |
| @@ -114,6 +115,22 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 54 unmodified lines | |||
| 114 | }), | 115 | }), |
| 115 | ), | 116 | ), |
| 116 | ) | 117 | ) |
| 118 | .handle("updateSpec", ({ params, payload }) => | ||
| 119 | capture( | ||
| 120 | Effect.gen(function* () { | ||
| 121 | const ext = yield* OpenApiExtensionService; | ||
| 122 | const result = yield* ext.updateSpec(params.slug, { | ||
| 123 | ...(payload.spec !== undefined ? { spec: payload.spec } : {}), | ||
| 124 | }); | ||
| 125 | return { | ||
| 126 | slug: result.slug, | ||
| 127 | toolCount: result.toolCount, | ||
| 128 | addedTools: [...result.addedTools], | ||
| 129 | removedTools: [...result.removedTools], | ||
| 130 | }; | ||
| 131 | }), | ||
| 132 | ), | ||
| 133 | ) | ||
| 117 | .handle("removeSpec", ({ params }) => | 134 | .handle("removeSpec", ({ params }) => |
| 118 | capture( | 135 | capture( |
| 119 | Effect.gen(function* () { | 136 | Effect.gen(function* () { |
| @@ -133,6 +133,9 @@ export default function AddOpenApiSource(props: { · 132 unmodified lines | |||
| 133 | // After analysis | 133 | // After analysis |
| 134 | const [preview, setPreview] = useState<SpecPreview | null>(null); | 134 | const [preview, setPreview] = useState<SpecPreview | null>(null); |
| 135 | const [baseUrl, setBaseUrl] = useState(""); | 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 | const identityFallbackName = isGoogleBundlePreset | 139 | const identityFallbackName = isGoogleBundlePreset |
| 137 | ? "Google" | 140 | ? "Google" |
| 138 | : preview | 141 | : preview |
| @@ -220,6 +223,8 @@ export default function AddOpenApiSource(props: { · 81 unmodified lines | |||
| 220 | const resolvedDisplayName = | 223 | const resolvedDisplayName = |
| 221 | identity.name.trim() || | 224 | identity.name.trim() || |
| 222 | (preview ? Option.getOrElse(preview.title, () => resolvedSourceId) : resolvedSourceId); | 225 | (preview ? Option.getOrElse(preview.title, () => resolvedSourceId) : resolvedSourceId); |
| 226 | const resolvedDescription = | ||
| 227 | descriptionDraft ?? (preview ? Option.getOrElse(preview.description, () => "") : ""); | ||
| 223 | 228 | ||
| 224 | // Register EVERY spec-detected auth method, not just a single selected one. | 229 | // Register EVERY spec-detected auth method, not just a single selected one. |
| 225 | // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the | 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 | payload: { | 332 | payload: { |
| 328 | spec: specForAdd, | 333 | spec: specForAdd, |
| 329 | slug: resolvedSourceId, | 334 | slug: resolvedSourceId, |
| 330 | description: resolvedDisplayName, | 335 | name: resolvedDisplayName, |
| 336 | ...(resolvedDescription.trim().length > 0 | ||
| 337 | ? { description: resolvedDescription.trim() } | ||
| 338 | : {}), | ||
| 331 | baseUrl: resolvedBaseUrl, | 339 | baseUrl: resolvedBaseUrl, |
| 332 | // Always send the edited method list (even empty) when the user has | 340 | // Always send the edited method list (even empty) when the user has |
| 333 | // inspected a preview: an explicit [] means "no auth methods", while | 341 | // inspected a preview: an explicit [] means "no auth methods", while |
| @@ -356,6 +364,7 @@ export default function AddOpenApiSource(props: { · 22 unmodified lines | |||
| 356 | doAdd, | 364 | doAdd, |
| 357 | resolvedSourceId, | 365 | resolvedSourceId, |
| 358 | resolvedDisplayName, | 366 | resolvedDisplayName, |
| 367 | resolvedDescription, | ||
| 359 | resolvedBaseUrl, | 368 | resolvedBaseUrl, |
| 360 | editedAuthenticationTemplate, | 369 | editedAuthenticationTemplate, |
| 361 | ]); | 370 | ]); |
| @@ -428,10 +437,12 @@ export default function AddOpenApiSource(props: { · 66 unmodified lines | |||
| 428 | {isGoogleBundlePreset ? ( | 437 | {isGoogleBundlePreset ? ( |
| 429 | <OpenApiSourceDetailsFields | 438 | <OpenApiSourceDetailsFields |
| 430 | title="Google" | 439 | title="Google" |
| 431 | description={`${bundleDiscoveryUrls.length} Google API${ | 440 | subtitle={`${bundleDiscoveryUrls.length} Google API${ |
| 432 | bundleDiscoveryUrls.length !== 1 ? "s" : "" | 441 | bundleDiscoveryUrls.length !== 1 ? "s" : "" |
| 433 | } · one shared OAuth consent`} | 442 | } · one shared OAuth consent`} |
| 434 | identity={identity} | 443 | identity={identity} |
| 444 | description={resolvedDescription} | ||
| 445 | onDescriptionChange={setDescriptionDraft} | ||
| 435 | baseUrl={resolvedBaseUrl} | 446 | baseUrl={resolvedBaseUrl} |
| 436 | onBaseUrlChange={setBaseUrl} | 447 | onBaseUrlChange={setBaseUrl} |
| 437 | baseUrlLabel="Base URL override (optional)" | 448 | baseUrlLabel="Base URL override (optional)" |
| @@ -441,7 +452,7 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | |||
| 441 | ) : preview ? ( | 452 | ) : preview ? ( |
| 442 | <OpenApiSourceDetailsFields | 453 | <OpenApiSourceDetailsFields |
| 443 | title={Option.getOrElse(preview.title, () => "API")} | 454 | title={Option.getOrElse(preview.title, () => "API")} |
| 444 | description={`${Option.getOrElse(preview.version, () => "")}${ | 455 | subtitle={`${Option.getOrElse(preview.version, () => "")}${ |
| 445 | Option.isSome(preview.version) ? " · " : "" | 456 | Option.isSome(preview.version) ? " · " : "" |
| 446 | }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${ | 457 | }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${ |
| 447 | preview.tags.length > 0 | 458 | preview.tags.length > 0 |
| @@ -449,6 +460,8 @@ export default function AddOpenApiSource(props: { · 1 unmodified line | |||
| 449 | : "" | 460 | : "" |
| 450 | }`} | 461 | }`} |
| 451 | identity={identity} | 462 | identity={identity} |
| 463 | description={resolvedDescription} | ||
| 464 | onDescriptionChange={setDescriptionDraft} | ||
| 452 | baseUrl={resolvedBaseUrl} | 465 | baseUrl={resolvedBaseUrl} |
| 453 | onBaseUrlChange={setBaseUrl} | 466 | onBaseUrlChange={setBaseUrl} |
| 454 | baseUrlOptions={baseUrlOptions} | 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 | type FreeformComboboxOption, | 12 | type FreeformComboboxOption, |
| 13 | } from "@executor-js/react/components/combobox"; | 13 | } from "@executor-js/react/components/combobox"; |
| 14 | import { Input } from "@executor-js/react/components/input"; | 14 | import { Input } from "@executor-js/react/components/input"; |
| 15 | import { Textarea } from "@executor-js/react/components/textarea"; | ||
| 15 | import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; | 16 | import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; |
| 16 | import { | 17 | import { |
| 17 | IntegrationIdentityFieldRows, | 18 | IntegrationIdentityFieldRows, |
| 18 | type IntegrationIdentity, | 19 | type IntegrationIdentity, |
| 19 | } from "@executor-js/react/plugins/integration-identity"; | 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 | export function OpenApiSourceDetailsFields(props: { | 26 | export function OpenApiSourceDetailsFields(props: { |
| 22 | readonly title: string; | 27 | readonly title: string; |
| 23 | readonly description?: string; | 28 | readonly subtitle?: string; |
| 24 | readonly identity: IntegrationIdentity; | 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 | readonly baseUrl: string; | 33 | readonly baseUrl: string; |
| 26 | readonly onBaseUrlChange: (value: string) => void; | 34 | readonly onBaseUrlChange: (value: string) => void; |
| 27 | readonly baseUrlOptions?: readonly FreeformComboboxOption[]; | 35 | readonly baseUrlOptions?: readonly FreeformComboboxOption[]; |
| @@ -38,6 +46,8 @@ export function OpenApiSourceDetailsFields(props: { · 10 unmodified lines | |||
| 38 | readonly baseUrlMissingMessage?: string; | 46 | readonly baseUrlMissingMessage?: string; |
| 39 | readonly footer?: string; | 47 | readonly footer?: string; |
| 40 | }) { | 48 | }) { |
| 49 | const specIsUrl = props.specUrl !== undefined && isUrlInput(props.specUrl); | ||
| 50 | |||
| 41 | return ( | 51 | return ( |
| 42 | <CardStack> | 52 | <CardStack> |
| 43 | <CardStackContent className="border-t-0"> | 53 | <CardStackContent className="border-t-0"> |
| @@ -47,8 +57,8 @@ export function OpenApiSourceDetailsFields(props: { · 3 unmodified lines | |||
| 47 | )} | 57 | )} |
| 48 | <CardStackEntryContent> | 58 | <CardStackEntryContent> |
| 49 | <CardStackEntryTitle>{props.title}</CardStackEntryTitle> | 59 | <CardStackEntryTitle>{props.title}</CardStackEntryTitle> |
| 50 | {props.description && ( | 60 | {props.subtitle && ( |
| 51 | <CardStackEntryDescription>{props.description}</CardStackEntryDescription> | 61 | <CardStackEntryDescription>{props.subtitle}</CardStackEntryDescription> |
| 52 | )} | 62 | )} |
| 53 | </CardStackEntryContent> | 63 | </CardStackEntryContent> |
| 54 | {props.saveState && props.saveState !== "idle" && ( | 64 | {props.saveState && props.saveState !== "idle" && ( |
| @@ -61,6 +71,18 @@ export function OpenApiSourceDetailsFields(props: { · 6 unmodified lines | |||
| 61 | identity={props.identity} | 71 | identity={props.identity} |
| 62 | namespaceReadOnly={props.namespaceReadOnly} | 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 | <div className="grid grid-cols-1 md:grid-cols-2"> | 86 | <div className="grid grid-cols-1 md:grid-cols-2"> |
| 65 | <CardStackEntryField label={props.baseUrlLabel ?? "Base URL"}> | 87 | <CardStackEntryField label={props.baseUrlLabel ?? "Base URL"}> |
| 66 | {props.baseUrlOptions && props.baseUrlOptions.length > 0 ? ( | 88 | {props.baseUrlOptions && props.baseUrlOptions.length > 0 ? ( |
| @@ -90,7 +112,7 @@ export function OpenApiSourceDetailsFields(props: { · 23 unmodified lines | |||
| 90 | <p className="text-[11px] text-muted-foreground">{props.baseUrlHint}</p> | 112 | <p className="text-[11px] text-muted-foreground">{props.baseUrlHint}</p> |
| 91 | )} | 113 | )} |
| 92 | </CardStackEntryField> | 114 | </CardStackEntryField> |
| 93 | {props.specUrl !== undefined && props.onSpecUrlChange && ( | 115 | {specIsUrl && props.onSpecUrlChange && ( |
| 94 | <CardStackEntryField label="Spec URL"> | 116 | <CardStackEntryField label="Spec URL"> |
| 95 | <Input | 117 | <Input |
| 96 | value={props.specUrl} | 118 | value={props.specUrl} |
| @@ -102,6 +124,19 @@ export function OpenApiSourceDetailsFields(props: { · 5 unmodified lines | |||
| 102 | </CardStackEntryField> | 124 | </CardStackEntryField> |
| 103 | )} | 125 | )} |
| 104 | </div> | 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 | {props.footer && ( | 140 | {props.footer && ( |
| 106 | <CardStackEntry> | 141 | <CardStackEntry> |
| 107 | <CardStackEntryContent> | 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 | export const removeOpenApiSpec = OpenApiClient.mutation("openapi", "removeSpec"); | 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 | // Add / merge custom auth methods onto an integration's `authenticationTemplate`. | 44 | // Add / merge custom auth methods onto an integration's `authenticationTemplate`. |
| 40 | export const openapiConfigure = OpenApiClient.mutation("openapi", "configure"); | 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 | import { openApiPresets } from "../sdk/presets"; | 3 | import { openApiPresets } from "../sdk/presets"; |
| 4 | 4 | ||
| 5 | const importAdd = () => import("./AddOpenApiSource"); | 5 | const importAdd = () => import("./AddOpenApiSource"); |
| 6 | const importEdit = () => import("./EditOpenApiSource"); | 6 | const importEditSheet = () => import("./UpdateSpecSection"); |
| 7 | const importAccounts = () => import("./OpenApiAccountsPanel"); | 7 | const importAccounts = () => import("./OpenApiAccountsPanel"); |
| 8 | 8 | ||
| 9 | export const openApiIntegrationPlugin: IntegrationPlugin = { | 9 | export const openApiIntegrationPlugin: IntegrationPlugin = { |
| 10 | key: "openapi", | 10 | key: "openapi", |
| 11 | label: "OpenAPI", | 11 | label: "OpenAPI", |
| 12 | add: lazy(importAdd), | 12 | add: lazy(importAdd), |
| 13 | edit: lazy(importEdit), | 13 | editSheet: lazy(importEditSheet), |
| 14 | accounts: lazy(importAccounts), | 14 | accounts: lazy(importAccounts), |
| 15 | presets: openApiPresets, | 15 | presets: openApiPresets, |
| 16 | preload: () => { | 16 | preload: () => { |
| 17 | void importAdd(); | 17 | void importAdd(); |
| 18 | void importEdit(); | 18 | void importEditSheet(); |
| 19 | void importAccounts(); | 19 | void importAccounts(); |
| 20 | }, | 20 | }, |
| 21 | }; | 21 | }; |
| @@ -19,6 +19,7 @@ import { type Authentication } from "./types"; · 18 unmodified lines | |||
| 19 | 19 | ||
| 20 | const recordWith = (templates: readonly Authentication[]): IntegrationRecord => ({ | 20 | const recordWith = (templates: readonly Authentication[]): IntegrationRecord => ({ |
| 21 | slug: IntegrationSlug.make("petstore"), | 21 | slug: IntegrationSlug.make("petstore"), |
| 22 | name: "Petstore", | ||
| 22 | description: "Petstore", | 23 | description: "Petstore", |
| 23 | kind: "openapi", | 24 | kind: "openapi", |
| 24 | canRemove: true, | 25 | canRemove: true, |
| @@ -84,6 +85,7 @@ describe("describeOpenApiAuthMethods", () => { · 59 unmodified lines | |||
| 84 | expect( | 85 | expect( |
| 85 | describeOpenApiAuthMethods({ | 86 | describeOpenApiAuthMethods({ |
| 86 | slug: IntegrationSlug.make("x"), | 87 | slug: IntegrationSlug.make("x"), |
| 88 | name: "x", | ||
| 87 | description: "x", | 89 | description: "x", |
| 88 | kind: "openapi", | 90 | kind: "openapi", |
| 89 | canRemove: true, | 91 | canRemove: true, |
| @@ -405,6 +405,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 404 unmodified lines | |||
| 405 | 405 | ||
| 406 | return ExtractionResult.make({ | 406 | return ExtractionResult.make({ |
| 407 | title: Option.fromNullishOr(doc.info?.title), | 407 | title: Option.fromNullishOr(doc.info?.title), |
| 408 | description: Option.fromNullishOr(doc.info?.description), | ||
| 408 | version: Option.fromNullishOr(doc.info?.version), | 409 | version: Option.fromNullishOr(doc.info?.version), |
| 409 | servers: docServers, | 410 | servers: docServers, |
| 410 | operations, | 411 | operations, |
| @@ -25,8 +25,13 @@ import { · 24 unmodified lines | |||
| 25 | IntegrationSlug, | 25 | IntegrationSlug, |
| 26 | ToolAddress, | 26 | ToolAddress, |
| 27 | } from "@executor-js/sdk"; | 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 | import { | 32 | import { |
| 29 | makeTestConfig, | 33 | makeTestConfig, |
| 34 | makeTestWorkspaceHarness, | ||
| 30 | memoryCredentialsPlugin, | 35 | memoryCredentialsPlugin, |
| 31 | typeCheckOutputTypeScript, | 36 | typeCheckOutputTypeScript, |
| 32 | } from "@executor-js/sdk/testing"; | 37 | } from "@executor-js/sdk/testing"; |
| @@ -36,6 +41,7 @@ import { type AuthenticationInput } from "./types"; · 3 unmodified lines | |||
| 36 | import { | 41 | import { |
| 37 | addOpenApiTestConnection, | 42 | addOpenApiTestConnection, |
| 38 | makeOpenApiHttpApiTestSourceConfig, | 43 | makeOpenApiHttpApiTestSourceConfig, |
| 44 | serveMutableOpenApiSpecTestServer, | ||
| 39 | serveOpenApiHttpApiTestServer, | 45 | serveOpenApiHttpApiTestServer, |
| 40 | unwrapInvocation, | 46 | unwrapInvocation, |
| 41 | } from "../testing"; | 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 | // removed: the v1-only behaviours below have no v2 equivalent — | 919 | // removed: the v1-only behaviours below have no v2 equivalent — |
| 708 | // - "adds an org source whose direct credentials are owned by the user scope" | 920 | // - "adds an org source whose direct credentials are owned by the user scope" |
| 709 | // - "sources.configure removes bindings for credential slots no longer present" | 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 | import { | 5 | import { |
| 6 | IntegrationAlreadyExistsError, | 6 | IntegrationAlreadyExistsError, |
| 7 | IntegrationDetectionResult, | 7 | IntegrationDetectionResult, |
| 8 | IntegrationNotFoundError, | ||
| 8 | IntegrationSlug, | 9 | IntegrationSlug, |
| 9 | ToolName, | 10 | ToolName, |
| 10 | ToolResult, | 11 | ToolResult, |
| @@ -149,7 +150,10 @@ export interface OpenApiSpecConfig { · 138 unmodified lines | |||
| 149 | readonly spec: OpenApiSpecInput; | 150 | readonly spec: OpenApiSpecInput; |
| 150 | /** The catalog slug for the new integration (the `<integration>` segment). */ | 151 | /** The catalog slug for the new integration (the `<integration>` segment). */ |
| 151 | readonly slug: string; | 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 | readonly description?: string; | 157 | readonly description?: string; |
| 154 | readonly baseUrl?: string; | 158 | readonly baseUrl?: string; |
| 155 | /** Static headers applied to every request (no secret material). */ | 159 | /** Static headers applied to every request (no secret material). */ |
| @@ -176,6 +180,21 @@ export interface OpenApiConfigureInput { · 20 unmodified lines | |||
| 176 | readonly mode?: "merge" | "replace"; | 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 | export interface OpenApiPluginExtension { | 198 | export interface OpenApiPluginExtension { |
| 180 | readonly previewSpec: ( | 199 | readonly previewSpec: ( |
| 181 | input: string | OpenApiPreviewInput, | 200 | input: string | OpenApiPreviewInput, |
| @@ -193,6 +212,20 @@ export interface OpenApiPluginExtension { · 11 unmodified lines | |||
| 193 | | IntegrationAlreadyExistsError | 212 | | IntegrationAlreadyExistsError |
| 194 | | StorageFailure | 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 | readonly removeSpec: (slug: string) => Effect.Effect<void, StorageFailure>; | 229 | readonly removeSpec: (slug: string) => Effect.Effect<void, StorageFailure>; |
| 197 | readonly getIntegration: (slug: string) => Effect.Effect<Integration | null, StorageFailure>; | 230 | readonly getIntegration: (slug: string) => Effect.Effect<Integration | null, StorageFailure>; |
| 198 | /** Read the integration's full opaque config, including its | 231 | /** Read the integration's full opaque config, including its |
| @@ -560,6 +593,8 @@ interface CompiledSpec { · 361 unmodified lines | |||
| 560 | readonly definitions: readonly ToolDefinition[]; | 593 | readonly definitions: readonly ToolDefinition[]; |
| 561 | readonly hoistedDefs: Record<string, unknown>; | 594 | readonly hoistedDefs: Record<string, unknown>; |
| 562 | readonly title: string | undefined; | 595 | readonly title: string | undefined; |
| 596 | /** The spec's `info.description`. */ | ||
| 597 | readonly description: string | undefined; | ||
| 563 | } | 598 | } |
| 564 | 599 | ||
| 565 | const compileSpec = ( | 600 | const compileSpec = ( |
| @@ -578,6 +613,7 @@ const compileSpec = ( · 12 unmodified lines | |||
| 578 | definitions: compileToolDefinitions(result.operations), | 613 | definitions: compileToolDefinitions(result.operations), |
| 579 | hoistedDefs, | 614 | hoistedDefs, |
| 580 | title: Option.getOrUndefined(result.title), | 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 | Effect.gen(function* () { | 827 | Effect.gen(function* () { |
| 792 | yield* ctx.core.integrations.register({ | 828 | yield* ctx.core.integrations.register({ |
| 793 | slug, | 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 | config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig, | 833 | config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig, |
| 796 | canRemove: true, | 834 | canRemove: true, |
| 797 | canRefresh: | 835 | canRefresh: |
| @@ -808,6 +846,112 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 10 unmodified lines | |||
| 808 | return { slug, toolCount: compiled.definitions.length }; | 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 | return { | 955 | return { |
| 812 | previewSpec: (input: string | OpenApiPreviewInput) => | 956 | previewSpec: (input: string | OpenApiPreviewInput) => |
| 813 | Effect.gen(function* () { | 957 | Effect.gen(function* () { |
| @@ -829,6 +973,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 15 unmodified lines | |||
| 829 | 973 | ||
| 830 | addSpec, | 974 | addSpec, |
| 831 | 975 | ||
| 976 | updateSpec, | ||
| 977 | |||
| 832 | removeSpec: (slug: string) => | 978 | removeSpec: (slug: string) => |
| 833 | ctx.transaction( | 979 | ctx.transaction( |
| 834 | Effect.gen(function* () { | 980 | Effect.gen(function* () { |
| @@ -135,6 +135,8 @@ export type PreviewOperation = typeof PreviewOperation.Type; · 134 unmodified lines | |||
| 135 | 135 | ||
| 136 | export const SpecPreview = Schema.Struct({ | 136 | export const SpecPreview = Schema.Struct({ |
| 137 | title: Schema.OptionFromOptional(Schema.String), | 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 | version: Schema.OptionFromOptional(Schema.String), | 140 | version: Schema.OptionFromOptional(Schema.String), |
| 139 | /** Reuses ServerInfo from extraction */ | 141 | /** Reuses ServerInfo from extraction */ |
| 140 | servers: Schema.Array(ServerInfo), | 142 | servers: Schema.Array(ServerInfo), |
| @@ -396,6 +398,7 @@ export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (s · 255 unmodified lines | |||
| 396 | 398 | ||
| 397 | return SpecPreview.make({ | 399 | return SpecPreview.make({ |
| 398 | title: result.title, | 400 | title: result.title, |
| 401 | description: result.description, | ||
| 399 | version: result.version, | 402 | version: result.version, |
| 400 | servers: result.servers, | 403 | servers: result.servers, |
| 401 | operationCount: result.operations.length, | 404 | operationCount: result.operations.length, |
| @@ -144,6 +144,7 @@ describe("OpenAPI plugin — spec blob storage", () => { · 143 unmodified lines | |||
| 144 | plugin.resolveTools!({ | 144 | plugin.resolveTools!({ |
| 145 | integration: { | 145 | integration: { |
| 146 | slug: IntegrationSlug.make("pointer_api"), | 146 | slug: IntegrationSlug.make("pointer_api"), |
| 147 | name: "pointer", | ||
| 147 | description: "pointer", | 148 | description: "pointer", |
| 148 | kind: "openapi", | 149 | kind: "openapi", |
| 149 | canRemove: true, | 150 | canRemove: true, |
| @@ -168,6 +168,8 @@ export type ExtractedOperation = typeof ExtractedOperation.Type; · 167 unmodified lines | |||
| 168 | 168 | ||
| 169 | export const ExtractionResult = Schema.Struct({ | 169 | export const ExtractionResult = Schema.Struct({ |
| 170 | title: Schema.OptionFromOptional(Schema.String), | 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 | version: Schema.OptionFromOptional(Schema.String), | 173 | version: Schema.OptionFromOptional(Schema.String), |
| 172 | servers: Schema.Array(ServerInfo), | 174 | servers: Schema.Array(ServerInfo), |
| 173 | operations: Schema.Array(ExtractedOperation), | 175 | operations: Schema.Array(ExtractedOperation), |
| @@ -141,6 +141,10 @@ export const createConnection = ExecutorApiClient.mutation("connections", "creat · 140 unmodified lines | |||
| 141 | 141 | ||
| 142 | export const removeConnection = ExecutorApiClient.mutation("connections", "remove"); | 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 | export const refreshConnection = ExecutorApiClient.mutation("connections", "refresh"); | 148 | export const refreshConnection = ExecutorApiClient.mutation("connections", "refresh"); |
| 145 | 149 | ||
| 146 | export const updateIntegration = ExecutorApiClient.mutation("integrations", "update"); | 150 | export const updateIntegration = ExecutorApiClient.mutation("integrations", "update"); |
| @@ -279,6 +283,7 @@ type CreateConnectionArg = { · 132 unmodified lines | |||
| 279 | readonly integration: IntegrationSlug; | 283 | readonly integration: IntegrationSlug; |
| 280 | readonly template: AuthTemplateSlug; | 284 | readonly template: AuthTemplateSlug; |
| 281 | readonly identityLabel?: string | null; | 285 | readonly identityLabel?: string | null; |
| 286 | readonly description?: string | null; | ||
| 282 | } & ( | 287 | } & ( |
| 283 | | { readonly value: string } | 288 | | { readonly value: string } |
| 284 | | { readonly values: Record<string, string> } | 289 | | { readonly values: Record<string, string> } |
| @@ -314,6 +319,7 @@ export const addConnectionOptimistic = Atom.family((owner: Owner) => · 29 unmodified lines | |||
| 314 | `tools.${payload.integration}.${payload.owner}.${payload.name}`, | 319 | `tools.${payload.integration}.${payload.owner}.${payload.name}`, |
| 315 | ), | 320 | ), |
| 316 | identityLabel: payload.identityLabel ?? null, | 321 | identityLabel: payload.identityLabel ?? null, |
| 322 | description: payload.description ?? null, | ||
| 317 | expiresAt: null, | 323 | expiresAt: null, |
| 318 | // Optimistic placeholder predates the server resolving which app (if | 324 | // Optimistic placeholder predates the server resolving which app (if |
| 319 | // any) minted this connection; reconciled on refresh. Matches the | 325 | // any) minted this connection; reconciled on refresh. Matches the |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | |||
| 25 | } from "../plugins/oauth-reconnect"; | 25 | } from "../plugins/oauth-reconnect"; |
| 26 | import { useOAuthPopupFlow } from "../plugins/oauth-sign-in"; | 26 | import { useOAuthPopupFlow } from "../plugins/oauth-sign-in"; |
| 27 | import { AddAccountModal } from "./add-account-modal"; | 27 | import { AddAccountModal } from "./add-account-modal"; |
| 28 | import { ConnectionEditSheet } from "./metadata-edit-sheet"; | ||
| 28 | import type { CreateCustomMethod } from "./add-custom-method-modal"; | 29 | import type { CreateCustomMethod } from "./add-custom-method-modal"; |
| 29 | import { Badge } from "./badge"; | 30 | import { Badge } from "./badge"; |
| 30 | import { Button } from "./button"; | 31 | import { Button } from "./button"; |
| @@ -48,8 +49,9 @@ import { · 17 unmodified lines | |||
| 48 | // --------------------------------------------------------------------------- | 49 | // --------------------------------------------------------------------------- |
| 49 | // Accounts section — the integration's connections, grouped by owner. | 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 | // Credentials are IMMUTABLE (no method switch); the editable surface is the |
| 52 | // rename, no method switch). "+ Add connection" opens the create modal. When both | 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 | // owners have zero accounts, the section collapses to a single empty CTA. | 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 | * reconnect to grant the newly-needed access (e.g. after a service was added). */ | 63 | * reconnect to grant the newly-needed access (e.g. after a service was added). */ |
| 62 | readonly needsReconsent: boolean; | 64 | readonly needsReconsent: boolean; |
| 63 | readonly showOwnerLabel: boolean; | 65 | readonly showOwnerLabel: boolean; |
| 66 | readonly onEdit: () => void; | ||
| 64 | readonly onReconnect: () => void; | 67 | readonly onReconnect: () => void; |
| 65 | readonly onRemove: () => void; | 68 | readonly onRemove: () => void; |
| 66 | }) { | 69 | }) { |
| @@ -84,6 +87,11 @@ function AccountRow(props: { · 17 unmodified lines | |||
| 84 | </Badge> | 87 | </Badge> |
| 85 | ) : null} | 88 | ) : null} |
| 86 | </CardStackEntryTitle> | 89 | </CardStackEntryTitle> |
| 90 | {connection.description && connection.description.length > 0 ? ( | ||
| 91 | <CardStackEntryDescription className="mt-1 text-xs"> | ||
| 92 | {connection.description} | ||
| 93 | </CardStackEntryDescription> | ||
| 94 | ) : null} | ||
| 87 | {needsReconsent ? ( | 95 | {needsReconsent ? ( |
| 88 | <CardStackEntryDescription className="mt-1 text-xs text-amber-700 dark:text-amber-400"> | 96 | <CardStackEntryDescription className="mt-1 text-xs text-amber-700 dark:text-amber-400"> |
| 89 | This integration now needs access this connection wasn't granted. | 97 | This integration now needs access this connection wasn't granted. |
| @@ -109,6 +117,9 @@ function AccountRow(props: { · 19 unmodified lines | |||
| 109 | </Button> | 117 | </Button> |
| 110 | </DropdownMenuTrigger> | 118 | </DropdownMenuTrigger> |
| 111 | <DropdownMenuContent align="end" className="w-40"> | 119 | <DropdownMenuContent align="end" className="w-40"> |
| 120 | <DropdownMenuItem className="text-sm" onClick={props.onEdit}> | ||
| 121 | Edit | ||
| 122 | </DropdownMenuItem> | ||
| 112 | <DropdownMenuItem className="text-sm" onClick={props.onReconnect}> | 123 | <DropdownMenuItem className="text-sm" onClick={props.onReconnect}> |
| 113 | Reconnect | 124 | Reconnect |
| 114 | </DropdownMenuItem> | 125 | </DropdownMenuItem> |
| @@ -127,6 +138,7 @@ function OwnerAccounts(props: { · 12 unmodified lines | |||
| 127 | readonly owner: Owner; | 138 | readonly owner: Owner; |
| 128 | readonly showOwnerLabels: boolean; | 139 | readonly showOwnerLabels: boolean; |
| 129 | readonly methods: readonly AuthMethod[]; | 140 | readonly methods: readonly AuthMethod[]; |
| 141 | readonly onEdit: (connection: Connection) => void; | ||
| 130 | readonly onDcrReconnect: (connection: Connection) => void; | 142 | readonly onDcrReconnect: (connection: Connection) => void; |
| 131 | /** The integration's declared oauth scopes — compared against each connection's | 143 | /** The integration's declared oauth scopes — compared against each connection's |
| 132 | * granted `oauthScope` to flag connections that must reconnect for new access. */ | 144 | * granted `oauthScope` to flag connections that must reconnect for new access. */ |
| @@ -274,6 +286,7 @@ function OwnerAccounts(props: { · 141 unmodified lines | |||
| 274 | connection={connection} | 286 | connection={connection} |
| 275 | needsReconsent={connectionNeedsReconsent(connection, props.declaredScopes)} | 287 | needsReconsent={connectionNeedsReconsent(connection, props.declaredScopes)} |
| 276 | showOwnerLabel={props.showOwnerLabels} | 288 | showOwnerLabel={props.showOwnerLabels} |
| 289 | onEdit={() => props.onEdit(connection)} | ||
| 277 | onReconnect={() => void handleReconnect(connection)} | 290 | onReconnect={() => void handleReconnect(connection)} |
| 278 | onRemove={() => void handleRemove(connection)} | 291 | onRemove={() => void handleRemove(connection)} |
| 279 | /> | 292 | /> |
| @@ -302,6 +315,7 @@ export function AccountsSection(props: { · 22 unmodified lines | |||
| 302 | removeCustomMethod, | 315 | removeCustomMethod, |
| 303 | } = props; | 316 | } = props; |
| 304 | const [adding, setAdding] = useState(false); | 317 | const [adding, setAdding] = useState(false); |
| 318 | const [editingConnection, setEditingConnection] = useState<Connection | null>(null); | ||
| 305 | const [reconnectHandoff, setReconnectHandoff] = useState<IntegrationAccountHandoff | null>(null); | 319 | const [reconnectHandoff, setReconnectHandoff] = useState<IntegrationAccountHandoff | null>(null); |
| 306 | const ownerDisplay = useOwnerDisplay(); | 320 | const ownerDisplay = useOwnerDisplay(); |
| 307 | const canAddConnection = methods.length > 0 || createCustomMethod !== undefined; | 321 | const canAddConnection = methods.length > 0 || createCustomMethod !== undefined; |
| @@ -419,6 +433,7 @@ export function AccountsSection(props: { · 111 unmodified lines | |||
| 419 | owner={owner} | 433 | owner={owner} |
| 420 | showOwnerLabels={ownerDisplay.showOwnerLabels} | 434 | showOwnerLabels={ownerDisplay.showOwnerLabels} |
| 421 | methods={methods} | 435 | methods={methods} |
| 436 | onEdit={setEditingConnection} | ||
| 422 | onDcrReconnect={(connection: Connection) => { | 437 | onDcrReconnect={(connection: Connection) => { |
| 423 | setReconnectHandoff({ | 438 | setReconnectHandoff({ |
| 424 | key: `reconnect:${connection.owner}:${String(connection.integration)}:${String( | 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 | {modal} | 453 | {modal} |
| 454 | |||
| 455 | <ConnectionEditSheet | ||
| 456 | connection={editingConnection} | ||
| 457 | onOpenChange={(open: boolean) => { | ||
| 458 | if (!open) setEditingConnection(null); | ||
| 459 | }} | ||
| 460 | /> | ||
| 439 | </section> | 461 | </section> |
| 440 | ); | 462 | ); |
| 441 | } | 463 | } |
| @@ -54,7 +54,7 @@ export function CommandPalette() { · 53 unmodified lines | |||
| 54 | onSuccess: ({ value }) => | 54 | onSuccess: ({ value }) => |
| 55 | value.map((integration: Integration) => ({ | 55 | value.map((integration: Integration) => ({ |
| 56 | id: String(integration.slug), | 56 | id: String(integration.slug), |
| 57 | name: integration.description || String(integration.slug), | 57 | name: integration.name || String(integration.slug), |
| 58 | kind: integration.kind, | 58 | kind: integration.kind, |
| 59 | url: integration.displayUrl, | 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 | <div className="flex flex-col gap-px"> | 142 | <div className="flex flex-col gap-px"> |
| 143 | {value.map((integration: Integration) => { | 143 | {value.map((integration: Integration) => { |
| 144 | const slug = String(integration.slug); | 144 | const slug = String(integration.slug); |
| 145 | const name = integration.description || slug; | 145 | const name = integration.name || slug; |
| 146 | const detailPath = `/integrations/${slug}`; | 146 | const detailPath = `/integrations/${slug}`; |
| 147 | const active = | 147 | const active = |
| 148 | props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); | 148 | props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); |
| @@ -20,19 +20,18 @@ import { · 19 unmodified lines | |||
| 20 | policiesOptimisticAtom, | 20 | policiesOptimisticAtom, |
| 21 | refreshConnection, | 21 | refreshConnection, |
| 22 | removeIntegrationOptimistic, | 22 | removeIntegrationOptimistic, |
| 23 | updateIntegration, | ||
| 24 | } from "../api/atoms"; | 23 | } from "../api/atoms"; |
| 25 | import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys"; | 24 | import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys"; |
| 26 | import { ToolTree } from "../components/tool-tree"; | 25 | import { ToolTree } from "../components/tool-tree"; |
| 27 | import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; | 26 | import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; |
| 28 | import type { ToolSummary } from "../components/tool-tree"; | 27 | import type { ToolSummary } from "../components/tool-tree"; |
| 29 | import { AccountsSection } from "../components/accounts-section"; | 28 | import { AccountsSection } from "../components/accounts-section"; |
| 29 | import { IntegrationEditSheet } from "../components/metadata-edit-sheet"; | ||
| 30 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs"; | 30 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs"; |
| 31 | import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements"; | 31 | import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements"; |
| 32 | import { usePolicyActions } from "../hooks/use-policy-actions"; | 32 | import { usePolicyActions } from "../hooks/use-policy-actions"; |
| 33 | import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client"; | 33 | import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client"; |
| 34 | import { Button } from "../components/button"; | 34 | import { Button } from "../components/button"; |
| 35 | import { Input } from "../components/input"; | ||
| 36 | import { Skeleton } from "../components/skeleton"; | 35 | import { Skeleton } from "../components/skeleton"; |
| 37 | 36 | ||
| 38 | // v2: the route's `namespace` param is the integration slug. Tools belong to | 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 | const refreshTools = useAtomRefresh(integrationToolsAllAtom(slug)); | 63 | const refreshTools = useAtomRefresh(integrationToolsAllAtom(slug)); |
| 65 | const doRemove = useAtomSet(removeIntegrationOptimistic, { mode: "promiseExit" }); | 64 | const doRemove = useAtomSet(removeIntegrationOptimistic, { mode: "promiseExit" }); |
| 66 | const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" }); | 65 | const doRefresh = useAtomSet(refreshConnection, { mode: "promiseExit" }); |
| 67 | const doUpdate = useAtomSet(updateIntegration, { mode: "promiseExit" }); | ||
| 68 | // Policies are owner-partitioned on write; the integration policy menu writes | 66 | // Policies are owner-partitioned on write; the integration policy menu writes |
| 69 | // Workspace (org) rules, preserving the prior default behavior. | 67 | // Workspace (org) rules, preserving the prior default behavior. |
| 70 | const policyActions = usePolicyActions("org"); | 68 | const policyActions = usePolicyActions("org"); |
| @@ -87,10 +85,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 16 unmodified lines | |||
| 87 | const [confirmDelete, setConfirmDelete] = useState(false); | 85 | const [confirmDelete, setConfirmDelete] = useState(false); |
| 88 | const [deleting, setDeleting] = useState(false); | 86 | const [deleting, setDeleting] = useState(false); |
| 89 | const [refreshing, setRefreshing] = useState(false); | 87 | const [refreshing, setRefreshing] = useState(false); |
| 90 | const [editing, setEditing] = useState(false); | 88 | const [editSheetOpen, setEditSheetOpen] = useState(false); |
| 91 | const [renaming, setRenaming] = useState(false); | ||
| 92 | const [nameDraft, setNameDraft] = useState(""); | ||
| 93 | const [savingName, setSavingName] = useState(false); | ||
| 94 | const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts"); | 89 | const [activeTab, setActiveTab] = useState<"accounts" | "tools">("accounts"); |
| 95 | const [locationSearch] = useState(() => | 90 | const [locationSearch] = useState(() => |
| 96 | typeof window === "undefined" ? "" : window.location.search, | 91 | typeof window === "undefined" ? "" : window.location.search, |
| @@ -98,7 +93,7 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 1 unmodified line | |||
| 98 | 93 | ||
| 99 | useEffect(() => { | 94 | useEffect(() => { |
| 100 | setConfirmDelete(false); | 95 | setConfirmDelete(false); |
| 101 | setRenaming(false); | 96 | setEditSheetOpen(false); |
| 102 | }, [namespace]); | 97 | }, [namespace]); |
| 103 | 98 | ||
| 104 | const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null; | 99 | const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null; |
| @@ -319,68 +314,15 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 214 unmodified lines | |||
| 319 | setRefreshing(false); | 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 | return ( | 317 | return ( |
| 361 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> | 318 | <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> |
| 362 | {/* Header bar */} | 319 | {/* Header bar */} |
| 363 | <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm"> | 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 | <div className="flex min-w-0 items-center gap-3"> | 321 | <div className="flex min-w-0 items-center gap-3"> |
| 365 | {renaming ? ( | 322 | <h2 className="truncate text-sm font-semibold text-foreground"> |
| 366 | <Input | 323 | {integrationData?.name || namespace} |
| 367 | autoFocus | 324 | </h2> |
| 368 | value={nameDraft} | 325 | {AsyncResult.isSuccess(tools) && ( |
| 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 && ( | ||
| 384 | <span className="hidden text-xs tabular-nums text-muted-foreground sm:block"> | 326 | <span className="hidden text-xs tabular-nums text-muted-foreground sm:block"> |
| 385 | {distinctToolCount} {distinctToolCount === 1 ? "tool" : "tools"} | 327 | {distinctToolCount} {distinctToolCount === 1 ? "tool" : "tools"} |
| 386 | </span> | 328 | </span> |
| @@ -388,33 +330,13 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 1 unmodified line | |||
| 388 | </div> | 330 | </div> |
| 389 | 331 | ||
| 390 | <div className="flex shrink-0 items-center gap-2"> | 332 | <div className="flex shrink-0 items-center gap-2"> |
| 391 | {renaming ? ( | 333 | {!confirmDelete && !isBuiltInIntegration && integrationData && ( |
| 392 | <> | 334 | <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}> |
| 393 | <Button variant="outline" size="sm" onClick={cancelRename} disabled={savingName}> | 335 | Edit |
| 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 | ||
| 414 | </Button> | 336 | </Button> |
| 415 | )} | 337 | )} |
| 416 | 338 | ||
| 417 | {canRefresh && !editing && !renaming && ( | 339 | {canRefresh && ( |
| 418 | <Button | 340 | <Button |
| 419 | variant="outline" | 341 | variant="outline" |
| 420 | size="sm" | 342 | size="sm" |
| @@ -426,8 +348,6 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 5 unmodified lines | |||
| 426 | )} | 348 | )} |
| 427 | 349 | ||
| 428 | {canRemove && | 350 | {canRemove && |
| 429 | !editing && | ||
| 430 | !renaming && | ||
| 431 | (confirmDelete ? ( | 351 | (confirmDelete ? ( |
| 432 | <div className="flex items-center gap-2"> | 352 | <div className="flex items-center gap-2"> |
| 433 | <Button | 353 | <Button |
| @@ -460,118 +380,106 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 26 unmodified lines | |||
| 460 | </div> | 380 | </div> |
| 461 | </div> | 381 | </div> |
| 462 | 382 | ||
| 463 | {/* Edit view */} | 383 | <Tabs |
| 464 | {editing && editPlugin ? ( | 384 | value={currentTab} |
| 465 | <div className="min-h-0 flex-1 overflow-y-auto"> | 385 | onValueChange={(value: string) => setActiveTab(value as "accounts" | "tools")} |
| 466 | <div className="mx-auto max-w-2xl px-6 py-8"> | 386 | className="min-h-0 flex-1 gap-0 overflow-hidden" |
| 467 | <Suspense fallback={<EditFormSkeleton />}> | 387 | > |
| 468 | <editPlugin.edit sourceId={namespace} onSave={handleEditSave} /> | 388 | <div className="shrink-0 border-b border-border/60 px-4 py-2"> |
| 469 | </Suspense> | 389 | <TabsList variant="line"> |
| 470 | </div> | 390 | {!isBuiltInIntegration && <TabsTrigger value="accounts">Accounts</TabsTrigger>} |
| 391 | <TabsTrigger value="tools">Tools</TabsTrigger> | ||
| 392 | </TabsList> | ||
| 471 | </div> | 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 | declare auth methods fill the `accounts` slot (real methods from | 396 | declare auth methods fill the `accounts` slot (real methods from |
| 487 | the plugin's config); otherwise we render the generic fallback. */} | 397 | the plugin's config); otherwise we render the generic fallback. */} |
| 488 | {!isBuiltInIntegration && ( | 398 | {!isBuiltInIntegration && ( |
| 489 | <TabsContent value="accounts" className="min-h-0 overflow-y-auto"> | 399 | <TabsContent value="accounts" className="min-h-0 overflow-y-auto"> |
| 490 | {editPlugin?.accounts ? ( | 400 | {editPlugin?.accounts ? ( |
| 491 | <Suspense fallback={<AccountsSkeleton />}> | 401 | <Suspense fallback={<AccountsSkeleton />}> |
| 492 | <editPlugin.accounts | 402 | <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 | ||
| 519 | sourceId={namespace} | 403 | sourceId={namespace} |
| 520 | variant="panel" | 404 | integrationName={integrationData?.name || namespace} |
| 521 | onAction={() => setEditing(true)} | 405 | accountHandoff={accountHandoff} |
| 522 | /> | 406 | /> |
| 523 | </Suspense> | 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, { | 446 | {/* Right: tool detail with Schema · TypeScript · Run tabs */} |
| 527 | onInitial: () => <IntegrationDetailSkeleton />, | 447 | <div className="flex min-w-0 flex-1 flex-col overflow-hidden"> |
| 528 | onFailure: () => ( | 448 | {selectedTool && selectedAddress && selectedBareName ? ( |
| 529 | <div className="p-6 text-sm text-destructive">Failed to load tools</div> | 449 | <ToolDetail |
| 530 | ), | 450 | address={selectedAddress} |
| 531 | onSuccess: () => ( | 451 | toolName={selectedTool.name} |
| 532 | <div className="flex min-h-0 flex-1 overflow-hidden"> | 452 | staticTool={selection?.static} |
| 533 | {/* Left: tool tree */} | 453 | policy={selectedTool.policy} |
| 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} | ||
| 539 | onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | 454 | onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} |
| 540 | onClearPolicy={(pattern) => void policyActions.clear(pattern)} | 455 | onClearPolicy={(pattern) => void policyActions.clear(pattern)} |
| 541 | policies={sortedPolicies} | 456 | {...(!selection?.static && selectedBareName |
| 542 | groupByConnection={!isBuiltInIntegration} | 457 | ? { |
| 458 | integration: slug, | ||
| 459 | runToolName: selectedBareName, | ||
| 460 | connections: integrationConnections, | ||
| 461 | initialConnectionName: selection?.connection ?? null, | ||
| 462 | } | ||
| 463 | : {})} | ||
| 543 | /> | 464 | /> |
| 544 | </div> | 465 | ) : ( |
| 545 | 466 | <ToolDetailEmpty hasTools={integrationTools.length > 0} /> | |
| 546 | {/* Right: tool detail with Schema · TypeScript · Run tabs */} | 467 | )} |
| 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> | ||
| 569 | </div> | 468 | </div> |
| 570 | ), | 469 | </div> |
| 571 | })} | 470 | ), |
| 572 | </TabsContent> | 471 | })} |
| 573 | </Tabs> | 472 | </TabsContent> |
| 574 | )} | 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 | </div> | 483 | </div> |
| 576 | ); | 484 | ); |
| 577 | } | 485 | } |
| @@ -623,26 +531,3 @@ function AccountsSkeleton() { · 45 unmodified lines | |||
| 623 | </div> | 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 | const plugin = pluginByKind.get(pluginKey); | 435 | const plugin = pluginByKind.get(pluginKey); |
| 436 | const SummaryComponent = plugin?.summary; | 436 | const SummaryComponent = plugin?.summary; |
| 437 | const slug = String(integration.slug); | 437 | const slug = String(integration.slug); |
| 438 | const name = integration.description || slug; | 438 | const name = integration.name || slug; |
| 439 | return ( | 439 | return ( |
| 440 | <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}> | 440 | <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}> |
| 441 | <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: slug }}> | 441 | <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: slug }}> |