History

Curate integration & connection metadata; update OpenAPI specs in place (#997)

* 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.

Rhys Sullivan committed 5 months ago

Commit 2b797e21 parent aa148a275 files · +4183 −550Browse files

Unified · Split

75 files
  1. apps/cloud/drizzle/0005_integration_descriptions.sql +10 −0
  2. apps/cloud/drizzle/meta/0005_snapshot.json +1266 −0
  3. apps/cloud/drizzle/meta/_journal.json +7 −0
  4. apps/cloud/package.json +1 −0
  5. apps/cloud/src/db/executor-schema.ts +5 −1
  6. apps/local/src/db/sqlite-fumadb.ts +13 −8
  7. bun.lock +21 −18
  8. e2e/cloud/spec-update-convergence.test.ts +245 −0
  9. e2e/package.json +1 −0
  10. e2e/scenarios/metadata-editing.test.ts +120 −0
  11. e2e/scenarios/openapi-update-spec.test.ts +204 −0
  12. e2e/scenarios/tool-descriptions.test.ts +545 −0
  13. e2e/src/surfaces/mcp.ts +19 −0
  14. packages/app/src/web/shell.tsx +1 −1
  15. packages/core/api/src/connections/api.ts +16 −0
  16. packages/core/api/src/handlers/connections.ts +22 −0
  17. packages/core/api/src/handlers/integrations.ts +3 −1
  18. packages/core/api/src/integrations/api.ts +3 −0
  19. packages/core/execution/src/description.test.ts +57 −2
  20. packages/core/execution/src/description.ts +57 −9
  21. packages/core/execution/src/promise.ts +4 −0
  22. packages/core/execution/src/tool-invoker.ts +10 −1
  23. packages/core/fumadb/package.json +2 −1
  24. packages/core/fumadb/src/adapters/drizzle/runtime-ensure.test.ts +86 −0
  25. packages/core/fumadb/src/adapters/drizzle/runtime.ts +44 −0
  26. packages/core/sdk/src/client.ts +22 −1
  27. packages/core/sdk/src/connection.ts +14 −0
  28. packages/core/sdk/src/core-schema.ts +22 −1
  29. packages/core/sdk/src/core-tools.ts +2 −0
  30. packages/core/sdk/src/executor.ts +127 −5
  31. packages/core/sdk/src/index.ts +1 −0
  32. packages/core/sdk/src/integration.ts +8 −0
  33. packages/core/sdk/src/plugin.ts +16 −2
  34. packages/core/sdk/src/promise.ts +1 −0
  35. packages/core/sdk/src/shared.ts +1 −0
  36. packages/plugins/graphql/src/api/group.ts +1 −0
  37. packages/plugins/graphql/src/api/handlers.ts +1 −0
  38. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +9 −1
  39. packages/plugins/graphql/src/react/EditGraphqlSource.tsx +0 −43
  40. packages/plugins/graphql/src/react/GraphqlSourceFields.tsx +20 −0
  41. packages/plugins/graphql/src/react/atoms.ts +2 −1
  42. packages/plugins/graphql/src/react/source-plugin.ts +2 −3
  43. packages/plugins/graphql/src/sdk/plugin.ts +14 −2
  44. packages/plugins/mcp/src/api/group.ts +5 −0
  45. packages/plugins/mcp/src/api/handlers.ts +4 −0
  46. packages/plugins/mcp/src/react/AddMcpSource.tsx +11 −1
  47. packages/plugins/mcp/src/react/EditMcpSource.tsx +43 −94
  48. packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx +22 −0
  49. packages/plugins/mcp/src/react/source-plugin.tsx +4 −4
  50. packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts +1 −0
  51. packages/plugins/mcp/src/sdk/discover.ts +1 −0
  52. packages/plugins/mcp/src/sdk/manifest.ts +4 −1
  53. packages/plugins/mcp/src/sdk/plugin.ts +11 −1
  54. packages/plugins/openapi/src/api/group.ts +35 −0
  55. packages/plugins/openapi/src/api/handlers.ts +17 −0
  56. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +16 −3
  57. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +0 −118
  58. packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx +39 −4
  59. packages/plugins/openapi/src/react/UpdateSpecSection.tsx +141 −0
  60. packages/plugins/openapi/src/react/atoms.ts +5 −0
  61. packages/plugins/openapi/src/react/source-plugin.ts +3 −3
  62. packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts +2 −0
  63. packages/plugins/openapi/src/sdk/extract.ts +1 −0
  64. packages/plugins/openapi/src/sdk/plugin.test.ts +212 −0
  65. packages/plugins/openapi/src/sdk/plugin.ts +148 −2
  66. packages/plugins/openapi/src/sdk/preview.ts +3 −0
  67. packages/plugins/openapi/src/sdk/spec-blob.test.ts +1 −0
  68. packages/plugins/openapi/src/sdk/types.ts +2 −0
  69. packages/react/src/api/atoms.tsx +6 −0
  70. packages/react/src/components/accounts-section.tsx +24 −2
  71. packages/react/src/components/command-palette.tsx +1 −1
  72. packages/react/src/components/metadata-edit-sheet.tsx +296 −0
  73. packages/react/src/multiplayer/shell.tsx +1 −1
  74. packages/react/src/pages/integration-detail.tsx +98 −213
  75. packages/react/src/pages/integrations.tsx +1 −1
apps/cloud/drizzle/0005_integration_descriptions.sql+10−0
@@ -0,0 +1,10 @@
1ALTER TABLE "integration" ALTER COLUMN "description" DROP NOT NULL;--> statement-breakpoint
2ALTER TABLE "connection" ADD COLUMN "description" text;--> statement-breakpoint
3ALTER TABLE "connection" ADD COLUMN "tools_synced_at" bigint;--> statement-breakpoint
4ALTER TABLE "integration" ADD COLUMN "name" text;--> statement-breakpoint
5ALTER 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.
9UPDATE "integration" SET "name" = "description" WHERE "name" IS NULL;--> statement-breakpoint
10UPDATE "integration" SET "description" = NULL;
apps/cloud/drizzle/meta/0005_snapshot.json+1266−0
@@ -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}
apps/cloud/drizzle/meta/_journal.json+7−0
@@ -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": true38 "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}
apps/cloud/package.json+1−0
@@ -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:",
apps/cloud/src/db/executor-schema.ts+5−1
@@ -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"),
apps/local/src/db/sqlite-fumadb.ts+13−8
@@ -4,7 +4,7 @@ import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql"; · 3 unmodified lines
4import { type FumaDB } from "@executor-js/fumadb";4import { type FumaDB } from "@executor-js/fumadb";
5import {5import {
6 createDrizzleRuntimeSchemaFromTables,6 createDrizzleRuntimeSchemaFromTables,
7 createDrizzleRuntimeSchemaSqlFromTables,7 ensureDrizzleRuntimeSchemaFromTables,
8} from "@executor-js/fumadb/adapters/drizzle";8} from "@executor-js/fumadb/adapters/drizzle";
9import { type schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/schema";9import { 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 to65 // 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 &&
bun.lock+21−18
@@ -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=="],
e2e/cloud/spec-update-convergence.test.ts+245−0
@@ -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.
11import { randomBytes } from "node:crypto";
12import { createServer } from "node:http";
13
14import { expect } from "@effect/vitest";
15import { Effect } from "effect";
16import type { HttpApiClient } from "effect/unstable/httpapi";
17import { composePluginApi } from "@executor-js/api/server";
18import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
19import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
20
21import { scenario } from "../src/scenario";
22import { Api, Target } from "../src/services";
23import type { Identity, Target as TargetShape } from "../src/target";
24
25const api = composePluginApi([openApiHttpPlugin()] as const);
26type 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.
30const 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
51const 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
72const V1_TOOLS = ["legacy.legacyOp", "ping.getOperation"];
73const V2_TOOLS = ["ping.getOperation", "widgets.listWidgets"];
74
75/** A real 127.0.0.1 server whose served spec can be swapped mid-scenario. */
76const 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) ──────
109const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
110
111const 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
128const 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. */
137const 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
149const apiKeyTemplate = [
150 {
151 slug: "apiKey",
152 type: "apiKey" as const,
153 headers: { authorization: ["Bearer ", { type: "variable" as const, name: "token" }] },
154 },
155];
156
157const 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
168const 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
173scenario(
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);
e2e/package.json+1−0
@@ -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:",
e2e/scenarios/metadata-editing.test.ts+120−0
@@ -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.
6import { randomBytes, randomUUID } from "node:crypto";
7
8import { expect } from "@effect/vitest";
9import { Effect } from "effect";
10import { composePluginApi } from "@executor-js/api/server";
11import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
12import {
13 AuthTemplateSlug,
14 ConnectionName,
15 IntegrationSlug,
16 ProviderItemId,
17} from "@executor-js/sdk/shared";
18
19import { scenario } from "../src/scenario";
20import { Api, Target } from "../src/services";
21
22const 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. */
26const 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
36scenario(
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);
e2e/scenarios/openapi-update-spec.test.ts+204−0
@@ -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.
7import { randomBytes, randomUUID } from "node:crypto";
8import { createServer } from "node:http";
9
10import { expect } from "@effect/vitest";
11import { Effect } from "effect";
12import { composePluginApi } from "@executor-js/api/server";
13import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
14import {
15 AuthTemplateSlug,
16 ConnectionName,
17 IntegrationSlug,
18 ProviderItemId,
19} from "@executor-js/sdk/shared";
20
21import { scenario } from "../src/scenario";
22import { Api, Target } from "../src/services";
23
24const api = composePluginApi([openApiHttpPlugin()] as const);
25
26const 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
47const 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. */
69const 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
101scenario(
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);
e2e/scenarios/tool-descriptions.test.ts+545−0
@@ -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.
15import { randomBytes, randomUUID } from "node:crypto";
16import { writeFileSync } from "node:fs";
17import { join } from "node:path";
18
19import { expect } from "@effect/vitest";
20import { Effect } from "effect";
21import { buildSchema, introspectionFromSchema } from "graphql";
22import { composePluginApi } from "@executor-js/api/server";
23import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
24import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
25import {
26 AuthTemplateSlug,
27 ConnectionName,
28 IntegrationSlug,
29 ProviderItemId,
30} from "@executor-js/sdk/shared";
31
32import { scenario } from "../src/scenario";
33import { Api, Mcp, RunDir, Target } from "../src/services";
34
35const 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
42const 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
143const 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
188const ordersIntrospectionJson = (): string =>
189 JSON.stringify({ data: introspectionFromSchema(buildSchema(ordersGraphqlSdl)) });
190
191// ---------------------------------------------------------------------------
192// Artifact rendering
193// ---------------------------------------------------------------------------
194
195interface 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
206const codeBlock = (lang: string, body: string): string => `\`\`\`${lang}\n${body}\n\`\`\``;
207
208const 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
239scenario(
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);
e2e/src/surfaces/mcp.ts+19−0
@@ -95,6 +95,11 @@ export interface McpCallResult { · 94 unmodified lines
95 readonly ok: boolean;95 readonly ok: boolean;
96}96}
97 97
98export interface McpToolDef {
99 readonly name: string;
100 readonly description: string;
101}
102
98/** How a connection surfaces a paused (approval-gated) execution. `browser` is103/** 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
134export interface McpSession {139export 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(() => {
packages/app/src/web/shell.tsx+1−1
@@ -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}/`);
packages/core/api/src/connections/api.ts+16−0
@@ -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 null53 // 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 a54 // 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.
88const UpdateConnectionPayload = Schema.Struct({
89 description: Schema.optional(Schema.NullOr(Schema.String)),
90 identityLabel: Schema.optional(Schema.NullOr(Schema.String)),
91});
92
85const CreateConnectionPayload = Schema.Struct({93const 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,
packages/core/api/src/handlers/connections.ts+22−0
@@ -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* () {
packages/core/api/src/handlers/integrations.ts+3−1
@@ -8,6 +8,7 @@ import { capture } from "@executor-js/api"; · 7 unmodified lines
8 8
9const toResponse = (i: Integration) => ({9const 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) {
packages/core/api/src/integrations/api.ts+3−0
@@ -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`. */
66const IntegrationResponse = Schema.Struct({66const 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
80const UpdateIntegrationPayload = Schema.Struct({82const 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
packages/core/execution/src/description.test.ts+57−2
@@ -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 }));
packages/core/execution/src/description.ts+57−9
@@ -1,5 +1,5 @@
1import { Effect } from "effect";1import { Effect } from "effect";
2import type { Connection, Executor } from "@executor-js/sdk/core";2import 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
56const 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. */
64interface ConnectionInventoryEntry {
65 readonly prefix: string;
66 readonly description?: string;
67}
68
69const 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
83const 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
98const 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
packages/core/execution/src/promise.ts+4−0
@@ -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]) =>
packages/core/execution/src/tool-invoker.ts+10−1
@@ -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
320export type ExecutorSourceListItem = {320export 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,
packages/core/fumadb/package.json+2−1
@@ -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",
packages/core/fumadb/src/adapters/drizzle/runtime-ensure.test.ts+86−0
@@ -0,0 +1,86 @@
1import { mkdtempSync, rmSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { expect, test } from "@effect/vitest";
6import { createClient } from "@libsql/client";
7import { drizzle } from "drizzle-orm/libsql";
8
9import {
10 createDrizzleRuntimeSchemaSqlFromTables,
11 ensureDrizzleRuntimeSchemaFromTables,
12} from "./index";
13import { column, idColumn, table } from "../../schema";
14
15const NS = "executor";
16
17// An older database baseline: `integration` has only id + slug.
18const 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).
27const 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
36const 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.)
46test("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});
packages/core/fumadb/src/adapters/drizzle/runtime.ts+44−0
@@ -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. */
341const 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
355const 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
333export const createDrizzleRuntimeSchemaSqlFromTables = (366export 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
375export const ensureDrizzleRuntimeSchemaFromTables = async (419export const ensureDrizzleRuntimeSchemaFromTables = async (
packages/core/sdk/src/client.ts+22−1
@@ -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). */
121export type EditSheetApplyResult =
122 | { readonly ok: true; readonly summary: string | null }
123 | { readonly ok: false };
124
125export interface EditSheetSectionProps {
126 readonly sourceId: string;
127 readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void;
128}
129
118export interface IntegrationPlugin {130export 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";
packages/core/sdk/src/connection.ts+14−0
@@ -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 it42 /** 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). */
101export interface UpdateConnectionInput {
102 readonly description?: string | null;
103 readonly identityLabel?: string | null;
104}
packages/core/sdk/src/core-schema.ts+22−1
@@ -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 through169 // The OWNER of `oauth_client` (a Personal connection may be minted through
149 // a shared Workspace app), set together with `oauth_client`; null for170 // a shared Workspace app), set together with `oauth_client`; null for
packages/core/sdk/src/core-tools.ts+2−0
@@ -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,
packages/core/sdk/src/executor.ts+127−5
@@ -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";
28import {29import {
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-time1855 // Defense in depth (and cleanup for rows created before the create-time
1819 // guard, or emptied by an external edit): a credentialed non-OAuth1856 // 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 made2446 // 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 },
packages/core/sdk/src/index.ts+1−0
@@ -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";
95export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool";96export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool";
96 97
packages/core/sdk/src/integration.ts+8−0
@@ -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. */
75export interface Integration {75export 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). */
143export interface RegisterIntegrationInput {148export 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;
packages/core/sdk/src/plugin.ts+16−2
@@ -6,7 +6,12 @@ import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/sp · 5 unmodified lines
6import type { StorageFailure } from "./fuma-runtime";6import type { StorageFailure } from "./fuma-runtime";
7 7
8import type { PluginBlobStore } from "./blob";8import type { PluginBlobStore } from "./blob";
9import type { Connection, ConnectionRef, CreateConnectionInput } from "./connection";9import type {
10 Connection,
11 ConnectionRef,
12 CreateConnectionInput,
13 UpdateConnectionInput,
14} from "./connection";
10import type {15import 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>;
packages/core/sdk/src/promise.ts+1−0
@@ -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),
packages/core/sdk/src/shared.ts+1−0
@@ -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";
46export type { CredentialProvider, ProviderEntry } from "./provider";47export type { CredentialProvider, ProviderEntry } from "./provider";
47export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool";48export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool";
packages/plugins/graphql/src/api/group.ts+1−0
@@ -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)),
packages/plugins/graphql/src/api/handlers.ts+1−0
@@ -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,
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+9−1
@@ -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 > 0116 ...(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 <AuthMethodListEditor144 <AuthMethodListEditor
137 list={authMethodList}145 list={authMethodList}
packages/plugins/graphql/src/react/EditGraphqlSource.tsx+0−43
@@ -1,43 +0,0 @@
1import { useAtomValue } from "@effect/atom-react";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3import * as Option from "effect/Option";
4
5import { IntegrationSlug } from "@executor-js/sdk/shared";
6import { decodeGraphqlIntegrationConfigOption } from "@executor-js/plugin-graphql";
7
8import { graphqlIntegrationConfigAtom } from "./atoms";
9import 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
17export 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}
packages/plugins/graphql/src/react/GraphqlSourceFields.tsx+20−0
@@ -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";
6import { Input } from "@executor-js/react/components/input";6import { Input } from "@executor-js/react/components/input";
7import { Textarea } from "@executor-js/react/components/textarea";
7import {8import {
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 );
packages/plugins/graphql/src/react/atoms.ts+2−1
@@ -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,
packages/plugins/graphql/src/react/source-plugin.ts+2−3
@@ -3,19 +3,18 @@ import type { IntegrationPlugin } from "@executor-js/sdk/client"; · 2 unmodified lines
3import { graphqlPresets } from "../sdk/presets";3import { graphqlPresets } from "../sdk/presets";
4 4
5const importAdd = () => import("./AddGraphqlSource");5const importAdd = () => import("./AddGraphqlSource");
6const importEdit = () => import("./EditGraphqlSource");
7const importAccounts = () => import("./GraphqlAccountsPanel");6const 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").
9export const graphqlIntegrationPlugin: IntegrationPlugin = {10export 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};
packages/plugins/graphql/src/sdk/plugin.ts+14−2
@@ -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,
packages/plugins/mcp/src/api/group.ts+5−0
@@ -31,6 +31,8 @@ const StringMap = Schema.Record(Schema.String, Schema.String); · 30 unmodified lines
31const AddRemoteServerPayload = Schema.Struct({31const 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
46const AddStdioServerPayload = Schema.Struct({48const 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// ---------------------------------------------------------------------------
packages/plugins/mcp/src/api/handlers.ts+4−0
@@ -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,
packages/plugins/mcp/src/react/AddMcpSource.tsx+11−1
@@ -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
83type State =84type 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}
packages/plugins/mcp/src/react/EditMcpSource.tsx+43−94
@@ -1,9 +1,10 @@
1import { useCallback, useMemo, useState } from "react";1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
5 5
6import { IntegrationSlug } from "@executor-js/sdk/shared";6import { IntegrationSlug } from "@executor-js/sdk/shared";
7import type { EditSheetApplyResult, EditSheetSectionProps } from "@executor-js/sdk/client";
7import { apiKeyMethodLabel, type AuthPlacement } from "@executor-js/sdk/http-auth";8import { apiKeyMethodLabel, type AuthPlacement } from "@executor-js/sdk/http-auth";
8import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";9import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
9import {10import {
@@ -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";
15import { Button } from "@executor-js/react/components/button";
16import {
17 CardStack,
18 CardStackContent,
19 CardStackEntry,
20 CardStackEntryContent,
21 CardStackEntryDescription,
22 CardStackEntryTitle,
23} from "@executor-js/react/components/card-stack";
24import { Badge } from "@executor-js/react/components/badge";16import { Badge } from "@executor-js/react/components/badge";
25import { FormErrorAlert } from "@executor-js/react/lib/integration-add";17import { 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 identity69// Remote edit — v2: the integration's endpoint is part of its identity
78// (opaque-to-core config); the editable surface is the declared auth-method70// (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
83function RemoteEdit(props: {76function 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 existing98 // 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 <AuthMethodListEditor164 <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
210function StdioReadOnly(props: {180function 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 the189 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 stdio198 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
250export default function EditMcpSource({210export 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}
packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx+22−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12} from "@executor-js/react/components/card-stack";12} from "@executor-js/react/components/card-stack";
13import { FieldError } from "@executor-js/react/components/field";13import { FieldError } from "@executor-js/react/components/field";
14import { Input } from "@executor-js/react/components/input";14import { Input } from "@executor-js/react/components/input";
15import { Textarea } from "@executor-js/react/components/textarea";
15import { Skeleton } from "@executor-js/react/components/skeleton";16import { Skeleton } from "@executor-js/react/components/skeleton";
16import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";17import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
17import { IOSSpinner } from "@executor-js/react/components/spinner";18import { 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 <Input125 <Input
104 value={props.url}126 value={props.url}
packages/plugins/mcp/src/react/source-plugin.tsx+4−4
@@ -3,11 +3,11 @@ import type { IntegrationPlugin } from "@executor-js/sdk/client"; · 2 unmodified lines
3import { mcpPresets } from "../sdk/presets";3import { mcpPresets } from "../sdk/presets";
4 4
5const importAdd = () => import("./AddMcpSource");5const importAdd = () => import("./AddMcpSource");
6const importEdit = () => import("./EditMcpSource");6const importEditSheet = () => import("./EditMcpSource");
7const importAccounts = () => import("./McpAccountsPanel");7const importAccounts = () => import("./McpAccountsPanel");
8 8
9const LazyAddMcpSource = lazy(importAdd);9const LazyAddMcpSource = lazy(importAdd);
10const LazyEditMcpSource = lazy(importEdit);10const LazyEditMcpSheet = lazy(importEditSheet);
11const LazyMcpAccountsPanel = lazy(importAccounts);11const LazyMcpAccountsPanel = lazy(importAccounts);
12 12
13type AddProps = ComponentProps<IntegrationPlugin["add"]>;13type 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 };
packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts+1−0
@@ -14,6 +14,7 @@ import { describeMcpAuthMethods, describeMcpIntegrationDisplay } from "./plugin" · 13 unmodified lines
14 14
15const recordWith = (config: IntegrationConfig): IntegrationRecord => ({15const 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,
packages/plugins/mcp/src/sdk/discover.ts+1−0
@@ -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 discovery61 // Close the connection after discovery
packages/plugins/mcp/src/sdk/manifest.ts+4−1
@@ -18,6 +18,8 @@ export interface McpToolManifestEntry { · 17 unmodified lines
18export interface McpServerMetadata {18export 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
23export interface McpToolManifest {25export interface McpToolManifest {
@@ -82,7 +84,7 @@ export const joinToolPath = (namespace: string | undefined, toolId: string): str · 58 unmodified lines
82 84
83export const extractManifestFromListToolsResult = (85export 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,
packages/plugins/mcp/src/sdk/plugin.ts+11−1
@@ -145,6 +145,8 @@ const readStamp = (annotations: unknown): McpToolStamp | null => · 144 unmodified lines
145const McpRemoteServerInputSchema = Schema.Struct({145const 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
160const McpStdioServerInputSchema = Schema.Struct({162const 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.integrations714 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,
packages/plugins/openapi/src/api/group.ts+35−0
@@ -4,6 +4,7 @@ import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth · 3 unmodified lines
4import {4import {
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
30const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 });
31
32const UpdateSpecErrors = [
33 InternalError,
34 OpenApiParseError,
35 OpenApiExtractionError,
36 OpenApiOAuthError,
37 IntegrationNotFound,
38] as const;
39
29const SlugParams = {40const SlugParams = {
30 slug: Schema.String,41 slug: Schema.String,
31};42};
@@ -64,6 +75,7 @@ const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMet · 32 unmodified lines
64const AddSpecPayload = Schema.Struct({75const 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".
109const UpdateSpecPayload = Schema.Struct({
110 spec: Schema.optional(OpenApiSpecInputPayload),
111});
112
113const 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
95const IntegrationView = Schema.Struct({122const 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,
packages/plugins/openapi/src/api/handlers.ts+17−0
@@ -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* () {
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+16−3
@@ -133,6 +133,9 @@ export default function AddOpenApiSource(props: { · 132 unmodified lines
133 // After analysis133 // 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 = isGoogleBundlePreset139 const identityFallbackName = isGoogleBundlePreset
137 ? "Google"140 ? "Google"
138 : preview141 : 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 the230 // 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 has340 // Always send the edited method list (even empty) when the user has
333 // inspected a preview: an explicit [] means "no auth methods", while341 // 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 <OpenApiSourceDetailsFields438 <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 <OpenApiSourceDetailsFields453 <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 > 0458 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}
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+0−118
@@ -1,118 +0,0 @@
1import { useAtomValue } from "@effect/atom-react";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3
4import { connectionsAllAtom } from "@executor-js/react/api/atoms";
5import { ownerLabel, useOwnerDisplay } from "@executor-js/react/api/owner-display";
6import { IntegrationSlug, type Connection } from "@executor-js/sdk/shared";
7import { Badge } from "@executor-js/react/components/badge";
8import { Button } from "@executor-js/react/components/button";
9import {
10 CardStack,
11 CardStackContent,
12 CardStackEntry,
13 CardStackEntryContent,
14 CardStackEntryDescription,
15 CardStackEntryTitle,
16} from "@executor-js/react/components/card-stack";
17
18import { 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
29export 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}
packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx+39−4
@@ -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";
14import { Input } from "@executor-js/react/components/input";14import { Input } from "@executor-js/react/components/input";
15import { Textarea } from "@executor-js/react/components/textarea";
15import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";16import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
16import {17import {
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. */
24const isUrlInput = (value: string): boolean => URL.canParse(value.trim());
25
21export function OpenApiSourceDetailsFields(props: {26export 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 <Input117 <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>
packages/plugins/openapi/src/react/UpdateSpecSection.tsx+141−0
@@ -0,0 +1,141 @@
1import { useCallback, useEffect, useRef, useState } from "react";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";
5
6import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
7import { messageFromExit } from "@executor-js/react/api/error-reporting";
8import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
9import { IntegrationSlug } from "@executor-js/sdk/shared";
10import type { EditSheetApplyResult, EditSheetSectionProps } from "@executor-js/sdk/client";
11import { Checkbox } from "@executor-js/react/components/checkbox";
12import { Label } from "@executor-js/react/components/label";
13import { Textarea } from "@executor-js/react/components/textarea";
14
15import { 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
25type UpdateOutcome = {
26 readonly toolCount: number;
27 readonly addedTools: readonly string[];
28 readonly removedTools: readonly string[];
29};
30
31const 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
44export 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}
packages/plugins/openapi/src/react/atoms.ts+5−0
@@ -36,6 +36,11 @@ export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec"); · 35 unmodified lines
36 36
37export const removeOpenApiSpec = OpenApiClient.mutation("openapi", "removeSpec");37export 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. */
42export 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`.
40export const openapiConfigure = OpenApiClient.mutation("openapi", "configure");45export const openapiConfigure = OpenApiClient.mutation("openapi", "configure");
41 46
packages/plugins/openapi/src/react/source-plugin.ts+3−3
@@ -3,19 +3,19 @@ import type { IntegrationPlugin } from "@executor-js/sdk/client"; · 2 unmodified lines
3import { openApiPresets } from "../sdk/presets";3import { openApiPresets } from "../sdk/presets";
4 4
5const importAdd = () => import("./AddOpenApiSource");5const importAdd = () => import("./AddOpenApiSource");
6const importEdit = () => import("./EditOpenApiSource");6const importEditSheet = () => import("./UpdateSpecSection");
7const importAccounts = () => import("./OpenApiAccountsPanel");7const importAccounts = () => import("./OpenApiAccountsPanel");
8 8
9export const openApiIntegrationPlugin: IntegrationPlugin = {9export 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};
packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts+2−0
@@ -19,6 +19,7 @@ import { type Authentication } from "./types"; · 18 unmodified lines
19 19
20const recordWith = (templates: readonly Authentication[]): IntegrationRecord => ({20const 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,
packages/plugins/openapi/src/sdk/extract.ts+1−0
@@ -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,
packages/plugins/openapi/src/sdk/plugin.test.ts+212−0
@@ -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";
28import { mkdtempSync } from "node:fs";
29import { tmpdir } from "node:os";
30import { join } from "node:path";
31
28import {32import {
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
36import {41import {
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"
packages/plugins/openapi/src/sdk/plugin.ts+148−2
@@ -5,6 +5,7 @@ import { HttpClient } from "effect/unstable/http"; · 4 unmodified lines
5import {5import {
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. */
185export interface UpdateSpecResult {
186 readonly slug: IntegrationSlug;
187 readonly toolCount: number;
188 readonly addedTools: readonly string[];
189 readonly removedTools: readonly string[];
190}
191
192export 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
179export interface OpenApiPluginExtension {198export 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 | IntegrationAlreadyExistsError212 | IntegrationAlreadyExistsError
194 | StorageFailure213 | 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 its231 /** 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
565const compileSpec = (600const 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* () {
packages/plugins/openapi/src/sdk/preview.ts+3−0
@@ -135,6 +135,8 @@ export type PreviewOperation = typeof PreviewOperation.Type; · 134 unmodified lines
135 135
136export const SpecPreview = Schema.Struct({136export 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,
packages/plugins/openapi/src/sdk/spec-blob.test.ts+1−0
@@ -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,
packages/plugins/openapi/src/sdk/types.ts+2−0
@@ -168,6 +168,8 @@ export type ExtractedOperation = typeof ExtractedOperation.Type; · 167 unmodified lines
168 168
169export const ExtractionResult = Schema.Struct({169export 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),
packages/react/src/api/atoms.tsx+6−0
@@ -141,6 +141,10 @@ export const createConnection = ExecutorApiClient.mutation("connections", "creat · 140 unmodified lines
141 141
142export const removeConnection = ExecutorApiClient.mutation("connections", "remove");142export 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. */
146export const updateConnection = ExecutorApiClient.mutation("connections", "update");
147
144export const refreshConnection = ExecutorApiClient.mutation("connections", "refresh");148export const refreshConnection = ExecutorApiClient.mutation("connections", "refresh");
145 149
146export const updateIntegration = ExecutorApiClient.mutation("integrations", "update");150export 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 (if324 // Optimistic placeholder predates the server resolving which app (if
319 // any) minted this connection; reconciled on refresh. Matches the325 // any) minted this connection; reconciled on refresh. Matches the
packages/react/src/components/accounts-section.tsx+24−2
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
25} from "../plugins/oauth-reconnect";25} from "../plugins/oauth-reconnect";
26import { useOAuthPopupFlow } from "../plugins/oauth-sign-in";26import { useOAuthPopupFlow } from "../plugins/oauth-sign-in";
27import { AddAccountModal } from "./add-account-modal";27import { AddAccountModal } from "./add-account-modal";
28import { ConnectionEditSheet } from "./metadata-edit-sheet";
28import type { CreateCustomMethod } from "./add-custom-method-modal";29import type { CreateCustomMethod } from "./add-custom-method-modal";
29import { Badge } from "./badge";30import { Badge } from "./badge";
30import { Button } from "./button";31import { 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 (no52// Credentials are IMMUTABLE (no method switch); the editable surface is the
52// rename, no method switch). "+ Add connection" opens the create modal. When both53// 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 Reconnect124 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's143 /** 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}
packages/react/src/components/command-palette.tsx+1−1
@@ -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 })),
packages/react/src/components/metadata-edit-sheet.tsx+296−0
@@ -0,0 +1,296 @@
1import { Suspense, useCallback, useEffect, useRef, useState, type ComponentType } from "react";
2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import { type Connection, type IntegrationSlug } from "@executor-js/sdk/shared";
5import type { EditSheetApplyResult, EditSheetSectionProps } from "@executor-js/sdk/client";
6import { toast } from "sonner";
7
8import { updateConnection, updateIntegration } from "../api/atoms";
9import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys";
10import { trackEvent } from "../api/analytics";
11import { messageFromExit } from "../api/error-reporting";
12import { Button } from "./button";
13import { Input } from "./input";
14import { Label } from "./label";
15import {
16 Sheet,
17 SheetContent,
18 SheetDescription,
19 SheetFooter,
20 SheetHeader,
21 SheetTitle,
22} from "./sheet";
23import { 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. */
34const connectionPrefix = (connection: Connection): string => {
35 const address = String(connection.address);
36 return address.startsWith("tools.") ? address.slice("tools.".length) : address;
37};
38
39function 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
52export 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
157export 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}
packages/react/src/multiplayer/shell.tsx+1−1
@@ -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}/`);
packages/react/src/pages/integration-detail.tsx+98−213
@@ -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";
25import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys";24import { connectionWriteKeys, integrationWriteKeys } from "../api/reactivity-keys";
26import { ToolTree } from "../components/tool-tree";25import { ToolTree } from "../components/tool-tree";
27import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail";26import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail";
28import type { ToolSummary } from "../components/tool-tree";27import type { ToolSummary } from "../components/tool-tree";
29import { AccountsSection } from "../components/accounts-section";28import { AccountsSection } from "../components/accounts-section";
29import { IntegrationEditSheet } from "../components/metadata-edit-sheet";
30import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs";30import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs";
31import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements";31import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements";
32import { usePolicyActions } from "../hooks/use-policy-actions";32import { usePolicyActions } from "../hooks/use-policy-actions";
33import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client";33import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client";
34import { Button } from "../components/button";34import { Button } from "../components/button";
35import { Input } from "../components/input";
36import { Skeleton } from "../components/skeleton";35import { Skeleton } from "../components/skeleton";
37 36
38// v2: the route's `namespace` param is the integration slug. Tools belong to37// 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 writes66 // 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 <Input323 {integrationData?.name || namespace}
367 autoFocus324 </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 <Button340 <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 <Button353 <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 that395 {/* Hub: integration-level auth methods + accounts. Plugins that
486 declare auth methods fill the `accounts` slot (real methods from396 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.accounts402 <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
627function 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}
packages/react/src/pages/integrations.tsx+1−1
@@ -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 }}>