History

openapi: refresh sources by re-fetching their origin URL

Persist the spec's origin URL on `openapi_source` so sources added via http(s) surface `canRefresh: true` and can be re-fetched via `sources.refresh(id)`. Hoists `addSpec`'s parse → extract → upsert path into a shared `rebuildSource(ctx, input)` helper that the new `refreshSource` plugin hook replays with the stored URL. Raw-text sources keep `canRefresh: false` and no-op on refresh. Includes cloud node test covering URL-backed refresh and the raw-text no-op branch, plus the drizzle migration for the new `source_url` column.

Rhys Sullivan committed 6 months ago

Commit 16dc8b81 parent 79848da7 files · +1635 −83Browse files

Unified · Split

7 files
  1. apps/cloud/drizzle/0001_harsh_meltdown.sql +1 −0
  2. apps/cloud/drizzle/meta/0001_snapshot.json +1276 −0
  3. apps/cloud/drizzle/meta/_journal.json +7 −0
  4. apps/cloud/src/services/executor-schema.ts +2 −0
  5. apps/cloud/src/services/sources-refresh.node.test.ts +177 −0
  6. packages/plugins/openapi/src/sdk/plugin.ts +161 −83
  7. packages/plugins/openapi/src/sdk/store.ts +11 −0
apps/cloud/drizzle/0001_harsh_meltdown.sql+1−0
@@ -0,0 +1 @@
1+ALTER TABLE "openapi_source" ADD COLUMN "source_url" text;
apps/cloud/drizzle/meta/0001_snapshot.json+1276−0
@@ -0,0 +1,1276 @@
1+{
2+ "id": "12ebb69b-fb44-4e61-80b9-a48b5471ef5a",
3+ "prevId": "93e9f8ef-fe9a-4a8c-b326-329464d876ca",
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": [
64+ "account_id"
65+ ],
66+ "columnsTo": [
67+ "id"
68+ ],
69+ "onDelete": "cascade",
70+ "onUpdate": "no action"
71+ },
72+ "memberships_organization_id_organizations_id_fk": {
73+ "name": "memberships_organization_id_organizations_id_fk",
74+ "tableFrom": "memberships",
75+ "tableTo": "organizations",
76+ "columnsFrom": [
77+ "organization_id"
78+ ],
79+ "columnsTo": [
80+ "id"
81+ ],
82+ "onDelete": "cascade",
83+ "onUpdate": "no action"
84+ }
85+ },
86+ "compositePrimaryKeys": {
87+ "memberships_account_id_organization_id_pk": {
88+ "name": "memberships_account_id_organization_id_pk",
89+ "columns": [
90+ "account_id",
91+ "organization_id"
92+ ]
93+ }
94+ },
95+ "uniqueConstraints": {},
96+ "policies": {},
97+ "checkConstraints": {},
98+ "isRLSEnabled": false
99+ },
100+ "public.organizations": {
101+ "name": "organizations",
102+ "schema": "",
103+ "columns": {
104+ "id": {
105+ "name": "id",
106+ "type": "text",
107+ "primaryKey": true,
108+ "notNull": true
109+ },
110+ "name": {
111+ "name": "name",
112+ "type": "text",
113+ "primaryKey": false,
114+ "notNull": true
115+ },
116+ "created_at": {
117+ "name": "created_at",
118+ "type": "timestamp with time zone",
119+ "primaryKey": false,
120+ "notNull": true,
121+ "default": "now()"
122+ }
123+ },
124+ "indexes": {},
125+ "foreignKeys": {},
126+ "compositePrimaryKeys": {},
127+ "uniqueConstraints": {},
128+ "policies": {},
129+ "checkConstraints": {},
130+ "isRLSEnabled": false
131+ },
132+ "public.blob": {
133+ "name": "blob",
134+ "schema": "",
135+ "columns": {
136+ "namespace": {
137+ "name": "namespace",
138+ "type": "text",
139+ "primaryKey": false,
140+ "notNull": true
141+ },
142+ "key": {
143+ "name": "key",
144+ "type": "text",
145+ "primaryKey": false,
146+ "notNull": true
147+ },
148+ "value": {
149+ "name": "value",
150+ "type": "text",
151+ "primaryKey": false,
152+ "notNull": true
153+ }
154+ },
155+ "indexes": {},
156+ "foreignKeys": {},
157+ "compositePrimaryKeys": {
158+ "blob_namespace_key_pk": {
159+ "name": "blob_namespace_key_pk",
160+ "columns": [
161+ "namespace",
162+ "key"
163+ ]
164+ }
165+ },
166+ "uniqueConstraints": {},
167+ "policies": {},
168+ "checkConstraints": {},
169+ "isRLSEnabled": false
170+ },
171+ "public.definition": {
172+ "name": "definition",
173+ "schema": "",
174+ "columns": {
175+ "id": {
176+ "name": "id",
177+ "type": "text",
178+ "primaryKey": false,
179+ "notNull": true
180+ },
181+ "scope_id": {
182+ "name": "scope_id",
183+ "type": "text",
184+ "primaryKey": false,
185+ "notNull": true
186+ },
187+ "source_id": {
188+ "name": "source_id",
189+ "type": "text",
190+ "primaryKey": false,
191+ "notNull": true
192+ },
193+ "plugin_id": {
194+ "name": "plugin_id",
195+ "type": "text",
196+ "primaryKey": false,
197+ "notNull": true
198+ },
199+ "name": {
200+ "name": "name",
201+ "type": "text",
202+ "primaryKey": false,
203+ "notNull": true
204+ },
205+ "schema": {
206+ "name": "schema",
207+ "type": "jsonb",
208+ "primaryKey": false,
209+ "notNull": true
210+ },
211+ "created_at": {
212+ "name": "created_at",
213+ "type": "timestamp",
214+ "primaryKey": false,
215+ "notNull": true
216+ }
217+ },
218+ "indexes": {
219+ "definition_scope_id_idx": {
220+ "name": "definition_scope_id_idx",
221+ "columns": [
222+ {
223+ "expression": "scope_id",
224+ "isExpression": false,
225+ "asc": true,
226+ "nulls": "last"
227+ }
228+ ],
229+ "isUnique": false,
230+ "concurrently": false,
231+ "method": "btree",
232+ "with": {}
233+ },
234+ "definition_source_id_idx": {
235+ "name": "definition_source_id_idx",
236+ "columns": [
237+ {
238+ "expression": "source_id",
239+ "isExpression": false,
240+ "asc": true,
241+ "nulls": "last"
242+ }
243+ ],
244+ "isUnique": false,
245+ "concurrently": false,
246+ "method": "btree",
247+ "with": {}
248+ },
249+ "definition_plugin_id_idx": {
250+ "name": "definition_plugin_id_idx",
251+ "columns": [
252+ {
253+ "expression": "plugin_id",
254+ "isExpression": false,
255+ "asc": true,
256+ "nulls": "last"
257+ }
258+ ],
259+ "isUnique": false,
260+ "concurrently": false,
261+ "method": "btree",
262+ "with": {}
263+ }
264+ },
265+ "foreignKeys": {},
266+ "compositePrimaryKeys": {
267+ "definition_scope_id_id_pk": {
268+ "name": "definition_scope_id_id_pk",
269+ "columns": [
270+ "scope_id",
271+ "id"
272+ ]
273+ }
274+ },
275+ "uniqueConstraints": {},
276+ "policies": {},
277+ "checkConstraints": {},
278+ "isRLSEnabled": false
279+ },
280+ "public.graphql_operation": {
281+ "name": "graphql_operation",
282+ "schema": "",
283+ "columns": {
284+ "id": {
285+ "name": "id",
286+ "type": "text",
287+ "primaryKey": false,
288+ "notNull": true
289+ },
290+ "scope_id": {
291+ "name": "scope_id",
292+ "type": "text",
293+ "primaryKey": false,
294+ "notNull": true
295+ },
296+ "source_id": {
297+ "name": "source_id",
298+ "type": "text",
299+ "primaryKey": false,
300+ "notNull": true
301+ },
302+ "binding": {
303+ "name": "binding",
304+ "type": "jsonb",
305+ "primaryKey": false,
306+ "notNull": true
307+ }
308+ },
309+ "indexes": {
310+ "graphql_operation_scope_id_idx": {
311+ "name": "graphql_operation_scope_id_idx",
312+ "columns": [
313+ {
314+ "expression": "scope_id",
315+ "isExpression": false,
316+ "asc": true,
317+ "nulls": "last"
318+ }
319+ ],
320+ "isUnique": false,
321+ "concurrently": false,
322+ "method": "btree",
323+ "with": {}
324+ },
325+ "graphql_operation_source_id_idx": {
326+ "name": "graphql_operation_source_id_idx",
327+ "columns": [
328+ {
329+ "expression": "source_id",
330+ "isExpression": false,
331+ "asc": true,
332+ "nulls": "last"
333+ }
334+ ],
335+ "isUnique": false,
336+ "concurrently": false,
337+ "method": "btree",
338+ "with": {}
339+ }
340+ },
341+ "foreignKeys": {},
342+ "compositePrimaryKeys": {
343+ "graphql_operation_scope_id_id_pk": {
344+ "name": "graphql_operation_scope_id_id_pk",
345+ "columns": [
346+ "scope_id",
347+ "id"
348+ ]
349+ }
350+ },
351+ "uniqueConstraints": {},
352+ "policies": {},
353+ "checkConstraints": {},
354+ "isRLSEnabled": false
355+ },
356+ "public.graphql_source": {
357+ "name": "graphql_source",
358+ "schema": "",
359+ "columns": {
360+ "id": {
361+ "name": "id",
362+ "type": "text",
363+ "primaryKey": false,
364+ "notNull": true
365+ },
366+ "scope_id": {
367+ "name": "scope_id",
368+ "type": "text",
369+ "primaryKey": false,
370+ "notNull": true
371+ },
372+ "name": {
373+ "name": "name",
374+ "type": "text",
375+ "primaryKey": false,
376+ "notNull": true
377+ },
378+ "endpoint": {
379+ "name": "endpoint",
380+ "type": "text",
381+ "primaryKey": false,
382+ "notNull": true
383+ },
384+ "headers": {
385+ "name": "headers",
386+ "type": "jsonb",
387+ "primaryKey": false,
388+ "notNull": false
389+ }
390+ },
391+ "indexes": {
392+ "graphql_source_scope_id_idx": {
393+ "name": "graphql_source_scope_id_idx",
394+ "columns": [
395+ {
396+ "expression": "scope_id",
397+ "isExpression": false,
398+ "asc": true,
399+ "nulls": "last"
400+ }
401+ ],
402+ "isUnique": false,
403+ "concurrently": false,
404+ "method": "btree",
405+ "with": {}
406+ }
407+ },
408+ "foreignKeys": {},
409+ "compositePrimaryKeys": {
410+ "graphql_source_scope_id_id_pk": {
411+ "name": "graphql_source_scope_id_id_pk",
412+ "columns": [
413+ "scope_id",
414+ "id"
415+ ]
416+ }
417+ },
418+ "uniqueConstraints": {},
419+ "policies": {},
420+ "checkConstraints": {},
421+ "isRLSEnabled": false
422+ },
423+ "public.mcp_binding": {
424+ "name": "mcp_binding",
425+ "schema": "",
426+ "columns": {
427+ "id": {
428+ "name": "id",
429+ "type": "text",
430+ "primaryKey": false,
431+ "notNull": true
432+ },
433+ "scope_id": {
434+ "name": "scope_id",
435+ "type": "text",
436+ "primaryKey": false,
437+ "notNull": true
438+ },
439+ "source_id": {
440+ "name": "source_id",
441+ "type": "text",
442+ "primaryKey": false,
443+ "notNull": true
444+ },
445+ "binding": {
446+ "name": "binding",
447+ "type": "jsonb",
448+ "primaryKey": false,
449+ "notNull": true
450+ },
451+ "created_at": {
452+ "name": "created_at",
453+ "type": "timestamp",
454+ "primaryKey": false,
455+ "notNull": true
456+ }
457+ },
458+ "indexes": {
459+ "mcp_binding_scope_id_idx": {
460+ "name": "mcp_binding_scope_id_idx",
461+ "columns": [
462+ {
463+ "expression": "scope_id",
464+ "isExpression": false,
465+ "asc": true,
466+ "nulls": "last"
467+ }
468+ ],
469+ "isUnique": false,
470+ "concurrently": false,
471+ "method": "btree",
472+ "with": {}
473+ },
474+ "mcp_binding_source_id_idx": {
475+ "name": "mcp_binding_source_id_idx",
476+ "columns": [
477+ {
478+ "expression": "source_id",
479+ "isExpression": false,
480+ "asc": true,
481+ "nulls": "last"
482+ }
483+ ],
484+ "isUnique": false,
485+ "concurrently": false,
486+ "method": "btree",
487+ "with": {}
488+ }
489+ },
490+ "foreignKeys": {},
491+ "compositePrimaryKeys": {
492+ "mcp_binding_scope_id_id_pk": {
493+ "name": "mcp_binding_scope_id_id_pk",
494+ "columns": [
495+ "scope_id",
496+ "id"
497+ ]
498+ }
499+ },
500+ "uniqueConstraints": {},
501+ "policies": {},
502+ "checkConstraints": {},
503+ "isRLSEnabled": false
504+ },
505+ "public.mcp_oauth_session": {
506+ "name": "mcp_oauth_session",
507+ "schema": "",
508+ "columns": {
509+ "id": {
510+ "name": "id",
511+ "type": "text",
512+ "primaryKey": false,
513+ "notNull": true
514+ },
515+ "scope_id": {
516+ "name": "scope_id",
517+ "type": "text",
518+ "primaryKey": false,
519+ "notNull": true
520+ },
521+ "session": {
522+ "name": "session",
523+ "type": "jsonb",
524+ "primaryKey": false,
525+ "notNull": true
526+ },
527+ "expires_at": {
528+ "name": "expires_at",
529+ "type": "integer",
530+ "primaryKey": false,
531+ "notNull": true
532+ },
533+ "created_at": {
534+ "name": "created_at",
535+ "type": "timestamp",
536+ "primaryKey": false,
537+ "notNull": true
538+ }
539+ },
540+ "indexes": {
541+ "mcp_oauth_session_scope_id_idx": {
542+ "name": "mcp_oauth_session_scope_id_idx",
543+ "columns": [
544+ {
545+ "expression": "scope_id",
546+ "isExpression": false,
547+ "asc": true,
548+ "nulls": "last"
549+ }
550+ ],
551+ "isUnique": false,
552+ "concurrently": false,
553+ "method": "btree",
554+ "with": {}
555+ }
556+ },
557+ "foreignKeys": {},
558+ "compositePrimaryKeys": {
559+ "mcp_oauth_session_scope_id_id_pk": {
560+ "name": "mcp_oauth_session_scope_id_id_pk",
561+ "columns": [
562+ "scope_id",
563+ "id"
564+ ]
565+ }
566+ },
567+ "uniqueConstraints": {},
568+ "policies": {},
569+ "checkConstraints": {},
570+ "isRLSEnabled": false
571+ },
572+ "public.mcp_source": {
573+ "name": "mcp_source",
574+ "schema": "",
575+ "columns": {
576+ "id": {
577+ "name": "id",
578+ "type": "text",
579+ "primaryKey": false,
580+ "notNull": true
581+ },
582+ "scope_id": {
583+ "name": "scope_id",
584+ "type": "text",
585+ "primaryKey": false,
586+ "notNull": true
587+ },
588+ "name": {
589+ "name": "name",
590+ "type": "text",
591+ "primaryKey": false,
592+ "notNull": true
593+ },
594+ "config": {
595+ "name": "config",
596+ "type": "jsonb",
597+ "primaryKey": false,
598+ "notNull": true
599+ },
600+ "created_at": {
601+ "name": "created_at",
602+ "type": "timestamp",
603+ "primaryKey": false,
604+ "notNull": true
605+ }
606+ },
607+ "indexes": {
608+ "mcp_source_scope_id_idx": {
609+ "name": "mcp_source_scope_id_idx",
610+ "columns": [
611+ {
612+ "expression": "scope_id",
613+ "isExpression": false,
614+ "asc": true,
615+ "nulls": "last"
616+ }
617+ ],
618+ "isUnique": false,
619+ "concurrently": false,
620+ "method": "btree",
621+ "with": {}
622+ }
623+ },
624+ "foreignKeys": {},
625+ "compositePrimaryKeys": {
626+ "mcp_source_scope_id_id_pk": {
627+ "name": "mcp_source_scope_id_id_pk",
628+ "columns": [
629+ "scope_id",
630+ "id"
631+ ]
632+ }
633+ },
634+ "uniqueConstraints": {},
635+ "policies": {},
636+ "checkConstraints": {},
637+ "isRLSEnabled": false
638+ },
639+ "public.openapi_oauth_session": {
640+ "name": "openapi_oauth_session",
641+ "schema": "",
642+ "columns": {
643+ "id": {
644+ "name": "id",
645+ "type": "text",
646+ "primaryKey": false,
647+ "notNull": true
648+ },
649+ "scope_id": {
650+ "name": "scope_id",
651+ "type": "text",
652+ "primaryKey": false,
653+ "notNull": true
654+ },
655+ "session": {
656+ "name": "session",
657+ "type": "jsonb",
658+ "primaryKey": false,
659+ "notNull": true
660+ },
661+ "created_at": {
662+ "name": "created_at",
663+ "type": "timestamp",
664+ "primaryKey": false,
665+ "notNull": true
666+ }
667+ },
668+ "indexes": {
669+ "openapi_oauth_session_scope_id_idx": {
670+ "name": "openapi_oauth_session_scope_id_idx",
671+ "columns": [
672+ {
673+ "expression": "scope_id",
674+ "isExpression": false,
675+ "asc": true,
676+ "nulls": "last"
677+ }
678+ ],
679+ "isUnique": false,
680+ "concurrently": false,
681+ "method": "btree",
682+ "with": {}
683+ }
684+ },
685+ "foreignKeys": {},
686+ "compositePrimaryKeys": {
687+ "openapi_oauth_session_scope_id_id_pk": {
688+ "name": "openapi_oauth_session_scope_id_id_pk",
689+ "columns": [
690+ "scope_id",
691+ "id"
692+ ]
693+ }
694+ },
695+ "uniqueConstraints": {},
696+ "policies": {},
697+ "checkConstraints": {},
698+ "isRLSEnabled": false
699+ },
700+ "public.openapi_operation": {
701+ "name": "openapi_operation",
702+ "schema": "",
703+ "columns": {
704+ "id": {
705+ "name": "id",
706+ "type": "text",
707+ "primaryKey": false,
708+ "notNull": true
709+ },
710+ "scope_id": {
711+ "name": "scope_id",
712+ "type": "text",
713+ "primaryKey": false,
714+ "notNull": true
715+ },
716+ "source_id": {
717+ "name": "source_id",
718+ "type": "text",
719+ "primaryKey": false,
720+ "notNull": true
721+ },
722+ "binding": {
723+ "name": "binding",
724+ "type": "jsonb",
725+ "primaryKey": false,
726+ "notNull": true
727+ }
728+ },
729+ "indexes": {
730+ "openapi_operation_scope_id_idx": {
731+ "name": "openapi_operation_scope_id_idx",
732+ "columns": [
733+ {
734+ "expression": "scope_id",
735+ "isExpression": false,
736+ "asc": true,
737+ "nulls": "last"
738+ }
739+ ],
740+ "isUnique": false,
741+ "concurrently": false,
742+ "method": "btree",
743+ "with": {}
744+ },
745+ "openapi_operation_source_id_idx": {
746+ "name": "openapi_operation_source_id_idx",
747+ "columns": [
748+ {
749+ "expression": "source_id",
750+ "isExpression": false,
751+ "asc": true,
752+ "nulls": "last"
753+ }
754+ ],
755+ "isUnique": false,
756+ "concurrently": false,
757+ "method": "btree",
758+ "with": {}
759+ }
760+ },
761+ "foreignKeys": {},
762+ "compositePrimaryKeys": {
763+ "openapi_operation_scope_id_id_pk": {
764+ "name": "openapi_operation_scope_id_id_pk",
765+ "columns": [
766+ "scope_id",
767+ "id"
768+ ]
769+ }
770+ },
771+ "uniqueConstraints": {},
772+ "policies": {},
773+ "checkConstraints": {},
774+ "isRLSEnabled": false
775+ },
776+ "public.openapi_source": {
777+ "name": "openapi_source",
778+ "schema": "",
779+ "columns": {
780+ "id": {
781+ "name": "id",
782+ "type": "text",
783+ "primaryKey": false,
784+ "notNull": true
785+ },
786+ "scope_id": {
787+ "name": "scope_id",
788+ "type": "text",
789+ "primaryKey": false,
790+ "notNull": true
791+ },
792+ "name": {
793+ "name": "name",
794+ "type": "text",
795+ "primaryKey": false,
796+ "notNull": true
797+ },
798+ "spec": {
799+ "name": "spec",
800+ "type": "text",
801+ "primaryKey": false,
802+ "notNull": true
803+ },
804+ "source_url": {
805+ "name": "source_url",
806+ "type": "text",
807+ "primaryKey": false,
808+ "notNull": false
809+ },
810+ "base_url": {
811+ "name": "base_url",
812+ "type": "text",
813+ "primaryKey": false,
814+ "notNull": false
815+ },
816+ "headers": {
817+ "name": "headers",
818+ "type": "jsonb",
819+ "primaryKey": false,
820+ "notNull": false
821+ },
822+ "oauth2": {
823+ "name": "oauth2",
824+ "type": "jsonb",
825+ "primaryKey": false,
826+ "notNull": false
827+ },
828+ "invocation_config": {
829+ "name": "invocation_config",
830+ "type": "jsonb",
831+ "primaryKey": false,
832+ "notNull": true
833+ }
834+ },
835+ "indexes": {
836+ "openapi_source_scope_id_idx": {
837+ "name": "openapi_source_scope_id_idx",
838+ "columns": [
839+ {
840+ "expression": "scope_id",
841+ "isExpression": false,
842+ "asc": true,
843+ "nulls": "last"
844+ }
845+ ],
846+ "isUnique": false,
847+ "concurrently": false,
848+ "method": "btree",
849+ "with": {}
850+ }
851+ },
852+ "foreignKeys": {},
853+ "compositePrimaryKeys": {
854+ "openapi_source_scope_id_id_pk": {
855+ "name": "openapi_source_scope_id_id_pk",
856+ "columns": [
857+ "scope_id",
858+ "id"
859+ ]
860+ }
861+ },
862+ "uniqueConstraints": {},
863+ "policies": {},
864+ "checkConstraints": {},
865+ "isRLSEnabled": false
866+ },
867+ "public.secret": {
868+ "name": "secret",
869+ "schema": "",
870+ "columns": {
871+ "id": {
872+ "name": "id",
873+ "type": "text",
874+ "primaryKey": false,
875+ "notNull": true
876+ },
877+ "scope_id": {
878+ "name": "scope_id",
879+ "type": "text",
880+ "primaryKey": false,
881+ "notNull": true
882+ },
883+ "name": {
884+ "name": "name",
885+ "type": "text",
886+ "primaryKey": false,
887+ "notNull": true
888+ },
889+ "provider": {
890+ "name": "provider",
891+ "type": "text",
892+ "primaryKey": false,
893+ "notNull": true
894+ },
895+ "created_at": {
896+ "name": "created_at",
897+ "type": "timestamp",
898+ "primaryKey": false,
899+ "notNull": true
900+ }
901+ },
902+ "indexes": {
903+ "secret_scope_id_idx": {
904+ "name": "secret_scope_id_idx",
905+ "columns": [
906+ {
907+ "expression": "scope_id",
908+ "isExpression": false,
909+ "asc": true,
910+ "nulls": "last"
911+ }
912+ ],
913+ "isUnique": false,
914+ "concurrently": false,
915+ "method": "btree",
916+ "with": {}
917+ },
918+ "secret_provider_idx": {
919+ "name": "secret_provider_idx",
920+ "columns": [
921+ {
922+ "expression": "provider",
923+ "isExpression": false,
924+ "asc": true,
925+ "nulls": "last"
926+ }
927+ ],
928+ "isUnique": false,
929+ "concurrently": false,
930+ "method": "btree",
931+ "with": {}
932+ }
933+ },
934+ "foreignKeys": {},
935+ "compositePrimaryKeys": {
936+ "secret_scope_id_id_pk": {
937+ "name": "secret_scope_id_id_pk",
938+ "columns": [
939+ "scope_id",
940+ "id"
941+ ]
942+ }
943+ },
944+ "uniqueConstraints": {},
945+ "policies": {},
946+ "checkConstraints": {},
947+ "isRLSEnabled": false
948+ },
949+ "public.source": {
950+ "name": "source",
951+ "schema": "",
952+ "columns": {
953+ "id": {
954+ "name": "id",
955+ "type": "text",
956+ "primaryKey": false,
957+ "notNull": true
958+ },
959+ "scope_id": {
960+ "name": "scope_id",
961+ "type": "text",
962+ "primaryKey": false,
963+ "notNull": true
964+ },
965+ "plugin_id": {
966+ "name": "plugin_id",
967+ "type": "text",
968+ "primaryKey": false,
969+ "notNull": true
970+ },
971+ "kind": {
972+ "name": "kind",
973+ "type": "text",
974+ "primaryKey": false,
975+ "notNull": true
976+ },
977+ "name": {
978+ "name": "name",
979+ "type": "text",
980+ "primaryKey": false,
981+ "notNull": true
982+ },
983+ "url": {
984+ "name": "url",
985+ "type": "text",
986+ "primaryKey": false,
987+ "notNull": false
988+ },
989+ "can_remove": {
990+ "name": "can_remove",
991+ "type": "boolean",
992+ "primaryKey": false,
993+ "notNull": true,
994+ "default": true
995+ },
996+ "can_refresh": {
997+ "name": "can_refresh",
998+ "type": "boolean",
999+ "primaryKey": false,
1000+ "notNull": true,
1001+ "default": false
1002+ },
1003+ "can_edit": {
1004+ "name": "can_edit",
1005+ "type": "boolean",
1006+ "primaryKey": false,
1007+ "notNull": true,
1008+ "default": false
1009+ },
1010+ "created_at": {
1011+ "name": "created_at",
1012+ "type": "timestamp",
1013+ "primaryKey": false,
1014+ "notNull": true
1015+ },
1016+ "updated_at": {
1017+ "name": "updated_at",
1018+ "type": "timestamp",
1019+ "primaryKey": false,
1020+ "notNull": true
1021+ }
1022+ },
1023+ "indexes": {
1024+ "source_scope_id_idx": {
1025+ "name": "source_scope_id_idx",
1026+ "columns": [
1027+ {
1028+ "expression": "scope_id",
1029+ "isExpression": false,
1030+ "asc": true,
1031+ "nulls": "last"
1032+ }
1033+ ],
1034+ "isUnique": false,
1035+ "concurrently": false,
1036+ "method": "btree",
1037+ "with": {}
1038+ },
1039+ "source_plugin_id_idx": {
1040+ "name": "source_plugin_id_idx",
1041+ "columns": [
1042+ {
1043+ "expression": "plugin_id",
1044+ "isExpression": false,
1045+ "asc": true,
1046+ "nulls": "last"
1047+ }
1048+ ],
1049+ "isUnique": false,
1050+ "concurrently": false,
1051+ "method": "btree",
1052+ "with": {}
1053+ }
1054+ },
1055+ "foreignKeys": {},
1056+ "compositePrimaryKeys": {
1057+ "source_scope_id_id_pk": {
1058+ "name": "source_scope_id_id_pk",
1059+ "columns": [
1060+ "scope_id",
1061+ "id"
1062+ ]
1063+ }
1064+ },
1065+ "uniqueConstraints": {},
1066+ "policies": {},
1067+ "checkConstraints": {},
1068+ "isRLSEnabled": false
1069+ },
1070+ "public.tool": {
1071+ "name": "tool",
1072+ "schema": "",
1073+ "columns": {
1074+ "id": {
1075+ "name": "id",
1076+ "type": "text",
1077+ "primaryKey": false,
1078+ "notNull": true
1079+ },
1080+ "scope_id": {
1081+ "name": "scope_id",
1082+ "type": "text",
1083+ "primaryKey": false,
1084+ "notNull": true
1085+ },
1086+ "source_id": {
1087+ "name": "source_id",
1088+ "type": "text",
1089+ "primaryKey": false,
1090+ "notNull": true
1091+ },
1092+ "plugin_id": {
1093+ "name": "plugin_id",
1094+ "type": "text",
1095+ "primaryKey": false,
1096+ "notNull": true
1097+ },
1098+ "name": {
1099+ "name": "name",
1100+ "type": "text",
1101+ "primaryKey": false,
1102+ "notNull": true
1103+ },
1104+ "description": {
1105+ "name": "description",
1106+ "type": "text",
1107+ "primaryKey": false,
1108+ "notNull": true
1109+ },
1110+ "input_schema": {
1111+ "name": "input_schema",
1112+ "type": "jsonb",
1113+ "primaryKey": false,
1114+ "notNull": false
1115+ },
1116+ "output_schema": {
1117+ "name": "output_schema",
1118+ "type": "jsonb",
1119+ "primaryKey": false,
1120+ "notNull": false
1121+ },
1122+ "created_at": {
1123+ "name": "created_at",
1124+ "type": "timestamp",
1125+ "primaryKey": false,
1126+ "notNull": true
1127+ },
1128+ "updated_at": {
1129+ "name": "updated_at",
1130+ "type": "timestamp",
1131+ "primaryKey": false,
1132+ "notNull": true
1133+ }
1134+ },
1135+ "indexes": {
1136+ "tool_scope_id_idx": {
1137+ "name": "tool_scope_id_idx",
1138+ "columns": [
1139+ {
1140+ "expression": "scope_id",
1141+ "isExpression": false,
1142+ "asc": true,
1143+ "nulls": "last"
1144+ }
1145+ ],
1146+ "isUnique": false,
1147+ "concurrently": false,
1148+ "method": "btree",
1149+ "with": {}
1150+ },
1151+ "tool_source_id_idx": {
1152+ "name": "tool_source_id_idx",
1153+ "columns": [
1154+ {
1155+ "expression": "source_id",
1156+ "isExpression": false,
1157+ "asc": true,
1158+ "nulls": "last"
1159+ }
1160+ ],
1161+ "isUnique": false,
1162+ "concurrently": false,
1163+ "method": "btree",
1164+ "with": {}
1165+ },
1166+ "tool_plugin_id_idx": {
1167+ "name": "tool_plugin_id_idx",
1168+ "columns": [
1169+ {
1170+ "expression": "plugin_id",
1171+ "isExpression": false,
1172+ "asc": true,
1173+ "nulls": "last"
1174+ }
1175+ ],
1176+ "isUnique": false,
1177+ "concurrently": false,
1178+ "method": "btree",
1179+ "with": {}
1180+ }
1181+ },
1182+ "foreignKeys": {},
1183+ "compositePrimaryKeys": {
1184+ "tool_scope_id_id_pk": {
1185+ "name": "tool_scope_id_id_pk",
1186+ "columns": [
1187+ "scope_id",
1188+ "id"
1189+ ]
1190+ }
1191+ },
1192+ "uniqueConstraints": {},
1193+ "policies": {},
1194+ "checkConstraints": {},
1195+ "isRLSEnabled": false
1196+ },
1197+ "public.workos_vault_metadata": {
1198+ "name": "workos_vault_metadata",
1199+ "schema": "",
1200+ "columns": {
1201+ "id": {
1202+ "name": "id",
1203+ "type": "text",
1204+ "primaryKey": false,
1205+ "notNull": true
1206+ },
1207+ "scope_id": {
1208+ "name": "scope_id",
1209+ "type": "text",
1210+ "primaryKey": false,
1211+ "notNull": true
1212+ },
1213+ "name": {
1214+ "name": "name",
1215+ "type": "text",
1216+ "primaryKey": false,
1217+ "notNull": true
1218+ },
1219+ "purpose": {
1220+ "name": "purpose",
1221+ "type": "text",
1222+ "primaryKey": false,
1223+ "notNull": false
1224+ },
1225+ "created_at": {
1226+ "name": "created_at",
1227+ "type": "timestamp",
1228+ "primaryKey": false,
1229+ "notNull": true
1230+ }
1231+ },
1232+ "indexes": {
1233+ "workos_vault_metadata_scope_id_idx": {
1234+ "name": "workos_vault_metadata_scope_id_idx",
1235+ "columns": [
1236+ {
1237+ "expression": "scope_id",
1238+ "isExpression": false,
1239+ "asc": true,
1240+ "nulls": "last"
1241+ }
1242+ ],
1243+ "isUnique": false,
1244+ "concurrently": false,
1245+ "method": "btree",
1246+ "with": {}
1247+ }
1248+ },
1249+ "foreignKeys": {},
1250+ "compositePrimaryKeys": {
1251+ "workos_vault_metadata_scope_id_id_pk": {
1252+ "name": "workos_vault_metadata_scope_id_id_pk",
1253+ "columns": [
1254+ "scope_id",
1255+ "id"
1256+ ]
1257+ }
1258+ },
1259+ "uniqueConstraints": {},
1260+ "policies": {},
1261+ "checkConstraints": {},
1262+ "isRLSEnabled": false
1263+ }
1264+ },
1265+ "enums": {},
1266+ "schemas": {},
1267+ "sequences": {},
1268+ "roles": {},
1269+ "policies": {},
1270+ "views": {},
1271+ "_meta": {
1272+ "columns": {},
1273+ "schemas": {},
1274+ "tables": {}
1275+ }
1276+}
apps/cloud/drizzle/meta/_journal.json+7−0
@@ -8,6 +8,13 @@ · 7 unmodified lines
88 "when": 1776367874348,
99 "tag": "0000_lame_rage",
1010 "breakpoints": true
11+ },
12+ {
13+ "idx": 1,
14+ "version": "7",
15+ "when": 1776678968180,
16+ "tag": "0001_harsh_meltdown",
17+ "breakpoints": true
1118 }
1219 ]
1320 }
apps/cloud/src/services/executor-schema.ts+2−0
@@ -68,6 +68,7 @@ export const openapi_source = pgTable("openapi_source", { · 67 unmodified lines
6868 scope_id: text('scope_id').notNull(),
6969 name: text('name').notNull(),
7070 spec: text('spec').notNull(),
71+ source_url: text('source_url'),
7172 base_url: text('base_url'),
7273 headers: jsonb('headers'),
7374 oauth2: jsonb('oauth2'),
@@ -176,3 +177,4 @@ export const blob = pgTable("blob", { · 102 unmodified lines
176177 primaryKey({ columns: [table.namespace, table.key] }),
177178 ]);
178179
180+
apps/cloud/src/services/sources-refresh.node.test.ts+177−0
@@ -0,0 +1,177 @@
1+// Refresh endpoint — covers `sources.refresh(id)` for an OpenAPI
2+// source added from a URL. Stands up a local HTTP server that serves
3+// one of two spec versions (swappable mid-test) so we can verify the
4+// refresh path re-fetches from the stored origin and replaces the
5+// operation set. Raw-text sources assert the no-op branch.
6+
7+import { describe, expect, it } from "@effect/vitest";
8+import { Effect } from "effect";
9+import http from "node:http";
10+import { AddressInfo } from "node:net";
11+
12+import { ScopeId } from "@executor/sdk";
13+
14+import { asOrg } from "./__test-harness__/api-harness";
15+
16+const specV1 = JSON.stringify({
17+ openapi: "3.0.0",
18+ info: { title: "Refresh Fixture", version: "1.0.0" },
19+ paths: {
20+ "/ping": {
21+ get: {
22+ operationId: "ping",
23+ summary: "ping",
24+ responses: { "200": { description: "ok" } },
25+ },
26+ },
27+ },
28+});
29+
30+const specV2 = JSON.stringify({
31+ openapi: "3.0.0",
32+ info: { title: "Refresh Fixture", version: "2.0.0" },
33+ paths: {
34+ "/ping": {
35+ get: {
36+ operationId: "ping",
37+ summary: "ping",
38+ responses: { "200": { description: "ok" } },
39+ },
40+ },
41+ "/pong": {
42+ get: {
43+ operationId: "pong",
44+ summary: "pong",
45+ responses: { "200": { description: "ok" } },
46+ },
47+ },
48+ },
49+});
50+
51+// Mutable ref: tests flip `current` between v1 and v2 around the
52+// refresh call. Using a single server keeps the URL stable across
53+// both addSpec and refresh — the plugin persists the original URL,
54+// so the second fetch goes back to the same endpoint.
55+const serveMutableSpec = () => {
56+ const state = { current: specV1, requests: 0 };
57+ const server = http.createServer((req, res) => {
58+ state.requests++;
59+ res.writeHead(200, { "content-type": "application/json" });
60+ res.end(state.current);
61+ });
62+ return new Promise<{
63+ baseUrl: string;
64+ setSpec: (s: string) => void;
65+ requestCount: () => number;
66+ close: () => Promise<void>;
67+ }>((resolve) => {
68+ server.listen(0, "127.0.0.1", () => {
69+ const { port } = server.address() as AddressInfo;
70+ resolve({
71+ baseUrl: `http://127.0.0.1:${port}`,
72+ setSpec: (s) => {
73+ state.current = s;
74+ },
75+ requestCount: () => state.requests,
76+ close: () =>
77+ new Promise((r) => {
78+ server.close(() => r());
79+ }),
80+ });
81+ });
82+ });
83+};
84+
85+describe("sources.refresh (HTTP)", () => {
86+ it.effect("addSpec from URL → canRefresh:true; refresh re-fetches and updates tools", () =>
87+ Effect.gen(function* () {
88+ const server = yield* Effect.promise(() => serveMutableSpec());
89+ try {
90+ const org = `org_${crypto.randomUUID()}`;
91+ const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`;
92+
93+ yield* asOrg(org, (client) =>
94+ client.openapi.addSpec({
95+ path: { scopeId: ScopeId.make(org) },
96+ payload: { spec: `${server.baseUrl}/spec.json`, namespace },
97+ }),
98+ );
99+
100+ const before = yield* asOrg(org, (client) =>
101+ client.sources.list({ path: { scopeId: ScopeId.make(org) } }),
102+ );
103+ const beforeSource = before.find((s) => s.id === namespace);
104+ expect(beforeSource?.canRefresh).toBe(true);
105+
106+ const fetchedBefore = yield* asOrg(org, (client) =>
107+ client.openapi.getSource({
108+ path: { scopeId: ScopeId.make(org), namespace },
109+ }),
110+ );
111+ expect(fetchedBefore?.config.sourceUrl).toBe(`${server.baseUrl}/spec.json`);
112+
113+ const beforeTools = yield* asOrg(org, (client) =>
114+ client.sources.tools({
115+ path: { scopeId: ScopeId.make(org), sourceId: namespace },
116+ }),
117+ );
118+ expect(beforeTools.length).toBe(1);
119+ expect(beforeTools.some((t) => t.name.startsWith("ping"))).toBe(true);
120+ expect(beforeTools.some((t) => t.name.startsWith("pong"))).toBe(false);
121+
122+ // Flip the remote to v2 (adds `pong`) and trigger refresh.
123+ server.setSpec(specV2);
124+ const requestsBefore = server.requestCount();
125+
126+ const refreshResult = yield* asOrg(org, (client) =>
127+ client.sources.refresh({
128+ path: { scopeId: ScopeId.make(org), sourceId: namespace },
129+ }),
130+ );
131+ expect(refreshResult.refreshed).toBe(true);
132+ expect(server.requestCount()).toBeGreaterThan(requestsBefore);
133+
134+ const afterTools = yield* asOrg(org, (client) =>
135+ client.sources.tools({
136+ path: { scopeId: ScopeId.make(org), sourceId: namespace },
137+ }),
138+ );
139+ expect(afterTools.length).toBe(2);
140+ expect(afterTools.some((t) => t.name.startsWith("ping"))).toBe(true);
141+ expect(afterTools.some((t) => t.name.startsWith("pong"))).toBe(true);
142+ } finally {
143+ yield* Effect.promise(() => server.close());
144+ }
145+ }),
146+ );
147+
148+ it.effect("addSpec from raw text → canRefresh:false; refresh is a no-op", () =>
149+ Effect.gen(function* () {
150+ const org = `org_${crypto.randomUUID()}`;
151+ const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`;
152+
153+ yield* asOrg(org, (client) =>
154+ client.openapi.addSpec({
155+ path: { scopeId: ScopeId.make(org) },
156+ payload: { spec: specV1, namespace },
157+ }),
158+ );
159+
160+ const sources = yield* asOrg(org, (client) =>
161+ client.sources.list({ path: { scopeId: ScopeId.make(org) } }),
162+ );
163+ const row = sources.find((s) => s.id === namespace);
164+ expect(row?.canRefresh).toBe(false);
165+
166+ // Raw-text sources reach the plugin with no stored URL and
167+ // silently no-op — UI gates the action on canRefresh, but the
168+ // server should not 500 if a caller slips through.
169+ const result = yield* asOrg(org, (client) =>
170+ client.sources.refresh({
171+ path: { scopeId: ScopeId.make(org), sourceId: namespace },
172+ }),
173+ );
174+ expect(result.refreshed).toBe(true);
175+ }),
176+ );
177+});
packages/plugins/openapi/src/sdk/plugin.ts+161−83
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
1818 SetSecretInput,
1919 SourceDetectionResult,
2020 definePlugin,
21+ type PluginCtx,
2122 type StorageFailure,
2223 type ToolAnnotations,
2324 type ToolRow,
@@ -270,10 +271,154 @@ const toOpenApiSourceConfig = ( · 246 unmodified lines
270271 headers: headersToConfigValues(config.headers),
271272 });
272273
274+const isHttpUrl = (s: string): boolean =>
275+ s.startsWith("http://") || s.startsWith("https://");
276+
273277 export const openApiPlugin = definePlugin(
274278 (options?: OpenApiPluginOptions) => {
275279 const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;
276280
281+ type RebuildInput = {
282+ readonly specText: string;
283+ readonly scope: string;
284+ readonly sourceUrl?: string;
285+ readonly name?: string;
286+ readonly baseUrl?: string;
287+ readonly namespace?: string;
288+ readonly headers?: Record<string, HeaderValue>;
289+ readonly oauth2?: OAuth2Auth;
290+ };
291+
292+ // ctx comes from the plugin runtime — the same instance is passed to
293+ // `extension(ctx)` and to every lifecycle hook (`refreshSource`, etc.),
294+ // so helpers parameterised on ctx can be called from either surface.
295+ const rebuildSource = (
296+ ctx: PluginCtx<OpenapiStore>,
297+ input: RebuildInput,
298+ ) =>
299+ Effect.gen(function* () {
300+ const doc = yield* parse(input.specText);
301+ const result = yield* extract(doc);
302+
303+ const namespace =
304+ input.namespace ??
305+ Option.getOrElse(result.title, () => "api")
306+ .toLowerCase()
307+ .replace(/[^a-z0-9]+/g, "_");
308+
309+ const hoistedDefs: Record<string, unknown> = {};
310+ if (doc.components?.schemas) {
311+ for (const [k, v] of Object.entries(doc.components.schemas)) {
312+ hoistedDefs[k] = normalizeOpenApiRefs(v);
313+ }
314+ }
315+
316+ const baseUrl = input.baseUrl ?? resolveBaseUrl(result.servers);
317+ const oauth2 = input.oauth2 ?? undefined;
318+ const invocationConfig = new InvocationConfig({
319+ baseUrl,
320+ headers: input.headers ?? {},
321+ oauth2: oauth2 ? Option.some(oauth2) : Option.none(),
322+ });
323+
324+ const definitions = compileToolDefinitions(result.operations);
325+ const sourceName =
326+ input.name ?? Option.getOrElse(result.title, () => namespace);
327+
328+ const sourceConfig: SourceConfig = {
329+ spec: input.specText,
330+ sourceUrl: input.sourceUrl,
331+ baseUrl: input.baseUrl,
332+ namespace: input.namespace,
333+ headers: input.headers,
334+ oauth2,
335+ };
336+
337+ const storedSource: StoredSource = {
338+ namespace,
339+ scope: input.scope,
340+ name: sourceName,
341+ config: sourceConfig,
342+ invocationConfig,
343+ };
344+
345+ const storedOps: StoredOperation[] = definitions.map((def) => ({
346+ toolId: `${namespace}.${def.toolPath}`,
347+ sourceId: namespace,
348+ binding: toBinding(def),
349+ }));
350+
351+ yield* ctx.transaction(
352+ Effect.gen(function* () {
353+ yield* ctx.storage.upsertSource(storedSource, storedOps);
354+
355+ yield* ctx.core.sources.register({
356+ id: namespace,
357+ scope: input.scope,
358+ kind: "openapi",
359+ name: sourceName,
360+ url: baseUrl || undefined,
361+ canRemove: true,
362+ // `canRefresh` reflects whether we still know the
363+ // origin URL — sources added from raw spec text have
364+ // nothing to re-fetch, so refresh stays disabled.
365+ canRefresh: input.sourceUrl != null,
366+ canEdit: true,
367+ tools: definitions.map((def) => ({
368+ name: def.toolPath,
369+ description: descriptionFor(def),
370+ inputSchema: normalizeOpenApiRefs(
371+ Option.getOrUndefined(def.operation.inputSchema),
372+ ),
373+ outputSchema: normalizeOpenApiRefs(
374+ Option.getOrUndefined(def.operation.outputSchema),
375+ ),
376+ })),
377+ });
378+
379+ if (Object.keys(hoistedDefs).length > 0) {
380+ yield* ctx.core.definitions.register({
381+ sourceId: namespace,
382+ scope: input.scope,
383+ definitions: hoistedDefs,
384+ });
385+ }
386+ }),
387+ );
388+
389+ return { sourceId: namespace, toolCount: definitions.length };
390+ });
391+
392+ // No-op for missing sources and for sources added from raw spec
393+ // text (no URL to re-fetch from). UIs gate the action via
394+ // `canRefresh` on the source row; reaching here without a URL
395+ // means the caller bypassed that gate, so we stay quiet rather
396+ // than surface a 500 through the unwhitelisted error channel.
397+ const refreshSourceInternal = (
398+ ctx: PluginCtx<OpenapiStore>,
399+ sourceId: string,
400+ scope: string,
401+ ) =>
402+ Effect.gen(function* () {
403+ const existing = yield* ctx.storage.getSource(sourceId, scope);
404+ if (!existing) return;
405+ const sourceUrl = existing.config.sourceUrl;
406+ if (!sourceUrl) return;
407+ const specText = yield* resolveSpecText(sourceUrl).pipe(
408+ Effect.provide(httpClientLayer),
409+ );
410+ yield* rebuildSource(ctx, {
411+ specText,
412+ scope,
413+ sourceUrl,
414+ name: existing.name,
415+ baseUrl: existing.config.baseUrl,
416+ namespace: existing.namespace,
417+ headers: existing.config.headers,
418+ oauth2: existing.config.oauth2,
419+ });
420+ });
421+
277422 return {
278423 id: "openapi" as const,
279424 schema: openapiSchema,
@@ -310,92 +455,16 @@ export const openApiPlugin = definePlugin( · 30 unmodified lines
310455 const specText = yield* resolveSpecText(config.spec).pipe(
311456 Effect.provide(httpClientLayer),
312457 );
313− const doc = yield* parse(specText);
314− const result = yield* extract(doc);
315−
316− const namespace =
317− config.namespace ??
318− Option.getOrElse(result.title, () => "api")
319− .toLowerCase()
320− .replace(/[^a-z0-9]+/g, "_");
321−
322− const hoistedDefs: Record<string, unknown> = {};
323− if (doc.components?.schemas) {
324− for (const [k, v] of Object.entries(doc.components.schemas)) {
325− hoistedDefs[k] = normalizeOpenApiRefs(v);
326− }
327− }
328−
329− const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers);
330− const oauth2 = config.oauth2 ?? undefined;
331− const invocationConfig = new InvocationConfig({
332− baseUrl,
333− headers: config.headers ?? {},
334− oauth2: oauth2 ? Option.some(oauth2) : Option.none(),
335− });
336−
337− const definitions = compileToolDefinitions(result.operations);
338− const sourceName =
339− config.name ?? Option.getOrElse(result.title, () => namespace);
340−
341− const sourceConfig: SourceConfig = {
342− spec: config.spec,
458+ return yield* rebuildSource(ctx, {
459+ specText,
460+ scope: config.scope,
461+ sourceUrl: isHttpUrl(config.spec) ? config.spec : undefined,
462+ name: config.name,
343463 baseUrl: config.baseUrl,
344464 namespace: config.namespace,
345465 headers: config.headers,
346− oauth2,
347− };
348−
349− const storedSource: StoredSource = {
350− namespace,
351− scope: config.scope,
352− name: sourceName,
353− config: sourceConfig,
354− invocationConfig,
355− };
356−
357− const storedOps: StoredOperation[] = definitions.map((def) => ({
358− toolId: `${namespace}.${def.toolPath}`,
359− sourceId: namespace,
360− binding: toBinding(def),
361− }));
362−
363− yield* ctx.transaction(
364− Effect.gen(function* () {
365− yield* ctx.storage.upsertSource(storedSource, storedOps);
366−
367− yield* ctx.core.sources.register({
368− id: namespace,
369− scope: config.scope,
370− kind: "openapi",
371− name: sourceName,
372− url: baseUrl || undefined,
373− canRemove: true,
374− canRefresh: false,
375− canEdit: true,
376− tools: definitions.map((def) => ({
377− name: def.toolPath,
378− description: descriptionFor(def),
379− inputSchema: normalizeOpenApiRefs(
380− Option.getOrUndefined(def.operation.inputSchema),
381− ),
382− outputSchema: normalizeOpenApiRefs(
383− Option.getOrUndefined(def.operation.outputSchema),
384− ),
385− })),
386− });
387−
388− if (Object.keys(hoistedDefs).length > 0) {
389− yield* ctx.core.definitions.register({
390− sourceId: namespace,
391− scope: config.scope,
392− definitions: hoistedDefs,
393− });
394− }
395− }),
396− );
397−
398− return { sourceId: namespace, toolCount: definitions.length };
466+ oauth2: config.oauth2,
467+ });
399468 });
400469
401470 const configFile = options?.configFile;
@@ -802,6 +871,15 @@ export const openApiPlugin = definePlugin( · 400 unmodified lines
802871 removeSource: ({ ctx, sourceId, scope }) =>
803872 ctx.storage.removeSource(sourceId, scope),
804873
874+ // Re-fetch the spec from its origin URL (captured at addSpec time)
875+ // and replay the same parse → extract → upsertSource → register
876+ // path used by addSpec. Sources without a stored URL surface a
877+ // typed `OpenApiParseError` — the executor only dispatches refresh
878+ // when `canRefresh: true`, so a raw-text source reaching here
879+ // means stale UI state, which is worth surfacing to the caller.
880+ refreshSource: ({ ctx, sourceId, scope }) =>
881+ refreshSourceInternal(ctx, sourceId, scope),
882+
805883 detect: ({ url }) =>
806884 Effect.gen(function* () {
807885 const trimmed = url.trim();
packages/plugins/openapi/src/sdk/store.ts+11−0
@@ -29,6 +29,11 @@ export const openapiSchema = defineSchema({ · 28 unmodified lines
2929 scope_id: { type: "string", required: true, index: true },
3030 name: { type: "string", required: true },
3131 spec: { type: "string", required: true },
32+ // Origin URL the spec was fetched from. Set when `addSpec` was
33+ // invoked with an http(s) URL; null when the caller passed raw
34+ // spec text. Drives `canRefresh` on the core source row and
35+ // is the address re-fetched on `refreshSource`.
36+ source_url: { type: "string", required: false },
3237 base_url: { type: "string", required: false },
3338 headers: { type: "json", required: false },
3439 oauth2: { type: "json", required: false },
@@ -62,6 +67,9 @@ export type OpenapiSchema = typeof openapiSchema; · 27 unmodified lines
6267
6368 export interface SourceConfig {
6469 readonly spec: string;
70+ /** Origin URL when the spec was fetched from http(s). Absent for
71+ * raw-text adds. Persisted so `refreshSource` can re-fetch. */
72+ readonly sourceUrl?: string;
6573 readonly baseUrl?: string;
6674 readonly namespace?: string;
6775 readonly headers?: Record<string, HeaderValue>;
@@ -92,6 +100,7 @@ export class StoredSourceSchema extends Schema.Class<StoredSourceSchema>( · 24 unmodified lines
92100 name: Schema.String,
93101 config: Schema.Struct({
94102 spec: Schema.String,
103+ sourceUrl: Schema.optional(Schema.String),
95104 baseUrl: Schema.optional(Schema.String),
96105 namespace: Schema.optional(Schema.String),
97106 headers: Schema.optional(
@@ -239,6 +248,7 @@ export const makeDefaultOpenapiStore = ({ · 141 unmodified lines
239248 name: row.name as string,
240249 config: {
241250 spec: row.spec as string,
251+ sourceUrl: (row.source_url as string | null | undefined) ?? undefined,
242252 baseUrl: (row.base_url as string | null | undefined) ?? undefined,
243253 headers,
244254 oauth2,
@@ -284,6 +294,7 @@ export const makeDefaultOpenapiStore = ({ · 39 unmodified lines
284294 scope_id: input.scope,
285295 name: input.name,
286296 spec: input.config.spec,
297+ source_url: input.config.sourceUrl ?? undefined,
287298 base_url: input.config.baseUrl ?? undefined,
288299 headers: (input.config.headers ?? {}) as unknown as Record<string, unknown>,
289300 oauth2: input.config.oauth2